diff --git a/.env.example b/.env.example index eeb10de0..75f4683a 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..e267fbcd 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,8 @@ -# Claude Relay Service +# Claude Relay Service (Antigravity Edition) + +> **二开维护:dadongwo** +> +> 目标:让 `claude`(Claude Code CLI)与 Antigravity / Gemini 账户体系无缝对接,并提供可观测、可运维的稳定转发服务。 > [!CAUTION] > **安全更新通知**:v1.1.248 及以下版本存在严重的管理员认证绕过漏洞,攻击者可未授权访问管理面板。 @@ -9,997 +13,169 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Node.js](https://img.shields.io/badge/Node.js-18+-green.svg)](https://nodejs.org/) -[![Redis](https://img.shields.io/badge/Redis-6+-red.svg)](https://redis.io/) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) -[![Docker Build](https://github.com/Wei-Shaw/claude-relay-service/actions/workflows/auto-release-pipeline.yml/badge.svg)](https://github.com/Wei-Shaw/claude-relay-service/actions/workflows/auto-release-pipeline.yml) -[![Docker Pulls](https://img.shields.io/docker/pulls/weishaw/claude-relay-service)](https://hub.docker.com/r/weishaw/claude-relay-service) -**🔐 自行搭建Claude API中转服务,支持多账户管理** - -[English](README_EN.md) • [快速开始](https://pincc.ai/) • [演示站点](https://demo.pincc.ai/admin-next/login) • [公告频道](https://t.me/claude_relay_service) +**🔐 Claude Code 原生适配 · Antigravity 生态 · 多账户管理** --- -## 💎 Claude/Codex 拼车服务推荐 +## 🌟 核心亮点 -
+这是一个二开项目:在原版 CRS 基础上补齐 Claude Code 协议层兼容、完善 Antigravity OAuth 与路径分流,并增强稳定性与可观测性。 -| 平台 | 类型 | 服务 | 介绍 | -|:---|:---|:---|:---| -| **[pincc.ai](https://pincc.ai/)** | 🏆 **官方运营** | ✅ Claude Code
✅ Codex CLI
| 项目直营,提供稳定的 Claude Code / Codex CLI 拼车服务 | -| **[ctok.ai](https://ctok.ai/)** | 🤝 合作伙伴 | ✅ Claude Code
✅ Codex CLI
| 社区认证,提供 Claude Code / Codex CLI 拼车 | +### 1. 🚀 Claude Code 原生级兼容 (Killer Feature) +无需任何魔法,让你的 `claude` 命令行工具像连接官方一样连接到本服务。 +- **Thinking Signature 伪造/缓存/恢复**:解决 Claude Code 3.7+ 对 `thoughtSignature` 的强校验,支持兜底签名策略与签名缓存。 +- **Tool Result 透传**:兼容 Base64 图片等复杂结构,避免转发丢失/格式错误。 +- **消息并发治理**:拆分 Claude Code 混合发送的 `tool_result + user_text`,按协议顺序转发。 +- **僵尸流看门狗**:SSE 连接 45 秒无有效数据自动断开,避免“假活着”导致会话/额度被占用。 -
+### 2. 🛡️ Antigravity & Gemini 深度集成 +- **Antigravity OAuth 支持**:新增 `gemini-antigravity` 账户类型,支持 OAuth 授权与权限校验。 +- **路径即路由 (Path-Based Routing)**: + - `/antigravity/api` -> 自动路由到 Antigravity 账户池 + - `/gemini-cli/api` -> 自动路由到 Gemini 账户池 + - 告别在模型名前加前缀(如 `gemini/claude-3-5`)的混乱做法,Client 端只需改 Base URL 即可。 +- **额度与模型动态列表适配**:针对 Antigravity 的 `fetchAvailableModels` 做标准化展示(管理后台)与透传(接口)。 + +### 3. ⚙️ 企业级稳定性 +- **智能重试与切换账号**:针对 Antigravity `429 Resource Exhausted`,自动清理会话并切换账号重试(流式/非流式均覆盖)。 +- **日志安全与轮转**:避免循环引用导致的进程崩溃,并对 Dump 文件进行大小控制与轮转。 +- **调试利器**:支持请求/响应/工具定义/上游请求与上游 SSE 响应的 JSONL 转储,便于复现与定位问题。 + +## 📊 额度与模型查询 (Antigravity 专属) + +### 查看账户额度 / Quota +本服务深度适配了 Antigravity 的实时配额查询接口 (v1internal:fetchAvailableModels)。 + +1. 进入管理后台 -> **账号管理 (Claude 账户)**。 +2. 找到您的 `gemini-antigravity` 类型账户。 +3. 点击卡片右上角的 **"测试/刷新"** 按钮。 +4. 系统会自动拉取上游最新的配额信息(支持 Gemini Pro / Flash / Image 等不同分类),并将其标准化展示为百分比与重置时间。 + +### 获取动态模型列表 +由于 Antigravity 的模型 ID 是动态更新的(如 `gemini-2.0-flash-exp`),本服务提供了透传查询接口。 + +- **接口地址(Anthropic/Claude Code 路由)**: `GET /antigravity/api/v1/models` +- **接口地址(OpenAI 兼容路由)**: `GET /openai/gemini/models`(或 `GET /openai/gemini/v1/models`) +- **说明**: `/antigravity/api/v1/models` 会实时透传 Antigravity 上游 `fetchAvailableModels` 结果,确保看到当前账户可用的最新模型列表。 --- -## ⚠️ 重要提醒 +## 🎮 快速开始指南 -**使用本项目前请仔细阅读:** +### 0. 环境要求 +- Node.js 18+(或使用 Docker) +- Redis 6+/7+ -🚨 **服务条款风险**: 使用本项目可能违反Anthropic的服务条款。请在使用前仔细阅读Anthropic的用户协议,使用本项目的一切风险由用户自行承担。 +### 1. Claude Code (CLI) 配置 -📖 **免责声明**: 本项目仅供技术学习和研究使用,作者不对因使用本项目导致的账户封禁、服务中断或其他损失承担任何责任。 +无需修改代码,只需设置环境变量即可无缝切换后端。 - -## 🤔 这个项目适合你吗? - -- 🌍 **地区限制**: 所在地区无法直接访问Claude Code服务? -- 🔒 **隐私担忧**: 担心第三方镜像服务会记录或泄露你的对话内容? -- 👥 **成本分摊**: 想和朋友一起分摊Claude Code Max订阅费用? -- ⚡ **稳定性**: 第三方镜像站经常故障不稳定,影响效率 ? - -如果有以上困惑,那这个项目可能适合你。 - -### 适合的场景 - -✅ **找朋友拼车**: 三五好友一起分摊Claude Code Max订阅 -✅ **隐私敏感**: 不想让第三方镜像看到你的对话内容 -✅ **技术折腾**: 有基本的技术基础,愿意自己搭建和维护 -✅ **稳定需求**: 需要长期稳定的Claude访问,不想受制于镜像站 -✅ **地区受限**: 无法直接访问Claude官方服务 - ---- - -## 💭 为什么要自己搭? - -### 现有镜像站可能的问题 - -- 🕵️ **隐私风险**: 你的对话内容都被人家看得一清二楚,商业机密什么的就别想了 -- 🐌 **性能不稳**: 用的人多了就慢,高峰期经常卡死 -- 💰 **价格不透明**: 不知道实际成本 - -### 自建的好处 - -- 🔐 **数据安全**: 所有接口请求都只经过你自己的服务器,直连Anthropic API -- ⚡ **性能可控**: 就你们几个人用,Max 200刀套餐基本上可以爽用Opus -- 💰 **成本透明**: 用了多少token一目了然,按官方价格换算了具体费用 -- 📊 **监控完整**: 使用情况、成本分析、性能监控全都有 - ---- - -## 🚀 核心功能 - -### 基础功能 - -- ✅ **多账户管理**: 可以添加多个Claude账户自动轮换 -- ✅ **自定义API Key**: 给每个人分配独立的Key -- ✅ **使用统计**: 详细记录每个人用了多少token - -### 高级功能 - -- 🔄 **智能切换**: 账户出问题自动换下一个 -- 🚀 **性能优化**: 连接池、缓存,减少延迟 -- 📊 **监控面板**: Web界面查看所有数据 -- 🛡️ **安全控制**: 访问限制、速率控制、客户端限制 -- 🌐 **代理支持**: 支持HTTP/SOCKS5代理 - ---- - -## 📋 部署要求 - -### 硬件要求(最低配置) - -- **CPU**: 1核心就够了 -- **内存**: 512MB(建议1GB) -- **硬盘**: 30GB可用空间 -- **网络**: 能访问到Anthropic API(建议使用US地区的机器) -- **建议**: 2核4G的基本够了,网络尽量选回国线路快一点的(为了提高速度,建议不要开代理或者设置服务器的IP直连) -- **经验**: 阿里云、腾讯云的海外主机经测试会被Cloudflare拦截,无法直接访问claude api - -### 软件要求 - -- **Node.js** 18或更高版本 -- **Redis** 6或更高版本 -- **操作系统**: 建议Linux - -### 费用估算 - -- **服务器**: 轻量云服务器,一个月30-60块 -- **Claude订阅**: 看你怎么分摊了 -- **其他**: 域名(可选) - ---- - -## 🚀 脚本部署(推荐) - -推荐使用管理脚本进行一键部署,简单快捷,自动处理所有依赖和配置。 - -### 快速安装 +#### 方案 A: 使用 Antigravity 账户池 (推荐) +适用于通过 Antigravity 渠道使用 Claude 模型 (如 `claude-opus-4-5` 等)。 ```bash -curl -fsSL https://pincc.ai/manage.sh -o manage.sh && chmod +x manage.sh && ./manage.sh install -``` +# 1. 设置 Base URL 为 Antigravity 专用路径 +export ANTHROPIC_BASE_URL="http://你的服务器IP:3000/antigravity/api/" -### 脚本功能 +# 2. 设置 API Key (在后台创建,权限需包含 'all' 或 'gemini') +export ANTHROPIC_AUTH_TOKEN="cr_xxxxxxxxxxxx" -- ✅ **一键安装**: 自动检测系统环境,安装 Node.js 18+、Redis 等依赖 -- ✅ **交互式配置**: 友好的配置向导,设置端口、Redis 连接等 -- ✅ **自动启动**: 安装完成后自动启动服务并显示访问地址 -- ✅ **便捷管理**: 通过 `crs` 命令随时管理服务状态 +# 3. 指定模型名称 (直接使用短名,无需前缀!) +export ANTHROPIC_MODEL="claude-opus-4-5" -### 管理命令 - -```bash -crs install # 安装服务 -crs start # 启动服务 -crs stop # 停止服务 -crs restart # 重启服务 -crs status # 查看状态 -crs update # 更新服务 -crs uninstall # 卸载服务 -``` - -### 安装示例 - -```bash -$ crs install - -# 会依次询问: -安装目录 (默认: ~/claude-relay-service): -服务端口 (默认: 3000): 8080 -Redis 地址 (默认: localhost): -Redis 端口 (默认: 6379): -Redis 密码 (默认: 无密码): - -# 安装完成后自动启动并显示: -服务已成功安装并启动! - -访问地址: - 本地 Web: http://localhost:8080/web - 公网 Web: http://YOUR_IP:8080/web - -管理员账号信息已保存到: data/init.json -``` - -### 系统要求 - -- 支持系统: Ubuntu/Debian、CentOS/RedHat、Arch Linux、macOS -- 自动安装 Node.js 18+ 和 Redis -- Redis 使用系统默认位置,数据独立于应用 - ---- - -## 📦 手动部署 - -### 第一步:环境准备 - -**Ubuntu/Debian用户:** - -```bash -# 安装Node.js -curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash - -sudo apt-get install -y nodejs - -# 安装Redis -sudo apt update -sudo apt install redis-server -sudo systemctl start redis-server -``` - -**CentOS/RHEL用户:** - -```bash -# 安装Node.js -curl -fsSL https://rpm.nodesource.com/setup_18.x | sudo bash - -sudo yum install -y nodejs - -# 安装Redis -sudo yum install redis -sudo systemctl start redis -``` - -### 第二步:下载和配置 - -```bash -# 下载项目 -git clone https://github.com/Wei-Shaw//claude-relay-service.git -cd claude-relay-service - -# 安装依赖 -npm install - -# 复制配置文件(重要!) -cp config/config.example.js config/config.js -cp .env.example .env -``` - -### 第三步:配置文件设置 - -**编辑 `.env` 文件:** - -```bash -# 这两个密钥随便生成,但要记住 -JWT_SECRET=你的超级秘密密钥 -ENCRYPTION_KEY=32位的加密密钥随便写 - -# Redis配置 -REDIS_HOST=localhost -REDIS_PORT=6379 -REDIS_PASSWORD= - -``` - -**编辑 `config/config.js` 文件:** - -```javascript -module.exports = { - server: { - port: 3000, // 服务端口,可以改 - host: '0.0.0.0' // 不用改 - }, - redis: { - host: '127.0.0.1', // Redis地址 - port: 6379 // Redis端口 - } - // 其他配置保持默认就行 -} -``` - -### 第四步:安装前端依赖并构建 - -```bash -# 安装前端依赖 -npm run install:web - -# 构建前端(生成 dist 目录) -npm run build:web -``` - -### 第五步:启动服务 - -```bash -# 初始化 -npm run setup # 会随机生成后台账号密码信息,存储在 data/init.json -# 或者通过环境变量预设管理员凭据: -# export ADMIN_USERNAME=cr_admin_custom -# export ADMIN_PASSWORD=your-secure-password - -# 启动服务 -npm run service:start:daemon # 后台运行 - -# 查看状态 -npm run service:status -``` - ---- - -## 🐳 Docker 部署 - -### Docker compose - -#### 第一步:下载构建docker-compose.yml文件的脚本并执行 -```bash -curl -fsSL https://pincc.ai/crs-compose.sh -o crs-compose.sh && chmod +x crs-compose.sh && ./crs-compose.sh -``` - -#### 第二步:启动 -```bash -docker-compose up -d -``` - -### Docker Compose 配置 - -docker-compose.yml 已包含: - -- ✅ 自动初始化管理员账号 -- ✅ 数据持久化(logs和data目录自动挂载) -- ✅ Redis数据库 -- ✅ 健康检查 -- ✅ 自动重启 - -### 环境变量说明 - -#### 必填项 - -- `JWT_SECRET`: JWT密钥,至少32个字符 -- `ENCRYPTION_KEY`: 加密密钥,必须是32个字符 - -#### 可选项 - -- `ADMIN_USERNAME`: 管理员用户名(不设置则自动生成) -- `ADMIN_PASSWORD`: 管理员密码(不设置则自动生成) -- `LOG_LEVEL`: 日志级别(默认:info) -- 更多配置项请参考 `.env.example` 文件 - -### 管理员凭据获取方式 - -1. **查看容器日志** - - ```bash - docker logs claude-relay-service - ``` - -2. **查看挂载的文件** - - ```bash - cat ./data/init.json - ``` - -3. **使用环境变量预设** - ```bash - # 在 .env 文件中设置 - ADMIN_USERNAME=cr_admin_custom - ADMIN_PASSWORD=your-secure-password - ``` - ---- - -## 🎮 开始使用 - -### 1. 打开管理界面 - -浏览器访问:`http://你的服务器IP:3000/web` - -管理员账号: - -- 自动生成:查看 data/init.json -- 环境变量预设:通过 ADMIN_USERNAME 和 ADMIN_PASSWORD 设置 -- Docker 部署:查看容器日志 `docker logs claude-relay-service` - -### 2. 添加Claude账户 - -这一步比较关键,需要OAuth授权: - -1. 点击「Claude账户」标签 -2. 如果你担心多个账号共用1个IP怕被封禁,可以选择设置静态代理IP(可选) -3. 点击「添加账户」 -4. 点击「生成授权链接」,会打开一个新页面 -5. 在新页面完成Claude登录和授权 -6. 复制返回的Authorization Code -7. 粘贴到页面完成添加 - -**注意**: 如果你在国内,这一步可能需要科学上网。 - -### 3. 创建API Key - -给每个使用者分配一个Key: - -1. 点击「API Keys」标签 -2. 点击「创建新Key」 -3. 给Key起个名字,比如「张三的Key」 -4. 设置使用限制(可选): - - **速率限制**: 限制每个时间窗口的请求次数和Token使用量 - - **并发限制**: 限制同时处理的请求数 - - **模型限制**: 限制可访问的模型列表 - - **客户端限制**: 限制只允许特定客户端使用(如ClaudeCode、Gemini-CLI等) -5. 保存,记下生成的Key - -### 4. 开始使用 Claude Code 和 Gemini CLI - -现在你可以用自己的服务替换官方API了: - -**Claude Code 设置环境变量:** - -默认使用标准 Claude 账号池: - -```bash -export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # 根据实际填写你服务器的ip地址或者域名 -export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥" -``` - -**VSCode Claude 插件配置:** - -如果使用 VSCode 的 Claude 插件,需要在 `~/.claude/config.json` 文件中配置: - -```json -{ - "primaryApiKey": "crs" -} -``` - -如果该文件不存在,请手动创建。Windows 用户路径为 `C:\Users\你的用户名\.claude\config.json`。 - -> 💡 **IntelliJ IDEA 用户推荐**:[Claude Code Plus](https://github.com/touwaeriol/claude-code-plus) - 将 Claude Code 直接集成到 IDE,支持代码理解、文件读写、命令执行。插件市场搜索 `Claude Code Plus` 即可安装。 - -**Gemini CLI 设置环境变量:** - -**方式一(推荐):通过 Gemini Assist API 方式访问** - -```bash -CODE_ASSIST_ENDPOINT="http://127.0.0.1:3000/gemini" # 根据实际填写你服务器的ip地址或者域名 -GOOGLE_CLOUD_ACCESS_TOKEN="后台创建的API密钥" -GOOGLE_GENAI_USE_GCA="true" -GEMINI_MODEL="gemini-2.5-pro" # 如果你有gemini3权限可以填: gemini-3-pro-preview -``` - -> **认证**:只能选 ```Login with Google``` 进行认证,如果跳 Google请删除 ```~/.gemini/settings.json``` 后再尝试启动```gemini```。 -> **注意**:gemini-cli 控制台会提示 `Failed to fetch user info: 401 Unauthorized`,但使用不受任何影响。 - -**方式二:通过 Gemini API 方式访问** - - -```bash -GOOGLE_GEMINI_BASE_URL="http://127.0.0.1:3000/gemini" # 根据实际填写你服务器的ip地址或者域名 -GEMINI_API_KEY="后台创建的API密钥" -GEMINI_MODEL="gemini-2.5-pro" # 如果你有gemini3权限可以填: gemini-3-pro-preview -``` - -> **认证**:只能选 ```Use Gemini API Key``` 进行认证,如果提示 ```Enter Gemini API Key``` 请直接留空按回车。如果一打开就跳 Google请删除 ```~/.gemini/settings.json``` 后再尝试启动```gemini```。 - -> 💡 **进阶用法**:想在 Claude Code 中直接使用 Gemini 3 模型?请参考 [Claude Code 调用 Gemini 3 模型指南](docs/claude-code-gemini3-guide/README.md) - -**使用 Claude Code:** - -```bash +# 4. 启动 claude ``` -**使用 Gemini CLI:** +#### 方案 B: 使用 Gemini 账户池 (Gemini Models) +适用于直接调用 Google Gemini 模型 (如 `gemini-2.5-pro`)。 ```bash -gemini # 或其他 Gemini CLI 命令 +export ANTHROPIC_BASE_URL="http://你的服务器IP:3000/gemini-cli/api/" +export ANTHROPIC_AUTH_TOKEN="cr_xxxxxxxxxxxx" +export ANTHROPIC_MODEL="gemini-2.5-pro" +claude ``` -**Codex 配置:** - -在 `~/.codex/config.toml` 文件**开头**添加以下配置: - -```toml -model_provider = "crs" -model = "gpt-5.1-codex-max" -model_reasoning_effort = "high" -disable_response_storage = true -preferred_auth_method = "apikey" - -[model_providers.crs] -name = "crs" -base_url = "http://127.0.0.1:3000/openai" # 根据实际填写你服务器的ip地址或者域名 -wire_api = "responses" -requires_openai_auth = true -env_key = "CRS_OAI_KEY" -``` - -在 `~/.codex/auth.json` 文件中配置API密钥为 null: - -```json -{ - "OPENAI_API_KEY": null -} -``` - -环境变量设置: +#### 方案 C: 标准 Claude 账户池 +适用于原版 Claude / Console / Bedrock 渠道。 ```bash -export CRS_OAI_KEY="后台创建的API密钥" +export ANTHROPIC_BASE_URL="http://你的服务器IP:3000/api/" +export ANTHROPIC_AUTH_TOKEN="cr_xxxxxxxxxxxx" +claude ``` -> ⚠️ 在通过 Nginx 反向代理 CRS 服务并使用 Codex CLI 时,需要在 http 块中添加 underscores_in_headers on;。因为 Nginx 默认会移除带下划线的请求头(如 session_id),一旦该头被丢弃,多账号环境下的粘性会话功能将失效。 - -**Droid CLI 配置:** - -Droid CLI 读取 `~/.factory/config.json`。可以在该文件中添加自定义模型以指向本服务的新端点: - -```json -{ - "custom_models": [ - { - "model_display_name": "Opus 4.5 [crs]", - "model": "claude-opus-4-5-20251101", - "base_url": "http://127.0.0.1:3000/droid/claude", - "api_key": "后台创建的API密钥", - "provider": "anthropic", - "max_tokens": 64000 - }, - { - "model_display_name": "GPT5-Codex [crs]", - "model": "gpt-5-codex", - "base_url": "http://127.0.0.1:3000/droid/openai", - "api_key": "后台创建的API密钥", - "provider": "openai", - "max_tokens": 16384 - }, - { - "model_display_name": "Gemini-3-Pro [crs]", - "model": "gemini-3-pro-preview", - "base_url": "http://127.0.0.1:3000/droid/comm/v1/", - "api_key": "后台创建的API密钥", - "provider": "generic-chat-completion-api", - "max_tokens": 65535 - }, - { - "model_display_name": "GLM-4.6 [crs]", - "model": "glm-4.6", - "base_url": "http://127.0.0.1:3000/droid/comm/v1/", - "api_key": "后台创建的API密钥", - "provider": "generic-chat-completion-api", - "max_tokens": 202800 - } - ] -} -``` - -> 💡 将示例中的 `http://127.0.0.1:3000` 替换为你的服务域名或公网地址,并写入后台生成的 API 密钥(cr_ 开头)。 - -### 5. 第三方工具API接入 - -本服务支持多种API端点格式,方便接入不同的第三方工具(如Cherry Studio等)。 - -#### Cherry Studio 接入示例 - -Cherry Studio支持多种AI服务的接入,下面是不同账号类型的详细配置: - -**1. Claude账号接入:** - -``` -# API地址 -http://你的服务器:3000/claude - -# 模型ID示例 -claude-sonnet-4-5-20250929 # Claude Sonnet 4.5 -claude-opus-4-20250514 # Claude Opus 4 -``` - -配置步骤: -- 供应商类型选择"Anthropic" -- API地址填入:`http://你的服务器:3000/claude` -- API Key填入:后台创建的API密钥(cr_开头) - -**2. Gemini账号接入:** - -``` -# API地址 -http://你的服务器:3000/gemini - -# 模型ID示例 -gemini-2.5-pro # Gemini 2.5 Pro -``` - -配置步骤: -- 供应商类型选择"Gemini" -- API地址填入:`http://你的服务器:3000/gemini` -- API Key填入:后台创建的API密钥(cr_开头) - -**3. Codex接入:** - -``` -# API地址 -http://你的服务器:3000/openai - -# 模型ID(固定) -gpt-5 # Codex使用固定模型ID -``` - -配置步骤: -- 供应商类型选择"Openai-Response" -- API地址填入:`http://你的服务器:3000/openai` -- API Key填入:后台创建的API密钥(cr_开头) -- **重要**:Codex只支持Openai-Response标准 - - -**Cherry Studio 地址格式重要说明:** - -- ✅ **推荐格式**:`http://你的服务器:3000/claude`(不加结尾 `/`,让 Cherry Studio 自动加上 v1) -- ✅ **等效格式**:`http://你的服务器:3000/claude/v1/`(手动指定 v1 并加结尾 `/`) -- 💡 **说明**:这两种格式在 Cherry Studio 中是完全等效的 -- ❌ **错误格式**:`http://你的服务器:3000/claude/`(单独的 `/` 结尾会被 Cherry Studio 忽略 v1 版本) - -#### 其他第三方工具接入 - -**接入要点:** - -- 所有账号类型都使用相同的API密钥(在后台统一创建) -- 根据不同的路由前缀自动识别账号类型 -- `/claude/` - 使用Claude账号池 -- `/droid/claude/` - 使用Droid类型Claude账号池(只建议api调用或Droid Cli中使用) -- `/gemini/` - 使用Gemini账号池 -- `/openai/` - 使用Codex账号(只支持Openai-Response格式) -- `/droid/openai/` - 使用Droid类型OpenAI兼容账号池(只建议api调用或Droid Cli中使用) -- 支持所有标准API端点(messages、models等) - -**重要说明:** - -- 确保在后台已添加对应类型的账号(Claude/Gemini/Codex) -- API密钥可以通用,系统会根据路由自动选择账号类型 -- 建议为不同用户创建不同的API密钥便于使用统计 - --- -## 🔧 日常维护 +### 2. Gemini CLI 配置 -### 服务管理 +支持通过 Gemini 协议直接访问。 + +**方式一:通过 Gemini Assist API (推荐)** ```bash -# 查看服务状态 -npm run service:status - -# 查看日志 -npm run service:logs - -# 重启服务 -npm run service:restart:daemon - -# 停止服务 -npm run service:stop +export CODE_ASSIST_ENDPOINT="http://你的服务器IP:3000/gemini" +export GOOGLE_CLOUD_ACCESS_TOKEN="cr_xxxxxxxxxxxx" # 使用 CRS 的 API Key +export GOOGLE_GENAI_USE_GCA="true" +export GEMINI_MODEL="gemini-2.5-pro" +gemini ``` -### 监控使用情况 +--- -- **Web界面**: `http://你的域名:3000/web` - 查看使用统计 -- **健康检查**: `http://你的域名:3000/health` - 确认服务正常 -- **日志文件**: `logs/` 目录下的各种日志文件 +## 📦 部署说明 -### 升级指南 - -当有新版本发布时,按照以下步骤升级服务: +### Docker Compose (推荐) ```bash -# 1. 进入项目目录 -cd claude-relay-service +# 1. 初始化配置 +cp .env.example .env +cp config/config.example.js config/config.js -# 2. 拉取最新代码 -git pull origin main +# 2. 编辑 .env(至少设置这两个) +# JWT_SECRET=...(随机字符串) +# ENCRYPTION_KEY=...(32位随机字符串) -# 如果遇到 package-lock.json 冲突,使用远程版本 -git checkout --theirs package-lock.json -git add package-lock.json +# 3. 启动 +docker-compose up -d +``` -# 3. 安装新的依赖(如果有) +### Node 方式(不使用 Docker) + +```bash npm install - -# 4. 安装并构建前端 -npm run install:web -npm run build:web - -# 5. 重启服务 -npm run service:restart:daemon - -# 6. 检查服务状态 -npm run service:status +cp .env.example .env +cp config/config.example.js config/config.js +npm run setup +npm run service:start:daemon ``` -**注意事项:** +### 管理面板 -- 升级前建议备份重要配置文件(.env, config/config.js) -- 查看更新日志了解是否有破坏性变更 -- 如果有数据库结构变更,会自动迁移 +- 地址: `http://IP:3000/web` +- 初始账号/密码:`npm run setup` 生成并写入 `data/init.json`(Docker 部署可通过容器日志定位)。 --- -## 🔒 客户端限制功能 +## 🔧 调试与排障(可选) -### 功能说明 +Dump 开关在 `.env.example` 中有完整说明。常用项: -客户端限制功能允许你控制每个API Key可以被哪些客户端使用,通过User-Agent识别客户端,提高API的安全性。 - -### 使用方法 - -1. **在创建或编辑API Key时启用客户端限制**: - - 勾选"启用客户端限制" - - 选择允许的客户端(支持多选) - -2. **预定义客户端**: - - **ClaudeCode**: 官方Claude CLI(匹配 `claude-cli/x.x.x (external, cli)` 格式) - - **Gemini-CLI**: Gemini命令行工具(匹配 `GeminiCLI/vx.x.x (platform; arch)` 格式) - -3. **调试和诊断**: - - 系统会在日志中记录所有请求的User-Agent - - 客户端验证失败时会返回403错误并记录详细信息 - - 通过日志可以查看实际的User-Agent格式,方便配置自定义客户端 - - -### 日志示例 - -认证成功时的日志: - -``` -🔓 Authenticated request from key: 测试Key (key-id) in 5ms - User-Agent: "claude-cli/1.0.58 (external, cli)" -``` - -客户端限制检查日志: - -``` -🔍 Checking client restriction for key: key-id (测试Key) - User-Agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" - Allowed clients: claude_code, gemini_cli -🚫 Client restriction failed for key: key-id (测试Key) from 127.0.0.1, User-Agent: Mozilla/5.0... -``` - -### 常见问题处理 - -**Redis连不上?** - -```bash -# 检查Redis是否启动 -redis-cli ping - -# 应该返回 PONG -``` - -**OAuth授权失败?** - -- 检查代理设置是否正确 -- 确保能正常访问 claude.ai -- 清除浏览器缓存重试 - -**API请求失败?** - -- 检查API Key是否正确 -- 查看日志文件找错误信息 -- 确认Claude账户状态正常 +- `ANTHROPIC_DEBUG_REQUEST_DUMP=true` +- `ANTHROPIC_DEBUG_RESPONSE_DUMP=true` +- `ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP=true` +- `ANTIGRAVITY_DEBUG_UPSTREAM_RESPONSE_DUMP=true` +- `DUMP_MAX_FILE_SIZE_BYTES=10485760` --- -## 🛠️ 进阶 +## 🤝 维护与致谢 -### 反向代理部署指南 - -在生产环境中,建议通过反向代理进行连接,以便使用自动 HTTPS、安全头部和性能优化。下面提供两种常用方案: **Caddy** 和 **Nginx Proxy Manager (NPM)**。 - ---- - -## Caddy 方案 - -Caddy 是一款自动管理 HTTPS 证书的 Web 服务器,配置简单、性能优秀,很适合不需要 Docker 环境的部署方案。 - -**1. 安装 Caddy** - -```bash -# Ubuntu/Debian -sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https -curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg -curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list -sudo apt update -sudo apt install caddy - -# CentOS/RHEL/Fedora -sudo yum install yum-plugin-copr -sudo yum copr enable @caddy/caddy -sudo yum install caddy -``` - -**2. Caddy 配置** - -编辑 `/etc/caddy/Caddyfile` : - -```caddy -your-domain.com { - # 反向代理到本地服务 - reverse_proxy 127.0.0.1:3000 { - # 支持流式响应或 SSE - flush_interval -1 - - # 传递真实 IP - header_up X-Real-IP {remote_host} - header_up X-Forwarded-For {remote_host} - header_up X-Forwarded-Proto {scheme} - - # 长读/写超时配置 - transport http { - read_timeout 300s - write_timeout 300s - dial_timeout 30s - } - } - - # 安全头部 - header { - Strict-Transport-Security "max-age=31536000; includeSubDomains" - X-Frame-Options "DENY" - X-Content-Type-Options "nosniff" - -Server - } -} -``` - -**3. 启动 Caddy** - -```bash -sudo caddy validate --config /etc/caddy/Caddyfile -sudo systemctl start caddy -sudo systemctl enable caddy -sudo systemctl status caddy -``` - -**4. 服务配置** - -Caddy 会自动管理 HTTPS,因此可以将服务限制在本地进行监听: - -```javascript -// config/config.js -module.exports = { - server: { - port: 3000, - host: '127.0.0.1' // 只监听本地 - } -} -``` - -**Caddy 特点** - -* 🔒 自动 HTTPS,零配置证书管理 -* 🛡️ 安全默认配置,启用现代 TLS 套件 -* ⚡ HTTP/2 和流式传输支持 -* 🔧 配置文件简洁,易于维护 - ---- - -## Nginx Proxy Manager (NPM) 方案 - -Nginx Proxy Manager 通过图形化界面管理反向代理和 HTTPS 证书,並以 Docker 容器部署。 - -**1. 在 NPM 创建新的 Proxy Host** - -Details 配置如下: - -| 项目 | 设置 | -| --------------------- | ----------------------- | -| Domain Names | relay.example.com | -| Scheme | http | -| Forward Hostname / IP | 192.168.0.1 (docker 机器 IP) | -| Forward Port | 3000 | -| Block Common Exploits | ☑️ | -| Websockets Support | ❌ **关闭** | -| Cache Assets | ❌ **关闭** | -| Access List | Publicly Accessible | - -> 注意: -> - 请确保 Claude Relay Service **监听 host 为 `0.0.0.0` 、容器 IP 或本机 IP**,以便 NPM 实现内网连接。 -> - **Websockets Support 和 Cache Assets 必须关闭**,否则会导致 SSE / 流式响应失败。 - -**2. Custom locations** - -無需添加任何内容,保持为空。 - -**3. SSL 设置** - -* **SSL Certificate**: Request a new SSL Certificate (Let's Encrypt) 或已有证书 -* ☑️ **Force SSL** -* ☑️ **HTTP/2 Support** -* ☑️ **HSTS Enabled** -* ☑️ **HSTS Subdomains** - -**4. Advanced 配置** - -Custom Nginx Configuration 中添加以下内容: - -```nginx -# 传递真实用户 IP -proxy_set_header X-Real-IP $remote_addr; -proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; -proxy_set_header X-Forwarded-Proto $scheme; - -# 支持 WebSocket / SSE 等流式通信 -proxy_http_version 1.1; -proxy_set_header Upgrade $http_upgrade; -proxy_set_header Connection "upgrade"; -proxy_buffering off; - -# 长连接 / 超时设置(适合 AI 聊天流式传输) -proxy_read_timeout 300s; -proxy_send_timeout 300s; -proxy_connect_timeout 30s; - -# ---- 安全性设置 ---- -# 严格 HTTPS 策略 (HSTS) -add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; - -# 阻挡点击劫持与内容嗅探 -add_header X-Frame-Options "DENY" always; -add_header X-Content-Type-Options "nosniff" always; - -# Referrer / Permissions 限制策略 -add_header Referrer-Policy "no-referrer-when-downgrade" always; -add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; - -# 隐藏服务器信息(等效于 Caddy 的 `-Server`) -proxy_hide_header Server; - -# ---- 性能微调 ---- -# 关闭代理端缓存,确保即时响应(SSE / Streaming) -proxy_cache_bypass $http_upgrade; -proxy_no_cache $http_upgrade; -proxy_request_buffering off; -``` - -**4. 启动和验证** - -* 保存后等待 NPM 自动申请 Let's Encrypt 证书(如果有)。 -* Dashboard 中查看 Proxy Host 状态,确保显示为 "Online"。 -* 访问 `https://relay.example.com`,如果显示绿色锁图标即表示 HTTPS 正常。 - -**NPM 特点** - -* 🔒 自动申请和续期证书 -* 🔧 图形化界面,方便管理多服务 -* ⚡ 原生支持 HTTP/2 / HTTPS -* 🚀 适合 Docker 容器部署 - ---- - -上述两种方案均可用于生产部署。 - ---- - -## 💡 使用建议 - -### 账户管理 - -- **定期检查**: 每周看看账户状态,及时处理异常 -- **合理分配**: 可以给不同的人分配不同的apikey,可以根据不同的apikey来分析用量 - -### 安全建议 - -- **使用HTTPS**: 强烈建议使用Caddy反向代理(自动HTTPS),确保数据传输安全 -- **定期备份**: 重要配置和数据要备份 -- **监控日志**: 定期查看异常日志 -- **更新密钥**: 定期更换JWT和加密密钥 -- **防火墙设置**: 只开放必要的端口(80, 443),隐藏直接服务端口 - ---- - -## 🆘 遇到问题怎么办? - -### 自助排查 - -1. **查看日志**: `logs/` 目录下的日志文件 -2. **检查配置**: 确认配置文件设置正确 -3. **测试连通性**: 用 curl 测试API是否正常 -4. **重启服务**: 有时候重启一下就好了 - -### 寻求帮助 - -- **GitHub Issues**: 提交详细的错误信息 -- **查看文档**: 仔细阅读错误信息和文档 -- **社区讨论**: 看看其他人是否遇到类似问题 - ---- - -## ❤️ 赞助支持 - -如果您觉得这个项目对您有帮助,请考虑赞助支持项目的持续开发。您的支持是我们最大的动力! - -
- - - Sponsor - - - - - - - -
wechatalipay
- -
- ---- - -## 📄 许可证 - -本项目采用 [MIT许可证](LICENSE)。 - ---- - -
- -**⭐ 觉得有用的话给个Star呗,这是对作者最大的鼓励!** - -**🤝 有问题欢迎提Issue,有改进建议欢迎PR** - -
+- **维护者**:dadongwo +- **Upstream**:Claude Relay Service(原版项目,已在本分支移除与功能无关的广告信息并专注于功能增强) diff --git a/README_EN.md b/README_EN.md index 2eac90ca..e3e8cdbb 100644 --- a/README_EN.md +++ b/README_EN.md @@ -1,4 +1,5 @@ -# Claude Relay Service +# Claude Relay Service (Antigravity Edition) + > [!CAUTION] > **Security Update**: v1.1.248 and below contain a critical admin authentication bypass vulnerability allowing unauthorized access to the admin panel. @@ -7,606 +8,117 @@
-[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![Node.js](https://img.shields.io/badge/Node.js-18+-green.svg)](https://nodejs.org/) -[![Redis](https://img.shields.io/badge/Redis-6+-red.svg)](https://redis.io/) -[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) - -**🔐 Self-hosted Claude API relay service with multi-account management** - -[中文文档](README.md) • [Preview](https://demo.pincc.ai/admin-next/login) • [Telegram Channel](https://t.me/claude_relay_service) - -
+This fork focuses on: +- Native compatibility for `claude` (Claude Code CLI) +- Antigravity OAuth integration + path-based routing +- Better stability for streaming (SSE) workloads +- Optional request/response dumps for debugging --- -## ⭐ If You Find It Useful, Please Give It a Star! +## Highlights -> Open source is not easy, your Star is my motivation to continue updating 🚀 -> Join [Telegram Channel](https://t.me/claude_relay_service) for the latest updates +- **Claude Code protocol compatibility**: `thoughtSignature` fallback + cache, tool_result passthrough, and message ordering fixes. +- **Antigravity OAuth**: account type `gemini-antigravity` with permission checks. +- **Path-based routing (Anthropic Messages API)**: + - `/api` -> Claude account pool (default) + - `/antigravity/api` -> Antigravity OAuth account pool + - `/gemini-cli/api` -> Gemini OAuth account pool +- **Stability**: + - Zombie stream watchdog (disconnect after 45s without valid data) + - Auto retry + account switching for Antigravity `429 Resource Exhausted` (streaming and non-streaming) +- **Observability**: JSONL dumps for request/response/tools/upstream (with size limit + rotation) --- -## ⚠️ Important Notice +## Quick Start -**Please read carefully before using this project:** +### Requirements +- Node.js 18+ (or Docker) +- Redis 6+/7+ -🚨 **Terms of Service Risk**: Using this project may violate Anthropic's terms of service. Please carefully read Anthropic's user agreement before use. All risks from using this project are borne by the user. - -📖 **Disclaimer**: This project is for technical learning and research purposes only. The author is not responsible for any account bans, service interruptions, or other losses caused by using this project. - -## 🤔 Is This Project Right for You? - -- 🌍 **Regional Restrictions**: Can't directly access Claude Code service in your region? -- 🔒 **Privacy Concerns**: Worried about third-party mirror services logging or leaking your conversation content? -- 👥 **Cost Sharing**: Want to share Claude Code Max subscription costs with friends? -- ⚡ **Stability Issues**: Third-party mirror sites often fail and are unstable, affecting efficiency? - -If you have any of these concerns, this project might be suitable for you. - -### Suitable Scenarios - -✅ **Cost Sharing with Friends**: 3-5 friends sharing Claude Code Max subscription, enjoying Opus freely -✅ **Privacy Sensitive**: Don't want third-party mirrors to see your conversation content -✅ **Technical Tinkering**: Have basic technical skills, willing to build and maintain yourself -✅ **Stability Needs**: Need long-term stable Claude access, don't want to be restricted by mirror sites -✅ **Regional Restrictions**: Cannot directly access Claude official service - -### Unsuitable Scenarios - -❌ **Complete Beginner**: Don't understand technology at all, don't even know how to buy a server -❌ **Occasional Use**: Use it only a few times a month, not worth the hassle -❌ **Registration Issues**: Cannot register Claude account yourself -❌ **Payment Issues**: No payment method to subscribe to Claude Code - -**If you're just an ordinary user with low privacy requirements, just want to casually play around and quickly experience Claude, then choosing a mirror site you're familiar with would be more suitable.** - ---- - -## 💭 Why Build Your Own? - -### Potential Issues with Existing Mirror Sites - -- 🕵️ **Privacy Risk**: Your conversation content is completely visible to others, forget about business secrets -- 🐌 **Performance Instability**: Slow when many people use it, often crashes during peak hours -- 💰 **Price Opacity**: Don't know the actual costs - -### Benefits of Self-hosting - -- 🔐 **Data Security**: All API requests only go through your own server, direct connection to Anthropic API -- ⚡ **Controllable Performance**: Only a few of you using it, Max $200 package basically allows you to enjoy Opus freely -- 💰 **Cost Transparency**: Clear view of how many tokens used, specific costs calculated at official prices -- 📊 **Complete Monitoring**: Usage statistics, cost analysis, performance monitoring all available - ---- - -## 🚀 Core Features - -> 📸 **[Click to view interface preview](docs/preview.md)** - See detailed screenshots of the Web management interface - -### Basic Features -- ✅ **Multi-account Management**: Add multiple Claude accounts for automatic rotation -- ✅ **Custom API Keys**: Assign independent keys to each person -- ✅ **Usage Statistics**: Detailed records of how many tokens each person used - -### Advanced Features -- 🔄 **Smart Switching**: Automatically switch to next account when one has issues -- 🚀 **Performance Optimization**: Connection pooling, caching to reduce latency -- 📊 **Monitoring Dashboard**: Web interface to view all data -- 🛡️ **Security Control**: Access restrictions, rate limiting -- 🌐 **Proxy Support**: Support for HTTP/SOCKS5 proxies - ---- - -## 📋 Deployment Requirements - -### Hardware Requirements (Minimum Configuration) -- **CPU**: 1 core is sufficient -- **Memory**: 512MB (1GB recommended) -- **Storage**: 30GB available space -- **Network**: Access to Anthropic API (recommend US region servers) -- **Recommendation**: 2 cores 4GB is basically enough, choose network with good return routes to your country (to improve speed, recommend not using proxy or setting server IP for direct connection) - -### Software Requirements -- **Node.js** 18 or higher -- **Redis** 6 or higher -- **Operating System**: Linux recommended - -### Cost Estimation -- **Server**: Light cloud server, $5-10 per month -- **Claude Subscription**: Depends on how you share costs -- **Others**: Domain name (optional) - ---- - -## 📦 Manual Deployment - -### Step 1: Environment Setup - -**Ubuntu/Debian users:** -```bash -# Install Node.js -curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash - -sudo apt-get install -y nodejs - -# Install Redis -sudo apt update -sudo apt install redis-server -sudo systemctl start redis-server -``` - -**CentOS/RHEL users:** -```bash -# Install Node.js -curl -fsSL https://rpm.nodesource.com/setup_18.x | sudo bash - -sudo yum install -y nodejs - -# Install Redis -sudo yum install redis -sudo systemctl start redis -``` - -### Step 2: Download and Configure +### Docker Compose (recommended) ```bash -# Download project -git clone https://github.com/Wei-Shaw/claude-relay-service.git -cd claude-relay-service - -# Install dependencies -npm install - -# Copy configuration files (Important!) -cp config/config.example.js config/config.js cp .env.example .env +cp config/config.example.js config/config.js + +# Edit .env at least: +# JWT_SECRET=... (random string) +# ENCRYPTION_KEY=... (32-char random string) + +docker-compose up -d ``` -### Step 3: Configuration File Setup - -**Edit `.env` file:** -```bash -# Generate these two keys randomly, but remember them -JWT_SECRET=your-super-secret-key -ENCRYPTION_KEY=32-character-encryption-key-write-randomly - -# Redis configuration -REDIS_HOST=localhost -REDIS_PORT=6379 -REDIS_PASSWORD= -``` - -**Edit `config/config.js` file:** -```javascript -module.exports = { - server: { - port: 3000, // Service port, can be changed - host: '0.0.0.0' // Don't change - }, - redis: { - host: '127.0.0.1', // Redis address - port: 6379 // Redis port - }, - // Keep other configurations as default -} -``` - -### Step 4: Start Service +### Node (no Docker) ```bash -# Initialize -npm run setup # Will randomly generate admin account password info, stored in data/init.json - -# Start service -npm run service:start:daemon # Run in background (recommended) - -# Check status -npm run service:status +npm install +cp .env.example .env +cp config/config.example.js config/config.js +npm run setup +npm run service:start:daemon ``` +### Admin UI + +- URL: `http://:3000/web` +- Initial credentials: generated by `npm run setup` and saved to `data/init.json` (Docker users can also inspect container logs). + --- -## 🎮 Getting Started +## Using with Claude Code (CLI) -### 1. Open Management Interface - -Browser visit: `http://your-server-IP:3000/web` - -Default admin account: Look in data/init.json - -### 2. Add Claude Account - -This step is quite important, requires OAuth authorization: - -1. Click "Claude Accounts" tab -2. If you're worried about multiple accounts sharing 1 IP getting banned, you can optionally set a static proxy IP -3. Click "Add Account" -4. Click "Generate Authorization Link", will open a new page -5. Complete Claude login and authorization in the new page -6. Copy the returned Authorization Code -7. Paste to page to complete addition - -**Note**: If you're in China, this step may require VPN. - -### 3. Create API Key - -Assign a key to each user: - -1. Click "API Keys" tab -2. Click "Create New Key" -3. Give the key a name, like "Zhang San's Key" -4. Set usage limits (optional) -5. Save, note down the generated key - -### 4. Start Using Claude Code and Gemini CLI - -Now you can replace the official API with your own service: - -**Claude Code Set Environment Variables:** - -Default uses standard Claude account pool: - -```bash -export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # Fill in your server's IP address or domain -export ANTHROPIC_AUTH_TOKEN="API key created in the backend" -``` - -**VSCode Claude Plugin Configuration:** - -If using VSCode Claude plugin, configure in `~/.claude/config.json`: - -```json -{ - "primaryApiKey": "crs" -} -``` - -If the file doesn't exist, create it manually. Windows users path is `C:\Users\YourUsername\.claude\config.json`. - -**Gemini CLI Set Environment Variables:** - -**Method 1 (Recommended): Via Gemini Assist API** - -Each account enjoys 1000 requests per day, 60 requests per minute free quota. - -```bash -CODE_ASSIST_ENDPOINT="http://127.0.0.1:3000/gemini" # Fill in your server's IP address or domain -GOOGLE_CLOUD_ACCESS_TOKEN="API key created in the backend" -GOOGLE_GENAI_USE_GCA="true" -GEMINI_MODEL="gemini-2.5-pro" -``` - -> **Note**: gemini-cli console will show `Failed to fetch user info: 401 Unauthorized`, but this doesn't affect usage. - -**Method 2: Via Gemini API** - -Very limited free quota, easily triggers 429 errors. - -```bash -GOOGLE_GEMINI_BASE_URL="http://127.0.0.1:3000/gemini" # Fill in your server's IP address or domain -GEMINI_API_KEY="API key created in the backend" -GEMINI_MODEL="gemini-2.5-pro" -``` - -**Use Claude Code:** +### Antigravity pool (recommended) ```bash +export ANTHROPIC_BASE_URL="http://:3000/antigravity/api/" +export ANTHROPIC_AUTH_TOKEN="cr_xxxxxxxxxxxx" +export ANTHROPIC_MODEL="claude-opus-4-5" claude ``` -**Use Gemini CLI:** +### Gemini pool ```bash -gemini +export ANTHROPIC_BASE_URL="http://:3000/gemini-cli/api/" +export ANTHROPIC_AUTH_TOKEN="cr_xxxxxxxxxxxx" +export ANTHROPIC_MODEL="gemini-2.5-pro" +claude ``` ---- - -## 🔧 Daily Maintenance - -### Service Management +### Standard Claude pool ```bash -# Check service status -npm run service:status - -# View logs -npm run service:logs - -# Restart service -npm run service:restart:daemon - -# Stop service -npm run service:stop +export ANTHROPIC_BASE_URL="http://:3000/api/" +export ANTHROPIC_AUTH_TOKEN="cr_xxxxxxxxxxxx" +claude ``` -### Monitor Usage - -- **Web Interface**: `http://your-domain:3000/web` - View usage statistics -- **Health Check**: `http://your-domain:3000/health` - Confirm service is normal -- **Log Files**: Various log files in `logs/` directory - -### Upgrade Guide - -When a new version is released, follow these steps to upgrade the service: - -```bash -# 1. Navigate to project directory -cd claude-relay-service - -# 2. Pull latest code -git pull origin main - -# If you encounter package-lock.json conflicts, use the remote version -git checkout --theirs package-lock.json -git add package-lock.json - -# 3. Install new dependencies (if any) -npm install - -# 4. Restart service -npm run service:restart:daemon - -# 5. Check service status -npm run service:status -``` - -**Important Notes:** -- Before upgrading, it's recommended to backup important configuration files (.env, config/config.js) -- Check the changelog to understand if there are any breaking changes -- Database structure changes will be migrated automatically if needed - -### Common Issue Resolution - -**Can't connect to Redis?** -```bash -# Check if Redis is running -redis-cli ping - -# Should return PONG -``` - -**OAuth authorization failed?** -- Check if proxy settings are correct -- Ensure normal access to claude.ai -- Clear browser cache and retry - -**API request failed?** -- Check if API Key is correct -- View log files for error information -- Confirm Claude account status is normal - --- -## 🛠️ Advanced Usage +## Antigravity Quota & Models -### Reverse Proxy Deployment Guide - -For production environments, it is recommended to use a reverse proxy for automatic HTTPS, security headers, and performance optimization. Two common solutions are provided below: **Caddy** and **Nginx Proxy Manager (NPM)**. +- Quota display: in Admin UI -> Accounts -> `gemini-antigravity` -> click **Test/Refresh**. +- Dynamic models list: + - Anthropic/Claude Code routing: `GET /antigravity/api/v1/models` (proxies Antigravity `fetchAvailableModels`) + - OpenAI-compatible routing: `GET /openai/gemini/models` (or `GET /openai/gemini/v1/models`) --- -## Caddy Solution +## Debug Dumps (optional) -Caddy is a web server that automatically manages HTTPS certificates, with simple configuration and excellent performance, ideal for deployments without Docker environments. +See `.env.example` for the full list. Common toggles: -**1. Install Caddy** - -```bash -# Ubuntu/Debian -sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https -curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg -curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list -sudo apt update -sudo apt install caddy - -# CentOS/RHEL/Fedora -sudo yum install yum-plugin-copr -sudo yum copr enable @caddy/caddy -sudo yum install caddy -``` - -**2. Caddy Configuration** - -Edit `/etc/caddy/Caddyfile`: - -```caddy -your-domain.com { - # Reverse proxy to local service - reverse_proxy 127.0.0.1:3000 { - # Support streaming responses or SSE - flush_interval -1 - - # Pass real IP - header_up X-Real-IP {remote_host} - header_up X-Forwarded-For {remote_host} - header_up X-Forwarded-Proto {scheme} - - # Long read/write timeout configuration - transport http { - read_timeout 300s - write_timeout 300s - dial_timeout 30s - } - } - - # Security headers - header { - Strict-Transport-Security "max-age=31536000; includeSubDomains" - X-Frame-Options "DENY" - X-Content-Type-Options "nosniff" - -Server - } -} -``` - -**3. Start Caddy** - -```bash -sudo caddy validate --config /etc/caddy/Caddyfile -sudo systemctl start caddy -sudo systemctl enable caddy -sudo systemctl status caddy -``` - -**4. Service Configuration** - -Since Caddy automatically manages HTTPS, you can restrict the service to listen locally only: - -```javascript -// config/config.js -module.exports = { - server: { - port: 3000, - host: '127.0.0.1' // Listen locally only - } -} -``` - -**Caddy Features** - -* 🔒 Automatic HTTPS with zero-configuration certificate management -* 🛡️ Secure default configuration with modern TLS suites -* ⚡ HTTP/2 and streaming support -* 🔧 Concise configuration files, easy to maintain +- `ANTHROPIC_DEBUG_REQUEST_DUMP=true` +- `ANTHROPIC_DEBUG_RESPONSE_DUMP=true` +- `ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP=true` +- `ANTIGRAVITY_DEBUG_UPSTREAM_RESPONSE_DUMP=true` +- `DUMP_MAX_FILE_SIZE_BYTES=10485760` --- -## Nginx Proxy Manager (NPM) Solution +## License -Nginx Proxy Manager manages reverse proxies and HTTPS certificates through a graphical interface, deployed as a Docker container. +This project is licensed under the [MIT License](LICENSE). -**1. Create a New Proxy Host in NPM** - -Configure the Details as follows: - -| Item | Setting | -| --------------------- | ------------------------ | -| Domain Names | relay.example.com | -| Scheme | http | -| Forward Hostname / IP | 192.168.0.1 (docker host IP) | -| Forward Port | 3000 | -| Block Common Exploits | ☑️ | -| Websockets Support | ❌ **Disable** | -| Cache Assets | ❌ **Disable** | -| Access List | Publicly Accessible | - -> Note: -> - Ensure Claude Relay Service **listens on `0.0.0.0`, container IP, or host IP** to allow NPM internal network connections. -> - **Websockets Support and Cache Assets must be disabled**, otherwise SSE / streaming responses will fail. - -**2. Custom locations** - -No content needed, keep it empty. - -**3. SSL Settings** - -* **SSL Certificate**: Request a new SSL Certificate (Let's Encrypt) or existing certificate -* ☑️ **Force SSL** -* ☑️ **HTTP/2 Support** -* ☑️ **HSTS Enabled** -* ☑️ **HSTS Subdomains** - -**4. Advanced Configuration** - -Add the following to Custom Nginx Configuration: - -```nginx -# Pass real user IP -proxy_set_header X-Real-IP $remote_addr; -proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; -proxy_set_header X-Forwarded-Proto $scheme; - -# Support WebSocket / SSE streaming -proxy_http_version 1.1; -proxy_set_header Upgrade $http_upgrade; -proxy_set_header Connection "upgrade"; -proxy_buffering off; - -# Long connection / timeout settings (for AI chat streaming) -proxy_read_timeout 300s; -proxy_send_timeout 300s; -proxy_connect_timeout 30s; - -# ---- Security Settings ---- -# Strict HTTPS policy (HSTS) -add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; - -# Block clickjacking and content sniffing -add_header X-Frame-Options "DENY" always; -add_header X-Content-Type-Options "nosniff" always; - -# Referrer / Permissions restriction policies -add_header Referrer-Policy "no-referrer-when-downgrade" always; -add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; - -# Hide server information (equivalent to Caddy's `-Server`) -proxy_hide_header Server; - -# ---- Performance Tuning ---- -# Disable proxy caching for real-time responses (SSE / Streaming) -proxy_cache_bypass $http_upgrade; -proxy_no_cache $http_upgrade; -proxy_request_buffering off; -``` - -**5. Launch and Verify** - -* After saving, wait for NPM to automatically request Let's Encrypt certificate (if applicable). -* Check Proxy Host status in Dashboard to ensure it shows "Online". -* Visit `https://relay.example.com`, if the green lock icon appears, HTTPS is working properly. - -**NPM Features** - -* 🔒 Automatic certificate application and renewal -* 🔧 Graphical interface for easy multi-service management -* ⚡ Native HTTP/2 / HTTPS support -* 🚀 Ideal for Docker container deployments - ---- - -Both solutions are suitable for production deployment. If you use a Docker environment, **Nginx Proxy Manager is more convenient**; if you want to keep software lightweight and automated, **Caddy is a better choice**. - ---- - -## 💡 Usage Recommendations - -### Account Management -- **Regular Checks**: Check account status weekly, handle exceptions promptly -- **Reasonable Allocation**: Can assign different API keys to different people, analyze usage based on different API keys - -### Security Recommendations -- **Use HTTPS**: Strongly recommend using Caddy reverse proxy (automatic HTTPS) to ensure secure data transmission -- **Regular Backups**: Back up important configurations and data -- **Monitor Logs**: Regularly check exception logs -- **Update Keys**: Regularly change JWT and encryption keys -- **Firewall Settings**: Only open necessary ports (80, 443), hide direct service ports - ---- - -## 🆘 What to Do When You Encounter Problems? - -### Self-troubleshooting -1. **Check Logs**: Log files in `logs/` directory -2. **Check Configuration**: Confirm configuration files are set correctly -3. **Test Connectivity**: Use curl to test if API is normal -4. **Restart Service**: Sometimes restarting fixes it - -### Seeking Help -- **GitHub Issues**: Submit detailed error information -- **Read Documentation**: Carefully read error messages and documentation -- **Community Discussion**: See if others have encountered similar problems - ---- - -## 📄 License -This project uses the [MIT License](LICENSE). - ---- - -
- -**⭐ If you find it useful, please give it a Star, this is the greatest encouragement to the author!** - -**🤝 Feel free to submit Issues for problems, welcome PRs for improvement suggestions** - -
\ No newline at end of file diff --git a/config/config.example.js b/config/config.example.js index 9cf26002..e5e0c340 100644 --- a/config/config.example.js +++ b/config/config.example.js @@ -205,6 +205,14 @@ const config = { hotReload: process.env.HOT_RELOAD === 'true' }, + // 💰 账户余额相关配置 + accountBalance: { + // 是否允许执行自定义余额脚本(安全开关) + // 说明:脚本能力可发起任意 HTTP 请求并在服务端执行 extractor 逻辑,建议仅在受控环境开启 + // 默认保持开启;如需禁用请显式设置:BALANCE_SCRIPT_ENABLED=false + enableBalanceScript: process.env.BALANCE_SCRIPT_ENABLED !== 'false' + }, + // 📬 用户消息队列配置 // 优化说明:锁在请求发送成功后立即释放(而非请求完成后),因为 Claude API 限流基于请求发送时刻计算 userMessageQueue: { diff --git a/package-lock.json b/package-lock.json index 2164fb09..d9ebcff0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -892,7 +892,6 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -3001,7 +3000,6 @@ "integrity": "sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3083,7 +3081,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3539,7 +3536,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001737", "electron-to-chromium": "^1.5.211", @@ -4427,7 +4423,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -4484,7 +4479,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -7592,7 +7586,6 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -9111,7 +9104,6 @@ "resolved": "https://registry.npmmirror.com/winston/-/winston-3.17.0.tgz", "integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==", "license": "MIT", - "peer": true, "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.2", diff --git a/src/app.js b/src/app.js index db15df2e..d17ea295 100644 --- a/src/app.js +++ b/src/app.js @@ -52,6 +52,16 @@ class Application { await redis.connect() logger.success('✅ Redis connected successfully') + // 💳 初始化账户余额查询服务(Provider 注册) + try { + const accountBalanceService = require('./services/accountBalanceService') + const { registerAllProviders } = require('./services/balanceProviders') + registerAllProviders(accountBalanceService) + logger.info('✅ 账户余额查询服务已初始化') + } catch (error) { + logger.warn('⚠️ 账户余额查询服务初始化失败:', error.message) + } + // 💰 初始化价格服务 logger.info('🔄 Initializing pricing service...') await pricingService.initialize() @@ -169,7 +179,7 @@ class Application { // 🔧 基础中间件 this.app.use( express.json({ - limit: '10mb', + limit: '100mb', verify: (req, res, buf, encoding) => { // 验证JSON格式 if (buf && buf.length && !buf.toString(encoding || 'utf8').trim()) { @@ -178,7 +188,7 @@ class Application { } }) ) - this.app.use(express.urlencoded({ extended: true, limit: '10mb' })) + this.app.use(express.urlencoded({ extended: true, limit: '100mb' })) this.app.use(securityMiddleware) // 🎯 信任代理 @@ -268,6 +278,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 和页面重定向) diff --git a/src/handlers/geminiHandlers.js b/src/handlers/geminiHandlers.js index 87295d31..05e3fd25 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') @@ -86,8 +87,7 @@ function generateSessionHash(req) { * 检查 API Key 权限 */ function checkPermissions(apiKeyData, requiredPermission = 'gemini') { - const permissions = apiKeyData?.permissions || 'all' - return permissions === 'all' || permissions === requiredPermission + return apiKeyService.hasPermission(apiKeyData?.permissions, requiredPermission) } /** @@ -508,20 +508,37 @@ async function handleMessages(req, res) { // OAuth 账户:使用现有的 sendGeminiRequest // 智能处理项目ID:优先使用配置的 projectId,降级到临时 tempProjectId const effectiveProjectId = account.projectId || account.tempProjectId || null + const oauthProvider = account.oauthProvider || 'gemini-cli' - geminiResponse = await sendGeminiRequest({ - messages, - model, - temperature, - maxTokens: max_tokens, - stream, - accessToken: account.accessToken, - proxy: account.proxy, - apiKeyId: apiKeyData.id, - signal: abortController.signal, - projectId: effectiveProjectId, - accountId: account.id - }) + if (oauthProvider === 'antigravity') { + geminiResponse = await sendAntigravityRequest({ + messages, + model, + temperature, + maxTokens: max_tokens, + stream, + accessToken: account.accessToken, + proxy: account.proxy, + apiKeyId: apiKeyData.id, + signal: abortController.signal, + projectId: effectiveProjectId, + accountId: account.id + }) + } else { + geminiResponse = await sendGeminiRequest({ + messages, + model, + temperature, + maxTokens: max_tokens, + stream, + accessToken: account.accessToken, + proxy: account.proxy, + apiKeyId: apiKeyData.id, + signal: abortController.signal, + projectId: effectiveProjectId, + accountId: account.id + }) + } } if (stream) { @@ -754,8 +771,16 @@ async function handleModels(req, res) { ] } } else { - // OAuth 账户:使用 OAuth token 获取模型列表 - models = await getAvailableModels(account.accessToken, account.proxy) + // OAuth 账户:根据 OAuth provider 选择上游 + const oauthProvider = account.oauthProvider || 'gemini-cli' + models = + oauthProvider === 'antigravity' + ? await geminiAccountService.fetchAvailableModelsAntigravity( + account.accessToken, + account.proxy, + account.refreshToken + ) + : await getAvailableModels(account.accessToken, account.proxy) } res.json({ @@ -927,7 +952,8 @@ function handleSimpleEndpoint(apiMethod) { const client = await geminiAccountService.getOauthClient( accessToken, refreshToken, - proxyConfig + proxyConfig, + account.oauthProvider ) // 直接转发请求体,不做特殊处理 @@ -1006,7 +1032,12 @@ async function handleLoadCodeAssist(req, res) { // 解析账户的代理配置 const proxyConfig = parseProxyConfig(account) - const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig) + const client = await geminiAccountService.getOauthClient( + accessToken, + refreshToken, + proxyConfig, + account.oauthProvider + ) // 智能处理项目ID const effectiveProjectId = projectId || cloudaicompanionProject || null @@ -1104,7 +1135,12 @@ async function handleOnboardUser(req, res) { // 解析账户的代理配置 const proxyConfig = parseProxyConfig(account) - const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig) + const client = await geminiAccountService.getOauthClient( + accessToken, + refreshToken, + proxyConfig, + account.oauthProvider + ) // 智能处理项目ID const effectiveProjectId = projectId || cloudaicompanionProject || null @@ -1256,7 +1292,8 @@ async function handleCountTokens(req, res) { const client = await geminiAccountService.getOauthClient( accessToken, refreshToken, - proxyConfig + proxyConfig, + account.oauthProvider ) response = await geminiAccountService.countTokens(client, contents, model, proxyConfig) } @@ -1366,13 +1403,20 @@ async function handleGenerateContent(req, res) { // 解析账户的代理配置 const proxyConfig = parseProxyConfig(account) - const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig) + const client = await geminiAccountService.getOauthClient( + accessToken, + refreshToken, + proxyConfig, + account.oauthProvider + ) // 智能处理项目ID:优先使用配置的 projectId,降级到临时 tempProjectId let effectiveProjectId = account.projectId || account.tempProjectId || null + const oauthProvider = account.oauthProvider || 'gemini-cli' + // 如果没有任何项目ID,尝试调用 loadCodeAssist 获取 - if (!effectiveProjectId) { + if (!effectiveProjectId && oauthProvider !== 'antigravity') { try { logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...') const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig) @@ -1388,6 +1432,12 @@ async function handleGenerateContent(req, res) { } } + if (!effectiveProjectId && oauthProvider === 'antigravity') { + // Antigravity 账号允许没有 projectId:生成一个稳定的临时 projectId 并缓存 + effectiveProjectId = `ag-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}` + await geminiAccountService.updateTempProjectId(accountId, effectiveProjectId) + } + // 如果还是没有项目ID,返回错误 if (!effectiveProjectId) { return res.status(403).json({ @@ -1410,14 +1460,24 @@ async function handleGenerateContent(req, res) { : '从loadCodeAssist获取' }) - const response = await geminiAccountService.generateContent( - client, - { model, request: actualRequestData }, - user_prompt_id, - effectiveProjectId, - req.apiKey?.id, - proxyConfig - ) + const response = + oauthProvider === 'antigravity' + ? await geminiAccountService.generateContentAntigravity( + client, + { model, request: actualRequestData }, + user_prompt_id, + effectiveProjectId, + req.apiKey?.id, + proxyConfig + ) + : await geminiAccountService.generateContent( + client, + { model, request: actualRequestData }, + user_prompt_id, + effectiveProjectId, + req.apiKey?.id, + proxyConfig + ) // 记录使用统计 if (response?.response?.usageMetadata) { @@ -1578,13 +1638,20 @@ async function handleStreamGenerateContent(req, res) { // 解析账户的代理配置 const proxyConfig = parseProxyConfig(account) - const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig) + const client = await geminiAccountService.getOauthClient( + accessToken, + refreshToken, + proxyConfig, + account.oauthProvider + ) // 智能处理项目ID:优先使用配置的 projectId,降级到临时 tempProjectId let effectiveProjectId = account.projectId || account.tempProjectId || null + const oauthProvider = account.oauthProvider || 'gemini-cli' + // 如果没有任何项目ID,尝试调用 loadCodeAssist 获取 - if (!effectiveProjectId) { + if (!effectiveProjectId && oauthProvider !== 'antigravity') { try { logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...') const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig) @@ -1600,6 +1667,11 @@ async function handleStreamGenerateContent(req, res) { } } + if (!effectiveProjectId && oauthProvider === 'antigravity') { + effectiveProjectId = `ag-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}` + await geminiAccountService.updateTempProjectId(accountId, effectiveProjectId) + } + // 如果还是没有项目ID,返回错误 if (!effectiveProjectId) { return res.status(403).json({ @@ -1622,15 +1694,26 @@ async function handleStreamGenerateContent(req, res) { : '从loadCodeAssist获取' }) - const streamResponse = await geminiAccountService.generateContentStream( - client, - { model, request: actualRequestData }, - user_prompt_id, - effectiveProjectId, - req.apiKey?.id, - abortController.signal, - proxyConfig - ) + const streamResponse = + oauthProvider === 'antigravity' + ? await geminiAccountService.generateContentStreamAntigravity( + client, + { model, request: actualRequestData }, + user_prompt_id, + effectiveProjectId, + req.apiKey?.id, + abortController.signal, + proxyConfig + ) + : await geminiAccountService.generateContentStream( + client, + { model, request: actualRequestData }, + user_prompt_id, + effectiveProjectId, + req.apiKey?.id, + abortController.signal, + proxyConfig + ) // 设置 SSE 响应头 res.setHeader('Content-Type', 'text/event-stream') @@ -1978,15 +2061,23 @@ async function handleStandardGenerateContent(req, res) { } else { // OAuth 账户 const { accessToken, refreshToken } = account + const oauthProvider = account.oauthProvider || 'gemini-cli' const client = await geminiAccountService.getOauthClient( accessToken, refreshToken, - proxyConfig + proxyConfig, + oauthProvider ) let effectiveProjectId = account.projectId || account.tempProjectId || null - if (!effectiveProjectId) { + if (oauthProvider === 'antigravity') { + if (!effectiveProjectId) { + // Antigravity 账号允许没有 projectId:生成一个稳定的临时 projectId 并缓存 + effectiveProjectId = `ag-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}` + await geminiAccountService.updateTempProjectId(actualAccountId, effectiveProjectId) + } + } else if (!effectiveProjectId) { try { logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...') const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig) @@ -2024,14 +2115,25 @@ async function handleStandardGenerateContent(req, res) { const userPromptId = `${crypto.randomUUID()}########0` - response = await geminiAccountService.generateContent( - client, - { model, request: actualRequestData }, - userPromptId, - effectiveProjectId, - req.apiKey?.id, - proxyConfig - ) + if (oauthProvider === 'antigravity') { + response = await geminiAccountService.generateContentAntigravity( + client, + { model, request: actualRequestData }, + userPromptId, + effectiveProjectId, + req.apiKey?.id, + proxyConfig + ) + } else { + response = await geminiAccountService.generateContent( + client, + { model, request: actualRequestData }, + userPromptId, + effectiveProjectId, + req.apiKey?.id, + proxyConfig + ) + } } // 记录使用统计 @@ -2263,12 +2365,20 @@ async function handleStandardStreamGenerateContent(req, res) { const client = await geminiAccountService.getOauthClient( accessToken, refreshToken, - proxyConfig + proxyConfig, + account.oauthProvider ) let effectiveProjectId = account.projectId || account.tempProjectId || null - if (!effectiveProjectId) { + const oauthProvider = account.oauthProvider || 'gemini-cli' + + if (oauthProvider === 'antigravity') { + if (!effectiveProjectId) { + effectiveProjectId = `ag-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}` + await geminiAccountService.updateTempProjectId(actualAccountId, effectiveProjectId) + } + } else if (!effectiveProjectId) { try { logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...') const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig) @@ -2306,15 +2416,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 响应头 diff --git a/src/middleware/auth.js b/src/middleware/auth.js index f051a266..1883a2a0 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -2050,7 +2050,7 @@ const globalRateLimit = async (req, res, next) => // 📊 请求大小限制中间件 const requestSizeLimit = (req, res, next) => { - const MAX_SIZE_MB = parseInt(process.env.REQUEST_MAX_SIZE_MB || '60', 10) + const MAX_SIZE_MB = parseInt(process.env.REQUEST_MAX_SIZE_MB || '100', 10) const maxSize = MAX_SIZE_MB * 1024 * 1024 const contentLength = parseInt(req.headers['content-length'] || '0') @@ -2059,7 +2059,7 @@ const requestSizeLimit = (req, res, next) => { return res.status(413).json({ error: 'Payload Too Large', message: 'Request body size exceeds limit', - limit: '10MB' + limit: `${MAX_SIZE_MB}MB` }) } diff --git a/src/models/redis.js b/src/models/redis.js index 8b41cabc..e69ba727 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -1521,6 +1521,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([ 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 d88444bd..5994f56d 100644 --- a/src/routes/admin/apiKeys.js +++ b/src/routes/admin/apiKeys.js @@ -8,6 +8,43 @@ const config = require('../../../config/config') const router = express.Router() +// 有效的权限值列表 +const VALID_PERMISSIONS = ['claude', 'gemini', 'openai', 'droid'] + +/** + * 验证权限数组格式 + * @param {any} permissions - 权限值(可以是数组或其他) + * @returns {string|null} - 返回错误消息,null 表示验证通过 + */ +function validatePermissions(permissions) { + // 空值或未定义表示全部服务 + if (permissions === undefined || permissions === null || permissions === '') { + return null + } + // 兼容旧格式字符串 + if (typeof permissions === 'string') { + if (permissions === 'all' || VALID_PERMISSIONS.includes(permissions)) { + return null + } + return `Invalid permissions value. Must be an array of: ${VALID_PERMISSIONS.join(', ')}` + } + // 新格式数组 + if (Array.isArray(permissions)) { + // 空数组表示全部服务 + if (permissions.length === 0) { + return null + } + // 验证数组中的每个值 + for (const perm of permissions) { + if (!VALID_PERMISSIONS.includes(perm)) { + return `Invalid permission value "${perm}". Valid values are: ${VALID_PERMISSIONS.join(', ')}` + } + } + return null + } + return `Permissions must be an array. Valid values are: ${VALID_PERMISSIONS.join(', ')}` +} + // 👥 用户管理 (用于API Key分配) // 获取所有用户列表(用于API Key分配) @@ -1382,16 +1419,10 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { } } - // 验证服务权限字段 - if ( - permissions !== undefined && - permissions !== null && - permissions !== '' && - !['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions) - ) { - return res.status(400).json({ - error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all' - }) + // 验证服务权限字段(支持数组格式) + const permissionsError = validatePermissions(permissions) + if (permissionsError) { + return res.status(400).json({ error: permissionsError }) } const newKey = await apiKeyService.generateApiKey({ @@ -1481,15 +1512,10 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => { .json({ error: 'Base name must be less than 90 characters to allow for numbering' }) } - if ( - permissions !== undefined && - permissions !== null && - permissions !== '' && - !['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions) - ) { - return res.status(400).json({ - error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all' - }) + // 验证服务权限字段(支持数组格式) + const batchPermissionsError = validatePermissions(permissions) + if (batchPermissionsError) { + return res.status(400).json({ error: batchPermissionsError }) } // 生成批量API Keys @@ -1592,13 +1618,12 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => { }) } - if ( - updates.permissions !== undefined && - !['claude', 'gemini', 'openai', 'droid', 'all'].includes(updates.permissions) - ) { - return res.status(400).json({ - error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all' - }) + // 验证服务权限字段(支持数组格式) + if (updates.permissions !== undefined) { + const updatePermissionsError = validatePermissions(updates.permissions) + if (updatePermissionsError) { + return res.status(400).json({ error: updatePermissionsError }) + } } logger.info( @@ -1873,11 +1898,10 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { } if (permissions !== undefined) { - // 验证权限值 - if (!['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions)) { - return res.status(400).json({ - error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all' - }) + // 验证服务权限字段(支持数组格式) + const singlePermissionsError = validatePermissions(permissions) + if (singlePermissionsError) { + return res.status(400).json({ error: singlePermissionsError }) } updates.permissions = permissions } 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/dashboard.js b/src/routes/admin/dashboard.js index fe2cb440..d98ed761 100644 --- a/src/routes/admin/dashboard.js +++ b/src/routes/admin/dashboard.js @@ -6,13 +6,11 @@ const bedrockAccountService = require('../../services/bedrockAccountService') const ccrAccountService = require('../../services/ccrAccountService') const geminiAccountService = require('../../services/geminiAccountService') const droidAccountService = require('../../services/droidAccountService') -const openaiAccountService = require('../../services/openaiAccountService') const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService') const redis = require('../../models/redis') const { authenticateAdmin } = require('../../middleware/auth') const logger = require('../../utils/logger') const CostCalculator = require('../../utils/costCalculator') -const pricingService = require('../../services/pricingService') const config = require('../../../config/config') const router = express.Router() diff --git a/src/routes/admin/geminiAccounts.js b/src/routes/admin/geminiAccounts.js index 35419ef8..ce4d6fb9 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 0b8cbecd..7fe901c7 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') @@ -37,6 +38,7 @@ router.use('/', openaiResponsesAccountsRoutes) router.use('/', droidAccountsRoutes) router.use('/', dashboardRoutes) router.use('/', usageStatsRoutes) +router.use('/', accountBalanceRoutes) router.use('/', systemRoutes) router.use('/', concurrencyRoutes) router.use('/', claudeRelayConfigRoutes) diff --git a/src/routes/api.js b/src/routes/api.js index c38c4d6f..6ec81cbd 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -20,6 +20,11 @@ const { sendMockWarmupStream } = require('../utils/warmupInterceptor') const { sanitizeUpstreamError } = require('../utils/errorSanitizer') +const { dumpAnthropicMessagesRequest } = require('../utils/anthropicRequestDump') +const { + handleAnthropicMessagesToGemini, + handleAnthropicCountTokensToGemini +} = require('../services/anthropicGeminiBridgeService') const router = express.Router() function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') { @@ -117,16 +122,18 @@ async function handleMessagesRequest(req, res) { try { const startTime = Date.now() - // Claude 服务权限校验,阻止未授权的 Key - if ( - req.apiKey.permissions && - req.apiKey.permissions !== 'all' && - req.apiKey.permissions !== 'claude' - ) { + const forcedVendor = req._anthropicVendor || null + const requiredService = + forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity' ? 'gemini' : 'claude' + + if (!apiKeyService.hasPermission(req.apiKey?.permissions, requiredService)) { return res.status(403).json({ error: { type: 'permission_error', - message: '此 API Key 无权访问 Claude 服务' + message: + requiredService === 'gemini' + ? '此 API Key 无权访问 Gemini 服务' + : '此 API Key 无权访问 Claude 服务' } }) } @@ -175,6 +182,25 @@ async function handleMessagesRequest(req, res) { } } + logger.api('📥 /v1/messages request received', { + model: req.body.model || null, + forcedVendor, + stream: req.body.stream === true + }) + + dumpAnthropicMessagesRequest(req, { + route: '/v1/messages', + forcedVendor, + model: req.body?.model || null, + stream: req.body?.stream === true + }) + + // /v1/messages 的扩展:按路径强制分流到 Gemini OAuth 账户(避免 model 前缀混乱) + if (forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity') { + const baseModel = (req.body.model || '').trim() + return await handleAnthropicMessagesToGemini(req, res, { vendor: forcedVendor, baseModel }) + } + // 检查是否为流式请求 const isStream = req.body.stream === true @@ -1024,8 +1050,8 @@ async function handleMessagesRequest(req, res) { const cacheReadTokens = jsonData.usage.cache_read_input_tokens || 0 // Parse the model to remove vendor prefix if present (e.g., "ccr,gemini-2.5-pro" -> "gemini-2.5-pro") const rawModel = jsonData.model || req.body.model || 'unknown' - const { baseModel } = parseVendorPrefixedModel(rawModel) - const model = baseModel || rawModel + const { baseModel: usageBaseModel } = parseVendorPrefixedModel(rawModel) + const model = usageBaseModel || rawModel // 记录真实的token使用量(包含模型信息和所有4种token以及账户ID) const { accountId: responseAccountId } = response @@ -1201,6 +1227,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 +1422,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/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/openaiGeminiRoutes.js b/src/routes/openaiGeminiRoutes.js index ef65acc1..9b6b5bb8 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, @@ -559,20 +591,41 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => { }) } else { // 非流式响应 + const oauthProvider = account.oauthProvider || 'gemini-cli' + let { projectId } = account + + if (oauthProvider === 'antigravity') { + projectId = ensureAntigravityProjectId(account) + if (!account.projectId && account.tempProjectId !== projectId) { + await geminiAccountService.updateTempProjectId(account.id, projectId) + account.tempProjectId = projectId + } + } + logger.info('GenerateContent request', { model, - projectId: account.projectId, + projectId, apiKeyId: apiKeyData.id }) - const response = await geminiAccountService.generateContent( - client, - { model, request: geminiRequestBody }, - null, // user_prompt_id - account.projectId, // 使用有权限的项目ID - apiKeyData.id, // 使用 API Key ID 作为 session ID - proxyConfig // 传递代理配置 - ) + const response = + oauthProvider === 'antigravity' + ? await geminiAccountService.generateContentAntigravity( + client, + { model, request: geminiRequestBody }, + null, // user_prompt_id + projectId, + apiKeyData.id, // 使用 API Key ID 作为 session ID + proxyConfig // 传递代理配置 + ) + : await geminiAccountService.generateContent( + client, + { model, request: geminiRequestBody }, + null, // user_prompt_id + projectId, // 使用有权限的项目ID + apiKeyData.id, // 使用 API Key ID 作为 session ID + proxyConfig // 传递代理配置 + ) // 转换为 OpenAI 格式并返回 const openaiResponse = convertGeminiResponseToOpenAI(response, model, false) @@ -580,7 +633,6 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => { // 记录使用统计 if (openaiResponse.usage) { try { - const apiKeyService = require('../services/apiKeyService') await apiKeyService.recordUsage( apiKeyData.id, openaiResponse.usage.prompt_tokens || 0, @@ -604,12 +656,15 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => { const duration = Date.now() - startTime logger.info(`OpenAI-Gemini request completed in ${duration}ms`) } catch (error) { - // 客户端主动断开连接是正常情况,使用 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 +700,8 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => { return undefined }) -// OpenAI 兼容的模型列表端点 -router.get('/v1/models', authenticateApiKey, async (req, res) => { +// 获取可用模型列表的共享处理器 +async function handleGetModels(req, res) { try { const apiKeyData = req.apiKey @@ -677,8 +732,21 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => { let models = [] if (account) { - // 获取实际的模型列表 - models = await getAvailableModels(account.accessToken, account.proxy) + // 获取实际的模型列表(失败时回退到默认列表,避免影响 /v1/models 可用性) + try { + const oauthProvider = account.oauthProvider || 'gemini-cli' + models = + oauthProvider === 'antigravity' + ? await geminiAccountService.fetchAvailableModelsAntigravity( + account.accessToken, + account.proxy, + account.refreshToken + ) + : await getAvailableModels(account.accessToken, account.proxy) + } catch (error) { + logger.warn('Failed to get Gemini models list from upstream, fallback to default:', error) + models = [] + } } else { // 返回默认模型列表 models = [ @@ -691,6 +759,17 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => { ] } + if (!models || models.length === 0) { + models = [ + { + id: 'gemini-2.0-flash-exp', + object: 'model', + created: Math.floor(Date.now() / 1000), + owned_by: 'google' + } + ] + } + // 如果启用了模型限制,过滤模型列表 if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels.length > 0) { models = models.filter((model) => apiKeyData.restrictedModels.includes(model.id)) @@ -710,8 +789,13 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => { } }) } - return undefined -}) +} + +// OpenAI 兼容的模型列表端点 (带 v1 版) +router.get('/v1/models', authenticateApiKey, handleGetModels) + +// OpenAI 兼容的模型列表端点 (根路径版,方便第三方加载) +router.get('/models', authenticateApiKey, handleGetModels) // OpenAI 兼容的模型详情端点 router.get('/v1/models/:model', authenticateApiKey, async (req, res) => { diff --git a/src/routes/openaiRoutes.js b/src/routes/openaiRoutes.js index 7faf9e87..7f1b04f1 100644 --- a/src/routes/openaiRoutes.js +++ b/src/routes/openaiRoutes.js @@ -20,8 +20,7 @@ function createProxyAgent(proxy) { // 检查 API Key 是否具备 OpenAI 权限 function checkOpenAIPermissions(apiKeyData) { - const permissions = apiKeyData?.permissions || 'all' - return permissions === 'all' || permissions === 'openai' + return apiKeyService.hasPermission(apiKeyData?.permissions, 'openai') } function normalizeHeaders(headers = {}) { 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..8c136e80 --- /dev/null +++ b/src/services/accountBalanceService.js @@ -0,0 +1,764 @@ +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 + } + + return await service.getAccount(accountId) + } + + 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) { + throw new Error('账户缺少 id') + } + + // 余额脚本配置状态(用于前端控制“刷新余额”按钮) + 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..faa4f592 --- /dev/null +++ b/src/services/anthropicGeminiBridgeService.js @@ -0,0 +1,3071 @@ +/** + * ============================================================================ + * 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 请求有有效的 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 + } + + if (systemParts.length > 0) { + geminiRequestBody.systemInstruction = + vendor === 'antigravity' ? { role: 'user', parts: systemParts } : { 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 = '') { + if (!rateLimitInfo) { + return + } + + const label = context ? ` (${context})` : '' + + try { + const { totalTokens, totalCost } = await updateRateLimitCounters( + rateLimitInfo, + usageSummary, + model + ) + 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 + ) + await applyRateLimitTracking( + req.rateLimitInfo, + { inputTokens, outputTokens, cacheCreateTokens: 0, cacheReadTokens: 0 }, + effectiveModel, + 'anthropic-messages' + ) + } + + 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 + ) + 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..19c3bd23 --- /dev/null +++ b/src/services/antigravityClient.js @@ -0,0 +1,594 @@ +const axios = require('axios') +const https = require('https') +const { v4: uuidv4 } = require('uuid') + +const ProxyHelper = require('../utils/proxyHelper') +const logger = require('../utils/logger') +const { + mapAntigravityUpstreamModel, + normalizeAntigravityModelInput, + getAntigravityModelMetadata +} = require('../utils/antigravityModel') +const { cleanJsonSchemaForGemini } = require('../utils/geminiSchemaCleaner') +const { dumpAntigravityUpstreamRequest } = require('../utils/antigravityUpstreamDump') + +const keepAliveAgent = new https.Agent({ + keepAlive: true, + keepAliveMsecs: 30000, + timeout: 120000, + maxSockets: 100, + maxFreeSockets: 10 +}) + +function getAntigravityApiUrl() { + return process.env.ANTIGRAVITY_API_URL || 'https://daily-cloudcode-pa.sandbox.googleapis.com' +} + +function normalizeBaseUrl(url) { + const str = String(url || '').trim() + return str.endsWith('/') ? str.slice(0, -1) : str +} + +function getAntigravityApiUrlCandidates() { + const configured = normalizeBaseUrl(getAntigravityApiUrl()) + const daily = 'https://daily-cloudcode-pa.sandbox.googleapis.com' + const prod = 'https://cloudcode-pa.googleapis.com' + + // 若显式配置了自定义 base url,则只使用该地址(不做 fallback,避免意外路由到别的环境)。 + if (process.env.ANTIGRAVITY_API_URL) { + return [configured] + } + + // 默认行为:优先 daily(与旧逻辑一致),失败时再尝试 prod(对齐 CLIProxyAPI)。 + if (configured === normalizeBaseUrl(daily)) { + return [configured, prod] + } + if (configured === normalizeBaseUrl(prod)) { + return [configured, daily] + } + + return [configured, prod, daily].filter(Boolean) +} + +function getAntigravityHeaders(accessToken, baseUrl) { + const resolvedBaseUrl = baseUrl || getAntigravityApiUrl() + let host = 'daily-cloudcode-pa.sandbox.googleapis.com' + try { + host = new URL(resolvedBaseUrl).host || host + } catch (e) { + // ignore + } + + return { + Host: host, + 'User-Agent': process.env.ANTIGRAVITY_USER_AGENT || 'antigravity/1.11.3 windows/amd64', + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'Accept-Encoding': 'gzip' + } +} + +function generateAntigravityProjectId() { + return `ag-${uuidv4().replace(/-/g, '').slice(0, 16)}` +} + +function generateAntigravitySessionId() { + return `sess-${uuidv4()}` +} + +function resolveAntigravityProjectId(projectId, requestData) { + const candidate = projectId || requestData?.project || requestData?.projectId || null + return candidate || generateAntigravityProjectId() +} + +function resolveAntigravitySessionId(sessionId, requestData) { + const candidate = + sessionId || requestData?.request?.sessionId || requestData?.request?.session_id || null + return candidate || generateAntigravitySessionId() +} + +function buildAntigravityEnvelope({ requestData, projectId, sessionId, userPromptId }) { + const model = mapAntigravityUpstreamModel(requestData?.model) + const resolvedProjectId = resolveAntigravityProjectId(projectId, requestData) + const resolvedSessionId = resolveAntigravitySessionId(sessionId, requestData) + const requestPayload = { + ...(requestData?.request || {}) + } + + if (requestPayload.session_id !== undefined) { + delete requestPayload.session_id + } + requestPayload.sessionId = resolvedSessionId + + const envelope = { + project: resolvedProjectId, + requestId: `req-${uuidv4()}`, + model, + userAgent: 'antigravity', + request: { + ...requestPayload + } + } + + if (userPromptId) { + envelope.user_prompt_id = userPromptId + envelope.userPromptId = userPromptId + } + + normalizeAntigravityEnvelope(envelope) + return { model, envelope } +} + +function normalizeAntigravityThinking(model, requestPayload) { + if (!requestPayload || typeof requestPayload !== 'object') { + return + } + + const { generationConfig } = requestPayload + if (!generationConfig || typeof generationConfig !== 'object') { + return + } + const { thinkingConfig } = generationConfig + if (!thinkingConfig || typeof thinkingConfig !== 'object') { + return + } + + const normalizedModel = normalizeAntigravityModelInput(model) + if (thinkingConfig.thinkingLevel && !normalizedModel.startsWith('gemini-3-')) { + delete thinkingConfig.thinkingLevel + } + + const metadata = getAntigravityModelMetadata(normalizedModel) + if (metadata && !metadata.thinking) { + delete generationConfig.thinkingConfig + return + } + if (!metadata || !metadata.thinking) { + return + } + + const budgetRaw = Number(thinkingConfig.thinkingBudget) + if (!Number.isFinite(budgetRaw)) { + return + } + let budget = Math.trunc(budgetRaw) + + const minBudget = Number.isFinite(metadata.thinking.min) ? metadata.thinking.min : null + const maxBudget = Number.isFinite(metadata.thinking.max) ? metadata.thinking.max : null + + if (maxBudget !== null && budget > maxBudget) { + budget = maxBudget + } + + let effectiveMax = Number.isFinite(generationConfig.maxOutputTokens) + ? generationConfig.maxOutputTokens + : null + let setDefaultMax = false + if (!effectiveMax && metadata.maxCompletionTokens) { + effectiveMax = metadata.maxCompletionTokens + setDefaultMax = true + } + + if (effectiveMax && budget >= effectiveMax) { + budget = Math.max(0, effectiveMax - 1) + } + + if (minBudget !== null && budget >= 0 && budget < minBudget) { + delete generationConfig.thinkingConfig + return + } + + thinkingConfig.thinkingBudget = budget + if (setDefaultMax) { + generationConfig.maxOutputTokens = effectiveMax + } +} + +function normalizeAntigravityEnvelope(envelope) { + if (!envelope || typeof envelope !== 'object') { + return + } + const model = String(envelope.model || '') + const requestPayload = envelope.request + if (!requestPayload || typeof requestPayload !== 'object') { + return + } + + if (requestPayload.safetySettings !== undefined) { + delete requestPayload.safetySettings + } + + // 对齐 CLIProxyAPI:有 tools 时默认启用 VALIDATED(除非显式 NONE) + if (Array.isArray(requestPayload.tools) && requestPayload.tools.length > 0) { + const existing = requestPayload?.toolConfig?.functionCallingConfig || null + if (existing?.mode !== 'NONE') { + const nextCfg = { ...(existing || {}), mode: 'VALIDATED' } + requestPayload.toolConfig = { functionCallingConfig: nextCfg } + } + } + + // 对齐 CLIProxyAPI:非 Claude 模型移除 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..437e18c5 --- /dev/null +++ b/src/services/antigravityRelayService.js @@ -0,0 +1,170 @@ +const apiKeyService = require('./apiKeyService') +const { convertMessagesToGemini, convertGeminiResponse } = require('./geminiRelayService') +const { normalizeAntigravityModelInput } = require('../utils/antigravityModel') +const antigravityClient = require('./antigravityClient') + +function buildRequestData({ messages, model, temperature, maxTokens, sessionId }) { + const requestedModel = normalizeAntigravityModelInput(model) + const { contents, systemInstruction } = convertMessagesToGemini(messages) + + const requestData = { + model: requestedModel, + request: { + contents, + generationConfig: { + temperature, + maxOutputTokens: maxTokens, + candidateCount: 1, + topP: 0.95, + topK: 40 + }, + ...(sessionId ? { sessionId } : {}) + } + } + + if (systemInstruction) { + requestData.request.systemInstruction = { parts: [{ text: systemInstruction }] } + } + + return requestData +} + +async function* handleStreamResponse(response, model, apiKeyId, accountId) { + let buffer = '' + let totalUsage = { + promptTokenCount: 0, + candidatesTokenCount: 0, + totalTokenCount: 0 + } + let usageRecorded = false + + try { + for await (const chunk of response.data) { + buffer += chunk.toString() + + const lines = buffer.split('\n') + buffer = lines.pop() || '' + + for (const line of lines) { + if (!line.trim()) { + continue + } + + let jsonData = line + if (line.startsWith('data: ')) { + jsonData = line.substring(6).trim() + } + + if (!jsonData || jsonData === '[DONE]') { + continue + } + + try { + const data = JSON.parse(jsonData) + const payload = data?.response || data + + if (payload?.usageMetadata) { + totalUsage = payload.usageMetadata + } + + const openaiChunk = convertGeminiResponse(payload, model, true) + if (openaiChunk) { + yield `data: ${JSON.stringify(openaiChunk)}\n\n` + const finishReason = openaiChunk.choices?.[0]?.finish_reason + if (finishReason === 'stop') { + yield 'data: [DONE]\n\n' + + if (apiKeyId && totalUsage.totalTokenCount > 0) { + await apiKeyService.recordUsage( + apiKeyId, + totalUsage.promptTokenCount || 0, + totalUsage.candidatesTokenCount || 0, + 0, + 0, + model, + accountId + ) + usageRecorded = true + } + return + } + } + } catch (e) { + // ignore chunk parse errors + } + } + } + } finally { + if (!usageRecorded && apiKeyId && totalUsage.totalTokenCount > 0) { + await apiKeyService.recordUsage( + apiKeyId, + totalUsage.promptTokenCount || 0, + totalUsage.candidatesTokenCount || 0, + 0, + 0, + model, + accountId + ) + } + } +} + +async function sendAntigravityRequest({ + messages, + model, + temperature = 0.7, + maxTokens = 4096, + stream = false, + accessToken, + proxy, + apiKeyId, + signal, + projectId, + accountId = null +}) { + const requestedModel = normalizeAntigravityModelInput(model) + + const requestData = buildRequestData({ + messages, + model: requestedModel, + temperature, + maxTokens, + sessionId: apiKeyId + }) + + const { response } = await antigravityClient.request({ + accessToken, + proxyConfig: proxy, + requestData, + projectId, + sessionId: apiKeyId, + stream, + signal, + params: { alt: 'sse' } + }) + + if (stream) { + return handleStreamResponse(response, requestedModel, apiKeyId, accountId) + } + + const payload = response.data?.response || response.data + const openaiResponse = convertGeminiResponse(payload, requestedModel, false) + + if (apiKeyId && openaiResponse?.usage) { + await apiKeyService.recordUsage( + apiKeyId, + openaiResponse.usage.prompt_tokens || 0, + openaiResponse.usage.completion_tokens || 0, + 0, + 0, + requestedModel, + accountId + ) + } + + return openaiResponse +} + +module.exports = { + sendAntigravityRequest +} diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js index 0e9e7597..771f973b 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -37,6 +37,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 @@ -89,7 +134,7 @@ class ApiKeyService { azureOpenaiAccountId = null, bedrockAccountId = null, // 添加 Bedrock 账号ID支持 droidAccountId = null, - permissions = 'all', // 可选值:'claude'、'gemini'、'openai'、'droid' 或 'all' + permissions = [], // 数组格式,空数组表示全部服务,如 ['claude', 'gemini'] isActive = true, concurrencyLimit = 0, rateLimitWindow = null, @@ -132,7 +177,7 @@ class ApiKeyService { azureOpenaiAccountId: azureOpenaiAccountId || '', bedrockAccountId: bedrockAccountId || '', // 添加 Bedrock 账号ID droidAccountId: droidAccountId || '', - permissions: permissions || 'all', + permissions: JSON.stringify(normalizePermissions(permissions)), enableModelRestriction: String(enableModelRestriction), restrictedModels: JSON.stringify(restrictedModels || []), enableClientRestriction: String(enableClientRestriction || false), @@ -186,7 +231,7 @@ class ApiKeyService { azureOpenaiAccountId: keyData.azureOpenaiAccountId, bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID droidAccountId: keyData.droidAccountId, - permissions: keyData.permissions, + permissions: normalizePermissions(keyData.permissions), enableModelRestriction: keyData.enableModelRestriction === 'true', restrictedModels: JSON.parse(keyData.restrictedModels), enableClientRestriction: keyData.enableClientRestriction === 'true', @@ -338,7 +383,7 @@ class ApiKeyService { azureOpenaiAccountId: keyData.azureOpenaiAccountId, bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID droidAccountId: keyData.droidAccountId, - permissions: keyData.permissions || 'all', + permissions: normalizePermissions(keyData.permissions), tokenLimit: parseInt(keyData.tokenLimit), concurrencyLimit: parseInt(keyData.concurrencyLimit || 0), rateLimitWindow: parseInt(keyData.rateLimitWindow || 0), @@ -467,7 +512,7 @@ class ApiKeyService { azureOpenaiAccountId: keyData.azureOpenaiAccountId, bedrockAccountId: keyData.bedrockAccountId, droidAccountId: keyData.droidAccountId, - permissions: keyData.permissions || 'all', + permissions: normalizePermissions(keyData.permissions), tokenLimit: parseInt(keyData.tokenLimit), concurrencyLimit: parseInt(keyData.concurrencyLimit || 0), rateLimitWindow: parseInt(keyData.rateLimitWindow || 0), @@ -525,7 +570,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) @@ -1568,7 +1613,7 @@ class ApiKeyService { userId: keyData.userId, userUsername: keyData.userUsername, createdBy: keyData.createdBy, - permissions: keyData.permissions, + permissions: normalizePermissions(keyData.permissions), dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0), totalCostLimit: parseFloat(keyData.totalCostLimit || 0), // 所有平台账户绑定字段 @@ -1820,4 +1865,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/geminiAccountService.js b/src/services/geminiAccountService.js index a23d81b3..50c3e922 100644 --- a/src/services/geminiAccountService.js +++ b/src/services/geminiAccountService.js @@ -16,11 +16,62 @@ const { } = require('../utils/tokenRefreshLogger') const tokenRefreshService = require('./tokenRefreshService') const LRUCache = require('../utils/lruCache') +const antigravityClient = require('./antigravityClient') -// Gemini CLI OAuth 配置 - 这些是公开的 Gemini CLI 凭据 -const OAUTH_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com' -const OAUTH_CLIENT_SECRET = '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' -const OAUTH_SCOPES = ['https://www.googleapis.com/auth/cloud-platform'] +// Gemini OAuth 配置 - 支持 Gemini CLI 与 Antigravity 两种 OAuth 应用 +const OAUTH_PROVIDER_GEMINI_CLI = 'gemini-cli' +const OAUTH_PROVIDER_ANTIGRAVITY = 'antigravity' + +const OAUTH_PROVIDERS = { + [OAUTH_PROVIDER_GEMINI_CLI]: { + // Gemini CLI OAuth 配置(公开) + clientId: + process.env.GEMINI_OAUTH_CLIENT_ID || + '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com', + clientSecret: process.env.GEMINI_OAUTH_CLIENT_SECRET || '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', + scopes: ['https://www.googleapis.com/auth/cloud-platform'] + }, + [OAUTH_PROVIDER_ANTIGRAVITY]: { + // Antigravity OAuth 配置(参考 gcli2api) + clientId: + process.env.ANTIGRAVITY_OAUTH_CLIENT_ID || + '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com', + clientSecret: + process.env.ANTIGRAVITY_OAUTH_CLIENT_SECRET || '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', + scopes: [ + 'https://www.googleapis.com/auth/cloud-platform', + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', + 'https://www.googleapis.com/auth/cclog', + 'https://www.googleapis.com/auth/experimentsandconfigs' + ] + } +} + +if (!process.env.GEMINI_OAUTH_CLIENT_SECRET) { + logger.warn( + '⚠️ GEMINI_OAUTH_CLIENT_SECRET 未设置,使用内置默认值(建议在生产环境通过环境变量覆盖)' + ) +} +if (!process.env.ANTIGRAVITY_OAUTH_CLIENT_SECRET) { + logger.warn( + '⚠️ ANTIGRAVITY_OAUTH_CLIENT_SECRET 未设置,使用内置默认值(建议在生产环境通过环境变量覆盖)' + ) +} + +function normalizeOauthProvider(oauthProvider) { + if (!oauthProvider) { + return OAUTH_PROVIDER_GEMINI_CLI + } + return oauthProvider === OAUTH_PROVIDER_ANTIGRAVITY + ? OAUTH_PROVIDER_ANTIGRAVITY + : OAUTH_PROVIDER_GEMINI_CLI +} + +function getOauthProviderConfig(oauthProvider) { + const normalized = normalizeOauthProvider(oauthProvider) + return OAUTH_PROVIDERS[normalized] || OAUTH_PROVIDERS[OAUTH_PROVIDER_GEMINI_CLI] +} // 🌐 TCP Keep-Alive Agent 配置 // 解决长时间流式请求中 NAT/防火墙空闲超时导致的连接中断问题 @@ -34,6 +85,117 @@ const keepAliveAgent = new https.Agent({ logger.info('🌐 Gemini HTTPS Agent initialized with TCP Keep-Alive support') +async function fetchAvailableModelsAntigravity( + accessToken, + proxyConfig = null, + refreshToken = null +) { + try { + let effectiveToken = accessToken + if (refreshToken) { + try { + const client = await getOauthClient( + accessToken, + refreshToken, + proxyConfig, + OAUTH_PROVIDER_ANTIGRAVITY + ) + if (client && client.getAccessToken) { + const latest = await client.getAccessToken() + if (latest?.token) { + effectiveToken = latest.token + } + } + } catch (error) { + logger.warn('Failed to refresh Antigravity access token for models list:', { + message: error.message + }) + } + } + + const data = await antigravityClient.fetchAvailableModels({ + accessToken: effectiveToken, + proxyConfig + }) + const modelsDict = data?.models + const created = Math.floor(Date.now() / 1000) + + const models = [] + const seen = new Set() + const { + getAntigravityModelAlias, + getAntigravityModelMetadata, + normalizeAntigravityModelInput + } = require('../utils/antigravityModel') + + const pushModel = (modelId) => { + if (!modelId || seen.has(modelId)) { + return + } + seen.add(modelId) + const metadata = getAntigravityModelMetadata(modelId) + const entry = { + id: modelId, + object: 'model', + created, + owned_by: 'antigravity' + } + if (metadata?.name) { + entry.name = metadata.name + } + if (metadata?.maxCompletionTokens) { + entry.max_completion_tokens = metadata.maxCompletionTokens + } + if (metadata?.thinking) { + entry.thinking = metadata.thinking + } + models.push(entry) + } + + if (modelsDict && typeof modelsDict === 'object') { + for (const modelId of Object.keys(modelsDict)) { + const normalized = normalizeAntigravityModelInput(modelId) + const alias = getAntigravityModelAlias(normalized) + if (!alias) { + continue + } + pushModel(alias) + + if (alias.endsWith('-thinking')) { + pushModel(alias.replace(/-thinking$/, '')) + } + + if (alias.startsWith('gemini-claude-')) { + pushModel(alias.replace(/^gemini-/, '')) + } + } + } + + return models + } catch (error) { + logger.error('Failed to fetch Antigravity models:', error.response?.data || error.message) + return [ + { + id: 'gemini-2.5-flash', + object: 'model', + created: Math.floor(Date.now() / 1000), + owned_by: 'antigravity' + } + ] + } +} + +async function countTokensAntigravity(client, contents, model, proxyConfig = null) { + const { token } = await client.getAccessToken() + const response = await antigravityClient.countTokens({ + accessToken: token, + proxyConfig, + contents, + model + }) + return response +} + // 加密相关常量 const ALGORITHM = 'aes-256-cbc' const ENCRYPTION_SALT = 'gemini-account-salt' @@ -124,14 +286,15 @@ setInterval( ) // 创建 OAuth2 客户端(支持代理配置) -function createOAuth2Client(redirectUri = null, proxyConfig = null) { +function createOAuth2Client(redirectUri = null, proxyConfig = null, oauthProvider = null) { // 如果没有提供 redirectUri,使用默认值 const uri = redirectUri || 'http://localhost:45462' + const oauthConfig = getOauthProviderConfig(oauthProvider) // 准备客户端选项 const clientOptions = { - clientId: OAUTH_CLIENT_ID, - clientSecret: OAUTH_CLIENT_SECRET, + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, redirectUri: uri } @@ -152,10 +315,17 @@ function createOAuth2Client(redirectUri = null, proxyConfig = null) { } // 生成授权 URL (支持 PKCE 和代理) -async function generateAuthUrl(state = null, redirectUri = null, proxyConfig = null) { +async function generateAuthUrl( + state = null, + redirectUri = null, + proxyConfig = null, + oauthProvider = null +) { // 使用新的 redirect URI const finalRedirectUri = redirectUri || 'https://codeassist.google.com/authcode' - const oAuth2Client = createOAuth2Client(finalRedirectUri, proxyConfig) + const normalizedProvider = normalizeOauthProvider(oauthProvider) + const oauthConfig = getOauthProviderConfig(normalizedProvider) + const oAuth2Client = createOAuth2Client(finalRedirectUri, proxyConfig, normalizedProvider) if (proxyConfig) { logger.info( @@ -172,7 +342,7 @@ async function generateAuthUrl(state = null, redirectUri = null, proxyConfig = n const authUrl = oAuth2Client.generateAuthUrl({ redirect_uri: finalRedirectUri, access_type: 'offline', - scope: OAUTH_SCOPES, + scope: oauthConfig.scopes, code_challenge_method: 'S256', code_challenge: codeVerifier.codeChallenge, state: stateValue, @@ -183,7 +353,8 @@ async function generateAuthUrl(state = null, redirectUri = null, proxyConfig = n authUrl, state: stateValue, codeVerifier: codeVerifier.codeVerifier, - redirectUri: finalRedirectUri + redirectUri: finalRedirectUri, + oauthProvider: normalizedProvider } } @@ -244,11 +415,14 @@ async function exchangeCodeForTokens( code, redirectUri = null, codeVerifier = null, - proxyConfig = null + proxyConfig = null, + oauthProvider = null ) { try { + const normalizedProvider = normalizeOauthProvider(oauthProvider) + const oauthConfig = getOauthProviderConfig(normalizedProvider) // 创建带代理配置的 OAuth2Client - const oAuth2Client = createOAuth2Client(redirectUri, proxyConfig) + const oAuth2Client = createOAuth2Client(redirectUri, proxyConfig, normalizedProvider) if (proxyConfig) { logger.info( @@ -274,7 +448,7 @@ async function exchangeCodeForTokens( return { access_token: tokens.access_token, refresh_token: tokens.refresh_token, - scope: tokens.scope || OAUTH_SCOPES.join(' '), + scope: tokens.scope || oauthConfig.scopes.join(' '), token_type: tokens.token_type || 'Bearer', expiry_date: tokens.expiry_date || Date.now() + tokens.expires_in * 1000 } @@ -285,9 +459,11 @@ async function exchangeCodeForTokens( } // 刷新访问令牌 -async function refreshAccessToken(refreshToken, proxyConfig = null) { +async function refreshAccessToken(refreshToken, proxyConfig = null, oauthProvider = null) { + const normalizedProvider = normalizeOauthProvider(oauthProvider) + const oauthConfig = getOauthProviderConfig(normalizedProvider) // 创建带代理配置的 OAuth2Client - const oAuth2Client = createOAuth2Client(null, proxyConfig) + const oAuth2Client = createOAuth2Client(null, proxyConfig, normalizedProvider) try { // 设置 refresh_token @@ -319,7 +495,7 @@ async function refreshAccessToken(refreshToken, proxyConfig = null) { return { access_token: credentials.access_token, refresh_token: credentials.refresh_token || refreshToken, // 保留原 refresh_token 如果没有返回新的 - scope: credentials.scope || OAUTH_SCOPES.join(' '), + scope: credentials.scope || oauthConfig.scopes.join(' '), token_type: credentials.token_type || 'Bearer', expiry_date: credentials.expiry_date || Date.now() + 3600000 // 默认1小时过期 } @@ -339,6 +515,8 @@ async function refreshAccessToken(refreshToken, proxyConfig = null) { async function createAccount(accountData) { const id = uuidv4() const now = new Date().toISOString() + const oauthProvider = normalizeOauthProvider(accountData.oauthProvider) + const oauthConfig = getOauthProviderConfig(oauthProvider) // 处理凭证数据 let geminiOauth = null @@ -371,7 +549,7 @@ async function createAccount(accountData) { geminiOauth = JSON.stringify({ access_token: accessToken, refresh_token: refreshToken, - scope: accountData.scope || OAUTH_SCOPES.join(' '), + scope: accountData.scope || oauthConfig.scopes.join(' '), token_type: accountData.tokenType || 'Bearer', expiry_date: accountData.expiryDate || Date.now() + 3600000 // 默认1小时 }) @@ -399,7 +577,8 @@ async function createAccount(accountData) { refreshToken: refreshToken ? encrypt(refreshToken) : '', expiresAt, // OAuth Token 过期时间(技术字段,自动刷新) // 只有OAuth方式才有scopes,手动添加的没有 - scopes: accountData.geminiOauth ? accountData.scopes || OAUTH_SCOPES.join(' ') : '', + scopes: accountData.geminiOauth ? accountData.scopes || oauthConfig.scopes.join(' ') : '', + oauthProvider, // ✅ 新增:账户订阅到期时间(业务字段,手动管理) subscriptionExpiresAt: accountData.subscriptionExpiresAt || null, @@ -508,6 +687,10 @@ async function updateAccount(accountId, updates) { updates.schedulable = updates.schedulable.toString() } + if (updates.oauthProvider !== undefined) { + updates.oauthProvider = normalizeOauthProvider(updates.oauthProvider) + } + // 加密敏感字段 if (updates.geminiOauth) { updates.geminiOauth = encrypt( @@ -885,12 +1068,13 @@ async function refreshAccountToken(accountId) { // 重新获取账户数据(可能已被其他进程刷新) const updatedAccount = await getAccount(accountId) if (updatedAccount && updatedAccount.accessToken) { + const oauthConfig = getOauthProviderConfig(updatedAccount.oauthProvider) const accessToken = decrypt(updatedAccount.accessToken) return { access_token: accessToken, refresh_token: updatedAccount.refreshToken ? decrypt(updatedAccount.refreshToken) : '', expiry_date: updatedAccount.expiresAt ? new Date(updatedAccount.expiresAt).getTime() : 0, - scope: updatedAccount.scope || OAUTH_SCOPES.join(' '), + scope: updatedAccount.scopes || oauthConfig.scopes.join(' '), token_type: 'Bearer' } } @@ -904,7 +1088,11 @@ async function refreshAccountToken(accountId) { // account.refreshToken 已经是解密后的值(从 getAccount 返回) // 传入账户的代理配置 - const newTokens = await refreshAccessToken(account.refreshToken, account.proxy) + const newTokens = await refreshAccessToken( + account.refreshToken, + account.proxy, + account.oauthProvider + ) // 更新账户信息 const updates = { @@ -1036,14 +1224,15 @@ async function getAccountRateLimitInfo(accountId) { } // 获取配置的OAuth客户端 - 参考GeminiCliSimulator的getOauthClient方法(支持代理) -async function getOauthClient(accessToken, refreshToken, proxyConfig = null) { - const client = createOAuth2Client(null, proxyConfig) +async function getOauthClient(accessToken, refreshToken, proxyConfig = null, oauthProvider = null) { + const normalizedProvider = normalizeOauthProvider(oauthProvider) + const oauthConfig = getOauthProviderConfig(normalizedProvider) + const client = createOAuth2Client(null, proxyConfig, normalizedProvider) const creds = { access_token: accessToken, refresh_token: refreshToken, - scope: - 'https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.profile openid https://www.googleapis.com/auth/userinfo.email', + scope: oauthConfig.scopes.join(' '), token_type: 'Bearer', expiry_date: 1754269905646 } @@ -1509,6 +1698,43 @@ async function generateContent( return response.data } +// 调用 Antigravity 上游生成内容(非流式) +async function generateContentAntigravity( + client, + requestData, + userPromptId, + projectId = null, + sessionId = null, + proxyConfig = null +) { + const { token } = await client.getAccessToken() + const { model } = antigravityClient.buildAntigravityEnvelope({ + requestData, + projectId, + sessionId, + userPromptId + }) + + logger.info('🪐 Antigravity generateContent API调用开始', { + model, + userPromptId, + projectId, + sessionId + }) + + const { response } = await antigravityClient.request({ + accessToken: token, + proxyConfig, + requestData, + projectId, + sessionId, + userPromptId, + stream: false + }) + logger.info('✅ Antigravity generateContent API调用成功') + return response.data +} + // 调用 Code Assist API 生成内容(流式) async function generateContentStream( client, @@ -1593,6 +1819,46 @@ async function generateContentStream( return response.data // 返回流对象 } +// 调用 Antigravity 上游生成内容(流式) +async function generateContentStreamAntigravity( + client, + requestData, + userPromptId, + projectId = null, + sessionId = null, + signal = null, + proxyConfig = null +) { + const { token } = await client.getAccessToken() + const { model } = antigravityClient.buildAntigravityEnvelope({ + requestData, + projectId, + sessionId, + userPromptId + }) + + logger.info('🌊 Antigravity streamGenerateContent API调用开始', { + model, + userPromptId, + projectId, + sessionId + }) + + const { response } = await antigravityClient.request({ + accessToken: token, + proxyConfig, + requestData, + projectId, + sessionId, + userPromptId, + stream: true, + signal, + params: { alt: 'sse' } + }) + logger.info('✅ Antigravity streamGenerateContent API调用成功,开始流式传输') + return response.data +} + // 更新账户的临时项目 ID async function updateTempProjectId(accountId, tempProjectId) { if (!tempProjectId) { @@ -1687,10 +1953,12 @@ module.exports = { generateEncryptionKey, decryptCache, // 暴露缓存对象以便测试和监控 countTokens, + countTokensAntigravity, generateContent, generateContentStream, + generateContentAntigravity, + generateContentStreamAntigravity, + fetchAvailableModelsAntigravity, updateTempProjectId, - resetAccountStatus, - OAUTH_CLIENT_ID, - OAUTH_SCOPES + resetAccountStatus } diff --git a/src/services/unifiedGeminiScheduler.js b/src/services/unifiedGeminiScheduler.js index 33193501..7082e7bb 100644 --- a/src/services/unifiedGeminiScheduler.js +++ b/src/services/unifiedGeminiScheduler.js @@ -4,11 +4,35 @@ const accountGroupService = require('./accountGroupService') const redis = require('../models/redis') const logger = require('../utils/logger') +const OAUTH_PROVIDER_GEMINI_CLI = 'gemini-cli' +const OAUTH_PROVIDER_ANTIGRAVITY = 'antigravity' +const KNOWN_OAUTH_PROVIDERS = [OAUTH_PROVIDER_GEMINI_CLI, OAUTH_PROVIDER_ANTIGRAVITY] + +function normalizeOauthProvider(oauthProvider) { + if (!oauthProvider) { + return OAUTH_PROVIDER_GEMINI_CLI + } + return oauthProvider === OAUTH_PROVIDER_ANTIGRAVITY + ? OAUTH_PROVIDER_ANTIGRAVITY + : OAUTH_PROVIDER_GEMINI_CLI +} + class UnifiedGeminiScheduler { constructor() { this.SESSION_MAPPING_PREFIX = 'unified_gemini_session_mapping:' } + _getSessionMappingKey(sessionHash, oauthProvider = null) { + if (!sessionHash) { + return null + } + if (!oauthProvider) { + return `${this.SESSION_MAPPING_PREFIX}${sessionHash}` + } + const normalized = normalizeOauthProvider(oauthProvider) + return `${this.SESSION_MAPPING_PREFIX}${normalized}:${sessionHash}` + } + // 🔧 辅助方法:检查账户是否可调度(兼容字符串和布尔值) _isSchedulable(schedulable) { // 如果是 undefined 或 null,默认为可调度 @@ -32,7 +56,8 @@ class UnifiedGeminiScheduler { requestedModel = null, options = {} ) { - const { allowApiAccounts = false } = options + const { allowApiAccounts = false, oauthProvider = null } = options + const normalizedOauthProvider = oauthProvider ? normalizeOauthProvider(oauthProvider) : null try { // 如果API Key绑定了专属账户或分组,优先使用 @@ -83,14 +108,23 @@ class UnifiedGeminiScheduler { this._isActive(boundAccount.isActive) && boundAccount.status !== 'error' ) { - logger.info( - `🎯 Using bound dedicated Gemini account: ${boundAccount.name} (${apiKeyData.geminiAccountId}) for API key ${apiKeyData.name}` - ) - // 更新账户的最后使用时间 - await geminiAccountService.markAccountUsed(apiKeyData.geminiAccountId) - return { - accountId: apiKeyData.geminiAccountId, - accountType: 'gemini' + if ( + normalizedOauthProvider && + normalizeOauthProvider(boundAccount.oauthProvider) !== normalizedOauthProvider + ) { + logger.warn( + `⚠️ Bound Gemini OAuth account ${boundAccount.name} oauthProvider=${normalizeOauthProvider(boundAccount.oauthProvider)} does not match requested oauthProvider=${normalizedOauthProvider}, falling back to pool` + ) + } else { + logger.info( + `🎯 Using bound dedicated Gemini account: ${boundAccount.name} (${apiKeyData.geminiAccountId}) for API key ${apiKeyData.name}` + ) + // 更新账户的最后使用时间 + await geminiAccountService.markAccountUsed(apiKeyData.geminiAccountId) + return { + accountId: apiKeyData.geminiAccountId, + accountType: 'gemini' + } } } else { logger.warn( @@ -102,7 +136,7 @@ class UnifiedGeminiScheduler { // 如果有会话哈希,检查是否有已映射的账户 if (sessionHash) { - const mappedAccount = await this._getSessionMapping(sessionHash) + const mappedAccount = await this._getSessionMapping(sessionHash, normalizedOauthProvider) if (mappedAccount) { // 验证映射的账户是否仍然可用 const isAvailable = await this._isAccountAvailable( @@ -111,7 +145,7 @@ class UnifiedGeminiScheduler { ) if (isAvailable) { // 🚀 智能会话续期(续期 unified 映射键,按配置) - await this._extendSessionMappingTTL(sessionHash) + await this._extendSessionMappingTTL(sessionHash, normalizedOauthProvider) logger.info( `🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}` ) @@ -132,11 +166,10 @@ class UnifiedGeminiScheduler { } // 获取所有可用账户 - const availableAccounts = await this._getAllAvailableAccounts( - apiKeyData, - requestedModel, - allowApiAccounts - ) + const availableAccounts = await this._getAllAvailableAccounts(apiKeyData, requestedModel, { + allowApiAccounts, + oauthProvider: normalizedOauthProvider + }) if (availableAccounts.length === 0) { // 提供更详细的错误信息 @@ -160,7 +193,8 @@ class UnifiedGeminiScheduler { await this._setSessionMapping( sessionHash, selectedAccount.accountId, - selectedAccount.accountType + selectedAccount.accountType, + normalizedOauthProvider ) logger.info( `🎯 Created new sticky session mapping: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) for session ${sessionHash}` @@ -189,7 +223,18 @@ class UnifiedGeminiScheduler { } // 📋 获取所有可用账户 - async _getAllAvailableAccounts(apiKeyData, requestedModel = null, allowApiAccounts = false) { + async _getAllAvailableAccounts( + apiKeyData, + requestedModel = null, + allowApiAccountsOrOptions = false + ) { + const options = + allowApiAccountsOrOptions && typeof allowApiAccountsOrOptions === 'object' + ? allowApiAccountsOrOptions + : { allowApiAccounts: allowApiAccountsOrOptions } + const { allowApiAccounts = false, oauthProvider = null } = options + const normalizedOauthProvider = oauthProvider ? normalizeOauthProvider(oauthProvider) : null + const availableAccounts = [] // 如果API Key绑定了专属账户,优先返回 @@ -254,6 +299,12 @@ class UnifiedGeminiScheduler { this._isActive(boundAccount.isActive) && boundAccount.status !== 'error' ) { + if ( + normalizedOauthProvider && + normalizeOauthProvider(boundAccount.oauthProvider) !== normalizedOauthProvider + ) { + return availableAccounts + } const isRateLimited = await this.isAccountRateLimited(boundAccount.id) if (!isRateLimited) { // 检查模型支持 @@ -303,6 +354,12 @@ class UnifiedGeminiScheduler { (account.accountType === 'shared' || !account.accountType) && // 兼容旧数据 this._isSchedulable(account.schedulable) ) { + if ( + normalizedOauthProvider && + normalizeOauthProvider(account.oauthProvider) !== normalizedOauthProvider + ) { + continue + } // 检查是否可调度 // 检查token是否过期 @@ -437,9 +494,10 @@ class UnifiedGeminiScheduler { } // 🔗 获取会话映射 - async _getSessionMapping(sessionHash) { + async _getSessionMapping(sessionHash, oauthProvider = null) { const client = redis.getClientSafe() - const mappingData = await client.get(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`) + const key = this._getSessionMappingKey(sessionHash, oauthProvider) + const mappingData = key ? await client.get(key) : null if (mappingData) { try { @@ -454,27 +512,42 @@ class UnifiedGeminiScheduler { } // 💾 设置会话映射 - async _setSessionMapping(sessionHash, accountId, accountType) { + async _setSessionMapping(sessionHash, accountId, accountType, oauthProvider = null) { const client = redis.getClientSafe() const mappingData = JSON.stringify({ accountId, accountType }) // 依据配置设置TTL(小时) const appConfig = require('../../config/config') const ttlHours = appConfig.session?.stickyTtlHours || 1 const ttlSeconds = Math.max(1, Math.floor(ttlHours * 60 * 60)) - await client.setex(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`, ttlSeconds, mappingData) + const key = this._getSessionMappingKey(sessionHash, oauthProvider) + if (!key) { + return + } + await client.setex(key, ttlSeconds, mappingData) } // 🗑️ 删除会话映射 async _deleteSessionMapping(sessionHash) { const client = redis.getClientSafe() - await client.del(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`) + if (!sessionHash) { + return + } + + const keys = [this._getSessionMappingKey(sessionHash)] + for (const provider of KNOWN_OAUTH_PROVIDERS) { + keys.push(this._getSessionMappingKey(sessionHash, provider)) + } + await client.del(keys.filter(Boolean)) } // 🔁 续期统一调度会话映射TTL(针对 unified_gemini_session_mapping:* 键),遵循会话配置 - async _extendSessionMappingTTL(sessionHash) { + async _extendSessionMappingTTL(sessionHash, oauthProvider = null) { try { const client = redis.getClientSafe() - const key = `${this.SESSION_MAPPING_PREFIX}${sessionHash}` + const key = this._getSessionMappingKey(sessionHash, oauthProvider) + if (!key) { + return false + } const remainingTTL = await client.ttl(key) if (remainingTTL === -2) { 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..683008fd 100644 --- a/src/utils/errorSanitizer.js +++ b/src/utils/errorSanitizer.js @@ -55,16 +55,69 @@ function sanitizeUpstreamError(errorData) { return errorData } - // 深拷贝避免修改原始对象 - const sanitized = JSON.parse(JSON.stringify(errorData)) + // AxiosError / Error:返回摘要,避免泄露请求体/headers/token 等敏感信息 + const looksLikeAxiosError = + errorData.isAxiosError || + (errorData.name === 'AxiosError' && (errorData.config || errorData.response)) + const looksLikeError = errorData instanceof Error || typeof errorData.message === 'string' + + if (looksLikeAxiosError || looksLikeError) { + const statusCode = errorData.response?.status + const upstreamBody = errorData.response?.data + const upstreamMessage = sanitizeErrorMessage(extractErrorMessage(upstreamBody) || '') + + return { + name: errorData.name || 'Error', + code: errorData.code, + statusCode, + message: sanitizeErrorMessage(errorData.message || ''), + upstreamMessage: upstreamMessage || undefined, + upstreamType: upstreamBody?.error?.type || upstreamBody?.error?.status || undefined + } + } // 递归清理嵌套的错误对象 + const visited = new WeakSet() + + const shouldRedactKey = (key) => { + if (!key) { + return false + } + const lowerKey = String(key).toLowerCase() + return ( + lowerKey === 'authorization' || + lowerKey === 'cookie' || + lowerKey.includes('api_key') || + lowerKey.includes('apikey') || + lowerKey.includes('access_token') || + lowerKey.includes('refresh_token') || + lowerKey.endsWith('token') || + lowerKey.includes('secret') || + lowerKey.includes('password') + ) + } + const sanitizeObject = (obj) => { if (!obj || typeof obj !== 'object') { return obj } + if (visited.has(obj)) { + return '[Circular]' + } + visited.add(obj) + + // 主动剔除常见“超大且敏感”的字段 + if (obj.config || obj.request || obj.response) { + return '[Redacted]' + } + for (const key in obj) { + if (shouldRedactKey(key)) { + obj[key] = '[REDACTED]' + continue + } + // 清理所有字符串字段,不仅仅是 message if (typeof obj[key] === 'string') { obj[key] = sanitizeErrorMessage(obj[key]) @@ -76,7 +129,9 @@ function sanitizeUpstreamError(errorData) { return obj } - return sanitizeObject(sanitized) + // 尽量不修改原对象:浅拷贝后递归清理 + const clone = Array.isArray(errorData) ? [...errorData] : { ...errorData } + return sanitizeObject(clone) } /** 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/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/src/validators/clients/codexCliValidator.js b/src/validators/clients/codexCliValidator.js index 4d94d98f..d8922bd2 100644 --- a/src/validators/clients/codexCliValidator.js +++ b/src/validators/clients/codexCliValidator.js @@ -125,8 +125,12 @@ class CodexCliValidator { const part1 = parts1[i] || 0 const part2 = parts2[i] || 0 - if (part1 < part2) return -1 - if (part1 > part2) return 1 + if (part1 < part2) { + return -1 + } + if (part1 > part2) { + return 1 + } } return 0 diff --git a/src/validators/clients/geminiCliValidator.js b/src/validators/clients/geminiCliValidator.js index 8e9ed0de..0d438384 100644 --- a/src/validators/clients/geminiCliValidator.js +++ b/src/validators/clients/geminiCliValidator.js @@ -53,7 +53,7 @@ class GeminiCliValidator { // 2. 对于 /gemini 路径,检查是否包含 generateContent if (path.includes('generateContent')) { // 包含 generateContent 的路径需要验证 User-Agent - const geminiCliPattern = /^GeminiCLI\/v?[\d\.]+/i + const geminiCliPattern = /^GeminiCLI\/v?[\d.]+/i if (!geminiCliPattern.test(userAgent)) { logger.debug( `Gemini CLI validation failed - UA mismatch for generateContent: ${userAgent}` @@ -84,8 +84,12 @@ class GeminiCliValidator { const part1 = parts1[i] || 0 const part2 = parts2[i] || 0 - if (part1 < part2) return -1 - if (part1 > part2) return 1 + if (part1 < part2) { + return -1 + } + if (part1 > part2) { + return 1 + } } return 0 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/src/components/accounts/AccountBalanceScriptModal.vue b/web/admin-spa/src/components/accounts/AccountBalanceScriptModal.vue new file mode 100644 index 00000000..17f2be00 --- /dev/null +++ b/web/admin-spa/src/components/accounts/AccountBalanceScriptModal.vue @@ -0,0 +1,302 @@ + + + + + diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue index 1953e76d..1d185fa4 100644 --- a/web/admin-spa/src/components/accounts/AccountForm.vue +++ b/web/admin-spa/src/components/accounts/AccountForm.vue @@ -477,6 +477,36 @@ +