diff --git a/.env.example b/.env.example index e767fa25..4ffd8499 100644 --- a/.env.example +++ b/.env.example @@ -33,6 +33,59 @@ CLAUDE_API_URL=https://api.anthropic.com/v1/messages CLAUDE_API_VERSION=2023-06-01 CLAUDE_BETA_HEADER=claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14 +# 🤖 Gemini OAuth / Antigravity 配置(可选) +# 不配置时使用内置默认值;如需自定义或避免在代码中出现 client secret,可在此覆盖 +# GEMINI_OAUTH_CLIENT_ID= +# GEMINI_OAUTH_CLIENT_SECRET= +# Gemini CLI OAuth redirect_uri(可选,默认 https://codeassist.google.com/authcode) +# GEMINI_OAUTH_REDIRECT_URI= +# ANTIGRAVITY_OAUTH_CLIENT_ID= +# ANTIGRAVITY_OAUTH_CLIENT_SECRET= +# Antigravity OAuth redirect_uri(可选,默认 http://localhost:45462;用于避免 redirect_uri_mismatch) +# ANTIGRAVITY_OAUTH_REDIRECT_URI=http://localhost:45462 +# Antigravity 上游地址(可选,默认 sandbox) +# ANTIGRAVITY_API_URL=https://daily-cloudcode-pa.sandbox.googleapis.com +# Antigravity User-Agent(可选) +# ANTIGRAVITY_USER_AGENT=antigravity/1.11.3 windows/amd64 + +# Claude Code(Anthropic Messages API)路由分流(无需额外环境变量): +# - /api -> Claude 账号池(默认) +# - /antigravity/api -> Antigravity OAuth +# - /gemini-cli/api -> Gemini CLI OAuth + +# ============================================================================ +# 🐛 调试 Dump 配置(可选) +# ============================================================================ +# 以下开启后会在项目根目录写入 .jsonl 调试文件,便于排查问题。 +# ⚠️ 生产环境建议关闭,避免磁盘占用。 +# +# 📄 输出文件列表: +# - anthropic-requests-dump.jsonl (客户端请求) +# - anthropic-responses-dump.jsonl (返回给客户端的响应) +# - anthropic-tools-dump.jsonl (工具定义快照) +# - antigravity-upstream-requests-dump.jsonl (发往上游的请求) +# - antigravity-upstream-responses-dump.jsonl (上游 SSE 响应) +# +# 📌 开关配置: +# ANTHROPIC_DEBUG_REQUEST_DUMP=true +# ANTHROPIC_DEBUG_RESPONSE_DUMP=true +# ANTHROPIC_DEBUG_TOOLS_DUMP=true +# ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP=true +# ANTIGRAVITY_DEBUG_UPSTREAM_RESPONSE_DUMP=true +# +# 📏 单条记录大小上限(字节),默认 2MB: +# ANTHROPIC_DEBUG_REQUEST_DUMP_MAX_BYTES=2097152 +# ANTHROPIC_DEBUG_RESPONSE_DUMP_MAX_BYTES=2097152 +# ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP_MAX_BYTES=2097152 +# +# 📦 整个 Dump 文件大小上限(字节),超过后自动轮转为 .bak 文件,默认 10MB: +# DUMP_MAX_FILE_SIZE_BYTES=10485760 +# +# 🔧 工具失败继续:当 tool_result 标记 is_error=true 时,提示模型不要中断任务 +# (仅 /antigravity/api 分流生效) +# ANTHROPIC_TOOL_ERROR_CONTINUE=true + + # 🚫 529错误处理配置 # 启用529错误处理,0表示禁用,>0表示过载状态持续时间(分钟) CLAUDE_OVERLOAD_HANDLING_MINUTES=0 diff --git a/README.md b/README.md index 9e358474..a7ba59a3 100644 --- a/README.md +++ b/README.md @@ -394,6 +394,9 @@ docker-compose.yml 已包含: **Claude Code 设置环境变量:** + +**使用标准 Claude 账号池** + 默认使用标准 Claude 账号池: ```bash @@ -401,6 +404,24 @@ export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # 根据实际填写你 export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥" ``` +**使用 Antigravity 账户池** + +适用于通过 Antigravity 渠道使用 Claude 模型(如 `claude-opus-4-5` 等)。 + +```bash +# 1. 设置 Base URL 为 Antigravity 专用路径 +export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/antigravity/api/" + +# 2. 设置 API Key(在后台创建,权限需包含 'all' 或 'gemini') +export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥" + +# 3. 指定模型名称(直接使用短名,无需前缀!) +export ANTHROPIC_MODEL="claude-opus-4-5" + +# 4. 启动 +claude +``` + **VSCode Claude 插件配置:** 如果使用 VSCode 的 Claude 插件,需要在 `~/.claude/config.json` 文件中配置: @@ -604,8 +625,9 @@ gpt-5 # Codex使用固定模型ID - 所有账号类型都使用相同的API密钥(在后台统一创建) - 根据不同的路由前缀自动识别账号类型 - `/claude/` - 使用Claude账号池 +- `/antigravity/api/` - 使用Antigravity账号池(推荐用于Claude Code) - `/droid/claude/` - 使用Droid类型Claude账号池(只建议api调用或Droid Cli中使用) -- `/gemini/` - 使用Gemini账号池 +- `/gemini/` - 使用Gemini账号池 - `/openai/` - 使用Codex账号(只支持Openai-Response格式) - `/droid/openai/` - 使用Droid类型OpenAI兼容账号池(只建议api调用或Droid Cli中使用) - 支持所有标准API端点(messages、models等) diff --git a/VERSION b/VERSION index 548ae3b2..02fbc144 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.251 +1.1.259 diff --git a/config/config.example.js b/config/config.example.js index 780e8360..eb6e4494 100644 --- a/config/config.example.js +++ b/config/config.example.js @@ -206,6 +206,14 @@ const config = { hotReload: process.env.HOT_RELOAD === 'true' }, + // 💰 账户余额相关配置 + accountBalance: { + // 是否允许执行自定义余额脚本(安全开关) + // 说明:脚本能力可发起任意 HTTP 请求并在服务端执行 extractor 逻辑,建议仅在受控环境开启 + // 默认保持开启;如需禁用请显式设置:BALANCE_SCRIPT_ENABLED=false + enableBalanceScript: process.env.BALANCE_SCRIPT_ENABLED !== 'false' + }, + // 📬 用户消息队列配置 // 优化说明:锁在请求发送成功后立即释放(而非请求完成后),因为 Claude API 限流基于请求发送时刻计算 userMessageQueue: { @@ -213,6 +221,13 @@ const config = { 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秒足以覆盖请求发送 + }, + + // 🎫 额度卡兑换上限配置(防盗刷) + quotaCardLimits: { + enabled: process.env.QUOTA_CARD_LIMITS_ENABLED !== 'false', // 默认启用 + maxExpiryDays: parseInt(process.env.QUOTA_CARD_MAX_EXPIRY_DAYS) || 90, // 最大有效期距今天数 + maxTotalCostLimit: parseFloat(process.env.QUOTA_CARD_MAX_TOTAL_COST_LIMIT) || 1000 // 最大总额度(美元) } } diff --git a/package-lock.json b/package-lock.json index 2164fb09..e6898fe4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "dotenv": "^16.3.1", "express": "^4.18.2", "google-auth-library": "^10.1.0", + "heapdump": "^0.3.15", "helmet": "^7.1.0", "https-proxy-agent": "^7.0.2", "inquirer": "^8.2.6", @@ -892,7 +893,6 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -3001,7 +3001,6 @@ "integrity": "sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3083,7 +3082,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3539,7 +3537,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001737", "electron-to-chromium": "^1.5.211", @@ -4427,7 +4424,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -4484,7 +4480,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -5404,6 +5399,19 @@ "node": ">= 0.4" } }, + "node_modules/heapdump": { + "version": "0.3.15", + "resolved": "https://registry.npmjs.org/heapdump/-/heapdump-0.3.15.tgz", + "integrity": "sha512-n8aSFscI9r3gfhOcAECAtXFaQ1uy4QSke6bnaL+iymYZ/dWs9cqDqHM+rALfsHUwukUbxsdlECZ0pKmJdQ/4OA==", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "nan": "^2.13.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/helmet": { "version": "7.2.0", "resolved": "https://registry.npmmirror.com/helmet/-/helmet-7.2.0.tgz", @@ -7019,6 +7027,12 @@ "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", "license": "ISC" }, + "node_modules/nan": { + "version": "2.24.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.24.0.tgz", + "integrity": "sha512-Vpf9qnVW1RaDkoNKFUvfxqAbtI8ncb8OJlqZ9wwpXzWPEsvsB1nvdUi6oYrHIkQ1Y/tMDnr1h4nczS0VB9Xykg==", + "license": "MIT" + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz", @@ -7592,7 +7606,6 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -9111,7 +9124,6 @@ "resolved": "https://registry.npmmirror.com/winston/-/winston-3.17.0.tgz", "integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==", "license": "MIT", - "peer": true, "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.2", diff --git a/scripts/data-transfer-enhanced.js b/scripts/data-transfer-enhanced.js index 09416fb4..52d1929f 100644 --- a/scripts/data-transfer-enhanced.js +++ b/scripts/data-transfer-enhanced.js @@ -152,62 +152,110 @@ async function exportUsageStats(keyId) { daily: {}, monthly: {}, hourly: {}, - models: {} + models: {}, + // 费用统计(String 类型) + costTotal: null, + costDaily: {}, + costMonthly: {}, + costHourly: {}, + opusTotal: null, + opusWeekly: {} } - // 导出总统计 - const totalKey = `usage:${keyId}` - const totalData = await redis.client.hgetall(totalKey) + // 导出总统计(Hash) + const totalData = await redis.client.hgetall(`usage:${keyId}`) if (totalData && Object.keys(totalData).length > 0) { stats.total = totalData } - // 导出每日统计(最近30天) - const today = new Date() - for (let i = 0; i < 30; i++) { - const date = new Date(today) - date.setDate(date.getDate() - i) - const dateStr = date.toISOString().split('T')[0] - const dailyKey = `usage:daily:${keyId}:${dateStr}` + // 导出费用总统计(String) + const costTotal = await redis.client.get(`usage:cost:total:${keyId}`) + if (costTotal) { + stats.costTotal = costTotal + } - const dailyData = await redis.client.hgetall(dailyKey) - if (dailyData && Object.keys(dailyData).length > 0) { - stats.daily[dateStr] = dailyData + // 导出 Opus 费用总统计(String) + const opusTotal = await redis.client.get(`usage:opus:total:${keyId}`) + if (opusTotal) { + stats.opusTotal = opusTotal + } + + // 导出每日统计(扫描现有 key,避免时区问题) + const dailyKeys = await redis.client.keys(`usage:daily:${keyId}:*`) + for (const key of dailyKeys) { + const date = key.split(':').pop() + const data = await redis.client.hgetall(key) + if (data && Object.keys(data).length > 0) { + stats.daily[date] = data } } - // 导出每月统计(最近12个月) - for (let i = 0; i < 12; i++) { - const date = new Date(today) - date.setMonth(date.getMonth() - i) - const monthStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` - const monthlyKey = `usage:monthly:${keyId}:${monthStr}` - - const monthlyData = await redis.client.hgetall(monthlyKey) - if (monthlyData && Object.keys(monthlyData).length > 0) { - stats.monthly[monthStr] = monthlyData + // 导出每日费用(扫描现有 key) + const costDailyKeys = await redis.client.keys(`usage:cost:daily:${keyId}:*`) + for (const key of costDailyKeys) { + const date = key.split(':').pop() + const value = await redis.client.get(key) + if (value) { + stats.costDaily[date] = value } } - // 导出小时统计(最近24小时) - for (let i = 0; i < 24; i++) { - const date = new Date(today) - date.setHours(date.getHours() - i) - const dateStr = date.toISOString().split('T')[0] - const hour = String(date.getHours()).padStart(2, '0') - const hourKey = `${dateStr}:${hour}` - const hourlyKey = `usage:hourly:${keyId}:${hourKey}` - - const hourlyData = await redis.client.hgetall(hourlyKey) - if (hourlyData && Object.keys(hourlyData).length > 0) { - stats.hourly[hourKey] = hourlyData + // 导出每月统计(扫描现有 key) + const monthlyKeys = await redis.client.keys(`usage:monthly:${keyId}:*`) + for (const key of monthlyKeys) { + const month = key.split(':').pop() + const data = await redis.client.hgetall(key) + if (data && Object.keys(data).length > 0) { + stats.monthly[month] = data } } - // 导出模型统计 - // 每日模型统计 - const modelDailyPattern = `usage:${keyId}:model:daily:*` - const modelDailyKeys = await redis.client.keys(modelDailyPattern) + // 导出每月费用(扫描现有 key) + const costMonthlyKeys = await redis.client.keys(`usage:cost:monthly:${keyId}:*`) + for (const key of costMonthlyKeys) { + const month = key.split(':').pop() + const value = await redis.client.get(key) + if (value) { + stats.costMonthly[month] = value + } + } + + // 导出 Opus 周费用(扫描现有 key) + const opusWeeklyKeys = await redis.client.keys(`usage:opus:weekly:${keyId}:*`) + for (const key of opusWeeklyKeys) { + const week = key.split(':').pop() + const value = await redis.client.get(key) + if (value) { + stats.opusWeekly[week] = value + } + } + + // 导出小时统计(扫描现有 key) + // key 格式: usage:hourly:{keyId}:{YYYY-MM-DD}:{HH} + const hourlyKeys = await redis.client.keys(`usage:hourly:${keyId}:*`) + for (const key of hourlyKeys) { + const parts = key.split(':') + const hourKey = `${parts[parts.length - 2]}:${parts[parts.length - 1]}` // YYYY-MM-DD:HH + const data = await redis.client.hgetall(key) + if (data && Object.keys(data).length > 0) { + stats.hourly[hourKey] = data + } + } + + // 导出小时费用(扫描现有 key) + // key 格式: usage:cost:hourly:{keyId}:{YYYY-MM-DD}:{HH} + const costHourlyKeys = await redis.client.keys(`usage:cost:hourly:${keyId}:*`) + for (const key of costHourlyKeys) { + const parts = key.split(':') + const hourKey = `${parts[parts.length - 2]}:${parts[parts.length - 1]}` // YYYY-MM-DD:HH + const value = await redis.client.get(key) + if (value) { + stats.costHourly[hourKey] = value + } + } + + // 导出模型统计(每日) + const modelDailyKeys = await redis.client.keys(`usage:${keyId}:model:daily:*`) for (const key of modelDailyKeys) { const match = key.match(/usage:.+:model:daily:(.+):(\d{4}-\d{2}-\d{2})$/) if (match) { @@ -223,9 +271,8 @@ async function exportUsageStats(keyId) { } } - // 每月模型统计 - const modelMonthlyPattern = `usage:${keyId}:model:monthly:*` - const modelMonthlyKeys = await redis.client.keys(modelMonthlyPattern) + // 导出模型统计(每月) + const modelMonthlyKeys = await redis.client.keys(`usage:${keyId}:model:monthly:*`) for (const key of modelMonthlyKeys) { const match = key.match(/usage:.+:model:monthly:(.+):(\d{4}-\d{2})$/) if (match) { @@ -258,7 +305,7 @@ async function importUsageStats(keyId, stats) { const pipeline = redis.client.pipeline() let importCount = 0 - // 导入总统计 + // 导入总统计(Hash) if (stats.total && Object.keys(stats.total).length > 0) { for (const [field, value] of Object.entries(stats.total)) { pipeline.hset(`usage:${keyId}`, field, value) @@ -266,7 +313,19 @@ async function importUsageStats(keyId, stats) { importCount++ } - // 导入每日统计 + // 导入费用总统计(String) + if (stats.costTotal) { + pipeline.set(`usage:cost:total:${keyId}`, stats.costTotal) + importCount++ + } + + // 导入 Opus 费用总统计(String) + if (stats.opusTotal) { + pipeline.set(`usage:opus:total:${keyId}`, stats.opusTotal) + importCount++ + } + + // 导入每日统计(Hash) if (stats.daily) { for (const [date, data] of Object.entries(stats.daily)) { for (const [field, value] of Object.entries(data)) { @@ -276,7 +335,15 @@ async function importUsageStats(keyId, stats) { } } - // 导入每月统计 + // 导入每日费用(String) + if (stats.costDaily) { + for (const [date, value] of Object.entries(stats.costDaily)) { + pipeline.set(`usage:cost:daily:${keyId}:${date}`, value) + importCount++ + } + } + + // 导入每月统计(Hash) if (stats.monthly) { for (const [month, data] of Object.entries(stats.monthly)) { for (const [field, value] of Object.entries(data)) { @@ -286,7 +353,23 @@ async function importUsageStats(keyId, stats) { } } - // 导入小时统计 + // 导入每月费用(String) + if (stats.costMonthly) { + for (const [month, value] of Object.entries(stats.costMonthly)) { + pipeline.set(`usage:cost:monthly:${keyId}:${month}`, value) + importCount++ + } + } + + // 导入 Opus 周费用(String,不加 TTL 保留历史全量) + if (stats.opusWeekly) { + for (const [week, value] of Object.entries(stats.opusWeekly)) { + pipeline.set(`usage:opus:weekly:${keyId}:${week}`, value) + importCount++ + } + } + + // 导入小时统计(Hash) if (stats.hourly) { for (const [hour, data] of Object.entries(stats.hourly)) { for (const [field, value] of Object.entries(data)) { @@ -296,10 +379,17 @@ async function importUsageStats(keyId, stats) { } } - // 导入模型统计 + // 导入小时费用(String) + if (stats.costHourly) { + for (const [hour, value] of Object.entries(stats.costHourly)) { + pipeline.set(`usage:cost:hourly:${keyId}:${hour}`, value) + importCount++ + } + } + + // 导入模型统计(Hash) if (stats.models) { for (const [model, modelStats] of Object.entries(stats.models)) { - // 每日模型统计 if (modelStats.daily) { for (const [date, data] of Object.entries(modelStats.daily)) { for (const [field, value] of Object.entries(data)) { @@ -309,7 +399,6 @@ async function importUsageStats(keyId, stats) { } } - // 每月模型统计 if (modelStats.monthly) { for (const [month, data] of Object.entries(modelStats.monthly)) { for (const [field, value] of Object.entries(data)) { @@ -547,13 +636,54 @@ async function exportData() { const globalStats = { daily: {}, monthly: {}, - hourly: {} + hourly: {}, + // 新增:索引和全局统计 + monthlyMonths: [], // usage:model:monthly:months Set + globalTotal: null, // usage:global:total Hash + globalDaily: {}, // usage:global:daily:* Hash + globalMonthly: {} // usage:global:monthly:* Hash } - // 导出全局每日模型统计 - const globalDailyPattern = 'usage:model:daily:*' - const globalDailyKeys = await redis.client.keys(globalDailyPattern) + // 导出月份索引 + const monthlyMonths = await redis.client.smembers('usage:model:monthly:months') + if (monthlyMonths && monthlyMonths.length > 0) { + globalStats.monthlyMonths = monthlyMonths + logger.info(`📤 Found ${monthlyMonths.length} months in index`) + } + + // 导出全局统计 + const globalTotal = await redis.client.hgetall('usage:global:total') + if (globalTotal && Object.keys(globalTotal).length > 0) { + globalStats.globalTotal = globalTotal + logger.info('📤 Found global total stats') + } + + // 导出全局每日统计 + const globalDailyKeys = await redis.client.keys('usage:global:daily:*') for (const key of globalDailyKeys) { + const date = key.replace('usage:global:daily:', '') + const data = await redis.client.hgetall(key) + if (data && Object.keys(data).length > 0) { + globalStats.globalDaily[date] = data + } + } + logger.info(`📤 Found ${Object.keys(globalStats.globalDaily).length} global daily stats`) + + // 导出全局每月统计 + const globalMonthlyKeys = await redis.client.keys('usage:global:monthly:*') + for (const key of globalMonthlyKeys) { + const month = key.replace('usage:global:monthly:', '') + const data = await redis.client.hgetall(key) + if (data && Object.keys(data).length > 0) { + globalStats.globalMonthly[month] = data + } + } + logger.info(`📤 Found ${Object.keys(globalStats.globalMonthly).length} global monthly stats`) + + // 导出全局每日模型统计 + const modelDailyPattern = 'usage:model:daily:*' + const modelDailyKeys = await redis.client.keys(modelDailyPattern) + for (const key of modelDailyKeys) { const match = key.match(/usage:model:daily:(.+):(\d{4}-\d{2}-\d{2})$/) if (match) { const model = match[1] @@ -569,9 +699,9 @@ async function exportData() { } // 导出全局每月模型统计 - const globalMonthlyPattern = 'usage:model:monthly:*' - const globalMonthlyKeys = await redis.client.keys(globalMonthlyPattern) - for (const key of globalMonthlyKeys) { + const modelMonthlyPattern = 'usage:model:monthly:*' + const modelMonthlyKeys = await redis.client.keys(modelMonthlyPattern) + for (const key of modelMonthlyKeys) { const match = key.match(/usage:model:monthly:(.+):(\d{4}-\d{2})$/) if (match) { const model = match[1] @@ -1040,6 +1170,42 @@ async function importData() { const pipeline = redis.client.pipeline() let globalStatCount = 0 + // 导入月份索引 + if (globalStats.monthlyMonths && globalStats.monthlyMonths.length > 0) { + for (const month of globalStats.monthlyMonths) { + pipeline.sadd('usage:model:monthly:months', month) + } + logger.info(`📥 Importing ${globalStats.monthlyMonths.length} months to index`) + } + + // 导入全局统计 + if (globalStats.globalTotal) { + for (const [field, value] of Object.entries(globalStats.globalTotal)) { + pipeline.hset('usage:global:total', field, value) + } + logger.info('📥 Importing global total stats') + } + + // 导入全局每日统计 + if (globalStats.globalDaily) { + for (const [date, data] of Object.entries(globalStats.globalDaily)) { + for (const [field, value] of Object.entries(data)) { + pipeline.hset(`usage:global:daily:${date}`, field, value) + } + } + logger.info(`📥 Importing ${Object.keys(globalStats.globalDaily).length} global daily stats`) + } + + // 导入全局每月统计 + if (globalStats.globalMonthly) { + for (const [month, data] of Object.entries(globalStats.globalMonthly)) { + for (const [field, value] of Object.entries(data)) { + pipeline.hset(`usage:global:monthly:${month}`, field, value) + } + } + logger.info(`📥 Importing ${Object.keys(globalStats.globalMonthly).length} global monthly stats`) + } + // 导入每日统计 if (globalStats.daily) { for (const [date, models] of Object.entries(globalStats.daily)) { @@ -1061,6 +1227,8 @@ async function importData() { } globalStatCount++ } + // 同时更新月份索引(兼容旧格式导出文件) + pipeline.sadd('usage:model:monthly:months', month) } } diff --git a/src/app.js b/src/app.js index d94f344c..5687cff7 100644 --- a/src/app.js +++ b/src/app.js @@ -11,6 +11,7 @@ const logger = require('./utils/logger') const redis = require('./models/redis') const pricingService = require('./services/pricingService') const cacheMonitor = require('./utils/cacheMonitor') +const { getSafeMessage } = require('./utils/errorSanitizer') // Import routes const apiRoutes = require('./routes/api') @@ -70,6 +71,11 @@ class Application { logger.success(`✅ 数据迁移完成,版本: ${currentVersion}`) } + // 📅 后台检查月份索引完整性(不阻塞启动) + redis.ensureMonthlyMonthsIndex().catch((err) => { + logger.error('📅 月份索引检查失败:', err.message) + }) + // 📊 后台异步迁移 usage 索引(不阻塞启动) redis.migrateUsageIndex().catch((err) => { logger.error('📊 Background usage index migration failed:', err) @@ -78,6 +84,16 @@ class Application { // 📊 迁移 alltime 模型统计(阻塞式,确保数据完整) await redis.migrateAlltimeModelStats() + // 💳 初始化账户余额查询服务(Provider 注册) + try { + const accountBalanceService = require('./services/accountBalanceService') + const { registerAllProviders } = require('./services/balanceProviders') + registerAllProviders(accountBalanceService) + logger.info('✅ 账户余额查询服务已初始化') + } catch (error) { + logger.warn('⚠️ 账户余额查询服务初始化失败:', error.message) + } + // 💰 初始化价格服务 logger.info('🔄 Initializing pricing service...') await pricingService.initialize() @@ -207,7 +223,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()) { @@ -216,7 +232,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) // 🎯 信任代理 @@ -306,6 +322,25 @@ class Application { this.app.use('/api', apiRoutes) this.app.use('/api', unifiedRoutes) // 统一智能路由(支持 /v1/chat/completions 等) this.app.use('/claude', apiRoutes) // /claude 路由别名,与 /api 功能相同 + // Anthropic (Claude Code) 路由:按路径强制分流到 Gemini OAuth 账户 + // - /antigravity/api/v1/messages -> Antigravity OAuth + // - /gemini-cli/api/v1/messages -> Gemini CLI OAuth + this.app.use( + '/antigravity/api', + (req, res, next) => { + req._anthropicVendor = 'antigravity' + next() + }, + apiRoutes + ) + this.app.use( + '/gemini-cli/api', + (req, res, next) => { + req._anthropicVendor = 'gemini-cli' + next() + }, + apiRoutes + ) this.app.use('/admin', adminRoutes) this.app.use('/users', userRoutes) // 使用 web 路由(包含 auth 和页面重定向) @@ -386,7 +421,7 @@ class Application { logger.error('❌ Health check failed:', { error: error.message, stack: error.stack }) res.status(503).json({ status: 'unhealthy', - error: error.message, + error: getSafeMessage(error), timestamp: new Date().toISOString() }) } diff --git a/src/handlers/geminiHandlers.js b/src/handlers/geminiHandlers.js index ccf0035d..d12f2f1c 100644 --- a/src/handlers/geminiHandlers.js +++ b/src/handlers/geminiHandlers.js @@ -9,6 +9,7 @@ const logger = require('../utils/logger') const geminiAccountService = require('../services/geminiAccountService') const geminiApiAccountService = require('../services/geminiApiAccountService') const { sendGeminiRequest, getAvailableModels } = require('../services/geminiRelayService') +const { sendAntigravityRequest } = require('../services/antigravityRelayService') const crypto = require('crypto') const sessionHelper = require('../utils/sessionHelper') const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler') @@ -17,6 +18,7 @@ const redis = require('../models/redis') const { updateRateLimitCounters } = require('../utils/rateLimitHelper') const { parseSSELine } = require('../utils/sseParser') const axios = require('axios') +const { getSafeMessage } = require('../utils/errorSanitizer') const ProxyHelper = require('../utils/proxyHelper') // ============================================================================ @@ -87,8 +89,7 @@ function generateSessionHash(req) { * 检查 API Key 权限 */ function checkPermissions(apiKeyData, requiredPermission = 'gemini') { - const permissions = apiKeyData?.permissions || 'all' - return permissions === 'all' || permissions === requiredPermission + return apiKeyService.hasPermission(apiKeyData?.permissions, requiredPermission) } /** @@ -137,7 +138,9 @@ async function applyRateLimitTracking(req, usageSummary, model, context = '') { const { totalTokens, totalCost } = await updateRateLimitCounters( req.rateLimitInfo, usageSummary, - model + model, + req.apiKey?.id, + 'gemini' ) if (totalTokens > 0) { @@ -354,7 +357,7 @@ async function handleMessages(req, res) { logger.error('Failed to select Gemini account:', error) return res.status(503).json({ error: { - message: error.message || 'No available Gemini accounts', + message: getSafeMessage(error) || 'No available Gemini accounts', type: 'service_unavailable' } }) @@ -493,7 +496,8 @@ async function handleMessages(req, res) { 0, 0, model, - accountId + accountId, + 'gemini' ) } } @@ -509,20 +513,37 @@ async function handleMessages(req, res) { // OAuth 账户:使用现有的 sendGeminiRequest // 智能处理项目ID:优先使用配置的 projectId,降级到临时 tempProjectId const effectiveProjectId = account.projectId || account.tempProjectId || null + const oauthProvider = account.oauthProvider || 'gemini-cli' - geminiResponse = await sendGeminiRequest({ - messages, - model, - temperature, - maxTokens: max_tokens, - stream, - accessToken: account.accessToken, - proxy: account.proxy, - apiKeyId: apiKeyData.id, - signal: abortController.signal, - projectId: effectiveProjectId, - accountId: account.id - }) + if (oauthProvider === 'antigravity') { + geminiResponse = await sendAntigravityRequest({ + messages, + model, + temperature, + maxTokens: max_tokens, + stream, + accessToken: account.accessToken, + proxy: account.proxy, + apiKeyId: apiKeyData.id, + signal: abortController.signal, + projectId: effectiveProjectId, + accountId: account.id + }) + } else { + geminiResponse = await sendGeminiRequest({ + messages, + model, + temperature, + maxTokens: max_tokens, + stream, + accessToken: account.accessToken, + proxy: account.proxy, + apiKeyId: apiKeyData.id, + signal: abortController.signal, + projectId: effectiveProjectId, + accountId: account.id + }) + } } if (stream) { @@ -580,7 +601,8 @@ async function handleMessages(req, res) { 0, 0, model, - accountId + accountId, + 'gemini' ) .then(() => { logger.info( @@ -598,7 +620,7 @@ async function handleMessages(req, res) { if (!res.headersSent) { res.status(500).json({ error: { - message: error.message || 'Stream error', + message: getSafeMessage(error) || 'Stream error', type: 'api_error' } }) @@ -646,7 +668,7 @@ async function handleMessages(req, res) { const status = errorStatus || 500 const errorResponse = { error: error.error || { - message: error.message || 'Internal server error', + message: getSafeMessage(error) || 'Internal server error', type: 'api_error' } } @@ -755,8 +777,16 @@ async function handleModels(req, res) { ] } } else { - // OAuth 账户:使用 OAuth token 获取模型列表 - models = await getAvailableModels(account.accessToken, account.proxy) + // OAuth 账户:根据 OAuth provider 选择上游 + const oauthProvider = account.oauthProvider || 'gemini-cli' + models = + oauthProvider === 'antigravity' + ? await geminiAccountService.fetchAvailableModelsAntigravity( + account.accessToken, + account.proxy, + account.refreshToken + ) + : await getAvailableModels(account.accessToken, account.proxy) } res.json({ @@ -843,7 +873,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: tokensUsed, tokens_remaining: @@ -931,7 +961,8 @@ function handleSimpleEndpoint(apiMethod) { const client = await geminiAccountService.getOauthClient( accessToken, refreshToken, - proxyConfig + proxyConfig, + account.oauthProvider ) // 直接转发请求体,不做特殊处理 @@ -1010,7 +1041,12 @@ async function handleLoadCodeAssist(req, res) { // 解析账户的代理配置 const proxyConfig = parseProxyConfig(account) - const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig) + const client = await geminiAccountService.getOauthClient( + accessToken, + refreshToken, + proxyConfig, + account.oauthProvider + ) // 智能处理项目ID const effectiveProjectId = projectId || cloudaicompanionProject || null @@ -1108,7 +1144,12 @@ async function handleOnboardUser(req, res) { // 解析账户的代理配置 const proxyConfig = parseProxyConfig(account) - const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig) + const client = await geminiAccountService.getOauthClient( + accessToken, + refreshToken, + proxyConfig, + account.oauthProvider + ) // 智能处理项目ID const effectiveProjectId = projectId || cloudaicompanionProject || null @@ -1156,6 +1197,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 请求 */ @@ -1260,7 +1405,8 @@ async function handleCountTokens(req, res) { const client = await geminiAccountService.getOauthClient( accessToken, refreshToken, - proxyConfig + proxyConfig, + account.oauthProvider ) response = await geminiAccountService.countTokens(client, contents, model, proxyConfig) } @@ -1271,7 +1417,7 @@ async function handleCountTokens(req, res) { logger.error(`Error in countTokens endpoint (${version})`, { error: error.message }) res.status(500).json({ error: { - message: error.message || 'Internal server error', + message: getSafeMessage(error) || 'Internal server error', type: 'api_error' } }) @@ -1370,13 +1516,20 @@ async function handleGenerateContent(req, res) { // 解析账户的代理配置 const proxyConfig = parseProxyConfig(account) - const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig) + const client = await geminiAccountService.getOauthClient( + accessToken, + refreshToken, + proxyConfig, + account.oauthProvider + ) // 智能处理项目ID:优先使用配置的 projectId,降级到临时 tempProjectId let effectiveProjectId = account.projectId || account.tempProjectId || null + const oauthProvider = account.oauthProvider || 'gemini-cli' + // 如果没有任何项目ID,尝试调用 loadCodeAssist 获取 - if (!effectiveProjectId) { + if (!effectiveProjectId && oauthProvider !== 'antigravity') { try { logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...') const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig) @@ -1392,6 +1545,12 @@ async function handleGenerateContent(req, res) { } } + if (!effectiveProjectId && oauthProvider === 'antigravity') { + // Antigravity 账号允许没有 projectId:生成一个稳定的临时 projectId 并缓存 + effectiveProjectId = `ag-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}` + await geminiAccountService.updateTempProjectId(accountId, effectiveProjectId) + } + // 如果还是没有项目ID,返回错误 if (!effectiveProjectId) { return res.status(403).json({ @@ -1414,14 +1573,24 @@ async function handleGenerateContent(req, res) { : '从loadCodeAssist获取' }) - const response = await geminiAccountService.generateContent( - client, - { model, request: actualRequestData }, - user_prompt_id, - effectiveProjectId, - req.apiKey?.id, - proxyConfig - ) + const response = + oauthProvider === 'antigravity' + ? await geminiAccountService.generateContentAntigravity( + client, + { model, request: actualRequestData }, + user_prompt_id, + effectiveProjectId, + req.apiKey?.id, + proxyConfig + ) + : await geminiAccountService.generateContent( + client, + { model, request: actualRequestData }, + user_prompt_id, + effectiveProjectId, + req.apiKey?.id, + proxyConfig + ) // 记录使用统计 if (response?.response?.usageMetadata) { @@ -1434,7 +1603,8 @@ async function handleGenerateContent(req, res) { 0, 0, model, - account.id + account.id, + 'gemini' ) logger.info( `📊 Recorded Gemini usage - Input: ${usage.promptTokenCount}, Output: ${usage.candidatesTokenCount}, Total: ${usage.totalTokenCount}` @@ -1470,7 +1640,7 @@ async function handleGenerateContent(req, res) { }) res.status(500).json({ error: { - message: error.message || 'Internal server error', + message: getSafeMessage(error) || 'Internal server error', type: 'api_error' } }) @@ -1582,13 +1752,20 @@ async function handleStreamGenerateContent(req, res) { // 解析账户的代理配置 const proxyConfig = parseProxyConfig(account) - const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig) + const client = await geminiAccountService.getOauthClient( + accessToken, + refreshToken, + proxyConfig, + account.oauthProvider + ) // 智能处理项目ID:优先使用配置的 projectId,降级到临时 tempProjectId let effectiveProjectId = account.projectId || account.tempProjectId || null + const oauthProvider = account.oauthProvider || 'gemini-cli' + // 如果没有任何项目ID,尝试调用 loadCodeAssist 获取 - if (!effectiveProjectId) { + if (!effectiveProjectId && oauthProvider !== 'antigravity') { try { logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...') const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig) @@ -1604,6 +1781,11 @@ async function handleStreamGenerateContent(req, res) { } } + if (!effectiveProjectId && oauthProvider === 'antigravity') { + effectiveProjectId = `ag-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}` + await geminiAccountService.updateTempProjectId(accountId, effectiveProjectId) + } + // 如果还是没有项目ID,返回错误 if (!effectiveProjectId) { return res.status(403).json({ @@ -1626,15 +1808,26 @@ async function handleStreamGenerateContent(req, res) { : '从loadCodeAssist获取' }) - const streamResponse = await geminiAccountService.generateContentStream( - client, - { model, request: actualRequestData }, - user_prompt_id, - effectiveProjectId, - req.apiKey?.id, - abortController.signal, - proxyConfig - ) + const streamResponse = + oauthProvider === 'antigravity' + ? await geminiAccountService.generateContentStreamAntigravity( + client, + { model, request: actualRequestData }, + user_prompt_id, + effectiveProjectId, + req.apiKey?.id, + abortController.signal, + proxyConfig + ) + : await geminiAccountService.generateContentStream( + client, + { model, request: actualRequestData }, + user_prompt_id, + effectiveProjectId, + req.apiKey?.id, + abortController.signal, + proxyConfig + ) // 设置 SSE 响应头 res.setHeader('Content-Type', 'text/event-stream') @@ -1731,7 +1924,8 @@ async function handleStreamGenerateContent(req, res) { 0, 0, model, - account.id + account.id, + 'gemini' ), applyRateLimitTracking( req, @@ -1768,7 +1962,7 @@ async function handleStreamGenerateContent(req, res) { if (!res.headersSent) { res.status(500).json({ error: { - message: error.message || 'Stream error', + message: getSafeMessage(error) || 'Stream error', type: 'api_error' } }) @@ -1778,7 +1972,7 @@ async function handleStreamGenerateContent(req, res) { res.write( `data: ${JSON.stringify({ error: { - message: error.message || 'Stream error', + message: getSafeMessage(error) || 'Stream error', type: 'stream_error', code: error.code } @@ -1807,7 +2001,7 @@ async function handleStreamGenerateContent(req, res) { if (!res.headersSent) { res.status(500).json({ error: { - message: error.message || 'Internal server error', + message: getSafeMessage(error) || 'Internal server error', type: 'api_error' } }) @@ -1982,15 +2176,23 @@ async function handleStandardGenerateContent(req, res) { } else { // OAuth 账户 const { accessToken, refreshToken } = account + const oauthProvider = account.oauthProvider || 'gemini-cli' const client = await geminiAccountService.getOauthClient( accessToken, refreshToken, - proxyConfig + proxyConfig, + oauthProvider ) let effectiveProjectId = account.projectId || account.tempProjectId || null - if (!effectiveProjectId) { + if (oauthProvider === 'antigravity') { + if (!effectiveProjectId) { + // Antigravity 账号允许没有 projectId:生成一个稳定的临时 projectId 并缓存 + effectiveProjectId = `ag-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}` + await geminiAccountService.updateTempProjectId(actualAccountId, effectiveProjectId) + } + } else if (!effectiveProjectId) { try { logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...') const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig) @@ -2028,14 +2230,25 @@ async function handleStandardGenerateContent(req, res) { const userPromptId = `${crypto.randomUUID()}########0` - response = await geminiAccountService.generateContent( - client, - { model, request: actualRequestData }, - userPromptId, - effectiveProjectId, - req.apiKey?.id, - proxyConfig - ) + if (oauthProvider === 'antigravity') { + response = await geminiAccountService.generateContentAntigravity( + client, + { model, request: actualRequestData }, + userPromptId, + effectiveProjectId, + req.apiKey?.id, + proxyConfig + ) + } else { + response = await geminiAccountService.generateContent( + client, + { model, request: actualRequestData }, + userPromptId, + effectiveProjectId, + req.apiKey?.id, + proxyConfig + ) + } } // 记录使用统计 @@ -2049,7 +2262,8 @@ async function handleStandardGenerateContent(req, res) { 0, 0, model, - accountId + accountId, + 'gemini' ) logger.info( `📊 Recorded Gemini usage - Input: ${usage.promptTokenCount}, Output: ${usage.candidatesTokenCount}, Total: ${usage.totalTokenCount}` @@ -2071,7 +2285,7 @@ async function handleStandardGenerateContent(req, res) { res.status(500).json({ error: { - message: error.message || 'Internal server error', + message: getSafeMessage(error) || 'Internal server error', type: 'api_error' } }) @@ -2267,12 +2481,20 @@ async function handleStandardStreamGenerateContent(req, res) { const client = await geminiAccountService.getOauthClient( accessToken, refreshToken, - proxyConfig + proxyConfig, + account.oauthProvider ) let effectiveProjectId = account.projectId || account.tempProjectId || null - if (!effectiveProjectId) { + const oauthProvider = account.oauthProvider || 'gemini-cli' + + if (oauthProvider === 'antigravity') { + if (!effectiveProjectId) { + effectiveProjectId = `ag-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}` + await geminiAccountService.updateTempProjectId(actualAccountId, effectiveProjectId) + } + } else if (!effectiveProjectId) { try { logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...') const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig) @@ -2310,15 +2532,27 @@ async function handleStandardStreamGenerateContent(req, res) { const userPromptId = `${crypto.randomUUID()}########0` - streamResponse = await geminiAccountService.generateContentStream( - client, - { model, request: actualRequestData }, - userPromptId, - effectiveProjectId, - req.apiKey?.id, - abortController.signal, - proxyConfig - ) + if (oauthProvider === 'antigravity') { + streamResponse = await geminiAccountService.generateContentStreamAntigravity( + client, + { model, request: actualRequestData }, + userPromptId, + effectiveProjectId, + req.apiKey?.id, + abortController.signal, + proxyConfig + ) + } else { + streamResponse = await geminiAccountService.generateContentStream( + client, + { model, request: actualRequestData }, + userPromptId, + effectiveProjectId, + req.apiKey?.id, + abortController.signal, + proxyConfig + ) + } } // 设置 SSE 响应头 @@ -2458,7 +2692,8 @@ async function handleStandardStreamGenerateContent(req, res) { 0, 0, model, - accountId + accountId, + 'gemini' ) .then(() => { logger.info( @@ -2486,7 +2721,7 @@ async function handleStandardStreamGenerateContent(req, res) { if (!res.headersSent) { res.status(500).json({ error: { - message: error.message || 'Stream error', + message: getSafeMessage(error) || 'Stream error', type: 'api_error' } }) @@ -2496,7 +2731,7 @@ async function handleStandardStreamGenerateContent(req, res) { res.write( `data: ${JSON.stringify({ error: { - message: error.message || 'Stream error', + message: getSafeMessage(error) || 'Stream error', type: 'stream_error', code: error.code } @@ -2580,6 +2815,7 @@ module.exports = { handleSimpleEndpoint, handleLoadCodeAssist, handleOnboardUser, + handleRetrieveUserQuota, handleCountTokens, handleGenerateContent, handleStreamGenerateContent, diff --git a/src/middleware/auth.js b/src/middleware/auth.js index f7997431..3a275faf 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -2048,7 +2048,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') @@ -2057,7 +2057,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` }) } diff --git a/src/models/redis.js b/src/models/redis.js index 0ec47a23..e841ba56 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -502,6 +502,21 @@ class RedisClient { return [...keyIds] } + // 添加标签到全局标签集合 + async addTag(tagName) { + await this.client.sadd('apikey:tags:all', tagName) + } + + // 从全局标签集合删除标签 + async removeTag(tagName) { + await this.client.srem('apikey:tags:all', tagName) + } + + // 获取全局标签集合 + async getGlobalTags() { + return await this.client.smembers('apikey:tags:all') + } + /** * 使用索引获取所有 API Key 的标签(优化版本) * 优先级:索引就绪时用 apikey:tags:all > apikey:idx:all + pipeline > SCAN @@ -1567,8 +1582,10 @@ class RedisClient { return result } - // 💰 增加当日费用 - async incrementDailyCost(keyId, amount) { + // 💰 增加当日费用(支持倍率成本和真实成本分开记录) + // amount: 倍率后的成本(用于限额校验) + // realAmount: 真实成本(用于对账),如果不传则等于 amount + async incrementDailyCost(keyId, amount, realAmount = null) { const today = getDateStringInTimezone() const tzDate = getDateInTimezone() const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( @@ -1582,25 +1599,33 @@ class RedisClient { const hourlyKey = `usage:cost:hourly:${keyId}:${currentHour}` const totalKey = `usage:cost:total:${keyId}` // 总费用键 - 永不过期,持续累加 + // 真实成本键(用于对账) + const realTotalKey = `usage:cost:real:total:${keyId}` + const realDailyKey = `usage:cost:real:daily:${keyId}:${today}` + const actualRealAmount = realAmount !== null ? realAmount : amount + logger.debug( - `💰 Incrementing cost for ${keyId}, amount: $${amount}, date: ${today}, dailyKey: ${dailyKey}` + `💰 Incrementing cost for ${keyId}, rated: $${amount}, real: $${actualRealAmount}, date: ${today}` ) const results = await Promise.all([ this.client.incrbyfloat(dailyKey, amount), this.client.incrbyfloat(monthlyKey, amount), this.client.incrbyfloat(hourlyKey, amount), - this.client.incrbyfloat(totalKey, amount), // ✅ 累加到总费用(永不过期) - // 设置过期时间(注意:totalKey 不设置过期时间,保持永久累计) + this.client.incrbyfloat(totalKey, amount), // 倍率后总费用(用于限额) + this.client.incrbyfloat(realTotalKey, actualRealAmount), // 真实总费用(用于对账) + this.client.incrbyfloat(realDailyKey, actualRealAmount), // 真实每日费用 + // 设置过期时间(注意:totalKey 和 realTotalKey 不设置过期时间,保持永久累计) this.client.expire(dailyKey, 86400 * 30), // 30天 this.client.expire(monthlyKey, 86400 * 90), // 90天 - this.client.expire(hourlyKey, 86400 * 7) // 7天 + this.client.expire(hourlyKey, 86400 * 7), // 7天 + this.client.expire(realDailyKey, 86400 * 30) // 30天 ]) logger.debug(`💰 Cost incremented successfully, new daily total: $${results[0]}`) } - // 💰 获取费用统计 + // 💰 获取费用统计(包含倍率成本和真实成本) async getCostStats(keyId) { const today = getDateStringInTimezone() const tzDate = getDateInTimezone() @@ -1610,18 +1635,22 @@ class RedisClient { )}` const currentHour = `${today}:${String(getHourInTimezone(new Date())).padStart(2, '0')}` - const [daily, monthly, hourly, total] = await Promise.all([ + const [daily, monthly, hourly, total, realTotal, realDaily] = await Promise.all([ this.client.get(`usage:cost:daily:${keyId}:${today}`), this.client.get(`usage:cost:monthly:${keyId}:${currentMonth}`), this.client.get(`usage:cost:hourly:${keyId}:${currentHour}`), - this.client.get(`usage:cost:total:${keyId}`) + this.client.get(`usage:cost:total:${keyId}`), + this.client.get(`usage:cost:real:total:${keyId}`), + this.client.get(`usage:cost:real:daily:${keyId}:${today}`) ]) return { daily: parseFloat(daily || 0), monthly: parseFloat(monthly || 0), hourly: parseFloat(hourly || 0), - total: parseFloat(total || 0) + total: parseFloat(total || 0), + realTotal: parseFloat(realTotal || 0), + realDaily: parseFloat(realDaily || 0) } } @@ -1637,22 +1666,30 @@ class RedisClient { return result } - // 💰 增加本周 Opus 费用 - async incrementWeeklyOpusCost(keyId, amount) { + // 💰 增加本周 Opus 费用(支持倍率成本和真实成本) + // amount: 倍率后的成本(用于限额校验) + // realAmount: 真实成本(用于对账),如果不传则等于 amount + async incrementWeeklyOpusCost(keyId, amount, realAmount = null) { const currentWeek = getWeekStringInTimezone() const weeklyKey = `usage:opus:weekly:${keyId}:${currentWeek}` const totalKey = `usage:opus:total:${keyId}` + const realWeeklyKey = `usage:opus:real:weekly:${keyId}:${currentWeek}` + const realTotalKey = `usage:opus:real:total:${keyId}` + const actualRealAmount = realAmount !== null ? realAmount : amount logger.debug( - `💰 Incrementing weekly Opus cost for ${keyId}, week: ${currentWeek}, amount: $${amount}` + `💰 Incrementing weekly Opus cost for ${keyId}, week: ${currentWeek}, rated: $${amount}, real: $${actualRealAmount}` ) // 使用 pipeline 批量执行,提高性能 const pipeline = this.client.pipeline() pipeline.incrbyfloat(weeklyKey, amount) pipeline.incrbyfloat(totalKey, amount) + pipeline.incrbyfloat(realWeeklyKey, actualRealAmount) + pipeline.incrbyfloat(realTotalKey, actualRealAmount) // 设置周费用键的过期时间为 2 周 pipeline.expire(weeklyKey, 14 * 24 * 3600) + pipeline.expire(realWeeklyKey, 14 * 24 * 3600) const results = await pipeline.exec() logger.debug(`💰 Opus cost incremented successfully, new weekly total: $${results[0][1]}`) @@ -2252,6 +2289,123 @@ class RedisClient { return await this.client.del(key) } + // 💰 账户余额缓存(API 查询结果) + async setAccountBalance(platform, accountId, balanceData, ttl = 3600) { + const key = `account_balance:${platform}:${accountId}` + + const payload = { + balance: + balanceData && balanceData.balance !== null && balanceData.balance !== undefined + ? String(balanceData.balance) + : '', + currency: balanceData?.currency || 'USD', + lastRefreshAt: balanceData?.lastRefreshAt || new Date().toISOString(), + queryMethod: balanceData?.queryMethod || 'api', + status: balanceData?.status || 'success', + errorMessage: balanceData?.errorMessage || balanceData?.error || '', + rawData: balanceData?.rawData ? JSON.stringify(balanceData.rawData) : '', + quota: balanceData?.quota ? JSON.stringify(balanceData.quota) : '' + } + + await this.client.hset(key, payload) + await this.client.expire(key, ttl) + } + + async getAccountBalance(platform, accountId) { + const key = `account_balance:${platform}:${accountId}` + const [data, ttlSeconds] = await Promise.all([this.client.hgetall(key), this.client.ttl(key)]) + + if (!data || Object.keys(data).length === 0) { + return null + } + + let rawData = null + if (data.rawData) { + try { + rawData = JSON.parse(data.rawData) + } catch (error) { + rawData = null + } + } + + let quota = null + if (data.quota) { + try { + quota = JSON.parse(data.quota) + } catch (error) { + quota = null + } + } + + return { + balance: data.balance ? parseFloat(data.balance) : null, + currency: data.currency || 'USD', + lastRefreshAt: data.lastRefreshAt || null, + queryMethod: data.queryMethod || null, + status: data.status || null, + errorMessage: data.errorMessage || '', + rawData, + quota, + ttlSeconds: Number.isFinite(ttlSeconds) ? ttlSeconds : null + } + } + + // 📊 账户余额缓存(本地统计) + async setLocalBalance(platform, accountId, statisticsData, ttl = 300) { + const key = `account_balance_local:${platform}:${accountId}` + + await this.client.hset(key, { + estimatedBalance: JSON.stringify(statisticsData || {}), + lastCalculated: new Date().toISOString() + }) + await this.client.expire(key, ttl) + } + + async getLocalBalance(platform, accountId) { + const key = `account_balance_local:${platform}:${accountId}` + const data = await this.client.hgetall(key) + + if (!data || !data.estimatedBalance) { + return null + } + + try { + return JSON.parse(data.estimatedBalance) + } catch (error) { + return null + } + } + + async deleteAccountBalance(platform, accountId) { + const key = `account_balance:${platform}:${accountId}` + const localKey = `account_balance_local:${platform}:${accountId}` + await this.client.del(key, localKey) + } + + // 🧩 账户余额脚本配置 + async setBalanceScriptConfig(platform, accountId, scriptConfig) { + const key = `account_balance_script:${platform}:${accountId}` + await this.client.set(key, JSON.stringify(scriptConfig || {})) + } + + async getBalanceScriptConfig(platform, accountId) { + const key = `account_balance_script:${platform}:${accountId}` + const raw = await this.client.get(key) + if (!raw) { + return null + } + try { + return JSON.parse(raw) + } catch (error) { + return null + } + } + + async deleteBalanceScriptConfig(platform, accountId) { + const key = `account_balance_script:${platform}:${accountId}` + return await this.client.del(key) + } + // 📈 系统统计 async getSystemStats() { const keys = await Promise.all([ @@ -4688,12 +4842,58 @@ redisClient.migrateGlobalStats = async function () { // 写入全局统计 await this.client.hset('usage:global:total', total) + + // 迁移月份索引(从现有的 usage:model:monthly:* key 中提取月份) + const monthlyKeys = await this.client.keys('usage:model:monthly:*') + const months = new Set() + for (const key of monthlyKeys) { + const match = key.match(/:(\d{4}-\d{2})$/) + if (match) { + months.add(match[1]) + } + } + if (months.size > 0) { + await this.client.sadd('usage:model:monthly:months', ...months) + logger.info(`📅 迁移月份索引: ${months.size} 个月份 (${[...months].sort().join(', ')})`) + } + logger.success( `✅ 迁移完成: ${keyIds.length} 个 API Key, ${total.requests} 请求, ${total.allTokens} tokens` ) return { success: true, migrated: keyIds.length, total } } +// 确保月份索引完整(后台检查,补充缺失的月份) +redisClient.ensureMonthlyMonthsIndex = async function () { + const logger = require('../utils/logger') + + // 扫描所有月份 key + const monthlyKeys = await this.client.keys('usage:model:monthly:*') + const allMonths = new Set() + for (const key of monthlyKeys) { + const match = key.match(/:(\d{4}-\d{2})$/) + if (match) { + allMonths.add(match[1]) + } + } + + if (allMonths.size === 0) { + return // 没有月份数据 + } + + // 获取索引中已有的月份 + const existingMonths = await this.client.smembers('usage:model:monthly:months') + const existingSet = new Set(existingMonths) + + // 找出缺失的月份 + const missingMonths = [...allMonths].filter((m) => !existingSet.has(m)) + + if (missingMonths.length > 0) { + await this.client.sadd('usage:model:monthly:months', ...missingMonths) + logger.info(`📅 补充月份索引: ${missingMonths.length} 个月份 (${missingMonths.sort().join(', ')})`) + } +} + // 检查是否需要迁移 redisClient.needsGlobalStatsMigration = async function () { const exists = await this.client.exists('usage:global:total') diff --git a/src/routes/admin/accountBalance.js b/src/routes/admin/accountBalance.js new file mode 100644 index 00000000..7f1d18db --- /dev/null +++ b/src/routes/admin/accountBalance.js @@ -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 diff --git a/src/routes/admin/apiKeys.js b/src/routes/admin/apiKeys.js index 86484332..5b3df468 100644 --- a/src/routes/admin/apiKeys.js +++ b/src/routes/admin/apiKeys.js @@ -8,15 +8,41 @@ const config = require('../../../config/config') const router = express.Router() -// 有效的服务权限值 -const VALID_SERVICES = ['claude', 'gemini', 'openai', 'droid'] +// 有效的权限值列表 +const VALID_PERMISSIONS = ['claude', 'gemini', 'openai', 'droid'] -// 验证 permissions 值(支持单选和多选) -const isValidPermissions = (permissions) => { - if (!permissions || permissions === 'all') return true - // 支持逗号分隔的多选格式 - const services = permissions.split(',') - return services.every((s) => VALID_SERVICES.includes(s.trim())) +/** + * 验证权限数组格式 + * @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分配) @@ -715,6 +741,91 @@ router.get('/api-keys/tags', authenticateAdmin, async (req, res) => { } }) +// 获取标签详情(含使用数量) +router.get('/api-keys/tags/details', authenticateAdmin, async (req, res) => { + try { + const tagDetails = await apiKeyService.getTagsWithCount() + logger.info(`📋 Retrieved ${tagDetails.length} tags with usage counts`) + return res.json({ success: true, data: tagDetails }) + } catch (error) { + logger.error('❌ Failed to get tag details:', error) + return res.status(500).json({ error: 'Failed to get tag details', message: error.message }) + } +}) + +// 创建新标签 +router.post('/api-keys/tags', authenticateAdmin, async (req, res) => { + try { + const { name } = req.body + if (!name || !name.trim()) { + return res.status(400).json({ error: '标签名称不能为空' }) + } + + const result = await apiKeyService.createTag(name.trim()) + if (!result.success) { + return res.status(400).json({ error: result.error }) + } + + logger.info(`🏷️ Created new tag: ${name}`) + return res.json({ success: true, message: '标签创建成功' }) + } catch (error) { + logger.error('❌ Failed to create tag:', error) + return res.status(500).json({ error: 'Failed to create tag', message: error.message }) + } +}) + +// 删除标签(从所有 API Key 中移除) +router.delete('/api-keys/tags/:tagName', authenticateAdmin, async (req, res) => { + try { + const { tagName } = req.params + if (!tagName) { + return res.status(400).json({ error: 'Tag name is required' }) + } + + const decodedTagName = decodeURIComponent(tagName) + const result = await apiKeyService.removeTagFromAllKeys(decodedTagName) + + logger.info(`🏷️ Removed tag "${decodedTagName}" from ${result.affectedCount} API keys`) + return res.json({ + success: true, + message: `Tag "${decodedTagName}" removed from ${result.affectedCount} API keys`, + affectedCount: result.affectedCount + }) + } catch (error) { + logger.error('❌ Failed to delete tag:', error) + return res.status(500).json({ error: 'Failed to delete tag', message: error.message }) + } +}) + +// 重命名标签 +router.put('/api-keys/tags/:tagName', authenticateAdmin, async (req, res) => { + try { + const { tagName } = req.params + const { newName } = req.body + if (!tagName || !newName || !newName.trim()) { + return res.status(400).json({ error: 'Tag name and new name are required' }) + } + + const decodedTagName = decodeURIComponent(tagName) + const trimmedNewName = newName.trim() + const result = await apiKeyService.renameTag(decodedTagName, trimmedNewName) + + if (result.error) { + return res.status(400).json({ error: result.error }) + } + + logger.info(`🏷️ Renamed tag "${decodedTagName}" to "${trimmedNewName}" in ${result.affectedCount} API keys`) + return res.json({ + success: true, + message: `Tag renamed in ${result.affectedCount} API keys`, + affectedCount: result.affectedCount + }) + } catch (error) { + logger.error('❌ Failed to rename tag:', error) + return res.status(500).json({ error: 'Failed to rename tag', message: error.message }) + } +}) + /** * 获取账户绑定的 API Key 数量统计 * GET /admin/accounts/binding-counts @@ -1436,16 +1547,10 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { } } - // 验证服务权限字段 - if ( - permissions !== undefined && - permissions !== null && - permissions !== '' && - !isValidPermissions(permissions) - ) { - return res.status(400).json({ - error: 'Invalid permissions value. Must be claude, gemini, openai, droid, all, or comma-separated combination' - }) + // 验证服务权限字段(支持数组格式) + const permissionsError = validatePermissions(permissions) + if (permissionsError) { + return res.status(400).json({ error: permissionsError }) } const newKey = await apiKeyService.generateApiKey({ @@ -1535,15 +1640,10 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => { .json({ error: 'Base name must be less than 90 characters to allow for numbering' }) } - if ( - permissions !== undefined && - permissions !== null && - permissions !== '' && - !isValidPermissions(permissions) - ) { - return res.status(400).json({ - error: 'Invalid permissions value. Must be claude, gemini, openai, droid, all, or comma-separated combination' - }) + // 验证服务权限字段(支持数组格式) + const batchPermissionsError = validatePermissions(permissions) + if (batchPermissionsError) { + return res.status(400).json({ error: batchPermissionsError }) } // 生成批量API Keys @@ -1646,13 +1746,12 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => { }) } - if ( - updates.permissions !== undefined && - !isValidPermissions(updates.permissions) - ) { - return res.status(400).json({ - error: 'Invalid permissions value. Must be claude, gemini, openai, droid, all, or comma-separated combination' - }) + // 验证服务权限字段(支持数组格式) + if (updates.permissions !== undefined) { + const updatePermissionsError = validatePermissions(updates.permissions) + if (updatePermissionsError) { + return res.status(400).json({ error: updatePermissionsError }) + } } logger.info( @@ -1927,11 +2026,10 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { } if (permissions !== undefined) { - // 验证权限值 - if (!isValidPermissions(permissions)) { - return res.status(400).json({ - error: 'Invalid permissions value. Must be claude, gemini, openai, droid, all, or comma-separated combination' - }) + // 验证服务权限字段(支持数组格式) + const singlePermissionsError = validatePermissions(permissions) + if (singlePermissionsError) { + return res.status(400).json({ error: singlePermissionsError }) } updates.permissions = permissions } diff --git a/src/routes/admin/balanceScripts.js b/src/routes/admin/balanceScripts.js new file mode 100644 index 00000000..ef7ffa01 --- /dev/null +++ b/src/routes/admin/balanceScripts.js @@ -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 diff --git a/src/routes/admin/bedrockAccounts.js b/src/routes/admin/bedrockAccounts.js index c9d3a17c..4b6a365b 100644 --- a/src/routes/admin/bedrockAccounts.js +++ b/src/routes/admin/bedrockAccounts.js @@ -122,6 +122,7 @@ router.post('/', authenticateAdmin, async (req, res) => { description, region, awsCredentials, + bearerToken, defaultModel, priority, accountType, @@ -145,9 +146,9 @@ router.post('/', authenticateAdmin, async (req, res) => { } // 验证credentialType的有效性 - if (credentialType && !['default', 'access_key', 'bearer_token'].includes(credentialType)) { + if (credentialType && !['access_key', 'bearer_token'].includes(credentialType)) { return res.status(400).json({ - error: 'Invalid credential type. Must be "default", "access_key", or "bearer_token"' + error: 'Invalid credential type. Must be "access_key" or "bearer_token"' }) } @@ -156,10 +157,11 @@ router.post('/', authenticateAdmin, async (req, res) => { description: description || '', region: region || 'us-east-1', awsCredentials, + bearerToken, defaultModel, priority: priority || 50, accountType: accountType || 'shared', - credentialType: credentialType || 'default' + credentialType: credentialType || 'access_key' }) if (!result.success) { @@ -206,10 +208,10 @@ router.put('/:accountId', authenticateAdmin, async (req, res) => { // 验证credentialType的有效性 if ( mappedUpdates.credentialType && - !['default', 'access_key', 'bearer_token'].includes(mappedUpdates.credentialType) + !['access_key', 'bearer_token'].includes(mappedUpdates.credentialType) ) { return res.status(400).json({ - error: 'Invalid credential type. Must be "default", "access_key", or "bearer_token"' + error: 'Invalid credential type. Must be "access_key" or "bearer_token"' }) } @@ -349,22 +351,15 @@ router.put('/:accountId/toggle-schedulable', authenticateAdmin, async (req, res) } }) -// 测试Bedrock账户连接 +// 测试Bedrock账户连接(SSE 流式) router.post('/:accountId/test', authenticateAdmin, async (req, res) => { try { const { accountId } = req.params - const result = await bedrockAccountService.testAccount(accountId) - - if (!result.success) { - return res.status(500).json({ error: 'Account test failed', message: result.error }) - } - - logger.success(`🧪 Admin tested Bedrock account: ${accountId} - ${result.data.status}`) - return res.json({ success: true, data: result.data }) + await bedrockAccountService.testAccountConnection(accountId, res) } catch (error) { logger.error('❌ Failed to test Bedrock account:', error) - return res.status(500).json({ error: 'Failed to test Bedrock account', message: error.message }) + // 错误已在服务层处理,这里仅做日志记录 } }) diff --git a/src/routes/admin/geminiAccounts.js b/src/routes/admin/geminiAccounts.js index 3d9c4abf..e3eb57d2 100644 --- a/src/routes/admin/geminiAccounts.js +++ b/src/routes/admin/geminiAccounts.js @@ -11,14 +11,19 @@ const { formatAccountExpiry, mapExpiryField } = require('./utils') const router = express.Router() // 🤖 Gemini OAuth 账户管理 +function getDefaultRedirectUri(oauthProvider) { + if (oauthProvider === 'antigravity') { + return process.env.ANTIGRAVITY_OAUTH_REDIRECT_URI || 'http://localhost:45462' + } + return process.env.GEMINI_OAUTH_REDIRECT_URI || 'https://codeassist.google.com/authcode' +} // 生成 Gemini OAuth 授权 URL router.post('/generate-auth-url', authenticateAdmin, async (req, res) => { try { - const { state, proxy } = req.body // 接收代理配置 + const { state, proxy, oauthProvider } = req.body // 接收代理配置与OAuth Provider - // 使用新的 codeassist.google.com 回调地址 - const redirectUri = 'https://codeassist.google.com/authcode' + const redirectUri = getDefaultRedirectUri(oauthProvider) logger.info(`Generating Gemini OAuth URL with redirect_uri: ${redirectUri}`) @@ -26,8 +31,9 @@ router.post('/generate-auth-url', authenticateAdmin, async (req, res) => { authUrl, state: authState, codeVerifier, - redirectUri: finalRedirectUri - } = await geminiAccountService.generateAuthUrl(state, redirectUri, proxy) + redirectUri: finalRedirectUri, + oauthProvider: resolvedOauthProvider + } = await geminiAccountService.generateAuthUrl(state, redirectUri, proxy, oauthProvider) // 创建 OAuth 会话,包含 codeVerifier 和代理配置 const sessionId = authState @@ -37,6 +43,7 @@ router.post('/generate-auth-url', authenticateAdmin, async (req, res) => { redirectUri: finalRedirectUri, codeVerifier, // 保存 PKCE code verifier proxy: proxy || null, // 保存代理配置 + oauthProvider: resolvedOauthProvider, createdAt: new Date().toISOString() }) @@ -45,7 +52,8 @@ router.post('/generate-auth-url', authenticateAdmin, async (req, res) => { success: true, data: { authUrl, - sessionId + sessionId, + oauthProvider: resolvedOauthProvider } }) } catch (error) { @@ -80,13 +88,14 @@ router.post('/poll-auth-status', authenticateAdmin, async (req, res) => { // 交换 Gemini 授权码 router.post('/exchange-code', authenticateAdmin, async (req, res) => { try { - const { code, sessionId, proxy: requestProxy } = req.body + const { code, sessionId, proxy: requestProxy, oauthProvider } = req.body + let resolvedOauthProvider = oauthProvider if (!code) { return res.status(400).json({ error: 'Authorization code is required' }) } - let redirectUri = 'https://codeassist.google.com/authcode' + let redirectUri = getDefaultRedirectUri(resolvedOauthProvider) let codeVerifier = null let proxyConfig = null @@ -97,11 +106,16 @@ router.post('/exchange-code', authenticateAdmin, async (req, res) => { const { redirectUri: sessionRedirectUri, codeVerifier: sessionCodeVerifier, - proxy + proxy, + oauthProvider: sessionOauthProvider } = sessionData redirectUri = sessionRedirectUri || redirectUri codeVerifier = sessionCodeVerifier proxyConfig = proxy // 获取代理配置 + if (!resolvedOauthProvider && sessionOauthProvider) { + // 会话里保存的 provider 仅作为兜底 + resolvedOauthProvider = sessionOauthProvider + } logger.info( `Using session redirect_uri: ${redirectUri}, has codeVerifier: ${!!codeVerifier}, has proxy from session: ${!!proxyConfig}` ) @@ -120,7 +134,8 @@ router.post('/exchange-code', authenticateAdmin, async (req, res) => { code, redirectUri, codeVerifier, - proxyConfig // 传递代理配置 + proxyConfig, // 传递代理配置 + resolvedOauthProvider ) // 清理 OAuth 会话 @@ -129,7 +144,7 @@ router.post('/exchange-code', authenticateAdmin, async (req, res) => { } logger.success('Successfully exchanged Gemini authorization code') - return res.json({ success: true, data: { tokens } }) + return res.json({ success: true, data: { tokens, oauthProvider: resolvedOauthProvider } }) } catch (error) { logger.error('❌ Failed to exchange Gemini authorization code:', error) return res.status(500).json({ error: 'Failed to exchange code', message: error.message }) diff --git a/src/routes/admin/index.js b/src/routes/admin/index.js index bb607e9d..01380a9c 100644 --- a/src/routes/admin/index.js +++ b/src/routes/admin/index.js @@ -21,6 +21,7 @@ const openaiResponsesAccountsRoutes = require('./openaiResponsesAccounts') const droidAccountsRoutes = require('./droidAccounts') const dashboardRoutes = require('./dashboard') const usageStatsRoutes = require('./usageStats') +const accountBalanceRoutes = require('./accountBalance') const systemRoutes = require('./system') const concurrencyRoutes = require('./concurrency') const claudeRelayConfigRoutes = require('./claudeRelayConfig') @@ -39,6 +40,7 @@ router.use('/', openaiResponsesAccountsRoutes) router.use('/', droidAccountsRoutes) router.use('/', dashboardRoutes) router.use('/', usageStatsRoutes) +router.use('/', accountBalanceRoutes) router.use('/', systemRoutes) router.use('/', concurrencyRoutes) router.use('/', claudeRelayConfigRoutes) diff --git a/src/routes/admin/quotaCards.js b/src/routes/admin/quotaCards.js index ab69420a..67fc1a68 100644 --- a/src/routes/admin/quotaCards.js +++ b/src/routes/admin/quotaCards.js @@ -12,6 +12,33 @@ const { authenticateAdmin } = require('../../middleware/auth') // 额度卡管理 // ═══════════════════════════════════════════════════════════════════════════ +// 获取额度卡上限配置 +router.get('/quota-cards/limits', authenticateAdmin, async (req, res) => { + try { + const config = await quotaCardService.getLimitsConfig() + res.json({ success: true, data: config }) + } catch (error) { + logger.error('❌ Failed to get quota card limits:', error) + res.status(500).json({ success: false, error: error.message }) + } +}) + +// 更新额度卡上限配置 +router.put('/quota-cards/limits', authenticateAdmin, async (req, res) => { + try { + const { enabled, maxExpiryDays, maxTotalCostLimit } = req.body + const config = await quotaCardService.saveLimitsConfig({ + enabled, + maxExpiryDays, + maxTotalCostLimit + }) + res.json({ success: true, data: config }) + } catch (error) { + logger.error('❌ Failed to save quota card limits:', error) + res.status(500).json({ success: false, error: error.message }) + } +}) + // 获取额度卡列表 router.get('/quota-cards', authenticateAdmin, async (req, res) => { try { @@ -185,120 +212,6 @@ router.post('/redemptions/:id/revoke', authenticateAdmin, async (req, res) => { } }) -// ═══════════════════════════════════════════════════════════════════════════ -// API Key 聚合类型转换 -// ═══════════════════════════════════════════════════════════════════════════ - -// 获取转换预览 -router.get('/api-keys/:id/convert-preview', authenticateAdmin, async (req, res) => { - try { - const preview = await apiKeyService.getConvertToAggregatedPreview(req.params.id) - res.json({ - success: true, - data: preview - }) - } catch (error) { - logger.error('❌ Failed to get convert preview:', error) - res.status(500).json({ - success: false, - error: error.message - }) - } -}) - -// 执行转换 -router.post('/api-keys/:id/convert-to-aggregated', authenticateAdmin, async (req, res) => { - try { - const { quotaLimit, permissions, serviceQuotaLimits, quotaUsed } = req.body - - if (quotaLimit === undefined || quotaLimit === null) { - return res.status(400).json({ - success: false, - error: 'quotaLimit is required' - }) - } - - if (!permissions || !Array.isArray(permissions) || permissions.length === 0) { - return res.status(400).json({ - success: false, - error: 'permissions must be a non-empty array' - }) - } - - const result = await apiKeyService.convertToAggregated(req.params.id, { - quotaLimit: parseFloat(quotaLimit), - permissions, - serviceQuotaLimits: serviceQuotaLimits || {}, - quotaUsed: quotaUsed !== undefined ? parseFloat(quotaUsed) : null - }) - - res.json({ - success: true, - data: result - }) - } catch (error) { - logger.error('❌ Failed to convert to aggregated:', error) - res.status(500).json({ - success: false, - error: error.message - }) - } -}) - -// 手动增加额度 -router.post('/api-keys/:id/add-quota', authenticateAdmin, async (req, res) => { - try { - const { quotaAmount } = req.body - - if (!quotaAmount || quotaAmount <= 0) { - return res.status(400).json({ - success: false, - error: 'quotaAmount must be a positive number' - }) - } - - const result = await apiKeyService.addQuota(req.params.id, parseFloat(quotaAmount)) - - res.json({ - success: true, - data: result - }) - } catch (error) { - logger.error('❌ Failed to add quota:', error) - res.status(500).json({ - success: false, - error: error.message - }) - } -}) - -// 手动减少额度 -router.post('/api-keys/:id/deduct-quota', authenticateAdmin, async (req, res) => { - try { - const { quotaAmount } = req.body - - if (!quotaAmount || quotaAmount <= 0) { - return res.status(400).json({ - success: false, - error: 'quotaAmount must be a positive number' - }) - } - - const result = await apiKeyService.deductQuotaLimit(req.params.id, parseFloat(quotaAmount)) - - res.json({ - success: true, - data: result - }) - } catch (error) { - logger.error('❌ Failed to deduct quota:', error) - res.status(500).json({ - success: false, - error: error.message - }) - } -}) - // 延长有效期 router.post('/api-keys/:id/extend-expiry', authenticateAdmin, async (req, res) => { try { diff --git a/src/routes/admin/usageStats.js b/src/routes/admin/usageStats.js index 69145d93..8351a738 100644 --- a/src/routes/admin/usageStats.js +++ b/src/routes/admin/usageStats.js @@ -8,6 +8,7 @@ const geminiApiAccountService = require('../../services/geminiApiAccountService' const openaiAccountService = require('../../services/openaiAccountService') const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService') const droidAccountService = require('../../services/droidAccountService') +const bedrockAccountService = require('../../services/bedrockAccountService') const redis = require('../../models/redis') const { authenticateAdmin } = require('../../middleware/auth') const logger = require('../../utils/logger') @@ -104,6 +105,7 @@ async function getUsageDataByIndex(indexKey, keyPattern, scanPattern) { const accountTypeNames = { claude: 'Claude官方', + 'claude-official': 'Claude官方', 'claude-console': 'Claude Console', ccr: 'Claude Console Relay', openai: 'OpenAI', @@ -111,6 +113,7 @@ const accountTypeNames = { gemini: 'Gemini', 'gemini-api': 'Gemini API', droid: 'Droid', + bedrock: 'AWS Bedrock', unknown: '未知渠道' } @@ -123,7 +126,8 @@ const resolveAccountByPlatform = async (accountId, platform) => { openai: openaiAccountService, 'openai-responses': openaiResponsesAccountService, droid: droidAccountService, - ccr: ccrAccountService + ccr: ccrAccountService, + bedrock: bedrockAccountService } if (platform && serviceMap[platform]) { @@ -247,7 +251,8 @@ router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req, 'openai-responses', 'gemini', 'gemini-api', - 'droid' + 'droid', + 'bedrock' ] if (!allowedPlatforms.includes(platform)) { return res.status(400).json({ @@ -260,7 +265,8 @@ router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req, openai: 'openai', 'openai-responses': 'openai-responses', 'gemini-api': 'gemini-api', - droid: 'droid' + droid: 'droid', + bedrock: 'bedrock' } const fallbackModelMap = { @@ -270,7 +276,8 @@ router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req, 'openai-responses': 'gpt-4o-mini-2024-07-18', gemini: 'gemini-1.5-flash', 'gemini-api': 'gemini-2.0-flash', - droid: 'unknown' + droid: 'unknown', + bedrock: 'us.anthropic.claude-3-5-sonnet-20241022-v2:0' } // 获取账户信息以获取创建时间 @@ -301,6 +308,11 @@ router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req, case 'droid': accountData = await droidAccountService.getAccount(accountId) break + case 'bedrock': { + const result = await bedrockAccountService.getAccount(accountId) + accountData = result?.success ? result.data : null + break + } } if (accountData && accountData.createdAt) { @@ -1107,7 +1119,7 @@ router.get('/account-usage-trend', authenticateAdmin, async (req, res) => { try { const { granularity = 'day', group = 'claude', days = 7, startDate, endDate } = req.query - const allowedGroups = ['claude', 'openai', 'gemini', 'droid'] + const allowedGroups = ['claude', 'openai', 'gemini', 'droid', 'bedrock'] if (!allowedGroups.includes(group)) { return res.status(400).json({ success: false, @@ -1119,7 +1131,8 @@ router.get('/account-usage-trend', authenticateAdmin, async (req, res) => { claude: 'Claude账户', openai: 'OpenAI账户', gemini: 'Gemini账户', - droid: 'Droid账户' + droid: 'Droid账户', + bedrock: 'Bedrock账户' } // 拉取各平台账号列表 @@ -1213,6 +1226,18 @@ router.get('/account-usage-trend', authenticateAdmin, async (req, res) => { platform: 'droid' } }) + } else if (group === 'bedrock') { + const result = await bedrockAccountService.getAllAccounts() + const bedrockAccounts = result?.success ? result.data : [] + accounts = bedrockAccounts.map((account) => { + const id = String(account.id || '') + const shortId = id ? id.slice(0, 8) : '未知' + return { + id, + name: account.name || `Bedrock账号 ${shortId}`, + platform: 'bedrock' + } + }) } if (!accounts || accounts.length === 0) { @@ -2616,6 +2641,8 @@ router.get('/api-keys/:keyId/usage-records', authenticateAdmin, async (req, res) const costData = CostCalculator.calculateCost(usage, record.model || 'unknown') const computedCost = typeof record.cost === 'number' ? record.cost : costData?.costs?.total || 0 + const realCost = + typeof record.realCost === 'number' ? record.realCost : costData?.costs?.total || 0 const totalTokens = record.totalTokens || usage.input_tokens + @@ -2643,11 +2670,10 @@ router.get('/api-keys/:keyId/usage-records', authenticateAdmin, async (req, res) totalTokens, isLongContextRequest: record.isLongContext || record.isLongContextRequest || false, cost: Number(computedCost.toFixed(6)), - costFormatted: - record.costFormatted || - costData?.formatted?.total || - CostCalculator.formatCost(computedCost), - costBreakdown: record.costBreakdown || { + costFormatted: CostCalculator.formatCost(computedCost), + realCost: Number(realCost.toFixed(6)), + realCostFormatted: CostCalculator.formatCost(realCost), + costBreakdown: record.realCostBreakdown || record.costBreakdown || { input: costData?.costs?.input || 0, output: costData?.costs?.output || 0, cacheCreate: costData?.costs?.cacheWrite || 0, @@ -2930,6 +2956,8 @@ router.get('/accounts/:accountId/usage-records', authenticateAdmin, async (req, const costData = CostCalculator.calculateCost(usage, record.model || 'unknown') const computedCost = typeof record.cost === 'number' ? record.cost : costData?.costs?.total || 0 + const realCost = + typeof record.realCost === 'number' ? record.realCost : costData?.costs?.total || 0 const totalTokens = record.totalTokens || usage.input_tokens + @@ -2955,11 +2983,10 @@ router.get('/accounts/:accountId/usage-records', authenticateAdmin, async (req, totalTokens, isLongContextRequest: record.isLongContext || record.isLongContextRequest || false, cost: Number(computedCost.toFixed(6)), - costFormatted: - record.costFormatted || - costData?.formatted?.total || - CostCalculator.formatCost(computedCost), - costBreakdown: record.costBreakdown || { + costFormatted: CostCalculator.formatCost(computedCost), + realCost: Number(realCost.toFixed(6)), + realCostFormatted: CostCalculator.formatCost(realCost), + costBreakdown: record.realCostBreakdown || record.costBreakdown || { input: costData?.costs?.input || 0, output: costData?.costs?.output || 0, cacheCreate: costData?.costs?.cacheWrite || 0, diff --git a/src/routes/api.js b/src/routes/api.js index c38c4d6f..cf17c484 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -20,16 +20,21 @@ const { sendMockWarmupStream } = require('../utils/warmupInterceptor') const { sanitizeUpstreamError } = require('../utils/errorSanitizer') +const { dumpAnthropicMessagesRequest } = require('../utils/anthropicRequestDump') +const { + handleAnthropicMessagesToGemini, + handleAnthropicCountTokensToGemini +} = require('../services/anthropicGeminiBridgeService') const router = express.Router() -function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') { +function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '', keyId = null, accountType = null) { if (!rateLimitInfo) { return Promise.resolve({ totalTokens: 0, totalCost: 0 }) } const label = context ? ` (${context})` : '' - return updateRateLimitCounters(rateLimitInfo, usageSummary, model) + return updateRateLimitCounters(rateLimitInfo, usageSummary, model, keyId, accountType) .then(({ totalTokens, totalCost }) => { if (totalTokens > 0) { logger.api(`📊 Updated rate limit token count${label}: +${totalTokens} tokens`) @@ -117,16 +122,18 @@ async function handleMessagesRequest(req, res) { try { const startTime = Date.now() - // Claude 服务权限校验,阻止未授权的 Key - if ( - req.apiKey.permissions && - req.apiKey.permissions !== 'all' && - req.apiKey.permissions !== 'claude' - ) { + const forcedVendor = req._anthropicVendor || null + const requiredService = + forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity' ? 'gemini' : 'claude' + + if (!apiKeyService.hasPermission(req.apiKey?.permissions, requiredService)) { return res.status(403).json({ error: { type: 'permission_error', - message: '此 API Key 无权访问 Claude 服务' + message: + requiredService === 'gemini' + ? '此 API Key 无权访问 Gemini 服务' + : '此 API Key 无权访问 Claude 服务' } }) } @@ -175,6 +182,25 @@ async function handleMessagesRequest(req, res) { } } + logger.api('📥 /v1/messages request received', { + model: req.body.model || null, + forcedVendor, + stream: req.body.stream === true + }) + + dumpAnthropicMessagesRequest(req, { + route: '/v1/messages', + forcedVendor, + model: req.body?.model || null, + stream: req.body?.stream === true + }) + + // /v1/messages 的扩展:按路径强制分流到 Gemini OAuth 账户(避免 model 前缀混乱) + if (forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity') { + const baseModel = (req.body.model || '').trim() + return await handleAnthropicMessagesToGemini(req, res, { vendor: forcedVendor, baseModel }) + } + // 检查是否为流式请求 const isStream = req.body.stream === true @@ -390,11 +416,18 @@ async function handleMessagesRequest(req, res) { // 根据账号类型选择对应的转发服务并调用 if (accountType === 'claude-official') { // 官方Claude账号使用原有的转发服务(会自己选择账号) + // 🧹 内存优化:提取需要的值,避免闭包捕获整个 req 对象 + const _apiKeyId = req.apiKey.id + const _rateLimitInfo = req.rateLimitInfo + const _requestBody = req.body // 传递后清除引用 + const _apiKey = req.apiKey + const _headers = req.headers + await claudeRelayService.relayStreamRequestWithUsageCapture( - req.body, - req.apiKey, + _requestBody, + _apiKey, res, - req.headers, + _headers, (usageData) => { // 回调函数:当检测到完整usage数据时记录真实token使用量 logger.info( @@ -444,13 +477,13 @@ async function handleMessagesRequest(req, res) { } apiKeyService - .recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId, 'claude') + .recordUsageWithDetails(_apiKeyId, usageObject, model, usageAccountId, accountType) .catch((error) => { logger.error('❌ Failed to record stream usage:', error) }) queueRateLimitUpdate( - req.rateLimitInfo, + _rateLimitInfo, { inputTokens, outputTokens, @@ -458,7 +491,9 @@ async function handleMessagesRequest(req, res) { cacheReadTokens }, model, - 'claude-stream' + 'claude-stream', + _apiKeyId, + accountType ) usageDataCaptured = true @@ -475,11 +510,18 @@ async function handleMessagesRequest(req, res) { ) } else if (accountType === 'claude-console') { // Claude Console账号使用Console转发服务(需要传递accountId) + // 🧹 内存优化:提取需要的值 + const _apiKeyIdConsole = req.apiKey.id + const _rateLimitInfoConsole = req.rateLimitInfo + const _requestBodyConsole = req.body + const _apiKeyConsole = req.apiKey + const _headersConsole = req.headers + await claudeConsoleRelayService.relayStreamRequestWithUsageCapture( - req.body, - req.apiKey, + _requestBodyConsole, + _apiKeyConsole, res, - req.headers, + _headersConsole, (usageData) => { // 回调函数:当检测到完整usage数据时记录真实token使用量 logger.info( @@ -530,7 +572,7 @@ async function handleMessagesRequest(req, res) { apiKeyService .recordUsageWithDetails( - req.apiKey.id, + _apiKeyIdConsole, usageObject, model, usageAccountId, @@ -541,7 +583,7 @@ async function handleMessagesRequest(req, res) { }) queueRateLimitUpdate( - req.rateLimitInfo, + _rateLimitInfoConsole, { inputTokens, outputTokens, @@ -549,7 +591,9 @@ async function handleMessagesRequest(req, res) { cacheReadTokens }, model, - 'claude-console-stream' + 'claude-console-stream', + _apiKeyIdConsole, + accountType ) usageDataCaptured = true @@ -567,6 +611,11 @@ async function handleMessagesRequest(req, res) { ) } else if (accountType === 'bedrock') { // Bedrock账号使用Bedrock转发服务 + // 🧹 内存优化:提取需要的值 + const _apiKeyIdBedrock = req.apiKey.id + const _rateLimitInfoBedrock = req.rateLimitInfo + const _requestBodyBedrock = req.body + try { const bedrockAccountResult = await bedrockAccountService.getAccount(accountId) if (!bedrockAccountResult.success) { @@ -574,7 +623,7 @@ async function handleMessagesRequest(req, res) { } const result = await bedrockRelayService.handleStreamRequest( - req.body, + _requestBodyBedrock, bedrockAccountResult.data, res ) @@ -585,13 +634,22 @@ async function handleMessagesRequest(req, res) { const outputTokens = result.usage.output_tokens || 0 apiKeyService - .recordUsage(req.apiKey.id, inputTokens, outputTokens, 0, 0, result.model, accountId) + .recordUsage( + _apiKeyIdBedrock, + inputTokens, + outputTokens, + 0, + 0, + result.model, + accountId, + 'bedrock' + ) .catch((error) => { logger.error('❌ Failed to record Bedrock stream usage:', error) }) queueRateLimitUpdate( - req.rateLimitInfo, + _rateLimitInfoBedrock, { inputTokens, outputTokens, @@ -599,7 +657,9 @@ async function handleMessagesRequest(req, res) { cacheReadTokens: 0 }, result.model, - 'bedrock-stream' + 'bedrock-stream', + _apiKeyIdBedrock, + 'bedrock' ) usageDataCaptured = true @@ -616,11 +676,18 @@ async function handleMessagesRequest(req, res) { } } else if (accountType === 'ccr') { // CCR账号使用CCR转发服务(需要传递accountId) + // 🧹 内存优化:提取需要的值 + const _apiKeyIdCcr = req.apiKey.id + const _rateLimitInfoCcr = req.rateLimitInfo + const _requestBodyCcr = req.body + const _apiKeyCcr = req.apiKey + const _headersCcr = req.headers + await ccrRelayService.relayStreamRequestWithUsageCapture( - req.body, - req.apiKey, + _requestBodyCcr, + _apiKeyCcr, res, - req.headers, + _headersCcr, (usageData) => { // 回调函数:当检测到完整usage数据时记录真实token使用量 logger.info( @@ -670,13 +737,13 @@ async function handleMessagesRequest(req, res) { } apiKeyService - .recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId, 'ccr') + .recordUsageWithDetails(_apiKeyIdCcr, usageObject, model, usageAccountId, 'ccr') .catch((error) => { logger.error('❌ Failed to record CCR stream usage:', error) }) queueRateLimitUpdate( - req.rateLimitInfo, + _rateLimitInfoCcr, { inputTokens, outputTokens, @@ -684,7 +751,9 @@ async function handleMessagesRequest(req, res) { cacheReadTokens }, model, - 'ccr-stream' + 'ccr-stream', + _apiKeyIdCcr, + 'ccr' ) usageDataCaptured = true @@ -711,18 +780,26 @@ async function handleMessagesRequest(req, res) { } }, 1000) // 1秒后检查 } else { + // 🧹 内存优化:提取需要的值,避免后续回调捕获整个 req + const _apiKeyIdNonStream = req.apiKey.id + const _apiKeyNameNonStream = req.apiKey.name + const _rateLimitInfoNonStream = req.rateLimitInfo + const _requestBodyNonStream = req.body + const _apiKeyNonStream = req.apiKey + const _headersNonStream = req.headers + // 🔍 检查客户端连接是否仍然有效(可能在并发排队等待期间断开) if (res.destroyed || res.socket?.destroyed || res.writableEnded) { logger.warn( - `⚠️ Client disconnected before non-stream request could start for key: ${req.apiKey?.name || 'unknown'}` + `⚠️ Client disconnected before non-stream request could start for key: ${_apiKeyNameNonStream || 'unknown'}` ) return undefined } // 非流式响应 - 只使用官方真实usage数据 logger.info('📄 Starting non-streaming request', { - apiKeyId: req.apiKey.id, - apiKeyName: req.apiKey.name + apiKeyId: _apiKeyIdNonStream, + apiKeyName: _apiKeyNameNonStream }) // 📊 监听 socket 事件以追踪连接状态变化 @@ -893,11 +970,11 @@ async function handleMessagesRequest(req, res) { ? await claudeAccountService.getAccount(accountId) : await claudeConsoleAccountService.getAccount(accountId) - if (account?.interceptWarmup === 'true' && isWarmupRequest(req.body)) { + if (account?.interceptWarmup === 'true' && isWarmupRequest(_requestBodyNonStream)) { logger.api( `🔥 Warmup request intercepted (non-stream) for account: ${account.name} (${accountId})` ) - return res.json(buildMockWarmupResponse(req.body.model)) + return res.json(buildMockWarmupResponse(_requestBodyNonStream.model)) } } @@ -910,11 +987,11 @@ async function handleMessagesRequest(req, res) { if (accountType === 'claude-official') { // 官方Claude账号使用原有的转发服务 response = await claudeRelayService.relayRequest( - req.body, - req.apiKey, - req, + _requestBodyNonStream, + _apiKeyNonStream, + req, // clientRequest 用于断开检测,保留但服务层已优化 res, - req.headers + _headersNonStream ) } else if (accountType === 'claude-console') { // Claude Console账号使用Console转发服务 @@ -922,11 +999,11 @@ async function handleMessagesRequest(req, res) { `[DEBUG] Calling claudeConsoleRelayService.relayRequest with accountId: ${accountId}` ) response = await claudeConsoleRelayService.relayRequest( - req.body, - req.apiKey, - req, + _requestBodyNonStream, + _apiKeyNonStream, + req, // clientRequest 保留用于断开检测 res, - req.headers, + _headersNonStream, accountId ) } else if (accountType === 'bedrock') { @@ -938,9 +1015,9 @@ async function handleMessagesRequest(req, res) { } const result = await bedrockRelayService.handleNonStreamRequest( - req.body, + _requestBodyNonStream, bedrockAccountResult.data, - req.headers + _headersNonStream ) // 构建标准响应格式 @@ -970,11 +1047,11 @@ async function handleMessagesRequest(req, res) { // CCR账号使用CCR转发服务 logger.debug(`[DEBUG] Calling ccrRelayService.relayRequest with accountId: ${accountId}`) response = await ccrRelayService.relayRequest( - req.body, - req.apiKey, - req, + _requestBodyNonStream, + _apiKeyNonStream, + req, // clientRequest 保留用于断开检测 res, - req.headers, + _headersNonStream, accountId ) } @@ -1023,24 +1100,25 @@ async function handleMessagesRequest(req, res) { const cacheCreateTokens = jsonData.usage.cache_creation_input_tokens || 0 const cacheReadTokens = jsonData.usage.cache_read_input_tokens || 0 // Parse the model to remove vendor prefix if present (e.g., "ccr,gemini-2.5-pro" -> "gemini-2.5-pro") - const rawModel = jsonData.model || req.body.model || 'unknown' - const { baseModel } = parseVendorPrefixedModel(rawModel) - const model = baseModel || rawModel + const rawModel = jsonData.model || _requestBodyNonStream.model || 'unknown' + const { baseModel: usageBaseModel } = parseVendorPrefixedModel(rawModel) + const model = usageBaseModel || rawModel // 记录真实的token使用量(包含模型信息和所有4种token以及账户ID) const { accountId: responseAccountId } = response await apiKeyService.recordUsage( - req.apiKey.id, + _apiKeyIdNonStream, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model, - responseAccountId + responseAccountId, + accountType ) await queueRateLimitUpdate( - req.rateLimitInfo, + _rateLimitInfoNonStream, { inputTokens, outputTokens, @@ -1048,7 +1126,9 @@ async function handleMessagesRequest(req, res) { cacheReadTokens }, model, - 'claude-non-stream' + 'claude-non-stream', + _apiKeyIdNonStream, + accountType ) usageRecorded = true @@ -1201,6 +1281,65 @@ router.post('/claude/v1/messages', authenticateApiKey, handleMessagesRequest) // 📋 模型列表端点 - 支持 Claude, OpenAI, Gemini router.get('/v1/models', authenticateApiKey, async (req, res) => { try { + // Claude Code / Anthropic baseUrl 的分流:/antigravity/api/v1/models 返回 Antigravity 实时模型列表 + //(通过 v1internal:fetchAvailableModels),避免依赖静态 modelService 列表。 + const forcedVendor = req._anthropicVendor || null + if (forcedVendor === 'antigravity') { + if (!apiKeyService.hasPermission(req.apiKey?.permissions, 'gemini')) { + return res.status(403).json({ + error: { + type: 'permission_error', + message: '此 API Key 无权访问 Gemini 服务' + } + }) + } + + const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler') + const geminiAccountService = require('../services/geminiAccountService') + + let accountSelection + try { + accountSelection = await unifiedGeminiScheduler.selectAccountForApiKey( + req.apiKey, + null, + null, + { oauthProvider: 'antigravity' } + ) + } catch (error) { + logger.error('Failed to select Gemini OAuth account (antigravity models):', error) + return res.status(503).json({ error: 'No available Gemini OAuth accounts' }) + } + + const account = await geminiAccountService.getAccount(accountSelection.accountId) + if (!account) { + return res.status(503).json({ error: 'Gemini OAuth account not found' }) + } + + let proxyConfig = null + if (account.proxy) { + try { + proxyConfig = + typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy + } catch (e) { + logger.warn('Failed to parse proxy configuration:', e) + } + } + + const models = await geminiAccountService.fetchAvailableModelsAntigravity( + account.accessToken, + proxyConfig, + account.refreshToken + ) + + // 可选:根据 API Key 的模型限制过滤(黑名单语义) + let filteredModels = models + if (req.apiKey.enableModelRestriction && req.apiKey.restrictedModels?.length > 0) { + filteredModels = models.filter((model) => !req.apiKey.restrictedModels.includes(model.id)) + } + + return res.json({ object: 'list', data: filteredModels }) + } + const modelService = require('../services/modelService') // 从 modelService 获取所有支持的模型 @@ -1337,20 +1476,27 @@ router.get('/v1/organizations/:org_id/usage', authenticateApiKey, async (req, re // 🔢 Token计数端点 - count_tokens beta API router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) => { - // 检查权限 - if ( - req.apiKey.permissions && - req.apiKey.permissions !== 'all' && - req.apiKey.permissions !== 'claude' - ) { + // 按路径强制分流到 Gemini OAuth 账户(避免 model 前缀混乱) + const forcedVendor = req._anthropicVendor || null + const requiredService = + forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity' ? 'gemini' : 'claude' + + if (!apiKeyService.hasPermission(req.apiKey?.permissions, requiredService)) { return res.status(403).json({ error: { type: 'permission_error', - message: 'This API key does not have permission to access Claude' + message: + requiredService === 'gemini' + ? 'This API key does not have permission to access Gemini' + : 'This API key does not have permission to access Claude' } }) } + if (requiredService === 'gemini') { + return await handleAnthropicCountTokensToGemini(req, res, { vendor: forcedVendor }) + } + // 🔗 会话绑定验证(与 messages 端点保持一致) const originalSessionId = claudeRelayConfigService.extractOriginalSessionId(req.body) const sessionValidation = await claudeRelayConfigService.validateNewSession( diff --git a/src/routes/apiStats.js b/src/routes/apiStats.js index a4d76368..7eee8e58 100644 --- a/src/routes/apiStats.js +++ b/src/routes/apiStats.js @@ -8,6 +8,7 @@ const openaiAccountService = require('../services/openaiAccountService') const serviceRatesService = require('../services/serviceRatesService') const { createClaudeTestPayload } = require('../utils/testPayloadHelper') const modelsConfig = require('../../config/models') +const { getSafeMessage } = require('../utils/errorSanitizer') const router = express.Router() @@ -183,7 +184,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', @@ -502,7 +503,20 @@ router.post('/api/user-stats', async (req, res) => { restrictedModels: fullKeyData.restrictedModels || [], enableClientRestriction: fullKeyData.enableClientRestriction || false, allowedClients: fullKeyData.allowedClients || [] - } + }, + + // Key 级别的服务倍率 + serviceRates: (() => { + try { + return fullKeyData.serviceRates + ? typeof fullKeyData.serviceRates === 'string' + ? JSON.parse(fullKeyData.serviceRates) + : fullKeyData.serviceRates + : {} + } catch (e) { + return {} + } + })() } return res.json({ @@ -625,7 +639,18 @@ router.post('/api/batch-stats', async (req, res) => { ...usage.monthly, cost: costStats.monthly }, - totalCost: costStats.total + totalCost: costStats.total, + serviceRates: (() => { + try { + return keyData.serviceRates + ? typeof keyData.serviceRates === 'string' + ? JSON.parse(keyData.serviceRates) + : keyData.serviceRates + : {} + } catch (e) { + return {} + } + })() } }) ) @@ -883,13 +908,11 @@ router.post('/api-key/test', async (req, res) => { if (!res.headersSent) { return res.status(500).json({ error: 'Test failed', - message: error.message || 'Internal server error' + message: getSafeMessage(error) }) } - res.write( - `data: ${JSON.stringify({ type: 'error', error: error.message || 'Test failed' })}\n\n` - ) + res.write(`data: ${JSON.stringify({ type: 'error', error: getSafeMessage(error) })}\n\n`) res.end() } }) @@ -926,8 +949,7 @@ router.post('/api-key/test-gemini', async (req, res) => { } // 检查 Gemini 权限 - const permissions = validation.keyData.permissions || 'all' - if (permissions !== 'all' && !permissions.includes('gemini')) { + if (!apiKeyService.hasPermission(validation.keyData.permissions, 'gemini')) { return res.status(403).json({ error: 'Permission denied', message: 'This API key does not have Gemini permission' @@ -1022,13 +1044,13 @@ router.post('/api-key/test-gemini', async (req, res) => { response.data.on('error', (err) => { res.write( - `data: ${JSON.stringify({ type: 'test_complete', success: false, error: err.message })}\n\n` + `data: ${JSON.stringify({ type: 'test_complete', success: false, error: getSafeMessage(err) })}\n\n` ) res.end() }) } catch (axiosError) { res.write( - `data: ${JSON.stringify({ type: 'test_complete', success: false, error: axiosError.message })}\n\n` + `data: ${JSON.stringify({ type: 'test_complete', success: false, error: getSafeMessage(axiosError) })}\n\n` ) res.end() } @@ -1038,13 +1060,11 @@ router.post('/api-key/test-gemini', async (req, res) => { if (!res.headersSent) { return res.status(500).json({ error: 'Test failed', - message: error.message || 'Internal server error' + message: getSafeMessage(error) }) } - res.write( - `data: ${JSON.stringify({ type: 'error', error: error.message || 'Test failed' })}\n\n` - ) + res.write(`data: ${JSON.stringify({ type: 'error', error: getSafeMessage(error) })}\n\n`) res.end() } }) @@ -1081,8 +1101,7 @@ router.post('/api-key/test-openai', async (req, res) => { } // 检查 OpenAI 权限 - const permissions = validation.keyData.permissions || 'all' - if (permissions !== 'all' && !permissions.includes('openai')) { + if (!apiKeyService.hasPermission(validation.keyData.permissions, 'openai')) { return res.status(403).json({ error: 'Permission denied', message: 'This API key does not have OpenAI permission' @@ -1179,13 +1198,13 @@ router.post('/api-key/test-openai', async (req, res) => { response.data.on('error', (err) => { res.write( - `data: ${JSON.stringify({ type: 'test_complete', success: false, error: err.message })}\n\n` + `data: ${JSON.stringify({ type: 'test_complete', success: false, error: getSafeMessage(err) })}\n\n` ) res.end() }) } catch (axiosError) { res.write( - `data: ${JSON.stringify({ type: 'test_complete', success: false, error: axiosError.message })}\n\n` + `data: ${JSON.stringify({ type: 'test_complete', success: false, error: getSafeMessage(axiosError) })}\n\n` ) res.end() } @@ -1195,13 +1214,11 @@ router.post('/api-key/test-openai', async (req, res) => { if (!res.headersSent) { return res.status(500).json({ error: 'Test failed', - message: error.message || 'Internal server error' + message: getSafeMessage(error) }) } - res.write( - `data: ${JSON.stringify({ type: 'error', error: error.message || 'Test failed' })}\n\n` - ) + res.write(`data: ${JSON.stringify({ type: 'error', error: getSafeMessage(error) })}\n\n`) res.end() } }) @@ -1392,4 +1409,153 @@ router.get('/service-rates', async (req, res) => { } }) +// 🎫 公开的额度卡兑换接口(通过 apiId 验证身份) +router.post('/api/redeem-card', async (req, res) => { + const quotaCardService = require('../services/quotaCardService') + + try { + const { apiId, code } = req.body + const clientIP = req.ip || req.connection?.remoteAddress || 'unknown' + const hour = new Date().toISOString().slice(0, 13) + + // 防暴力破解:检查失败锁定 + const failKey = `redeem_card:fail:${clientIP}` + const failCount = parseInt((await redis.client.get(failKey)) || '0') + if (failCount >= 5) { + logger.security(`🔒 Card redemption locked for IP: ${clientIP}`) + return res.status(403).json({ + success: false, + error: '失败次数过多,请1小时后再试' + }) + } + + // 防暴力破解:检查 IP 速率限制 + const ipKey = `redeem_card:ip:${clientIP}:${hour}` + const ipCount = await redis.client.incr(ipKey) + await redis.client.expire(ipKey, 3600) + if (ipCount > 10) { + logger.security(`🚨 Card redemption rate limit for IP: ${clientIP}`) + return res.status(429).json({ + success: false, + error: '请求过于频繁,请稍后再试' + }) + } + + if (!apiId || !code) { + return res.status(400).json({ + success: false, + error: '请输入卡号' + }) + } + + // 验证 apiId 格式 + if ( + typeof apiId !== 'string' || + !apiId.match(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i) + ) { + return res.status(400).json({ + success: false, + error: 'API ID 格式无效' + }) + } + + // 验证 API Key 存在且有效 + const keyData = await redis.getApiKey(apiId) + if (!keyData || Object.keys(keyData).length === 0) { + return res.status(404).json({ + success: false, + error: 'API Key 不存在' + }) + } + + if (keyData.isActive !== 'true') { + return res.status(403).json({ + success: false, + error: 'API Key 已禁用' + }) + } + + // 调用兑换服务 + const result = await quotaCardService.redeemCard(code, apiId, null, keyData.name || 'API Stats') + + // 成功时清除失败计数(静默处理,不影响成功响应) + redis.client.del(failKey).catch(() => {}) + + logger.api(`🎫 Card redeemed via API Stats: ${code} -> ${apiId}`) + + res.json({ + success: true, + data: result + }) + } catch (error) { + // 失败时增加失败计数(静默处理,不影响错误响应) + const clientIP = req.ip || req.connection?.remoteAddress || 'unknown' + const failKey = `redeem_card:fail:${clientIP}` + redis.client + .incr(failKey) + .then(() => redis.client.expire(failKey, 3600)) + .catch(() => {}) + + logger.error('❌ Failed to redeem card:', error) + res.status(400).json({ + success: false, + error: error.message + }) + } +}) + +// 📋 公开的兑换记录查询接口(通过 apiId 验证身份) +router.get('/api/redemption-history', async (req, res) => { + const quotaCardService = require('../services/quotaCardService') + + try { + const { apiId, limit = 50, offset = 0 } = req.query + + if (!apiId) { + return res.status(400).json({ + success: false, + error: '缺少 API ID' + }) + } + + // 验证 apiId 格式 + if ( + typeof apiId !== 'string' || + !apiId.match(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i) + ) { + return res.status(400).json({ + success: false, + error: 'API ID 格式无效' + }) + } + + // 验证 API Key 存在 + const keyData = await redis.getApiKey(apiId) + if (!keyData || Object.keys(keyData).length === 0) { + return res.status(404).json({ + success: false, + error: 'API Key 不存在' + }) + } + + // 获取该 API Key 的兑换记录 + const result = await quotaCardService.getRedemptions({ + apiKeyId: apiId, + limit: parseInt(limit), + offset: parseInt(offset) + }) + + res.json({ + success: true, + data: result + }) + } catch (error) { + logger.error('❌ Failed to get redemption history:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + module.exports = router diff --git a/src/routes/azureOpenaiRoutes.js b/src/routes/azureOpenaiRoutes.js index ca0aa8fe..ce476c09 100644 --- a/src/routes/azureOpenaiRoutes.js +++ b/src/routes/azureOpenaiRoutes.js @@ -86,7 +86,8 @@ class AtomicUsageReporter { cacheCreateTokens, cacheReadTokens, modelToRecord, - accountId + accountId, + 'azure-openai' ) // 同步更新 Azure 账户的 lastUsedAt 和累计使用量 diff --git a/src/routes/droidRoutes.js b/src/routes/droidRoutes.js index f8479cde..b6d9932a 100644 --- a/src/routes/droidRoutes.js +++ b/src/routes/droidRoutes.js @@ -4,12 +4,12 @@ const { authenticateApiKey } = require('../middleware/auth') const droidRelayService = require('../services/droidRelayService') const sessionHelper = require('../utils/sessionHelper') const logger = require('../utils/logger') +const apiKeyService = require('../services/apiKeyService') const router = express.Router() function hasDroidPermission(apiKeyData) { - const permissions = apiKeyData?.permissions || 'all' - return permissions === 'all' || permissions === 'droid' + return apiKeyService.hasPermission(apiKeyData?.permissions, 'droid') } /** diff --git a/src/routes/geminiRoutes.js b/src/routes/geminiRoutes.js index eefeba57..8fdbcd45 100644 --- a/src/routes/geminiRoutes.js +++ b/src/routes/geminiRoutes.js @@ -29,6 +29,7 @@ const { handleStreamGenerateContent, handleLoadCodeAssist, handleOnboardUser, + handleRetrieveUserQuota, handleCountTokens, handleStandardGenerateContent, handleStandardStreamGenerateContent, @@ -68,7 +69,7 @@ router.get('/usage', authenticateApiKey, handleUsage) router.get('/key-info', authenticateApiKey, handleKeyInfo) // ============================================================================ -// v1internal 独有路由(listExperiments) +// v1internal 独有路由 // ============================================================================ /** @@ -81,6 +82,12 @@ router.post( handleSimpleEndpoint('listExperiments') ) +/** + * POST /v1internal:retrieveUserQuota + * 获取用户配额信息(Gemini CLI 0.22.2+ 需要) + */ +router.post('/v1internal\\:retrieveUserQuota', authenticateApiKey, handleRetrieveUserQuota) + /** * POST /v1beta/models/:modelName:listExperiments * 带模型参数的实验列表(只有 geminiRoutes 定义此路由) diff --git a/src/routes/openaiClaudeRoutes.js b/src/routes/openaiClaudeRoutes.js index 35314e4a..99c0b27b 100644 --- a/src/routes/openaiClaudeRoutes.js +++ b/src/routes/openaiClaudeRoutes.js @@ -8,10 +8,12 @@ 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') const claudeCodeHeadersService = require('../services/claudeCodeHeadersService') +const { getSafeMessage } = require('../utils/errorSanitizer') const sessionHelper = require('../utils/sessionHelper') const { updateRateLimitCounters } = require('../utils/rateLimitHelper') const pricingService = require('../services/pricingService') @@ -19,18 +21,17 @@ 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 = '') { +function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '', keyId = null, accountType = null) { if (!rateLimitInfo) { return } const label = context ? ` (${context})` : '' - updateRateLimitCounters(rateLimitInfo, usageSummary, model) + updateRateLimitCounters(rateLimitInfo, usageSummary, model, keyId, accountType) .then(({ totalTokens, totalCost }) => { if (totalTokens > 0) { logger.api(`📊 Updated rate limit token count${label}: +${totalTokens} tokens`) @@ -235,7 +236,7 @@ async function handleChatCompletion(req, res, apiKeyData) { } throw error } - const { accountId } = accountSelection + const { accountId, accountType } = accountSelection // 获取该账号存储的 Claude Code headers const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId) @@ -265,72 +266,107 @@ async function handleChatCompletion(req, res, apiKeyData) { } }) - // 使用转换后的响应流 (使用 OAuth-only beta header,添加 Claude Code 必需的 headers) - await claudeRelayService.relayStreamRequestWithUsageCapture( - claudeRequest, - apiKeyData, - res, - claudeCodeHeaders, - (usage) => { - // 记录使用统计 - if (usage && usage.input_tokens !== undefined && usage.output_tokens !== undefined) { - const model = usage.model || claudeRequest.model - const cacheCreateTokens = - (usage.cache_creation && typeof usage.cache_creation === 'object' - ? (usage.cache_creation.ephemeral_5m_input_tokens || 0) + - (usage.cache_creation.ephemeral_1h_input_tokens || 0) - : usage.cache_creation_input_tokens || 0) || 0 - const cacheReadTokens = usage.cache_read_input_tokens || 0 + // 使用转换后的响应流 (根据账户类型选择转发服务) + // 创建 usage 回调函数 + const usageCallback = (usage) => { + // 记录使用统计 + if (usage && usage.input_tokens !== undefined && usage.output_tokens !== undefined) { + const model = usage.model || claudeRequest.model + const cacheCreateTokens = + (usage.cache_creation && typeof usage.cache_creation === 'object' + ? (usage.cache_creation.ephemeral_5m_input_tokens || 0) + + (usage.cache_creation.ephemeral_1h_input_tokens || 0) + : usage.cache_creation_input_tokens || 0) || 0 + const cacheReadTokens = usage.cache_read_input_tokens || 0 - // 使用新的 recordUsageWithDetails 方法来支持详细的缓存数据 - apiKeyService - .recordUsageWithDetails( - apiKeyData.id, - usage, // 直接传递整个 usage 对象,包含可能的 cache_creation 详细数据 - model, - accountId - ) - .catch((error) => { - logger.error('❌ Failed to record usage:', error) - }) - - queueRateLimitUpdate( - req.rateLimitInfo, - { - inputTokens: usage.input_tokens || 0, - outputTokens: usage.output_tokens || 0, - cacheCreateTokens, - cacheReadTokens - }, + // 使用新的 recordUsageWithDetails 方法来支持详细的缓存数据 + apiKeyService + .recordUsageWithDetails( + apiKeyData.id, + usage, // 直接传递整个 usage 对象,包含可能的 cache_creation 详细数据 model, - 'openai-claude-stream' + accountId, + accountType ) - } - }, - // 流转换器 - (() => { - // 为每个请求创建独立的会话ID - const sessionId = `chatcmpl-${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}` - return (chunk) => openaiToClaude.convertStreamChunk(chunk, req.body.model, sessionId) - })(), - { - betaHeader: - 'oauth-2025-04-20,claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14' + .catch((error) => { + logger.error('❌ Failed to record usage:', error) + }) + + queueRateLimitUpdate( + req.rateLimitInfo, + { + inputTokens: usage.input_tokens || 0, + outputTokens: usage.output_tokens || 0, + cacheCreateTokens, + cacheReadTokens + }, + model, + `openai-${accountType}-stream`, + req.apiKey?.id, + accountType + ) } - ) + } + + // 创建流转换器 + const sessionId = `chatcmpl-${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}` + const streamTransformer = (chunk) => + openaiToClaude.convertStreamChunk(chunk, req.body.model, sessionId) + + // 根据账户类型选择转发服务 + if (accountType === 'claude-console') { + // Claude Console 账户使用 Console 转发服务 + await claudeConsoleRelayService.relayStreamRequestWithUsageCapture( + claudeRequest, + apiKeyData, + res, + claudeCodeHeaders, + usageCallback, + accountId, + streamTransformer + ) + } else { + // Claude Official 账户使用标准转发服务 + await claudeRelayService.relayStreamRequestWithUsageCapture( + claudeRequest, + apiKeyData, + res, + claudeCodeHeaders, + usageCallback, + streamTransformer, + { + betaHeader: + 'oauth-2025-04-20,claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14' + } + ) + } } else { // 非流式请求 logger.info(`📄 Processing OpenAI non-stream request for model: ${req.body.model}`) - // 发送请求到 Claude (使用 OAuth-only beta header,添加 Claude Code 必需的 headers) - const claudeResponse = await claudeRelayService.relayRequest( - claudeRequest, - apiKeyData, - req, - res, - claudeCodeHeaders, - { betaHeader: 'oauth-2025-04-20' } - ) + // 根据账户类型选择转发服务 + let claudeResponse + if (accountType === 'claude-console') { + // Claude Console 账户使用 Console 转发服务 + claudeResponse = await claudeConsoleRelayService.relayRequest( + claudeRequest, + apiKeyData, + req, + res, + claudeCodeHeaders, + accountId + ) + } else { + // Claude Official 账户使用标准转发服务 + claudeResponse = await claudeRelayService.relayRequest( + claudeRequest, + apiKeyData, + req, + res, + claudeCodeHeaders, + { betaHeader: 'oauth-2025-04-20' } + ) + } // 解析 Claude 响应 let claudeData @@ -376,7 +412,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 +428,9 @@ async function handleChatCompletion(req, res, apiKeyData) { cacheReadTokens }, claudeRequest.model, - 'openai-claude-non-stream' + `openai-${accountType}-non-stream`, + req.apiKey?.id, + accountType ) } @@ -418,7 +457,7 @@ async function handleChatCompletion(req, res, apiKeyData) { const status = error.status || 500 res.status(status).json({ error: { - message: error.message || 'Internal server error', + message: getSafeMessage(error), type: 'server_error', code: 'internal_error' } diff --git a/src/routes/openaiGeminiRoutes.js b/src/routes/openaiGeminiRoutes.js index ef65acc1..d4c39146 100644 --- a/src/routes/openaiGeminiRoutes.js +++ b/src/routes/openaiGeminiRoutes.js @@ -6,6 +6,7 @@ const geminiAccountService = require('../services/geminiAccountService') const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler') const { getAvailableModels } = require('../services/geminiRelayService') const crypto = require('crypto') +const apiKeyService = require('../services/apiKeyService') // 生成会话哈希 function generateSessionHash(req) { @@ -19,10 +20,19 @@ function generateSessionHash(req) { return crypto.createHash('sha256').update(sessionData).digest('hex') } +function ensureAntigravityProjectId(account) { + if (account.projectId) { + return account.projectId + } + if (account.tempProjectId) { + return account.tempProjectId + } + return `ag-${crypto.randomBytes(8).toString('hex')}` +} + // 检查 API Key 权限 function checkPermissions(apiKeyData, requiredPermission = 'gemini') { - const permissions = apiKeyData.permissions || 'all' - return permissions === 'all' || permissions === requiredPermission + return apiKeyService.hasPermission(apiKeyData?.permissions, requiredPermission) } // 转换 OpenAI 消息格式到 Gemini 格式 @@ -335,25 +345,48 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => { const client = await geminiAccountService.getOauthClient( account.accessToken, account.refreshToken, - proxyConfig + proxyConfig, + account.oauthProvider ) if (actualStream) { // 流式响应 + const oauthProvider = account.oauthProvider || 'gemini-cli' + let { projectId } = account + + if (oauthProvider === 'antigravity') { + projectId = ensureAntigravityProjectId(account) + if (!account.projectId && account.tempProjectId !== projectId) { + await geminiAccountService.updateTempProjectId(account.id, projectId) + account.tempProjectId = projectId + } + } + logger.info('StreamGenerateContent request', { model, - projectId: account.projectId, + projectId, apiKeyId: apiKeyData.id }) - const streamResponse = await geminiAccountService.generateContentStream( - client, - { model, request: geminiRequestBody }, - null, // user_prompt_id - account.projectId, // 使用有权限的项目ID - apiKeyData.id, // 使用 API Key ID 作为 session ID - abortController.signal, // 传递中止信号 - proxyConfig // 传递代理配置 - ) + const streamResponse = + oauthProvider === 'antigravity' + ? await geminiAccountService.generateContentStreamAntigravity( + client, + { model, request: geminiRequestBody }, + null, // user_prompt_id + projectId, + apiKeyData.id, // 使用 API Key ID 作为 session ID + abortController.signal, // 传递中止信号 + proxyConfig // 传递代理配置 + ) + : await geminiAccountService.generateContentStream( + client, + { model, request: geminiRequestBody }, + null, // user_prompt_id + projectId, // 使用有权限的项目ID + apiKeyData.id, // 使用 API Key ID 作为 session ID + abortController.signal, // 传递中止信号 + proxyConfig // 传递代理配置 + ) // 设置流式响应头 res.setHeader('Content-Type', 'text/event-stream') @@ -499,7 +532,6 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => { // 记录使用统计 if (!usageReported && totalUsage.totalTokenCount > 0) { try { - const apiKeyService = require('../services/apiKeyService') await apiKeyService.recordUsage( apiKeyData.id, totalUsage.promptTokenCount || 0, @@ -507,7 +539,8 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => { 0, // cacheCreateTokens 0, // cacheReadTokens model, - account.id + account.id, + 'gemini' ) logger.info( `📊 Recorded Gemini stream usage - Input: ${totalUsage.promptTokenCount}, Output: ${totalUsage.candidatesTokenCount}, Total: ${totalUsage.totalTokenCount}` @@ -559,20 +592,41 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => { }) } else { // 非流式响应 + const oauthProvider = account.oauthProvider || 'gemini-cli' + let { projectId } = account + + if (oauthProvider === 'antigravity') { + projectId = ensureAntigravityProjectId(account) + if (!account.projectId && account.tempProjectId !== projectId) { + await geminiAccountService.updateTempProjectId(account.id, projectId) + account.tempProjectId = projectId + } + } + logger.info('GenerateContent request', { model, - projectId: account.projectId, + projectId, apiKeyId: apiKeyData.id }) - const response = await geminiAccountService.generateContent( - client, - { model, request: geminiRequestBody }, - null, // user_prompt_id - account.projectId, // 使用有权限的项目ID - apiKeyData.id, // 使用 API Key ID 作为 session ID - proxyConfig // 传递代理配置 - ) + const response = + oauthProvider === 'antigravity' + ? await geminiAccountService.generateContentAntigravity( + client, + { model, request: geminiRequestBody }, + null, // user_prompt_id + projectId, + apiKeyData.id, // 使用 API Key ID 作为 session ID + proxyConfig // 传递代理配置 + ) + : await geminiAccountService.generateContent( + client, + { model, request: geminiRequestBody }, + null, // user_prompt_id + projectId, // 使用有权限的项目ID + apiKeyData.id, // 使用 API Key ID 作为 session ID + proxyConfig // 传递代理配置 + ) // 转换为 OpenAI 格式并返回 const openaiResponse = convertGeminiResponseToOpenAI(response, model, false) @@ -580,7 +634,6 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => { // 记录使用统计 if (openaiResponse.usage) { try { - const apiKeyService = require('../services/apiKeyService') await apiKeyService.recordUsage( apiKeyData.id, openaiResponse.usage.prompt_tokens || 0, @@ -588,7 +641,8 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => { 0, // cacheCreateTokens 0, // cacheReadTokens model, - account.id + account.id, + 'gemini' ) logger.info( `📊 Recorded Gemini usage - Input: ${openaiResponse.usage.prompt_tokens}, Output: ${openaiResponse.usage.completion_tokens}, Total: ${openaiResponse.usage.total_tokens}` @@ -604,12 +658,15 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => { const duration = Date.now() - startTime logger.info(`OpenAI-Gemini request completed in ${duration}ms`) } catch (error) { - // 客户端主动断开连接是正常情况,使用 INFO 级别 - if (error.message === 'Client disconnected') { - logger.info('🔌 OpenAI-Gemini stream ended: Client disconnected') - } else { - 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) { @@ -645,8 +702,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 @@ -677,8 +734,21 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => { let models = [] if (account) { - // 获取实际的模型列表 - models = await getAvailableModels(account.accessToken, account.proxy) + // 获取实际的模型列表(失败时回退到默认列表,避免影响 /v1/models 可用性) + try { + const oauthProvider = account.oauthProvider || 'gemini-cli' + models = + oauthProvider === 'antigravity' + ? await geminiAccountService.fetchAvailableModelsAntigravity( + account.accessToken, + account.proxy, + account.refreshToken + ) + : await getAvailableModels(account.accessToken, account.proxy) + } catch (error) { + logger.warn('Failed to get Gemini models list from upstream, fallback to default:', error) + models = [] + } } else { // 返回默认模型列表 models = [ @@ -691,6 +761,17 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => { ] } + if (!models || models.length === 0) { + models = [ + { + id: 'gemini-2.0-flash-exp', + object: 'model', + created: Math.floor(Date.now() / 1000), + owned_by: 'google' + } + ] + } + // 如果启用了模型限制,过滤模型列表 if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels.length > 0) { models = models.filter((model) => apiKeyData.restrictedModels.includes(model.id)) @@ -710,8 +791,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) => { diff --git a/src/routes/openaiRoutes.js b/src/routes/openaiRoutes.js index c999af97..7e9811fe 100644 --- a/src/routes/openaiRoutes.js +++ b/src/routes/openaiRoutes.js @@ -14,6 +14,7 @@ const crypto = require('crypto') const ProxyHelper = require('../utils/proxyHelper') const { updateRateLimitCounters } = require('../utils/rateLimitHelper') const { IncrementalSSEParser } = require('../utils/sseParser') +const { getSafeMessage } = require('../utils/errorSanitizer') // 创建代理 Agent(使用统一的代理工具) function createProxyAgent(proxy) { @@ -22,8 +23,7 @@ function createProxyAgent(proxy) { // 检查 API Key 是否具备 OpenAI 权限 function checkOpenAIPermissions(apiKeyData) { - const permissions = apiKeyData?.permissions || 'all' - return permissions === 'all' || permissions === 'openai' + return apiKeyService.hasPermission(apiKeyData?.permissions, 'openai') } function normalizeHeaders(headers = {}) { @@ -70,7 +70,7 @@ function extractCodexUsageHeaders(headers) { return hasData ? snapshot : null } -async function applyRateLimitTracking(req, usageSummary, model, context = '') { +async function applyRateLimitTracking(req, usageSummary, model, context = '', accountType = null) { if (!req.rateLimitInfo) { return } @@ -81,7 +81,9 @@ async function applyRateLimitTracking(req, usageSummary, model, context = '') { const { totalTokens, totalCost } = await updateRateLimitCounters( req.rateLimitInfo, usageSummary, - model + model, + req.apiKey?.id, + accountType ) if (totalTokens > 0) { @@ -613,7 +615,8 @@ const handleResponses = async (req, res) => { 0, // OpenAI没有cache_creation_tokens cacheReadTokens, actualModel, - accountId + accountId, + 'openai' ) logger.info( @@ -629,7 +632,8 @@ const handleResponses = async (req, res) => { cacheReadTokens }, actualModel, - 'openai-non-stream' + 'openai-non-stream', + 'openai' ) } @@ -727,7 +731,8 @@ const handleResponses = async (req, res) => { 0, // OpenAI没有cache_creation_tokens cacheReadTokens, modelToRecord, - accountId + accountId, + 'openai' ) logger.info( @@ -744,7 +749,8 @@ const handleResponses = async (req, res) => { cacheReadTokens }, modelToRecord, - 'openai-stream' + 'openai-stream', + 'openai' ) } catch (error) { logger.error('Failed to record OpenAI usage:', error) @@ -834,13 +840,15 @@ const handleResponses = async (req, res) => { let responsePayload = error.response?.data if (!responsePayload) { - responsePayload = { error: { message: error.message || 'Internal server error' } } + responsePayload = { error: { message: getSafeMessage(error) } } } else if (typeof responsePayload === 'string') { - responsePayload = { error: { message: responsePayload } } + responsePayload = { error: { message: getSafeMessage(responsePayload) } } } else if (typeof responsePayload === 'object' && !responsePayload.error) { responsePayload = { - error: { message: responsePayload.message || error.message || 'Internal server error' } + error: { message: getSafeMessage(responsePayload.message || error) } } + } else if (responsePayload.error?.message) { + responsePayload.error.message = getSafeMessage(responsePayload.error.message) } if (!res.headersSent) { @@ -893,7 +901,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: tokensUsed, tokens_remaining: diff --git a/src/routes/unified.js b/src/routes/unified.js index a8a8e69d..c1401137 100644 --- a/src/routes/unified.js +++ b/src/routes/unified.js @@ -8,6 +8,7 @@ const { handleStreamGenerateContent: geminiHandleStreamGenerateContent } = require('../handlers/geminiHandlers') const openaiRoutes = require('./openaiRoutes') +const apiKeyService = require('../services/apiKeyService') const router = express.Router() @@ -45,11 +46,11 @@ async function routeToBackend(req, res, requestedModel) { logger.info(`🔀 Routing request - Model: ${requestedModel}, Backend: ${backend}`) // 检查权限 - const permissions = req.apiKey.permissions || 'all' + const { permissions } = req.apiKey if (backend === 'claude') { // Claude 后端:通过 OpenAI 兼容层 - if (permissions !== 'all' && permissions !== 'claude') { + if (!apiKeyService.hasPermission(permissions, 'claude')) { return res.status(403).json({ error: { message: 'This API key does not have permission to access Claude', @@ -61,7 +62,7 @@ async function routeToBackend(req, res, requestedModel) { await handleChatCompletion(req, res, req.apiKey) } else if (backend === 'openai') { // OpenAI 后端 - if (permissions !== 'all' && permissions !== 'openai') { + if (!apiKeyService.hasPermission(permissions, 'openai')) { return res.status(403).json({ error: { message: 'This API key does not have permission to access OpenAI', @@ -73,7 +74,7 @@ async function routeToBackend(req, res, requestedModel) { return await openaiRoutes.handleResponses(req, res) } else if (backend === 'gemini') { // Gemini 后端 - if (permissions !== 'all' && permissions !== 'gemini') { + if (!apiKeyService.hasPermission(permissions, 'gemini')) { return res.status(403).json({ error: { message: 'This API key does not have permission to access Gemini', diff --git a/src/services/accountBalanceService.js b/src/services/accountBalanceService.js new file mode 100644 index 00000000..ec25f171 --- /dev/null +++ b/src/services/accountBalanceService.js @@ -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 diff --git a/src/services/anthropicGeminiBridgeService.js b/src/services/anthropicGeminiBridgeService.js new file mode 100644 index 00000000..58c79e07 --- /dev/null +++ b/src/services/anthropicGeminiBridgeService.js @@ -0,0 +1,3088 @@ +/** + * ============================================================================ + * Anthropic → Gemini/Antigravity 桥接服务 + * ============================================================================ + * + * 【模块功能】 + * 本模块负责将 Anthropic Claude API 格式的请求转换为 Gemini/Antigravity 格式, + * 并将响应转换回 Anthropic 格式返回给客户端(如 Claude Code)。 + * + * 【支持的后端 (vendor)】 + * - gemini-cli: 原生 Google Gemini API + * - antigravity: Claude 代理层 (CLIProxyAPI),使用 Gemini 格式但有额外约束 + * + * 【核心处理流程】 + * 1. 接收 Anthropic 格式请求 (/v1/messages) + * 2. 标准化消息 (normalizeAnthropicMessages) - 处理 thinking blocks、tool_result 等 + * 3. 转换工具定义 (convertAnthropicToolsToGeminiTools) - 压缩描述、清洗 schema + * 4. 转换消息内容 (convertAnthropicMessagesToGeminiContents) + * 5. 构建 Gemini 请求 (buildGeminiRequestFromAnthropic) + * 6. 发送请求并处理 SSE 流式响应 + * 7. 将 Gemini 响应转换回 Anthropic 格式返回 + * + * 【Antigravity 特殊处理】 + * - 工具描述压缩:限制 400 字符,避免 prompt 超长 + * - Schema description 压缩:限制 200 字符,保留关键约束信息 + * - Thinking signature 校验:防止格式错误导致 400 + * - Tool result 截断:限制 20 万字符 + * - 缺失 tool_result 自动补全:避免 tool_use concurrency 错误 + */ + +const util = require('util') +const crypto = require('crypto') +const fs = require('fs') +const path = require('path') +const logger = require('../utils/logger') +const { getProjectRoot } = require('../utils/projectPaths') +const geminiAccountService = require('./geminiAccountService') +const unifiedGeminiScheduler = require('./unifiedGeminiScheduler') +const sessionHelper = require('../utils/sessionHelper') +const signatureCache = require('../utils/signatureCache') +const apiKeyService = require('./apiKeyService') +const { updateRateLimitCounters } = require('../utils/rateLimitHelper') +const { parseSSELine } = require('../utils/sseParser') +const { sanitizeUpstreamError } = require('../utils/errorSanitizer') +const { cleanJsonSchemaForGemini } = require('../utils/geminiSchemaCleaner') +const { + dumpAnthropicNonStreamResponse, + dumpAnthropicStreamSummary +} = require('../utils/anthropicResponseDump') +const { + dumpAntigravityStreamEvent, + dumpAntigravityStreamSummary +} = require('../utils/antigravityUpstreamResponseDump') + +// ============================================================================ +// 常量定义 +// ============================================================================ + +// 默认签名 +const THOUGHT_SIGNATURE_FALLBACK = 'skip_thought_signature_validator' + +// 支持的后端类型 +const SUPPORTED_VENDORS = new Set(['gemini-cli', 'antigravity']) +// 需要跳过的系统提醒前缀(Claude 内部消息,不应转发给上游) +const SYSTEM_REMINDER_PREFIX = '' +// 调试:工具定义 dump 相关 +const TOOLS_DUMP_ENV = 'ANTHROPIC_DEBUG_TOOLS_DUMP' +const TOOLS_DUMP_FILENAME = 'anthropic-tools-dump.jsonl' +// 环境变量:工具调用失败时是否回退到文本输出 +const TEXT_TOOL_FALLBACK_ENV = 'ANTHROPIC_TEXT_TOOL_FALLBACK' +// 环境变量:工具报错时是否继续执行(而非中断) +const TOOL_ERROR_CONTINUE_ENV = 'ANTHROPIC_TOOL_ERROR_CONTINUE' +// Antigravity 工具顶级描述的最大字符数(防止 prompt 超长) +const MAX_ANTIGRAVITY_TOOL_DESCRIPTION_CHARS = 400 +// Antigravity 参数 schema description 的最大字符数(保留关键约束信息) +const MAX_ANTIGRAVITY_SCHEMA_DESCRIPTION_CHARS = 200 +// Antigravity:当已经决定要走工具时,避免“只宣布步骤就结束” +const ANTIGRAVITY_TOOL_FOLLOW_THROUGH_PROMPT = + 'When a step requires calling a tool, call the tool immediately in the same turn. Do not stop after announcing the step. Updating todos alone (e.g., TodoWrite) is not enough; you must actually invoke the target MCP tool (browser_*, etc.) before ending the turn.' +// 工具报错时注入的 system prompt,提示模型不要中断 +const TOOL_ERROR_CONTINUE_PROMPT = + 'Tool calls may fail (e.g., missing prerequisites). When a tool result indicates an error, do not stop: briefly explain the cause and continue with an alternative approach or the remaining steps.' +// Antigravity 账号前置注入的系统提示词 +const ANTIGRAVITY_SYSTEM_INSTRUCTION_PREFIX = ` +You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding. +You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question. +The USER will send you requests, which you must always prioritize addressing. Along with each USER request, we will attach additional metadata about their current state, such as what files they have open and where their cursor is. +This information may or may not be relevant to the coding task, it is up for you to decide. + + +- **Proactiveness**. As an agent, you are allowed to be proactive, but only in the course of completing the user's task. For example, if the user asks you to add a new component, you can edit the code, verify build and test statuses, and take any other obvious follow-up actions, such as performing additional research. However, avoid surprising the user. For example, if the user asks HOW to approach something, you should answer their question and instead of jumping into editing a file.` + +// ============================================================================ +// 辅助函数:基础工具 +// ============================================================================ + +/** + * 确保 Antigravity 请求有有效的 projectId + * 如果账户没有配置 projectId,则生成一个临时 ID + */ +function ensureAntigravityProjectId(account) { + if (account.projectId) { + return account.projectId + } + if (account.tempProjectId) { + return account.tempProjectId + } + return `ag-${crypto.randomBytes(8).toString('hex')}` +} + +/** + * 从 Anthropic 消息内容中提取纯文本 + * 支持字符串和 content blocks 数组两种格式 + * @param {string|Array} content - Anthropic 消息内容 + * @returns {string} 提取的文本 + */ +function extractAnthropicText(content) { + if (content === null || content === undefined) { + return '' + } + if (typeof content === 'string') { + return content + } + if (!Array.isArray(content)) { + return '' + } + return content + .filter((part) => part && part.type === 'text') + .map((part) => part.text || '') + .join('') +} + +/** + * 检查文本是否应该跳过(不转发给上游) + * 主要过滤 Claude 内部的 system-reminder 消息 + */ +function shouldSkipText(text) { + if (!text || typeof text !== 'string') { + return true + } + return text.trimStart().startsWith(SYSTEM_REMINDER_PREFIX) +} + +/** + * 构建 Gemini 格式的 system parts + * 将 Anthropic 的 system prompt 转换为 Gemini 的 parts 数组 + * @param {string|Array} system - Anthropic 的 system prompt + * @returns {Array} Gemini 格式的 parts + */ +function buildSystemParts(system) { + const parts = [] + if (!system) { + return parts + } + if (Array.isArray(system)) { + for (const part of system) { + if (!part || part.type !== 'text') { + continue + } + const text = extractAnthropicText(part.text || '') + if (text && !shouldSkipText(text)) { + parts.push({ text }) + } + } + return parts + } + const text = extractAnthropicText(system) + if (text && !shouldSkipText(text)) { + parts.push({ text }) + } + return parts +} + +/** + * 构建 tool_use ID 到工具名称的映射 + * 用于在处理 tool_result 时查找对应的工具名 + * @param {Array} messages - 消息列表 + * @returns {Map} tool_use_id -> tool_name 的映射 + */ +function buildToolUseIdToNameMap(messages) { + const toolUseIdToName = new Map() + + for (const message of messages || []) { + if (message?.role !== 'assistant') { + continue + } + const content = message?.content + if (!Array.isArray(content)) { + continue + } + for (const part of content) { + if (!part || part.type !== 'tool_use') { + continue + } + if (part.id && part.name) { + toolUseIdToName.set(part.id, part.name) + } + } + } + + return toolUseIdToName +} + +/** + * 标准化工具调用的输入参数 + * 确保输入始终是对象格式 + */ +function normalizeToolUseInput(input) { + if (input === null || input === undefined) { + return {} + } + if (typeof input === 'object') { + return input + } + if (typeof input === 'string') { + const trimmed = input.trim() + if (!trimmed) { + return {} + } + try { + const parsed = JSON.parse(trimmed) + if (parsed && typeof parsed === 'object') { + return parsed + } + } catch (_) { + return {} + } + } + return {} +} + +// Antigravity 工具结果的最大字符数(约 20 万,防止 prompt 超长) +const MAX_ANTIGRAVITY_TOOL_RESULT_CHARS = 200000 + +// ============================================================================ +// 辅助函数:Antigravity 体积压缩 +// 这些函数用于压缩工具描述、schema 等,避免 prompt 超过 Antigravity 的上限 +// ============================================================================ + +/** + * 截断文本并添加截断提示(带换行) + * @param {string} text - 原始文本 + * @param {number} maxChars - 最大字符数 + * @returns {string} 截断后的文本 + */ +function truncateText(text, maxChars) { + if (!text || typeof text !== 'string') { + return '' + } + if (text.length <= maxChars) { + return text + } + return `${text.slice(0, maxChars)}\n...[truncated ${text.length - maxChars} chars]` +} + +/** + * 截断文本并添加截断提示(内联模式,不带换行) + */ +function truncateInlineText(text, maxChars) { + if (!text || typeof text !== 'string') { + return '' + } + if (text.length <= maxChars) { + return text + } + return `${text.slice(0, maxChars)}...[truncated ${text.length - maxChars} chars]` +} + +/** + * 压缩工具顶级描述 + * 取前 6 行,合并为单行,截断到 400 字符 + * 这样可以在保留关键信息的同时大幅减少体积 + * @param {string} description - 原始工具描述 + * @returns {string} 压缩后的描述 + */ +function compactToolDescriptionForAntigravity(description) { + if (!description || typeof description !== 'string') { + return '' + } + const normalized = description.replace(/\r\n/g, '\n').trim() + if (!normalized) { + return '' + } + + const lines = normalized + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + + if (lines.length === 0) { + return '' + } + + const compacted = lines.slice(0, 6).join(' ') + return truncateInlineText(compacted, MAX_ANTIGRAVITY_TOOL_DESCRIPTION_CHARS) +} + +/** + * 压缩 JSON Schema 属性描述 + * 压缩多余空白,截断到 200 字符 + * 这是为了保留关键参数约束(如 ji 工具的 action 只能是 "记忆"/"回忆") + * @param {string} description - 原始描述 + * @returns {string} 压缩后的描述 + */ +function compactSchemaDescriptionForAntigravity(description) { + if (!description || typeof description !== 'string') { + return '' + } + const normalized = description.replace(/\s+/g, ' ').trim() + if (!normalized) { + return '' + } + return truncateInlineText(normalized, MAX_ANTIGRAVITY_SCHEMA_DESCRIPTION_CHARS) +} + +/** + * 递归压缩 JSON Schema 中所有层级的 description 字段 + * 保留并压缩 description(而不是删除),确保关键参数约束信息不丢失 + * @param {Object} schema - JSON Schema 对象 + * @returns {Object} 压缩后的 schema + */ +function compactJsonSchemaDescriptionsForAntigravity(schema) { + if (schema === null || schema === undefined) { + return schema + } + if (typeof schema !== 'object') { + return schema + } + if (Array.isArray(schema)) { + return schema.map((item) => compactJsonSchemaDescriptionsForAntigravity(item)) + } + + const cleaned = {} + for (const [key, value] of Object.entries(schema)) { + if (key === 'description') { + const compacted = compactSchemaDescriptionForAntigravity(value) + if (compacted) { + cleaned.description = compacted + } + continue + } + cleaned[key] = compactJsonSchemaDescriptionsForAntigravity(value) + } + return cleaned +} + +/** + * 清洗 thinking block 的 signature + * 检查格式是否合法(Base64-like token),不合法则返回空串 + * 这是为了避免 "Invalid signature in thinking block" 400 错误 + * @param {string} signature - 原始 signature + * @returns {string} 清洗后的 signature(不合法则为空串) + */ +function sanitizeThoughtSignatureForAntigravity(signature) { + if (!signature || typeof signature !== 'string') { + return '' + } + const trimmed = signature.trim() + if (!trimmed) { + return '' + } + + const compacted = trimmed.replace(/\s+/g, '') + if (compacted.length > 65536) { + return '' + } + + const looksLikeToken = /^[A-Za-z0-9+/_=-]+$/.test(compacted) + if (!looksLikeToken) { + return '' + } + + if (compacted.length < 8) { + return '' + } + + return compacted +} + +/** + * 检测是否是 Antigravity 的 INVALID_ARGUMENT (400) 错误 + * 用于在日志中特殊标记这类错误,方便调试 + * + * @param {Object} sanitized - sanitizeUpstreamError 处理后的错误对象 + * @returns {boolean} 是否是参数无效错误 + */ +function isInvalidAntigravityArgumentError(sanitized) { + if (!sanitized || typeof sanitized !== 'object') { + return false + } + const upstreamType = String(sanitized.upstreamType || '').toUpperCase() + if (upstreamType === 'INVALID_ARGUMENT') { + return true + } + const message = String(sanitized.upstreamMessage || sanitized.message || '') + return /invalid argument/i.test(message) +} + +/** + * 汇总 Antigravity 请求信息用于调试 + * 当发生 400 错误时,输出请求的关键统计信息,帮助定位问题 + * + * @param {Object} requestData - 发送给 Antigravity 的请求数据 + * @returns {Object} 请求摘要信息 + */ +function summarizeAntigravityRequestForDebug(requestData) { + const request = requestData?.request || {} + const contents = Array.isArray(request.contents) ? request.contents : [] + const partStats = { text: 0, thought: 0, functionCall: 0, functionResponse: 0, other: 0 } + let functionResponseIds = 0 + let fallbackSignatureCount = 0 + + for (const message of contents) { + const parts = Array.isArray(message?.parts) ? message.parts : [] + for (const part of parts) { + if (!part || typeof part !== 'object') { + continue + } + if (part.thoughtSignature === THOUGHT_SIGNATURE_FALLBACK) { + fallbackSignatureCount += 1 + } + if (part.thought) { + partStats.thought += 1 + continue + } + if (part.functionCall) { + partStats.functionCall += 1 + continue + } + if (part.functionResponse) { + partStats.functionResponse += 1 + if (part.functionResponse.id) { + functionResponseIds += 1 + } + continue + } + if (typeof part.text === 'string') { + partStats.text += 1 + continue + } + partStats.other += 1 + } + } + + return { + model: requestData?.model, + toolCount: Array.isArray(request.tools) ? request.tools.length : 0, + toolConfigMode: request.toolConfig?.functionCallingConfig?.mode, + thinkingConfig: request.generationConfig?.thinkingConfig, + maxOutputTokens: request.generationConfig?.maxOutputTokens, + contentsCount: contents.length, + partStats, + functionResponseIds, + fallbackSignatureCount + } +} + +/** + * 清洗工具结果的 content blocks + * - 移除 base64 图片(避免体积过大) + * - 截断文本内容到 20 万字符 + * @param {Array} blocks - content blocks 数组 + * @returns {Array} 清洗后的 blocks + */ +function sanitizeToolResultBlocksForAntigravity(blocks) { + const cleaned = [] + let usedChars = 0 + let removedImage = false + + for (const block of blocks) { + if (!block || typeof block !== 'object') { + continue + } + + if ( + block.type === 'image' && + block.source?.type === 'base64' && + typeof block.source?.data === 'string' + ) { + removedImage = true + continue + } + + if (block.type === 'text' && typeof block.text === 'string') { + const remaining = MAX_ANTIGRAVITY_TOOL_RESULT_CHARS - usedChars + if (remaining <= 0) { + break + } + const text = truncateText(block.text, remaining) + cleaned.push({ ...block, text }) + usedChars += text.length + continue + } + + cleaned.push(block) + usedChars += 100 + if (usedChars >= MAX_ANTIGRAVITY_TOOL_RESULT_CHARS) { + break + } + } + + if (removedImage) { + cleaned.push({ + type: 'text', + text: '[image omitted to fit Antigravity prompt limits; use the file path in the previous text block]' + }) + } + + return cleaned +} + +// ============================================================================ +// 核心函数:消息标准化和转换 +// ============================================================================ + +/** + * 标准化工具结果内容 + * 支持字符串和 content blocks 数组两种格式 + * 对 Antigravity 会进行截断和图片移除处理 + */ +function normalizeToolResultContent(content, { vendor = null } = {}) { + if (content === null || content === undefined) { + return '' + } + if (typeof content === 'string') { + if (vendor === 'antigravity') { + return truncateText(content, MAX_ANTIGRAVITY_TOOL_RESULT_CHARS) + } + return content + } + // Claude Code 的 tool_result.content 通常是 content blocks 数组(例如 [{type:"text",text:"..."}])。 + // 为对齐 CLIProxyAPI/Antigravity 的行为,这里优先保留原始 JSON 结构(数组/对象), + // 避免上游将其视为“无效 tool_result”从而触发 tool_use concurrency 400。 + if (Array.isArray(content) || (content && typeof content === 'object')) { + if (vendor === 'antigravity' && Array.isArray(content)) { + return sanitizeToolResultBlocksForAntigravity(content) + } + return content + } + return '' +} + +/** + * 标准化 Anthropic 消息列表 + * 这是关键的预处理函数,处理以下问题: + * + * 1. Antigravity thinking block 顺序调整 + * - Antigravity 要求 thinking blocks 必须在 assistant 消息的最前面 + * - 移除 thinking block 中的 cache_control 字段(上游不接受) + * + * 2. tool_use 后的冗余内容剥离 + * - 移除 tool_use 后的空文本、"(no content)" 等冗余 part + * + * 3. 缺失 tool_result 补全(Antigravity 专用) + * - 检测消息历史中是否有 tool_use 没有对应的 tool_result + * - 自动插入合成的 tool_result(is_error: true) + * - 避免 "tool_use concurrency" 400 错误 + * + * 4. tool_result 和 user 文本拆分 + * - Claude Code 可能把 tool_result 和用户文本混在一个 user message 中 + * - 拆分为两个 message 以符合 Anthropic 规范 + * + * @param {Array} messages - 原始消息列表 + * @param {Object} options - 选项,包含 vendor + * @returns {Array} 标准化后的消息列表 + */ +function normalizeAnthropicMessages(messages, { vendor = null } = {}) { + if (!Array.isArray(messages) || messages.length === 0) { + return messages + } + + const pendingToolUseIds = [] + const isIgnorableTrailingText = (part) => { + if (!part || part.type !== 'text') { + return false + } + if (typeof part.text !== 'string') { + return false + } + const trimmed = part.text.trim() + if (trimmed === '' || trimmed === '(no content)') { + return true + } + if (part.cache_control?.type === 'ephemeral' && trimmed === '(no content)') { + return true + } + return false + } + + const normalizeAssistantThinkingOrderForVendor = (parts) => { + if (vendor !== 'antigravity') { + return parts + } + const thinkingBlocks = [] + const otherBlocks = [] + for (const part of parts) { + if (!part) { + continue + } + if (part.type === 'thinking' || part.type === 'redacted_thinking') { + // 移除 cache_control 字段,上游 API 不接受 thinking block 中包含此字段 + // 错误信息: "thinking.cache_control: Extra inputs are not permitted" + const { cache_control: _cache_control, ...cleanedPart } = part + thinkingBlocks.push(cleanedPart) + continue + } + if (isIgnorableTrailingText(part)) { + continue + } + otherBlocks.push(part) + } + if (thinkingBlocks.length === 0) { + return otherBlocks + } + return [...thinkingBlocks, ...otherBlocks] + } + + const stripNonToolPartsAfterToolUse = (parts) => { + let seenToolUse = false + const cleaned = [] + for (const part of parts) { + if (!part) { + continue + } + if (part.type === 'tool_use') { + seenToolUse = true + cleaned.push(part) + continue + } + if (!seenToolUse) { + cleaned.push(part) + continue + } + if (isIgnorableTrailingText(part)) { + continue + } + } + return cleaned + } + + const normalized = [] + + for (const message of messages) { + if (!message || !Array.isArray(message.content)) { + normalized.push(message) + continue + } + + let parts = message.content.filter(Boolean) + if (message.role === 'assistant') { + parts = normalizeAssistantThinkingOrderForVendor(parts) + } + + if (vendor === 'antigravity' && message.role === 'assistant') { + if (pendingToolUseIds.length > 0) { + normalized.push({ + role: 'user', + content: pendingToolUseIds.map((toolUseId) => ({ + type: 'tool_result', + tool_use_id: toolUseId, + is_error: true, + content: [ + { + type: 'text', + text: '[tool_result missing; tool execution interrupted]' + } + ] + })) + }) + pendingToolUseIds.length = 0 + } + + const stripped = stripNonToolPartsAfterToolUse(parts) + const toolUseIds = stripped + .filter((part) => part?.type === 'tool_use' && typeof part.id === 'string') + .map((part) => part.id) + if (toolUseIds.length > 0) { + pendingToolUseIds.push(...toolUseIds) + } + + normalized.push({ ...message, content: stripped }) + continue + } + + if (vendor === 'antigravity' && message.role === 'user' && pendingToolUseIds.length > 0) { + const toolResults = parts.filter((p) => p.type === 'tool_result') + const toolResultIds = new Set( + toolResults.map((p) => p.tool_use_id).filter((id) => typeof id === 'string') + ) + const missing = pendingToolUseIds.filter((id) => !toolResultIds.has(id)) + if (missing.length > 0) { + const synthetic = missing.map((toolUseId) => ({ + type: 'tool_result', + tool_use_id: toolUseId, + is_error: true, + content: [ + { + type: 'text', + text: '[tool_result missing; tool execution interrupted]' + } + ] + })) + parts = [...toolResults, ...synthetic, ...parts.filter((p) => p.type !== 'tool_result')] + } + pendingToolUseIds.length = 0 + } + + if (message.role !== 'user') { + normalized.push({ ...message, content: parts }) + continue + } + + const toolResults = parts.filter((p) => p.type === 'tool_result') + if (toolResults.length === 0) { + normalized.push({ ...message, content: parts }) + continue + } + + const nonToolResults = parts.filter((p) => p.type !== 'tool_result') + if (nonToolResults.length === 0) { + normalized.push({ ...message, content: toolResults }) + continue + } + + // Claude Code 可能把 tool_result 和下一条用户文本合并在同一个 user message 中。 + // 但上游(Antigravity/Claude)会按 Anthropic 规则校验:tool_use 后的下一条 message + // 必须只包含 tool_result blocks。这里做兼容拆分,避免 400 tool-use concurrency。 + normalized.push({ ...message, content: toolResults }) + normalized.push({ ...message, content: nonToolResults }) + } + + if (vendor === 'antigravity' && pendingToolUseIds.length > 0) { + normalized.push({ + role: 'user', + content: pendingToolUseIds.map((toolUseId) => ({ + type: 'tool_result', + tool_use_id: toolUseId, + is_error: true, + content: [ + { + type: 'text', + text: '[tool_result missing; tool execution interrupted]' + } + ] + })) + }) + pendingToolUseIds.length = 0 + } + + return normalized +} + +// ============================================================================ +// 核心函数:工具定义转换 +// ============================================================================ + +/** + * 将 Anthropic 工具定义转换为 Gemini/Antigravity 格式 + * + * 主要工作: + * 1. 工具描述压缩(Antigravity: 400 字符上限) + * 2. JSON Schema 清洗(移除不支持的字段如 $schema, format 等) + * 3. Schema description 压缩(Antigravity: 200 字符上限,保留关键约束) + * 4. 输出格式差异: + * - Antigravity: 使用 parametersJsonSchema + * - Gemini: 使用 parameters + * + * @param {Array} tools - Anthropic 格式的工具定义数组 + * @param {Object} options - 选项,包含 vendor + * @returns {Array|null} Gemini 格式的工具定义,或 null + */ +function convertAnthropicToolsToGeminiTools(tools, { vendor = null } = {}) { + if (!Array.isArray(tools) || tools.length === 0) { + return null + } + + // 说明:Gemini / Antigravity 对工具 schema 的接受程度不同;这里做“尽可能兼容”的最小清洗,降低 400 概率。 + const sanitizeSchemaForFunctionDeclarations = (schema) => { + const allowedKeys = new Set([ + 'type', + 'properties', + 'required', + 'description', + 'enum', + 'items', + 'anyOf', + 'oneOf', + 'allOf', + 'additionalProperties', + 'minimum', + 'maximum', + 'minItems', + 'maxItems', + 'minLength', + 'maxLength' + ]) + + if (schema === null || schema === undefined) { + return null + } + + // primitives: keep as-is (e.g. type/description/nullable/minimum...) + if (typeof schema !== 'object') { + return schema + } + + if (Array.isArray(schema)) { + return schema + .map((item) => sanitizeSchemaForFunctionDeclarations(item)) + .filter((item) => item !== null && item !== undefined) + } + + const sanitized = {} + for (const [key, value] of Object.entries(schema)) { + // Antigravity/Cloud Code 的 function_declarations.parameters 不接受 $schema / $id 等元字段 + if (key === '$schema' || key === '$id') { + continue + } + // 去除常见的非必要字段,减少上游 schema 校验失败概率 + if (key === 'title' || key === 'default' || key === 'examples' || key === 'example') { + continue + } + // 上游对 JSON Schema "format" 支持不稳定(特别是 format=uri),直接移除以降低 400 概率 + if (key === 'format') { + continue + } + if (!allowedKeys.has(key)) { + continue + } + + if (key === 'properties') { + if (value && typeof value === 'object' && !Array.isArray(value)) { + const props = {} + for (const [propName, propSchema] of Object.entries(value)) { + const sanitizedProp = sanitizeSchemaForFunctionDeclarations(propSchema) + if (sanitizedProp && typeof sanitizedProp === 'object') { + props[propName] = sanitizedProp + } + } + sanitized.properties = props + } + continue + } + + if (key === 'required') { + if (Array.isArray(value)) { + const req = value.filter((item) => typeof item === 'string') + if (req.length > 0) { + sanitized.required = req + } + } + continue + } + + if (key === 'enum') { + if (Array.isArray(value)) { + const en = value.filter( + (item) => + typeof item === 'string' || typeof item === 'number' || typeof item === 'boolean' + ) + if (en.length > 0) { + sanitized.enum = en + } + } + continue + } + + if (key === 'additionalProperties') { + if (typeof value === 'boolean') { + sanitized.additionalProperties = value + } else if (value && typeof value === 'object') { + const ap = sanitizeSchemaForFunctionDeclarations(value) + if (ap && typeof ap === 'object') { + sanitized.additionalProperties = ap + } + } + continue + } + + const sanitizedValue = sanitizeSchemaForFunctionDeclarations(value) + if (sanitizedValue === null || sanitizedValue === undefined) { + continue + } + sanitized[key] = sanitizedValue + } + + // 兜底:确保 schema 至少是一个 object schema + if (!sanitized.type) { + if (sanitized.items) { + sanitized.type = 'array' + } else if (sanitized.properties || sanitized.required || sanitized.additionalProperties) { + sanitized.type = 'object' + } else if (sanitized.enum) { + sanitized.type = 'string' + } else { + sanitized.type = 'object' + sanitized.properties = {} + } + } + + if (sanitized.type === 'object' && !sanitized.properties) { + sanitized.properties = {} + } + + return sanitized + } + + const functionDeclarations = tools + .map((tool) => { + const toolDef = tool?.custom && typeof tool.custom === 'object' ? tool.custom : tool + if (!toolDef || !toolDef.name) { + return null + } + + const toolDescription = + vendor === 'antigravity' + ? compactToolDescriptionForAntigravity(toolDef.description || '') + : toolDef.description || '' + + const schema = + vendor === 'antigravity' + ? compactJsonSchemaDescriptionsForAntigravity( + cleanJsonSchemaForGemini(toolDef.input_schema) + ) + : sanitizeSchemaForFunctionDeclarations(toolDef.input_schema) || { + type: 'object', + properties: {} + } + + const baseDecl = { + name: toolDef.name, + description: toolDescription + } + + // CLIProxyAPI/Antigravity 侧使用 parametersJsonSchema(而不是 parameters)。 + if (vendor === 'antigravity') { + return { ...baseDecl, parametersJsonSchema: schema } + } + return { ...baseDecl, parameters: schema } + }) + .filter(Boolean) + + if (functionDeclarations.length === 0) { + return null + } + + return [ + { + functionDeclarations + } + ] +} + +/** + * 将 Anthropic 的 tool_choice 转换为 Gemini 的 toolConfig + * 映射关系: + * auto → AUTO(模型自决定是否调用工具) + * any → ANY(必须调用某个工具) + * tool → ANY + allowedFunctionNames(指定工具) + * none → NONE(禁止调用工具) + */ +function convertAnthropicToolChoiceToGeminiToolConfig(toolChoice) { + if (!toolChoice || typeof toolChoice !== 'object') { + return null + } + + const { type } = toolChoice + if (!type) { + return null + } + + if (type === 'auto') { + return { functionCallingConfig: { mode: 'AUTO' } } + } + + if (type === 'any') { + return { functionCallingConfig: { mode: 'ANY' } } + } + + if (type === 'tool') { + const { name } = toolChoice + if (!name) { + return { functionCallingConfig: { mode: 'ANY' } } + } + return { + functionCallingConfig: { + mode: 'ANY', + allowedFunctionNames: [name] + } + } + } + + if (type === 'none') { + return { functionCallingConfig: { mode: 'NONE' } } + } + + return null +} + +// ============================================================================ +// 核心函数:消息内容转换 +// ============================================================================ + +/** + * 将 Anthropic 消息转换为 Gemini contents 格式 + * + * 处理的内容类型: + * - text: 纯文本内容 + * - thinking: 思考过程(转换为 Gemini 的 thought part) + * - image: 图片(转换为 inlineData) + * - tool_use: 工具调用(转换为 functionCall) + * - tool_result: 工具结果(转换为 functionResponse) + * + * Antigravity 特殊处理: + * - thinking block 转换为 { thought: true, text, thoughtSignature } + * - signature 清洗和校验(不伪造签名) + * - 空 thinking block 跳过(避免 400 错误) + * - stripThinking 模式:完全剔除 thinking blocks + * + * @param {Array} messages - 标准化后的消息列表 + * @param {Map} toolUseIdToName - tool_use ID 到工具名的映射 + * @param {Object} options - 选项,包含 vendor、stripThinking + * @returns {Array} Gemini 格式的 contents + */ +function convertAnthropicMessagesToGeminiContents( + messages, + toolUseIdToName, + { vendor = null, stripThinking = false, sessionId = null } = {} +) { + const contents = [] + for (const message of messages || []) { + const role = message?.role === 'assistant' ? 'model' : 'user' + + const content = message?.content + const parts = [] + let lastAntigravityThoughtSignature = '' + + if (typeof content === 'string') { + const text = extractAnthropicText(content) + if (text && !shouldSkipText(text)) { + parts.push({ text }) + } + } else if (Array.isArray(content)) { + for (const part of content) { + if (!part || !part.type) { + continue + } + + if (part.type === 'text') { + const text = extractAnthropicText(part.text || '') + if (text && !shouldSkipText(text)) { + parts.push({ text }) + } + continue + } + + if (part.type === 'thinking' || part.type === 'redacted_thinking') { + // 当 thinking 未启用时,跳过所有 thinking blocks,避免 Antigravity 400 错误: + // "When thinking is disabled, an assistant message cannot contain thinking" + if (stripThinking) { + continue + } + + const thinkingText = extractAnthropicText(part.thinking || part.text || '') + if (vendor === 'antigravity') { + const hasThinkingText = thinkingText && !shouldSkipText(thinkingText) + // 先尝试使用请求中的签名,如果没有则尝试从缓存恢复 + let signature = sanitizeThoughtSignatureForAntigravity(part.signature) + if (!signature && sessionId && hasThinkingText) { + const cachedSig = signatureCache.getCachedSignature(sessionId, thinkingText) + if (cachedSig) { + signature = cachedSig + logger.debug('[SignatureCache] Restored signature from cache for thinking block') + } + } + const hasSignature = Boolean(signature) + + // Claude Code 有时会发送空的 thinking block(无 thinking / 无 signature)。 + // 传给 Antigravity 会变成仅含 thoughtSignature 的 part,容易触发 INVALID_ARGUMENT。 + if (!hasThinkingText && !hasSignature) { + continue + } + + // Antigravity 会校验 thoughtSignature;缺失/不合法时无法伪造,只能丢弃该块避免 400。 + if (!hasSignature) { + continue + } + + lastAntigravityThoughtSignature = signature + const thoughtPart = { thought: true, thoughtSignature: signature } + if (hasThinkingText) { + thoughtPart.text = thinkingText + } + parts.push(thoughtPart) + } else if (thinkingText && !shouldSkipText(thinkingText)) { + parts.push({ text: thinkingText }) + } + continue + } + + if (part.type === 'image') { + const source = part.source || {} + if (source.type === 'base64' && source.data) { + const mediaType = source.media_type || source.mediaType || 'application/octet-stream' + const inlineData = + vendor === 'antigravity' + ? { mime_type: mediaType, data: source.data } + : { mimeType: mediaType, data: source.data } + parts.push({ inlineData }) + } + continue + } + + if (part.type === 'tool_use') { + if (part.name) { + const toolCallId = typeof part.id === 'string' && part.id ? part.id : undefined + const args = normalizeToolUseInput(part.input) + const functionCall = { + ...(vendor === 'antigravity' && toolCallId ? { id: toolCallId } : {}), + name: part.name, + args + } + + // Antigravity 对历史工具调用的 functionCall 会校验 thoughtSignature; + // Claude Code 侧的签名存放在 thinking block(part.signature),这里需要回填到 functionCall part 上。 + // [大东的绝杀补丁] 再次尝试! + if (vendor === 'antigravity') { + // 如果没有真签名,就用“免检金牌” + const effectiveSignature = + lastAntigravityThoughtSignature || THOUGHT_SIGNATURE_FALLBACK + + // 必须把这个塞进去 + // Antigravity 要求:每个包含 thoughtSignature 的 part 都必须有 thought: true + parts.push({ + thought: true, + thoughtSignature: effectiveSignature, + functionCall + }) + } else { + parts.push({ functionCall }) + } + } + continue + } + + if (part.type === 'tool_result') { + const toolUseId = part.tool_use_id + const toolName = toolUseId ? toolUseIdToName.get(toolUseId) : null + if (!toolName) { + continue + } + + const raw = normalizeToolResultContent(part.content, { vendor }) + + let parsedResponse = null + if (raw && typeof raw === 'string') { + try { + parsedResponse = JSON.parse(raw) + } catch (_) { + parsedResponse = null + } + } + + if (vendor === 'antigravity') { + const toolCallId = typeof toolUseId === 'string' && toolUseId ? toolUseId : undefined + const result = parsedResponse !== null ? parsedResponse : raw || '' + const response = part.is_error === true ? { result, is_error: true } : { result } + + parts.push({ + functionResponse: { + ...(toolCallId ? { id: toolCallId } : {}), + name: toolName, + response + } + }) + } else { + const response = + parsedResponse !== null + ? parsedResponse + : { + content: raw || '', + is_error: part.is_error === true + } + + parts.push({ + functionResponse: { + name: toolName, + response + } + }) + } + } + } + } + + if (parts.length === 0) { + continue + } + + contents.push({ + role, + parts + }) + } + return contents +} + +/** + * 检查是否可以为 Antigravity 启用 thinking 功能 + * + * 规则:查找最后一个 assistant 消息,检查其 thinking block 是否有效 + * - 如果有 thinking 文本或 signature,则可以启用 + * - 如果是空 thinking block(无文本且无 signature),则不能启用 + * + * 这是为了避免 "When thinking is disabled, an assistant message cannot contain thinking" 错误 + * + * @param {Array} messages - 消息列表 + * @returns {boolean} 是否可以启用 thinking + */ +function canEnableAntigravityThinking(messages) { + if (!Array.isArray(messages) || messages.length === 0) { + return true + } + + // Antigravity 会校验历史 thinking blocks 的 signature;缺失/不合法时必须禁用 thinking,避免 400。 + for (const message of messages) { + if (!message || message.role !== 'assistant') { + continue + } + const { content } = message + if (!Array.isArray(content) || content.length === 0) { + continue + } + for (const part of content) { + if (!part || (part.type !== 'thinking' && part.type !== 'redacted_thinking')) { + continue + } + const signature = sanitizeThoughtSignatureForAntigravity(part.signature) + if (!signature) { + return false + } + } + } + + let lastAssistant = null + for (let i = messages.length - 1; i >= 0; i -= 1) { + const message = messages[i] + if (message && message.role === 'assistant') { + lastAssistant = message + break + } + } + if ( + !lastAssistant || + !Array.isArray(lastAssistant.content) || + lastAssistant.content.length === 0 + ) { + return true + } + + const parts = lastAssistant.content.filter(Boolean) + const hasToolBlocks = parts.some( + (part) => part?.type === 'tool_use' || part?.type === 'tool_result' + ) + if (!hasToolBlocks) { + return true + } + + const first = parts[0] + if (!first || (first.type !== 'thinking' && first.type !== 'redacted_thinking')) { + return false + } + + return true +} + +// ============================================================================ +// 核心函数:构建最终请求 +// ============================================================================ + +/** + * 构建 Gemini/Antigravity 请求体 + * 这是整个转换流程的主函数,串联所有转换步骤: + * + * 1. normalizeAnthropicMessages - 消息标准化 + * 2. buildToolUseIdToNameMap - 构建 tool_use ID 映射 + * 3. canEnableAntigravityThinking - 检查 thinking 是否可启用 + * 4. convertAnthropicMessagesToGeminiContents - 转换消息内容 + * 5. buildSystemParts - 构建 system prompt + * 6. convertAnthropicToolsToGeminiTools - 转换工具定义 + * 7. convertAnthropicToolChoiceToGeminiToolConfig - 转换工具选择 + * 8. 构建 generationConfig(温度、maxTokens、thinking 等) + * + * @param {Object} body - Anthropic 请求体 + * @param {string} baseModel - 基础模型名 + * @param {Object} options - 选项,包含 vendor + * @returns {Object} { model, request } Gemini 请求对象 + */ +function buildGeminiRequestFromAnthropic( + body, + baseModel, + { vendor = null, sessionId = null } = {} +) { + const normalizedMessages = normalizeAnthropicMessages(body.messages || [], { vendor }) + const toolUseIdToName = buildToolUseIdToNameMap(normalizedMessages || []) + + // 提前判断是否可以启用 thinking,以便决定是否需要剥离 thinking blocks + let canEnableThinking = false + if (vendor === 'antigravity' && body?.thinking?.type === 'enabled') { + const budgetRaw = Number(body.thinking.budget_tokens) + if (Number.isFinite(budgetRaw)) { + canEnableThinking = canEnableAntigravityThinking(normalizedMessages) + } + } + + const contents = convertAnthropicMessagesToGeminiContents( + normalizedMessages || [], + toolUseIdToName, + { + vendor, + // 当 Antigravity 无法启用 thinking 时,剥离所有 thinking blocks + stripThinking: vendor === 'antigravity' && !canEnableThinking, + sessionId + } + ) + const systemParts = buildSystemParts(body.system) + + if (vendor === 'antigravity' && isEnvEnabled(process.env[TOOL_ERROR_CONTINUE_ENV])) { + systemParts.push({ text: TOOL_ERROR_CONTINUE_PROMPT }) + } + if (vendor === 'antigravity') { + systemParts.push({ text: ANTIGRAVITY_TOOL_FOLLOW_THROUGH_PROMPT }) + } + + const temperature = typeof body.temperature === 'number' ? body.temperature : 1 + const maxTokens = Number.isFinite(body.max_tokens) ? body.max_tokens : 4096 + + const generationConfig = { + temperature, + maxOutputTokens: maxTokens, + candidateCount: 1 + } + + if (typeof body.top_p === 'number') { + generationConfig.topP = body.top_p + } + if (typeof body.top_k === 'number') { + generationConfig.topK = body.top_k + } + + // 使用前面已经计算好的 canEnableThinking 结果 + if (vendor === 'antigravity' && body?.thinking?.type === 'enabled') { + const budgetRaw = Number(body.thinking.budget_tokens) + if (Number.isFinite(budgetRaw)) { + if (canEnableThinking) { + generationConfig.thinkingConfig = { + thinkingBudget: Math.trunc(budgetRaw), + include_thoughts: true + } + } else { + logger.warn( + '⚠️ Antigravity thinking request dropped: last assistant message lacks usable thinking block', + { model: baseModel } + ) + } + } + } + + const geminiRequestBody = { + contents, + generationConfig + } + + // antigravity: 前置注入系统提示词 + if (vendor === 'antigravity') { + const allParts = [{ text: ANTIGRAVITY_SYSTEM_INSTRUCTION_PREFIX }, ...systemParts] + geminiRequestBody.systemInstruction = { role: 'user', parts: allParts } + } else if (systemParts.length > 0) { + geminiRequestBody.systemInstruction = { parts: systemParts } + } + + const geminiTools = convertAnthropicToolsToGeminiTools(body.tools, { vendor }) + if (geminiTools) { + geminiRequestBody.tools = geminiTools + } + + const toolConfig = convertAnthropicToolChoiceToGeminiToolConfig(body.tool_choice) + if (toolConfig) { + geminiRequestBody.toolConfig = toolConfig + } else if (geminiTools) { + // Anthropic 的默认语义是 tools 存在且未设置 tool_choice 时为 auto。 + // Gemini/Antigravity 的 function calling 默认可能不会启用,因此显式设置为 AUTO,避免“永远不产出 tool_use”。 + geminiRequestBody.toolConfig = { functionCallingConfig: { mode: 'AUTO' } } + } + + return { model: baseModel, request: geminiRequestBody } +} + +// ============================================================================ +// 辅助函数:Gemini 响应解析 +// ============================================================================ + +/** + * 从 Gemini 响应中提取文本内容 + * @param {Object} payload - Gemini 响应 payload + * @param {boolean} includeThought - 是否包含 thinking 文本 + * @returns {string} 提取的文本 + */ +function extractGeminiText(payload, { includeThought = false } = {}) { + const candidate = payload?.candidates?.[0] + const parts = candidate?.content?.parts + if (!Array.isArray(parts)) { + return '' + } + return parts + .filter( + (part) => typeof part?.text === 'string' && part.text && (includeThought || !part.thought) + ) + .map((part) => part.text) + .filter(Boolean) + .join('') +} + +/** + * 从 Gemini 响应中提取 thinking 文本内容 + */ +function extractGeminiThoughtText(payload) { + const candidate = payload?.candidates?.[0] + const parts = candidate?.content?.parts + if (!Array.isArray(parts)) { + return '' + } + return parts + .filter((part) => part?.thought && typeof part?.text === 'string' && part.text) + .map((part) => part.text) + .filter(Boolean) + .join('') +} + +/** + * 从 Gemini 响应中提取 thinking signature + * 用于在下一轮对话中传回给 Antigravity + */ +function extractGeminiThoughtSignature(payload) { + const candidate = payload?.candidates?.[0] + const parts = candidate?.content?.parts + if (!Array.isArray(parts)) { + return '' + } + + const resolveSignature = (part) => { + if (!part) { + return '' + } + return part.thoughtSignature || part.thought_signature || part.signature || '' + } + + // 优先:functionCall part 上的 signature(上游可能把签名挂在工具调用 part 上) + for (const part of parts) { + if (!part?.functionCall?.name) { + continue + } + const signature = resolveSignature(part) + if (signature) { + return signature + } + } + + // 回退:thought part 上的 signature + for (const part of parts) { + if (!part?.thought) { + continue + } + const signature = resolveSignature(part) + if (signature) { + return signature + } + } + return '' +} + +/** + * 解析 Gemini 响应的 token 使用情况 + * 计算输出 token 数(包括 candidate + thought tokens) + */ +function resolveUsageOutputTokens(usageMetadata) { + if (!usageMetadata || typeof usageMetadata !== 'object') { + return 0 + } + const promptTokens = usageMetadata.promptTokenCount || 0 + const candidateTokens = usageMetadata.candidatesTokenCount || 0 + const thoughtTokens = usageMetadata.thoughtsTokenCount || 0 + const totalTokens = usageMetadata.totalTokenCount || 0 + + let outputTokens = candidateTokens + thoughtTokens + if (outputTokens === 0 && totalTokens > 0) { + outputTokens = totalTokens - promptTokens + if (outputTokens < 0) { + outputTokens = 0 + } + } + return outputTokens +} + +/** + * 检查环境变量是否启用 + * 支持 true/1/yes/on 等值 + */ +function isEnvEnabled(value) { + if (!value) { + return false + } + const normalized = String(value).trim().toLowerCase() + return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on' +} + +/** + * 从文本中提取 Write 工具调用 + * 处理模型在文本中输出 "Write: " 格式的情况 + * 这是一个兜底机制,用于处理 function calling 失败的情况 + */ +function tryExtractWriteToolFromText(text, fallbackCwd) { + if (!text || typeof text !== 'string') { + return null + } + + const lines = text.split(/\r?\n/) + const index = lines.findIndex((line) => /^\s*Write\s*:\s*/i.test(line)) + if (index < 0) { + return null + } + + const header = lines[index] + const rawPath = header.replace(/^\s*Write\s*:\s*/i, '').trim() + if (!rawPath) { + return null + } + + const content = lines.slice(index + 1).join('\n') + const prefixText = lines.slice(0, index).join('\n').trim() + + // Claude Code 的 Write 工具要求绝对路径。若模型给的是相对路径,仅在本地运行代理时可用; + // 这里提供一个可选回退:使用服务端 cwd 解析。 + let filePath = rawPath + if (!path.isAbsolute(filePath) && fallbackCwd) { + filePath = path.resolve(fallbackCwd, filePath) + } + + return { + prefixText: prefixText || '', + tool: { + name: 'Write', + input: { + file_path: filePath, + content: content || '' + } + } + } +} + +function mapGeminiFinishReasonToAnthropicStopReason(finishReason) { + const normalized = (finishReason || '').toString().toUpperCase() + if (normalized === 'MAX_TOKENS') { + return 'max_tokens' + } + return 'end_turn' +} + +/** + * 生成工具调用 ID + * 使用 toolu_ 前缀 + 随机字符串 + */ +function buildToolUseId() { + return `toolu_${crypto.randomBytes(10).toString('hex')}` +} + +/** + * 稳定的 JSON 序列化(键按字母顺序排列) + * 用于生成可比较的 JSON 字符串 + */ +function stableJsonStringify(value) { + if (value === null || value === undefined) { + return 'null' + } + if (Array.isArray(value)) { + return `[${value.map((item) => stableJsonStringify(item)).join(',')}]` + } + if (typeof value === 'object') { + const keys = Object.keys(value).sort() + const pairs = keys.map((key) => `${JSON.stringify(key)}:${stableJsonStringify(value[key])}`) + return `{${pairs.join(',')}}` + } + return JSON.stringify(value) +} + +/** + * 从 Gemini 响应中提取 parts 数组 + */ +function extractGeminiParts(payload) { + const candidate = payload?.candidates?.[0] + const parts = candidate?.content?.parts + if (!Array.isArray(parts)) { + return [] + } + return parts +} + +// ============================================================================ +// 核心函数:Gemini 响应转换为 Anthropic 格式 +// ============================================================================ + +/** + * 将 Gemini 响应转换为 Anthropic content blocks + * + * 处理的内容类型: + * - text: 纯文本 → { type: "text", text } + * - thought: 思考过程 → { type: "thinking", thinking, signature } + * - functionCall: 工具调用 → { type: "tool_use", id, name, input } + * + * 注意:thinking blocks 会被调整到数组最前面(符合 Anthropic 规范) + */ +function convertGeminiPayloadToAnthropicContent(payload) { + const parts = extractGeminiParts(payload) + const content = [] + let currentText = '' + + const flushText = () => { + if (!currentText) { + return + } + content.push({ type: 'text', text: currentText }) + currentText = '' + } + + const pushThinkingBlock = (thinkingText, signature) => { + const normalizedThinking = typeof thinkingText === 'string' ? thinkingText : '' + const normalizedSignature = typeof signature === 'string' ? signature : '' + if (!normalizedThinking && !normalizedSignature) { + return + } + const block = { type: 'thinking', thinking: normalizedThinking } + if (normalizedSignature) { + block.signature = normalizedSignature + } + content.push(block) + } + + const resolveSignature = (part) => { + if (!part) { + return '' + } + return part.thoughtSignature || part.thought_signature || part.signature || '' + } + + for (const part of parts) { + const isThought = part?.thought === true + if (isThought) { + flushText() + pushThinkingBlock(typeof part?.text === 'string' ? part.text : '', resolveSignature(part)) + continue + } + + if (typeof part?.text === 'string' && part.text) { + currentText += part.text + continue + } + + const functionCall = part?.functionCall + if (functionCall?.name) { + flushText() + + // 上游可能把 thought signature 挂在 functionCall part 上:需要原样传回给客户端, + // 以便下一轮对话能携带 signature。 + const functionCallSignature = resolveSignature(part) + if (functionCallSignature) { + pushThinkingBlock('', functionCallSignature) + } + + const toolUseId = + typeof functionCall.id === 'string' && functionCall.id ? functionCall.id : buildToolUseId() + content.push({ + type: 'tool_use', + id: toolUseId, + name: functionCall.name, + input: functionCall.args || {} + }) + } + } + + flushText() + const thinkingBlocks = content.filter( + (b) => b && (b.type === 'thinking' || b.type === 'redacted_thinking') + ) + if (thinkingBlocks.length > 0) { + const firstType = content?.[0]?.type + if (firstType !== 'thinking' && firstType !== 'redacted_thinking') { + const others = content.filter( + (b) => b && b.type !== 'thinking' && b.type !== 'redacted_thinking' + ) + return [...thinkingBlocks, ...others] + } + } + return content +} + +/** + * 构建 Anthropic 格式的错误响应 + */ +function buildAnthropicError(message) { + return { + type: 'error', + error: { + type: 'api_error', + message: message || 'Upstream error' + } + } +} + +/** + * 判断是否应该在无工具模式下重试 + * 当上游报告 JSON Schema 或工具相关错误时,移除工具定义重试 + */ +function shouldRetryWithoutTools(sanitizedError) { + const message = (sanitizedError?.upstreamMessage || sanitizedError?.message || '').toLowerCase() + if (!message) { + return false + } + return ( + message.includes('json schema is invalid') || + message.includes('invalid json payload') || + message.includes('tools.') || + message.includes('function_declarations') + ) +} + +/** + * 从请求中移除工具定义(用于重试) + */ +function stripToolsFromRequest(requestData) { + if (!requestData || !requestData.request) { + return requestData + } + const nextRequest = { + ...requestData, + request: { + ...requestData.request + } + } + delete nextRequest.request.tools + delete nextRequest.request.toolConfig + return nextRequest +} + +/** + * 写入 Anthropic SSE 事件 + * 将事件和数据以 SSE 格式发送给客户端 + */ +function writeAnthropicSseEvent(res, event, data) { + res.write(`event: ${event}\n`) + res.write(`data: ${JSON.stringify(data)}\n\n`) +} + +// ============================================================================ +// 调试和跟踪函数 +// ============================================================================ + +/** + * 记录工具定义到文件(调试用) + * 只在环境变量 ANTHROPIC_DEBUG_TOOLS_DUMP 启用时生效 + */ +function dumpToolsPayload({ vendor, model, tools, toolChoice }) { + if (!isEnvEnabled(process.env[TOOLS_DUMP_ENV])) { + return + } + if (!Array.isArray(tools) || tools.length === 0) { + return + } + if (vendor !== 'antigravity') { + return + } + + const filePath = path.join(getProjectRoot(), TOOLS_DUMP_FILENAME) + const payload = { + timestamp: new Date().toISOString(), + vendor, + model, + tool_choice: toolChoice || null, + tools + } + + try { + fs.appendFileSync(filePath, `${JSON.stringify(payload)}\n`, 'utf8') + logger.warn(`🧾 Tools payload dumped to ${filePath}`) + } catch (error) { + logger.warn('Failed to dump tools payload:', error.message) + } +} + +/** + * 更新速率限制计数器 + * 跟踪 token 使用量和成本 + */ +async function applyRateLimitTracking(rateLimitInfo, usageSummary, model, context = '', keyId = null) { + if (!rateLimitInfo) { + return + } + + const label = context ? ` (${context})` : '' + + try { + const { totalTokens, totalCost } = await updateRateLimitCounters( + rateLimitInfo, + usageSummary, + model, + keyId, + 'gemini' + ) + if (totalTokens > 0) { + logger.api(`📊 Updated rate limit token count${label}: +${totalTokens} tokens`) + } + if (typeof totalCost === 'number' && totalCost > 0) { + logger.api(`💰 Updated rate limit cost count${label}: +$${totalCost.toFixed(6)}`) + } + } catch (error) { + logger.error(`❌ Failed to update rate limit counters${label}:`, error) + } +} + +// ============================================================================ +// 主入口函数:API 请求处理 +// ============================================================================ + +/** + * 处理 Anthropic 格式的请求并转发到 Gemini/Antigravity + * + * 这是整个模块的主入口,完整流程: + * 1. 验证 vendor 支持 + * 2. 选择可用的 Gemini 账户 + * 3. 模型回退匹配(如果请求的模型不可用) + * 4. 构建 Gemini 请求 (buildGeminiRequestFromAnthropic) + * 5. 发送请求(流式或非流式) + * 6. 处理响应并转换为 Anthropic 格式 + * 7. 如果工具相关错误,尝试移除工具重试 + * 8. 返回结果给客户端 + * + * @param {Object} req - Express 请求对象 + * @param {Object} res - Express 响应对象 + * @param {Object} options - 包含 vendor 和 baseModel + */ +async function handleAnthropicMessagesToGemini(req, res, { vendor, baseModel }) { + if (!SUPPORTED_VENDORS.has(vendor)) { + return res.status(400).json(buildAnthropicError(`Unsupported vendor: ${vendor}`)) + } + + dumpToolsPayload({ + vendor, + model: baseModel, + tools: req.body?.tools || null, + toolChoice: req.body?.tool_choice || null + }) + + const pickFallbackModel = (account, requestedModel) => { + const supportedModels = Array.isArray(account?.supportedModels) ? account.supportedModels : [] + if (supportedModels.length === 0) { + return requestedModel + } + + const normalize = (m) => String(m || '').replace(/^models\//, '') + const requested = normalize(requestedModel) + const normalizedSupported = supportedModels.map(normalize) + + if (normalizedSupported.includes(requested)) { + return requestedModel + } + + // Claude Code 常见探测模型:优先回退到 Opus 4.5(如果账号支持) + const preferred = ['claude-opus-4-5', 'claude-sonnet-4-5-thinking', 'claude-sonnet-4-5'] + for (const candidate of preferred) { + if (normalizedSupported.includes(candidate)) { + return candidate + } + } + + return normalizedSupported[0] + } + + const isStream = req.body?.stream === true + const sessionHash = sessionHelper.generateSessionHash(req.body) + const upstreamSessionId = sessionHash || req.apiKey?.id || null + + let accountSelection + try { + accountSelection = await unifiedGeminiScheduler.selectAccountForApiKey( + req.apiKey, + sessionHash, + baseModel, + { oauthProvider: vendor } + ) + } catch (error) { + logger.error('Failed to select Gemini account (via /v1/messages):', error) + return res + .status(503) + .json(buildAnthropicError(error.message || 'No available Gemini accounts')) + } + + let { accountId } = accountSelection + const { accountType } = accountSelection + if (accountType !== 'gemini') { + return res + .status(400) + .json(buildAnthropicError('Only Gemini OAuth accounts are supported for this vendor')) + } + + const account = await geminiAccountService.getAccount(accountId) + if (!account) { + return res.status(503).json(buildAnthropicError('Gemini OAuth account not found')) + } + + await geminiAccountService.markAccountUsed(account.id) + + 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 client = await geminiAccountService.getOauthClient( + account.accessToken, + account.refreshToken, + proxyConfig, + account.oauthProvider + ) + + let { projectId } = account + if (vendor === 'antigravity') { + projectId = ensureAntigravityProjectId(account) + if (!account.projectId && account.tempProjectId !== projectId) { + await geminiAccountService.updateTempProjectId(account.id, projectId) + account.tempProjectId = projectId + } + } + + const effectiveModel = pickFallbackModel(account, baseModel) + if (effectiveModel !== baseModel) { + logger.warn('⚠️ Requested model not supported by account, falling back', { + requestedModel: baseModel, + effectiveModel, + vendor, + accountId + }) + } + + let requestData = buildGeminiRequestFromAnthropic(req.body, effectiveModel, { + vendor, + sessionId: sessionHash + }) + + // Antigravity 上游对 function calling 的启用/校验更严格:参考实现普遍使用 VALIDATED。 + // 这里仅在 tools 存在且未显式禁用(tool_choice=none)时应用,避免破坏原始语义。 + if ( + vendor === 'antigravity' && + Array.isArray(requestData?.request?.tools) && + requestData.request.tools.length > 0 + ) { + const existingCfg = requestData?.request?.toolConfig?.functionCallingConfig || null + const mode = existingCfg?.mode + if (mode !== 'NONE') { + const nextCfg = { ...(existingCfg || {}), mode: 'VALIDATED' } + requestData = { + ...requestData, + request: { + ...requestData.request, + toolConfig: { functionCallingConfig: nextCfg } + } + } + } + } + + // Antigravity 默认启用 tools(对齐 CLIProxyAPI)。若上游拒绝 schema,会在下方自动重试去掉 tools/toolConfig。 + + const abortController = new AbortController() + req.on('close', () => { + if (!abortController.signal.aborted) { + abortController.abort() + } + }) + + if (!isStream) { + try { + const attemptRequest = async (payload) => { + if (vendor === 'antigravity') { + return await geminiAccountService.generateContentAntigravity( + client, + payload, + null, + projectId, + upstreamSessionId, + proxyConfig + ) + } + return await geminiAccountService.generateContent( + client, + payload, + null, + projectId, + upstreamSessionId, + proxyConfig + ) + } + + let rawResponse + try { + rawResponse = await attemptRequest(requestData) + } catch (error) { + const sanitized = sanitizeUpstreamError(error) + if (shouldRetryWithoutTools(sanitized) && requestData.request?.tools) { + logger.warn('⚠️ Tool schema rejected by upstream, retrying without tools', { + vendor, + accountId + }) + rawResponse = await attemptRequest(stripToolsFromRequest(requestData)) + } else if ( + // [429 账户切换] 检测到 Antigravity 配额耗尽错误时,尝试切换账户重试 + vendor === 'antigravity' && + sanitized.statusCode === 429 && + (sanitized.message?.toLowerCase()?.includes('exhausted') || + sanitized.upstreamMessage?.toLowerCase()?.includes('exhausted') || + sanitized.message?.toLowerCase()?.includes('capacity')) + ) { + logger.warn( + '⚠️ Antigravity 429 quota exhausted (non-stream), switching account and retrying', + { + vendor, + accountId, + model: effectiveModel + } + ) + // 删除当前会话映射,让调度器选择其他账户 + if (sessionHash) { + await unifiedGeminiScheduler._deleteSessionMapping(sessionHash) + } + // 重新选择账户 + try { + const newAccountSelection = await unifiedGeminiScheduler.selectAccountForApiKey( + req.apiKey, + sessionHash, + effectiveModel, + { oauthProvider: vendor } + ) + const newAccountId = newAccountSelection.accountId + const newClient = await geminiAccountService.getGeminiClient(newAccountId) + if (!newClient) { + throw new Error('Failed to get new Gemini client for retry') + } + logger.info( + `🔄 Retrying non-stream with new account: ${newAccountId} (was: ${accountId})` + ) + // 用新账户的 client 重试 + rawResponse = + vendor === 'antigravity' + ? await geminiAccountService.generateContentAntigravity( + newClient, + requestData, + null, + projectId, + upstreamSessionId, + proxyConfig + ) + : await geminiAccountService.generateContent( + newClient, + requestData, + null, + projectId, + upstreamSessionId, + proxyConfig + ) + // 更新 accountId 以便后续使用记录 + accountId = newAccountId + } catch (retryError) { + logger.error('❌ Failed to retry non-stream with new account:', retryError) + throw error // 抛出原始错误 + } + } else { + throw error + } + } + + const payload = rawResponse?.response || rawResponse + let content = convertGeminiPayloadToAnthropicContent(payload) + let hasToolUse = content.some((block) => block.type === 'tool_use') + + // Antigravity 某些模型可能不会返回 functionCall(导致永远没有 tool_use),但会把 “Write: xxx” 以纯文本形式输出。 + // 可选回退:解析该文本并合成标准 tool_use,交给 claude-cli 去执行。 + if (!hasToolUse && isEnvEnabled(process.env[TEXT_TOOL_FALLBACK_ENV])) { + const fullText = extractGeminiText(payload) + const extracted = tryExtractWriteToolFromText(fullText, process.cwd()) + if (extracted?.tool) { + const toolUseId = buildToolUseId() + const blocks = [] + if (extracted.prefixText) { + blocks.push({ type: 'text', text: extracted.prefixText }) + } + blocks.push({ + type: 'tool_use', + id: toolUseId, + name: extracted.tool.name, + input: extracted.tool.input + }) + content = blocks + hasToolUse = true + logger.warn('⚠️ Synthesized tool_use from plain text Write directive', { + vendor, + accountId, + tool: extracted.tool.name + }) + } + } + + const usageMetadata = payload?.usageMetadata || {} + const inputTokens = usageMetadata.promptTokenCount || 0 + const outputTokens = resolveUsageOutputTokens(usageMetadata) + const finishReason = payload?.candidates?.[0]?.finishReason + + const stopReason = hasToolUse + ? 'tool_use' + : mapGeminiFinishReasonToAnthropicStopReason(finishReason) + + if (req.apiKey?.id && (inputTokens > 0 || outputTokens > 0)) { + await apiKeyService.recordUsage( + req.apiKey.id, + inputTokens, + outputTokens, + 0, + 0, + effectiveModel, + accountId, + 'gemini' + ) + await applyRateLimitTracking( + req.rateLimitInfo, + { inputTokens, outputTokens, cacheCreateTokens: 0, cacheReadTokens: 0 }, + effectiveModel, + 'anthropic-messages', + req.apiKey?.id + ) + } + + const responseBody = { + id: `msg_${crypto.randomBytes(12).toString('hex')}`, + type: 'message', + role: 'assistant', + model: req.body.model || effectiveModel, + content, + stop_reason: stopReason, + stop_sequence: null, + usage: { + input_tokens: inputTokens, + output_tokens: outputTokens + } + } + + dumpAnthropicNonStreamResponse(req, 200, responseBody, { + vendor, + accountId, + effectiveModel, + forcedVendor: vendor + }) + + return res.status(200).json(responseBody) + } catch (error) { + const sanitized = sanitizeUpstreamError(error) + logger.error('Upstream Gemini error (via /v1/messages):', sanitized) + dumpAnthropicNonStreamResponse( + req, + sanitized.statusCode || 502, + buildAnthropicError(sanitized.upstreamMessage || sanitized.message), + { vendor, accountId, effectiveModel, forcedVendor: vendor, upstreamError: sanitized } + ) + return res + .status(sanitized.statusCode || 502) + .json(buildAnthropicError(sanitized.upstreamMessage || sanitized.message)) + } + } + + const messageId = `msg_${crypto.randomBytes(12).toString('hex')}` + const responseModel = req.body.model || effectiveModel + + try { + const startStream = async (payload) => { + if (vendor === 'antigravity') { + return await geminiAccountService.generateContentStreamAntigravity( + client, + payload, + null, + projectId, + upstreamSessionId, + abortController.signal, + proxyConfig + ) + } + return await geminiAccountService.generateContentStream( + client, + payload, + null, + projectId, + upstreamSessionId, + abortController.signal, + proxyConfig + ) + } + + let streamResponse + try { + streamResponse = await startStream(requestData) + } catch (error) { + const sanitized = sanitizeUpstreamError(error) + if (shouldRetryWithoutTools(sanitized) && requestData.request?.tools) { + logger.warn('⚠️ Tool schema rejected by upstream, retrying stream without tools', { + vendor, + accountId + }) + streamResponse = await startStream(stripToolsFromRequest(requestData)) + } else if ( + // [429 账户切换] 检测到 Antigravity 配额耗尽错误时,尝试切换账户重试 + vendor === 'antigravity' && + sanitized.statusCode === 429 && + (sanitized.message?.toLowerCase()?.includes('exhausted') || + sanitized.upstreamMessage?.toLowerCase()?.includes('exhausted') || + sanitized.message?.toLowerCase()?.includes('capacity')) + ) { + logger.warn('⚠️ Antigravity 429 quota exhausted, switching account and retrying', { + vendor, + accountId, + model: effectiveModel + }) + // 删除当前会话映射,让调度器选择其他账户 + if (sessionHash) { + await unifiedGeminiScheduler._deleteSessionMapping(sessionHash) + } + // 重新选择账户 + try { + const newAccountSelection = await unifiedGeminiScheduler.selectAccountForApiKey( + req.apiKey, + sessionHash, + effectiveModel, + { oauthProvider: vendor } + ) + const newAccountId = newAccountSelection.accountId + const newClient = await geminiAccountService.getGeminiClient(newAccountId) + if (!newClient) { + throw new Error('Failed to get new Gemini client for retry') + } + logger.info(`🔄 Retrying with new account: ${newAccountId} (was: ${accountId})`) + // 用新账户的 client 重试 + streamResponse = + vendor === 'antigravity' + ? await geminiAccountService.generateContentStreamAntigravity( + newClient, + requestData, + null, + projectId, + upstreamSessionId, + abortController.signal, + proxyConfig + ) + : await geminiAccountService.generateContentStream( + newClient, + requestData, + null, + projectId, + upstreamSessionId, + abortController.signal, + proxyConfig + ) + // 更新 accountId 以便后续使用记录 + accountId = newAccountId + } catch (retryError) { + logger.error('❌ Failed to retry with new account:', retryError) + throw error // 抛出原始错误 + } + } else { + throw error + } + } + + // 仅在上游流成功建立后再开始向客户端发送 SSE。 + // 这样如果上游在握手阶段直接返回 4xx/5xx(例如 schema 400 或配额 429), + // 我们可以返回真实 HTTP 状态码,而不是先 200 再在 SSE 内发 error 事件。 + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + res.setHeader('X-Accel-Buffering', 'no') + + writeAnthropicSseEvent(res, 'message_start', { + type: 'message_start', + message: { + id: messageId, + type: 'message', + role: 'assistant', + model: responseModel, + content: [], + stop_reason: null, + stop_sequence: null, + usage: { + input_tokens: 0, + output_tokens: 0 + } + } + }) + + const isAntigravityVendor = vendor === 'antigravity' + const wantsThinkingBlockFirst = + isAntigravityVendor && + requestData?.request?.generationConfig?.thinkingConfig?.include_thoughts === true + + // ======================================================================== + // [大东的 2.0 补丁 - 修复版] 活跃度看门狗 (Watchdog) + // ======================================================================== + let activityTimeout = null + const STREAM_ACTIVITY_TIMEOUT_MS = 45000 // 45秒无数据视为卡死 + + const resetActivityTimeout = () => { + if (activityTimeout) { + clearTimeout(activityTimeout) + } + activityTimeout = setTimeout(() => { + if (finished) { + return + } + + // 🛑【关键修改】先锁门!防止 abort() 触发的 onError 再次写入 res + finished = true + + logger.warn('⚠️ Upstream stream zombie detected (no data for 45s). Forcing termination.', { + requestId: req.requestId + }) + + if (!abortController.signal.aborted) { + abortController.abort() + } + + writeAnthropicSseEvent(res, 'error', { + type: 'error', + error: { + type: 'overloaded_error', + message: 'Upstream stream timed out (zombie connection). Please try again.' + } + }) + res.end() + }, STREAM_ACTIVITY_TIMEOUT_MS) + } + + // 🔥【这里!】一定要加这句来启动它! + resetActivityTimeout() + // ======================================================================== + + let buffer = '' + let emittedText = '' + let emittedThinking = '' + let emittedThoughtSignature = '' + let finished = false + let usageMetadata = null + let finishReason = null + let emittedAnyToolUse = false + let sseEventIndex = 0 + const emittedToolCallKeys = new Set() + const emittedToolUseNames = new Set() + const pendingToolCallsById = new Map() + + let currentIndex = wantsThinkingBlockFirst ? 0 : -1 + let currentBlockType = wantsThinkingBlockFirst ? 'thinking' : null + + const startTextBlock = (index) => { + writeAnthropicSseEvent(res, 'content_block_start', { + type: 'content_block_start', + index, + content_block: { type: 'text', text: '' } + }) + } + + const stopCurrentBlock = () => { + writeAnthropicSseEvent(res, 'content_block_stop', { + type: 'content_block_stop', + index: currentIndex + }) + } + + const startThinkingBlock = (index) => { + writeAnthropicSseEvent(res, 'content_block_start', { + type: 'content_block_start', + index, + content_block: { type: 'thinking', thinking: '' } + }) + } + + if (wantsThinkingBlockFirst) { + startThinkingBlock(0) + } + + const switchBlockType = (nextType) => { + if (currentBlockType === nextType) { + return + } + if (currentBlockType === 'text' || currentBlockType === 'thinking') { + stopCurrentBlock() + } + currentIndex += 1 + currentBlockType = nextType + if (nextType === 'text') { + startTextBlock(currentIndex) + } else if (nextType === 'thinking') { + startThinkingBlock(currentIndex) + } + } + + const canStartThinkingBlock = (_hasSignature = false) => { + // Antigravity 特殊处理:某些情况下不应启动 thinking block + if (isAntigravityVendor) { + // 如果 wantsThinkingBlockFirst 且已发送过工具调用,不应再启动 thinking + if (wantsThinkingBlockFirst && emittedAnyToolUse) { + return false + } + // [移除规则2] 签名可能在后续 chunk 中到达,不应提前阻止 thinking 启动 + } + if (currentIndex < 0) { + return true + } + if (currentBlockType === 'thinking') { + return true + } + if (emittedThinking || emittedThoughtSignature) { + return true + } + return false + } + + const emitToolUseBlock = (name, args, id = null) => { + const toolUseId = typeof id === 'string' && id ? id : buildToolUseId() + const jsonArgs = stableJsonStringify(args || {}) + + if (name) { + emittedToolUseNames.add(name) + } + currentIndex += 1 + const toolIndex = currentIndex + + writeAnthropicSseEvent(res, 'content_block_start', { + type: 'content_block_start', + index: toolIndex, + content_block: { type: 'tool_use', id: toolUseId, name, input: {} } + }) + + writeAnthropicSseEvent(res, 'content_block_delta', { + type: 'content_block_delta', + index: toolIndex, + delta: { type: 'input_json_delta', partial_json: jsonArgs } + }) + + writeAnthropicSseEvent(res, 'content_block_stop', { + type: 'content_block_stop', + index: toolIndex + }) + emittedAnyToolUse = true + currentBlockType = null + } + + const resolveFunctionCallArgs = (functionCall) => { + if (!functionCall || typeof functionCall !== 'object') { + return { args: null, json: '', canContinue: false } + } + const canContinue = + functionCall.willContinue === true || + functionCall.will_continue === true || + functionCall.continue === true || + functionCall.willContinue === 'true' || + functionCall.will_continue === 'true' + + const raw = + functionCall.args !== undefined + ? functionCall.args + : functionCall.partialArgs !== undefined + ? functionCall.partialArgs + : functionCall.partial_args !== undefined + ? functionCall.partial_args + : functionCall.argsJson !== undefined + ? functionCall.argsJson + : functionCall.args_json !== undefined + ? functionCall.args_json + : '' + + if (raw && typeof raw === 'object' && !Array.isArray(raw)) { + return { args: raw, json: '', canContinue } + } + + const json = + typeof raw === 'string' ? raw : raw === null || raw === undefined ? '' : String(raw) + if (!json) { + return { args: null, json: '', canContinue } + } + + try { + const parsed = JSON.parse(json) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return { args: parsed, json: '', canContinue } + } + } catch (_) { + // ignore: treat as partial JSON string + } + + return { args: null, json, canContinue } + } + + const flushPendingToolCallById = (id, { force = false } = {}) => { + const pending = pendingToolCallsById.get(id) + if (!pending) { + return + } + if (!pending.name) { + return + } + if (!pending.args && pending.argsJson) { + try { + const parsed = JSON.parse(pending.argsJson) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + pending.args = parsed + pending.argsJson = '' + } + } catch (_) { + // keep buffering + } + } + if (!pending.args) { + if (!force) { + return + } + pending.args = {} + } + + const toolKey = `id:${id}` + if (emittedToolCallKeys.has(toolKey)) { + pendingToolCallsById.delete(id) + return + } + emittedToolCallKeys.add(toolKey) + + if (currentBlockType === 'text' || currentBlockType === 'thinking') { + stopCurrentBlock() + } + currentBlockType = 'tool_use' + emitToolUseBlock(pending.name, pending.args, id) + pendingToolCallsById.delete(id) + } + + const finalize = async () => { + if (finished) { + return + } + finished = true + + // 若存在未完成的工具调用(例如 args 分段但上游提前结束),尽力 flush,避免客户端卡死。 + for (const id of pendingToolCallsById.keys()) { + flushPendingToolCallById(id, { force: true }) + } + + // 上游可能在没有 finishReason 的情况下静默结束(例如 browser_snapshot 输出过大被截断)。 + // 这种情况下主动向客户端发送错误,避免长时间挂起。 + if (!finishReason) { + logger.warn( + '⚠️ Upstream stream ended without finishReason; sending overloaded_error to client', + { + requestId: req.requestId, + model: effectiveModel, + hasToolCalls: emittedAnyToolUse + } + ) + + writeAnthropicSseEvent(res, 'error', { + type: 'error', + error: { + type: 'overloaded_error', + message: + 'Upstream connection interrupted unexpectedly (missing finish reason). Please retry.' + } + }) + + // 记录摘要便于排查 + dumpAnthropicStreamSummary(req, { + vendor, + accountId, + effectiveModel, + responseModel, + stop_reason: 'error', + tool_use_names: Array.from(emittedToolUseNames).filter(Boolean), + text_preview: emittedText ? emittedText.slice(0, 800) : '', + usage: { input_tokens: 0, output_tokens: 0 } + }) + + if (vendor === 'antigravity') { + dumpAntigravityStreamSummary({ + requestId: req.requestId, + model: effectiveModel, + totalEvents: sseEventIndex, + finishReason: null, + hasThinking: Boolean(emittedThinking || emittedThoughtSignature), + hasToolCalls: emittedAnyToolUse, + toolCallNames: Array.from(emittedToolUseNames).filter(Boolean), + usage: { input_tokens: 0, output_tokens: 0 }, + textPreview: emittedText ? emittedText.slice(0, 500) : '', + error: 'missing_finish_reason' + }).catch(() => {}) + } + + res.end() + return + } + + const inputTokens = usageMetadata?.promptTokenCount || 0 + const outputTokens = resolveUsageOutputTokens(usageMetadata) + + if (currentBlockType === 'text' || currentBlockType === 'thinking') { + stopCurrentBlock() + } + + writeAnthropicSseEvent(res, 'message_delta', { + type: 'message_delta', + delta: { + stop_reason: emittedAnyToolUse + ? 'tool_use' + : mapGeminiFinishReasonToAnthropicStopReason(finishReason), + stop_sequence: null + }, + usage: { + output_tokens: outputTokens + } + }) + + writeAnthropicSseEvent(res, 'message_stop', { type: 'message_stop' }) + res.end() + + dumpAnthropicStreamSummary(req, { + vendor, + accountId, + effectiveModel, + responseModel, + stop_reason: emittedAnyToolUse + ? 'tool_use' + : mapGeminiFinishReasonToAnthropicStopReason(finishReason), + tool_use_names: Array.from(emittedToolUseNames).filter(Boolean), + text_preview: emittedText ? emittedText.slice(0, 800) : '', + usage: { input_tokens: inputTokens, output_tokens: outputTokens } + }) + + // 记录 Antigravity 上游流摘要用于调试 + if (vendor === 'antigravity') { + dumpAntigravityStreamSummary({ + requestId: req.requestId, + model: effectiveModel, + totalEvents: sseEventIndex, + finishReason, + hasThinking: Boolean(emittedThinking || emittedThoughtSignature), + hasToolCalls: emittedAnyToolUse, + toolCallNames: Array.from(emittedToolUseNames).filter(Boolean), + usage: { input_tokens: inputTokens, output_tokens: outputTokens }, + textPreview: emittedText ? emittedText.slice(0, 500) : '' + }).catch(() => {}) + } + + if (req.apiKey?.id && (inputTokens > 0 || outputTokens > 0)) { + await apiKeyService.recordUsage( + req.apiKey.id, + inputTokens, + outputTokens, + 0, + 0, + effectiveModel, + accountId, + 'gemini' + ) + await applyRateLimitTracking( + req.rateLimitInfo, + { inputTokens, outputTokens, cacheCreateTokens: 0, cacheReadTokens: 0 }, + effectiveModel, + 'anthropic-messages-stream' + ) + } + } + + streamResponse.on('data', (chunk) => { + resetActivityTimeout() // <--- 【新增】收到数据了,重置倒计时! + + if (finished) { + return + } + + buffer += chunk.toString() + const lines = buffer.split('\n') + buffer = lines.pop() || '' + + for (const line of lines) { + if (!line.trim()) { + continue + } + + const parsed = parseSSELine(line) + if (parsed.type === 'control') { + continue + } + if (parsed.type !== 'data' || !parsed.data) { + continue + } + + const payload = parsed.data?.response || parsed.data + + // 记录上游 SSE 事件用于调试 + if (vendor === 'antigravity') { + sseEventIndex += 1 + dumpAntigravityStreamEvent({ + requestId: req.requestId, + eventIndex: sseEventIndex, + eventType: parsed.type, + data: payload + }).catch(() => {}) + } + + const { usageMetadata: currentUsageMetadata, candidates } = payload || {} + if (currentUsageMetadata) { + usageMetadata = currentUsageMetadata + } + + const [candidate] = Array.isArray(candidates) ? candidates : [] + const { finishReason: currentFinishReason } = candidate || {} + if (currentFinishReason) { + finishReason = currentFinishReason + } + + const parts = extractGeminiParts(payload) + const rawThoughtSignature = extractGeminiThoughtSignature(payload) + // Antigravity 专用净化:确保签名格式符合 API 要求 + const thoughtSignature = isAntigravityVendor + ? sanitizeThoughtSignatureForAntigravity(rawThoughtSignature) + : rawThoughtSignature + const fullThoughtForToolOrdering = extractGeminiThoughtText(payload) + + if (wantsThinkingBlockFirst) { + // 关键:确保 thinking/signature 在 tool_use 之前输出,避免出现 tool_use 后紧跟 thinking(signature) + // 导致下一轮请求的 thinking 校验/工具调用校验失败(Antigravity 会返回 400)。 + if (thoughtSignature && canStartThinkingBlock()) { + let delta = '' + if (thoughtSignature.startsWith(emittedThoughtSignature)) { + delta = thoughtSignature.slice(emittedThoughtSignature.length) + } else if (thoughtSignature !== emittedThoughtSignature) { + delta = thoughtSignature + } + if (delta) { + switchBlockType('thinking') + writeAnthropicSseEvent(res, 'content_block_delta', { + type: 'content_block_delta', + index: currentIndex, + delta: { type: 'signature_delta', signature: delta } + }) + emittedThoughtSignature = thoughtSignature + } + } + + if (fullThoughtForToolOrdering && canStartThinkingBlock()) { + let delta = '' + if (fullThoughtForToolOrdering.startsWith(emittedThinking)) { + delta = fullThoughtForToolOrdering.slice(emittedThinking.length) + } else { + delta = fullThoughtForToolOrdering + } + if (delta) { + switchBlockType('thinking') + emittedThinking = fullThoughtForToolOrdering + writeAnthropicSseEvent(res, 'content_block_delta', { + type: 'content_block_delta', + index: currentIndex, + delta: { type: 'thinking_delta', thinking: delta } + }) + } + } + } + for (const part of parts) { + const functionCall = part?.functionCall + if (!functionCall?.name) { + continue + } + + const id = typeof functionCall.id === 'string' && functionCall.id ? functionCall.id : null + const { args, json, canContinue } = resolveFunctionCallArgs(functionCall) + + // 若没有 id(无法聚合多段 args),只在拿到可用 args 时才 emit + if (!id) { + const finalArgs = args || {} + const toolKey = `${functionCall.name}:${stableJsonStringify(finalArgs)}` + if (emittedToolCallKeys.has(toolKey)) { + continue + } + emittedToolCallKeys.add(toolKey) + + if (currentBlockType === 'text' || currentBlockType === 'thinking') { + stopCurrentBlock() + } + currentBlockType = 'tool_use' + emitToolUseBlock(functionCall.name, finalArgs, null) + continue + } + + const pending = pendingToolCallsById.get(id) || { + id, + name: functionCall.name, + args: null, + argsJson: '' + } + pending.name = functionCall.name + if (args) { + pending.args = args + pending.argsJson = '' + } else if (json) { + pending.argsJson += json + } + pendingToolCallsById.set(id, pending) + + // 能确定“本次已完整”时再 emit;否则继续等待后续 SSE 事件补全 args。 + if (!canContinue) { + flushPendingToolCallById(id) + } + } + + if (thoughtSignature && canStartThinkingBlock(true)) { + let delta = '' + if (thoughtSignature.startsWith(emittedThoughtSignature)) { + delta = thoughtSignature.slice(emittedThoughtSignature.length) + } else if (thoughtSignature !== emittedThoughtSignature) { + delta = thoughtSignature + } + if (delta) { + switchBlockType('thinking') + writeAnthropicSseEvent(res, 'content_block_delta', { + type: 'content_block_delta', + index: currentIndex, + delta: { type: 'signature_delta', signature: delta } + }) + emittedThoughtSignature = thoughtSignature + } + } + + const fullThought = extractGeminiThoughtText(payload) + if ( + fullThought && + canStartThinkingBlock(Boolean(thoughtSignature || emittedThoughtSignature)) + ) { + let delta = '' + if (fullThought.startsWith(emittedThinking)) { + delta = fullThought.slice(emittedThinking.length) + } else { + delta = fullThought + } + if (delta) { + switchBlockType('thinking') + emittedThinking = fullThought + writeAnthropicSseEvent(res, 'content_block_delta', { + type: 'content_block_delta', + index: currentIndex, + delta: { type: 'thinking_delta', thinking: delta } + }) + // [签名缓存] 当 thinking 内容和签名都有时,缓存供后续请求使用 + if (isAntigravityVendor && sessionHash && emittedThoughtSignature) { + signatureCache.cacheSignature(sessionHash, fullThought, emittedThoughtSignature) + } + } + } + + const fullText = extractGeminiText(payload) + if (fullText) { + let delta = '' + if (fullText.startsWith(emittedText)) { + delta = fullText.slice(emittedText.length) + } else { + delta = fullText + } + if (delta) { + switchBlockType('text') + emittedText = fullText + writeAnthropicSseEvent(res, 'content_block_delta', { + type: 'content_block_delta', + index: currentIndex, + delta: { type: 'text_delta', text: delta } + }) + } + } + } + }) + + streamResponse.on('end', () => { + if (activityTimeout) { + clearTimeout(activityTimeout) + } // <--- 【新增】正常结束,取消报警 + + finalize().catch((e) => logger.error('Failed to finalize Anthropic SSE response:', e)) + }) + + streamResponse.on('error', (error) => { + if (activityTimeout) { + clearTimeout(activityTimeout) + } // <--- 【新增】报错了,取消报警 + + if (finished) { + return + } + const sanitized = sanitizeUpstreamError(error) + logger.error('Upstream Gemini stream error (via /v1/messages):', sanitized) + writeAnthropicSseEvent( + res, + 'error', + buildAnthropicError(sanitized.upstreamMessage || sanitized.message) + ) + res.end() + }) + + return undefined + } catch (error) { + // ============================================================ + // [大东修复 3.0] 彻底防止 JSON 循环引用导致服务崩溃 + // ============================================================ + + // 1. 使用 util.inspect 安全地将错误对象转为字符串,不使用 JSON.stringify + const safeErrorDetails = util.inspect(error, { + showHidden: false, + depth: 2, + colors: false, + breakLength: Infinity + }) + + // 2. 打印安全日志,绝对不会崩 + logger.error(`❌ [Critical] Failed to start Gemini stream. 错误详情:\n${safeErrorDetails}`) + + const sanitized = sanitizeUpstreamError(error) + + // 3. 特殊处理 Antigravity 的参数错误 (400),输出详细请求信息便于调试 + if ( + vendor === 'antigravity' && + effectiveModel.includes('claude') && + isInvalidAntigravityArgumentError(sanitized) + ) { + logger.warn('⚠️ Antigravity Claude invalid argument detected', { + requestId: req.requestId, + ...summarizeAntigravityRequestForDebug(requestData), + statusCode: sanitized.statusCode, + upstreamType: sanitized.upstreamType, + upstreamMessage: sanitized.upstreamMessage || sanitized.message + }) + } + + // 4. 确保返回 JSON 响应给客户端 (让客户端知道出错了并重试) + if (!res.headersSent) { + // 记录非流式响应日志 + dumpAnthropicNonStreamResponse( + req, + sanitized.statusCode || 502, + buildAnthropicError(sanitized.upstreamMessage || sanitized.message), + { vendor, accountId, effectiveModel, forcedVendor: vendor, upstreamError: sanitized } + ) + + return res + .status(sanitized.statusCode || 502) + .json(buildAnthropicError(sanitized.upstreamMessage || sanitized.message)) + } + + // 5. 如果头已经发了,走 SSE 发送错误 + writeAnthropicSseEvent( + res, + 'error', + buildAnthropicError(sanitized.upstreamMessage || sanitized.message) + ) + res.end() + return undefined + } +} + +async function handleAnthropicCountTokensToGemini(req, res, { vendor }) { + if (!SUPPORTED_VENDORS.has(vendor)) { + return res.status(400).json(buildAnthropicError(`Unsupported vendor: ${vendor}`)) + } + + const sessionHash = sessionHelper.generateSessionHash(req.body) + + const model = (req.body?.model || '').trim() + if (!model) { + return res.status(400).json(buildAnthropicError('Missing model')) + } + + let accountSelection + try { + accountSelection = await unifiedGeminiScheduler.selectAccountForApiKey( + req.apiKey, + sessionHash, + model, + { oauthProvider: vendor } + ) + } catch (error) { + logger.error('Failed to select Gemini account (count_tokens):', error) + return res + .status(503) + .json(buildAnthropicError(error.message || 'No available Gemini accounts')) + } + + const { accountId, accountType } = accountSelection + if (accountType !== 'gemini') { + return res + .status(400) + .json(buildAnthropicError('Only Gemini OAuth accounts are supported for this vendor')) + } + + const account = await geminiAccountService.getAccount(accountId) + if (!account) { + return res.status(503).json(buildAnthropicError('Gemini OAuth account not found')) + } + + await geminiAccountService.markAccountUsed(account.id) + + 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 client = await geminiAccountService.getOauthClient( + account.accessToken, + account.refreshToken, + proxyConfig, + account.oauthProvider + ) + + const normalizedMessages = normalizeAnthropicMessages(req.body.messages || [], { vendor }) + const toolUseIdToName = buildToolUseIdToNameMap(normalizedMessages || []) + + let canEnableThinking = false + if (vendor === 'antigravity' && req.body?.thinking?.type === 'enabled') { + const budgetRaw = Number(req.body.thinking.budget_tokens) + if (Number.isFinite(budgetRaw)) { + canEnableThinking = canEnableAntigravityThinking(normalizedMessages) + } + } + + const contents = convertAnthropicMessagesToGeminiContents( + normalizedMessages || [], + toolUseIdToName, + { + vendor, + stripThinking: vendor === 'antigravity' && !canEnableThinking, + sessionId: sessionHash + } + ) + + try { + const countResult = + vendor === 'antigravity' + ? await geminiAccountService.countTokensAntigravity(client, contents, model, proxyConfig) + : await geminiAccountService.countTokens(client, contents, model, proxyConfig) + + const totalTokens = countResult?.totalTokens || 0 + return res.status(200).json({ input_tokens: totalTokens }) + } catch (error) { + const sanitized = sanitizeUpstreamError(error) + logger.error('Upstream token count error (via /v1/messages/count_tokens):', sanitized) + return res + .status(sanitized.statusCode || 502) + .json(buildAnthropicError(sanitized.upstreamMessage || sanitized.message)) + } +} + +// ============================================================================ +// 模块导出 +// ============================================================================ + +module.exports = { + // 主入口:处理 /v1/messages 请求 + handleAnthropicMessagesToGemini, + // 辅助入口:处理 /v1/messages/count_tokens 请求 + handleAnthropicCountTokensToGemini +} diff --git a/src/services/antigravityClient.js b/src/services/antigravityClient.js new file mode 100644 index 00000000..9ce1eb93 --- /dev/null +++ b/src/services/antigravityClient.js @@ -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 +} diff --git a/src/services/antigravityRelayService.js b/src/services/antigravityRelayService.js new file mode 100644 index 00000000..6a3378f1 --- /dev/null +++ b/src/services/antigravityRelayService.js @@ -0,0 +1,173 @@ +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, + 'gemini' + ) + 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, + 'gemini' + ) + } + } +} + +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, + 'gemini' + ) + } + + return openaiResponse +} + +module.exports = { + sendAntigravityRequest +} diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js index ed07ec01..b640bd6c 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -38,6 +38,51 @@ const ACCOUNT_CATEGORY_MAP = { droid: 'droid' } +/** + * 规范化权限数据,兼容旧格式(字符串)和新格式(数组) + * @param {string|array} permissions - 权限数据 + * @returns {array} - 权限数组,空数组表示全部服务 + */ +function normalizePermissions(permissions) { + if (!permissions) { + return [] // 空 = 全部服务 + } + if (Array.isArray(permissions)) { + return permissions + } + // 尝试解析 JSON 字符串(新格式存储) + if (typeof permissions === 'string') { + if (permissions.startsWith('[')) { + try { + const parsed = JSON.parse(permissions) + if (Array.isArray(parsed)) { + return parsed + } + } catch (e) { + // 解析失败,继续处理为普通字符串 + } + } + // 旧格式 'all' 转为空数组 + if (permissions === 'all') { + return [] + } + // 旧单个字符串转为数组 + return [permissions] + } + return [] +} + +/** + * 检查是否有访问特定服务的权限 + * @param {string|array} permissions - 权限数据 + * @param {string} service - 服务名称(claude/gemini/openai/droid) + * @returns {boolean} - 是否有权限 + */ +function hasPermission(permissions, service) { + const perms = normalizePermissions(permissions) + return perms.length === 0 || perms.includes(service) // 空数组 = 全部服务 +} + function normalizeAccountTypeKey(type) { if (!type) { return null @@ -90,7 +135,7 @@ class ApiKeyService { azureOpenaiAccountId = null, bedrockAccountId = null, // 添加 Bedrock 账号ID支持 droidAccountId = null, - permissions = 'all', // 可选值:'claude'、'gemini'、'openai'、'droid' 或 'all',聚合Key为数组 + permissions = [], // 数组格式,空数组表示全部服务,如 ['claude', 'gemini'] isActive = true, concurrencyLimit = 0, rateLimitWindow = null, @@ -108,12 +153,7 @@ class ApiKeyService { activationUnit = 'days', // 新增:激活时间单位 'hours' 或 'days' expirationMode = 'fixed', // 新增:过期模式 'fixed'(固定时间) 或 'activation'(首次使用后激活) icon = '', // 新增:图标(base64编码) - // 聚合 Key 相关字段 - isAggregated = false, // 是否为聚合 Key - quotaLimit = 0, // CC 额度上限(聚合 Key 使用) - quotaUsed = 0, // 已消耗 CC 额度 - serviceQuotaLimits = {}, // 分服务额度限制 { claude: 50, codex: 30 } - serviceQuotaUsed = {} // 分服务已消耗额度 + serviceRates = {} // API Key 级别服务倍率覆盖 } = options // 生成简单的API Key (64字符十六进制) @@ -121,15 +161,8 @@ class ApiKeyService { const keyId = uuidv4() const hashedKey = this._hashApiKey(apiKey) - // 处理 permissions:聚合 Key 使用数组,传统 Key 使用字符串 + // 处理 permissions let permissionsValue = permissions - if (isAggregated && !Array.isArray(permissions)) { - // 聚合 Key 但 permissions 不是数组,转换为数组 - permissionsValue = - permissions === 'all' - ? ['claude', 'codex', 'gemini', 'droid', 'bedrock', 'azure', 'ccr'] - : [permissions] - } const keyData = { id: keyId, @@ -149,9 +182,7 @@ class ApiKeyService { azureOpenaiAccountId: azureOpenaiAccountId || '', bedrockAccountId: bedrockAccountId || '', // 添加 Bedrock 账号ID droidAccountId: droidAccountId || '', - permissions: Array.isArray(permissionsValue) - ? JSON.stringify(permissionsValue) - : permissionsValue || 'all', + permissions: JSON.stringify(normalizePermissions(permissions)), enableModelRestriction: String(enableModelRestriction), restrictedModels: JSON.stringify(restrictedModels || []), enableClientRestriction: String(enableClientRestriction || false), @@ -172,12 +203,7 @@ class ApiKeyService { userId: options.userId || '', userUsername: options.userUsername || '', icon: icon || '', // 新增:图标(base64编码) - // 聚合 Key 相关字段 - isAggregated: String(isAggregated), - quotaLimit: String(quotaLimit || 0), - quotaUsed: String(quotaUsed || 0), - serviceQuotaLimits: JSON.stringify(serviceQuotaLimits || {}), - serviceQuotaUsed: JSON.stringify(serviceQuotaUsed || {}) + serviceRates: JSON.stringify(serviceRates || {}) // API Key 级别服务倍率 } // 保存API Key数据并建立哈希映射 @@ -207,9 +233,7 @@ class ApiKeyService { logger.warn(`Failed to add key ${keyId} to API Key index:`, err.message) } - logger.success( - `🔑 Generated new API key: ${name} (${keyId})${isAggregated ? ' [Aggregated]' : ''}` - ) + logger.success(`🔑 Generated new API key: ${name} (${keyId})`) // 解析 permissions 用于返回 let parsedPermissions = keyData.permissions @@ -237,7 +261,7 @@ class ApiKeyService { azureOpenaiAccountId: keyData.azureOpenaiAccountId, bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID droidAccountId: keyData.droidAccountId, - permissions: parsedPermissions, + permissions: normalizePermissions(keyData.permissions), enableModelRestriction: keyData.enableModelRestriction === 'true', restrictedModels: JSON.parse(keyData.restrictedModels), enableClientRestriction: keyData.enableClientRestriction === 'true', @@ -254,12 +278,7 @@ class ApiKeyService { createdAt: keyData.createdAt, expiresAt: keyData.expiresAt, createdBy: keyData.createdBy, - // 聚合 Key 相关字段 - isAggregated: keyData.isAggregated === 'true', - quotaLimit: parseFloat(keyData.quotaLimit || 0), - quotaUsed: parseFloat(keyData.quotaUsed || 0), - serviceQuotaLimits: JSON.parse(keyData.serviceQuotaLimits || '{}'), - serviceQuotaUsed: JSON.parse(keyData.serviceQuotaUsed || '{}') + serviceRates: JSON.parse(keyData.serviceRates || '{}') // API Key 级别服务倍率 } } @@ -399,14 +418,10 @@ class ApiKeyService { // 不是 JSON,保持原值 } - // 解析聚合 Key 相关字段 - let serviceQuotaLimits = {} - let serviceQuotaUsed = {} + // 解析 serviceRates + let serviceRates = {} try { - serviceQuotaLimits = keyData.serviceQuotaLimits - ? JSON.parse(keyData.serviceQuotaLimits) - : {} - serviceQuotaUsed = keyData.serviceQuotaUsed ? JSON.parse(keyData.serviceQuotaUsed) : {} + serviceRates = keyData.serviceRates ? JSON.parse(keyData.serviceRates) : {} } catch (e) { // 解析失败使用默认值 } @@ -426,7 +441,7 @@ class ApiKeyService { azureOpenaiAccountId: keyData.azureOpenaiAccountId, bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID droidAccountId: keyData.droidAccountId, - permissions, + permissions: normalizePermissions(keyData.permissions), tokenLimit: parseInt(keyData.tokenLimit), concurrencyLimit: parseInt(keyData.concurrencyLimit || 0), rateLimitWindow: parseInt(keyData.rateLimitWindow || 0), @@ -443,12 +458,7 @@ class ApiKeyService { totalCost: costData.totalCost || 0, weeklyOpusCost: costData.weeklyOpusCost || 0, tags, - // 聚合 Key 相关字段 - isAggregated: keyData.isAggregated === 'true', - quotaLimit: parseFloat(keyData.quotaLimit || 0), - quotaUsed: parseFloat(keyData.quotaUsed || 0), - serviceQuotaLimits, - serviceQuotaUsed + serviceRates } } } catch (error) { @@ -560,7 +570,7 @@ class ApiKeyService { azureOpenaiAccountId: keyData.azureOpenaiAccountId, bedrockAccountId: keyData.bedrockAccountId, droidAccountId: keyData.droidAccountId, - permissions: keyData.permissions || 'all', + permissions: normalizePermissions(keyData.permissions), tokenLimit: parseInt(keyData.tokenLimit), concurrencyLimit: parseInt(keyData.concurrencyLimit || 0), rateLimitWindow: parseInt(keyData.rateLimitWindow || 0), @@ -586,9 +596,154 @@ class ApiKeyService { } } - // 🏷️ 获取所有标签(轻量级,使用 SCAN + Pipeline) + // 🏷️ 获取所有标签(合并索引和全局集合) async getAllTags() { - return await redis.scanAllApiKeyTags() + const indexTags = await redis.scanAllApiKeyTags() + const globalTags = await redis.getGlobalTags() + // 过滤空值和空格 + return [ + ...new Set( + [...indexTags, ...globalTags].map((t) => (t ? t.trim() : '')).filter((t) => t) + ) + ].sort() + } + + // 🏷️ 创建新标签 + async createTag(tagName) { + const existingTags = await this.getAllTags() + if (existingTags.includes(tagName)) { + return { success: false, error: '标签已存在' } + } + await redis.addTag(tagName) + return { success: true } + } + + // 🏷️ 获取标签详情(含使用数量) + async getTagsWithCount() { + const apiKeys = await redis.getAllApiKeys() + const tagCounts = new Map() + + // 统计 API Key 上的标签(trim 后统计) + for (const key of apiKeys) { + if (key.isDeleted === 'true') continue + let tags = [] + try { + const parsed = key.tags ? JSON.parse(key.tags) : [] + tags = Array.isArray(parsed) ? parsed : [] + } catch { + tags = [] + } + for (const tag of tags) { + if (typeof tag === 'string') { + const trimmed = tag.trim() + if (trimmed) { + tagCounts.set(trimmed, (tagCounts.get(trimmed) || 0) + 1) + } + } + } + } + + // 直接获取全局标签集合(避免重复扫描) + const globalTags = await redis.getGlobalTags() + for (const tag of globalTags) { + const trimmed = tag ? tag.trim() : '' + if (trimmed && !tagCounts.has(trimmed)) { + tagCounts.set(trimmed, 0) + } + } + + return Array.from(tagCounts.entries()) + .map(([name, count]) => ({ name, count })) + .sort((a, b) => b.count - a.count) + } + + // 🏷️ 从所有 API Key 中移除指定标签 + async removeTagFromAllKeys(tagName) { + const normalizedName = (tagName || '').trim() + if (!normalizedName) { + return { affectedCount: 0 } + } + + const apiKeys = await redis.getAllApiKeys() + let affectedCount = 0 + + for (const key of apiKeys) { + if (key.isDeleted === 'true') continue + let tags = [] + try { + const parsed = key.tags ? JSON.parse(key.tags) : [] + tags = Array.isArray(parsed) ? parsed : [] + } catch { + tags = [] + } + + // 匹配时 trim 比较,过滤非字符串 + const strTags = tags.filter((t) => typeof t === 'string') + if (strTags.some((t) => t.trim() === normalizedName)) { + const newTags = strTags.filter((t) => t.trim() !== normalizedName) + await this.updateApiKey(key.id, { tags: newTags }) + affectedCount++ + } + } + + // 同时从全局标签集合删除 + await redis.removeTag(normalizedName) + await redis.removeTag(tagName) // 也删除原始值(可能带空格) + + return { affectedCount } + } + + // 🏷️ 重命名标签 + async renameTag(oldName, newName) { + if (!newName || !newName.trim()) { + return { affectedCount: 0, error: '新标签名不能为空' } + } + + const normalizedOld = (oldName || '').trim() + const normalizedNew = newName.trim() + + if (!normalizedOld) { + return { affectedCount: 0, error: '旧标签名不能为空' } + } + + const apiKeys = await redis.getAllApiKeys() + let affectedCount = 0 + let foundInKeys = false + + for (const key of apiKeys) { + if (key.isDeleted === 'true') continue + let tags = [] + try { + const parsed = key.tags ? JSON.parse(key.tags) : [] + tags = Array.isArray(parsed) ? parsed : [] + } catch { + tags = [] + } + + // 匹配时 trim 比较,过滤非字符串 + const strTags = tags.filter((t) => typeof t === 'string') + if (strTags.some((t) => t.trim() === normalizedOld)) { + foundInKeys = true + const newTags = [...new Set(strTags.map((t) => (t.trim() === normalizedOld ? normalizedNew : t)))] + await this.updateApiKey(key.id, { tags: newTags }) + affectedCount++ + } + } + + // 检查全局集合是否有该标签 + const globalTags = await redis.getGlobalTags() + const foundInGlobal = globalTags.some((t) => typeof t === 'string' && t.trim() === normalizedOld) + + if (!foundInKeys && !foundInGlobal) { + return { affectedCount: 0, error: '标签不存在' } + } + + // 同时更新全局标签集合(删旧加新) + await redis.removeTag(normalizedOld) + await redis.removeTag(oldName) // 也删除原始值 + await redis.addTag(normalizedNew) + + return { affectedCount } } // 📋 获取所有API Keys @@ -623,7 +778,7 @@ class ApiKeyService { key.isActive = key.isActive === 'true' key.enableModelRestriction = key.enableModelRestriction === 'true' key.enableClientRestriction = key.enableClientRestriction === 'true' - key.permissions = key.permissions || 'all' // 兼容旧数据 + key.permissions = normalizePermissions(key.permissions) key.dailyCostLimit = parseFloat(key.dailyCostLimit || 0) key.totalCostLimit = parseFloat(key.totalCostLimit || 0) key.weeklyOpusCostLimit = parseFloat(key.weeklyOpusCostLimit || 0) @@ -1060,12 +1215,7 @@ class ApiKeyService { 'userId', // 新增:用户ID(所有者变更) 'userUsername', // 新增:用户名(所有者变更) 'createdBy', // 新增:创建者(所有者变更) - // 聚合 Key 相关字段 - 'isAggregated', - 'quotaLimit', - 'quotaUsed', - 'serviceQuotaLimits', - 'serviceQuotaUsed' + 'serviceRates' // API Key 级别服务倍率 ] const updatedData = { ...keyData } @@ -1075,21 +1225,17 @@ class ApiKeyService { field === 'restrictedModels' || field === 'allowedClients' || field === 'tags' || - field === 'serviceQuotaLimits' || - field === 'serviceQuotaUsed' + field === 'serviceRates' ) { // 特殊处理数组/对象字段 - updatedData[field] = JSON.stringify( - value || (field === 'serviceQuotaLimits' || field === 'serviceQuotaUsed' ? {} : []) - ) + updatedData[field] = JSON.stringify(value || (field === 'serviceRates' ? {} : [])) } else if (field === 'permissions') { - // permissions 可能是数组(聚合 Key)或字符串(传统 Key) + // permissions 可能是数组或字符串 updatedData[field] = Array.isArray(value) ? JSON.stringify(value) : value || 'all' } else if ( field === 'enableModelRestriction' || field === 'enableClientRestriction' || - field === 'isActivated' || - field === 'isAggregated' + field === 'isActivated' ) { // 布尔值转字符串 updatedData[field] = String(value) @@ -1358,7 +1504,7 @@ class ApiKeyService { } } - // 📊 记录使用情况(支持缓存token和账户级别统计) + // 📊 记录使用情况(支持缓存token和账户级别统计,应用服务倍率) async recordUsage( keyId, inputTokens = 0, @@ -1366,7 +1512,8 @@ class ApiKeyService { cacheCreateTokens = 0, cacheReadTokens = 0, model = 'unknown', - accountId = null + accountId = null, + accountType = null ) { try { const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens @@ -1404,12 +1551,21 @@ class ApiKeyService { isLongContextRequest ) - // 记录费用统计 - if (costInfo.costs.total > 0) { - await redis.incrementDailyCost(keyId, costInfo.costs.total) + // 记录费用统计(应用服务倍率) + const realCost = costInfo.costs.total + let ratedCost = realCost + if (realCost > 0) { + const serviceRatesService = require('./serviceRatesService') + const service = serviceRatesService.getService(accountType, model) + ratedCost = await this.calculateRatedCost(keyId, service, realCost) + + await redis.incrementDailyCost(keyId, ratedCost, realCost) logger.database( - `💰 Recorded cost for ${keyId}: $${costInfo.costs.total.toFixed(6)}, model: ${model}` + `💰 Recorded cost for ${keyId}: rated=$${ratedCost.toFixed(6)}, real=$${realCost.toFixed(6)}, model: ${model}, service: ${service}` ) + + // 记录 Opus 周费用(如果适用) + await this.recordOpusCost(keyId, ratedCost, realCost, model, accountType) } else { logger.debug(`💰 No cost recorded for ${keyId} - zero cost for model: ${model}`) } @@ -1452,19 +1608,20 @@ class ApiKeyService { } } - // 记录单次请求的使用详情 - const usageCost = costInfo && costInfo.costs ? costInfo.costs.total || 0 : 0 + // 记录单次请求的使用详情(同时保存真实成本和倍率成本) await redis.addUsageRecord(keyId, { timestamp: new Date().toISOString(), model, accountId: accountId || null, + accountType: accountType || null, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, totalTokens, - cost: Number(usageCost.toFixed(6)), - costBreakdown: costInfo && costInfo.costs ? costInfo.costs : undefined + cost: Number(ratedCost.toFixed(6)), + realCost: Number(realCost.toFixed(6)), + realCostBreakdown: costInfo && costInfo.costs ? costInfo.costs : undefined }) const logParts = [`Model: ${model}`, `Input: ${inputTokens}`, `Output: ${outputTokens}`] @@ -1483,28 +1640,26 @@ class ApiKeyService { } // 📊 记录 Opus 模型费用(仅限 claude 和 claude-console 账户) - async recordOpusCost(keyId, cost, model, accountType) { + // ratedCost: 倍率后的成本(用于限额校验) + // realCost: 真实成本(用于对账),如果不传则等于 ratedCost + async recordOpusCost(keyId, ratedCost, realCost, model, accountType) { try { // 判断是否为 Opus 模型 if (!model || !model.toLowerCase().includes('claude-opus')) { return // 不是 Opus 模型,直接返回 } - // 判断是否为 claude、claude-console 或 ccr 账户 - if ( - !accountType || - (accountType !== 'claude' && accountType !== 'claude-console' && accountType !== 'ccr') - ) { + // 判断是否为 claude-official、claude-console 或 ccr 账户 + const opusAccountTypes = ['claude-official', 'claude-console', 'ccr'] + if (!accountType || !opusAccountTypes.includes(accountType)) { logger.debug(`⚠️ Skipping Opus cost recording for non-Claude account type: ${accountType}`) return // 不是 claude 账户,直接返回 } - // 记录 Opus 周费用 - await redis.incrementWeeklyOpusCost(keyId, cost) + // 记录 Opus 周费用(倍率成本和真实成本) + await redis.incrementWeeklyOpusCost(keyId, ratedCost, realCost) logger.database( - `💰 Recorded Opus weekly cost for ${keyId}: $${cost.toFixed( - 6 - )}, model: ${model}, account type: ${accountType}` + `💰 Recorded Opus weekly cost for ${keyId}: rated=$${ratedCost.toFixed(6)}, real=$${realCost.toFixed(6)}, model: ${model}` ) } catch (error) { logger.error('❌ Failed to record Opus cost:', error) @@ -1603,15 +1758,22 @@ class ApiKeyService { costInfo.isLongContextRequest || false // 传递 1M 上下文请求标记 ) - // 记录费用统计 - if (costInfo.totalCost > 0) { - await redis.incrementDailyCost(keyId, costInfo.totalCost) + // 记录费用统计(应用服务倍率) + const realCostWithDetails = costInfo.totalCost || 0 + let ratedCostWithDetails = realCostWithDetails + if (realCostWithDetails > 0) { + const serviceRatesService = require('./serviceRatesService') + const service = serviceRatesService.getService(accountType, model) + ratedCostWithDetails = await this.calculateRatedCost(keyId, service, realCostWithDetails) + + // 记录倍率成本和真实成本 + await redis.incrementDailyCost(keyId, ratedCostWithDetails, realCostWithDetails) logger.database( - `💰 Recorded cost for ${keyId}: $${costInfo.totalCost.toFixed(6)}, model: ${model}` + `💰 Recorded cost for ${keyId}: rated=$${ratedCostWithDetails.toFixed(6)}, real=$${realCostWithDetails.toFixed(6)}, model: ${model}, service: ${service}` ) - // 记录 Opus 周费用(如果适用) - await this.recordOpusCost(keyId, costInfo.totalCost, model, accountType) + // 记录 Opus 周费用(如果适用,也应用倍率) + await this.recordOpusCost(keyId, ratedCostWithDetails, realCostWithDetails, model, accountType) // 记录详细的缓存费用(如果有) if (costInfo.ephemeral5mCost > 0 || costInfo.ephemeral1hCost > 0) { @@ -1683,8 +1845,9 @@ class ApiKeyService { ephemeral5mTokens, ephemeral1hTokens, totalTokens, - cost: Number((costInfo.totalCost || 0).toFixed(6)), - costBreakdown: { + cost: Number(ratedCostWithDetails.toFixed(6)), + realCost: Number(realCostWithDetails.toFixed(6)), + realCostBreakdown: { input: costInfo.inputCost || 0, output: costInfo.outputCost || 0, cacheCreate: costInfo.cacheCreateCost || 0, @@ -1921,9 +2084,19 @@ class ApiKeyService { const recordLimit = optionObject.recordLimit || 20 const recentRecords = await redis.getUsageRecords(keyId, recordLimit) + // API 兼容:同时输出 costBreakdown 和 realCostBreakdown + const compatibleRecords = recentRecords.map((record) => { + const breakdown = record.realCostBreakdown || record.costBreakdown + return { + ...record, + costBreakdown: breakdown, + realCostBreakdown: breakdown + } + }) + return { ...usageStats, - recentRecords + recentRecords: compatibleRecords } } @@ -2022,7 +2195,7 @@ class ApiKeyService { userId: keyData.userId, userUsername: keyData.userUsername, createdBy: keyData.createdBy, - permissions: keyData.permissions, + permissions: normalizePermissions(keyData.permissions), dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0), totalCostLimit: parseFloat(keyData.totalCostLimit || 0), // 所有平台账户绑定字段 @@ -2268,320 +2441,97 @@ class ApiKeyService { } // ═══════════════════════════════════════════════════════════════════════════ - // 聚合 Key 相关方法 + // 服务倍率和费用限制相关方法 // ═══════════════════════════════════════════════════════════════════════════ /** - * 判断是否为聚合 Key - */ - isAggregatedKey(keyData) { - return keyData && keyData.isAggregated === 'true' - } - - /** - * 获取转换为聚合 Key 的预览信息 + * 计算应用倍率后的费用 + * 公式:消费计费 = 真实消费 × 全局倍率 × Key 倍率 * @param {string} keyId - API Key ID - * @returns {Object} 转换预览信息 - */ - async getConvertToAggregatedPreview(keyId) { - try { - const keyData = await redis.getApiKey(keyId) - if (!keyData || Object.keys(keyData).length === 0) { - throw new Error('API key not found') - } - - if (keyData.isAggregated === 'true') { - throw new Error('API key is already aggregated') - } - - // 获取当前服务倍率 - const ratesConfig = await serviceRatesService.getRates() - - // 确定原服务类型 - let originalService = keyData.permissions || 'all' - try { - const parsed = JSON.parse(originalService) - if (Array.isArray(parsed)) { - originalService = parsed[0] || 'claude' - } - } catch (e) { - // 不是 JSON,保持原值 - } - - // 映射 permissions 到服务类型 - const serviceMap = { - all: 'claude', - claude: 'claude', - gemini: 'gemini', - openai: 'codex', - droid: 'droid', - bedrock: 'bedrock', - azure: 'azure', - ccr: 'ccr' - } - const mappedService = serviceMap[originalService] || 'claude' - const serviceRate = ratesConfig.rates[mappedService] || 1.0 - - // 获取已消耗的真实成本 - const costStats = await redis.getCostStats(keyId) - const totalRealCost = costStats?.total || 0 - - // 获取原限额 - const originalLimit = parseFloat(keyData.totalCostLimit || 0) - - // 计算建议的 CC 额度 - const suggestedQuotaLimit = originalLimit * serviceRate - const suggestedQuotaUsed = totalRealCost * serviceRate - - // 获取可用服务列表 - const availableServices = Object.keys(ratesConfig.rates) - - return { - keyId, - keyName: keyData.name, - originalService: mappedService, - originalPermissions: originalService, - originalLimit, - originalUsed: totalRealCost, - serviceRate, - suggestedQuotaLimit: Math.round(suggestedQuotaLimit * 1000) / 1000, - suggestedQuotaUsed: Math.round(suggestedQuotaUsed * 1000) / 1000, - availableServices, - ratesConfig: ratesConfig.rates - } - } catch (error) { - logger.error('❌ Failed to get convert preview:', error) - throw error - } - } - - /** - * 将传统 Key 转换为聚合 Key - * @param {string} keyId - API Key ID - * @param {Object} options - 转换选项 - * @param {number} options.quotaLimit - CC 额度上限 - * @param {Array} options.permissions - 允许的服务列表 - * @param {Object} options.serviceQuotaLimits - 分服务额度限制(可选) - * @returns {Object} 转换结果 - */ - async convertToAggregated(keyId, options = {}) { - try { - const keyData = await redis.getApiKey(keyId) - if (!keyData || Object.keys(keyData).length === 0) { - throw new Error('API key not found') - } - - if (keyData.isAggregated === 'true') { - throw new Error('API key is already aggregated') - } - - const { - quotaLimit, - permissions, - serviceQuotaLimits = {}, - quotaUsed = null // 如果不传,自动计算 - } = options - - if (quotaLimit === undefined || quotaLimit === null) { - throw new Error('quotaLimit is required') - } - - if (!permissions || !Array.isArray(permissions) || permissions.length === 0) { - throw new Error('permissions must be a non-empty array') - } - - // 计算已消耗的 CC 额度 - let calculatedQuotaUsed = quotaUsed - if (calculatedQuotaUsed === null) { - // 自动计算:获取原服务的倍率,按倍率换算已消耗成本 - const preview = await this.getConvertToAggregatedPreview(keyId) - calculatedQuotaUsed = preview.suggestedQuotaUsed - } - - // 更新 Key 数据 - const updates = { - isAggregated: true, - quotaLimit, - quotaUsed: calculatedQuotaUsed, - permissions, - serviceQuotaLimits, - serviceQuotaUsed: {} // 转换时重置分服务统计 - } - - await this.updateApiKey(keyId, updates) - - logger.success(`🔄 Converted API key ${keyId} to aggregated key with ${quotaLimit} CC quota`) - - return { - success: true, - keyId, - quotaLimit, - quotaUsed: calculatedQuotaUsed, - permissions - } - } catch (error) { - logger.error('❌ Failed to convert to aggregated key:', error) - throw error - } - } - - /** - * 快速检查聚合 Key 额度(用于请求前检查) - * @param {string} keyId - API Key ID - * @returns {Object} { allowed: boolean, reason?: string, quotaRemaining?: number } - */ - async quickQuotaCheck(keyId) { - try { - const [quotaUsed, quotaLimit, isAggregated, isActive] = await redis.client.hmget( - `api_key:${keyId}`, - 'quotaUsed', - 'quotaLimit', - 'isAggregated', - 'isActive' - ) - - // 非聚合 Key 不检查额度 - if (isAggregated !== 'true') { - return { allowed: true, isAggregated: false } - } - - if (isActive !== 'true') { - return { allowed: false, reason: 'inactive', isAggregated: true } - } - - const used = parseFloat(quotaUsed || 0) - const limit = parseFloat(quotaLimit || 0) - - // 额度为 0 表示不限制 - if (limit === 0) { - return { allowed: true, isAggregated: true, quotaRemaining: Infinity } - } - - if (used >= limit) { - return { - allowed: false, - reason: 'quota_exceeded', - isAggregated: true, - quotaUsed: used, - quotaLimit: limit - } - } - - return { allowed: true, isAggregated: true, quotaRemaining: limit - used } - } catch (error) { - logger.error('❌ Quick quota check failed:', error) - // 出错时允许通过,避免阻塞请求 - return { allowed: true, error: error.message } - } - } - - /** - * 异步扣减聚合 Key 额度(请求完成后调用) - * @param {string} keyId - API Key ID - * @param {number} costUSD - 真实成本(USD) * @param {string} service - 服务类型 - * @returns {Promise} + * @param {number} realCost - 真实成本(USD) + * @returns {Promise} 应用倍率后的费用 */ - async deductQuotaAsync(keyId, costUSD, service) { - // 使用 setImmediate 确保不阻塞响应 - setImmediate(async () => { + async calculateRatedCost(keyId, service, realCost) { + try { + // 获取全局倍率 + const globalRate = await serviceRatesService.getServiceRate(service) + + // 获取 Key 倍率 + const keyData = await redis.getApiKey(keyId) + let keyRates = {} try { - // 获取服务倍率 - const rate = await serviceRatesService.getServiceRate(service) - const quotaToDeduct = costUSD * rate - - // 原子更新总额度 - await redis.client.hincrbyfloat(`api_key:${keyId}`, 'quotaUsed', quotaToDeduct) - - // 更新分服务额度 - const serviceQuotaUsedStr = await redis.client.hget(`api_key:${keyId}`, 'serviceQuotaUsed') - let serviceQuotaUsed = {} - try { - serviceQuotaUsed = JSON.parse(serviceQuotaUsedStr || '{}') - } catch (e) { - serviceQuotaUsed = {} - } - serviceQuotaUsed[service] = (serviceQuotaUsed[service] || 0) + quotaToDeduct - await redis.client.hset( - `api_key:${keyId}`, - 'serviceQuotaUsed', - JSON.stringify(serviceQuotaUsed) - ) - - logger.debug( - `📊 Deducted ${quotaToDeduct.toFixed(4)} CC quota from key ${keyId} (service: ${service}, rate: ${rate})` - ) - } catch (error) { - logger.error(`❌ Failed to deduct quota for key ${keyId}:`, error) + keyRates = JSON.parse(keyData?.serviceRates || '{}') + } catch (e) { + keyRates = {} } - }) + const keyRate = keyRates[service] ?? 1.0 + + // 相乘计算 + return realCost * globalRate * keyRate + } catch (error) { + logger.error('❌ Failed to calculate rated cost:', error) + // 出错时返回原始费用 + return realCost + } } /** - * 增加聚合 Key 额度(用于核销额度卡) + * 增加 API Key 费用限制(用于核销额度卡) * @param {string} keyId - API Key ID - * @param {number} quotaAmount - 要增加的 CC 额度 - * @returns {Promise} { success: boolean, newQuotaLimit: number } + * @param {number} amount - 要增加的金额(USD) + * @returns {Promise} { success: boolean, newTotalCostLimit: number } */ - async addQuota(keyId, quotaAmount) { + async addTotalCostLimit(keyId, amount) { try { const keyData = await redis.getApiKey(keyId) if (!keyData || Object.keys(keyData).length === 0) { throw new Error('API key not found') } - if (keyData.isAggregated !== 'true') { - throw new Error('Only aggregated keys can add quota') - } + const currentLimit = parseFloat(keyData.totalCostLimit || 0) + const newLimit = currentLimit + amount - const currentLimit = parseFloat(keyData.quotaLimit || 0) - const newLimit = currentLimit + quotaAmount + await redis.client.hset(`apikey:${keyId}`, 'totalCostLimit', String(newLimit)) - await redis.client.hset(`api_key:${keyId}`, 'quotaLimit', String(newLimit)) + logger.success(`💰 Added $${amount} to key ${keyId}, new limit: $${newLimit}`) - logger.success(`💰 Added ${quotaAmount} CC quota to key ${keyId}, new limit: ${newLimit}`) - - return { success: true, previousLimit: currentLimit, newQuotaLimit: newLimit } + return { success: true, previousLimit: currentLimit, newTotalCostLimit: newLimit } } catch (error) { - logger.error('❌ Failed to add quota:', error) + logger.error('❌ Failed to add total cost limit:', error) throw error } } /** - * 减少聚合 Key 额度(用于撤销核销) + * 减少 API Key 费用限制(用于撤销核销) * @param {string} keyId - API Key ID - * @param {number} quotaAmount - 要减少的 CC 额度 - * @returns {Promise} { success: boolean, newQuotaLimit: number, actualDeducted: number } + * @param {number} amount - 要减少的金额(USD) + * @returns {Promise} { success: boolean, newTotalCostLimit: number, actualDeducted: number } */ - async deductQuotaLimit(keyId, quotaAmount) { + async deductTotalCostLimit(keyId, amount) { try { const keyData = await redis.getApiKey(keyId) if (!keyData || Object.keys(keyData).length === 0) { throw new Error('API key not found') } - if (keyData.isAggregated !== 'true') { - throw new Error('Only aggregated keys can deduct quota') - } - - const currentLimit = parseFloat(keyData.quotaLimit || 0) - const currentUsed = parseFloat(keyData.quotaUsed || 0) + const currentLimit = parseFloat(keyData.totalCostLimit || 0) + const costStats = await redis.getCostStats(keyId) + const currentUsed = costStats?.total || 0 // 不能扣到比已使用的还少 const minLimit = currentUsed - const actualDeducted = Math.min(quotaAmount, currentLimit - minLimit) - const newLimit = Math.max(currentLimit - quotaAmount, minLimit) + const actualDeducted = Math.min(amount, currentLimit - minLimit) + const newLimit = Math.max(currentLimit - amount, minLimit) - await redis.client.hset(`api_key:${keyId}`, 'quotaLimit', String(newLimit)) + await redis.client.hset(`apikey:${keyId}`, 'totalCostLimit', String(newLimit)) - logger.success( - `💸 Deducted ${actualDeducted} CC quota from key ${keyId}, new limit: ${newLimit}` - ) + logger.success(`💸 Deducted $${actualDeducted} from key ${keyId}, new limit: $${newLimit}`) - return { success: true, previousLimit: currentLimit, newQuotaLimit: newLimit, actualDeducted } + return { success: true, previousLimit: currentLimit, newTotalCostLimit: newLimit, actualDeducted } } catch (error) { - logger.error('❌ Failed to deduct quota limit:', error) + logger.error('❌ Failed to deduct total cost limit:', error) throw error } } @@ -2643,4 +2593,8 @@ const apiKeyService = new ApiKeyService() // 为了方便其他服务调用,导出 recordUsage 方法 apiKeyService.recordUsageMetrics = apiKeyService.recordUsage.bind(apiKeyService) +// 导出权限辅助函数供路由使用 +apiKeyService.hasPermission = hasPermission +apiKeyService.normalizePermissions = normalizePermissions + module.exports = apiKeyService diff --git a/src/services/balanceProviders/baseBalanceProvider.js b/src/services/balanceProviders/baseBalanceProvider.js new file mode 100644 index 00000000..ececd2e5 --- /dev/null +++ b/src/services/balanceProviders/baseBalanceProvider.js @@ -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} + * 形如: + * { + * 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 diff --git a/src/services/balanceProviders/claudeBalanceProvider.js b/src/services/balanceProviders/claudeBalanceProvider.js new file mode 100644 index 00000000..89783028 --- /dev/null +++ b/src/services/balanceProviders/claudeBalanceProvider.js @@ -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 diff --git a/src/services/balanceProviders/claudeConsoleBalanceProvider.js b/src/services/balanceProviders/claudeConsoleBalanceProvider.js new file mode 100644 index 00000000..f5441047 --- /dev/null +++ b/src/services/balanceProviders/claudeConsoleBalanceProvider.js @@ -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 diff --git a/src/services/balanceProviders/geminiBalanceProvider.js b/src/services/balanceProviders/geminiBalanceProvider.js new file mode 100644 index 00000000..0f7fb783 --- /dev/null +++ b/src/services/balanceProviders/geminiBalanceProvider.js @@ -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 diff --git a/src/services/balanceProviders/genericBalanceProvider.js b/src/services/balanceProviders/genericBalanceProvider.js new file mode 100644 index 00000000..6b3efe2b --- /dev/null +++ b/src/services/balanceProviders/genericBalanceProvider.js @@ -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 diff --git a/src/services/balanceProviders/index.js b/src/services/balanceProviders/index.js new file mode 100644 index 00000000..47806f1d --- /dev/null +++ b/src/services/balanceProviders/index.js @@ -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 } diff --git a/src/services/balanceProviders/openaiResponsesBalanceProvider.js b/src/services/balanceProviders/openaiResponsesBalanceProvider.js new file mode 100644 index 00000000..9ff8433e --- /dev/null +++ b/src/services/balanceProviders/openaiResponsesBalanceProvider.js @@ -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 diff --git a/src/services/balanceScriptService.js b/src/services/balanceScriptService.js new file mode 100644 index 00000000..3d348d33 --- /dev/null +++ b/src/services/balanceScriptService.js @@ -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 + * - 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() diff --git a/src/services/bedrockAccountService.js b/src/services/bedrockAccountService.js index 38c1c84a..0bb270c0 100644 --- a/src/services/bedrockAccountService.js +++ b/src/services/bedrockAccountService.js @@ -35,12 +35,13 @@ class BedrockAccountService { description = '', region = process.env.AWS_REGION || 'us-east-1', awsCredentials = null, // { accessKeyId, secretAccessKey, sessionToken } + bearerToken = null, // AWS Bearer Token for Bedrock API Keys defaultModel = 'us.anthropic.claude-sonnet-4-20250514-v1:0', isActive = true, accountType = 'shared', // 'dedicated' or 'shared' priority = 50, // 调度优先级 (1-100,数字越小优先级越高) schedulable = true, // 是否可被调度 - credentialType = 'default' // 'default', 'access_key', 'bearer_token' + credentialType = 'access_key' // 'access_key', 'bearer_token'(默认为 access_key) } = options const accountId = uuidv4() @@ -71,6 +72,11 @@ class BedrockAccountService { accountData.awsCredentials = this._encryptAwsCredentials(awsCredentials) } + // 加密存储 Bearer Token + if (bearerToken) { + accountData.bearerToken = this._encryptAwsCredentials({ token: bearerToken }) + } + const client = redis.getClientSafe() await client.set(`bedrock_account:${accountId}`, JSON.stringify(accountData)) await redis.addToIndex('bedrock_account:index', accountId) @@ -107,9 +113,85 @@ class BedrockAccountService { const account = JSON.parse(accountData) - // 解密AWS凭证用于内部使用 - if (account.awsCredentials) { - account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials) + // 根据凭证类型解密对应的凭证 + // 增强逻辑:优先按照 credentialType 解密,如果字段不存在则尝试解密实际存在的字段(兜底) + try { + let accessKeyDecrypted = false + let bearerTokenDecrypted = false + + // 第一步:按照 credentialType 尝试解密对应的凭证 + if (account.credentialType === 'access_key' && account.awsCredentials) { + // Access Key 模式:解密 AWS 凭证 + account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials) + accessKeyDecrypted = true + logger.debug( + `🔓 解密 Access Key 成功 - ID: ${accountId}, 类型: ${account.credentialType}` + ) + } else if (account.credentialType === 'bearer_token' && account.bearerToken) { + // Bearer Token 模式:解密 Bearer Token + const decrypted = this._decryptAwsCredentials(account.bearerToken) + account.bearerToken = decrypted.token + bearerTokenDecrypted = true + logger.debug( + `🔓 解密 Bearer Token 成功 - ID: ${accountId}, 类型: ${account.credentialType}` + ) + } else if (!account.credentialType || account.credentialType === 'default') { + // 向后兼容:旧版本账号可能没有 credentialType 字段,尝试解密所有存在的凭证 + if (account.awsCredentials) { + account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials) + accessKeyDecrypted = true + } + if (account.bearerToken) { + const decrypted = this._decryptAwsCredentials(account.bearerToken) + account.bearerToken = decrypted.token + bearerTokenDecrypted = true + } + logger.debug( + `🔓 兼容模式解密 - ID: ${accountId}, Access Key: ${accessKeyDecrypted}, Bearer Token: ${bearerTokenDecrypted}` + ) + } + + // 第二步:兜底逻辑 - 如果按照 credentialType 没有解密到任何凭证,尝试解密实际存在的字段 + if (!accessKeyDecrypted && !bearerTokenDecrypted) { + logger.warn( + `⚠️ credentialType="${account.credentialType}" 与实际字段不匹配,尝试兜底解密 - ID: ${accountId}` + ) + if (account.awsCredentials) { + account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials) + accessKeyDecrypted = true + logger.warn( + `🔓 兜底解密 Access Key 成功 - ID: ${accountId}, credentialType 应为 'access_key'` + ) + } + if (account.bearerToken) { + const decrypted = this._decryptAwsCredentials(account.bearerToken) + account.bearerToken = decrypted.token + bearerTokenDecrypted = true + logger.warn( + `🔓 兜底解密 Bearer Token 成功 - ID: ${accountId}, credentialType 应为 'bearer_token'` + ) + } + } + + // 验证至少解密了一种凭证 + if (!accessKeyDecrypted && !bearerTokenDecrypted) { + logger.error( + `❌ 未找到任何凭证可解密 - ID: ${accountId}, credentialType: ${account.credentialType}, hasAwsCredentials: ${!!account.awsCredentials}, hasBearerToken: ${!!account.bearerToken}` + ) + return { + success: false, + error: 'No valid credentials found in account data' + } + } + } catch (decryptError) { + logger.error( + `❌ 解密Bedrock凭证失败 - ID: ${accountId}, 类型: ${account.credentialType}`, + decryptError + ) + return { + success: false, + error: `Credentials decryption failed: ${decryptError.message}` + } } logger.debug(`🔍 获取Bedrock账户 - ID: ${accountId}, 名称: ${account.name}`) @@ -162,7 +244,11 @@ class BedrockAccountService { updatedAt: account.updatedAt, type: 'bedrock', platform: 'bedrock', - hasCredentials: !!account.awsCredentials + // 根据凭证类型判断是否有凭证 + hasCredentials: + account.credentialType === 'bearer_token' + ? !!account.bearerToken + : !!account.awsCredentials }) } } @@ -242,6 +328,15 @@ class BedrockAccountService { logger.info(`🔐 重新加密Bedrock账户凭证 - ID: ${accountId}`) } + // 更新 Bearer Token + if (updates.bearerToken !== undefined) { + if (updates.bearerToken) { + account.bearerToken = this._encryptAwsCredentials({ token: updates.bearerToken }) + } else { + delete account.bearerToken + } + } + // ✅ 直接保存 subscriptionExpiresAt(如果提供) // Bedrock 没有 token 刷新逻辑,不会覆盖此字段 if (updates.subscriptionExpiresAt !== undefined) { @@ -353,13 +448,45 @@ class BedrockAccountService { const account = accountResult.data - logger.info(`🧪 测试Bedrock账户连接 - ID: ${accountId}, 名称: ${account.name}`) + logger.info( + `🧪 测试Bedrock账户连接 - ID: ${accountId}, 名称: ${account.name}, 凭证类型: ${account.credentialType}` + ) - // 尝试获取模型列表来测试连接 + // 验证凭证是否已解密 + const hasValidCredentials = + (account.credentialType === 'access_key' && account.awsCredentials) || + (account.credentialType === 'bearer_token' && account.bearerToken) || + (!account.credentialType && (account.awsCredentials || account.bearerToken)) + + if (!hasValidCredentials) { + logger.error( + `❌ 测试失败:账户没有有效凭证 - ID: ${accountId}, credentialType: ${account.credentialType}` + ) + return { + success: false, + error: 'No valid credentials found after decryption' + } + } + + // 尝试创建 Bedrock 客户端来验证凭证格式 + try { + bedrockRelayService._getBedrockClient(account.region, account) + logger.debug(`✅ Bedrock客户端创建成功 - ID: ${accountId}`) + } catch (clientError) { + logger.error(`❌ 创建Bedrock客户端失败 - ID: ${accountId}`, clientError) + return { + success: false, + error: `Failed to create Bedrock client: ${clientError.message}` + } + } + + // 获取可用模型列表(硬编码,但至少验证了凭证格式正确) const models = await bedrockRelayService.getAvailableModels(account) if (models && models.length > 0) { - logger.info(`✅ Bedrock账户测试成功 - ID: ${accountId}, 发现 ${models.length} 个模型`) + logger.info( + `✅ Bedrock账户测试成功 - ID: ${accountId}, 发现 ${models.length} 个模型, 凭证类型: ${account.credentialType}` + ) return { success: true, data: { @@ -384,6 +511,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 - 账户对象 diff --git a/src/services/bedrockRelayService.js b/src/services/bedrockRelayService.js index d04e42b2..0a73e115 100644 --- a/src/services/bedrockRelayService.js +++ b/src/services/bedrockRelayService.js @@ -48,13 +48,17 @@ class BedrockRelayService { secretAccessKey: bedrockAccount.awsCredentials.secretAccessKey, sessionToken: bedrockAccount.awsCredentials.sessionToken } + } else if (bedrockAccount?.bearerToken) { + // Bearer Token 模式:AWS SDK >= 3.400.0 会自动检测环境变量 + clientConfig.token = { token: bedrockAccount.bearerToken } + logger.debug(`🔑 使用 Bearer Token 认证 - 账户: ${bedrockAccount.name || 'unknown'}`) } else { // 检查是否有环境变量凭证 if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) { clientConfig.credentials = fromEnv() } else { throw new Error( - 'AWS凭证未配置。请在Bedrock账户中配置AWS访问密钥,或设置环境变量AWS_ACCESS_KEY_ID和AWS_SECRET_ACCESS_KEY' + 'AWS凭证未配置。请在Bedrock账户中配置AWS访问密钥或Bearer Token,或设置环境变量AWS_ACCESS_KEY_ID和AWS_SECRET_ACCESS_KEY' ) } } @@ -431,6 +435,18 @@ class BedrockRelayService { _mapToBedrockModel(modelName) { // 标准Claude模型名到Bedrock模型名的映射表 const modelMapping = { + // Claude 4.5 Opus + 'claude-opus-4-5': 'us.anthropic.claude-opus-4-5-20251101-v1:0', + 'claude-opus-4-5-20251101': 'us.anthropic.claude-opus-4-5-20251101-v1:0', + + // Claude 4.5 Sonnet + 'claude-sonnet-4-5': 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', + 'claude-sonnet-4-5-20250929': 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', + + // Claude 4.5 Haiku + 'claude-haiku-4-5': 'us.anthropic.claude-haiku-4-5-20251001-v1:0', + 'claude-haiku-4-5-20251001': 'us.anthropic.claude-haiku-4-5-20251001-v1:0', + // Claude Sonnet 4 'claude-sonnet-4': 'us.anthropic.claude-sonnet-4-20250514-v1:0', 'claude-sonnet-4-20250514': 'us.anthropic.claude-sonnet-4-20250514-v1:0', diff --git a/src/services/claudeRelayService.js b/src/services/claudeRelayService.js index 95da1c7a..cab37ee3 100644 --- a/src/services/claudeRelayService.js +++ b/src/services/claudeRelayService.js @@ -29,51 +29,51 @@ const safeClone = class ClaudeRelayService { constructor() { this.claudeApiUrl = 'https://api.anthropic.com/v1/messages?beta=true' + // 🧹 内存优化:用于存储请求体字符串,避免闭包捕获 + this.bodyStore = new Map() + this._bodyStoreIdCounter = 0 this.apiVersion = config.claude.apiVersion this.betaHeader = config.claude.betaHeader this.systemPrompt = config.claude.systemPrompt this.claudeCodeSystemPrompt = "You are Claude Code, Anthropic's official CLI for Claude." + this.toolNameSuffix = null + this.toolNameSuffixGeneratedAt = 0 + this.toolNameSuffixTtlMs = 60 * 60 * 1000 } // 🔧 根据模型ID和客户端传递的 anthropic-beta 获取最终的 header - // 规则: - // 1. 如果客户端传递了 anthropic-beta,检查是否包含 oauth-2025-04-20 - // 2. 如果没有 oauth-2025-04-20,则添加到 claude-code-20250219 后面(如果有的话),否则放在第一位 - // 3. 如果客户端没传递,则根据模型判断:haiku 不需要 claude-code,其他模型需要 _getBetaHeader(modelId, clientBetaHeader) { const OAUTH_BETA = 'oauth-2025-04-20' const CLAUDE_CODE_BETA = 'claude-code-20250219' + const INTERLEAVED_THINKING_BETA = 'interleaved-thinking-2025-05-14' + const TOOL_STREAMING_BETA = 'fine-grained-tool-streaming-2025-05-14' - // 如果客户端传递了 anthropic-beta - if (clientBetaHeader) { - // 检查是否已包含 oauth-2025-04-20 - if (clientBetaHeader.includes(OAUTH_BETA)) { - return clientBetaHeader - } - - // 需要添加 oauth-2025-04-20 - const parts = clientBetaHeader.split(',').map((p) => p.trim()) - - // 找到 claude-code-20250219 的位置 - const claudeCodeIndex = parts.findIndex((p) => p === CLAUDE_CODE_BETA) - - if (claudeCodeIndex !== -1) { - // 在 claude-code-20250219 后面插入 - parts.splice(claudeCodeIndex + 1, 0, OAUTH_BETA) - } else { - // 放在第一位 - parts.unshift(OAUTH_BETA) - } - - return parts.join(',') - } - - // 客户端没有传递,根据模型判断 const isHaikuModel = modelId && modelId.toLowerCase().includes('haiku') - if (isHaikuModel) { - return 'oauth-2025-04-20,interleaved-thinking-2025-05-14' + const baseBetas = isHaikuModel + ? [OAUTH_BETA, INTERLEAVED_THINKING_BETA] + : [CLAUDE_CODE_BETA, OAUTH_BETA, INTERLEAVED_THINKING_BETA, TOOL_STREAMING_BETA] + + const betaList = [] + const seen = new Set() + const addBeta = (beta) => { + if (!beta || seen.has(beta)) { + return + } + seen.add(beta) + betaList.push(beta) } - return 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14' + + baseBetas.forEach(addBeta) + + if (clientBetaHeader) { + clientBetaHeader + .split(',') + .map((p) => p.trim()) + .filter(Boolean) + .forEach(addBeta) + } + + return betaList.join(',') } _buildStandardRateLimitMessage(resetTime) { @@ -148,6 +148,235 @@ class ClaudeRelayService { return ClaudeCodeValidator.includesClaudeCodeSystemPrompt(requestBody, 1) } + _isClaudeCodeUserAgent(clientHeaders) { + const userAgent = clientHeaders?.['user-agent'] || clientHeaders?.['User-Agent'] + return typeof userAgent === 'string' && /^claude-cli\/[^\s]+\s+\(/i.test(userAgent) + } + + _isActualClaudeCodeRequest(requestBody, clientHeaders) { + return this.isRealClaudeCodeRequest(requestBody) && this._isClaudeCodeUserAgent(clientHeaders) + } + + _getHeaderValueCaseInsensitive(headers, key) { + if (!headers || typeof headers !== 'object') { + return undefined + } + const lowerKey = key.toLowerCase() + for (const candidate of Object.keys(headers)) { + if (candidate.toLowerCase() === lowerKey) { + return headers[candidate] + } + } + return undefined + } + + _isClaudeCodeCredentialError(body) { + const message = this._extractErrorMessage(body) + if (!message) { + return false + } + const lower = message.toLowerCase() + return ( + lower.includes('only authorized for use with claude code') || + lower.includes('cannot be used for other api requests') + ) + } + + _toPascalCaseToolName(name) { + const parts = name.split(/[_-]/).filter(Boolean) + if (parts.length === 0) { + return name + } + const pascal = parts + .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) + .join('') + return `${pascal}_tool` + } + + _getToolNameSuffix() { + const now = Date.now() + if (!this.toolNameSuffix || now - this.toolNameSuffixGeneratedAt > this.toolNameSuffixTtlMs) { + this.toolNameSuffix = Math.random().toString(36).substring(2, 8) + this.toolNameSuffixGeneratedAt = now + } + return this.toolNameSuffix + } + + _toRandomizedToolName(name) { + const suffix = this._getToolNameSuffix() + return `${name}_${suffix}` + } + + _transformToolNamesInRequestBody(body, options = {}) { + if (!body || typeof body !== 'object') { + return null + } + + const useRandomized = options.useRandomizedToolNames === true + const forwardMap = new Map() + const reverseMap = new Map() + + const transformName = (name) => { + if (typeof name !== 'string' || name.length === 0) { + return name + } + if (forwardMap.has(name)) { + return forwardMap.get(name) + } + const transformed = useRandomized + ? this._toRandomizedToolName(name) + : this._toPascalCaseToolName(name) + if (transformed !== name) { + forwardMap.set(name, transformed) + reverseMap.set(transformed, name) + } + return transformed + } + + if (Array.isArray(body.tools)) { + body.tools.forEach((tool) => { + if (tool && typeof tool.name === 'string') { + tool.name = transformName(tool.name) + } + }) + } + + if (body.tool_choice && typeof body.tool_choice === 'object') { + if (typeof body.tool_choice.name === 'string') { + body.tool_choice.name = transformName(body.tool_choice.name) + } + } + + if (Array.isArray(body.messages)) { + body.messages.forEach((message) => { + const content = message?.content + if (Array.isArray(content)) { + content.forEach((block) => { + if (block?.type === 'tool_use' && typeof block.name === 'string') { + block.name = transformName(block.name) + } + }) + } + }) + } + + return reverseMap.size > 0 ? reverseMap : null + } + + _restoreToolName(name, toolNameMap) { + if (!toolNameMap || toolNameMap.size === 0) { + return name + } + return toolNameMap.get(name) || name + } + + _restoreToolNamesInContentBlocks(content, toolNameMap) { + if (!Array.isArray(content)) { + return + } + + content.forEach((block) => { + if (block?.type === 'tool_use' && typeof block.name === 'string') { + block.name = this._restoreToolName(block.name, toolNameMap) + } + }) + } + + _restoreToolNamesInResponseObject(responseBody, toolNameMap) { + if (!responseBody || typeof responseBody !== 'object') { + return + } + + if (Array.isArray(responseBody.content)) { + this._restoreToolNamesInContentBlocks(responseBody.content, toolNameMap) + } + + if (responseBody.message && Array.isArray(responseBody.message.content)) { + this._restoreToolNamesInContentBlocks(responseBody.message.content, toolNameMap) + } + } + + _restoreToolNamesInResponseBody(responseBody, toolNameMap) { + if (!responseBody || !toolNameMap || toolNameMap.size === 0) { + return responseBody + } + + if (typeof responseBody === 'string') { + try { + const parsed = JSON.parse(responseBody) + this._restoreToolNamesInResponseObject(parsed, toolNameMap) + return JSON.stringify(parsed) + } catch (error) { + return responseBody + } + } + + if (typeof responseBody === 'object') { + this._restoreToolNamesInResponseObject(responseBody, toolNameMap) + } + + return responseBody + } + + _restoreToolNamesInStreamEvent(event, toolNameMap) { + if (!event || typeof event !== 'object') { + return + } + + if (event.content_block && event.content_block.type === 'tool_use') { + if (typeof event.content_block.name === 'string') { + event.content_block.name = this._restoreToolName(event.content_block.name, toolNameMap) + } + } + + if (event.delta && event.delta.type === 'tool_use') { + if (typeof event.delta.name === 'string') { + event.delta.name = this._restoreToolName(event.delta.name, toolNameMap) + } + } + + if (event.message && Array.isArray(event.message.content)) { + this._restoreToolNamesInContentBlocks(event.message.content, toolNameMap) + } + + if (Array.isArray(event.content)) { + this._restoreToolNamesInContentBlocks(event.content, toolNameMap) + } + } + + _createToolNameStripperStreamTransformer(streamTransformer, toolNameMap) { + if (!toolNameMap || toolNameMap.size === 0) { + return streamTransformer + } + + return (payload) => { + const transformed = streamTransformer ? streamTransformer(payload) : payload + if (!transformed || typeof transformed !== 'string') { + return transformed + } + + const lines = transformed.split('\n') + const updated = lines.map((line) => { + if (!line.startsWith('data:')) { + return line + } + const jsonStr = line.slice(5).trimStart() + if (!jsonStr || jsonStr === '[DONE]') { + return line + } + try { + const data = JSON.parse(jsonStr) + this._restoreToolNamesInStreamEvent(data, toolNameMap) + return `data: ${JSON.stringify(data)}` + } catch (error) { + return line + } + }) + + return updated.join('\n') + } + } + // 🚀 转发请求到Claude API async relayRequest( requestBody, @@ -161,6 +390,7 @@ class ClaudeRelayService { let queueLockAcquired = false let queueRequestId = null let selectedAccountId = null + let bodyStoreIdNonStream = null // 🧹 在 try 块外声明,以便 finally 清理 try { // 调试日志:查看API Key数据 @@ -319,7 +549,12 @@ class ClaudeRelayService { // 获取有效的访问token const accessToken = await claudeAccountService.getValidAccessToken(accountId) + const isRealClaudeCodeRequest = this._isActualClaudeCodeRequest(requestBody, clientHeaders) const processedBody = this._processRequestBody(requestBody, account) + // 🧹 内存优化:存储到 bodyStore,避免闭包捕获 + const originalBodyString = JSON.stringify(processedBody) + bodyStoreIdNonStream = ++this._bodyStoreIdCounter + this.bodyStore.set(bodyStoreIdNonStream, originalBodyString) // 获取代理配置 const proxyAgent = await this._getProxyAgent(accountId) @@ -340,36 +575,59 @@ class ClaudeRelayService { clientResponse.once('close', handleClientDisconnect) } - // 发送请求到Claude API(传入回调以获取请求对象) - // 🔄 403 重试机制:仅对 claude-official 类型账户(OAuth 或 Setup Token) - const maxRetries = this._shouldRetryOn403(accountType) ? 2 : 0 - let retryCount = 0 - let response - let shouldRetry = false + const makeRequestWithRetries = async (requestOptions) => { + const maxRetries = this._shouldRetryOn403(accountType) ? 2 : 0 + let retryCount = 0 + let response + let shouldRetry = false - do { - response = await this._makeClaudeRequest( - processedBody, - accessToken, - proxyAgent, - clientHeaders, - accountId, - (req) => { - upstreamRequest = req - }, - options - ) - - // 检查是否需要重试 403 - shouldRetry = response.statusCode === 403 && retryCount < maxRetries - if (shouldRetry) { - retryCount++ - logger.warn( - `🔄 403 error for account ${accountId}, retry ${retryCount}/${maxRetries} after 2s` + do { + // 🧹 每次重试从 bodyStore 解析新对象,避免闭包捕获 + let retryRequestBody + try { + retryRequestBody = JSON.parse(this.bodyStore.get(bodyStoreIdNonStream)) + } catch (parseError) { + logger.error(`❌ Failed to parse body for retry: ${parseError.message}`) + throw new Error(`Request body parse failed: ${parseError.message}`) + } + response = await this._makeClaudeRequest( + retryRequestBody, + accessToken, + proxyAgent, + clientHeaders, + accountId, + (req) => { + upstreamRequest = req + }, + { + ...requestOptions, + isRealClaudeCodeRequest + } ) - await this._sleep(2000) - } - } while (shouldRetry) + + shouldRetry = response.statusCode === 403 && retryCount < maxRetries + if (shouldRetry) { + retryCount++ + logger.warn( + `🔄 403 error for account ${accountId}, retry ${retryCount}/${maxRetries} after 2s` + ) + await this._sleep(2000) + } + } while (shouldRetry) + + return { response, retryCount } + } + + let requestOptions = options + let { response, retryCount } = await makeRequestWithRetries(requestOptions) + + if ( + this._isClaudeCodeCredentialError(response.body) && + requestOptions.useRandomizedToolNames !== true + ) { + requestOptions = { ...requestOptions, useRandomizedToolNames: true } + ;({ response, retryCount } = await makeRequestWithRetries(requestOptions)) + } // 如果进行了重试,记录最终结果 if (retryCount > 0) { @@ -669,6 +927,10 @@ class ClaudeRelayService { ) throw error } finally { + // 🧹 清理 bodyStore + if (bodyStoreIdNonStream !== null) { + this.bodyStore.delete(bodyStoreIdNonStream) + } // 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放) if (queueLockAcquired && queueRequestId && selectedAccountId) { try { @@ -1043,23 +1305,19 @@ class ClaudeRelayService { // 获取过滤后的客户端 headers const filteredHeaders = this._filterClientHeaders(clientHeaders) - // 判断是否是真实的 Claude Code 请求 - const isRealClaudeCode = this.isRealClaudeCodeRequest(body) + const isRealClaudeCode = + requestOptions.isRealClaudeCodeRequest === undefined + ? this.isRealClaudeCodeRequest(body) + : requestOptions.isRealClaudeCodeRequest === true // 如果不是真实的 Claude Code 请求,需要使用从账户获取的 Claude Code headers let finalHeaders = { ...filteredHeaders } let requestPayload = body if (!isRealClaudeCode) { - // 获取该账号存储的 Claude Code headers const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId) - - // 只添加客户端没有提供的 headers Object.keys(claudeCodeHeaders).forEach((key) => { - const lowerKey = key.toLowerCase() - if (!finalHeaders[key] && !finalHeaders[lowerKey]) { - finalHeaders[key] = claudeCodeHeaders[key] - } + finalHeaders[key] = claudeCodeHeaders[key] }) } @@ -1081,6 +1339,13 @@ class ClaudeRelayService { requestPayload = extensionResult.body finalHeaders = extensionResult.headers + let toolNameMap = null + if (!isRealClaudeCode) { + toolNameMap = this._transformToolNamesInRequestBody(requestPayload, { + useRandomizedToolNames: requestOptions.useRandomizedToolNames === true + }) + } + // 序列化请求体,计算 content-length const bodyString = JSON.stringify(requestPayload) const contentLength = Buffer.byteLength(bodyString, 'utf8') @@ -1108,13 +1373,14 @@ class ClaudeRelayService { // 根据模型和客户端传递的 anthropic-beta 动态设置 header const modelId = requestPayload?.model || body?.model - const clientBetaHeader = clientHeaders?.['anthropic-beta'] + const clientBetaHeader = this._getHeaderValueCaseInsensitive(clientHeaders, 'anthropic-beta') headers['anthropic-beta'] = this._getBetaHeader(modelId, clientBetaHeader) return { requestPayload, bodyString, headers, - isRealClaudeCode + isRealClaudeCode, + toolNameMap } } @@ -1180,7 +1446,8 @@ class ClaudeRelayService { return prepared.abortResponse } - const { bodyString, headers } = prepared + let { bodyString } = prepared + const { headers, isRealClaudeCode, toolNameMap } = prepared return new Promise((resolve, reject) => { // 支持自定义路径(如 count_tokens) @@ -1235,6 +1502,10 @@ class ClaudeRelayService { responseBody = responseData.toString('utf8') } + if (!isRealClaudeCode) { + responseBody = this._restoreToolNamesInResponseBody(responseBody, toolNameMap) + } + const response = { statusCode: res.statusCode, headers: res.headers, @@ -1293,6 +1564,8 @@ class ClaudeRelayService { // 写入请求体 req.write(bodyString) + // 🧹 内存优化:立即清空 bodyString 引用,避免闭包捕获 + bodyString = null req.end() }) } @@ -1474,7 +1747,12 @@ class ClaudeRelayService { // 获取有效的访问token const accessToken = await claudeAccountService.getValidAccessToken(accountId) + const isRealClaudeCodeRequest = this._isActualClaudeCodeRequest(requestBody, clientHeaders) const processedBody = this._processRequestBody(requestBody, account) + // 🧹 内存优化:存储到 bodyStore,不放入 requestOptions 避免闭包捕获 + const originalBodyString = JSON.stringify(processedBody) + const bodyStoreId = ++this._bodyStoreIdCounter + this.bodyStore.set(bodyStoreId, originalBodyString) // 获取代理配置 const proxyAgent = await this._getProxyAgent(accountId) @@ -1496,7 +1774,11 @@ class ClaudeRelayService { accountType, sessionHash, streamTransformer, - options, + { + ...options, + bodyStoreId, + isRealClaudeCodeRequest + }, isDedicatedOfficialAccount, // 📬 新增回调:在收到响应头时释放队列锁 async () => { @@ -1585,7 +1867,12 @@ class ClaudeRelayService { return prepared.abortResponse } - const { bodyString, headers } = prepared + let { bodyString } = prepared + const { headers, toolNameMap } = prepared + const toolNameStreamTransformer = this._createToolNameStripperStreamTransformer( + streamTransformer, + toolNameMap + ) return new Promise((resolve, reject) => { const url = new URL(this.claudeApiUrl) @@ -1693,8 +1980,22 @@ class ClaudeRelayService { try { // 递归调用自身进行重试 + // 🧹 从 bodyStore 获取字符串用于重试 + if ( + !requestOptions.bodyStoreId || + !this.bodyStore.has(requestOptions.bodyStoreId) + ) { + throw new Error('529 retry requires valid bodyStoreId') + } + let retryBody + try { + retryBody = JSON.parse(this.bodyStore.get(requestOptions.bodyStoreId)) + } catch (parseError) { + logger.error(`❌ Failed to parse body for 529 retry: ${parseError.message}`) + throw new Error(`529 retry body parse failed: ${parseError.message}`) + } const retryResult = await this._makeClaudeStreamRequestWithUsageCapture( - body, + retryBody, accessToken, proxyAgent, clientHeaders, @@ -1789,11 +2090,48 @@ class ClaudeRelayService { errorData += chunk.toString() }) - res.on('end', () => { + res.on('end', async () => { logger.error( `❌ Claude API error response (Account: ${account?.name || accountId}):`, errorData ) + if ( + this._isClaudeCodeCredentialError(errorData) && + requestOptions.useRandomizedToolNames !== true && + requestOptions.bodyStoreId && + this.bodyStore.has(requestOptions.bodyStoreId) + ) { + let retryBody + try { + retryBody = JSON.parse(this.bodyStore.get(requestOptions.bodyStoreId)) + } catch (parseError) { + logger.error(`❌ Failed to parse body for 403 retry: ${parseError.message}`) + reject(new Error(`403 retry body parse failed: ${parseError.message}`)) + return + } + try { + const retryResult = await this._makeClaudeStreamRequestWithUsageCapture( + retryBody, + accessToken, + proxyAgent, + clientHeaders, + responseStream, + usageCallback, + accountId, + accountType, + sessionHash, + streamTransformer, + { ...requestOptions, useRandomizedToolNames: true }, + isDedicatedOfficialAccount, + onResponseStart, + retryCount + ) + resolve(retryResult) + } catch (retryError) { + reject(retryError) + } + return + } if (this._isOrganizationDisabledError(res.statusCode, errorData)) { ;(async () => { try { @@ -1828,7 +2166,7 @@ class ClaudeRelayService { } // 如果有 streamTransformer(如测试请求),使用前端期望的格式 - if (streamTransformer) { + if (toolNameStreamTransformer) { responseStream.write( `data: ${JSON.stringify({ type: 'error', error: errorMessage })}\n\n` ) @@ -1867,6 +2205,11 @@ class ClaudeRelayService { let rateLimitDetected = false // 限流检测标志 // 监听数据块,解析SSE并寻找usage信息 + // 🧹 内存优化:在闭包创建前提取需要的值,避免闭包捕获 body 和 requestOptions + // body 和 requestOptions 只在闭包外使用,闭包内只引用基本类型 + const requestedModel = body?.model || 'unknown' + const { isRealClaudeCodeRequest } = requestOptions + res.on('data', (chunk) => { try { const chunkStr = chunk.toString() @@ -1882,8 +2225,8 @@ class ClaudeRelayService { if (isStreamWritable(responseStream)) { const linesToForward = lines.join('\n') + (lines.length > 0 ? '\n' : '') // 如果有流转换器,应用转换 - if (streamTransformer) { - const transformed = streamTransformer(linesToForward) + if (toolNameStreamTransformer) { + const transformed = toolNameStreamTransformer(linesToForward) if (transformed) { responseStream.write(transformed) } @@ -2016,8 +2359,8 @@ class ClaudeRelayService { try { // 处理缓冲区中剩余的数据 if (buffer.trim() && isStreamWritable(responseStream)) { - if (streamTransformer) { - const transformed = streamTransformer(buffer) + if (toolNameStreamTransformer) { + const transformed = toolNameStreamTransformer(buffer) if (transformed) { responseStream.write(transformed) } @@ -2072,7 +2415,7 @@ class ClaudeRelayService { // 打印原始的usage数据为JSON字符串,避免嵌套问题 logger.info( - `📊 === Stream Request Usage Summary === Model: ${body.model}, Total Events: ${allUsageData.length}, Usage Data: ${JSON.stringify(allUsageData)}` + `📊 === Stream Request Usage Summary === Model: ${requestedModel}, Total Events: ${allUsageData.length}, Usage Data: ${JSON.stringify(allUsageData)}` ) // 一般一个请求只会使用一个模型,即使有多个usage事件也应该合并 @@ -2082,7 +2425,7 @@ class ClaudeRelayService { output_tokens: totalUsage.output_tokens, cache_creation_input_tokens: totalUsage.cache_creation_input_tokens, cache_read_input_tokens: totalUsage.cache_read_input_tokens, - model: allUsageData[allUsageData.length - 1].model || body.model // 使用最后一个模型或请求模型 + model: allUsageData[allUsageData.length - 1].model || requestedModel // 使用最后一个模型或请求模型 } // 如果有详细的cache_creation数据,合并它们 @@ -2191,15 +2534,15 @@ class ClaudeRelayService { } // 只有真实的 Claude Code 请求才更新 headers(流式请求) - if ( - clientHeaders && - Object.keys(clientHeaders).length > 0 && - this.isRealClaudeCodeRequest(body) - ) { + if (clientHeaders && Object.keys(clientHeaders).length > 0 && isRealClaudeCodeRequest) { await claudeCodeHeadersService.storeAccountHeaders(accountId, clientHeaders) } } + // 🧹 清理 bodyStore + if (requestOptions.bodyStoreId) { + this.bodyStore.delete(requestOptions.bodyStoreId) + } logger.debug('🌊 Claude stream response with usage capture completed') resolve() }) @@ -2256,6 +2599,10 @@ class ClaudeRelayService { ) responseStream.end() } + // 🧹 清理 bodyStore + if (requestOptions.bodyStoreId) { + this.bodyStore.delete(requestOptions.bodyStoreId) + } reject(error) }) @@ -2285,6 +2632,10 @@ class ClaudeRelayService { ) responseStream.end() } + // 🧹 清理 bodyStore + if (requestOptions.bodyStoreId) { + this.bodyStore.delete(requestOptions.bodyStoreId) + } reject(new Error('Request timeout')) }) @@ -2298,6 +2649,8 @@ class ClaudeRelayService { // 写入请求体 req.write(bodyString) + // 🧹 内存优化:立即清空 bodyString 引用,避免闭包捕获 + bodyString = null req.end() }) } diff --git a/src/services/droidRelayService.js b/src/services/droidRelayService.js index 77522b78..2ca40d4c 100644 --- a/src/services/droidRelayService.js +++ b/src/services/droidRelayService.js @@ -90,7 +90,7 @@ class DroidRelayService { return normalizedBody } - async _applyRateLimitTracking(rateLimitInfo, usageSummary, model, context = '') { + async _applyRateLimitTracking(rateLimitInfo, usageSummary, model, context = '', keyId = null) { if (!rateLimitInfo) { return } @@ -99,7 +99,9 @@ class DroidRelayService { const { totalTokens, totalCost } = await updateRateLimitCounters( rateLimitInfo, usageSummary, - model + model, + keyId, + 'droid' ) if (totalTokens > 0) { @@ -606,7 +608,8 @@ class DroidRelayService { clientRequest?.rateLimitInfo, usageSummary, model, - ' [stream]' + ' [stream]', + keyId ) logger.success(`Droid stream completed - Account: ${account.name}`) @@ -1225,7 +1228,8 @@ class DroidRelayService { clientRequest?.rateLimitInfo, usageSummary, model, - endpointLabel + endpointLabel, + keyId ) logger.success( diff --git a/src/services/geminiAccountService.js b/src/services/geminiAccountService.js index 4d9fb10c..250fd684 100644 --- a/src/services/geminiAccountService.js +++ b/src/services/geminiAccountService.js @@ -14,16 +14,67 @@ const { } = require('../utils/tokenRefreshLogger') const tokenRefreshService = require('./tokenRefreshService') const { createEncryptor } = require('../utils/commonHelper') +const antigravityClient = require('./antigravityClient') // Gemini 账户键前缀 const GEMINI_ACCOUNT_KEY_PREFIX = 'gemini_account:' const SHARED_GEMINI_ACCOUNTS_KEY = 'shared_gemini_accounts' const ACCOUNT_SESSION_MAPPING_PREFIX = 'gemini_session_account_mapping:' -// Gemini CLI OAuth 配置 - 这些是公开的 Gemini CLI 凭据 -const OAUTH_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com' -const OAUTH_CLIENT_SECRET = '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' -const OAUTH_SCOPES = ['https://www.googleapis.com/auth/cloud-platform'] +// Gemini OAuth 配置 - 支持 Gemini CLI 与 Antigravity 两种 OAuth 应用 +const OAUTH_PROVIDER_GEMINI_CLI = 'gemini-cli' +const OAUTH_PROVIDER_ANTIGRAVITY = 'antigravity' + +const OAUTH_PROVIDERS = { + [OAUTH_PROVIDER_GEMINI_CLI]: { + // Gemini CLI OAuth 配置(公开) + clientId: + process.env.GEMINI_OAUTH_CLIENT_ID || + '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com', + clientSecret: process.env.GEMINI_OAUTH_CLIENT_SECRET || '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', + scopes: ['https://www.googleapis.com/auth/cloud-platform'] + }, + [OAUTH_PROVIDER_ANTIGRAVITY]: { + // Antigravity OAuth 配置(参考 gcli2api) + clientId: + process.env.ANTIGRAVITY_OAUTH_CLIENT_ID || + '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com', + clientSecret: + process.env.ANTIGRAVITY_OAUTH_CLIENT_SECRET || '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', + scopes: [ + 'https://www.googleapis.com/auth/cloud-platform', + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', + 'https://www.googleapis.com/auth/cclog', + 'https://www.googleapis.com/auth/experimentsandconfigs' + ] + } +} + +if (!process.env.GEMINI_OAUTH_CLIENT_SECRET) { + logger.warn( + '⚠️ GEMINI_OAUTH_CLIENT_SECRET 未设置,使用内置默认值(建议在生产环境通过环境变量覆盖)' + ) +} +if (!process.env.ANTIGRAVITY_OAUTH_CLIENT_SECRET) { + logger.warn( + '⚠️ ANTIGRAVITY_OAUTH_CLIENT_SECRET 未设置,使用内置默认值(建议在生产环境通过环境变量覆盖)' + ) +} + +function normalizeOauthProvider(oauthProvider) { + if (!oauthProvider) { + return OAUTH_PROVIDER_GEMINI_CLI + } + return oauthProvider === OAUTH_PROVIDER_ANTIGRAVITY + ? OAUTH_PROVIDER_ANTIGRAVITY + : OAUTH_PROVIDER_GEMINI_CLI +} + +function getOauthProviderConfig(oauthProvider) { + const normalized = normalizeOauthProvider(oauthProvider) + return OAUTH_PROVIDERS[normalized] || OAUTH_PROVIDERS[OAUTH_PROVIDER_GEMINI_CLI] +} // 🌐 TCP Keep-Alive Agent 配置 // 解决长时间流式请求中 NAT/防火墙空闲超时导致的连接中断问题 @@ -41,6 +92,117 @@ logger.info('🌐 Gemini HTTPS Agent initialized with TCP Keep-Alive support') const encryptor = createEncryptor('gemini-account-salt') const { encrypt, decrypt } = encryptor +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 +} + // 🧹 定期清理缓存(每10分钟) setInterval( () => { @@ -51,14 +213,15 @@ setInterval( ) // 创建 OAuth2 客户端(支持代理配置) -function createOAuth2Client(redirectUri = null, proxyConfig = null) { +function createOAuth2Client(redirectUri = null, proxyConfig = null, oauthProvider = null) { // 如果没有提供 redirectUri,使用默认值 const uri = redirectUri || 'http://localhost:45462' + const oauthConfig = getOauthProviderConfig(oauthProvider) // 准备客户端选项 const clientOptions = { - clientId: OAUTH_CLIENT_ID, - clientSecret: OAUTH_CLIENT_SECRET, + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, redirectUri: uri } @@ -79,10 +242,17 @@ function createOAuth2Client(redirectUri = null, proxyConfig = null) { } // 生成授权 URL (支持 PKCE 和代理) -async function generateAuthUrl(state = null, redirectUri = null, proxyConfig = null) { +async function generateAuthUrl( + state = null, + redirectUri = null, + proxyConfig = null, + oauthProvider = null +) { // 使用新的 redirect URI const finalRedirectUri = redirectUri || 'https://codeassist.google.com/authcode' - const oAuth2Client = createOAuth2Client(finalRedirectUri, proxyConfig) + const normalizedProvider = normalizeOauthProvider(oauthProvider) + const oauthConfig = getOauthProviderConfig(normalizedProvider) + const oAuth2Client = createOAuth2Client(finalRedirectUri, proxyConfig, normalizedProvider) if (proxyConfig) { logger.info( @@ -99,7 +269,7 @@ async function generateAuthUrl(state = null, redirectUri = null, proxyConfig = n const authUrl = oAuth2Client.generateAuthUrl({ redirect_uri: finalRedirectUri, access_type: 'offline', - scope: OAUTH_SCOPES, + scope: oauthConfig.scopes, code_challenge_method: 'S256', code_challenge: codeVerifier.codeChallenge, state: stateValue, @@ -110,7 +280,8 @@ async function generateAuthUrl(state = null, redirectUri = null, proxyConfig = n authUrl, state: stateValue, codeVerifier: codeVerifier.codeVerifier, - redirectUri: finalRedirectUri + redirectUri: finalRedirectUri, + oauthProvider: normalizedProvider } } @@ -171,11 +342,14 @@ async function exchangeCodeForTokens( code, redirectUri = null, codeVerifier = null, - proxyConfig = null + proxyConfig = null, + oauthProvider = null ) { try { + const normalizedProvider = normalizeOauthProvider(oauthProvider) + const oauthConfig = getOauthProviderConfig(normalizedProvider) // 创建带代理配置的 OAuth2Client - const oAuth2Client = createOAuth2Client(redirectUri, proxyConfig) + const oAuth2Client = createOAuth2Client(redirectUri, proxyConfig, normalizedProvider) if (proxyConfig) { logger.info( @@ -201,7 +375,7 @@ async function exchangeCodeForTokens( return { access_token: tokens.access_token, refresh_token: tokens.refresh_token, - scope: tokens.scope || OAUTH_SCOPES.join(' '), + scope: tokens.scope || oauthConfig.scopes.join(' '), token_type: tokens.token_type || 'Bearer', expiry_date: tokens.expiry_date || Date.now() + tokens.expires_in * 1000 } @@ -212,9 +386,11 @@ async function exchangeCodeForTokens( } // 刷新访问令牌 -async function refreshAccessToken(refreshToken, proxyConfig = null) { +async function refreshAccessToken(refreshToken, proxyConfig = null, oauthProvider = null) { + const normalizedProvider = normalizeOauthProvider(oauthProvider) + const oauthConfig = getOauthProviderConfig(normalizedProvider) // 创建带代理配置的 OAuth2Client - const oAuth2Client = createOAuth2Client(null, proxyConfig) + const oAuth2Client = createOAuth2Client(null, proxyConfig, normalizedProvider) try { // 设置 refresh_token @@ -246,7 +422,7 @@ async function refreshAccessToken(refreshToken, proxyConfig = null) { return { access_token: credentials.access_token, refresh_token: credentials.refresh_token || refreshToken, // 保留原 refresh_token 如果没有返回新的 - scope: credentials.scope || OAUTH_SCOPES.join(' '), + scope: credentials.scope || oauthConfig.scopes.join(' '), token_type: credentials.token_type || 'Bearer', expiry_date: credentials.expiry_date || Date.now() + 3600000 // 默认1小时过期 } @@ -266,6 +442,8 @@ async function refreshAccessToken(refreshToken, proxyConfig = null) { async function createAccount(accountData) { const id = uuidv4() const now = new Date().toISOString() + const oauthProvider = normalizeOauthProvider(accountData.oauthProvider) + const oauthConfig = getOauthProviderConfig(oauthProvider) // 处理凭证数据 let geminiOauth = null @@ -298,7 +476,7 @@ async function createAccount(accountData) { geminiOauth = JSON.stringify({ access_token: accessToken, refresh_token: refreshToken, - scope: accountData.scope || OAUTH_SCOPES.join(' '), + scope: accountData.scope || oauthConfig.scopes.join(' '), token_type: accountData.tokenType || 'Bearer', expiry_date: accountData.expiryDate || Date.now() + 3600000 // 默认1小时 }) @@ -326,7 +504,8 @@ async function createAccount(accountData) { refreshToken: refreshToken ? encrypt(refreshToken) : '', expiresAt, // OAuth Token 过期时间(技术字段,自动刷新) // 只有OAuth方式才有scopes,手动添加的没有 - scopes: accountData.geminiOauth ? accountData.scopes || OAUTH_SCOPES.join(' ') : '', + scopes: accountData.geminiOauth ? accountData.scopes || oauthConfig.scopes.join(' ') : '', + oauthProvider, // ✅ 新增:账户订阅到期时间(业务字段,手动管理) subscriptionExpiresAt: accountData.subscriptionExpiresAt || null, @@ -436,6 +615,10 @@ async function updateAccount(accountId, updates) { updates.schedulable = updates.schedulable.toString() } + if (updates.oauthProvider !== undefined) { + updates.oauthProvider = normalizeOauthProvider(updates.oauthProvider) + } + // 加密敏感字段 if (updates.geminiOauth) { updates.geminiOauth = encrypt( @@ -824,12 +1007,13 @@ async function refreshAccountToken(accountId) { // 重新获取账户数据(可能已被其他进程刷新) const updatedAccount = await getAccount(accountId) if (updatedAccount && updatedAccount.accessToken) { + const oauthConfig = getOauthProviderConfig(updatedAccount.oauthProvider) const accessToken = decrypt(updatedAccount.accessToken) return { access_token: accessToken, refresh_token: updatedAccount.refreshToken ? decrypt(updatedAccount.refreshToken) : '', expiry_date: updatedAccount.expiresAt ? new Date(updatedAccount.expiresAt).getTime() : 0, - scope: updatedAccount.scope || OAUTH_SCOPES.join(' '), + scope: updatedAccount.scopes || oauthConfig.scopes.join(' '), token_type: 'Bearer' } } @@ -843,7 +1027,11 @@ async function refreshAccountToken(accountId) { // account.refreshToken 已经是解密后的值(从 getAccount 返回) // 传入账户的代理配置 - const newTokens = await refreshAccessToken(account.refreshToken, account.proxy) + const newTokens = await refreshAccessToken( + account.refreshToken, + account.proxy, + account.oauthProvider + ) // 更新账户信息 const updates = { @@ -975,14 +1163,15 @@ async function getAccountRateLimitInfo(accountId) { } // 获取配置的OAuth客户端 - 参考GeminiCliSimulator的getOauthClient方法(支持代理) -async function getOauthClient(accessToken, refreshToken, proxyConfig = null) { - const client = createOAuth2Client(null, proxyConfig) +async function getOauthClient(accessToken, refreshToken, proxyConfig = null, oauthProvider = null) { + const normalizedProvider = normalizeOauthProvider(oauthProvider) + const oauthConfig = getOauthProviderConfig(normalizedProvider) + const client = createOAuth2Client(null, proxyConfig, normalizedProvider) const creds = { access_token: accessToken, refresh_token: refreshToken, - scope: - 'https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.profile openid https://www.googleapis.com/auth/userinfo.email', + scope: oauthConfig.scopes.join(' '), token_type: 'Bearer', expiry_date: 1754269905646 } @@ -1448,6 +1637,43 @@ async function generateContent( return response.data } +// 调用 Antigravity 上游生成内容(非流式) +async function generateContentAntigravity( + client, + requestData, + userPromptId, + projectId = null, + sessionId = null, + proxyConfig = null +) { + const { token } = await client.getAccessToken() + const { model } = antigravityClient.buildAntigravityEnvelope({ + requestData, + projectId, + sessionId, + userPromptId + }) + + logger.info('🪐 Antigravity generateContent API调用开始', { + model, + userPromptId, + projectId, + sessionId + }) + + const { response } = await antigravityClient.request({ + accessToken: token, + proxyConfig, + requestData, + projectId, + sessionId, + userPromptId, + stream: false + }) + logger.info('✅ Antigravity generateContent API调用成功') + return response.data +} + // 调用 Code Assist API 生成内容(流式) async function generateContentStream( client, @@ -1532,6 +1758,46 @@ async function generateContentStream( return response.data // 返回流对象 } +// 调用 Antigravity 上游生成内容(流式) +async function generateContentStreamAntigravity( + client, + requestData, + userPromptId, + projectId = null, + sessionId = null, + signal = null, + proxyConfig = null +) { + const { token } = await client.getAccessToken() + const { model } = antigravityClient.buildAntigravityEnvelope({ + requestData, + projectId, + sessionId, + userPromptId + }) + + logger.info('🌊 Antigravity streamGenerateContent API调用开始', { + model, + userPromptId, + projectId, + sessionId + }) + + const { response } = await antigravityClient.request({ + accessToken: token, + proxyConfig, + requestData, + projectId, + sessionId, + userPromptId, + stream: true, + signal, + params: { alt: 'sse' } + }) + logger.info('✅ Antigravity streamGenerateContent API调用成功,开始流式传输') + return response.data +} + // 更新账户的临时项目 ID async function updateTempProjectId(accountId, tempProjectId) { if (!tempProjectId) { @@ -1625,10 +1891,12 @@ module.exports = { decrypt, encryptor, // 暴露加密器以便测试和监控 countTokens, + countTokensAntigravity, generateContent, generateContentStream, + generateContentAntigravity, + generateContentStreamAntigravity, + fetchAvailableModelsAntigravity, updateTempProjectId, - resetAccountStatus, - OAUTH_CLIENT_ID, - OAUTH_SCOPES + resetAccountStatus } diff --git a/src/services/geminiRelayService.js b/src/services/geminiRelayService.js index 3d9a8ed5..8a4aad03 100644 --- a/src/services/geminiRelayService.js +++ b/src/services/geminiRelayService.js @@ -163,7 +163,8 @@ async function* handleStreamResponse(response, model, apiKeyId, accountId = null 0, // cacheCreateTokens (Gemini 没有这个概念) 0, // cacheReadTokens (Gemini 没有这个概念) model, - accountId + accountId, + 'gemini' ) .catch((error) => { logger.error('❌ Failed to record Gemini usage:', error) @@ -317,7 +318,8 @@ async function sendGeminiRequest({ 0, // cacheCreateTokens 0, // cacheReadTokens model, - accountId + accountId, + 'gemini' ) .catch((error) => { logger.error('❌ Failed to record Gemini usage:', error) diff --git a/src/services/openaiResponsesRelayService.js b/src/services/openaiResponsesRelayService.js index d7db8eaa..23711718 100644 --- a/src/services/openaiResponsesRelayService.js +++ b/src/services/openaiResponsesRelayService.js @@ -557,7 +557,8 @@ class OpenAIResponsesRelayService { cacheCreateTokens, cacheReadTokens, modelToRecord, - account.id + account.id, + 'openai-responses' ) logger.info( @@ -685,7 +686,8 @@ class OpenAIResponsesRelayService { cacheCreateTokens, cacheReadTokens, actualModel, - account.id + account.id, + 'openai-responses' ) logger.info( diff --git a/src/services/quotaCardService.js b/src/services/quotaCardService.js index 009ef397..c7cd93ec 100644 --- a/src/services/quotaCardService.js +++ b/src/services/quotaCardService.js @@ -12,6 +12,51 @@ class QuotaCardService { this.CARD_PREFIX = 'quota_card:' this.REDEMPTION_PREFIX = 'redemption:' this.CARD_CODE_PREFIX = 'CC' // 卡号前缀 + this.LIMITS_CONFIG_KEY = 'system:quota_card_limits' + } + + /** + * 获取额度卡上限配置 + */ + async getLimitsConfig() { + try { + const configStr = await redis.client.get(this.LIMITS_CONFIG_KEY) + if (configStr) { + return JSON.parse(configStr) + } + // 没有 Redis 配置时,使用 config.js 默认值 + const config = require('../../config/config') + return config.quotaCardLimits || { + enabled: true, + maxExpiryDays: 90, + maxTotalCostLimit: 1000 + } + } catch (error) { + logger.error('❌ Failed to get limits config:', error) + return { enabled: true, maxExpiryDays: 90, maxTotalCostLimit: 1000 } + } + } + + /** + * 保存额度卡上限配置 + */ + async saveLimitsConfig(config) { + try { + const parsedDays = parseInt(config.maxExpiryDays) + const parsedCost = parseFloat(config.maxTotalCostLimit) + const newConfig = { + enabled: config.enabled !== false, + maxExpiryDays: Number.isNaN(parsedDays) ? 90 : parsedDays, + maxTotalCostLimit: Number.isNaN(parsedCost) ? 1000 : parsedCost, + updatedAt: new Date().toISOString() + } + await redis.client.set(this.LIMITS_CONFIG_KEY, JSON.stringify(newConfig)) + logger.info('✅ Quota card limits config saved') + return newConfig + } catch (error) { + logger.error('❌ Failed to save limits config:', error) + throw error + } } /** @@ -248,53 +293,119 @@ class QuotaCardService { // 获取卡信息 const card = await this.getCardByCode(code) if (!card) { - throw new Error('Card not found') + throw new Error('卡号不存在') } // 检查卡状态 if (card.status !== 'unused') { - throw new Error(`Card is ${card.status}, cannot redeem`) + const statusMap = { used: '已使用', expired: '已过期', revoked: '已撤销' } + throw new Error(`卡片${statusMap[card.status] || card.status},无法兑换`) } // 检查卡是否过期 if (card.expiresAt && new Date(card.expiresAt) < new Date()) { // 更新卡状态为过期 await this._updateCardStatus(card.id, 'expired') - throw new Error('Card has expired') + throw new Error('卡片已过期') } // 获取 API Key 信息 const apiKeyService = require('./apiKeyService') const keyData = await redis.getApiKey(apiKeyId) if (!keyData || Object.keys(keyData).length === 0) { - throw new Error('API key not found') + throw new Error('API Key 不存在') } - // 检查 API Key 是否为聚合类型(只有聚合 Key 才能核销额度卡) - if (card.type !== 'time' && keyData.isAggregated !== 'true') { - throw new Error('Only aggregated keys can redeem quota cards') - } + // 获取上限配置 + const limits = await this.getLimitsConfig() // 执行核销 const redemptionId = uuidv4() const now = new Date().toISOString() // 记录核销前状态 - const beforeQuota = parseFloat(keyData.quotaLimit || 0) + const beforeLimit = parseFloat(keyData.totalCostLimit || 0) const beforeExpiry = keyData.expiresAt || '' // 应用卡效果 - let afterQuota = beforeQuota + let afterLimit = beforeLimit let afterExpiry = beforeExpiry + let quotaAdded = 0 + let timeAdded = 0 + let actualTimeUnit = card.timeUnit // 实际使用的时间单位(截断时会改为 days) + const warnings = [] // 截断警告信息 if (card.type === 'quota' || card.type === 'combo') { - const result = await apiKeyService.addQuota(apiKeyId, card.quotaAmount) - afterQuota = result.newQuotaLimit + let amountToAdd = card.quotaAmount + + // 上限保护:检查是否超过最大额度限制 + if (limits.enabled && limits.maxTotalCostLimit > 0) { + const maxAllowed = limits.maxTotalCostLimit - beforeLimit + if (amountToAdd > maxAllowed) { + amountToAdd = Math.max(0, maxAllowed) + warnings.push(`额度已达上限,本次仅增加 ${amountToAdd} CC(原卡面 ${card.quotaAmount} CC)`) + logger.warn(`额度卡兑换超出上限,已截断:原 ${card.quotaAmount} -> 实际 ${amountToAdd}`) + } + } + + if (amountToAdd > 0) { + const result = await apiKeyService.addTotalCostLimit(apiKeyId, amountToAdd) + afterLimit = result.newTotalCostLimit + quotaAdded = amountToAdd + } } if (card.type === 'time' || card.type === 'combo') { + // 计算新的过期时间 + let baseDate = beforeExpiry ? new Date(beforeExpiry) : new Date() + if (baseDate < new Date()) { + baseDate = new Date() + } + + let newExpiry = new Date(baseDate) + switch (card.timeUnit) { + case 'hours': + newExpiry.setTime(newExpiry.getTime() + card.timeAmount * 60 * 60 * 1000) + break + case 'days': + newExpiry.setDate(newExpiry.getDate() + card.timeAmount) + break + case 'months': + newExpiry.setMonth(newExpiry.getMonth() + card.timeAmount) + break + } + + // 上限保护:检查是否超过最大有效期 + if (limits.enabled && limits.maxExpiryDays > 0) { + const maxExpiry = new Date() + maxExpiry.setDate(maxExpiry.getDate() + limits.maxExpiryDays) + if (newExpiry > maxExpiry) { + newExpiry = maxExpiry + warnings.push(`有效期已达上限(${limits.maxExpiryDays}天),时间已截断`) + logger.warn(`时间卡兑换超出上限,已截断至 ${maxExpiry.toISOString()}`) + } + } + const result = await apiKeyService.extendExpiry(apiKeyId, card.timeAmount, card.timeUnit) - afterExpiry = result.newExpiresAt + // 如果有上限保护,使用截断后的时间 + if (limits.enabled && limits.maxExpiryDays > 0) { + const maxExpiry = new Date() + maxExpiry.setDate(maxExpiry.getDate() + limits.maxExpiryDays) + if (new Date(result.newExpiresAt) > maxExpiry) { + await redis.client.hset(`apikey:${apiKeyId}`, 'expiresAt', maxExpiry.toISOString()) + afterExpiry = maxExpiry.toISOString() + // 计算实际增加的天数,截断时统一用天 + const actualDays = Math.max(0, Math.ceil((maxExpiry - baseDate) / (1000 * 60 * 60 * 24))) + timeAdded = actualDays + actualTimeUnit = 'days' + } else { + afterExpiry = result.newExpiresAt + timeAdded = card.timeAmount + } + } else { + afterExpiry = result.newExpiresAt + timeAdded = card.timeAmount + } } // 更新卡状态 @@ -321,11 +432,11 @@ class QuotaCardService { username, apiKeyId, apiKeyName: keyData.name || '', - quotaAdded: String(card.type === 'time' ? 0 : card.quotaAmount), - timeAdded: String(card.type === 'quota' ? 0 : card.timeAmount), - timeUnit: card.timeUnit, - beforeQuota: String(beforeQuota), - afterQuota: String(afterQuota), + quotaAdded: String(quotaAdded), + timeAdded: String(timeAdded), + timeUnit: actualTimeUnit, + beforeLimit: String(beforeLimit), + afterLimit: String(afterLimit), beforeExpiry, afterExpiry, timestamp: now, @@ -343,14 +454,15 @@ class QuotaCardService { return { success: true, + warnings, redemptionId, cardCode: card.code, cardType: card.type, - quotaAdded: card.type === 'time' ? 0 : card.quotaAmount, - timeAdded: card.type === 'quota' ? 0 : card.timeAmount, - timeUnit: card.timeUnit, - beforeQuota, - afterQuota, + quotaAdded, + timeAdded, + timeUnit: actualTimeUnit, + beforeLimit, + afterLimit, beforeExpiry, afterExpiry } @@ -383,13 +495,13 @@ class QuotaCardService { const now = new Date().toISOString() // 撤销效果 - let actualQuotaDeducted = 0 + let actualDeducted = 0 if (parseFloat(redemptionData.quotaAdded) > 0) { - const result = await apiKeyService.deductQuotaLimit( + const result = await apiKeyService.deductTotalCostLimit( redemptionData.apiKeyId, parseFloat(redemptionData.quotaAdded) ) - actualQuotaDeducted = result.actualDeducted + actualDeducted = result.actualDeducted } // 注意:时间卡撤销比较复杂,这里简化处理,不回退时间 @@ -401,7 +513,7 @@ class QuotaCardService { revokedAt: now, revokedBy, revokeReason: reason, - actualQuotaDeducted: String(actualQuotaDeducted) + actualDeducted: String(actualDeducted) }) // 更新卡状态 @@ -423,7 +535,7 @@ class QuotaCardService { success: true, redemptionId, cardCode: redemptionData.cardCode, - actualQuotaDeducted, + actualDeducted, reason } } catch (error) { @@ -469,8 +581,8 @@ class QuotaCardService { quotaAdded: parseFloat(data.quotaAdded || 0), timeAdded: parseInt(data.timeAdded || 0), timeUnit: data.timeUnit, - beforeQuota: parseFloat(data.beforeQuota || 0), - afterQuota: parseFloat(data.afterQuota || 0), + beforeLimit: parseFloat(data.beforeLimit || 0), + afterLimit: parseFloat(data.afterLimit || 0), beforeExpiry: data.beforeExpiry, afterExpiry: data.afterExpiry, timestamp: data.timestamp, @@ -478,7 +590,7 @@ class QuotaCardService { revokedAt: data.revokedAt, revokedBy: data.revokedBy, revokeReason: data.revokeReason, - actualQuotaDeducted: parseFloat(data.actualQuotaDeducted || 0) + actualDeducted: parseFloat(data.actualDeducted || 0) }) } } diff --git a/src/services/rateLimitCleanupService.js b/src/services/rateLimitCleanupService.js index ebf8f44b..0775b650 100644 --- a/src/services/rateLimitCleanupService.js +++ b/src/services/rateLimitCleanupService.js @@ -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 调用) */ diff --git a/src/services/serviceRatesService.js b/src/services/serviceRatesService.js index 2d6f76bb..62740a87 100644 --- a/src/services/serviceRatesService.js +++ b/src/services/serviceRatesService.js @@ -207,6 +207,36 @@ class ServiceRatesService { return 'claude' } + /** + * 根据账户类型获取服务类型(优先级高于模型推断) + */ + getServiceFromAccountType(accountType) { + if (!accountType) return null + + const mapping = { + claude: 'claude', + 'claude-official': 'claude', + 'claude-console': 'claude', + ccr: 'ccr', + bedrock: 'bedrock', + gemini: 'gemini', + 'openai-responses': 'codex', + openai: 'codex', + azure: 'azure', + 'azure-openai': 'azure', + droid: 'droid' + } + + return mapping[accountType] || null + } + + /** + * 获取服务类型(优先 accountType,后备 model) + */ + getService(accountType, model) { + return this.getServiceFromAccountType(accountType) || this.getServiceFromModel(model) + } + /** * 获取所有支持的服务列表 */ diff --git a/src/services/unifiedGeminiScheduler.js b/src/services/unifiedGeminiScheduler.js index 381ff51a..3316e7da 100644 --- a/src/services/unifiedGeminiScheduler.js +++ b/src/services/unifiedGeminiScheduler.js @@ -5,11 +5,51 @@ const redis = require('../models/redis') const logger = require('../utils/logger') const { isSchedulable, isActive, sortAccountsByPriority } = require('../utils/commonHelper') +const OAUTH_PROVIDER_GEMINI_CLI = 'gemini-cli' +const OAUTH_PROVIDER_ANTIGRAVITY = 'antigravity' +const KNOWN_OAUTH_PROVIDERS = [OAUTH_PROVIDER_GEMINI_CLI, OAUTH_PROVIDER_ANTIGRAVITY] + +function normalizeOauthProvider(oauthProvider) { + if (!oauthProvider) { + return OAUTH_PROVIDER_GEMINI_CLI + } + return oauthProvider === OAUTH_PROVIDER_ANTIGRAVITY + ? OAUTH_PROVIDER_ANTIGRAVITY + : OAUTH_PROVIDER_GEMINI_CLI +} + class UnifiedGeminiScheduler { constructor() { this.SESSION_MAPPING_PREFIX = 'unified_gemini_session_mapping:' } + _getSessionMappingKey(sessionHash, oauthProvider = null) { + if (!sessionHash) { + return null + } + if (!oauthProvider) { + return `${this.SESSION_MAPPING_PREFIX}${sessionHash}` + } + const normalized = normalizeOauthProvider(oauthProvider) + return `${this.SESSION_MAPPING_PREFIX}${normalized}:${sessionHash}` + } + + // 🔧 辅助方法:检查账户是否可调度(兼容字符串和布尔值) + _isSchedulable(schedulable) { + // 如果是 undefined 或 null,默认为可调度 + if (schedulable === undefined || schedulable === null) { + return true + } + // 明确设置为 false(布尔值)或 'false'(字符串)时不可调度 + return schedulable !== false && schedulable !== 'false' + } + + // 🔧 辅助方法:检查账户是否激活(兼容字符串和布尔值) + _isActive(isActive) { + // 兼容布尔值 true 和字符串 'true' + return isActive === true || isActive === 'true' + } + // 🎯 统一调度Gemini账号 async selectAccountForApiKey( apiKeyData, @@ -17,7 +57,8 @@ class UnifiedGeminiScheduler { requestedModel = null, options = {} ) { - const { allowApiAccounts = false } = options + const { allowApiAccounts = false, oauthProvider = null } = options + const normalizedOauthProvider = oauthProvider ? normalizeOauthProvider(oauthProvider) : null try { // 如果API Key绑定了专属账户或分组,优先使用 @@ -59,15 +100,28 @@ class UnifiedGeminiScheduler { // 普通 Gemini OAuth 专属账户 else { const boundAccount = await geminiAccountService.getAccount(apiKeyData.geminiAccountId) - if (boundAccount && isActive(boundAccount.isActive) && boundAccount.status !== 'error') { - logger.info( - `🎯 Using bound dedicated Gemini account: ${boundAccount.name} (${apiKeyData.geminiAccountId}) for API key ${apiKeyData.name}` - ) - // 更新账户的最后使用时间 - await geminiAccountService.markAccountUsed(apiKeyData.geminiAccountId) - return { - accountId: apiKeyData.geminiAccountId, - accountType: 'gemini' + if ( + boundAccount && + this._isActive(boundAccount.isActive) && + 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( + `🎯 Using bound dedicated Gemini account: ${boundAccount.name} (${apiKeyData.geminiAccountId}) for API key ${apiKeyData.name}` + ) + // 更新账户的最后使用时间 + await geminiAccountService.markAccountUsed(apiKeyData.geminiAccountId) + return { + accountId: apiKeyData.geminiAccountId, + accountType: 'gemini' + } } } else { logger.warn( @@ -79,7 +133,7 @@ class UnifiedGeminiScheduler { // 如果有会话哈希,检查是否有已映射的账户 if (sessionHash) { - const mappedAccount = await this._getSessionMapping(sessionHash) + const mappedAccount = await this._getSessionMapping(sessionHash, normalizedOauthProvider) if (mappedAccount) { // 验证映射的账户是否仍然可用 const isAvailable = await this._isAccountAvailable( @@ -88,7 +142,7 @@ class UnifiedGeminiScheduler { ) if (isAvailable) { // 🚀 智能会话续期(续期 unified 映射键,按配置) - await this._extendSessionMappingTTL(sessionHash) + await this._extendSessionMappingTTL(sessionHash, normalizedOauthProvider) logger.info( `🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}` ) @@ -109,11 +163,10 @@ class UnifiedGeminiScheduler { } // 获取所有可用账户 - const availableAccounts = await this._getAllAvailableAccounts( - apiKeyData, - requestedModel, - allowApiAccounts - ) + const availableAccounts = await this._getAllAvailableAccounts(apiKeyData, requestedModel, { + allowApiAccounts, + oauthProvider: normalizedOauthProvider + }) if (availableAccounts.length === 0) { // 提供更详细的错误信息 @@ -137,7 +190,8 @@ class UnifiedGeminiScheduler { await this._setSessionMapping( sessionHash, selectedAccount.accountId, - selectedAccount.accountType + selectedAccount.accountType, + normalizedOauthProvider ) logger.info( `🎯 Created new sticky session mapping: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) for session ${sessionHash}` @@ -166,7 +220,18 @@ class UnifiedGeminiScheduler { } // 📋 获取所有可用账户 - async _getAllAvailableAccounts(apiKeyData, requestedModel = null, allowApiAccounts = false) { + async _getAllAvailableAccounts( + apiKeyData, + requestedModel = null, + allowApiAccountsOrOptions = false + ) { + const options = + allowApiAccountsOrOptions && typeof allowApiAccountsOrOptions === 'object' + ? allowApiAccountsOrOptions + : { allowApiAccounts: allowApiAccountsOrOptions } + const { allowApiAccounts = false, oauthProvider = null } = options + const normalizedOauthProvider = oauthProvider ? normalizeOauthProvider(oauthProvider) : null + const availableAccounts = [] // 如果API Key绑定了专属账户,优先返回 @@ -222,7 +287,17 @@ class UnifiedGeminiScheduler { // 普通 Gemini OAuth 账户 else if (!apiKeyData.geminiAccountId.startsWith('group:')) { const boundAccount = await geminiAccountService.getAccount(apiKeyData.geminiAccountId) - if (boundAccount && isActive(boundAccount.isActive) && boundAccount.status !== 'error') { + if ( + boundAccount && + this._isActive(boundAccount.isActive) && + boundAccount.status !== 'error' + ) { + if ( + normalizedOauthProvider && + normalizeOauthProvider(boundAccount.oauthProvider) !== normalizedOauthProvider + ) { + return availableAccounts + } const isRateLimited = await this.isAccountRateLimited(boundAccount.id) if (!isRateLimited) { // 检查模型支持 @@ -272,6 +347,12 @@ class UnifiedGeminiScheduler { (account.accountType === 'shared' || !account.accountType) && // 兼容旧数据 isSchedulable(account.schedulable) ) { + if ( + normalizedOauthProvider && + normalizeOauthProvider(account.oauthProvider) !== normalizedOauthProvider + ) { + continue + } // 检查是否可调度 // 检查token是否过期 @@ -391,9 +472,10 @@ class UnifiedGeminiScheduler { } // 🔗 获取会话映射 - async _getSessionMapping(sessionHash) { + async _getSessionMapping(sessionHash, oauthProvider = null) { const client = redis.getClientSafe() - const mappingData = await client.get(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`) + const key = this._getSessionMappingKey(sessionHash, oauthProvider) + const mappingData = key ? await client.get(key) : null if (mappingData) { try { @@ -408,27 +490,42 @@ class UnifiedGeminiScheduler { } // 💾 设置会话映射 - async _setSessionMapping(sessionHash, accountId, accountType) { + async _setSessionMapping(sessionHash, accountId, accountType, oauthProvider = null) { const client = redis.getClientSafe() const mappingData = JSON.stringify({ accountId, accountType }) // 依据配置设置TTL(小时) const appConfig = require('../../config/config') const ttlHours = appConfig.session?.stickyTtlHours || 1 const ttlSeconds = Math.max(1, Math.floor(ttlHours * 60 * 60)) - await client.setex(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`, ttlSeconds, mappingData) + const key = this._getSessionMappingKey(sessionHash, oauthProvider) + if (!key) { + return + } + await client.setex(key, ttlSeconds, mappingData) } // 🗑️ 删除会话映射 async _deleteSessionMapping(sessionHash) { const client = redis.getClientSafe() - await client.del(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`) + if (!sessionHash) { + return + } + + const keys = [this._getSessionMappingKey(sessionHash)] + for (const provider of KNOWN_OAUTH_PROVIDERS) { + keys.push(this._getSessionMappingKey(sessionHash, provider)) + } + await client.del(keys.filter(Boolean)) } // 🔁 续期统一调度会话映射TTL(针对 unified_gemini_session_mapping:* 键),遵循会话配置 - async _extendSessionMappingTTL(sessionHash) { + async _extendSessionMappingTTL(sessionHash, oauthProvider = null) { try { const client = redis.getClientSafe() - const key = `${this.SESSION_MAPPING_PREFIX}${sessionHash}` + const key = this._getSessionMappingKey(sessionHash, oauthProvider) + if (!key) { + return false + } const remainingTTL = await client.ttl(key) if (remainingTTL === -2) { diff --git a/src/utils/anthropicRequestDump.js b/src/utils/anthropicRequestDump.js new file mode 100644 index 00000000..8e755064 --- /dev/null +++ b/src/utils/anthropicRequestDump.js @@ -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 +} diff --git a/src/utils/anthropicResponseDump.js b/src/utils/anthropicResponseDump.js new file mode 100644 index 00000000..c21605bc --- /dev/null +++ b/src/utils/anthropicResponseDump.js @@ -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 +} diff --git a/src/utils/antigravityModel.js b/src/utils/antigravityModel.js new file mode 100644 index 00000000..53811b4b --- /dev/null +++ b/src/utils/antigravityModel.js @@ -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 +} diff --git a/src/utils/antigravityUpstreamDump.js b/src/utils/antigravityUpstreamDump.js new file mode 100644 index 00000000..56120aa5 --- /dev/null +++ b/src/utils/antigravityUpstreamDump.js @@ -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 +} diff --git a/src/utils/antigravityUpstreamResponseDump.js b/src/utils/antigravityUpstreamResponseDump.js new file mode 100644 index 00000000..177b1d11 --- /dev/null +++ b/src/utils/antigravityUpstreamResponseDump.js @@ -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 +} diff --git a/src/utils/errorSanitizer.js b/src/utils/errorSanitizer.js index 44c17cd5..f72afdac 100644 --- a/src/utils/errorSanitizer.js +++ b/src/utils/errorSanitizer.js @@ -1,162 +1,228 @@ /** - * 错误消息清理工具 - * 用于移除上游错误中的供应商特定信息(如 URL、引用等) + * 错误消息清理工具 - 白名单错误码制 + * 所有错误映射到预定义的标准错误码,原始消息只记日志不返回前端 */ +const logger = require('./logger') + +// 标准错误码定义 +const ERROR_CODES = { + E001: { message: 'Service temporarily unavailable', status: 503 }, + E002: { message: 'Network connection failed', status: 502 }, + E003: { message: 'Authentication failed', status: 401 }, + E004: { message: 'Rate limit exceeded', status: 429 }, + E005: { message: 'Invalid request', status: 400 }, + E006: { message: 'Model not available', status: 503 }, + E007: { message: 'Upstream service error', status: 502 }, + E008: { message: 'Request timeout', status: 504 }, + E009: { message: 'Permission denied', status: 403 }, + E010: { message: 'Resource not found', status: 404 }, + E011: { message: 'Account temporarily unavailable', status: 503 }, + E012: { message: 'Server overloaded', status: 529 }, + E013: { message: 'Invalid API key', status: 401 }, + E014: { message: 'Quota exceeded', status: 429 }, + E015: { message: 'Internal server error', status: 500 } +} + +// 错误特征匹配规则(按优先级排序) +const ERROR_MATCHERS = [ + // 网络层错误 + { pattern: /ENOTFOUND|DNS|getaddrinfo/i, code: 'E002' }, + { pattern: /ECONNREFUSED|ECONNRESET|connection refused/i, code: 'E002' }, + { pattern: /ETIMEDOUT|timeout/i, code: 'E008' }, + { pattern: /ECONNABORTED|aborted/i, code: 'E002' }, + + // 认证错误 + { pattern: /unauthorized|invalid.*token|token.*invalid|invalid.*key/i, code: 'E003' }, + { pattern: /invalid.*api.*key|api.*key.*invalid/i, code: 'E013' }, + { pattern: /authentication|auth.*fail/i, code: 'E003' }, + + // 权限错误 + { pattern: /forbidden|permission.*denied|access.*denied/i, code: 'E009' }, + { pattern: /does not have.*permission/i, code: 'E009' }, + + // 限流错误 + { pattern: /rate.*limit|too many requests|429/i, code: 'E004' }, + { pattern: /quota.*exceeded|usage.*limit/i, code: 'E014' }, + + // 过载错误 + { pattern: /overloaded|529|capacity/i, code: 'E012' }, + + // 账户错误 + { pattern: /account.*disabled|organization.*disabled/i, code: 'E011' }, + { pattern: /too many active sessions/i, code: 'E011' }, + + // 模型错误 + { pattern: /model.*not.*found|model.*unavailable|unsupported.*model/i, code: 'E006' }, + + // 请求错误 + { pattern: /bad.*request|invalid.*request|malformed/i, code: 'E005' }, + { pattern: /not.*found|404/i, code: 'E010' }, + + // 上游错误 + { pattern: /upstream|502|bad.*gateway/i, code: 'E007' }, + { pattern: /503|service.*unavailable/i, code: 'E001' } +] + /** - * 清理错误消息中的 URL 和供应商引用 - * @param {string} message - 原始错误消息 - * @returns {string} - 清理后的消息 + * 根据原始错误匹配标准错误码 + * @param {Error|string|object} error - 原始错误 + * @param {object} options - 选项 + * @param {string} options.context - 错误上下文(用于日志) + * @param {boolean} options.logOriginal - 是否记录原始错误(默认true) + * @returns {{ code: string, message: string, status: number }} */ -function sanitizeErrorMessage(message) { - if (typeof message !== 'string') { - return message +function mapToErrorCode(error, options = {}) { + const { context = 'unknown', logOriginal = true } = options + + // 提取原始错误信息 + const originalMessage = extractOriginalMessage(error) + const errorCode = error?.code || error?.response?.status + const statusCode = error?.response?.status || error?.status || error?.statusCode + + // 记录原始错误到日志(供调试) + if (logOriginal && originalMessage) { + logger.debug(`[ErrorSanitizer] Original error (${context}):`, { + message: originalMessage, + code: errorCode, + status: statusCode + }) } - // 移除 URL(http:// 或 https://) - let cleaned = message.replace(/https?:\/\/[^\s]+/gi, '') + // 匹配错误码 + let matchedCode = 'E015' // 默认:内部服务器错误 - // 移除常见的供应商引用模式 - cleaned = cleaned.replace(/For more (?:details|information|help)[,\s]*/gi, '') - cleaned = cleaned.replace(/(?:please\s+)?visit\s+\S*/gi, '') // 移除 "visit xxx" - cleaned = cleaned.replace(/(?:see|check)\s+(?:our|the)\s+\S*/gi, '') // 移除 "see our xxx" - cleaned = cleaned.replace(/(?:contact|reach)\s+(?:us|support)\s+at\s+\S*/gi, '') // 移除联系信息 - - // 移除供应商特定关键词(包括整个单词) - cleaned = cleaned.replace(/88code\S*/gi, '') - cleaned = cleaned.replace(/duck\S*/gi, '') - cleaned = cleaned.replace(/packy\S*/gi, '') - cleaned = cleaned.replace(/ikun\S*/gi, '') - cleaned = cleaned.replace(/privnode\S*/gi, '') - cleaned = cleaned.replace(/yescode\S*/gi, '') - cleaned = cleaned.replace(/yes.vg\S*/gi, '') - cleaned = cleaned.replace(/share\S*/gi, '') - cleaned = cleaned.replace(/yhlxj\S*/gi, '') - cleaned = cleaned.replace(/gac\S*/gi, '') - cleaned = cleaned.replace(/driod\S*/gi, '') - - cleaned = cleaned.replace(/\s+/g, ' ').trim() - - // 如果消息被清理得太短或为空,返回通用消息 - if (cleaned.length < 5) { - return 'The requested model is currently unavailable' + // 先按 HTTP 状态码快速匹配 + if (statusCode) { + if (statusCode === 401) matchedCode = 'E003' + else if (statusCode === 403) matchedCode = 'E009' + else if (statusCode === 404) matchedCode = 'E010' + else if (statusCode === 429) matchedCode = 'E004' + else if (statusCode === 502) matchedCode = 'E007' + else if (statusCode === 503) matchedCode = 'E001' + else if (statusCode === 504) matchedCode = 'E008' + else if (statusCode === 529) matchedCode = 'E012' } - return cleaned + // 再按消息内容精确匹配(可能覆盖状态码匹配) + if (originalMessage) { + for (const matcher of ERROR_MATCHERS) { + if (matcher.pattern.test(originalMessage)) { + matchedCode = matcher.code + break + } + } + } + + // 按错误 code 匹配(网络错误) + if (errorCode) { + const codeStr = String(errorCode).toUpperCase() + if (codeStr === 'ENOTFOUND' || codeStr === 'EAI_AGAIN') matchedCode = 'E002' + else if (codeStr === 'ECONNREFUSED' || codeStr === 'ECONNRESET') matchedCode = 'E002' + else if (codeStr === 'ETIMEDOUT' || codeStr === 'ESOCKETTIMEDOUT') matchedCode = 'E008' + else if (codeStr === 'ECONNABORTED') matchedCode = 'E002' + } + + const result = ERROR_CODES[matchedCode] + return { + code: matchedCode, + message: result.message, + status: result.status + } } /** - * 递归清理对象中的所有错误消息字段 - * @param {Object} errorData - 原始错误数据对象 - * @returns {Object} - 清理后的错误数据 + * 提取原始错误消息 */ -function sanitizeUpstreamError(errorData) { - if (!errorData || typeof errorData !== 'object') { - return errorData - } - - // 深拷贝避免修改原始对象 - const sanitized = JSON.parse(JSON.stringify(errorData)) - - // 递归清理嵌套的错误对象 - const sanitizeObject = (obj) => { - if (!obj || typeof obj !== 'object') { - return obj - } - - for (const key in obj) { - // 清理所有字符串字段,不仅仅是 message - if (typeof obj[key] === 'string') { - obj[key] = sanitizeErrorMessage(obj[key]) - } else if (typeof obj[key] === 'object') { - sanitizeObject(obj[key]) - } - } - - return obj - } - - return sanitizeObject(sanitized) -} - -/** - * 提取错误消息(支持多种错误格式) - * @param {*} body - 错误响应体(字符串或对象) - * @returns {string} - 提取的错误消息 - */ -function extractErrorMessage(body) { - if (!body) { - return '' - } - - // 处理字符串类型 - if (typeof body === 'string') { - const trimmed = body.trim() - if (!trimmed) { - return '' - } - try { - const parsed = JSON.parse(trimmed) - return extractErrorMessage(parsed) - } catch (error) { - return trimmed - } - } - - // 处理对象类型 - if (typeof body === 'object') { - // 常见错误格式: { error: "message" } - if (typeof body.error === 'string') { - return body.error - } - // 嵌套错误格式: { error: { message: "..." } } - if (body.error && typeof body.error === 'object') { - if (typeof body.error.message === 'string') { - return body.error.message - } - if (typeof body.error.error === 'string') { - return body.error.error - } - } - // 直接消息格式: { message: "..." } - if (typeof body.message === 'string') { - return body.message - } - } - +function extractOriginalMessage(error) { + if (!error) return '' + if (typeof error === 'string') return error + if (error.message) return error.message + if (error.response?.data?.error?.message) return error.response.data.error.message + if (error.response?.data?.error) return String(error.response.data.error) + if (error.response?.data?.message) return error.response.data.message return '' } /** - * 检测是否为账户被禁用或不可用的 400 错误 - * @param {number} statusCode - HTTP 状态码 - * @param {*} body - 响应体 - * @returns {boolean} - 是否为账户禁用错误 + * 创建安全的错误响应对象 + * @param {Error|string|object} error - 原始错误 + * @param {object} options - 选项 + * @returns {{ error: { code: string, message: string }, status: number }} */ -function isAccountDisabledError(statusCode, body) { - if (statusCode !== 400) { - return false +function createSafeErrorResponse(error, options = {}) { + const mapped = mapToErrorCode(error, options) + return { + error: { + code: mapped.code, + message: mapped.message + }, + status: mapped.status } +} - const message = extractErrorMessage(body) - if (!message) { - return false - } - // 将消息全部转换为小写,进行模糊匹配(避免大小写问题) - const lowerMessage = message.toLowerCase() - // 检测常见的账户禁用/不可用模式 +/** + * 创建安全的 SSE 错误事件 + * @param {Error|string|object} error - 原始错误 + * @param {object} options - 选项 + * @returns {string} - SSE 格式的错误事件 + */ +function createSafeSSEError(error, options = {}) { + const mapped = mapToErrorCode(error, options) + return `event: error\ndata: ${JSON.stringify({ + error: mapped.message, + code: mapped.code, + timestamp: new Date().toISOString() + })}\n\n` +} + +/** + * 获取安全的错误消息(用于替换 error.message) + * @param {Error|string|object} error - 原始错误 + * @param {object} options - 选项 + * @returns {string} + */ +function getSafeMessage(error, options = {}) { + return mapToErrorCode(error, options).message +} + +// 兼容旧接口 +function sanitizeErrorMessage(message) { + if (!message) return 'Service temporarily unavailable' + return mapToErrorCode({ message }, { logOriginal: false }).message +} + +function sanitizeUpstreamError(errorData) { + return createSafeErrorResponse(errorData, { logOriginal: false }) +} + +function extractErrorMessage(body) { + return extractOriginalMessage(body) +} + +function isAccountDisabledError(statusCode, body) { + if (statusCode !== 400) return false + const message = extractOriginalMessage(body) + if (!message) return false + const lower = message.toLowerCase() return ( - lowerMessage.includes('organization has been disabled') || - lowerMessage.includes('account has been disabled') || - lowerMessage.includes('account is disabled') || - lowerMessage.includes('no account supporting') || - lowerMessage.includes('account not found') || - lowerMessage.includes('invalid account') || - lowerMessage.includes('too many active sessions') + lower.includes('organization has been disabled') || + lower.includes('account has been disabled') || + lower.includes('account is disabled') || + lower.includes('no account supporting') || + lower.includes('account not found') || + lower.includes('invalid account') || + lower.includes('too many active sessions') ) } module.exports = { + ERROR_CODES, + mapToErrorCode, + createSafeErrorResponse, + createSafeSSEError, + getSafeMessage, + // 兼容旧接口 sanitizeErrorMessage, sanitizeUpstreamError, extractErrorMessage, diff --git a/src/utils/featureFlags.js b/src/utils/featureFlags.js new file mode 100644 index 00000000..c2dd4f07 --- /dev/null +++ b/src/utils/featureFlags.js @@ -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 +} diff --git a/src/utils/geminiSchemaCleaner.js b/src/utils/geminiSchemaCleaner.js new file mode 100644 index 00000000..fa4d1b33 --- /dev/null +++ b/src/utils/geminiSchemaCleaner.js @@ -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 +} diff --git a/src/utils/modelHelper.js b/src/utils/modelHelper.js index a42ee317..591c1974 100644 --- a/src/utils/modelHelper.js +++ b/src/utils/modelHelper.js @@ -5,6 +5,10 @@ * Supports parsing model strings like "ccr,model_name" to extract vendor type and base model. */ +// 仅保留原仓库既有的模型前缀:CCR 路由 +// Gemini/Antigravity 采用“路径分流”,避免在 model 字段里混入 vendor 前缀造成混乱 +const SUPPORTED_VENDOR_PREFIXES = ['ccr'] + /** * Parse vendor-prefixed model string * @param {string} modelStr - Model string, potentially with vendor prefix (e.g., "ccr,gemini-2.5-pro") @@ -19,16 +23,21 @@ function parseVendorPrefixedModel(modelStr) { const trimmed = modelStr.trim() const lowerTrimmed = trimmed.toLowerCase() - // Check for ccr prefix (case insensitive) - if (lowerTrimmed.startsWith('ccr,')) { + for (const vendorPrefix of SUPPORTED_VENDOR_PREFIXES) { + if (!lowerTrimmed.startsWith(`${vendorPrefix},`)) { + continue + } + const parts = trimmed.split(',') - if (parts.length >= 2) { - // Extract base model (everything after the first comma, rejoined in case model name contains commas) - const baseModel = parts.slice(1).join(',').trim() - return { - vendor: 'ccr', - baseModel - } + if (parts.length < 2) { + break + } + + // Extract base model (everything after the first comma, rejoined in case model name contains commas) + const baseModel = parts.slice(1).join(',').trim() + return { + vendor: vendorPrefix, + baseModel } } diff --git a/src/utils/projectPaths.js b/src/utils/projectPaths.js new file mode 100644 index 00000000..c2f30762 --- /dev/null +++ b/src/utils/projectPaths.js @@ -0,0 +1,10 @@ +const path = require('path') + +// 该文件位于 src/utils 下,向上两级即项目根目录。 +function getProjectRoot() { + return path.resolve(__dirname, '..', '..') +} + +module.exports = { + getProjectRoot +} diff --git a/src/utils/rateLimitHelper.js b/src/utils/rateLimitHelper.js index 38c38568..45c00c0f 100644 --- a/src/utils/rateLimitHelper.js +++ b/src/utils/rateLimitHelper.js @@ -7,9 +7,10 @@ function toNumber(value) { return Number.isFinite(num) ? num : 0 } -async function updateRateLimitCounters(rateLimitInfo, usageSummary, model) { +// keyId 和 accountType 用于计算倍率成本 +async function updateRateLimitCounters(rateLimitInfo, usageSummary, model, keyId = null, accountType = null) { if (!rateLimitInfo) { - return { totalTokens: 0, totalCost: 0 } + return { totalTokens: 0, totalCost: 0, ratedCost: 0 } } const client = redis.getClient() @@ -59,11 +60,25 @@ async function updateRateLimitCounters(rateLimitInfo, usageSummary, model) { } } - if (totalCost > 0 && rateLimitInfo.costCountKey) { - await client.incrbyfloat(rateLimitInfo.costCountKey, totalCost) + // 计算倍率成本(用于限流计数) + let ratedCost = totalCost + if (totalCost > 0 && keyId) { + try { + const apiKeyService = require('../services/apiKeyService') + const serviceRatesService = require('../services/serviceRatesService') + const service = serviceRatesService.getService(accountType, model) + ratedCost = await apiKeyService.calculateRatedCost(keyId, service, totalCost) + } catch (error) { + // 倍率计算失败时使用真实成本 + ratedCost = totalCost + } } - return { totalTokens, totalCost } + if (ratedCost > 0 && rateLimitInfo.costCountKey) { + await client.incrbyfloat(rateLimitInfo.costCountKey, ratedCost) + } + + return { totalTokens, totalCost, ratedCost } } module.exports = { diff --git a/src/utils/safeRotatingAppend.js b/src/utils/safeRotatingAppend.js new file mode 100644 index 00000000..21afecc6 --- /dev/null +++ b/src/utils/safeRotatingAppend.js @@ -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 +} diff --git a/src/utils/signatureCache.js b/src/utils/signatureCache.js new file mode 100644 index 00000000..7f691b8e --- /dev/null +++ b/src/utils/signatureCache.js @@ -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 +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 +} diff --git a/src/validators/clients/claudeCodeValidator.js b/src/validators/clients/claudeCodeValidator.js index 2a49fca2..4788f178 100644 --- a/src/validators/clients/claudeCodeValidator.js +++ b/src/validators/clients/claudeCodeValidator.js @@ -62,12 +62,17 @@ class ClaudeCodeValidator { for (const entry of systemEntries) { const rawText = typeof entry?.text === 'string' ? entry.text : '' - const { bestScore } = bestSimilarityByTemplates(rawText) + const { bestScore, templateId, maskedRaw } = bestSimilarityByTemplates(rawText) if (bestScore < threshold) { logger.error( `Claude system prompt similarity below threshold: score=${bestScore.toFixed(4)}, threshold=${threshold}` ) - logger.warn(`Claude system prompt detail: ${rawText}`) + const preview = typeof maskedRaw === 'string' ? maskedRaw.slice(0, 200) : '' + logger.warn( + `Claude system prompt detail: templateId=${templateId || 'unknown'}, preview=${preview}${ + maskedRaw && maskedRaw.length > 200 ? '…' : '' + }` + ) return false } } diff --git a/tests/accountBalanceService.test.js b/tests/accountBalanceService.test.js new file mode 100644 index 00000000..c2a9c3a8 --- /dev/null +++ b/tests/accountBalanceService.test.js @@ -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) + }) +}) diff --git a/web/admin-spa/index.html b/web/admin-spa/index.html index 27732008..53328eb7 100644 --- a/web/admin-spa/index.html +++ b/web/admin-spa/index.html @@ -5,20 +5,6 @@ Claude Relay Service - 管理后台 - - - - - - - - - - - - - - diff --git a/web/admin-spa/package-lock.json b/web/admin-spa/package-lock.json index 481df56a..9405609e 100644 --- a/web/admin-spa/package-lock.json +++ b/web/admin-spa/package-lock.json @@ -1157,6 +1157,7 @@ "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/lodash": "*" } @@ -1351,6 +1352,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1587,6 +1589,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", @@ -3060,13 +3063,15 @@ "version": "4.17.21", "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash-es": { "version": "4.17.21", "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz", "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash-unified": { "version": "1.0.3", @@ -3618,6 +3623,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -3764,6 +3770,7 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -4028,6 +4035,7 @@ "integrity": "sha512-33xGNBsDJAkzt0PvninskHlWnTIPgDtTwhg0U38CUoNP/7H6wI2Cz6dUeoNPbjdTdsYTGuiFFASuUOWovH0SyQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -4525,6 +4533,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4915,6 +4924,7 @@ "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -5115,6 +5125,7 @@ "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.18.tgz", "integrity": "sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.18", "@vue/compiler-sfc": "3.5.18", diff --git a/web/admin-spa/src/assets/fonts/inter/Inter-Bold.woff2 b/web/admin-spa/src/assets/fonts/inter/Inter-Bold.woff2 new file mode 100644 index 00000000..b9e3cb3b Binary files /dev/null and b/web/admin-spa/src/assets/fonts/inter/Inter-Bold.woff2 differ diff --git a/web/admin-spa/src/assets/fonts/inter/Inter-Light.woff2 b/web/admin-spa/src/assets/fonts/inter/Inter-Light.woff2 new file mode 100644 index 00000000..f3e012a4 Binary files /dev/null and b/web/admin-spa/src/assets/fonts/inter/Inter-Light.woff2 differ diff --git a/web/admin-spa/src/assets/fonts/inter/Inter-Medium.woff2 b/web/admin-spa/src/assets/fonts/inter/Inter-Medium.woff2 new file mode 100644 index 00000000..fdfdcc69 Binary files /dev/null and b/web/admin-spa/src/assets/fonts/inter/Inter-Medium.woff2 differ diff --git a/web/admin-spa/src/assets/fonts/inter/Inter-Regular.woff2 b/web/admin-spa/src/assets/fonts/inter/Inter-Regular.woff2 new file mode 100644 index 00000000..2bcd222e Binary files /dev/null and b/web/admin-spa/src/assets/fonts/inter/Inter-Regular.woff2 differ diff --git a/web/admin-spa/src/assets/fonts/inter/Inter-SemiBold.woff2 b/web/admin-spa/src/assets/fonts/inter/Inter-SemiBold.woff2 new file mode 100644 index 00000000..fbae113d Binary files /dev/null and b/web/admin-spa/src/assets/fonts/inter/Inter-SemiBold.woff2 differ diff --git a/web/admin-spa/src/assets/fonts/inter/inter.css b/web/admin-spa/src/assets/fonts/inter/inter.css new file mode 100644 index 00000000..4d5d03bf --- /dev/null +++ b/web/admin-spa/src/assets/fonts/inter/inter.css @@ -0,0 +1,46 @@ +/* Inter 字体本地化 - 仅包含项目使用的字重 (300, 400, 500, 600, 700) */ + +/* Inter Light - 300 */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 300; + font-display: swap; + src: url('./Inter-Light.woff2') format('woff2'); +} + +/* Inter Regular - 400 */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('./Inter-Regular.woff2') format('woff2'); +} + +/* Inter Medium - 500 */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('./Inter-Medium.woff2') format('woff2'); +} + +/* Inter SemiBold - 600 */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url('./Inter-SemiBold.woff2') format('woff2'); +} + +/* Inter Bold - 700 */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url('./Inter-Bold.woff2') format('woff2'); +} diff --git a/web/admin-spa/src/components/accounts/AccountBalanceScriptModal.vue b/web/admin-spa/src/components/accounts/AccountBalanceScriptModal.vue new file mode 100644 index 00000000..d3f85aa9 --- /dev/null +++ b/web/admin-spa/src/components/accounts/AccountBalanceScriptModal.vue @@ -0,0 +1,293 @@ + + + + + diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue index d6e00ecd..af9b3d24 100644 --- a/web/admin-spa/src/components/accounts/AccountForm.vue +++ b/web/admin-spa/src/components/accounts/AccountForm.vue @@ -477,6 +477,36 @@ +