diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..f10a08c131395c6e55512eae9215f76e621bb3b9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,78 @@ +# Dependencies +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Environment files +.env.local +.env.*.local + +# Logs +logs/ +*.log + +# Data files +data/ +temp/ + +# Git +.git/ +.gitignore +.gitattributes + +# GitHub +.github/ + +# Documentation +README.md +README_EN.md +CHANGELOG.md +docs/ +*.md + +# Development files +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Docker files +docker-compose.yml +docker-compose.*.yml +Dockerfile +.dockerignore + +# Test files +test/ +tests/ +__tests__/ +*.test.js +*.spec.js +coverage/ +.nyc_output/ + +# Build files +# dist/ # 前端构建阶段需要复制源文件,所以不能忽略 +build/ +*.pid +*.seed +*.pid.lock + +# 但可以忽略本地已构建的 dist 目录 +web/admin-spa/dist/ + +# CI/CD +.travis.yml +.gitlab-ci.yml +azure-pipelines.yml + +# Package manager files +# package-lock.json # 需要保留此文件以支持 npm ci +yarn.lock +pnpm-lock.yaml + +# CLI +cli/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..c70db5fd28e99c66234ad5d9d1a1691ff3d6657a --- /dev/null +++ b/.env.example @@ -0,0 +1,113 @@ +# 🚀 Claude Relay Service Configuration + +# 🌐 服务器配置 +PORT=3000 +HOST=0.0.0.0 +NODE_ENV=production + +# 🔐 安全配置 +JWT_SECRET=your-jwt-secret-here +ADMIN_SESSION_TIMEOUT=86400000 +API_KEY_PREFIX=cr_ +ENCRYPTION_KEY=your-encryption-key-here + +# 👤 管理员凭据(可选,不设置则自动生成) +# ADMIN_USERNAME=cr_admin_custom +# ADMIN_PASSWORD=your-secure-password + +# 📊 Redis 配置 +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_DB=0 +REDIS_ENABLE_TLS= + +# 🔗 会话管理配置 +# 粘性会话TTL配置(小时),默认1小时 +STICKY_SESSION_TTL_HOURS=1 +# 续期阈值(分钟),默认0分钟(不续期) +STICKY_SESSION_RENEWAL_THRESHOLD_MINUTES=15 + +# 🎯 Claude API 配置 +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 + +# 🚫 529错误处理配置 +# 启用529错误处理,0表示禁用,>0表示过载状态持续时间(分钟) +CLAUDE_OVERLOAD_HANDLING_MINUTES=0 + +# 🌐 代理配置 +DEFAULT_PROXY_TIMEOUT=600000 +MAX_PROXY_RETRIES=3 +# IP协议族配置:true=IPv4, false=IPv6, 默认IPv4(兼容性更好) +PROXY_USE_IPV4=true + +# ⏱️ 请求超时配置 +REQUEST_TIMEOUT=600000 # 请求超时设置(毫秒),默认10分钟 + +# 📈 使用限制 +DEFAULT_TOKEN_LIMIT=1000000 + +# 📝 日志配置 +LOG_LEVEL=info +LOG_MAX_SIZE=10m +LOG_MAX_FILES=5 + +# 🔧 系统配置 +CLEANUP_INTERVAL=3600000 +TOKEN_USAGE_RETENTION=2592000000 +HEALTH_CHECK_INTERVAL=60000 +TIMEZONE_OFFSET=8 # UTC偏移小时数,默认+8(中国时区) +METRICS_WINDOW=5 # 实时指标统计窗口(分钟),可选1-60,默认5分钟 + +# 🎨 Web 界面配置 +WEB_TITLE=Claude Relay Service +WEB_DESCRIPTION=Multi-account Claude API relay service with beautiful management interface +WEB_LOGO_URL=/assets/logo.png + +# 🛠️ 开发配置 +DEBUG=false +DEBUG_HTTP_TRAFFIC=false # 启用HTTP请求/响应调试日志(仅开发环境) +ENABLE_CORS=true +TRUST_PROXY=true + +# 🔒 客户端限制(可选) +# ALLOW_CUSTOM_CLIENTS=false + +# 🔐 LDAP 认证配置 +LDAP_ENABLED=false +LDAP_URL=ldaps://ldap-1.test1.bj.yxops.net:636 +LDAP_BIND_DN=cn=admin,dc=example,dc=com +LDAP_BIND_PASSWORD=admin_password +LDAP_SEARCH_BASE=dc=example,dc=com +LDAP_SEARCH_FILTER=(uid={{username}}) +LDAP_SEARCH_ATTRIBUTES=dn,uid,cn,mail,givenName,sn +LDAP_TIMEOUT=5000 +LDAP_CONNECT_TIMEOUT=10000 + +# 🔒 LDAP TLS/SSL 配置 (用于 ldaps:// URL) +# 是否忽略证书验证错误 (设置为false可忽略自签名证书错误) +LDAP_TLS_REJECT_UNAUTHORIZED=true +# CA 证书文件路径 (可选,用于自定义CA证书) +# LDAP_TLS_CA_FILE=/path/to/ca-cert.pem +# 客户端证书文件路径 (可选,用于双向认证) +# LDAP_TLS_CERT_FILE=/path/to/client-cert.pem +# 客户端私钥文件路径 (可选,用于双向认证) +# LDAP_TLS_KEY_FILE=/path/to/client-key.pem +# 服务器名称 (可选,用于 SNI) +# LDAP_TLS_SERVERNAME=ldap.example.com + +# 🗺️ LDAP 用户属性映射 +LDAP_USER_ATTR_USERNAME=uid +LDAP_USER_ATTR_DISPLAY_NAME=cn +LDAP_USER_ATTR_EMAIL=mail +LDAP_USER_ATTR_FIRST_NAME=givenName +LDAP_USER_ATTR_LAST_NAME=sn + +# 👥 用户管理配置 +USER_MANAGEMENT_ENABLED=false +DEFAULT_USER_ROLE=user +USER_SESSION_TIMEOUT=86400000 +MAX_API_KEYS_PER_USER=1 +ALLOW_USER_DELETE_API_KEYS=false diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000000000000000000000000000000000000..30281309cb554da3bfd73f2c2d228bf7212fec58 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,85 @@ +module.exports = { + root: true, + env: { + node: true, + es2021: true, + commonjs: true + }, + extends: ['eslint:recommended', 'plugin:prettier/recommended'], + parserOptions: { + sourceType: 'module', + ecmaVersion: 'latest' + }, + plugins: ['prettier'], + rules: { + // 基础规则 + 'no-console': 'off', // Node.js 项目允许 console + 'consistent-return': 'off', + 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'warn', + 'prettier/prettier': 'error', + + // 变量相关 + 'no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrors: 'none' + } + ], + 'prefer-const': 'error', + 'no-var': 'error', + 'no-shadow': 'error', + + // 代码质量 + eqeqeq: ['error', 'always'], + curly: ['error', 'all'], + 'no-throw-literal': 'error', + 'prefer-promise-reject-errors': 'error', + + // 代码风格 + 'object-shorthand': 'error', + 'prefer-template': 'error', + 'template-curly-spacing': ['error', 'never'], + + // Node.js 特定规则 + 'no-path-concat': 'error', + 'handle-callback-err': 'error', + + // ES6+ 规则 + 'arrow-body-style': ['error', 'as-needed'], + 'prefer-arrow-callback': 'error', + 'prefer-destructuring': [ + 'error', + { + array: false, + object: true + } + ], + + // 格式化规则(由 Prettier 处理) + semi: 'off', + quotes: 'off', + indent: 'off', + 'comma-dangle': 'off' + }, + overrides: [ + { + // CLI 和脚本文件 + files: ['cli/**/*.js', 'scripts/**/*.js'], + rules: { + 'no-process-exit': 'off' // CLI 脚本允许 process.exit + } + }, + { + // 测试文件 + files: ['**/*.test.js', '**/*.spec.js', 'tests/**/*.js'], + env: { + jest: true + }, + rules: { + 'no-unused-expressions': 'off' + } + } + ] +} diff --git a/.github/AUTO_RELEASE_GUIDE.md b/.github/AUTO_RELEASE_GUIDE.md new file mode 100644 index 0000000000000000000000000000000000000000..383be0dc34c728a45effc1e6b46914f55fef6f26 --- /dev/null +++ b/.github/AUTO_RELEASE_GUIDE.md @@ -0,0 +1,164 @@ +# 自动版本发布指南 + +## 📋 概述 + +本项目配置了自动版本发布功能,每次推送到 `main` 分支时会自动递增版本号并创建 GitHub Release。 + +## 🚀 工作原理 + +### 自动版本递增规则 + +- **版本格式**: `v..` (例如: v1.0.2) +- **递增规则**: 每次推送到 main 分支,自动递增 patch 版本号 + - v1.0.1 → v1.0.2 + - v1.0.9 → v1.0.10 + - v1.0.99 → v1.0.100 + +### 触发条件 + +当满足以下条件时,会自动创建新版本: + +1. 推送到 `main` 分支 +2. 有实际的代码变更(不包括纯文档更新) +3. 自上次发布以来有新的提交 + +## 📝 使用方法 + +### 1. 常规开发流程 + +```bash +# 在 dev 分支开发 +git checkout dev +# ... 进行开发 ... +git add . +git commit -m "feat: 添加新功能" +git push origin dev + +# 合并到 main 分支 +git checkout main +git merge dev +git push origin main # 这会触发自动发布 +``` + +### 2. 跳过自动发布 + +如果你的提交不想触发自动发布,在 commit 消息中添加 `[skip ci]`: + +```bash +git commit -m "docs: 更新文档 [skip ci]" +``` + +### 3. 手动控制版本号 + +如果需要发布大版本或中版本更新: + +```bash +# 大版本更新 (1.0.x → 2.0.0) +git tag -a v2.0.0 -m "Major release v2.0.0" +git push origin v2.0.0 + +# 中版本更新 (1.0.x → 1.1.0) +git tag -a v1.1.0 -m "Minor release v1.1.0" +git push origin v1.1.0 +``` + +## 🔧 配置说明 + +### 工作流文件 + +- **位置**: `.github/workflows/auto-release.yml` +- **功能**: + - 获取最新版本标签 + - 计算下一个版本号 + - 生成 changelog + - 创建 GitHub Release + - 更新 CHANGELOG.md 文件 + - 发送 Telegram 通知(可选) + +### Changelog 生成 + +使用 [git-cliff](https://github.com/orhun/git-cliff) 自动生成更新日志: + +- **配置文件**: `.github/cliff.toml` +- **提交规范**: 遵循 [Conventional Commits](https://www.conventionalcommits.org/) + - `feat:` 新功能 + - `fix:` Bug 修复 + - `docs:` 文档更新 + - `chore:` 其他变更 + - `refactor:` 代码重构 + - `perf:` 性能优化 + +## 📊 查看发布历史 + +1. **GitHub Releases 页面**: + - 访问 `https://github.com///releases` + - 查看所有发布版本和更新内容 + +2. **CHANGELOG.md**: + - 项目根目录的 `CHANGELOG.md` 文件 + - 包含完整的版本历史 + +## ❓ 常见问题 + +### Q: 如何查看当前版本? + +```bash +# 查看最新标签 +git describe --tags --abbrev=0 + +# 查看所有标签 +git tag -l +``` + +### Q: 自动发布失败怎么办? + +1. 检查 GitHub Actions 日志 +2. 确认是否有权限创建标签和发布 +3. 检查是否有语法错误 + +### Q: 如何回滚版本? + +自动发布只是创建标签和 Release,不会影响代码: + +```bash +# 回滚到特定版本 +git checkout v1.0.1 + +# 或者使用 Docker 镜像的特定版本 +docker pull weishaw/claude-relay-service:v1.0.1 +``` + +### Q: 如何修改版本递增规则? + +编辑 `.github/workflows/auto-release.yml` 中的版本计算逻辑: + +```yaml +# 当前是递增 patch 版本 +NEW_PATCH=$((PATCH + 1)) + +# 可以改为递增 minor 版本 +NEW_MINOR=$((MINOR + 1)) +NEW_PATCH=0 +``` + +## 📱 Telegram 通知(可选) + +自动发布系统支持发送通知到 Telegram 频道。配置后,每次发布新版本都会自动发送通知。 + +### 快速设置 + +1. 创建 Telegram Bot(通过 @BotFather) +2. 将 Bot 添加到频道作为管理员 +3. 获取频道的 Chat ID +4. 在 GitHub 仓库添加 Secrets: + - `TELEGRAM_BOT_TOKEN` + - `TELEGRAM_CHAT_ID` + +详细设置步骤请参考 [Telegram 通知设置指南](./TELEGRAM_SETUP.md) + +## 🔗 相关链接 + +- [GitHub Actions 工作流使用指南](./WORKFLOW_USAGE.md) +- [Telegram 通知设置指南](./TELEGRAM_SETUP.md) +- [Docker Hub 设置指南](./DOCKER_HUB_SETUP.md) +- [Git Cliff 配置文档](https://git-cliff.org/docs/configuration) \ No newline at end of file diff --git a/.github/DOCKER_HUB_SETUP.md b/.github/DOCKER_HUB_SETUP.md new file mode 100644 index 0000000000000000000000000000000000000000..b7718864df2d91560bbb050f52ef5e5507da20cb --- /dev/null +++ b/.github/DOCKER_HUB_SETUP.md @@ -0,0 +1,109 @@ +# Docker Hub 自动发布配置指南 + +本文档说明如何配置 GitHub Actions 自动构建并发布 Docker 镜像到 Docker Hub。 + +## 📋 前置要求 + +1. Docker Hub 账号 +2. GitHub 仓库的管理员权限 + +## 🔐 配置 GitHub Secrets + +在 GitHub 仓库中配置以下 secrets: + +1. 进入仓库设置:`Settings` → `Secrets and variables` → `Actions` +2. 点击 `New repository secret` +3. 添加以下 secrets: + +### 必需的 Secrets + +| Secret 名称 | 说明 | 如何获取 | +|------------|------|---------| +| `DOCKERHUB_USERNAME` | Docker Hub 用户名 | 你的 Docker Hub 登录用户名 | +| `DOCKERHUB_TOKEN` | Docker Hub Access Token | 见下方说明 | + +### 获取 Docker Hub Access Token + +1. 登录 [Docker Hub](https://hub.docker.com/) +2. 点击右上角头像 → `Account Settings` +3. 选择 `Security` → `Access Tokens` +4. 点击 `New Access Token` +5. 填写描述(如:`GitHub Actions`) +6. 选择权限:`Read, Write, Delete` +7. 点击 `Generate` +8. **立即复制 token**(只显示一次) + +## 🚀 工作流程说明 + +### 触发条件 + +- **自动触发**:推送到 `main` 分支 +- **版本发布**:创建 `v*` 格式的 tag(如 `v1.0.0`) +- **手动触发**:在 Actions 页面手动运行 + +### 镜像标签策略 + +工作流会自动创建以下标签: + +- `latest`:始终指向 main 分支的最新构建 +- `main`:main 分支的构建 +- `v1.0.0`:版本标签(当创建 tag 时) +- `1.0`:主次版本标签 +- `1`:主版本标签 +- `main-sha-xxxxxxx`:包含 commit SHA 的标签 + +### 支持的平台 + +- `linux/amd64`:Intel/AMD 架构 +- `linux/arm64`:ARM64 架构(如 Apple Silicon, 树莓派等) + +## 📦 使用发布的镜像 + +```bash +# 拉取最新版本 +docker pull weishaw/claude-relay-service:latest + +# 拉取特定版本 +docker pull weishaw/claude-relay-service:v1.0.0 + +# 运行容器 +docker run -d \ + --name claude-relay \ + -p 3000:3000 \ + -v ./data:/app/data \ + -v ./logs:/app/logs \ + -e ADMIN_USERNAME=my_admin \ + -e ADMIN_PASSWORD=my_password \ + weishaw/claude-relay-service:latest +``` + +## 🔍 验证配置 + +1. 推送代码到 main 分支 +2. 在 GitHub 仓库页面点击 `Actions` 标签 +3. 查看 `Docker Build & Push` 工作流运行状态 +4. 成功后在 Docker Hub 查看镜像 + +## 🛡️ 安全功能 + +- **漏洞扫描**:使用 Trivy 自动扫描镜像漏洞 +- **扫描报告**:上传到 GitHub Security 标签页 +- **自动更新 README**:同步更新 Docker Hub 的项目描述 + +## ❓ 常见问题 + +### 构建失败 + +- 检查 secrets 是否正确配置 +- 确认 Docker Hub token 有足够权限 +- 查看 Actions 日志详细错误信息 + +### 镜像推送失败 + +- 确认 Docker Hub 用户名正确 +- 检查是否达到 Docker Hub 免费账户限制 +- Token 可能过期,需要重新生成 + +### 多平台构建慢 + +这是正常的,因为需要模拟不同架构。可以在不需要时修改 `platforms` 配置。 \ No newline at end of file diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000000000000000000000000000000000000..5217c07c4deb5e04cf123eee681eb95362841dca --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,12 @@ +# These are supported funding model platforms + +github: # Your GitHub username for GitHub Sponsors +patreon: # Replace with your Patreon username if you have one +open_collective: # Replace with your Open Collective username if you have one +ko_fi: # Replace with your Ko-fi username if you have one +tidelift: # Replace with your Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with your Community Bridge project-name +liberapay: # Replace with your Liberapay username +issuehunt: # Replace with your IssueHunt username +otechie: # Replace with your Otechie username +custom: ['https://afdian.com/a/claude-relay-service'] # Your custom donation link (Afdian) diff --git a/.github/RELEASE_PROCESS.md b/.github/RELEASE_PROCESS.md new file mode 100644 index 0000000000000000000000000000000000000000..9ba1d3c43ee6c1b747c4bf41fe233e392dc61a2f --- /dev/null +++ b/.github/RELEASE_PROCESS.md @@ -0,0 +1,94 @@ +# 发布流程说明 + +## 概述 + +本项目采用全自动化的版本管理和发布流程。VERSION文件由GitHub Actions自动维护,无需手动修改。 + +## 自动发布流程 + +### 1. 工作原理 + +1. **代码推送**: 当你推送代码到main分支时 +2. **自动版本更新**: `auto-version-bump.yml`会: + - 检测是否有实质性代码变更(排除.md文件、docs/目录等) + - 如果有代码变更,自动将版本号+1并更新VERSION文件 + - 提交VERSION文件更新到main分支 +3. **自动发布**: `release-on-version.yml`会: + - 检测到只有VERSION文件变更的提交 + - 自动创建Git tag + - 创建GitHub Release + - 构建并推送Docker镜像 + - 发送Telegram通知(如果配置) + +### 2. 工作流文件说明 + +- **auto-version-bump.yml**: 自动检测代码变更并更新VERSION文件 +- **release-on-version.yml**: 检测VERSION文件单独提交并触发发布 +- **docker-publish.yml**: 在tag创建时构建Docker镜像(备用) +- **release.yml**: 在tag创建时生成Release(备用) + +### 3. 版本号规范 + +- 使用语义化版本号:`MAJOR.MINOR.PATCH` +- 默认自动递增PATCH版本(例如:1.1.10 → 1.1.11) +- VERSION文件只包含版本号,不包含`v`前缀 +- Git tag会自动添加`v`前缀 + +### 4. 触发条件 + +**会触发版本更新的文件变更**: +- 源代码文件(.js, .ts, .jsx, .tsx等) +- 配置文件(package.json, Dockerfile等) +- 其他功能性文件 + +**不会触发版本更新的文件变更**: +- Markdown文件(*.md) +- 文档目录(docs/) +- GitHub配置(.github/) +- VERSION文件本身 +- .gitignore、LICENSE等 + +## 使用指南 + +### 正常开发流程 + +1. 进行代码开发和修改 +2. 提交并推送到main分支 +3. 系统自动完成版本更新和发布 + +```bash +# 正常的开发流程 +git add . +git commit -m "feat: 添加新功能" +git push origin main + +# GitHub Actions会自动: +# 1. 检测到代码变更 +# 2. 更新VERSION文件(例如:1.1.10 → 1.1.11) +# 3. 创建新的release和Docker镜像 +``` + +### 跳过版本更新 + +如果只是更新文档或其他非代码文件,系统会自动识别并跳过版本更新。 + +## 故障排除 + +### 版本没有自动更新 + +1. 检查是否有实质性代码变更 +2. 查看GitHub Actions运行日志 +3. 确认推送的是main分支 + +### 需要手动触发发布 + +如果需要手动控制版本: +1. 直接修改VERSION文件 +2. 提交并推送 +3. 系统会检测到VERSION变更并触发发布 + +## 注意事项 + +- **不要**在同一个提交中既修改代码又修改VERSION文件 +- **不要**手动创建tag,让系统自动管理 +- 系统会自动避免死循环(GitHub Actions的提交不会触发新的版本更新) \ No newline at end of file diff --git a/.github/TELEGRAM_SETUP.md b/.github/TELEGRAM_SETUP.md new file mode 100644 index 0000000000000000000000000000000000000000..82f4f8e57576f81039592e077831ccf4ede6c8f9 --- /dev/null +++ b/.github/TELEGRAM_SETUP.md @@ -0,0 +1,110 @@ +# Telegram 自动通知设置指南 + +## 📋 概述 + +当 GitHub Actions 自动发布新版本时,系统会自动发送通知到你的 Telegram 频道。 + +## 🚀 设置步骤 + +### 1. 创建 Telegram Bot + +1. 在 Telegram 中找到 [@BotFather](https://t.me/botfather) +2. 发送 `/newbot` 命令 +3. 按提示设置 Bot 名称(例如:Claude Relay Updates) +4. 设置 Bot 用户名(例如:claude_relay_bot) +5. **保存 Bot Token**(格式类似:`1234567890:ABCdefGHIjklMNOpqrsTUVwxyz`) + +### 2. 创建或选择 Telegram 频道 + +1. 创建一个新频道或使用现有频道 +2. 将你的 Bot 添加为频道管理员: + - 进入频道设置 + - 管理员 → 添加管理员 + - 搜索你的 Bot 用户名 + - 赋予发送消息权限 + +### 3. 获取频道 Chat ID + +有几种方法获取频道的 Chat ID: + +#### 方法 1:使用 Web Telegram +1. 打开 https://web.telegram.org +2. 进入你的频道 +3. 查看 URL,格式为:`https://web.telegram.org/k/#-1234567890` +4. Chat ID 就是 `#` 后面的数字(包括负号):`-1234567890` + +#### 方法 2:使用 Bot API +1. 先在频道发送一条消息 +2. 访问:`https://api.telegram.org/bot/getUpdates` +3. 找到你的频道消息,查看 `chat.id` 字段 + +#### 方法 3:使用频道用户名 +如果频道是公开的,可以直接使用 `@频道用户名` 作为 Chat ID + +### 4. 添加 GitHub Secrets + +1. 访问你的 GitHub 仓库 +2. 进入 Settings → Secrets and variables → Actions +3. 点击 "New repository secret" +4. 添加以下两个 Secrets: + + **TELEGRAM_BOT_TOKEN** + - Name: `TELEGRAM_BOT_TOKEN` + - Value: 你的 Bot Token(例如:`1234567890:ABCdefGHIjklMNOpqrsTUVwxyz`) + + **TELEGRAM_CHAT_ID** + - Name: `TELEGRAM_CHAT_ID` + - Value: 你的频道 Chat ID(例如:`-1234567890` 或 `@your_channel`) + +## ✅ 测试配置 + +配置完成后,下次推送到 main 分支时,你的 Telegram 频道将收到类似这样的通知: + +``` +🚀 Claude Relay Service 新版本发布! + +📦 版本号: 1.1.3 + +📝 更新内容: +- feat: 添加 Telegram 自动通知功能 +- fix: 修复某个问题 + +🐳 Docker 部署: +docker pull weishaw/claude-relay-service:v1.1.3 +docker pull weishaw/claude-relay-service:latest + +🔗 相关链接: +• GitHub Release +• 完整更新日志 +• Docker Hub + +#ClaudeRelay #Update #v1_1_3 +``` + +## 🔧 自定义通知 + +如果你想修改通知格式,编辑 `.github/workflows/auto-release.yml` 中的 `Send Telegram Notification` 步骤。 + +## ❓ 常见问题 + +### Q: 通知发送失败怎么办? + +检查: +1. Bot Token 是否正确 +2. Bot 是否已添加为频道管理员 +3. Chat ID 是否正确(注意负号) +4. GitHub Secrets 是否正确配置 + +### Q: 可以发送到多个频道吗? + +可以修改工作流,添加多个通知步骤,或使用逗号分隔多个 Chat ID。 + +### Q: 通知失败会影响版本发布吗? + +不会。通知步骤配置了 `continue-on-error: true`,即使通知失败也不会影响版本发布。 + +## 🔐 安全提示 + +- **永远不要**在代码中直接写入 Bot Token +- 始终使用 GitHub Secrets 存储敏感信息 +- 定期更换 Bot Token 以保证安全 \ No newline at end of file diff --git a/.github/WORKFLOW_USAGE.md b/.github/WORKFLOW_USAGE.md new file mode 100644 index 0000000000000000000000000000000000000000..b4a657800c3150ae943c7e2b647a6aceb90bbcae --- /dev/null +++ b/.github/WORKFLOW_USAGE.md @@ -0,0 +1,129 @@ +# GitHub Actions 工作流使用指南 + +## 📋 概述 + +本项目配置了自动化 CI/CD 流程,每次推送到 main 分支都会自动构建并发布 Docker 镜像到 Docker Hub。 + +## 🚀 工作流程 + +### 1. Docker 构建和发布 (`docker-publish.yml`) + +**功能:** +- 自动构建多平台 Docker 镜像(amd64, arm64) +- 推送到 Docker Hub +- 执行安全漏洞扫描 +- 更新 Docker Hub 描述 + +**触发条件:** +- 推送到 `main` 分支 +- 创建版本标签(如 `v1.0.0`) +- Pull Request(仅构建,不推送) +- 手动触发 + +### 2. 发布管理 (`release.yml`) + +**功能:** +- 自动创建 GitHub Release +- 生成更新日志 +- 关联 Docker 镜像版本 + +**触发条件:** +- 创建版本标签(如 `v1.0.0`) + +### 3. 自动版本发布 (`auto-release.yml`) + +**功能:** +- 自动递增版本号(patch 版本) +- 自动创建版本标签 +- 生成 GitHub Release +- 更新 CHANGELOG.md + +**触发条件:** +- 推送到 `main` 分支(自动触发) +- 忽略纯文档更新 + +## 📝 版本发布流程 + +### 1. 常规更新(推送到 main) + +```bash +git add . +git commit -m "fix: 修复登录问题" +git push origin main +``` + +**结果:** +- 自动构建并推送 `latest` 标签到 Docker Hub +- 更新 `main` 标签 +- **自动递增版本号并创建 Release**(例如:v1.0.1 → v1.0.2) +- 生成更新日志 + +### 2. 版本发布 + +```bash +# 创建版本标签 +git tag -a v1.0.0 -m "Release version 1.0.0" +git push origin v1.0.0 +``` + +**结果:** +- 构建并推送以下标签到 Docker Hub: + - `v1.0.0`(完整版本) + - `1.0`(主次版本) + - `1`(主版本) + - `latest`(最新版本) +- 创建 GitHub Release +- 生成更新日志 + +## 🔧 手动触发构建 + +1. 访问仓库的 Actions 页面 +2. 选择 "Docker Build & Push" 工作流 +3. 点击 "Run workflow" +4. 选择分支并运行 + +## 📊 查看构建状态 + +- **Actions 页面**:查看所有工作流运行历史 +- **README 徽章**:实时显示构建状态 +- **Docker Hub**:查看镜像标签和拉取次数 + +## 🛡️ 安全扫描 + +每次构建都会运行 Trivy 安全扫描: +- 扫描结果上传到 GitHub Security 标签页 +- 发现高危漏洞会在 Actions 日志中警告 + +## ❓ 常见问题 + +### Q: 如何回滚到之前的版本? + +```bash +# 使用特定版本标签 +docker pull weishaw/claude-relay-service:v1.0.0 + +# 或在 docker-compose.yml 中指定版本 +image: weishaw/claude-relay-service:v1.0.0 +``` + +### Q: 如何跳过自动构建? + +在 commit 消息中添加 `[skip ci]`: +```bash +git commit -m "docs: 更新文档 [skip ci]" +``` + +### Q: 构建失败如何调试? + +1. 查看 Actions 日志详细错误信息 +2. 在本地测试 Docker 构建: + ```bash + docker build -t test . + ``` + +## 📚 相关文档 + +- [自动版本发布指南](.github/AUTO_RELEASE_GUIDE.md) +- [Docker Hub 配置指南](.github/DOCKER_HUB_SETUP.md) +- [GitHub Actions 文档](https://docs.github.com/en/actions) +- [Docker 官方文档](https://docs.docker.com/) \ No newline at end of file diff --git a/.github/cliff.toml b/.github/cliff.toml new file mode 100644 index 0000000000000000000000000000000000000000..f78621d3cfc5553193261dae83b963d6ba0112fc --- /dev/null +++ b/.github/cliff.toml @@ -0,0 +1,68 @@ +# git-cliff configuration file +# https://git-cliff.org/docs/configuration + +[changelog] +# changelog header +header = """ +# Changelog + +All notable changes to this project will be documented in this file. +""" +# template for the changelog body +body = """ +{% if version %}\ + ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ + ## [unreleased] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | upper_first }} + {% for commit in commits %} + - {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }} ([{{ commit.id | truncate(length=7, end="") }}](https://github.com/Wei-Shaw/claude-relay-service/commit/{{ commit.id }})) + {%- endfor %} +{% endfor %}\n +""" +# remove the leading and trailing whitespace from the template +trim = true +# changelog footer +footer = """ +""" + +[git] +# parse the commits based on https://www.conventionalcommits.org +conventional_commits = true +# filter out the commits that are not conventional +filter_unconventional = true +# process each line of a commit as an individual commit +split_commits = false +# regex for preprocessing the commit messages +commit_preprocessors = [ + { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/Wei-Shaw/claude-relay-service/issues/${2}))" }, +] +# regex for parsing and grouping commits +commit_parsers = [ + { message = "^feat", group = "Features" }, + { message = "^fix", group = "Bug Fixes" }, + { message = "^docs", group = "Documentation" }, + { message = "^perf", group = "Performance" }, + { message = "^refactor", group = "Refactor" }, + { message = "^style", group = "Styling" }, + { message = "^test", group = "Testing" }, + { message = "^chore\\(release\\): prepare for", skip = true }, + { message = "^chore", group = "Miscellaneous Tasks" }, + { body = ".*security", group = "Security" }, +] +# protect breaking changes from being skipped due to matching a skipping commit_parser +protect_breaking_commits = false +# filter out the commits that are not matched by commit parsers +filter_commits = false +# glob pattern for matching git tags +tag_pattern = "v[0-9]*" +# regex for skipping tags +skip_tags = "v0.1.0-beta.1" +# regex for ignoring tags +ignore_tags = "" +# sort the tags topologically +topo_order = false +# sort the commits inside sections by oldest/newest order +sort_commits = "oldest" \ No newline at end of file diff --git a/.github/secret_scanning.yml b/.github/secret_scanning.yml new file mode 100644 index 0000000000000000000000000000000000000000..9350743b984840e87a70803683ecd77d7afac9fb --- /dev/null +++ b/.github/secret_scanning.yml @@ -0,0 +1,6 @@ +# GitHub Secret Scanning Configuration +# This file excludes specific paths from secret scanning + +paths-ignore: + - 'src/services/geminiAccountService.js' + - 'data/demo/Gemini-CLI-2-API/gemini-core.js' \ No newline at end of file diff --git a/.github/workflows/auto-release-pipeline.yml b/.github/workflows/auto-release-pipeline.yml new file mode 100644 index 0000000000000000000000000000000000000000..ad5f2e015ee92b4d2682e5c35ee9aa62fc70d429 --- /dev/null +++ b/.github/workflows/auto-release-pipeline.yml @@ -0,0 +1,490 @@ +name: Auto Release Pipeline + +on: + push: + branches: + - main + +permissions: + contents: write + packages: write + +jobs: + release-pipeline: + runs-on: ubuntu-latest + # 跳过由GitHub Actions创建的提交,避免死循环 + if: github.event.pusher.name != 'github-actions[bot]' && !contains(github.event.head_commit.message, '[skip ci]') + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Check if version bump is needed + id: check + run: | + # 检测是否是合并提交 + PARENT_COUNT=$(git rev-list --parents -n 1 HEAD | wc -w) + PARENT_COUNT=$((PARENT_COUNT - 1)) + echo "Parent count: $PARENT_COUNT" + + if [ "$PARENT_COUNT" -gt 1 ]; then + # 合并提交:获取合并进来的所有文件变更 + echo "Detected merge commit, getting all merged changes" + # 获取合并基准点 + MERGE_BASE=$(git merge-base HEAD^1 HEAD^2 2>/dev/null || echo "") + if [ -n "$MERGE_BASE" ]; then + # 获取从合并基准到 HEAD 的所有变更 + CHANGED_FILES=$(git diff --name-only $MERGE_BASE..HEAD) + else + # 如果无法获取合并基准,使用第二个父提交 + CHANGED_FILES=$(git diff --name-only HEAD^2..HEAD) + fi + else + # 普通提交:获取相对于上一个提交的变更 + CHANGED_FILES=$(git diff --name-only HEAD~1..HEAD 2>/dev/null || git diff --name-only $(git rev-list --max-parents=0 HEAD)..HEAD) + fi + + echo "Changed files:" + echo "$CHANGED_FILES" + + # 检查是否只有无关文件(.md, docs/, .github/等) + SIGNIFICANT_CHANGES=false + while IFS= read -r file; do + # 跳过空行 + [ -z "$file" ] && continue + + # 检查是否是需要忽略的文件 + if [[ ! "$file" =~ \.(md|txt)$ ]] && + [[ ! "$file" =~ ^docs/ ]] && + [[ ! "$file" =~ ^\.github/ ]] && + [[ "$file" != "VERSION" ]] && + [[ "$file" != ".gitignore" ]] && + [[ "$file" != "LICENSE" ]]; then + echo "Found significant change in: $file" + SIGNIFICANT_CHANGES=true + break + fi + done <<< "$CHANGED_FILES" + + if [ "$SIGNIFICANT_CHANGES" = true ]; then + echo "Significant changes detected, version bump needed" + echo "needs_bump=true" >> $GITHUB_OUTPUT + else + echo "No significant changes, skipping version bump" + echo "needs_bump=false" >> $GITHUB_OUTPUT + fi + + - name: Get current version + if: steps.check.outputs.needs_bump == 'true' + id: get_version + run: | + # 获取最新的tag版本 + LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") + echo "Latest tag: $LATEST_TAG" + TAG_VERSION=${LATEST_TAG#v} + + # 获取VERSION文件中的版本 + FILE_VERSION=$(cat VERSION | tr -d '[:space:]') + echo "VERSION file: $FILE_VERSION" + + # 比较tag版本和文件版本,取较大值 + function version_gt() { test "$(printf '%s\n' "$@" | sort -V | head -n 1)" != "$1"; } + + if version_gt "$FILE_VERSION" "$TAG_VERSION"; then + VERSION="$FILE_VERSION" + echo "Using VERSION file: $VERSION (newer than tag)" + else + VERSION="$TAG_VERSION" + echo "Using tag version: $VERSION (newer or equal to file)" + fi + + echo "Current version: $VERSION" + echo "current_version=$VERSION" >> $GITHUB_OUTPUT + + - name: Calculate next version + if: steps.check.outputs.needs_bump == 'true' + id: next_version + run: | + VERSION="${{ steps.get_version.outputs.current_version }}" + + # 分割版本号 + IFS='.' read -r -a version_parts <<< "$VERSION" + MAJOR="${version_parts[0]:-0}" + MINOR="${version_parts[1]:-0}" + PATCH="${version_parts[2]:-0}" + + # 默认递增patch版本 + NEW_PATCH=$((PATCH + 1)) + NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}" + + echo "New version: $NEW_VERSION" + echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT + echo "new_tag=v$NEW_VERSION" >> $GITHUB_OUTPUT + + - name: Update VERSION file + if: steps.check.outputs.needs_bump == 'true' + run: | + echo "${{ steps.next_version.outputs.new_version }}" > VERSION + + # 配置git + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # 提交VERSION文件 - 添加 [skip ci] 以避免再次触发 + git add VERSION + git commit -m "chore: sync VERSION file with release ${{ steps.next_version.outputs.new_tag }} [skip ci]" + + # 构建前端并推送到 web-dist 分支 + - name: Setup Node.js + if: steps.check.outputs.needs_bump == 'true' + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'npm' + cache-dependency-path: web/admin-spa/package-lock.json + + - name: Build Frontend + if: steps.check.outputs.needs_bump == 'true' + run: | + echo "Building frontend for version ${{ steps.next_version.outputs.new_version }}..." + cd web/admin-spa + npm ci + npm run build + echo "Frontend build completed" + + - name: Push Frontend Build to web-dist Branch + if: steps.check.outputs.needs_bump == 'true' + run: | + # 创建临时目录 + TEMP_DIR=$(mktemp -d) + echo "Using temp directory: $TEMP_DIR" + + # 复制构建产物到临时目录 + cp -r web/admin-spa/dist/* "$TEMP_DIR/" + + # 检查 web-dist 分支是否存在 + if git ls-remote --heads origin web-dist | grep -q web-dist; then + echo "Checking out existing web-dist branch" + git fetch origin web-dist:web-dist + git checkout web-dist + else + echo "Creating new web-dist branch" + git checkout --orphan web-dist + fi + + # 清空当前目录(保留 .git) + git rm -rf . 2>/dev/null || true + + # 复制构建产物 + cp -r "$TEMP_DIR"/* . + + # 添加 README + cat > README.md << EOF + # Claude Relay Service - Web Frontend Build + + This branch contains the pre-built frontend assets for Claude Relay Service. + + **DO NOT EDIT FILES IN THIS BRANCH DIRECTLY** + + These files are automatically generated by the CI/CD pipeline. + + Version: ${{ steps.next_version.outputs.new_version }} + Build Date: $(date -u +"%Y-%m-%d %H:%M:%S UTC") + EOF + + # 创建 .gitignore 文件以排除 node_modules + cat > .gitignore << EOF + node_modules/ + *.log + .DS_Store + .env + EOF + + # 只添加必要的文件,排除 node_modules + git add --all -- ':!node_modules' + git commit -m "chore: update frontend build for v${{ steps.next_version.outputs.new_version }} [skip ci]" + git push origin web-dist --force + + # 切换回主分支 + git checkout main + + # 清理临时目录 + rm -rf "$TEMP_DIR" + + echo "Frontend build pushed to web-dist branch successfully" + + - name: Install git-cliff + if: steps.check.outputs.needs_bump == 'true' + run: | + wget -q https://github.com/orhun/git-cliff/releases/download/v1.4.0/git-cliff-1.4.0-x86_64-unknown-linux-gnu.tar.gz + tar -xzf git-cliff-1.4.0-x86_64-unknown-linux-gnu.tar.gz + chmod +x git-cliff-1.4.0/git-cliff + sudo mv git-cliff-1.4.0/git-cliff /usr/local/bin/ + + - name: Generate changelog + if: steps.check.outputs.needs_bump == 'true' + id: changelog + run: | + # 获取上一个tag以来的更新日志 + LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + if [ -n "$LATEST_TAG" ]; then + # 排除VERSION文件的提交 + CHANGELOG=$(git-cliff --config .github/cliff.toml $LATEST_TAG..HEAD --strip header | grep -v "bump version" | sed '/^$/d' || echo "- 代码优化和改进") + else + CHANGELOG=$(git-cliff --config .github/cliff.toml --strip header || echo "- 初始版本发布") + fi + echo "content<> $GITHUB_OUTPUT + echo "$CHANGELOG" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Create and push tag + if: steps.check.outputs.needs_bump == 'true' + run: | + NEW_TAG="${{ steps.next_version.outputs.new_tag }}" + git tag -a "$NEW_TAG" -m "Release $NEW_TAG" + git push origin HEAD:main "$NEW_TAG" + + - name: Prepare image names + id: image_names + if: steps.check.outputs.needs_bump == 'true' + run: | + DOCKER_USERNAME="${{ secrets.DOCKERHUB_USERNAME }}" + if [ -z "$DOCKER_USERNAME" ]; then + DOCKER_USERNAME="weishaw" + fi + + DOCKER_IMAGE=$(echo "${DOCKER_USERNAME}/claude-relay-service" | tr '[:upper:]' '[:lower:]') + GHCR_IMAGE=$(echo "ghcr.io/${{ github.repository_owner }}/claude-relay-service" | tr '[:upper:]' '[:lower:]') + + { + echo "docker_image=${DOCKER_IMAGE}" + echo "ghcr_image=${GHCR_IMAGE}" + } >> "$GITHUB_OUTPUT" + + - name: Create GitHub Release + if: steps.check.outputs.needs_bump == 'true' + uses: softprops/action-gh-release@v1 + with: + tag_name: ${{ steps.next_version.outputs.new_tag }} + name: Release ${{ steps.next_version.outputs.new_version }} + body: | + ## 🐳 Docker 镜像 + + ```bash + docker pull ${{ steps.image_names.outputs.docker_image }}:${{ steps.next_version.outputs.new_tag }} + docker pull ${{ steps.image_names.outputs.docker_image }}:latest + docker pull ${{ steps.image_names.outputs.ghcr_image }}:${{ steps.next_version.outputs.new_tag }} + docker pull ${{ steps.image_names.outputs.ghcr_image }}:latest + ``` + + ## 📦 主要更新 + + ${{ steps.changelog.outputs.content }} + + ## 📋 完整更新日志 + + 查看 [所有版本](https://github.com/${{ github.repository }}/releases) + draft: false + prerelease: false + generate_release_notes: true + + # 自动清理旧的tags和releases(保持最近50个) + - name: Cleanup old tags and releases + if: steps.check.outputs.needs_bump == 'true' + continue-on-error: true + env: + TAGS_TO_KEEP: 50 + run: | + echo "🧹 自动清理旧版本,保持最近 $TAGS_TO_KEEP 个tag..." + + # 获取所有版本tag并按版本号排序(从旧到新) + echo "正在获取所有tags..." + ALL_TAGS=$(git ls-remote --tags origin | grep -E 'refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$' | awk '{print $2}' | sed 's|refs/tags/||' | sort -V) + + # 检查是否获取到tags + if [ -z "$ALL_TAGS" ]; then + echo "⚠️ 未找到任何版本tag" + exit 0 + fi + + TOTAL_COUNT=$(echo "$ALL_TAGS" | wc -l) + + echo "📊 当前tag统计:" + echo "- 总数: $TOTAL_COUNT" + echo "- 配置保留: $TAGS_TO_KEEP" + + if [ "$TOTAL_COUNT" -gt "$TAGS_TO_KEEP" ]; then + DELETE_COUNT=$((TOTAL_COUNT - TAGS_TO_KEEP)) + echo "- 将要删除: $DELETE_COUNT 个最旧的tag" + + # 获取要删除的tags(最老的) + TAGS_TO_DELETE=$(echo "$ALL_TAGS" | head -n "$DELETE_COUNT") + + # 显示将要删除的版本范围 + OLDEST_TO_DELETE=$(echo "$TAGS_TO_DELETE" | head -1) + NEWEST_TO_DELETE=$(echo "$TAGS_TO_DELETE" | tail -1) + echo "" + echo "🗑️ 将要删除的版本范围:" + echo "- 从: $OLDEST_TO_DELETE" + echo "- 到: $NEWEST_TO_DELETE" + + echo "" + echo "开始执行删除..." + SUCCESS_COUNT=0 + FAIL_COUNT=0 + + for tag in $TAGS_TO_DELETE; do + echo -n " 删除 $tag ... " + + # 先检查release是否存在 + if gh release view "$tag" >/dev/null 2>&1; then + # Release存在,删除release会同时删除tag + if gh release delete "$tag" --yes --cleanup-tag 2>/dev/null; then + echo "✅ (release+tag)" + SUCCESS_COUNT=$((SUCCESS_COUNT + 1)) + else + echo "❌ (release删除失败)" + FAIL_COUNT=$((FAIL_COUNT + 1)) + fi + else + # Release不存在,只删除tag + if git push origin --delete "$tag" 2>/dev/null; then + echo "✅ (仅tag)" + SUCCESS_COUNT=$((SUCCESS_COUNT + 1)) + else + echo "⏭️ (已不存在)" + FAIL_COUNT=$((FAIL_COUNT + 1)) + fi + fi + done + + echo "" + echo "📊 清理结果:" + echo "- 成功删除: $SUCCESS_COUNT" + echo "- 失败/跳过: $FAIL_COUNT" + + # 重新获取并显示保留的版本范围 + echo "" + echo "正在验证清理结果..." + REMAINING_TAGS=$(git ls-remote --tags origin | grep -E 'refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$' | awk '{print $2}' | sed 's|refs/tags/||' | sort -V) + REMAINING_COUNT=$(echo "$REMAINING_TAGS" | wc -l) + OLDEST=$(echo "$REMAINING_TAGS" | head -1) + NEWEST=$(echo "$REMAINING_TAGS" | tail -1) + + echo "✅ 清理完成!" + echo "" + echo "📌 当前保留的版本:" + echo "- 最旧版本: $OLDEST" + echo "- 最新版本: $NEWEST" + echo "- 版本总数: $REMAINING_COUNT" + + # 验证是否达到预期 + if [ "$REMAINING_COUNT" -le "$TAGS_TO_KEEP" ]; then + echo "- 状态: ✅ 符合预期(≤$TAGS_TO_KEEP)" + else + echo "- 状态: ⚠️ 超出预期(某些tag可能删除失败)" + fi + else + echo "✅ 当前tag数量($TOTAL_COUNT)未超过限制($TAGS_TO_KEEP),无需清理" + fi + + # Docker构建步骤 + - name: Set up QEMU + if: steps.check.outputs.needs_bump == 'true' + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + if: steps.check.outputs.needs_bump == 'true' + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + if: steps.check.outputs.needs_bump == 'true' + uses: docker/login-action@v3 + with: + registry: docker.io + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Log in to GitHub Container Registry + if: steps.check.outputs.needs_bump == 'true' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push Docker image + if: steps.check.outputs.needs_bump == 'true' + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ${{ steps.image_names.outputs.docker_image }}:${{ steps.next_version.outputs.new_tag }} + ${{ steps.image_names.outputs.docker_image }}:latest + ${{ steps.image_names.outputs.docker_image }}:${{ steps.next_version.outputs.new_version }} + ${{ steps.image_names.outputs.ghcr_image }}:${{ steps.next_version.outputs.new_tag }} + ${{ steps.image_names.outputs.ghcr_image }}:latest + ${{ steps.image_names.outputs.ghcr_image }}:${{ steps.next_version.outputs.new_version }} + labels: | + org.opencontainers.image.version=${{ steps.next_version.outputs.new_version }} + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.source=https://github.com/${{ github.repository }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Send Telegram Notification + if: steps.check.outputs.needs_bump == 'true' && env.TELEGRAM_BOT_TOKEN != '' && env.TELEGRAM_CHAT_ID != '' + env: + TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} + DOCKER_IMAGE: ${{ steps.image_names.outputs.docker_image }} + GHCR_IMAGE: ${{ steps.image_names.outputs.ghcr_image }} + continue-on-error: true + run: | + VERSION="${{ steps.next_version.outputs.new_version }}" + TAG="${{ steps.next_version.outputs.new_tag }}" + REPO="${{ github.repository }}" + + # 获取更新内容并限制长度 + CHANGELOG="${{ steps.changelog.outputs.content }}" + CHANGELOG_TRUNCATED=$(echo "$CHANGELOG" | head -c 1000) + if [ ${#CHANGELOG} -gt 1000 ]; then + CHANGELOG_TRUNCATED="${CHANGELOG_TRUNCATED}..." + fi + + # 构建消息内容 + MESSAGE="🚀 *Claude Relay Service 新版本发布!*"$'\n'$'\n' + MESSAGE+="📦 版本号: \`${VERSION}\`"$'\n'$'\n' + MESSAGE+="📝 *更新内容:*"$'\n' + MESSAGE+="${CHANGELOG_TRUNCATED}"$'\n'$'\n' + MESSAGE+="🐳 *Docker 部署:*"$'\n' + MESSAGE+="\`\`\`bash"$'\n' + MESSAGE+="docker pull ${DOCKER_IMAGE}:${TAG}"$'\n' + MESSAGE+="docker pull ${DOCKER_IMAGE}:latest"$'\n' + MESSAGE+="docker pull ${GHCR_IMAGE}:${TAG}"$'\n' + MESSAGE+="docker pull ${GHCR_IMAGE}:latest"$'\n' + MESSAGE+="\`\`\`"$'\n'$'\n' + MESSAGE+="🔗 *相关链接:*"$'\n' + MESSAGE+="• [GitHub Release](https://github.com/${REPO}/releases/tag/${TAG})"$'\n' + MESSAGE+="• [完整更新日志](https://github.com/${REPO}/releases)"$'\n' + MESSAGE+="• [Docker Hub](https://hub.docker.com/r/${DOCKER_IMAGE%/*}/claude-relay-service)"$'\n' + MESSAGE+="• [GHCR](https://ghcr.io/${GHCR_IMAGE#ghcr.io/})"$'\n'$'\n' + MESSAGE+="#ClaudeRelay #Update #v${VERSION//./_}" + + # 使用 jq 构建 JSON 并发送 + jq -n \ + --arg chat_id "${TELEGRAM_CHAT_ID}" \ + --arg text "${MESSAGE}" \ + '{ + chat_id: $chat_id, + text: $text, + parse_mode: "Markdown", + disable_web_page_preview: false + }' | \ + curl -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ + -H "Content-Type: application/json" \ + -d @- \ No newline at end of file diff --git a/.github/workflows/pr-lint-check.yml b/.github/workflows/pr-lint-check.yml new file mode 100644 index 0000000000000000000000000000000000000000..fa2ecf3cc2b06bbf441e2c062352e29ac038dd17 --- /dev/null +++ b/.github/workflows/pr-lint-check.yml @@ -0,0 +1,320 @@ +name: PR Lint and Format Check + +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - '**.js' + - '**.jsx' + - '**.ts' + - '**.tsx' + - '**.vue' + - '**.json' + - '**.cjs' + - '**.mjs' + - '.prettierrc' + - '.eslintrc.cjs' + - 'package.json' + - 'web/admin-spa/**' + +permissions: + contents: read + pull-requests: write + issues: write + +jobs: + lint-and-format: + runs-on: ubuntu-latest + name: Check Code Quality + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'npm' + + - name: Cache dependencies + uses: actions/cache@v3 + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + + - name: Install dependencies + run: | + npm ci --prefer-offline --no-audit + # 安装 web 目录的依赖(如果存在) + if [ -d "web/admin-spa" ] && [ -f "web/admin-spa/package.json" ]; then + cd web/admin-spa + npm ci --prefer-offline --no-audit + cd ../.. + fi + + - name: Get changed files + id: changed-files + uses: tj-actions/changed-files@v41 + with: + files: | + **/*.js + **/*.jsx + **/*.ts + **/*.tsx + **/*.vue + **/*.cjs + **/*.mjs + **/*.json + files_ignore: | + node_modules/** + dist/** + build/** + coverage/** + .git/** + logs/** + temp/** + tmp/** + + - name: Check Prettier formatting + if: steps.changed-files.outputs.any_changed == 'true' + id: prettier-check + run: | + echo "🔍 Checking Prettier formatting for changed files..." + echo "Changed files: ${{ steps.changed-files.outputs.all_changed_files }}" + + # 初始化标志 + PRETTIER_FAILED=false + PRETTIER_OUTPUT="" + + # 检查每个改变的文件 + for file in ${{ steps.changed-files.outputs.all_changed_files }}; do + if [ -f "$file" ]; then + echo "Checking: $file" + + # 根据文件位置选择正确的 prettier 配置 + if [[ "$file" == web/admin-spa/* ]]; then + # 前端文件:进入前端目录运行 prettier + cd web/admin-spa + RELATIVE_FILE="${file#web/admin-spa/}" + if ! npx prettier --check "$RELATIVE_FILE" 2>&1; then + PRETTIER_FAILED=true + DIFF=$(npx prettier "$RELATIVE_FILE" | diff -u "$RELATIVE_FILE" - || true) + if [ -n "$DIFF" ]; then + PRETTIER_OUTPUT="${PRETTIER_OUTPUT}❌ File needs formatting: $file\n" + PRETTIER_OUTPUT="${PRETTIER_OUTPUT}\`\`\`diff\n${DIFF}\n\`\`\`\n\n" + fi + else + echo "✅ $file is properly formatted" + fi + cd ../.. + else + # 后端文件:使用根目录的 prettier + if ! npx prettier --check "$file" 2>&1; then + PRETTIER_FAILED=true + DIFF=$(npx prettier "$file" | diff -u "$file" - || true) + if [ -n "$DIFF" ]; then + PRETTIER_OUTPUT="${PRETTIER_OUTPUT}❌ File needs formatting: $file\n" + PRETTIER_OUTPUT="${PRETTIER_OUTPUT}\`\`\`diff\n${DIFF}\n\`\`\`\n\n" + fi + else + echo "✅ $file is properly formatted" + fi + fi + fi + done + + # 输出结果 + if [ "$PRETTIER_FAILED" = true ]; then + echo "prettier_failed=true" >> $GITHUB_OUTPUT + echo -e "$PRETTIER_OUTPUT" > prettier-report.md + echo "❌ Some files are not properly formatted." + echo "Please run: npm run format (backend) or cd web/admin-spa && npm run format (frontend)" + exit 1 + else + echo "prettier_failed=false" >> $GITHUB_OUTPUT + echo "✅ All files are properly formatted" + fi + + - name: Run ESLint + if: steps.changed-files.outputs.any_changed == 'true' + id: eslint-check + run: | + echo "🔍 Running ESLint on changed files..." + + # 分离前端和后端文件 + BACKEND_FILES="" + FRONTEND_FILES="" + + for file in ${{ steps.changed-files.outputs.all_changed_files }}; do + if [[ "$file" =~ \.(js|jsx|vue|cjs|mjs)$ ]] && [ -f "$file" ]; then + if [[ "$file" == web/admin-spa/* ]]; then + FRONTEND_FILES="$FRONTEND_FILES ${file#web/admin-spa/}" + else + BACKEND_FILES="$BACKEND_FILES $file" + fi + fi + done + + ESLINT_FAILED=false + ESLINT_OUTPUT="" + + # 检查后端文件 + if [ -n "$BACKEND_FILES" ]; then + echo "Linting backend files: $BACKEND_FILES" + set +e + BACKEND_OUTPUT=$(npx eslint $BACKEND_FILES --format stylish 2>&1) + BACKEND_EXIT_CODE=$? + set -e + + if [ $BACKEND_EXIT_CODE -ne 0 ]; then + ESLINT_FAILED=true + ESLINT_OUTPUT="${ESLINT_OUTPUT}### Backend ESLint Issues\n\`\`\`\n${BACKEND_OUTPUT}\n\`\`\`\n\n" + fi + fi + + # 检查前端文件 + if [ -n "$FRONTEND_FILES" ]; then + echo "Linting frontend files: $FRONTEND_FILES" + cd web/admin-spa + set +e + FRONTEND_OUTPUT=$(npx eslint $FRONTEND_FILES --format stylish 2>&1) + FRONTEND_EXIT_CODE=$? + set -e + cd ../.. + + if [ $FRONTEND_EXIT_CODE -ne 0 ]; then + ESLINT_FAILED=true + ESLINT_OUTPUT="${ESLINT_OUTPUT}### Frontend ESLint Issues\n\`\`\`\n${FRONTEND_OUTPUT}\n\`\`\`\n\n" + fi + fi + + # 输出结果 + if [ "$ESLINT_FAILED" = true ]; then + echo "eslint_failed=true" >> $GITHUB_OUTPUT + echo "❌ ESLint found issues" + + # 创建错误报告 + echo "## ESLint Report" > eslint-report.md + echo "$ESLINT_OUTPUT" >> eslint-report.md + echo "" >> eslint-report.md + echo "Please fix these issues by running:" >> eslint-report.md + echo '```bash' >> eslint-report.md + echo "# Backend: npm run lint" >> eslint-report.md + echo "# Frontend: cd web/admin-spa && npm run lint" >> eslint-report.md + echo '```' >> eslint-report.md + + exit 1 + else + echo "eslint_failed=false" >> $GITHUB_OUTPUT + echo "✅ ESLint check passed" + fi + + - name: Debug PR Context + if: failure() + run: | + echo "PR Number: ${{ github.event.pull_request.number }}" + echo "Repo: ${{ github.repository }}" + echo "Event Name: ${{ github.event_name }}" + echo "Actor: ${{ github.actor }}" + + - name: Comment PR with results + if: failure() + continue-on-error: true # 即使评论失败也继续 + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + let comment = '## 🚨 Code Quality Check Failed\n\n'; + + // 读取 Prettier 报告 + if (fs.existsSync('prettier-report.md')) { + const prettierReport = fs.readFileSync('prettier-report.md', 'utf8'); + comment += '### Prettier Formatting Issues\n\n'; + comment += prettierReport; + comment += '\n**Fix command:**\n```bash\nnpm run format\n```\n\n'; + } + + // 读取 ESLint 报告 + if (fs.existsSync('eslint-report.md')) { + const eslintReport = fs.readFileSync('eslint-report.md', 'utf8'); + comment += '### ESLint Issues\n\n'; + comment += eslintReport; + } + + comment += '\n---\n'; + comment += '💡 **提示**: 在本地运行以下命令来自动修复大部分问题:\n'; + comment += '```bash\n'; + comment += '# 后端代码\n'; + comment += 'npm run format # 修复后端 Prettier 格式问题\n'; + comment += 'npm run lint # 修复后端 ESLint 问题\n'; + comment += '\n'; + comment += '# 前端代码\n'; + comment += 'cd web/admin-spa\n'; + comment += 'npm run format # 修复前端 Prettier 格式问题\n'; + comment += 'npm run lint # 修复前端 ESLint 问题\n'; + comment += '```\n'; + + // 查找是否已有机器人评论 + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const botComment = comments.find(comment => + comment.user.type === 'Bot' && + comment.body.includes('Code Quality Check Failed') + ); + + if (botComment) { + // 更新现有评论 + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body: comment + }); + } else { + // 创建新评论 + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: comment + }); + } + + - name: Success comment + if: success() && steps.changed-files.outputs.any_changed == 'true' + continue-on-error: true # 即使评论失败也继续 + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }} + script: | + // 查找是否已有失败的评论 + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const botComment = comments.find(comment => + comment.user.type === 'Bot' && + comment.body.includes('Code Quality Check Failed') + ); + + if (botComment) { + // 如果之前有失败评论,更新为成功 + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body: '## ✅ Code Quality Check Passed\n\nAll files are properly formatted and pass linting checks!' + }); + } \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..d4bf3f2b78286f9c4dfb901805be282c03e007be --- /dev/null +++ b/.gitignore @@ -0,0 +1,251 @@ +# fork add +docs/ + +# Dependencies +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Environment variables +.env +.env.* +!.env.example + +# Claude specific directories +.claude/ + +# MCP configuration (local only) +.mcp.json +.spec-workflow/ + +# Data directory (contains sensitive information) +data/ +!data/.gitkeep + +# Redis data directory +redis_data/ + +# Logs directory +logs/ +*.log +startup.log +app.log + +# Configuration files (may contain sensitive data) +config/config.js +!config/config.example.js + +# Runtime data +pids/ +*.pid +*.seed +*.pid.lock + +# Coverage directory used by tools like istanbul +coverage/ +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage +.grunt + +# Bower dependency directory +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons +build/Release + +# Dependency directories +jspm_packages/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# parcel-bundler cache +.cache +.parcel-cache + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt +# Gatsby files +.cache/ +public + +# Vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# Temporary folders +tmp/ +temp/ +.tmp/ +.temp/ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db +desktop.ini + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Backup files +*.bak +*.backup +*.backup.* +.env.backup.* +config.js.backup.* +*~ + +# Archive files (unless specifically needed) +*.7z +*.dmg +*.gz +*.iso +*.jar +*.rar +*.tar +*.zip + +# Application specific files +# JWT secrets and encryption keys +secrets/ +keys/ +certs/ + +# Database dumps +*.sql +*.db +*.sqlite +*.sqlite3 + +# Redis dumps +dump.rdb +appendonly.aof + +# PM2 files +ecosystem.config.js +.pm2/ + +# Docker files (keep main ones, ignore volumes) +.docker/ +docker-volumes/ + +# Monitoring data +prometheus/ +grafana/ + +# Test files and coverage +test-results/ +coverage/ +.nyc_output/ + +# Documentation build +docs/build/ +docs/dist/ + +# Deployment files +deploy/ +.deploy/ + +# Package lock files (choose one) +# Uncomment the one you DON'T want to track +# package-lock.json +# yarn.lock +# pnpm-lock.yaml + +# Local development files +.local/ +local/ + +# Debug files +debug.log +error.log +access.log +http-debug*.log +logs/http-debug-*.log + +src/middleware/debugInterceptor.js + +# Session files +sessions/ + +# Upload directories +uploads/ +files/ + +# Cache directories +.cache/ +cache/ + +# Build artifacts +build/ +dist/ +out/ + +# Runtime files +*.sock + +# Old admin interface (deprecated) +web/admin/ +web/apiStats/ + +# Admin SPA build files +web/admin-spa/dist/ diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000000000000000000000000000000000000..7c104027017578a00e26bf5a80d406c6ead3f7eb --- /dev/null +++ b/.prettierrc @@ -0,0 +1,14 @@ +{ + "semi": false, + "trailingComma": "none", + "singleQuote": true, + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "lf", + "quoteProps": "as-needed", + "bracketSameLine": false, + "proseWrap": "preserve" +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000000000000000000000000000000000..f57ed7d32d637e5aa833e74d35fb5259b01ac902 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,275 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +这个文件为 Claude Code (claude.ai/code) 提供在此代码库中工作的指导。 + +## 项目概述 + +Claude Relay Service 是一个功能完整的 AI API 中转服务,支持 Claude 和 Gemini 双平台。提供多账户管理、API Key 认证、代理配置和现代化 Web 管理界面。该服务作为客户端(如 SillyTavern、Claude Code、Gemini CLI)与 AI API 之间的中间件,提供认证、限流、监控等功能。 + +## 核心架构 + +### 关键架构概念 + +- **代理认证流**: 客户端用自建API Key → 验证 → 获取Claude账户OAuth token → 转发到Anthropic +- **Token管理**: 自动监控OAuth token过期并刷新,支持10秒提前刷新策略 +- **代理支持**: 每个Claude账户支持独立代理配置,OAuth token交换也通过代理进行 +- **数据加密**: 敏感数据(refreshToken, accessToken)使用AES加密存储在Redis + +### 主要服务组件 + +- **claudeRelayService.js**: 核心代理服务,处理请求转发和流式响应 +- **claudeAccountService.js**: Claude账户管理,OAuth token刷新和账户选择 +- **geminiAccountService.js**: Gemini账户管理,Google OAuth token刷新和账户选择 +- **apiKeyService.js**: API Key管理,验证、限流和使用统计 +- **oauthHelper.js**: OAuth工具,PKCE流程实现和代理支持 + +### 认证和代理流程 + +1. 客户端使用自建API Key(cr\_前缀格式)发送请求 +2. authenticateApiKey中间件验证API Key有效性和速率限制 +3. claudeAccountService自动选择可用Claude账户 +4. 检查OAuth access token有效性,过期则自动刷新(使用代理) +5. 移除客户端API Key,使用OAuth Bearer token转发请求 +6. 通过账户配置的代理发送到Anthropic API +7. 流式或非流式返回响应,记录使用统计 + +### OAuth集成 + +- **PKCE流程**: 完整的OAuth 2.0 PKCE实现,支持代理 +- **自动刷新**: 智能token过期检测和自动刷新机制 +- **代理支持**: OAuth授权和token交换全程支持代理配置 +- **安全存储**: claudeAiOauth数据加密存储,包含accessToken、refreshToken、scopes + +## 常用命令 + +### 基本开发命令 + +````bash +# 安装依赖和初始化 +npm install +npm run setup # 生成配置和管理员凭据 +npm run install:web # 安装Web界面依赖 + +# 开发和运行 +npm run dev # 开发模式(热重载) +npm start # 生产模式 +npm test # 运行测试 +npm run lint # 代码检查 + +# Docker部署 +docker-compose up -d # 推荐方式 +docker-compose --profile monitoring up -d # 包含监控 + +# 服务管理 +npm run service:start:daemon # 后台启动(推荐) +npm run service:status # 查看服务状态 +npm run service:logs # 查看日志 +npm run service:stop # 停止服务 + +### 开发环境配置 +必须配置的环境变量: +- `JWT_SECRET`: JWT密钥(32字符以上随机字符串) +- `ENCRYPTION_KEY`: 数据加密密钥(32字符固定长度) +- `REDIS_HOST`: Redis主机地址(默认localhost) +- `REDIS_PORT`: Redis端口(默认6379) +- `REDIS_PASSWORD`: Redis密码(可选) + +初始化命令: +```bash +cp config/config.example.js config/config.js +cp .env.example .env +npm run setup # 自动生成密钥并创建管理员账户 +```` + +## Web界面功能 + +### OAuth账户添加流程 + +1. **基本信息和代理设置**: 配置账户名称、描述和代理参数 +2. **OAuth授权**: + - 生成授权URL → 用户打开链接并登录Claude Code账号 + - 授权后会显示Authorization Code → 复制并粘贴到输入框 + - 系统自动交换token并创建账户 + +### 核心管理功能 + +- **实时仪表板**: 系统统计、账户状态、使用量监控 +- **API Key管理**: 创建、配额设置、使用统计查看 +- **Claude账户管理**: OAuth账户添加、代理配置、状态监控 +- **系统日志**: 实时日志查看,多级别过滤 +- **主题系统**: 支持明亮/暗黑模式切换,自动保存用户偏好设置 + +## 重要端点 + +### API转发端点 + +- `POST /api/v1/messages` - 主要消息处理端点(支持流式) +- `GET /api/v1/models` - 模型列表(兼容性) +- `GET /api/v1/usage` - 使用统计查询 +- `GET /api/v1/key-info` - API Key信息 + +### OAuth管理端点 + +- `POST /admin/claude-accounts/generate-auth-url` - 生成OAuth授权URL(含代理) +- `POST /admin/claude-accounts/exchange-code` - 交换authorization code +- `POST /admin/claude-accounts` - 创建OAuth账户 + +### 系统端点 + +- `GET /health` - 健康检查 +- `GET /web` - Web管理界面 +- `GET /admin/dashboard` - 系统概览数据 + +## 故障排除 + +### OAuth相关问题 + +1. **代理配置错误**: 检查代理设置是否正确,OAuth token交换也需要代理 +2. **授权码无效**: 确保复制了完整的Authorization Code,没有遗漏字符 +3. **Token刷新失败**: 检查refreshToken有效性和代理配置 + +### Gemini Token刷新问题 + +1. **刷新失败**: 确保 refresh_token 有效且未过期 +2. **错误日志**: 查看 `logs/token-refresh-error.log` 获取详细错误信息 +3. **测试脚本**: 运行 `node scripts/test-gemini-refresh.js` 测试 token 刷新 + +### 常见开发问题 + +1. **Redis连接失败**: 确认Redis服务运行,检查连接配置 +2. **管理员登录失败**: 检查init.json同步到Redis,运行npm run setup +3. **API Key格式错误**: 确保使用cr\_前缀格式 +4. **代理连接问题**: 验证SOCKS5/HTTP代理配置和认证信息 + +### 调试工具 + +- **日志系统**: Winston结构化日志,支持不同级别 +- **CLI工具**: 命令行状态查看和管理 +- **Web界面**: 实时日志查看和系统监控 +- **健康检查**: /health端点提供系统状态 + +## 开发最佳实践 + +### 代码格式化要求 + +- **必须使用 Prettier 格式化所有代码** +- 后端代码(src/):运行 `npx prettier --write ` 格式化 +- 前端代码(web/admin-spa/):已安装 `prettier-plugin-tailwindcss`,运行 `npx prettier --write ` 格式化 +- 提交前检查格式:`npx prettier --check ` +- 格式化所有文件:`npm run format`(如果配置了此脚本) + +### 前端开发特殊要求 + +- **响应式设计**: 必须兼容不同设备尺寸(手机、平板、桌面),使用 Tailwind CSS 响应式前缀(sm:、md:、lg:、xl:) +- **暗黑模式兼容**: 项目已集成完整的暗黑模式支持,所有新增/修改的UI组件都必须同时兼容明亮模式和暗黑模式 + - 使用 Tailwind CSS 的 `dark:` 前缀为暗黑模式提供样式 + - 文本颜色:`text-gray-700 dark:text-gray-200` + - 背景颜色:`bg-white dark:bg-gray-800` + - 边框颜色:`border-gray-200 dark:border-gray-700` + - 状态颜色保持一致:`text-blue-500`、`text-green-600`、`text-red-500` 等 +- **主题切换**: 使用 `stores/theme.js` 中的 `useThemeStore()` 来实现主题切换功能 +- **玻璃态效果**: 保持现有的玻璃态设计风格,在暗黑模式下调整透明度和背景色 +- **图标和交互**: 确保所有图标、按钮、交互元素在两种模式下都清晰可见且易于操作 + +### 代码修改原则 + +- 对现有文件进行修改时,首先检查代码库的现有模式和风格 +- 尽可能重用现有的服务和工具函数,避免重复代码 +- 遵循项目现有的错误处理和日志记录模式 +- 敏感数据必须使用加密存储(参考 claudeAccountService.js 中的加密实现) + +### 测试和质量保证 + +- 运行 `npm run lint` 进行代码风格检查(使用 ESLint) +- 运行 `npm test` 执行测试套件(Jest + SuperTest 配置) +- 在修改核心服务后,使用 CLI 工具验证功能:`npm run cli status` +- 检查日志文件 `logs/claude-relay-*.log` 确认服务正常运行 +- 注意:当前项目缺少实际测试文件,建议补充单元测试和集成测试 + +### 开发工作流 + +- **功能开发**: 始终从理解现有代码开始,重用已有的服务和模式 +- **调试流程**: 使用 Winston 日志 + Web 界面实时日志查看 + CLI 状态工具 +- **代码审查**: 关注安全性(加密存储)、性能(异步处理)、错误处理 +- **部署前检查**: 运行 lint → 测试 CLI 功能 → 检查日志 → Docker 构建 + +### 常见文件位置 + +- 核心服务逻辑:`src/services/` 目录 +- 路由处理:`src/routes/` 目录 +- 中间件:`src/middleware/` 目录 +- 配置管理:`config/config.js` +- Redis 模型:`src/models/redis.js` +- 工具函数:`src/utils/` 目录 +- 前端主题管理:`web/admin-spa/src/stores/theme.js` +- 前端组件:`web/admin-spa/src/components/` 目录 +- 前端页面:`web/admin-spa/src/views/` 目录 + +### 重要架构决策 + +- 所有敏感数据(OAuth token、refreshToken)都使用 AES 加密存储在 Redis +- 每个 Claude 账户支持独立的代理配置,包括 SOCKS5 和 HTTP 代理 +- API Key 使用哈希存储,支持 `cr_` 前缀格式 +- 请求流程:API Key 验证 → 账户选择 → Token 刷新(如需)→ 请求转发 +- 支持流式和非流式响应,客户端断开时自动清理资源 + +### 核心数据流和性能优化 + +- **哈希映射优化**: API Key 验证从 O(n) 优化到 O(1) 查找 +- **智能 Usage 捕获**: 从 SSE 流中解析真实的 token 使用数据 +- **多维度统计**: 支持按时间、模型、用户的实时使用统计 +- **异步处理**: 非阻塞的统计记录和日志写入 +- **原子操作**: Redis 管道操作确保数据一致性 + +### 安全和容错机制 + +- **多层加密**: API Key 哈希 + OAuth Token AES 加密 +- **零信任验证**: 每个请求都需要完整的认证链 +- **优雅降级**: Redis 连接失败时的回退机制 +- **自动重试**: 指数退避重试策略和错误隔离 +- **资源清理**: 客户端断开时的自动清理机制 + +## 项目特定注意事项 + +### Redis 数据结构 + +- **API Keys**: `api_key:{id}` (详细信息) + `api_key_hash:{hash}` (快速查找) +- **Claude 账户**: `claude_account:{id}` (加密的 OAuth 数据) +- **管理员**: `admin:{id}` + `admin_username:{username}` (用户名映射) +- **会话**: `session:{token}` (JWT 会话管理) +- **使用统计**: `usage:daily:{date}:{key}:{model}` (多维度统计) +- **系统信息**: `system_info` (系统状态缓存) + +### 流式响应处理 + +- 支持 SSE (Server-Sent Events) 流式传输 +- 自动从流中解析 usage 数据并记录 +- 客户端断开时通过 AbortController 清理资源 +- 错误时发送适当的 SSE 错误事件 + +### CLI 工具使用示例 + +```bash +# 创建新的 API Key +npm run cli keys create -- --name "MyApp" --limit 1000 + +# 查看系统状态 +npm run cli status + +# 管理 Claude 账户 +npm run cli accounts list +npm run cli accounts refresh + +# 管理员操作 +npm run cli admin create -- --username admin2 +npm run cli admin reset-password -- --username admin +``` + +# important-instruction-reminders + +Do what has been asked; nothing more, nothing less. +NEVER create files unless they're absolutely necessary for achieving your goal. +ALWAYS prefer editing an existing file to creating a new one. +NEVER proactively create documentation files (\*.md) or README files. Only create documentation files if explicitly requested by the User. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..c6306ca390dc74a383ee6082963f53137a35ce07 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,71 @@ +# 🎯 前端构建阶段 +FROM node:18-alpine AS frontend-builder + +# 📁 设置工作目录 +WORKDIR /app/web/admin-spa + +# 📦 复制前端依赖文件 +COPY web/admin-spa/package*.json ./ + +# 🔽 安装前端依赖 +RUN npm ci + +# 📋 复制前端源代码 +COPY web/admin-spa/ ./ + +# 🏗️ 构建前端 +RUN npm run build + +# 🐳 主应用阶段 +FROM node:18-alpine + +# 📋 设置标签 +LABEL maintainer="claude-relay-service@example.com" +LABEL description="Claude Code API Relay Service" +LABEL version="1.0.0" + +# 🔧 安装系统依赖 +RUN apk add --no-cache \ + curl \ + dumb-init \ + sed \ + && rm -rf /var/cache/apk/* + +# 📁 设置工作目录 +WORKDIR /app + +# 📦 复制 package 文件 +COPY package*.json ./ + +# 🔽 安装依赖 (生产环境) +RUN npm ci --only=production && \ + npm cache clean --force + +# 📋 复制应用代码 +COPY . . + +# 📦 从构建阶段复制前端产物 +COPY --from=frontend-builder /app/web/admin-spa/dist /app/web/admin-spa/dist + +# 🔧 复制并设置启动脚本权限 +COPY docker-entrypoint.sh /usr/local/bin/ +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +# 📁 创建必要目录 +RUN mkdir -p logs data temp + +# 🔧 预先创建配置文件 +RUN if [ ! -f "/app/config/config.js" ] && [ -f "/app/config/config.example.js" ]; then \ + cp /app/config/config.example.js /app/config/config.js; \ + fi + +# 🌐 暴露端口 +EXPOSE 3000 + +# 🏥 健康检查 +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:3000/health || exit 1 + +# 🚀 启动应用 +ENTRYPOINT ["dumb-init", "--", "/usr/local/bin/docker-entrypoint.sh"] +CMD ["node", "src/app.js"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..c2bf91f733ababcc020aad504db4dc8560c3fddc --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Wesley Liddick + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..22a701a5e2335a9c1f6d87bb15b02c788be884cf --- /dev/null +++ b/Makefile @@ -0,0 +1,259 @@ +# Claude Relay Service Makefile +# 功能完整的 AI API 中转服务,支持 Claude 和 Gemini 双平台 + +.PHONY: help install setup dev start test lint clean docker-up docker-down service-start service-stop service-status logs cli-admin cli-keys cli-accounts cli-status + +# 默认目标:显示帮助信息 +help: + @echo "Claude Relay Service - AI API 中转服务" + @echo "" + @echo "可用命令:" + @echo "" + @echo " 📦 安装和初始化:" + @echo " install - 安装项目依赖" + @echo " install-web - 安装Web界面依赖" + @echo " setup - 生成配置文件和管理员凭据" + @echo " clean - 清理依赖和构建文件" + @echo "" + @echo " 🎨 前端构建:" + @echo " build-web - 构建 Web 管理界面" + @echo " build-all - 构建完整项目(后端+前端)" + @echo "" + @echo " 🚀 开发和运行:" + @echo " dev - 开发模式运行(热重载)" + @echo " start - 生产模式运行" + @echo " test - 运行测试套件" + @echo " lint - 代码风格检查" + @echo "" + @echo " 🐳 Docker 部署:" + @echo " docker-up - 启动 Docker 服务" + @echo " docker-up-full - 启动 Docker 服务(包含监控)" + @echo " docker-down - 停止 Docker 服务" + @echo " docker-logs - 查看 Docker 日志" + @echo "" + @echo " 🔧 服务管理:" + @echo " service-start - 前台启动服务" + @echo " service-daemon - 后台启动服务(守护进程)" + @echo " service-stop - 停止服务" + @echo " service-restart - 重启服务" + @echo " service-restart-daemon - 重启服务(守护进程)" + @echo " service-status - 查看服务状态" + @echo " logs - 查看应用日志" + @echo " logs-follow - 实时查看日志" + @echo "" + @echo " ⚙️ CLI 管理工具:" + @echo " cli-admin - 管理员操作" + @echo " cli-keys - API Key 管理" + @echo " cli-accounts - Claude 账户管理" + @echo " cli-status - 系统状态查看" + @echo "" + @echo " 💡 快速开始:" + @echo " make setup && make dev" + @echo "" + +# 安装和初始化 +install: + @echo "📦 安装项目依赖..." + npm install + +install-web: + @echo "📦 安装 Web 界面依赖..." + npm run install:web + +# 前端构建 +build-web: + @echo "🎨 构建 Web 管理界面..." + npm run build:web + +build-all: install install-web build-web + @echo "🎉 完整项目构建完成!" + +setup: + @echo "⚙️ 初始化项目配置和管理员凭据..." + @if [ ! -f config/config.js ]; then cp config/config.example.js config/config.js; fi + @if [ ! -f .env ]; then cp .env.example .env; fi + npm run setup + +clean: + @echo "🧹 清理依赖和构建文件..." + rm -rf node_modules + rm -rf web/node_modules + rm -rf web/admin-spa/dist + rm -rf web/admin-spa/node_modules + rm -rf logs/*.log + +# 开发和运行 +dev: + @echo "🚀 启动开发模式(热重载)..." + npm run dev + +start: + @echo "🚀 启动生产模式..." + npm start + +test: + @echo "🧪 运行测试套件..." + npm test + +lint: + @echo "🔍 执行代码风格检查..." + npm run lint + +# Docker 部署 +docker-up: + @echo "🐳 启动 Docker 服务..." + docker-compose up -d + +docker-up-full: + @echo "🐳 启动 Docker 服务(包含监控)..." + docker-compose --profile monitoring up -d + +docker-down: + @echo "🛑 停止 Docker 服务..." + docker-compose down + +docker-logs: + @echo "📋 查看 Docker 服务日志..." + docker-compose logs -f + +# 服务管理 +service-start: + @echo "🚀 前台启动服务..." + npm run service:start + +service-daemon: + @echo "🔧 后台启动服务(守护进程)..." + npm run service:start:daemon + +service-stop: + @echo "🛑 停止服务..." + npm run service:stop + +service-restart: + @echo "🔄 重启服务..." + npm run service:restart + +service-restart-daemon: + @echo "🔄 重启服务(守护进程)..." + npm run service:restart:daemon + +service-status: + @echo "📊 查看服务状态..." + npm run service:status + +logs: + @echo "📋 查看应用日志..." + npm run service:logs + +logs-follow: + @echo "📋 实时查看日志..." + npm run service:logs:follow + +# CLI 管理工具 +cli-admin: + @echo "👤 启动管理员操作 CLI..." + npm run cli admin + +cli-keys: + @echo "🔑 启动 API Key 管理 CLI..." + npm run cli keys + +cli-accounts: + @echo "👥 启动 Claude 账户管理 CLI..." + npm run cli accounts + +cli-status: + @echo "📊 查看系统状态..." + npm run cli status + +# 开发辅助命令 +check-config: + @echo "🔍 检查配置文件..." + @if [ ! -f config/config.js ]; then echo "❌ config/config.js 不存在,请运行 'make setup'"; exit 1; fi + @if [ ! -f .env ]; then echo "❌ .env 不存在,请运行 'make setup'"; exit 1; fi + @echo "✅ 配置文件检查通过" + +health-check: + @echo "🏥 执行健康检查..." + @curl -s http://localhost:3000/health || echo "❌ 服务未运行或不可访问" + +# 快速启动组合命令 +quick-start: setup dev + +quick-daemon: setup service-daemon + @echo "🎉 服务已在后台启动!" + @echo "运行 'make service-status' 查看状态" + @echo "运行 'make logs-follow' 查看实时日志" + +# 全栈开发环境 +dev-full: install install-web build-web setup dev + @echo "🚀 全栈开发环境启动!" + +# 完整部署流程 +deploy: clean install install-web build-web setup test lint docker-up + @echo "🎉 部署完成!" + @echo "访问 Web 管理界面: http://localhost:3000/web" + @echo "API 端点: http://localhost:3000/api/v1/messages" + +# 生产部署准备 +production-build: clean install install-web build-web + @echo "🚀 生产环境构建完成!" + +# 维护命令 +backup-redis: + @echo "💾 备份 Redis 数据..." + @docker exec claude-relay-service-redis-1 redis-cli BGSAVE || echo "❌ Redis 备份失败" + +restore-redis: + @echo "♻️ 恢复 Redis 数据..." + @echo "请手动恢复 Redis 数据文件" + +# 监控和日志 +monitor: + @echo "📊 启动监控面板..." + @echo "Grafana: http://localhost:3001" + @echo "Redis Commander: http://localhost:8081" + +tail-logs: + @echo "📋 实时查看日志..." + tail -f logs/claude-relay-*.log + +# 开发工具 +format: + @echo "🎨 格式化代码..." + npm run lint -- --fix + +check-deps: + @echo "🔍 检查依赖更新..." + npm outdated + +update-deps: + @echo "⬆️ 更新依赖..." + npm update + +# 测试相关 +test-coverage: + @echo "📊 运行测试覆盖率..." + npm test -- --coverage + +test-watch: + @echo "👀 监视模式运行测试..." + npm test -- --watch + +# Git 相关 +git-status: + @echo "📋 Git 状态..." + git status --short + +git-pull: + @echo "⬇️ 拉取最新代码..." + git pull origin main + +# 安全检查 +security-audit: + @echo "🔒 执行安全审计..." + npm audit + +security-fix: + @echo "🔧 修复安全漏洞..." + npm audit fix \ No newline at end of file diff --git a/README.md b/README.md index fe2c0f4ced9ccb018f899a07525baffbd36bb21c..95a47a550dab5ba8d35c9e21e5cf0a899d2b2b0e 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,941 @@ +# Claude Relay Service + +
+ +[![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/Codex 拼车服务推荐 + +
+ +| 平台 | 类型 | 服务 | 介绍 | +|:---|:---|:---|:---| +| **[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 拼车 | + + +
+ +--- + +## ⚠️ 重要提醒 + +**使用本项目前请仔细阅读:** + +🚨 **服务条款风险**: 使用本项目可能违反Anthropic的服务条款。请在使用前仔细阅读Anthropic的用户协议,使用本项目的一切风险由用户自行承担。 + +📖 **免责声明**: 本项目仅供技术学习和研究使用,作者不对因使用本项目导致的账户封禁、服务中断或其他损失承担任何责任。 + + +## 🤔 这个项目适合你吗? + +- 🌍 **地区限制**: 所在地区无法直接访问Claude Code服务? +- 🔒 **隐私担忧**: 担心第三方镜像服务会记录或泄露你的对话内容? +- 👥 **成本分摊**: 想和朋友一起分摊Claude Code Max订阅费用? +- ⚡ **稳定性**: 第三方镜像站经常故障不稳定,影响效率 ? + +如果有以上困惑,那这个项目可能适合你。 + +### 适合的场景 + +✅ **找朋友拼车**: 三五好友一起分摊Claude Code Max订阅 +✅ **隐私敏感**: 不想让第三方镜像看到你的对话内容 +✅ **技术折腾**: 有基本的技术基础,愿意自己搭建和维护 +✅ **稳定需求**: 需要长期稳定的Claude访问,不想受制于镜像站 +✅ **地区受限**: 无法直接访问Claude官方服务 + --- -title: Cc -emoji: ⚡ -colorFrom: gray -colorTo: blue -sdk: docker -pinned: false + +## 💭 为什么要自己搭? + +### 现有镜像站可能的问题 + +- 🕵️ **隐私风险**: 你的对话内容都被人家看得一清二楚,商业机密什么的就别想了 +- 🐌 **性能不稳**: 用的人多了就慢,高峰期经常卡死 +- 💰 **价格不透明**: 不知道实际成本 + +### 自建的好处 + +- 🔐 **数据安全**: 所有接口请求都只经过你自己的服务器,直连Anthropic API +- ⚡ **性能可控**: 就你们几个人用,Max 200刀套餐基本上可以爽用Opus +- 💰 **成本透明**: 用了多少token一目了然,按官方价格换算了具体费用 +- 📊 **监控完整**: 使用情况、成本分析、性能监控全都有 + +--- + +## 🚀 核心功能 + +### 基础功能 + +- ✅ **多账户管理**: 可以添加多个Claude账户自动轮换 +- ✅ **自定义API Key**: 给每个人分配独立的Key +- ✅ **使用统计**: 详细记录每个人用了多少token + +### 高级功能 + +- 🔄 **智能切换**: 账户出问题自动换下一个 +- 🚀 **性能优化**: 连接池、缓存,减少延迟 +- 📊 **监控面板**: Web界面查看所有数据 +- 🛡️ **安全控制**: 访问限制、速率控制、客户端限制 +- 🌐 **代理支持**: 支持HTTP/SOCKS5代理 + --- -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +## 📋 部署要求 + +### 硬件要求(最低配置) + +- **CPU**: 1核心就够了 +- **内存**: 512MB(建议1GB) +- **硬盘**: 30GB可用空间 +- **网络**: 能访问到Anthropic API(建议使用US地区的机器) +- **建议**: 2核4G的基本够了,网络尽量选回国线路快一点的(为了提高速度,建议不要开代理或者设置服务器的IP直连) +- **经验**: 阿里云、腾讯云的海外主机经测试会被Cloudflare拦截,无法直接访问claude api + +### 软件要求 + +- **Node.js** 18或更高版本 +- **Redis** 6或更高版本 +- **操作系统**: 建议Linux + +### 费用估算 + +- **服务器**: 轻量云服务器,一个月30-60块 +- **Claude订阅**: 看你怎么分摊了 +- **其他**: 域名(可选) + +--- + +## 🚀 脚本部署(推荐) + +推荐使用管理脚本进行一键部署,简单快捷,自动处理所有依赖和配置。 + +### 快速安装 + +```bash +curl -fsSL https://pincc.ai/manage.sh -o manage.sh && chmod +x manage.sh && ./manage.sh install +``` + +### 脚本功能 + +- ✅ **一键安装**: 自动检测系统环境,安装 Node.js 18+、Redis 等依赖 +- ✅ **交互式配置**: 友好的配置向导,设置端口、Redis 连接等 +- ✅ **自动启动**: 安装完成后自动启动服务并显示访问地址 +- ✅ **便捷管理**: 通过 `crs` 命令随时管理服务状态 + +### 管理命令 + +```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`。 + +**Gemini CLI 设置环境变量:** + +```bash +GEMINI_MODEL="gemini-2.5-pro" +GOOGLE_GEMINI_BASE_URL="http://127.0.0.1:3000/gemini" # 根据实际填写你服务器的ip地址或者域名 +GEMINI_API_KEY="后台创建的API密钥" # 使用相同的API密钥即可 +``` +**使用 Claude Code:** + +```bash +claude +``` + +**使用 Gemini CLI:** + +```bash +gemini # 或其他 Gemini CLI 命令 +``` + +**Codex 配置:** + +在 `~/.codex/config.toml` 文件**开头**添加以下配置: + +```toml +model_provider = "crs" +model = "gpt-5-codex" +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 +} +``` + +环境变量设置: + +```bash +export CRS_OAI_KEY="后台创建的API密钥" +``` + +> ⚠️ 在通过 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": "Sonnet 4.5 [crs]", + "model": "claude-sonnet-4-5-20250929", + "base_url": "http://127.0.0.1:3000/droid/claude", + "api_key": "后台创建的API密钥", + "provider": "anthropic", + "max_tokens": 8192 + }, + { + "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 + } + ] +} +``` + +> 💡 将示例中的 `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密钥便于使用统计 + +--- + +## 🔧 日常维护 + +### 服务管理 + +```bash +# 查看服务状态 +npm run service:status + +# 查看日志 +npm run service:logs + +# 重启服务 +npm run service:restart:daemon + +# 停止服务 +npm run service:stop +``` + +### 监控使用情况 + +- **Web界面**: `http://你的域名:3000/web` - 查看使用统计 +- **健康检查**: `http://你的域名:3000/health` - 确认服务正常 +- **日志文件**: `logs/` 目录下的各种日志文件 + +### 升级指南 + +当有新版本发布时,按照以下步骤升级服务: + +```bash +# 1. 进入项目目录 +cd claude-relay-service + +# 2. 拉取最新代码 +git pull origin main + +# 如果遇到 package-lock.json 冲突,使用远程版本 +git checkout --theirs package-lock.json +git add package-lock.json + +# 3. 安装新的依赖(如果有) +npm install + +# 4. 安装并构建前端 +npm run install:web +npm run build:web + +# 5. 重启服务 +npm run service:restart:daemon + +# 6. 检查服务状态 +npm run service:status +``` + +**注意事项:** + +- 升级前建议备份重要配置文件(.env, config/config.js) +- 查看更新日志了解是否有破坏性变更 +- 如果有数据库结构变更,会自动迁移 + +--- + +## 🔒 客户端限制功能 + +### 功能说明 + +客户端限制功能允许你控制每个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账户状态正常 + +--- + +## 🛠️ 进阶 + +### 反向代理部署指南 + +在生产环境中,建议通过反向代理进行连接,以便使用自动 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**: 提交详细的错误信息 +- **查看文档**: 仔细阅读错误信息和文档 +- **社区讨论**: 看看其他人是否遇到类似问题 + +--- + +## 📄 许可证 + +本项目采用 [MIT许可证](LICENSE)。 + +--- + +
+ +**⭐ 觉得有用的话给个Star呗,这是对作者最大的鼓励!** + +**🤝 有问题欢迎提Issue,有改进建议欢迎PR** + +
diff --git a/README_EN.md b/README_EN.md new file mode 100644 index 0000000000000000000000000000000000000000..6573021b69095de89fa54e4f1565a6d9c11656f6 --- /dev/null +++ b/README_EN.md @@ -0,0 +1,560 @@ +# Claude Relay Service + +
+ +[![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) + +
+ +--- + +## ⭐ If You Find It Useful, Please Give It a Star! + +> 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 + +--- + +## ⚠️ Important Notice + +**Please read carefully before using this project:** + +🚨 **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 + +```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 +``` + +### 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 + +```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 +``` + +--- + +## 🎮 Getting Started + +### 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 + +Now you can replace the official API with your own service: + +**Set environment variables:** +```bash +export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # Fill in your server's IP address or domain according to actual situation +export ANTHROPIC_AUTH_TOKEN="API key created in the backend" +``` + +**Use claude:** +```bash +claude +``` + +--- + +## 🔧 Daily Maintenance + +### Service Management + +```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 +``` + +### 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 + +### 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)**. + +--- + +## Caddy Solution + +Caddy is a web server that automatically manages HTTPS certificates, with simple configuration and excellent performance, ideal for deployments without Docker environments. + +**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 + +--- + +## Nginx Proxy Manager (NPM) Solution + +Nginx Proxy Manager manages reverse proxies and HTTPS certificates through a graphical interface, deployed as a Docker container. + +**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/VERSION b/VERSION new file mode 100644 index 0000000000000000000000000000000000000000..01793f6847fececdc1fbf3d5af052dbf788fd9f2 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.1.175 diff --git a/cli/index.js b/cli/index.js new file mode 100644 index 0000000000000000000000000000000000000000..908a9311aacf7dba73ed1b378105bef6c7e4ec7d --- /dev/null +++ b/cli/index.js @@ -0,0 +1,1025 @@ +#!/usr/bin/env node + +const { Command } = require('commander') +const inquirer = require('inquirer') +const chalk = require('chalk') +const ora = require('ora') +const { table } = require('table') +const bcrypt = require('bcryptjs') +const fs = require('fs') +const path = require('path') + +const redis = require('../src/models/redis') +const apiKeyService = require('../src/services/apiKeyService') +const claudeAccountService = require('../src/services/claudeAccountService') +const bedrockAccountService = require('../src/services/bedrockAccountService') + +const program = new Command() + +// 🎨 样式 +const styles = { + title: chalk.bold.blue, + success: chalk.green, + error: chalk.red, + warning: chalk.yellow, + info: chalk.cyan, + dim: chalk.dim +} + +// 🔧 初始化 +async function initialize() { + const spinner = ora('正在连接 Redis...').start() + try { + await redis.connect() + spinner.succeed('Redis 连接成功') + } catch (error) { + spinner.fail('Redis 连接失败') + console.error(styles.error(error.message)) + process.exit(1) + } +} + +// 🔐 管理员账户管理 +program + .command('admin') + .description('管理员账户操作') + .action(async () => { + await initialize() + + // 直接执行创建初始管理员 + await createInitialAdmin() + + await redis.disconnect() + }) + +// 🔑 API Key 管理 +program + .command('keys') + .description('API Key 管理操作') + .action(async () => { + await initialize() + + const { action } = await inquirer.prompt([ + { + type: 'list', + name: 'action', + message: '请选择操作:', + choices: [ + { name: '📋 查看所有 API Keys', value: 'list' }, + { name: '🔧 修改 API Key 过期时间', value: 'update-expiry' }, + { name: '🔄 续期即将过期的 API Key', value: 'renew' }, + { name: '🗑️ 删除 API Key', value: 'delete' } + ] + } + ]) + + switch (action) { + case 'list': + await listApiKeys() + break + case 'update-expiry': + await updateApiKeyExpiry() + break + case 'renew': + await renewApiKeys() + break + case 'delete': + await deleteApiKey() + break + } + + await redis.disconnect() + }) + +// 📊 系统状态 +program + .command('status') + .description('查看系统状态') + .action(async () => { + await initialize() + + const spinner = ora('正在获取系统状态...').start() + + try { + const [, apiKeys, accounts] = await Promise.all([ + redis.getSystemStats(), + apiKeyService.getAllApiKeys(), + claudeAccountService.getAllAccounts() + ]) + + spinner.succeed('系统状态获取成功') + + console.log(styles.title('\n📊 系统状态概览\n')) + + const statusData = [ + ['项目', '数量', '状态'], + ['API Keys', apiKeys.length, `${apiKeys.filter((k) => k.isActive).length} 活跃`], + ['Claude 账户', accounts.length, `${accounts.filter((a) => a.isActive).length} 活跃`], + ['Redis 连接', redis.isConnected ? '已连接' : '未连接', redis.isConnected ? '🟢' : '🔴'], + ['运行时间', `${Math.floor(process.uptime() / 60)} 分钟`, '🕐'] + ] + + console.log(table(statusData)) + + // 使用统计 + const totalTokens = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.tokens || 0), 0) + const totalRequests = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.requests || 0), 0) + + console.log(styles.title('\n📈 使用统计\n')) + console.log(`总 Token 使用量: ${styles.success(totalTokens.toLocaleString())}`) + console.log(`总请求数: ${styles.success(totalRequests.toLocaleString())}`) + } catch (error) { + spinner.fail('获取系统状态失败') + console.error(styles.error(error.message)) + } + + await redis.disconnect() + }) + +// ☁️ Bedrock 账户管理 +program + .command('bedrock') + .description('Bedrock 账户管理操作') + .action(async () => { + await initialize() + + const { action } = await inquirer.prompt([ + { + type: 'list', + name: 'action', + message: '请选择操作:', + choices: [ + { name: '📋 查看所有 Bedrock 账户', value: 'list' }, + { name: '➕ 创建 Bedrock 账户', value: 'create' }, + { name: '✏️ 编辑 Bedrock 账户', value: 'edit' }, + { name: '🔄 切换账户状态', value: 'toggle' }, + { name: '🧪 测试账户连接', value: 'test' }, + { name: '🗑️ 删除账户', value: 'delete' } + ] + } + ]) + + switch (action) { + case 'list': + await listBedrockAccounts() + break + case 'create': + await createBedrockAccount() + break + case 'edit': + await editBedrockAccount() + break + case 'toggle': + await toggleBedrockAccount() + break + case 'test': + await testBedrockAccount() + break + case 'delete': + await deleteBedrockAccount() + break + } + + await redis.disconnect() + }) + +// 实现具体功能函数 + +async function createInitialAdmin() { + console.log(styles.title('\n🔐 创建初始管理员账户\n')) + + // 检查是否已存在 init.json + const initFilePath = path.join(__dirname, '..', 'data', 'init.json') + if (fs.existsSync(initFilePath)) { + const existingData = JSON.parse(fs.readFileSync(initFilePath, 'utf8')) + console.log(styles.warning('⚠️ 检测到已存在管理员账户!')) + console.log(` 用户名: ${existingData.adminUsername}`) + console.log(` 创建时间: ${new Date(existingData.initializedAt).toLocaleString()}`) + + const { overwrite } = await inquirer.prompt([ + { + type: 'confirm', + name: 'overwrite', + message: '是否覆盖现有管理员账户?', + default: false + } + ]) + + if (!overwrite) { + console.log(styles.info('ℹ️ 已取消创建')) + return + } + } + + const adminData = await inquirer.prompt([ + { + type: 'input', + name: 'username', + message: '用户名:', + default: 'admin', + validate: (input) => input.length >= 3 || '用户名至少3个字符' + }, + { + type: 'password', + name: 'password', + message: '密码:', + validate: (input) => input.length >= 8 || '密码至少8个字符' + }, + { + type: 'password', + name: 'confirmPassword', + message: '确认密码:', + validate: (input, answers) => input === answers.password || '密码不匹配' + } + ]) + + const spinner = ora('正在创建管理员账户...').start() + + try { + // 1. 先更新 init.json(唯一真实数据源) + const initData = { + initializedAt: new Date().toISOString(), + adminUsername: adminData.username, + adminPassword: adminData.password, // 保存明文密码 + version: '1.0.0', + updatedAt: new Date().toISOString() + } + + // 确保 data 目录存在 + const dataDir = path.join(__dirname, '..', 'data') + if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }) + } + + // 写入文件 + fs.writeFileSync(initFilePath, JSON.stringify(initData, null, 2)) + + // 2. 再更新 Redis 缓存 + const passwordHash = await bcrypt.hash(adminData.password, 12) + + const credentials = { + username: adminData.username, + passwordHash, + createdAt: new Date().toISOString(), + lastLogin: null, + updatedAt: new Date().toISOString() + } + + await redis.setSession('admin_credentials', credentials, 0) // 永不过期 + + spinner.succeed('管理员账户创建成功') + console.log(`${styles.success('✅')} 用户名: ${adminData.username}`) + console.log(`${styles.success('✅')} 密码: ${adminData.password}`) + console.log(`${styles.info('ℹ️')} 请妥善保管登录凭据`) + console.log(`${styles.info('ℹ️')} 凭据已保存到: ${initFilePath}`) + console.log(`${styles.warning('⚠️')} 如果服务正在运行,请重启服务以加载新凭据`) + } catch (error) { + spinner.fail('创建管理员账户失败') + console.error(styles.error(error.message)) + } +} + +// API Key 管理功能 +async function listApiKeys() { + const spinner = ora('正在获取 API Keys...').start() + + try { + const apiKeys = await apiKeyService.getAllApiKeys() + spinner.succeed(`找到 ${apiKeys.length} 个 API Keys`) + + if (apiKeys.length === 0) { + console.log(styles.warning('没有找到任何 API Keys')) + return + } + + const tableData = [['名称', 'API Key', '状态', '过期时间', '使用量', 'Token限制']] + + apiKeys.forEach((key) => { + const now = new Date() + const expiresAt = key.expiresAt ? new Date(key.expiresAt) : null + let expiryStatus = '永不过期' + + if (expiresAt) { + if (expiresAt < now) { + expiryStatus = styles.error(`已过期 (${expiresAt.toLocaleDateString()})`) + } else { + const daysLeft = Math.ceil((expiresAt - now) / (1000 * 60 * 60 * 24)) + if (daysLeft <= 7) { + expiryStatus = styles.warning(`${daysLeft}天后过期 (${expiresAt.toLocaleDateString()})`) + } else { + expiryStatus = styles.success(`${expiresAt.toLocaleDateString()}`) + } + } + } + + tableData.push([ + key.name, + key.apiKey ? `${key.apiKey.substring(0, 20)}...` : '-', + key.isActive ? '🟢 活跃' : '🔴 停用', + expiryStatus, + `${(key.usage?.total?.tokens || 0).toLocaleString()}`, + key.tokenLimit ? key.tokenLimit.toLocaleString() : '无限制' + ]) + }) + + console.log(styles.title('\n🔑 API Keys 列表:\n')) + console.log(table(tableData)) + } catch (error) { + spinner.fail('获取 API Keys 失败') + console.error(styles.error(error.message)) + } +} + +async function updateApiKeyExpiry() { + try { + // 获取所有 API Keys + const apiKeys = await apiKeyService.getAllApiKeys() + + if (apiKeys.length === 0) { + console.log(styles.warning('没有找到任何 API Keys')) + return + } + + // 选择要修改的 API Key + const { selectedKey } = await inquirer.prompt([ + { + type: 'list', + name: 'selectedKey', + message: '选择要修改的 API Key:', + choices: apiKeys.map((key) => ({ + name: `${key.name} (${key.apiKey?.substring(0, 20)}...) - ${key.expiresAt ? new Date(key.expiresAt).toLocaleDateString() : '永不过期'}`, + value: key + })) + } + ]) + + console.log(`\n当前 API Key: ${selectedKey.name}`) + console.log( + `当前过期时间: ${selectedKey.expiresAt ? new Date(selectedKey.expiresAt).toLocaleString() : '永不过期'}` + ) + + // 选择新的过期时间 + const { expiryOption } = await inquirer.prompt([ + { + type: 'list', + name: 'expiryOption', + message: '选择新的过期时间:', + choices: [ + { name: '⏰ 1分后(测试用)', value: '1m' }, + { name: '⏰ 1小时后(测试用)', value: '1h' }, + { name: '📅 1天后', value: '1d' }, + { name: '📅 7天后', value: '7d' }, + { name: '📅 30天后', value: '30d' }, + { name: '📅 90天后', value: '90d' }, + { name: '📅 365天后', value: '365d' }, + { name: '♾️ 永不过期', value: 'never' }, + { name: '🎯 自定义日期时间', value: 'custom' } + ] + } + ]) + + let newExpiresAt = null + + if (expiryOption === 'never') { + newExpiresAt = null + } else if (expiryOption === 'custom') { + const { customDate, customTime } = await inquirer.prompt([ + { + type: 'input', + name: 'customDate', + message: '输入日期 (YYYY-MM-DD):', + default: new Date().toISOString().split('T')[0], + validate: (input) => { + const date = new Date(input) + return !isNaN(date.getTime()) || '请输入有效的日期格式' + } + }, + { + type: 'input', + name: 'customTime', + message: '输入时间 (HH:MM):', + default: '00:00', + validate: (input) => /^\d{2}:\d{2}$/.test(input) || '请输入有效的时间格式 (HH:MM)' + } + ]) + + newExpiresAt = new Date(`${customDate}T${customTime}:00`).toISOString() + } else { + // 计算新的过期时间 + const now = new Date() + const durations = { + '1m': 60 * 1000, + '1h': 60 * 60 * 1000, + '1d': 24 * 60 * 60 * 1000, + '7d': 7 * 24 * 60 * 60 * 1000, + '30d': 30 * 24 * 60 * 60 * 1000, + '90d': 90 * 24 * 60 * 60 * 1000, + '365d': 365 * 24 * 60 * 60 * 1000 + } + + newExpiresAt = new Date(now.getTime() + durations[expiryOption]).toISOString() + } + + // 确认修改 + const confirmMsg = newExpiresAt + ? `确认将过期时间修改为: ${new Date(newExpiresAt).toLocaleString()}?` + : '确认设置为永不过期?' + + const { confirmed } = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirmed', + message: confirmMsg, + default: true + } + ]) + + if (!confirmed) { + console.log(styles.info('已取消修改')) + return + } + + // 执行修改 + const spinner = ora('正在修改过期时间...').start() + + try { + await apiKeyService.updateApiKey(selectedKey.id, { expiresAt: newExpiresAt }) + spinner.succeed('过期时间修改成功') + + console.log(styles.success(`\n✅ API Key "${selectedKey.name}" 的过期时间已更新`)) + console.log( + `新的过期时间: ${newExpiresAt ? new Date(newExpiresAt).toLocaleString() : '永不过期'}` + ) + } catch (error) { + spinner.fail('修改失败') + console.error(styles.error(error.message)) + } + } catch (error) { + console.error(styles.error('操作失败:', error.message)) + } +} + +async function renewApiKeys() { + const spinner = ora('正在查找即将过期的 API Keys...').start() + + try { + const apiKeys = await apiKeyService.getAllApiKeys() + const now = new Date() + const sevenDaysLater = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000) + + // 筛选即将过期的 Keys(7天内) + const expiringKeys = apiKeys.filter((key) => { + if (!key.expiresAt) { + return false + } + const expiresAt = new Date(key.expiresAt) + return expiresAt > now && expiresAt <= sevenDaysLater + }) + + spinner.stop() + + if (expiringKeys.length === 0) { + console.log(styles.info('没有即将过期的 API Keys(7天内)')) + return + } + + console.log(styles.warning(`\n找到 ${expiringKeys.length} 个即将过期的 API Keys:\n`)) + + expiringKeys.forEach((key, index) => { + const daysLeft = Math.ceil((new Date(key.expiresAt) - now) / (1000 * 60 * 60 * 24)) + console.log( + `${index + 1}. ${key.name} - ${daysLeft}天后过期 (${new Date(key.expiresAt).toLocaleDateString()})` + ) + }) + + const { renewOption } = await inquirer.prompt([ + { + type: 'list', + name: 'renewOption', + message: '选择续期方式:', + choices: [ + { name: '📅 全部续期30天', value: 'all30' }, + { name: '📅 全部续期90天', value: 'all90' }, + { name: '🎯 逐个选择续期', value: 'individual' } + ] + } + ]) + + if (renewOption.startsWith('all')) { + const days = renewOption === 'all30' ? 30 : 90 + const renewSpinner = ora(`正在为所有 API Keys 续期 ${days} 天...`).start() + + for (const key of expiringKeys) { + try { + const newExpiresAt = new Date( + new Date(key.expiresAt).getTime() + days * 24 * 60 * 60 * 1000 + ).toISOString() + await apiKeyService.updateApiKey(key.id, { expiresAt: newExpiresAt }) + } catch (error) { + renewSpinner.fail(`续期 ${key.name} 失败: ${error.message}`) + } + } + + renewSpinner.succeed(`成功续期 ${expiringKeys.length} 个 API Keys`) + } else { + // 逐个选择续期 + for (const key of expiringKeys) { + console.log(`\n处理: ${key.name}`) + + const { action } = await inquirer.prompt([ + { + type: 'list', + name: 'action', + message: '选择操作:', + choices: [ + { name: '续期30天', value: '30' }, + { name: '续期90天', value: '90' }, + { name: '跳过', value: 'skip' } + ] + } + ]) + + if (action !== 'skip') { + const days = parseInt(action) + const newExpiresAt = new Date( + new Date(key.expiresAt).getTime() + days * 24 * 60 * 60 * 1000 + ).toISOString() + + try { + await apiKeyService.updateApiKey(key.id, { expiresAt: newExpiresAt }) + console.log(styles.success(`✅ 已续期 ${days} 天`)) + } catch (error) { + console.log(styles.error(`❌ 续期失败: ${error.message}`)) + } + } + } + } + } catch (error) { + spinner.fail('操作失败') + console.error(styles.error(error.message)) + } +} + +async function deleteApiKey() { + try { + const apiKeys = await apiKeyService.getAllApiKeys() + + if (apiKeys.length === 0) { + console.log(styles.warning('没有找到任何 API Keys')) + return + } + + const { selectedKeys } = await inquirer.prompt([ + { + type: 'checkbox', + name: 'selectedKeys', + message: '选择要删除的 API Keys (空格选择,回车确认):', + choices: apiKeys.map((key) => ({ + name: `${key.name} (${key.apiKey?.substring(0, 20)}...)`, + value: key.id + })) + } + ]) + + if (selectedKeys.length === 0) { + console.log(styles.info('未选择任何 API Key')) + return + } + + const { confirmed } = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirmed', + message: styles.warning(`确认删除 ${selectedKeys.length} 个 API Keys?`), + default: false + } + ]) + + if (!confirmed) { + console.log(styles.info('已取消删除')) + return + } + + const spinner = ora('正在删除 API Keys...').start() + let successCount = 0 + + for (const keyId of selectedKeys) { + try { + await apiKeyService.deleteApiKey(keyId) + successCount++ + } catch (error) { + spinner.fail(`删除失败: ${error.message}`) + } + } + + spinner.succeed(`成功删除 ${successCount}/${selectedKeys.length} 个 API Keys`) + } catch (error) { + console.error(styles.error('删除失败:', error.message)) + } +} + +// async function listClaudeAccounts() { +// const spinner = ora('正在获取 Claude 账户...').start(); + +// try { +// const accounts = await claudeAccountService.getAllAccounts(); +// spinner.succeed(`找到 ${accounts.length} 个 Claude 账户`); + +// if (accounts.length === 0) { +// console.log(styles.warning('没有找到任何 Claude 账户')); +// return; +// } + +// const tableData = [ +// ['ID', '名称', '邮箱', '状态', '代理', '最后使用'] +// ]; + +// accounts.forEach(account => { +// tableData.push([ +// account.id.substring(0, 8) + '...', +// account.name, +// account.email || '-', +// account.isActive ? (account.status === 'active' ? '🟢 活跃' : '🟡 待激活') : '🔴 停用', +// account.proxy ? '🌐 是' : '-', +// account.lastUsedAt ? new Date(account.lastUsedAt).toLocaleDateString() : '-' +// ]); +// }); + +// console.log('\n🏢 Claude 账户列表:\n'); +// console.log(table(tableData)); + +// } catch (error) { +// spinner.fail('获取 Claude 账户失败'); +// console.error(styles.error(error.message)); +// } +// } + +// ☁️ Bedrock 账户管理函数 + +async function listBedrockAccounts() { + const spinner = ora('正在获取 Bedrock 账户...').start() + + try { + const result = await bedrockAccountService.getAllAccounts() + if (!result.success) { + throw new Error(result.error) + } + + const accounts = result.data + spinner.succeed(`找到 ${accounts.length} 个 Bedrock 账户`) + + if (accounts.length === 0) { + console.log(styles.warning('没有找到任何 Bedrock 账户')) + return + } + + const tableData = [['ID', '名称', '区域', '模型', '状态', '凭证类型', '创建时间']] + + accounts.forEach((account) => { + tableData.push([ + `${account.id.substring(0, 8)}...`, + account.name, + account.region, + account.defaultModel?.split('.').pop() || 'default', + account.isActive ? (account.schedulable ? '🟢 活跃' : '🟡 不可调度') : '🔴 停用', + account.credentialType, + account.createdAt ? new Date(account.createdAt).toLocaleDateString() : '-' + ]) + }) + + console.log('\n☁️ Bedrock 账户列表:\n') + console.log(table(tableData)) + } catch (error) { + spinner.fail('获取 Bedrock 账户失败') + console.error(styles.error(error.message)) + } +} + +async function createBedrockAccount() { + console.log(styles.title('\n➕ 创建 Bedrock 账户\n')) + + const questions = [ + { + type: 'input', + name: 'name', + message: '账户名称:', + validate: (input) => input.trim() !== '' + }, + { + type: 'input', + name: 'description', + message: '描述 (可选):' + }, + { + type: 'list', + name: 'region', + message: '选择 AWS 区域:', + choices: [ + { name: 'us-east-1 (北弗吉尼亚)', value: 'us-east-1' }, + { name: 'us-west-2 (俄勒冈)', value: 'us-west-2' }, + { name: 'eu-west-1 (爱尔兰)', value: 'eu-west-1' }, + { name: 'ap-southeast-1 (新加坡)', value: 'ap-southeast-1' } + ] + }, + { + type: 'list', + name: 'credentialType', + message: '凭证类型:', + choices: [ + { name: '默认凭证链 (环境变量/AWS配置)', value: 'default' }, + { name: '访问密钥 (Access Key)', value: 'access_key' }, + { name: 'Bearer Token (API Key)', value: 'bearer_token' } + ] + } + ] + + // 根据凭证类型添加额外问题 + const answers = await inquirer.prompt(questions) + + if (answers.credentialType === 'access_key') { + const credQuestions = await inquirer.prompt([ + { + type: 'input', + name: 'accessKeyId', + message: 'AWS Access Key ID:', + validate: (input) => input.trim() !== '' + }, + { + type: 'password', + name: 'secretAccessKey', + message: 'AWS Secret Access Key:', + validate: (input) => input.trim() !== '' + }, + { + type: 'input', + name: 'sessionToken', + message: 'Session Token (可选,用于临时凭证):' + } + ]) + + answers.awsCredentials = { + accessKeyId: credQuestions.accessKeyId, + secretAccessKey: credQuestions.secretAccessKey + } + + if (credQuestions.sessionToken) { + answers.awsCredentials.sessionToken = credQuestions.sessionToken + } + } + + const spinner = ora('正在创建 Bedrock 账户...').start() + + try { + const result = await bedrockAccountService.createAccount(answers) + + if (!result.success) { + throw new Error(result.error) + } + + spinner.succeed('Bedrock 账户创建成功') + console.log(styles.success(`账户 ID: ${result.data.id}`)) + console.log(styles.info(`名称: ${result.data.name}`)) + console.log(styles.info(`区域: ${result.data.region}`)) + } catch (error) { + spinner.fail('创建 Bedrock 账户失败') + console.error(styles.error(error.message)) + } +} + +async function testBedrockAccount() { + const spinner = ora('正在获取 Bedrock 账户...').start() + + try { + const result = await bedrockAccountService.getAllAccounts() + if (!result.success || result.data.length === 0) { + spinner.fail('没有可测试的 Bedrock 账户') + return + } + + spinner.succeed('账户列表获取成功') + + const choices = result.data.map((account) => ({ + name: `${account.name} (${account.region})`, + value: account.id + })) + + const { accountId } = await inquirer.prompt([ + { + type: 'list', + name: 'accountId', + message: '选择要测试的账户:', + choices + } + ]) + + const testSpinner = ora('正在测试账户连接...').start() + + const testResult = await bedrockAccountService.testAccount(accountId) + + if (testResult.success) { + testSpinner.succeed('账户连接测试成功') + console.log(styles.success(`状态: ${testResult.data.status}`)) + console.log(styles.info(`区域: ${testResult.data.region}`)) + console.log(styles.info(`可用模型数量: ${testResult.data.modelsCount || 'N/A'}`)) + } else { + testSpinner.fail('账户连接测试失败') + console.error(styles.error(testResult.error)) + } + } catch (error) { + spinner.fail('测试过程中发生错误') + console.error(styles.error(error.message)) + } +} + +async function toggleBedrockAccount() { + const spinner = ora('正在获取 Bedrock 账户...').start() + + try { + const result = await bedrockAccountService.getAllAccounts() + if (!result.success || result.data.length === 0) { + spinner.fail('没有可操作的 Bedrock 账户') + return + } + + spinner.succeed('账户列表获取成功') + + const choices = result.data.map((account) => ({ + name: `${account.name} (${account.isActive ? '🟢 活跃' : '🔴 停用'})`, + value: account.id + })) + + const { accountId } = await inquirer.prompt([ + { + type: 'list', + name: 'accountId', + message: '选择要切换状态的账户:', + choices + } + ]) + + const toggleSpinner = ora('正在切换账户状态...').start() + + // 获取当前状态 + const accountResult = await bedrockAccountService.getAccount(accountId) + if (!accountResult.success) { + throw new Error('无法获取账户信息') + } + + const newStatus = !accountResult.data.isActive + const updateResult = await bedrockAccountService.updateAccount(accountId, { + isActive: newStatus + }) + + if (updateResult.success) { + toggleSpinner.succeed('账户状态切换成功') + console.log(styles.success(`新状态: ${newStatus ? '🟢 活跃' : '🔴 停用'}`)) + } else { + throw new Error(updateResult.error) + } + } catch (error) { + spinner.fail('切换账户状态失败') + console.error(styles.error(error.message)) + } +} + +async function editBedrockAccount() { + const spinner = ora('正在获取 Bedrock 账户...').start() + + try { + const result = await bedrockAccountService.getAllAccounts() + if (!result.success || result.data.length === 0) { + spinner.fail('没有可编辑的 Bedrock 账户') + return + } + + spinner.succeed('账户列表获取成功') + + const choices = result.data.map((account) => ({ + name: `${account.name} (${account.region})`, + value: account.id + })) + + const { accountId } = await inquirer.prompt([ + { + type: 'list', + name: 'accountId', + message: '选择要编辑的账户:', + choices + } + ]) + + const accountResult = await bedrockAccountService.getAccount(accountId) + if (!accountResult.success) { + throw new Error('无法获取账户信息') + } + + const account = accountResult.data + + const updates = await inquirer.prompt([ + { + type: 'input', + name: 'name', + message: '账户名称:', + default: account.name + }, + { + type: 'input', + name: 'description', + message: '描述:', + default: account.description + }, + { + type: 'number', + name: 'priority', + message: '优先级 (1-100):', + default: account.priority, + validate: (input) => input >= 1 && input <= 100 + } + ]) + + const updateSpinner = ora('正在更新账户...').start() + + const updateResult = await bedrockAccountService.updateAccount(accountId, updates) + + if (updateResult.success) { + updateSpinner.succeed('账户更新成功') + } else { + throw new Error(updateResult.error) + } + } catch (error) { + spinner.fail('编辑账户失败') + console.error(styles.error(error.message)) + } +} + +async function deleteBedrockAccount() { + const spinner = ora('正在获取 Bedrock 账户...').start() + + try { + const result = await bedrockAccountService.getAllAccounts() + if (!result.success || result.data.length === 0) { + spinner.fail('没有可删除的 Bedrock 账户') + return + } + + spinner.succeed('账户列表获取成功') + + const choices = result.data.map((account) => ({ + name: `${account.name} (${account.region})`, + value: { id: account.id, name: account.name } + })) + + const { account } = await inquirer.prompt([ + { + type: 'list', + name: 'account', + message: '选择要删除的账户:', + choices + } + ]) + + const { confirm } = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirm', + message: `确定要删除账户 "${account.name}" 吗?此操作无法撤销!`, + default: false + } + ]) + + if (!confirm) { + console.log(styles.info('已取消删除')) + return + } + + const deleteSpinner = ora('正在删除账户...').start() + + const deleteResult = await bedrockAccountService.deleteAccount(account.id) + + if (deleteResult.success) { + deleteSpinner.succeed('账户删除成功') + } else { + throw new Error(deleteResult.error) + } + } catch (error) { + spinner.fail('删除账户失败') + console.error(styles.error(error.message)) + } +} + +// 程序信息 +program.name('claude-relay-cli').description('Claude Relay Service 命令行管理工具').version('1.0.0') + +// 解析命令行参数 +program.parse() + +// 如果没有提供命令,显示帮助 +if (!process.argv.slice(2).length) { + console.log(styles.title('🚀 Claude Relay Service CLI\n')) + console.log('使用以下命令管理服务:\n') + console.log(' claude-relay-cli admin - 创建初始管理员账户') + console.log(' claude-relay-cli keys - API Key 管理(查看/修改过期时间/续期/删除)') + console.log(' claude-relay-cli bedrock - Bedrock 账户管理(创建/查看/编辑/测试/删除)') + console.log(' claude-relay-cli status - 查看系统状态') + console.log('\n使用 --help 查看详细帮助信息') +} diff --git a/config/config.example.js b/config/config.example.js new file mode 100644 index 0000000000000000000000000000000000000000..adb17ec613082de573aac282400e3cbdb4eedb55 --- /dev/null +++ b/config/config.example.js @@ -0,0 +1,185 @@ +const path = require('path') +require('dotenv').config() + +const config = { + // 🌐 服务器配置 + server: { + port: parseInt(process.env.PORT) || 3000, + host: process.env.HOST || '0.0.0.0', + nodeEnv: process.env.NODE_ENV || 'development', + trustProxy: process.env.TRUST_PROXY === 'true' + }, + + // 🔐 安全配置 + security: { + jwtSecret: process.env.JWT_SECRET || 'CHANGE-THIS-JWT-SECRET-IN-PRODUCTION', + adminSessionTimeout: parseInt(process.env.ADMIN_SESSION_TIMEOUT) || 86400000, // 24小时 + apiKeyPrefix: process.env.API_KEY_PREFIX || 'cr_', + encryptionKey: process.env.ENCRYPTION_KEY || 'CHANGE-THIS-32-CHARACTER-KEY-NOW' + }, + + // 📊 Redis配置 + redis: { + host: process.env.REDIS_HOST || '127.0.0.1', + port: parseInt(process.env.REDIS_PORT) || 6379, + password: process.env.REDIS_PASSWORD || '', + db: parseInt(process.env.REDIS_DB) || 0, + connectTimeout: 10000, + commandTimeout: 5000, + retryDelayOnFailover: 100, + maxRetriesPerRequest: 3, + lazyConnect: true, + enableTLS: process.env.REDIS_ENABLE_TLS === 'true' + }, + + // 🔗 会话管理配置 + session: { + // 粘性会话TTL配置(小时),默认1小时 + stickyTtlHours: parseFloat(process.env.STICKY_SESSION_TTL_HOURS) || 1, + // 续期阈值(分钟),默认0分钟(不续期) + renewalThresholdMinutes: parseInt(process.env.STICKY_SESSION_RENEWAL_THRESHOLD_MINUTES) || 0 + }, + + // 🎯 Claude API配置 + claude: { + apiUrl: process.env.CLAUDE_API_URL || 'https://api.anthropic.com/v1/messages', + apiVersion: process.env.CLAUDE_API_VERSION || '2023-06-01', + betaHeader: + process.env.CLAUDE_BETA_HEADER || + 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14', + overloadHandling: { + enabled: (() => { + const minutes = parseInt(process.env.CLAUDE_OVERLOAD_HANDLING_MINUTES) || 0 + // 验证配置值:限制在0-1440分钟(24小时)内 + return Math.max(0, Math.min(minutes, 1440)) + })() + } + }, + + // ☁️ Bedrock API配置 + bedrock: { + enabled: process.env.CLAUDE_CODE_USE_BEDROCK === '1', + defaultRegion: process.env.AWS_REGION || 'us-east-1', + smallFastModelRegion: process.env.ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION, + defaultModel: process.env.ANTHROPIC_MODEL || 'us.anthropic.claude-sonnet-4-20250514-v1:0', + smallFastModel: + process.env.ANTHROPIC_SMALL_FAST_MODEL || 'us.anthropic.claude-3-5-haiku-20241022-v1:0', + maxOutputTokens: parseInt(process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS) || 4096, + maxThinkingTokens: parseInt(process.env.MAX_THINKING_TOKENS) || 1024, + enablePromptCaching: process.env.DISABLE_PROMPT_CACHING !== '1' + }, + + // 🌐 代理配置 + proxy: { + timeout: parseInt(process.env.DEFAULT_PROXY_TIMEOUT) || 600000, // 10分钟 + maxRetries: parseInt(process.env.MAX_PROXY_RETRIES) || 3, + // IP协议族配置:true=IPv4, false=IPv6, 默认IPv4(兼容性更好) + useIPv4: process.env.PROXY_USE_IPV4 !== 'false' // 默认 true,只有明确设置为 'false' 才使用 IPv6 + }, + + // ⏱️ 请求超时配置 + requestTimeout: parseInt(process.env.REQUEST_TIMEOUT) || 600000, // 默认 10 分钟 + + // 📈 使用限制 + limits: { + defaultTokenLimit: parseInt(process.env.DEFAULT_TOKEN_LIMIT) || 1000000 + }, + + // 📝 日志配置 + logging: { + level: process.env.LOG_LEVEL || 'info', + dirname: path.join(__dirname, '..', 'logs'), + maxSize: process.env.LOG_MAX_SIZE || '10m', + maxFiles: parseInt(process.env.LOG_MAX_FILES) || 5 + }, + + // 🔧 系统配置 + system: { + cleanupInterval: parseInt(process.env.CLEANUP_INTERVAL) || 3600000, // 1小时 + tokenUsageRetention: parseInt(process.env.TOKEN_USAGE_RETENTION) || 2592000000, // 30天 + healthCheckInterval: parseInt(process.env.HEALTH_CHECK_INTERVAL) || 60000, // 1分钟 + timezone: process.env.SYSTEM_TIMEZONE || 'Asia/Shanghai', // 默认UTC+8(中国时区) + timezoneOffset: parseInt(process.env.TIMEZONE_OFFSET) || 8 // UTC偏移小时数,默认+8 + }, + + // 🎨 Web界面配置 + web: { + title: process.env.WEB_TITLE || 'Claude Relay Service', + description: + process.env.WEB_DESCRIPTION || + 'Multi-account Claude API relay service with beautiful management interface', + logoUrl: process.env.WEB_LOGO_URL || '/assets/logo.png', + enableCors: process.env.ENABLE_CORS === 'true', + sessionSecret: process.env.WEB_SESSION_SECRET || 'CHANGE-THIS-SESSION-SECRET' + }, + + // 🔐 LDAP 认证配置 + ldap: { + enabled: process.env.LDAP_ENABLED === 'true', + server: { + url: process.env.LDAP_URL || 'ldap://localhost:389', + bindDN: process.env.LDAP_BIND_DN || 'cn=admin,dc=example,dc=com', + bindCredentials: process.env.LDAP_BIND_PASSWORD || 'admin', + searchBase: process.env.LDAP_SEARCH_BASE || 'dc=example,dc=com', + searchFilter: process.env.LDAP_SEARCH_FILTER || '(uid={{username}})', + searchAttributes: process.env.LDAP_SEARCH_ATTRIBUTES + ? process.env.LDAP_SEARCH_ATTRIBUTES.split(',') + : ['dn', 'uid', 'cn', 'mail', 'givenName', 'sn'], + timeout: parseInt(process.env.LDAP_TIMEOUT) || 5000, + connectTimeout: parseInt(process.env.LDAP_CONNECT_TIMEOUT) || 10000, + // TLS/SSL 配置 + tls: { + // 是否忽略证书错误 (用于自签名证书) + rejectUnauthorized: process.env.LDAP_TLS_REJECT_UNAUTHORIZED !== 'false', // 默认验证证书,设置为false则忽略 + // CA证书文件路径 (可选,用于自定义CA证书) + ca: process.env.LDAP_TLS_CA_FILE + ? require('fs').readFileSync(process.env.LDAP_TLS_CA_FILE) + : undefined, + // 客户端证书文件路径 (可选,用于双向认证) + cert: process.env.LDAP_TLS_CERT_FILE + ? require('fs').readFileSync(process.env.LDAP_TLS_CERT_FILE) + : undefined, + // 客户端私钥文件路径 (可选,用于双向认证) + key: process.env.LDAP_TLS_KEY_FILE + ? require('fs').readFileSync(process.env.LDAP_TLS_KEY_FILE) + : undefined, + // 服务器名称 (用于SNI,可选) + servername: process.env.LDAP_TLS_SERVERNAME || undefined + } + }, + userMapping: { + username: process.env.LDAP_USER_ATTR_USERNAME || 'uid', + displayName: process.env.LDAP_USER_ATTR_DISPLAY_NAME || 'cn', + email: process.env.LDAP_USER_ATTR_EMAIL || 'mail', + firstName: process.env.LDAP_USER_ATTR_FIRST_NAME || 'givenName', + lastName: process.env.LDAP_USER_ATTR_LAST_NAME || 'sn' + } + }, + + // 👥 用户管理配置 + userManagement: { + enabled: process.env.USER_MANAGEMENT_ENABLED === 'true', + defaultUserRole: process.env.DEFAULT_USER_ROLE || 'user', + userSessionTimeout: parseInt(process.env.USER_SESSION_TIMEOUT) || 86400000, // 24小时 + maxApiKeysPerUser: parseInt(process.env.MAX_API_KEYS_PER_USER) || 1, + allowUserDeleteApiKeys: process.env.ALLOW_USER_DELETE_API_KEYS === 'true' // 默认不允许用户删除自己的API Keys + }, + + // 📢 Webhook通知配置 + webhook: { + enabled: process.env.WEBHOOK_ENABLED !== 'false', // 默认启用 + urls: process.env.WEBHOOK_URLS + ? process.env.WEBHOOK_URLS.split(',').map((url) => url.trim()) + : [], + timeout: parseInt(process.env.WEBHOOK_TIMEOUT) || 10000, // 10秒超时 + retries: parseInt(process.env.WEBHOOK_RETRIES) || 3 // 重试3次 + }, + + // 🛠️ 开发配置 + development: { + debug: process.env.DEBUG === 'true', + hotReload: process.env.HOT_RELOAD === 'true' + } +} + +module.exports = config diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..608284e174a52f9001f03671e7fdffcfa17f76b4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,165 @@ +version: '3.8' + +# Claude Relay Service Docker Compose 配置 +# 所有配置通过环境变量设置,无需映射 .env 文件 + +services: + # 🚀 Claude Relay Service + claude-relay: + build: . + image: weishaw/claude-relay-service:latest + restart: unless-stopped + ports: + # 绑定地址:生产环境建议使用反向代理,设置 BIND_HOST=127.0.0.1 + - "${BIND_HOST:-0.0.0.0}:${PORT:-3000}:3000" + volumes: + - ./logs:/app/logs + - ./data:/app/data + environment: + # 🌐 服务器配置 + - NODE_ENV=production + - PORT=3000 + - HOST=0.0.0.0 + + # 🔐 安全配置(必填) + - JWT_SECRET=${JWT_SECRET} # 必填:至少32字符的随机字符串 + - ENCRYPTION_KEY=${ENCRYPTION_KEY} # 必填:32字符的加密密钥 + - ADMIN_SESSION_TIMEOUT=${ADMIN_SESSION_TIMEOUT:-86400000} + - API_KEY_PREFIX=${API_KEY_PREFIX:-cr_} + + # 👤 管理员凭据(可选) + - ADMIN_USERNAME=${ADMIN_USERNAME:-} + - ADMIN_PASSWORD=${ADMIN_PASSWORD:-} + + # 📊 Redis 配置 + - REDIS_HOST=redis + - REDIS_PORT=6379 + - REDIS_PASSWORD=${REDIS_PASSWORD:-} + - REDIS_DB=${REDIS_DB:-0} + - REDIS_ENABLE_TLS=${REDIS_ENABLE_TLS:-} + + # 🎯 Claude API 配置 + - CLAUDE_API_URL=${CLAUDE_API_URL:-https://api.anthropic.com/v1/messages} + - CLAUDE_API_VERSION=${CLAUDE_API_VERSION:-2023-06-01} + - CLAUDE_BETA_HEADER=${CLAUDE_BETA_HEADER:-claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14} + + # 🌐 代理配置 + - DEFAULT_PROXY_TIMEOUT=${DEFAULT_PROXY_TIMEOUT:-60000} + - MAX_PROXY_RETRIES=${MAX_PROXY_RETRIES:-3} + + # 📈 使用限制 + - DEFAULT_TOKEN_LIMIT=${DEFAULT_TOKEN_LIMIT:-1000000} + + # 📝 日志配置 + - LOG_LEVEL=${LOG_LEVEL:-info} + - LOG_MAX_SIZE=${LOG_MAX_SIZE:-10m} + - LOG_MAX_FILES=${LOG_MAX_FILES:-5} + + # 🔧 系统配置 + - CLEANUP_INTERVAL=${CLEANUP_INTERVAL:-3600000} + - TOKEN_USAGE_RETENTION=${TOKEN_USAGE_RETENTION:-2592000000} + - HEALTH_CHECK_INTERVAL=${HEALTH_CHECK_INTERVAL:-60000} + - TIMEZONE_OFFSET=${TIMEZONE_OFFSET:-8} + + # 🎨 Web 界面配置 + - WEB_TITLE=${WEB_TITLE:-Claude Relay Service} + - WEB_DESCRIPTION=${WEB_DESCRIPTION:-Multi-account Claude API relay service} + - WEB_LOGO_URL=${WEB_LOGO_URL:-/assets/logo.png} + + # 🛠️ 开发配置 + - DEBUG=${DEBUG:-false} + - ENABLE_CORS=${ENABLE_CORS:-true} + - TRUST_PROXY=${TRUST_PROXY:-true} + depends_on: + - redis + networks: + - claude-relay-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/health"] + interval: 30s + timeout: 10s + retries: 3 + + # 📊 Redis Database + redis: + image: redis:7-alpine + restart: unless-stopped + # 仅在容器网络内部暴露端口,不映射到主机 + expose: + - "6379" + # 注意:如需本地调试访问,可取消下行注释(生产环境禁用) + # ports: + # - "127.0.0.1:${REDIS_PORT:-6379}:6379" + volumes: + - ./redis_data:/data + command: redis-server --save 60 1 --appendonly yes --appendfsync everysec + networks: + - claude-relay-network + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 30s + timeout: 10s + retries: 3 + + # 📈 Redis Monitoring (Optional) + redis-commander: + image: rediscommander/redis-commander:latest + restart: unless-stopped + ports: + - "127.0.0.1:${REDIS_WEB_PORT:-8081}:8081" + environment: + - REDIS_HOSTS=local:redis:6379 + depends_on: + - redis + networks: + - claude-relay-network + profiles: + - monitoring + + # 📊 Application Monitoring (Optional) + prometheus: + image: prom/prometheus:latest + restart: unless-stopped + ports: + - "127.0.0.1:${PROMETHEUS_PORT:-9090}:9090" + volumes: + - ./config/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus_data:/prometheus + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--web.console.libraries=/etc/prometheus/console_libraries' + - '--web.console.templates=/etc/prometheus/consoles' + - '--web.enable-lifecycle' + networks: + - claude-relay-network + profiles: + - monitoring + + # 📈 Grafana Dashboard (Optional) + grafana: + image: grafana/grafana:latest + restart: unless-stopped + ports: + - "127.0.0.1:${GRAFANA_PORT:-3001}:3000" + environment: + - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:-admin123} + volumes: + - grafana_data:/var/lib/grafana + - ./config/grafana:/etc/grafana/provisioning + depends_on: + - prometheus + networks: + - claude-relay-network + profiles: + - monitoring + +volumes: + prometheus_data: + driver: local + grafana_data: + driver: local + +networks: + claude-relay-network: + driver: bridge \ No newline at end of file diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000000000000000000000000000000000000..b4e37c50d8081ae7492d47b1d1d6247a0c421409 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,65 @@ +#!/bin/sh +set -e + +echo "🚀 Claude Relay Service 启动中..." + +# 检查关键环境变量 +if [ -z "$JWT_SECRET" ]; then + echo "❌ 错误: JWT_SECRET 环境变量未设置" + echo " 请在 docker-compose.yml 中设置 JWT_SECRET" + echo " 例如: JWT_SECRET=your-random-secret-key-at-least-32-chars" + exit 1 +fi + +if [ -z "$ENCRYPTION_KEY" ]; then + echo "❌ 错误: ENCRYPTION_KEY 环境变量未设置" + echo " 请在 docker-compose.yml 中设置 ENCRYPTION_KEY" + echo " 例如: ENCRYPTION_KEY=your-32-character-encryption-key" + exit 1 +fi + +# 检查并复制配置文件 +if [ ! -f "/app/config/config.js" ]; then + echo "📋 检测到 config.js 不存在,从模板创建..." + if [ -f "/app/config/config.example.js" ]; then + cp /app/config/config.example.js /app/config/config.js + echo "✅ config.js 已创建" + else + echo "❌ 错误: config.example.js 不存在" + exit 1 + fi +fi + +# 显示配置信息 +echo "✅ 环境配置已就绪" +echo " JWT_SECRET: [已设置]" +echo " ENCRYPTION_KEY: [已设置]" +echo " REDIS_HOST: ${REDIS_HOST:-localhost}" +echo " PORT: ${PORT:-3000}" + +# 检查是否需要初始化 +if [ ! -f "/app/data/init.json" ]; then + echo "📋 首次启动,执行初始化设置..." + + # 如果设置了环境变量,显示提示 + if [ -n "$ADMIN_USERNAME" ] || [ -n "$ADMIN_PASSWORD" ]; then + echo "📌 检测到预设的管理员凭据" + fi + + # 执行初始化脚本 + node /app/scripts/setup.js + + echo "✅ 初始化完成" +else + echo "✅ 检测到已有配置,跳过初始化" + + # 如果 init.json 存在但环境变量也设置了,显示警告 + if [ -n "$ADMIN_USERNAME" ] || [ -n "$ADMIN_PASSWORD" ]; then + echo "⚠️ 警告: 检测到环境变量 ADMIN_USERNAME/ADMIN_PASSWORD,但系统已初始化" + echo " 如需使用新凭据,请删除 data/init.json 文件后重启容器" + fi +fi + +# 启动应用 +echo "🌐 启动 Claude Relay Service..." +exec "$@" \ No newline at end of file diff --git a/nodemon.json b/nodemon.json new file mode 100644 index 0000000000000000000000000000000000000000..c4b3c5fb67d4367181ef42222c7ff32cf4b82e93 --- /dev/null +++ b/nodemon.json @@ -0,0 +1,5 @@ +{ + "watch": ["src"], + "ext": "js,json", + "exec": "npm run lint && node src/app.js" +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..039664e6e608f8f12018d30d9514d69389831d7a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,9170 @@ +{ + "name": "claude-relay-service", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "claude-relay-service", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@aws-sdk/client-bedrock-runtime": "^3.861.0", + "@aws-sdk/credential-providers": "^3.859.0", + "axios": "^1.6.0", + "bcryptjs": "^2.4.3", + "chalk": "^4.1.2", + "commander": "^11.1.0", + "compression": "^1.7.4", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "google-auth-library": "^10.1.0", + "helmet": "^7.1.0", + "https-proxy-agent": "^7.0.2", + "inquirer": "^8.2.6", + "ioredis": "^5.3.2", + "ldapjs": "^3.0.7", + "morgan": "^1.10.0", + "nodemailer": "^7.0.6", + "ora": "^5.4.1", + "rate-limiter-flexible": "^5.0.5", + "socks-proxy-agent": "^8.0.2", + "string-similarity": "^4.0.4", + "table": "^6.8.1", + "uuid": "^9.0.1", + "winston": "^3.11.0", + "winston-daily-rotate-file": "^4.7.1" + }, + "devDependencies": { + "@types/node": "^20.8.9", + "eslint": "^8.53.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.4", + "jest": "^29.7.0", + "nodemon": "^3.0.1", + "prettier": "^3.6.2", + "supertest": "^6.3.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.883.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.883.0.tgz", + "integrity": "sha512-jWFwY+jc1NcyO8hlAAznL3p+8vbCpgon0GlxaagIwyI0x7Dx0IklyEhlF51UloWCdAyZxw1SNxsIQeQpETFpRw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.883.0", + "@aws-sdk/credential-provider-node": "3.883.0", + "@aws-sdk/eventstream-handler-node": "3.873.0", + "@aws-sdk/middleware-eventstream": "3.873.0", + "@aws-sdk/middleware-host-header": "3.873.0", + "@aws-sdk/middleware-logger": "3.876.0", + "@aws-sdk/middleware-recursion-detection": "3.873.0", + "@aws-sdk/middleware-user-agent": "3.883.0", + "@aws-sdk/middleware-websocket": "3.873.0", + "@aws-sdk/region-config-resolver": "3.873.0", + "@aws-sdk/token-providers": "3.883.0", + "@aws-sdk/types": "3.862.0", + "@aws-sdk/util-endpoints": "3.879.0", + "@aws-sdk/util-user-agent-browser": "3.873.0", + "@aws-sdk/util-user-agent-node": "3.883.0", + "@smithy/config-resolver": "^4.1.5", + "@smithy/core": "^3.9.2", + "@smithy/eventstream-serde-browser": "^4.0.5", + "@smithy/eventstream-serde-config-resolver": "^4.1.3", + "@smithy/eventstream-serde-node": "^4.0.5", + "@smithy/fetch-http-handler": "^5.1.1", + "@smithy/hash-node": "^4.0.5", + "@smithy/invalid-dependency": "^4.0.5", + "@smithy/middleware-content-length": "^4.0.5", + "@smithy/middleware-endpoint": "^4.1.21", + "@smithy/middleware-retry": "^4.1.22", + "@smithy/middleware-serde": "^4.0.9", + "@smithy/middleware-stack": "^4.0.5", + "@smithy/node-config-provider": "^4.1.4", + "@smithy/node-http-handler": "^4.1.1", + "@smithy/protocol-http": "^5.1.3", + "@smithy/smithy-client": "^4.5.2", + "@smithy/types": "^4.3.2", + "@smithy/url-parser": "^4.0.5", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.29", + "@smithy/util-defaults-mode-node": "^4.0.29", + "@smithy/util-endpoints": "^3.0.7", + "@smithy/util-middleware": "^4.0.5", + "@smithy/util-retry": "^4.0.7", + "@smithy/util-stream": "^4.2.4", + "@smithy/util-utf8": "^4.0.0", + "@types/uuid": "^9.0.1", + "tslib": "^2.6.2", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-cognito-identity": { + "version": "3.883.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.883.0.tgz", + "integrity": "sha512-/uezRmLtkx7kZkC0o6B+hahCVBTij2ghCW+kXgbK0tz6Gl7WDYRIyszR9Vf0wDUqsj5S3hgBXKr6zR4V4ULTmw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.883.0", + "@aws-sdk/credential-provider-node": "3.883.0", + "@aws-sdk/middleware-host-header": "3.873.0", + "@aws-sdk/middleware-logger": "3.876.0", + "@aws-sdk/middleware-recursion-detection": "3.873.0", + "@aws-sdk/middleware-user-agent": "3.883.0", + "@aws-sdk/region-config-resolver": "3.873.0", + "@aws-sdk/types": "3.862.0", + "@aws-sdk/util-endpoints": "3.879.0", + "@aws-sdk/util-user-agent-browser": "3.873.0", + "@aws-sdk/util-user-agent-node": "3.883.0", + "@smithy/config-resolver": "^4.1.5", + "@smithy/core": "^3.9.2", + "@smithy/fetch-http-handler": "^5.1.1", + "@smithy/hash-node": "^4.0.5", + "@smithy/invalid-dependency": "^4.0.5", + "@smithy/middleware-content-length": "^4.0.5", + "@smithy/middleware-endpoint": "^4.1.21", + "@smithy/middleware-retry": "^4.1.22", + "@smithy/middleware-serde": "^4.0.9", + "@smithy/middleware-stack": "^4.0.5", + "@smithy/node-config-provider": "^4.1.4", + "@smithy/node-http-handler": "^4.1.1", + "@smithy/protocol-http": "^5.1.3", + "@smithy/smithy-client": "^4.5.2", + "@smithy/types": "^4.3.2", + "@smithy/url-parser": "^4.0.5", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.29", + "@smithy/util-defaults-mode-node": "^4.0.29", + "@smithy/util-endpoints": "^3.0.7", + "@smithy/util-middleware": "^4.0.5", + "@smithy/util-retry": "^4.0.7", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sso": { + "version": "3.883.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/client-sso/-/client-sso-3.883.0.tgz", + "integrity": "sha512-Ybjw76yPceEBO7+VLjy5+/Gr0A1UNymSDHda5w8tfsS2iHZt/vuD6wrYpHdLoUx4H5la8ZhwcSfK/+kmE+QLPw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.883.0", + "@aws-sdk/middleware-host-header": "3.873.0", + "@aws-sdk/middleware-logger": "3.876.0", + "@aws-sdk/middleware-recursion-detection": "3.873.0", + "@aws-sdk/middleware-user-agent": "3.883.0", + "@aws-sdk/region-config-resolver": "3.873.0", + "@aws-sdk/types": "3.862.0", + "@aws-sdk/util-endpoints": "3.879.0", + "@aws-sdk/util-user-agent-browser": "3.873.0", + "@aws-sdk/util-user-agent-node": "3.883.0", + "@smithy/config-resolver": "^4.1.5", + "@smithy/core": "^3.9.2", + "@smithy/fetch-http-handler": "^5.1.1", + "@smithy/hash-node": "^4.0.5", + "@smithy/invalid-dependency": "^4.0.5", + "@smithy/middleware-content-length": "^4.0.5", + "@smithy/middleware-endpoint": "^4.1.21", + "@smithy/middleware-retry": "^4.1.22", + "@smithy/middleware-serde": "^4.0.9", + "@smithy/middleware-stack": "^4.0.5", + "@smithy/node-config-provider": "^4.1.4", + "@smithy/node-http-handler": "^4.1.1", + "@smithy/protocol-http": "^5.1.3", + "@smithy/smithy-client": "^4.5.2", + "@smithy/types": "^4.3.2", + "@smithy/url-parser": "^4.0.5", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.29", + "@smithy/util-defaults-mode-node": "^4.0.29", + "@smithy/util-endpoints": "^3.0.7", + "@smithy/util-middleware": "^4.0.5", + "@smithy/util-retry": "^4.0.7", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.883.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/core/-/core-3.883.0.tgz", + "integrity": "sha512-FmkqnqBLkXi4YsBPbF6vzPa0m4XKUuvgKDbamfw4DZX2CzfBZH6UU4IwmjNV3ZM38m0xraHarK8KIbGSadN3wg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.862.0", + "@aws-sdk/xml-builder": "3.873.0", + "@smithy/core": "^3.9.2", + "@smithy/node-config-provider": "^4.1.4", + "@smithy/property-provider": "^4.0.5", + "@smithy/protocol-http": "^5.1.3", + "@smithy/signature-v4": "^5.1.3", + "@smithy/smithy-client": "^4.5.2", + "@smithy/types": "^4.3.2", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-middleware": "^4.0.5", + "@smithy/util-utf8": "^4.0.0", + "fast-xml-parser": "5.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-cognito-identity": { + "version": "3.883.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-cognito-identity/-/credential-provider-cognito-identity-3.883.0.tgz", + "integrity": "sha512-r5KQ1UP1LxtZ5PfBQr08zgn1fIgpDlyDSk59h3kpj91+xcuaQtn3241D61iTv0ICFTaurO5SqM25f87aQuAsDw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-cognito-identity": "3.883.0", + "@aws-sdk/types": "3.862.0", + "@smithy/property-provider": "^4.0.5", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.883.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-env/-/credential-provider-env-3.883.0.tgz", + "integrity": "sha512-Z6tPBXPCodfhIF1rvQKoeRGMkwL6TK0xdl1UoMIA1x4AfBpPICAF77JkFBExk/pdiFYq1d04Qzddd/IiujSlLg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.883.0", + "@aws-sdk/types": "3.862.0", + "@smithy/property-provider": "^4.0.5", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.883.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-http/-/credential-provider-http-3.883.0.tgz", + "integrity": "sha512-P589ug1lMOOEYLTaQJjSP+Gee34za8Kk2LfteNQfO9SpByHFgGj++Sg8VyIe30eZL8Q+i4qTt24WDCz1c+dgYg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.883.0", + "@aws-sdk/types": "3.862.0", + "@smithy/fetch-http-handler": "^5.1.1", + "@smithy/node-http-handler": "^4.1.1", + "@smithy/property-provider": "^4.0.5", + "@smithy/protocol-http": "^5.1.3", + "@smithy/smithy-client": "^4.5.2", + "@smithy/types": "^4.3.2", + "@smithy/util-stream": "^4.2.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.883.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.883.0.tgz", + "integrity": "sha512-n6z9HTzuDEdugXvPiE/95VJXbF4/gBffdV/SRHDJKtDHaRuvp/gggbfmfVSTFouGVnlKPb2pQWQsW3Nr/Y3Lrw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.883.0", + "@aws-sdk/credential-provider-env": "3.883.0", + "@aws-sdk/credential-provider-http": "3.883.0", + "@aws-sdk/credential-provider-process": "3.883.0", + "@aws-sdk/credential-provider-sso": "3.883.0", + "@aws-sdk/credential-provider-web-identity": "3.883.0", + "@aws-sdk/nested-clients": "3.883.0", + "@aws-sdk/types": "3.862.0", + "@smithy/credential-provider-imds": "^4.0.7", + "@smithy/property-provider": "^4.0.5", + "@smithy/shared-ini-file-loader": "^4.0.5", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.883.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-node/-/credential-provider-node-3.883.0.tgz", + "integrity": "sha512-QIUhsatsrwfB9ZsKpmi0EySSfexVP61wgN7hr493DOileh2QsKW4XATEfsWNmx0dj9323Vg1Mix7bXtRfl9cGg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.883.0", + "@aws-sdk/credential-provider-http": "3.883.0", + "@aws-sdk/credential-provider-ini": "3.883.0", + "@aws-sdk/credential-provider-process": "3.883.0", + "@aws-sdk/credential-provider-sso": "3.883.0", + "@aws-sdk/credential-provider-web-identity": "3.883.0", + "@aws-sdk/types": "3.862.0", + "@smithy/credential-provider-imds": "^4.0.7", + "@smithy/property-provider": "^4.0.5", + "@smithy/shared-ini-file-loader": "^4.0.5", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.883.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-process/-/credential-provider-process-3.883.0.tgz", + "integrity": "sha512-m1shbHY/Vppy4EdddG9r8x64TO/9FsCjokp5HbKcZvVoTOTgUJrdT8q2TAQJ89+zYIJDqsKbqfrmfwJ1zOdnGQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.883.0", + "@aws-sdk/types": "3.862.0", + "@smithy/property-provider": "^4.0.5", + "@smithy/shared-ini-file-loader": "^4.0.5", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.883.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.883.0.tgz", + "integrity": "sha512-37ve9Tult08HLXrJFHJM/sGB/vO7wzI6v1RUUfeTiShqx8ZQ5fTzCTNY/duO96jCtCexmFNSycpQzh7lDIf0aA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-sso": "3.883.0", + "@aws-sdk/core": "3.883.0", + "@aws-sdk/token-providers": "3.883.0", + "@aws-sdk/types": "3.862.0", + "@smithy/property-provider": "^4.0.5", + "@smithy/shared-ini-file-loader": "^4.0.5", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.883.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.883.0.tgz", + "integrity": "sha512-SL82K9Jb0vpuTadqTO4Fpdu7SKtebZ3Yo4LZvk/U0UauVMlJj5ZTos0mFx1QSMB9/4TpqifYrSZcdnxgYg8Eqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.883.0", + "@aws-sdk/nested-clients": "3.883.0", + "@aws-sdk/types": "3.862.0", + "@smithy/property-provider": "^4.0.5", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-providers": { + "version": "3.883.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/credential-providers/-/credential-providers-3.883.0.tgz", + "integrity": "sha512-gIoGVbOTAaWm9muDo5QI42EAYW03RyNbtGb+Yhiy72EX15aZhRsW9v9Gs1YxC2d7dTW5Zs3qXMcenoMzas5aQg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-cognito-identity": "3.883.0", + "@aws-sdk/core": "3.883.0", + "@aws-sdk/credential-provider-cognito-identity": "3.883.0", + "@aws-sdk/credential-provider-env": "3.883.0", + "@aws-sdk/credential-provider-http": "3.883.0", + "@aws-sdk/credential-provider-ini": "3.883.0", + "@aws-sdk/credential-provider-node": "3.883.0", + "@aws-sdk/credential-provider-process": "3.883.0", + "@aws-sdk/credential-provider-sso": "3.883.0", + "@aws-sdk/credential-provider-web-identity": "3.883.0", + "@aws-sdk/nested-clients": "3.883.0", + "@aws-sdk/types": "3.862.0", + "@smithy/config-resolver": "^4.1.5", + "@smithy/core": "^3.9.2", + "@smithy/credential-provider-imds": "^4.0.7", + "@smithy/node-config-provider": "^4.1.4", + "@smithy/property-provider": "^4.0.5", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.873.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.873.0.tgz", + "integrity": "sha512-c3j9Q3RSR4+/01oHgx8b4WuD2HinVAalbsL7rJKlw86sP6ef1Gq7rVYFn74Ooh+2fIVecvX3cla/tdkR8PwBtA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.862.0", + "@smithy/eventstream-codec": "^4.0.5", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.873.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.873.0.tgz", + "integrity": "sha512-x/BFHxZcfL6siwAPILmF8bGuWAmxDhrXvTlxJZOwwozWnhgRSxgxX2sitpWGvS8pL64DoABwCWSgsgyoXJlMFw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.862.0", + "@smithy/protocol-http": "^5.1.3", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.873.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/middleware-host-header/-/middleware-host-header-3.873.0.tgz", + "integrity": "sha512-KZ/W1uruWtMOs7D5j3KquOxzCnV79KQW9MjJFZM/M0l6KI8J6V3718MXxFHsTjUE4fpdV6SeCNLV1lwGygsjJA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.862.0", + "@smithy/protocol-http": "^5.1.3", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.876.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/middleware-logger/-/middleware-logger-3.876.0.tgz", + "integrity": "sha512-cpWJhOuMSyz9oV25Z/CMHCBTgafDCbv7fHR80nlRrPdPZ8ETNsahwRgltXP1QJJ8r3X/c1kwpOR7tc+RabVzNA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.862.0", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.873.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.873.0.tgz", + "integrity": "sha512-OtgY8EXOzRdEWR//WfPkA/fXl0+WwE8hq0y9iw2caNyKPtca85dzrrZWnPqyBK/cpImosrpR1iKMYr41XshsCg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.862.0", + "@smithy/protocol-http": "^5.1.3", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.883.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.883.0.tgz", + "integrity": "sha512-q58uLYnGLg7hsnWpdj7Cd1Ulsq1/PUJOHvAfgcBuiDE/+Fwh0DZxZZyjrU+Cr+dbeowIdUaOO8BEDDJ0CUenJw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.883.0", + "@aws-sdk/types": "3.862.0", + "@aws-sdk/util-endpoints": "3.879.0", + "@smithy/core": "^3.9.2", + "@smithy/protocol-http": "^5.1.3", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-websocket": { + "version": "3.873.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/middleware-websocket/-/middleware-websocket-3.873.0.tgz", + "integrity": "sha512-NLh9JmE460/WIVlsoP4vR5zbgPu50uVHXiEyr5lf34MatayiMTiC7Dd9KecKys8tppVVqahOMkOLb4/nl0hk6Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.862.0", + "@aws-sdk/util-format-url": "3.873.0", + "@smithy/eventstream-codec": "^4.0.5", + "@smithy/eventstream-serde-browser": "^4.0.5", + "@smithy/fetch-http-handler": "^5.1.1", + "@smithy/protocol-http": "^5.1.3", + "@smithy/signature-v4": "^5.1.3", + "@smithy/types": "^4.3.2", + "@smithy/util-hex-encoding": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.883.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/nested-clients/-/nested-clients-3.883.0.tgz", + "integrity": "sha512-IhzDM+v0ga53GOOrZ9jmGNr7JU5OR6h6ZK9NgB7GXaa+gsDbqfUuXRwyKDYXldrTXf1sUR3vy1okWDXA7S2ejQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.883.0", + "@aws-sdk/middleware-host-header": "3.873.0", + "@aws-sdk/middleware-logger": "3.876.0", + "@aws-sdk/middleware-recursion-detection": "3.873.0", + "@aws-sdk/middleware-user-agent": "3.883.0", + "@aws-sdk/region-config-resolver": "3.873.0", + "@aws-sdk/types": "3.862.0", + "@aws-sdk/util-endpoints": "3.879.0", + "@aws-sdk/util-user-agent-browser": "3.873.0", + "@aws-sdk/util-user-agent-node": "3.883.0", + "@smithy/config-resolver": "^4.1.5", + "@smithy/core": "^3.9.2", + "@smithy/fetch-http-handler": "^5.1.1", + "@smithy/hash-node": "^4.0.5", + "@smithy/invalid-dependency": "^4.0.5", + "@smithy/middleware-content-length": "^4.0.5", + "@smithy/middleware-endpoint": "^4.1.21", + "@smithy/middleware-retry": "^4.1.22", + "@smithy/middleware-serde": "^4.0.9", + "@smithy/middleware-stack": "^4.0.5", + "@smithy/node-config-provider": "^4.1.4", + "@smithy/node-http-handler": "^4.1.1", + "@smithy/protocol-http": "^5.1.3", + "@smithy/smithy-client": "^4.5.2", + "@smithy/types": "^4.3.2", + "@smithy/url-parser": "^4.0.5", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.29", + "@smithy/util-defaults-mode-node": "^4.0.29", + "@smithy/util-endpoints": "^3.0.7", + "@smithy/util-middleware": "^4.0.5", + "@smithy/util-retry": "^4.0.7", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.873.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/region-config-resolver/-/region-config-resolver-3.873.0.tgz", + "integrity": "sha512-q9sPoef+BBG6PJnc4x60vK/bfVwvRWsPgcoQyIra057S/QGjq5VkjvNk6H8xedf6vnKlXNBwq9BaANBXnldUJg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.862.0", + "@smithy/node-config-provider": "^4.1.4", + "@smithy/types": "^4.3.2", + "@smithy/util-config-provider": "^4.0.0", + "@smithy/util-middleware": "^4.0.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.883.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/token-providers/-/token-providers-3.883.0.tgz", + "integrity": "sha512-tcj/Z5paGn9esxhmmkEW7gt39uNoIRbXG1UwJrfKu4zcTr89h86PDiIE2nxUO3CMQf1KgncPpr5WouPGzkh/QQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.883.0", + "@aws-sdk/nested-clients": "3.883.0", + "@aws-sdk/types": "3.862.0", + "@smithy/property-provider": "^4.0.5", + "@smithy/shared-ini-file-loader": "^4.0.5", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.862.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/types/-/types-3.862.0.tgz", + "integrity": "sha512-Bei+RL0cDxxV+lW2UezLbCYYNeJm6Nzee0TpW0FfyTRBhH9C1XQh4+x+IClriXvgBnRquTMMYsmJfvx8iyLKrg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.879.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/util-endpoints/-/util-endpoints-3.879.0.tgz", + "integrity": "sha512-aVAJwGecYoEmbEFju3127TyJDF9qJsKDUUTRMDuS8tGn+QiWQFnfInmbt+el9GU1gEJupNTXV+E3e74y51fb7A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.862.0", + "@smithy/types": "^4.3.2", + "@smithy/url-parser": "^4.0.5", + "@smithy/util-endpoints": "^3.0.7", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-format-url": { + "version": "3.873.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/util-format-url/-/util-format-url-3.873.0.tgz", + "integrity": "sha512-v//b9jFnhzTKKV3HFTw2MakdM22uBAs2lBov51BWmFXuFtSTdBLrR7zgfetQPE3PVkFai0cmtJQPdc3MX+T/cQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.862.0", + "@smithy/querystring-builder": "^4.0.5", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.873.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/util-locate-window/-/util-locate-window-3.873.0.tgz", + "integrity": "sha512-xcVhZF6svjM5Rj89T1WzkjQmrTF6dpR2UvIHPMTnSZoNe6CixejPZ6f0JJ2kAhO8H+dUHwNBlsUgOTIKiK/Syg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.873.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.873.0.tgz", + "integrity": "sha512-AcRdbK6o19yehEcywI43blIBhOCSo6UgyWcuOJX5CFF8k39xm1ILCjQlRRjchLAxWrm0lU0Q7XV90RiMMFMZtA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.862.0", + "@smithy/types": "^4.3.2", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.883.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.883.0.tgz", + "integrity": "sha512-28cQZqC+wsKUHGpTBr+afoIdjS6IoEJkMqcZsmo2Ag8LzmTa6BUWQenFYB0/9BmDy4PZFPUn+uX+rJgWKB+jzA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "3.883.0", + "@aws-sdk/types": "3.862.0", + "@smithy/node-config-provider": "^4.1.4", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.873.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/xml-builder/-/xml-builder-3.873.0.tgz", + "integrity": "sha512-kLO7k7cGJ6KaHiExSJWojZurF7SnGMDHXRuQunFnEoD0n1yB6Lqy/S/zHiQ7oJnBhPr9q0TW9qFkrsZb1Uc54w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.4", + "resolved": "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.28.4.tgz", + "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.4", + "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.28.4.tgz", + "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.4", + "@babel/types": "^7.28.4", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@babel/core/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/generator": { + "version": "7.28.3", + "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.28.3.tgz", + "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.3", + "@babel/types": "^7.28.2", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.4", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.28.4.tgz", + "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.4" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", + "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", + "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.4", + "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.28.4.tgz", + "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@babel/traverse/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/types": { + "version": "7.28.4", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.28.4.tgz", + "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmmirror.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/@dabh/diagnostics/-/diagnostics-2.0.3.tgz", + "integrity": "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==", + "license": "MIT", + "dependencies": { + "colorspace": "1.1.x", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmmirror.com/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmmirror.com/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@eslint/eslintrc/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmmirror.com/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmmirror.com/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@inquirer/external-editor/-/external-editor-1.0.1.tgz", + "integrity": "sha512-Oau4yL24d2B5IL4ma4UpbQigkVhzPDXLoqy1ggK4gnHg/stmkffJE4oOXHXF3uz0UEpywG68KcyXsyYpA1Re/Q==", + "license": "MIT", + "dependencies": { + "chardet": "^2.1.0", + "iconv-lite": "^0.6.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@ioredis/commands": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/@ioredis/commands/-/commands-1.3.1.tgz", + "integrity": "sha512-bYtU8avhGIcje3IhvF9aSjsa5URMZBHnwKtOvXsT4sfYy9gppW11gLPT/9oNqlJZD47yPKveQFTAFWpHjKvUoQ==", + "license": "MIT" + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmmirror.com/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmmirror.com/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmmirror.com/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmmirror.com/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmmirror.com/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.30", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", + "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@ldapjs/asn1": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/@ldapjs/asn1/-/asn1-2.0.0.tgz", + "integrity": "sha512-G9+DkEOirNgdPmD0I8nu57ygQJKOOgFEMKknEuQvIHbGLwP3ny1mY+OTUYLCbCaGJP4sox5eYgBJRuSUpnAddA==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT" + }, + "node_modules/@ldapjs/attribute": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/@ldapjs/attribute/-/attribute-1.0.0.tgz", + "integrity": "sha512-ptMl2d/5xJ0q+RgmnqOi3Zgwk/TMJYG7dYMC0Keko+yZU6n+oFM59MjQOUht5pxJeS4FWrImhu/LebX24vJNRQ==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "2.0.0", + "@ldapjs/protocol": "^1.2.1", + "process-warning": "^2.1.0" + } + }, + "node_modules/@ldapjs/change": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/@ldapjs/change/-/change-1.0.0.tgz", + "integrity": "sha512-EOQNFH1RIku3M1s0OAJOzGfAohuFYXFY4s73wOhRm4KFGhmQQ7MChOh2YtYu9Kwgvuq1B0xKciXVzHCGkB5V+Q==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "2.0.0", + "@ldapjs/attribute": "1.0.0" + } + }, + "node_modules/@ldapjs/controls": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/@ldapjs/controls/-/controls-2.1.0.tgz", + "integrity": "sha512-2pFdD1yRC9V9hXfAWvCCO2RRWK9OdIEcJIos/9cCVP9O4k72BY1bLDQQ4KpUoJnl4y/JoD4iFgM+YWT3IfITWw==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "^1.2.0", + "@ldapjs/protocol": "^1.2.1" + } + }, + "node_modules/@ldapjs/controls/node_modules/@ldapjs/asn1": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/@ldapjs/asn1/-/asn1-1.2.0.tgz", + "integrity": "sha512-KX/qQJ2xxzvO2/WOvr1UdQ+8P5dVvuOLk/C9b1bIkXxZss8BaR28njXdPgFCpj5aHaf1t8PmuVnea+N9YG9YMw==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT" + }, + "node_modules/@ldapjs/dn": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@ldapjs/dn/-/dn-1.1.0.tgz", + "integrity": "sha512-R72zH5ZeBj/Fujf/yBu78YzpJjJXG46YHFo5E4W1EqfNpo1UsVPqdLrRMXeKIsJT3x9dJVIfR6OpzgINlKpi0A==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "2.0.0", + "process-warning": "^2.1.0" + } + }, + "node_modules/@ldapjs/filter": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/@ldapjs/filter/-/filter-2.1.1.tgz", + "integrity": "sha512-TwPK5eEgNdUO1ABPBUQabcZ+h9heDORE4V9WNZqCtYLKc06+6+UAJ3IAbr0L0bYTnkkWC/JEQD2F+zAFsuikNw==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "2.0.0", + "@ldapjs/protocol": "^1.2.1", + "process-warning": "^2.1.0" + } + }, + "node_modules/@ldapjs/messages": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/@ldapjs/messages/-/messages-1.3.0.tgz", + "integrity": "sha512-K7xZpXJ21bj92jS35wtRbdcNrwmxAtPwy4myeh9duy/eR3xQKvikVycbdWVzkYEAVE5Ce520VXNOwCHjomjCZw==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "^2.0.0", + "@ldapjs/attribute": "^1.0.0", + "@ldapjs/change": "^1.0.0", + "@ldapjs/controls": "^2.1.0", + "@ldapjs/dn": "^1.1.0", + "@ldapjs/filter": "^2.1.1", + "@ldapjs/protocol": "^1.2.1", + "process-warning": "^2.2.0" + } + }, + "node_modules/@ldapjs/protocol": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/@ldapjs/protocol/-/protocol-1.2.1.tgz", + "integrity": "sha512-O89xFDLW2gBoZWNXuXpBSM32/KealKCTb3JGtJdtUQc7RjAk8XzrRgyz02cPAwGKwKPxy0ivuC7UP9bmN87egQ==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT" + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmmirror.com/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmmirror.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.2.2", + "resolved": "https://registry.npmmirror.com/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz", + "integrity": "sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmmirror.com/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmmirror.com/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmmirror.com/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@smithy/abort-controller": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/abort-controller/-/abort-controller-4.1.0.tgz", + "integrity": "sha512-wEhSYznxOmx7EdwK1tYEWJF5+/wmSFsff9BfTOn8oO/+KPl3gsmThrb6MJlWbOC391+Ya31s5JuHiC2RlT80Zg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@smithy/config-resolver/-/config-resolver-4.2.0.tgz", + "integrity": "sha512-FA10YhPFLy23uxeWu7pOM2ctlw+gzbPMTZQwrZ8FRIfyJ/p8YIVz7AVTB5jjLD+QIerydyKcVMZur8qzzDILAQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.2.0", + "@smithy/types": "^4.4.0", + "@smithy/util-config-provider": "^4.1.0", + "@smithy/util-middleware": "^4.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.10.0", + "resolved": "https://registry.npmmirror.com/@smithy/core/-/core-3.10.0.tgz", + "integrity": "sha512-bXyD3Ij6b1qDymEYlEcF+QIjwb9gObwZNaRjETJsUEvSIzxFdynSQ3E4ysY7lUFSBzeWBNaFvX+5A0smbC2q6A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/middleware-serde": "^4.1.0", + "@smithy/protocol-http": "^5.2.0", + "@smithy/types": "^4.4.0", + "@smithy/util-base64": "^4.1.0", + "@smithy/util-body-length-browser": "^4.1.0", + "@smithy/util-middleware": "^4.1.0", + "@smithy/util-stream": "^4.3.0", + "@smithy/util-utf8": "^4.1.0", + "@types/uuid": "^9.0.1", + "tslib": "^2.6.2", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/credential-provider-imds/-/credential-provider-imds-4.1.0.tgz", + "integrity": "sha512-iVwNhxTsCQTPdp++4C/d9xvaDmuEWhXi55qJobMp9QMaEHRGH3kErU4F8gohtdsawRqnUy/ANylCjKuhcR2mPw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.2.0", + "@smithy/property-provider": "^4.1.0", + "@smithy/types": "^4.4.0", + "@smithy/url-parser": "^4.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-codec": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/eventstream-codec/-/eventstream-codec-4.1.0.tgz", + "integrity": "sha512-MSOb6pwG3Tss1UwlZMHC+rYergWCo4fwep3Y1fJxwdLLxReSaKFfXxPQhEHi/8LSNQFEcBYBxybgjXjw4jJWqQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.4.0", + "@smithy/util-hex-encoding": "^4.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-browser": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.1.0.tgz", + "integrity": "sha512-VvHXoBoLos2OCdMtUvKWK7ckcvun6ZP4KBYhf38+kszk6BEuK9k8c3xbIMIpC6K4vTK72qHlHAdBoR9qU+F7xw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.1.0", + "@smithy/types": "^4.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-config-resolver": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.2.0.tgz", + "integrity": "sha512-T7YlcU0cP2bjAC4eXo9E6puqrrmqv5VHBL8bPMOMgEE1p4m+bwkDWRQpeiXqn/idoKM1qwXq8PvRLYmpbYB6uw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-node": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.1.0.tgz", + "integrity": "sha512-WlIKVRkcPjwuN3x+e8+5KOI9nL6s93bxgWH+39VwwQMl+4FagKPtTM3VCumSoZJ9qn/CNl4W5mVdFFRkDF84lQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.1.0", + "@smithy/types": "^4.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-universal": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.1.0.tgz", + "integrity": "sha512-GjMezHHd0xrjJcWLAcnXlVePe7PY8KsdxzKeXcMn7V3vfIScGUpKQJrlSmEXwzFH9Mjl0G0EdOS5GzewZEwtxg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-codec": "^4.1.0", + "@smithy/types": "^4.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/@smithy/fetch-http-handler/-/fetch-http-handler-5.2.0.tgz", + "integrity": "sha512-VZenjDdVaUGiy3hwQtxm75nhXZrhFG+3xyL93qCQAlYDyhT/jeDWM8/3r5uCFMlTmmyrIjiDyiOynVFchb0BSg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.2.0", + "@smithy/querystring-builder": "^4.1.0", + "@smithy/types": "^4.4.0", + "@smithy/util-base64": "^4.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/hash-node/-/hash-node-4.1.0.tgz", + "integrity": "sha512-mXkJQ/6lAXTuoSsEH+d/fHa4ms4qV5LqYoPLYhmhCRTNcMMdg+4Ya8cMgU1W8+OR40eX0kzsExT7fAILqtTl2w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.4.0", + "@smithy/util-buffer-from": "^4.1.0", + "@smithy/util-utf8": "^4.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/invalid-dependency/-/invalid-dependency-4.1.0.tgz", + "integrity": "sha512-4/FcV6aCMzgpM4YyA/GRzTtG28G0RQJcWK722MmpIgzOyfSceWcI9T9c8matpHU9qYYLaWtk8pSGNCLn5kzDRw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/is-array-buffer/-/is-array-buffer-4.1.0.tgz", + "integrity": "sha512-ePTYUOV54wMogio+he4pBybe8fwg4sDvEVDBU8ZlHOZXbXK3/C0XfJgUCu6qAZcawv05ZhZzODGUerFBPsPUDQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/middleware-content-length/-/middleware-content-length-4.1.0.tgz", + "integrity": "sha512-x3dgLFubk/ClKVniJu+ELeZGk4mq7Iv0HgCRUlxNUIcerHTLVmq7Q5eGJL0tOnUltY6KFw5YOKaYxwdcMwox/w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.2.0", + "@smithy/types": "^4.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@smithy/middleware-endpoint/-/middleware-endpoint-4.2.0.tgz", + "integrity": "sha512-J1eCF7pPDwgv7fGwRd2+Y+H9hlIolF3OZ2PjptonzzyOXXGh/1KGJAHpEcY1EX+WLlclKu2yC5k+9jWXdUG4YQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.10.0", + "@smithy/middleware-serde": "^4.1.0", + "@smithy/node-config-provider": "^4.2.0", + "@smithy/shared-ini-file-loader": "^4.1.0", + "@smithy/types": "^4.4.0", + "@smithy/url-parser": "^4.1.0", + "@smithy/util-middleware": "^4.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@smithy/middleware-retry/-/middleware-retry-4.2.0.tgz", + "integrity": "sha512-raL5oWYf5ALl3jCJrajE8enKJEnV/2wZkKS6mb3ZRY2tg3nj66ssdWy5Ps8E6Yu8Wqh3Tt+Sb9LozjvwZupq+A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.2.0", + "@smithy/protocol-http": "^5.2.0", + "@smithy/service-error-classification": "^4.1.0", + "@smithy/smithy-client": "^4.6.0", + "@smithy/types": "^4.4.0", + "@smithy/util-middleware": "^4.1.0", + "@smithy/util-retry": "^4.1.0", + "@types/uuid": "^9.0.1", + "tslib": "^2.6.2", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/middleware-serde/-/middleware-serde-4.1.0.tgz", + "integrity": "sha512-CtLFYlHt7c2VcztyVRc+25JLV4aGpmaSv9F1sPB0AGFL6S+RPythkqpGDa2XBQLJQooKkjLA1g7Xe4450knShg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.2.0", + "@smithy/types": "^4.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/middleware-stack/-/middleware-stack-4.1.0.tgz", + "integrity": "sha512-91Fuw4IKp0eK8PNhMXrHRcYA1jvbZ9BJGT91wwPy3bTQT8mHTcQNius/EhSQTlT9QUI3Ki1wjHeNXbWK0tO8YQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@smithy/node-config-provider/-/node-config-provider-4.2.0.tgz", + "integrity": "sha512-8/fpilqKurQ+f8nFvoFkJ0lrymoMJ+5/CQV5IcTv/MyKhk2Q/EFYCAgTSWHD4nMi9ux9NyBBynkyE9SLg2uSLA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.1.0", + "@smithy/shared-ini-file-loader": "^4.1.0", + "@smithy/types": "^4.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@smithy/node-http-handler/-/node-http-handler-4.2.0.tgz", + "integrity": "sha512-G4NV70B4hF9vBrUkkvNfWO6+QR4jYjeO4tc+4XrKCb4nPYj49V9Hu8Ftio7Mb0/0IlFyEOORudHrm+isY29nCA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.1.0", + "@smithy/protocol-http": "^5.2.0", + "@smithy/querystring-builder": "^4.1.0", + "@smithy/types": "^4.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/property-provider/-/property-provider-4.1.0.tgz", + "integrity": "sha512-eksMjMHUlG5PwOUWO3k+rfLNOPVPJ70mUzyYNKb5lvyIuAwS4zpWGsxGiuT74DFWonW0xRNy+jgzGauUzX7SyA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/@smithy/protocol-http/-/protocol-http-5.2.0.tgz", + "integrity": "sha512-bwjlh5JwdOQnA01be+5UvHK4HQz4iaRKlVG46hHSJuqi0Ribt3K06Z1oQ29i35Np4G9MCDgkOGcHVyLMreMcbg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/querystring-builder/-/querystring-builder-4.1.0.tgz", + "integrity": "sha512-JqTWmVIq4AF8R8OK/2cCCiQo5ZJ0SRPsDkDgLO5/3z8xxuUp1oMIBBjfuueEe+11hGTZ6rRebzYikpKc6yQV9Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.4.0", + "@smithy/util-uri-escape": "^4.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/querystring-parser/-/querystring-parser-4.1.0.tgz", + "integrity": "sha512-VgdHhr8YTRsjOl4hnKFm7xEMOCRTnKw3FJ1nU+dlWNhdt/7eEtxtkdrJdx7PlRTabdANTmvyjE4umUl9cK4awg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/service-error-classification/-/service-error-classification-4.1.0.tgz", + "integrity": "sha512-UBpNFzBNmS20jJomuYn++Y+soF8rOK9AvIGjS9yGP6uRXF5rP18h4FDUsoNpWTlSsmiJ87e2DpZo9ywzSMH7PQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.4.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.1.0.tgz", + "integrity": "sha512-W0VMlz9yGdQ/0ZAgWICFjFHTVU0YSfGoCVpKaExRM/FDkTeP/yz8OKvjtGjs6oFokCRm0srgj/g4Cg0xuHu8Rw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/@smithy/signature-v4/-/signature-v4-5.2.0.tgz", + "integrity": "sha512-ObX1ZqG2DdZQlXx9mLD7yAR8AGb7yXurGm+iWx9x4l1fBZ8CZN2BRT09aSbcXVPZXWGdn5VtMuupjxhOTI2EjA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.1.0", + "@smithy/protocol-http": "^5.2.0", + "@smithy/types": "^4.4.0", + "@smithy/util-hex-encoding": "^4.1.0", + "@smithy/util-middleware": "^4.1.0", + "@smithy/util-uri-escape": "^4.1.0", + "@smithy/util-utf8": "^4.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/smithy-client": { + "version": "4.6.0", + "resolved": "https://registry.npmmirror.com/@smithy/smithy-client/-/smithy-client-4.6.0.tgz", + "integrity": "sha512-TvlIshqx5PIi0I0AiR+PluCpJ8olVG++xbYkAIGCUkByaMUlfOXLgjQTmYbr46k4wuDe8eHiTIlUflnjK2drPQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.10.0", + "@smithy/middleware-endpoint": "^4.2.0", + "@smithy/middleware-stack": "^4.1.0", + "@smithy/protocol-http": "^5.2.0", + "@smithy/types": "^4.4.0", + "@smithy/util-stream": "^4.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.4.0", + "resolved": "https://registry.npmmirror.com/@smithy/types/-/types-4.4.0.tgz", + "integrity": "sha512-4jY91NgZz+ZnSFcVzWwngOW6VuK3gR/ihTwSU1R/0NENe9Jd8SfWgbhDCAGUWL3bI7DiDSW7XF6Ui6bBBjrqXw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/url-parser": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/url-parser/-/url-parser-4.1.0.tgz", + "integrity": "sha512-/LYEIOuO5B2u++tKr1NxNxhZTrr3A63jW8N73YTwVeUyAlbB/YM+hkftsvtKAcMt3ADYo0FsF1GY3anehffSVQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/querystring-parser": "^4.1.0", + "@smithy/types": "^4.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-base64": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/util-base64/-/util-base64-4.1.0.tgz", + "integrity": "sha512-RUGd4wNb8GeW7xk+AY5ghGnIwM96V0l2uzvs/uVHf+tIuVX2WSvynk5CxNoBCsM2rQRSZElAo9rt3G5mJ/gktQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.1.0", + "@smithy/util-utf8": "^4.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-browser": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/util-body-length-browser/-/util-body-length-browser-4.1.0.tgz", + "integrity": "sha512-V2E2Iez+bo6bUMOTENPr6eEmepdY8Hbs+Uc1vkDKgKNA/brTJqOW/ai3JO1BGj9GbCeLqw90pbbH7HFQyFotGQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-node": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/util-body-length-node/-/util-body-length-node-4.1.0.tgz", + "integrity": "sha512-BOI5dYjheZdgR9XiEM3HJcEMCXSoqbzu7CzIgYrx0UtmvtC3tC2iDGpJLsSRFffUpy8ymsg2ARMP5fR8mtuUQQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/util-buffer-from/-/util-buffer-from-4.1.0.tgz", + "integrity": "sha512-N6yXcjfe/E+xKEccWEKzK6M+crMrlwaCepKja0pNnlSkm6SjAeLKKA++er5Ba0I17gvKfN/ThV+ZOx/CntKTVw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-config-provider": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/util-config-provider/-/util-config-provider-4.1.0.tgz", + "integrity": "sha512-swXz2vMjrP1ZusZWVTB/ai5gK+J8U0BWvP10v9fpcFvg+Xi/87LHvHfst2IgCs1i0v4qFZfGwCmeD/KNCdJZbQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.1.0.tgz", + "integrity": "sha512-D27cLtJtC4EEeERJXS+JPoogz2tE5zeE3zhWSSu6ER5/wJ5gihUxIzoarDX6K1U27IFTHit5YfHqU4Y9RSGE0w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.1.0", + "@smithy/smithy-client": "^4.6.0", + "@smithy/types": "^4.4.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.1.0.tgz", + "integrity": "sha512-gnZo3u5dP1o87plKupg39alsbeIY1oFFnCyV2nI/++pL19vTtBLgOyftLEjPjuXmoKn2B2rskX8b7wtC/+3Okg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/config-resolver": "^4.2.0", + "@smithy/credential-provider-imds": "^4.1.0", + "@smithy/node-config-provider": "^4.2.0", + "@smithy/property-provider": "^4.1.0", + "@smithy/smithy-client": "^4.6.0", + "@smithy/types": "^4.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-endpoints": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/util-endpoints/-/util-endpoints-3.1.0.tgz", + "integrity": "sha512-5LFg48KkunBVGrNs3dnQgLlMXJLVo7k9sdZV5su3rjO3c3DmQ2LwUZI0Zr49p89JWK6sB7KmzyI2fVcDsZkwuw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.2.0", + "@smithy/types": "^4.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/util-hex-encoding/-/util-hex-encoding-4.1.0.tgz", + "integrity": "sha512-1LcueNN5GYC4tr8mo14yVYbh/Ur8jHhWOxniZXii+1+ePiIbsLZ5fEI0QQGtbRRP5mOhmooos+rLmVASGGoq5w==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-middleware": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/util-middleware/-/util-middleware-4.1.0.tgz", + "integrity": "sha512-612onNcKyxhP7/YOTKFTb2F6sPYtMRddlT5mZvYf1zduzaGzkYhpYIPxIeeEwBZFjnvEqe53Ijl2cYEfJ9d6/Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-retry": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/util-retry/-/util-retry-4.1.0.tgz", + "integrity": "sha512-5AGoBHb207xAKSVwaUnaER+L55WFY8o2RhlafELZR3mB0J91fpL+Qn+zgRkPzns3kccGaF2vy0HmNVBMWmN6dA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/service-error-classification": "^4.1.0", + "@smithy/types": "^4.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-stream": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/@smithy/util-stream/-/util-stream-4.3.0.tgz", + "integrity": "sha512-ZOYS94jksDwvsCJtppHprUhsIscRnCKGr6FXCo3SxgQ31ECbza3wqDBqSy6IsAak+h/oAXb1+UYEBmDdseAjUQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/fetch-http-handler": "^5.2.0", + "@smithy/node-http-handler": "^4.2.0", + "@smithy/types": "^4.4.0", + "@smithy/util-base64": "^4.1.0", + "@smithy/util-buffer-from": "^4.1.0", + "@smithy/util-hex-encoding": "^4.1.0", + "@smithy/util-utf8": "^4.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-uri-escape": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/util-uri-escape/-/util-uri-escape-4.1.0.tgz", + "integrity": "sha512-b0EFQkq35K5NHUYxU72JuoheM6+pytEVUGlTwiFxWFpmddA+Bpz3LgsPRIpBk8lnPE47yT7AF2Egc3jVnKLuPg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/util-utf8/-/util-utf8-4.1.0.tgz", + "integrity": "sha512-mEu1/UIXAdNYuBcyEPbjScKi/+MQVXNIuY/7Cm5XLIWe319kDrT5SizBE95jqtmEXoDbGoZxKLCMttdZdqTZKQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmmirror.com/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmmirror.com/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmmirror.com/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmmirror.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/node": { + "version": "20.19.13", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-20.19.13.tgz", + "integrity": "sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmmirror.com/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", + "license": "MIT" + }, + "node_modules/@types/uuid": { + "version": "9.0.8", + "resolved": "https://registry.npmmirror.com/@types/uuid/-/uuid-9.0.8.tgz", + "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.33", + "resolved": "https://registry.npmmirror.com/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmmirror.com/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmmirror.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmmirror.com/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmmirror.com/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.11.0", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.11.0.tgz", + "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmmirror.com/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmmirror.com/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmmirror.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmmirror.com/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/backoff": { + "version": "2.5.0", + "resolved": "https://registry.npmmirror.com/backoff/-/backoff-2.5.0.tgz", + "integrity": "sha512-wC5ihrnUXmR2douXmXLCe5O3zg3GKIyvRi/hi58a/XyRxVI+3/yM0PYueQOZXPXQ9pxBislYkw+sF9b7C/RuMA==", + "license": "MIT", + "dependencies": { + "precond": "0.2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmmirror.com/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmmirror.com/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bowser": { + "version": "2.12.1", + "resolved": "https://registry.npmmirror.com/bowser/-/bowser-2.12.1.tgz", + "integrity": "sha512-z4rE2Gxh7tvshQ4hluIT7XcFrgLIQaw9X3A+kTTRdovCz5PMukm/0QC/BKSYPj3omF5Qfypn9O/c5kgpmvYUCw==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.25.4", + "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.25.4.tgz", + "integrity": "sha512-4jYpcjabC606xJ3kw2QwGEZKX0Aw7sgQdZCvIK9dhVSPh76BKo+C+btT1RRofH7B+8iNpEbgGNVWiLki5q93yg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001737", + "electron-to-chromium": "^1.5.211", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmmirror.com/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmmirror.com/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001741", + "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001741.tgz", + "integrity": "sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/chardet": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/chardet/-/chardet-2.1.0.tgz", + "integrity": "sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA==", + "license": "MIT" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmmirror.com/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmmirror.com/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmmirror.com/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/cli-width/-/cli-width-3.0.0.tgz", + "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", + "license": "ISC", + "engines": { + "node": ">= 10" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmmirror.com/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmmirror.com/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", + "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/color": { + "version": "3.2.1", + "resolved": "https://registry.npmmirror.com/color/-/color-3.2.1.tgz", + "integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.3", + "color-string": "^1.6.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmmirror.com/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/color/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/colorspace": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/colorspace/-/colorspace-1.1.4.tgz", + "integrity": "sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==", + "license": "MIT", + "dependencies": { + "color": "^3.1.3", + "text-hex": "1.0.x" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmmirror.com/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmmirror.com/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmmirror.com/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmmirror.com/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmmirror.com/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmmirror.com/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/dedent": { + "version": "1.7.0", + "resolved": "https://registry.npmmirror.com/dedent/-/dedent-1.7.0.tgz", + "integrity": "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmmirror.com/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.214", + "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.214.tgz", + "integrity": "sha512-TpvUNdha+X3ybfU78NoQatKvQEm1oq3lf2QbnmCEdw+Bd9RuIAY+hJTvq1avzHM0f7EJfnH3vbCnbzKzisc/9Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmmirror.com/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmmirror.com/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmmirror.com/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmmirror.com/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.5.4", + "resolved": "https://registry.npmmirror.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.4.tgz", + "integrity": "sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.0", + "synckit": "^0.11.7" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmmirror.com/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/eslint/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmmirror.com/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmmirror.com/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/express": { + "version": "4.21.2", + "resolved": "https://registry.npmmirror.com/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-xml-parser": { + "version": "5.2.5", + "resolved": "https://registry.npmmirror.com/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", + "integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^2.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmmirror.com/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", + "license": "MIT" + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/figures": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/figures/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/file-stream-rotator": { + "version": "0.6.1", + "resolved": "https://registry.npmmirror.com/file-stream-rotator/-/file-stream-rotator-0.6.1.tgz", + "integrity": "sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ==", + "license": "MIT", + "dependencies": { + "moment": "^2.29.1" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmmirror.com/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmmirror.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/formidable": { + "version": "2.1.5", + "resolved": "https://registry.npmmirror.com/formidable/-/formidable-2.1.5.tgz", + "integrity": "sha512-Oz5Hwvwak/DCaXVVUtPn4oLMLLy1CdclLKO1LFgU7XzDpVMUU5UjlSLpGMocyQNNk8F6IJW9M/YdooSn2MRI+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0", + "qs": "^6.11.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmmirror.com/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gaxios": { + "version": "7.1.1", + "resolved": "https://registry.npmmirror.com/gaxios/-/gaxios-7.1.1.tgz", + "integrity": "sha512-Odju3uBUJyVCkW64nLD4wKLhbh93bh6vIg/ZIXkWiLPBrdgtc65+tls/qml+un3pr6JqYVFDZbbmLDQT68rTOQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/gcp-metadata/-/gcp-metadata-7.0.1.tgz", + "integrity": "sha512-UcO3kefx6dCcZkgcTGgVOTFb7b1LlQ02hY1omMjjrrBzkajRMCFgYOjs7J71WqnuG1k2b+9ppGL7FsOfhZMQKQ==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmmirror.com/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmmirror.com/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/google-auth-library": { + "version": "10.3.0", + "resolved": "https://registry.npmmirror.com/google-auth-library/-/google-auth-library-10.3.0.tgz", + "integrity": "sha512-ylSE3RlCRZfZB56PFJSfUCuiuPq83Fx8hqu1KPWGK8FVdSaxlp/qkeMMX/DT/18xkwXIHvXEXkZsljRwfrdEfQ==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.0.0", + "gcp-metadata": "^7.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/google-logging-utils/-/google-logging-utils-1.1.1.tgz", + "integrity": "sha512-rcX58I7nqpu4mbKztFeOAObbomBbHU2oIb/d3tJfF3dizGSApqtSwYJigGCooHdnMyQBIw8BrWyK96w3YXgr6A==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/gtoken": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/gtoken/-/gtoken-8.0.0.tgz", + "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", + "license": "MIT", + "dependencies": { + "gaxios": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/helmet/-/helmet-7.2.0.tgz", + "integrity": "sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmmirror.com/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/inquirer": { + "version": "8.2.7", + "resolved": "https://registry.npmmirror.com/inquirer/-/inquirer-8.2.7.tgz", + "integrity": "sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA==", + "license": "MIT", + "dependencies": { + "@inquirer/external-editor": "^1.0.0", + "ansi-escapes": "^4.2.1", + "chalk": "^4.1.1", + "cli-cursor": "^3.1.0", + "cli-width": "^3.0.0", + "figures": "^3.0.0", + "lodash": "^4.17.21", + "mute-stream": "0.0.8", + "ora": "^5.4.1", + "run-async": "^2.4.0", + "rxjs": "^7.5.5", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "through": "^2.3.6", + "wrap-ansi": "^6.0.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/ioredis": { + "version": "5.7.0", + "resolved": "https://registry.npmmirror.com/ioredis/-/ioredis-5.7.0.tgz", + "integrity": "sha512-NUcA93i1lukyXU+riqEyPtSEkyFq8tX90uL659J+qpCZ3rEdViB/APC58oAhIh3+bJln2hzdlZbBZsGNrlsR8g==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "^1.3.0", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ioredis/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ioredis/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmmirror.com/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmmirror.com/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmmirror.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmmirror.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmmirror.com/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmmirror.com/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmmirror.com/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", + "license": "MIT" + }, + "node_modules/ldapjs": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/ldapjs/-/ldapjs-3.0.7.tgz", + "integrity": "sha512-1ky+WrN+4CFMuoekUOv7Y1037XWdjKpu0xAPwSP+9KdvmV9PG+qOKlssDV6a+U32apwxdD3is/BZcWOYzN30cg==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "^2.0.0", + "@ldapjs/attribute": "^1.0.0", + "@ldapjs/change": "^1.0.0", + "@ldapjs/controls": "^2.1.0", + "@ldapjs/dn": "^1.1.0", + "@ldapjs/filter": "^2.1.1", + "@ldapjs/messages": "^1.3.0", + "@ldapjs/protocol": "^1.2.1", + "abstract-logging": "^2.0.1", + "assert-plus": "^1.0.0", + "backoff": "^2.5.0", + "once": "^1.4.0", + "vasync": "^2.2.1", + "verror": "^1.10.1" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmmirror.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/logform": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "license": "MIT", + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/logform/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmmirror.com/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmmirror.com/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/morgan": { + "version": "1.10.1", + "resolved": "https://registry.npmmirror.com/morgan/-/morgan-1.10.1.tgz", + "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", + "license": "MIT", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.3.0", + "on-headers": "~1.1.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/morgan/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmmirror.com/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "license": "ISC" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmmirror.com/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.20", + "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.20.tgz", + "integrity": "sha512-7gK6zSXEH6neM212JgfYFXe+GmZQM+fia5SsusuBIUgnPheLFBmIPhtFoAQRj8/7wASYQnbDlHPVwY0BefoFgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nodemailer": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.6.tgz", + "integrity": "sha512-F44uVzgwo49xboqbFgBGkRaiMgtoBrBEWCVincJPK9+S9Adkzt/wXCLKbf7dxucmxfTI5gHGB+bEmdyzN6QKjw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/nodemon": { + "version": "3.1.10", + "resolved": "https://registry.npmmirror.com/nodemon/-/nodemon-3.1.10.tgz", + "integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nodemon/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nodemon/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nodemon/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/nodemon/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "license": "MIT", + "dependencies": { + "fn.name": "1.x.x" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmmirror.com/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmmirror.com/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/precond": { + "version": "0.2.3", + "resolved": "https://registry.npmmirror.com/precond/-/precond-0.2.3.tgz", + "integrity": "sha512-QCYG84SgGyGzqJ/vlMsxeXd/pgL/I94ixdNFyh1PusWmTCyVfPJjZ1K1jvHtsbfnXQs2TSkEP2fR7QiMZAnKFQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmmirror.com/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", + "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/process-warning": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/process-warning/-/process-warning-2.3.2.tgz", + "integrity": "sha512-n9wh8tvBe5sFmsqlg+XQhaQLumwpqoAUruLwjCopgTmUBjJ/fjtBsJzKleCaIGBOMXYEhp1YfKl4d7rJ5ZKJGA==", + "license": "MIT" + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmmirror.com/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmmirror.com/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmmirror.com/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/rate-limiter-flexible": { + "version": "5.0.5", + "resolved": "https://registry.npmmirror.com/rate-limiter-flexible/-/rate-limiter-flexible-5.0.5.tgz", + "integrity": "sha512-+/dSQfo+3FYwYygUs/V2BBdwGa9nFtakDwKt4l0bnvNB53TNT++QSFewwHX9qXrZJuMe9j+TUaU21lm5ARgqdQ==", + "license": "ISC" + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmmirror.com/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-async": { + "version": "2.4.1", + "resolved": "https://registry.npmmirror.com/run-async/-/run-async-2.4.1.tgz", + "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmmirror.com/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmmirror.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmmirror.com/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmmirror.com/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmmirror.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/simple-swizzle/node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "license": "MIT" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmmirror.com/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmmirror.com/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/socks-proxy-agent/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socks-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmmirror.com/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmmirror.com/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmmirror.com/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-similarity": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/string-similarity/-/string-similarity-4.0.4.tgz", + "integrity": "sha512-/q/8Q4Bl4ZKAPjj8WerIBJWALKkaPRfrvhfF8k/B23i4nzrlRj2/go1m90In7nG/3XDSbOo0+pu6RvCTM9RGMQ==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "ISC" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strnum": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/strnum/-/strnum-2.1.1.tgz", + "integrity": "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/superagent": { + "version": "8.1.2", + "resolved": "https://registry.npmmirror.com/superagent/-/superagent-8.1.2.tgz", + "integrity": "sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==", + "deprecated": "Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.0", + "cookiejar": "^2.1.4", + "debug": "^4.3.4", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.0", + "formidable": "^2.1.2", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.11.0", + "semver": "^7.3.8" + }, + "engines": { + "node": ">=6.4.0 <13 || >=14" + } + }, + "node_modules/superagent/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmmirror.com/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/superagent/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/supertest": { + "version": "6.3.4", + "resolved": "https://registry.npmmirror.com/supertest/-/supertest-6.3.4.tgz", + "integrity": "sha512-erY3HFDG0dPnhw4U+udPfrzXa4xhSG+n4rxfRuZWCUvjFWwKl+OxWf/7zk50s84/fAAs7vf5QAb9uRa0cCykxw==", + "deprecated": "Please upgrade to supertest v7.1.3+, see release notes at https://github.com/forwardemail/supertest/releases/tag/v7.1.3 - maintenance is supported by Forward Email @ https://forwardemail.net", + "dev": true, + "license": "MIT", + "dependencies": { + "methods": "^1.1.2", + "superagent": "^8.1.2" + }, + "engines": { + "node": ">=6.4.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/synckit": { + "version": "0.11.11", + "resolved": "https://registry.npmmirror.com/synckit/-/synckit-0.11.11.tgz", + "integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.9" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/table": { + "version": "6.9.0", + "resolved": "https://registry.npmmirror.com/table/-/table-6.9.0.tgz", + "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", + "license": "BSD-3-Clause", + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/table/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/table/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", + "license": "MIT" + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmmirror.com/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "license": "MIT" + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmmirror.com/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmmirror.com/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmmirror.com/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmmirror.com/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vasync": { + "version": "2.2.1", + "resolved": "https://registry.npmmirror.com/vasync/-/vasync-2.2.1.tgz", + "integrity": "sha512-Hq72JaTpcTFdWiNA4Y22Amej2GH3BFmBaKPPlDZ4/oC8HNn2ISHLkFrJU4Ds8R3jcUi7oo5Y9jcMHKjES+N9wQ==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "verror": "1.10.0" + } + }, + "node_modules/vasync/node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmmirror.com/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "node_modules/verror": { + "version": "1.10.1", + "resolved": "https://registry.npmmirror.com/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmmirror.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/winston": { + "version": "3.17.0", + "resolved": "https://registry.npmmirror.com/winston/-/winston-3.17.0.tgz", + "integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==", + "license": "MIT", + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.2", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.7.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.9.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-daily-rotate-file": { + "version": "4.7.1", + "resolved": "https://registry.npmmirror.com/winston-daily-rotate-file/-/winston-daily-rotate-file-4.7.1.tgz", + "integrity": "sha512-7LGPiYGBPNyGHLn9z33i96zx/bd71pjBn9tqQzO3I4Tayv94WPmBNwKC7CO1wPHdP9uvu+Md/1nr6VSH9h0iaA==", + "license": "MIT", + "dependencies": { + "file-stream-rotator": "^0.6.1", + "object-hash": "^2.0.1", + "triple-beam": "^1.3.0", + "winston-transport": "^4.4.0" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "winston": "^3" + } + }, + "node_modules/winston-transport": { + "version": "4.9.0", + "resolved": "https://registry.npmmirror.com/winston-transport/-/winston-transport-4.9.0.tgz", + "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + "license": "MIT", + "dependencies": { + "logform": "^2.7.0", + "readable-stream": "^3.6.2", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmmirror.com/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000000000000000000000000000000000000..72ea4720b11f2b77255c8178cf48f6c61d7477a2 --- /dev/null +++ b/package.json @@ -0,0 +1,101 @@ +{ + "name": "claude-relay-service", + "version": "1.0.0", + "description": "Claude Code API relay service with multi-account management, OpenAI compatibility, and API key authentication", + "main": "src/app.js", + "scripts": { + "start": "npm run lint && node src/app.js", + "dev": "nodemon", + "build:web": "cd web/admin-spa && npm run build", + "install:web": "cd web/admin-spa && npm install", + "update:pricing": "node scripts/update-model-pricing.js", + "setup": "node scripts/setup.js", + "cli": "node cli/index.js", + "init:costs": "node src/cli/initCosts.js", + "service": "node scripts/manage.js", + "service:start": "node scripts/manage.js start", + "service:start:daemon": "node scripts/manage.js start -d", + "service:start:d": "node scripts/manage.js start -d", + "service:daemon": "node scripts/manage.js start -d", + "service:stop": "node scripts/manage.js stop", + "service:restart": "node scripts/manage.js restart", + "service:restart:daemon": "node scripts/manage.js restart -d", + "service:logs:follow": "node scripts/manage.js logs -f", + "service:restart:d": "node scripts/manage.js restart -d", + "service:status": "node scripts/manage.js status", + "service:logs": "node scripts/manage.js logs", + "monitor": "bash scripts/monitor-enhanced.sh", + "status": "bash scripts/status-unified.sh", + "status:detail": "bash scripts/status-unified.sh --detail", + "test": "jest", + "lint": "eslint src/**/*.js cli/**/*.js scripts/**/*.js --fix", + "lint:check": "eslint src/**/*.js cli/**/*.js scripts/**/*.js", + "format": "prettier --write \"src/**/*.js\" \"cli/**/*.js\" \"scripts/**/*.js\"", + "format:check": "prettier --check \"src/**/*.js\" \"cli/**/*.js\" \"scripts/**/*.js\"", + "docker:build": "docker build -t claude-relay-service .", + "docker:up": "docker-compose up -d", + "docker:down": "docker-compose down", + "migrate:apikey-expiry": "node scripts/migrate-apikey-expiry.js", + "migrate:apikey-expiry:dry": "node scripts/migrate-apikey-expiry.js --dry-run", + "migrate:fix-usage-stats": "node scripts/fix-usage-stats.js", + "data:export": "node scripts/data-transfer.js export", + "data:import": "node scripts/data-transfer.js import", + "data:export:sanitized": "node scripts/data-transfer.js export --sanitize", + "data:export:enhanced": "node scripts/data-transfer-enhanced.js export", + "data:export:encrypted": "node scripts/data-transfer-enhanced.js export --decrypt=false", + "data:import:enhanced": "node scripts/data-transfer-enhanced.js import", + "data:debug": "node scripts/debug-redis-keys.js", + "test:pricing-fallback": "node scripts/test-pricing-fallback.js" + }, + "dependencies": { + "@aws-sdk/client-bedrock-runtime": "^3.861.0", + "@aws-sdk/credential-providers": "^3.859.0", + "axios": "^1.6.0", + "bcryptjs": "^2.4.3", + "chalk": "^4.1.2", + "commander": "^11.1.0", + "compression": "^1.7.4", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "google-auth-library": "^10.1.0", + "helmet": "^7.1.0", + "https-proxy-agent": "^7.0.2", + "inquirer": "^8.2.6", + "ioredis": "^5.3.2", + "ldapjs": "^3.0.7", + "morgan": "^1.10.0", + "nodemailer": "^7.0.6", + "ora": "^5.4.1", + "rate-limiter-flexible": "^5.0.5", + "socks-proxy-agent": "^8.0.2", + "string-similarity": "^4.0.4", + "table": "^6.8.1", + "uuid": "^9.0.1", + "winston": "^3.11.0", + "winston-daily-rotate-file": "^4.7.1" + }, + "devDependencies": { + "@types/node": "^20.8.9", + "eslint": "^8.53.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.4", + "jest": "^29.7.0", + "nodemon": "^3.0.1", + "prettier": "^3.6.2", + "supertest": "^6.3.3" + }, + "engines": { + "node": ">=18.0.0" + }, + "keywords": [ + "claude", + "api", + "proxy", + "relay", + "claude-code", + "anthropic" + ], + "author": "Claude Relay Service", + "license": "MIT" +} diff --git a/resources/model-pricing/README.md b/resources/model-pricing/README.md new file mode 100644 index 0000000000000000000000000000000000000000..e6cfac4e3108d51f9bb9bc4995c2d577fdcf261f --- /dev/null +++ b/resources/model-pricing/README.md @@ -0,0 +1,37 @@ +# Model Pricing Data + +This directory contains a local copy of the LiteLLM model pricing data as a fallback mechanism. + +## Source +The original file is maintained by the LiteLLM project: +- Repository: https://github.com/BerriAI/litellm +- File: https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json + +## Purpose +This local copy serves as a fallback when the remote file cannot be downloaded due to: +- Network restrictions +- Firewall rules +- DNS resolution issues +- GitHub being blocked in certain regions +- Docker container network limitations + +## Update Process +The pricingService will: +1. First attempt to download the latest version from GitHub +2. If download fails, use this local copy as fallback +3. Log a warning when using the fallback file + +## Manual Update +To manually update this file with the latest pricing data: +```bash +curl -s https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json -o model_prices_and_context_window.json +``` + +## File Format +The file contains JSON data with model pricing information including: +- Model names and identifiers +- Input/output token costs +- Context window sizes +- Model capabilities + +Last updated: 2025-08-10 \ No newline at end of file diff --git a/resources/model-pricing/model_prices_and_context_window.json b/resources/model-pricing/model_prices_and_context_window.json new file mode 100644 index 0000000000000000000000000000000000000000..3987fe7e51114e751d6f814537dcb1bb983a3d2e --- /dev/null +++ b/resources/model-pricing/model_prices_and_context_window.json @@ -0,0 +1,22423 @@ +{ + "1024-x-1024/50-steps/bedrock/amazon.nova-canvas-v1:0": { + "litellm_provider": "bedrock", + "max_input_tokens": 2600, + "mode": "image_generation", + "output_cost_per_image": 0.06 + }, + "1024-x-1024/50-steps/stability.stable-diffusion-xl-v1": { + "litellm_provider": "bedrock", + "max_input_tokens": 77, + "max_tokens": 77, + "mode": "image_generation", + "output_cost_per_image": 0.04 + }, + "1024-x-1024/dall-e-2": { + "input_cost_per_pixel": 1.9e-08, + "litellm_provider": "openai", + "mode": "image_generation", + "output_cost_per_pixel": 0.0 + }, + "1024-x-1024/max-steps/stability.stable-diffusion-xl-v1": { + "litellm_provider": "bedrock", + "max_input_tokens": 77, + "max_tokens": 77, + "mode": "image_generation", + "output_cost_per_image": 0.08 + }, + "256-x-256/dall-e-2": { + "input_cost_per_pixel": 2.4414e-07, + "litellm_provider": "openai", + "mode": "image_generation", + "output_cost_per_pixel": 0.0 + }, + "512-x-512/50-steps/stability.stable-diffusion-xl-v0": { + "litellm_provider": "bedrock", + "max_input_tokens": 77, + "max_tokens": 77, + "mode": "image_generation", + "output_cost_per_image": 0.018 + }, + "512-x-512/dall-e-2": { + "input_cost_per_pixel": 6.86e-08, + "litellm_provider": "openai", + "mode": "image_generation", + "output_cost_per_pixel": 0.0 + }, + "512-x-512/max-steps/stability.stable-diffusion-xl-v0": { + "litellm_provider": "bedrock", + "max_input_tokens": 77, + "max_tokens": 77, + "mode": "image_generation", + "output_cost_per_image": 0.036 + }, + "ai21.j2-mid-v1": { + "input_cost_per_token": 1.25e-05, + "litellm_provider": "bedrock", + "max_input_tokens": 8191, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 1.25e-05 + }, + "ai21.j2-ultra-v1": { + "input_cost_per_token": 1.88e-05, + "litellm_provider": "bedrock", + "max_input_tokens": 8191, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 1.88e-05 + }, + "ai21.jamba-1-5-large-v1:0": { + "input_cost_per_token": 2e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 256000, + "max_output_tokens": 256000, + "max_tokens": 256000, + "mode": "chat", + "output_cost_per_token": 8e-06 + }, + "ai21.jamba-1-5-mini-v1:0": { + "input_cost_per_token": 2e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 256000, + "max_output_tokens": 256000, + "max_tokens": 256000, + "mode": "chat", + "output_cost_per_token": 4e-07 + }, + "ai21.jamba-instruct-v1:0": { + "input_cost_per_token": 5e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 70000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 7e-07, + "supports_system_messages": true + }, + "aiml/dall-e-2": { + "litellm_provider": "aiml", + "metadata": { + "notes": "DALL-E 2 via AI/ML API - Reliable text-to-image generation" + }, + "mode": "image_generation", + "output_cost_per_image": 0.021, + "source": "https://docs.aimlapi.com/", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "aiml/dall-e-3": { + "litellm_provider": "aiml", + "metadata": { + "notes": "DALL-E 3 via AI/ML API - High-quality text-to-image generation" + }, + "mode": "image_generation", + "output_cost_per_image": 0.042, + "source": "https://docs.aimlapi.com/", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "aiml/flux-pro": { + "litellm_provider": "aiml", + "metadata": { + "notes": "Flux Dev - Development version optimized for experimentation" + }, + "mode": "image_generation", + "output_cost_per_image": 0.053, + "source": "https://docs.aimlapi.com/", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "aiml/flux-pro/v1.1": { + "litellm_provider": "aiml", + "mode": "image_generation", + "output_cost_per_image": 0.042, + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "aiml/flux-pro/v1.1-ultra": { + "litellm_provider": "aiml", + "mode": "image_generation", + "output_cost_per_image": 0.063, + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "aiml/flux-realism": { + "litellm_provider": "aiml", + "metadata": { + "notes": "Flux Pro - Professional-grade image generation model" + }, + "mode": "image_generation", + "output_cost_per_image": 0.037, + "source": "https://docs.aimlapi.com/", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "aiml/flux/dev": { + "litellm_provider": "aiml", + "metadata": { + "notes": "Flux Dev - Development version optimized for experimentation" + }, + "mode": "image_generation", + "output_cost_per_image": 0.026, + "source": "https://docs.aimlapi.com/", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "aiml/flux/kontext-max/text-to-image": { + "litellm_provider": "aiml", + "metadata": { + "notes": "Flux Pro v1.1 - Enhanced version with improved capabilities and 6x faster inference speed" + }, + "mode": "image_generation", + "output_cost_per_image": 0.084, + "source": "https://docs.aimlapi.com/", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "aiml/flux/kontext-pro/text-to-image": { + "litellm_provider": "aiml", + "metadata": { + "notes": "Flux Pro v1.1 - Enhanced version with improved capabilities and 6x faster inference speed" + }, + "mode": "image_generation", + "output_cost_per_image": 0.042, + "source": "https://docs.aimlapi.com/", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "aiml/flux/schnell": { + "litellm_provider": "aiml", + "metadata": { + "notes": "Flux Schnell - Fast generation model optimized for speed" + }, + "mode": "image_generation", + "output_cost_per_image": 0.003, + "source": "https://docs.aimlapi.com/", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "amazon.nova-lite-v1:0": { + "input_cost_per_token": 6e-08, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 300000, + "max_output_tokens": 10000, + "max_tokens": 10000, + "mode": "chat", + "output_cost_per_token": 2.4e-07, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_vision": true + }, + "amazon.nova-micro-v1:0": { + "input_cost_per_token": 3.5e-08, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 128000, + "max_output_tokens": 10000, + "max_tokens": 10000, + "mode": "chat", + "output_cost_per_token": 1.4e-07, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true + }, + "amazon.nova-pro-v1:0": { + "input_cost_per_token": 8e-07, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 300000, + "max_output_tokens": 10000, + "max_tokens": 10000, + "mode": "chat", + "output_cost_per_token": 3.2e-06, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_vision": true + }, + "amazon.rerank-v1:0": { + "input_cost_per_query": 0.001, + "input_cost_per_token": 0.0, + "litellm_provider": "bedrock", + "max_document_chunks_per_query": 100, + "max_input_tokens": 32000, + "max_output_tokens": 32000, + "max_query_tokens": 32000, + "max_tokens": 32000, + "max_tokens_per_document_chunk": 512, + "mode": "rerank", + "output_cost_per_token": 0.0 + }, + "amazon.titan-embed-image-v1": { + "input_cost_per_image": 6e-05, + "input_cost_per_token": 8e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 128, + "max_tokens": 128, + "metadata": { + "notes": "'supports_image_input' is a deprecated field. Use 'supports_embedding_image_input' instead." + }, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_vector_size": 1024, + "source": "https://us-east-1.console.aws.amazon.com/bedrock/home?region=us-east-1#/providers?model=amazon.titan-image-generator-v1", + "supports_embedding_image_input": true, + "supports_image_input": true + }, + "amazon.titan-embed-text-v1": { + "input_cost_per_token": 1e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 8192, + "max_tokens": 8192, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_vector_size": 1536 + }, + "amazon.titan-embed-text-v2:0": { + "input_cost_per_token": 2e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 8192, + "max_tokens": 8192, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_vector_size": 1024 + }, + "twelvelabs.marengo-embed-2-7-v1:0": { + "input_cost_per_token": 7e-05, + "litellm_provider": "bedrock", + "max_input_tokens": 77, + "max_tokens": 77, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_vector_size": 1024, + "supports_embedding_image_input": true, + "supports_image_input": true + }, + "us.twelvelabs.marengo-embed-2-7-v1:0": { + "input_cost_per_token": 7e-05, + "input_cost_per_video_per_second": 0.0007, + "input_cost_per_audio_per_second": 0.00014, + "input_cost_per_image": 0.0001, + "litellm_provider": "bedrock", + "max_input_tokens": 77, + "max_tokens": 77, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_vector_size": 1024, + "supports_embedding_image_input": true, + "supports_image_input": true + }, + "eu.twelvelabs.marengo-embed-2-7-v1:0": { + "input_cost_per_token": 7e-05, + "input_cost_per_video_per_second": 0.0007, + "input_cost_per_audio_per_second": 0.00014, + "input_cost_per_image": 0.0001, + "litellm_provider": "bedrock", + "max_input_tokens": 77, + "max_tokens": 77, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_vector_size": 1024, + "supports_embedding_image_input": true, + "supports_image_input": true + }, + "twelvelabs.pegasus-1-2-v1:0": { + "input_cost_per_video_per_second": 0.00049, + "output_cost_per_token": 7.5e-06, + "litellm_provider": "bedrock", + "mode": "chat", + "supports_video_input": true + }, + "us.twelvelabs.pegasus-1-2-v1:0": { + "input_cost_per_video_per_second": 0.00049, + "output_cost_per_token": 7.5e-06, + "litellm_provider": "bedrock", + "mode": "chat", + "supports_video_input": true + }, + "eu.twelvelabs.pegasus-1-2-v1:0": { + "input_cost_per_video_per_second": 0.00049, + "output_cost_per_token": 7.5e-06, + "litellm_provider": "bedrock", + "mode": "chat", + "supports_video_input": true + }, + "amazon.titan-text-express-v1": { + "input_cost_per_token": 1.3e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 42000, + "max_output_tokens": 8000, + "max_tokens": 8000, + "mode": "chat", + "output_cost_per_token": 1.7e-06 + }, + "amazon.titan-text-lite-v1": { + "input_cost_per_token": 3e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 42000, + "max_output_tokens": 4000, + "max_tokens": 4000, + "mode": "chat", + "output_cost_per_token": 4e-07 + }, + "amazon.titan-text-premier-v1:0": { + "input_cost_per_token": 5e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 42000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 1.5e-06 + }, + "anthropic.claude-3-5-haiku-20241022-v1:0": { + "cache_creation_input_token_cost": 1e-06, + "cache_read_input_token_cost": 8e-08, + "input_cost_per_token": 8e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 4e-06, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "anthropic.claude-3-5-sonnet-20240620-v1:0": { + "input_cost_per_token": 3e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "anthropic.claude-3-5-sonnet-20241022-v2:0": { + "cache_creation_input_token_cost": 3.75e-06, + "cache_read_input_token_cost": 3e-07, + "input_cost_per_token": 3e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "anthropic.claude-3-7-sonnet-20250219-v1:0": { + "cache_creation_input_token_cost": 3.75e-06, + "cache_read_input_token_cost": 3e-07, + "input_cost_per_token": 3e-06, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "anthropic.claude-3-haiku-20240307-v1:0": { + "input_cost_per_token": 2.5e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.25e-06, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "anthropic.claude-3-opus-20240229-v1:0": { + "input_cost_per_token": 1.5e-05, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 7.5e-05, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "anthropic.claude-3-sonnet-20240229-v1:0": { + "input_cost_per_token": 3e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "anthropic.claude-instant-v1": { + "input_cost_per_token": 8e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 2.4e-06, + "supports_tool_choice": true + }, + "anthropic.claude-opus-4-1-20250805-v1:0": { + "cache_creation_input_token_cost": 1.875e-05, + "cache_read_input_token_cost": 1.5e-06, + "input_cost_per_token": 1.5e-05, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 7.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "anthropic.claude-opus-4-20250514-v1:0": { + "cache_creation_input_token_cost": 1.875e-05, + "cache_read_input_token_cost": 1.5e-06, + "input_cost_per_token": 1.5e-05, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 7.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "anthropic.claude-sonnet-4-20250514-v1:0": { + "cache_creation_input_token_cost": 3.75e-06, + "cache_read_input_token_cost": 3e-07, + "input_cost_per_token": 3e-06, + "input_cost_per_token_above_200k_tokens": 6e-06, + "output_cost_per_token_above_200k_tokens": 2.25e-05, + "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, + "cache_read_input_token_cost_above_200k_tokens": 6e-07, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "anthropic.claude-v1": { + "input_cost_per_token": 8e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 2.4e-05 + }, + "anthropic.claude-v2:1": { + "input_cost_per_token": 8e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 2.4e-05, + "supports_tool_choice": true + }, + "anyscale/HuggingFaceH4/zephyr-7b-beta": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "anyscale", + "max_input_tokens": 16384, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1.5e-07 + }, + "anyscale/codellama/CodeLlama-34b-Instruct-hf": { + "input_cost_per_token": 1e-06, + "litellm_provider": "anyscale", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1e-06 + }, + "anyscale/codellama/CodeLlama-70b-Instruct-hf": { + "input_cost_per_token": 1e-06, + "litellm_provider": "anyscale", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1e-06, + "source": "https://docs.anyscale.com/preview/endpoints/text-generation/supported-models/codellama-CodeLlama-70b-Instruct-hf" + }, + "anyscale/google/gemma-7b-it": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "anyscale", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.5e-07, + "source": "https://docs.anyscale.com/preview/endpoints/text-generation/supported-models/google-gemma-7b-it" + }, + "anyscale/meta-llama/Llama-2-13b-chat-hf": { + "input_cost_per_token": 2.5e-07, + "litellm_provider": "anyscale", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 2.5e-07 + }, + "anyscale/meta-llama/Llama-2-70b-chat-hf": { + "input_cost_per_token": 1e-06, + "litellm_provider": "anyscale", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1e-06 + }, + "anyscale/meta-llama/Llama-2-7b-chat-hf": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "anyscale", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.5e-07 + }, + "anyscale/meta-llama/Meta-Llama-3-70B-Instruct": { + "input_cost_per_token": 1e-06, + "litellm_provider": "anyscale", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1e-06, + "source": "https://docs.anyscale.com/preview/endpoints/text-generation/supported-models/meta-llama-Meta-Llama-3-70B-Instruct" + }, + "anyscale/meta-llama/Meta-Llama-3-8B-Instruct": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "anyscale", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.5e-07, + "source": "https://docs.anyscale.com/preview/endpoints/text-generation/supported-models/meta-llama-Meta-Llama-3-8B-Instruct" + }, + "anyscale/mistralai/Mistral-7B-Instruct-v0.1": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "anyscale", + "max_input_tokens": 16384, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1.5e-07, + "source": "https://docs.anyscale.com/preview/endpoints/text-generation/supported-models/mistralai-Mistral-7B-Instruct-v0.1", + "supports_function_calling": true + }, + "anyscale/mistralai/Mixtral-8x22B-Instruct-v0.1": { + "input_cost_per_token": 9e-07, + "litellm_provider": "anyscale", + "max_input_tokens": 65536, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 9e-07, + "source": "https://docs.anyscale.com/preview/endpoints/text-generation/supported-models/mistralai-Mixtral-8x22B-Instruct-v0.1", + "supports_function_calling": true + }, + "anyscale/mistralai/Mixtral-8x7B-Instruct-v0.1": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "anyscale", + "max_input_tokens": 16384, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1.5e-07, + "source": "https://docs.anyscale.com/preview/endpoints/text-generation/supported-models/mistralai-Mixtral-8x7B-Instruct-v0.1", + "supports_function_calling": true + }, + "apac.amazon.nova-lite-v1:0": { + "input_cost_per_token": 6.3e-08, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 300000, + "max_output_tokens": 10000, + "max_tokens": 10000, + "mode": "chat", + "output_cost_per_token": 2.52e-07, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_vision": true + }, + "apac.amazon.nova-micro-v1:0": { + "input_cost_per_token": 3.7e-08, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 128000, + "max_output_tokens": 10000, + "max_tokens": 10000, + "mode": "chat", + "output_cost_per_token": 1.48e-07, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true + }, + "apac.amazon.nova-pro-v1:0": { + "input_cost_per_token": 8.4e-07, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 300000, + "max_output_tokens": 10000, + "max_tokens": 10000, + "mode": "chat", + "output_cost_per_token": 3.36e-06, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_vision": true + }, + "apac.anthropic.claude-3-5-sonnet-20240620-v1:0": { + "input_cost_per_token": 3e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "apac.anthropic.claude-3-5-sonnet-20241022-v2:0": { + "cache_creation_input_token_cost": 3.75e-06, + "cache_read_input_token_cost": 3e-07, + "input_cost_per_token": 3e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "apac.anthropic.claude-3-haiku-20240307-v1:0": { + "input_cost_per_token": 2.5e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.25e-06, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "apac.anthropic.claude-3-sonnet-20240229-v1:0": { + "input_cost_per_token": 3e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "apac.anthropic.claude-sonnet-4-20250514-v1:0": { + "cache_creation_input_token_cost": 3.75e-06, + "cache_read_input_token_cost": 3e-07, + "input_cost_per_token": 3e-06, + "input_cost_per_token_above_200k_tokens": 6e-06, + "output_cost_per_token_above_200k_tokens": 2.25e-05, + "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, + "cache_read_input_token_cost_above_200k_tokens": 6e-07, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "assemblyai/best": { + "input_cost_per_second": 3.333e-05, + "litellm_provider": "assemblyai", + "mode": "audio_transcription", + "output_cost_per_second": 0.0 + }, + "assemblyai/nano": { + "input_cost_per_second": 0.00010278, + "litellm_provider": "assemblyai", + "mode": "audio_transcription", + "output_cost_per_second": 0.0 + }, + "azure/ada": { + "input_cost_per_token": 1e-07, + "litellm_provider": "azure", + "max_input_tokens": 8191, + "max_tokens": 8191, + "mode": "embedding", + "output_cost_per_token": 0.0 + }, + "azure/codex-mini": { + "cache_read_input_token_cost": 3.75e-07, + "input_cost_per_token": 1.5e-06, + "litellm_provider": "azure", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "responses", + "output_cost_per_token": 6e-06, + "supported_endpoints": [ + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure/command-r-plus": { + "input_cost_per_token": 3e-06, + "litellm_provider": "azure", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "supports_function_calling": true + }, + "azure/computer-use-preview": { + "input_cost_per_token": 3e-06, + "litellm_provider": "azure", + "max_input_tokens": 8192, + "max_output_tokens": 1024, + "max_tokens": 1024, + "mode": "chat", + "output_cost_per_token": 1.2e-05, + "supported_endpoints": [ + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure/eu/gpt-4o-2024-08-06": { + "cache_read_input_token_cost": 1.375e-06, + "input_cost_per_token": 2.75e-06, + "litellm_provider": "azure", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1.1e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure/eu/gpt-4o-2024-11-20": { + "cache_creation_input_token_cost": 1.38e-06, + "input_cost_per_token": 2.75e-06, + "litellm_provider": "azure", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1.1e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure/eu/gpt-4o-mini-2024-07-18": { + "cache_read_input_token_cost": 8.3e-08, + "input_cost_per_token": 1.65e-07, + "litellm_provider": "azure", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 6.6e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure/eu/gpt-4o-mini-realtime-preview-2024-12-17": { + "cache_creation_input_audio_token_cost": 3.3e-07, + "cache_read_input_token_cost": 3.3e-07, + "input_cost_per_audio_token": 1.1e-05, + "input_cost_per_token": 6.6e-07, + "litellm_provider": "azure", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_audio_token": 2.2e-05, + "output_cost_per_token": 2.64e-06, + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "azure/eu/gpt-4o-realtime-preview-2024-10-01": { + "cache_creation_input_audio_token_cost": 2.2e-05, + "cache_read_input_token_cost": 2.75e-06, + "input_cost_per_audio_token": 0.00011, + "input_cost_per_token": 5.5e-06, + "litellm_provider": "azure", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_audio_token": 0.00022, + "output_cost_per_token": 2.2e-05, + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "azure/eu/gpt-4o-realtime-preview-2024-12-17": { + "cache_read_input_audio_token_cost": 2.5e-06, + "cache_read_input_token_cost": 2.75e-06, + "input_cost_per_audio_token": 4.4e-05, + "input_cost_per_token": 5.5e-06, + "litellm_provider": "azure", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_audio_token": 8e-05, + "output_cost_per_token": 2.2e-05, + "supported_modalities": [ + "text", + "audio" + ], + "supported_output_modalities": [ + "text", + "audio" + ], + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "azure/eu/o1-2024-12-17": { + "cache_read_input_token_cost": 8.25e-06, + "input_cost_per_token": 1.65e-05, + "litellm_provider": "azure", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "chat", + "output_cost_per_token": 6.6e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure/eu/o1-mini-2024-09-12": { + "cache_read_input_token_cost": 6.05e-07, + "input_cost_per_token": 1.21e-06, + "input_cost_per_token_batches": 6.05e-07, + "litellm_provider": "azure", + "max_input_tokens": 128000, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 4.84e-06, + "output_cost_per_token_batches": 2.42e-06, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_vision": false + }, + "azure/eu/o1-preview-2024-09-12": { + "cache_read_input_token_cost": 8.25e-06, + "input_cost_per_token": 1.65e-05, + "litellm_provider": "azure", + "max_input_tokens": 128000, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 6.6e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_vision": false + }, + "azure/eu/o3-mini-2025-01-31": { + "cache_read_input_token_cost": 6.05e-07, + "input_cost_per_token": 1.21e-06, + "input_cost_per_token_batches": 6.05e-07, + "litellm_provider": "azure", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "chat", + "output_cost_per_token": 4.84e-06, + "output_cost_per_token_batches": 2.42e-06, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "azure/global-standard/gpt-4o-2024-08-06": { + "cache_read_input_token_cost": 1.25e-06, + "deprecation_date": "2025-08-20", + "input_cost_per_token": 2.5e-06, + "litellm_provider": "azure", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure/global-standard/gpt-4o-2024-11-20": { + "cache_read_input_token_cost": 1.25e-06, + "deprecation_date": "2025-12-20", + "input_cost_per_token": 2.5e-06, + "litellm_provider": "azure", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure/global-standard/gpt-4o-mini": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "azure", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 6e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure/global/gpt-4o-2024-08-06": { + "cache_read_input_token_cost": 1.25e-06, + "input_cost_per_token": 2.5e-06, + "litellm_provider": "azure", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure/global/gpt-4o-2024-11-20": { + "cache_read_input_token_cost": 1.25e-06, + "input_cost_per_token": 2.5e-06, + "litellm_provider": "azure", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure/gpt-3.5-turbo": { + "input_cost_per_token": 5e-07, + "litellm_provider": "azure", + "max_input_tokens": 4097, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.5e-06, + "supports_function_calling": true, + "supports_tool_choice": true + }, + "azure/gpt-3.5-turbo-0125": { + "deprecation_date": "2025-03-31", + "input_cost_per_token": 5e-07, + "litellm_provider": "azure", + "max_input_tokens": 16384, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.5e-06, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true + }, + "azure/gpt-3.5-turbo-instruct-0914": { + "input_cost_per_token": 1.5e-06, + "litellm_provider": "azure_text", + "max_input_tokens": 4097, + "max_tokens": 4097, + "mode": "completion", + "output_cost_per_token": 2e-06 + }, + "azure/gpt-35-turbo": { + "input_cost_per_token": 5e-07, + "litellm_provider": "azure", + "max_input_tokens": 4097, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.5e-06, + "supports_function_calling": true, + "supports_tool_choice": true + }, + "azure/gpt-35-turbo-0125": { + "deprecation_date": "2025-05-31", + "input_cost_per_token": 5e-07, + "litellm_provider": "azure", + "max_input_tokens": 16384, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.5e-06, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true + }, + "azure/gpt-35-turbo-0301": { + "deprecation_date": "2025-02-13", + "input_cost_per_token": 2e-07, + "litellm_provider": "azure", + "max_input_tokens": 4097, + "max_output_tokens": 4096, + "max_tokens": 4097, + "mode": "chat", + "output_cost_per_token": 2e-06, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true + }, + "azure/gpt-35-turbo-0613": { + "deprecation_date": "2025-02-13", + "input_cost_per_token": 1.5e-06, + "litellm_provider": "azure", + "max_input_tokens": 4097, + "max_output_tokens": 4096, + "max_tokens": 4097, + "mode": "chat", + "output_cost_per_token": 2e-06, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true + }, + "azure/gpt-35-turbo-1106": { + "deprecation_date": "2025-03-31", + "input_cost_per_token": 1e-06, + "litellm_provider": "azure", + "max_input_tokens": 16384, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 2e-06, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true + }, + "azure/gpt-35-turbo-16k": { + "input_cost_per_token": 3e-06, + "litellm_provider": "azure", + "max_input_tokens": 16385, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 4e-06, + "supports_tool_choice": true + }, + "azure/gpt-35-turbo-16k-0613": { + "input_cost_per_token": 3e-06, + "litellm_provider": "azure", + "max_input_tokens": 16385, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 4e-06, + "supports_function_calling": true, + "supports_tool_choice": true + }, + "azure/gpt-35-turbo-instruct": { + "input_cost_per_token": 1.5e-06, + "litellm_provider": "azure_text", + "max_input_tokens": 4097, + "max_tokens": 4097, + "mode": "completion", + "output_cost_per_token": 2e-06 + }, + "azure/gpt-35-turbo-instruct-0914": { + "input_cost_per_token": 1.5e-06, + "litellm_provider": "azure_text", + "max_input_tokens": 4097, + "max_tokens": 4097, + "mode": "completion", + "output_cost_per_token": 2e-06 + }, + "azure/gpt-4": { + "input_cost_per_token": 3e-05, + "litellm_provider": "azure", + "max_input_tokens": 8192, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 6e-05, + "supports_function_calling": true, + "supports_tool_choice": true + }, + "azure/gpt-4-0125-preview": { + "input_cost_per_token": 1e-05, + "litellm_provider": "azure", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 3e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true + }, + "azure/gpt-4-0613": { + "input_cost_per_token": 3e-05, + "litellm_provider": "azure", + "max_input_tokens": 8192, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 6e-05, + "supports_function_calling": true, + "supports_tool_choice": true + }, + "azure/gpt-4-1106-preview": { + "input_cost_per_token": 1e-05, + "litellm_provider": "azure", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 3e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true + }, + "azure/gpt-4-32k": { + "input_cost_per_token": 6e-05, + "litellm_provider": "azure", + "max_input_tokens": 32768, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.00012, + "supports_tool_choice": true + }, + "azure/gpt-4-32k-0613": { + "input_cost_per_token": 6e-05, + "litellm_provider": "azure", + "max_input_tokens": 32768, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.00012, + "supports_tool_choice": true + }, + "azure/gpt-4-turbo": { + "input_cost_per_token": 1e-05, + "litellm_provider": "azure", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 3e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true + }, + "azure/gpt-4-turbo-2024-04-09": { + "input_cost_per_token": 1e-05, + "litellm_provider": "azure", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 3e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure/gpt-4-turbo-vision-preview": { + "input_cost_per_token": 1e-05, + "litellm_provider": "azure", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 3e-05, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure/gpt-4.1": { + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token": 2e-06, + "input_cost_per_token_batches": 1e-06, + "litellm_provider": "azure", + "max_input_tokens": 1047576, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 8e-06, + "output_cost_per_token_batches": 4e-06, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "azure/gpt-4.1-2025-04-14": { + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token": 2e-06, + "input_cost_per_token_batches": 1e-06, + "litellm_provider": "azure", + "max_input_tokens": 1047576, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 8e-06, + "output_cost_per_token_batches": 4e-06, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "azure/gpt-4.1-mini": { + "cache_read_input_token_cost": 1e-07, + "input_cost_per_token": 4e-07, + "input_cost_per_token_batches": 2e-07, + "litellm_provider": "azure", + "max_input_tokens": 1047576, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 1.6e-06, + "output_cost_per_token_batches": 8e-07, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "azure/gpt-4.1-mini-2025-04-14": { + "cache_read_input_token_cost": 1e-07, + "input_cost_per_token": 4e-07, + "input_cost_per_token_batches": 2e-07, + "litellm_provider": "azure", + "max_input_tokens": 1047576, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 1.6e-06, + "output_cost_per_token_batches": 8e-07, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": false + }, + "azure/gpt-4.1-nano": { + "cache_read_input_token_cost": 2.5e-08, + "input_cost_per_token": 1e-07, + "input_cost_per_token_batches": 5e-08, + "litellm_provider": "azure", + "max_input_tokens": 1047576, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 4e-07, + "output_cost_per_token_batches": 2e-07, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure/gpt-4.1-nano-2025-04-14": { + "cache_read_input_token_cost": 2.5e-08, + "input_cost_per_token": 1e-07, + "input_cost_per_token_batches": 5e-08, + "litellm_provider": "azure", + "max_input_tokens": 1047576, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 4e-07, + "output_cost_per_token_batches": 2e-07, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure/gpt-4.5-preview": { + "cache_read_input_token_cost": 3.75e-05, + "input_cost_per_token": 7.5e-05, + "input_cost_per_token_batches": 3.75e-05, + "litellm_provider": "azure", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 0.00015, + "output_cost_per_token_batches": 7.5e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure/gpt-4o": { + "cache_read_input_token_cost": 1.25e-06, + "input_cost_per_token": 2.5e-06, + "litellm_provider": "azure", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure/gpt-4o-2024-05-13": { + "input_cost_per_token": 5e-06, + "litellm_provider": "azure", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure/gpt-4o-2024-08-06": { + "cache_read_input_token_cost": 1.25e-06, + "input_cost_per_token": 2.5e-06, + "litellm_provider": "azure", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure/gpt-4o-2024-11-20": { + "cache_read_input_token_cost": 1.25e-06, + "input_cost_per_token": 2.75e-06, + "litellm_provider": "azure", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1.1e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure/gpt-4o-audio-preview-2024-12-17": { + "input_cost_per_audio_token": 4e-05, + "input_cost_per_token": 2.5e-06, + "litellm_provider": "azure", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_audio_token": 8e-05, + "output_cost_per_token": 1e-05, + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supported_modalities": [ + "text", + "audio" + ], + "supported_output_modalities": [ + "text", + "audio" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": false, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "azure/gpt-4o-mini": { + "cache_read_input_token_cost": 7.5e-08, + "input_cost_per_token": 1.65e-07, + "litellm_provider": "azure", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 6.6e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure/gpt-4o-mini-2024-07-18": { + "cache_read_input_token_cost": 7.5e-08, + "input_cost_per_token": 1.65e-07, + "litellm_provider": "azure", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 6.6e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure/gpt-4o-mini-audio-preview-2024-12-17": { + "input_cost_per_audio_token": 4e-05, + "input_cost_per_token": 2.5e-06, + "litellm_provider": "azure", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_audio_token": 8e-05, + "output_cost_per_token": 1e-05, + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supported_modalities": [ + "text", + "audio" + ], + "supported_output_modalities": [ + "text", + "audio" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": false, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "azure/gpt-4o-mini-realtime-preview-2024-12-17": { + "cache_creation_input_audio_token_cost": 3e-07, + "cache_read_input_token_cost": 3e-07, + "input_cost_per_audio_token": 1e-05, + "input_cost_per_token": 6e-07, + "litellm_provider": "azure", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_audio_token": 2e-05, + "output_cost_per_token": 2.4e-06, + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "azure/gpt-4o-mini-transcribe": { + "input_cost_per_audio_token": 3e-06, + "input_cost_per_token": 1.25e-06, + "litellm_provider": "azure", + "max_input_tokens": 16000, + "max_output_tokens": 2000, + "mode": "audio_transcription", + "output_cost_per_token": 5e-06, + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, + "azure/gpt-4o-mini-tts": { + "input_cost_per_token": 2.5e-06, + "litellm_provider": "azure", + "mode": "audio_speech", + "output_cost_per_audio_token": 1.2e-05, + "output_cost_per_second": 0.00025, + "output_cost_per_token": 1e-05, + "supported_endpoints": [ + "/v1/audio/speech" + ], + "supported_modalities": [ + "text", + "audio" + ], + "supported_output_modalities": [ + "audio" + ] + }, + "azure/gpt-4o-realtime-preview-2024-10-01": { + "cache_creation_input_audio_token_cost": 2e-05, + "cache_read_input_token_cost": 2.5e-06, + "input_cost_per_audio_token": 0.0001, + "input_cost_per_token": 5e-06, + "litellm_provider": "azure", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_audio_token": 0.0002, + "output_cost_per_token": 2e-05, + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "azure/gpt-4o-realtime-preview-2024-12-17": { + "cache_read_input_token_cost": 2.5e-06, + "input_cost_per_audio_token": 4e-05, + "input_cost_per_token": 5e-06, + "litellm_provider": "azure", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_audio_token": 8e-05, + "output_cost_per_token": 2e-05, + "supported_modalities": [ + "text", + "audio" + ], + "supported_output_modalities": [ + "text", + "audio" + ], + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "azure/gpt-4o-transcribe": { + "input_cost_per_audio_token": 6e-06, + "input_cost_per_token": 2.5e-06, + "litellm_provider": "azure", + "max_input_tokens": 16000, + "max_output_tokens": 2000, + "mode": "audio_transcription", + "output_cost_per_token": 1e-05, + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, + "azure/gpt-5": { + "cache_read_input_token_cost": 1.25e-07, + "input_cost_per_token": 1.25e-06, + "litellm_provider": "azure", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure/gpt-5-2025-08-07": { + "cache_read_input_token_cost": 1.25e-07, + "input_cost_per_token": 1.25e-06, + "litellm_provider": "azure", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure/gpt-5-chat": { + "cache_read_input_token_cost": 1.25e-07, + "input_cost_per_token": 1.25e-06, + "litellm_provider": "azure", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1e-05, + "source": "https://azure.microsoft.com/en-us/blog/gpt-5-in-azure-ai-foundry-the-future-of-ai-apps-and-agents-starts-here/", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": false, + "supports_vision": true + }, + "azure/gpt-5-chat-latest": { + "cache_read_input_token_cost": 1.25e-07, + "input_cost_per_token": 1.25e-06, + "litellm_provider": "azure", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": false, + "supports_vision": true + }, + "azure/gpt-5-codex": { + "cache_read_input_token_cost": 1.25e-07, + "input_cost_per_token": 1.25e-06, + "litellm_provider": "azure", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "responses", + "output_cost_per_token": 1e-05, + "supported_endpoints": [ + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure/gpt-5-mini": { + "cache_read_input_token_cost": 2.5e-08, + "input_cost_per_token": 2.5e-07, + "litellm_provider": "azure", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2e-06, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure/gpt-5-mini-2025-08-07": { + "cache_read_input_token_cost": 2.5e-08, + "input_cost_per_token": 2.5e-07, + "litellm_provider": "azure", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2e-06, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure/gpt-5-nano": { + "cache_read_input_token_cost": 5e-09, + "input_cost_per_token": 5e-08, + "litellm_provider": "azure", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 4e-07, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure/gpt-5-nano-2025-08-07": { + "cache_read_input_token_cost": 5e-09, + "input_cost_per_token": 5e-08, + "litellm_provider": "azure", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 4e-07, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure/gpt-image-1": { + "input_cost_per_pixel": 4.0054321e-08, + "litellm_provider": "azure", + "mode": "image_generation", + "output_cost_per_pixel": 0.0, + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "azure/hd/1024-x-1024/dall-e-3": { + "input_cost_per_pixel": 7.629e-08, + "litellm_provider": "azure", + "mode": "image_generation", + "output_cost_per_token": 0.0 + }, + "azure/hd/1024-x-1792/dall-e-3": { + "input_cost_per_pixel": 6.539e-08, + "litellm_provider": "azure", + "mode": "image_generation", + "output_cost_per_token": 0.0 + }, + "azure/hd/1792-x-1024/dall-e-3": { + "input_cost_per_pixel": 6.539e-08, + "litellm_provider": "azure", + "mode": "image_generation", + "output_cost_per_token": 0.0 + }, + "azure/high/1024-x-1024/gpt-image-1": { + "input_cost_per_pixel": 1.59263611e-07, + "litellm_provider": "azure", + "mode": "image_generation", + "output_cost_per_pixel": 0.0, + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "azure/high/1024-x-1536/gpt-image-1": { + "input_cost_per_pixel": 1.58945719e-07, + "litellm_provider": "azure", + "mode": "image_generation", + "output_cost_per_pixel": 0.0, + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "azure/high/1536-x-1024/gpt-image-1": { + "input_cost_per_pixel": 1.58945719e-07, + "litellm_provider": "azure", + "mode": "image_generation", + "output_cost_per_pixel": 0.0, + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "azure/low/1024-x-1024/gpt-image-1": { + "input_cost_per_pixel": 1.0490417e-08, + "litellm_provider": "azure", + "mode": "image_generation", + "output_cost_per_pixel": 0.0, + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "azure/low/1024-x-1536/gpt-image-1": { + "input_cost_per_pixel": 1.0172526e-08, + "litellm_provider": "azure", + "mode": "image_generation", + "output_cost_per_pixel": 0.0, + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "azure/low/1536-x-1024/gpt-image-1": { + "input_cost_per_pixel": 1.0172526e-08, + "litellm_provider": "azure", + "mode": "image_generation", + "output_cost_per_pixel": 0.0, + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "azure/medium/1024-x-1024/gpt-image-1": { + "input_cost_per_pixel": 4.0054321e-08, + "litellm_provider": "azure", + "mode": "image_generation", + "output_cost_per_pixel": 0.0, + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "azure/medium/1024-x-1536/gpt-image-1": { + "input_cost_per_pixel": 4.0054321e-08, + "litellm_provider": "azure", + "mode": "image_generation", + "output_cost_per_pixel": 0.0, + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "azure/medium/1536-x-1024/gpt-image-1": { + "input_cost_per_pixel": 4.0054321e-08, + "litellm_provider": "azure", + "mode": "image_generation", + "output_cost_per_pixel": 0.0, + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "azure/mistral-large-2402": { + "input_cost_per_token": 8e-06, + "litellm_provider": "azure", + "max_input_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 2.4e-05, + "supports_function_calling": true + }, + "azure/mistral-large-latest": { + "input_cost_per_token": 8e-06, + "litellm_provider": "azure", + "max_input_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 2.4e-05, + "supports_function_calling": true + }, + "azure/o1": { + "cache_read_input_token_cost": 7.5e-06, + "input_cost_per_token": 1.5e-05, + "litellm_provider": "azure", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "chat", + "output_cost_per_token": 6e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure/o1-2024-12-17": { + "cache_read_input_token_cost": 7.5e-06, + "input_cost_per_token": 1.5e-05, + "litellm_provider": "azure", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "chat", + "output_cost_per_token": 6e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure/o1-mini": { + "cache_read_input_token_cost": 6.05e-07, + "input_cost_per_token": 1.21e-06, + "litellm_provider": "azure", + "max_input_tokens": 128000, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 4.84e-06, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_vision": false + }, + "azure/o1-mini-2024-09-12": { + "cache_read_input_token_cost": 5.5e-07, + "input_cost_per_token": 1.1e-06, + "litellm_provider": "azure", + "max_input_tokens": 128000, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 4.4e-06, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_vision": false + }, + "azure/o1-preview": { + "cache_read_input_token_cost": 7.5e-06, + "input_cost_per_token": 1.5e-05, + "litellm_provider": "azure", + "max_input_tokens": 128000, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 6e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_vision": false + }, + "azure/o1-preview-2024-09-12": { + "cache_read_input_token_cost": 7.5e-06, + "input_cost_per_token": 1.5e-05, + "litellm_provider": "azure", + "max_input_tokens": 128000, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 6e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_vision": false + }, + "azure/o3": { + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token": 2e-06, + "litellm_provider": "azure", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "chat", + "output_cost_per_token": 8e-06, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure/o3-2025-04-16": { + "cache_read_input_token_cost": 2.5e-06, + "input_cost_per_token": 1e-05, + "litellm_provider": "azure", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "chat", + "output_cost_per_token": 4e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure/o3-deep-research": { + "cache_read_input_token_cost": 2.5e-06, + "input_cost_per_token": 1e-05, + "litellm_provider": "azure", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "responses", + "output_cost_per_token": 4e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "azure/o3-mini": { + "cache_read_input_token_cost": 5.5e-07, + "input_cost_per_token": 1.1e-06, + "litellm_provider": "azure", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "chat", + "output_cost_per_token": 4.4e-06, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "azure/o3-mini-2025-01-31": { + "cache_read_input_token_cost": 5.5e-07, + "input_cost_per_token": 1.1e-06, + "litellm_provider": "azure", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "chat", + "output_cost_per_token": 4.4e-06, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "azure/o3-pro": { + "input_cost_per_token": 2e-05, + "input_cost_per_token_batches": 1e-05, + "litellm_provider": "azure", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "responses", + "output_cost_per_token": 8e-05, + "output_cost_per_token_batches": 4e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure/o3-pro-2025-06-10": { + "input_cost_per_token": 2e-05, + "input_cost_per_token_batches": 1e-05, + "litellm_provider": "azure", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "responses", + "output_cost_per_token": 8e-05, + "output_cost_per_token_batches": 4e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": false, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure/o4-mini": { + "cache_read_input_token_cost": 2.75e-07, + "input_cost_per_token": 1.1e-06, + "litellm_provider": "azure", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "chat", + "output_cost_per_token": 4.4e-06, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure/o4-mini-2025-04-16": { + "cache_read_input_token_cost": 2.75e-07, + "input_cost_per_token": 1.1e-06, + "litellm_provider": "azure", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "chat", + "output_cost_per_token": 4.4e-06, + "supports_function_calling": true, + "supports_parallel_function_calling": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure/standard/1024-x-1024/dall-e-2": { + "input_cost_per_pixel": 0.0, + "litellm_provider": "azure", + "mode": "image_generation", + "output_cost_per_token": 0.0 + }, + "azure/standard/1024-x-1024/dall-e-3": { + "input_cost_per_pixel": 3.81469e-08, + "litellm_provider": "azure", + "mode": "image_generation", + "output_cost_per_token": 0.0 + }, + "azure/standard/1024-x-1792/dall-e-3": { + "input_cost_per_pixel": 4.359e-08, + "litellm_provider": "azure", + "mode": "image_generation", + "output_cost_per_token": 0.0 + }, + "azure/standard/1792-x-1024/dall-e-3": { + "input_cost_per_pixel": 4.359e-08, + "litellm_provider": "azure", + "mode": "image_generation", + "output_cost_per_token": 0.0 + }, + "azure/text-embedding-3-large": { + "input_cost_per_token": 1.3e-07, + "litellm_provider": "azure", + "max_input_tokens": 8191, + "max_tokens": 8191, + "mode": "embedding", + "output_cost_per_token": 0.0 + }, + "azure/text-embedding-3-small": { + "input_cost_per_token": 2e-08, + "litellm_provider": "azure", + "max_input_tokens": 8191, + "max_tokens": 8191, + "mode": "embedding", + "output_cost_per_token": 0.0 + }, + "azure/text-embedding-ada-002": { + "input_cost_per_token": 1e-07, + "litellm_provider": "azure", + "max_input_tokens": 8191, + "max_tokens": 8191, + "mode": "embedding", + "output_cost_per_token": 0.0 + }, + "azure/tts-1": { + "input_cost_per_character": 1.5e-05, + "litellm_provider": "azure", + "mode": "audio_speech" + }, + "azure/tts-1-hd": { + "input_cost_per_character": 3e-05, + "litellm_provider": "azure", + "mode": "audio_speech" + }, + "azure/us/gpt-4o-2024-08-06": { + "cache_read_input_token_cost": 1.375e-06, + "input_cost_per_token": 2.75e-06, + "litellm_provider": "azure", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1.1e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure/us/gpt-4o-2024-11-20": { + "cache_creation_input_token_cost": 1.38e-06, + "input_cost_per_token": 2.75e-06, + "litellm_provider": "azure", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1.1e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure/us/gpt-4o-mini-2024-07-18": { + "cache_read_input_token_cost": 8.3e-08, + "input_cost_per_token": 1.65e-07, + "litellm_provider": "azure", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 6.6e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure/us/gpt-4o-mini-realtime-preview-2024-12-17": { + "cache_creation_input_audio_token_cost": 3.3e-07, + "cache_read_input_token_cost": 3.3e-07, + "input_cost_per_audio_token": 1.1e-05, + "input_cost_per_token": 6.6e-07, + "litellm_provider": "azure", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_audio_token": 2.2e-05, + "output_cost_per_token": 2.64e-06, + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "azure/us/gpt-4o-realtime-preview-2024-10-01": { + "cache_creation_input_audio_token_cost": 2.2e-05, + "cache_read_input_token_cost": 2.75e-06, + "input_cost_per_audio_token": 0.00011, + "input_cost_per_token": 5.5e-06, + "litellm_provider": "azure", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_audio_token": 0.00022, + "output_cost_per_token": 2.2e-05, + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "azure/us/gpt-4o-realtime-preview-2024-12-17": { + "cache_read_input_audio_token_cost": 2.5e-06, + "cache_read_input_token_cost": 2.75e-06, + "input_cost_per_audio_token": 4.4e-05, + "input_cost_per_token": 5.5e-06, + "litellm_provider": "azure", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_audio_token": 8e-05, + "output_cost_per_token": 2.2e-05, + "supported_modalities": [ + "text", + "audio" + ], + "supported_output_modalities": [ + "text", + "audio" + ], + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "azure/us/o1-2024-12-17": { + "cache_read_input_token_cost": 8.25e-06, + "input_cost_per_token": 1.65e-05, + "litellm_provider": "azure", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "chat", + "output_cost_per_token": 6.6e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure/us/o1-mini-2024-09-12": { + "cache_read_input_token_cost": 6.05e-07, + "input_cost_per_token": 1.21e-06, + "input_cost_per_token_batches": 6.05e-07, + "litellm_provider": "azure", + "max_input_tokens": 128000, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 4.84e-06, + "output_cost_per_token_batches": 2.42e-06, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_vision": false + }, + "azure/us/o1-preview-2024-09-12": { + "cache_read_input_token_cost": 8.25e-06, + "input_cost_per_token": 1.65e-05, + "litellm_provider": "azure", + "max_input_tokens": 128000, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 6.6e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_vision": false + }, + "azure/us/o3-mini-2025-01-31": { + "cache_read_input_token_cost": 6.05e-07, + "input_cost_per_token": 1.21e-06, + "input_cost_per_token_batches": 6.05e-07, + "litellm_provider": "azure", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "chat", + "output_cost_per_token": 4.84e-06, + "output_cost_per_token_batches": 2.42e-06, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "azure/whisper-1": { + "input_cost_per_second": 0.0001, + "litellm_provider": "azure", + "mode": "audio_transcription", + "output_cost_per_second": 0.0001 + }, + "azure_ai/Cohere-embed-v3-english": { + "input_cost_per_token": 1e-07, + "litellm_provider": "azure_ai", + "max_input_tokens": 512, + "max_tokens": 512, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_vector_size": 1024, + "source": "https://azuremarketplace.microsoft.com/en-us/marketplace/apps/cohere.cohere-embed-v3-english-offer?tab=PlansAndPrice", + "supports_embedding_image_input": true + }, + "azure_ai/Cohere-embed-v3-multilingual": { + "input_cost_per_token": 1e-07, + "litellm_provider": "azure_ai", + "max_input_tokens": 512, + "max_tokens": 512, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_vector_size": 1024, + "source": "https://azuremarketplace.microsoft.com/en-us/marketplace/apps/cohere.cohere-embed-v3-english-offer?tab=PlansAndPrice", + "supports_embedding_image_input": true + }, + "azure_ai/FLUX-1.1-pro": { + "litellm_provider": "azure_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/black-forest-labs-flux-1-kontext-pro-and-flux1-1-pro-now-available-in-azure-ai-f/4434659", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "azure_ai/FLUX.1-Kontext-pro": { + "litellm_provider": "azure_ai", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://azuremarketplace.microsoft.com/pt-br/marketplace/apps/cohere.cohere-embed-4-offer?tab=PlansAndPrice", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "azure_ai/Llama-3.2-11B-Vision-Instruct": { + "input_cost_per_token": 3.7e-07, + "litellm_provider": "azure_ai", + "max_input_tokens": 128000, + "max_output_tokens": 2048, + "max_tokens": 2048, + "mode": "chat", + "output_cost_per_token": 3.7e-07, + "source": "https://azuremarketplace.microsoft.com/en/marketplace/apps/metagenai.meta-llama-3-2-11b-vision-instruct-offer?tab=Overview", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure_ai/Llama-3.2-90B-Vision-Instruct": { + "input_cost_per_token": 2.04e-06, + "litellm_provider": "azure_ai", + "max_input_tokens": 128000, + "max_output_tokens": 2048, + "max_tokens": 2048, + "mode": "chat", + "output_cost_per_token": 2.04e-06, + "source": "https://azuremarketplace.microsoft.com/en/marketplace/apps/metagenai.meta-llama-3-2-90b-vision-instruct-offer?tab=Overview", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure_ai/Llama-3.3-70B-Instruct": { + "input_cost_per_token": 7.1e-07, + "litellm_provider": "azure_ai", + "max_input_tokens": 128000, + "max_output_tokens": 2048, + "max_tokens": 2048, + "mode": "chat", + "output_cost_per_token": 7.1e-07, + "source": "https://azuremarketplace.microsoft.com/en/marketplace/apps/metagenai.llama-3-3-70b-instruct-offer?tab=Overview", + "supports_function_calling": true, + "supports_tool_choice": true + }, + "azure_ai/Llama-4-Maverick-17B-128E-Instruct-FP8": { + "input_cost_per_token": 1.41e-06, + "litellm_provider": "azure_ai", + "max_input_tokens": 1000000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 3.5e-07, + "source": "https://azure.microsoft.com/en-us/blog/introducing-the-llama-4-herd-in-azure-ai-foundry-and-azure-databricks/", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure_ai/Llama-4-Scout-17B-16E-Instruct": { + "input_cost_per_token": 2e-07, + "litellm_provider": "azure_ai", + "max_input_tokens": 10000000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 7.8e-07, + "source": "https://azure.microsoft.com/en-us/blog/introducing-the-llama-4-herd-in-azure-ai-foundry-and-azure-databricks/", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "azure_ai/Meta-Llama-3-70B-Instruct": { + "input_cost_per_token": 1.1e-06, + "litellm_provider": "azure_ai", + "max_input_tokens": 8192, + "max_output_tokens": 2048, + "max_tokens": 2048, + "mode": "chat", + "output_cost_per_token": 3.7e-07, + "supports_tool_choice": true + }, + "azure_ai/Meta-Llama-3.1-405B-Instruct": { + "input_cost_per_token": 5.33e-06, + "litellm_provider": "azure_ai", + "max_input_tokens": 128000, + "max_output_tokens": 2048, + "max_tokens": 2048, + "mode": "chat", + "output_cost_per_token": 1.6e-05, + "source": "https://azuremarketplace.microsoft.com/en-us/marketplace/apps/metagenai.meta-llama-3-1-405b-instruct-offer?tab=PlansAndPrice", + "supports_tool_choice": true + }, + "azure_ai/Meta-Llama-3.1-70B-Instruct": { + "input_cost_per_token": 2.68e-06, + "litellm_provider": "azure_ai", + "max_input_tokens": 128000, + "max_output_tokens": 2048, + "max_tokens": 2048, + "mode": "chat", + "output_cost_per_token": 3.54e-06, + "source": "https://azuremarketplace.microsoft.com/en-us/marketplace/apps/metagenai.meta-llama-3-1-70b-instruct-offer?tab=PlansAndPrice", + "supports_tool_choice": true + }, + "azure_ai/Meta-Llama-3.1-8B-Instruct": { + "input_cost_per_token": 3e-07, + "litellm_provider": "azure_ai", + "max_input_tokens": 128000, + "max_output_tokens": 2048, + "max_tokens": 2048, + "mode": "chat", + "output_cost_per_token": 6.1e-07, + "source": "https://azuremarketplace.microsoft.com/en-us/marketplace/apps/metagenai.meta-llama-3-1-8b-instruct-offer?tab=PlansAndPrice", + "supports_tool_choice": true + }, + "azure_ai/Phi-3-medium-128k-instruct": { + "input_cost_per_token": 1.7e-07, + "litellm_provider": "azure_ai", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 6.8e-07, + "source": "https://azure.microsoft.com/en-us/pricing/details/phi-3/", + "supports_tool_choice": true, + "supports_vision": false + }, + "azure_ai/Phi-3-medium-4k-instruct": { + "input_cost_per_token": 1.7e-07, + "litellm_provider": "azure_ai", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 6.8e-07, + "source": "https://azure.microsoft.com/en-us/pricing/details/phi-3/", + "supports_tool_choice": true, + "supports_vision": false + }, + "azure_ai/Phi-3-mini-128k-instruct": { + "input_cost_per_token": 1.3e-07, + "litellm_provider": "azure_ai", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 5.2e-07, + "source": "https://azure.microsoft.com/en-us/pricing/details/phi-3/", + "supports_tool_choice": true, + "supports_vision": false + }, + "azure_ai/Phi-3-mini-4k-instruct": { + "input_cost_per_token": 1.3e-07, + "litellm_provider": "azure_ai", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 5.2e-07, + "source": "https://azure.microsoft.com/en-us/pricing/details/phi-3/", + "supports_tool_choice": true, + "supports_vision": false + }, + "azure_ai/Phi-3-small-128k-instruct": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "azure_ai", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 6e-07, + "source": "https://azure.microsoft.com/en-us/pricing/details/phi-3/", + "supports_tool_choice": true, + "supports_vision": false + }, + "azure_ai/Phi-3-small-8k-instruct": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "azure_ai", + "max_input_tokens": 8192, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 6e-07, + "source": "https://azure.microsoft.com/en-us/pricing/details/phi-3/", + "supports_tool_choice": true, + "supports_vision": false + }, + "azure_ai/Phi-3.5-MoE-instruct": { + "input_cost_per_token": 1.6e-07, + "litellm_provider": "azure_ai", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 6.4e-07, + "source": "https://azure.microsoft.com/en-us/pricing/details/phi-3/", + "supports_tool_choice": true, + "supports_vision": false + }, + "azure_ai/Phi-3.5-mini-instruct": { + "input_cost_per_token": 1.3e-07, + "litellm_provider": "azure_ai", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 5.2e-07, + "source": "https://azure.microsoft.com/en-us/pricing/details/phi-3/", + "supports_tool_choice": true, + "supports_vision": false + }, + "azure_ai/Phi-3.5-vision-instruct": { + "input_cost_per_token": 1.3e-07, + "litellm_provider": "azure_ai", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 5.2e-07, + "source": "https://azure.microsoft.com/en-us/pricing/details/phi-3/", + "supports_tool_choice": true, + "supports_vision": true + }, + "azure_ai/Phi-4": { + "input_cost_per_token": 1.25e-07, + "litellm_provider": "azure_ai", + "max_input_tokens": 16384, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 5e-07, + "source": "https://techcommunity.microsoft.com/blog/machinelearningblog/affordable-innovation-unveiling-the-pricing-of-phi-3-slms-on-models-as-a-service/4156495", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "azure_ai/Phi-4-mini-instruct": { + "input_cost_per_token": 7.5e-08, + "litellm_provider": "azure_ai", + "max_input_tokens": 131072, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 3e-07, + "source": "https://techcommunity.microsoft.com/blog/Azure-AI-Services-blog/announcing-new-phi-pricing-empowering-your-business-with-small-language-models/4395112", + "supports_function_calling": true + }, + "azure_ai/Phi-4-multimodal-instruct": { + "input_cost_per_audio_token": 4e-06, + "input_cost_per_token": 8e-08, + "litellm_provider": "azure_ai", + "max_input_tokens": 131072, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 3.2e-07, + "source": "https://techcommunity.microsoft.com/blog/Azure-AI-Services-blog/announcing-new-phi-pricing-empowering-your-business-with-small-language-models/4395112", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_vision": true + }, + "azure_ai/cohere-rerank-v3-english": { + "input_cost_per_query": 0.002, + "input_cost_per_token": 0.0, + "litellm_provider": "azure_ai", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_query_tokens": 2048, + "max_tokens": 4096, + "mode": "rerank", + "output_cost_per_token": 0.0 + }, + "azure_ai/cohere-rerank-v3-multilingual": { + "input_cost_per_query": 0.002, + "input_cost_per_token": 0.0, + "litellm_provider": "azure_ai", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_query_tokens": 2048, + "max_tokens": 4096, + "mode": "rerank", + "output_cost_per_token": 0.0 + }, + "azure_ai/cohere-rerank-v3.5": { + "input_cost_per_query": 0.002, + "input_cost_per_token": 0.0, + "litellm_provider": "azure_ai", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_query_tokens": 2048, + "max_tokens": 4096, + "mode": "rerank", + "output_cost_per_token": 0.0 + }, + "azure_ai/deepseek-r1": { + "input_cost_per_token": 1.35e-06, + "litellm_provider": "azure_ai", + "max_input_tokens": 128000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 5.4e-06, + "source": "https://techcommunity.microsoft.com/blog/machinelearningblog/deepseek-r1-improved-performance-higher-limits-and-transparent-pricing/4386367", + "supports_reasoning": true, + "supports_tool_choice": true + }, + "azure_ai/deepseek-v3": { + "input_cost_per_token": 1.14e-06, + "litellm_provider": "azure_ai", + "max_input_tokens": 128000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 4.56e-06, + "source": "https://techcommunity.microsoft.com/blog/machinelearningblog/announcing-deepseek-v3-on-azure-ai-foundry-and-github/4390438", + "supports_tool_choice": true + }, + "azure_ai/deepseek-v3-0324": { + "input_cost_per_token": 1.14e-06, + "litellm_provider": "azure_ai", + "max_input_tokens": 128000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 4.56e-06, + "source": "https://techcommunity.microsoft.com/blog/machinelearningblog/announcing-deepseek-v3-on-azure-ai-foundry-and-github/4390438", + "supports_function_calling": true, + "supports_tool_choice": true + }, + "azure_ai/embed-v-4-0": { + "input_cost_per_token": 1.2e-07, + "litellm_provider": "azure_ai", + "max_input_tokens": 128000, + "max_tokens": 128000, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_vector_size": 3072, + "source": "https://azuremarketplace.microsoft.com/pt-br/marketplace/apps/cohere.cohere-embed-4-offer?tab=PlansAndPrice", + "supported_endpoints": [ + "/v1/embeddings" + ], + "supported_modalities": [ + "text", + "image" + ], + "supports_embedding_image_input": true + }, + "azure_ai/global/grok-3": { + "input_cost_per_token": 3e-06, + "litellm_provider": "azure_ai", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "source": "https://devblogs.microsoft.com/foundry/announcing-grok-3-and-grok-3-mini-on-azure-ai-foundry/", + "supports_function_calling": true, + "supports_response_schema": false, + "supports_tool_choice": true, + "supports_web_search": true + }, + "azure_ai/global/grok-3-mini": { + "input_cost_per_token": 2.5e-07, + "litellm_provider": "azure_ai", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1.27e-06, + "source": "https://devblogs.microsoft.com/foundry/announcing-grok-3-and-grok-3-mini-on-azure-ai-foundry/", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": true, + "supports_web_search": true + }, + "azure_ai/grok-3": { + "input_cost_per_token": 3.3e-06, + "litellm_provider": "azure_ai", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1.65e-05, + "source": "https://devblogs.microsoft.com/foundry/announcing-grok-3-and-grok-3-mini-on-azure-ai-foundry/", + "supports_function_calling": true, + "supports_response_schema": false, + "supports_tool_choice": true, + "supports_web_search": true + }, + "azure_ai/grok-3-mini": { + "input_cost_per_token": 2.75e-07, + "litellm_provider": "azure_ai", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1.38e-06, + "source": "https://devblogs.microsoft.com/foundry/announcing-grok-3-and-grok-3-mini-on-azure-ai-foundry/", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": true, + "supports_web_search": true + }, + "azure_ai/jais-30b-chat": { + "input_cost_per_token": 0.0032, + "litellm_provider": "azure_ai", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.00971, + "source": "https://azure.microsoft.com/en-us/products/ai-services/ai-foundry/models/jais-30b-chat" + }, + "azure_ai/jamba-instruct": { + "input_cost_per_token": 5e-07, + "litellm_provider": "azure_ai", + "max_input_tokens": 70000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 7e-07, + "supports_tool_choice": true + }, + "azure_ai/ministral-3b": { + "input_cost_per_token": 4e-08, + "litellm_provider": "azure_ai", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 4e-08, + "source": "https://azuremarketplace.microsoft.com/en/marketplace/apps/000-000.ministral-3b-2410-offer?tab=Overview", + "supports_function_calling": true, + "supports_tool_choice": true + }, + "azure_ai/mistral-large": { + "input_cost_per_token": 4e-06, + "litellm_provider": "azure_ai", + "max_input_tokens": 32000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 1.2e-05, + "supports_function_calling": true, + "supports_tool_choice": true + }, + "azure_ai/mistral-large-2407": { + "input_cost_per_token": 2e-06, + "litellm_provider": "azure_ai", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 6e-06, + "source": "https://azuremarketplace.microsoft.com/en/marketplace/apps/000-000.mistral-ai-large-2407-offer?tab=Overview", + "supports_function_calling": true, + "supports_tool_choice": true + }, + "azure_ai/mistral-large-latest": { + "input_cost_per_token": 2e-06, + "litellm_provider": "azure_ai", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 6e-06, + "source": "https://azuremarketplace.microsoft.com/en/marketplace/apps/000-000.mistral-ai-large-2407-offer?tab=Overview", + "supports_function_calling": true, + "supports_tool_choice": true + }, + "azure_ai/mistral-medium-2505": { + "input_cost_per_token": 4e-07, + "litellm_provider": "azure_ai", + "max_input_tokens": 131072, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 2e-06, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_tool_choice": true + }, + "azure_ai/mistral-nemo": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "azure_ai", + "max_input_tokens": 131072, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.5e-07, + "source": "https://azuremarketplace.microsoft.com/en/marketplace/apps/000-000.mistral-nemo-12b-2407?tab=PlansAndPrice", + "supports_function_calling": true + }, + "azure_ai/mistral-small": { + "input_cost_per_token": 1e-06, + "litellm_provider": "azure_ai", + "max_input_tokens": 32000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 3e-06, + "supports_function_calling": true, + "supports_tool_choice": true + }, + "azure_ai/mistral-small-2503": { + "input_cost_per_token": 1e-06, + "litellm_provider": "azure_ai", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3e-06, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "babbage-002": { + "input_cost_per_token": 4e-07, + "litellm_provider": "text-completion-openai", + "max_input_tokens": 16384, + "max_output_tokens": 4096, + "max_tokens": 16384, + "mode": "completion", + "output_cost_per_token": 4e-07 + }, + "bedrock/*/1-month-commitment/cohere.command-light-text-v14": { + "input_cost_per_second": 0.001902, + "litellm_provider": "bedrock", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_second": 0.001902, + "supports_tool_choice": true + }, + "bedrock/*/1-month-commitment/cohere.command-text-v14": { + "input_cost_per_second": 0.011, + "litellm_provider": "bedrock", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_second": 0.011, + "supports_tool_choice": true + }, + "bedrock/*/6-month-commitment/cohere.command-light-text-v14": { + "input_cost_per_second": 0.0011416, + "litellm_provider": "bedrock", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_second": 0.0011416, + "supports_tool_choice": true + }, + "bedrock/*/6-month-commitment/cohere.command-text-v14": { + "input_cost_per_second": 0.0066027, + "litellm_provider": "bedrock", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_second": 0.0066027, + "supports_tool_choice": true + }, + "bedrock/ap-northeast-1/1-month-commitment/anthropic.claude-instant-v1": { + "input_cost_per_second": 0.01475, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_second": 0.01475, + "supports_tool_choice": true + }, + "bedrock/ap-northeast-1/1-month-commitment/anthropic.claude-v1": { + "input_cost_per_second": 0.0455, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_second": 0.0455 + }, + "bedrock/ap-northeast-1/1-month-commitment/anthropic.claude-v2:1": { + "input_cost_per_second": 0.0455, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_second": 0.0455, + "supports_tool_choice": true + }, + "bedrock/ap-northeast-1/6-month-commitment/anthropic.claude-instant-v1": { + "input_cost_per_second": 0.008194, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_second": 0.008194, + "supports_tool_choice": true + }, + "bedrock/ap-northeast-1/6-month-commitment/anthropic.claude-v1": { + "input_cost_per_second": 0.02527, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_second": 0.02527 + }, + "bedrock/ap-northeast-1/6-month-commitment/anthropic.claude-v2:1": { + "input_cost_per_second": 0.02527, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_second": 0.02527, + "supports_tool_choice": true + }, + "bedrock/ap-northeast-1/anthropic.claude-instant-v1": { + "input_cost_per_token": 2.23e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 7.55e-06, + "supports_tool_choice": true + }, + "bedrock/ap-northeast-1/anthropic.claude-v1": { + "input_cost_per_token": 8e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 2.4e-05, + "supports_tool_choice": true + }, + "bedrock/ap-northeast-1/anthropic.claude-v2:1": { + "input_cost_per_token": 8e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 2.4e-05, + "supports_tool_choice": true + }, + "bedrock/ap-south-1/meta.llama3-70b-instruct-v1:0": { + "input_cost_per_token": 3.18e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 4.2e-06 + }, + "bedrock/ap-south-1/meta.llama3-8b-instruct-v1:0": { + "input_cost_per_token": 3.6e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 7.2e-07 + }, + "bedrock/ca-central-1/meta.llama3-70b-instruct-v1:0": { + "input_cost_per_token": 3.05e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 4.03e-06 + }, + "bedrock/ca-central-1/meta.llama3-8b-instruct-v1:0": { + "input_cost_per_token": 3.5e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 6.9e-07 + }, + "bedrock/eu-central-1/1-month-commitment/anthropic.claude-instant-v1": { + "input_cost_per_second": 0.01635, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_second": 0.01635, + "supports_tool_choice": true + }, + "bedrock/eu-central-1/1-month-commitment/anthropic.claude-v1": { + "input_cost_per_second": 0.0415, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_second": 0.0415 + }, + "bedrock/eu-central-1/1-month-commitment/anthropic.claude-v2:1": { + "input_cost_per_second": 0.0415, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_second": 0.0415, + "supports_tool_choice": true + }, + "bedrock/eu-central-1/6-month-commitment/anthropic.claude-instant-v1": { + "input_cost_per_second": 0.009083, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_second": 0.009083, + "supports_tool_choice": true + }, + "bedrock/eu-central-1/6-month-commitment/anthropic.claude-v1": { + "input_cost_per_second": 0.02305, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_second": 0.02305 + }, + "bedrock/eu-central-1/6-month-commitment/anthropic.claude-v2:1": { + "input_cost_per_second": 0.02305, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_second": 0.02305, + "supports_tool_choice": true + }, + "bedrock/eu-central-1/anthropic.claude-instant-v1": { + "input_cost_per_token": 2.48e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 8.38e-06, + "supports_tool_choice": true + }, + "bedrock/eu-central-1/anthropic.claude-v1": { + "input_cost_per_token": 8e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 2.4e-05 + }, + "bedrock/eu-central-1/anthropic.claude-v2:1": { + "input_cost_per_token": 8e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 2.4e-05, + "supports_tool_choice": true + }, + "bedrock/eu-west-1/meta.llama3-70b-instruct-v1:0": { + "input_cost_per_token": 2.86e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 3.78e-06 + }, + "bedrock/eu-west-1/meta.llama3-8b-instruct-v1:0": { + "input_cost_per_token": 3.2e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 6.5e-07 + }, + "bedrock/eu-west-2/meta.llama3-70b-instruct-v1:0": { + "input_cost_per_token": 3.45e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 4.55e-06 + }, + "bedrock/eu-west-2/meta.llama3-8b-instruct-v1:0": { + "input_cost_per_token": 3.9e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 7.8e-07 + }, + "bedrock/eu-west-3/mistral.mistral-7b-instruct-v0:2": { + "input_cost_per_token": 2e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 32000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 2.6e-07, + "supports_tool_choice": true + }, + "bedrock/eu-west-3/mistral.mistral-large-2402-v1:0": { + "input_cost_per_token": 1.04e-05, + "litellm_provider": "bedrock", + "max_input_tokens": 32000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 3.12e-05, + "supports_function_calling": true + }, + "bedrock/eu-west-3/mistral.mixtral-8x7b-instruct-v0:1": { + "input_cost_per_token": 5.9e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 32000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 9.1e-07, + "supports_tool_choice": true + }, + "bedrock/invoke/anthropic.claude-3-5-sonnet-20240620-v1:0": { + "input_cost_per_token": 3e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "metadata": { + "notes": "Anthropic via Invoke route does not currently support pdf input." + }, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "bedrock/sa-east-1/meta.llama3-70b-instruct-v1:0": { + "input_cost_per_token": 4.45e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 5.88e-06 + }, + "bedrock/sa-east-1/meta.llama3-8b-instruct-v1:0": { + "input_cost_per_token": 5e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.01e-06 + }, + "bedrock/us-east-1/1-month-commitment/anthropic.claude-instant-v1": { + "input_cost_per_second": 0.011, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_second": 0.011, + "supports_tool_choice": true + }, + "bedrock/us-east-1/1-month-commitment/anthropic.claude-v1": { + "input_cost_per_second": 0.0175, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_second": 0.0175 + }, + "bedrock/us-east-1/1-month-commitment/anthropic.claude-v2:1": { + "input_cost_per_second": 0.0175, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_second": 0.0175, + "supports_tool_choice": true + }, + "bedrock/us-east-1/6-month-commitment/anthropic.claude-instant-v1": { + "input_cost_per_second": 0.00611, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_second": 0.00611, + "supports_tool_choice": true + }, + "bedrock/us-east-1/6-month-commitment/anthropic.claude-v1": { + "input_cost_per_second": 0.00972, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_second": 0.00972 + }, + "bedrock/us-east-1/6-month-commitment/anthropic.claude-v2:1": { + "input_cost_per_second": 0.00972, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_second": 0.00972, + "supports_tool_choice": true + }, + "bedrock/us-east-1/anthropic.claude-instant-v1": { + "input_cost_per_token": 8e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 2.4e-06, + "supports_tool_choice": true + }, + "bedrock/us-east-1/anthropic.claude-v1": { + "input_cost_per_token": 8e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 2.4e-05, + "supports_tool_choice": true + }, + "bedrock/us-east-1/anthropic.claude-v2:1": { + "input_cost_per_token": 8e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 2.4e-05, + "supports_tool_choice": true + }, + "bedrock/us-east-1/meta.llama3-70b-instruct-v1:0": { + "input_cost_per_token": 2.65e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 3.5e-06 + }, + "bedrock/us-east-1/meta.llama3-8b-instruct-v1:0": { + "input_cost_per_token": 3e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 6e-07 + }, + "bedrock/us-east-1/mistral.mistral-7b-instruct-v0:2": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 32000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 2e-07, + "supports_tool_choice": true + }, + "bedrock/us-east-1/mistral.mistral-large-2402-v1:0": { + "input_cost_per_token": 8e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 32000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 2.4e-05, + "supports_function_calling": true + }, + "bedrock/us-east-1/mistral.mixtral-8x7b-instruct-v0:1": { + "input_cost_per_token": 4.5e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 32000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 7e-07, + "supports_tool_choice": true + }, + "bedrock/us-gov-east-1/amazon.nova-pro-v1:0": { + "input_cost_per_token": 9.6e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 300000, + "max_output_tokens": 10000, + "max_tokens": 10000, + "mode": "chat", + "output_cost_per_token": 3.84e-06, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_vision": true + }, + "bedrock/us-gov-east-1/amazon.titan-embed-text-v1": { + "input_cost_per_token": 1e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 8192, + "max_tokens": 8192, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_vector_size": 1536 + }, + "bedrock/us-gov-east-1/amazon.titan-embed-text-v2:0": { + "input_cost_per_token": 2e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 8192, + "max_tokens": 8192, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_vector_size": 1024 + }, + "bedrock/us-gov-east-1/amazon.titan-text-express-v1": { + "input_cost_per_token": 1.3e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 42000, + "max_output_tokens": 8000, + "max_tokens": 8000, + "mode": "chat", + "output_cost_per_token": 1.7e-06 + }, + "bedrock/us-gov-east-1/amazon.titan-text-lite-v1": { + "input_cost_per_token": 3e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 42000, + "max_output_tokens": 4000, + "max_tokens": 4000, + "mode": "chat", + "output_cost_per_token": 4e-07 + }, + "bedrock/us-gov-east-1/amazon.titan-text-premier-v1:0": { + "input_cost_per_token": 5e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 42000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 1.5e-06 + }, + "bedrock/us-gov-east-1/anthropic.claude-3-5-sonnet-20240620-v1:0": { + "input_cost_per_token": 3.6e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.8e-05, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "bedrock/us-gov-east-1/anthropic.claude-3-haiku-20240307-v1:0": { + "input_cost_per_token": 3e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.5e-06, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "bedrock/us-gov-east-1/meta.llama3-70b-instruct-v1:0": { + "input_cost_per_token": 2.65e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 8000, + "max_output_tokens": 2048, + "max_tokens": 2048, + "mode": "chat", + "output_cost_per_token": 3.5e-06, + "supports_pdf_input": true + }, + "bedrock/us-gov-east-1/meta.llama3-8b-instruct-v1:0": { + "input_cost_per_token": 3e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 8000, + "max_output_tokens": 2048, + "max_tokens": 2048, + "mode": "chat", + "output_cost_per_token": 2.65e-06, + "supports_pdf_input": true + }, + "bedrock/us-gov-west-1/amazon.nova-pro-v1:0": { + "input_cost_per_token": 9.6e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 300000, + "max_output_tokens": 10000, + "max_tokens": 10000, + "mode": "chat", + "output_cost_per_token": 3.84e-06, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_vision": true + }, + "bedrock/us-gov-west-1/amazon.titan-embed-text-v1": { + "input_cost_per_token": 1e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 8192, + "max_tokens": 8192, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_vector_size": 1536 + }, + "bedrock/us-gov-west-1/amazon.titan-embed-text-v2:0": { + "input_cost_per_token": 2e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 8192, + "max_tokens": 8192, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_vector_size": 1024 + }, + "bedrock/us-gov-west-1/amazon.titan-text-express-v1": { + "input_cost_per_token": 1.3e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 42000, + "max_output_tokens": 8000, + "max_tokens": 8000, + "mode": "chat", + "output_cost_per_token": 1.7e-06 + }, + "bedrock/us-gov-west-1/amazon.titan-text-lite-v1": { + "input_cost_per_token": 3e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 42000, + "max_output_tokens": 4000, + "max_tokens": 4000, + "mode": "chat", + "output_cost_per_token": 4e-07 + }, + "bedrock/us-gov-west-1/amazon.titan-text-premier-v1:0": { + "input_cost_per_token": 5e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 42000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 1.5e-06 + }, + "bedrock/us-gov-west-1/anthropic.claude-3-5-sonnet-20240620-v1:0": { + "input_cost_per_token": 3.6e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.8e-05, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "bedrock/us-gov-west-1/anthropic.claude-3-haiku-20240307-v1:0": { + "input_cost_per_token": 3e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.5e-06, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "bedrock/us-gov-west-1/meta.llama3-70b-instruct-v1:0": { + "input_cost_per_token": 2.65e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 8000, + "max_output_tokens": 2048, + "max_tokens": 2048, + "mode": "chat", + "output_cost_per_token": 3.5e-06, + "supports_pdf_input": true + }, + "bedrock/us-gov-west-1/meta.llama3-8b-instruct-v1:0": { + "input_cost_per_token": 3e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 8000, + "max_output_tokens": 2048, + "max_tokens": 2048, + "mode": "chat", + "output_cost_per_token": 2.65e-06, + "supports_pdf_input": true + }, + "bedrock/us-west-1/meta.llama3-70b-instruct-v1:0": { + "input_cost_per_token": 2.65e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 3.5e-06 + }, + "bedrock/us-west-1/meta.llama3-8b-instruct-v1:0": { + "input_cost_per_token": 3e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 6e-07 + }, + "bedrock/us-west-2/1-month-commitment/anthropic.claude-instant-v1": { + "input_cost_per_second": 0.011, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_second": 0.011, + "supports_tool_choice": true + }, + "bedrock/us-west-2/1-month-commitment/anthropic.claude-v1": { + "input_cost_per_second": 0.0175, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_second": 0.0175 + }, + "bedrock/us-west-2/1-month-commitment/anthropic.claude-v2:1": { + "input_cost_per_second": 0.0175, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_second": 0.0175, + "supports_tool_choice": true + }, + "bedrock/us-west-2/6-month-commitment/anthropic.claude-instant-v1": { + "input_cost_per_second": 0.00611, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_second": 0.00611, + "supports_tool_choice": true + }, + "bedrock/us-west-2/6-month-commitment/anthropic.claude-v1": { + "input_cost_per_second": 0.00972, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_second": 0.00972 + }, + "bedrock/us-west-2/6-month-commitment/anthropic.claude-v2:1": { + "input_cost_per_second": 0.00972, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_second": 0.00972, + "supports_tool_choice": true + }, + "bedrock/us-west-2/anthropic.claude-instant-v1": { + "input_cost_per_token": 8e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 2.4e-06, + "supports_tool_choice": true + }, + "bedrock/us-west-2/anthropic.claude-v1": { + "input_cost_per_token": 8e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 2.4e-05, + "supports_tool_choice": true + }, + "bedrock/us-west-2/anthropic.claude-v2:1": { + "input_cost_per_token": 8e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 100000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 2.4e-05, + "supports_tool_choice": true + }, + "bedrock/us-west-2/mistral.mistral-7b-instruct-v0:2": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 32000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 2e-07, + "supports_tool_choice": true + }, + "bedrock/us-west-2/mistral.mistral-large-2402-v1:0": { + "input_cost_per_token": 8e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 32000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 2.4e-05, + "supports_function_calling": true + }, + "bedrock/us-west-2/mistral.mixtral-8x7b-instruct-v0:1": { + "input_cost_per_token": 4.5e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 32000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 7e-07, + "supports_tool_choice": true + }, + "bedrock/us.anthropic.claude-3-5-haiku-20241022-v1:0": { + "cache_creation_input_token_cost": 1e-06, + "cache_read_input_token_cost": 8e-08, + "input_cost_per_token": 8e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 4e-06, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "cerebras/llama-3.3-70b": { + "input_cost_per_token": 8.5e-07, + "litellm_provider": "cerebras", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "supports_function_calling": true, + "supports_tool_choice": true + }, + "cerebras/llama3.1-70b": { + "input_cost_per_token": 6e-07, + "litellm_provider": "cerebras", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6e-07, + "supports_function_calling": true, + "supports_tool_choice": true + }, + "cerebras/llama3.1-8b": { + "input_cost_per_token": 1e-07, + "litellm_provider": "cerebras", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1e-07, + "supports_function_calling": true, + "supports_tool_choice": true + }, + "cerebras/openai/gpt-oss-120b": { + "input_cost_per_token": 2.5e-07, + "litellm_provider": "cerebras", + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 6.9e-07, + "source": "https://www.cerebras.ai/blog/openai-gpt-oss-120b-runs-fastest-on-cerebras", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "cerebras/qwen-3-32b": { + "input_cost_per_token": 4e-07, + "litellm_provider": "cerebras", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 8e-07, + "source": "https://inference-docs.cerebras.ai/support/pricing", + "supports_function_calling": true, + "supports_tool_choice": true + }, + "chat-bison": { + "input_cost_per_character": 2.5e-07, + "input_cost_per_token": 1.25e-07, + "litellm_provider": "vertex_ai-chat-models", + "max_input_tokens": 8192, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_character": 5e-07, + "output_cost_per_token": 1.25e-07, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_tool_choice": true + }, + "chat-bison-32k": { + "input_cost_per_character": 2.5e-07, + "input_cost_per_token": 1.25e-07, + "litellm_provider": "vertex_ai-chat-models", + "max_input_tokens": 32000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_character": 5e-07, + "output_cost_per_token": 1.25e-07, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_tool_choice": true + }, + "chat-bison-32k@002": { + "input_cost_per_character": 2.5e-07, + "input_cost_per_token": 1.25e-07, + "litellm_provider": "vertex_ai-chat-models", + "max_input_tokens": 32000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_character": 5e-07, + "output_cost_per_token": 1.25e-07, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_tool_choice": true + }, + "chat-bison@001": { + "input_cost_per_character": 2.5e-07, + "input_cost_per_token": 1.25e-07, + "litellm_provider": "vertex_ai-chat-models", + "max_input_tokens": 8192, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_character": 5e-07, + "output_cost_per_token": 1.25e-07, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_tool_choice": true + }, + "chat-bison@002": { + "deprecation_date": "2025-04-09", + "input_cost_per_character": 2.5e-07, + "input_cost_per_token": 1.25e-07, + "litellm_provider": "vertex_ai-chat-models", + "max_input_tokens": 8192, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_character": 5e-07, + "output_cost_per_token": 1.25e-07, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_tool_choice": true + }, + "chatdolphin": { + "input_cost_per_token": 5e-07, + "litellm_provider": "nlp_cloud", + "max_input_tokens": 16384, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 5e-07 + }, + "chatgpt-4o-latest": { + "input_cost_per_token": 5e-06, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "claude-3-5-haiku-20241022": { + "cache_creation_input_token_cost": 1e-06, + "cache_creation_input_token_cost_above_1hr": 6e-06, + "cache_read_input_token_cost": 8e-08, + "deprecation_date": "2025-10-01", + "input_cost_per_token": 8e-07, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 4e-06, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "tool_use_system_prompt_tokens": 264 + }, + "claude-3-5-haiku-latest": { + "cache_creation_input_token_cost": 1.25e-06, + "cache_creation_input_token_cost_above_1hr": 6e-06, + "cache_read_input_token_cost": 1e-07, + "deprecation_date": "2025-10-01", + "input_cost_per_token": 1e-06, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 5e-06, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "tool_use_system_prompt_tokens": 264 + }, + "claude-3-5-sonnet-20240620": { + "cache_creation_input_token_cost": 3.75e-06, + "cache_creation_input_token_cost_above_1hr": 6e-06, + "cache_read_input_token_cost": 3e-07, + "deprecation_date": "2025-06-01", + "input_cost_per_token": 3e-06, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-3-5-sonnet-20241022": { + "cache_creation_input_token_cost": 3.75e-06, + "cache_creation_input_token_cost_above_1hr": 6e-06, + "cache_read_input_token_cost": 3e-07, + "deprecation_date": "2025-10-01", + "input_cost_per_token": 3e-06, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-3-5-sonnet-latest": { + "cache_creation_input_token_cost": 3.75e-06, + "cache_creation_input_token_cost_above_1hr": 6e-06, + "cache_read_input_token_cost": 3e-07, + "deprecation_date": "2025-06-01", + "input_cost_per_token": 3e-06, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-3-7-sonnet-20250219": { + "cache_creation_input_token_cost": 3.75e-06, + "cache_creation_input_token_cost_above_1hr": 6e-06, + "cache_read_input_token_cost": 3e-07, + "deprecation_date": "2026-02-01", + "input_cost_per_token": 3e-06, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-3-7-sonnet-latest": { + "cache_creation_input_token_cost": 3.75e-06, + "cache_creation_input_token_cost_above_1hr": 6e-06, + "cache_read_input_token_cost": 3e-07, + "deprecation_date": "2025-06-01", + "input_cost_per_token": 3e-06, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-3-haiku-20240307": { + "cache_creation_input_token_cost": 3e-07, + "cache_creation_input_token_cost_above_1hr": 6e-06, + "cache_read_input_token_cost": 3e-08, + "deprecation_date": "2025-03-01", + "input_cost_per_token": 2.5e-07, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.25e-06, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 264 + }, + "claude-3-opus-20240229": { + "cache_creation_input_token_cost": 1.875e-05, + "cache_creation_input_token_cost_above_1hr": 6e-06, + "cache_read_input_token_cost": 1.5e-06, + "deprecation_date": "2025-03-01", + "input_cost_per_token": 1.5e-05, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 7.5e-05, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 395 + }, + "claude-3-opus-latest": { + "cache_creation_input_token_cost": 1.875e-05, + "cache_creation_input_token_cost_above_1hr": 6e-06, + "cache_read_input_token_cost": 1.5e-06, + "deprecation_date": "2025-03-01", + "input_cost_per_token": 1.5e-05, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 7.5e-05, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 395 + }, + "claude-4-opus-20250514": { + "cache_creation_input_token_cost": 1.875e-05, + "cache_read_input_token_cost": 1.5e-06, + "input_cost_per_token": 1.5e-05, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 7.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-4-sonnet-20250514": { + "cache_creation_input_token_cost": 3.75e-06, + "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, + "cache_read_input_token_cost": 3e-07, + "cache_read_input_token_cost_above_200k_tokens": 6e-07, + "input_cost_per_token": 3e-06, + "input_cost_per_token_above_200k_tokens": 6e-06, + "litellm_provider": "anthropic", + "max_input_tokens": 1000000, + "max_output_tokens": 1000000, + "max_tokens": 1000000, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "output_cost_per_token_above_200k_tokens": 2.25e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "anthropic/claude-sonnet-4-5": { + "cache_creation_input_token_cost": 3.75e-06, + "cache_read_input_token_cost": 3e-07, + "input_cost_per_token": 3e-06, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 200000, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "claude-sonnet-4-5-20250929": { + "cache_creation_input_token_cost": 3.75e-06, + "cache_read_input_token_cost": 3e-07, + "input_cost_per_token": 3e-06, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 200000, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "claude-opus-4-1": { + "cache_creation_input_token_cost": 1.875e-05, + "cache_creation_input_token_cost_above_1hr": 3e-05, + "cache_read_input_token_cost": 1.5e-06, + "input_cost_per_token": 1.5e-05, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 7.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-opus-4-1-20250805": { + "cache_creation_input_token_cost": 1.875e-05, + "cache_creation_input_token_cost_above_1hr": 3e-05, + "cache_read_input_token_cost": 1.5e-06, + "input_cost_per_token": 1.5e-05, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 7.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-opus-4-20250514": { + "cache_creation_input_token_cost": 1.875e-05, + "cache_creation_input_token_cost_above_1hr": 3e-05, + "cache_read_input_token_cost": 1.5e-06, + "input_cost_per_token": 1.5e-05, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 7.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-sonnet-4-20250514": { + "cache_creation_input_token_cost": 3.75e-06, + "cache_creation_input_token_cost_above_1hr": 6e-06, + "cache_read_input_token_cost": 3e-07, + "input_cost_per_token": 3e-06, + "input_cost_per_token_above_200k_tokens": 6e-06, + "output_cost_per_token_above_200k_tokens": 2.25e-05, + "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, + "cache_read_input_token_cost_above_200k_tokens": 6e-07, + "litellm_provider": "anthropic", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "cloudflare/@cf/meta/llama-2-7b-chat-fp16": { + "input_cost_per_token": 1.923e-06, + "litellm_provider": "cloudflare", + "max_input_tokens": 3072, + "max_output_tokens": 3072, + "max_tokens": 3072, + "mode": "chat", + "output_cost_per_token": 1.923e-06 + }, + "cloudflare/@cf/meta/llama-2-7b-chat-int8": { + "input_cost_per_token": 1.923e-06, + "litellm_provider": "cloudflare", + "max_input_tokens": 2048, + "max_output_tokens": 2048, + "max_tokens": 2048, + "mode": "chat", + "output_cost_per_token": 1.923e-06 + }, + "cloudflare/@cf/mistral/mistral-7b-instruct-v0.1": { + "input_cost_per_token": 1.923e-06, + "litellm_provider": "cloudflare", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.923e-06 + }, + "cloudflare/@hf/thebloke/codellama-7b-instruct-awq": { + "input_cost_per_token": 1.923e-06, + "litellm_provider": "cloudflare", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.923e-06 + }, + "code-bison": { + "input_cost_per_character": 2.5e-07, + "input_cost_per_token": 1.25e-07, + "litellm_provider": "vertex_ai-code-text-models", + "max_input_tokens": 6144, + "max_output_tokens": 1024, + "max_tokens": 1024, + "mode": "chat", + "output_cost_per_character": 5e-07, + "output_cost_per_token": 1.25e-07, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_tool_choice": true + }, + "code-bison-32k@002": { + "input_cost_per_character": 2.5e-07, + "input_cost_per_token": 1.25e-07, + "litellm_provider": "vertex_ai-code-text-models", + "max_input_tokens": 6144, + "max_output_tokens": 1024, + "max_tokens": 1024, + "mode": "completion", + "output_cost_per_character": 5e-07, + "output_cost_per_token": 1.25e-07, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" + }, + "code-bison32k": { + "input_cost_per_character": 2.5e-07, + "input_cost_per_token": 1.25e-07, + "litellm_provider": "vertex_ai-code-text-models", + "max_input_tokens": 6144, + "max_output_tokens": 1024, + "max_tokens": 1024, + "mode": "completion", + "output_cost_per_character": 5e-07, + "output_cost_per_token": 1.25e-07, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" + }, + "code-bison@001": { + "input_cost_per_character": 2.5e-07, + "input_cost_per_token": 1.25e-07, + "litellm_provider": "vertex_ai-code-text-models", + "max_input_tokens": 6144, + "max_output_tokens": 1024, + "max_tokens": 1024, + "mode": "completion", + "output_cost_per_character": 5e-07, + "output_cost_per_token": 1.25e-07, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" + }, + "code-bison@002": { + "input_cost_per_character": 2.5e-07, + "input_cost_per_token": 1.25e-07, + "litellm_provider": "vertex_ai-code-text-models", + "max_input_tokens": 6144, + "max_output_tokens": 1024, + "max_tokens": 1024, + "mode": "completion", + "output_cost_per_character": 5e-07, + "output_cost_per_token": 1.25e-07, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" + }, + "code-gecko": { + "input_cost_per_token": 1.25e-07, + "litellm_provider": "vertex_ai-code-text-models", + "max_input_tokens": 2048, + "max_output_tokens": 64, + "max_tokens": 64, + "mode": "completion", + "output_cost_per_token": 1.25e-07, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" + }, + "code-gecko-latest": { + "input_cost_per_token": 1.25e-07, + "litellm_provider": "vertex_ai-code-text-models", + "max_input_tokens": 2048, + "max_output_tokens": 64, + "max_tokens": 64, + "mode": "completion", + "output_cost_per_token": 1.25e-07, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" + }, + "code-gecko@001": { + "input_cost_per_token": 1.25e-07, + "litellm_provider": "vertex_ai-code-text-models", + "max_input_tokens": 2048, + "max_output_tokens": 64, + "max_tokens": 64, + "mode": "completion", + "output_cost_per_token": 1.25e-07, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" + }, + "code-gecko@002": { + "input_cost_per_token": 1.25e-07, + "litellm_provider": "vertex_ai-code-text-models", + "max_input_tokens": 2048, + "max_output_tokens": 64, + "max_tokens": 64, + "mode": "completion", + "output_cost_per_token": 1.25e-07, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" + }, + "codechat-bison": { + "input_cost_per_character": 2.5e-07, + "input_cost_per_token": 1.25e-07, + "litellm_provider": "vertex_ai-code-chat-models", + "max_input_tokens": 6144, + "max_output_tokens": 1024, + "max_tokens": 1024, + "mode": "chat", + "output_cost_per_character": 5e-07, + "output_cost_per_token": 1.25e-07, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_tool_choice": true + }, + "codechat-bison-32k": { + "input_cost_per_character": 2.5e-07, + "input_cost_per_token": 1.25e-07, + "litellm_provider": "vertex_ai-code-chat-models", + "max_input_tokens": 32000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_character": 5e-07, + "output_cost_per_token": 1.25e-07, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_tool_choice": true + }, + "codechat-bison-32k@002": { + "input_cost_per_character": 2.5e-07, + "input_cost_per_token": 1.25e-07, + "litellm_provider": "vertex_ai-code-chat-models", + "max_input_tokens": 32000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_character": 5e-07, + "output_cost_per_token": 1.25e-07, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_tool_choice": true + }, + "codechat-bison@001": { + "input_cost_per_character": 2.5e-07, + "input_cost_per_token": 1.25e-07, + "litellm_provider": "vertex_ai-code-chat-models", + "max_input_tokens": 6144, + "max_output_tokens": 1024, + "max_tokens": 1024, + "mode": "chat", + "output_cost_per_character": 5e-07, + "output_cost_per_token": 1.25e-07, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_tool_choice": true + }, + "codechat-bison@002": { + "input_cost_per_character": 2.5e-07, + "input_cost_per_token": 1.25e-07, + "litellm_provider": "vertex_ai-code-chat-models", + "max_input_tokens": 6144, + "max_output_tokens": 1024, + "max_tokens": 1024, + "mode": "chat", + "output_cost_per_character": 5e-07, + "output_cost_per_token": 1.25e-07, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_tool_choice": true + }, + "codechat-bison@latest": { + "input_cost_per_character": 2.5e-07, + "input_cost_per_token": 1.25e-07, + "litellm_provider": "vertex_ai-code-chat-models", + "max_input_tokens": 6144, + "max_output_tokens": 1024, + "max_tokens": 1024, + "mode": "chat", + "output_cost_per_character": 5e-07, + "output_cost_per_token": 1.25e-07, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_tool_choice": true + }, + "codestral/codestral-2405": { + "input_cost_per_token": 0.0, + "litellm_provider": "codestral", + "max_input_tokens": 32000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 0.0, + "source": "https://docs.mistral.ai/capabilities/code_generation/", + "supports_assistant_prefill": true, + "supports_tool_choice": true + }, + "codestral/codestral-latest": { + "input_cost_per_token": 0.0, + "litellm_provider": "codestral", + "max_input_tokens": 32000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 0.0, + "source": "https://docs.mistral.ai/capabilities/code_generation/", + "supports_assistant_prefill": true, + "supports_tool_choice": true + }, + "codex-mini-latest": { + "cache_read_input_token_cost": 3.75e-07, + "input_cost_per_token": 1.5e-06, + "litellm_provider": "openai", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "responses", + "output_cost_per_token": 6e-06, + "supported_endpoints": [ + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "cohere.command-light-text-v14": { + "input_cost_per_token": 3e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 6e-07, + "supports_tool_choice": true + }, + "cohere.command-r-plus-v1:0": { + "input_cost_per_token": 3e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "supports_tool_choice": true + }, + "cohere.command-r-v1:0": { + "input_cost_per_token": 5e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.5e-06, + "supports_tool_choice": true + }, + "cohere.command-text-v14": { + "input_cost_per_token": 1.5e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 2e-06, + "supports_tool_choice": true + }, + "cohere.embed-english-v3": { + "input_cost_per_token": 1e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 512, + "max_tokens": 512, + "mode": "embedding", + "output_cost_per_token": 0.0, + "supports_embedding_image_input": true + }, + "cohere.embed-multilingual-v3": { + "input_cost_per_token": 1e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 512, + "max_tokens": 512, + "mode": "embedding", + "output_cost_per_token": 0.0, + "supports_embedding_image_input": true + }, + "cohere.rerank-v3-5:0": { + "input_cost_per_query": 0.002, + "input_cost_per_token": 0.0, + "litellm_provider": "bedrock", + "max_document_chunks_per_query": 100, + "max_input_tokens": 32000, + "max_output_tokens": 32000, + "max_query_tokens": 32000, + "max_tokens": 32000, + "max_tokens_per_document_chunk": 512, + "mode": "rerank", + "output_cost_per_token": 0.0 + }, + "command": { + "input_cost_per_token": 1e-06, + "litellm_provider": "cohere", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "completion", + "output_cost_per_token": 2e-06 + }, + "command-a-03-2025": { + "input_cost_per_token": 2.5e-06, + "litellm_provider": "cohere_chat", + "max_input_tokens": 256000, + "max_output_tokens": 8000, + "max_tokens": 8000, + "mode": "chat", + "output_cost_per_token": 1e-05, + "supports_function_calling": true, + "supports_tool_choice": true + }, + "command-light": { + "input_cost_per_token": 3e-07, + "litellm_provider": "cohere_chat", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 6e-07, + "supports_tool_choice": true + }, + "command-nightly": { + "input_cost_per_token": 1e-06, + "litellm_provider": "cohere", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "completion", + "output_cost_per_token": 2e-06 + }, + "command-r": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "cohere_chat", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 6e-07, + "supports_function_calling": true, + "supports_tool_choice": true + }, + "command-r-08-2024": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "cohere_chat", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 6e-07, + "supports_function_calling": true, + "supports_tool_choice": true + }, + "command-r-plus": { + "input_cost_per_token": 2.5e-06, + "litellm_provider": "cohere_chat", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1e-05, + "supports_function_calling": true, + "supports_tool_choice": true + }, + "command-r-plus-08-2024": { + "input_cost_per_token": 2.5e-06, + "litellm_provider": "cohere_chat", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1e-05, + "supports_function_calling": true, + "supports_tool_choice": true + }, + "command-r7b-12-2024": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "cohere_chat", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 3.75e-08, + "source": "https://docs.cohere.com/v2/docs/command-r7b", + "supports_function_calling": true, + "supports_tool_choice": true + }, + "computer-use-preview": { + "input_cost_per_token": 3e-06, + "litellm_provider": "azure", + "max_input_tokens": 8192, + "max_output_tokens": 1024, + "max_tokens": 1024, + "mode": "chat", + "output_cost_per_token": 1.2e-05, + "supported_endpoints": [ + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "deepseek-chat": { + "cache_read_input_token_cost": 6e-08, + "input_cost_per_token": 6e-07, + "litellm_provider": "deepseek", + "max_input_tokens": 131072, + "max_output_tokens": 8192, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1.7e-06, + "source": "https://api-docs.deepseek.com/quick_start/pricing", + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "deepseek-reasoner": { + "cache_read_input_token_cost": 6e-08, + "input_cost_per_token": 6e-07, + "litellm_provider": "deepseek", + "max_input_tokens": 131072, + "max_output_tokens": 65536, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1.7e-06, + "source": "https://api-docs.deepseek.com/quick_start/pricing", + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supports_function_calling": false, + "supports_native_streaming": true, + "supports_parallel_function_calling": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": false + }, + "dashscope/qwen-coder": { + "input_cost_per_token": 3e-07, + "litellm_provider": "dashscope", + "max_input_tokens": 1000000, + "max_output_tokens": 16384, + "max_tokens": 1000000, + "mode": "chat", + "output_cost_per_token": 1.5e-06, + "source": "https://www.alibabacloud.com/help/en/model-studio/models", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "dashscope/qwen-flash": { + "litellm_provider": "dashscope", + "max_input_tokens": 997952, + "max_output_tokens": 32768, + "max_tokens": 1000000, + "mode": "chat", + "source": "https://www.alibabacloud.com/help/en/model-studio/models", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "tiered_pricing": [ + { + "input_cost_per_token": 5e-08, + "output_cost_per_token": 4e-07, + "range": [ + 0, + 256000.0 + ] + }, + { + "input_cost_per_token": 2.5e-07, + "output_cost_per_token": 2e-06, + "range": [ + 256000.0, + 1000000.0 + ] + } + ] + }, + "dashscope/qwen-flash-2025-07-28": { + "litellm_provider": "dashscope", + "max_input_tokens": 997952, + "max_output_tokens": 32768, + "max_tokens": 1000000, + "mode": "chat", + "source": "https://www.alibabacloud.com/help/en/model-studio/models", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "tiered_pricing": [ + { + "input_cost_per_token": 5e-08, + "output_cost_per_token": 4e-07, + "range": [ + 0, + 256000.0 + ] + }, + { + "input_cost_per_token": 2.5e-07, + "output_cost_per_token": 2e-06, + "range": [ + 256000.0, + 1000000.0 + ] + } + ] + }, + "dashscope/qwen-max": { + "input_cost_per_token": 1.6e-06, + "litellm_provider": "dashscope", + "max_input_tokens": 30720, + "max_output_tokens": 8192, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 6.4e-06, + "source": "https://www.alibabacloud.com/help/en/model-studio/models", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "dashscope/qwen-plus": { + "input_cost_per_token": 4e-07, + "litellm_provider": "dashscope", + "max_input_tokens": 129024, + "max_output_tokens": 16384, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "source": "https://www.alibabacloud.com/help/en/model-studio/models", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "dashscope/qwen-plus-2025-01-25": { + "input_cost_per_token": 4e-07, + "litellm_provider": "dashscope", + "max_input_tokens": 129024, + "max_output_tokens": 8192, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "source": "https://www.alibabacloud.com/help/en/model-studio/models", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "dashscope/qwen-plus-2025-04-28": { + "input_cost_per_token": 4e-07, + "litellm_provider": "dashscope", + "max_input_tokens": 129024, + "max_output_tokens": 16384, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_reasoning_token": 4e-06, + "output_cost_per_token": 1.2e-06, + "source": "https://www.alibabacloud.com/help/en/model-studio/models", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "dashscope/qwen-plus-2025-07-14": { + "input_cost_per_token": 4e-07, + "litellm_provider": "dashscope", + "max_input_tokens": 129024, + "max_output_tokens": 16384, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_reasoning_token": 4e-06, + "output_cost_per_token": 1.2e-06, + "source": "https://www.alibabacloud.com/help/en/model-studio/models", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "dashscope/qwen-plus-2025-07-28": { + "litellm_provider": "dashscope", + "max_input_tokens": 997952, + "max_output_tokens": 32768, + "max_tokens": 1000000, + "mode": "chat", + "source": "https://www.alibabacloud.com/help/en/model-studio/models", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "tiered_pricing": [ + { + "input_cost_per_token": 4e-07, + "output_cost_per_reasoning_token": 4e-06, + "output_cost_per_token": 1.2e-06, + "range": [ + 0, + 256000.0 + ] + }, + { + "input_cost_per_token": 1.2e-06, + "output_cost_per_reasoning_token": 1.2e-05, + "output_cost_per_token": 3.6e-06, + "range": [ + 256000.0, + 1000000.0 + ] + } + ] + }, + "dashscope/qwen-plus-2025-09-11": { + "litellm_provider": "dashscope", + "max_input_tokens": 997952, + "max_output_tokens": 32768, + "max_tokens": 1000000, + "mode": "chat", + "source": "https://www.alibabacloud.com/help/en/model-studio/models", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "tiered_pricing": [ + { + "input_cost_per_token": 4e-07, + "output_cost_per_reasoning_token": 4e-06, + "output_cost_per_token": 1.2e-06, + "range": [ + 0, + 256000.0 + ] + }, + { + "input_cost_per_token": 1.2e-06, + "output_cost_per_reasoning_token": 1.2e-05, + "output_cost_per_token": 3.6e-06, + "range": [ + 256000.0, + 1000000.0 + ] + } + ] + }, + "dashscope/qwen-plus-latest": { + "litellm_provider": "dashscope", + "max_input_tokens": 997952, + "max_output_tokens": 32768, + "max_tokens": 1000000, + "mode": "chat", + "source": "https://www.alibabacloud.com/help/en/model-studio/models", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "tiered_pricing": [ + { + "input_cost_per_token": 4e-07, + "output_cost_per_reasoning_token": 4e-06, + "output_cost_per_token": 1.2e-06, + "range": [ + 0, + 256000.0 + ] + }, + { + "input_cost_per_token": 1.2e-06, + "output_cost_per_reasoning_token": 1.2e-05, + "output_cost_per_token": 3.6e-06, + "range": [ + 256000.0, + 1000000.0 + ] + } + ] + }, + "dashscope/qwen-turbo": { + "input_cost_per_token": 5e-08, + "litellm_provider": "dashscope", + "max_input_tokens": 129024, + "max_output_tokens": 16384, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_reasoning_token": 5e-07, + "output_cost_per_token": 2e-07, + "source": "https://www.alibabacloud.com/help/en/model-studio/models", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "dashscope/qwen-turbo-2024-11-01": { + "input_cost_per_token": 5e-08, + "litellm_provider": "dashscope", + "max_input_tokens": 1000000, + "max_output_tokens": 8192, + "max_tokens": 1000000, + "mode": "chat", + "output_cost_per_token": 2e-07, + "source": "https://www.alibabacloud.com/help/en/model-studio/models", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "dashscope/qwen-turbo-2025-04-28": { + "input_cost_per_token": 5e-08, + "litellm_provider": "dashscope", + "max_input_tokens": 1000000, + "max_output_tokens": 16384, + "max_tokens": 1000000, + "mode": "chat", + "output_cost_per_reasoning_token": 5e-07, + "output_cost_per_token": 2e-07, + "source": "https://www.alibabacloud.com/help/en/model-studio/models", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "dashscope/qwen-turbo-latest": { + "input_cost_per_token": 5e-08, + "litellm_provider": "dashscope", + "max_input_tokens": 1000000, + "max_output_tokens": 16384, + "max_tokens": 1000000, + "mode": "chat", + "output_cost_per_reasoning_token": 5e-07, + "output_cost_per_token": 2e-07, + "source": "https://www.alibabacloud.com/help/en/model-studio/models", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "dashscope/qwen3-30b-a3b": { + "litellm_provider": "dashscope", + "max_input_tokens": 129024, + "max_output_tokens": 16384, + "max_tokens": 131072, + "mode": "chat", + "source": "https://www.alibabacloud.com/help/en/model-studio/models", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "dashscope/qwen3-coder-flash": { + "litellm_provider": "dashscope", + "max_input_tokens": 997952, + "max_output_tokens": 65536, + "max_tokens": 1000000, + "mode": "chat", + "source": "https://www.alibabacloud.com/help/en/model-studio/models", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "tiered_pricing": [ + { + "cache_read_input_token_cost": 8e-08, + "input_cost_per_token": 3e-07, + "output_cost_per_token": 1.5e-06, + "range": [ + 0, + 32000.0 + ] + }, + { + "cache_read_input_token_cost": 1.2e-07, + "input_cost_per_token": 5e-07, + "output_cost_per_token": 2.5e-06, + "range": [ + 32000.0, + 128000.0 + ] + }, + { + "cache_read_input_token_cost": 2e-07, + "input_cost_per_token": 8e-07, + "output_cost_per_token": 4e-06, + "range": [ + 128000.0, + 256000.0 + ] + }, + { + "cache_read_input_token_cost": 4e-07, + "input_cost_per_token": 1.6e-06, + "output_cost_per_token": 9.6e-06, + "range": [ + 256000.0, + 1000000.0 + ] + } + ] + }, + "dashscope/qwen3-coder-flash-2025-07-28": { + "litellm_provider": "dashscope", + "max_input_tokens": 997952, + "max_output_tokens": 65536, + "max_tokens": 1000000, + "mode": "chat", + "source": "https://www.alibabacloud.com/help/en/model-studio/models", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "tiered_pricing": [ + { + "input_cost_per_token": 3e-07, + "output_cost_per_token": 1.5e-06, + "range": [ + 0, + 32000.0 + ] + }, + { + "input_cost_per_token": 5e-07, + "output_cost_per_token": 2.5e-06, + "range": [ + 32000.0, + 128000.0 + ] + }, + { + "input_cost_per_token": 8e-07, + "output_cost_per_token": 4e-06, + "range": [ + 128000.0, + 256000.0 + ] + }, + { + "input_cost_per_token": 1.6e-06, + "output_cost_per_token": 9.6e-06, + "range": [ + 256000.0, + 1000000.0 + ] + } + ] + }, + "dashscope/qwen3-coder-plus": { + "litellm_provider": "dashscope", + "max_input_tokens": 997952, + "max_output_tokens": 65536, + "max_tokens": 1000000, + "mode": "chat", + "source": "https://www.alibabacloud.com/help/en/model-studio/models", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "tiered_pricing": [ + { + "cache_read_input_token_cost": 1e-07, + "input_cost_per_token": 1e-06, + "output_cost_per_token": 5e-06, + "range": [ + 0, + 32000.0 + ] + }, + { + "cache_read_input_token_cost": 1.8e-07, + "input_cost_per_token": 1.8e-06, + "output_cost_per_token": 9e-06, + "range": [ + 32000.0, + 128000.0 + ] + }, + { + "cache_read_input_token_cost": 3e-07, + "input_cost_per_token": 3e-06, + "output_cost_per_token": 1.5e-05, + "range": [ + 128000.0, + 256000.0 + ] + }, + { + "cache_read_input_token_cost": 6e-07, + "input_cost_per_token": 6e-06, + "output_cost_per_token": 6e-05, + "range": [ + 256000.0, + 1000000.0 + ] + } + ] + }, + "dashscope/qwen3-coder-plus-2025-07-22": { + "litellm_provider": "dashscope", + "max_input_tokens": 997952, + "max_output_tokens": 65536, + "max_tokens": 1000000, + "mode": "chat", + "source": "https://www.alibabacloud.com/help/en/model-studio/models", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "tiered_pricing": [ + { + "input_cost_per_token": 1e-06, + "output_cost_per_token": 5e-06, + "range": [ + 0, + 32000.0 + ] + }, + { + "input_cost_per_token": 1.8e-06, + "output_cost_per_token": 9e-06, + "range": [ + 32000.0, + 128000.0 + ] + }, + { + "input_cost_per_token": 3e-06, + "output_cost_per_token": 1.5e-05, + "range": [ + 128000.0, + 256000.0 + ] + }, + { + "input_cost_per_token": 6e-06, + "output_cost_per_token": 6e-05, + "range": [ + 256000.0, + 1000000.0 + ] + } + ] + }, + "dashscope/qwen3-max-preview": { + "litellm_provider": "dashscope", + "max_input_tokens": 258048, + "max_output_tokens": 65536, + "max_tokens": 262144, + "mode": "chat", + "source": "https://www.alibabacloud.com/help/en/model-studio/models", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "tiered_pricing": [ + { + "input_cost_per_token": 1.2e-06, + "output_cost_per_token": 6e-06, + "range": [ + 0, + 32000.0 + ] + }, + { + "input_cost_per_token": 2.4e-06, + "output_cost_per_token": 1.2e-05, + "range": [ + 32000.0, + 128000.0 + ] + }, + { + "input_cost_per_token": 3e-06, + "output_cost_per_token": 1.5e-05, + "range": [ + 128000.0, + 252000.0 + ] + } + ] + }, + "dashscope/qwq-plus": { + "input_cost_per_token": 8e-07, + "litellm_provider": "dashscope", + "max_input_tokens": 98304, + "max_output_tokens": 8192, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 2.4e-06, + "source": "https://www.alibabacloud.com/help/en/model-studio/models", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "databricks/databricks-bge-large-en": { + "input_cost_per_token": 1.0003e-07, + "input_dbu_cost_per_token": 1.429e-06, + "litellm_provider": "databricks", + "max_input_tokens": 512, + "max_tokens": 512, + "metadata": { + "notes": "Input/output cost per token is dbu cost * $0.070, based on databricks Llama 3.1 70B conversion. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." + }, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_dbu_cost_per_token": 0.0, + "output_vector_size": 1024, + "source": "https://www.databricks.com/product/pricing/foundation-model-serving" + }, + "databricks/databricks-claude-3-7-sonnet": { + "input_cost_per_token": 2.5e-06, + "input_dbu_cost_per_token": 3.571e-05, + "litellm_provider": "databricks", + "max_input_tokens": 200000, + "max_output_tokens": 128000, + "max_tokens": 200000, + "metadata": { + "notes": "Input/output cost per token is dbu cost * $0.070, based on databricks Claude 3.7 conversion. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." + }, + "mode": "chat", + "output_cost_per_token": 1.7857e-05, + "output_db_cost_per_token": 0.000214286, + "source": "https://www.databricks.com/product/pricing/foundation-model-serving", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "databricks/databricks-gte-large-en": { + "input_cost_per_token": 1.2999e-07, + "input_dbu_cost_per_token": 1.857e-06, + "litellm_provider": "databricks", + "max_input_tokens": 8192, + "max_tokens": 8192, + "metadata": { + "notes": "Input/output cost per token is dbu cost * $0.070, based on databricks Llama 3.1 70B conversion. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." + }, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_dbu_cost_per_token": 0.0, + "output_vector_size": 1024, + "source": "https://www.databricks.com/product/pricing/foundation-model-serving" + }, + "databricks/databricks-llama-2-70b-chat": { + "input_cost_per_token": 5.0001e-07, + "input_dbu_cost_per_token": 7.143e-06, + "litellm_provider": "databricks", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "metadata": { + "notes": "Input/output cost per token is dbu cost * $0.070, based on databricks Llama 3.1 70B conversion. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." + }, + "mode": "chat", + "output_cost_per_token": 1.5e-06, + "output_dbu_cost_per_token": 2.1429e-05, + "source": "https://www.databricks.com/product/pricing/foundation-model-serving", + "supports_tool_choice": true + }, + "databricks/databricks-llama-4-maverick": { + "input_cost_per_token": 5e-06, + "input_dbu_cost_per_token": 7.143e-05, + "litellm_provider": "databricks", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "metadata": { + "notes": "Databricks documentation now provides both DBU costs (_dbu_cost_per_token) and dollar costs(_cost_per_token)." + }, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "output_dbu_cost_per_token": 0.00021429, + "source": "https://www.databricks.com/product/pricing/foundation-model-serving", + "supports_tool_choice": true + }, + "databricks/databricks-meta-llama-3-1-405b-instruct": { + "input_cost_per_token": 5e-06, + "input_dbu_cost_per_token": 7.1429e-05, + "litellm_provider": "databricks", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "metadata": { + "notes": "Input/output cost per token is dbu cost * $0.070, based on databricks Llama 3.1 70B conversion. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." + }, + "mode": "chat", + "output_cost_per_token": 1.500002e-05, + "output_db_cost_per_token": 0.000214286, + "source": "https://www.databricks.com/product/pricing/foundation-model-serving", + "supports_tool_choice": true + }, + "databricks/databricks-meta-llama-3-3-70b-instruct": { + "input_cost_per_token": 1.00002e-06, + "input_dbu_cost_per_token": 1.4286e-05, + "litellm_provider": "databricks", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "metadata": { + "notes": "Input/output cost per token is dbu cost * $0.070, based on databricks Llama 3.1 70B conversion. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." + }, + "mode": "chat", + "output_cost_per_token": 2.99999e-06, + "output_dbu_cost_per_token": 4.2857e-05, + "source": "https://www.databricks.com/product/pricing/foundation-model-serving", + "supports_tool_choice": true + }, + "databricks/databricks-meta-llama-3-70b-instruct": { + "input_cost_per_token": 1.00002e-06, + "input_dbu_cost_per_token": 1.4286e-05, + "litellm_provider": "databricks", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "metadata": { + "notes": "Input/output cost per token is dbu cost * $0.070, based on databricks Llama 3.1 70B conversion. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." + }, + "mode": "chat", + "output_cost_per_token": 2.99999e-06, + "output_dbu_cost_per_token": 4.2857e-05, + "source": "https://www.databricks.com/product/pricing/foundation-model-serving", + "supports_tool_choice": true + }, + "databricks/databricks-mixtral-8x7b-instruct": { + "input_cost_per_token": 5.0001e-07, + "input_dbu_cost_per_token": 7.143e-06, + "litellm_provider": "databricks", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "metadata": { + "notes": "Input/output cost per token is dbu cost * $0.070, based on databricks Llama 3.1 70B conversion. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." + }, + "mode": "chat", + "output_cost_per_token": 9.9902e-07, + "output_dbu_cost_per_token": 1.4286e-05, + "source": "https://www.databricks.com/product/pricing/foundation-model-serving", + "supports_tool_choice": true + }, + "databricks/databricks-mpt-30b-instruct": { + "input_cost_per_token": 9.9902e-07, + "input_dbu_cost_per_token": 1.4286e-05, + "litellm_provider": "databricks", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "metadata": { + "notes": "Input/output cost per token is dbu cost * $0.070, based on databricks Llama 3.1 70B conversion. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." + }, + "mode": "chat", + "output_cost_per_token": 9.9902e-07, + "output_dbu_cost_per_token": 1.4286e-05, + "source": "https://www.databricks.com/product/pricing/foundation-model-serving", + "supports_tool_choice": true + }, + "databricks/databricks-mpt-7b-instruct": { + "input_cost_per_token": 5.0001e-07, + "input_dbu_cost_per_token": 7.143e-06, + "litellm_provider": "databricks", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "metadata": { + "notes": "Input/output cost per token is dbu cost * $0.070, based on databricks Llama 3.1 70B conversion. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." + }, + "mode": "chat", + "output_cost_per_token": 0.0, + "output_dbu_cost_per_token": 0.0, + "source": "https://www.databricks.com/product/pricing/foundation-model-serving", + "supports_tool_choice": true + }, + "davinci-002": { + "input_cost_per_token": 2e-06, + "litellm_provider": "text-completion-openai", + "max_input_tokens": 16384, + "max_output_tokens": 4096, + "max_tokens": 16384, + "mode": "completion", + "output_cost_per_token": 2e-06 + }, + "deepgram/base": { + "input_cost_per_second": 0.00020833, + "litellm_provider": "deepgram", + "metadata": { + "calculation": "$0.0125/60 seconds = $0.00020833 per second", + "original_pricing_per_minute": 0.0125 + }, + "mode": "audio_transcription", + "output_cost_per_second": 0.0, + "source": "https://deepgram.com/pricing", + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, + "deepgram/base-conversationalai": { + "input_cost_per_second": 0.00020833, + "litellm_provider": "deepgram", + "metadata": { + "calculation": "$0.0125/60 seconds = $0.00020833 per second", + "original_pricing_per_minute": 0.0125 + }, + "mode": "audio_transcription", + "output_cost_per_second": 0.0, + "source": "https://deepgram.com/pricing", + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, + "deepgram/base-finance": { + "input_cost_per_second": 0.00020833, + "litellm_provider": "deepgram", + "metadata": { + "calculation": "$0.0125/60 seconds = $0.00020833 per second", + "original_pricing_per_minute": 0.0125 + }, + "mode": "audio_transcription", + "output_cost_per_second": 0.0, + "source": "https://deepgram.com/pricing", + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, + "deepgram/base-general": { + "input_cost_per_second": 0.00020833, + "litellm_provider": "deepgram", + "metadata": { + "calculation": "$0.0125/60 seconds = $0.00020833 per second", + "original_pricing_per_minute": 0.0125 + }, + "mode": "audio_transcription", + "output_cost_per_second": 0.0, + "source": "https://deepgram.com/pricing", + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, + "deepgram/base-meeting": { + "input_cost_per_second": 0.00020833, + "litellm_provider": "deepgram", + "metadata": { + "calculation": "$0.0125/60 seconds = $0.00020833 per second", + "original_pricing_per_minute": 0.0125 + }, + "mode": "audio_transcription", + "output_cost_per_second": 0.0, + "source": "https://deepgram.com/pricing", + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, + "deepgram/base-phonecall": { + "input_cost_per_second": 0.00020833, + "litellm_provider": "deepgram", + "metadata": { + "calculation": "$0.0125/60 seconds = $0.00020833 per second", + "original_pricing_per_minute": 0.0125 + }, + "mode": "audio_transcription", + "output_cost_per_second": 0.0, + "source": "https://deepgram.com/pricing", + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, + "deepgram/base-video": { + "input_cost_per_second": 0.00020833, + "litellm_provider": "deepgram", + "metadata": { + "calculation": "$0.0125/60 seconds = $0.00020833 per second", + "original_pricing_per_minute": 0.0125 + }, + "mode": "audio_transcription", + "output_cost_per_second": 0.0, + "source": "https://deepgram.com/pricing", + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, + "deepgram/base-voicemail": { + "input_cost_per_second": 0.00020833, + "litellm_provider": "deepgram", + "metadata": { + "calculation": "$0.0125/60 seconds = $0.00020833 per second", + "original_pricing_per_minute": 0.0125 + }, + "mode": "audio_transcription", + "output_cost_per_second": 0.0, + "source": "https://deepgram.com/pricing", + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, + "deepgram/enhanced": { + "input_cost_per_second": 0.00024167, + "litellm_provider": "deepgram", + "metadata": { + "calculation": "$0.0145/60 seconds = $0.00024167 per second", + "original_pricing_per_minute": 0.0145 + }, + "mode": "audio_transcription", + "output_cost_per_second": 0.0, + "source": "https://deepgram.com/pricing", + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, + "deepgram/enhanced-finance": { + "input_cost_per_second": 0.00024167, + "litellm_provider": "deepgram", + "metadata": { + "calculation": "$0.0145/60 seconds = $0.00024167 per second", + "original_pricing_per_minute": 0.0145 + }, + "mode": "audio_transcription", + "output_cost_per_second": 0.0, + "source": "https://deepgram.com/pricing", + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, + "deepgram/enhanced-general": { + "input_cost_per_second": 0.00024167, + "litellm_provider": "deepgram", + "metadata": { + "calculation": "$0.0145/60 seconds = $0.00024167 per second", + "original_pricing_per_minute": 0.0145 + }, + "mode": "audio_transcription", + "output_cost_per_second": 0.0, + "source": "https://deepgram.com/pricing", + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, + "deepgram/enhanced-meeting": { + "input_cost_per_second": 0.00024167, + "litellm_provider": "deepgram", + "metadata": { + "calculation": "$0.0145/60 seconds = $0.00024167 per second", + "original_pricing_per_minute": 0.0145 + }, + "mode": "audio_transcription", + "output_cost_per_second": 0.0, + "source": "https://deepgram.com/pricing", + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, + "deepgram/enhanced-phonecall": { + "input_cost_per_second": 0.00024167, + "litellm_provider": "deepgram", + "metadata": { + "calculation": "$0.0145/60 seconds = $0.00024167 per second", + "original_pricing_per_minute": 0.0145 + }, + "mode": "audio_transcription", + "output_cost_per_second": 0.0, + "source": "https://deepgram.com/pricing", + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, + "deepgram/nova": { + "input_cost_per_second": 7.167e-05, + "litellm_provider": "deepgram", + "metadata": { + "calculation": "$0.0043/60 seconds = $0.00007167 per second", + "original_pricing_per_minute": 0.0043 + }, + "mode": "audio_transcription", + "output_cost_per_second": 0.0, + "source": "https://deepgram.com/pricing", + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, + "deepgram/nova-2": { + "input_cost_per_second": 7.167e-05, + "litellm_provider": "deepgram", + "metadata": { + "calculation": "$0.0043/60 seconds = $0.00007167 per second", + "original_pricing_per_minute": 0.0043 + }, + "mode": "audio_transcription", + "output_cost_per_second": 0.0, + "source": "https://deepgram.com/pricing", + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, + "deepgram/nova-2-atc": { + "input_cost_per_second": 7.167e-05, + "litellm_provider": "deepgram", + "metadata": { + "calculation": "$0.0043/60 seconds = $0.00007167 per second", + "original_pricing_per_minute": 0.0043 + }, + "mode": "audio_transcription", + "output_cost_per_second": 0.0, + "source": "https://deepgram.com/pricing", + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, + "deepgram/nova-2-automotive": { + "input_cost_per_second": 7.167e-05, + "litellm_provider": "deepgram", + "metadata": { + "calculation": "$0.0043/60 seconds = $0.00007167 per second", + "original_pricing_per_minute": 0.0043 + }, + "mode": "audio_transcription", + "output_cost_per_second": 0.0, + "source": "https://deepgram.com/pricing", + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, + "deepgram/nova-2-conversationalai": { + "input_cost_per_second": 7.167e-05, + "litellm_provider": "deepgram", + "metadata": { + "calculation": "$0.0043/60 seconds = $0.00007167 per second", + "original_pricing_per_minute": 0.0043 + }, + "mode": "audio_transcription", + "output_cost_per_second": 0.0, + "source": "https://deepgram.com/pricing", + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, + "deepgram/nova-2-drivethru": { + "input_cost_per_second": 7.167e-05, + "litellm_provider": "deepgram", + "metadata": { + "calculation": "$0.0043/60 seconds = $0.00007167 per second", + "original_pricing_per_minute": 0.0043 + }, + "mode": "audio_transcription", + "output_cost_per_second": 0.0, + "source": "https://deepgram.com/pricing", + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, + "deepgram/nova-2-finance": { + "input_cost_per_second": 7.167e-05, + "litellm_provider": "deepgram", + "metadata": { + "calculation": "$0.0043/60 seconds = $0.00007167 per second", + "original_pricing_per_minute": 0.0043 + }, + "mode": "audio_transcription", + "output_cost_per_second": 0.0, + "source": "https://deepgram.com/pricing", + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, + "deepgram/nova-2-general": { + "input_cost_per_second": 7.167e-05, + "litellm_provider": "deepgram", + "metadata": { + "calculation": "$0.0043/60 seconds = $0.00007167 per second", + "original_pricing_per_minute": 0.0043 + }, + "mode": "audio_transcription", + "output_cost_per_second": 0.0, + "source": "https://deepgram.com/pricing", + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, + "deepgram/nova-2-meeting": { + "input_cost_per_second": 7.167e-05, + "litellm_provider": "deepgram", + "metadata": { + "calculation": "$0.0043/60 seconds = $0.00007167 per second", + "original_pricing_per_minute": 0.0043 + }, + "mode": "audio_transcription", + "output_cost_per_second": 0.0, + "source": "https://deepgram.com/pricing", + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, + "deepgram/nova-2-phonecall": { + "input_cost_per_second": 7.167e-05, + "litellm_provider": "deepgram", + "metadata": { + "calculation": "$0.0043/60 seconds = $0.00007167 per second", + "original_pricing_per_minute": 0.0043 + }, + "mode": "audio_transcription", + "output_cost_per_second": 0.0, + "source": "https://deepgram.com/pricing", + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, + "deepgram/nova-2-video": { + "input_cost_per_second": 7.167e-05, + "litellm_provider": "deepgram", + "metadata": { + "calculation": "$0.0043/60 seconds = $0.00007167 per second", + "original_pricing_per_minute": 0.0043 + }, + "mode": "audio_transcription", + "output_cost_per_second": 0.0, + "source": "https://deepgram.com/pricing", + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, + "deepgram/nova-2-voicemail": { + "input_cost_per_second": 7.167e-05, + "litellm_provider": "deepgram", + "metadata": { + "calculation": "$0.0043/60 seconds = $0.00007167 per second", + "original_pricing_per_minute": 0.0043 + }, + "mode": "audio_transcription", + "output_cost_per_second": 0.0, + "source": "https://deepgram.com/pricing", + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, + "deepgram/nova-3": { + "input_cost_per_second": 7.167e-05, + "litellm_provider": "deepgram", + "metadata": { + "calculation": "$0.0043/60 seconds = $0.00007167 per second", + "original_pricing_per_minute": 0.0043 + }, + "mode": "audio_transcription", + "output_cost_per_second": 0.0, + "source": "https://deepgram.com/pricing", + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, + "deepgram/nova-3-general": { + "input_cost_per_second": 7.167e-05, + "litellm_provider": "deepgram", + "metadata": { + "calculation": "$0.0043/60 seconds = $0.00007167 per second", + "original_pricing_per_minute": 0.0043 + }, + "mode": "audio_transcription", + "output_cost_per_second": 0.0, + "source": "https://deepgram.com/pricing", + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, + "deepgram/nova-3-medical": { + "input_cost_per_second": 8.667e-05, + "litellm_provider": "deepgram", + "metadata": { + "calculation": "$0.0052/60 seconds = $0.00008667 per second (multilingual)", + "original_pricing_per_minute": 0.0052 + }, + "mode": "audio_transcription", + "output_cost_per_second": 0.0, + "source": "https://deepgram.com/pricing", + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, + "deepgram/nova-general": { + "input_cost_per_second": 7.167e-05, + "litellm_provider": "deepgram", + "metadata": { + "calculation": "$0.0043/60 seconds = $0.00007167 per second", + "original_pricing_per_minute": 0.0043 + }, + "mode": "audio_transcription", + "output_cost_per_second": 0.0, + "source": "https://deepgram.com/pricing", + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, + "deepgram/nova-phonecall": { + "input_cost_per_second": 7.167e-05, + "litellm_provider": "deepgram", + "metadata": { + "calculation": "$0.0043/60 seconds = $0.00007167 per second", + "original_pricing_per_minute": 0.0043 + }, + "mode": "audio_transcription", + "output_cost_per_second": 0.0, + "source": "https://deepgram.com/pricing", + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, + "deepgram/whisper": { + "input_cost_per_second": 0.0001, + "litellm_provider": "deepgram", + "metadata": { + "notes": "Deepgram's hosted OpenAI Whisper models - pricing may differ from native Deepgram models" + }, + "mode": "audio_transcription", + "output_cost_per_second": 0.0, + "source": "https://deepgram.com/pricing", + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, + "deepgram/whisper-base": { + "input_cost_per_second": 0.0001, + "litellm_provider": "deepgram", + "metadata": { + "notes": "Deepgram's hosted OpenAI Whisper models - pricing may differ from native Deepgram models" + }, + "mode": "audio_transcription", + "output_cost_per_second": 0.0, + "source": "https://deepgram.com/pricing", + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, + "deepgram/whisper-large": { + "input_cost_per_second": 0.0001, + "litellm_provider": "deepgram", + "metadata": { + "notes": "Deepgram's hosted OpenAI Whisper models - pricing may differ from native Deepgram models" + }, + "mode": "audio_transcription", + "output_cost_per_second": 0.0, + "source": "https://deepgram.com/pricing", + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, + "deepgram/whisper-medium": { + "input_cost_per_second": 0.0001, + "litellm_provider": "deepgram", + "metadata": { + "notes": "Deepgram's hosted OpenAI Whisper models - pricing may differ from native Deepgram models" + }, + "mode": "audio_transcription", + "output_cost_per_second": 0.0, + "source": "https://deepgram.com/pricing", + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, + "deepgram/whisper-small": { + "input_cost_per_second": 0.0001, + "litellm_provider": "deepgram", + "metadata": { + "notes": "Deepgram's hosted OpenAI Whisper models - pricing may differ from native Deepgram models" + }, + "mode": "audio_transcription", + "output_cost_per_second": 0.0, + "source": "https://deepgram.com/pricing", + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, + "deepgram/whisper-tiny": { + "input_cost_per_second": 0.0001, + "litellm_provider": "deepgram", + "metadata": { + "notes": "Deepgram's hosted OpenAI Whisper models - pricing may differ from native Deepgram models" + }, + "mode": "audio_transcription", + "output_cost_per_second": 0.0, + "source": "https://deepgram.com/pricing", + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, + "deepinfra/Gryphe/MythoMax-L2-13b": { + "input_cost_per_token": 7.2e-08, + "litellm_provider": "deepinfra", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 7.2e-08, + "supports_tool_choice": true + }, + "deepinfra/NousResearch/Hermes-3-Llama-3.1-405B": { + "input_cost_per_token": 7e-07, + "litellm_provider": "deepinfra", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 8e-07, + "supports_tool_choice": true + }, + "deepinfra/NousResearch/Hermes-3-Llama-3.1-70B": { + "input_cost_per_token": 1e-07, + "litellm_provider": "deepinfra", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 2.8e-07, + "supports_tool_choice": false + }, + "deepinfra/Qwen/QwQ-32B": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "deepinfra", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 4e-07, + "supports_tool_choice": true + }, + "deepinfra/Qwen/Qwen2.5-72B-Instruct": { + "input_cost_per_token": 1.2e-07, + "litellm_provider": "deepinfra", + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 3.9e-07, + "supports_tool_choice": true + }, + "deepinfra/Qwen/Qwen2.5-7B-Instruct": { + "input_cost_per_token": 4e-08, + "litellm_provider": "deepinfra", + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 1e-07, + "supports_tool_choice": false + }, + "deepinfra/Qwen/Qwen2.5-VL-32B-Instruct": { + "input_cost_per_token": 2e-07, + "litellm_provider": "deepinfra", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6e-07, + "supports_tool_choice": true + }, + "deepinfra/Qwen/Qwen3-14B": { + "input_cost_per_token": 6e-08, + "litellm_provider": "deepinfra", + "max_input_tokens": 40960, + "max_output_tokens": 40960, + "max_tokens": 40960, + "mode": "chat", + "output_cost_per_token": 2.4e-07, + "supports_tool_choice": true + }, + "deepinfra/Qwen/Qwen3-235B-A22B": { + "input_cost_per_token": 1.3e-07, + "litellm_provider": "deepinfra", + "max_input_tokens": 40960, + "max_output_tokens": 40960, + "max_tokens": 40960, + "mode": "chat", + "output_cost_per_token": 6e-07, + "supports_tool_choice": true + }, + "deepinfra/Qwen/Qwen3-235B-A22B-Instruct-2507": { + "input_cost_per_token": 1.3e-07, + "litellm_provider": "deepinfra", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 6e-07, + "supports_tool_choice": true + }, + "deepinfra/Qwen/Qwen3-235B-A22B-Thinking-2507": { + "input_cost_per_token": 1.3e-07, + "litellm_provider": "deepinfra", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 6e-07, + "supports_tool_choice": true + }, + "deepinfra/Qwen/Qwen3-30B-A3B": { + "input_cost_per_token": 8e-08, + "litellm_provider": "deepinfra", + "max_input_tokens": 40960, + "max_output_tokens": 40960, + "max_tokens": 40960, + "mode": "chat", + "output_cost_per_token": 2.9e-07, + "supports_tool_choice": true + }, + "deepinfra/Qwen/Qwen3-32B": { + "input_cost_per_token": 1e-07, + "litellm_provider": "deepinfra", + "max_input_tokens": 40960, + "max_output_tokens": 40960, + "max_tokens": 40960, + "mode": "chat", + "output_cost_per_token": 3e-07, + "supports_tool_choice": true + }, + "deepinfra/Qwen/Qwen3-Coder-480B-A35B-Instruct": { + "input_cost_per_token": 4e-07, + "litellm_provider": "deepinfra", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 1.6e-06, + "supports_tool_choice": true + }, + "deepinfra/Qwen/Qwen3-Coder-480B-A35B-Instruct-Turbo": { + "cache_read_input_token_cost": 2.4e-07, + "input_cost_per_token": 3e-07, + "litellm_provider": "deepinfra", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "supports_tool_choice": true + }, + "deepinfra/Sao10K/L3-8B-Lunaris-v1-Turbo": { + "input_cost_per_token": 2e-08, + "litellm_provider": "deepinfra", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 5e-08, + "supports_tool_choice": false + }, + "deepinfra/Sao10K/L3.1-70B-Euryale-v2.2": { + "input_cost_per_token": 6.5e-07, + "litellm_provider": "deepinfra", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 7.5e-07, + "supports_tool_choice": false + }, + "deepinfra/Sao10K/L3.3-70B-Euryale-v2.3": { + "input_cost_per_token": 6.5e-07, + "litellm_provider": "deepinfra", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 7.5e-07, + "supports_tool_choice": false + }, + "deepinfra/allenai/olmOCR-7B-0725-FP8": { + "input_cost_per_token": 2.7e-07, + "litellm_provider": "deepinfra", + "max_input_tokens": 16384, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1.5e-06, + "supports_tool_choice": false + }, + "deepinfra/anthropic/claude-3-7-sonnet-latest": { + "cache_read_input_token_cost": 3.3e-07, + "input_cost_per_token": 3.3e-06, + "litellm_provider": "deepinfra", + "max_input_tokens": 200000, + "max_output_tokens": 200000, + "max_tokens": 200000, + "mode": "chat", + "output_cost_per_token": 1.65e-05, + "supports_tool_choice": true + }, + "deepinfra/anthropic/claude-4-opus": { + "input_cost_per_token": 1.65e-05, + "litellm_provider": "deepinfra", + "max_input_tokens": 200000, + "max_output_tokens": 200000, + "max_tokens": 200000, + "mode": "chat", + "output_cost_per_token": 8.25e-05, + "supports_tool_choice": true + }, + "deepinfra/anthropic/claude-4-sonnet": { + "input_cost_per_token": 3.3e-06, + "litellm_provider": "deepinfra", + "max_input_tokens": 200000, + "max_output_tokens": 200000, + "max_tokens": 200000, + "mode": "chat", + "output_cost_per_token": 1.65e-05, + "supports_tool_choice": true + }, + "deepinfra/deepseek-ai/DeepSeek-R1": { + "input_cost_per_token": 7e-07, + "litellm_provider": "deepinfra", + "max_input_tokens": 163840, + "max_output_tokens": 163840, + "max_tokens": 163840, + "mode": "chat", + "output_cost_per_token": 2.4e-06, + "supports_tool_choice": true + }, + "deepinfra/deepseek-ai/DeepSeek-R1-0528": { + "cache_read_input_token_cost": 4e-07, + "input_cost_per_token": 5e-07, + "litellm_provider": "deepinfra", + "max_input_tokens": 163840, + "max_output_tokens": 163840, + "max_tokens": 163840, + "mode": "chat", + "output_cost_per_token": 2.15e-06, + "supports_tool_choice": true + }, + "deepinfra/deepseek-ai/DeepSeek-R1-0528-Turbo": { + "input_cost_per_token": 1e-06, + "litellm_provider": "deepinfra", + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 3e-06, + "supports_tool_choice": true + }, + "deepinfra/deepseek-ai/DeepSeek-R1-Distill-Llama-70B": { + "input_cost_per_token": 1e-07, + "litellm_provider": "deepinfra", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 4e-07, + "supports_tool_choice": false + }, + "deepinfra/deepseek-ai/DeepSeek-R1-Distill-Qwen-32B": { + "input_cost_per_token": 7.5e-08, + "litellm_provider": "deepinfra", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1.5e-07, + "supports_tool_choice": true + }, + "deepinfra/deepseek-ai/DeepSeek-R1-Turbo": { + "input_cost_per_token": 1e-06, + "litellm_provider": "deepinfra", + "max_input_tokens": 40960, + "max_output_tokens": 40960, + "max_tokens": 40960, + "mode": "chat", + "output_cost_per_token": 3e-06, + "supports_tool_choice": true + }, + "deepinfra/deepseek-ai/DeepSeek-V3": { + "input_cost_per_token": 3.8e-07, + "litellm_provider": "deepinfra", + "max_input_tokens": 163840, + "max_output_tokens": 163840, + "max_tokens": 163840, + "mode": "chat", + "output_cost_per_token": 8.9e-07, + "supports_tool_choice": true + }, + "deepinfra/deepseek-ai/DeepSeek-V3-0324": { + "cache_read_input_token_cost": 2.24e-07, + "input_cost_per_token": 2.8e-07, + "litellm_provider": "deepinfra", + "max_input_tokens": 163840, + "max_output_tokens": 163840, + "max_tokens": 163840, + "mode": "chat", + "output_cost_per_token": 8.8e-07, + "supports_tool_choice": true + }, + "deepinfra/deepseek-ai/DeepSeek-V3.1": { + "cache_read_input_token_cost": 2.16e-07, + "input_cost_per_token": 2.7e-07, + "litellm_provider": "deepinfra", + "max_input_tokens": 163840, + "max_output_tokens": 163840, + "max_tokens": 163840, + "mode": "chat", + "output_cost_per_token": 1e-06, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "deepinfra/google/gemini-2.0-flash-001": { + "input_cost_per_token": 1e-07, + "litellm_provider": "deepinfra", + "max_input_tokens": 1000000, + "max_output_tokens": 1000000, + "max_tokens": 1000000, + "mode": "chat", + "output_cost_per_token": 4e-07, + "supports_tool_choice": true + }, + "deepinfra/google/gemini-2.5-flash": { + "input_cost_per_token": 2.1e-07, + "litellm_provider": "deepinfra", + "max_input_tokens": 1000000, + "max_output_tokens": 1000000, + "max_tokens": 1000000, + "mode": "chat", + "output_cost_per_token": 1.75e-06, + "supports_tool_choice": true + }, + "deepinfra/google/gemini-2.5-pro": { + "input_cost_per_token": 8.75e-07, + "litellm_provider": "deepinfra", + "max_input_tokens": 1000000, + "max_output_tokens": 1000000, + "max_tokens": 1000000, + "mode": "chat", + "output_cost_per_token": 7e-06, + "supports_tool_choice": true + }, + "deepinfra/google/gemma-3-12b-it": { + "input_cost_per_token": 5e-08, + "litellm_provider": "deepinfra", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1e-07, + "supports_tool_choice": true + }, + "deepinfra/google/gemma-3-27b-it": { + "input_cost_per_token": 9e-08, + "litellm_provider": "deepinfra", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1.7e-07, + "supports_tool_choice": true + }, + "deepinfra/google/gemma-3-4b-it": { + "input_cost_per_token": 4e-08, + "litellm_provider": "deepinfra", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 8e-08, + "supports_tool_choice": true + }, + "deepinfra/meta-llama/Llama-3.2-11B-Vision-Instruct": { + "input_cost_per_token": 4.9e-08, + "litellm_provider": "deepinfra", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 4.9e-08, + "supports_tool_choice": false + }, + "deepinfra/meta-llama/Llama-3.2-3B-Instruct": { + "input_cost_per_token": 1.2e-08, + "litellm_provider": "deepinfra", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 2.4e-08, + "supports_tool_choice": true + }, + "deepinfra/meta-llama/Llama-3.3-70B-Instruct": { + "input_cost_per_token": 2.3e-07, + "litellm_provider": "deepinfra", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 4e-07, + "supports_tool_choice": true + }, + "deepinfra/meta-llama/Llama-3.3-70B-Instruct-Turbo": { + "input_cost_per_token": 3.8e-08, + "litellm_provider": "deepinfra", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1.2e-07, + "supports_tool_choice": true + }, + "deepinfra/meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "deepinfra", + "max_input_tokens": 1048576, + "max_output_tokens": 1048576, + "max_tokens": 1048576, + "mode": "chat", + "output_cost_per_token": 6e-07, + "supports_tool_choice": true + }, + "deepinfra/meta-llama/Llama-4-Scout-17B-16E-Instruct": { + "input_cost_per_token": 8e-08, + "litellm_provider": "deepinfra", + "max_input_tokens": 327680, + "max_output_tokens": 327680, + "max_tokens": 327680, + "mode": "chat", + "output_cost_per_token": 3e-07, + "supports_tool_choice": true + }, + "deepinfra/meta-llama/Llama-Guard-3-8B": { + "input_cost_per_token": 5.5e-08, + "litellm_provider": "deepinfra", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 5.5e-08, + "supports_tool_choice": false + }, + "deepinfra/meta-llama/Llama-Guard-4-12B": { + "input_cost_per_token": 1.8e-07, + "litellm_provider": "deepinfra", + "max_input_tokens": 163840, + "max_output_tokens": 163840, + "max_tokens": 163840, + "mode": "chat", + "output_cost_per_token": 1.8e-07, + "supports_tool_choice": false + }, + "deepinfra/meta-llama/Meta-Llama-3-8B-Instruct": { + "input_cost_per_token": 3e-08, + "litellm_provider": "deepinfra", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 6e-08, + "supports_tool_choice": true + }, + "deepinfra/meta-llama/Meta-Llama-3.1-70B-Instruct": { + "input_cost_per_token": 2.3e-07, + "litellm_provider": "deepinfra", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 4e-07, + "supports_tool_choice": true + }, + "deepinfra/meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo": { + "input_cost_per_token": 1e-07, + "litellm_provider": "deepinfra", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 2.8e-07, + "supports_tool_choice": true + }, + "deepinfra/meta-llama/Meta-Llama-3.1-8B-Instruct": { + "input_cost_per_token": 3e-08, + "litellm_provider": "deepinfra", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 5e-08, + "supports_tool_choice": true + }, + "deepinfra/meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo": { + "input_cost_per_token": 1.5e-08, + "litellm_provider": "deepinfra", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 2e-08, + "supports_tool_choice": true + }, + "deepinfra/microsoft/WizardLM-2-8x22B": { + "input_cost_per_token": 4.8e-07, + "litellm_provider": "deepinfra", + "max_input_tokens": 65536, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 4.8e-07, + "supports_tool_choice": false + }, + "deepinfra/microsoft/phi-4": { + "input_cost_per_token": 7e-08, + "litellm_provider": "deepinfra", + "max_input_tokens": 16384, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1.4e-07, + "supports_tool_choice": true + }, + "deepinfra/mistralai/Mistral-Nemo-Instruct-2407": { + "input_cost_per_token": 2e-08, + "litellm_provider": "deepinfra", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 4e-08, + "supports_tool_choice": true + }, + "deepinfra/mistralai/Mistral-Small-24B-Instruct-2501": { + "input_cost_per_token": 5e-08, + "litellm_provider": "deepinfra", + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 8e-08, + "supports_tool_choice": true + }, + "deepinfra/mistralai/Mistral-Small-3.2-24B-Instruct-2506": { + "input_cost_per_token": 5e-08, + "litellm_provider": "deepinfra", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1e-07, + "supports_tool_choice": true + }, + "deepinfra/mistralai/Mixtral-8x7B-Instruct-v0.1": { + "input_cost_per_token": 8e-08, + "litellm_provider": "deepinfra", + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 2.4e-07, + "supports_tool_choice": true + }, + "deepinfra/moonshotai/Kimi-K2-Instruct": { + "input_cost_per_token": 5e-07, + "litellm_provider": "deepinfra", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 2e-06, + "supports_tool_choice": true + }, + "deepinfra/nvidia/Llama-3.1-Nemotron-70B-Instruct": { + "input_cost_per_token": 1.2e-07, + "litellm_provider": "deepinfra", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 3e-07, + "supports_tool_choice": true + }, + "deepinfra/openai/gpt-oss-120b": { + "input_cost_per_token": 9e-08, + "litellm_provider": "deepinfra", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 4.5e-07, + "supports_tool_choice": true + }, + "deepinfra/openai/gpt-oss-20b": { + "input_cost_per_token": 4e-08, + "litellm_provider": "deepinfra", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1.6e-07, + "supports_tool_choice": true + }, + "deepinfra/zai-org/GLM-4.5": { + "input_cost_per_token": 5.5e-07, + "litellm_provider": "deepinfra", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 2e-06, + "supports_tool_choice": true + }, + "deepinfra/zai-org/GLM-4.5-Air": { + "input_cost_per_token": 2e-07, + "litellm_provider": "deepinfra", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1.1e-06, + "supports_tool_choice": true + }, + "deepseek/deepseek-chat": { + "cache_creation_input_token_cost": 0.0, + "cache_read_input_token_cost": 7e-08, + "input_cost_per_token": 2.7e-07, + "input_cost_per_token_cache_hit": 7e-08, + "litellm_provider": "deepseek", + "max_input_tokens": 65536, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.1e-06, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_tool_choice": true + }, + "deepseek/deepseek-coder": { + "input_cost_per_token": 1.4e-07, + "input_cost_per_token_cache_hit": 1.4e-08, + "litellm_provider": "deepseek", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 2.8e-07, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_tool_choice": true + }, + "deepseek/deepseek-r1": { + "input_cost_per_token": 5.5e-07, + "input_cost_per_token_cache_hit": 1.4e-07, + "litellm_provider": "deepseek", + "max_input_tokens": 65536, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 2.19e-06, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "deepseek/deepseek-reasoner": { + "input_cost_per_token": 5.5e-07, + "input_cost_per_token_cache_hit": 1.4e-07, + "litellm_provider": "deepseek", + "max_input_tokens": 65536, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 2.19e-06, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "deepseek/deepseek-v3": { + "cache_creation_input_token_cost": 0.0, + "cache_read_input_token_cost": 7e-08, + "input_cost_per_token": 2.7e-07, + "input_cost_per_token_cache_hit": 7e-08, + "litellm_provider": "deepseek", + "max_input_tokens": 65536, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.1e-06, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_tool_choice": true + }, + "deepseek.v3-v1:0": { + "input_cost_per_token": 5.8e-07, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 163840, + "max_output_tokens": 81920, + "max_tokens": 163840, + "mode": "chat", + "output_cost_per_token": 1.68e-06, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "dolphin": { + "input_cost_per_token": 5e-07, + "litellm_provider": "nlp_cloud", + "max_input_tokens": 16384, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "completion", + "output_cost_per_token": 5e-07 + }, + "doubao-embedding": { + "input_cost_per_token": 0.0, + "litellm_provider": "volcengine", + "max_input_tokens": 4096, + "max_tokens": 4096, + "metadata": { + "notes": "Volcengine Doubao embedding model - standard version with 2560 dimensions" + }, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_vector_size": 2560 + }, + "doubao-embedding-large": { + "input_cost_per_token": 0.0, + "litellm_provider": "volcengine", + "max_input_tokens": 4096, + "max_tokens": 4096, + "metadata": { + "notes": "Volcengine Doubao embedding model - large version with 2048 dimensions" + }, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_vector_size": 2048 + }, + "doubao-embedding-large-text-240915": { + "input_cost_per_token": 0.0, + "litellm_provider": "volcengine", + "max_input_tokens": 4096, + "max_tokens": 4096, + "metadata": { + "notes": "Volcengine Doubao embedding model - text-240915 version with 4096 dimensions" + }, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_vector_size": 4096 + }, + "doubao-embedding-large-text-250515": { + "input_cost_per_token": 0.0, + "litellm_provider": "volcengine", + "max_input_tokens": 4096, + "max_tokens": 4096, + "metadata": { + "notes": "Volcengine Doubao embedding model - text-250515 version with 2048 dimensions" + }, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_vector_size": 2048 + }, + "doubao-embedding-text-240715": { + "input_cost_per_token": 0.0, + "litellm_provider": "volcengine", + "max_input_tokens": 4096, + "max_tokens": 4096, + "metadata": { + "notes": "Volcengine Doubao embedding model - text-240715 version with 2560 dimensions" + }, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_vector_size": 2560 + }, + "elevenlabs/scribe_v1": { + "input_cost_per_second": 6.11e-05, + "litellm_provider": "elevenlabs", + "metadata": { + "calculation": "$0.22/hour = $0.00366/minute = $0.0000611 per second (enterprise pricing)", + "notes": "ElevenLabs Scribe v1 - state-of-the-art speech recognition model with 99 language support", + "original_pricing_per_hour": 0.22 + }, + "mode": "audio_transcription", + "output_cost_per_second": 0.0, + "source": "https://elevenlabs.io/pricing", + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, + "elevenlabs/scribe_v1_experimental": { + "input_cost_per_second": 6.11e-05, + "litellm_provider": "elevenlabs", + "metadata": { + "calculation": "$0.22/hour = $0.00366/minute = $0.0000611 per second (enterprise pricing)", + "notes": "ElevenLabs Scribe v1 experimental - enhanced version of the main Scribe model", + "original_pricing_per_hour": 0.22 + }, + "mode": "audio_transcription", + "output_cost_per_second": 0.0, + "source": "https://elevenlabs.io/pricing", + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, + "embed-english-light-v2.0": { + "input_cost_per_token": 1e-07, + "litellm_provider": "cohere", + "max_input_tokens": 1024, + "max_tokens": 1024, + "mode": "embedding", + "output_cost_per_token": 0.0 + }, + "embed-english-light-v3.0": { + "input_cost_per_token": 1e-07, + "litellm_provider": "cohere", + "max_input_tokens": 1024, + "max_tokens": 1024, + "mode": "embedding", + "output_cost_per_token": 0.0 + }, + "embed-english-v2.0": { + "input_cost_per_token": 1e-07, + "litellm_provider": "cohere", + "max_input_tokens": 4096, + "max_tokens": 4096, + "mode": "embedding", + "output_cost_per_token": 0.0 + }, + "embed-english-v3.0": { + "input_cost_per_image": 0.0001, + "input_cost_per_token": 1e-07, + "litellm_provider": "cohere", + "max_input_tokens": 1024, + "max_tokens": 1024, + "metadata": { + "notes": "'supports_image_input' is a deprecated field. Use 'supports_embedding_image_input' instead." + }, + "mode": "embedding", + "output_cost_per_token": 0.0, + "supports_embedding_image_input": true, + "supports_image_input": true + }, + "embed-multilingual-v2.0": { + "input_cost_per_token": 1e-07, + "litellm_provider": "cohere", + "max_input_tokens": 768, + "max_tokens": 768, + "mode": "embedding", + "output_cost_per_token": 0.0 + }, + "embed-multilingual-v3.0": { + "input_cost_per_token": 1e-07, + "litellm_provider": "cohere", + "max_input_tokens": 1024, + "max_tokens": 1024, + "mode": "embedding", + "output_cost_per_token": 0.0, + "supports_embedding_image_input": true + }, + "eu.amazon.nova-lite-v1:0": { + "input_cost_per_token": 7.8e-08, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 300000, + "max_output_tokens": 10000, + "max_tokens": 10000, + "mode": "chat", + "output_cost_per_token": 3.12e-07, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_vision": true + }, + "eu.amazon.nova-micro-v1:0": { + "input_cost_per_token": 4.6e-08, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 128000, + "max_output_tokens": 10000, + "max_tokens": 10000, + "mode": "chat", + "output_cost_per_token": 1.84e-07, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true + }, + "eu.amazon.nova-pro-v1:0": { + "input_cost_per_token": 1.05e-06, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 300000, + "max_output_tokens": 10000, + "max_tokens": 10000, + "mode": "chat", + "output_cost_per_token": 4.2e-06, + "source": "https://aws.amazon.com/bedrock/pricing/", + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_vision": true + }, + "eu.anthropic.claude-3-5-haiku-20241022-v1:0": { + "input_cost_per_token": 2.5e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.25e-06, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "eu.anthropic.claude-3-5-sonnet-20240620-v1:0": { + "input_cost_per_token": 3e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "eu.anthropic.claude-3-5-sonnet-20241022-v2:0": { + "input_cost_per_token": 3e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "eu.anthropic.claude-3-7-sonnet-20250219-v1:0": { + "input_cost_per_token": 3e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "eu.anthropic.claude-3-haiku-20240307-v1:0": { + "input_cost_per_token": 2.5e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.25e-06, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "eu.anthropic.claude-3-opus-20240229-v1:0": { + "input_cost_per_token": 1.5e-05, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 7.5e-05, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "eu.anthropic.claude-3-sonnet-20240229-v1:0": { + "input_cost_per_token": 3e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "eu.anthropic.claude-opus-4-1-20250805-v1:0": { + "cache_creation_input_token_cost": 1.875e-05, + "cache_read_input_token_cost": 1.5e-06, + "input_cost_per_token": 1.5e-05, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 7.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "eu.anthropic.claude-opus-4-20250514-v1:0": { + "cache_creation_input_token_cost": 1.875e-05, + "cache_read_input_token_cost": 1.5e-06, + "input_cost_per_token": 1.5e-05, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 7.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "eu.anthropic.claude-sonnet-4-20250514-v1:0": { + "cache_creation_input_token_cost": 3.75e-06, + "cache_read_input_token_cost": 3e-07, + "input_cost_per_token": 3e-06, + "input_cost_per_token_above_200k_tokens": 6e-06, + "output_cost_per_token_above_200k_tokens": 2.25e-05, + "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, + "cache_read_input_token_cost_above_200k_tokens": 6e-07, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "eu.meta.llama3-2-1b-instruct-v1:0": { + "input_cost_per_token": 1.3e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.3e-07, + "supports_function_calling": true, + "supports_tool_choice": false + }, + "eu.meta.llama3-2-3b-instruct-v1:0": { + "input_cost_per_token": 1.9e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.9e-07, + "supports_function_calling": true, + "supports_tool_choice": false + }, + "eu.mistral.pixtral-large-2502-v1:0": { + "input_cost_per_token": 2e-06, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6e-06, + "supports_function_calling": true, + "supports_tool_choice": false + }, + "featherless_ai/featherless-ai/Qwerky-72B": { + "litellm_provider": "featherless_ai", + "max_input_tokens": 32768, + "max_output_tokens": 4096, + "max_tokens": 32768, + "mode": "chat" + }, + "featherless_ai/featherless-ai/Qwerky-QwQ-32B": { + "litellm_provider": "featherless_ai", + "max_input_tokens": 32768, + "max_output_tokens": 4096, + "max_tokens": 32768, + "mode": "chat" + }, + "fireworks-ai-4.1b-to-16b": { + "input_cost_per_token": 2e-07, + "litellm_provider": "fireworks_ai", + "output_cost_per_token": 2e-07 + }, + "fireworks-ai-56b-to-176b": { + "input_cost_per_token": 1.2e-06, + "litellm_provider": "fireworks_ai", + "output_cost_per_token": 1.2e-06 + }, + "fireworks-ai-above-16b": { + "input_cost_per_token": 9e-07, + "litellm_provider": "fireworks_ai", + "output_cost_per_token": 9e-07 + }, + "fireworks-ai-default": { + "input_cost_per_token": 0.0, + "litellm_provider": "fireworks_ai", + "output_cost_per_token": 0.0 + }, + "fireworks-ai-embedding-150m-to-350m": { + "input_cost_per_token": 1.6e-08, + "litellm_provider": "fireworks_ai-embedding-models", + "output_cost_per_token": 0.0 + }, + "fireworks-ai-embedding-up-to-150m": { + "input_cost_per_token": 8e-09, + "litellm_provider": "fireworks_ai-embedding-models", + "output_cost_per_token": 0.0 + }, + "fireworks-ai-moe-up-to-56b": { + "input_cost_per_token": 5e-07, + "litellm_provider": "fireworks_ai", + "output_cost_per_token": 5e-07 + }, + "fireworks-ai-up-to-4b": { + "input_cost_per_token": 2e-07, + "litellm_provider": "fireworks_ai", + "output_cost_per_token": 2e-07 + }, + "fireworks_ai/WhereIsAI/UAE-Large-V1": { + "input_cost_per_token": 1.6e-08, + "litellm_provider": "fireworks_ai-embedding-models", + "max_input_tokens": 512, + "max_tokens": 512, + "mode": "embedding", + "output_cost_per_token": 0.0, + "source": "https://fireworks.ai/pricing" + }, + "fireworks_ai/accounts/fireworks/models/deepseek-coder-v2-instruct": { + "input_cost_per_token": 1.2e-06, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 65536, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "source": "https://fireworks.ai/pricing", + "supports_function_calling": false, + "supports_response_schema": true, + "supports_tool_choice": false + }, + "fireworks_ai/accounts/fireworks/models/deepseek-r1": { + "input_cost_per_token": 3e-06, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 128000, + "max_output_tokens": 20480, + "max_tokens": 20480, + "mode": "chat", + "output_cost_per_token": 8e-06, + "source": "https://fireworks.ai/pricing", + "supports_response_schema": true, + "supports_tool_choice": false + }, + "fireworks_ai/accounts/fireworks/models/deepseek-r1-0528": { + "input_cost_per_token": 3e-06, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 160000, + "max_output_tokens": 160000, + "max_tokens": 160000, + "mode": "chat", + "output_cost_per_token": 8e-06, + "source": "https://fireworks.ai/pricing", + "supports_response_schema": true, + "supports_tool_choice": false + }, + "fireworks_ai/accounts/fireworks/models/deepseek-r1-basic": { + "input_cost_per_token": 5.5e-07, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 128000, + "max_output_tokens": 20480, + "max_tokens": 20480, + "mode": "chat", + "output_cost_per_token": 2.19e-06, + "source": "https://fireworks.ai/pricing", + "supports_response_schema": true, + "supports_tool_choice": false + }, + "fireworks_ai/accounts/fireworks/models/deepseek-v3": { + "input_cost_per_token": 9e-07, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 128000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 9e-07, + "source": "https://fireworks.ai/pricing", + "supports_response_schema": true, + "supports_tool_choice": false + }, + "fireworks_ai/accounts/fireworks/models/deepseek-v3-0324": { + "input_cost_per_token": 9e-07, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 163840, + "max_output_tokens": 163840, + "max_tokens": 163840, + "mode": "chat", + "output_cost_per_token": 9e-07, + "source": "https://fireworks.ai/models/fireworks/deepseek-v3-0324", + "supports_response_schema": true, + "supports_tool_choice": false + }, + "fireworks_ai/accounts/fireworks/models/deepseek-v3p1": { + "input_cost_per_token": 5.6e-07, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 128000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.68e-06, + "source": "https://fireworks.ai/pricing", + "supports_response_schema": true, + "supports_tool_choice": true + }, + "fireworks_ai/accounts/fireworks/models/firefunction-v2": { + "input_cost_per_token": 9e-07, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 9e-07, + "source": "https://fireworks.ai/pricing", + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "fireworks_ai/accounts/fireworks/models/glm-4p5": { + "input_cost_per_token": 5.5e-07, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 128000, + "max_output_tokens": 96000, + "max_tokens": 96000, + "mode": "chat", + "output_cost_per_token": 2.19e-06, + "source": "https://fireworks.ai/models/fireworks/glm-4p5", + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "fireworks_ai/accounts/fireworks/models/glm-4p5-air": { + "input_cost_per_token": 2.2e-07, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 128000, + "max_output_tokens": 96000, + "max_tokens": 96000, + "mode": "chat", + "output_cost_per_token": 8.8e-07, + "source": "https://artificialanalysis.ai/models/glm-4-5-air", + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "fireworks_ai/accounts/fireworks/models/gpt-oss-120b": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 6e-07, + "source": "https://fireworks.ai/pricing", + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "fireworks_ai/accounts/fireworks/models/gpt-oss-20b": { + "input_cost_per_token": 5e-08, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 2e-07, + "source": "https://fireworks.ai/pricing", + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "fireworks_ai/accounts/fireworks/models/kimi-k2-instruct": { + "input_cost_per_token": 6e-07, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 131072, + "max_output_tokens": 16384, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 2.5e-06, + "source": "https://fireworks.ai/models/fireworks/kimi-k2-instruct", + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "fireworks_ai/accounts/fireworks/models/llama-v3p1-405b-instruct": { + "input_cost_per_token": 3e-06, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 3e-06, + "source": "https://fireworks.ai/pricing", + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "fireworks_ai/accounts/fireworks/models/llama-v3p1-8b-instruct": { + "input_cost_per_token": 1e-07, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 16384, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1e-07, + "source": "https://fireworks.ai/pricing", + "supports_function_calling": false, + "supports_response_schema": true, + "supports_tool_choice": false + }, + "fireworks_ai/accounts/fireworks/models/llama-v3p2-11b-vision-instruct": { + "input_cost_per_token": 2e-07, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 16384, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 2e-07, + "source": "https://fireworks.ai/pricing", + "supports_function_calling": false, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": true + }, + "fireworks_ai/accounts/fireworks/models/llama-v3p2-1b-instruct": { + "input_cost_per_token": 1e-07, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 16384, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1e-07, + "source": "https://fireworks.ai/pricing", + "supports_function_calling": false, + "supports_response_schema": true, + "supports_tool_choice": false + }, + "fireworks_ai/accounts/fireworks/models/llama-v3p2-3b-instruct": { + "input_cost_per_token": 1e-07, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 16384, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1e-07, + "source": "https://fireworks.ai/pricing", + "supports_function_calling": false, + "supports_response_schema": true, + "supports_tool_choice": false + }, + "fireworks_ai/accounts/fireworks/models/llama-v3p2-90b-vision-instruct": { + "input_cost_per_token": 9e-07, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 16384, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 9e-07, + "source": "https://fireworks.ai/pricing", + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": true + }, + "fireworks_ai/accounts/fireworks/models/llama4-maverick-instruct-basic": { + "input_cost_per_token": 2.2e-07, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 8.8e-07, + "source": "https://fireworks.ai/pricing", + "supports_response_schema": true, + "supports_tool_choice": false + }, + "fireworks_ai/accounts/fireworks/models/llama4-scout-instruct-basic": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 6e-07, + "source": "https://fireworks.ai/pricing", + "supports_response_schema": true, + "supports_tool_choice": false + }, + "fireworks_ai/accounts/fireworks/models/mixtral-8x22b-instruct-hf": { + "input_cost_per_token": 1.2e-06, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 65536, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "source": "https://fireworks.ai/pricing", + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "fireworks_ai/accounts/fireworks/models/qwen2-72b-instruct": { + "input_cost_per_token": 9e-07, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 9e-07, + "source": "https://fireworks.ai/pricing", + "supports_function_calling": false, + "supports_response_schema": true, + "supports_tool_choice": false + }, + "fireworks_ai/accounts/fireworks/models/qwen2p5-coder-32b-instruct": { + "input_cost_per_token": 9e-07, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 9e-07, + "source": "https://fireworks.ai/pricing", + "supports_function_calling": false, + "supports_response_schema": true, + "supports_tool_choice": false + }, + "fireworks_ai/accounts/fireworks/models/yi-large": { + "input_cost_per_token": 3e-06, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 3e-06, + "source": "https://fireworks.ai/pricing", + "supports_function_calling": false, + "supports_response_schema": true, + "supports_tool_choice": false + }, + "fireworks_ai/nomic-ai/nomic-embed-text-v1": { + "input_cost_per_token": 8e-09, + "litellm_provider": "fireworks_ai-embedding-models", + "max_input_tokens": 8192, + "max_tokens": 8192, + "mode": "embedding", + "output_cost_per_token": 0.0, + "source": "https://fireworks.ai/pricing" + }, + "fireworks_ai/nomic-ai/nomic-embed-text-v1.5": { + "input_cost_per_token": 8e-09, + "litellm_provider": "fireworks_ai-embedding-models", + "max_input_tokens": 8192, + "max_tokens": 8192, + "mode": "embedding", + "output_cost_per_token": 0.0, + "source": "https://fireworks.ai/pricing" + }, + "fireworks_ai/thenlper/gte-base": { + "input_cost_per_token": 8e-09, + "litellm_provider": "fireworks_ai-embedding-models", + "max_input_tokens": 512, + "max_tokens": 512, + "mode": "embedding", + "output_cost_per_token": 0.0, + "source": "https://fireworks.ai/pricing" + }, + "fireworks_ai/thenlper/gte-large": { + "input_cost_per_token": 1.6e-08, + "litellm_provider": "fireworks_ai-embedding-models", + "max_input_tokens": 512, + "max_tokens": 512, + "mode": "embedding", + "output_cost_per_token": 0.0, + "source": "https://fireworks.ai/pricing" + }, + "friendliai/meta-llama-3.1-70b-instruct": { + "input_cost_per_token": 6e-07, + "litellm_provider": "friendliai", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 6e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "friendliai/meta-llama-3.1-8b-instruct": { + "input_cost_per_token": 1e-07, + "litellm_provider": "friendliai", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "ft:babbage-002": { + "input_cost_per_token": 4e-07, + "input_cost_per_token_batches": 2e-07, + "litellm_provider": "text-completion-openai", + "max_input_tokens": 16384, + "max_output_tokens": 4096, + "max_tokens": 16384, + "mode": "completion", + "output_cost_per_token": 4e-07, + "output_cost_per_token_batches": 2e-07 + }, + "ft:davinci-002": { + "input_cost_per_token": 2e-06, + "input_cost_per_token_batches": 1e-06, + "litellm_provider": "text-completion-openai", + "max_input_tokens": 16384, + "max_output_tokens": 4096, + "max_tokens": 16384, + "mode": "completion", + "output_cost_per_token": 2e-06, + "output_cost_per_token_batches": 1e-06 + }, + "ft:gpt-3.5-turbo": { + "input_cost_per_token": 3e-06, + "input_cost_per_token_batches": 1.5e-06, + "litellm_provider": "openai", + "max_input_tokens": 16385, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 6e-06, + "output_cost_per_token_batches": 3e-06, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "ft:gpt-3.5-turbo-0125": { + "input_cost_per_token": 3e-06, + "litellm_provider": "openai", + "max_input_tokens": 16385, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 6e-06, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "ft:gpt-3.5-turbo-0613": { + "input_cost_per_token": 3e-06, + "litellm_provider": "openai", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 6e-06, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "ft:gpt-3.5-turbo-1106": { + "input_cost_per_token": 3e-06, + "litellm_provider": "openai", + "max_input_tokens": 16385, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 6e-06, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "ft:gpt-4-0613": { + "input_cost_per_token": 3e-05, + "litellm_provider": "openai", + "max_input_tokens": 8192, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 6e-05, + "source": "OpenAI needs to add pricing for this ft model, will be updated when added by OpenAI. Defaulting to base model pricing", + "supports_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "ft:gpt-4o-2024-08-06": { + "input_cost_per_token": 3.75e-06, + "input_cost_per_token_batches": 1.875e-06, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "output_cost_per_token_batches": 7.5e-06, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "ft:gpt-4o-2024-11-20": { + "cache_creation_input_token_cost": 1.875e-06, + "input_cost_per_token": 3.75e-06, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "ft:gpt-4o-mini-2024-07-18": { + "cache_read_input_token_cost": 1.5e-07, + "input_cost_per_token": 3e-07, + "input_cost_per_token_batches": 1.5e-07, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "output_cost_per_token_batches": 6e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gemini-1.0-pro": { + "input_cost_per_character": 1.25e-07, + "input_cost_per_image": 0.0025, + "input_cost_per_token": 5e-07, + "input_cost_per_video_per_second": 0.002, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 32760, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_character": 3.75e-07, + "output_cost_per_token": 1.5e-06, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#google_models", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true + }, + "gemini-1.0-pro-001": { + "deprecation_date": "2025-04-09", + "input_cost_per_character": 1.25e-07, + "input_cost_per_image": 0.0025, + "input_cost_per_token": 5e-07, + "input_cost_per_video_per_second": 0.002, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 32760, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_character": 3.75e-07, + "output_cost_per_token": 1.5e-06, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true + }, + "gemini-1.0-pro-002": { + "deprecation_date": "2025-04-09", + "input_cost_per_character": 1.25e-07, + "input_cost_per_image": 0.0025, + "input_cost_per_token": 5e-07, + "input_cost_per_video_per_second": 0.002, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 32760, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_character": 3.75e-07, + "output_cost_per_token": 1.5e-06, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true + }, + "gemini-1.0-pro-vision": { + "input_cost_per_image": 0.0025, + "input_cost_per_token": 5e-07, + "litellm_provider": "vertex_ai-vision-models", + "max_images_per_prompt": 16, + "max_input_tokens": 16384, + "max_output_tokens": 2048, + "max_tokens": 2048, + "max_video_length": 2, + "max_videos_per_prompt": 1, + "mode": "chat", + "output_cost_per_token": 1.5e-06, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gemini-1.0-pro-vision-001": { + "deprecation_date": "2025-04-09", + "input_cost_per_image": 0.0025, + "input_cost_per_token": 5e-07, + "litellm_provider": "vertex_ai-vision-models", + "max_images_per_prompt": 16, + "max_input_tokens": 16384, + "max_output_tokens": 2048, + "max_tokens": 2048, + "max_video_length": 2, + "max_videos_per_prompt": 1, + "mode": "chat", + "output_cost_per_token": 1.5e-06, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gemini-1.0-ultra": { + "input_cost_per_character": 1.25e-07, + "input_cost_per_image": 0.0025, + "input_cost_per_token": 5e-07, + "input_cost_per_video_per_second": 0.002, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 8192, + "max_output_tokens": 2048, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_character": 3.75e-07, + "output_cost_per_token": 1.5e-06, + "source": "As of Jun, 2024. There is no available doc on vertex ai pricing gemini-1.0-ultra-001. Using gemini-1.0-pro pricing. Got max_tokens info here: https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true + }, + "gemini-1.0-ultra-001": { + "input_cost_per_character": 1.25e-07, + "input_cost_per_image": 0.0025, + "input_cost_per_token": 5e-07, + "input_cost_per_video_per_second": 0.002, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 8192, + "max_output_tokens": 2048, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_character": 3.75e-07, + "output_cost_per_token": 1.5e-06, + "source": "As of Jun, 2024. There is no available doc on vertex ai pricing gemini-1.0-ultra-001. Using gemini-1.0-pro pricing. Got max_tokens info here: https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true + }, + "gemini-1.5-flash": { + "input_cost_per_audio_per_second": 2e-06, + "input_cost_per_audio_per_second_above_128k_tokens": 4e-06, + "input_cost_per_character": 1.875e-08, + "input_cost_per_character_above_128k_tokens": 2.5e-07, + "input_cost_per_image": 2e-05, + "input_cost_per_image_above_128k_tokens": 4e-05, + "input_cost_per_token": 7.5e-08, + "input_cost_per_token_above_128k_tokens": 1e-06, + "input_cost_per_video_per_second": 2e-05, + "input_cost_per_video_per_second_above_128k_tokens": 4e-05, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1000000, + "max_output_tokens": 8192, + "max_pdf_size_mb": 30, + "max_tokens": 8192, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_character": 7.5e-08, + "output_cost_per_character_above_128k_tokens": 1.5e-07, + "output_cost_per_token": 3e-07, + "output_cost_per_token_above_128k_tokens": 6e-07, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gemini-1.5-flash-001": { + "deprecation_date": "2025-05-24", + "input_cost_per_audio_per_second": 2e-06, + "input_cost_per_audio_per_second_above_128k_tokens": 4e-06, + "input_cost_per_character": 1.875e-08, + "input_cost_per_character_above_128k_tokens": 2.5e-07, + "input_cost_per_image": 2e-05, + "input_cost_per_image_above_128k_tokens": 4e-05, + "input_cost_per_token": 7.5e-08, + "input_cost_per_token_above_128k_tokens": 1e-06, + "input_cost_per_video_per_second": 2e-05, + "input_cost_per_video_per_second_above_128k_tokens": 4e-05, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1000000, + "max_output_tokens": 8192, + "max_pdf_size_mb": 30, + "max_tokens": 8192, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_character": 7.5e-08, + "output_cost_per_character_above_128k_tokens": 1.5e-07, + "output_cost_per_token": 3e-07, + "output_cost_per_token_above_128k_tokens": 6e-07, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gemini-1.5-flash-002": { + "deprecation_date": "2025-09-24", + "input_cost_per_audio_per_second": 2e-06, + "input_cost_per_audio_per_second_above_128k_tokens": 4e-06, + "input_cost_per_character": 1.875e-08, + "input_cost_per_character_above_128k_tokens": 2.5e-07, + "input_cost_per_image": 2e-05, + "input_cost_per_image_above_128k_tokens": 4e-05, + "input_cost_per_token": 7.5e-08, + "input_cost_per_token_above_128k_tokens": 1e-06, + "input_cost_per_video_per_second": 2e-05, + "input_cost_per_video_per_second_above_128k_tokens": 4e-05, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 8192, + "max_pdf_size_mb": 30, + "max_tokens": 8192, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_character": 7.5e-08, + "output_cost_per_character_above_128k_tokens": 1.5e-07, + "output_cost_per_token": 3e-07, + "output_cost_per_token_above_128k_tokens": 6e-07, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#gemini-1.5-flash", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gemini-1.5-flash-exp-0827": { + "input_cost_per_audio_per_second": 2e-06, + "input_cost_per_audio_per_second_above_128k_tokens": 4e-06, + "input_cost_per_character": 1.875e-08, + "input_cost_per_character_above_128k_tokens": 2.5e-07, + "input_cost_per_image": 2e-05, + "input_cost_per_image_above_128k_tokens": 4e-05, + "input_cost_per_token": 4.688e-09, + "input_cost_per_token_above_128k_tokens": 1e-06, + "input_cost_per_video_per_second": 2e-05, + "input_cost_per_video_per_second_above_128k_tokens": 4e-05, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1000000, + "max_output_tokens": 8192, + "max_pdf_size_mb": 30, + "max_tokens": 8192, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_character": 1.875e-08, + "output_cost_per_character_above_128k_tokens": 3.75e-08, + "output_cost_per_token": 4.6875e-09, + "output_cost_per_token_above_128k_tokens": 9.375e-09, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gemini-1.5-flash-preview-0514": { + "input_cost_per_audio_per_second": 2e-06, + "input_cost_per_audio_per_second_above_128k_tokens": 4e-06, + "input_cost_per_character": 1.875e-08, + "input_cost_per_character_above_128k_tokens": 2.5e-07, + "input_cost_per_image": 2e-05, + "input_cost_per_image_above_128k_tokens": 4e-05, + "input_cost_per_token": 7.5e-08, + "input_cost_per_token_above_128k_tokens": 1e-06, + "input_cost_per_video_per_second": 2e-05, + "input_cost_per_video_per_second_above_128k_tokens": 4e-05, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1000000, + "max_output_tokens": 8192, + "max_pdf_size_mb": 30, + "max_tokens": 8192, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_character": 1.875e-08, + "output_cost_per_character_above_128k_tokens": 3.75e-08, + "output_cost_per_token": 4.6875e-09, + "output_cost_per_token_above_128k_tokens": 9.375e-09, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gemini-1.5-pro": { + "input_cost_per_audio_per_second": 3.125e-05, + "input_cost_per_audio_per_second_above_128k_tokens": 6.25e-05, + "input_cost_per_character": 3.125e-07, + "input_cost_per_character_above_128k_tokens": 6.25e-07, + "input_cost_per_image": 0.00032875, + "input_cost_per_image_above_128k_tokens": 0.0006575, + "input_cost_per_token": 1.25e-06, + "input_cost_per_token_above_128k_tokens": 2.5e-06, + "input_cost_per_video_per_second": 0.00032875, + "input_cost_per_video_per_second_above_128k_tokens": 0.0006575, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 2097152, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_character": 1.25e-06, + "output_cost_per_character_above_128k_tokens": 2.5e-06, + "output_cost_per_token": 5e-06, + "output_cost_per_token_above_128k_tokens": 1e-05, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gemini-1.5-pro-001": { + "deprecation_date": "2025-05-24", + "input_cost_per_audio_per_second": 3.125e-05, + "input_cost_per_audio_per_second_above_128k_tokens": 6.25e-05, + "input_cost_per_character": 3.125e-07, + "input_cost_per_character_above_128k_tokens": 6.25e-07, + "input_cost_per_image": 0.00032875, + "input_cost_per_image_above_128k_tokens": 0.0006575, + "input_cost_per_token": 1.25e-06, + "input_cost_per_token_above_128k_tokens": 2.5e-06, + "input_cost_per_video_per_second": 0.00032875, + "input_cost_per_video_per_second_above_128k_tokens": 0.0006575, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 1000000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_character": 1.25e-06, + "output_cost_per_character_above_128k_tokens": 2.5e-06, + "output_cost_per_token": 5e-06, + "output_cost_per_token_above_128k_tokens": 1e-05, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gemini-1.5-pro-002": { + "deprecation_date": "2025-09-24", + "input_cost_per_audio_per_second": 3.125e-05, + "input_cost_per_audio_per_second_above_128k_tokens": 6.25e-05, + "input_cost_per_character": 3.125e-07, + "input_cost_per_character_above_128k_tokens": 6.25e-07, + "input_cost_per_image": 0.00032875, + "input_cost_per_image_above_128k_tokens": 0.0006575, + "input_cost_per_token": 1.25e-06, + "input_cost_per_token_above_128k_tokens": 2.5e-06, + "input_cost_per_video_per_second": 0.00032875, + "input_cost_per_video_per_second_above_128k_tokens": 0.0006575, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 2097152, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_character": 1.25e-06, + "output_cost_per_character_above_128k_tokens": 2.5e-06, + "output_cost_per_token": 5e-06, + "output_cost_per_token_above_128k_tokens": 1e-05, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#gemini-1.5-pro", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gemini-1.5-pro-preview-0215": { + "input_cost_per_audio_per_second": 3.125e-05, + "input_cost_per_audio_per_second_above_128k_tokens": 6.25e-05, + "input_cost_per_character": 3.125e-07, + "input_cost_per_character_above_128k_tokens": 6.25e-07, + "input_cost_per_image": 0.00032875, + "input_cost_per_image_above_128k_tokens": 0.0006575, + "input_cost_per_token": 7.8125e-08, + "input_cost_per_token_above_128k_tokens": 1.5625e-07, + "input_cost_per_video_per_second": 0.00032875, + "input_cost_per_video_per_second_above_128k_tokens": 0.0006575, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 1000000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_character": 1.25e-06, + "output_cost_per_character_above_128k_tokens": 2.5e-06, + "output_cost_per_token": 3.125e-07, + "output_cost_per_token_above_128k_tokens": 6.25e-07, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gemini-1.5-pro-preview-0409": { + "input_cost_per_audio_per_second": 3.125e-05, + "input_cost_per_audio_per_second_above_128k_tokens": 6.25e-05, + "input_cost_per_character": 3.125e-07, + "input_cost_per_character_above_128k_tokens": 6.25e-07, + "input_cost_per_image": 0.00032875, + "input_cost_per_image_above_128k_tokens": 0.0006575, + "input_cost_per_token": 7.8125e-08, + "input_cost_per_token_above_128k_tokens": 1.5625e-07, + "input_cost_per_video_per_second": 0.00032875, + "input_cost_per_video_per_second_above_128k_tokens": 0.0006575, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 1000000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_character": 1.25e-06, + "output_cost_per_character_above_128k_tokens": 2.5e-06, + "output_cost_per_token": 3.125e-07, + "output_cost_per_token_above_128k_tokens": 6.25e-07, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "gemini-1.5-pro-preview-0514": { + "input_cost_per_audio_per_second": 3.125e-05, + "input_cost_per_audio_per_second_above_128k_tokens": 6.25e-05, + "input_cost_per_character": 3.125e-07, + "input_cost_per_character_above_128k_tokens": 6.25e-07, + "input_cost_per_image": 0.00032875, + "input_cost_per_image_above_128k_tokens": 0.0006575, + "input_cost_per_token": 7.8125e-08, + "input_cost_per_token_above_128k_tokens": 1.5625e-07, + "input_cost_per_video_per_second": 0.00032875, + "input_cost_per_video_per_second_above_128k_tokens": 0.0006575, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 1000000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_character": 1.25e-06, + "output_cost_per_character_above_128k_tokens": 2.5e-06, + "output_cost_per_token": 3.125e-07, + "output_cost_per_token_above_128k_tokens": 6.25e-07, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gemini-2.0-flash": { + "cache_read_input_token_cost": 2.5e-08, + "input_cost_per_audio_token": 7e-07, + "input_cost_per_token": 1e-07, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 8192, + "max_pdf_size_mb": 30, + "max_tokens": 8192, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 4e-07, + "source": "https://ai.google.dev/pricing#2_0flash", + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text", + "image" + ], + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-2.0-flash-001": { + "cache_read_input_token_cost": 3.75e-08, + "deprecation_date": "2026-02-05", + "input_cost_per_audio_token": 1e-06, + "input_cost_per_token": 1.5e-07, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 8192, + "max_pdf_size_mb": 30, + "max_tokens": 8192, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 6e-07, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text", + "image" + ], + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-2.0-flash-exp": { + "cache_read_input_token_cost": 3.75e-08, + "input_cost_per_audio_per_second": 0, + "input_cost_per_audio_per_second_above_128k_tokens": 0, + "input_cost_per_character": 0, + "input_cost_per_character_above_128k_tokens": 0, + "input_cost_per_image": 0, + "input_cost_per_image_above_128k_tokens": 0, + "input_cost_per_token": 1.5e-07, + "input_cost_per_token_above_128k_tokens": 0, + "input_cost_per_video_per_second": 0, + "input_cost_per_video_per_second_above_128k_tokens": 0, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 8192, + "max_pdf_size_mb": 30, + "max_tokens": 8192, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_character": 0, + "output_cost_per_character_above_128k_tokens": 0, + "output_cost_per_token": 6e-07, + "output_cost_per_token_above_128k_tokens": 0, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text", + "image" + ], + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-2.0-flash-lite": { + "cache_read_input_token_cost": 1.875e-08, + "input_cost_per_audio_token": 7.5e-08, + "input_cost_per_token": 7.5e-08, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 8192, + "max_pdf_size_mb": 50, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 3e-07, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#gemini-2.0-flash", + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-2.0-flash-lite-001": { + "cache_read_input_token_cost": 1.875e-08, + "deprecation_date": "2026-02-25", + "input_cost_per_audio_token": 7.5e-08, + "input_cost_per_token": 7.5e-08, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 8192, + "max_pdf_size_mb": 50, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 3e-07, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#gemini-2.0-flash", + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-2.0-flash-live-preview-04-09": { + "cache_read_input_token_cost": 7.5e-08, + "input_cost_per_audio_token": 3e-06, + "input_cost_per_image": 3e-06, + "input_cost_per_token": 5e-07, + "input_cost_per_video_per_second": 3e-06, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_audio_token": 1.2e-05, + "output_cost_per_token": 2e-06, + "rpm": 10, + "source": "https://cloud.google.com/vertex-ai/docs/generative-ai/model-reference/gemini#gemini-2-0-flash-live-preview-04-09", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text", + "audio" + ], + "supports_audio_output": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_vision": true, + "supports_web_search": true, + "tpm": 250000 + }, + "gemini-2.0-flash-preview-image-generation": { + "cache_read_input_token_cost": 2.5e-08, + "input_cost_per_audio_token": 7e-07, + "input_cost_per_token": 1e-07, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 8192, + "max_pdf_size_mb": 30, + "max_tokens": 8192, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 4e-07, + "source": "https://ai.google.dev/pricing#2_0flash", + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text", + "image" + ], + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-2.0-flash-thinking-exp": { + "cache_read_input_token_cost": 0.0, + "input_cost_per_audio_per_second": 0, + "input_cost_per_audio_per_second_above_128k_tokens": 0, + "input_cost_per_character": 0, + "input_cost_per_character_above_128k_tokens": 0, + "input_cost_per_image": 0, + "input_cost_per_image_above_128k_tokens": 0, + "input_cost_per_token": 0, + "input_cost_per_token_above_128k_tokens": 0, + "input_cost_per_video_per_second": 0, + "input_cost_per_video_per_second_above_128k_tokens": 0, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 8192, + "max_pdf_size_mb": 30, + "max_tokens": 8192, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_character": 0, + "output_cost_per_character_above_128k_tokens": 0, + "output_cost_per_token": 0, + "output_cost_per_token_above_128k_tokens": 0, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#gemini-2.0-flash", + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text", + "image" + ], + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-2.0-flash-thinking-exp-01-21": { + "cache_read_input_token_cost": 0.0, + "input_cost_per_audio_per_second": 0, + "input_cost_per_audio_per_second_above_128k_tokens": 0, + "input_cost_per_character": 0, + "input_cost_per_character_above_128k_tokens": 0, + "input_cost_per_image": 0, + "input_cost_per_image_above_128k_tokens": 0, + "input_cost_per_token": 0, + "input_cost_per_token_above_128k_tokens": 0, + "input_cost_per_video_per_second": 0, + "input_cost_per_video_per_second_above_128k_tokens": 0, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_pdf_size_mb": 30, + "max_tokens": 65536, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_character": 0, + "output_cost_per_character_above_128k_tokens": 0, + "output_cost_per_token": 0, + "output_cost_per_token_above_128k_tokens": 0, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#gemini-2.0-flash", + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text", + "image" + ], + "supports_audio_output": false, + "supports_function_calling": false, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": false, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-2.0-pro-exp-02-05": { + "cache_read_input_token_cost": 3.125e-07, + "input_cost_per_token": 1.25e-06, + "input_cost_per_token_above_200k_tokens": 2.5e-06, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 2097152, + "max_output_tokens": 8192, + "max_pdf_size_mb": 30, + "max_tokens": 8192, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 1e-05, + "output_cost_per_token_above_200k_tokens": 1.5e-05, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_input": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-2.5-flash": { + "cache_read_input_token_cost": 7.5e-08, + "input_cost_per_audio_token": 1e-06, + "input_cost_per_token": 3e-07, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 2.5e-06, + "output_cost_per_token": 2.5e-06, + "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-2.5-flash-image-preview": { + "cache_read_input_token_cost": 7.5e-08, + "input_cost_per_audio_token": 1e-06, + "input_cost_per_token": 3e-07, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "image_generation", + "output_cost_per_image": 0.039, + "output_cost_per_reasoning_token": 3e-05, + "output_cost_per_token": 3e-05, + "rpm": 100000, + "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text", + "image" + ], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_vision": true, + "supports_web_search": true, + "tpm": 8000000 + }, + "gemini-2.5-flash-lite": { + "cache_read_input_token_cost": 2.5e-08, + "input_cost_per_audio_token": 5e-07, + "input_cost_per_token": 1e-07, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 4e-07, + "output_cost_per_token": 4e-07, + "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-2.5-flash-lite-preview-09-2025": { + "cache_read_input_token_cost": 2.5e-08, + "input_cost_per_audio_token": 3e-07, + "input_cost_per_token": 1e-07, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 4e-07, + "output_cost_per_token": 4e-07, + "source": "https://developers.googleblog.com/en/continuing-to-bring-you-our-latest-models-with-an-improved-gemini-2-5-flash-and-flash-lite-release/", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-2.5-flash-preview-09-2025": { + "cache_read_input_token_cost": 7.5e-08, + "input_cost_per_audio_token": 1e-06, + "input_cost_per_token": 3e-07, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 2.5e-06, + "output_cost_per_token": 2.5e-06, + "source": "https://developers.googleblog.com/en/continuing-to-bring-you-our-latest-models-with-an-improved-gemini-2-5-flash-and-flash-lite-release/", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-flash-latest": { + "cache_read_input_token_cost": 7.5e-08, + "input_cost_per_audio_token": 1e-06, + "input_cost_per_token": 3e-07, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 2.5e-06, + "output_cost_per_token": 2.5e-06, + "source": "https://developers.googleblog.com/en/continuing-to-bring-you-our-latest-models-with-an-improved-gemini-2-5-flash-and-flash-lite-release/", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-flash-lite-latest": { + "cache_read_input_token_cost": 2.5e-08, + "input_cost_per_audio_token": 3e-07, + "input_cost_per_token": 1e-07, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 4e-07, + "output_cost_per_token": 4e-07, + "source": "https://developers.googleblog.com/en/continuing-to-bring-you-our-latest-models-with-an-improved-gemini-2-5-flash-and-flash-lite-release/", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-2.5-flash-lite-preview-06-17": { + "cache_read_input_token_cost": 2.5e-08, + "input_cost_per_audio_token": 5e-07, + "input_cost_per_token": 1e-07, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 4e-07, + "output_cost_per_token": 4e-07, + "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-2.5-flash-preview-04-17": { + "cache_read_input_token_cost": 3.75e-08, + "input_cost_per_audio_token": 1e-06, + "input_cost_per_token": 1.5e-07, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 3.5e-06, + "output_cost_per_token": 6e-07, + "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-2.5-flash-preview-05-20": { + "cache_read_input_token_cost": 7.5e-08, + "input_cost_per_audio_token": 1e-06, + "input_cost_per_token": 3e-07, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 2.5e-06, + "output_cost_per_token": 2.5e-06, + "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-2.5-pro": { + "cache_read_input_token_cost": 3.125e-07, + "input_cost_per_token": 1.25e-06, + "input_cost_per_token_above_200k_tokens": 2.5e-06, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 1e-05, + "output_cost_per_token_above_200k_tokens": 1.5e-05, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-2.5-pro-exp-03-25": { + "cache_read_input_token_cost": 3.125e-07, + "input_cost_per_token": 1.25e-06, + "input_cost_per_token_above_200k_tokens": 2.5e-06, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 1e-05, + "output_cost_per_token_above_200k_tokens": 1.5e-05, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_input": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-2.5-pro-preview-03-25": { + "cache_read_input_token_cost": 3.125e-07, + "input_cost_per_audio_token": 1.25e-06, + "input_cost_per_token": 1.25e-06, + "input_cost_per_token_above_200k_tokens": 2.5e-06, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 1e-05, + "output_cost_per_token_above_200k_tokens": 1.5e-05, + "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-2.5-pro-preview-05-06": { + "cache_read_input_token_cost": 3.125e-07, + "input_cost_per_audio_token": 1.25e-06, + "input_cost_per_token": 1.25e-06, + "input_cost_per_token_above_200k_tokens": 2.5e-06, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 1e-05, + "output_cost_per_token_above_200k_tokens": 1.5e-05, + "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supported_regions": [ + "global" + ], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-2.5-pro-preview-06-05": { + "cache_read_input_token_cost": 3.125e-07, + "input_cost_per_audio_token": 1.25e-06, + "input_cost_per_token": 1.25e-06, + "input_cost_per_token_above_200k_tokens": 2.5e-06, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 1e-05, + "output_cost_per_token_above_200k_tokens": 1.5e-05, + "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-2.5-pro-preview-tts": { + "cache_read_input_token_cost": 3.125e-07, + "input_cost_per_audio_token": 7e-07, + "input_cost_per_token": 1.25e-06, + "input_cost_per_token_above_200k_tokens": 2.5e-06, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 1e-05, + "output_cost_per_token_above_200k_tokens": 1.5e-05, + "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-2.5-pro-preview", + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "audio" + ], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-embedding-001": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "vertex_ai-embedding-models", + "max_input_tokens": 2048, + "max_tokens": 2048, + "mode": "embedding", + "output_cost_per_token": 0, + "output_vector_size": 3072, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models" + }, + "gemini-flash-experimental": { + "input_cost_per_character": 0, + "input_cost_per_token": 0, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 1000000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_character": 0, + "output_cost_per_token": 0, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/gemini-experimental", + "supports_function_calling": false, + "supports_parallel_function_calling": true, + "supports_tool_choice": true + }, + "gemini-pro": { + "input_cost_per_character": 1.25e-07, + "input_cost_per_image": 0.0025, + "input_cost_per_token": 5e-07, + "input_cost_per_video_per_second": 0.002, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 32760, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_character": 3.75e-07, + "output_cost_per_token": 1.5e-06, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true + }, + "gemini-pro-experimental": { + "input_cost_per_character": 0, + "input_cost_per_token": 0, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 1000000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_character": 0, + "output_cost_per_token": 0, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/gemini-experimental", + "supports_function_calling": false, + "supports_parallel_function_calling": true, + "supports_tool_choice": true + }, + "gemini-pro-vision": { + "input_cost_per_image": 0.0025, + "input_cost_per_token": 5e-07, + "litellm_provider": "vertex_ai-vision-models", + "max_images_per_prompt": 16, + "max_input_tokens": 16384, + "max_output_tokens": 2048, + "max_tokens": 2048, + "max_video_length": 2, + "max_videos_per_prompt": 1, + "mode": "chat", + "output_cost_per_token": 1.5e-06, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gemini/gemini-1.5-flash": { + "input_cost_per_token": 7.5e-08, + "input_cost_per_token_above_128k_tokens": 1.5e-07, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 8192, + "max_pdf_size_mb": 30, + "max_tokens": 8192, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 3e-07, + "output_cost_per_token_above_128k_tokens": 6e-07, + "rpm": 2000, + "source": "https://ai.google.dev/pricing", + "supports_function_calling": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "tpm": 4000000 + }, + "gemini/gemini-1.5-flash-001": { + "cache_creation_input_token_cost": 1e-06, + "cache_read_input_token_cost": 1.875e-08, + "deprecation_date": "2025-05-24", + "input_cost_per_token": 7.5e-08, + "input_cost_per_token_above_128k_tokens": 1.5e-07, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 8192, + "max_pdf_size_mb": 30, + "max_tokens": 8192, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 3e-07, + "output_cost_per_token_above_128k_tokens": 6e-07, + "rpm": 2000, + "source": "https://ai.google.dev/pricing", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "tpm": 4000000 + }, + "gemini/gemini-1.5-flash-002": { + "cache_creation_input_token_cost": 1e-06, + "cache_read_input_token_cost": 1.875e-08, + "deprecation_date": "2025-09-24", + "input_cost_per_token": 7.5e-08, + "input_cost_per_token_above_128k_tokens": 1.5e-07, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 8192, + "max_pdf_size_mb": 30, + "max_tokens": 8192, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 3e-07, + "output_cost_per_token_above_128k_tokens": 6e-07, + "rpm": 2000, + "source": "https://ai.google.dev/pricing", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "tpm": 4000000 + }, + "gemini/gemini-1.5-flash-8b": { + "input_cost_per_token": 0, + "input_cost_per_token_above_128k_tokens": 0, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 8192, + "max_pdf_size_mb": 30, + "max_tokens": 8192, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 0, + "output_cost_per_token_above_128k_tokens": 0, + "rpm": 4000, + "source": "https://ai.google.dev/pricing", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "tpm": 4000000 + }, + "gemini/gemini-1.5-flash-8b-exp-0827": { + "input_cost_per_token": 0, + "input_cost_per_token_above_128k_tokens": 0, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1000000, + "max_output_tokens": 8192, + "max_pdf_size_mb": 30, + "max_tokens": 8192, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 0, + "output_cost_per_token_above_128k_tokens": 0, + "rpm": 4000, + "source": "https://ai.google.dev/pricing", + "supports_function_calling": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "tpm": 4000000 + }, + "gemini/gemini-1.5-flash-8b-exp-0924": { + "input_cost_per_token": 0, + "input_cost_per_token_above_128k_tokens": 0, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 8192, + "max_pdf_size_mb": 30, + "max_tokens": 8192, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 0, + "output_cost_per_token_above_128k_tokens": 0, + "rpm": 4000, + "source": "https://ai.google.dev/pricing", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "tpm": 4000000 + }, + "gemini/gemini-1.5-flash-exp-0827": { + "input_cost_per_token": 0, + "input_cost_per_token_above_128k_tokens": 0, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 8192, + "max_pdf_size_mb": 30, + "max_tokens": 8192, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 0, + "output_cost_per_token_above_128k_tokens": 0, + "rpm": 2000, + "source": "https://ai.google.dev/pricing", + "supports_function_calling": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "tpm": 4000000 + }, + "gemini/gemini-1.5-flash-latest": { + "input_cost_per_token": 7.5e-08, + "input_cost_per_token_above_128k_tokens": 1.5e-07, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 8192, + "max_pdf_size_mb": 30, + "max_tokens": 8192, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 3e-07, + "output_cost_per_token_above_128k_tokens": 6e-07, + "rpm": 2000, + "source": "https://ai.google.dev/pricing", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "tpm": 4000000 + }, + "gemini/gemini-1.5-pro": { + "input_cost_per_token": 3.5e-06, + "input_cost_per_token_above_128k_tokens": 7e-06, + "litellm_provider": "gemini", + "max_input_tokens": 2097152, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.05e-05, + "output_cost_per_token_above_128k_tokens": 2.1e-05, + "rpm": 1000, + "source": "https://ai.google.dev/pricing", + "supports_function_calling": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "tpm": 4000000 + }, + "gemini/gemini-1.5-pro-001": { + "deprecation_date": "2025-05-24", + "input_cost_per_token": 3.5e-06, + "input_cost_per_token_above_128k_tokens": 7e-06, + "litellm_provider": "gemini", + "max_input_tokens": 2097152, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.05e-05, + "output_cost_per_token_above_128k_tokens": 2.1e-05, + "rpm": 1000, + "source": "https://ai.google.dev/pricing", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "tpm": 4000000 + }, + "gemini/gemini-1.5-pro-002": { + "deprecation_date": "2025-09-24", + "input_cost_per_token": 3.5e-06, + "input_cost_per_token_above_128k_tokens": 7e-06, + "litellm_provider": "gemini", + "max_input_tokens": 2097152, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.05e-05, + "output_cost_per_token_above_128k_tokens": 2.1e-05, + "rpm": 1000, + "source": "https://ai.google.dev/pricing", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "tpm": 4000000 + }, + "gemini/gemini-1.5-pro-exp-0801": { + "input_cost_per_token": 3.5e-06, + "input_cost_per_token_above_128k_tokens": 7e-06, + "litellm_provider": "gemini", + "max_input_tokens": 2097152, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.05e-05, + "output_cost_per_token_above_128k_tokens": 2.1e-05, + "rpm": 1000, + "source": "https://ai.google.dev/pricing", + "supports_function_calling": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "tpm": 4000000 + }, + "gemini/gemini-1.5-pro-exp-0827": { + "input_cost_per_token": 0, + "input_cost_per_token_above_128k_tokens": 0, + "litellm_provider": "gemini", + "max_input_tokens": 2097152, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0, + "output_cost_per_token_above_128k_tokens": 0, + "rpm": 1000, + "source": "https://ai.google.dev/pricing", + "supports_function_calling": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "tpm": 4000000 + }, + "gemini/gemini-1.5-pro-latest": { + "input_cost_per_token": 3.5e-06, + "input_cost_per_token_above_128k_tokens": 7e-06, + "litellm_provider": "gemini", + "max_input_tokens": 1048576, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.05e-06, + "output_cost_per_token_above_128k_tokens": 2.1e-05, + "rpm": 1000, + "source": "https://ai.google.dev/pricing", + "supports_function_calling": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "tpm": 4000000 + }, + "gemini/gemini-2.0-flash": { + "cache_read_input_token_cost": 2.5e-08, + "input_cost_per_audio_token": 7e-07, + "input_cost_per_token": 1e-07, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 8192, + "max_pdf_size_mb": 30, + "max_tokens": 8192, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 4e-07, + "rpm": 10000, + "source": "https://ai.google.dev/pricing#2_0flash", + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text", + "image" + ], + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_vision": true, + "supports_web_search": true, + "tpm": 10000000 + }, + "gemini/gemini-2.0-flash-001": { + "cache_read_input_token_cost": 2.5e-08, + "input_cost_per_audio_token": 7e-07, + "input_cost_per_token": 1e-07, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 8192, + "max_pdf_size_mb": 30, + "max_tokens": 8192, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 4e-07, + "rpm": 10000, + "source": "https://ai.google.dev/pricing#2_0flash", + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text", + "image" + ], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "tpm": 10000000 + }, + "gemini/gemini-2.0-flash-exp": { + "cache_read_input_token_cost": 0.0, + "input_cost_per_audio_per_second": 0, + "input_cost_per_audio_per_second_above_128k_tokens": 0, + "input_cost_per_character": 0, + "input_cost_per_character_above_128k_tokens": 0, + "input_cost_per_image": 0, + "input_cost_per_image_above_128k_tokens": 0, + "input_cost_per_token": 0, + "input_cost_per_token_above_128k_tokens": 0, + "input_cost_per_video_per_second": 0, + "input_cost_per_video_per_second_above_128k_tokens": 0, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 8192, + "max_pdf_size_mb": 30, + "max_tokens": 8192, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_character": 0, + "output_cost_per_character_above_128k_tokens": 0, + "output_cost_per_token": 0, + "output_cost_per_token_above_128k_tokens": 0, + "rpm": 10, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#gemini-2.0-flash", + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text", + "image" + ], + "supports_audio_output": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "tpm": 4000000 + }, + "gemini/gemini-2.0-flash-lite": { + "cache_read_input_token_cost": 1.875e-08, + "input_cost_per_audio_token": 7.5e-08, + "input_cost_per_token": 7.5e-08, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 8192, + "max_pdf_size_mb": 50, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 3e-07, + "rpm": 4000, + "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-2.0-flash-lite", + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_output": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "tpm": 4000000 + }, + "gemini/gemini-2.0-flash-lite-preview-02-05": { + "cache_read_input_token_cost": 1.875e-08, + "input_cost_per_audio_token": 7.5e-08, + "input_cost_per_token": 7.5e-08, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 8192, + "max_pdf_size_mb": 30, + "max_tokens": 8192, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 3e-07, + "rpm": 60000, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#gemini-2.0-flash-lite", + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "tpm": 10000000 + }, + "gemini/gemini-2.0-flash-live-001": { + "cache_read_input_token_cost": 7.5e-08, + "input_cost_per_audio_token": 2.1e-06, + "input_cost_per_image": 2.1e-06, + "input_cost_per_token": 3.5e-07, + "input_cost_per_video_per_second": 2.1e-06, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_audio_token": 8.5e-06, + "output_cost_per_token": 1.5e-06, + "rpm": 10, + "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2-0-flash-live-001", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text", + "audio" + ], + "supports_audio_output": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_vision": true, + "supports_web_search": true, + "tpm": 250000 + }, + "gemini/gemini-2.0-flash-preview-image-generation": { + "cache_read_input_token_cost": 2.5e-08, + "input_cost_per_audio_token": 7e-07, + "input_cost_per_token": 1e-07, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 8192, + "max_pdf_size_mb": 30, + "max_tokens": 8192, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 4e-07, + "rpm": 10000, + "source": "https://ai.google.dev/pricing#2_0flash", + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text", + "image" + ], + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "tpm": 10000000 + }, + "gemini/gemini-2.0-flash-thinking-exp": { + "cache_read_input_token_cost": 0.0, + "input_cost_per_audio_per_second": 0, + "input_cost_per_audio_per_second_above_128k_tokens": 0, + "input_cost_per_character": 0, + "input_cost_per_character_above_128k_tokens": 0, + "input_cost_per_image": 0, + "input_cost_per_image_above_128k_tokens": 0, + "input_cost_per_token": 0, + "input_cost_per_token_above_128k_tokens": 0, + "input_cost_per_video_per_second": 0, + "input_cost_per_video_per_second_above_128k_tokens": 0, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_pdf_size_mb": 30, + "max_tokens": 8192, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_character": 0, + "output_cost_per_character_above_128k_tokens": 0, + "output_cost_per_token": 0, + "output_cost_per_token_above_128k_tokens": 0, + "rpm": 10, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#gemini-2.0-flash", + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text", + "image" + ], + "supports_audio_output": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "tpm": 4000000 + }, + "gemini/gemini-2.0-flash-thinking-exp-01-21": { + "cache_read_input_token_cost": 0.0, + "input_cost_per_audio_per_second": 0, + "input_cost_per_audio_per_second_above_128k_tokens": 0, + "input_cost_per_character": 0, + "input_cost_per_character_above_128k_tokens": 0, + "input_cost_per_image": 0, + "input_cost_per_image_above_128k_tokens": 0, + "input_cost_per_token": 0, + "input_cost_per_token_above_128k_tokens": 0, + "input_cost_per_video_per_second": 0, + "input_cost_per_video_per_second_above_128k_tokens": 0, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_pdf_size_mb": 30, + "max_tokens": 8192, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_character": 0, + "output_cost_per_character_above_128k_tokens": 0, + "output_cost_per_token": 0, + "output_cost_per_token_above_128k_tokens": 0, + "rpm": 10, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#gemini-2.0-flash", + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text", + "image" + ], + "supports_audio_output": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "tpm": 4000000 + }, + "gemini/gemini-2.0-pro-exp-02-05": { + "cache_read_input_token_cost": 0.0, + "input_cost_per_audio_per_second": 0, + "input_cost_per_audio_per_second_above_128k_tokens": 0, + "input_cost_per_character": 0, + "input_cost_per_character_above_128k_tokens": 0, + "input_cost_per_image": 0, + "input_cost_per_image_above_128k_tokens": 0, + "input_cost_per_token": 0, + "input_cost_per_token_above_128k_tokens": 0, + "input_cost_per_video_per_second": 0, + "input_cost_per_video_per_second_above_128k_tokens": 0, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 2097152, + "max_output_tokens": 8192, + "max_pdf_size_mb": 30, + "max_tokens": 8192, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_character": 0, + "output_cost_per_character_above_128k_tokens": 0, + "output_cost_per_token": 0, + "output_cost_per_token_above_128k_tokens": 0, + "rpm": 2, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true, + "tpm": 1000000 + }, + "gemini/gemini-2.5-flash": { + "cache_read_input_token_cost": 7.5e-08, + "input_cost_per_audio_token": 1e-06, + "input_cost_per_token": 3e-07, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 2.5e-06, + "output_cost_per_token": 2.5e-06, + "rpm": 100000, + "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_vision": true, + "supports_web_search": true, + "tpm": 8000000 + }, + "gemini/gemini-2.5-flash-image-preview": { + "cache_read_input_token_cost": 7.5e-08, + "input_cost_per_audio_token": 1e-06, + "input_cost_per_token": 3e-07, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "image_generation", + "output_cost_per_image": 0.039, + "output_cost_per_reasoning_token": 3e-05, + "output_cost_per_token": 3e-05, + "rpm": 100000, + "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text", + "image" + ], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_vision": true, + "supports_web_search": true, + "tpm": 8000000 + }, + "gemini/gemini-2.5-flash-lite": { + "cache_read_input_token_cost": 2.5e-08, + "input_cost_per_audio_token": 5e-07, + "input_cost_per_token": 1e-07, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 4e-07, + "output_cost_per_token": 4e-07, + "rpm": 15, + "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-lite", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_vision": true, + "supports_web_search": true, + "tpm": 250000 + }, + "gemini/gemini-2.5-flash-lite-preview-09-2025": { + "cache_read_input_token_cost": 2.5e-08, + "input_cost_per_audio_token": 3e-07, + "input_cost_per_token": 1e-07, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 4e-07, + "output_cost_per_token": 4e-07, + "rpm": 15, + "source": "https://developers.googleblog.com/en/continuing-to-bring-you-our-latest-models-with-an-improved-gemini-2-5-flash-and-flash-lite-release/", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_vision": true, + "supports_web_search": true, + "tpm": 250000 + }, + "gemini/gemini-2.5-flash-preview-09-2025": { + "cache_read_input_token_cost": 7.5e-08, + "input_cost_per_audio_token": 1e-06, + "input_cost_per_token": 3e-07, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 2.5e-06, + "output_cost_per_token": 2.5e-06, + "rpm": 15, + "source": "https://developers.googleblog.com/en/continuing-to-bring-you-our-latest-models-with-an-improved-gemini-2-5-flash-and-flash-lite-release/", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_vision": true, + "supports_web_search": true, + "tpm": 250000 + }, + "gemini/gemini-flash-latest": { + "cache_read_input_token_cost": 7.5e-08, + "input_cost_per_audio_token": 1e-06, + "input_cost_per_token": 3e-07, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 2.5e-06, + "output_cost_per_token": 2.5e-06, + "rpm": 15, + "source": "https://developers.googleblog.com/en/continuing-to-bring-you-our-latest-models-with-an-improved-gemini-2-5-flash-and-flash-lite-release/", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_vision": true, + "supports_web_search": true, + "tpm": 250000 + }, + "gemini/gemini-flash-lite-latest": { + "cache_read_input_token_cost": 2.5e-08, + "input_cost_per_audio_token": 3e-07, + "input_cost_per_token": 1e-07, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 4e-07, + "output_cost_per_token": 4e-07, + "rpm": 15, + "source": "https://developers.googleblog.com/en/continuing-to-bring-you-our-latest-models-with-an-improved-gemini-2-5-flash-and-flash-lite-release/", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_vision": true, + "supports_web_search": true, + "tpm": 250000 + }, + "gemini/gemini-2.5-flash-lite-preview-06-17": { + "cache_read_input_token_cost": 2.5e-08, + "input_cost_per_audio_token": 5e-07, + "input_cost_per_token": 1e-07, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 4e-07, + "output_cost_per_token": 4e-07, + "rpm": 15, + "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-lite", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_vision": true, + "supports_web_search": true, + "tpm": 250000 + }, + "gemini/gemini-2.5-flash-preview-04-17": { + "cache_read_input_token_cost": 3.75e-08, + "input_cost_per_audio_token": 1e-06, + "input_cost_per_token": 1.5e-07, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 3.5e-06, + "output_cost_per_token": 6e-07, + "rpm": 10, + "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "tpm": 250000 + }, + "gemini/gemini-2.5-flash-preview-05-20": { + "cache_read_input_token_cost": 7.5e-08, + "input_cost_per_audio_token": 1e-06, + "input_cost_per_token": 3e-07, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 2.5e-06, + "output_cost_per_token": 2.5e-06, + "rpm": 10, + "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_vision": true, + "supports_web_search": true, + "tpm": 250000 + }, + "gemini/gemini-2.5-flash-preview-tts": { + "cache_read_input_token_cost": 3.75e-08, + "input_cost_per_audio_token": 1e-06, + "input_cost_per_token": 1.5e-07, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 3.5e-06, + "output_cost_per_token": 6e-07, + "rpm": 10, + "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "audio" + ], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "tpm": 250000 + }, + "gemini/gemini-2.5-pro": { + "cache_read_input_token_cost": 3.125e-07, + "input_cost_per_token": 1.25e-06, + "input_cost_per_token_above_200k_tokens": 2.5e-06, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 1e-05, + "output_cost_per_token_above_200k_tokens": 1.5e-05, + "rpm": 2000, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true, + "tpm": 800000 + }, + "gemini/gemini-2.5-pro-exp-03-25": { + "cache_read_input_token_cost": 0.0, + "input_cost_per_token": 0.0, + "input_cost_per_token_above_200k_tokens": 0.0, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 0.0, + "output_cost_per_token_above_200k_tokens": 0.0, + "rpm": 5, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true, + "tpm": 250000 + }, + "gemini/gemini-2.5-pro-preview-03-25": { + "cache_read_input_token_cost": 3.125e-07, + "input_cost_per_audio_token": 7e-07, + "input_cost_per_token": 1.25e-06, + "input_cost_per_token_above_200k_tokens": 2.5e-06, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 1e-05, + "output_cost_per_token_above_200k_tokens": 1.5e-05, + "rpm": 10000, + "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-2.5-pro-preview", + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "tpm": 10000000 + }, + "gemini/gemini-2.5-pro-preview-05-06": { + "cache_read_input_token_cost": 3.125e-07, + "input_cost_per_audio_token": 7e-07, + "input_cost_per_token": 1.25e-06, + "input_cost_per_token_above_200k_tokens": 2.5e-06, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 1e-05, + "output_cost_per_token_above_200k_tokens": 1.5e-05, + "rpm": 10000, + "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-2.5-pro-preview", + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_vision": true, + "supports_web_search": true, + "tpm": 10000000 + }, + "gemini/gemini-2.5-pro-preview-06-05": { + "cache_read_input_token_cost": 3.125e-07, + "input_cost_per_audio_token": 7e-07, + "input_cost_per_token": 1.25e-06, + "input_cost_per_token_above_200k_tokens": 2.5e-06, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 1e-05, + "output_cost_per_token_above_200k_tokens": 1.5e-05, + "rpm": 10000, + "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-2.5-pro-preview", + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_vision": true, + "supports_web_search": true, + "tpm": 10000000 + }, + "gemini/gemini-2.5-pro-preview-tts": { + "cache_read_input_token_cost": 3.125e-07, + "input_cost_per_audio_token": 7e-07, + "input_cost_per_token": 1.25e-06, + "input_cost_per_token_above_200k_tokens": 2.5e-06, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 1e-05, + "output_cost_per_token_above_200k_tokens": 1.5e-05, + "rpm": 10000, + "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-2.5-pro-preview", + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "audio" + ], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "tpm": 10000000 + }, + "gemini/gemini-exp-1114": { + "input_cost_per_token": 0, + "input_cost_per_token_above_128k_tokens": 0, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 8192, + "max_pdf_size_mb": 30, + "max_tokens": 8192, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "metadata": { + "notes": "Rate limits not documented for gemini-exp-1114. Assuming same as gemini-1.5-pro.", + "supports_tool_choice": true + }, + "mode": "chat", + "output_cost_per_token": 0, + "output_cost_per_token_above_128k_tokens": 0, + "rpm": 1000, + "source": "https://ai.google.dev/pricing", + "supports_function_calling": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "tpm": 4000000 + }, + "gemini/gemini-exp-1206": { + "input_cost_per_token": 0, + "input_cost_per_token_above_128k_tokens": 0, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 2097152, + "max_output_tokens": 8192, + "max_pdf_size_mb": 30, + "max_tokens": 8192, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "metadata": { + "notes": "Rate limits not documented for gemini-exp-1206. Assuming same as gemini-1.5-pro.", + "supports_tool_choice": true + }, + "mode": "chat", + "output_cost_per_token": 0, + "output_cost_per_token_above_128k_tokens": 0, + "rpm": 1000, + "source": "https://ai.google.dev/pricing", + "supports_function_calling": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "tpm": 4000000 + }, + "gemini/gemini-gemma-2-27b-it": { + "input_cost_per_token": 3.5e-07, + "litellm_provider": "gemini", + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.05e-06, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gemini/gemini-gemma-2-9b-it": { + "input_cost_per_token": 3.5e-07, + "litellm_provider": "gemini", + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.05e-06, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gemini/gemini-pro": { + "input_cost_per_token": 3.5e-07, + "input_cost_per_token_above_128k_tokens": 7e-07, + "litellm_provider": "gemini", + "max_input_tokens": 32760, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.05e-06, + "output_cost_per_token_above_128k_tokens": 2.1e-06, + "rpd": 30000, + "rpm": 360, + "source": "https://ai.google.dev/gemini-api/docs/models/gemini", + "supports_function_calling": true, + "supports_tool_choice": true, + "tpm": 120000 + }, + "gemini/gemini-pro-vision": { + "input_cost_per_token": 3.5e-07, + "input_cost_per_token_above_128k_tokens": 7e-07, + "litellm_provider": "gemini", + "max_input_tokens": 30720, + "max_output_tokens": 2048, + "max_tokens": 2048, + "mode": "chat", + "output_cost_per_token": 1.05e-06, + "output_cost_per_token_above_128k_tokens": 2.1e-06, + "rpd": 30000, + "rpm": 360, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true, + "tpm": 120000 + }, + "gemini/gemma-3-27b-it": { + "input_cost_per_audio_per_second": 0, + "input_cost_per_audio_per_second_above_128k_tokens": 0, + "input_cost_per_character": 0, + "input_cost_per_character_above_128k_tokens": 0, + "input_cost_per_image": 0, + "input_cost_per_image_above_128k_tokens": 0, + "input_cost_per_token": 0, + "input_cost_per_token_above_128k_tokens": 0, + "input_cost_per_video_per_second": 0, + "input_cost_per_video_per_second_above_128k_tokens": 0, + "litellm_provider": "gemini", + "max_input_tokens": 131072, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_character": 0, + "output_cost_per_character_above_128k_tokens": 0, + "output_cost_per_token": 0, + "output_cost_per_token_above_128k_tokens": 0, + "source": "https://aistudio.google.com", + "supports_audio_output": false, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gemini/imagen-3.0-fast-generate-001": { + "litellm_provider": "gemini", + "mode": "image_generation", + "output_cost_per_image": 0.02, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" + }, + "gemini/imagen-3.0-generate-001": { + "litellm_provider": "gemini", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" + }, + "gemini/imagen-3.0-generate-002": { + "litellm_provider": "gemini", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" + }, + "gemini/imagen-4.0-fast-generate-001": { + "litellm_provider": "gemini", + "mode": "image_generation", + "output_cost_per_image": 0.02, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" + }, + "gemini/imagen-4.0-generate-001": { + "litellm_provider": "gemini", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" + }, + "gemini/imagen-4.0-ultra-generate-001": { + "litellm_provider": "gemini", + "mode": "image_generation", + "output_cost_per_image": 0.06, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" + }, + "gemini/learnlm-1.5-pro-experimental": { + "input_cost_per_audio_per_second": 0, + "input_cost_per_audio_per_second_above_128k_tokens": 0, + "input_cost_per_character": 0, + "input_cost_per_character_above_128k_tokens": 0, + "input_cost_per_image": 0, + "input_cost_per_image_above_128k_tokens": 0, + "input_cost_per_token": 0, + "input_cost_per_token_above_128k_tokens": 0, + "input_cost_per_video_per_second": 0, + "input_cost_per_video_per_second_above_128k_tokens": 0, + "litellm_provider": "gemini", + "max_input_tokens": 32767, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_character": 0, + "output_cost_per_character_above_128k_tokens": 0, + "output_cost_per_token": 0, + "output_cost_per_token_above_128k_tokens": 0, + "source": "https://aistudio.google.com", + "supports_audio_output": false, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gemini/veo-2.0-generate-001": { + "litellm_provider": "gemini", + "max_input_tokens": 1024, + "max_tokens": 1024, + "mode": "video_generation", + "output_cost_per_second": 0.35, + "source": "https://ai.google.dev/gemini-api/docs/video", + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "gemini/veo-3.0-fast-generate-preview": { + "litellm_provider": "gemini", + "max_input_tokens": 1024, + "max_tokens": 1024, + "mode": "video_generation", + "output_cost_per_second": 0.4, + "source": "https://ai.google.dev/gemini-api/docs/video", + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "gemini/veo-3.0-generate-preview": { + "litellm_provider": "gemini", + "max_input_tokens": 1024, + "max_tokens": 1024, + "mode": "video_generation", + "output_cost_per_second": 0.75, + "source": "https://ai.google.dev/gemini-api/docs/video", + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "gpt-3.5-turbo": { + "input_cost_per_token": 0.5e-06, + "litellm_provider": "openai", + "max_input_tokens": 16385, + "max_output_tokens": 4096, + "max_tokens": 4097, + "mode": "chat", + "output_cost_per_token": 1.5e-06, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-3.5-turbo-0125": { + "input_cost_per_token": 5e-07, + "litellm_provider": "openai", + "max_input_tokens": 16385, + "max_output_tokens": 4096, + "max_tokens": 16385, + "mode": "chat", + "output_cost_per_token": 1.5e-06, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-3.5-turbo-0301": { + "input_cost_per_token": 1.5e-06, + "litellm_provider": "openai", + "max_input_tokens": 4097, + "max_output_tokens": 4096, + "max_tokens": 4097, + "mode": "chat", + "output_cost_per_token": 2e-06, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-3.5-turbo-0613": { + "input_cost_per_token": 1.5e-06, + "litellm_provider": "openai", + "max_input_tokens": 4097, + "max_output_tokens": 4096, + "max_tokens": 4097, + "mode": "chat", + "output_cost_per_token": 2e-06, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-3.5-turbo-1106": { + "input_cost_per_token": 1e-06, + "litellm_provider": "openai", + "max_input_tokens": 16385, + "max_output_tokens": 4096, + "max_tokens": 16385, + "mode": "chat", + "output_cost_per_token": 2e-06, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-3.5-turbo-16k": { + "input_cost_per_token": 3e-06, + "litellm_provider": "openai", + "max_input_tokens": 16385, + "max_output_tokens": 4096, + "max_tokens": 16385, + "mode": "chat", + "output_cost_per_token": 4e-06, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-3.5-turbo-16k-0613": { + "input_cost_per_token": 3e-06, + "litellm_provider": "openai", + "max_input_tokens": 16385, + "max_output_tokens": 4096, + "max_tokens": 16385, + "mode": "chat", + "output_cost_per_token": 4e-06, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-3.5-turbo-instruct": { + "input_cost_per_token": 1.5e-06, + "litellm_provider": "text-completion-openai", + "max_input_tokens": 8192, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "completion", + "output_cost_per_token": 2e-06 + }, + "gpt-3.5-turbo-instruct-0914": { + "input_cost_per_token": 1.5e-06, + "litellm_provider": "text-completion-openai", + "max_input_tokens": 8192, + "max_output_tokens": 4097, + "max_tokens": 4097, + "mode": "completion", + "output_cost_per_token": 2e-06 + }, + "gpt-4": { + "input_cost_per_token": 3e-05, + "litellm_provider": "openai", + "max_input_tokens": 8192, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 6e-05, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-4-0125-preview": { + "input_cost_per_token": 1e-05, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 3e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-4-0314": { + "input_cost_per_token": 3e-05, + "litellm_provider": "openai", + "max_input_tokens": 8192, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 6e-05, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-4-0613": { + "deprecation_date": "2025-06-06", + "input_cost_per_token": 3e-05, + "litellm_provider": "openai", + "max_input_tokens": 8192, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 6e-05, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-4-1106-preview": { + "input_cost_per_token": 1e-05, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 3e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-4-1106-vision-preview": { + "deprecation_date": "2024-12-06", + "input_cost_per_token": 1e-05, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 3e-05, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-4-32k": { + "input_cost_per_token": 6e-05, + "litellm_provider": "openai", + "max_input_tokens": 32768, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.00012, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-4-32k-0314": { + "input_cost_per_token": 6e-05, + "litellm_provider": "openai", + "max_input_tokens": 32768, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.00012, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-4-32k-0613": { + "input_cost_per_token": 6e-05, + "litellm_provider": "openai", + "max_input_tokens": 32768, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.00012, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-4-turbo": { + "input_cost_per_token": 1e-05, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 3e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-4-turbo-2024-04-09": { + "input_cost_per_token": 1e-05, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 3e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-4-turbo-preview": { + "input_cost_per_token": 1e-05, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 3e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-4-vision-preview": { + "deprecation_date": "2024-12-06", + "input_cost_per_token": 1e-05, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 3e-05, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-4.1": { + "cache_read_input_token_cost": 5e-07, + "cache_read_input_token_cost_priority": 8.75e-07, + "input_cost_per_token": 2e-06, + "input_cost_per_token_batches": 1e-06, + "input_cost_per_token_priority": 3.5e-06, + "litellm_provider": "openai", + "max_input_tokens": 1047576, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 8e-06, + "output_cost_per_token_batches": 4e-06, + "output_cost_per_token_priority": 1.4e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-4.1-2025-04-14": { + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token": 2e-06, + "input_cost_per_token_batches": 1e-06, + "litellm_provider": "openai", + "max_input_tokens": 1047576, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 8e-06, + "output_cost_per_token_batches": 4e-06, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-4.1-mini": { + "cache_read_input_token_cost": 1e-07, + "cache_read_input_token_cost_priority": 1.75e-07, + "input_cost_per_token": 4e-07, + "input_cost_per_token_batches": 2e-07, + "input_cost_per_token_priority": 7e-07, + "litellm_provider": "openai", + "max_input_tokens": 1047576, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 1.6e-06, + "output_cost_per_token_batches": 8e-07, + "output_cost_per_token_priority": 2.8e-06, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-4.1-mini-2025-04-14": { + "cache_read_input_token_cost": 1e-07, + "input_cost_per_token": 4e-07, + "input_cost_per_token_batches": 2e-07, + "litellm_provider": "openai", + "max_input_tokens": 1047576, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 1.6e-06, + "output_cost_per_token_batches": 8e-07, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-4.1-nano": { + "cache_read_input_token_cost": 2.5e-08, + "cache_read_input_token_cost_priority": 5e-08, + "input_cost_per_token": 1e-07, + "input_cost_per_token_batches": 5e-08, + "input_cost_per_token_priority": 2e-07, + "litellm_provider": "openai", + "max_input_tokens": 1047576, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 4e-07, + "output_cost_per_token_batches": 2e-07, + "output_cost_per_token_priority": 8e-07, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-4.1-nano-2025-04-14": { + "cache_read_input_token_cost": 2.5e-08, + "input_cost_per_token": 1e-07, + "input_cost_per_token_batches": 5e-08, + "litellm_provider": "openai", + "max_input_tokens": 1047576, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 4e-07, + "output_cost_per_token_batches": 2e-07, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-4.5-preview": { + "cache_read_input_token_cost": 3.75e-05, + "input_cost_per_token": 7.5e-05, + "input_cost_per_token_batches": 3.75e-05, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 0.00015, + "output_cost_per_token_batches": 7.5e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-4.5-preview-2025-02-27": { + "cache_read_input_token_cost": 3.75e-05, + "deprecation_date": "2025-07-14", + "input_cost_per_token": 7.5e-05, + "input_cost_per_token_batches": 3.75e-05, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 0.00015, + "output_cost_per_token_batches": 7.5e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-4o": { + "cache_read_input_token_cost": 1.25e-06, + "cache_read_input_token_cost_priority": 2.125e-06, + "input_cost_per_token": 2.5e-06, + "input_cost_per_token_batches": 1.25e-06, + "input_cost_per_token_priority": 4.25e-06, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1e-05, + "output_cost_per_token_batches": 5e-06, + "output_cost_per_token_priority": 1.7e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-4o-2024-05-13": { + "input_cost_per_token": 5e-06, + "input_cost_per_token_batches": 2.5e-06, + "input_cost_per_token_priority": 8.75e-06, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "output_cost_per_token_batches": 7.5e-06, + "output_cost_per_token_priority": 2.625e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-4o-2024-08-06": { + "cache_read_input_token_cost": 1.25e-06, + "input_cost_per_token": 2.5e-06, + "input_cost_per_token_batches": 1.25e-06, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1e-05, + "output_cost_per_token_batches": 5e-06, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-4o-2024-11-20": { + "cache_read_input_token_cost": 1.25e-06, + "input_cost_per_token": 2.5e-06, + "input_cost_per_token_batches": 1.25e-06, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1e-05, + "output_cost_per_token_batches": 5e-06, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-4o-audio-preview": { + "input_cost_per_audio_token": 0.0001, + "input_cost_per_token": 2.5e-06, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_audio_token": 0.0002, + "output_cost_per_token": 1e-05, + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-4o-audio-preview-2024-10-01": { + "input_cost_per_audio_token": 0.0001, + "input_cost_per_token": 2.5e-06, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_audio_token": 0.0002, + "output_cost_per_token": 1e-05, + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-4o-audio-preview-2024-12-17": { + "input_cost_per_audio_token": 4e-05, + "input_cost_per_token": 2.5e-06, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_audio_token": 8e-05, + "output_cost_per_token": 1e-05, + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-4o-audio-preview-2025-06-03": { + "input_cost_per_audio_token": 4e-05, + "input_cost_per_token": 2.5e-06, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_audio_token": 8e-05, + "output_cost_per_token": 1e-05, + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-4o-mini": { + "cache_read_input_token_cost": 7.5e-08, + "cache_read_input_token_cost_priority": 1.25e-07, + "input_cost_per_token": 1.5e-07, + "input_cost_per_token_batches": 7.5e-08, + "input_cost_per_token_priority": 2.5e-07, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 6e-07, + "output_cost_per_token_batches": 3e-07, + "output_cost_per_token_priority": 1e-06, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-4o-mini-2024-07-18": { + "cache_read_input_token_cost": 7.5e-08, + "input_cost_per_token": 1.5e-07, + "input_cost_per_token_batches": 7.5e-08, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 6e-07, + "output_cost_per_token_batches": 3e-07, + "search_context_cost_per_query": { + "search_context_size_high": 0.03, + "search_context_size_low": 0.025, + "search_context_size_medium": 0.0275 + }, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-4o-mini-audio-preview": { + "input_cost_per_audio_token": 1e-05, + "input_cost_per_token": 1.5e-07, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_audio_token": 2e-05, + "output_cost_per_token": 6e-07, + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-4o-mini-audio-preview-2024-12-17": { + "input_cost_per_audio_token": 1e-05, + "input_cost_per_token": 1.5e-07, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_audio_token": 2e-05, + "output_cost_per_token": 6e-07, + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-4o-mini-realtime-preview": { + "cache_creation_input_audio_token_cost": 3e-07, + "cache_read_input_token_cost": 3e-07, + "input_cost_per_audio_token": 1e-05, + "input_cost_per_token": 6e-07, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_audio_token": 2e-05, + "output_cost_per_token": 2.4e-06, + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-4o-mini-realtime-preview-2024-12-17": { + "cache_creation_input_audio_token_cost": 3e-07, + "cache_read_input_token_cost": 3e-07, + "input_cost_per_audio_token": 1e-05, + "input_cost_per_token": 6e-07, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_audio_token": 2e-05, + "output_cost_per_token": 2.4e-06, + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-4o-mini-search-preview": { + "cache_read_input_token_cost": 7.5e-08, + "input_cost_per_token": 1.5e-07, + "input_cost_per_token_batches": 7.5e-08, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 6e-07, + "output_cost_per_token_batches": 3e-07, + "search_context_cost_per_query": { + "search_context_size_high": 0.03, + "search_context_size_low": 0.025, + "search_context_size_medium": 0.0275 + }, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "gpt-4o-mini-search-preview-2025-03-11": { + "cache_read_input_token_cost": 7.5e-08, + "input_cost_per_token": 1.5e-07, + "input_cost_per_token_batches": 7.5e-08, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 6e-07, + "output_cost_per_token_batches": 3e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-4o-mini-transcribe": { + "input_cost_per_audio_token": 3e-06, + "input_cost_per_token": 1.25e-06, + "litellm_provider": "openai", + "max_input_tokens": 16000, + "max_output_tokens": 2000, + "mode": "audio_transcription", + "output_cost_per_token": 5e-06, + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, + "gpt-4o-mini-tts": { + "input_cost_per_token": 2.5e-06, + "litellm_provider": "openai", + "mode": "audio_speech", + "output_cost_per_audio_token": 1.2e-05, + "output_cost_per_second": 0.00025, + "output_cost_per_token": 1e-05, + "supported_endpoints": [ + "/v1/audio/speech" + ], + "supported_modalities": [ + "text", + "audio" + ], + "supported_output_modalities": [ + "audio" + ] + }, + "gpt-4o-realtime-preview": { + "cache_read_input_token_cost": 2.5e-06, + "input_cost_per_audio_token": 4e-05, + "input_cost_per_token": 5e-06, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_audio_token": 8e-05, + "output_cost_per_token": 2e-05, + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-4o-realtime-preview-2024-10-01": { + "cache_creation_input_audio_token_cost": 2e-05, + "cache_read_input_token_cost": 2.5e-06, + "input_cost_per_audio_token": 0.0001, + "input_cost_per_token": 5e-06, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_audio_token": 0.0002, + "output_cost_per_token": 2e-05, + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-4o-realtime-preview-2024-12-17": { + "cache_read_input_token_cost": 2.5e-06, + "input_cost_per_audio_token": 4e-05, + "input_cost_per_token": 5e-06, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_audio_token": 8e-05, + "output_cost_per_token": 2e-05, + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-4o-realtime-preview-2025-06-03": { + "cache_read_input_token_cost": 2.5e-06, + "input_cost_per_audio_token": 4e-05, + "input_cost_per_token": 5e-06, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_audio_token": 8e-05, + "output_cost_per_token": 2e-05, + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-4o-search-preview": { + "cache_read_input_token_cost": 1.25e-06, + "input_cost_per_token": 2.5e-06, + "input_cost_per_token_batches": 1.25e-06, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1e-05, + "output_cost_per_token_batches": 5e-06, + "search_context_cost_per_query": { + "search_context_size_high": 0.05, + "search_context_size_low": 0.03, + "search_context_size_medium": 0.035 + }, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "gpt-4o-search-preview-2025-03-11": { + "cache_read_input_token_cost": 1.25e-06, + "input_cost_per_token": 2.5e-06, + "input_cost_per_token_batches": 1.25e-06, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1e-05, + "output_cost_per_token_batches": 5e-06, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-4o-transcribe": { + "input_cost_per_audio_token": 6e-06, + "input_cost_per_token": 2.5e-06, + "litellm_provider": "openai", + "max_input_tokens": 16000, + "max_output_tokens": 2000, + "mode": "audio_transcription", + "output_cost_per_token": 1e-05, + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, + "gpt-5": { + "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_flex": 6.25e-08, + "cache_read_input_token_cost_priority": 2.5e-07, + "input_cost_per_token": 1.25e-06, + "input_cost_per_token_flex": 6.25e-07, + "input_cost_per_token_priority": 2.5e-06, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1e-05, + "output_cost_per_token_flex": 5e-06, + "output_cost_per_token_priority": 2e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-5-2025-08-07": { + "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_flex": 6.25e-08, + "cache_read_input_token_cost_priority": 2.5e-07, + "input_cost_per_token": 1.25e-06, + "input_cost_per_token_flex": 6.25e-07, + "input_cost_per_token_priority": 2.5e-06, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1e-05, + "output_cost_per_token_flex": 5e-06, + "output_cost_per_token_priority": 2e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_service_tier": true, + "supports_vision": true + }, + "gpt-5-chat": { + "cache_read_input_token_cost": 1.25e-07, + "input_cost_per_token": 1.25e-06, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": false, + "supports_native_streaming": true, + "supports_parallel_function_calling": false, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": false, + "supports_vision": true + }, + "gpt-5-chat-latest": { + "cache_read_input_token_cost": 1.25e-07, + "input_cost_per_token": 1.25e-06, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": false, + "supports_native_streaming": true, + "supports_parallel_function_calling": false, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": false, + "supports_vision": true + }, + "gpt-5-codex": { + "cache_read_input_token_cost": 1.25e-07, + "input_cost_per_token": 1.25e-06, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "responses", + "output_cost_per_token": 1e-05, + "supported_endpoints": [ + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": false, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_system_messages": false, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-5-mini": { + "cache_read_input_token_cost": 2.5e-08, + "cache_read_input_token_cost_flex": 1.25e-08, + "cache_read_input_token_cost_priority": 4.5e-08, + "input_cost_per_token": 2.5e-07, + "input_cost_per_token_flex": 1.25e-07, + "input_cost_per_token_priority": 4.5e-07, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2e-06, + "output_cost_per_token_flex": 1e-06, + "output_cost_per_token_priority": 3.6e-06, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-5-mini-2025-08-07": { + "cache_read_input_token_cost": 2.5e-08, + "cache_read_input_token_cost_flex": 1.25e-08, + "cache_read_input_token_cost_priority": 4.5e-08, + "input_cost_per_token": 2.5e-07, + "input_cost_per_token_flex": 1.25e-07, + "input_cost_per_token_priority": 4.5e-07, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2e-06, + "output_cost_per_token_flex": 1e-06, + "output_cost_per_token_priority": 3.6e-06, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-5-nano": { + "cache_read_input_token_cost": 5e-09, + "cache_read_input_token_cost_flex": 2.5e-09, + "input_cost_per_token": 5e-08, + "input_cost_per_token_flex": 2.5e-08, + "input_cost_per_token_priority": 2.5e-06, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 4e-07, + "output_cost_per_token_flex": 2e-07, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-5-nano-2025-08-07": { + "cache_read_input_token_cost": 5e-09, + "cache_read_input_token_cost_flex": 2.5e-09, + "input_cost_per_token": 5e-08, + "input_cost_per_token_flex": 2.5e-08, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 4e-07, + "output_cost_per_token_flex": 2e-07, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-image-1": { + "input_cost_per_pixel": 4.0054321e-08, + "litellm_provider": "openai", + "mode": "image_generation", + "output_cost_per_pixel": 0.0, + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "gpt-realtime": { + "cache_creation_input_audio_token_cost": 4e-07, + "cache_read_input_token_cost": 4e-07, + "input_cost_per_audio_token": 3.2e-05, + "input_cost_per_image": 5e-06, + "input_cost_per_token": 4e-06, + "litellm_provider": "openai", + "max_input_tokens": 32000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_audio_token": 6.4e-05, + "output_cost_per_token": 1.6e-05, + "supported_endpoints": [ + "/v1/realtime" + ], + "supported_modalities": [ + "text", + "image", + "audio" + ], + "supported_output_modalities": [ + "text", + "audio" + ], + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-realtime-2025-08-28": { + "cache_creation_input_audio_token_cost": 4e-07, + "cache_read_input_token_cost": 4e-07, + "input_cost_per_audio_token": 3.2e-05, + "input_cost_per_image": 5e-06, + "input_cost_per_token": 4e-06, + "litellm_provider": "openai", + "max_input_tokens": 32000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_audio_token": 6.4e-05, + "output_cost_per_token": 1.6e-05, + "supported_endpoints": [ + "/v1/realtime" + ], + "supported_modalities": [ + "text", + "image", + "audio" + ], + "supported_output_modalities": [ + "text", + "audio" + ], + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gradient_ai/alibaba-qwen3-32b": { + "litellm_provider": "gradient_ai", + "max_tokens": 2048, + "mode": "chat", + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supported_modalities": [ + "text" + ], + "supports_tool_choice": false + }, + "gradient_ai/anthropic-claude-3-opus": { + "input_cost_per_token": 1.5e-05, + "litellm_provider": "gradient_ai", + "max_tokens": 1024, + "mode": "chat", + "output_cost_per_token": 7.5e-05, + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supported_modalities": [ + "text" + ], + "supports_tool_choice": false + }, + "gradient_ai/anthropic-claude-3.5-haiku": { + "input_cost_per_token": 8e-07, + "litellm_provider": "gradient_ai", + "max_tokens": 1024, + "mode": "chat", + "output_cost_per_token": 4e-06, + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supported_modalities": [ + "text" + ], + "supports_tool_choice": false + }, + "gradient_ai/anthropic-claude-3.5-sonnet": { + "input_cost_per_token": 3e-06, + "litellm_provider": "gradient_ai", + "max_tokens": 1024, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supported_modalities": [ + "text" + ], + "supports_tool_choice": false + }, + "gradient_ai/anthropic-claude-3.7-sonnet": { + "input_cost_per_token": 3e-06, + "litellm_provider": "gradient_ai", + "max_tokens": 1024, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supported_modalities": [ + "text" + ], + "supports_tool_choice": false + }, + "gradient_ai/deepseek-r1-distill-llama-70b": { + "input_cost_per_token": 9.9e-07, + "litellm_provider": "gradient_ai", + "max_tokens": 8000, + "mode": "chat", + "output_cost_per_token": 9.9e-07, + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supported_modalities": [ + "text" + ], + "supports_tool_choice": false + }, + "gradient_ai/llama3-8b-instruct": { + "input_cost_per_token": 2e-07, + "litellm_provider": "gradient_ai", + "max_tokens": 512, + "mode": "chat", + "output_cost_per_token": 2e-07, + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supported_modalities": [ + "text" + ], + "supports_tool_choice": false + }, + "gradient_ai/llama3.3-70b-instruct": { + "input_cost_per_token": 6.5e-07, + "litellm_provider": "gradient_ai", + "max_tokens": 2048, + "mode": "chat", + "output_cost_per_token": 6.5e-07, + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supported_modalities": [ + "text" + ], + "supports_tool_choice": false + }, + "gradient_ai/mistral-nemo-instruct-2407": { + "input_cost_per_token": 3e-07, + "litellm_provider": "gradient_ai", + "max_tokens": 512, + "mode": "chat", + "output_cost_per_token": 3e-07, + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supported_modalities": [ + "text" + ], + "supports_tool_choice": false + }, + "gradient_ai/openai-gpt-4o": { + "litellm_provider": "gradient_ai", + "max_tokens": 16384, + "mode": "chat", + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supported_modalities": [ + "text" + ], + "supports_tool_choice": false + }, + "gradient_ai/openai-gpt-4o-mini": { + "litellm_provider": "gradient_ai", + "max_tokens": 16384, + "mode": "chat", + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supported_modalities": [ + "text" + ], + "supports_tool_choice": false + }, + "gradient_ai/openai-o3": { + "input_cost_per_token": 2e-06, + "litellm_provider": "gradient_ai", + "max_tokens": 100000, + "mode": "chat", + "output_cost_per_token": 8e-06, + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supported_modalities": [ + "text" + ], + "supports_tool_choice": false + }, + "gradient_ai/openai-o3-mini": { + "input_cost_per_token": 1.1e-06, + "litellm_provider": "gradient_ai", + "max_tokens": 100000, + "mode": "chat", + "output_cost_per_token": 4.4e-06, + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supported_modalities": [ + "text" + ], + "supports_tool_choice": false + }, + "groq/deepseek-r1-distill-llama-70b": { + "input_cost_per_token": 7.5e-07, + "litellm_provider": "groq", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 9.9e-07, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "groq/distil-whisper-large-v3-en": { + "input_cost_per_second": 5.56e-06, + "litellm_provider": "groq", + "mode": "audio_transcription", + "output_cost_per_second": 0.0 + }, + "groq/gemma-7b-it": { + "deprecation_date": "2024-12-18", + "input_cost_per_token": 7e-08, + "litellm_provider": "groq", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 7e-08, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "groq/gemma2-9b-it": { + "input_cost_per_token": 2e-07, + "litellm_provider": "groq", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 2e-07, + "supports_function_calling": false, + "supports_response_schema": true, + "supports_tool_choice": false + }, + "groq/llama-3.1-405b-reasoning": { + "input_cost_per_token": 5.9e-07, + "litellm_provider": "groq", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 7.9e-07, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "groq/llama-3.1-70b-versatile": { + "deprecation_date": "2025-01-24", + "input_cost_per_token": 5.9e-07, + "litellm_provider": "groq", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 7.9e-07, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "groq/llama-3.1-8b-instant": { + "input_cost_per_token": 5e-08, + "litellm_provider": "groq", + "max_input_tokens": 128000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 8e-08, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "groq/llama-3.2-11b-text-preview": { + "deprecation_date": "2024-10-28", + "input_cost_per_token": 1.8e-07, + "litellm_provider": "groq", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.8e-07, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "groq/llama-3.2-11b-vision-preview": { + "deprecation_date": "2025-04-14", + "input_cost_per_token": 1.8e-07, + "litellm_provider": "groq", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.8e-07, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "groq/llama-3.2-1b-preview": { + "deprecation_date": "2025-04-14", + "input_cost_per_token": 4e-08, + "litellm_provider": "groq", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 4e-08, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "groq/llama-3.2-3b-preview": { + "deprecation_date": "2025-04-14", + "input_cost_per_token": 6e-08, + "litellm_provider": "groq", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 6e-08, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "groq/llama-3.2-90b-text-preview": { + "deprecation_date": "2024-11-25", + "input_cost_per_token": 9e-07, + "litellm_provider": "groq", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 9e-07, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "groq/llama-3.2-90b-vision-preview": { + "deprecation_date": "2025-04-14", + "input_cost_per_token": 9e-07, + "litellm_provider": "groq", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 9e-07, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "groq/llama-3.3-70b-specdec": { + "deprecation_date": "2025-04-14", + "input_cost_per_token": 5.9e-07, + "litellm_provider": "groq", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 9.9e-07, + "supports_tool_choice": true + }, + "groq/llama-3.3-70b-versatile": { + "input_cost_per_token": 5.9e-07, + "litellm_provider": "groq", + "max_input_tokens": 128000, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 7.9e-07, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "groq/llama-guard-3-8b": { + "input_cost_per_token": 2e-07, + "litellm_provider": "groq", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 2e-07 + }, + "groq/llama2-70b-4096": { + "input_cost_per_token": 7e-07, + "litellm_provider": "groq", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 8e-07, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "groq/llama3-groq-70b-8192-tool-use-preview": { + "deprecation_date": "2025-01-06", + "input_cost_per_token": 8.9e-07, + "litellm_provider": "groq", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 8.9e-07, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "groq/llama3-groq-8b-8192-tool-use-preview": { + "deprecation_date": "2025-01-06", + "input_cost_per_token": 1.9e-07, + "litellm_provider": "groq", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.9e-07, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "groq/meta-llama/llama-4-maverick-17b-128e-instruct": { + "input_cost_per_token": 2e-07, + "litellm_provider": "groq", + "max_input_tokens": 131072, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 6e-07, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "groq/meta-llama/llama-4-scout-17b-16e-instruct": { + "input_cost_per_token": 1.1e-07, + "litellm_provider": "groq", + "max_input_tokens": 131072, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 3.4e-07, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "groq/mistral-saba-24b": { + "input_cost_per_token": 7.9e-07, + "litellm_provider": "groq", + "max_input_tokens": 32000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 7.9e-07 + }, + "groq/mixtral-8x7b-32768": { + "deprecation_date": "2025-03-20", + "input_cost_per_token": 2.4e-07, + "litellm_provider": "groq", + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 2.4e-07, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "groq/moonshotai/kimi-k2-instruct": { + "input_cost_per_token": 1e-06, + "litellm_provider": "groq", + "max_input_tokens": 131072, + "max_output_tokens": 16384, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 3e-06, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "groq/openai/gpt-oss-120b": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "groq", + "max_input_tokens": 131072, + "max_output_tokens": 32766, + "max_tokens": 32766, + "mode": "chat", + "output_cost_per_token": 7.5e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_web_search": true + }, + "groq/openai/gpt-oss-20b": { + "input_cost_per_token": 1e-07, + "litellm_provider": "groq", + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 5e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_web_search": true + }, + "groq/playai-tts": { + "input_cost_per_character": 5e-05, + "litellm_provider": "groq", + "max_input_tokens": 10000, + "max_output_tokens": 10000, + "max_tokens": 10000, + "mode": "audio_speech" + }, + "groq/qwen/qwen3-32b": { + "input_cost_per_token": 2.9e-07, + "litellm_provider": "groq", + "max_input_tokens": 131000, + "max_output_tokens": 131000, + "max_tokens": 131000, + "mode": "chat", + "output_cost_per_token": 5.9e-07, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "groq/whisper-large-v3": { + "input_cost_per_second": 3.083e-05, + "litellm_provider": "groq", + "mode": "audio_transcription", + "output_cost_per_second": 0.0 + }, + "groq/whisper-large-v3-turbo": { + "input_cost_per_second": 1.111e-05, + "litellm_provider": "groq", + "mode": "audio_transcription", + "output_cost_per_second": 0.0 + }, + "hd/1024-x-1024/dall-e-3": { + "input_cost_per_pixel": 7.629e-08, + "litellm_provider": "openai", + "mode": "image_generation", + "output_cost_per_pixel": 0.0 + }, + "hd/1024-x-1792/dall-e-3": { + "input_cost_per_pixel": 6.539e-08, + "litellm_provider": "openai", + "mode": "image_generation", + "output_cost_per_pixel": 0.0 + }, + "hd/1792-x-1024/dall-e-3": { + "input_cost_per_pixel": 6.539e-08, + "litellm_provider": "openai", + "mode": "image_generation", + "output_cost_per_pixel": 0.0 + }, + "heroku/claude-3-5-haiku": { + "litellm_provider": "heroku", + "max_tokens": 4096, + "mode": "chat", + "supports_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "heroku/claude-3-5-sonnet-latest": { + "litellm_provider": "heroku", + "max_tokens": 8192, + "mode": "chat", + "supports_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "heroku/claude-3-7-sonnet": { + "litellm_provider": "heroku", + "max_tokens": 8192, + "mode": "chat", + "supports_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "heroku/claude-4-sonnet": { + "litellm_provider": "heroku", + "max_tokens": 8192, + "mode": "chat", + "supports_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "high/1024-x-1024/gpt-image-1": { + "input_cost_per_pixel": 1.59263611e-07, + "litellm_provider": "openai", + "mode": "image_generation", + "output_cost_per_pixel": 0.0, + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "high/1024-x-1536/gpt-image-1": { + "input_cost_per_pixel": 1.58945719e-07, + "litellm_provider": "openai", + "mode": "image_generation", + "output_cost_per_pixel": 0.0, + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "high/1536-x-1024/gpt-image-1": { + "input_cost_per_pixel": 1.58945719e-07, + "litellm_provider": "openai", + "mode": "image_generation", + "output_cost_per_pixel": 0.0, + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "hyperbolic/NousResearch/Hermes-3-Llama-3.1-70B": { + "input_cost_per_token": 1.2e-07, + "litellm_provider": "hyperbolic", + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 3e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "hyperbolic/Qwen/QwQ-32B": { + "input_cost_per_token": 2e-07, + "litellm_provider": "hyperbolic", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 2e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "hyperbolic/Qwen/Qwen2.5-72B-Instruct": { + "input_cost_per_token": 1.2e-07, + "litellm_provider": "hyperbolic", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 3e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "hyperbolic/Qwen/Qwen2.5-Coder-32B-Instruct": { + "input_cost_per_token": 1.2e-07, + "litellm_provider": "hyperbolic", + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 3e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "hyperbolic/Qwen/Qwen3-235B-A22B": { + "input_cost_per_token": 2e-06, + "litellm_provider": "hyperbolic", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 2e-06, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "hyperbolic/deepseek-ai/DeepSeek-R1": { + "input_cost_per_token": 4e-07, + "litellm_provider": "hyperbolic", + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 4e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "hyperbolic/deepseek-ai/DeepSeek-R1-0528": { + "input_cost_per_token": 2.5e-07, + "litellm_provider": "hyperbolic", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "hyperbolic/deepseek-ai/DeepSeek-V3": { + "input_cost_per_token": 2e-07, + "litellm_provider": "hyperbolic", + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 2e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "hyperbolic/deepseek-ai/DeepSeek-V3-0324": { + "input_cost_per_token": 4e-07, + "litellm_provider": "hyperbolic", + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 4e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "hyperbolic/meta-llama/Llama-3.2-3B-Instruct": { + "input_cost_per_token": 1.2e-07, + "litellm_provider": "hyperbolic", + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 3e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "hyperbolic/meta-llama/Llama-3.3-70B-Instruct": { + "input_cost_per_token": 1.2e-07, + "litellm_provider": "hyperbolic", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 3e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "hyperbolic/meta-llama/Meta-Llama-3-70B-Instruct": { + "input_cost_per_token": 1.2e-07, + "litellm_provider": "hyperbolic", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 3e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "hyperbolic/meta-llama/Meta-Llama-3.1-405B-Instruct": { + "input_cost_per_token": 1.2e-07, + "litellm_provider": "hyperbolic", + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 3e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "hyperbolic/meta-llama/Meta-Llama-3.1-70B-Instruct": { + "input_cost_per_token": 1.2e-07, + "litellm_provider": "hyperbolic", + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 3e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "hyperbolic/meta-llama/Meta-Llama-3.1-8B-Instruct": { + "input_cost_per_token": 1.2e-07, + "litellm_provider": "hyperbolic", + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 3e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "hyperbolic/moonshotai/Kimi-K2-Instruct": { + "input_cost_per_token": 2e-06, + "litellm_provider": "hyperbolic", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 2e-06, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "j2-light": { + "input_cost_per_token": 3e-06, + "litellm_provider": "ai21", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "completion", + "output_cost_per_token": 3e-06 + }, + "j2-mid": { + "input_cost_per_token": 1e-05, + "litellm_provider": "ai21", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "completion", + "output_cost_per_token": 1e-05 + }, + "j2-ultra": { + "input_cost_per_token": 1.5e-05, + "litellm_provider": "ai21", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "completion", + "output_cost_per_token": 1.5e-05 + }, + "jamba-1.5": { + "input_cost_per_token": 2e-07, + "litellm_provider": "ai21", + "max_input_tokens": 256000, + "max_output_tokens": 256000, + "max_tokens": 256000, + "mode": "chat", + "output_cost_per_token": 4e-07, + "supports_tool_choice": true + }, + "jamba-1.5-large": { + "input_cost_per_token": 2e-06, + "litellm_provider": "ai21", + "max_input_tokens": 256000, + "max_output_tokens": 256000, + "max_tokens": 256000, + "mode": "chat", + "output_cost_per_token": 8e-06, + "supports_tool_choice": true + }, + "jamba-1.5-large@001": { + "input_cost_per_token": 2e-06, + "litellm_provider": "ai21", + "max_input_tokens": 256000, + "max_output_tokens": 256000, + "max_tokens": 256000, + "mode": "chat", + "output_cost_per_token": 8e-06, + "supports_tool_choice": true + }, + "jamba-1.5-mini": { + "input_cost_per_token": 2e-07, + "litellm_provider": "ai21", + "max_input_tokens": 256000, + "max_output_tokens": 256000, + "max_tokens": 256000, + "mode": "chat", + "output_cost_per_token": 4e-07, + "supports_tool_choice": true + }, + "jamba-1.5-mini@001": { + "input_cost_per_token": 2e-07, + "litellm_provider": "ai21", + "max_input_tokens": 256000, + "max_output_tokens": 256000, + "max_tokens": 256000, + "mode": "chat", + "output_cost_per_token": 4e-07, + "supports_tool_choice": true + }, + "jamba-large-1.6": { + "input_cost_per_token": 2e-06, + "litellm_provider": "ai21", + "max_input_tokens": 256000, + "max_output_tokens": 256000, + "max_tokens": 256000, + "mode": "chat", + "output_cost_per_token": 8e-06, + "supports_tool_choice": true + }, + "jamba-large-1.7": { + "input_cost_per_token": 2e-06, + "litellm_provider": "ai21", + "max_input_tokens": 256000, + "max_output_tokens": 256000, + "max_tokens": 256000, + "mode": "chat", + "output_cost_per_token": 8e-06, + "supports_tool_choice": true + }, + "jamba-mini-1.6": { + "input_cost_per_token": 2e-07, + "litellm_provider": "ai21", + "max_input_tokens": 256000, + "max_output_tokens": 256000, + "max_tokens": 256000, + "mode": "chat", + "output_cost_per_token": 4e-07, + "supports_tool_choice": true + }, + "jamba-mini-1.7": { + "input_cost_per_token": 2e-07, + "litellm_provider": "ai21", + "max_input_tokens": 256000, + "max_output_tokens": 256000, + "max_tokens": 256000, + "mode": "chat", + "output_cost_per_token": 4e-07, + "supports_tool_choice": true + }, + "jina-reranker-v2-base-multilingual": { + "input_cost_per_token": 1.8e-08, + "litellm_provider": "jina_ai", + "max_document_chunks_per_query": 2048, + "max_input_tokens": 1024, + "max_output_tokens": 1024, + "max_tokens": 1024, + "mode": "rerank", + "output_cost_per_token": 1.8e-08 + }, + "lambda_ai/deepseek-llama3.3-70b": { + "input_cost_per_token": 2e-07, + "litellm_provider": "lambda_ai", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 6e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_reasoning": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "lambda_ai/deepseek-r1-0528": { + "input_cost_per_token": 2e-07, + "litellm_provider": "lambda_ai", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 6e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_reasoning": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "lambda_ai/deepseek-r1-671b": { + "input_cost_per_token": 8e-07, + "litellm_provider": "lambda_ai", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 8e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_reasoning": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "lambda_ai/deepseek-v3-0324": { + "input_cost_per_token": 2e-07, + "litellm_provider": "lambda_ai", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 6e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "lambda_ai/hermes3-405b": { + "input_cost_per_token": 8e-07, + "litellm_provider": "lambda_ai", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 8e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "lambda_ai/hermes3-70b": { + "input_cost_per_token": 1.2e-07, + "litellm_provider": "lambda_ai", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 3e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "lambda_ai/hermes3-8b": { + "input_cost_per_token": 2.5e-08, + "litellm_provider": "lambda_ai", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 4e-08, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "lambda_ai/lfm-40b": { + "input_cost_per_token": 1e-07, + "litellm_provider": "lambda_ai", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 2e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "lambda_ai/lfm-7b": { + "input_cost_per_token": 2.5e-08, + "litellm_provider": "lambda_ai", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 4e-08, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "lambda_ai/llama-4-maverick-17b-128e-instruct-fp8": { + "input_cost_per_token": 5e-08, + "litellm_provider": "lambda_ai", + "max_input_tokens": 131072, + "max_output_tokens": 8192, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "lambda_ai/llama-4-scout-17b-16e-instruct": { + "input_cost_per_token": 5e-08, + "litellm_provider": "lambda_ai", + "max_input_tokens": 16384, + "max_output_tokens": 8192, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "lambda_ai/llama3.1-405b-instruct-fp8": { + "input_cost_per_token": 8e-07, + "litellm_provider": "lambda_ai", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 8e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "lambda_ai/llama3.1-70b-instruct-fp8": { + "input_cost_per_token": 1.2e-07, + "litellm_provider": "lambda_ai", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 3e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "lambda_ai/llama3.1-8b-instruct": { + "input_cost_per_token": 2.5e-08, + "litellm_provider": "lambda_ai", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 4e-08, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "lambda_ai/llama3.1-nemotron-70b-instruct-fp8": { + "input_cost_per_token": 1.2e-07, + "litellm_provider": "lambda_ai", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 3e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "lambda_ai/llama3.2-11b-vision-instruct": { + "input_cost_per_token": 1.5e-08, + "litellm_provider": "lambda_ai", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 2.5e-08, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "lambda_ai/llama3.2-3b-instruct": { + "input_cost_per_token": 1.5e-08, + "litellm_provider": "lambda_ai", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 2.5e-08, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "lambda_ai/llama3.3-70b-instruct-fp8": { + "input_cost_per_token": 1.2e-07, + "litellm_provider": "lambda_ai", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 3e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "lambda_ai/qwen25-coder-32b-instruct": { + "input_cost_per_token": 5e-08, + "litellm_provider": "lambda_ai", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "lambda_ai/qwen3-32b-fp8": { + "input_cost_per_token": 5e-08, + "litellm_provider": "lambda_ai", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_reasoning": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "low/1024-x-1024/gpt-image-1": { + "input_cost_per_pixel": 1.0490417e-08, + "litellm_provider": "openai", + "mode": "image_generation", + "output_cost_per_pixel": 0.0, + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "low/1024-x-1536/gpt-image-1": { + "input_cost_per_pixel": 1.0172526e-08, + "litellm_provider": "openai", + "mode": "image_generation", + "output_cost_per_pixel": 0.0, + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "low/1536-x-1024/gpt-image-1": { + "input_cost_per_pixel": 1.0172526e-08, + "litellm_provider": "openai", + "mode": "image_generation", + "output_cost_per_pixel": 0.0, + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "luminous-base": { + "input_cost_per_token": 3e-05, + "litellm_provider": "aleph_alpha", + "max_tokens": 2048, + "mode": "completion", + "output_cost_per_token": 3.3e-05 + }, + "luminous-base-control": { + "input_cost_per_token": 3.75e-05, + "litellm_provider": "aleph_alpha", + "max_tokens": 2048, + "mode": "chat", + "output_cost_per_token": 4.125e-05 + }, + "luminous-extended": { + "input_cost_per_token": 4.5e-05, + "litellm_provider": "aleph_alpha", + "max_tokens": 2048, + "mode": "completion", + "output_cost_per_token": 4.95e-05 + }, + "luminous-extended-control": { + "input_cost_per_token": 5.625e-05, + "litellm_provider": "aleph_alpha", + "max_tokens": 2048, + "mode": "chat", + "output_cost_per_token": 6.1875e-05 + }, + "luminous-supreme": { + "input_cost_per_token": 0.000175, + "litellm_provider": "aleph_alpha", + "max_tokens": 2048, + "mode": "completion", + "output_cost_per_token": 0.0001925 + }, + "luminous-supreme-control": { + "input_cost_per_token": 0.00021875, + "litellm_provider": "aleph_alpha", + "max_tokens": 2048, + "mode": "chat", + "output_cost_per_token": 0.000240625 + }, + "max-x-max/50-steps/stability.stable-diffusion-xl-v0": { + "litellm_provider": "bedrock", + "max_input_tokens": 77, + "max_tokens": 77, + "mode": "image_generation", + "output_cost_per_image": 0.036 + }, + "max-x-max/max-steps/stability.stable-diffusion-xl-v0": { + "litellm_provider": "bedrock", + "max_input_tokens": 77, + "max_tokens": 77, + "mode": "image_generation", + "output_cost_per_image": 0.072 + }, + "medium/1024-x-1024/gpt-image-1": { + "input_cost_per_pixel": 4.0054321e-08, + "litellm_provider": "openai", + "mode": "image_generation", + "output_cost_per_pixel": 0.0, + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "medium/1024-x-1536/gpt-image-1": { + "input_cost_per_pixel": 4.0054321e-08, + "litellm_provider": "openai", + "mode": "image_generation", + "output_cost_per_pixel": 0.0, + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "medium/1536-x-1024/gpt-image-1": { + "input_cost_per_pixel": 4.0054321e-08, + "litellm_provider": "openai", + "mode": "image_generation", + "output_cost_per_pixel": 0.0, + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "medlm-large": { + "input_cost_per_character": 5e-06, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 8192, + "max_output_tokens": 1024, + "max_tokens": 1024, + "mode": "chat", + "output_cost_per_character": 1.5e-05, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_tool_choice": true + }, + "medlm-medium": { + "input_cost_per_character": 5e-07, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 32768, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_character": 1e-06, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_tool_choice": true + }, + "meta.llama2-13b-chat-v1": { + "input_cost_per_token": 7.5e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1e-06 + }, + "meta.llama2-70b-chat-v1": { + "input_cost_per_token": 1.95e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 2.56e-06 + }, + "meta.llama3-1-405b-instruct-v1:0": { + "input_cost_per_token": 5.32e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.6e-05, + "supports_function_calling": true, + "supports_tool_choice": false + }, + "meta.llama3-1-70b-instruct-v1:0": { + "input_cost_per_token": 9.9e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 128000, + "max_output_tokens": 2048, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 9.9e-07, + "supports_function_calling": true, + "supports_tool_choice": false + }, + "meta.llama3-1-8b-instruct-v1:0": { + "input_cost_per_token": 2.2e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 128000, + "max_output_tokens": 2048, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.2e-07, + "supports_function_calling": true, + "supports_tool_choice": false + }, + "meta.llama3-2-11b-instruct-v1:0": { + "input_cost_per_token": 3.5e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3.5e-07, + "supports_function_calling": true, + "supports_tool_choice": false, + "supports_vision": true + }, + "meta.llama3-2-1b-instruct-v1:0": { + "input_cost_per_token": 1e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1e-07, + "supports_function_calling": true, + "supports_tool_choice": false + }, + "meta.llama3-2-3b-instruct-v1:0": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.5e-07, + "supports_function_calling": true, + "supports_tool_choice": false + }, + "meta.llama3-2-90b-instruct-v1:0": { + "input_cost_per_token": 2e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2e-06, + "supports_function_calling": true, + "supports_tool_choice": false, + "supports_vision": true + }, + "meta.llama3-3-70b-instruct-v1:0": { + "input_cost_per_token": 7.2e-07, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 7.2e-07, + "supports_function_calling": true, + "supports_tool_choice": false + }, + "meta.llama3-70b-instruct-v1:0": { + "input_cost_per_token": 2.65e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 3.5e-06 + }, + "meta.llama3-8b-instruct-v1:0": { + "input_cost_per_token": 3e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 6e-07 + }, + "meta.llama4-maverick-17b-instruct-v1:0": { + "input_cost_per_token": 2.4e-07, + "input_cost_per_token_batches": 1.2e-07, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 9.7e-07, + "output_cost_per_token_batches": 4.85e-07, + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text", + "code" + ], + "supports_function_calling": true, + "supports_tool_choice": false + }, + "meta.llama4-scout-17b-instruct-v1:0": { + "input_cost_per_token": 1.7e-07, + "input_cost_per_token_batches": 8.5e-08, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 6.6e-07, + "output_cost_per_token_batches": 3.3e-07, + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text", + "code" + ], + "supports_function_calling": true, + "supports_tool_choice": false + }, + "meta_llama/Llama-3.3-70B-Instruct": { + "litellm_provider": "meta_llama", + "max_input_tokens": 128000, + "max_output_tokens": 4028, + "max_tokens": 128000, + "mode": "chat", + "source": "https://llama.developer.meta.com/docs/models", + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_tool_choice": true + }, + "meta_llama/Llama-3.3-8B-Instruct": { + "litellm_provider": "meta_llama", + "max_input_tokens": 128000, + "max_output_tokens": 4028, + "max_tokens": 128000, + "mode": "chat", + "source": "https://llama.developer.meta.com/docs/models", + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_tool_choice": true + }, + "meta_llama/Llama-4-Maverick-17B-128E-Instruct-FP8": { + "litellm_provider": "meta_llama", + "max_input_tokens": 1000000, + "max_output_tokens": 4028, + "max_tokens": 128000, + "mode": "chat", + "source": "https://llama.developer.meta.com/docs/models", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_tool_choice": true + }, + "meta_llama/Llama-4-Scout-17B-16E-Instruct-FP8": { + "litellm_provider": "meta_llama", + "max_input_tokens": 10000000, + "max_output_tokens": 4028, + "max_tokens": 128000, + "mode": "chat", + "source": "https://llama.developer.meta.com/docs/models", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_tool_choice": true + }, + "mistral.mistral-7b-instruct-v0:2": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 32000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 2e-07, + "supports_tool_choice": true + }, + "mistral.mistral-large-2402-v1:0": { + "input_cost_per_token": 8e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 32000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 2.4e-05, + "supports_function_calling": true + }, + "mistral.mistral-large-2407-v1:0": { + "input_cost_per_token": 3e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 128000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 9e-06, + "supports_function_calling": true, + "supports_tool_choice": true + }, + "mistral.mistral-small-2402-v1:0": { + "input_cost_per_token": 1e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 32000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 3e-06, + "supports_function_calling": true + }, + "mistral.mixtral-8x7b-instruct-v0:1": { + "input_cost_per_token": 4.5e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 32000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 7e-07, + "supports_tool_choice": true + }, + "mistral/codestral-2405": { + "input_cost_per_token": 1e-06, + "litellm_provider": "mistral", + "max_input_tokens": 32000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 3e-06, + "supports_assistant_prefill": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "mistral/codestral-latest": { + "input_cost_per_token": 1e-06, + "litellm_provider": "mistral", + "max_input_tokens": 32000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 3e-06, + "supports_assistant_prefill": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "mistral/codestral-mamba-latest": { + "input_cost_per_token": 2.5e-07, + "litellm_provider": "mistral", + "max_input_tokens": 256000, + "max_output_tokens": 256000, + "max_tokens": 256000, + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "source": "https://mistral.ai/technology/", + "supports_assistant_prefill": true, + "supports_tool_choice": true + }, + "mistral/devstral-medium-2507": { + "input_cost_per_token": 4e-07, + "litellm_provider": "mistral", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2e-06, + "source": "https://mistral.ai/news/devstral", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "mistral/devstral-small-2505": { + "input_cost_per_token": 1e-07, + "litellm_provider": "mistral", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3e-07, + "source": "https://mistral.ai/news/devstral", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "mistral/devstral-small-2507": { + "input_cost_per_token": 1e-07, + "litellm_provider": "mistral", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3e-07, + "source": "https://mistral.ai/news/devstral", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "mistral/magistral-medium-2506": { + "input_cost_per_token": 2e-06, + "litellm_provider": "mistral", + "max_input_tokens": 40000, + "max_output_tokens": 40000, + "max_tokens": 40000, + "mode": "chat", + "output_cost_per_token": 5e-06, + "source": "https://mistral.ai/news/magistral", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "mistral/magistral-medium-latest": { + "input_cost_per_token": 2e-06, + "litellm_provider": "mistral", + "max_input_tokens": 40000, + "max_output_tokens": 40000, + "max_tokens": 40000, + "mode": "chat", + "output_cost_per_token": 5e-06, + "source": "https://mistral.ai/news/magistral", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "mistral/magistral-small-2506": { + "input_cost_per_token": 5e-07, + "litellm_provider": "mistral", + "max_input_tokens": 40000, + "max_output_tokens": 40000, + "max_tokens": 40000, + "mode": "chat", + "output_cost_per_token": 1.5e-06, + "source": "https://mistral.ai/pricing#api-pricing", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "mistral/magistral-small-latest": { + "input_cost_per_token": 5e-07, + "litellm_provider": "mistral", + "max_input_tokens": 40000, + "max_output_tokens": 40000, + "max_tokens": 40000, + "mode": "chat", + "output_cost_per_token": 1.5e-06, + "source": "https://mistral.ai/pricing#api-pricing", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "mistral/mistral-embed": { + "input_cost_per_token": 1e-07, + "litellm_provider": "mistral", + "max_input_tokens": 8192, + "max_tokens": 8192, + "mode": "embedding" + }, + "mistral/mistral-large-2402": { + "input_cost_per_token": 4e-06, + "litellm_provider": "mistral", + "max_input_tokens": 32000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 1.2e-05, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "mistral/mistral-large-2407": { + "input_cost_per_token": 3e-06, + "litellm_provider": "mistral", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 9e-06, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "mistral/mistral-large-2411": { + "input_cost_per_token": 2e-06, + "litellm_provider": "mistral", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6e-06, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "mistral/mistral-large-latest": { + "input_cost_per_token": 2e-06, + "litellm_provider": "mistral", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6e-06, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "mistral/mistral-medium": { + "input_cost_per_token": 2.7e-06, + "litellm_provider": "mistral", + "max_input_tokens": 32000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 8.1e-06, + "supports_assistant_prefill": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "mistral/mistral-medium-2312": { + "input_cost_per_token": 2.7e-06, + "litellm_provider": "mistral", + "max_input_tokens": 32000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 8.1e-06, + "supports_assistant_prefill": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "mistral/mistral-medium-2505": { + "input_cost_per_token": 4e-07, + "litellm_provider": "mistral", + "max_input_tokens": 131072, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 2e-06, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "mistral/mistral-medium-latest": { + "input_cost_per_token": 4e-07, + "litellm_provider": "mistral", + "max_input_tokens": 131072, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 2e-06, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "mistral/mistral-small": { + "input_cost_per_token": 1e-07, + "litellm_provider": "mistral", + "max_input_tokens": 32000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 3e-07, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "mistral/mistral-small-latest": { + "input_cost_per_token": 1e-07, + "litellm_provider": "mistral", + "max_input_tokens": 32000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 3e-07, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "mistral/mistral-tiny": { + "input_cost_per_token": 2.5e-07, + "litellm_provider": "mistral", + "max_input_tokens": 32000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "supports_assistant_prefill": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "mistral/open-codestral-mamba": { + "input_cost_per_token": 2.5e-07, + "litellm_provider": "mistral", + "max_input_tokens": 256000, + "max_output_tokens": 256000, + "max_tokens": 256000, + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "source": "https://mistral.ai/technology/", + "supports_assistant_prefill": true, + "supports_tool_choice": true + }, + "mistral/open-mistral-7b": { + "input_cost_per_token": 2.5e-07, + "litellm_provider": "mistral", + "max_input_tokens": 32000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "supports_assistant_prefill": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "mistral/open-mistral-nemo": { + "input_cost_per_token": 3e-07, + "litellm_provider": "mistral", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3e-07, + "source": "https://mistral.ai/technology/", + "supports_assistant_prefill": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "mistral/open-mistral-nemo-2407": { + "input_cost_per_token": 3e-07, + "litellm_provider": "mistral", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3e-07, + "source": "https://mistral.ai/technology/", + "supports_assistant_prefill": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "mistral/open-mixtral-8x22b": { + "input_cost_per_token": 2e-06, + "litellm_provider": "mistral", + "max_input_tokens": 65336, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 6e-06, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "mistral/open-mixtral-8x7b": { + "input_cost_per_token": 7e-07, + "litellm_provider": "mistral", + "max_input_tokens": 32000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 7e-07, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "mistral/pixtral-12b-2409": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "mistral", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.5e-07, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "mistral/pixtral-large-2411": { + "input_cost_per_token": 2e-06, + "litellm_provider": "mistral", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6e-06, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "mistral/pixtral-large-latest": { + "input_cost_per_token": 2e-06, + "litellm_provider": "mistral", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6e-06, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "moonshot/kimi-k2-0711-preview": { + "cache_read_input_token_cost": 1.5e-07, + "input_cost_per_token": 6e-07, + "litellm_provider": "moonshot", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 2.5e-06, + "source": "https://platform.moonshot.ai/docs/pricing/chat#generation-model-kimi-k2", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_web_search": true + }, + "moonshot/kimi-latest": { + "cache_read_input_token_cost": 1.5e-07, + "input_cost_per_token": 2e-06, + "litellm_provider": "moonshot", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 5e-06, + "source": "https://platform.moonshot.ai/docs/pricing", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "moonshot/kimi-latest-128k": { + "cache_read_input_token_cost": 1.5e-07, + "input_cost_per_token": 2e-06, + "litellm_provider": "moonshot", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 5e-06, + "source": "https://platform.moonshot.ai/docs/pricing", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "moonshot/kimi-latest-32k": { + "cache_read_input_token_cost": 1.5e-07, + "input_cost_per_token": 1e-06, + "litellm_provider": "moonshot", + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 3e-06, + "source": "https://platform.moonshot.ai/docs/pricing", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "moonshot/kimi-latest-8k": { + "cache_read_input_token_cost": 1.5e-07, + "input_cost_per_token": 2e-07, + "litellm_provider": "moonshot", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 2e-06, + "source": "https://platform.moonshot.ai/docs/pricing", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "moonshot/kimi-thinking-preview": { + "input_cost_per_token": 3e-05, + "litellm_provider": "moonshot", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 3e-05, + "source": "https://platform.moonshot.ai/docs/pricing", + "supports_vision": true + }, + "moonshot/moonshot-v1-128k": { + "input_cost_per_token": 2e-06, + "litellm_provider": "moonshot", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 5e-06, + "source": "https://platform.moonshot.ai/docs/pricing", + "supports_function_calling": true, + "supports_tool_choice": true + }, + "moonshot/moonshot-v1-128k-0430": { + "input_cost_per_token": 2e-06, + "litellm_provider": "moonshot", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 5e-06, + "source": "https://platform.moonshot.ai/docs/pricing", + "supports_function_calling": true, + "supports_tool_choice": true + }, + "moonshot/moonshot-v1-128k-vision-preview": { + "input_cost_per_token": 2e-06, + "litellm_provider": "moonshot", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 5e-06, + "source": "https://platform.moonshot.ai/docs/pricing", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "moonshot/moonshot-v1-32k": { + "input_cost_per_token": 1e-06, + "litellm_provider": "moonshot", + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 3e-06, + "source": "https://platform.moonshot.ai/docs/pricing", + "supports_function_calling": true, + "supports_tool_choice": true + }, + "moonshot/moonshot-v1-32k-0430": { + "input_cost_per_token": 1e-06, + "litellm_provider": "moonshot", + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 3e-06, + "source": "https://platform.moonshot.ai/docs/pricing", + "supports_function_calling": true, + "supports_tool_choice": true + }, + "moonshot/moonshot-v1-32k-vision-preview": { + "input_cost_per_token": 1e-06, + "litellm_provider": "moonshot", + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 3e-06, + "source": "https://platform.moonshot.ai/docs/pricing", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "moonshot/moonshot-v1-8k": { + "input_cost_per_token": 2e-07, + "litellm_provider": "moonshot", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 2e-06, + "source": "https://platform.moonshot.ai/docs/pricing", + "supports_function_calling": true, + "supports_tool_choice": true + }, + "moonshot/moonshot-v1-8k-0430": { + "input_cost_per_token": 2e-07, + "litellm_provider": "moonshot", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 2e-06, + "source": "https://platform.moonshot.ai/docs/pricing", + "supports_function_calling": true, + "supports_tool_choice": true + }, + "moonshot/moonshot-v1-8k-vision-preview": { + "input_cost_per_token": 2e-07, + "litellm_provider": "moonshot", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 2e-06, + "source": "https://platform.moonshot.ai/docs/pricing", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "moonshot/moonshot-v1-auto": { + "input_cost_per_token": 2e-06, + "litellm_provider": "moonshot", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 5e-06, + "source": "https://platform.moonshot.ai/docs/pricing", + "supports_function_calling": true, + "supports_tool_choice": true + }, + "morph/morph-v3-fast": { + "input_cost_per_token": 8e-07, + "litellm_provider": "morph", + "max_input_tokens": 16000, + "max_output_tokens": 16000, + "max_tokens": 16000, + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "supports_function_calling": false, + "supports_parallel_function_calling": false, + "supports_system_messages": true, + "supports_tool_choice": false, + "supports_vision": false + }, + "morph/morph-v3-large": { + "input_cost_per_token": 9e-07, + "litellm_provider": "morph", + "max_input_tokens": 16000, + "max_output_tokens": 16000, + "max_tokens": 16000, + "mode": "chat", + "output_cost_per_token": 1.9e-06, + "supports_function_calling": false, + "supports_parallel_function_calling": false, + "supports_system_messages": true, + "supports_tool_choice": false, + "supports_vision": false + }, + "multimodalembedding": { + "input_cost_per_character": 2e-07, + "input_cost_per_image": 0.0001, + "input_cost_per_token": 8e-07, + "input_cost_per_video_per_second": 0.0005, + "input_cost_per_video_per_second_above_15s_interval": 0.002, + "input_cost_per_video_per_second_above_8s_interval": 0.001, + "litellm_provider": "vertex_ai-embedding-models", + "max_input_tokens": 2048, + "max_tokens": 2048, + "mode": "embedding", + "output_cost_per_token": 0, + "output_vector_size": 768, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models", + "supported_endpoints": [ + "/v1/embeddings" + ], + "supported_modalities": [ + "text", + "image", + "video" + ] + }, + "multimodalembedding@001": { + "input_cost_per_character": 2e-07, + "input_cost_per_image": 0.0001, + "input_cost_per_token": 8e-07, + "input_cost_per_video_per_second": 0.0005, + "input_cost_per_video_per_second_above_15s_interval": 0.002, + "input_cost_per_video_per_second_above_8s_interval": 0.001, + "litellm_provider": "vertex_ai-embedding-models", + "max_input_tokens": 2048, + "max_tokens": 2048, + "mode": "embedding", + "output_cost_per_token": 0, + "output_vector_size": 768, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models", + "supported_endpoints": [ + "/v1/embeddings" + ], + "supported_modalities": [ + "text", + "image", + "video" + ] + }, + "nscale/Qwen/QwQ-32B": { + "input_cost_per_token": 1.8e-07, + "litellm_provider": "nscale", + "mode": "chat", + "output_cost_per_token": 2e-07, + "source": "https://docs.nscale.com/docs/inference/serverless-models/current#chat-models" + }, + "nscale/Qwen/Qwen2.5-Coder-32B-Instruct": { + "input_cost_per_token": 6e-08, + "litellm_provider": "nscale", + "mode": "chat", + "output_cost_per_token": 2e-07, + "source": "https://docs.nscale.com/docs/inference/serverless-models/current#chat-models" + }, + "nscale/Qwen/Qwen2.5-Coder-3B-Instruct": { + "input_cost_per_token": 1e-08, + "litellm_provider": "nscale", + "mode": "chat", + "output_cost_per_token": 3e-08, + "source": "https://docs.nscale.com/docs/inference/serverless-models/current#chat-models" + }, + "nscale/Qwen/Qwen2.5-Coder-7B-Instruct": { + "input_cost_per_token": 1e-08, + "litellm_provider": "nscale", + "mode": "chat", + "output_cost_per_token": 3e-08, + "source": "https://docs.nscale.com/docs/inference/serverless-models/current#chat-models" + }, + "nscale/black-forest-labs/FLUX.1-schnell": { + "input_cost_per_pixel": 1.3e-09, + "litellm_provider": "nscale", + "mode": "image_generation", + "output_cost_per_pixel": 0.0, + "source": "https://docs.nscale.com/docs/inference/serverless-models/current#image-models", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "nscale/deepseek-ai/DeepSeek-R1-Distill-Llama-70B": { + "input_cost_per_token": 3.75e-07, + "litellm_provider": "nscale", + "metadata": { + "notes": "Pricing listed as $0.75/1M tokens total. Assumed 50/50 split for input/output." + }, + "mode": "chat", + "output_cost_per_token": 3.75e-07, + "source": "https://docs.nscale.com/docs/inference/serverless-models/current#chat-models" + }, + "nscale/deepseek-ai/DeepSeek-R1-Distill-Llama-8B": { + "input_cost_per_token": 2.5e-08, + "litellm_provider": "nscale", + "metadata": { + "notes": "Pricing listed as $0.05/1M tokens total. Assumed 50/50 split for input/output." + }, + "mode": "chat", + "output_cost_per_token": 2.5e-08, + "source": "https://docs.nscale.com/docs/inference/serverless-models/current#chat-models" + }, + "nscale/deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B": { + "input_cost_per_token": 9e-08, + "litellm_provider": "nscale", + "metadata": { + "notes": "Pricing listed as $0.18/1M tokens total. Assumed 50/50 split for input/output." + }, + "mode": "chat", + "output_cost_per_token": 9e-08, + "source": "https://docs.nscale.com/docs/inference/serverless-models/current#chat-models" + }, + "nscale/deepseek-ai/DeepSeek-R1-Distill-Qwen-14B": { + "input_cost_per_token": 7e-08, + "litellm_provider": "nscale", + "metadata": { + "notes": "Pricing listed as $0.14/1M tokens total. Assumed 50/50 split for input/output." + }, + "mode": "chat", + "output_cost_per_token": 7e-08, + "source": "https://docs.nscale.com/docs/inference/serverless-models/current#chat-models" + }, + "nscale/deepseek-ai/DeepSeek-R1-Distill-Qwen-32B": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "nscale", + "metadata": { + "notes": "Pricing listed as $0.30/1M tokens total. Assumed 50/50 split for input/output." + }, + "mode": "chat", + "output_cost_per_token": 1.5e-07, + "source": "https://docs.nscale.com/docs/inference/serverless-models/current#chat-models" + }, + "nscale/deepseek-ai/DeepSeek-R1-Distill-Qwen-7B": { + "input_cost_per_token": 2e-07, + "litellm_provider": "nscale", + "metadata": { + "notes": "Pricing listed as $0.40/1M tokens total. Assumed 50/50 split for input/output." + }, + "mode": "chat", + "output_cost_per_token": 2e-07, + "source": "https://docs.nscale.com/docs/inference/serverless-models/current#chat-models" + }, + "nscale/meta-llama/Llama-3.1-8B-Instruct": { + "input_cost_per_token": 3e-08, + "litellm_provider": "nscale", + "metadata": { + "notes": "Pricing listed as $0.06/1M tokens total. Assumed 50/50 split for input/output." + }, + "mode": "chat", + "output_cost_per_token": 3e-08, + "source": "https://docs.nscale.com/docs/inference/serverless-models/current#chat-models" + }, + "nscale/meta-llama/Llama-3.3-70B-Instruct": { + "input_cost_per_token": 2e-07, + "litellm_provider": "nscale", + "metadata": { + "notes": "Pricing listed as $0.40/1M tokens total. Assumed 50/50 split for input/output." + }, + "mode": "chat", + "output_cost_per_token": 2e-07, + "source": "https://docs.nscale.com/docs/inference/serverless-models/current#chat-models" + }, + "nscale/meta-llama/Llama-4-Scout-17B-16E-Instruct": { + "input_cost_per_token": 9e-08, + "litellm_provider": "nscale", + "mode": "chat", + "output_cost_per_token": 2.9e-07, + "source": "https://docs.nscale.com/docs/inference/serverless-models/current#chat-models" + }, + "nscale/mistralai/mixtral-8x22b-instruct-v0.1": { + "input_cost_per_token": 6e-07, + "litellm_provider": "nscale", + "metadata": { + "notes": "Pricing listed as $1.20/1M tokens total. Assumed 50/50 split for input/output." + }, + "mode": "chat", + "output_cost_per_token": 6e-07, + "source": "https://docs.nscale.com/docs/inference/serverless-models/current#chat-models" + }, + "nscale/stabilityai/stable-diffusion-xl-base-1.0": { + "input_cost_per_pixel": 3e-09, + "litellm_provider": "nscale", + "mode": "image_generation", + "output_cost_per_pixel": 0.0, + "source": "https://docs.nscale.com/docs/inference/serverless-models/current#image-models", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "o1": { + "cache_read_input_token_cost": 7.5e-06, + "input_cost_per_token": 1.5e-05, + "litellm_provider": "openai", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "chat", + "output_cost_per_token": 6e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "o1-2024-12-17": { + "cache_read_input_token_cost": 7.5e-06, + "input_cost_per_token": 1.5e-05, + "litellm_provider": "openai", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "chat", + "output_cost_per_token": 6e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "o1-mini": { + "cache_read_input_token_cost": 5.5e-07, + "input_cost_per_token": 1.1e-06, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 4.4e-06, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_vision": true + }, + "o1-mini-2024-09-12": { + "cache_read_input_token_cost": 1.5e-06, + "input_cost_per_token": 3e-06, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 1.2e-05, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_vision": true + }, + "o1-preview": { + "cache_read_input_token_cost": 7.5e-06, + "input_cost_per_token": 1.5e-05, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 6e-05, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_vision": true + }, + "o1-preview-2024-09-12": { + "cache_read_input_token_cost": 7.5e-06, + "input_cost_per_token": 1.5e-05, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 6e-05, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_vision": true + }, + "o1-pro": { + "input_cost_per_token": 0.00015, + "input_cost_per_token_batches": 7.5e-05, + "litellm_provider": "openai", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "responses", + "output_cost_per_token": 0.0006, + "output_cost_per_token_batches": 0.0003, + "supported_endpoints": [ + "/v1/responses", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": false, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "o1-pro-2025-03-19": { + "input_cost_per_token": 0.00015, + "input_cost_per_token_batches": 7.5e-05, + "litellm_provider": "openai", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "responses", + "output_cost_per_token": 0.0006, + "output_cost_per_token_batches": 0.0003, + "supported_endpoints": [ + "/v1/responses", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": false, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "o3": { + "cache_read_input_token_cost": 5e-07, + "cache_read_input_token_cost_flex": 2.5e-07, + "cache_read_input_token_cost_priority": 8.75e-07, + "input_cost_per_token": 2e-06, + "input_cost_per_token_flex": 1e-06, + "input_cost_per_token_priority": 3.5e-06, + "litellm_provider": "openai", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "chat", + "output_cost_per_token": 8e-06, + "output_cost_per_token_flex": 4e-06, + "output_cost_per_token_priority": 1.4e-05, + "supported_endpoints": [ + "/v1/responses", + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": false, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "o3-2025-04-16": { + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token": 2e-06, + "litellm_provider": "openai", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "chat", + "output_cost_per_token": 8e-06, + "supported_endpoints": [ + "/v1/responses", + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": false, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "o3-deep-research": { + "cache_read_input_token_cost": 2.5e-06, + "input_cost_per_token": 1e-05, + "input_cost_per_token_batches": 5e-06, + "litellm_provider": "openai", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "responses", + "output_cost_per_token": 4e-05, + "output_cost_per_token_batches": 2e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "o3-deep-research-2025-06-26": { + "cache_read_input_token_cost": 2.5e-06, + "input_cost_per_token": 1e-05, + "input_cost_per_token_batches": 5e-06, + "litellm_provider": "openai", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "responses", + "output_cost_per_token": 4e-05, + "output_cost_per_token_batches": 2e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "o3-mini": { + "cache_read_input_token_cost": 5.5e-07, + "input_cost_per_token": 1.1e-06, + "litellm_provider": "openai", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "chat", + "output_cost_per_token": 4.4e-06, + "supports_function_calling": true, + "supports_parallel_function_calling": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "o3-mini-2025-01-31": { + "cache_read_input_token_cost": 5.5e-07, + "input_cost_per_token": 1.1e-06, + "litellm_provider": "openai", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "chat", + "output_cost_per_token": 4.4e-06, + "supports_function_calling": true, + "supports_parallel_function_calling": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "o3-pro": { + "input_cost_per_token": 2e-05, + "input_cost_per_token_batches": 1e-05, + "litellm_provider": "openai", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "responses", + "output_cost_per_token": 8e-05, + "output_cost_per_token_batches": 4e-05, + "supported_endpoints": [ + "/v1/responses", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": false, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "o3-pro-2025-06-10": { + "input_cost_per_token": 2e-05, + "input_cost_per_token_batches": 1e-05, + "litellm_provider": "openai", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "responses", + "output_cost_per_token": 8e-05, + "output_cost_per_token_batches": 4e-05, + "supported_endpoints": [ + "/v1/responses", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": false, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "o4-mini": { + "cache_read_input_token_cost": 2.75e-07, + "cache_read_input_token_cost_flex": 1.375e-07, + "cache_read_input_token_cost_priority": 5e-07, + "input_cost_per_token": 1.1e-06, + "input_cost_per_token_flex": 5.5e-07, + "input_cost_per_token_priority": 2e-06, + "litellm_provider": "openai", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "chat", + "output_cost_per_token": 4.4e-06, + "output_cost_per_token_flex": 2.2e-06, + "output_cost_per_token_priority": 8e-06, + "supports_function_calling": true, + "supports_parallel_function_calling": false, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "o4-mini-2025-04-16": { + "cache_read_input_token_cost": 2.75e-07, + "input_cost_per_token": 1.1e-06, + "litellm_provider": "openai", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "chat", + "output_cost_per_token": 4.4e-06, + "supports_function_calling": true, + "supports_parallel_function_calling": false, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "o4-mini-deep-research": { + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token": 2e-06, + "input_cost_per_token_batches": 1e-06, + "litellm_provider": "openai", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "responses", + "output_cost_per_token": 8e-06, + "output_cost_per_token_batches": 4e-06, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "o4-mini-deep-research-2025-06-26": { + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token": 2e-06, + "input_cost_per_token_batches": 1e-06, + "litellm_provider": "openai", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "responses", + "output_cost_per_token": 8e-06, + "output_cost_per_token_batches": 4e-06, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "oci/meta.llama-3.1-405b-instruct": { + "input_cost_per_token": 1.068e-05, + "litellm_provider": "oci", + "max_input_tokens": 128000, + "max_output_tokens": 4000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.068e-05, + "source": "https://www.oracle.com/artificial-intelligence/generative-ai/generative-ai-service/pricing", + "supports_function_calling": true, + "supports_response_schema": false + }, + "oci/meta.llama-3.2-90b-vision-instruct": { + "input_cost_per_token": 2e-06, + "litellm_provider": "oci", + "max_input_tokens": 128000, + "max_output_tokens": 4000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2e-06, + "source": "https://www.oracle.com/artificial-intelligence/generative-ai/generative-ai-service/pricing", + "supports_function_calling": true, + "supports_response_schema": false + }, + "oci/meta.llama-3.3-70b-instruct": { + "input_cost_per_token": 7.2e-07, + "litellm_provider": "oci", + "max_input_tokens": 128000, + "max_output_tokens": 4000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 7.2e-07, + "source": "https://www.oracle.com/artificial-intelligence/generative-ai/generative-ai-service/pricing", + "supports_function_calling": true, + "supports_response_schema": false + }, + "oci/meta.llama-4-maverick-17b-128e-instruct-fp8": { + "input_cost_per_token": 7.2e-07, + "litellm_provider": "oci", + "max_input_tokens": 512000, + "max_output_tokens": 4000, + "max_tokens": 512000, + "mode": "chat", + "output_cost_per_token": 7.2e-07, + "source": "https://www.oracle.com/artificial-intelligence/generative-ai/generative-ai-service/pricing", + "supports_function_calling": true, + "supports_response_schema": false + }, + "oci/meta.llama-4-scout-17b-16e-instruct": { + "input_cost_per_token": 7.2e-07, + "litellm_provider": "oci", + "max_input_tokens": 192000, + "max_output_tokens": 4000, + "max_tokens": 192000, + "mode": "chat", + "output_cost_per_token": 7.2e-07, + "source": "https://www.oracle.com/artificial-intelligence/generative-ai/generative-ai-service/pricing", + "supports_function_calling": true, + "supports_response_schema": false + }, + "oci/xai.grok-3": { + "input_cost_per_token": 3e-06, + "litellm_provider": "oci", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1.5e-07, + "source": "https://www.oracle.com/artificial-intelligence/generative-ai/generative-ai-service/pricing", + "supports_function_calling": true, + "supports_response_schema": false + }, + "oci/xai.grok-3-fast": { + "input_cost_per_token": 5e-06, + "litellm_provider": "oci", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 2.5e-05, + "source": "https://www.oracle.com/artificial-intelligence/generative-ai/generative-ai-service/pricing", + "supports_function_calling": true, + "supports_response_schema": false + }, + "oci/xai.grok-3-mini": { + "input_cost_per_token": 3e-07, + "litellm_provider": "oci", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 5e-07, + "source": "https://www.oracle.com/artificial-intelligence/generative-ai/generative-ai-service/pricing", + "supports_function_calling": true, + "supports_response_schema": false + }, + "oci/xai.grok-3-mini-fast": { + "input_cost_per_token": 6e-07, + "litellm_provider": "oci", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 4e-06, + "source": "https://www.oracle.com/artificial-intelligence/generative-ai/generative-ai-service/pricing", + "supports_function_calling": true, + "supports_response_schema": false + }, + "oci/xai.grok-4": { + "input_cost_per_token": 3e-06, + "litellm_provider": "oci", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.5e-07, + "source": "https://www.oracle.com/artificial-intelligence/generative-ai/generative-ai-service/pricing", + "supports_function_calling": true, + "supports_response_schema": false + }, + "ollama/codegeex4": { + "input_cost_per_token": 0.0, + "litellm_provider": "ollama", + "max_input_tokens": 32768, + "max_output_tokens": 8192, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 0.0, + "supports_function_calling": false + }, + "ollama/codegemma": { + "input_cost_per_token": 0.0, + "litellm_provider": "ollama", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "completion", + "output_cost_per_token": 0.0 + }, + "ollama/codellama": { + "input_cost_per_token": 0.0, + "litellm_provider": "ollama", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "completion", + "output_cost_per_token": 0.0 + }, + "ollama/deepseek-coder-v2-base": { + "input_cost_per_token": 0.0, + "litellm_provider": "ollama", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "completion", + "output_cost_per_token": 0.0, + "supports_function_calling": true + }, + "ollama/deepseek-coder-v2-instruct": { + "input_cost_per_token": 0.0, + "litellm_provider": "ollama", + "max_input_tokens": 32768, + "max_output_tokens": 8192, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 0.0, + "supports_function_calling": true + }, + "ollama/deepseek-coder-v2-lite-base": { + "input_cost_per_token": 0.0, + "litellm_provider": "ollama", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "completion", + "output_cost_per_token": 0.0, + "supports_function_calling": true + }, + "ollama/deepseek-coder-v2-lite-instruct": { + "input_cost_per_token": 0.0, + "litellm_provider": "ollama", + "max_input_tokens": 32768, + "max_output_tokens": 8192, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 0.0, + "supports_function_calling": true + }, + "ollama/deepseek-v3.1:671b-cloud" : { + "input_cost_per_token": 0.0, + "litellm_provider": "ollama", + "max_input_tokens": 163840, + "max_output_tokens": 163840, + "max_tokens": 163840, + "mode": "chat", + "output_cost_per_token": 0.0, + "supports_function_calling": true + }, + "ollama/gpt-oss:120b-cloud" : { + "input_cost_per_token": 0.0, + "litellm_provider": "ollama", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 0.0, + "supports_function_calling": true + }, + "ollama/gpt-oss:20b-cloud" : { + "input_cost_per_token": 0.0, + "litellm_provider": "ollama", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 0.0, + "supports_function_calling": true + }, + "ollama/internlm2_5-20b-chat": { + "input_cost_per_token": 0.0, + "litellm_provider": "ollama", + "max_input_tokens": 32768, + "max_output_tokens": 8192, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 0.0, + "supports_function_calling": true + }, + "ollama/llama2": { + "input_cost_per_token": 0.0, + "litellm_provider": "ollama", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.0 + }, + "ollama/llama2-uncensored": { + "input_cost_per_token": 0.0, + "litellm_provider": "ollama", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "completion", + "output_cost_per_token": 0.0 + }, + "ollama/llama2:13b": { + "input_cost_per_token": 0.0, + "litellm_provider": "ollama", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.0 + }, + "ollama/llama2:70b": { + "input_cost_per_token": 0.0, + "litellm_provider": "ollama", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.0 + }, + "ollama/llama2:7b": { + "input_cost_per_token": 0.0, + "litellm_provider": "ollama", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.0 + }, + "ollama/llama3": { + "input_cost_per_token": 0.0, + "litellm_provider": "ollama", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.0 + }, + "ollama/llama3.1": { + "input_cost_per_token": 0.0, + "litellm_provider": "ollama", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 0.0, + "supports_function_calling": true + }, + "ollama/llama3:70b": { + "input_cost_per_token": 0.0, + "litellm_provider": "ollama", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.0 + }, + "ollama/llama3:8b": { + "input_cost_per_token": 0.0, + "litellm_provider": "ollama", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.0 + }, + "ollama/mistral": { + "input_cost_per_token": 0.0, + "litellm_provider": "ollama", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "completion", + "output_cost_per_token": 0.0, + "supports_function_calling": true + }, + "ollama/mistral-7B-Instruct-v0.1": { + "input_cost_per_token": 0.0, + "litellm_provider": "ollama", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.0, + "supports_function_calling": true + }, + "ollama/mistral-7B-Instruct-v0.2": { + "input_cost_per_token": 0.0, + "litellm_provider": "ollama", + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 0.0, + "supports_function_calling": true + }, + "ollama/mistral-large-instruct-2407": { + "input_cost_per_token": 0.0, + "litellm_provider": "ollama", + "max_input_tokens": 65536, + "max_output_tokens": 8192, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 0.0, + "supports_function_calling": true + }, + "ollama/mixtral-8x22B-Instruct-v0.1": { + "input_cost_per_token": 0.0, + "litellm_provider": "ollama", + "max_input_tokens": 65536, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 0.0, + "supports_function_calling": true + }, + "ollama/mixtral-8x7B-Instruct-v0.1": { + "input_cost_per_token": 0.0, + "litellm_provider": "ollama", + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 0.0, + "supports_function_calling": true + }, + "ollama/orca-mini": { + "input_cost_per_token": 0.0, + "litellm_provider": "ollama", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "completion", + "output_cost_per_token": 0.0 + }, + "ollama/qwen3-coder:480b-cloud": { + "input_cost_per_token": 0.0, + "litellm_provider": "ollama", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 0.0, + "supports_function_calling": true + }, + "ollama/vicuna": { + "input_cost_per_token": 0.0, + "litellm_provider": "ollama", + "max_input_tokens": 2048, + "max_output_tokens": 2048, + "max_tokens": 2048, + "mode": "completion", + "output_cost_per_token": 0.0 + }, + "omni-moderation-2024-09-26": { + "input_cost_per_token": 0.0, + "litellm_provider": "openai", + "max_input_tokens": 32768, + "max_output_tokens": 0, + "max_tokens": 32768, + "mode": "moderation", + "output_cost_per_token": 0.0 + }, + "omni-moderation-latest": { + "input_cost_per_token": 0.0, + "litellm_provider": "openai", + "max_input_tokens": 32768, + "max_output_tokens": 0, + "max_tokens": 32768, + "mode": "moderation", + "output_cost_per_token": 0.0 + }, + "omni-moderation-latest-intents": { + "input_cost_per_token": 0.0, + "litellm_provider": "openai", + "max_input_tokens": 32768, + "max_output_tokens": 0, + "max_tokens": 32768, + "mode": "moderation", + "output_cost_per_token": 0.0 + }, + "openai.gpt-oss-120b-1:0": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6e-07, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "openai.gpt-oss-20b-1:0": { + "input_cost_per_token": 7e-08, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3e-07, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "openrouter/anthropic/claude-2": { + "input_cost_per_token": 1.102e-05, + "litellm_provider": "openrouter", + "max_output_tokens": 8191, + "max_tokens": 100000, + "mode": "chat", + "output_cost_per_token": 3.268e-05, + "supports_tool_choice": true + }, + "openrouter/anthropic/claude-3-5-haiku": { + "input_cost_per_token": 1e-06, + "litellm_provider": "openrouter", + "max_tokens": 200000, + "mode": "chat", + "output_cost_per_token": 5e-06, + "supports_function_calling": true, + "supports_tool_choice": true + }, + "openrouter/anthropic/claude-3-5-haiku-20241022": { + "input_cost_per_token": 1e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 5e-06, + "supports_function_calling": true, + "supports_tool_choice": true, + "tool_use_system_prompt_tokens": 264 + }, + "openrouter/anthropic/claude-3-haiku": { + "input_cost_per_image": 0.0004, + "input_cost_per_token": 2.5e-07, + "litellm_provider": "openrouter", + "max_tokens": 200000, + "mode": "chat", + "output_cost_per_token": 1.25e-06, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/anthropic/claude-3-haiku-20240307": { + "input_cost_per_token": 2.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.25e-06, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 264 + }, + "openrouter/anthropic/claude-3-opus": { + "input_cost_per_token": 1.5e-05, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 7.5e-05, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 395 + }, + "openrouter/anthropic/claude-3-sonnet": { + "input_cost_per_image": 0.0048, + "input_cost_per_token": 3e-06, + "litellm_provider": "openrouter", + "max_tokens": 200000, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/anthropic/claude-3.5-sonnet": { + "input_cost_per_token": 3e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "openrouter/anthropic/claude-3.5-sonnet:beta": { + "input_cost_per_token": 3e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "openrouter/anthropic/claude-3.7-sonnet": { + "input_cost_per_image": 0.0048, + "input_cost_per_token": 3e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "openrouter/anthropic/claude-3.7-sonnet:beta": { + "input_cost_per_image": 0.0048, + "input_cost_per_token": 3e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "openrouter/anthropic/claude-instant-v1": { + "input_cost_per_token": 1.63e-06, + "litellm_provider": "openrouter", + "max_output_tokens": 8191, + "max_tokens": 100000, + "mode": "chat", + "output_cost_per_token": 5.51e-06, + "supports_tool_choice": true + }, + "openrouter/anthropic/claude-opus-4": { + "input_cost_per_image": 0.0048, + "input_cost_per_token": 1.5e-05, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 7.5e-05, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "openrouter/anthropic/claude-opus-4.1": { + "input_cost_per_image": 0.0048, + "input_cost_per_token": 1.5e-05, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 7.5e-05, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "openrouter/anthropic/claude-sonnet-4": { + "input_cost_per_image": 0.0048, + "input_cost_per_token": 3e-06, + "input_cost_per_token_above_200k_tokens": 6e-06, + "output_cost_per_token_above_200k_tokens": 2.25e-05, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "openrouter/bytedance/ui-tars-1.5-7b": { + "input_cost_per_token": 1e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 2048, + "max_tokens": 2048, + "mode": "chat", + "output_cost_per_token": 2e-07, + "source": "https://openrouter.ai/api/v1/models/bytedance/ui-tars-1.5-7b", + "supports_tool_choice": true + }, + "openrouter/cognitivecomputations/dolphin-mixtral-8x7b": { + "input_cost_per_token": 5e-07, + "litellm_provider": "openrouter", + "max_tokens": 32769, + "mode": "chat", + "output_cost_per_token": 5e-07, + "supports_tool_choice": true + }, + "openrouter/cohere/command-r-plus": { + "input_cost_per_token": 3e-06, + "litellm_provider": "openrouter", + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "supports_tool_choice": true + }, + "openrouter/databricks/dbrx-instruct": { + "input_cost_per_token": 6e-07, + "litellm_provider": "openrouter", + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 6e-07, + "supports_tool_choice": true + }, + "openrouter/deepseek/deepseek-chat": { + "input_cost_per_token": 1.4e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 65536, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 2.8e-07, + "supports_prompt_caching": true, + "supports_tool_choice": true + }, + "openrouter/deepseek/deepseek-chat-v3-0324": { + "input_cost_per_token": 1.4e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 65536, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 2.8e-07, + "supports_prompt_caching": true, + "supports_tool_choice": true + }, + "openrouter/deepseek/deepseek-chat-v3.1": { + "input_cost_per_token": 2e-07, + "input_cost_per_token_cache_hit": 2e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 163840, + "max_output_tokens": 163840, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 8e-07, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "openrouter/deepseek/deepseek-coder": { + "input_cost_per_token": 1.4e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 66000, + "max_output_tokens": 4096, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 2.8e-07, + "supports_prompt_caching": true, + "supports_tool_choice": true + }, + "openrouter/deepseek/deepseek-r1": { + "input_cost_per_token": 5.5e-07, + "input_cost_per_token_cache_hit": 1.4e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 65336, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 2.19e-06, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "openrouter/deepseek/deepseek-r1-0528": { + "input_cost_per_token": 5e-07, + "input_cost_per_token_cache_hit": 1.4e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 65336, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 2.15e-06, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "openrouter/fireworks/firellava-13b": { + "input_cost_per_token": 2e-07, + "litellm_provider": "openrouter", + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 2e-07, + "supports_tool_choice": true + }, + "openrouter/google/gemini-2.0-flash-001": { + "input_cost_per_audio_token": 7e-07, + "input_cost_per_token": 1e-07, + "litellm_provider": "openrouter", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 8192, + "max_pdf_size_mb": 30, + "max_tokens": 8192, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 4e-07, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/google/gemini-2.5-flash": { + "input_cost_per_audio_token": 7e-07, + "input_cost_per_token": 3e-07, + "litellm_provider": "openrouter", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 8192, + "max_pdf_size_mb": 30, + "max_tokens": 8192, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 2.5e-06, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/google/gemini-2.5-pro": { + "input_cost_per_audio_token": 7e-07, + "input_cost_per_token": 1.25e-06, + "litellm_provider": "openrouter", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 8192, + "max_pdf_size_mb": 30, + "max_tokens": 8192, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 1e-05, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/google/gemini-pro-1.5": { + "input_cost_per_image": 0.00265, + "input_cost_per_token": 2.5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 7.5e-06, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/google/gemini-pro-vision": { + "input_cost_per_image": 0.0025, + "input_cost_per_token": 1.25e-07, + "litellm_provider": "openrouter", + "max_tokens": 45875, + "mode": "chat", + "output_cost_per_token": 3.75e-07, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/google/palm-2-chat-bison": { + "input_cost_per_token": 5e-07, + "litellm_provider": "openrouter", + "max_tokens": 25804, + "mode": "chat", + "output_cost_per_token": 5e-07, + "supports_tool_choice": true + }, + "openrouter/google/palm-2-codechat-bison": { + "input_cost_per_token": 5e-07, + "litellm_provider": "openrouter", + "max_tokens": 20070, + "mode": "chat", + "output_cost_per_token": 5e-07, + "supports_tool_choice": true + }, + "openrouter/gryphe/mythomax-l2-13b": { + "input_cost_per_token": 1.875e-06, + "litellm_provider": "openrouter", + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.875e-06, + "supports_tool_choice": true + }, + "openrouter/jondurbin/airoboros-l2-70b-2.1": { + "input_cost_per_token": 1.3875e-05, + "litellm_provider": "openrouter", + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.3875e-05, + "supports_tool_choice": true + }, + "openrouter/mancer/weaver": { + "input_cost_per_token": 5.625e-06, + "litellm_provider": "openrouter", + "max_tokens": 8000, + "mode": "chat", + "output_cost_per_token": 5.625e-06, + "supports_tool_choice": true + }, + "openrouter/meta-llama/codellama-34b-instruct": { + "input_cost_per_token": 5e-07, + "litellm_provider": "openrouter", + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 5e-07, + "supports_tool_choice": true + }, + "openrouter/meta-llama/llama-2-13b-chat": { + "input_cost_per_token": 2e-07, + "litellm_provider": "openrouter", + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 2e-07, + "supports_tool_choice": true + }, + "openrouter/meta-llama/llama-2-70b-chat": { + "input_cost_per_token": 1.5e-06, + "litellm_provider": "openrouter", + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.5e-06, + "supports_tool_choice": true + }, + "openrouter/meta-llama/llama-3-70b-instruct": { + "input_cost_per_token": 5.9e-07, + "litellm_provider": "openrouter", + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 7.9e-07, + "supports_tool_choice": true + }, + "openrouter/meta-llama/llama-3-70b-instruct:nitro": { + "input_cost_per_token": 9e-07, + "litellm_provider": "openrouter", + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 9e-07, + "supports_tool_choice": true + }, + "openrouter/meta-llama/llama-3-8b-instruct:extended": { + "input_cost_per_token": 2.25e-07, + "litellm_provider": "openrouter", + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 2.25e-06, + "supports_tool_choice": true + }, + "openrouter/meta-llama/llama-3-8b-instruct:free": { + "input_cost_per_token": 0.0, + "litellm_provider": "openrouter", + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.0, + "supports_tool_choice": true + }, + "openrouter/microsoft/wizardlm-2-8x22b:nitro": { + "input_cost_per_token": 1e-06, + "litellm_provider": "openrouter", + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 1e-06, + "supports_tool_choice": true + }, + "openrouter/mistralai/mistral-7b-instruct": { + "input_cost_per_token": 1.3e-07, + "litellm_provider": "openrouter", + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.3e-07, + "supports_tool_choice": true + }, + "openrouter/mistralai/mistral-7b-instruct:free": { + "input_cost_per_token": 0.0, + "litellm_provider": "openrouter", + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.0, + "supports_tool_choice": true + }, + "openrouter/mistralai/mistral-large": { + "input_cost_per_token": 8e-06, + "litellm_provider": "openrouter", + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 2.4e-05, + "supports_tool_choice": true + }, + "openrouter/mistralai/mistral-small-3.1-24b-instruct": { + "input_cost_per_token": 1e-07, + "litellm_provider": "openrouter", + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 3e-07, + "supports_tool_choice": true + }, + "openrouter/mistralai/mistral-small-3.2-24b-instruct": { + "input_cost_per_token": 1e-07, + "litellm_provider": "openrouter", + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 3e-07, + "supports_tool_choice": true + }, + "openrouter/mistralai/mixtral-8x22b-instruct": { + "input_cost_per_token": 6.5e-07, + "litellm_provider": "openrouter", + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 6.5e-07, + "supports_tool_choice": true + }, + "openrouter/nousresearch/nous-hermes-llama2-13b": { + "input_cost_per_token": 2e-07, + "litellm_provider": "openrouter", + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 2e-07, + "supports_tool_choice": true + }, + "openrouter/openai/gpt-3.5-turbo": { + "input_cost_per_token": 1.5e-06, + "litellm_provider": "openrouter", + "max_tokens": 4095, + "mode": "chat", + "output_cost_per_token": 2e-06, + "supports_tool_choice": true + }, + "openrouter/openai/gpt-3.5-turbo-16k": { + "input_cost_per_token": 3e-06, + "litellm_provider": "openrouter", + "max_tokens": 16383, + "mode": "chat", + "output_cost_per_token": 4e-06, + "supports_tool_choice": true + }, + "openrouter/openai/gpt-4": { + "input_cost_per_token": 3e-05, + "litellm_provider": "openrouter", + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 6e-05, + "supports_tool_choice": true + }, + "openrouter/openai/gpt-4-vision-preview": { + "input_cost_per_image": 0.01445, + "input_cost_per_token": 1e-05, + "litellm_provider": "openrouter", + "max_tokens": 130000, + "mode": "chat", + "output_cost_per_token": 3e-05, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/openai/gpt-4.1": { + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token": 2e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1047576, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 8e-06, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/openai/gpt-4.1-2025-04-14": { + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token": 2e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1047576, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 8e-06, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/openai/gpt-4.1-mini": { + "cache_read_input_token_cost": 1e-07, + "input_cost_per_token": 4e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1047576, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 1.6e-06, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/openai/gpt-4.1-mini-2025-04-14": { + "cache_read_input_token_cost": 1e-07, + "input_cost_per_token": 4e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1047576, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 1.6e-06, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/openai/gpt-4.1-nano": { + "cache_read_input_token_cost": 2.5e-08, + "input_cost_per_token": 1e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1047576, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 4e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/openai/gpt-4.1-nano-2025-04-14": { + "cache_read_input_token_cost": 2.5e-08, + "input_cost_per_token": 1e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1047576, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 4e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/openai/gpt-4o": { + "input_cost_per_token": 2.5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/openai/gpt-4o-2024-05-13": { + "input_cost_per_token": 5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/openai/gpt-5-chat": { + "cache_read_input_token_cost": 1.25e-07, + "input_cost_per_token": 1.25e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1e-05, + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_reasoning": true, + "supports_tool_choice": true + }, + "openrouter/openai/gpt-5-codex": { + "cache_read_input_token_cost": 1.25e-07, + "input_cost_per_token": 1.25e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1e-05, + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_reasoning": true, + "supports_tool_choice": true + }, + "openrouter/openai/gpt-5": { + "cache_read_input_token_cost": 1.25e-07, + "input_cost_per_token": 1.25e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1e-05, + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_reasoning": true, + "supports_tool_choice": true + }, + "openrouter/openai/gpt-5-mini": { + "cache_read_input_token_cost": 2.5e-08, + "input_cost_per_token": 2.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2e-06, + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_reasoning": true, + "supports_tool_choice": true + }, + "openrouter/openai/gpt-5-nano": { + "cache_read_input_token_cost": 5e-09, + "input_cost_per_token": 5e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 4e-07, + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_reasoning": true, + "supports_tool_choice": true + }, + "openrouter/openai/gpt-oss-120b": { + "input_cost_per_token": 1.8e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 8e-07, + "source": "https://openrouter.ai/openai/gpt-oss-120b", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "openrouter/openai/gpt-oss-20b": { + "input_cost_per_token": 1.8e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 8e-07, + "source": "https://openrouter.ai/openai/gpt-oss-20b", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "openrouter/openai/o1": { + "cache_read_input_token_cost": 7.5e-06, + "input_cost_per_token": 1.5e-05, + "litellm_provider": "openrouter", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "chat", + "output_cost_per_token": 6e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/openai/o1-mini": { + "input_cost_per_token": 3e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 1.2e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "openrouter/openai/o1-mini-2024-09-12": { + "input_cost_per_token": 3e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 1.2e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "openrouter/openai/o1-preview": { + "input_cost_per_token": 1.5e-05, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 6e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "openrouter/openai/o1-preview-2024-09-12": { + "input_cost_per_token": 1.5e-05, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 6e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "openrouter/openai/o3-mini": { + "input_cost_per_token": 1.1e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 4.4e-06, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "openrouter/openai/o3-mini-high": { + "input_cost_per_token": 1.1e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 128000, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 4.4e-06, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "openrouter/pygmalionai/mythalion-13b": { + "input_cost_per_token": 1.875e-06, + "litellm_provider": "openrouter", + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.875e-06, + "supports_tool_choice": true + }, + "openrouter/qwen/qwen-2.5-coder-32b-instruct": { + "input_cost_per_token": 1.8e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 33792, + "max_output_tokens": 33792, + "max_tokens": 33792, + "mode": "chat", + "output_cost_per_token": 1.8e-07, + "supports_tool_choice": true + }, + "openrouter/qwen/qwen-vl-plus": { + "input_cost_per_token": 2.1e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 8192, + "max_output_tokens": 2048, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 6.3e-07, + "supports_tool_choice": true + }, + "openrouter/qwen/qwen3-coder": { + "input_cost_per_token": 1e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 1000000, + "max_tokens": 1000000, + "mode": "chat", + "output_cost_per_token": 5e-06, + "source": "https://openrouter.ai/qwen/qwen3-coder", + "supports_tool_choice": true + }, + "openrouter/switchpoint/router": { + "input_cost_per_token": 8.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 3.4e-06, + "source": "https://openrouter.ai/switchpoint/router", + "supports_tool_choice": true + }, + "openrouter/undi95/remm-slerp-l2-13b": { + "input_cost_per_token": 1.875e-06, + "litellm_provider": "openrouter", + "max_tokens": 6144, + "mode": "chat", + "output_cost_per_token": 1.875e-06, + "supports_tool_choice": true + }, + "openrouter/x-ai/grok-4": { + "input_cost_per_token": 3e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 256000, + "max_output_tokens": 256000, + "max_tokens": 256000, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "source": "https://openrouter.ai/x-ai/grok-4", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_web_search": true + }, + "openrouter/x-ai/grok-4-fast:free": { + "input_cost_per_token": 0, + "litellm_provider": "openrouter", + "max_input_tokens": 2000000, + "max_output_tokens": 30000, + "max_tokens": 2000000, + "mode": "chat", + "output_cost_per_token": 0, + "source": "https://openrouter.ai/x-ai/grok-4-fast:free", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_web_search": false + }, + "ovhcloud/DeepSeek-R1-Distill-Llama-70B": { + "input_cost_per_token": 6.7e-07, + "litellm_provider": "ovhcloud", + "max_input_tokens": 131000, + "max_output_tokens": 131000, + "max_tokens": 131000, + "mode": "chat", + "output_cost_per_token": 6.7e-07, + "source": "https://endpoints.ai.cloud.ovh.net/models/deepseek-r1-distill-llama-70b", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "ovhcloud/Llama-3.1-8B-Instruct": { + "input_cost_per_token": 1e-07, + "litellm_provider": "ovhcloud", + "max_input_tokens": 131000, + "max_output_tokens": 131000, + "max_tokens": 131000, + "mode": "chat", + "output_cost_per_token": 1e-07, + "source": "https://endpoints.ai.cloud.ovh.net/models/llama-3-1-8b-instruct", + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "ovhcloud/Meta-Llama-3_1-70B-Instruct": { + "input_cost_per_token": 6.7e-07, + "litellm_provider": "ovhcloud", + "max_input_tokens": 131000, + "max_output_tokens": 131000, + "max_tokens": 131000, + "mode": "chat", + "output_cost_per_token": 6.7e-07, + "source": "https://endpoints.ai.cloud.ovh.net/models/meta-llama-3-1-70b-instruct", + "supports_function_calling": false, + "supports_response_schema": false, + "supports_tool_choice": false + }, + "ovhcloud/Meta-Llama-3_3-70B-Instruct": { + "input_cost_per_token": 6.7e-07, + "litellm_provider": "ovhcloud", + "max_input_tokens": 131000, + "max_output_tokens": 131000, + "max_tokens": 131000, + "mode": "chat", + "output_cost_per_token": 6.7e-07, + "source": "https://endpoints.ai.cloud.ovh.net/models/meta-llama-3-3-70b-instruct", + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "ovhcloud/Mistral-7B-Instruct-v0.3": { + "input_cost_per_token": 1e-07, + "litellm_provider": "ovhcloud", + "max_input_tokens": 127000, + "max_output_tokens": 127000, + "max_tokens": 127000, + "mode": "chat", + "output_cost_per_token": 1e-07, + "source": "https://endpoints.ai.cloud.ovh.net/models/mistral-7b-instruct-v0-3", + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "ovhcloud/Mistral-Nemo-Instruct-2407": { + "input_cost_per_token": 1.3e-07, + "litellm_provider": "ovhcloud", + "max_input_tokens": 118000, + "max_output_tokens": 118000, + "max_tokens": 118000, + "mode": "chat", + "output_cost_per_token": 1.3e-07, + "source": "https://endpoints.ai.cloud.ovh.net/models/mistral-nemo-instruct-2407", + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "ovhcloud/Mistral-Small-3.2-24B-Instruct-2506": { + "input_cost_per_token": 9e-08, + "litellm_provider": "ovhcloud", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.8e-07, + "source": "https://endpoints.ai.cloud.ovh.net/models/mistral-small-3-2-24b-instruct-2506", + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "ovhcloud/Mixtral-8x7B-Instruct-v0.1": { + "input_cost_per_token": 6.3e-07, + "litellm_provider": "ovhcloud", + "max_input_tokens": 32000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 6.3e-07, + "source": "https://endpoints.ai.cloud.ovh.net/models/mixtral-8x7b-instruct-v0-1", + "supports_function_calling": false, + "supports_response_schema": true, + "supports_tool_choice": false + }, + "ovhcloud/Qwen2.5-Coder-32B-Instruct": { + "input_cost_per_token": 8.7e-07, + "litellm_provider": "ovhcloud", + "max_input_tokens": 32000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 8.7e-07, + "source": "https://endpoints.ai.cloud.ovh.net/models/qwen2-5-coder-32b-instruct", + "supports_function_calling": false, + "supports_response_schema": true, + "supports_tool_choice": false + }, + "ovhcloud/Qwen2.5-VL-72B-Instruct": { + "input_cost_per_token": 9.1e-07, + "litellm_provider": "ovhcloud", + "max_input_tokens": 32000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 9.1e-07, + "source": "https://endpoints.ai.cloud.ovh.net/models/qwen2-5-vl-72b-instruct", + "supports_function_calling": false, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": true + }, + "ovhcloud/Qwen3-32B": { + "input_cost_per_token": 8e-08, + "litellm_provider": "ovhcloud", + "max_input_tokens": 32000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 2.3e-07, + "source": "https://endpoints.ai.cloud.ovh.net/models/qwen3-32b", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "ovhcloud/gpt-oss-120b": { + "input_cost_per_token": 8e-08, + "litellm_provider": "ovhcloud", + "max_input_tokens": 131000, + "max_output_tokens": 131000, + "max_tokens": 131000, + "mode": "chat", + "output_cost_per_token": 4e-07, + "source": "https://endpoints.ai.cloud.ovh.net/models/gpt-oss-120b", + "supports_function_calling": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": false + }, + "ovhcloud/gpt-oss-20b": { + "input_cost_per_token": 4e-08, + "litellm_provider": "ovhcloud", + "max_input_tokens": 131000, + "max_output_tokens": 131000, + "max_tokens": 131000, + "mode": "chat", + "output_cost_per_token": 1.5e-07, + "source": "https://endpoints.ai.cloud.ovh.net/models/gpt-oss-20b", + "supports_function_calling": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": false + }, + "ovhcloud/llava-v1.6-mistral-7b-hf": { + "input_cost_per_token": 2.9e-07, + "litellm_provider": "ovhcloud", + "max_input_tokens": 32000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 2.9e-07, + "source": "https://endpoints.ai.cloud.ovh.net/models/llava-next-mistral-7b", + "supports_function_calling": false, + "supports_response_schema": true, + "supports_tool_choice": false, + "supports_vision": true + }, + "ovhcloud/mamba-codestral-7B-v0.1": { + "input_cost_per_token": 1.9e-07, + "litellm_provider": "ovhcloud", + "max_input_tokens": 256000, + "max_output_tokens": 256000, + "max_tokens": 256000, + "mode": "chat", + "output_cost_per_token": 1.9e-07, + "source": "https://endpoints.ai.cloud.ovh.net/models/mamba-codestral-7b-v0-1", + "supports_function_calling": false, + "supports_response_schema": true, + "supports_tool_choice": false + }, + "palm/chat-bison": { + "input_cost_per_token": 1.25e-07, + "litellm_provider": "palm", + "max_input_tokens": 8192, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.25e-07, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" + }, + "palm/chat-bison-001": { + "input_cost_per_token": 1.25e-07, + "litellm_provider": "palm", + "max_input_tokens": 8192, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.25e-07, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" + }, + "palm/text-bison": { + "input_cost_per_token": 1.25e-07, + "litellm_provider": "palm", + "max_input_tokens": 8192, + "max_output_tokens": 1024, + "max_tokens": 1024, + "mode": "completion", + "output_cost_per_token": 1.25e-07, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" + }, + "palm/text-bison-001": { + "input_cost_per_token": 1.25e-07, + "litellm_provider": "palm", + "max_input_tokens": 8192, + "max_output_tokens": 1024, + "max_tokens": 1024, + "mode": "completion", + "output_cost_per_token": 1.25e-07, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" + }, + "palm/text-bison-safety-off": { + "input_cost_per_token": 1.25e-07, + "litellm_provider": "palm", + "max_input_tokens": 8192, + "max_output_tokens": 1024, + "max_tokens": 1024, + "mode": "completion", + "output_cost_per_token": 1.25e-07, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" + }, + "palm/text-bison-safety-recitation-off": { + "input_cost_per_token": 1.25e-07, + "litellm_provider": "palm", + "max_input_tokens": 8192, + "max_output_tokens": 1024, + "max_tokens": 1024, + "mode": "completion", + "output_cost_per_token": 1.25e-07, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" + }, + "perplexity/codellama-34b-instruct": { + "input_cost_per_token": 3.5e-07, + "litellm_provider": "perplexity", + "max_input_tokens": 16384, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1.4e-06 + }, + "perplexity/codellama-70b-instruct": { + "input_cost_per_token": 7e-07, + "litellm_provider": "perplexity", + "max_input_tokens": 16384, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 2.8e-06 + }, + "perplexity/llama-2-70b-chat": { + "input_cost_per_token": 7e-07, + "litellm_provider": "perplexity", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 2.8e-06 + }, + "perplexity/llama-3.1-70b-instruct": { + "input_cost_per_token": 1e-06, + "litellm_provider": "perplexity", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1e-06 + }, + "perplexity/llama-3.1-8b-instruct": { + "input_cost_per_token": 2e-07, + "litellm_provider": "perplexity", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 2e-07 + }, + "perplexity/llama-3.1-sonar-huge-128k-online": { + "deprecation_date": "2025-02-22", + "input_cost_per_token": 5e-06, + "litellm_provider": "perplexity", + "max_input_tokens": 127072, + "max_output_tokens": 127072, + "max_tokens": 127072, + "mode": "chat", + "output_cost_per_token": 5e-06 + }, + "perplexity/llama-3.1-sonar-large-128k-chat": { + "deprecation_date": "2025-02-22", + "input_cost_per_token": 1e-06, + "litellm_provider": "perplexity", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1e-06 + }, + "perplexity/llama-3.1-sonar-large-128k-online": { + "deprecation_date": "2025-02-22", + "input_cost_per_token": 1e-06, + "litellm_provider": "perplexity", + "max_input_tokens": 127072, + "max_output_tokens": 127072, + "max_tokens": 127072, + "mode": "chat", + "output_cost_per_token": 1e-06 + }, + "perplexity/llama-3.1-sonar-small-128k-chat": { + "deprecation_date": "2025-02-22", + "input_cost_per_token": 2e-07, + "litellm_provider": "perplexity", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 2e-07 + }, + "perplexity/llama-3.1-sonar-small-128k-online": { + "deprecation_date": "2025-02-22", + "input_cost_per_token": 2e-07, + "litellm_provider": "perplexity", + "max_input_tokens": 127072, + "max_output_tokens": 127072, + "max_tokens": 127072, + "mode": "chat", + "output_cost_per_token": 2e-07 + }, + "perplexity/mistral-7b-instruct": { + "input_cost_per_token": 7e-08, + "litellm_provider": "perplexity", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 2.8e-07 + }, + "perplexity/mixtral-8x7b-instruct": { + "input_cost_per_token": 7e-08, + "litellm_provider": "perplexity", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 2.8e-07 + }, + "perplexity/pplx-70b-chat": { + "input_cost_per_token": 7e-07, + "litellm_provider": "perplexity", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 2.8e-06 + }, + "perplexity/pplx-70b-online": { + "input_cost_per_request": 0.005, + "input_cost_per_token": 0.0, + "litellm_provider": "perplexity", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 2.8e-06 + }, + "perplexity/pplx-7b-chat": { + "input_cost_per_token": 7e-08, + "litellm_provider": "perplexity", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 2.8e-07 + }, + "perplexity/pplx-7b-online": { + "input_cost_per_request": 0.005, + "input_cost_per_token": 0.0, + "litellm_provider": "perplexity", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 2.8e-07 + }, + "perplexity/sonar": { + "input_cost_per_token": 1e-06, + "litellm_provider": "perplexity", + "max_input_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1e-06, + "search_context_cost_per_query": { + "search_context_size_high": 0.012, + "search_context_size_low": 0.005, + "search_context_size_medium": 0.008 + }, + "supports_web_search": true + }, + "perplexity/sonar-deep-research": { + "citation_cost_per_token": 2e-06, + "input_cost_per_token": 2e-06, + "litellm_provider": "perplexity", + "max_input_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_reasoning_token": 3e-06, + "output_cost_per_token": 8e-06, + "search_context_cost_per_query": { + "search_context_size_high": 0.005, + "search_context_size_low": 0.005, + "search_context_size_medium": 0.005 + }, + "supports_reasoning": true, + "supports_web_search": true + }, + "perplexity/sonar-medium-chat": { + "input_cost_per_token": 6e-07, + "litellm_provider": "perplexity", + "max_input_tokens": 16384, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1.8e-06 + }, + "perplexity/sonar-medium-online": { + "input_cost_per_request": 0.005, + "input_cost_per_token": 0, + "litellm_provider": "perplexity", + "max_input_tokens": 12000, + "max_output_tokens": 12000, + "max_tokens": 12000, + "mode": "chat", + "output_cost_per_token": 1.8e-06 + }, + "perplexity/sonar-pro": { + "input_cost_per_token": 3e-06, + "litellm_provider": "perplexity", + "max_input_tokens": 200000, + "max_output_tokens": 8000, + "max_tokens": 8000, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.014, + "search_context_size_low": 0.006, + "search_context_size_medium": 0.01 + }, + "supports_web_search": true + }, + "perplexity/sonar-reasoning": { + "input_cost_per_token": 1e-06, + "litellm_provider": "perplexity", + "max_input_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-06, + "search_context_cost_per_query": { + "search_context_size_high": 0.014, + "search_context_size_low": 0.005, + "search_context_size_medium": 0.008 + }, + "supports_reasoning": true, + "supports_web_search": true + }, + "perplexity/sonar-reasoning-pro": { + "input_cost_per_token": 2e-06, + "litellm_provider": "perplexity", + "max_input_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 8e-06, + "search_context_cost_per_query": { + "search_context_size_high": 0.014, + "search_context_size_low": 0.006, + "search_context_size_medium": 0.01 + }, + "supports_reasoning": true, + "supports_web_search": true + }, + "perplexity/sonar-small-chat": { + "input_cost_per_token": 7e-08, + "litellm_provider": "perplexity", + "max_input_tokens": 16384, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 2.8e-07 + }, + "perplexity/sonar-small-online": { + "input_cost_per_request": 0.005, + "input_cost_per_token": 0, + "litellm_provider": "perplexity", + "max_input_tokens": 12000, + "max_output_tokens": 12000, + "max_tokens": 12000, + "mode": "chat", + "output_cost_per_token": 2.8e-07 + }, + "qwen.qwen3-coder-480b-a35b-v1:0": { + "input_cost_per_token": 2.2e-07, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 262000, + "max_output_tokens": 65536, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 1.8e-06, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "qwen.qwen3-235b-a22b-2507-v1:0": { + "input_cost_per_token": 2.2e-07, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 262144, + "max_output_tokens": 131072, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 8.8e-07, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "qwen.qwen3-coder-30b-a3b-v1:0": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 262144, + "max_output_tokens": 131072, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 6.0e-07, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "qwen.qwen3-32b-v1:0": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 131072, + "max_output_tokens": 16384, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 6.0e-07, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "recraft/recraftv2": { + "litellm_provider": "recraft", + "mode": "image_generation", + "output_cost_per_image": 0.022, + "source": "https://www.recraft.ai/docs#pricing", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "recraft/recraftv3": { + "litellm_provider": "recraft", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://www.recraft.ai/docs#pricing", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "replicate/meta/llama-2-13b": { + "input_cost_per_token": 1e-07, + "litellm_provider": "replicate", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 5e-07, + "supports_tool_choice": true + }, + "replicate/meta/llama-2-13b-chat": { + "input_cost_per_token": 1e-07, + "litellm_provider": "replicate", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 5e-07, + "supports_tool_choice": true + }, + "replicate/meta/llama-2-70b": { + "input_cost_per_token": 6.5e-07, + "litellm_provider": "replicate", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 2.75e-06, + "supports_tool_choice": true + }, + "replicate/meta/llama-2-70b-chat": { + "input_cost_per_token": 6.5e-07, + "litellm_provider": "replicate", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 2.75e-06, + "supports_tool_choice": true + }, + "replicate/meta/llama-2-7b": { + "input_cost_per_token": 5e-08, + "litellm_provider": "replicate", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "supports_tool_choice": true + }, + "replicate/meta/llama-2-7b-chat": { + "input_cost_per_token": 5e-08, + "litellm_provider": "replicate", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "supports_tool_choice": true + }, + "replicate/meta/llama-3-70b": { + "input_cost_per_token": 6.5e-07, + "litellm_provider": "replicate", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 2.75e-06, + "supports_tool_choice": true + }, + "replicate/meta/llama-3-70b-instruct": { + "input_cost_per_token": 6.5e-07, + "litellm_provider": "replicate", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 2.75e-06, + "supports_tool_choice": true + }, + "replicate/meta/llama-3-8b": { + "input_cost_per_token": 5e-08, + "litellm_provider": "replicate", + "max_input_tokens": 8086, + "max_output_tokens": 8086, + "max_tokens": 8086, + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "supports_tool_choice": true + }, + "replicate/meta/llama-3-8b-instruct": { + "input_cost_per_token": 5e-08, + "litellm_provider": "replicate", + "max_input_tokens": 8086, + "max_output_tokens": 8086, + "max_tokens": 8086, + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "supports_tool_choice": true + }, + "replicate/mistralai/mistral-7b-instruct-v0.2": { + "input_cost_per_token": 5e-08, + "litellm_provider": "replicate", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "supports_tool_choice": true + }, + "replicate/mistralai/mistral-7b-v0.1": { + "input_cost_per_token": 5e-08, + "litellm_provider": "replicate", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "supports_tool_choice": true + }, + "replicate/mistralai/mixtral-8x7b-instruct-v0.1": { + "input_cost_per_token": 3e-07, + "litellm_provider": "replicate", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1e-06, + "supports_tool_choice": true + }, + "rerank-english-v2.0": { + "input_cost_per_query": 0.002, + "input_cost_per_token": 0.0, + "litellm_provider": "cohere", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_query_tokens": 2048, + "max_tokens": 4096, + "mode": "rerank", + "output_cost_per_token": 0.0 + }, + "rerank-english-v3.0": { + "input_cost_per_query": 0.002, + "input_cost_per_token": 0.0, + "litellm_provider": "cohere", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_query_tokens": 2048, + "max_tokens": 4096, + "mode": "rerank", + "output_cost_per_token": 0.0 + }, + "rerank-multilingual-v2.0": { + "input_cost_per_query": 0.002, + "input_cost_per_token": 0.0, + "litellm_provider": "cohere", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_query_tokens": 2048, + "max_tokens": 4096, + "mode": "rerank", + "output_cost_per_token": 0.0 + }, + "rerank-multilingual-v3.0": { + "input_cost_per_query": 0.002, + "input_cost_per_token": 0.0, + "litellm_provider": "cohere", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_query_tokens": 2048, + "max_tokens": 4096, + "mode": "rerank", + "output_cost_per_token": 0.0 + }, + "rerank-v3.5": { + "input_cost_per_query": 0.002, + "input_cost_per_token": 0.0, + "litellm_provider": "cohere", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_query_tokens": 2048, + "max_tokens": 4096, + "mode": "rerank", + "output_cost_per_token": 0.0 + }, + "sagemaker/meta-textgeneration-llama-2-13b": { + "input_cost_per_token": 0.0, + "litellm_provider": "sagemaker", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "completion", + "output_cost_per_token": 0.0 + }, + "sagemaker/meta-textgeneration-llama-2-13b-f": { + "input_cost_per_token": 0.0, + "litellm_provider": "sagemaker", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.0 + }, + "sagemaker/meta-textgeneration-llama-2-70b": { + "input_cost_per_token": 0.0, + "litellm_provider": "sagemaker", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "completion", + "output_cost_per_token": 0.0 + }, + "sagemaker/meta-textgeneration-llama-2-70b-b-f": { + "input_cost_per_token": 0.0, + "litellm_provider": "sagemaker", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.0 + }, + "sagemaker/meta-textgeneration-llama-2-7b": { + "input_cost_per_token": 0.0, + "litellm_provider": "sagemaker", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "completion", + "output_cost_per_token": 0.0 + }, + "sagemaker/meta-textgeneration-llama-2-7b-f": { + "input_cost_per_token": 0.0, + "litellm_provider": "sagemaker", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.0 + }, + "sambanova/DeepSeek-R1": { + "input_cost_per_token": 5e-06, + "litellm_provider": "sambanova", + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 7e-06, + "source": "https://cloud.sambanova.ai/plans/pricing" + }, + "sambanova/DeepSeek-R1-Distill-Llama-70B": { + "input_cost_per_token": 7e-07, + "litellm_provider": "sambanova", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1.4e-06, + "source": "https://cloud.sambanova.ai/plans/pricing" + }, + "sambanova/DeepSeek-V3-0324": { + "input_cost_per_token": 3e-06, + "litellm_provider": "sambanova", + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 4.5e-06, + "source": "https://cloud.sambanova.ai/plans/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "sambanova/Llama-4-Maverick-17B-128E-Instruct": { + "input_cost_per_token": 6.3e-07, + "litellm_provider": "sambanova", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "metadata": { + "notes": "For vision models, images are converted to 6432 input tokens and are billed at that amount" + }, + "mode": "chat", + "output_cost_per_token": 1.8e-06, + "source": "https://cloud.sambanova.ai/plans/pricing", + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "sambanova/Llama-4-Scout-17B-16E-Instruct": { + "input_cost_per_token": 4e-07, + "litellm_provider": "sambanova", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "metadata": { + "notes": "For vision models, images are converted to 6432 input tokens and are billed at that amount" + }, + "mode": "chat", + "output_cost_per_token": 7e-07, + "source": "https://cloud.sambanova.ai/plans/pricing", + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "sambanova/Meta-Llama-3.1-405B-Instruct": { + "input_cost_per_token": 5e-06, + "litellm_provider": "sambanova", + "max_input_tokens": 16384, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1e-05, + "source": "https://cloud.sambanova.ai/plans/pricing", + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "sambanova/Meta-Llama-3.1-8B-Instruct": { + "input_cost_per_token": 1e-07, + "litellm_provider": "sambanova", + "max_input_tokens": 16384, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 2e-07, + "source": "https://cloud.sambanova.ai/plans/pricing", + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "sambanova/Meta-Llama-3.2-1B-Instruct": { + "input_cost_per_token": 4e-08, + "litellm_provider": "sambanova", + "max_input_tokens": 16384, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 8e-08, + "source": "https://cloud.sambanova.ai/plans/pricing" + }, + "sambanova/Meta-Llama-3.2-3B-Instruct": { + "input_cost_per_token": 8e-08, + "litellm_provider": "sambanova", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.6e-07, + "source": "https://cloud.sambanova.ai/plans/pricing" + }, + "sambanova/Meta-Llama-3.3-70B-Instruct": { + "input_cost_per_token": 6e-07, + "litellm_provider": "sambanova", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "source": "https://cloud.sambanova.ai/plans/pricing", + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "sambanova/Meta-Llama-Guard-3-8B": { + "input_cost_per_token": 3e-07, + "litellm_provider": "sambanova", + "max_input_tokens": 16384, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 3e-07, + "source": "https://cloud.sambanova.ai/plans/pricing" + }, + "sambanova/QwQ-32B": { + "input_cost_per_token": 5e-07, + "litellm_provider": "sambanova", + "max_input_tokens": 16384, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1e-06, + "source": "https://cloud.sambanova.ai/plans/pricing" + }, + "sambanova/Qwen2-Audio-7B-Instruct": { + "input_cost_per_token": 5e-07, + "litellm_provider": "sambanova", + "max_input_tokens": 4096, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.0001, + "source": "https://cloud.sambanova.ai/plans/pricing", + "supports_audio_input": true + }, + "sambanova/Qwen3-32B": { + "input_cost_per_token": 4e-07, + "litellm_provider": "sambanova", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 8e-07, + "source": "https://cloud.sambanova.ai/plans/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "sambanova/DeepSeek-V3.1": { + "max_tokens": 32768, + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "input_cost_per_token": 3e-06, + "output_cost_per_token": 4.5e-06, + "litellm_provider": "sambanova", + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "source": "https://cloud.sambanova.ai/plans/pricing" + }, + "sambanova/gpt-oss-120b": { + "max_tokens": 131072, + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "input_cost_per_token": 3e-06, + "output_cost_per_token": 4.5e-06, + "litellm_provider": "sambanova", + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "source": "https://cloud.sambanova.ai/plans/pricing" + }, + "sample_spec": { + "code_interpreter_cost_per_session": 0.0, + "computer_use_input_cost_per_1k_tokens": 0.0, + "computer_use_output_cost_per_1k_tokens": 0.0, + "deprecation_date": "date when the model becomes deprecated in the format YYYY-MM-DD", + "file_search_cost_per_1k_calls": 0.0, + "file_search_cost_per_gb_per_day": 0.0, + "input_cost_per_audio_token": 0.0, + "input_cost_per_token": 0.0, + "litellm_provider": "one of https://docs.litellm.ai/docs/providers", + "max_input_tokens": "max input tokens, if the provider specifies it. if not default to max_tokens", + "max_output_tokens": "max output tokens, if the provider specifies it. if not default to max_tokens", + "max_tokens": "LEGACY parameter. set to max_output_tokens if provider specifies it. IF not set to max_input_tokens, if provider specifies it.", + "mode": "one of: chat, embedding, completion, image_generation, audio_transcription, audio_speech, image_generation, moderation, rerank", + "output_cost_per_reasoning_token": 0.0, + "output_cost_per_token": 0.0, + "search_context_cost_per_query": { + "search_context_size_high": 0.0, + "search_context_size_low": 0.0, + "search_context_size_medium": 0.0 + }, + "supported_regions": [ + "global", + "us-west-2", + "eu-west-1", + "ap-southeast-1", + "ap-northeast-1" + ], + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_vision": true, + "supports_web_search": true, + "vector_store_cost_per_gb_per_day": 0.0 + }, + "snowflake/claude-3-5-sonnet": { + "litellm_provider": "snowflake", + "max_input_tokens": 18000, + "max_output_tokens": 8192, + "max_tokens": 18000, + "mode": "chat", + "supports_computer_use": true + }, + "snowflake/deepseek-r1": { + "litellm_provider": "snowflake", + "max_input_tokens": 32768, + "max_output_tokens": 8192, + "max_tokens": 32768, + "mode": "chat", + "supports_reasoning": true + }, + "snowflake/gemma-7b": { + "litellm_provider": "snowflake", + "max_input_tokens": 8000, + "max_output_tokens": 8192, + "max_tokens": 8000, + "mode": "chat" + }, + "snowflake/jamba-1.5-large": { + "litellm_provider": "snowflake", + "max_input_tokens": 256000, + "max_output_tokens": 8192, + "max_tokens": 256000, + "mode": "chat" + }, + "snowflake/jamba-1.5-mini": { + "litellm_provider": "snowflake", + "max_input_tokens": 256000, + "max_output_tokens": 8192, + "max_tokens": 256000, + "mode": "chat" + }, + "snowflake/jamba-instruct": { + "litellm_provider": "snowflake", + "max_input_tokens": 256000, + "max_output_tokens": 8192, + "max_tokens": 256000, + "mode": "chat" + }, + "snowflake/llama2-70b-chat": { + "litellm_provider": "snowflake", + "max_input_tokens": 4096, + "max_output_tokens": 8192, + "max_tokens": 4096, + "mode": "chat" + }, + "snowflake/llama3-70b": { + "litellm_provider": "snowflake", + "max_input_tokens": 8000, + "max_output_tokens": 8192, + "max_tokens": 8000, + "mode": "chat" + }, + "snowflake/llama3-8b": { + "litellm_provider": "snowflake", + "max_input_tokens": 8000, + "max_output_tokens": 8192, + "max_tokens": 8000, + "mode": "chat" + }, + "snowflake/llama3.1-405b": { + "litellm_provider": "snowflake", + "max_input_tokens": 128000, + "max_output_tokens": 8192, + "max_tokens": 128000, + "mode": "chat" + }, + "snowflake/llama3.1-70b": { + "litellm_provider": "snowflake", + "max_input_tokens": 128000, + "max_output_tokens": 8192, + "max_tokens": 128000, + "mode": "chat" + }, + "snowflake/llama3.1-8b": { + "litellm_provider": "snowflake", + "max_input_tokens": 128000, + "max_output_tokens": 8192, + "max_tokens": 128000, + "mode": "chat" + }, + "snowflake/llama3.2-1b": { + "litellm_provider": "snowflake", + "max_input_tokens": 128000, + "max_output_tokens": 8192, + "max_tokens": 128000, + "mode": "chat" + }, + "snowflake/llama3.2-3b": { + "litellm_provider": "snowflake", + "max_input_tokens": 128000, + "max_output_tokens": 8192, + "max_tokens": 128000, + "mode": "chat" + }, + "snowflake/llama3.3-70b": { + "litellm_provider": "snowflake", + "max_input_tokens": 128000, + "max_output_tokens": 8192, + "max_tokens": 128000, + "mode": "chat" + }, + "snowflake/mistral-7b": { + "litellm_provider": "snowflake", + "max_input_tokens": 32000, + "max_output_tokens": 8192, + "max_tokens": 32000, + "mode": "chat" + }, + "snowflake/mistral-large": { + "litellm_provider": "snowflake", + "max_input_tokens": 32000, + "max_output_tokens": 8192, + "max_tokens": 32000, + "mode": "chat" + }, + "snowflake/mistral-large2": { + "litellm_provider": "snowflake", + "max_input_tokens": 128000, + "max_output_tokens": 8192, + "max_tokens": 128000, + "mode": "chat" + }, + "snowflake/mixtral-8x7b": { + "litellm_provider": "snowflake", + "max_input_tokens": 32000, + "max_output_tokens": 8192, + "max_tokens": 32000, + "mode": "chat" + }, + "snowflake/reka-core": { + "litellm_provider": "snowflake", + "max_input_tokens": 32000, + "max_output_tokens": 8192, + "max_tokens": 32000, + "mode": "chat" + }, + "snowflake/reka-flash": { + "litellm_provider": "snowflake", + "max_input_tokens": 100000, + "max_output_tokens": 8192, + "max_tokens": 100000, + "mode": "chat" + }, + "snowflake/snowflake-arctic": { + "litellm_provider": "snowflake", + "max_input_tokens": 4096, + "max_output_tokens": 8192, + "max_tokens": 4096, + "mode": "chat" + }, + "snowflake/snowflake-llama-3.1-405b": { + "litellm_provider": "snowflake", + "max_input_tokens": 8000, + "max_output_tokens": 8192, + "max_tokens": 8000, + "mode": "chat" + }, + "snowflake/snowflake-llama-3.3-70b": { + "litellm_provider": "snowflake", + "max_input_tokens": 8000, + "max_output_tokens": 8192, + "max_tokens": 8000, + "mode": "chat" + }, + "stability.sd3-5-large-v1:0": { + "litellm_provider": "bedrock", + "max_input_tokens": 77, + "max_tokens": 77, + "mode": "image_generation", + "output_cost_per_image": 0.08 + }, + "stability.sd3-large-v1:0": { + "litellm_provider": "bedrock", + "max_input_tokens": 77, + "max_tokens": 77, + "mode": "image_generation", + "output_cost_per_image": 0.08 + }, + "stability.stable-image-core-v1:0": { + "litellm_provider": "bedrock", + "max_input_tokens": 77, + "max_tokens": 77, + "mode": "image_generation", + "output_cost_per_image": 0.04 + }, + "stability.stable-image-core-v1:1": { + "litellm_provider": "bedrock", + "max_input_tokens": 77, + "max_tokens": 77, + "mode": "image_generation", + "output_cost_per_image": 0.04 + }, + "stability.stable-image-ultra-v1:0": { + "litellm_provider": "bedrock", + "max_input_tokens": 77, + "max_tokens": 77, + "mode": "image_generation", + "output_cost_per_image": 0.14 + }, + "stability.stable-image-ultra-v1:1": { + "litellm_provider": "bedrock", + "max_input_tokens": 77, + "max_tokens": 77, + "mode": "image_generation", + "output_cost_per_image": 0.14 + }, + "standard/1024-x-1024/dall-e-3": { + "input_cost_per_pixel": 3.81469e-08, + "litellm_provider": "openai", + "mode": "image_generation", + "output_cost_per_pixel": 0.0 + }, + "standard/1024-x-1792/dall-e-3": { + "input_cost_per_pixel": 4.359e-08, + "litellm_provider": "openai", + "mode": "image_generation", + "output_cost_per_pixel": 0.0 + }, + "standard/1792-x-1024/dall-e-3": { + "input_cost_per_pixel": 4.359e-08, + "litellm_provider": "openai", + "mode": "image_generation", + "output_cost_per_pixel": 0.0 + }, + "text-bison": { + "input_cost_per_character": 2.5e-07, + "litellm_provider": "vertex_ai-text-models", + "max_input_tokens": 8192, + "max_output_tokens": 2048, + "max_tokens": 2048, + "mode": "completion", + "output_cost_per_character": 5e-07, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" + }, + "text-bison32k": { + "input_cost_per_character": 2.5e-07, + "input_cost_per_token": 1.25e-07, + "litellm_provider": "vertex_ai-text-models", + "max_input_tokens": 8192, + "max_output_tokens": 1024, + "max_tokens": 1024, + "mode": "completion", + "output_cost_per_character": 5e-07, + "output_cost_per_token": 1.25e-07, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" + }, + "text-bison32k@002": { + "input_cost_per_character": 2.5e-07, + "input_cost_per_token": 1.25e-07, + "litellm_provider": "vertex_ai-text-models", + "max_input_tokens": 8192, + "max_output_tokens": 1024, + "max_tokens": 1024, + "mode": "completion", + "output_cost_per_character": 5e-07, + "output_cost_per_token": 1.25e-07, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" + }, + "text-bison@001": { + "input_cost_per_character": 2.5e-07, + "litellm_provider": "vertex_ai-text-models", + "max_input_tokens": 8192, + "max_output_tokens": 1024, + "max_tokens": 1024, + "mode": "completion", + "output_cost_per_character": 5e-07, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" + }, + "text-bison@002": { + "input_cost_per_character": 2.5e-07, + "litellm_provider": "vertex_ai-text-models", + "max_input_tokens": 8192, + "max_output_tokens": 1024, + "max_tokens": 1024, + "mode": "completion", + "output_cost_per_character": 5e-07, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" + }, + "text-completion-codestral/codestral-2405": { + "input_cost_per_token": 0.0, + "litellm_provider": "text-completion-codestral", + "max_input_tokens": 32000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "completion", + "output_cost_per_token": 0.0, + "source": "https://docs.mistral.ai/capabilities/code_generation/" + }, + "text-completion-codestral/codestral-latest": { + "input_cost_per_token": 0.0, + "litellm_provider": "text-completion-codestral", + "max_input_tokens": 32000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "completion", + "output_cost_per_token": 0.0, + "source": "https://docs.mistral.ai/capabilities/code_generation/" + }, + "text-embedding-004": { + "input_cost_per_character": 2.5e-08, + "input_cost_per_token": 1e-07, + "litellm_provider": "vertex_ai-embedding-models", + "max_input_tokens": 2048, + "max_tokens": 2048, + "mode": "embedding", + "output_cost_per_token": 0, + "output_vector_size": 768, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models" + }, + "text-embedding-005": { + "input_cost_per_character": 2.5e-08, + "input_cost_per_token": 1e-07, + "litellm_provider": "vertex_ai-embedding-models", + "max_input_tokens": 2048, + "max_tokens": 2048, + "mode": "embedding", + "output_cost_per_token": 0, + "output_vector_size": 768, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models" + }, + "text-embedding-3-large": { + "input_cost_per_token": 1.3e-07, + "input_cost_per_token_batches": 6.5e-08, + "litellm_provider": "openai", + "max_input_tokens": 8191, + "max_tokens": 8191, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_cost_per_token_batches": 0.0, + "output_vector_size": 3072 + }, + "text-embedding-3-small": { + "input_cost_per_token": 2e-08, + "input_cost_per_token_batches": 1e-08, + "litellm_provider": "openai", + "max_input_tokens": 8191, + "max_tokens": 8191, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_cost_per_token_batches": 0.0, + "output_vector_size": 1536 + }, + "text-embedding-ada-002": { + "input_cost_per_token": 1e-07, + "litellm_provider": "openai", + "max_input_tokens": 8191, + "max_tokens": 8191, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_vector_size": 1536 + }, + "text-embedding-ada-002-v2": { + "input_cost_per_token": 1e-07, + "input_cost_per_token_batches": 5e-08, + "litellm_provider": "openai", + "max_input_tokens": 8191, + "max_tokens": 8191, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_cost_per_token_batches": 0.0 + }, + "text-embedding-large-exp-03-07": { + "input_cost_per_character": 2.5e-08, + "input_cost_per_token": 1e-07, + "litellm_provider": "vertex_ai-embedding-models", + "max_input_tokens": 8192, + "max_tokens": 8192, + "mode": "embedding", + "output_cost_per_token": 0, + "output_vector_size": 3072, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models" + }, + "text-embedding-preview-0409": { + "input_cost_per_token": 6.25e-09, + "input_cost_per_token_batch_requests": 5e-09, + "litellm_provider": "vertex_ai-embedding-models", + "max_input_tokens": 3072, + "max_tokens": 3072, + "mode": "embedding", + "output_cost_per_token": 0, + "output_vector_size": 768, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" + }, + "text-moderation-007": { + "input_cost_per_token": 0.0, + "litellm_provider": "openai", + "max_input_tokens": 32768, + "max_output_tokens": 0, + "max_tokens": 32768, + "mode": "moderation", + "output_cost_per_token": 0.0 + }, + "text-moderation-latest": { + "input_cost_per_token": 0.0, + "litellm_provider": "openai", + "max_input_tokens": 32768, + "max_output_tokens": 0, + "max_tokens": 32768, + "mode": "moderation", + "output_cost_per_token": 0.0 + }, + "text-moderation-stable": { + "input_cost_per_token": 0.0, + "litellm_provider": "openai", + "max_input_tokens": 32768, + "max_output_tokens": 0, + "max_tokens": 32768, + "mode": "moderation", + "output_cost_per_token": 0.0 + }, + "text-multilingual-embedding-002": { + "input_cost_per_character": 2.5e-08, + "input_cost_per_token": 1e-07, + "litellm_provider": "vertex_ai-embedding-models", + "max_input_tokens": 2048, + "max_tokens": 2048, + "mode": "embedding", + "output_cost_per_token": 0, + "output_vector_size": 768, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models" + }, + "text-multilingual-embedding-preview-0409": { + "input_cost_per_token": 6.25e-09, + "litellm_provider": "vertex_ai-embedding-models", + "max_input_tokens": 3072, + "max_tokens": 3072, + "mode": "embedding", + "output_cost_per_token": 0, + "output_vector_size": 768, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" + }, + "text-unicorn": { + "input_cost_per_token": 1e-05, + "litellm_provider": "vertex_ai-text-models", + "max_input_tokens": 8192, + "max_output_tokens": 1024, + "max_tokens": 1024, + "mode": "completion", + "output_cost_per_token": 2.8e-05, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" + }, + "text-unicorn@001": { + "input_cost_per_token": 1e-05, + "litellm_provider": "vertex_ai-text-models", + "max_input_tokens": 8192, + "max_output_tokens": 1024, + "max_tokens": 1024, + "mode": "completion", + "output_cost_per_token": 2.8e-05, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" + }, + "textembedding-gecko": { + "input_cost_per_character": 2.5e-08, + "input_cost_per_token": 1e-07, + "litellm_provider": "vertex_ai-embedding-models", + "max_input_tokens": 3072, + "max_tokens": 3072, + "mode": "embedding", + "output_cost_per_token": 0, + "output_vector_size": 768, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" + }, + "textembedding-gecko-multilingual": { + "input_cost_per_character": 2.5e-08, + "input_cost_per_token": 1e-07, + "litellm_provider": "vertex_ai-embedding-models", + "max_input_tokens": 3072, + "max_tokens": 3072, + "mode": "embedding", + "output_cost_per_token": 0, + "output_vector_size": 768, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" + }, + "textembedding-gecko-multilingual@001": { + "input_cost_per_character": 2.5e-08, + "input_cost_per_token": 1e-07, + "litellm_provider": "vertex_ai-embedding-models", + "max_input_tokens": 3072, + "max_tokens": 3072, + "mode": "embedding", + "output_cost_per_token": 0, + "output_vector_size": 768, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" + }, + "textembedding-gecko@001": { + "input_cost_per_character": 2.5e-08, + "input_cost_per_token": 1e-07, + "litellm_provider": "vertex_ai-embedding-models", + "max_input_tokens": 3072, + "max_tokens": 3072, + "mode": "embedding", + "output_cost_per_token": 0, + "output_vector_size": 768, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" + }, + "textembedding-gecko@003": { + "input_cost_per_character": 2.5e-08, + "input_cost_per_token": 1e-07, + "litellm_provider": "vertex_ai-embedding-models", + "max_input_tokens": 3072, + "max_tokens": 3072, + "mode": "embedding", + "output_cost_per_token": 0, + "output_vector_size": 768, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" + }, + "together-ai-21.1b-41b": { + "input_cost_per_token": 8e-07, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 8e-07 + }, + "together-ai-4.1b-8b": { + "input_cost_per_token": 2e-07, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 2e-07 + }, + "together-ai-41.1b-80b": { + "input_cost_per_token": 9e-07, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 9e-07 + }, + "together-ai-8.1b-21b": { + "input_cost_per_token": 3e-07, + "litellm_provider": "together_ai", + "max_tokens": 1000, + "mode": "chat", + "output_cost_per_token": 3e-07 + }, + "together-ai-81.1b-110b": { + "input_cost_per_token": 1.8e-06, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 1.8e-06 + }, + "together-ai-embedding-151m-to-350m": { + "input_cost_per_token": 1.6e-08, + "litellm_provider": "together_ai", + "mode": "embedding", + "output_cost_per_token": 0.0 + }, + "together-ai-embedding-up-to-150m": { + "input_cost_per_token": 8e-09, + "litellm_provider": "together_ai", + "mode": "embedding", + "output_cost_per_token": 0.0 + }, + "together-ai-up-to-4b": { + "input_cost_per_token": 1e-07, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 1e-07 + }, + "together_ai/Qwen/Qwen2.5-72B-Instruct-Turbo": { + "litellm_provider": "together_ai", + "mode": "chat", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true + }, + "together_ai/Qwen/Qwen2.5-7B-Instruct-Turbo": { + "litellm_provider": "together_ai", + "mode": "chat", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true + }, + "together_ai/Qwen/Qwen3-235B-A22B-Instruct-2507-tput": { + "input_cost_per_token": 2e-07, + "litellm_provider": "together_ai", + "max_input_tokens": 262000, + "mode": "chat", + "output_cost_per_token": 6e-06, + "source": "https://www.together.ai/models/qwen3-235b-a22b-instruct-2507-fp8", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true + }, + "together_ai/Qwen/Qwen3-235B-A22B-Thinking-2507": { + "input_cost_per_token": 6.5e-07, + "litellm_provider": "together_ai", + "max_input_tokens": 256000, + "mode": "chat", + "output_cost_per_token": 3e-06, + "source": "https://www.together.ai/models/qwen3-235b-a22b-thinking-2507", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true + }, + "together_ai/Qwen/Qwen3-235B-A22B-fp8-tput": { + "input_cost_per_token": 2e-07, + "litellm_provider": "together_ai", + "max_input_tokens": 40000, + "mode": "chat", + "output_cost_per_token": 6e-07, + "source": "https://www.together.ai/models/qwen3-235b-a22b-fp8-tput", + "supports_function_calling": false, + "supports_parallel_function_calling": false, + "supports_tool_choice": false + }, + "together_ai/Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8": { + "input_cost_per_token": 2e-06, + "litellm_provider": "together_ai", + "max_input_tokens": 256000, + "mode": "chat", + "output_cost_per_token": 2e-06, + "source": "https://www.together.ai/models/qwen3-coder-480b-a35b-instruct", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true + }, + "together_ai/deepseek-ai/DeepSeek-R1": { + "input_cost_per_token": 3e-06, + "litellm_provider": "together_ai", + "max_input_tokens": 128000, + "max_output_tokens": 20480, + "max_tokens": 20480, + "mode": "chat", + "output_cost_per_token": 7e-06, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true + }, + "together_ai/deepseek-ai/DeepSeek-R1-0528-tput": { + "input_cost_per_token": 5.5e-07, + "litellm_provider": "together_ai", + "max_input_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.19e-06, + "source": "https://www.together.ai/models/deepseek-r1-0528-throughput", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true + }, + "together_ai/deepseek-ai/DeepSeek-V3": { + "input_cost_per_token": 1.25e-06, + "litellm_provider": "together_ai", + "max_input_tokens": 65536, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.25e-06, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true + }, + "together_ai/deepseek-ai/DeepSeek-V3.1": { + "input_cost_per_token": 6e-07, + "litellm_provider": "together_ai", + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.7e-06, + "source": "https://www.together.ai/models/deepseek-v3-1", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "together_ai/meta-llama/Llama-3.2-3B-Instruct-Turbo": { + "litellm_provider": "together_ai", + "mode": "chat", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true + }, + "together_ai/meta-llama/Llama-3.3-70B-Instruct-Turbo": { + "input_cost_per_token": 8.8e-07, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 8.8e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "together_ai/meta-llama/Llama-3.3-70B-Instruct-Turbo-Free": { + "input_cost_per_token": 0, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 0, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "together_ai/meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8": { + "input_cost_per_token": 2.7e-07, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 8.5e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true + }, + "together_ai/meta-llama/Llama-4-Scout-17B-16E-Instruct": { + "input_cost_per_token": 1.8e-07, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 5.9e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true + }, + "together_ai/meta-llama/Meta-Llama-3.1-405B-Instruct-Turbo": { + "input_cost_per_token": 3.5e-06, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 3.5e-06, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true + }, + "together_ai/meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo": { + "input_cost_per_token": 8.8e-07, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 8.8e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "together_ai/meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo": { + "input_cost_per_token": 1.8e-07, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 1.8e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "together_ai/mistralai/Mistral-7B-Instruct-v0.1": { + "litellm_provider": "together_ai", + "mode": "chat", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "together_ai/mistralai/Mistral-Small-24B-Instruct-2501": { + "litellm_provider": "together_ai", + "mode": "chat", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true + }, + "together_ai/mistralai/Mixtral-8x7B-Instruct-v0.1": { + "input_cost_per_token": 6e-07, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 6e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "together_ai/moonshotai/Kimi-K2-Instruct": { + "input_cost_per_token": 1e-06, + "litellm_provider": "together_ai", + "mode": "chat", + "output_cost_per_token": 3e-06, + "source": "https://www.together.ai/models/kimi-k2-instruct", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true + }, + "together_ai/openai/gpt-oss-120b": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "together_ai", + "max_input_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6e-07, + "source": "https://www.together.ai/models/gpt-oss-120b", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true + }, + "together_ai/openai/gpt-oss-20b": { + "input_cost_per_token": 5e-08, + "litellm_provider": "together_ai", + "max_input_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2e-07, + "source": "https://www.together.ai/models/gpt-oss-20b", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true + }, + "together_ai/togethercomputer/CodeLlama-34b-Instruct": { + "litellm_provider": "together_ai", + "mode": "chat", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true + }, + "together_ai/zai-org/GLM-4.5-Air-FP8": { + "input_cost_per_token": 2e-07, + "litellm_provider": "together_ai", + "max_input_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.1e-06, + "source": "https://www.together.ai/models/glm-4-5-air", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true + }, + "tts-1": { + "input_cost_per_character": 1.5e-05, + "litellm_provider": "openai", + "mode": "audio_speech", + "supported_endpoints": [ + "/v1/audio/speech" + ] + }, + "tts-1-hd": { + "input_cost_per_character": 3e-05, + "litellm_provider": "openai", + "mode": "audio_speech", + "supported_endpoints": [ + "/v1/audio/speech" + ] + }, + "us.amazon.nova-lite-v1:0": { + "input_cost_per_token": 6e-08, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 300000, + "max_output_tokens": 10000, + "max_tokens": 10000, + "mode": "chat", + "output_cost_per_token": 2.4e-07, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_vision": true + }, + "us.amazon.nova-micro-v1:0": { + "input_cost_per_token": 3.5e-08, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 128000, + "max_output_tokens": 10000, + "max_tokens": 10000, + "mode": "chat", + "output_cost_per_token": 1.4e-07, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true + }, + "us.amazon.nova-premier-v1:0": { + "input_cost_per_token": 2.5e-06, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 10000, + "max_tokens": 10000, + "mode": "chat", + "output_cost_per_token": 1.25e-05, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": false, + "supports_response_schema": true, + "supports_vision": true + }, + "us.amazon.nova-pro-v1:0": { + "input_cost_per_token": 8e-07, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 300000, + "max_output_tokens": 10000, + "max_tokens": 10000, + "mode": "chat", + "output_cost_per_token": 3.2e-06, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_vision": true + }, + "us.anthropic.claude-3-5-haiku-20241022-v1:0": { + "cache_creation_input_token_cost": 1e-06, + "cache_read_input_token_cost": 8e-08, + "input_cost_per_token": 8e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 4e-06, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "us.anthropic.claude-3-5-sonnet-20240620-v1:0": { + "input_cost_per_token": 3e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "us.anthropic.claude-3-5-sonnet-20241022-v2:0": { + "cache_creation_input_token_cost": 3.75e-06, + "cache_read_input_token_cost": 3e-07, + "input_cost_per_token": 3e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "us.anthropic.claude-3-7-sonnet-20250219-v1:0": { + "cache_creation_input_token_cost": 3.75e-06, + "cache_read_input_token_cost": 3e-07, + "input_cost_per_token": 3e-06, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "us.anthropic.claude-3-haiku-20240307-v1:0": { + "input_cost_per_token": 2.5e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.25e-06, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "us.anthropic.claude-3-opus-20240229-v1:0": { + "input_cost_per_token": 1.5e-05, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 7.5e-05, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "us.anthropic.claude-3-sonnet-20240229-v1:0": { + "input_cost_per_token": 3e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "us.anthropic.claude-opus-4-1-20250805-v1:0": { + "cache_creation_input_token_cost": 1.875e-05, + "cache_read_input_token_cost": 1.5e-06, + "input_cost_per_token": 1.5e-05, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 7.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "us.anthropic.claude-sonnet-4-5-20250929-v1:0": { + "cache_creation_input_token_cost": 3.75e-06, + "cache_read_input_token_cost": 3e-07, + "input_cost_per_token": 3e-06, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 200000, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "us.anthropic.claude-opus-4-20250514-v1:0": { + "cache_creation_input_token_cost": 1.875e-05, + "cache_read_input_token_cost": 1.5e-06, + "input_cost_per_token": 1.5e-05, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 7.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "us.anthropic.claude-sonnet-4-20250514-v1:0": { + "cache_creation_input_token_cost": 3.75e-06, + "cache_read_input_token_cost": 3e-07, + "input_cost_per_token": 3e-06, + "input_cost_per_token_above_200k_tokens": 6e-06, + "output_cost_per_token_above_200k_tokens": 2.25e-05, + "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, + "cache_read_input_token_cost_above_200k_tokens": 6e-07, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "us.deepseek.r1-v1:0": { + "input_cost_per_token": 1.35e-06, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 5.4e-06, + "supports_function_calling": false, + "supports_reasoning": true, + "supports_tool_choice": false + }, + "us.meta.llama3-1-405b-instruct-v1:0": { + "input_cost_per_token": 5.32e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.6e-05, + "supports_function_calling": true, + "supports_tool_choice": false + }, + "us.meta.llama3-1-70b-instruct-v1:0": { + "input_cost_per_token": 9.9e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 128000, + "max_output_tokens": 2048, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 9.9e-07, + "supports_function_calling": true, + "supports_tool_choice": false + }, + "us.meta.llama3-1-8b-instruct-v1:0": { + "input_cost_per_token": 2.2e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 128000, + "max_output_tokens": 2048, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.2e-07, + "supports_function_calling": true, + "supports_tool_choice": false + }, + "us.meta.llama3-2-11b-instruct-v1:0": { + "input_cost_per_token": 3.5e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3.5e-07, + "supports_function_calling": true, + "supports_tool_choice": false, + "supports_vision": true + }, + "us.meta.llama3-2-1b-instruct-v1:0": { + "input_cost_per_token": 1e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1e-07, + "supports_function_calling": true, + "supports_tool_choice": false + }, + "us.meta.llama3-2-3b-instruct-v1:0": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.5e-07, + "supports_function_calling": true, + "supports_tool_choice": false + }, + "us.meta.llama3-2-90b-instruct-v1:0": { + "input_cost_per_token": 2e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2e-06, + "supports_function_calling": true, + "supports_tool_choice": false, + "supports_vision": true + }, + "us.meta.llama3-3-70b-instruct-v1:0": { + "input_cost_per_token": 7.2e-07, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 7.2e-07, + "supports_function_calling": true, + "supports_tool_choice": false + }, + "us.meta.llama4-maverick-17b-instruct-v1:0": { + "input_cost_per_token": 2.4e-07, + "input_cost_per_token_batches": 1.2e-07, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 9.7e-07, + "output_cost_per_token_batches": 4.85e-07, + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text", + "code" + ], + "supports_function_calling": true, + "supports_tool_choice": false + }, + "us.meta.llama4-scout-17b-instruct-v1:0": { + "input_cost_per_token": 1.7e-07, + "input_cost_per_token_batches": 8.5e-08, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 6.6e-07, + "output_cost_per_token_batches": 3.3e-07, + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text", + "code" + ], + "supports_function_calling": true, + "supports_tool_choice": false + }, + "us.mistral.pixtral-large-2502-v1:0": { + "input_cost_per_token": 2e-06, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6e-06, + "supports_function_calling": true, + "supports_tool_choice": false + }, + "v0/v0-1.0-md": { + "input_cost_per_token": 3e-06, + "litellm_provider": "v0", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "v0/v0-1.5-lg": { + "input_cost_per_token": 1.5e-05, + "litellm_provider": "v0", + "max_input_tokens": 512000, + "max_output_tokens": 512000, + "max_tokens": 512000, + "mode": "chat", + "output_cost_per_token": 7.5e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "v0/v0-1.5-md": { + "input_cost_per_token": 3e-06, + "litellm_provider": "v0", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vercel_ai_gateway/alibaba/qwen-3-14b": { + "input_cost_per_token": 8e-08, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 40960, + "max_output_tokens": 16384, + "max_tokens": 40960, + "mode": "chat", + "output_cost_per_token": 2.4e-07 + }, + "vercel_ai_gateway/alibaba/qwen-3-235b": { + "input_cost_per_token": 2e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 40960, + "max_output_tokens": 16384, + "max_tokens": 40960, + "mode": "chat", + "output_cost_per_token": 6e-07 + }, + "vercel_ai_gateway/alibaba/qwen-3-30b": { + "input_cost_per_token": 1e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 40960, + "max_output_tokens": 16384, + "max_tokens": 40960, + "mode": "chat", + "output_cost_per_token": 3e-07 + }, + "vercel_ai_gateway/alibaba/qwen-3-32b": { + "input_cost_per_token": 1e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 40960, + "max_output_tokens": 16384, + "max_tokens": 40960, + "mode": "chat", + "output_cost_per_token": 3e-07 + }, + "vercel_ai_gateway/alibaba/qwen3-coder": { + "input_cost_per_token": 4e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 262144, + "max_output_tokens": 66536, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 1.6e-06 + }, + "vercel_ai_gateway/amazon/nova-lite": { + "input_cost_per_token": 6e-08, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 300000, + "max_output_tokens": 8192, + "max_tokens": 300000, + "mode": "chat", + "output_cost_per_token": 2.4e-07 + }, + "vercel_ai_gateway/amazon/nova-micro": { + "input_cost_per_token": 3.5e-08, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 128000, + "max_output_tokens": 8192, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.4e-07 + }, + "vercel_ai_gateway/amazon/nova-pro": { + "input_cost_per_token": 8e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 300000, + "max_output_tokens": 8192, + "max_tokens": 300000, + "mode": "chat", + "output_cost_per_token": 3.2e-06 + }, + "vercel_ai_gateway/amazon/titan-embed-text-v2": { + "input_cost_per_token": 2e-08, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 0, + "max_output_tokens": 0, + "max_tokens": 0, + "mode": "chat", + "output_cost_per_token": 0.0 + }, + "vercel_ai_gateway/anthropic/claude-3-haiku": { + "cache_creation_input_token_cost": 3e-07, + "cache_read_input_token_cost": 3e-08, + "input_cost_per_token": 2.5e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 200000, + "mode": "chat", + "output_cost_per_token": 1.25e-06 + }, + "vercel_ai_gateway/anthropic/claude-3-opus": { + "cache_creation_input_token_cost": 1.875e-05, + "cache_read_input_token_cost": 1.5e-06, + "input_cost_per_token": 1.5e-05, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 200000, + "mode": "chat", + "output_cost_per_token": 7.5e-05 + }, + "vercel_ai_gateway/anthropic/claude-3.5-haiku": { + "cache_creation_input_token_cost": 1e-06, + "cache_read_input_token_cost": 8e-08, + "input_cost_per_token": 8e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 200000, + "mode": "chat", + "output_cost_per_token": 4e-06 + }, + "vercel_ai_gateway/anthropic/claude-3.5-sonnet": { + "cache_creation_input_token_cost": 3.75e-06, + "cache_read_input_token_cost": 3e-07, + "input_cost_per_token": 3e-06, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 200000, + "mode": "chat", + "output_cost_per_token": 1.5e-05 + }, + "vercel_ai_gateway/anthropic/claude-3.7-sonnet": { + "cache_creation_input_token_cost": 3.75e-06, + "cache_read_input_token_cost": 3e-07, + "input_cost_per_token": 3e-06, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 200000, + "mode": "chat", + "output_cost_per_token": 1.5e-05 + }, + "vercel_ai_gateway/anthropic/claude-4-opus": { + "cache_creation_input_token_cost": 1.875e-05, + "cache_read_input_token_cost": 1.5e-06, + "input_cost_per_token": 1.5e-05, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 200000, + "mode": "chat", + "output_cost_per_token": 7.5e-05 + }, + "vercel_ai_gateway/anthropic/claude-4-sonnet": { + "cache_creation_input_token_cost": 3.75e-06, + "cache_read_input_token_cost": 3e-07, + "input_cost_per_token": 3e-06, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 200000, + "mode": "chat", + "output_cost_per_token": 1.5e-05 + }, + "vercel_ai_gateway/cohere/command-a": { + "input_cost_per_token": 2.5e-06, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 256000, + "max_output_tokens": 8000, + "max_tokens": 256000, + "mode": "chat", + "output_cost_per_token": 1e-05 + }, + "vercel_ai_gateway/cohere/command-r": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6e-07 + }, + "vercel_ai_gateway/cohere/command-r-plus": { + "input_cost_per_token": 2.5e-06, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1e-05 + }, + "vercel_ai_gateway/cohere/embed-v4.0": { + "input_cost_per_token": 1.2e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 0, + "max_output_tokens": 0, + "max_tokens": 0, + "mode": "chat", + "output_cost_per_token": 0.0 + }, + "vercel_ai_gateway/deepseek/deepseek-r1": { + "input_cost_per_token": 5.5e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 128000, + "max_output_tokens": 8192, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.19e-06 + }, + "vercel_ai_gateway/deepseek/deepseek-r1-distill-llama-70b": { + "input_cost_per_token": 7.5e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 9.9e-07 + }, + "vercel_ai_gateway/deepseek/deepseek-v3": { + "input_cost_per_token": 9e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 128000, + "max_output_tokens": 8192, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 9e-07 + }, + "vercel_ai_gateway/google/gemini-2.0-flash": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 1048576, + "max_output_tokens": 8192, + "max_tokens": 1048576, + "mode": "chat", + "output_cost_per_token": 6e-07 + }, + "vercel_ai_gateway/google/gemini-2.0-flash-lite": { + "input_cost_per_token": 7.5e-08, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 1048576, + "max_output_tokens": 8192, + "max_tokens": 1048576, + "mode": "chat", + "output_cost_per_token": 3e-07 + }, + "vercel_ai_gateway/google/gemini-2.5-flash": { + "input_cost_per_token": 3e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 1000000, + "max_output_tokens": 65536, + "max_tokens": 1000000, + "mode": "chat", + "output_cost_per_token": 2.5e-06 + }, + "vercel_ai_gateway/google/gemini-2.5-pro": { + "input_cost_per_token": 2.5e-06, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 1048576, + "mode": "chat", + "output_cost_per_token": 1e-05 + }, + "vercel_ai_gateway/google/gemini-embedding-001": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 0, + "max_output_tokens": 0, + "max_tokens": 0, + "mode": "embedding", + "output_cost_per_token": 0.0 + }, + "vercel_ai_gateway/google/gemma-2-9b": { + "input_cost_per_token": 2e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 2e-07 + }, + "vercel_ai_gateway/google/text-embedding-005": { + "input_cost_per_token": 2.5e-08, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 0, + "max_output_tokens": 0, + "max_tokens": 0, + "mode": "embedding", + "output_cost_per_token": 0.0 + }, + "vercel_ai_gateway/google/text-multilingual-embedding-002": { + "input_cost_per_token": 2.5e-08, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 0, + "max_output_tokens": 0, + "max_tokens": 0, + "mode": "embedding", + "output_cost_per_token": 0.0 + }, + "vercel_ai_gateway/inception/mercury-coder-small": { + "input_cost_per_token": 2.5e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 32000, + "max_output_tokens": 16384, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 1e-06 + }, + "vercel_ai_gateway/meta/llama-3-70b": { + "input_cost_per_token": 5.9e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 7.9e-07 + }, + "vercel_ai_gateway/meta/llama-3-8b": { + "input_cost_per_token": 5e-08, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 8e-08 + }, + "vercel_ai_gateway/meta/llama-3.1-70b": { + "input_cost_per_token": 7.2e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 128000, + "max_output_tokens": 8192, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 7.2e-07 + }, + "vercel_ai_gateway/meta/llama-3.1-8b": { + "input_cost_per_token": 5e-08, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 131000, + "max_output_tokens": 131072, + "max_tokens": 131000, + "mode": "chat", + "output_cost_per_token": 8e-08 + }, + "vercel_ai_gateway/meta/llama-3.2-11b": { + "input_cost_per_token": 1.6e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 128000, + "max_output_tokens": 8192, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.6e-07 + }, + "vercel_ai_gateway/meta/llama-3.2-1b": { + "input_cost_per_token": 1e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 128000, + "max_output_tokens": 8192, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1e-07 + }, + "vercel_ai_gateway/meta/llama-3.2-3b": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 128000, + "max_output_tokens": 8192, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.5e-07 + }, + "vercel_ai_gateway/meta/llama-3.2-90b": { + "input_cost_per_token": 7.2e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 128000, + "max_output_tokens": 8192, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 7.2e-07 + }, + "vercel_ai_gateway/meta/llama-3.3-70b": { + "input_cost_per_token": 7.2e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 128000, + "max_output_tokens": 8192, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 7.2e-07 + }, + "vercel_ai_gateway/meta/llama-4-maverick": { + "input_cost_per_token": 2e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 131072, + "max_output_tokens": 8192, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 6e-07 + }, + "vercel_ai_gateway/meta/llama-4-scout": { + "input_cost_per_token": 1e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 131072, + "max_output_tokens": 8192, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 3e-07 + }, + "vercel_ai_gateway/mistral/codestral": { + "input_cost_per_token": 3e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 256000, + "max_output_tokens": 4000, + "max_tokens": 256000, + "mode": "chat", + "output_cost_per_token": 9e-07 + }, + "vercel_ai_gateway/mistral/codestral-embed": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 0, + "max_output_tokens": 0, + "max_tokens": 0, + "mode": "chat", + "output_cost_per_token": 0.0 + }, + "vercel_ai_gateway/mistral/devstral-small": { + "input_cost_per_token": 7e-08, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.8e-07 + }, + "vercel_ai_gateway/mistral/magistral-medium": { + "input_cost_per_token": 2e-06, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 128000, + "max_output_tokens": 64000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-06 + }, + "vercel_ai_gateway/mistral/magistral-small": { + "input_cost_per_token": 5e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 128000, + "max_output_tokens": 64000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.5e-06 + }, + "vercel_ai_gateway/mistral/ministral-3b": { + "input_cost_per_token": 4e-08, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 128000, + "max_output_tokens": 4000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 4e-08 + }, + "vercel_ai_gateway/mistral/ministral-8b": { + "input_cost_per_token": 1e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 128000, + "max_output_tokens": 4000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1e-07 + }, + "vercel_ai_gateway/mistral/mistral-embed": { + "input_cost_per_token": 1e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 0, + "max_output_tokens": 0, + "max_tokens": 0, + "mode": "chat", + "output_cost_per_token": 0.0 + }, + "vercel_ai_gateway/mistral/mistral-large": { + "input_cost_per_token": 2e-06, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 32000, + "max_output_tokens": 4000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 6e-06 + }, + "vercel_ai_gateway/mistral/mistral-saba-24b": { + "input_cost_per_token": 7.9e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 7.9e-07 + }, + "vercel_ai_gateway/mistral/mistral-small": { + "input_cost_per_token": 1e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 32000, + "max_output_tokens": 4000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 3e-07 + }, + "vercel_ai_gateway/mistral/mixtral-8x22b-instruct": { + "input_cost_per_token": 1.2e-06, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 65536, + "max_output_tokens": 2048, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 1.2e-06 + }, + "vercel_ai_gateway/mistral/pixtral-12b": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 128000, + "max_output_tokens": 4000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.5e-07 + }, + "vercel_ai_gateway/mistral/pixtral-large": { + "input_cost_per_token": 2e-06, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 128000, + "max_output_tokens": 4000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6e-06 + }, + "vercel_ai_gateway/moonshotai/kimi-k2": { + "input_cost_per_token": 5.5e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 131072, + "max_output_tokens": 16384, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 2.2e-06 + }, + "vercel_ai_gateway/morph/morph-v3-fast": { + "input_cost_per_token": 8e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 32768, + "max_output_tokens": 16384, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 1.2e-06 + }, + "vercel_ai_gateway/morph/morph-v3-large": { + "input_cost_per_token": 9e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 32768, + "max_output_tokens": 16384, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 1.9e-06 + }, + "vercel_ai_gateway/openai/gpt-3.5-turbo": { + "input_cost_per_token": 5e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 16385, + "max_output_tokens": 4096, + "max_tokens": 16385, + "mode": "chat", + "output_cost_per_token": 1.5e-06 + }, + "vercel_ai_gateway/openai/gpt-3.5-turbo-instruct": { + "input_cost_per_token": 1.5e-06, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 8192, + "max_output_tokens": 4096, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 2e-06 + }, + "vercel_ai_gateway/openai/gpt-4-turbo": { + "input_cost_per_token": 1e-05, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3e-05 + }, + "vercel_ai_gateway/openai/gpt-4.1": { + "cache_creation_input_token_cost": 0.0, + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token": 2e-06, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 1047576, + "max_output_tokens": 32768, + "max_tokens": 1047576, + "mode": "chat", + "output_cost_per_token": 8e-06 + }, + "vercel_ai_gateway/openai/gpt-4.1-mini": { + "cache_creation_input_token_cost": 0.0, + "cache_read_input_token_cost": 1e-07, + "input_cost_per_token": 4e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 1047576, + "max_output_tokens": 32768, + "max_tokens": 1047576, + "mode": "chat", + "output_cost_per_token": 1.6e-06 + }, + "vercel_ai_gateway/openai/gpt-4.1-nano": { + "cache_creation_input_token_cost": 0.0, + "cache_read_input_token_cost": 2.5e-08, + "input_cost_per_token": 1e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 1047576, + "max_output_tokens": 32768, + "max_tokens": 1047576, + "mode": "chat", + "output_cost_per_token": 4e-07 + }, + "vercel_ai_gateway/openai/gpt-4o": { + "cache_creation_input_token_cost": 0.0, + "cache_read_input_token_cost": 1.25e-06, + "input_cost_per_token": 2.5e-06, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1e-05 + }, + "vercel_ai_gateway/openai/gpt-4o-mini": { + "cache_creation_input_token_cost": 0.0, + "cache_read_input_token_cost": 7.5e-08, + "input_cost_per_token": 1.5e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6e-07 + }, + "vercel_ai_gateway/openai/o1": { + "cache_creation_input_token_cost": 0.0, + "cache_read_input_token_cost": 7.5e-06, + "input_cost_per_token": 1.5e-05, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 200000, + "mode": "chat", + "output_cost_per_token": 6e-05 + }, + "vercel_ai_gateway/openai/o3": { + "cache_creation_input_token_cost": 0.0, + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token": 2e-06, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 200000, + "mode": "chat", + "output_cost_per_token": 8e-06 + }, + "vercel_ai_gateway/openai/o3-mini": { + "cache_creation_input_token_cost": 0.0, + "cache_read_input_token_cost": 5.5e-07, + "input_cost_per_token": 1.1e-06, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 200000, + "mode": "chat", + "output_cost_per_token": 4.4e-06 + }, + "vercel_ai_gateway/openai/o4-mini": { + "cache_creation_input_token_cost": 0.0, + "cache_read_input_token_cost": 2.75e-07, + "input_cost_per_token": 1.1e-06, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 200000, + "mode": "chat", + "output_cost_per_token": 4.4e-06 + }, + "vercel_ai_gateway/openai/text-embedding-3-large": { + "input_cost_per_token": 1.3e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 0, + "max_output_tokens": 0, + "max_tokens": 0, + "mode": "embedding", + "output_cost_per_token": 0.0 + }, + "vercel_ai_gateway/openai/text-embedding-3-small": { + "input_cost_per_token": 2e-08, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 0, + "max_output_tokens": 0, + "max_tokens": 0, + "mode": "embedding", + "output_cost_per_token": 0.0 + }, + "vercel_ai_gateway/openai/text-embedding-ada-002": { + "input_cost_per_token": 1e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 0, + "max_output_tokens": 0, + "max_tokens": 0, + "mode": "embedding", + "output_cost_per_token": 0.0 + }, + "vercel_ai_gateway/perplexity/sonar": { + "input_cost_per_token": 1e-06, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 127000, + "max_output_tokens": 8000, + "max_tokens": 127000, + "mode": "chat", + "output_cost_per_token": 1e-06 + }, + "vercel_ai_gateway/perplexity/sonar-pro": { + "input_cost_per_token": 3e-06, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 200000, + "max_output_tokens": 8000, + "max_tokens": 200000, + "mode": "chat", + "output_cost_per_token": 1.5e-05 + }, + "vercel_ai_gateway/perplexity/sonar-reasoning": { + "input_cost_per_token": 1e-06, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 127000, + "max_output_tokens": 8000, + "max_tokens": 127000, + "mode": "chat", + "output_cost_per_token": 5e-06 + }, + "vercel_ai_gateway/perplexity/sonar-reasoning-pro": { + "input_cost_per_token": 2e-06, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 127000, + "max_output_tokens": 8000, + "max_tokens": 127000, + "mode": "chat", + "output_cost_per_token": 8e-06 + }, + "vercel_ai_gateway/vercel/v0-1.0-md": { + "input_cost_per_token": 3e-06, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 128000, + "max_output_tokens": 32000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.5e-05 + }, + "vercel_ai_gateway/vercel/v0-1.5-md": { + "input_cost_per_token": 3e-06, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 128000, + "max_output_tokens": 32768, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.5e-05 + }, + "vercel_ai_gateway/xai/grok-2": { + "input_cost_per_token": 2e-06, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 131072, + "max_output_tokens": 4000, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1e-05 + }, + "vercel_ai_gateway/xai/grok-2-vision": { + "input_cost_per_token": 2e-06, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 1e-05 + }, + "vercel_ai_gateway/xai/grok-3": { + "input_cost_per_token": 3e-06, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1.5e-05 + }, + "vercel_ai_gateway/xai/grok-3-fast": { + "input_cost_per_token": 5e-06, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 2.5e-05 + }, + "vercel_ai_gateway/xai/grok-3-mini": { + "input_cost_per_token": 3e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 5e-07 + }, + "vercel_ai_gateway/xai/grok-3-mini-fast": { + "input_cost_per_token": 6e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 4e-06 + }, + "vercel_ai_gateway/xai/grok-4": { + "input_cost_per_token": 3e-06, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 256000, + "max_output_tokens": 256000, + "max_tokens": 256000, + "mode": "chat", + "output_cost_per_token": 1.5e-05 + }, + "vercel_ai_gateway/zai/glm-4.5": { + "input_cost_per_token": 6e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 2.2e-06 + }, + "vercel_ai_gateway/zai/glm-4.5-air": { + "input_cost_per_token": 2e-07, + "litellm_provider": "vercel_ai_gateway", + "max_input_tokens": 128000, + "max_output_tokens": 96000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.1e-06 + }, + "vertex_ai/claude-3-5-haiku": { + "input_cost_per_token": 1e-06, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 5e-06, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_tool_choice": true + }, + "vertex_ai/claude-3-5-haiku@20241022": { + "input_cost_per_token": 1e-06, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 5e-06, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_tool_choice": true + }, + "vertex_ai/claude-3-5-sonnet": { + "input_cost_per_token": 3e-06, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/claude-3-5-sonnet-v2": { + "input_cost_per_token": 3e-06, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/claude-3-5-sonnet-v2@20241022": { + "input_cost_per_token": 3e-06, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/claude-3-5-sonnet@20240620": { + "input_cost_per_token": 3e-06, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/claude-3-7-sonnet@20250219": { + "cache_creation_input_token_cost": 3.75e-06, + "cache_read_input_token_cost": 3e-07, + "deprecation_date": "2025-06-01", + "input_cost_per_token": 3e-06, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "vertex_ai/claude-3-haiku": { + "input_cost_per_token": 2.5e-07, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.25e-06, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/claude-3-haiku@20240307": { + "input_cost_per_token": 2.5e-07, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.25e-06, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/claude-3-opus": { + "input_cost_per_token": 1.5e-05, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 7.5e-05, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/claude-3-opus@20240229": { + "input_cost_per_token": 1.5e-05, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 7.5e-05, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/claude-3-sonnet": { + "input_cost_per_token": 3e-06, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/claude-3-sonnet@20240229": { + "input_cost_per_token": 3e-06, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/claude-opus-4": { + "cache_creation_input_token_cost": 1.875e-05, + "cache_read_input_token_cost": 1.5e-06, + "input_cost_per_token": 1.5e-05, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 7.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "vertex_ai/claude-opus-4-1": { + "cache_creation_input_token_cost": 1.875e-05, + "cache_read_input_token_cost": 1.5e-06, + "input_cost_per_token": 1.5e-05, + "input_cost_per_token_batches": 7.5e-06, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 7.5e-05, + "output_cost_per_token_batches": 3.75e-05, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/claude-opus-4-1@20250805": { + "cache_creation_input_token_cost": 1.875e-05, + "cache_read_input_token_cost": 1.5e-06, + "input_cost_per_token": 1.5e-05, + "input_cost_per_token_batches": 7.5e-06, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 7.5e-05, + "output_cost_per_token_batches": 3.75e-05, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/claude-sonnet-4-5": { + "cache_creation_input_token_cost": 3.75e-06, + "cache_read_input_token_cost": 3e-07, + "input_cost_per_token": 3e-06, + "input_cost_per_token_batches": 1.5e-06, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 200000, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "output_cost_per_token_batches": 7.5e-06, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/claude-sonnet-4-5@20250929": { + "cache_creation_input_token_cost": 3.75e-06, + "cache_read_input_token_cost": 3e-07, + "input_cost_per_token": 3e-06, + "input_cost_per_token_batches": 1.5e-06, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 200000, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "output_cost_per_token_batches": 7.5e-06, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/claude-opus-4@20250514": { + "cache_creation_input_token_cost": 1.875e-05, + "cache_read_input_token_cost": 1.5e-06, + "input_cost_per_token": 1.5e-05, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 7.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "vertex_ai/claude-sonnet-4": { + "cache_creation_input_token_cost": 3.75e-06, + "cache_read_input_token_cost": 3e-07, + "input_cost_per_token": 3e-06, + "input_cost_per_token_above_200k_tokens": 6e-06, + "output_cost_per_token_above_200k_tokens": 2.25e-05, + "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, + "cache_read_input_token_cost_above_200k_tokens": 6e-07, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "vertex_ai/claude-sonnet-4@20250514": { + "cache_creation_input_token_cost": 3.75e-06, + "cache_read_input_token_cost": 3e-07, + "input_cost_per_token": 3e-06, + "input_cost_per_token_above_200k_tokens": 6e-06, + "output_cost_per_token_above_200k_tokens": 2.25e-05, + "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, + "cache_read_input_token_cost_above_200k_tokens": 6e-07, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "vertex_ai/codestral-2501": { + "input_cost_per_token": 2e-07, + "litellm_provider": "vertex_ai-mistral_models", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6e-07, + "supports_function_calling": true, + "supports_tool_choice": true + }, + "vertex_ai/codestral@2405": { + "input_cost_per_token": 2e-07, + "litellm_provider": "vertex_ai-mistral_models", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6e-07, + "supports_function_calling": true, + "supports_tool_choice": true + }, + "vertex_ai/codestral@latest": { + "input_cost_per_token": 2e-07, + "litellm_provider": "vertex_ai-mistral_models", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6e-07, + "supports_function_calling": true, + "supports_tool_choice": true + }, + "vertex_ai/deepseek-ai/deepseek-v3.1-maas": { + "input_cost_per_token": 1.35e-06, + "litellm_provider": "vertex_ai-deepseek_models", + "max_input_tokens": 163840, + "max_output_tokens": 32768, + "max_tokens": 163840, + "mode": "chat", + "output_cost_per_token": 5.4e-06, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#partner-models", + "supported_regions": [ + "us-west2" + ], + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "vertex_ai/deepseek-ai/deepseek-r1-0528-maas": { + "input_cost_per_token": 1.35e-06, + "litellm_provider": "vertex_ai-deepseek_models", + "max_input_tokens": 65336, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 5.4e-06, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#partner-models", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "vertex_ai/imagegeneration@006": { + "litellm_provider": "vertex_ai-image-models", + "mode": "image_generation", + "output_cost_per_image": 0.02, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" + }, + "vertex_ai/imagen-3.0-fast-generate-001": { + "litellm_provider": "vertex_ai-image-models", + "mode": "image_generation", + "output_cost_per_image": 0.02, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" + }, + "vertex_ai/imagen-3.0-generate-001": { + "litellm_provider": "vertex_ai-image-models", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" + }, + "vertex_ai/imagen-3.0-generate-002": { + "litellm_provider": "vertex_ai-image-models", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" + }, + "vertex_ai/imagen-4.0-fast-generate-001": { + "litellm_provider": "vertex_ai-image-models", + "mode": "image_generation", + "output_cost_per_image": 0.02, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" + }, + "vertex_ai/imagen-4.0-generate-001": { + "litellm_provider": "vertex_ai-image-models", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" + }, + "vertex_ai/imagen-4.0-ultra-generate-001": { + "litellm_provider": "vertex_ai-image-models", + "mode": "image_generation", + "output_cost_per_image": 0.06, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" + }, + "vertex_ai/jamba-1.5": { + "input_cost_per_token": 2e-07, + "litellm_provider": "vertex_ai-ai21_models", + "max_input_tokens": 256000, + "max_output_tokens": 256000, + "max_tokens": 256000, + "mode": "chat", + "output_cost_per_token": 4e-07, + "supports_tool_choice": true + }, + "vertex_ai/jamba-1.5-large": { + "input_cost_per_token": 2e-06, + "litellm_provider": "vertex_ai-ai21_models", + "max_input_tokens": 256000, + "max_output_tokens": 256000, + "max_tokens": 256000, + "mode": "chat", + "output_cost_per_token": 8e-06, + "supports_tool_choice": true + }, + "vertex_ai/jamba-1.5-large@001": { + "input_cost_per_token": 2e-06, + "litellm_provider": "vertex_ai-ai21_models", + "max_input_tokens": 256000, + "max_output_tokens": 256000, + "max_tokens": 256000, + "mode": "chat", + "output_cost_per_token": 8e-06, + "supports_tool_choice": true + }, + "vertex_ai/jamba-1.5-mini": { + "input_cost_per_token": 2e-07, + "litellm_provider": "vertex_ai-ai21_models", + "max_input_tokens": 256000, + "max_output_tokens": 256000, + "max_tokens": 256000, + "mode": "chat", + "output_cost_per_token": 4e-07, + "supports_tool_choice": true + }, + "vertex_ai/jamba-1.5-mini@001": { + "input_cost_per_token": 2e-07, + "litellm_provider": "vertex_ai-ai21_models", + "max_input_tokens": 256000, + "max_output_tokens": 256000, + "max_tokens": 256000, + "mode": "chat", + "output_cost_per_token": 4e-07, + "supports_tool_choice": true + }, + "vertex_ai/meta/llama-3.1-405b-instruct-maas": { + "input_cost_per_token": 5e-06, + "litellm_provider": "vertex_ai-llama_models", + "max_input_tokens": 128000, + "max_output_tokens": 2048, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.6e-05, + "source": "https://console.cloud.google.com/vertex-ai/publishers/meta/model-garden/llama-3.2-90b-vision-instruct-maas", + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/meta/llama-3.1-70b-instruct-maas": { + "input_cost_per_token": 0.0, + "litellm_provider": "vertex_ai-llama_models", + "max_input_tokens": 128000, + "max_output_tokens": 2048, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.0, + "source": "https://console.cloud.google.com/vertex-ai/publishers/meta/model-garden/llama-3.2-90b-vision-instruct-maas", + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/meta/llama-3.1-8b-instruct-maas": { + "input_cost_per_token": 0.0, + "litellm_provider": "vertex_ai-llama_models", + "max_input_tokens": 128000, + "max_output_tokens": 2048, + "max_tokens": 128000, + "metadata": { + "notes": "VertexAI states that The Llama 3.1 API service for llama-3.1-70b-instruct-maas and llama-3.1-8b-instruct-maas are in public preview and at no cost." + }, + "mode": "chat", + "output_cost_per_token": 0.0, + "source": "https://console.cloud.google.com/vertex-ai/publishers/meta/model-garden/llama-3.2-90b-vision-instruct-maas", + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/meta/llama-3.2-90b-vision-instruct-maas": { + "input_cost_per_token": 0.0, + "litellm_provider": "vertex_ai-llama_models", + "max_input_tokens": 128000, + "max_output_tokens": 2048, + "max_tokens": 128000, + "metadata": { + "notes": "VertexAI states that The Llama 3.2 API service is at no cost during public preview, and will be priced as per dollar-per-1M-tokens at GA." + }, + "mode": "chat", + "output_cost_per_token": 0.0, + "source": "https://console.cloud.google.com/vertex-ai/publishers/meta/model-garden/llama-3.2-90b-vision-instruct-maas", + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/meta/llama-4-maverick-17b-128e-instruct-maas": { + "input_cost_per_token": 3.5e-07, + "litellm_provider": "vertex_ai-llama_models", + "max_input_tokens": 1000000, + "max_output_tokens": 1000000, + "max_tokens": 1000000, + "mode": "chat", + "output_cost_per_token": 1.15e-06, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#partner-models", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text", + "code" + ], + "supports_function_calling": true, + "supports_tool_choice": true + }, + "vertex_ai/meta/llama-4-maverick-17b-16e-instruct-maas": { + "input_cost_per_token": 3.5e-07, + "litellm_provider": "vertex_ai-llama_models", + "max_input_tokens": 1000000, + "max_output_tokens": 1000000, + "max_tokens": 1000000, + "mode": "chat", + "output_cost_per_token": 1.15e-06, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#partner-models", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text", + "code" + ], + "supports_function_calling": true, + "supports_tool_choice": true + }, + "vertex_ai/meta/llama-4-scout-17b-128e-instruct-maas": { + "input_cost_per_token": 2.5e-07, + "litellm_provider": "vertex_ai-llama_models", + "max_input_tokens": 10000000, + "max_output_tokens": 10000000, + "max_tokens": 10000000, + "mode": "chat", + "output_cost_per_token": 7e-07, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#partner-models", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text", + "code" + ], + "supports_function_calling": true, + "supports_tool_choice": true + }, + "vertex_ai/meta/llama-4-scout-17b-16e-instruct-maas": { + "input_cost_per_token": 2.5e-07, + "litellm_provider": "vertex_ai-llama_models", + "max_input_tokens": 10000000, + "max_output_tokens": 10000000, + "max_tokens": 10000000, + "mode": "chat", + "output_cost_per_token": 7e-07, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#partner-models", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text", + "code" + ], + "supports_function_calling": true, + "supports_tool_choice": true + }, + "vertex_ai/meta/llama3-405b-instruct-maas": { + "input_cost_per_token": 0.0, + "litellm_provider": "vertex_ai-llama_models", + "max_input_tokens": 32000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.0, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#partner-models", + "supports_tool_choice": true + }, + "vertex_ai/meta/llama3-70b-instruct-maas": { + "input_cost_per_token": 0.0, + "litellm_provider": "vertex_ai-llama_models", + "max_input_tokens": 32000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.0, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#partner-models", + "supports_tool_choice": true + }, + "vertex_ai/meta/llama3-8b-instruct-maas": { + "input_cost_per_token": 0.0, + "litellm_provider": "vertex_ai-llama_models", + "max_input_tokens": 32000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 0.0, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#partner-models", + "supports_tool_choice": true + }, + "vertex_ai/mistral-large-2411": { + "input_cost_per_token": 2e-06, + "litellm_provider": "vertex_ai-mistral_models", + "max_input_tokens": 128000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 6e-06, + "supports_function_calling": true, + "supports_tool_choice": true + }, + "vertex_ai/mistral-large@2407": { + "input_cost_per_token": 2e-06, + "litellm_provider": "vertex_ai-mistral_models", + "max_input_tokens": 128000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 6e-06, + "supports_function_calling": true, + "supports_tool_choice": true + }, + "vertex_ai/mistral-large@2411-001": { + "input_cost_per_token": 2e-06, + "litellm_provider": "vertex_ai-mistral_models", + "max_input_tokens": 128000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 6e-06, + "supports_function_calling": true, + "supports_tool_choice": true + }, + "vertex_ai/mistral-large@latest": { + "input_cost_per_token": 2e-06, + "litellm_provider": "vertex_ai-mistral_models", + "max_input_tokens": 128000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 6e-06, + "supports_function_calling": true, + "supports_tool_choice": true + }, + "vertex_ai/mistral-nemo@2407": { + "input_cost_per_token": 3e-06, + "litellm_provider": "vertex_ai-mistral_models", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3e-06, + "supports_function_calling": true, + "supports_tool_choice": true + }, + "vertex_ai/mistral-nemo@latest": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "vertex_ai-mistral_models", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.5e-07, + "supports_function_calling": true, + "supports_tool_choice": true + }, + "vertex_ai/mistral-small-2503": { + "input_cost_per_token": 1e-06, + "litellm_provider": "vertex_ai-mistral_models", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3e-06, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "vertex_ai/mistral-small-2503@001": { + "input_cost_per_token": 1e-06, + "litellm_provider": "vertex_ai-mistral_models", + "max_input_tokens": 32000, + "max_output_tokens": 8191, + "max_tokens": 8191, + "mode": "chat", + "output_cost_per_token": 3e-06, + "supports_function_calling": true, + "supports_tool_choice": true + }, + "vertex_ai/openai/gpt-oss-120b-maas": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "vertex_ai-openai_models", + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 6e-07, + "source": "https://console.cloud.google.com/vertex-ai/publishers/openai/model-garden/gpt-oss-120b-maas", + "supports_reasoning": true + }, + "vertex_ai/openai/gpt-oss-20b-maas": { + "input_cost_per_token": 7.5e-08, + "litellm_provider": "vertex_ai-openai_models", + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 3e-07, + "source": "https://console.cloud.google.com/vertex-ai/publishers/openai/model-garden/gpt-oss-120b-maas", + "supports_reasoning": true + }, + "vertex_ai/qwen/qwen3-235b-a22b-instruct-2507-maas": { + "input_cost_per_token": 2.5e-07, + "litellm_provider": "vertex_ai-qwen_models", + "max_input_tokens": 262144, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1e-06, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "supports_function_calling": true, + "supports_tool_choice": true + }, + "vertex_ai/qwen/qwen3-coder-480b-a35b-instruct-maas": { + "input_cost_per_token": 1e-06, + "litellm_provider": "vertex_ai-qwen_models", + "max_input_tokens": 262144, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 4e-06, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "supports_function_calling": true, + "supports_tool_choice": true + }, + "vertex_ai/qwen/qwen3-next-80b-a3b-instruct-maas": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "vertex_ai-qwen_models", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "supports_function_calling": true, + "supports_tool_choice": true + }, + "vertex_ai/qwen/qwen3-next-80b-a3b-thinking-maas": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "vertex_ai-qwen_models", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "supports_function_calling": true, + "supports_tool_choice": true + }, + "vertex_ai/veo-2.0-generate-001": { + "litellm_provider": "vertex_ai-video-models", + "max_input_tokens": 1024, + "max_tokens": 1024, + "mode": "video_generation", + "output_cost_per_second": 0.35, + "source": "https://ai.google.dev/gemini-api/docs/video", + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "vertex_ai/veo-3.0-fast-generate-preview": { + "litellm_provider": "vertex_ai-video-models", + "max_input_tokens": 1024, + "max_tokens": 1024, + "mode": "video_generation", + "output_cost_per_second": 0.4, + "source": "https://ai.google.dev/gemini-api/docs/video", + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "vertex_ai/veo-3.0-generate-preview": { + "litellm_provider": "vertex_ai-video-models", + "max_input_tokens": 1024, + "max_tokens": 1024, + "mode": "video_generation", + "output_cost_per_second": 0.75, + "source": "https://ai.google.dev/gemini-api/docs/video", + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "video" + ] + }, + "voyage/rerank-2": { + "input_cost_per_query": 5e-08, + "input_cost_per_token": 5e-08, + "litellm_provider": "voyage", + "max_input_tokens": 16000, + "max_output_tokens": 16000, + "max_query_tokens": 16000, + "max_tokens": 16000, + "mode": "rerank", + "output_cost_per_token": 0.0 + }, + "voyage/rerank-2-lite": { + "input_cost_per_query": 2e-08, + "input_cost_per_token": 2e-08, + "litellm_provider": "voyage", + "max_input_tokens": 8000, + "max_output_tokens": 8000, + "max_query_tokens": 8000, + "max_tokens": 8000, + "mode": "rerank", + "output_cost_per_token": 0.0 + }, + "voyage/voyage-2": { + "input_cost_per_token": 1e-07, + "litellm_provider": "voyage", + "max_input_tokens": 4000, + "max_tokens": 4000, + "mode": "embedding", + "output_cost_per_token": 0.0 + }, + "voyage/voyage-3": { + "input_cost_per_token": 6e-08, + "litellm_provider": "voyage", + "max_input_tokens": 32000, + "max_tokens": 32000, + "mode": "embedding", + "output_cost_per_token": 0.0 + }, + "voyage/voyage-3-large": { + "input_cost_per_token": 1.8e-07, + "litellm_provider": "voyage", + "max_input_tokens": 32000, + "max_tokens": 32000, + "mode": "embedding", + "output_cost_per_token": 0.0 + }, + "voyage/voyage-3-lite": { + "input_cost_per_token": 2e-08, + "litellm_provider": "voyage", + "max_input_tokens": 32000, + "max_tokens": 32000, + "mode": "embedding", + "output_cost_per_token": 0.0 + }, + "voyage/voyage-code-2": { + "input_cost_per_token": 1.2e-07, + "litellm_provider": "voyage", + "max_input_tokens": 16000, + "max_tokens": 16000, + "mode": "embedding", + "output_cost_per_token": 0.0 + }, + "voyage/voyage-code-3": { + "input_cost_per_token": 1.8e-07, + "litellm_provider": "voyage", + "max_input_tokens": 32000, + "max_tokens": 32000, + "mode": "embedding", + "output_cost_per_token": 0.0 + }, + "voyage/voyage-context-3": { + "input_cost_per_token": 1.8e-07, + "litellm_provider": "voyage", + "max_input_tokens": 120000, + "max_tokens": 120000, + "mode": "embedding", + "output_cost_per_token": 0.0 + }, + "voyage/voyage-finance-2": { + "input_cost_per_token": 1.2e-07, + "litellm_provider": "voyage", + "max_input_tokens": 32000, + "max_tokens": 32000, + "mode": "embedding", + "output_cost_per_token": 0.0 + }, + "voyage/voyage-large-2": { + "input_cost_per_token": 1.2e-07, + "litellm_provider": "voyage", + "max_input_tokens": 16000, + "max_tokens": 16000, + "mode": "embedding", + "output_cost_per_token": 0.0 + }, + "voyage/voyage-law-2": { + "input_cost_per_token": 1.2e-07, + "litellm_provider": "voyage", + "max_input_tokens": 16000, + "max_tokens": 16000, + "mode": "embedding", + "output_cost_per_token": 0.0 + }, + "voyage/voyage-lite-01": { + "input_cost_per_token": 1e-07, + "litellm_provider": "voyage", + "max_input_tokens": 4096, + "max_tokens": 4096, + "mode": "embedding", + "output_cost_per_token": 0.0 + }, + "voyage/voyage-lite-02-instruct": { + "input_cost_per_token": 1e-07, + "litellm_provider": "voyage", + "max_input_tokens": 4000, + "max_tokens": 4000, + "mode": "embedding", + "output_cost_per_token": 0.0 + }, + "voyage/voyage-multimodal-3": { + "input_cost_per_token": 1.2e-07, + "litellm_provider": "voyage", + "max_input_tokens": 32000, + "max_tokens": 32000, + "mode": "embedding", + "output_cost_per_token": 0.0 + }, + "wandb/openai/gpt-oss-120b": { + "max_tokens": 131072, + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "input_cost_per_token": 0.015, + "output_cost_per_token": 0.06, + "litellm_provider": "wandb", + "mode": "chat" + }, + "wandb/openai/gpt-oss-20b": { + "max_tokens": 131072, + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "input_cost_per_token": 0.005, + "output_cost_per_token": 0.02, + "litellm_provider": "wandb", + "mode": "chat" + }, + "wandb/zai-org/GLM-4.5": { + "max_tokens": 131072, + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "input_cost_per_token": 0.055, + "output_cost_per_token": 0.2, + "litellm_provider": "wandb", + "mode": "chat" + }, + "wandb/Qwen/Qwen3-235B-A22B-Instruct-2507": { + "max_tokens": 262144, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "input_cost_per_token": 0.01, + "output_cost_per_token": 0.01, + "litellm_provider": "wandb", + "mode": "chat" + }, + "wandb/Qwen/Qwen3-Coder-480B-A35B-Instruct": { + "max_tokens": 262144, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "input_cost_per_token": 0.1, + "output_cost_per_token": 0.15, + "litellm_provider": "wandb", + "mode": "chat" + }, + "wandb/Qwen/Qwen3-235B-A22B-Thinking-2507": { + "max_tokens": 262144, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "input_cost_per_token": 0.01, + "output_cost_per_token": 0.01, + "litellm_provider": "wandb", + "mode": "chat" + }, + "wandb/moonshotai/Kimi-K2-Instruct": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 0.135, + "output_cost_per_token": 0.4, + "litellm_provider": "wandb", + "mode": "chat" + }, + "wandb/meta-llama/Llama-3.1-8B-Instruct": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 0.022, + "output_cost_per_token": 0.022, + "litellm_provider": "wandb", + "mode": "chat" + }, + "wandb/deepseek-ai/DeepSeek-V3.1": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 0.055, + "output_cost_per_token": 0.165, + "litellm_provider": "wandb", + "mode": "chat" + }, + "wandb/deepseek-ai/DeepSeek-R1-0528": { + "max_tokens": 161000, + "max_input_tokens": 161000, + "max_output_tokens": 161000, + "input_cost_per_token": 0.135, + "output_cost_per_token": 0.54, + "litellm_provider": "wandb", + "mode": "chat" + }, + "wandb/deepseek-ai/DeepSeek-V3-0324": { + "max_tokens": 161000, + "max_input_tokens": 161000, + "max_output_tokens": 161000, + "input_cost_per_token": 0.114, + "output_cost_per_token": 0.275, + "litellm_provider": "wandb", + "mode": "chat" + }, + "wandb/meta-llama/Llama-3.3-70B-Instruct": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 0.071, + "output_cost_per_token": 0.071, + "litellm_provider": "wandb", + "mode": "chat" + }, + "wandb/meta-llama/Llama-4-Scout-17B-16E-Instruct": { + "max_tokens": 64000, + "max_input_tokens": 64000, + "max_output_tokens": 64000, + "input_cost_per_token": 0.017, + "output_cost_per_token": 0.066, + "litellm_provider": "wandb", + "mode": "chat" + }, + "wandb/microsoft/Phi-4-mini-instruct": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 0.008, + "output_cost_per_token": 0.035, + "litellm_provider": "wandb", + "mode": "chat" + }, + "watsonx/ibm/granite-3-8b-instruct": { + "input_cost_per_token": 0.0002, + "litellm_provider": "watsonx", + "max_input_tokens": 8192, + "max_output_tokens": 1024, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 0.0002, + "supports_audio_input": false, + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": false, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "watsonx/mistralai/mistral-large": { + "input_cost_per_token": 3e-06, + "litellm_provider": "watsonx", + "max_input_tokens": 131072, + "max_output_tokens": 16384, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1e-05, + "supports_audio_input": false, + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": false, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "whisper-1": { + "input_cost_per_second": 0.0001, + "litellm_provider": "openai", + "mode": "audio_transcription", + "output_cost_per_second": 0.0001, + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, + "vertex_ai/qwen/qwen3-next-80b-a3b-instruct-maas": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "vertex_ai-qwen_models", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "supports_function_calling": true, + "supports_tool_choice": true + }, + "vertex_ai/qwen/qwen3-next-80b-a3b-thinking-maas": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "vertex_ai-qwen_models", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "supports_function_calling": true, + "supports_tool_choice": true + }, + "xai/grok-2": { + "input_cost_per_token": 2e-06, + "litellm_provider": "xai", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1e-05, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_web_search": true + }, + "xai/grok-2-1212": { + "input_cost_per_token": 2e-06, + "litellm_provider": "xai", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1e-05, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_web_search": true + }, + "xai/grok-2-latest": { + "input_cost_per_token": 2e-06, + "litellm_provider": "xai", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1e-05, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_web_search": true + }, + "xai/grok-2-vision": { + "input_cost_per_image": 2e-06, + "input_cost_per_token": 2e-06, + "litellm_provider": "xai", + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 1e-05, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "xai/grok-2-vision-1212": { + "input_cost_per_image": 2e-06, + "input_cost_per_token": 2e-06, + "litellm_provider": "xai", + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 1e-05, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "xai/grok-2-vision-latest": { + "input_cost_per_image": 2e-06, + "input_cost_per_token": 2e-06, + "litellm_provider": "xai", + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 1e-05, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "xai/grok-3": { + "input_cost_per_token": 3e-06, + "litellm_provider": "xai", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "source": "https://x.ai/api#pricing", + "supports_function_calling": true, + "supports_response_schema": false, + "supports_tool_choice": true, + "supports_web_search": true + }, + "xai/grok-3-beta": { + "input_cost_per_token": 3e-06, + "litellm_provider": "xai", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "source": "https://x.ai/api#pricing", + "supports_function_calling": true, + "supports_response_schema": false, + "supports_tool_choice": true, + "supports_web_search": true + }, + "xai/grok-3-fast-beta": { + "input_cost_per_token": 5e-06, + "litellm_provider": "xai", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 2.5e-05, + "source": "https://x.ai/api#pricing", + "supports_function_calling": true, + "supports_response_schema": false, + "supports_tool_choice": true, + "supports_web_search": true + }, + "xai/grok-3-fast-latest": { + "input_cost_per_token": 5e-06, + "litellm_provider": "xai", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 2.5e-05, + "source": "https://x.ai/api#pricing", + "supports_function_calling": true, + "supports_response_schema": false, + "supports_tool_choice": true, + "supports_web_search": true + }, + "xai/grok-3-latest": { + "input_cost_per_token": 3e-06, + "litellm_provider": "xai", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "source": "https://x.ai/api#pricing", + "supports_function_calling": true, + "supports_response_schema": false, + "supports_tool_choice": true, + "supports_web_search": true + }, + "xai/grok-3-mini": { + "input_cost_per_token": 3e-07, + "litellm_provider": "xai", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 5e-07, + "source": "https://x.ai/api#pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": true, + "supports_web_search": true + }, + "xai/grok-3-mini-beta": { + "input_cost_per_token": 3e-07, + "litellm_provider": "xai", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 5e-07, + "source": "https://x.ai/api#pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": true, + "supports_web_search": true + }, + "xai/grok-3-mini-fast": { + "input_cost_per_token": 6e-07, + "litellm_provider": "xai", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 4e-06, + "source": "https://x.ai/api#pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": true, + "supports_web_search": true + }, + "xai/grok-3-mini-fast-beta": { + "input_cost_per_token": 6e-07, + "litellm_provider": "xai", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 4e-06, + "source": "https://x.ai/api#pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": true, + "supports_web_search": true + }, + "xai/grok-3-mini-fast-latest": { + "input_cost_per_token": 6e-07, + "litellm_provider": "xai", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 4e-06, + "source": "https://x.ai/api#pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": true, + "supports_web_search": true + }, + "xai/grok-3-mini-latest": { + "input_cost_per_token": 3e-07, + "litellm_provider": "xai", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 5e-07, + "source": "https://x.ai/api#pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_tool_choice": true, + "supports_web_search": true + }, + "xai/grok-4": { + "input_cost_per_token": 3e-06, + "litellm_provider": "xai", + "max_input_tokens": 256000, + "max_output_tokens": 256000, + "max_tokens": 256000, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "source": "https://docs.x.ai/docs/models", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_web_search": true + }, + "xai/grok-4-fast-reasoning": { + "litellm_provider": "xai", + "max_input_tokens": 2e6, + "max_output_tokens": 2e6, + "max_tokens": 2e6, + "mode": "chat", + "input_cost_per_token": 0.2e-06, + "output_cost_per_token": 0.5e-06, + "cache_read_input_token_cost": 0.05e-06, + "source": "https://docs.x.ai/docs/models", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_web_search": true + }, + "xai/grok-4-fast-non-reasoning": { + "litellm_provider": "xai", + "max_input_tokens": 2e6, + "max_output_tokens": 2e6, + "cache_read_input_token_cost": 0.05e-06, + "max_tokens": 2e6, + "mode": "chat", + "input_cost_per_token": 0.2e-06, + "output_cost_per_token": 0.5e-06, + "source": "https://docs.x.ai/docs/models", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_web_search": true + }, + "xai/grok-4-0709": { + "input_cost_per_token": 3e-06, + "litellm_provider": "xai", + "max_input_tokens": 256000, + "max_output_tokens": 256000, + "max_tokens": 256000, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "source": "https://docs.x.ai/docs/models", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_web_search": true + }, + "xai/grok-4-latest": { + "input_cost_per_token": 3e-06, + "litellm_provider": "xai", + "max_input_tokens": 256000, + "max_output_tokens": 256000, + "max_tokens": 256000, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "source": "https://docs.x.ai/docs/models", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_web_search": true + }, + "xai/grok-beta": { + "input_cost_per_token": 5e-06, + "litellm_provider": "xai", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "xai/grok-code-fast": { + "cache_read_input_token_cost": 2e-08, + "input_cost_per_token": 2e-07, + "litellm_provider": "xai", + "max_input_tokens": 256000, + "max_output_tokens": 256000, + "max_tokens": 256000, + "mode": "chat", + "output_cost_per_token": 1.5e-06, + "source": "https://docs.x.ai/docs/models", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "xai/grok-code-fast-1": { + "cache_read_input_token_cost": 2e-08, + "input_cost_per_token": 2e-07, + "litellm_provider": "xai", + "max_input_tokens": 256000, + "max_output_tokens": 256000, + "max_tokens": 256000, + "mode": "chat", + "output_cost_per_token": 1.5e-06, + "source": "https://docs.x.ai/docs/models", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "xai/grok-code-fast-1-0825": { + "cache_read_input_token_cost": 2e-08, + "input_cost_per_token": 2e-07, + "litellm_provider": "xai", + "max_input_tokens": 256000, + "max_output_tokens": 256000, + "max_tokens": 256000, + "mode": "chat", + "output_cost_per_token": 1.5e-06, + "source": "https://docs.x.ai/docs/models", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "xai/grok-vision-beta": { + "input_cost_per_image": 5e-06, + "input_cost_per_token": 5e-06, + "litellm_provider": "xai", + "max_input_tokens": 8192, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + } +} diff --git a/scripts/analyze-log-sessions.js b/scripts/analyze-log-sessions.js new file mode 100644 index 0000000000000000000000000000000000000000..da76521e901707585c98be11f362582b94448f49 --- /dev/null +++ b/scripts/analyze-log-sessions.js @@ -0,0 +1,606 @@ +#!/usr/bin/env node + +/** + * 从日志文件分析Claude账户请求时间的CLI工具 + * 用于恢复会话窗口数据 + */ + +const fs = require('fs') +const path = require('path') +const readline = require('readline') +const zlib = require('zlib') +const redis = require('../src/models/redis') + +class LogSessionAnalyzer { + constructor() { + // 更新正则表达式以匹配实际的日志格式 + this.accountUsagePattern = + /🎯 Using sticky session shared account: (.+?) \(([a-f0-9-]{36})\) for session ([a-f0-9]+)/ + this.processingPattern = + /📡 Processing streaming API request with usage capture for key: (.+?), account: ([a-f0-9-]{36}), session: ([a-f0-9]+)/ + this.completedPattern = /🔗 ✅ Request completed in (\d+)ms for key: (.+)/ + this.usageRecordedPattern = + /🔗 📊 Stream usage recorded \(real\) - Model: (.+?), Input: (\d+), Output: (\d+), Cache Create: (\d+), Cache Read: (\d+), Total: (\d+) tokens/ + this.timestampPattern = /\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]/ + this.accounts = new Map() + this.requestHistory = [] + this.sessions = new Map() // 记录会话信息 + } + + // 解析时间戳 + parseTimestamp(line) { + const match = line.match(this.timestampPattern) + if (match) { + return new Date(match[1]) + } + return null + } + + // 分析单个日志文件 + async analyzeLogFile(filePath) { + console.log(`📖 分析日志文件: ${filePath}`) + + let fileStream = fs.createReadStream(filePath) + + // 如果是gz文件,需要先解压 + if (filePath.endsWith('.gz')) { + console.log(' 🗜️ 检测到gz压缩文件,正在解压...') + fileStream = fileStream.pipe(zlib.createGunzip()) + } + + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity + }) + + let lineCount = 0 + let requestCount = 0 + let usageCount = 0 + + for await (const line of rl) { + lineCount++ + + // 解析时间戳 + const timestamp = this.parseTimestamp(line) + if (!timestamp) { + continue + } + + // 查找账户使用记录 + const accountUsageMatch = line.match(this.accountUsagePattern) + if (accountUsageMatch) { + const accountName = accountUsageMatch[1] + const accountId = accountUsageMatch[2] + const sessionId = accountUsageMatch[3] + + if (!this.accounts.has(accountId)) { + this.accounts.set(accountId, { + accountId, + accountName, + requests: [], + firstRequest: timestamp, + lastRequest: timestamp, + totalRequests: 0, + sessions: new Set() + }) + } + + const account = this.accounts.get(accountId) + account.sessions.add(sessionId) + + if (timestamp < account.firstRequest) { + account.firstRequest = timestamp + } + if (timestamp > account.lastRequest) { + account.lastRequest = timestamp + } + } + + // 查找请求处理记录 + const processingMatch = line.match(this.processingPattern) + if (processingMatch) { + const apiKeyName = processingMatch[1] + const accountId = processingMatch[2] + const sessionId = processingMatch[3] + + if (!this.accounts.has(accountId)) { + this.accounts.set(accountId, { + accountId, + accountName: 'Unknown', + requests: [], + firstRequest: timestamp, + lastRequest: timestamp, + totalRequests: 0, + sessions: new Set() + }) + } + + const account = this.accounts.get(accountId) + account.requests.push({ + timestamp, + apiKeyName, + sessionId, + type: 'processing' + }) + + account.sessions.add(sessionId) + account.totalRequests++ + requestCount++ + + if (timestamp > account.lastRequest) { + account.lastRequest = timestamp + } + + // 记录到全局请求历史 + this.requestHistory.push({ + timestamp, + accountId, + apiKeyName, + sessionId, + type: 'processing' + }) + } + + // 查找请求完成记录 + const completedMatch = line.match(this.completedPattern) + if (completedMatch) { + const duration = parseInt(completedMatch[1]) + const apiKeyName = completedMatch[2] + + // 记录到全局请求历史 + this.requestHistory.push({ + timestamp, + apiKeyName, + duration, + type: 'completed' + }) + } + + // 查找使用统计记录 + const usageMatch = line.match(this.usageRecordedPattern) + if (usageMatch) { + const model = usageMatch[1] + const inputTokens = parseInt(usageMatch[2]) + const outputTokens = parseInt(usageMatch[3]) + const cacheCreateTokens = parseInt(usageMatch[4]) + const cacheReadTokens = parseInt(usageMatch[5]) + const totalTokens = parseInt(usageMatch[6]) + + usageCount++ + + // 记录到全局请求历史 + this.requestHistory.push({ + timestamp, + type: 'usage', + model, + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens, + totalTokens + }) + } + } + + console.log( + ` 📊 解析完成: ${lineCount} 行, 找到 ${requestCount} 个请求记录, ${usageCount} 个使用统计` + ) + } + + // 分析日志目录中的所有文件 + async analyzeLogDirectory(logDir = './logs') { + console.log(`🔍 扫描日志目录: ${logDir}\n`) + + try { + const files = fs.readdirSync(logDir) + const logFiles = files + .filter( + (file) => + file.includes('claude-relay') && + (file.endsWith('.log') || + file.endsWith('.log.1') || + file.endsWith('.log.gz') || + file.match(/\.log\.\d+\.gz$/) || + file.match(/\.log\.\d+$/)) + ) + .sort() + .reverse() // 最新的文件优先 + + if (logFiles.length === 0) { + console.log('❌ 没有找到日志文件') + return + } + + console.log(`📁 找到 ${logFiles.length} 个日志文件:`) + logFiles.forEach((file) => console.log(` - ${file}`)) + console.log('') + + // 分析每个文件 + for (const file of logFiles) { + const filePath = path.join(logDir, file) + await this.analyzeLogFile(filePath) + } + } catch (error) { + console.error(`❌ 读取日志目录失败: ${error.message}`) + throw error + } + } + + // 分析单个日志文件(支持直接传入文件路径) + async analyzeSingleFile(filePath) { + console.log(`🔍 分析单个日志文件: ${filePath}\n`) + + try { + if (!fs.existsSync(filePath)) { + console.log('❌ 文件不存在') + return + } + + await this.analyzeLogFile(filePath) + } catch (error) { + console.error(`❌ 分析文件失败: ${error.message}`) + throw error + } + } + + // 计算会话窗口 + calculateSessionWindow(requestTime) { + const hour = requestTime.getHours() + const windowStartHour = Math.floor(hour / 5) * 5 + + const windowStart = new Date(requestTime) + windowStart.setHours(windowStartHour, 0, 0, 0) + + const windowEnd = new Date(windowStart) + windowEnd.setHours(windowEnd.getHours() + 5) + + return { windowStart, windowEnd } + } + + // 分析会话窗口 + analyzeSessionWindows() { + console.log('🕐 分析会话窗口...\n') + + const now = new Date() + const results = [] + + for (const [accountId, accountData] of this.accounts) { + const requests = accountData.requests.sort((a, b) => a.timestamp - b.timestamp) + + // 按会话窗口分组请求 + const windowGroups = new Map() + + for (const request of requests) { + const { windowStart, windowEnd } = this.calculateSessionWindow(request.timestamp) + const windowKey = `${windowStart.getTime()}-${windowEnd.getTime()}` + + if (!windowGroups.has(windowKey)) { + windowGroups.set(windowKey, { + windowStart, + windowEnd, + requests: [], + isActive: now >= windowStart && now < windowEnd + }) + } + + windowGroups.get(windowKey).requests.push(request) + } + + // 转换为数组并排序 + const windowArray = Array.from(windowGroups.values()).sort( + (a, b) => b.windowStart - a.windowStart + ) // 最新的窗口优先 + + const result = { + accountId, + accountName: accountData.accountName, + totalRequests: accountData.totalRequests, + firstRequest: accountData.firstRequest, + lastRequest: accountData.lastRequest, + sessions: accountData.sessions, + windows: windowArray, + currentActiveWindow: windowArray.find((w) => w.isActive) || null, + mostRecentWindow: windowArray[0] || null + } + + results.push(result) + } + + return results.sort((a, b) => b.lastRequest - a.lastRequest) + } + + // 显示分析结果 + displayResults(results) { + console.log('📊 分析结果:\n') + console.log('='.repeat(80)) + + for (const result of results) { + console.log(`🏢 账户: ${result.accountName || 'Unknown'} (${result.accountId})`) + console.log(` 总请求数: ${result.totalRequests}`) + console.log(` 会话数: ${result.sessions ? result.sessions.size : 0}`) + console.log(` 首次请求: ${result.firstRequest.toLocaleString()}`) + console.log(` 最后请求: ${result.lastRequest.toLocaleString()}`) + + if (result.currentActiveWindow) { + console.log( + ` ✅ 当前活跃窗口: ${result.currentActiveWindow.windowStart.toLocaleString()} - ${result.currentActiveWindow.windowEnd.toLocaleString()}` + ) + console.log(` 窗口内请求: ${result.currentActiveWindow.requests.length} 次`) + const progress = this.calculateWindowProgress( + result.currentActiveWindow.windowStart, + result.currentActiveWindow.windowEnd + ) + console.log(` 窗口进度: ${progress}%`) + } else if (result.mostRecentWindow) { + const window = result.mostRecentWindow + console.log( + ` ⏰ 最近窗口(已过期): ${window.windowStart.toLocaleString()} - ${window.windowEnd.toLocaleString()}` + ) + console.log(` 窗口内请求: ${window.requests.length} 次`) + const hoursAgo = Math.round((new Date() - window.windowEnd) / (1000 * 60 * 60)) + console.log(` 过期时间: ${hoursAgo} 小时前`) + } else { + console.log(' ❌ 无会话窗口数据') + } + + // 显示最近几个窗口 + if (result.windows.length > 1) { + console.log(` 📈 历史窗口: ${result.windows.length} 个`) + const recentWindows = result.windows.slice(0, 3) + for (let i = 0; i < recentWindows.length; i++) { + const window = recentWindows[i] + const status = window.isActive ? '活跃' : '已过期' + console.log( + ` ${i + 1}. ${window.windowStart.toLocaleString()} - ${window.windowEnd.toLocaleString()} (${status}, ${window.requests.length}次请求)` + ) + } + } + + // 显示最近几个会话的API Key使用情况 + const accountData = this.accounts.get(result.accountId) + if (accountData && accountData.requests && accountData.requests.length > 0) { + const apiKeyStats = {} + + for (const req of accountData.requests) { + if (!apiKeyStats[req.apiKeyName]) { + apiKeyStats[req.apiKeyName] = 0 + } + apiKeyStats[req.apiKeyName]++ + } + + console.log(' 🔑 API Key使用统计:') + for (const [keyName, count] of Object.entries(apiKeyStats)) { + console.log(` - ${keyName}: ${count} 次`) + } + } + + console.log('') + } + + console.log('='.repeat(80)) + console.log(`总计: ${results.length} 个账户, ${this.requestHistory.length} 个日志记录\n`) + } + + // 计算窗口进度百分比 + calculateWindowProgress(windowStart, windowEnd) { + const now = new Date() + const totalDuration = windowEnd.getTime() - windowStart.getTime() + const elapsedTime = now.getTime() - windowStart.getTime() + return Math.max(0, Math.min(100, Math.round((elapsedTime / totalDuration) * 100))) + } + + // 更新Redis中的会话窗口数据 + async updateRedisSessionWindows(results, dryRun = true) { + if (dryRun) { + console.log('🧪 模拟模式 - 不会实际更新Redis数据\n') + } else { + console.log('💾 更新Redis中的会话窗口数据...\n') + await redis.connect() + } + + let updatedCount = 0 + let skippedCount = 0 + + for (const result of results) { + try { + const accountData = await redis.getClaudeAccount(result.accountId) + + if (!accountData || Object.keys(accountData).length === 0) { + console.log(`⚠️ 账户 ${result.accountId} 在Redis中不存在,跳过`) + skippedCount++ + continue + } + + console.log(`🔄 处理账户: ${accountData.name || result.accountId}`) + + // 确定要设置的会话窗口 + let targetWindow = null + + if (result.currentActiveWindow) { + targetWindow = result.currentActiveWindow + console.log( + ` ✅ 使用当前活跃窗口: ${targetWindow.windowStart.toLocaleString()} - ${targetWindow.windowEnd.toLocaleString()}` + ) + } else if (result.mostRecentWindow) { + const window = result.mostRecentWindow + const now = new Date() + + // 如果最近窗口是在过去24小时内的,可以考虑恢复 + const hoursSinceWindow = (now - window.windowEnd) / (1000 * 60 * 60) + + if (hoursSinceWindow <= 24) { + console.log( + ` 🕐 最近窗口在24小时内,但已过期: ${window.windowStart.toLocaleString()} - ${window.windowEnd.toLocaleString()}` + ) + console.log(` ❌ 不恢复已过期窗口(${hoursSinceWindow.toFixed(1)}小时前过期)`) + } else { + console.log(' ⏰ 最近窗口超过24小时前,不予恢复') + } + } + + if (targetWindow && !dryRun) { + // 更新Redis中的会话窗口数据 + accountData.sessionWindowStart = targetWindow.windowStart.toISOString() + accountData.sessionWindowEnd = targetWindow.windowEnd.toISOString() + accountData.lastUsedAt = result.lastRequest.toISOString() + accountData.lastRequestTime = result.lastRequest.toISOString() + + await redis.setClaudeAccount(result.accountId, accountData) + updatedCount++ + + console.log(' ✅ 已更新会话窗口数据') + } else if (targetWindow) { + updatedCount++ + console.log( + ` 🧪 [模拟] 将设置会话窗口: ${targetWindow.windowStart.toLocaleString()} - ${targetWindow.windowEnd.toLocaleString()}` + ) + } else { + skippedCount++ + console.log(' ⏭️ 跳过(无有效窗口)') + } + + console.log('') + } catch (error) { + console.error(`❌ 处理账户 ${result.accountId} 时出错: ${error.message}`) + skippedCount++ + } + } + + if (!dryRun) { + await redis.disconnect() + } + + console.log('📊 更新结果:') + console.log(` ✅ 已更新: ${updatedCount}`) + console.log(` ⏭️ 已跳过: ${skippedCount}`) + console.log(` 📋 总计: ${results.length}`) + } + + // 主分析函数 + async analyze(options = {}) { + const { logDir = './logs', singleFile = null, updateRedis = false, dryRun = true } = options + + try { + console.log('🔍 Claude账户会话窗口分析工具\n') + + // 分析日志文件 + if (singleFile) { + await this.analyzeSingleFile(singleFile) + } else { + await this.analyzeLogDirectory(logDir) + } + + if (this.accounts.size === 0) { + console.log('❌ 没有找到任何Claude账户的请求记录') + return [] + } + + // 分析会话窗口 + const results = this.analyzeSessionWindows() + + // 显示结果 + this.displayResults(results) + + // 更新Redis(如果需要) + if (updateRedis) { + await this.updateRedisSessionWindows(results, dryRun) + } + + return results + } catch (error) { + console.error('❌ 分析失败:', error) + throw error + } + } +} + +// 命令行参数解析 +function parseArgs() { + const args = process.argv.slice(2) + const options = { + logDir: './logs', + singleFile: null, + updateRedis: false, + dryRun: true + } + + for (const arg of args) { + if (arg.startsWith('--log-dir=')) { + options.logDir = arg.split('=')[1] + } else if (arg.startsWith('--file=')) { + options.singleFile = arg.split('=')[1] + } else if (arg === '--update-redis') { + options.updateRedis = true + } else if (arg === '--no-dry-run') { + options.dryRun = false + } else if (arg === '--help' || arg === '-h') { + showHelp() + process.exit(0) + } + } + + return options +} + +// 显示帮助信息 +function showHelp() { + console.log(` +Claude账户会话窗口日志分析工具 + +从日志文件中分析Claude账户的请求时间,计算会话窗口,并可选择性地更新Redis数据。 + +用法: + node scripts/analyze-log-sessions.js [选项] + +选项: + --log-dir=PATH 日志文件目录 (默认: ./logs) + --file=PATH 分析单个日志文件 + --update-redis 更新Redis中的会话窗口数据 + --no-dry-run 实际执行Redis更新(默认为模拟模式) + --help, -h 显示此帮助信息 + +示例: + # 分析默认日志目录 + node scripts/analyze-log-sessions.js + + # 分析指定目录的日志 + node scripts/analyze-log-sessions.js --log-dir=/path/to/logs + + # 分析单个日志文件 + node scripts/analyze-log-sessions.js --file=/path/to/logfile.log + + # 模拟更新Redis数据(不实际更新) + node scripts/analyze-log-sessions.js --file=/path/to/logfile.log --update-redis + + # 实际更新Redis数据 + node scripts/analyze-log-sessions.js --file=/path/to/logfile.log --update-redis --no-dry-run + +会话窗口规则: + - Claude官方规定每5小时为一个会话窗口 + - 窗口按整点对齐(如 05:00-10:00, 10:00-15:00) + - 只有当前时间在窗口内的才被认为是活跃窗口 + - 工具会自动识别并恢复活跃的会话窗口 +`) +} + +// 主函数 +async function main() { + try { + const options = parseArgs() + + const analyzer = new LogSessionAnalyzer() + await analyzer.analyze(options) + + console.log('🎉 分析完成') + } catch (error) { + console.error('💥 程序执行失败:', error) + process.exit(1) + } +} + +// 如果直接运行此脚本 +if (require.main === module) { + main() +} + +module.exports = LogSessionAnalyzer diff --git a/scripts/check-redis-keys.js b/scripts/check-redis-keys.js new file mode 100644 index 0000000000000000000000000000000000000000..93d70fb1651b52935deb644b45746a9ebc89a744 --- /dev/null +++ b/scripts/check-redis-keys.js @@ -0,0 +1,53 @@ +/** + * 检查 Redis 中的所有键 + */ + +const redis = require('../src/models/redis') + +async function checkRedisKeys() { + console.log('🔍 检查 Redis 中的所有键...\n') + + try { + // 确保 Redis 已连接 + await redis.connect() + + // 获取所有键 + const allKeys = await redis.client.keys('*') + console.log(`找到 ${allKeys.length} 个键\n`) + + // 按类型分组 + const keysByType = {} + + allKeys.forEach((key) => { + const prefix = key.split(':')[0] + if (!keysByType[prefix]) { + keysByType[prefix] = [] + } + keysByType[prefix].push(key) + }) + + // 显示各类型的键 + Object.keys(keysByType) + .sort() + .forEach((type) => { + console.log(`\n📁 ${type}: ${keysByType[type].length} 个`) + + // 显示前 5 个键作为示例 + const keysToShow = keysByType[type].slice(0, 5) + keysToShow.forEach((key) => { + console.log(` - ${key}`) + }) + + if (keysByType[type].length > 5) { + console.log(` ... 还有 ${keysByType[type].length - 5} 个`) + } + }) + } catch (error) { + console.error('❌ 错误:', error) + console.error(error.stack) + } finally { + process.exit(0) + } +} + +checkRedisKeys() diff --git a/scripts/data-transfer-enhanced.js b/scripts/data-transfer-enhanced.js new file mode 100644 index 0000000000000000000000000000000000000000..09416fb4598521dc9b4a0371f5a6d55a1ebd001e --- /dev/null +++ b/scripts/data-transfer-enhanced.js @@ -0,0 +1,1132 @@ +#!/usr/bin/env node + +/** + * 增强版数据导出/导入工具 + * 支持加密数据的处理 + */ + +const fs = require('fs').promises +const crypto = require('crypto') +const redis = require('../src/models/redis') +const logger = require('../src/utils/logger') +const readline = require('readline') +const config = require('../config/config') + +// 解析命令行参数 +const args = process.argv.slice(2) +const command = args[0] +const params = {} + +args.slice(1).forEach((arg) => { + const [key, value] = arg.split('=') + params[key.replace('--', '')] = value || true +}) + +// 创建 readline 接口 +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout +}) + +async function askConfirmation(question) { + return new Promise((resolve) => { + rl.question(`${question} (yes/no): `, (answer) => { + resolve(answer.toLowerCase() === 'yes' || answer.toLowerCase() === 'y') + }) + }) +} + +// Claude 账户解密函数 +function decryptClaudeData(encryptedData) { + if (!encryptedData || !config.security.encryptionKey) { + return encryptedData + } + + try { + if (encryptedData.includes(':')) { + const parts = encryptedData.split(':') + const key = crypto.scryptSync(config.security.encryptionKey, 'salt', 32) + const iv = Buffer.from(parts[0], 'hex') + const encrypted = parts[1] + + const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv) + let decrypted = decipher.update(encrypted, 'hex', 'utf8') + decrypted += decipher.final('utf8') + return decrypted + } + return encryptedData + } catch (error) { + logger.warn(`⚠️ Failed to decrypt data: ${error.message}`) + return encryptedData + } +} + +// Gemini 账户解密函数 +function decryptGeminiData(encryptedData) { + if (!encryptedData || !config.security.encryptionKey) { + return encryptedData + } + + try { + if (encryptedData.includes(':')) { + const parts = encryptedData.split(':') + const key = crypto.scryptSync(config.security.encryptionKey, 'gemini-account-salt', 32) + const iv = Buffer.from(parts[0], 'hex') + const encrypted = parts[1] + + const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv) + let decrypted = decipher.update(encrypted, 'hex', 'utf8') + decrypted += decipher.final('utf8') + return decrypted + } + return encryptedData + } catch (error) { + logger.warn(`⚠️ Failed to decrypt data: ${error.message}`) + return encryptedData + } +} + +// API Key 哈希函数(与apiKeyService保持一致) +function hashApiKey(apiKey) { + if (!apiKey || !config.security.encryptionKey) { + return apiKey + } + + return crypto + .createHash('sha256') + .update(apiKey + config.security.encryptionKey) + .digest('hex') +} + +// 检查是否为明文API Key(通过格式判断,不依赖前缀) +function isPlaintextApiKey(apiKey) { + if (!apiKey || typeof apiKey !== 'string') { + return false + } + + // SHA256哈希值固定为64个十六进制字符,如果是哈希值则返回false + if (apiKey.length === 64 && /^[a-f0-9]+$/i.test(apiKey)) { + return false // 已经是哈希值 + } + + // 其他情况都认为是明文API Key(包括sk-ant-、cr_、自定义前缀等) + return true +} + +// 数据加密函数(用于导入) +function encryptClaudeData(data) { + if (!data || !config.security.encryptionKey) { + return data + } + + const key = crypto.scryptSync(config.security.encryptionKey, 'salt', 32) + const iv = crypto.randomBytes(16) + + const cipher = crypto.createCipheriv('aes-256-cbc', key, iv) + let encrypted = cipher.update(data, 'utf8', 'hex') + encrypted += cipher.final('hex') + + return `${iv.toString('hex')}:${encrypted}` +} + +function encryptGeminiData(data) { + if (!data || !config.security.encryptionKey) { + return data + } + + const key = crypto.scryptSync(config.security.encryptionKey, 'gemini-account-salt', 32) + const iv = crypto.randomBytes(16) + + const cipher = crypto.createCipheriv('aes-256-cbc', key, iv) + let encrypted = cipher.update(data, 'utf8', 'hex') + encrypted += cipher.final('hex') + + return `${iv.toString('hex')}:${encrypted}` +} + +// 导出使用统计数据 +async function exportUsageStats(keyId) { + try { + const stats = { + total: {}, + daily: {}, + monthly: {}, + hourly: {}, + models: {} + } + + // 导出总统计 + const totalKey = `usage:${keyId}` + const totalData = await redis.client.hgetall(totalKey) + if (totalData && Object.keys(totalData).length > 0) { + stats.total = totalData + } + + // 导出每日统计(最近30天) + const today = new Date() + for (let i = 0; i < 30; i++) { + const date = new Date(today) + date.setDate(date.getDate() - i) + const dateStr = date.toISOString().split('T')[0] + const dailyKey = `usage:daily:${keyId}:${dateStr}` + + const dailyData = await redis.client.hgetall(dailyKey) + if (dailyData && Object.keys(dailyData).length > 0) { + stats.daily[dateStr] = dailyData + } + } + + // 导出每月统计(最近12个月) + for (let i = 0; i < 12; i++) { + const date = new Date(today) + date.setMonth(date.getMonth() - i) + const monthStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` + const monthlyKey = `usage:monthly:${keyId}:${monthStr}` + + const monthlyData = await redis.client.hgetall(monthlyKey) + if (monthlyData && Object.keys(monthlyData).length > 0) { + stats.monthly[monthStr] = monthlyData + } + } + + // 导出小时统计(最近24小时) + for (let i = 0; i < 24; i++) { + const date = new Date(today) + date.setHours(date.getHours() - i) + const dateStr = date.toISOString().split('T')[0] + const hour = String(date.getHours()).padStart(2, '0') + const hourKey = `${dateStr}:${hour}` + const hourlyKey = `usage:hourly:${keyId}:${hourKey}` + + const hourlyData = await redis.client.hgetall(hourlyKey) + if (hourlyData && Object.keys(hourlyData).length > 0) { + stats.hourly[hourKey] = hourlyData + } + } + + // 导出模型统计 + // 每日模型统计 + const modelDailyPattern = `usage:${keyId}:model:daily:*` + const modelDailyKeys = await redis.client.keys(modelDailyPattern) + for (const key of modelDailyKeys) { + const match = key.match(/usage:.+:model:daily:(.+):(\d{4}-\d{2}-\d{2})$/) + if (match) { + const model = match[1] + const date = match[2] + const data = await redis.client.hgetall(key) + if (data && Object.keys(data).length > 0) { + if (!stats.models[model]) { + stats.models[model] = { daily: {}, monthly: {} } + } + stats.models[model].daily[date] = data + } + } + } + + // 每月模型统计 + const modelMonthlyPattern = `usage:${keyId}:model:monthly:*` + const modelMonthlyKeys = await redis.client.keys(modelMonthlyPattern) + for (const key of modelMonthlyKeys) { + const match = key.match(/usage:.+:model:monthly:(.+):(\d{4}-\d{2})$/) + if (match) { + const model = match[1] + const month = match[2] + const data = await redis.client.hgetall(key) + if (data && Object.keys(data).length > 0) { + if (!stats.models[model]) { + stats.models[model] = { daily: {}, monthly: {} } + } + stats.models[model].monthly[month] = data + } + } + } + + return stats + } catch (error) { + logger.warn(`⚠️ Failed to export usage stats for ${keyId}: ${error.message}`) + return null + } +} + +// 导入使用统计数据 +async function importUsageStats(keyId, stats) { + try { + if (!stats) { + return + } + + const pipeline = redis.client.pipeline() + let importCount = 0 + + // 导入总统计 + if (stats.total && Object.keys(stats.total).length > 0) { + for (const [field, value] of Object.entries(stats.total)) { + pipeline.hset(`usage:${keyId}`, field, value) + } + importCount++ + } + + // 导入每日统计 + if (stats.daily) { + for (const [date, data] of Object.entries(stats.daily)) { + for (const [field, value] of Object.entries(data)) { + pipeline.hset(`usage:daily:${keyId}:${date}`, field, value) + } + importCount++ + } + } + + // 导入每月统计 + if (stats.monthly) { + for (const [month, data] of Object.entries(stats.monthly)) { + for (const [field, value] of Object.entries(data)) { + pipeline.hset(`usage:monthly:${keyId}:${month}`, field, value) + } + importCount++ + } + } + + // 导入小时统计 + if (stats.hourly) { + for (const [hour, data] of Object.entries(stats.hourly)) { + for (const [field, value] of Object.entries(data)) { + pipeline.hset(`usage:hourly:${keyId}:${hour}`, field, value) + } + importCount++ + } + } + + // 导入模型统计 + if (stats.models) { + for (const [model, modelStats] of Object.entries(stats.models)) { + // 每日模型统计 + if (modelStats.daily) { + for (const [date, data] of Object.entries(modelStats.daily)) { + for (const [field, value] of Object.entries(data)) { + pipeline.hset(`usage:${keyId}:model:daily:${model}:${date}`, field, value) + } + importCount++ + } + } + + // 每月模型统计 + if (modelStats.monthly) { + for (const [month, data] of Object.entries(modelStats.monthly)) { + for (const [field, value] of Object.entries(data)) { + pipeline.hset(`usage:${keyId}:model:monthly:${model}:${month}`, field, value) + } + importCount++ + } + } + } + } + + await pipeline.exec() + logger.info(` 📊 Imported ${importCount} usage stat entries for API Key ${keyId}`) + } catch (error) { + logger.warn(`⚠️ Failed to import usage stats for ${keyId}: ${error.message}`) + } +} + +// 数据脱敏函数 +function sanitizeData(data, type) { + const sanitized = { ...data } + + switch (type) { + case 'apikey': + if (sanitized.apiKey) { + sanitized.apiKey = `${sanitized.apiKey.substring(0, 10)}...[REDACTED]` + } + break + + case 'claude_account': + if (sanitized.email) { + sanitized.email = '[REDACTED]' + } + if (sanitized.password) { + sanitized.password = '[REDACTED]' + } + if (sanitized.accessToken) { + sanitized.accessToken = '[REDACTED]' + } + if (sanitized.refreshToken) { + sanitized.refreshToken = '[REDACTED]' + } + if (sanitized.claudeAiOauth) { + sanitized.claudeAiOauth = '[REDACTED]' + } + if (sanitized.proxyPassword) { + sanitized.proxyPassword = '[REDACTED]' + } + break + + case 'gemini_account': + if (sanitized.geminiOauth) { + sanitized.geminiOauth = '[REDACTED]' + } + if (sanitized.accessToken) { + sanitized.accessToken = '[REDACTED]' + } + if (sanitized.refreshToken) { + sanitized.refreshToken = '[REDACTED]' + } + if (sanitized.proxyPassword) { + sanitized.proxyPassword = '[REDACTED]' + } + break + + case 'admin': + if (sanitized.password) { + sanitized.password = '[REDACTED]' + } + break + } + + return sanitized +} + +// 导出数据 +async function exportData() { + try { + const outputFile = params.output || `backup-${new Date().toISOString().split('T')[0]}.json` + const types = params.types ? params.types.split(',') : ['all'] + const shouldSanitize = params.sanitize === true + const shouldDecrypt = params.decrypt !== false // 默认解密 + + logger.info('🔄 Starting data export...') + logger.info(`📁 Output file: ${outputFile}`) + logger.info(`📋 Data types: ${types.join(', ')}`) + logger.info(`🔒 Sanitize sensitive data: ${shouldSanitize ? 'YES' : 'NO'}`) + logger.info(`🔓 Decrypt data: ${shouldDecrypt ? 'YES' : 'NO'}`) + + await redis.connect() + logger.success('✅ Connected to Redis') + + const exportDataObj = { + metadata: { + version: '2.0', + exportDate: new Date().toISOString(), + sanitized: shouldSanitize, + decrypted: shouldDecrypt, + types + }, + data: {} + } + + // 导出 API Keys + if (types.includes('all') || types.includes('apikeys')) { + logger.info('📤 Exporting API Keys...') + const keys = await redis.client.keys('apikey:*') + const apiKeys = [] + + for (const key of keys) { + if (key === 'apikey:hash_map') { + continue + } + + const data = await redis.client.hgetall(key) + if (data && Object.keys(data).length > 0) { + // 获取该 API Key 的 ID + const keyId = data.id + + // 导出使用统计数据 + if (keyId && (types.includes('all') || types.includes('stats'))) { + data.usageStats = await exportUsageStats(keyId) + } + + apiKeys.push(shouldSanitize ? sanitizeData(data, 'apikey') : data) + } + } + + exportDataObj.data.apiKeys = apiKeys + logger.success(`✅ Exported ${apiKeys.length} API Keys`) + } + + // 导出 Claude 账户 + if (types.includes('all') || types.includes('accounts')) { + logger.info('📤 Exporting Claude accounts...') + const keys = await redis.client.keys('claude:account:*') + logger.info(`Found ${keys.length} Claude account keys in Redis`) + const accounts = [] + + for (const key of keys) { + const data = await redis.client.hgetall(key) + + if (data && Object.keys(data).length > 0) { + // 解密敏感字段 + if (shouldDecrypt && !shouldSanitize) { + if (data.email) { + data.email = decryptClaudeData(data.email) + } + if (data.password) { + data.password = decryptClaudeData(data.password) + } + if (data.accessToken) { + data.accessToken = decryptClaudeData(data.accessToken) + } + if (data.refreshToken) { + data.refreshToken = decryptClaudeData(data.refreshToken) + } + if (data.claudeAiOauth) { + const decrypted = decryptClaudeData(data.claudeAiOauth) + try { + data.claudeAiOauth = JSON.parse(decrypted) + } catch (e) { + data.claudeAiOauth = decrypted + } + } + } + + accounts.push(shouldSanitize ? sanitizeData(data, 'claude_account') : data) + } + } + + exportDataObj.data.claudeAccounts = accounts + logger.success(`✅ Exported ${accounts.length} Claude accounts`) + + // 导出 Gemini 账户 + logger.info('📤 Exporting Gemini accounts...') + const geminiKeys = await redis.client.keys('gemini_account:*') + logger.info(`Found ${geminiKeys.length} Gemini account keys in Redis`) + const geminiAccounts = [] + + for (const key of geminiKeys) { + const data = await redis.client.hgetall(key) + + if (data && Object.keys(data).length > 0) { + // 解密敏感字段 + if (shouldDecrypt && !shouldSanitize) { + if (data.geminiOauth) { + const decrypted = decryptGeminiData(data.geminiOauth) + try { + data.geminiOauth = JSON.parse(decrypted) + } catch (e) { + data.geminiOauth = decrypted + } + } + if (data.accessToken) { + data.accessToken = decryptGeminiData(data.accessToken) + } + if (data.refreshToken) { + data.refreshToken = decryptGeminiData(data.refreshToken) + } + } + + geminiAccounts.push(shouldSanitize ? sanitizeData(data, 'gemini_account') : data) + } + } + + exportDataObj.data.geminiAccounts = geminiAccounts + logger.success(`✅ Exported ${geminiAccounts.length} Gemini accounts`) + } + + // 导出管理员 + if (types.includes('all') || types.includes('admins')) { + logger.info('📤 Exporting admins...') + const keys = await redis.client.keys('admin:*') + const admins = [] + + for (const key of keys) { + if (key.includes('admin_username:')) { + continue + } + + const data = await redis.client.hgetall(key) + if (data && Object.keys(data).length > 0) { + admins.push(shouldSanitize ? sanitizeData(data, 'admin') : data) + } + } + + exportDataObj.data.admins = admins + logger.success(`✅ Exported ${admins.length} admins`) + } + + // 导出全局模型统计(如果需要) + if (types.includes('all') || types.includes('stats')) { + logger.info('📤 Exporting global model statistics...') + const globalStats = { + daily: {}, + monthly: {}, + hourly: {} + } + + // 导出全局每日模型统计 + const globalDailyPattern = 'usage:model:daily:*' + const globalDailyKeys = await redis.client.keys(globalDailyPattern) + for (const key of globalDailyKeys) { + const match = key.match(/usage:model:daily:(.+):(\d{4}-\d{2}-\d{2})$/) + if (match) { + const model = match[1] + const date = match[2] + const data = await redis.client.hgetall(key) + if (data && Object.keys(data).length > 0) { + if (!globalStats.daily[date]) { + globalStats.daily[date] = {} + } + globalStats.daily[date][model] = data + } + } + } + + // 导出全局每月模型统计 + const globalMonthlyPattern = 'usage:model:monthly:*' + const globalMonthlyKeys = await redis.client.keys(globalMonthlyPattern) + for (const key of globalMonthlyKeys) { + const match = key.match(/usage:model:monthly:(.+):(\d{4}-\d{2})$/) + if (match) { + const model = match[1] + const month = match[2] + const data = await redis.client.hgetall(key) + if (data && Object.keys(data).length > 0) { + if (!globalStats.monthly[month]) { + globalStats.monthly[month] = {} + } + globalStats.monthly[month][model] = data + } + } + } + + // 导出全局每小时模型统计 + const globalHourlyPattern = 'usage:model:hourly:*' + const globalHourlyKeys = await redis.client.keys(globalHourlyPattern) + for (const key of globalHourlyKeys) { + const match = key.match(/usage:model:hourly:(.+):(\d{4}-\d{2}-\d{2}:\d{2})$/) + if (match) { + const model = match[1] + const hour = match[2] + const data = await redis.client.hgetall(key) + if (data && Object.keys(data).length > 0) { + if (!globalStats.hourly[hour]) { + globalStats.hourly[hour] = {} + } + globalStats.hourly[hour][model] = data + } + } + } + + exportDataObj.data.globalModelStats = globalStats + logger.success('✅ Exported global model statistics') + } + + // 写入文件 + await fs.writeFile(outputFile, JSON.stringify(exportDataObj, null, 2)) + + // 显示导出摘要 + console.log(`\n${'='.repeat(60)}`) + console.log('✅ Export Complete!') + console.log('='.repeat(60)) + console.log(`Output file: ${outputFile}`) + console.log(`File size: ${(await fs.stat(outputFile)).size} bytes`) + + if (exportDataObj.data.apiKeys) { + console.log(`API Keys: ${exportDataObj.data.apiKeys.length}`) + } + if (exportDataObj.data.claudeAccounts) { + console.log(`Claude Accounts: ${exportDataObj.data.claudeAccounts.length}`) + } + if (exportDataObj.data.geminiAccounts) { + console.log(`Gemini Accounts: ${exportDataObj.data.geminiAccounts.length}`) + } + if (exportDataObj.data.admins) { + console.log(`Admins: ${exportDataObj.data.admins.length}`) + } + console.log('='.repeat(60)) + + if (shouldSanitize) { + logger.warn('⚠️ Sensitive data has been sanitized in this export.') + } + if (shouldDecrypt) { + logger.info('🔓 Encrypted data has been decrypted for portability.') + } + } catch (error) { + logger.error('💥 Export failed:', error) + process.exit(1) + } finally { + await redis.disconnect() + rl.close() + } +} + +// 显示帮助信息 +function showHelp() { + console.log(` +Enhanced Data Transfer Tool for Claude Relay Service + +This tool handles encrypted data export/import between environments. + +Usage: + node scripts/data-transfer-enhanced.js [options] + +Commands: + export Export data from Redis to a JSON file + import Import data from a JSON file to Redis + +Export Options: + --output=FILE Output filename (default: backup-YYYY-MM-DD.json) + --types=TYPE,... Data types: apikeys,accounts,admins,stats,all (default: all) + stats: Include usage statistics with API keys + --sanitize Remove sensitive data from export + --decrypt=false Keep data encrypted (default: true - decrypt for portability) + +Import Options: + --input=FILE Input filename (required) + --force Overwrite existing data without asking + --skip-conflicts Skip conflicting data without asking + +Important Notes: + - The tool automatically handles encryption/decryption during import + - If importing decrypted data, it will be re-encrypted automatically + - If importing encrypted data, it will be stored as-is + - Sanitized exports cannot be properly imported (missing sensitive data) + - Automatic handling of plaintext API Keys + * Uses your configured API_KEY_PREFIX from config (sk-, cr_, etc.) + * Automatically detects plaintext vs hashed API Keys by format + * Plaintext API Keys are automatically hashed during import + * Hash mappings are created correctly for plaintext keys + * Supports custom prefixes and legacy format detection + * No manual conversion needed - just import your backup file + +Examples: + # Export all data with decryption (for migration) + node scripts/data-transfer-enhanced.js export + + # Export without decrypting (for backup) + node scripts/data-transfer-enhanced.js export --decrypt=false + + # Import data (auto-handles encryption and plaintext API keys) + node scripts/data-transfer-enhanced.js import --input=backup.json + + # Import with force overwrite + node scripts/data-transfer-enhanced.js import --input=backup.json --force +`) +} + +// 导入数据 +async function importData() { + try { + const inputFile = params.input + if (!inputFile) { + logger.error('❌ Please specify input file with --input=filename.json') + process.exit(1) + } + + const forceOverwrite = params.force === true + const skipConflicts = params['skip-conflicts'] === true + + logger.info('🔄 Starting data import...') + logger.info(`📁 Input file: ${inputFile}`) + logger.info( + `⚡ Mode: ${forceOverwrite ? 'FORCE OVERWRITE' : skipConflicts ? 'SKIP CONFLICTS' : 'ASK ON CONFLICT'}` + ) + + // 读取文件 + const fileContent = await fs.readFile(inputFile, 'utf8') + const importDataObj = JSON.parse(fileContent) + + // 验证文件格式 + if (!importDataObj.metadata || !importDataObj.data) { + logger.error('❌ Invalid backup file format') + process.exit(1) + } + + logger.info(`📅 Backup date: ${importDataObj.metadata.exportDate}`) + logger.info(`🔒 Sanitized: ${importDataObj.metadata.sanitized ? 'YES' : 'NO'}`) + logger.info(`🔓 Decrypted: ${importDataObj.metadata.decrypted ? 'YES' : 'NO'}`) + + if (importDataObj.metadata.sanitized) { + logger.warn('⚠️ This backup contains sanitized data. Sensitive fields will be missing!') + const proceed = await askConfirmation('Continue with sanitized data?') + if (!proceed) { + logger.info('❌ Import cancelled') + return + } + } + + // 显示导入摘要 + console.log(`\n${'='.repeat(60)}`) + console.log('📋 Import Summary:') + console.log('='.repeat(60)) + if (importDataObj.data.apiKeys) { + console.log(`API Keys to import: ${importDataObj.data.apiKeys.length}`) + } + if (importDataObj.data.claudeAccounts) { + console.log(`Claude Accounts to import: ${importDataObj.data.claudeAccounts.length}`) + } + if (importDataObj.data.geminiAccounts) { + console.log(`Gemini Accounts to import: ${importDataObj.data.geminiAccounts.length}`) + } + if (importDataObj.data.admins) { + console.log(`Admins to import: ${importDataObj.data.admins.length}`) + } + console.log(`${'='.repeat(60)}\n`) + + // 确认导入 + const confirmed = await askConfirmation('⚠️ Proceed with import?') + if (!confirmed) { + logger.info('❌ Import cancelled') + return + } + + // 连接 Redis + await redis.connect() + logger.success('✅ Connected to Redis') + + const stats = { + imported: 0, + skipped: 0, + errors: 0 + } + + // 导入 API Keys + if (importDataObj.data.apiKeys) { + logger.info('\n📥 Importing API Keys...') + for (const apiKey of importDataObj.data.apiKeys) { + try { + const exists = await redis.client.exists(`apikey:${apiKey.id}`) + + if (exists && !forceOverwrite) { + if (skipConflicts) { + logger.warn(`⏭️ Skipped existing API Key: ${apiKey.name} (${apiKey.id})`) + stats.skipped++ + continue + } else { + const overwrite = await askConfirmation( + `API Key "${apiKey.name}" (${apiKey.id}) exists. Overwrite?` + ) + if (!overwrite) { + stats.skipped++ + continue + } + } + } + + // 保存使用统计数据以便单独导入 + const { usageStats } = apiKey + + // 从apiKey对象中删除usageStats字段,避免存储到主键中 + const apiKeyData = { ...apiKey } + delete apiKeyData.usageStats + + // 检查并处理API Key哈希 + let plainTextApiKey = null + let hashedApiKey = null + + if (apiKeyData.apiKey && isPlaintextApiKey(apiKeyData.apiKey)) { + // 如果是明文API Key,保存明文并计算哈希 + plainTextApiKey = apiKeyData.apiKey + hashedApiKey = hashApiKey(plainTextApiKey) + logger.info(`🔐 Detected plaintext API Key for: ${apiKey.name} (${apiKey.id})`) + } else if (apiKeyData.apiKey) { + // 如果已经是哈希值,直接使用 + hashedApiKey = apiKeyData.apiKey + logger.info(`🔍 Using existing hashed API Key for: ${apiKey.name} (${apiKey.id})`) + } + + // API Key字段始终存储哈希值 + if (hashedApiKey) { + apiKeyData.apiKey = hashedApiKey + } + + // 使用 hset 存储到哈希表 + const pipeline = redis.client.pipeline() + for (const [field, value] of Object.entries(apiKeyData)) { + pipeline.hset(`apikey:${apiKey.id}`, field, value) + } + await pipeline.exec() + + // 更新哈希映射:hash_map的key必须是哈希值 + if (!importDataObj.metadata.sanitized && hashedApiKey) { + await redis.client.hset('apikey:hash_map', hashedApiKey, apiKey.id) + logger.info( + `📝 Updated hash mapping: ${hashedApiKey.substring(0, 8)}... -> ${apiKey.id}` + ) + } + + // 导入使用统计数据 + if (usageStats) { + await importUsageStats(apiKey.id, usageStats) + } + + logger.success(`✅ Imported API Key: ${apiKey.name} (${apiKey.id})`) + stats.imported++ + } catch (error) { + logger.error(`❌ Failed to import API Key ${apiKey.id}:`, error.message) + stats.errors++ + } + } + } + + // 导入 Claude 账户 + if (importDataObj.data.claudeAccounts) { + logger.info('\n📥 Importing Claude accounts...') + for (const account of importDataObj.data.claudeAccounts) { + try { + const exists = await redis.client.exists(`claude:account:${account.id}`) + + if (exists && !forceOverwrite) { + if (skipConflicts) { + logger.warn(`⏭️ Skipped existing Claude account: ${account.name} (${account.id})`) + stats.skipped++ + continue + } else { + const overwrite = await askConfirmation( + `Claude account "${account.name}" (${account.id}) exists. Overwrite?` + ) + if (!overwrite) { + stats.skipped++ + continue + } + } + } + + // 复制账户数据以避免修改原始数据 + const accountData = { ...account } + + // 如果数据已解密且不是脱敏数据,需要重新加密 + if (importDataObj.metadata.decrypted && !importDataObj.metadata.sanitized) { + logger.info(`🔐 Re-encrypting sensitive data for Claude account: ${account.name}`) + + if (accountData.email) { + accountData.email = encryptClaudeData(accountData.email) + } + if (accountData.password) { + accountData.password = encryptClaudeData(accountData.password) + } + if (accountData.accessToken) { + accountData.accessToken = encryptClaudeData(accountData.accessToken) + } + if (accountData.refreshToken) { + accountData.refreshToken = encryptClaudeData(accountData.refreshToken) + } + if (accountData.claudeAiOauth) { + // 如果是对象,先序列化再加密 + const oauthStr = + typeof accountData.claudeAiOauth === 'object' + ? JSON.stringify(accountData.claudeAiOauth) + : accountData.claudeAiOauth + accountData.claudeAiOauth = encryptClaudeData(oauthStr) + } + } + + // 使用 hset 存储到哈希表 + const pipeline = redis.client.pipeline() + for (const [field, value] of Object.entries(accountData)) { + if (field === 'claudeAiOauth' && typeof value === 'object') { + // 确保对象被序列化 + pipeline.hset(`claude:account:${account.id}`, field, JSON.stringify(value)) + } else { + pipeline.hset(`claude:account:${account.id}`, field, value) + } + } + await pipeline.exec() + + logger.success(`✅ Imported Claude account: ${account.name} (${account.id})`) + stats.imported++ + } catch (error) { + logger.error(`❌ Failed to import Claude account ${account.id}:`, error.message) + stats.errors++ + } + } + } + + // 导入 Gemini 账户 + if (importDataObj.data.geminiAccounts) { + logger.info('\n📥 Importing Gemini accounts...') + for (const account of importDataObj.data.geminiAccounts) { + try { + const exists = await redis.client.exists(`gemini_account:${account.id}`) + + if (exists && !forceOverwrite) { + if (skipConflicts) { + logger.warn(`⏭️ Skipped existing Gemini account: ${account.name} (${account.id})`) + stats.skipped++ + continue + } else { + const overwrite = await askConfirmation( + `Gemini account "${account.name}" (${account.id}) exists. Overwrite?` + ) + if (!overwrite) { + stats.skipped++ + continue + } + } + } + + // 复制账户数据以避免修改原始数据 + const accountData = { ...account } + + // 如果数据已解密且不是脱敏数据,需要重新加密 + if (importDataObj.metadata.decrypted && !importDataObj.metadata.sanitized) { + logger.info(`🔐 Re-encrypting sensitive data for Gemini account: ${account.name}`) + + if (accountData.geminiOauth) { + const oauthStr = + typeof accountData.geminiOauth === 'object' + ? JSON.stringify(accountData.geminiOauth) + : accountData.geminiOauth + accountData.geminiOauth = encryptGeminiData(oauthStr) + } + if (accountData.accessToken) { + accountData.accessToken = encryptGeminiData(accountData.accessToken) + } + if (accountData.refreshToken) { + accountData.refreshToken = encryptGeminiData(accountData.refreshToken) + } + } + + // 使用 hset 存储到哈希表 + const pipeline = redis.client.pipeline() + for (const [field, value] of Object.entries(accountData)) { + pipeline.hset(`gemini_account:${account.id}`, field, value) + } + await pipeline.exec() + + logger.success(`✅ Imported Gemini account: ${account.name} (${account.id})`) + stats.imported++ + } catch (error) { + logger.error(`❌ Failed to import Gemini account ${account.id}:`, error.message) + stats.errors++ + } + } + } + + // 导入管理员账户 + if (importDataObj.data.admins) { + logger.info('\n📥 Importing admins...') + for (const admin of importDataObj.data.admins) { + try { + const exists = await redis.client.exists(`admin:${admin.id}`) + + if (exists && !forceOverwrite) { + if (skipConflicts) { + logger.warn(`⏭️ Skipped existing admin: ${admin.username} (${admin.id})`) + stats.skipped++ + continue + } else { + const overwrite = await askConfirmation( + `Admin "${admin.username}" (${admin.id}) exists. Overwrite?` + ) + if (!overwrite) { + stats.skipped++ + continue + } + } + } + + // 使用 hset 存储到哈希表 + const pipeline = redis.client.pipeline() + for (const [field, value] of Object.entries(admin)) { + pipeline.hset(`admin:${admin.id}`, field, value) + } + await pipeline.exec() + + // 更新用户名映射 + await redis.client.set(`admin_username:${admin.username}`, admin.id) + + logger.success(`✅ Imported admin: ${admin.username} (${admin.id})`) + stats.imported++ + } catch (error) { + logger.error(`❌ Failed to import admin ${admin.id}:`, error.message) + stats.errors++ + } + } + } + + // 导入全局模型统计 + if (importDataObj.data.globalModelStats) { + logger.info('\n📥 Importing global model statistics...') + try { + const globalStats = importDataObj.data.globalModelStats + const pipeline = redis.client.pipeline() + let globalStatCount = 0 + + // 导入每日统计 + if (globalStats.daily) { + for (const [date, models] of Object.entries(globalStats.daily)) { + for (const [model, data] of Object.entries(models)) { + for (const [field, value] of Object.entries(data)) { + pipeline.hset(`usage:model:daily:${model}:${date}`, field, value) + } + globalStatCount++ + } + } + } + + // 导入每月统计 + if (globalStats.monthly) { + for (const [month, models] of Object.entries(globalStats.monthly)) { + for (const [model, data] of Object.entries(models)) { + for (const [field, value] of Object.entries(data)) { + pipeline.hset(`usage:model:monthly:${model}:${month}`, field, value) + } + globalStatCount++ + } + } + } + + // 导入每小时统计 + if (globalStats.hourly) { + for (const [hour, models] of Object.entries(globalStats.hourly)) { + for (const [model, data] of Object.entries(models)) { + for (const [field, value] of Object.entries(data)) { + pipeline.hset(`usage:model:hourly:${model}:${hour}`, field, value) + } + globalStatCount++ + } + } + } + + await pipeline.exec() + logger.success(`✅ Imported ${globalStatCount} global model stat entries`) + stats.imported += globalStatCount + } catch (error) { + logger.error('❌ Failed to import global model stats:', error.message) + stats.errors++ + } + } + + // 显示导入结果 + console.log(`\n${'='.repeat(60)}`) + console.log('✅ Import Complete!') + console.log('='.repeat(60)) + console.log(`Successfully imported: ${stats.imported}`) + console.log(`Skipped: ${stats.skipped}`) + console.log(`Errors: ${stats.errors}`) + console.log('='.repeat(60)) + } catch (error) { + logger.error('💥 Import failed:', error) + process.exit(1) + } finally { + await redis.disconnect() + rl.close() + } +} + +// 主函数 +async function main() { + if (!command || command === '--help' || command === 'help') { + showHelp() + process.exit(0) + } + + switch (command) { + case 'export': + await exportData() + break + + case 'import': + await importData() + break + + default: + logger.error(`❌ Unknown command: ${command}`) + showHelp() + process.exit(1) + } +} + +// 运行 +main().catch((error) => { + logger.error('💥 Unexpected error:', error) + process.exit(1) +}) diff --git a/scripts/data-transfer.js b/scripts/data-transfer.js new file mode 100644 index 0000000000000000000000000000000000000000..1995095866b8eff6ded3c63939df0ac6742735eb --- /dev/null +++ b/scripts/data-transfer.js @@ -0,0 +1,738 @@ +#!/usr/bin/env node + +/** + * 数据导出/导入工具 + * + * 使用方法: + * 导出: node scripts/data-transfer.js export --output=backup.json [options] + * 导入: node scripts/data-transfer.js import --input=backup.json [options] + * + * 选项: + * --types: 要导出/导入的数据类型(apikeys,accounts,admins,all) + * --sanitize: 导出时脱敏敏感数据 + * --force: 导入时强制覆盖已存在的数据 + * --skip-conflicts: 导入时跳过冲突的数据 + */ + +const fs = require('fs').promises +const redis = require('../src/models/redis') +const logger = require('../src/utils/logger') +const readline = require('readline') + +// 解析命令行参数 +const args = process.argv.slice(2) +const command = args[0] +const params = {} + +args.slice(1).forEach((arg) => { + const [key, value] = arg.split('=') + params[key.replace('--', '')] = value || true +}) + +// 创建 readline 接口 +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout +}) + +async function askConfirmation(question) { + return new Promise((resolve) => { + rl.question(`${question} (yes/no): `, (answer) => { + resolve(answer.toLowerCase() === 'yes' || answer.toLowerCase() === 'y') + }) + }) +} + +// 数据脱敏函数 +function sanitizeData(data, type) { + const sanitized = { ...data } + + switch (type) { + case 'apikey': + // 隐藏 API Key 的大部分内容 + if (sanitized.apiKey) { + sanitized.apiKey = `${sanitized.apiKey.substring(0, 10)}...[REDACTED]` + } + break + + case 'claude_account': + case 'gemini_account': + // 隐藏 OAuth tokens + if (sanitized.accessToken) { + sanitized.accessToken = '[REDACTED]' + } + if (sanitized.refreshToken) { + sanitized.refreshToken = '[REDACTED]' + } + if (sanitized.claudeAiOauth) { + sanitized.claudeAiOauth = '[REDACTED]' + } + // 隐藏代理密码 + if (sanitized.proxyPassword) { + sanitized.proxyPassword = '[REDACTED]' + } + break + + case 'admin': + // 隐藏管理员密码 + if (sanitized.password) { + sanitized.password = '[REDACTED]' + } + break + } + + return sanitized +} + +// CSV 字段映射配置 +const CSV_FIELD_MAPPING = { + // 基本信息 + id: 'ID', + name: '名称', + description: '描述', + isActive: '状态', + createdAt: '创建时间', + lastUsedAt: '最后使用时间', + createdBy: '创建者', + + // API Key 信息 + apiKey: 'API密钥', + tokenLimit: '令牌限制', + + // 过期设置 + expirationMode: '过期模式', + expiresAt: '过期时间', + activationDays: '激活天数', + activationUnit: '激活单位', + isActivated: '已激活', + activatedAt: '激活时间', + + // 权限设置 + permissions: '服务权限', + + // 限制设置 + rateLimitWindow: '速率窗口(分钟)', + rateLimitRequests: '请求次数限制', + rateLimitCost: '费用限制(美元)', + concurrencyLimit: '并发限制', + dailyCostLimit: '日费用限制(美元)', + totalCostLimit: '总费用限制(美元)', + weeklyOpusCostLimit: '周Opus费用限制(美元)', + + // 账户绑定 + claudeAccountId: 'Claude专属账户', + claudeConsoleAccountId: 'Claude控制台账户', + geminiAccountId: 'Gemini专属账户', + openaiAccountId: 'OpenAI专属账户', + azureOpenaiAccountId: 'Azure OpenAI专属账户', + bedrockAccountId: 'Bedrock专属账户', + + // 限制配置 + enableModelRestriction: '启用模型限制', + restrictedModels: '限制的模型', + enableClientRestriction: '启用客户端限制', + allowedClients: '允许的客户端', + + // 标签和用户 + tags: '标签', + userId: '用户ID', + userUsername: '用户名', + + // 其他信息 + icon: '图标' +} + +// 数据格式化函数 +function formatCSVValue(key, value, shouldSanitize = false) { + if (!value || value === '' || value === 'null' || value === 'undefined') { + return '' + } + + switch (key) { + case 'apiKey': + if (shouldSanitize && value.length > 10) { + return `${value.substring(0, 10)}...[已脱敏]` + } + return value + + case 'isActive': + case 'isActivated': + case 'enableModelRestriction': + case 'enableClientRestriction': + return value === 'true' ? '是' : '否' + + case 'expirationMode': + return value === 'activation' ? '首次使用后激活' : value === 'fixed' ? '固定时间' : value + + case 'activationUnit': + return value === 'hours' ? '小时' : value === 'days' ? '天' : value + + case 'permissions': + switch (value) { + case 'all': + return '全部服务' + case 'claude': + return '仅Claude' + case 'gemini': + return '仅Gemini' + case 'openai': + return '仅OpenAI' + default: + return value + } + + case 'restrictedModels': + case 'allowedClients': + case 'tags': + try { + const parsed = JSON.parse(value) + return Array.isArray(parsed) ? parsed.join('; ') : value + } catch { + return value + } + + case 'createdAt': + case 'lastUsedAt': + case 'activatedAt': + case 'expiresAt': + if (value) { + try { + return new Date(value).toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }) + } catch { + return value + } + } + return '' + + case 'rateLimitWindow': + case 'rateLimitRequests': + case 'concurrencyLimit': + case 'activationDays': + case 'tokenLimit': + return value === '0' || value === 0 ? '无限制' : value + + case 'rateLimitCost': + case 'dailyCostLimit': + case 'totalCostLimit': + case 'weeklyOpusCostLimit': + return value === '0' || value === 0 ? '无限制' : `$${value}` + + default: + return value + } +} + +// 转义 CSV 字段 +function escapeCSVField(field) { + if (field === null || field === undefined) { + return '' + } + + const str = String(field) + + // 如果包含逗号、引号或换行符,需要用引号包围 + if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) { + // 先转义引号(双引号变成两个双引号) + const escaped = str.replace(/"/g, '""') + return `"${escaped}"` + } + + return str +} + +// 转换数据为 CSV 格式 +function convertToCSV(exportDataObj, shouldSanitize = false) { + if (!exportDataObj.data.apiKeys || exportDataObj.data.apiKeys.length === 0) { + throw new Error('CSV format only supports API Keys export. Please use --types=apikeys') + } + + const { apiKeys } = exportDataObj.data + const fields = Object.keys(CSV_FIELD_MAPPING) + const headers = Object.values(CSV_FIELD_MAPPING) + + // 生成标题行 + const csvLines = [headers.map(escapeCSVField).join(',')] + + // 生成数据行 + for (const apiKey of apiKeys) { + const row = fields.map((field) => { + const value = formatCSVValue(field, apiKey[field], shouldSanitize) + return escapeCSVField(value) + }) + csvLines.push(row.join(',')) + } + + return csvLines.join('\n') +} + +// 导出数据 +async function exportData() { + try { + const format = params.format || 'json' + const fileExtension = format === 'csv' ? '.csv' : '.json' + const defaultFileName = `backup-${new Date().toISOString().split('T')[0]}${fileExtension}` + const outputFile = params.output || defaultFileName + const types = params.types ? params.types.split(',') : ['all'] + const shouldSanitize = params.sanitize === true + + // CSV 格式验证 + if (format === 'csv' && !types.includes('apikeys') && !types.includes('all')) { + logger.error('❌ CSV format only supports API Keys export. Please use --types=apikeys') + process.exit(1) + } + + logger.info('🔄 Starting data export...') + logger.info(`📁 Output file: ${outputFile}`) + logger.info(`📋 Data types: ${types.join(', ')}`) + logger.info(`📄 Output format: ${format.toUpperCase()}`) + logger.info(`🔒 Sanitize sensitive data: ${shouldSanitize ? 'YES' : 'NO'}`) + + // 连接 Redis + await redis.connect() + logger.success('✅ Connected to Redis') + + const exportDataObj = { + metadata: { + version: '1.0', + exportDate: new Date().toISOString(), + sanitized: shouldSanitize, + types + }, + data: {} + } + + // 导出 API Keys + if (types.includes('all') || types.includes('apikeys')) { + logger.info('📤 Exporting API Keys...') + const keys = await redis.client.keys('apikey:*') + const apiKeys = [] + + for (const key of keys) { + if (key === 'apikey:hash_map') { + continue + } + + // 使用 hgetall 而不是 get,因为数据存储在哈希表中 + const data = await redis.client.hgetall(key) + + if (data && Object.keys(data).length > 0) { + apiKeys.push(shouldSanitize ? sanitizeData(data, 'apikey') : data) + } + } + + exportDataObj.data.apiKeys = apiKeys + logger.success(`✅ Exported ${apiKeys.length} API Keys`) + } + + // 导出 Claude 账户 + if (types.includes('all') || types.includes('accounts')) { + logger.info('📤 Exporting Claude accounts...') + // 注意:Claude 账户使用 claude:account: 前缀,不是 claude_account: + const keys = await redis.client.keys('claude:account:*') + logger.info(`Found ${keys.length} Claude account keys in Redis`) + const accounts = [] + + for (const key of keys) { + // 使用 hgetall 而不是 get,因为数据存储在哈希表中 + const data = await redis.client.hgetall(key) + + if (data && Object.keys(data).length > 0) { + // 解析 JSON 字段(如果存在) + if (data.claudeAiOauth) { + try { + data.claudeAiOauth = JSON.parse(data.claudeAiOauth) + } catch (e) { + // 保持原样 + } + } + accounts.push(shouldSanitize ? sanitizeData(data, 'claude_account') : data) + } + } + + exportDataObj.data.claudeAccounts = accounts + logger.success(`✅ Exported ${accounts.length} Claude accounts`) + + // 导出 Gemini 账户 + logger.info('📤 Exporting Gemini accounts...') + const geminiKeys = await redis.client.keys('gemini_account:*') + logger.info(`Found ${geminiKeys.length} Gemini account keys in Redis`) + const geminiAccounts = [] + + for (const key of geminiKeys) { + // 使用 hgetall 而不是 get,因为数据存储在哈希表中 + const data = await redis.client.hgetall(key) + + if (data && Object.keys(data).length > 0) { + geminiAccounts.push(shouldSanitize ? sanitizeData(data, 'gemini_account') : data) + } + } + + exportDataObj.data.geminiAccounts = geminiAccounts + logger.success(`✅ Exported ${geminiAccounts.length} Gemini accounts`) + } + + // 导出管理员 + if (types.includes('all') || types.includes('admins')) { + logger.info('📤 Exporting admins...') + const keys = await redis.client.keys('admin:*') + const admins = [] + + for (const key of keys) { + if (key.includes('admin_username:')) { + continue + } + + // 使用 hgetall 而不是 get,因为数据存储在哈希表中 + const data = await redis.client.hgetall(key) + + if (data && Object.keys(data).length > 0) { + admins.push(shouldSanitize ? sanitizeData(data, 'admin') : data) + } + } + + exportDataObj.data.admins = admins + logger.success(`✅ Exported ${admins.length} admins`) + } + + // 根据格式写入文件 + let fileContent + if (format === 'csv') { + fileContent = convertToCSV(exportDataObj, shouldSanitize) + // 添加 UTF-8 BOM 以便 Excel 正确识别中文 + fileContent = `\ufeff${fileContent}` + await fs.writeFile(outputFile, fileContent, 'utf8') + } else { + await fs.writeFile(outputFile, JSON.stringify(exportDataObj, null, 2)) + } + + // 显示导出摘要 + console.log(`\n${'='.repeat(60)}`) + console.log('✅ Export Complete!') + console.log('='.repeat(60)) + console.log(`Output file: ${outputFile}`) + console.log(`File size: ${(await fs.stat(outputFile)).size} bytes`) + + if (exportDataObj.data.apiKeys) { + console.log(`API Keys: ${exportDataObj.data.apiKeys.length}`) + } + if (exportDataObj.data.claudeAccounts) { + console.log(`Claude Accounts: ${exportDataObj.data.claudeAccounts.length}`) + } + if (exportDataObj.data.geminiAccounts) { + console.log(`Gemini Accounts: ${exportDataObj.data.geminiAccounts.length}`) + } + if (exportDataObj.data.admins) { + console.log(`Admins: ${exportDataObj.data.admins.length}`) + } + console.log('='.repeat(60)) + + if (shouldSanitize) { + logger.warn('⚠️ Sensitive data has been sanitized in this export.') + } + } catch (error) { + logger.error('💥 Export failed:', error) + process.exit(1) + } finally { + await redis.disconnect() + rl.close() + } +} + +// 导入数据 +async function importData() { + try { + const inputFile = params.input + if (!inputFile) { + logger.error('❌ Please specify input file with --input=filename.json') + process.exit(1) + } + + const forceOverwrite = params.force === true + const skipConflicts = params['skip-conflicts'] === true + + logger.info('🔄 Starting data import...') + logger.info(`📁 Input file: ${inputFile}`) + logger.info( + `⚡ Mode: ${forceOverwrite ? 'FORCE OVERWRITE' : skipConflicts ? 'SKIP CONFLICTS' : 'ASK ON CONFLICT'}` + ) + + // 读取文件 + const fileContent = await fs.readFile(inputFile, 'utf8') + const importDataObj = JSON.parse(fileContent) + + // 验证文件格式 + if (!importDataObj.metadata || !importDataObj.data) { + logger.error('❌ Invalid backup file format') + process.exit(1) + } + + logger.info(`📅 Backup date: ${importDataObj.metadata.exportDate}`) + logger.info(`🔒 Sanitized: ${importDataObj.metadata.sanitized ? 'YES' : 'NO'}`) + + if (importDataObj.metadata.sanitized) { + logger.warn('⚠️ This backup contains sanitized data. Sensitive fields will be missing!') + const proceed = await askConfirmation('Continue with sanitized data?') + if (!proceed) { + logger.info('❌ Import cancelled') + return + } + } + + // 显示导入摘要 + console.log(`\n${'='.repeat(60)}`) + console.log('📋 Import Summary:') + console.log('='.repeat(60)) + if (importDataObj.data.apiKeys) { + console.log(`API Keys to import: ${importDataObj.data.apiKeys.length}`) + } + if (importDataObj.data.claudeAccounts) { + console.log(`Claude Accounts to import: ${importDataObj.data.claudeAccounts.length}`) + } + if (importDataObj.data.geminiAccounts) { + console.log(`Gemini Accounts to import: ${importDataObj.data.geminiAccounts.length}`) + } + if (importDataObj.data.admins) { + console.log(`Admins to import: ${importDataObj.data.admins.length}`) + } + console.log(`${'='.repeat(60)}\n`) + + // 确认导入 + const confirmed = await askConfirmation('⚠️ Proceed with import?') + if (!confirmed) { + logger.info('❌ Import cancelled') + return + } + + // 连接 Redis + await redis.connect() + logger.success('✅ Connected to Redis') + + const stats = { + imported: 0, + skipped: 0, + errors: 0 + } + + // 导入 API Keys + if (importDataObj.data.apiKeys) { + logger.info('\n📥 Importing API Keys...') + for (const apiKey of importDataObj.data.apiKeys) { + try { + const exists = await redis.client.exists(`apikey:${apiKey.id}`) + + if (exists && !forceOverwrite) { + if (skipConflicts) { + logger.warn(`⏭️ Skipped existing API Key: ${apiKey.name} (${apiKey.id})`) + stats.skipped++ + continue + } else { + const overwrite = await askConfirmation( + `API Key "${apiKey.name}" (${apiKey.id}) exists. Overwrite?` + ) + if (!overwrite) { + stats.skipped++ + continue + } + } + } + + // 使用 hset 存储到哈希表 + const pipeline = redis.client.pipeline() + for (const [field, value] of Object.entries(apiKey)) { + pipeline.hset(`apikey:${apiKey.id}`, field, value) + } + await pipeline.exec() + + // 更新哈希映射 + if (apiKey.apiKey && !importDataObj.metadata.sanitized) { + await redis.client.hset('apikey:hash_map', apiKey.apiKey, apiKey.id) + } + + logger.success(`✅ Imported API Key: ${apiKey.name} (${apiKey.id})`) + stats.imported++ + } catch (error) { + logger.error(`❌ Failed to import API Key ${apiKey.id}:`, error.message) + stats.errors++ + } + } + } + + // 导入 Claude 账户 + if (importDataObj.data.claudeAccounts) { + logger.info('\n📥 Importing Claude accounts...') + for (const account of importDataObj.data.claudeAccounts) { + try { + const exists = await redis.client.exists(`claude_account:${account.id}`) + + if (exists && !forceOverwrite) { + if (skipConflicts) { + logger.warn(`⏭️ Skipped existing Claude account: ${account.name} (${account.id})`) + stats.skipped++ + continue + } else { + const overwrite = await askConfirmation( + `Claude account "${account.name}" (${account.id}) exists. Overwrite?` + ) + if (!overwrite) { + stats.skipped++ + continue + } + } + } + + // 使用 hset 存储到哈希表 + const pipeline = redis.client.pipeline() + for (const [field, value] of Object.entries(account)) { + // 如果是对象,需要序列化 + if (field === 'claudeAiOauth' && typeof value === 'object') { + pipeline.hset(`claude_account:${account.id}`, field, JSON.stringify(value)) + } else { + pipeline.hset(`claude_account:${account.id}`, field, value) + } + } + await pipeline.exec() + logger.success(`✅ Imported Claude account: ${account.name} (${account.id})`) + stats.imported++ + } catch (error) { + logger.error(`❌ Failed to import Claude account ${account.id}:`, error.message) + stats.errors++ + } + } + } + + // 导入 Gemini 账户 + if (importDataObj.data.geminiAccounts) { + logger.info('\n📥 Importing Gemini accounts...') + for (const account of importDataObj.data.geminiAccounts) { + try { + const exists = await redis.client.exists(`gemini_account:${account.id}`) + + if (exists && !forceOverwrite) { + if (skipConflicts) { + logger.warn(`⏭️ Skipped existing Gemini account: ${account.name} (${account.id})`) + stats.skipped++ + continue + } else { + const overwrite = await askConfirmation( + `Gemini account "${account.name}" (${account.id}) exists. Overwrite?` + ) + if (!overwrite) { + stats.skipped++ + continue + } + } + } + + // 使用 hset 存储到哈希表 + const pipeline = redis.client.pipeline() + for (const [field, value] of Object.entries(account)) { + pipeline.hset(`gemini_account:${account.id}`, field, value) + } + await pipeline.exec() + logger.success(`✅ Imported Gemini account: ${account.name} (${account.id})`) + stats.imported++ + } catch (error) { + logger.error(`❌ Failed to import Gemini account ${account.id}:`, error.message) + stats.errors++ + } + } + } + + // 显示导入结果 + console.log(`\n${'='.repeat(60)}`) + console.log('✅ Import Complete!') + console.log('='.repeat(60)) + console.log(`Successfully imported: ${stats.imported}`) + console.log(`Skipped: ${stats.skipped}`) + console.log(`Errors: ${stats.errors}`) + console.log('='.repeat(60)) + } catch (error) { + logger.error('💥 Import failed:', error) + process.exit(1) + } finally { + await redis.disconnect() + rl.close() + } +} + +// 显示帮助信息 +function showHelp() { + console.log(` +Data Transfer Tool for Claude Relay Service + +This tool allows you to export and import data between environments. + +Usage: + node scripts/data-transfer.js [options] + +Commands: + export Export data from Redis to a JSON file + import Import data from a JSON file to Redis + +Export Options: + --output=FILE Output filename (default: backup-YYYY-MM-DD.json/.csv) + --types=TYPE,... Data types to export: apikeys,accounts,admins,all (default: all) + --format=FORMAT Output format: json,csv (default: json) + --sanitize Remove sensitive data from export + +Import Options: + --input=FILE Input filename (required) + --force Overwrite existing data without asking + --skip-conflicts Skip conflicting data without asking + +Examples: + # Export all data + node scripts/data-transfer.js export + + # Export only API keys with sanitized data + node scripts/data-transfer.js export --types=apikeys --sanitize + + # Import data, skip conflicts + node scripts/data-transfer.js import --input=backup.json --skip-conflicts + + # Export specific data types + node scripts/data-transfer.js export --types=apikeys,accounts --output=prod-data.json + + # Export API keys to CSV format + node scripts/data-transfer.js export --types=apikeys --format=csv --sanitize + + # Export to CSV with custom filename + node scripts/data-transfer.js export --types=apikeys --format=csv --output=api-keys.csv +`) +} + +// 主函数 +async function main() { + if (!command || command === '--help' || command === 'help') { + showHelp() + process.exit(0) + } + + switch (command) { + case 'export': + await exportData() + break + + case 'import': + await importData() + break + + default: + logger.error(`❌ Unknown command: ${command}`) + showHelp() + process.exit(1) + } +} + +// 运行 +main().catch((error) => { + logger.error('💥 Unexpected error:', error) + process.exit(1) +}) diff --git a/scripts/debug-redis-keys.js b/scripts/debug-redis-keys.js new file mode 100644 index 0000000000000000000000000000000000000000..4cd7e9d3d09c7b3ca6fce6519166b1bf6f41617d --- /dev/null +++ b/scripts/debug-redis-keys.js @@ -0,0 +1,126 @@ +#!/usr/bin/env node + +/** + * Redis 键调试工具 + * 用于查看 Redis 中存储的所有键和数据结构 + */ + +const redis = require('../src/models/redis') +const logger = require('../src/utils/logger') + +async function debugRedisKeys() { + try { + logger.info('🔄 Connecting to Redis...') + await redis.connect() + logger.success('✅ Connected to Redis') + + // 获取所有键 + const allKeys = await redis.client.keys('*') + logger.info(`\n📊 Total keys in Redis: ${allKeys.length}\n`) + + // 按类型分组 + const keysByType = { + apiKeys: [], + claudeAccounts: [], + geminiAccounts: [], + admins: [], + sessions: [], + usage: [], + other: [] + } + + // 分类键 + for (const key of allKeys) { + if (key.startsWith('apikey:')) { + keysByType.apiKeys.push(key) + } else if (key.startsWith('claude_account:')) { + keysByType.claudeAccounts.push(key) + } else if (key.startsWith('gemini_account:')) { + keysByType.geminiAccounts.push(key) + } else if (key.startsWith('admin:') || key.startsWith('admin_username:')) { + keysByType.admins.push(key) + } else if (key.startsWith('session:')) { + keysByType.sessions.push(key) + } else if ( + key.includes('usage') || + key.includes('rate_limit') || + key.includes('concurrency') + ) { + keysByType.usage.push(key) + } else { + keysByType.other.push(key) + } + } + + // 显示分类结果 + console.log('='.repeat(60)) + console.log('📂 Keys by Category:') + console.log('='.repeat(60)) + console.log(`API Keys: ${keysByType.apiKeys.length}`) + console.log(`Claude Accounts: ${keysByType.claudeAccounts.length}`) + console.log(`Gemini Accounts: ${keysByType.geminiAccounts.length}`) + console.log(`Admins: ${keysByType.admins.length}`) + console.log(`Sessions: ${keysByType.sessions.length}`) + console.log(`Usage/Rate Limit: ${keysByType.usage.length}`) + console.log(`Other: ${keysByType.other.length}`) + console.log('='.repeat(60)) + + // 详细显示每个类别的键 + if (keysByType.apiKeys.length > 0) { + console.log('\n🔑 API Keys:') + for (const key of keysByType.apiKeys.slice(0, 5)) { + console.log(` - ${key}`) + } + if (keysByType.apiKeys.length > 5) { + console.log(` ... and ${keysByType.apiKeys.length - 5} more`) + } + } + + if (keysByType.claudeAccounts.length > 0) { + console.log('\n🤖 Claude Accounts:') + for (const key of keysByType.claudeAccounts) { + console.log(` - ${key}`) + } + } + + if (keysByType.geminiAccounts.length > 0) { + console.log('\n💎 Gemini Accounts:') + for (const key of keysByType.geminiAccounts) { + console.log(` - ${key}`) + } + } + + if (keysByType.other.length > 0) { + console.log('\n❓ Other Keys:') + for (const key of keysByType.other.slice(0, 10)) { + console.log(` - ${key}`) + } + if (keysByType.other.length > 10) { + console.log(` ... and ${keysByType.other.length - 10} more`) + } + } + + // 检查数据类型 + console.log(`\n${'='.repeat(60)}`) + console.log('🔍 Checking Data Types:') + console.log('='.repeat(60)) + + // 随机检查几个键的类型 + const sampleKeys = allKeys.slice(0, Math.min(10, allKeys.length)) + for (const key of sampleKeys) { + const type = await redis.client.type(key) + console.log(`${key} => ${type}`) + } + } catch (error) { + logger.error('💥 Debug failed:', error) + } finally { + await redis.disconnect() + logger.info('👋 Disconnected from Redis') + } +} + +// 运行调试 +debugRedisKeys().catch((error) => { + logger.error('💥 Unexpected error:', error) + process.exit(1) +}) diff --git a/scripts/fix-inquirer.js b/scripts/fix-inquirer.js new file mode 100644 index 0000000000000000000000000000000000000000..c21592bb4312987a089f3c69a6c6111c1587e4b6 --- /dev/null +++ b/scripts/fix-inquirer.js @@ -0,0 +1,29 @@ +#!/usr/bin/env node + +/** + * 修复 inquirer ESM 问题 + * 降级到支持 CommonJS 的版本 + */ + +const { execSync } = require('child_process') + +console.log('🔧 修复 inquirer ESM 兼容性问题...\n') + +try { + // 卸载当前版本 + console.log('📦 卸载当前 inquirer 版本...') + execSync('npm uninstall inquirer', { stdio: 'inherit' }) + + // 安装兼容 CommonJS 的版本 (8.x 是最后支持 CommonJS 的主要版本) + console.log('\n📦 安装兼容版本 inquirer@8.2.6...') + execSync('npm install inquirer@8.2.6', { stdio: 'inherit' }) + + console.log('\n✅ 修复完成!') + console.log('\n现在可以正常使用 CLI 工具了:') + console.log(' npm run cli admin') + console.log(' npm run cli keys') + console.log(' npm run cli status') +} catch (error) { + console.error('❌ 修复失败:', error.message) + process.exit(1) +} diff --git a/scripts/fix-usage-stats.js b/scripts/fix-usage-stats.js new file mode 100644 index 0000000000000000000000000000000000000000..af9cf984297cd1ca25237c6bd03a3adb8cceeb34 --- /dev/null +++ b/scripts/fix-usage-stats.js @@ -0,0 +1,227 @@ +#!/usr/bin/env node + +/** + * 数据迁移脚本:修复历史使用统计数据 + * + * 功能: + * 1. 统一 totalTokens 和 allTokens 字段 + * 2. 确保 allTokens 包含所有类型的 tokens + * 3. 修复历史数据的不一致性 + * + * 使用方法: + * node scripts/fix-usage-stats.js [--dry-run] + */ + +require('dotenv').config() +const redis = require('../src/models/redis') +const logger = require('../src/utils/logger') + +// 解析命令行参数 +const args = process.argv.slice(2) +const isDryRun = args.includes('--dry-run') + +async function fixUsageStats() { + try { + logger.info('🔧 开始修复使用统计数据...') + if (isDryRun) { + logger.info('📝 DRY RUN 模式 - 不会实际修改数据') + } + + // 连接到 Redis + await redis.connect() + logger.success('✅ 已连接到 Redis') + + const client = redis.getClientSafe() + + // 统计信息 + const stats = { + totalKeys: 0, + fixedTotalKeys: 0, + fixedDailyKeys: 0, + fixedMonthlyKeys: 0, + fixedModelKeys: 0, + errors: 0 + } + + // 1. 修复 API Key 级别的总统计 + logger.info('\n📊 修复 API Key 总统计数据...') + const apiKeyPattern = 'apikey:*' + const apiKeys = await client.keys(apiKeyPattern) + stats.totalKeys = apiKeys.length + + for (const apiKeyKey of apiKeys) { + const keyId = apiKeyKey.replace('apikey:', '') + const usageKey = `usage:${keyId}` + + try { + const usageData = await client.hgetall(usageKey) + if (usageData && Object.keys(usageData).length > 0) { + const inputTokens = parseInt(usageData.totalInputTokens) || 0 + const outputTokens = parseInt(usageData.totalOutputTokens) || 0 + const cacheCreateTokens = parseInt(usageData.totalCacheCreateTokens) || 0 + const cacheReadTokens = parseInt(usageData.totalCacheReadTokens) || 0 + const currentAllTokens = parseInt(usageData.totalAllTokens) || 0 + + // 计算正确的 allTokens + const correctAllTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + + if (currentAllTokens !== correctAllTokens && correctAllTokens > 0) { + logger.info(` 修复 ${keyId}: ${currentAllTokens} -> ${correctAllTokens}`) + + if (!isDryRun) { + await client.hset(usageKey, 'totalAllTokens', correctAllTokens) + } + stats.fixedTotalKeys++ + } + } + } catch (error) { + logger.error(` 错误处理 ${keyId}: ${error.message}`) + stats.errors++ + } + } + + // 2. 修复每日统计数据 + logger.info('\n📅 修复每日统计数据...') + const dailyPattern = 'usage:daily:*' + const dailyKeys = await client.keys(dailyPattern) + + for (const dailyKey of dailyKeys) { + try { + const data = await client.hgetall(dailyKey) + if (data && Object.keys(data).length > 0) { + const inputTokens = parseInt(data.inputTokens) || 0 + const outputTokens = parseInt(data.outputTokens) || 0 + const cacheCreateTokens = parseInt(data.cacheCreateTokens) || 0 + const cacheReadTokens = parseInt(data.cacheReadTokens) || 0 + const currentAllTokens = parseInt(data.allTokens) || 0 + + const correctAllTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + + if (currentAllTokens !== correctAllTokens && correctAllTokens > 0) { + if (!isDryRun) { + await client.hset(dailyKey, 'allTokens', correctAllTokens) + } + stats.fixedDailyKeys++ + } + } + } catch (error) { + logger.error(` 错误处理 ${dailyKey}: ${error.message}`) + stats.errors++ + } + } + + // 3. 修复每月统计数据 + logger.info('\n📆 修复每月统计数据...') + const monthlyPattern = 'usage:monthly:*' + const monthlyKeys = await client.keys(monthlyPattern) + + for (const monthlyKey of monthlyKeys) { + try { + const data = await client.hgetall(monthlyKey) + if (data && Object.keys(data).length > 0) { + const inputTokens = parseInt(data.inputTokens) || 0 + const outputTokens = parseInt(data.outputTokens) || 0 + const cacheCreateTokens = parseInt(data.cacheCreateTokens) || 0 + const cacheReadTokens = parseInt(data.cacheReadTokens) || 0 + const currentAllTokens = parseInt(data.allTokens) || 0 + + const correctAllTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + + if (currentAllTokens !== correctAllTokens && correctAllTokens > 0) { + if (!isDryRun) { + await client.hset(monthlyKey, 'allTokens', correctAllTokens) + } + stats.fixedMonthlyKeys++ + } + } + } catch (error) { + logger.error(` 错误处理 ${monthlyKey}: ${error.message}`) + stats.errors++ + } + } + + // 4. 修复模型级别的统计数据 + logger.info('\n🤖 修复模型级别统计数据...') + const modelPatterns = [ + 'usage:model:daily:*', + 'usage:model:monthly:*', + 'usage:*:model:daily:*', + 'usage:*:model:monthly:*' + ] + + for (const pattern of modelPatterns) { + const modelKeys = await client.keys(pattern) + + for (const modelKey of modelKeys) { + try { + const data = await client.hgetall(modelKey) + if (data && Object.keys(data).length > 0) { + const inputTokens = parseInt(data.inputTokens) || 0 + const outputTokens = parseInt(data.outputTokens) || 0 + const cacheCreateTokens = parseInt(data.cacheCreateTokens) || 0 + const cacheReadTokens = parseInt(data.cacheReadTokens) || 0 + const currentAllTokens = parseInt(data.allTokens) || 0 + + const correctAllTokens = + inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + + if (currentAllTokens !== correctAllTokens && correctAllTokens > 0) { + if (!isDryRun) { + await client.hset(modelKey, 'allTokens', correctAllTokens) + } + stats.fixedModelKeys++ + } + } + } catch (error) { + logger.error(` 错误处理 ${modelKey}: ${error.message}`) + stats.errors++ + } + } + } + + // 5. 验证修复结果 + if (!isDryRun) { + logger.info('\n✅ 验证修复结果...') + + // 随机抽样验证 + const sampleSize = Math.min(5, apiKeys.length) + for (let i = 0; i < sampleSize; i++) { + const randomIndex = Math.floor(Math.random() * apiKeys.length) + const keyId = apiKeys[randomIndex].replace('apikey:', '') + const usage = await redis.getUsageStats(keyId) + + logger.info(` 样本 ${keyId}:`) + logger.info(` Total tokens: ${usage.total.tokens}`) + logger.info(` All tokens: ${usage.total.allTokens}`) + logger.info(` 一致性: ${usage.total.tokens === usage.total.allTokens ? '✅' : '❌'}`) + } + } + + // 打印统计结果 + logger.info('\n📊 修复统计:') + logger.info(` 总 API Keys: ${stats.totalKeys}`) + logger.info(` 修复的总统计: ${stats.fixedTotalKeys}`) + logger.info(` 修复的日统计: ${stats.fixedDailyKeys}`) + logger.info(` 修复的月统计: ${stats.fixedMonthlyKeys}`) + logger.info(` 修复的模型统计: ${stats.fixedModelKeys}`) + logger.info(` 错误数: ${stats.errors}`) + + if (isDryRun) { + logger.info('\n💡 这是 DRY RUN - 没有实际修改数据') + logger.info(' 运行不带 --dry-run 参数来实际执行修复') + } else { + logger.success('\n✅ 数据修复完成!') + } + } catch (error) { + logger.error('❌ 修复过程出错:', error) + process.exit(1) + } finally { + await redis.disconnect() + } +} + +// 执行修复 +fixUsageStats().catch((error) => { + logger.error('❌ 未处理的错误:', error) + process.exit(1) +}) diff --git a/scripts/generate-test-data.js b/scripts/generate-test-data.js new file mode 100644 index 0000000000000000000000000000000000000000..a1b181bd9f8791e6b08d7f607f01c9c55ff645c6 --- /dev/null +++ b/scripts/generate-test-data.js @@ -0,0 +1,280 @@ +#!/usr/bin/env node + +/** + * 历史数据生成脚本 + * 用于测试不同时间范围的Token统计功能 + * + * 使用方法: + * node scripts/generate-test-data.js [--clean] + * + * 选项: + * --clean: 清除所有测试数据 + */ + +const redis = require('../src/models/redis') +const logger = require('../src/utils/logger') + +// 解析命令行参数 +const args = process.argv.slice(2) +const shouldClean = args.includes('--clean') + +// 模拟的模型列表 +const models = [ + 'claude-sonnet-4-20250514', + 'claude-3-5-sonnet-20241022', + 'claude-3-5-haiku-20241022', + 'claude-3-opus-20240229' +] + +// 生成指定日期的数据 +async function generateDataForDate(apiKeyId, date, dayOffset) { + const client = redis.getClientSafe() + const dateStr = date.toISOString().split('T')[0] + const month = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` + + // 根据日期偏移量调整数据量(越近的日期数据越多) + const requestCount = Math.max(5, 20 - dayOffset * 2) // 5-20个请求 + + logger.info(`📊 Generating ${requestCount} requests for ${dateStr}`) + + for (let i = 0; i < requestCount; i++) { + // 随机选择模型 + const model = models[Math.floor(Math.random() * models.length)] + + // 生成随机Token数据 + const inputTokens = Math.floor(Math.random() * 2000) + 500 // 500-2500 + const outputTokens = Math.floor(Math.random() * 3000) + 1000 // 1000-4000 + const cacheCreateTokens = Math.random() > 0.7 ? Math.floor(Math.random() * 1000) : 0 // 30%概率有缓存创建 + const cacheReadTokens = Math.random() > 0.5 ? Math.floor(Math.random() * 500) : 0 // 50%概率有缓存读取 + + const coreTokens = inputTokens + outputTokens + const allTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + + // 更新各种统计键 + const totalKey = `usage:${apiKeyId}` + const dailyKey = `usage:daily:${apiKeyId}:${dateStr}` + const monthlyKey = `usage:monthly:${apiKeyId}:${month}` + const modelDailyKey = `usage:model:daily:${model}:${dateStr}` + const modelMonthlyKey = `usage:model:monthly:${model}:${month}` + const keyModelDailyKey = `usage:${apiKeyId}:model:daily:${model}:${dateStr}` + const keyModelMonthlyKey = `usage:${apiKeyId}:model:monthly:${model}:${month}` + + await Promise.all([ + // 总计数据 + client.hincrby(totalKey, 'totalTokens', coreTokens), + client.hincrby(totalKey, 'totalInputTokens', inputTokens), + client.hincrby(totalKey, 'totalOutputTokens', outputTokens), + client.hincrby(totalKey, 'totalCacheCreateTokens', cacheCreateTokens), + client.hincrby(totalKey, 'totalCacheReadTokens', cacheReadTokens), + client.hincrby(totalKey, 'totalAllTokens', allTokens), + client.hincrby(totalKey, 'totalRequests', 1), + + // 每日统计 + client.hincrby(dailyKey, 'tokens', coreTokens), + client.hincrby(dailyKey, 'inputTokens', inputTokens), + client.hincrby(dailyKey, 'outputTokens', outputTokens), + client.hincrby(dailyKey, 'cacheCreateTokens', cacheCreateTokens), + client.hincrby(dailyKey, 'cacheReadTokens', cacheReadTokens), + client.hincrby(dailyKey, 'allTokens', allTokens), + client.hincrby(dailyKey, 'requests', 1), + + // 每月统计 + client.hincrby(monthlyKey, 'tokens', coreTokens), + client.hincrby(monthlyKey, 'inputTokens', inputTokens), + client.hincrby(monthlyKey, 'outputTokens', outputTokens), + client.hincrby(monthlyKey, 'cacheCreateTokens', cacheCreateTokens), + client.hincrby(monthlyKey, 'cacheReadTokens', cacheReadTokens), + client.hincrby(monthlyKey, 'allTokens', allTokens), + client.hincrby(monthlyKey, 'requests', 1), + + // 模型统计 - 每日 + client.hincrby(modelDailyKey, 'totalInputTokens', inputTokens), + client.hincrby(modelDailyKey, 'totalOutputTokens', outputTokens), + client.hincrby(modelDailyKey, 'totalCacheCreateTokens', cacheCreateTokens), + client.hincrby(modelDailyKey, 'totalCacheReadTokens', cacheReadTokens), + client.hincrby(modelDailyKey, 'totalAllTokens', allTokens), + client.hincrby(modelDailyKey, 'requests', 1), + + // 模型统计 - 每月 + client.hincrby(modelMonthlyKey, 'totalInputTokens', inputTokens), + client.hincrby(modelMonthlyKey, 'totalOutputTokens', outputTokens), + client.hincrby(modelMonthlyKey, 'totalCacheCreateTokens', cacheCreateTokens), + client.hincrby(modelMonthlyKey, 'totalCacheReadTokens', cacheReadTokens), + client.hincrby(modelMonthlyKey, 'totalAllTokens', allTokens), + client.hincrby(modelMonthlyKey, 'requests', 1), + + // API Key级别的模型统计 - 每日 + // 同时存储带total前缀和不带前缀的字段,保持兼容性 + client.hincrby(keyModelDailyKey, 'inputTokens', inputTokens), + client.hincrby(keyModelDailyKey, 'outputTokens', outputTokens), + client.hincrby(keyModelDailyKey, 'cacheCreateTokens', cacheCreateTokens), + client.hincrby(keyModelDailyKey, 'cacheReadTokens', cacheReadTokens), + client.hincrby(keyModelDailyKey, 'allTokens', allTokens), + client.hincrby(keyModelDailyKey, 'totalInputTokens', inputTokens), + client.hincrby(keyModelDailyKey, 'totalOutputTokens', outputTokens), + client.hincrby(keyModelDailyKey, 'totalCacheCreateTokens', cacheCreateTokens), + client.hincrby(keyModelDailyKey, 'totalCacheReadTokens', cacheReadTokens), + client.hincrby(keyModelDailyKey, 'totalAllTokens', allTokens), + client.hincrby(keyModelDailyKey, 'requests', 1), + + // API Key级别的模型统计 - 每月 + client.hincrby(keyModelMonthlyKey, 'inputTokens', inputTokens), + client.hincrby(keyModelMonthlyKey, 'outputTokens', outputTokens), + client.hincrby(keyModelMonthlyKey, 'cacheCreateTokens', cacheCreateTokens), + client.hincrby(keyModelMonthlyKey, 'cacheReadTokens', cacheReadTokens), + client.hincrby(keyModelMonthlyKey, 'allTokens', allTokens), + client.hincrby(keyModelMonthlyKey, 'totalInputTokens', inputTokens), + client.hincrby(keyModelMonthlyKey, 'totalOutputTokens', outputTokens), + client.hincrby(keyModelMonthlyKey, 'totalCacheCreateTokens', cacheCreateTokens), + client.hincrby(keyModelMonthlyKey, 'totalCacheReadTokens', cacheReadTokens), + client.hincrby(keyModelMonthlyKey, 'totalAllTokens', allTokens), + client.hincrby(keyModelMonthlyKey, 'requests', 1) + ]) + } +} + +// 清除测试数据 +async function cleanTestData() { + const client = redis.getClientSafe() + const apiKeyService = require('../src/services/apiKeyService') + + logger.info('🧹 Cleaning test data...') + + // 获取所有API Keys + const allKeys = await apiKeyService.getAllApiKeys() + + // 找出所有测试 API Keys + const testKeys = allKeys.filter((key) => key.name && key.name.startsWith('Test API Key')) + + for (const testKey of testKeys) { + const apiKeyId = testKey.id + + // 获取所有相关的键 + const patterns = [ + `usage:${apiKeyId}`, + `usage:daily:${apiKeyId}:*`, + `usage:monthly:${apiKeyId}:*`, + `usage:${apiKeyId}:model:daily:*`, + `usage:${apiKeyId}:model:monthly:*` + ] + + for (const pattern of patterns) { + const keys = await client.keys(pattern) + if (keys.length > 0) { + await client.del(...keys) + logger.info(`🗑️ Deleted ${keys.length} keys matching pattern: ${pattern}`) + } + } + + // 删除 API Key 本身 + await apiKeyService.deleteApiKey(apiKeyId) + logger.info(`🗑️ Deleted test API Key: ${testKey.name} (${apiKeyId})`) + } + + // 清除模型统计 + const modelPatterns = ['usage:model:daily:*', 'usage:model:monthly:*'] + + for (const pattern of modelPatterns) { + const keys = await client.keys(pattern) + if (keys.length > 0) { + await client.del(...keys) + logger.info(`🗑️ Deleted ${keys.length} keys matching pattern: ${pattern}`) + } + } +} + +// 主函数 +async function main() { + try { + await redis.connect() + logger.success('✅ Connected to Redis') + + // 创建测试API Keys + const apiKeyService = require('../src/services/apiKeyService') + const testApiKeys = [] + const createdKeys = [] + + // 总是创建新的测试 API Keys + logger.info('📝 Creating test API Keys...') + + for (let i = 1; i <= 3; i++) { + const newKey = await apiKeyService.generateApiKey({ + name: `Test API Key ${i}`, + description: `Test key for historical data generation ${i}`, + tokenLimit: 10000000, + concurrencyLimit: 10, + rateLimitWindow: 60, + rateLimitRequests: 100 + }) + + testApiKeys.push(newKey.id) + createdKeys.push(newKey) + logger.success(`✅ Created test API Key: ${newKey.name} (${newKey.id})`) + logger.info(` 🔑 API Key: ${newKey.apiKey}`) + } + + if (shouldClean) { + await cleanTestData() + logger.success('✅ Test data cleaned successfully') + return + } + + // 生成历史数据 + const now = new Date() + + for (const apiKeyId of testApiKeys) { + logger.info(`\n🔄 Generating data for API Key: ${apiKeyId}`) + + // 生成过去30天的数据 + for (let dayOffset = 0; dayOffset < 30; dayOffset++) { + const date = new Date(now) + date.setDate(date.getDate() - dayOffset) + + await generateDataForDate(apiKeyId, date, dayOffset) + } + + logger.success(`✅ Generated 30 days of historical data for API Key: ${apiKeyId}`) + } + + // 显示统计摘要 + logger.info('\n📊 Test Data Summary:') + logger.info('='.repeat(60)) + + for (const apiKeyId of testApiKeys) { + const totalKey = `usage:${apiKeyId}` + const totalData = await redis.getClientSafe().hgetall(totalKey) + + if (totalData && Object.keys(totalData).length > 0) { + logger.info(`\nAPI Key: ${apiKeyId}`) + logger.info(` Total Requests: ${totalData.totalRequests || 0}`) + logger.info(` Total Tokens (Core): ${totalData.totalTokens || 0}`) + logger.info(` Total Tokens (All): ${totalData.totalAllTokens || 0}`) + logger.info(` Input Tokens: ${totalData.totalInputTokens || 0}`) + logger.info(` Output Tokens: ${totalData.totalOutputTokens || 0}`) + logger.info(` Cache Create Tokens: ${totalData.totalCacheCreateTokens || 0}`) + logger.info(` Cache Read Tokens: ${totalData.totalCacheReadTokens || 0}`) + } + } + + logger.info(`\n${'='.repeat(60)}`) + logger.success('\n✅ Test data generation completed!') + logger.info('\n📋 Created API Keys:') + for (const key of createdKeys) { + logger.info(`- ${key.name}: ${key.apiKey}`) + } + logger.info('\n💡 Tips:') + logger.info('- Check the admin panel to see the different time ranges') + logger.info('- Use --clean flag to remove all test data and API Keys') + logger.info('- The script generates more recent data to simulate real usage patterns') + } catch (error) { + logger.error('❌ Error:', error) + } finally { + await redis.disconnect() + } +} + +// 运行脚本 +main().catch((error) => { + logger.error('💥 Unexpected error:', error) + process.exit(1) +}) diff --git a/scripts/manage-session-windows.js b/scripts/manage-session-windows.js new file mode 100644 index 0000000000000000000000000000000000000000..a8ed7a2d5e2c24dfea66160e0bd2b5419bd8e164 --- /dev/null +++ b/scripts/manage-session-windows.js @@ -0,0 +1,561 @@ +#!/usr/bin/env node + +/** + * 会话窗口管理脚本 + * 用于调试、恢复和管理Claude账户的会话窗口 + */ + +const redis = require('../src/models/redis') +const claudeAccountService = require('../src/services/claudeAccountService') +const readline = require('readline') + +// 创建readline接口 +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout +}) + +// 辅助函数:询问用户输入 +function askQuestion(question) { + return new Promise((resolve) => { + rl.question(question, resolve) + }) +} + +// 辅助函数:解析时间输入 +function parseTimeInput(input) { + const now = new Date() + + // 如果是 HH:MM 格式 + const timeMatch = input.match(/^(\d{1,2}):(\d{2})$/) + if (timeMatch) { + const hour = parseInt(timeMatch[1]) + const minute = parseInt(timeMatch[2]) + + if (hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59) { + const time = new Date(now) + time.setHours(hour, minute, 0, 0) + return time + } + } + + // 如果是相对时间(如 "2小时前") + const relativeMatch = input.match(/^(\d+)(小时|分钟)前$/) + if (relativeMatch) { + const amount = parseInt(relativeMatch[1]) + const unit = relativeMatch[2] + const time = new Date(now) + + if (unit === '小时') { + time.setHours(time.getHours() - amount) + } else if (unit === '分钟') { + time.setMinutes(time.getMinutes() - amount) + } + + return time + } + + // 如果是 ISO 格式或其他日期格式 + const parsedDate = new Date(input) + if (!isNaN(parsedDate.getTime())) { + return parsedDate + } + + return null +} + +// 辅助函数:显示可用的时间窗口选项 +function showTimeWindowOptions() { + const now = new Date() + console.log('\n⏰ 可用的5小时时间窗口:') + + for (let hour = 0; hour < 24; hour += 5) { + const start = hour + const end = hour + 5 + const startStr = `${String(start).padStart(2, '0')}:00` + const endStr = `${String(end).padStart(2, '0')}:00` + + const currentHour = now.getHours() + const isActive = currentHour >= start && currentHour < end + const status = isActive ? ' 🟢 (当前活跃)' : '' + + console.log(` ${start / 5 + 1}. ${startStr} - ${endStr}${status}`) + } + console.log('') +} + +const commands = { + // 调试所有账户的会话窗口状态 + async debug() { + console.log('🔍 开始调试会话窗口状态...\n') + + const accounts = await redis.getAllClaudeAccounts() + console.log(`📊 找到 ${accounts.length} 个Claude账户\n`) + + const stats = { + total: accounts.length, + hasWindow: 0, + hasLastUsed: 0, + canRecover: 0, + expired: 0 + } + + for (const account of accounts) { + console.log(`🏢 ${account.name} (${account.id})`) + console.log(` 状态: ${account.isActive === 'true' ? '✅ 活跃' : '❌ 禁用'}`) + + if (account.sessionWindowStart && account.sessionWindowEnd) { + stats.hasWindow++ + const windowStart = new Date(account.sessionWindowStart) + const windowEnd = new Date(account.sessionWindowEnd) + const now = new Date() + const isActive = now < windowEnd + + console.log(` 会话窗口: ${windowStart.toLocaleString()} - ${windowEnd.toLocaleString()}`) + console.log(` 窗口状态: ${isActive ? '✅ 活跃' : '❌ 已过期'}`) + + // 只有在窗口已过期时才显示可恢复窗口 + if (!isActive && account.lastUsedAt) { + const lastUsed = new Date(account.lastUsedAt) + const recoveredWindowStart = claudeAccountService._calculateSessionWindowStart(lastUsed) + const recoveredWindowEnd = + claudeAccountService._calculateSessionWindowEnd(recoveredWindowStart) + + if (now < recoveredWindowEnd) { + stats.canRecover++ + console.log( + ` 可恢复窗口: ✅ ${recoveredWindowStart.toLocaleString()} - ${recoveredWindowEnd.toLocaleString()}` + ) + } else { + stats.expired++ + console.log( + ` 可恢复窗口: ❌ 已过期 (${recoveredWindowStart.toLocaleString()} - ${recoveredWindowEnd.toLocaleString()})` + ) + } + } + } else { + console.log(' 会话窗口: ❌ 无') + + // 没有会话窗口时,检查是否有可恢复的窗口 + if (account.lastUsedAt) { + const lastUsed = new Date(account.lastUsedAt) + const now = new Date() + const windowStart = claudeAccountService._calculateSessionWindowStart(lastUsed) + const windowEnd = claudeAccountService._calculateSessionWindowEnd(windowStart) + + if (now < windowEnd) { + stats.canRecover++ + console.log( + ` 可恢复窗口: ✅ ${windowStart.toLocaleString()} - ${windowEnd.toLocaleString()}` + ) + } else { + stats.expired++ + console.log( + ` 可恢复窗口: ❌ 已过期 (${windowStart.toLocaleString()} - ${windowEnd.toLocaleString()})` + ) + } + } + } + + if (account.lastUsedAt) { + stats.hasLastUsed++ + const lastUsed = new Date(account.lastUsedAt) + const now = new Date() + const minutesAgo = Math.round((now - lastUsed) / (1000 * 60)) + + console.log(` 最后使用: ${lastUsed.toLocaleString()} (${minutesAgo}分钟前)`) + } else { + console.log(' 最后使用: ❌ 无记录') + } + + console.log('') + } + + console.log('📈 汇总统计:') + console.log(` 总账户数: ${stats.total}`) + console.log(` 有会话窗口: ${stats.hasWindow}`) + console.log(` 有使用记录: ${stats.hasLastUsed}`) + console.log(` 可以恢复: ${stats.canRecover}`) + console.log(` 窗口已过期: ${stats.expired}`) + }, + + // 初始化会话窗口(默认行为) + async init() { + console.log('🔄 初始化会话窗口...\n') + const result = await claudeAccountService.initializeSessionWindows() + + console.log('\n📊 初始化结果:') + console.log(` 总账户数: ${result.total}`) + console.log(` 成功初始化: ${result.initialized}`) + console.log(` 已跳过(已有窗口): ${result.skipped}`) + console.log(` 窗口已过期: ${result.expired}`) + console.log(` 无使用数据: ${result.noData}`) + + if (result.error) { + console.log(` 错误: ${result.error}`) + } + }, + + // 强制重新计算所有会话窗口 + async force() { + console.log('🔄 强制重新计算所有会话窗口...\n') + const result = await claudeAccountService.initializeSessionWindows(true) + + console.log('\n📊 强制重算结果:') + console.log(` 总账户数: ${result.total}`) + console.log(` 成功初始化: ${result.initialized}`) + console.log(` 窗口已过期: ${result.expired}`) + console.log(` 无使用数据: ${result.noData}`) + + if (result.error) { + console.log(` 错误: ${result.error}`) + } + }, + + // 清除所有会话窗口 + async clear() { + console.log('🗑️ 清除所有会话窗口...\n') + + const accounts = await redis.getAllClaudeAccounts() + let clearedCount = 0 + + for (const account of accounts) { + if (account.sessionWindowStart || account.sessionWindowEnd) { + delete account.sessionWindowStart + delete account.sessionWindowEnd + delete account.lastRequestTime + + await redis.setClaudeAccount(account.id, account) + clearedCount++ + + console.log(`✅ 清除账户 ${account.name} (${account.id}) 的会话窗口`) + } + } + + console.log(`\n📊 清除完成: 共清除 ${clearedCount} 个账户的会话窗口`) + }, + + // 创建测试会话窗口(将lastUsedAt设置为当前时间) + async test() { + console.log('🧪 创建测试会话窗口...\n') + + const accounts = await redis.getAllClaudeAccounts() + if (accounts.length === 0) { + console.log('❌ 没有找到Claude账户') + return + } + + const now = new Date() + let updatedCount = 0 + + for (const account of accounts) { + if (account.isActive === 'true') { + // 设置为当前时间(模拟刚刚使用) + account.lastUsedAt = now.toISOString() + + // 计算新的会话窗口 + const windowStart = claudeAccountService._calculateSessionWindowStart(now) + const windowEnd = claudeAccountService._calculateSessionWindowEnd(windowStart) + + account.sessionWindowStart = windowStart.toISOString() + account.sessionWindowEnd = windowEnd.toISOString() + account.lastRequestTime = now.toISOString() + + await redis.setClaudeAccount(account.id, account) + updatedCount++ + + console.log(`✅ 为账户 ${account.name} 创建测试会话窗口:`) + console.log(` 窗口时间: ${windowStart.toLocaleString()} - ${windowEnd.toLocaleString()}`) + console.log(` 最后使用: ${now.toLocaleString()}\n`) + } + } + + console.log(`📊 测试完成: 为 ${updatedCount} 个活跃账户创建了测试会话窗口`) + }, + + // 手动设置账户的会话窗口 + async set() { + console.log('🔧 手动设置会话窗口...\n') + + // 获取所有账户 + const accounts = await redis.getAllClaudeAccounts() + if (accounts.length === 0) { + console.log('❌ 没有找到Claude账户') + return + } + + // 显示账户列表 + console.log('📋 可用的Claude账户:') + accounts.forEach((account, index) => { + const status = account.isActive === 'true' ? '✅' : '❌' + const hasWindow = account.sessionWindowStart ? '🕐' : '➖' + console.log(` ${index + 1}. ${status} ${hasWindow} ${account.name} (${account.id})`) + }) + + // 让用户选择账户 + const accountIndex = await askQuestion('\n请选择账户 (输入编号): ') + const selectedIndex = parseInt(accountIndex) - 1 + + if (selectedIndex < 0 || selectedIndex >= accounts.length) { + console.log('❌ 无效的账户编号') + return + } + + const selectedAccount = accounts[selectedIndex] + console.log(`\n🎯 已选择账户: ${selectedAccount.name}`) + + // 显示当前会话窗口状态 + if (selectedAccount.sessionWindowStart && selectedAccount.sessionWindowEnd) { + const windowStart = new Date(selectedAccount.sessionWindowStart) + const windowEnd = new Date(selectedAccount.sessionWindowEnd) + const now = new Date() + const isActive = now >= windowStart && now < windowEnd + + console.log('📊 当前会话窗口:') + console.log(` 时间: ${windowStart.toLocaleString()} - ${windowEnd.toLocaleString()}`) + console.log(` 状态: ${isActive ? '✅ 活跃' : '❌ 已过期'}`) + } else { + console.log('📊 当前会话窗口: ❌ 无') + } + + // 显示设置选项 + console.log('\n🛠️ 设置选项:') + console.log(' 1. 使用预设时间窗口') + console.log(' 2. 自定义最后使用时间') + console.log(' 3. 直接设置窗口时间') + console.log(' 4. 清除会话窗口') + + const option = await askQuestion('\n请选择设置方式 (1-4): ') + + switch (option) { + case '1': + await setPresetWindow(selectedAccount) + break + case '2': + await setCustomLastUsed(selectedAccount) + break + case '3': + await setDirectWindow(selectedAccount) + break + case '4': + await clearAccountWindow(selectedAccount) + break + default: + console.log('❌ 无效的选项') + return + } + }, + + // 显示帮助信息 + help() { + console.log('🔧 会话窗口管理脚本\n') + console.log('用法: node scripts/manage-session-windows.js \n') + console.log('命令:') + console.log(' debug - 调试所有账户的会话窗口状态') + console.log(' init - 初始化会话窗口(跳过已有窗口的账户)') + console.log(' force - 强制重新计算所有会话窗口') + console.log(' test - 创建测试会话窗口(设置当前时间为使用时间)') + console.log(' set - 手动设置特定账户的会话窗口 🆕') + console.log(' clear - 清除所有会话窗口') + console.log(' help - 显示此帮助信息\n') + console.log('示例:') + console.log(' node scripts/manage-session-windows.js debug') + console.log(' node scripts/manage-session-windows.js set') + console.log(' node scripts/manage-session-windows.js test') + console.log(' node scripts/manage-session-windows.js force') + } +} + +// 设置函数实现 + +// 使用预设时间窗口 +async function setPresetWindow(account) { + showTimeWindowOptions() + + const windowChoice = await askQuestion('请选择时间窗口 (1-5): ') + const windowIndex = parseInt(windowChoice) - 1 + + if (windowIndex < 0 || windowIndex >= 5) { + console.log('❌ 无效的窗口选择') + return + } + + const now = new Date() + const startHour = windowIndex * 5 + + // 创建窗口开始时间 + const windowStart = new Date(now) + windowStart.setHours(startHour, 0, 0, 0) + + // 创建窗口结束时间 + const windowEnd = new Date(windowStart) + windowEnd.setHours(windowEnd.getHours() + 5) + + // 如果选择的窗口已经过期,则设置为明天的同一时间段 + if (windowEnd <= now) { + windowStart.setDate(windowStart.getDate() + 1) + windowEnd.setDate(windowEnd.getDate() + 1) + } + + // 询问是否要设置为当前时间作为最后使用时间 + const setLastUsed = await askQuestion('是否设置当前时间为最后使用时间? (y/N): ') + + // 更新账户数据 + account.sessionWindowStart = windowStart.toISOString() + account.sessionWindowEnd = windowEnd.toISOString() + account.lastRequestTime = now.toISOString() + + if (setLastUsed.toLowerCase() === 'y' || setLastUsed.toLowerCase() === 'yes') { + account.lastUsedAt = now.toISOString() + } + + await redis.setClaudeAccount(account.id, account) + + console.log('\n✅ 已设置会话窗口:') + console.log(` 账户: ${account.name}`) + console.log(` 窗口: ${windowStart.toLocaleString()} - ${windowEnd.toLocaleString()}`) + console.log(` 状态: ${now >= windowStart && now < windowEnd ? '✅ 活跃' : '⏰ 未来窗口'}`) +} + +// 自定义最后使用时间 +async function setCustomLastUsed(account) { + console.log('\n📝 设置最后使用时间:') + console.log('支持的时间格式:') + console.log(' - HH:MM (如: 14:30)') + console.log(' - 相对时间 (如: 2小时前, 30分钟前)') + console.log(' - ISO格式 (如: 2025-07-28T14:30:00)') + + const timeInput = await askQuestion('\n请输入最后使用时间: ') + const lastUsedTime = parseTimeInput(timeInput) + + if (!lastUsedTime) { + console.log('❌ 无效的时间格式') + return + } + + // 基于最后使用时间计算会话窗口 + const windowStart = claudeAccountService._calculateSessionWindowStart(lastUsedTime) + const windowEnd = claudeAccountService._calculateSessionWindowEnd(windowStart) + + // 更新账户数据 + account.lastUsedAt = lastUsedTime.toISOString() + account.sessionWindowStart = windowStart.toISOString() + account.sessionWindowEnd = windowEnd.toISOString() + account.lastRequestTime = lastUsedTime.toISOString() + + await redis.setClaudeAccount(account.id, account) + + console.log('\n✅ 已设置会话窗口:') + console.log(` 账户: ${account.name}`) + console.log(` 最后使用: ${lastUsedTime.toLocaleString()}`) + console.log(` 窗口: ${windowStart.toLocaleString()} - ${windowEnd.toLocaleString()}`) + + const now = new Date() + console.log(` 状态: ${now >= windowStart && now < windowEnd ? '✅ 活跃' : '❌ 已过期'}`) +} + +// 直接设置窗口时间 +async function setDirectWindow(account) { + console.log('\n⏰ 直接设置窗口时间:') + + const startInput = await askQuestion('请输入窗口开始时间 (HH:MM): ') + const startTime = parseTimeInput(startInput) + + if (!startTime) { + console.log('❌ 无效的开始时间格式') + return + } + + // 自动计算结束时间(开始时间+5小时) + const endTime = new Date(startTime) + endTime.setHours(endTime.getHours() + 5) + + // 如果跨天,询问是否确认 + if (endTime.getDate() !== startTime.getDate()) { + const confirm = await askQuestion(`窗口将跨天到次日 ${endTime.getHours()}:00,确认吗? (y/N): `) + if (confirm.toLowerCase() !== 'y' && confirm.toLowerCase() !== 'yes') { + console.log('❌ 已取消设置') + return + } + } + + const now = new Date() + + // 更新账户数据 + account.sessionWindowStart = startTime.toISOString() + account.sessionWindowEnd = endTime.toISOString() + account.lastRequestTime = now.toISOString() + + // 询问是否更新最后使用时间 + const updateLastUsed = await askQuestion('是否将最后使用时间设置为窗口开始时间? (y/N): ') + if (updateLastUsed.toLowerCase() === 'y' || updateLastUsed.toLowerCase() === 'yes') { + account.lastUsedAt = startTime.toISOString() + } + + await redis.setClaudeAccount(account.id, account) + + console.log('\n✅ 已设置会话窗口:') + console.log(` 账户: ${account.name}`) + console.log(` 窗口: ${startTime.toLocaleString()} - ${endTime.toLocaleString()}`) + console.log( + ` 状态: ${now >= startTime && now < endTime ? '✅ 活跃' : now < startTime ? '⏰ 未来窗口' : '❌ 已过期'}` + ) +} + +// 清除账户会话窗口 +async function clearAccountWindow(account) { + const confirm = await askQuestion(`确认清除账户 "${account.name}" 的会话窗口吗? (y/N): `) + + if (confirm.toLowerCase() !== 'y' && confirm.toLowerCase() !== 'yes') { + console.log('❌ 已取消操作') + return + } + + // 清除会话窗口相关数据 + delete account.sessionWindowStart + delete account.sessionWindowEnd + delete account.lastRequestTime + + await redis.setClaudeAccount(account.id, account) + + console.log(`\n✅ 已清除账户 "${account.name}" 的会话窗口`) +} + +async function main() { + const command = process.argv[2] || 'help' + + if (!commands[command]) { + console.error(`❌ 未知命令: ${command}`) + commands.help() + process.exit(1) + } + + if (command === 'help') { + commands.help() + return + } + + try { + // 连接Redis + await redis.connect() + + // 执行命令 + await commands[command]() + } catch (error) { + console.error('❌ 执行失败:', error) + process.exit(1) + } finally { + await redis.disconnect() + rl.close() + } +} + +// 如果直接运行此脚本 +if (require.main === module) { + main().then(() => { + console.log('\n🎉 操作完成') + process.exit(0) + }) +} + +module.exports = { commands } diff --git a/scripts/manage.js b/scripts/manage.js new file mode 100644 index 0000000000000000000000000000000000000000..df8a14f036731c0a51aae9e7f5646e00cd5bd241 --- /dev/null +++ b/scripts/manage.js @@ -0,0 +1,333 @@ +#!/usr/bin/env node + +const { spawn, exec } = require('child_process') +const fs = require('fs') +const path = require('path') +const process = require('process') + +const PID_FILE = path.join(__dirname, '..', 'claude-relay-service.pid') +const LOG_FILE = path.join(__dirname, '..', 'logs', 'service.log') +const ERROR_LOG_FILE = path.join(__dirname, '..', 'logs', 'service-error.log') +const APP_FILE = path.join(__dirname, '..', 'src', 'app.js') + +class ServiceManager { + constructor() { + this.ensureLogDir() + } + + ensureLogDir() { + const logDir = path.dirname(LOG_FILE) + if (!fs.existsSync(logDir)) { + fs.mkdirSync(logDir, { recursive: true }) + } + } + + getPid() { + try { + if (fs.existsSync(PID_FILE)) { + const pid = parseInt(fs.readFileSync(PID_FILE, 'utf8').trim()) + return pid + } + } catch (error) { + console.error('读取PID文件失败:', error.message) + } + return null + } + + isProcessRunning(pid) { + try { + process.kill(pid, 0) + return true + } catch (error) { + return false + } + } + + writePid(pid) { + try { + fs.writeFileSync(PID_FILE, pid.toString()) + console.log(`✅ PID ${pid} 已保存到 ${PID_FILE}`) + } catch (error) { + console.error('写入PID文件失败:', error.message) + } + } + + removePidFile() { + try { + if (fs.existsSync(PID_FILE)) { + fs.unlinkSync(PID_FILE) + console.log('🗑️ 已清理PID文件') + } + } catch (error) { + console.error('清理PID文件失败:', error.message) + } + } + + getStatus() { + const pid = this.getPid() + if (pid && this.isProcessRunning(pid)) { + return { running: true, pid } + } + return { running: false, pid: null } + } + + start(daemon = false) { + const status = this.getStatus() + if (status.running) { + console.log(`⚠️ 服务已在运行中 (PID: ${status.pid})`) + return false + } + + console.log('🚀 启动 Claude Relay Service...') + + if (daemon) { + // 后台运行模式 - 使用nohup实现真正的后台运行 + const { exec: execChild } = require('child_process') + + const command = `nohup node "${APP_FILE}" > "${LOG_FILE}" 2> "${ERROR_LOG_FILE}" & echo $!` + + execChild(command, (error, stdout) => { + if (error) { + console.error('❌ 后台启动失败:', error.message) + return + } + + const pid = parseInt(stdout.trim()) + if (pid && !isNaN(pid)) { + this.writePid(pid) + console.log(`🔄 服务已在后台启动 (PID: ${pid})`) + console.log(`📝 日志文件: ${LOG_FILE}`) + console.log(`❌ 错误日志: ${ERROR_LOG_FILE}`) + console.log('✅ 终端现在可以安全关闭') + } else { + console.error('❌ 无法获取进程ID') + } + }) + + // 给exec一点时间执行 + setTimeout(() => { + process.exit(0) + }, 1000) + } else { + // 前台运行模式 + const child = spawn('node', [APP_FILE], { + stdio: 'inherit' + }) + + console.log(`🔄 服务已启动 (PID: ${child.pid})`) + + this.writePid(child.pid) + + // 监听进程退出 + child.on('exit', (code, signal) => { + this.removePidFile() + if (code !== 0) { + console.log(`💥 进程退出 (代码: ${code}, 信号: ${signal})`) + } + }) + + child.on('error', (error) => { + console.error('❌ 启动失败:', error.message) + this.removePidFile() + }) + } + + return true + } + + stop() { + const status = this.getStatus() + if (!status.running) { + console.log('⚠️ 服务未在运行') + this.removePidFile() // 清理可能存在的过期PID文件 + return false + } + + console.log(`🛑 停止服务 (PID: ${status.pid})...`) + + try { + // 优雅关闭:先发送SIGTERM + process.kill(status.pid, 'SIGTERM') + + // 等待进程退出 + let attempts = 0 + const maxAttempts = 30 // 30秒超时 + + const checkExit = setInterval(() => { + attempts++ + if (!this.isProcessRunning(status.pid)) { + clearInterval(checkExit) + console.log('✅ 服务已停止') + this.removePidFile() + return + } + + if (attempts >= maxAttempts) { + clearInterval(checkExit) + console.log('⚠️ 优雅关闭超时,强制终止进程...') + try { + process.kill(status.pid, 'SIGKILL') + console.log('✅ 服务已强制停止') + } catch (error) { + console.error('❌ 强制停止失败:', error.message) + } + this.removePidFile() + } + }, 1000) + } catch (error) { + console.error('❌ 停止服务失败:', error.message) + this.removePidFile() + return false + } + + return true + } + + restart(daemon = false) { + console.log('🔄 重启服务...') + this.stop() + // 等待停止完成 + setTimeout(() => { + this.start(daemon) + }, 2000) + + return true + } + + status() { + const status = this.getStatus() + if (status.running) { + console.log(`✅ 服务正在运行 (PID: ${status.pid})`) + + // 显示进程信息 + exec(`ps -p ${status.pid} -o pid,ppid,pcpu,pmem,etime,cmd --no-headers`, (error, stdout) => { + if (!error && stdout.trim()) { + console.log('\n📊 进程信息:') + console.log('PID\tPPID\tCPU%\tMEM%\tTIME\t\tCOMMAND') + console.log(stdout.trim()) + } + }) + } else { + console.log('❌ 服务未运行') + } + return status.running + } + + logs(lines = 50) { + console.log(`📖 最近 ${lines} 行日志:\n`) + + exec(`tail -n ${lines} ${LOG_FILE}`, (error, stdout) => { + if (error) { + console.error('读取日志失败:', error.message) + return + } + console.log(stdout) + }) + } + + help() { + console.log(` +🔧 Claude Relay Service 进程管理器 + +用法: npm run service [options] + +重要提示: + 如果要传递参数,请在npm run命令中使用 -- 分隔符 + npm run service -- [options] + +命令: + start [-d|--daemon] 启动服务 (-d: 后台运行) + stop 停止服务 + restart [-d|--daemon] 重启服务 (-d: 后台运行) + status 查看服务状态 + logs [lines] 查看日志 (默认50行) + help 显示帮助信息 + +命令缩写: + s, start 启动服务 + r, restart 重启服务 + st, status 查看状态 + l, log, logs 查看日志 + halt, stop 停止服务 + h, help 显示帮助 + +示例: + npm run service start # 前台启动 + npm run service -- start -d # 后台启动(正确方式) + npm run service:start:d # 后台启动(推荐快捷方式) + npm run service:daemon # 后台启动(推荐快捷方式) + npm run service stop # 停止服务 + npm run service -- restart -d # 后台重启(正确方式) + npm run service:restart:d # 后台重启(推荐快捷方式) + npm run service status # 查看状态 + npm run service logs # 查看日志 + npm run service -- logs 100 # 查看最近100行日志 + +推荐的快捷方式(无需 -- 分隔符): + npm run service:start:d # 等同于 npm run service -- start -d + npm run service:restart:d # 等同于 npm run service -- restart -d + npm run service:daemon # 等同于 npm run service -- start -d + +直接使用脚本(推荐): + node scripts/manage.js start -d # 后台启动 + node scripts/manage.js restart -d # 后台重启 + node scripts/manage.js status # 查看状态 + node scripts/manage.js logs 100 # 查看最近100行日志 + +文件位置: + PID文件: ${PID_FILE} + 日志文件: ${LOG_FILE} + 错误日志: ${ERROR_LOG_FILE} + `) + } +} + +// 主程序 +function main() { + const manager = new ServiceManager() + const args = process.argv.slice(2) + const command = args[0] + const isDaemon = args.includes('-d') || args.includes('--daemon') + + switch (command) { + case 'start': + case 's': + manager.start(isDaemon) + break + case 'stop': + case 'halt': + manager.stop() + break + case 'restart': + case 'r': + manager.restart(isDaemon) + break + case 'status': + case 'st': + manager.status() + break + case 'logs': + case 'log': + case 'l': { + const lines = parseInt(args[1]) || 50 + manager.logs(lines) + break + } + case 'help': + case '--help': + case '-h': + case 'h': + manager.help() + break + default: + console.log('❌ 未知命令:', command) + manager.help() + process.exit(1) + } +} + +if (require.main === module) { + main() +} + +module.exports = ServiceManager diff --git a/scripts/manage.sh b/scripts/manage.sh new file mode 100644 index 0000000000000000000000000000000000000000..181bb2bead75bfc438aee8286ca108d8276f0758 --- /dev/null +++ b/scripts/manage.sh @@ -0,0 +1,1757 @@ +#!/bin/bash + +# Claude Relay Service 管理脚本 +# 用于安装、更新、卸载、启动、停止、重启服务 +# 可以使用 crs 快捷命令调用 + +# 颜色定义 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;36m' # 改为青色(Cyan),更易读 +MAGENTA='\033[0;35m' +BOLD='\033[1m' +NC='\033[0m' # No Color + +# 默认配置 +DEFAULT_INSTALL_DIR="$HOME/claude-relay-service" +DEFAULT_REDIS_HOST="localhost" +DEFAULT_REDIS_PORT="6379" +DEFAULT_REDIS_PASSWORD="" +DEFAULT_APP_PORT="3000" + +# 全局变量 +INSTALL_DIR="" +APP_DIR="" +REDIS_HOST="" +REDIS_PORT="" +REDIS_PASSWORD="" +APP_PORT="" +PUBLIC_IP_CACHE_FILE="/tmp/.crs_public_ip_cache" +PUBLIC_IP_CACHE_DURATION=3600 # 1小时缓存 + +# 打印带颜色的消息 +print_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +print_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +# 检测操作系统 +detect_os() { + if [[ "$OSTYPE" == "linux-gnu"* ]]; then + if [ -f /etc/debian_version ]; then + OS="debian" + PACKAGE_MANAGER="apt-get" + elif [ -f /etc/redhat-release ]; then + OS="redhat" + PACKAGE_MANAGER="yum" + elif [ -f /etc/arch-release ]; then + OS="arch" + PACKAGE_MANAGER="pacman" + else + OS="unknown" + fi + elif [[ "$OSTYPE" == "darwin"* ]]; then + OS="macos" + PACKAGE_MANAGER="brew" + else + OS="unknown" + fi +} + +# 检查命令是否存在 +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# 检查端口是否被占用 +check_port() { + local port=$1 + if command_exists lsof; then + lsof -i ":$port" >/dev/null 2>&1 + elif command_exists netstat; then + netstat -tuln | grep ":$port " >/dev/null 2>&1 + elif command_exists ss; then + ss -tuln | grep ":$port " >/dev/null 2>&1 + else + return 1 + fi +} + +# 生成随机字符串 +generate_random_string() { + local length=$1 + if command_exists openssl; then + openssl rand -hex $((length/2)) + else + cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w $length | head -n 1 + fi +} + +# 获取公网IP +get_public_ip() { + local cached_ip="" + local cache_age=0 + + # 检查缓存 + if [ -f "$PUBLIC_IP_CACHE_FILE" ]; then + local current_time=$(date +%s) + local cache_time=$(stat -c %Y "$PUBLIC_IP_CACHE_FILE" 2>/dev/null || stat -f %m "$PUBLIC_IP_CACHE_FILE" 2>/dev/null || echo 0) + cache_age=$((current_time - cache_time)) + + if [ $cache_age -lt $PUBLIC_IP_CACHE_DURATION ]; then + cached_ip=$(cat "$PUBLIC_IP_CACHE_FILE" 2>/dev/null) + if [ -n "$cached_ip" ]; then + echo "$cached_ip" + return 0 + fi + fi + fi + + # 获取新的公网IP + local public_ip="" + if command_exists curl; then + public_ip=$(curl -s --connect-timeout 5 https://ipinfo.io/json | grep -o '"ip":"[^"]*"' | cut -d'"' -f4 2>/dev/null) + elif command_exists wget; then + public_ip=$(wget -qO- --timeout=5 https://ipinfo.io/json | grep -o '"ip":"[^"]*"' | cut -d'"' -f4 2>/dev/null) + fi + + # 如果获取失败,尝试备用API + if [ -z "$public_ip" ]; then + if command_exists curl; then + public_ip=$(curl -s --connect-timeout 5 https://api.ipify.org 2>/dev/null) + elif command_exists wget; then + public_ip=$(wget -qO- --timeout=5 https://api.ipify.org 2>/dev/null) + fi + fi + + # 保存到缓存 + if [ -n "$public_ip" ]; then + echo "$public_ip" > "$PUBLIC_IP_CACHE_FILE" + echo "$public_ip" + else + echo "localhost" + fi +} + +# 检查Node.js版本 +check_node_version() { + if ! command_exists node; then + return 1 + fi + + local node_version=$(node -v | sed 's/v//') + local major_version=$(echo $node_version | cut -d. -f1) + + if [ "$major_version" -lt 18 ]; then + return 1 + fi + + return 0 +} + +# 安装Node.js 18+ +install_nodejs() { + print_info "开始安装 Node.js 18+" + + case $OS in + "debian") + # 使用 NodeSource 仓库 + curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash - + sudo $PACKAGE_MANAGER install -y nodejs + ;; + "redhat") + curl -fsSL https://rpm.nodesource.com/setup_18.x | sudo bash - + sudo $PACKAGE_MANAGER install -y nodejs + ;; + "arch") + sudo $PACKAGE_MANAGER -S --noconfirm nodejs npm + ;; + "macos") + if ! command_exists brew; then + print_error "请先安装 Homebrew: https://brew.sh" + return 1 + fi + brew install node@18 + ;; + *) + print_error "不支持的操作系统,请手动安装 Node.js 18+" + return 1 + ;; + esac + + # 验证安装 + if check_node_version; then + print_success "Node.js 安装成功: $(node -v)" + return 0 + else + print_error "Node.js 安装失败或版本不符合要求" + return 1 + fi +} + +# 安装基础依赖 +install_dependencies() { + print_info "检查并安装基础依赖..." + + local deps_to_install=() + + # 检查 git + if ! command_exists git; then + deps_to_install+=("git") + fi + + # 检查其他基础工具 + case $OS in + "debian"|"redhat") + if ! command_exists curl; then + deps_to_install+=("curl") + fi + if ! command_exists wget; then + deps_to_install+=("wget") + fi + if ! command_exists lsof; then + deps_to_install+=("lsof") + fi + ;; + esac + + # 安装缺失的依赖 + if [ ${#deps_to_install[@]} -gt 0 ]; then + print_info "需要安装: ${deps_to_install[*]}" + case $OS in + "debian") + sudo $PACKAGE_MANAGER update + sudo $PACKAGE_MANAGER install -y "${deps_to_install[@]}" + ;; + "redhat") + sudo $PACKAGE_MANAGER install -y "${deps_to_install[@]}" + ;; + "arch") + sudo $PACKAGE_MANAGER -S --noconfirm "${deps_to_install[@]}" + ;; + "macos") + brew install "${deps_to_install[@]}" + ;; + esac + fi + + # 检查 Node.js + if ! check_node_version; then + print_warning "未检测到 Node.js 18+ 版本" + install_nodejs || return 1 + else + print_success "Node.js 版本检查通过: $(node -v)" + fi + + # 检查 npm + if ! command_exists npm; then + print_error "npm 未安装" + return 1 + else + print_success "npm 版本: $(npm -v)" + fi + + return 0 +} + +# 检查Redis +check_redis() { + print_info "检查 Redis 配置..." + + # 交互式询问Redis配置 + echo -e "\n${BLUE}Redis 配置${NC}" + echo -n "Redis 地址 (默认: $DEFAULT_REDIS_HOST): " + read input + REDIS_HOST=${input:-$DEFAULT_REDIS_HOST} + + echo -n "Redis 端口 (默认: $DEFAULT_REDIS_PORT): " + read input + REDIS_PORT=${input:-$DEFAULT_REDIS_PORT} + + echo -n "Redis 密码 (默认: 无密码): " + read -s input + echo + REDIS_PASSWORD=${input:-$DEFAULT_REDIS_PASSWORD} + + # 测试Redis连接 + print_info "测试 Redis 连接..." + if command_exists redis-cli; then + local redis_args=(-h "$REDIS_HOST" -p "$REDIS_PORT") + if [ -n "$REDIS_PASSWORD" ]; then + redis_args+=(-a "$REDIS_PASSWORD") + fi + + if redis-cli "${redis_args[@]}" ping 2>/dev/null | grep -q "PONG"; then + print_success "Redis 连接成功" + return 0 + else + print_error "Redis 连接失败" + return 1 + fi + else + print_warning "redis-cli 未安装,跳过连接测试" + # 仅检查端口是否开放 + if check_port $REDIS_PORT; then + print_info "检测到端口 $REDIS_PORT 已开放" + return 0 + else + print_warning "端口 $REDIS_PORT 未开放,请确保 Redis 正在运行" + return 1 + fi + fi +} + +# 安装本地Redis(可选) +install_local_redis() { + print_info "是否需要在本地安装 Redis?(y/N): " + read -n 1 install_redis + echo + + if [[ ! "$install_redis" =~ ^[Yy]$ ]]; then + return 0 + fi + + case $OS in + "debian") + sudo $PACKAGE_MANAGER update + sudo $PACKAGE_MANAGER install -y redis-server + sudo systemctl start redis-server + sudo systemctl enable redis-server + ;; + "redhat") + sudo $PACKAGE_MANAGER install -y redis + sudo systemctl start redis + sudo systemctl enable redis + ;; + "arch") + sudo $PACKAGE_MANAGER -S --noconfirm redis + sudo systemctl start redis + sudo systemctl enable redis + ;; + "macos") + brew install redis + brew services start redis + ;; + *) + print_error "不支持的操作系统,请手动安装 Redis" + return 1 + ;; + esac + + print_success "Redis 安装完成" + return 0 +} + + +# 检查是否已安装 +check_installation() { + if [ -d "$APP_DIR" ] && [ -f "$APP_DIR/package.json" ]; then + return 0 + fi + return 1 +} + +# 安装服务 +install_service() { + print_info "开始安装 Claude Relay Service..." + + # 询问安装目录 + echo -n "安装目录 (默认: $DEFAULT_INSTALL_DIR): " + read input + INSTALL_DIR=${input:-$DEFAULT_INSTALL_DIR} + APP_DIR="$INSTALL_DIR/app" + + # 询问服务端口 + echo -n "服务端口 (默认: $DEFAULT_APP_PORT): " + read input + APP_PORT=${input:-$DEFAULT_APP_PORT} + + # 检查端口是否被占用 + if check_port $APP_PORT; then + print_warning "端口 $APP_PORT 已被占用" + echo -n "是否继续?(y/N): " + read -n 1 continue_install + echo + if [[ ! "$continue_install" =~ ^[Yy]$ ]]; then + return 1 + fi + fi + + # 检查是否已安装 + if check_installation; then + print_warning "检测到已安装的服务" + echo -n "是否要重新安装?(y/N): " + read -n 1 reinstall + echo + if [[ ! "$reinstall" =~ ^[Yy]$ ]]; then + return 0 + fi + fi + + # 创建安装目录 + mkdir -p "$INSTALL_DIR" + + # 克隆项目 + print_info "克隆项目代码..." + if [ -d "$APP_DIR" ]; then + rm -rf "$APP_DIR" + fi + + if ! git clone https://github.com/Wei-Shaw/claude-relay-service.git "$APP_DIR"; then + print_error "克隆项目失败" + return 1 + fi + + # 进入项目目录 + cd "$APP_DIR" + + # 安装npm依赖 + print_info "安装项目依赖..." + npm install + + # 确保脚本有执行权限(仅在权限不正确时设置) + if [ -f "$APP_DIR/scripts/manage.sh" ] && [ ! -x "$APP_DIR/scripts/manage.sh" ]; then + chmod +x "$APP_DIR/scripts/manage.sh" + print_success "已设置脚本执行权限" + fi + + # 创建配置文件 + print_info "创建配置文件..." + + # 复制示例配置 + if [ -f "config/config.example.js" ]; then + cp config/config.example.js config/config.js + fi + + # 创建.env文件 + cat > .env << EOF +# 环境变量配置 +NODE_ENV=production +PORT=$APP_PORT + +# JWT配置 +JWT_SECRET=$(generate_random_string 64) + +# 加密配置 +ENCRYPTION_KEY=$(generate_random_string 32) + +# Redis配置 +REDIS_HOST=$REDIS_HOST +REDIS_PORT=$REDIS_PORT +REDIS_PASSWORD=$REDIS_PASSWORD + +# 日志配置 +LOG_LEVEL=info +EOF + + # 运行setup命令 + print_info "运行初始化设置..." + npm run setup + + # 获取预构建的前端文件 + print_info "获取预构建的前端文件..." + + # 创建目标目录 + mkdir -p web/admin-spa/dist + + # 从 web-dist 分支获取构建好的文件 + if git ls-remote --heads origin web-dist | grep -q web-dist; then + print_info "从 web-dist 分支下载前端文件..." + + # 创建临时目录用于 clone + TEMP_CLONE_DIR=$(mktemp -d) + + # 使用 sparse-checkout 来只获取需要的文件 + git clone --depth 1 --branch web-dist --single-branch \ + https://github.com/Wei-Shaw/claude-relay-service.git \ + "$TEMP_CLONE_DIR" 2>/dev/null || { + # 如果 HTTPS 失败,尝试使用当前仓库的 remote URL + REPO_URL=$(git config --get remote.origin.url) + git clone --depth 1 --branch web-dist --single-branch "$REPO_URL" "$TEMP_CLONE_DIR" + } + + # 复制文件到目标目录(排除 .git 和 README.md) + rsync -av --exclude='.git' --exclude='README.md' "$TEMP_CLONE_DIR/" web/admin-spa/dist/ 2>/dev/null || { + # 如果没有 rsync,使用 cp + cp -r "$TEMP_CLONE_DIR"/* web/admin-spa/dist/ 2>/dev/null + rm -rf web/admin-spa/dist/.git 2>/dev/null + rm -f web/admin-spa/dist/README.md 2>/dev/null + } + + # 清理临时目录 + rm -rf "$TEMP_CLONE_DIR" + + print_success "前端文件下载完成" + else + print_warning "web-dist 分支不存在,尝试本地构建..." + + # 检查是否有 Node.js 和 npm + if command_exists npm; then + # 回退到原始构建方式 + if [ -f "web/admin-spa/package.json" ]; then + print_info "开始本地构建前端..." + cd web/admin-spa + npm install + npm run build + cd ../.. + print_success "前端本地构建完成" + else + print_error "无法找到前端项目文件" + fi + else + print_error "无法获取前端文件,且本地环境不支持构建" + print_info "请确保仓库已正确配置 web-dist 分支" + fi + fi + + # 创建软链接 + create_symlink + + print_success "安装完成!" + + # 自动启动服务 + print_info "正在启动服务..." + start_service + + # 等待服务启动 + sleep 3 + + # 显示状态 + show_status + + # 获取公网IP + local public_ip=$(get_public_ip) + + echo -e "\n${GREEN}服务已成功安装并启动!${NC}" + echo -e "\n${YELLOW}访问地址:${NC}" + echo -e " 本地 Web: ${GREEN}http://localhost:$APP_PORT/web${NC}" + echo -e " 本地 API: ${GREEN}http://localhost:$APP_PORT/api/v1${NC}" + if [ "$public_ip" != "localhost" ]; then + echo -e " 公网 Web: ${GREEN}http://$public_ip:$APP_PORT/web${NC}" + echo -e " 公网 API: ${GREEN}http://$public_ip:$APP_PORT/api/v1${NC}" + fi + echo -e "\n${YELLOW}管理命令:${NC}" + echo " 查看状态: crs status" + echo " 停止服务: crs stop" + echo " 重启服务: crs restart" +} + + +# 更新服务 +update_service() { + if ! check_installation; then + print_error "服务未安装,请先运行: $0 install" + return 1 + fi + + print_info "更新 Claude Relay Service..." + + cd "$APP_DIR" + + # 保存当前运行状态 + local was_running=false + if pgrep -f "node.*src/app.js" > /dev/null; then + was_running=true + print_info "检测到服务正在运行,将在更新后自动重启..." + stop_service + fi + + # 备份配置文件(只备份.env,config.js可从example恢复) + print_info "备份配置文件..." + if [ -f ".env" ]; then + cp .env .env.backup.$(date +%Y%m%d%H%M%S) + fi + + # 检查本地修改 + print_info "检查本地文件修改..." + local has_changes=false + if git status --porcelain | grep -v "^??" | grep -q .; then + has_changes=true + print_warning "检测到本地文件已修改:" + git status --short | grep -v "^??" + echo "" + echo -e "${YELLOW}警告:更新将使用远程版本覆盖本地修改!${NC}" + + # 创建本地修改的备份 + local backup_branch="backup-$(date +%Y%m%d-%H%M%S)" + print_info "创建本地修改备份分支: $backup_branch" + git stash push -m "Backup before update $(date +%Y-%m-%d)" >/dev/null 2>&1 + git branch "$backup_branch" 2>/dev/null || true + + echo -e "${GREEN}已创建备份分支: $backup_branch${NC}" + echo "如需恢复,可执行: git checkout $backup_branch" + echo "" + + echo -n "是否继续更新?(y/N): " + read -n 1 confirm_update + echo + + if [[ ! "$confirm_update" =~ ^[Yy]$ ]]; then + print_info "已取消更新" + # 恢复 stash 的修改 + git stash pop >/dev/null 2>&1 || true + # 如果之前在运行,重新启动服务 + if [ "$was_running" = true ]; then + print_info "重新启动服务..." + start_service + fi + return 0 + fi + fi + + # 获取最新代码(强制使用远程版本) + print_info "获取最新代码..." + + # 先获取远程更新 + if ! git fetch origin main; then + print_error "获取远程代码失败,请检查网络连接" + return 1 + fi + + # 强制重置到远程版本 + print_info "应用远程更新..." + if ! git reset --hard origin/main; then + print_error "重置到远程版本失败" + # 尝试恢复 + print_info "尝试恢复..." + git reset --hard HEAD + return 1 + fi + + # 清理未跟踪的文件(可选,保留用户新建的文件) + # git clean -fd # 注释掉,避免删除用户的新文件 + + print_success "代码已更新到最新版本" + + # 更新依赖 + print_info "更新依赖..." + npm install + + # 确保脚本有执行权限(仅在权限不正确时设置) + if [ -f "$APP_DIR/scripts/manage.sh" ] && [ ! -x "$APP_DIR/scripts/manage.sh" ]; then + chmod +x "$APP_DIR/scripts/manage.sh" + fi + + # 获取最新的预构建前端文件 + print_info "更新前端文件..." + + # 创建目标目录 + mkdir -p web/admin-spa/dist + + # 清理旧的前端文件(保留用户自定义文件) + if [ -d "web/admin-spa/dist" ]; then + print_info "清理旧的前端文件..." + # 只删除已知的前端文件,保留用户可能添加的自定义文件 + rm -rf web/admin-spa/dist/assets 2>/dev/null + rm -f web/admin-spa/dist/index.html 2>/dev/null + rm -f web/admin-spa/dist/favicon.ico 2>/dev/null + fi + + # 从 web-dist 分支获取构建好的文件 + if git ls-remote --heads origin web-dist | grep -q web-dist; then + print_info "从 web-dist 分支下载最新前端文件..." + + # 创建临时目录用于 clone + TEMP_CLONE_DIR=$(mktemp -d) + + # 添加错误处理 + if [ ! -d "$TEMP_CLONE_DIR" ]; then + print_error "无法创建临时目录" + return 1 + fi + + # 使用 sparse-checkout 来只获取需要的文件,添加重试机制 + local clone_success=false + for attempt in 1 2 3; do + print_info "尝试下载前端文件 (第 $attempt 次)..." + + if git clone --depth 1 --branch web-dist --single-branch \ + https://github.com/Wei-Shaw/claude-relay-service.git \ + "$TEMP_CLONE_DIR" 2>/dev/null; then + clone_success=true + break + fi + + # 如果 HTTPS 失败,尝试使用当前仓库的 remote URL + REPO_URL=$(git config --get remote.origin.url) + if git clone --depth 1 --branch web-dist --single-branch "$REPO_URL" "$TEMP_CLONE_DIR" 2>/dev/null; then + clone_success=true + break + fi + + if [ $attempt -lt 3 ]; then + print_warning "下载失败,等待 2 秒后重试..." + sleep 2 + fi + done + + if [ "$clone_success" = false ]; then + print_error "无法下载前端文件" + rm -rf "$TEMP_CLONE_DIR" + return 1 + fi + + # 复制文件到目标目录(排除 .git 和 README.md) + rsync -av --exclude='.git' --exclude='README.md' "$TEMP_CLONE_DIR/" web/admin-spa/dist/ 2>/dev/null || { + # 如果没有 rsync,使用 cp + cp -r "$TEMP_CLONE_DIR"/* web/admin-spa/dist/ 2>/dev/null + rm -rf web/admin-spa/dist/.git 2>/dev/null + rm -f web/admin-spa/dist/README.md 2>/dev/null + } + + # 清理临时目录 + rm -rf "$TEMP_CLONE_DIR" + + print_success "前端文件更新完成" + else + print_warning "web-dist 分支不存在,尝试本地构建..." + + # 检查是否有 Node.js 和 npm + if command_exists npm; then + # 回退到原始构建方式 + if [ -f "web/admin-spa/package.json" ]; then + print_info "开始本地构建前端..." + cd web/admin-spa + npm install + npm run build + cd ../.. + print_success "前端本地构建完成" + else + print_error "无法找到前端项目文件" + fi + else + print_error "无法获取前端文件,且本地环境不支持构建" + print_info "请确保仓库已正确配置 web-dist 分支" + fi + fi + + # 更新软链接到最新版本 + create_symlink + + # 如果之前在运行,则重新启动服务 + if [ "$was_running" = true ]; then + print_info "重新启动服务..." + start_service + fi + + print_success "更新完成!" + + # 显示更新摘要 + echo "" + echo -e "${BLUE}=== 更新摘要 ===${NC}" + + # 显示版本信息 + if [ -f "$APP_DIR/VERSION" ]; then + echo -e "当前版本: ${GREEN}$(cat "$APP_DIR/VERSION")${NC}" + fi + + # 显示最新的提交信息 + local latest_commit=$(git log -1 --oneline 2>/dev/null) + if [ -n "$latest_commit" ]; then + echo -e "最新提交: ${GREEN}$latest_commit${NC}" + fi + + # 显示备份信息 + echo -e "\n${YELLOW}配置文件备份:${NC}" + ls -la .env.backup.* 2>/dev/null | tail -3 || echo " 无备份文件" + + # 提醒用户检查配置 + echo -e "\n${YELLOW}提示:${NC}" + echo " - 配置文件已自动备份" + echo " - 如有本地修改已保存到备份分支" + echo " - 建议检查 .env 和 config/config.js 配置" + + echo -e "\n${BLUE}==================${NC}" +} + +# 卸载服务 +uninstall_service() { + if [ -z "$INSTALL_DIR" ]; then + echo -n "请输入安装目录 (默认: $DEFAULT_INSTALL_DIR): " + read input + INSTALL_DIR=${input:-$DEFAULT_INSTALL_DIR} + APP_DIR="$INSTALL_DIR/app" + fi + + if [ ! -d "$INSTALL_DIR" ]; then + print_error "安装目录不存在" + return 1 + fi + + print_warning "即将卸载 Claude Relay Service" + echo -n "确定要卸载吗?(y/N): " + read -n 1 confirm + echo + + if [[ ! "$confirm" =~ ^[Yy]$ ]]; then + return 0 + fi + + # 停止服务 + stop_service + + # 备份数据 + echo -n "是否备份数据?(y/N): " + read -n 1 backup + echo + + if [[ "$backup" =~ ^[Yy]$ ]]; then + local backup_dir="$HOME/claude-relay-backup-$(date +%Y%m%d%H%M%S)" + mkdir -p "$backup_dir" + + # Redis使用系统默认位置,不需要备份 + + # 备份配置文件 + if [ -f "$APP_DIR/.env" ]; then + cp "$APP_DIR/.env" "$backup_dir/" + fi + if [ -f "$APP_DIR/config/config.js" ]; then + cp "$APP_DIR/config/config.js" "$backup_dir/" + fi + + print_success "数据已备份到: $backup_dir" + fi + + # 删除安装目录 + rm -rf "$INSTALL_DIR" + + print_success "卸载完成!" +} + +# 启动服务 +start_service() { + if ! check_installation; then + print_error "服务未安装,请先运行: $0 install" + return 1 + fi + + print_info "启动服务..." + + cd "$APP_DIR" + + # 检查是否已运行 + if pgrep -f "node.*src/app.js" > /dev/null; then + print_warning "服务已在运行" + return 0 + fi + + # 确保日志目录存在 + mkdir -p "$APP_DIR/logs" + + # 检查pm2是否可用并且不是从package.json脚本调用的 + if command_exists pm2 && [ "$1" != "--no-pm2" ]; then + print_info "使用 pm2 启动服务..." + # 直接使用pm2启动,避免循环调用 + pm2 start "$APP_DIR/src/app.js" --name "claude-relay" --log "$APP_DIR/logs/pm2.log" 2>/dev/null + sleep 2 + + # 检查是否启动成功 + if pm2 list 2>/dev/null | grep -q "claude-relay"; then + print_success "服务已通过 pm2 启动" + pm2 save 2>/dev/null || true + else + print_warning "pm2 启动失败,尝试直接启动..." + start_service_direct + fi + else + start_service_direct + fi + + sleep 2 + + # 验证服务是否成功启动 + if pgrep -f "node.*src/app.js" > /dev/null; then + show_status + else + print_error "服务启动失败,请查看日志: $APP_DIR/logs/service.log" + if [ -f "$APP_DIR/logs/service.log" ]; then + echo "最近的错误日志:" + tail -n 20 "$APP_DIR/logs/service.log" + fi + return 1 + fi +} + +# 直接启动服务(不使用pm2) +start_service_direct() { + print_info "使用后台进程启动服务..." + + # 使用setsid创建新会话,确保进程完全脱离终端 + if command_exists setsid; then + # setsid方式(推荐) + setsid nohup node "$APP_DIR/src/app.js" > "$APP_DIR/logs/service.log" 2>&1 < /dev/null & + local pid=$! + sleep 1 + + # 获取实际的子进程PID + local real_pid=$(pgrep -f "node.*src/app.js" | head -1) + if [ -n "$real_pid" ]; then + echo $real_pid > "$APP_DIR/.pid" + print_success "服务已在后台启动 (PID: $real_pid)" + else + echo $pid > "$APP_DIR/.pid" + print_success "服务已在后台启动 (PID: $pid)" + fi + else + # 备用方式:使用nohup和disown + nohup node "$APP_DIR/src/app.js" > "$APP_DIR/logs/service.log" 2>&1 < /dev/null & + local pid=$! + disown $pid 2>/dev/null || true + echo $pid > "$APP_DIR/.pid" + print_success "服务已在后台启动 (PID: $pid)" + fi +} + +# 停止服务 +stop_service() { + print_info "停止服务..." + + # 尝试使用pm2停止 + if command_exists pm2 && [ -n "$APP_DIR" ] && [ -d "$APP_DIR" ]; then + cd "$APP_DIR" 2>/dev/null + pm2 stop claude-relay 2>/dev/null || true + pm2 delete claude-relay 2>/dev/null || true + fi + + # 使用PID文件停止 + if [ -f "$APP_DIR/.pid" ]; then + local pid=$(cat "$APP_DIR/.pid") + if kill -0 $pid 2>/dev/null; then + kill $pid + rm -f "$APP_DIR/.pid" + fi + fi + + # 强制停止所有相关进程 + pkill -f "node.*src/app.js" 2>/dev/null || true + + # 等待进程完全退出(最多等待10秒) + local wait_count=0 + while pgrep -f "node.*src/app.js" > /dev/null; do + if [ $wait_count -ge 10 ]; then + print_warning "进程停止超时,尝试强制终止..." + pkill -9 -f "node.*src/app.js" 2>/dev/null || true + sleep 1 + break + fi + sleep 1 + wait_count=$((wait_count + 1)) + done + + # 最终确认进程已停止 + if pgrep -f "node.*src/app.js" > /dev/null; then + print_error "无法完全停止服务进程" + return 1 + fi + + print_success "服务已停止" +} + +# 重启服务 +restart_service() { + print_info "重启服务..." + + # 停止服务并检查结果 + if ! stop_service; then + print_error "停止服务失败" + return 1 + fi + + # 短暂等待,确保端口释放 + sleep 1 + + # 启动服务,如果失败则重试 + local retry_count=0 + while [ $retry_count -lt 3 ]; do + # 清除可能的僵尸进程检测 + if ! pgrep -f "node.*src/app.js" > /dev/null; then + # 进程确实已停止,可以启动 + if start_service; then + return 0 + fi + fi + + retry_count=$((retry_count + 1)) + if [ $retry_count -lt 3 ]; then + print_warning "启动失败,等待2秒后重试(第 $retry_count 次)..." + sleep 2 + fi + done + + print_error "重启服务失败" + return 1 +} + +# 更新模型价格 +update_model_pricing() { + if ! check_installation; then + print_error "服务未安装,请先运行: $0 install" + return 1 + fi + + print_info "更新模型价格数据..." + + cd "$APP_DIR" + + # 运行更新脚本 + if npm run update:pricing; then + print_success "模型价格数据更新完成" + + # 显示更新后的信息 + if [ -f "data/model_pricing.json" ]; then + local model_count=$(grep -o '"[^"]*"\s*:' data/model_pricing.json | wc -l) + local file_size=$(du -h data/model_pricing.json | cut -f1) + echo -e "\n更新信息:" + echo -e " 模型数量: ${GREEN}$model_count${NC}" + echo -e " 文件大小: ${GREEN}$file_size${NC}" + echo -e " 文件位置: $APP_DIR/data/model_pricing.json" + fi + else + print_error "模型价格数据更新失败" + return 1 + fi +} + +# 切换分支 +switch_branch() { + if ! check_installation; then + print_error "服务未安装,请先运行: $0 install" + return 1 + fi + + cd "$APP_DIR" + + # 获取当前分支 + local current_branch=$(git branch --show-current 2>/dev/null) + if [ -z "$current_branch" ]; then + print_error "无法获取当前分支信息" + return 1 + fi + + print_info "当前分支: ${GREEN}$current_branch${NC}" + + # 获取所有远程分支 + print_info "获取远程分支列表..." + git fetch origin --prune >/dev/null 2>&1 + + # 显示可用分支 + echo -e "\n${YELLOW}可用分支:${NC}" + local branches=$(git branch -r | grep -v HEAD | sed 's/origin\///' | sed 's/^ *//') + local branch_array=() + local i=1 + + while IFS= read -r branch; do + if [ "$branch" = "$current_branch" ]; then + echo -e " $i) $branch ${GREEN}(当前)${NC}" + else + echo " $i) $branch" + fi + branch_array+=("$branch") + ((i++)) + done <<< "$branches" + + echo "" + echo -n "请选择要切换的分支 (输入编号或分支名,0 取消): " + read branch_choice + + # 处理用户输入 + local target_branch="" + if [ "$branch_choice" = "0" ]; then + print_info "已取消切换" + return 0 + elif [[ "$branch_choice" =~ ^[0-9]+$ ]]; then + # 用户输入的是编号 + local index=$((branch_choice - 1)) + if [ $index -ge 0 ] && [ $index -lt ${#branch_array[@]} ]; then + target_branch="${branch_array[$index]}" + else + print_error "无效的编号" + return 1 + fi + else + # 用户输入的是分支名 + target_branch="$branch_choice" + # 验证分支是否存在 + if ! echo "$branches" | grep -q "^$target_branch$"; then + print_error "分支 '$target_branch' 不存在" + return 1 + fi + fi + + # 如果是同一个分支,无需切换 + if [ "$target_branch" = "$current_branch" ]; then + print_info "已经在分支 $target_branch 上" + return 0 + fi + + print_info "准备切换到分支: ${GREEN}$target_branch${NC}" + + # 保存当前运行状态 + local was_running=false + if pgrep -f "node.*src/app.js" > /dev/null; then + was_running=true + print_info "检测到服务正在运行,将在切换后自动重启..." + stop_service + fi + + # 处理本地修改(主要是权限变更导致的) + print_info "检查本地修改..." + + # 先重置所有权限相关的修改(特别是manage.sh的权限) + git status --porcelain | while read -r line; do + local file=$(echo "$line" | awk '{print $2}') + if [ -n "$file" ]; then + # 检查是否只是权限变更 + if git diff --summary "$file" 2>/dev/null | grep -q "mode change"; then + print_info "重置文件权限变更: $file" + git checkout HEAD -- "$file" 2>/dev/null || true + fi + fi + done + + # 检查是否还有其他实质性修改 + if git status --porcelain | grep -v "^??" | grep -q .; then + print_warning "检测到本地文件修改:" + git status --short | grep -v "^??" + echo "" + echo -n "是否要保存这些修改?(y/N): " + read -n 1 save_changes + echo + + if [[ "$save_changes" =~ ^[Yy]$ ]]; then + # 暂存修改 + print_info "暂存本地修改..." + git stash push -m "Branch switch from $current_branch to $target_branch $(date +%Y-%m-%d)" >/dev/null 2>&1 + else + # 丢弃修改 + print_info "丢弃本地修改..." + git reset --hard HEAD >/dev/null 2>&1 + fi + fi + + # 切换分支 + print_info "切换分支..." + + # 检查本地是否已有该分支 + if git show-ref --verify --quiet "refs/heads/$target_branch"; then + # 本地已有分支,切换并更新 + if ! git checkout "$target_branch" 2>/dev/null; then + print_error "切换分支失败" + return 1 + fi + + # 更新到最新 + print_info "更新到远程最新版本..." + git pull origin "$target_branch" --rebase 2>/dev/null || { + # 如果rebase失败,使用reset + print_warning "更新失败,强制同步到远程版本..." + git fetch origin "$target_branch" + git reset --hard "origin/$target_branch" + } + else + # 创建并切换到新分支 + if ! git checkout -b "$target_branch" "origin/$target_branch" 2>/dev/null; then + print_error "创建并切换分支失败" + return 1 + fi + fi + + print_success "已切换到分支: $target_branch" + + # 确保脚本有执行权限(切换分支后必须执行) + if [ -f "$APP_DIR/scripts/manage.sh" ]; then + chmod +x "$APP_DIR/scripts/manage.sh" + print_info "已设置脚本执行权限" + fi + + # 更新依赖(如果package.json有变化) + if git diff "$current_branch..$target_branch" --name-only | grep -q "package.json"; then + print_info "检测到 package.json 变化,更新依赖..." + npm install + fi + + # 更新前端文件(如果切换到不同版本) + if [ "$target_branch" != "$current_branch" ]; then + print_info "更新前端文件..." + + # 创建目标目录 + mkdir -p web/admin-spa/dist + + # 清理旧的前端文件 + if [ -d "web/admin-spa/dist" ]; then + rm -rf web/admin-spa/dist/* 2>/dev/null || true + fi + + # 尝试从对应的 web-dist 分支获取前端文件 + if git ls-remote --heads origin "web-dist-$target_branch" | grep -q "web-dist-$target_branch"; then + print_info "从 web-dist-$target_branch 分支下载前端文件..." + local web_branch="web-dist-$target_branch" + elif git ls-remote --heads origin web-dist | grep -q web-dist; then + print_info "从 web-dist 分支下载前端文件..." + local web_branch="web-dist" + else + print_warning "未找到预构建的前端文件" + web_branch="" + fi + + if [ -n "$web_branch" ]; then + # 创建临时目录用于 clone + TEMP_CLONE_DIR=$(mktemp -d) + + # 下载前端文件 + if git clone --depth 1 --branch "$web_branch" --single-branch \ + https://github.com/Wei-Shaw/claude-relay-service.git \ + "$TEMP_CLONE_DIR" 2>/dev/null; then + + # 复制文件到目标目录 + rsync -av --exclude='.git' --exclude='README.md' "$TEMP_CLONE_DIR/" web/admin-spa/dist/ 2>/dev/null || { + cp -r "$TEMP_CLONE_DIR"/* web/admin-spa/dist/ 2>/dev/null + rm -rf web/admin-spa/dist/.git 2>/dev/null + rm -f web/admin-spa/dist/README.md 2>/dev/null + } + + print_success "前端文件更新完成" + else + print_warning "下载前端文件失败" + fi + + # 清理临时目录 + rm -rf "$TEMP_CLONE_DIR" + fi + fi + + # 检查是否有暂存的修改可以恢复 + if [[ "$save_changes" =~ ^[Yy]$ ]] && git stash list | grep -q "Branch switch from $current_branch to $target_branch"; then + echo "" + echo -n "是否要恢复之前暂存的修改?(y/N): " + read -n 1 restore_stash + echo + + if [[ "$restore_stash" =~ ^[Yy]$ ]]; then + print_info "恢复暂存的修改..." + git stash pop >/dev/null 2>&1 || print_warning "恢复修改时出现冲突,请手动解决" + fi + fi + + # 如果之前在运行,则重新启动服务 + if [ "$was_running" = true ]; then + print_info "重新启动服务..." + start_service + fi + + # 显示切换后的信息 + echo "" + echo -e "${GREEN}=== 分支切换完成 ===${NC}" + echo -e "当前分支: ${GREEN}$target_branch${NC}" + + # 显示版本信息 + if [ -f "$APP_DIR/VERSION" ]; then + echo -e "当前版本: ${GREEN}$(cat "$APP_DIR/VERSION")${NC}" + fi + + # 显示最新提交 + local latest_commit=$(git log -1 --oneline 2>/dev/null) + if [ -n "$latest_commit" ]; then + echo -e "最新提交: ${GREEN}$latest_commit${NC}" + fi + + echo "" + print_info "提示:如遇到问题,可以运行 'crs update' 强制更新到最新版本" +} + +# 显示状态 +show_status() { + echo -e "\n${BLUE}=== Claude Relay Service 状态 ===${NC}" + + # 获取实际端口 + local actual_port="$APP_PORT" + if [ -z "$actual_port" ] && [ -f "$APP_DIR/.env" ]; then + actual_port=$(grep "^PORT=" "$APP_DIR/.env" 2>/dev/null | cut -d'=' -f2) + fi + actual_port=${actual_port:-3000} + + # 检查进程 + local pid=$(pgrep -f "node.*src/app.js" | head -1) + if [ -n "$pid" ]; then + echo -e "服务状态: ${GREEN}运行中${NC}" + echo "进程 PID: $pid" + + # 显示进程信息 + if command_exists ps; then + local proc_info=$(ps -p $pid -o comm,etime,rss --no-headers 2>/dev/null) + if [ -n "$proc_info" ]; then + echo "进程信息: $proc_info" + fi + fi + echo "服务端口: $actual_port" + + # 获取公网IP + local public_ip=$(get_public_ip) + + # 显示访问地址 + echo -e "\n访问地址:" + echo -e " 本地 Web: ${GREEN}http://localhost:$actual_port/web${NC}" + echo -e " 本地 API: ${GREEN}http://localhost:$actual_port/api/v1${NC}" + if [ "$public_ip" != "localhost" ]; then + echo -e " 公网 Web: ${GREEN}http://$public_ip:$actual_port/web${NC}" + echo -e " 公网 API: ${GREEN}http://$public_ip:$actual_port/api/v1${NC}" + fi + else + echo -e "服务状态: ${RED}未运行${NC}" + fi + + # 显示安装信息 + if [ -n "$INSTALL_DIR" ] && [ -d "$INSTALL_DIR" ]; then + echo -e "\n安装目录: $INSTALL_DIR" + elif [ -d "$DEFAULT_INSTALL_DIR" ]; then + echo -e "\n安装目录: $DEFAULT_INSTALL_DIR" + fi + + # Redis状态 + if command_exists redis-cli; then + echo -e "\nRedis 状态:" + local redis_cmd="redis-cli" + if [ -n "$REDIS_HOST" ]; then + redis_cmd="$redis_cmd -h $REDIS_HOST" + fi + if [ -n "$REDIS_PORT" ]; then + redis_cmd="$redis_cmd -p $REDIS_PORT" + fi + if [ -n "$REDIS_PASSWORD" ]; then + redis_cmd="$redis_cmd -a '$REDIS_PASSWORD'" + fi + + if $redis_cmd ping 2>/dev/null | grep -q "PONG"; then + echo -e " 连接状态: ${GREEN}正常${NC}" + else + echo -e " 连接状态: ${RED}异常${NC}" + fi + fi + + echo -e "\n${BLUE}===========================${NC}" +} + +# 显示帮助 +show_help() { + echo "Claude Relay Service 管理脚本" + echo "" + echo "用法: $0 [命令]" + echo "" + echo "命令:" + echo " install - 安装服务" + echo " update - 更新服务" + echo " uninstall - 卸载服务" + echo " start - 启动服务" + echo " stop - 停止服务" + echo " restart - 重启服务" + echo " status - 查看状态" + echo " switch-branch - 切换分支" + echo " update-pricing - 更新模型价格数据" + echo " symlink - 创建 crs 快捷命令" + echo " help - 显示帮助" + echo "" +} + +# 交互式菜单 +show_menu() { + clear + echo -e "${BOLD}======================================${NC}" + echo -e "${BOLD} Claude Relay Service (CRS) 管理工具 ${NC}" + echo -e "${BOLD}======================================${NC}" + echo "" + + # 显示当前状态 + echo -e "${YELLOW}当前状态:${NC}" + if check_installation; then + echo -e " 安装状态: ${GREEN}已安装${NC} (目录: $INSTALL_DIR)" + + # 获取实际端口 + local actual_port="$APP_PORT" + if [ -z "$actual_port" ] && [ -f "$APP_DIR/.env" ]; then + actual_port=$(grep "^PORT=" "$APP_DIR/.env" 2>/dev/null | cut -d'=' -f2) + fi + actual_port=${actual_port:-3000} + + # 检查服务状态 + local pid=$(pgrep -f "node.*src/app.js" | head -1) + if [ -n "$pid" ]; then + echo -e " 运行状态: ${GREEN}运行中${NC}" + echo -e " 进程 PID: $pid" + echo -e " 服务端口: $actual_port" + + # 获取公网IP + local public_ip=$(get_public_ip) + if [ "$public_ip" != "localhost" ]; then + echo -e " 公网地址: ${GREEN}http://$public_ip:$actual_port/web${NC}" + else + echo -e " Web 界面: ${GREEN}http://localhost:$actual_port/web${NC}" + fi + else + echo -e " 运行状态: ${RED}未运行${NC}" + fi + else + echo -e " 安装状态: ${RED}未安装${NC}" + fi + + # Redis状态 + if command_exists redis-cli && [ -n "$REDIS_HOST" ]; then + local redis_cmd="redis-cli -h $REDIS_HOST -p ${REDIS_PORT:-6379}" + if [ -n "$REDIS_PASSWORD" ]; then + redis_cmd="$redis_cmd -a '$REDIS_PASSWORD'" + fi + + if $redis_cmd ping 2>/dev/null | grep -q "PONG"; then + echo -e " Redis 状态: ${GREEN}连接正常${NC}" + else + echo -e " Redis 状态: ${RED}连接异常${NC}" + fi + fi + + echo "" + echo -e "${BOLD}--------------------------------------${NC}" + echo -e "${YELLOW}请选择操作:${NC}" + echo "" + + if ! check_installation; then + echo " 1) 安装服务" + echo " 2) 退出" + echo "" + echo -n "请输入选项 [1-2]: " + else + echo " 1) 查看状态" + echo " 2) 启动服务" + echo " 3) 停止服务" + echo " 4) 重启服务" + echo " 5) 更新服务" + echo " 6) 切换分支" + echo " 7) 更新模型价格" + echo " 8) 卸载服务" + echo " 9) 退出" + echo "" + echo -n "请输入选项 [1-9]: " + fi +} + +# 处理菜单选择 +handle_menu_choice() { + local choice=$1 + + if ! check_installation; then + case $choice in + 1) + echo "" + # 检查依赖 + if ! install_dependencies; then + print_error "依赖安装失败" + echo -n "按回车键继续..." + read + return 1 + fi + + # 检查Redis + if ! check_redis; then + print_warning "Redis 连接失败" + install_local_redis + + # 重新测试连接 + REDIS_HOST="localhost" + REDIS_PORT="6379" + if ! check_redis; then + print_error "Redis 配置失败,请手动安装并配置 Redis" + echo -n "按回车键继续..." + read + return 1 + fi + fi + + # 安装服务 + install_service + + # 创建软链接 + create_symlink + + echo -n "按回车键继续..." + read + ;; + 2) + echo "退出管理工具" + exit 0 + ;; + *) + print_error "无效选项" + sleep 1 + ;; + esac + else + case $choice in + 1) + echo "" + show_status + echo -n "按回车键继续..." + read + ;; + 2) + echo "" + start_service + echo -n "按回车键继续..." + read + ;; + 3) + echo "" + stop_service + echo -n "按回车键继续..." + read + ;; + 4) + echo "" + restart_service + echo -n "按回车键继续..." + read + ;; + 5) + echo "" + update_service + echo -n "按回车键继续..." + read + ;; + 6) + echo "" + switch_branch + echo -n "按回车键继续..." + read + ;; + 7) + echo "" + update_model_pricing + echo -n "按回车键继续..." + read + ;; + 8) + echo "" + uninstall_service + if [ $? -eq 0 ]; then + exit 0 + fi + ;; + 9) + echo "退出管理工具" + exit 0 + ;; + *) + print_error "无效选项" + sleep 1 + ;; + esac + fi +} + +# 创建软链接 +create_symlink() { + # 获取脚本的绝对路径 + local script_path="" + + # 优先使用项目中的 manage.sh(在 app/scripts 目录下) + if [ -n "$APP_DIR" ] && [ -f "$APP_DIR/scripts/manage.sh" ]; then + script_path="$APP_DIR/scripts/manage.sh" + # 确保脚本有执行权限 + chmod +x "$script_path" 2>/dev/null || sudo chmod +x "$script_path" 2>/dev/null || true + elif [ -f "/app/scripts/manage.sh" ] && [ "$(basename "$0")" = "manage.sh" ]; then + # Docker 容器中的路径 + script_path="/app/scripts/manage.sh" + elif command_exists realpath; then + script_path="$(realpath "$0")" + elif command_exists readlink && readlink -f "$0" >/dev/null 2>&1; then + script_path="$(readlink -f "$0")" + else + # 备用方法:使用pwd和脚本名 + script_path="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")" + fi + + local symlink_path="/usr/bin/crs" + + print_info "创建命令行快捷方式..." + print_info "APP_DIR: $APP_DIR" + print_info "脚本路径: $script_path" + + # 检查脚本文件是否存在 + if [ ! -f "$script_path" ]; then + print_error "找不到脚本文件: $script_path" + print_info "当前目录: $(pwd)" + print_info "脚本参数 \$0: $0" + if [ -n "$APP_DIR" ]; then + print_info "检查项目目录结构:" + ls -la "$APP_DIR/" 2>/dev/null | head -5 + if [ -d "$APP_DIR/scripts" ]; then + print_info "scripts 目录内容:" + ls -la "$APP_DIR/scripts/" 2>/dev/null | grep manage.sh + fi + fi + return 1 + fi + + # 如果已存在,直接删除并重新创建(默认使用代码中的最新版本) + if [ -L "$symlink_path" ] || [ -f "$symlink_path" ]; then + print_info "更新已存在的软链接..." + sudo rm -f "$symlink_path" 2>/dev/null || { + print_error "删除旧文件失败" + return 1 + } + fi + + # 创建软链接 + if sudo ln -s "$script_path" "$symlink_path"; then + print_success "已创建快捷命令 'crs'" + echo "您现在可以在任何地方使用 'crs' 命令管理服务" + + # 验证软链接 + if [ -L "$symlink_path" ]; then + print_info "软链接验证成功" + else + print_warning "软链接验证失败" + fi + else + print_error "创建软链接失败" + print_info "请手动执行以下命令:" + echo " sudo ln -s '$script_path' '$symlink_path'" + return 1 + fi +} + +# 加载已安装的配置 +load_config() { + # 尝试找到安装目录 + if [ -z "$INSTALL_DIR" ]; then + if [ -d "$DEFAULT_INSTALL_DIR" ]; then + INSTALL_DIR="$DEFAULT_INSTALL_DIR" + fi + fi + + if [ -n "$INSTALL_DIR" ]; then + # 检查是否使用了标准的安装结构(项目在 app 子目录) + if [ -d "$INSTALL_DIR/app" ] && [ -f "$INSTALL_DIR/app/package.json" ]; then + APP_DIR="$INSTALL_DIR/app" + # 检查是否直接克隆了项目(项目在根目录) + elif [ -f "$INSTALL_DIR/package.json" ]; then + APP_DIR="$INSTALL_DIR" + else + APP_DIR="$INSTALL_DIR/app" + fi + + # 加载.env配置 + if [ -f "$APP_DIR/.env" ]; then + export $(cat "$APP_DIR/.env" | grep -v '^#' | xargs) + # 特别加载端口配置 + APP_PORT=$(grep "^PORT=" "$APP_DIR/.env" 2>/dev/null | cut -d'=' -f2) + fi + fi +} + +# 主函数 +main() { + # 检测操作系统 + detect_os + + if [ "$OS" == "unknown" ]; then + print_error "不支持的操作系统" + exit 1 + fi + + # 加载配置 + load_config + + # 处理命令 + case "$1" in + install) + # 检查依赖 + if ! install_dependencies; then + print_error "依赖安装失败" + exit 1 + fi + + # 检查Redis + if ! check_redis; then + print_warning "Redis 连接失败" + install_local_redis + + # 重新测试连接 + REDIS_HOST="localhost" + REDIS_PORT="6379" + if ! check_redis; then + print_error "Redis 配置失败,请手动安装并配置 Redis" + exit 1 + fi + fi + + # 安装服务 + install_service + + # 创建软链接 + create_symlink + ;; + update) + update_service + ;; + uninstall) + uninstall_service + ;; + start) + start_service + ;; + stop) + stop_service + ;; + restart) + restart_service + ;; + status) + show_status + ;; + switch-branch) + switch_branch + ;; + update-pricing) + update_model_pricing + ;; + symlink) + # 单独创建软链接 + # 确保 APP_DIR 已设置 + if [ -z "$APP_DIR" ]; then + print_error "请先安装项目后再创建软链接" + print_info "运行: $0 install" + exit 1 + fi + create_symlink + ;; + help) + show_help + ;; + "") + # 无参数时显示交互式菜单 + while true; do + show_menu + read choice + handle_menu_choice "$choice" + done + ;; + *) + print_error "未知命令: $1" + echo "" + show_help + ;; + esac +} + +# 运行主函数 +main "$@" diff --git a/scripts/migrate-apikey-expiry.js b/scripts/migrate-apikey-expiry.js new file mode 100644 index 0000000000000000000000000000000000000000..1be8a82eed98231df15b376727a2934cfa3c9135 --- /dev/null +++ b/scripts/migrate-apikey-expiry.js @@ -0,0 +1,192 @@ +#!/usr/bin/env node + +/** + * 数据迁移脚本:为现有 API Key 设置默认有效期 + * + * 使用方法: + * node scripts/migrate-apikey-expiry.js [--days=30] [--dry-run] + * + * 参数: + * --days: 设置默认有效期天数(默认30天) + * --dry-run: 仅模拟运行,不实际修改数据 + */ + +const redis = require('../src/models/redis') +const logger = require('../src/utils/logger') +const readline = require('readline') + +// 解析命令行参数 +const args = process.argv.slice(2) +const params = {} +args.forEach((arg) => { + const [key, value] = arg.split('=') + params[key.replace('--', '')] = value || true +}) + +const DEFAULT_DAYS = params.days ? parseInt(params.days) : 30 +const DRY_RUN = params['dry-run'] === true + +// 创建 readline 接口用于用户确认 +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout +}) + +async function askConfirmation(question) { + return new Promise((resolve) => { + rl.question(`${question} (yes/no): `, (answer) => { + resolve(answer.toLowerCase() === 'yes' || answer.toLowerCase() === 'y') + }) + }) +} + +async function migrateApiKeys() { + try { + logger.info('🔄 Starting API Key expiry migration...') + logger.info(`📅 Default expiry period: ${DEFAULT_DAYS} days`) + logger.info(`🔍 Mode: ${DRY_RUN ? 'DRY RUN (no changes will be made)' : 'LIVE RUN'}`) + + // 连接 Redis + await redis.connect() + logger.success('✅ Connected to Redis') + + // 获取所有 API Keys + const apiKeys = await redis.getAllApiKeys() + logger.info(`📊 Found ${apiKeys.length} API Keys in total`) + + // 统计信息 + const stats = { + total: apiKeys.length, + needsMigration: 0, + alreadyHasExpiry: 0, + migrated: 0, + errors: 0 + } + + // 需要迁移的 Keys + const keysToMigrate = [] + + // 分析每个 API Key + for (const key of apiKeys) { + if (!key.expiresAt || key.expiresAt === 'null' || key.expiresAt === '') { + keysToMigrate.push(key) + stats.needsMigration++ + logger.info(`📌 API Key "${key.name}" (${key.id}) needs migration`) + } else { + stats.alreadyHasExpiry++ + const expiryDate = new Date(key.expiresAt) + logger.info( + `✓ API Key "${key.name}" (${key.id}) already has expiry: ${expiryDate.toLocaleString()}` + ) + } + } + + if (keysToMigrate.length === 0) { + logger.success('✨ No API Keys need migration!') + return + } + + // 显示迁移摘要 + console.log(`\n${'='.repeat(60)}`) + console.log('📋 Migration Summary:') + console.log('='.repeat(60)) + console.log(`Total API Keys: ${stats.total}`) + console.log(`Already have expiry: ${stats.alreadyHasExpiry}`) + console.log(`Need migration: ${stats.needsMigration}`) + console.log(`Default expiry: ${DEFAULT_DAYS} days from now`) + console.log(`${'='.repeat(60)}\n`) + + // 如果不是 dry run,请求确认 + if (!DRY_RUN) { + const confirmed = await askConfirmation( + `⚠️ This will set expiry dates for ${keysToMigrate.length} API Keys. Continue?` + ) + + if (!confirmed) { + logger.warn('❌ Migration cancelled by user') + return + } + } + + // 计算新的过期时间 + const newExpiryDate = new Date() + newExpiryDate.setDate(newExpiryDate.getDate() + DEFAULT_DAYS) + const newExpiryISO = newExpiryDate.toISOString() + + logger.info(`\n🚀 Starting migration... New expiry date: ${newExpiryDate.toLocaleString()}`) + + // 执行迁移 + for (const key of keysToMigrate) { + try { + if (!DRY_RUN) { + // 直接更新 Redis 中的数据 + // 使用 hset 更新单个字段 + await redis.client.hset(`apikey:${key.id}`, 'expiresAt', newExpiryISO) + logger.success(`✅ Migrated: "${key.name}" (${key.id})`) + } else { + logger.info(`[DRY RUN] Would migrate: "${key.name}" (${key.id})`) + } + stats.migrated++ + } catch (error) { + logger.error(`❌ Error migrating "${key.name}" (${key.id}):`, error.message) + stats.errors++ + } + } + + // 显示最终结果 + console.log(`\n${'='.repeat(60)}`) + console.log('✅ Migration Complete!') + console.log('='.repeat(60)) + console.log(`Successfully migrated: ${stats.migrated}`) + console.log(`Errors: ${stats.errors}`) + console.log(`New expiry date: ${newExpiryDate.toLocaleString()}`) + console.log(`${'='.repeat(60)}\n`) + + if (DRY_RUN) { + logger.warn('⚠️ This was a DRY RUN. No actual changes were made.') + logger.info('💡 Run without --dry-run flag to apply changes.') + } + } catch (error) { + logger.error('💥 Migration failed:', error) + process.exit(1) + } finally { + // 清理 + rl.close() + await redis.disconnect() + logger.info('👋 Disconnected from Redis') + } +} + +// 显示帮助信息 +if (params.help) { + console.log(` +API Key Expiry Migration Script + +This script adds expiry dates to existing API Keys that don't have one. + +Usage: + node scripts/migrate-apikey-expiry.js [options] + +Options: + --days=NUMBER Set default expiry days (default: 30) + --dry-run Simulate the migration without making changes + --help Show this help message + +Examples: + # Set 30-day expiry for all API Keys without expiry + node scripts/migrate-apikey-expiry.js + + # Set 90-day expiry + node scripts/migrate-apikey-expiry.js --days=90 + + # Test run without making changes + node scripts/migrate-apikey-expiry.js --dry-run +`) + process.exit(0) +} + +// 运行迁移 +migrateApiKeys().catch((error) => { + logger.error('💥 Unexpected error:', error) + process.exit(1) +}) diff --git a/scripts/monitor-enhanced.sh b/scripts/monitor-enhanced.sh new file mode 100644 index 0000000000000000000000000000000000000000..fc7c8c40e478eedd3183b9838926441f7e452c02 --- /dev/null +++ b/scripts/monitor-enhanced.sh @@ -0,0 +1,273 @@ +#!/bin/bash + +# Claude Relay Service - 增强版实时监控脚本 +# 结合并发监控和系统状态的完整监控方案 + +# 加载环境变量 +if [ -f .env ]; then + export $(grep -v '^#' .env | xargs) +fi + +echo "🔍 Claude Relay Service - 增强版实时监控" +echo "按 Ctrl+C 退出 | 按 's' 切换详细/简单模式" +echo "========================================" + +# 获取服务配置 +SERVICE_HOST=${HOST:-127.0.0.1} +SERVICE_PORT=${PORT:-3000} + +# 如果HOST是0.0.0.0,客户端应该连接localhost +if [ "$SERVICE_HOST" = "0.0.0.0" ]; then + SERVICE_HOST="127.0.0.1" +fi + +SERVICE_URL="http://${SERVICE_HOST}:${SERVICE_PORT}" + +# 获取Redis配置 +REDIS_HOST=${REDIS_HOST:-127.0.0.1} +REDIS_PORT=${REDIS_PORT:-6379} +REDIS_CMD="redis-cli -h $REDIS_HOST -p $REDIS_PORT" + +if [ ! -z "$REDIS_PASSWORD" ]; then + REDIS_CMD="redis-cli -h $REDIS_HOST -p $REDIS_PORT -a $REDIS_PASSWORD" +fi + +# 检查Redis连接 +if ! $REDIS_CMD ping > /dev/null 2>&1; then + echo "❌ Redis连接失败,请检查Redis服务是否运行" + echo " 配置: $REDIS_HOST:$REDIS_PORT" + exit 1 +fi + +# 显示模式: simple(简单) / detailed(详细) +DISPLAY_MODE="simple" + +# 获取API Key详细信息 +get_api_key_info() { + local api_key_id=$1 + local api_key_name=$($REDIS_CMD hget "apikey:$api_key_id" name 2>/dev/null) + local concurrency_limit=$($REDIS_CMD hget "apikey:$api_key_id" concurrencyLimit 2>/dev/null) + local token_limit=$($REDIS_CMD hget "apikey:$api_key_id" tokenLimit 2>/dev/null) + local created_at=$($REDIS_CMD hget "apikey:$api_key_id" createdAt 2>/dev/null) + + if [ -z "$api_key_name" ]; then + api_key_name="Unknown" + fi + + if [ -z "$concurrency_limit" ] || [ "$concurrency_limit" = "0" ]; then + concurrency_limit="无限制" + fi + + if [ -z "$token_limit" ] || [ "$token_limit" = "0" ]; then + token_limit="无限制" + else + token_limit=$(printf "%'d" $token_limit) + fi + + echo "$api_key_name|$concurrency_limit|$token_limit|$created_at" +} + +# 获取使用统计信息 +get_usage_stats() { + local api_key_id=$1 + local today=$(date '+%Y-%m-%d') + local current_month=$(date '+%Y-%m') + + # 获取总体使用量 + local total_requests=$($REDIS_CMD hget "usage:$api_key_id" totalRequests 2>/dev/null) + local total_tokens=$($REDIS_CMD hget "usage:$api_key_id" totalTokens 2>/dev/null) + + # 获取今日使用量 + local daily_requests=$($REDIS_CMD hget "usage:daily:$api_key_id:$today" requests 2>/dev/null) + local daily_tokens=$($REDIS_CMD hget "usage:daily:$api_key_id:$today" tokens 2>/dev/null) + + total_requests=${total_requests:-0} + total_tokens=${total_tokens:-0} + daily_requests=${daily_requests:-0} + daily_tokens=${daily_tokens:-0} + + echo "$total_requests|$total_tokens|$daily_requests|$daily_tokens" +} + +# 格式化数字 +format_number() { + local num=$1 + if [ "$num" -ge 1000000 ]; then + echo "$(echo "scale=1; $num / 1000000" | bc 2>/dev/null)M" + elif [ "$num" -ge 1000 ]; then + echo "$(echo "scale=1; $num / 1000" | bc 2>/dev/null)K" + else + echo "$num" + fi +} + +# 获取系统信息 +get_system_info() { + # Redis信息 + local redis_info=$($REDIS_CMD info server 2>/dev/null) + local redis_memory_info=$($REDIS_CMD info memory 2>/dev/null) + + local redis_version=$(echo "$redis_info" | grep redis_version | cut -d: -f2 | tr -d '\r' 2>/dev/null) + local redis_uptime=$(echo "$redis_info" | grep uptime_in_seconds | cut -d: -f2 | tr -d '\r' 2>/dev/null) + local used_memory=$(echo "$redis_memory_info" | grep used_memory_human | cut -d: -f2 | tr -d '\r' 2>/dev/null) + + local redis_uptime_hours=0 + if [ ! -z "$redis_uptime" ]; then + redis_uptime_hours=$((redis_uptime / 3600)) + fi + + # 服务状态 + local service_status="unknown" + local service_uptime="0" + if command -v curl > /dev/null 2>&1; then + local health_response=$(curl -s ${SERVICE_URL}/health 2>/dev/null) + if [ $? -eq 0 ]; then + service_status=$(echo "$health_response" | grep -o '"status":"[^"]*"' | cut -d'"' -f4 | head -1) + service_uptime=$(echo "$health_response" | grep -o '"uptime":[^,}]*' | cut -d: -f2 | head -1) + fi + fi + + local service_uptime_hours="0" + if [ ! -z "$service_uptime" ] && [ "$service_uptime" != "null" ]; then + service_uptime_hours=$(echo "scale=1; $service_uptime / 3600" | bc 2>/dev/null) + fi + + echo "$redis_version|$redis_uptime_hours|$used_memory|$service_status|$service_uptime_hours" +} + +# 主监控函数 +monitor_enhanced() { + while true; do + clear + echo "🔍 Claude Relay Service - 增强版实时监控 | $(date '+%Y-%m-%d %H:%M:%S')" + echo "模式: $DISPLAY_MODE | 服务: $SERVICE_URL | Redis: $REDIS_HOST:$REDIS_PORT" + echo "========================================" + + # 获取系统信息 + local system_info=$(get_system_info) + local redis_version=$(echo "$system_info" | cut -d'|' -f1) + local redis_uptime=$(echo "$system_info" | cut -d'|' -f2) + local redis_memory=$(echo "$system_info" | cut -d'|' -f3) + local service_status=$(echo "$system_info" | cut -d'|' -f4) + local service_uptime=$(echo "$system_info" | cut -d'|' -f5) + + # 系统状态概览 + echo "🏥 系统状态概览:" + if [ "$service_status" = "healthy" ]; then + echo " ✅ 服务: 健康 (运行 ${service_uptime}h)" + else + echo " ⚠️ 服务: 异常 ($service_status)" + fi + echo " 📊 Redis: v${redis_version} (运行 ${redis_uptime}h, 内存 ${redis_memory})" + echo "" + + # 获取并发信息 + local concurrency_keys=$($REDIS_CMD --scan --pattern "concurrency:*" 2>/dev/null) + local total_concurrent=0 + local active_keys=0 + local concurrent_details="" + + if [ ! -z "$concurrency_keys" ]; then + for key in $concurrency_keys; do + local count=$($REDIS_CMD get "$key" 2>/dev/null) + if [ ! -z "$count" ] && [ "$count" -gt 0 ]; then + local api_key_id=${key#concurrency:} + local key_info=$(get_api_key_info "$api_key_id") + local key_name=$(echo "$key_info" | cut -d'|' -f1) + local concurrency_limit=$(echo "$key_info" | cut -d'|' -f2) + + concurrent_details="${concurrent_details}${key_name}:${count}/${concurrency_limit} " + total_concurrent=$((total_concurrent + count)) + active_keys=$((active_keys + 1)) + fi + done + fi + + # 并发状态显示 + echo "📊 当前并发状态:" + if [ $total_concurrent -eq 0 ]; then + echo " 💤 无活跃并发连接" + else + echo " 🔥 总并发: $total_concurrent 个连接 ($active_keys 个API Key)" + if [ "$DISPLAY_MODE" = "detailed" ]; then + echo " 📋 详情: $concurrent_details" + fi + fi + echo "" + + # API Key统计 + local total_keys=$($REDIS_CMD keys "apikey:*" 2>/dev/null | grep -v "apikey:hash_map" | wc -l) + local total_accounts=$($REDIS_CMD keys "claude:account:*" 2>/dev/null | wc -l) + + echo "📋 资源统计:" + echo " 🔑 API Keys: $total_keys 个" + echo " 🏢 Claude账户: $total_accounts 个" + + # 详细模式显示更多信息 + if [ "$DISPLAY_MODE" = "detailed" ]; then + echo "" + echo "📈 使用统计 (今日/总计):" + + # 获取所有API Key + local api_keys=$($REDIS_CMD keys "apikey:*" 2>/dev/null | grep -v "apikey:hash_map") + local total_daily_requests=0 + local total_daily_tokens=0 + local total_requests=0 + local total_tokens=0 + + if [ ! -z "$api_keys" ]; then + for key in $api_keys; do + local api_key_id=${key#apikey:} + local key_info=$(get_api_key_info "$api_key_id") + local key_name=$(echo "$key_info" | cut -d'|' -f1) + local usage_info=$(get_usage_stats "$api_key_id") + + local key_total_requests=$(echo "$usage_info" | cut -d'|' -f1) + local key_total_tokens=$(echo "$usage_info" | cut -d'|' -f2) + local key_daily_requests=$(echo "$usage_info" | cut -d'|' -f3) + local key_daily_tokens=$(echo "$usage_info" | cut -d'|' -f4) + + total_daily_requests=$((total_daily_requests + key_daily_requests)) + total_daily_tokens=$((total_daily_tokens + key_daily_tokens)) + total_requests=$((total_requests + key_total_requests)) + total_tokens=$((total_tokens + key_total_tokens)) + + if [ $((key_daily_requests + key_total_requests)) -gt 0 ]; then + echo " 📱 $key_name: ${key_daily_requests}req/$(format_number $key_daily_tokens) | ${key_total_requests}req/$(format_number $key_total_tokens)" + fi + done + fi + + echo " 🌍 系统总计: ${total_daily_requests}req/$(format_number $total_daily_tokens) | ${total_requests}req/$(format_number $total_tokens)" + fi + + echo "" + echo "🔄 刷新间隔: 5秒 | 按 Ctrl+C 退出 | 按 Enter 切换详细/简单模式" + + # 非阻塞读取用户输入 + read -t 5 user_input + if [ $? -eq 0 ]; then + case "$user_input" in + "s"|"S"|"") + if [ "$DISPLAY_MODE" = "simple" ]; then + DISPLAY_MODE="detailed" + else + DISPLAY_MODE="simple" + fi + ;; + esac + fi + done +} + +# 信号处理 +cleanup() { + echo "" + echo "👋 监控已停止" + exit 0 +} + +trap cleanup SIGINT SIGTERM + +# 开始监控 +monitor_enhanced \ No newline at end of file diff --git a/scripts/setup.js b/scripts/setup.js new file mode 100644 index 0000000000000000000000000000000000000000..1ad76a9babb4187d25e63f4eea3aaaf49ec64d25 --- /dev/null +++ b/scripts/setup.js @@ -0,0 +1,128 @@ +const fs = require('fs') +const path = require('path') +const crypto = require('crypto') +const chalk = require('chalk') +const ora = require('ora') + +const config = require('../config/config') + +async function setup() { + console.log(chalk.blue.bold('\n🚀 Claude Relay Service 初始化设置\n')) + + const spinner = ora('正在进行初始化设置...').start() + + try { + // 1. 创建必要目录 + const directories = ['logs', 'data', 'temp'] + + directories.forEach((dir) => { + const dirPath = path.join(__dirname, '..', dir) + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }) + } + }) + + // 2. 生成环境配置文件 + if (!fs.existsSync(path.join(__dirname, '..', '.env'))) { + const envTemplate = fs.readFileSync(path.join(__dirname, '..', '.env.example'), 'utf8') + + // 生成随机密钥 + const jwtSecret = crypto.randomBytes(64).toString('hex') + const encryptionKey = crypto.randomBytes(32).toString('hex') + + const envContent = envTemplate + .replace('your-jwt-secret-here', jwtSecret) + .replace('your-encryption-key-here', encryptionKey) + + fs.writeFileSync(path.join(__dirname, '..', '.env'), envContent) + } + + // 3. 生成或使用环境变量中的管理员凭据 + const adminUsername = + process.env.ADMIN_USERNAME || `cr_admin_${crypto.randomBytes(4).toString('hex')}` + const adminPassword = + process.env.ADMIN_PASSWORD || + crypto + .randomBytes(16) + .toString('base64') + .replace(/[^a-zA-Z0-9]/g, '') + .substring(0, 16) + + // 如果使用了环境变量,显示提示 + if (process.env.ADMIN_USERNAME || process.env.ADMIN_PASSWORD) { + console.log(chalk.yellow('\n📌 使用环境变量中的管理员凭据')) + } + + // 4. 创建初始化完成标记文件 + const initData = { + initializedAt: new Date().toISOString(), + adminUsername, + adminPassword, + version: '1.0.0' + } + + fs.writeFileSync( + path.join(__dirname, '..', 'data', 'init.json'), + JSON.stringify(initData, null, 2) + ) + + spinner.succeed('初始化设置完成') + + console.log(chalk.green('\n✅ 设置完成!\n')) + console.log(chalk.yellow('📋 重要信息:\n')) + console.log(` 管理员用户名: ${chalk.cyan(adminUsername)}`) + console.log(` 管理员密码: ${chalk.cyan(adminPassword)}`) + + // 如果是自动生成的凭据,强调需要保存 + if (!process.env.ADMIN_USERNAME && !process.env.ADMIN_PASSWORD) { + console.log(chalk.red('\n⚠️ 请立即保存这些凭据!首次登录后建议修改密码。')) + console.log( + chalk.yellow( + '\n💡 提示: 也可以通过环境变量 ADMIN_USERNAME 和 ADMIN_PASSWORD 预设管理员凭据。\n' + ) + ) + } else { + console.log(chalk.green('\n✅ 已使用预设的管理员凭据。\n')) + } + + console.log(chalk.blue('🚀 启动服务:\n')) + console.log(' npm start - 启动生产服务') + console.log(' npm run dev - 启动开发服务') + console.log(' npm run cli admin - 管理员CLI工具\n') + + console.log(chalk.blue('🌐 访问地址:\n')) + console.log(` Web管理界面: http://localhost:${config.server.port}/web`) + console.log(` API端点: http://localhost:${config.server.port}/api/v1/messages`) + console.log(` 健康检查: http://localhost:${config.server.port}/health\n`) + } catch (error) { + spinner.fail('初始化设置失败') + console.error(chalk.red('❌ 错误:'), error.message) + process.exit(1) + } +} + +// 检查是否已初始化 +function checkInitialized() { + const initFile = path.join(__dirname, '..', 'data', 'init.json') + if (fs.existsSync(initFile)) { + const initData = JSON.parse(fs.readFileSync(initFile, 'utf8')) + console.log(chalk.yellow('⚠️ 服务已经初始化过了!')) + console.log(` 初始化时间: ${new Date(initData.initializedAt).toLocaleString()}`) + console.log(` 管理员用户名: ${initData.adminUsername}`) + console.log('\n如需重新初始化,请删除 data/init.json 文件后再运行此命令。') + console.log(chalk.red('\n⚠️ 重要提示:')) + console.log(' 1. 删除 init.json 文件后运行 npm run setup') + console.log(' 2. 生成新的账号密码后,需要重启服务才能生效') + console.log(' 3. 使用 npm run service:restart 重启服务\n') + return true + } + return false +} + +if (require.main === module) { + if (!checkInitialized()) { + setup() + } +} + +module.exports = { setup, checkInitialized } diff --git a/scripts/status-unified.sh b/scripts/status-unified.sh new file mode 100644 index 0000000000000000000000000000000000000000..878f9c4dab0008a3181c85d29036d8022036a0c3 --- /dev/null +++ b/scripts/status-unified.sh @@ -0,0 +1,262 @@ +#!/bin/bash + +# Claude Relay Service - 统一状态检查脚本 +# 提供完整的系统状态概览 + +# 加载环境变量 +if [ -f .env ]; then + export $(grep -v '^#' .env | xargs) +fi + +# 参数处理 +DETAIL_MODE=false +if [ "$1" = "--detail" ] || [ "$1" = "-d" ]; then + DETAIL_MODE=true +fi + +echo "🔍 Claude Relay Service - 系统状态检查" +if [ "$DETAIL_MODE" = true ]; then + echo "模式: 详细信息" +else + echo "模式: 概览 (使用 --detail 查看详细信息)" +fi +echo "========================================" + +# 获取服务配置 +SERVICE_HOST=${HOST:-127.0.0.1} +SERVICE_PORT=${PORT:-3000} + +if [ "$SERVICE_HOST" = "0.0.0.0" ]; then + SERVICE_HOST="127.0.0.1" +fi + +SERVICE_URL="http://${SERVICE_HOST}:${SERVICE_PORT}" + +# 获取Redis配置 +REDIS_HOST=${REDIS_HOST:-127.0.0.1} +REDIS_PORT=${REDIS_PORT:-6379} +REDIS_CMD="redis-cli -h $REDIS_HOST -p $REDIS_PORT" + +if [ ! -z "$REDIS_PASSWORD" ]; then + REDIS_CMD="redis-cli -h $REDIS_HOST -p $REDIS_PORT -a $REDIS_PASSWORD" +fi + +# 检查Redis连接 +echo "🔍 连接检查:" +if $REDIS_CMD ping > /dev/null 2>&1; then + echo " ✅ Redis连接正常 ($REDIS_HOST:$REDIS_PORT)" +else + echo " ❌ Redis连接失败 ($REDIS_HOST:$REDIS_PORT)" + exit 1 +fi + +# 检查服务状态 +if command -v curl > /dev/null 2>&1; then + health_response=$(curl -s ${SERVICE_URL}/health 2>/dev/null) + if [ $? -eq 0 ]; then + health_status=$(echo "$health_response" | grep -o '"status":"[^"]*"' | cut -d'"' -f4 | head -1) + if [ "$health_status" = "healthy" ]; then + echo " ✅ 服务状态正常 ($SERVICE_URL)" + else + echo " ⚠️ 服务状态异常: $health_status ($SERVICE_URL)" + fi + else + echo " ❌ 服务无法访问 ($SERVICE_URL)" + fi +else + echo " ⚠️ curl命令不可用,无法检查服务状态" +fi + +echo "" + +# 格式化数字函数 +format_number() { + local num=$1 + if [ "$num" -ge 1000000 ]; then + echo "$(echo "scale=1; $num / 1000000" | bc 2>/dev/null)M" + elif [ "$num" -ge 1000 ]; then + echo "$(echo "scale=1; $num / 1000" | bc 2>/dev/null)K" + else + echo "$num" + fi +} + +# 系统信息 +echo "🏥 系统信息:" + +# Redis信息 +redis_info=$($REDIS_CMD info server 2>/dev/null) +redis_memory_info=$($REDIS_CMD info memory 2>/dev/null) + +redis_version=$(echo "$redis_info" | grep redis_version | cut -d: -f2 | tr -d '\r' 2>/dev/null) +redis_uptime=$(echo "$redis_info" | grep uptime_in_seconds | cut -d: -f2 | tr -d '\r' 2>/dev/null) +used_memory=$(echo "$redis_memory_info" | grep used_memory_human | cut -d: -f2 | tr -d '\r' 2>/dev/null) + +if [ ! -z "$redis_version" ]; then + echo " 📊 Redis版本: $redis_version" +fi + +if [ ! -z "$redis_uptime" ]; then + uptime_hours=$((redis_uptime / 3600)) + echo " ⏱️ Redis运行时间: $uptime_hours 小时" +fi + +if [ ! -z "$used_memory" ]; then + echo " 💾 Redis内存使用: $used_memory" +fi + +# 服务信息 +if command -v curl > /dev/null 2>&1; then + health_response=$(curl -s ${SERVICE_URL}/health 2>/dev/null) + if [ $? -eq 0 ]; then + uptime=$(echo "$health_response" | grep -o '"uptime":[^,}]*' | cut -d: -f2 | head -1) + + if [ ! -z "$uptime" ] && [ "$uptime" != "null" ]; then + uptime_hours=$(echo "scale=1; $uptime / 3600" | bc 2>/dev/null) + if [ ! -z "$uptime_hours" ]; then + echo " ⏰ 服务运行时间: $uptime_hours 小时" + fi + fi + + # 检查端口 + if netstat -ln 2>/dev/null | grep -q ":${SERVICE_PORT} "; then + echo " 🔌 端口${SERVICE_PORT}: 正在监听" + else + echo " ❌ 端口${SERVICE_PORT}: 未监听" + fi + fi +fi + +echo "" + +# 并发状态 +echo "📊 并发状态:" +concurrency_keys=$($REDIS_CMD --scan --pattern "concurrency:*" 2>/dev/null) + +if [ -z "$concurrency_keys" ]; then + echo " 💤 当前无活跃并发连接" +else + total_concurrent=0 + active_keys=0 + + for key in $concurrency_keys; do + count=$($REDIS_CMD get "$key" 2>/dev/null) + if [ ! -z "$count" ] && [ "$count" -gt 0 ]; then + api_key_id=${key#concurrency:} + + if [ "$DETAIL_MODE" = true ]; then + api_key_name=$($REDIS_CMD hget "apikey:$api_key_id" name 2>/dev/null) + concurrency_limit=$($REDIS_CMD hget "apikey:$api_key_id" concurrencyLimit 2>/dev/null) + + if [ -z "$api_key_name" ]; then + api_key_name="Unknown" + fi + + if [ -z "$concurrency_limit" ] || [ "$concurrency_limit" = "0" ]; then + limit_text="无限制" + else + limit_text="$concurrency_limit" + fi + + echo " 🔑 $api_key_name: $count 个并发 (限制: $limit_text)" + fi + + total_concurrent=$((total_concurrent + count)) + active_keys=$((active_keys + 1)) + fi + done + + echo " 📈 总计: $total_concurrent 个活跃并发连接 ($active_keys 个API Key)" +fi + +echo "" + +# 资源统计 +echo "📋 资源统计:" + +total_keys=$($REDIS_CMD keys "apikey:*" 2>/dev/null | grep -v "apikey:hash_map" | wc -l) +total_accounts=$($REDIS_CMD keys "claude:account:*" 2>/dev/null | wc -l) + +echo " 🔑 API Key总数: $total_keys" +echo " 🏢 Claude账户数: $total_accounts" + +# 详细模式下的使用统计 +if [ "$DETAIL_MODE" = true ]; then + echo "" + echo "📈 使用统计:" + + today=$(date '+%Y-%m-%d') + current_month=$(date '+%Y-%m') + + # 系统总体统计 + total_daily_requests=0 + total_daily_tokens=0 + total_requests=0 + total_tokens=0 + + api_keys=$($REDIS_CMD keys "apikey:*" 2>/dev/null | grep -v "apikey:hash_map") + + if [ ! -z "$api_keys" ]; then + echo " 📱 API Key详情:" + + for key in $api_keys; do + api_key_id=${key#apikey:} + + # API Key基本信息 + api_key_name=$($REDIS_CMD hget "apikey:$api_key_id" name 2>/dev/null) + token_limit=$($REDIS_CMD hget "apikey:$api_key_id" tokenLimit 2>/dev/null) + created_at=$($REDIS_CMD hget "apikey:$api_key_id" createdAt 2>/dev/null) + + # 使用统计 + key_total_requests=$($REDIS_CMD hget "usage:$api_key_id" totalRequests 2>/dev/null) + key_total_tokens=$($REDIS_CMD hget "usage:$api_key_id" totalTokens 2>/dev/null) + key_daily_requests=$($REDIS_CMD hget "usage:daily:$api_key_id:$today" requests 2>/dev/null) + key_daily_tokens=$($REDIS_CMD hget "usage:daily:$api_key_id:$today" tokens 2>/dev/null) + + # 默认值处理 + api_key_name=${api_key_name:-"Unknown"} + token_limit=${token_limit:-0} + key_total_requests=${key_total_requests:-0} + key_total_tokens=${key_total_tokens:-0} + key_daily_requests=${key_daily_requests:-0} + key_daily_tokens=${key_daily_tokens:-0} + + # 格式化Token限制 + if [ "$token_limit" = "0" ]; then + limit_text="无限制" + else + limit_text=$(format_number $token_limit) + fi + + # 创建时间格式化 + if [ ! -z "$created_at" ]; then + created_date=$(echo "$created_at" | cut -d'T' -f1) + else + created_date="未知" + fi + + echo " • $api_key_name (创建: $created_date, 限制: $limit_text)" + echo " 今日: ${key_daily_requests}请求 / $(format_number $key_daily_tokens)tokens" + echo " 总计: ${key_total_requests}请求 / $(format_number $key_total_tokens)tokens" + echo "" + + # 累计统计 + total_daily_requests=$((total_daily_requests + key_daily_requests)) + total_daily_tokens=$((total_daily_tokens + key_daily_tokens)) + total_requests=$((total_requests + key_total_requests)) + total_tokens=$((total_tokens + key_total_tokens)) + done + fi + + echo " 🌍 系统总计:" + echo " 今日: ${total_daily_requests}请求 / $(format_number $total_daily_tokens)tokens" + echo " 总计: ${total_requests}请求 / $(format_number $total_tokens)tokens" +fi + +echo "" +echo "✅ 状态检查完成 - $(date '+%Y-%m-%d %H:%M:%S')" + +if [ "$DETAIL_MODE" = false ]; then + echo "" + echo "💡 使用 'npm run status -- --detail' 查看详细信息" +fi \ No newline at end of file diff --git a/scripts/test-account-display.js b/scripts/test-account-display.js new file mode 100644 index 0000000000000000000000000000000000000000..de5daad042a25ef46dcd2fa5ae1082c93b7ffb69 --- /dev/null +++ b/scripts/test-account-display.js @@ -0,0 +1,143 @@ +/** + * 测试账号显示问题是否已修复 + */ + +const axios = require('axios') +const config = require('../config/config') + +// 从 init.json 读取管理员凭据 +const fs = require('fs') +const path = require('path') + +async function testAccountDisplay() { + console.log('🔍 测试账号显示问题...\n') + + try { + // 读取管理员凭据 + const initPath = path.join(__dirname, '..', 'config', 'init.json') + if (!fs.existsSync(initPath)) { + console.error('❌ 找不到 init.json 文件,请运行 npm run setup') + process.exit(1) + } + + const initData = JSON.parse(fs.readFileSync(initPath, 'utf8')) + const adminUser = initData.admins?.[0] + if (!adminUser) { + console.error('❌ 没有找到管理员账号') + process.exit(1) + } + + const baseURL = `http://localhost:${config.server.port}` + + // 登录获取 token + console.log('🔐 登录管理员账号...') + const loginResp = await axios.post(`${baseURL}/admin/login`, { + username: adminUser.username, + password: adminUser.password + }) + + if (!loginResp.data.success) { + console.error('❌ 登录失败') + process.exit(1) + } + + const { token } = loginResp.data + console.log('✅ 登录成功\n') + + // 设置请求头 + const headers = { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' + } + + // 获取 Claude OAuth 账号 + console.log('📋 获取 Claude OAuth 账号...') + const claudeResp = await axios.get(`${baseURL}/admin/claude-accounts`, { headers }) + const claudeAccounts = claudeResp.data.data || [] + + console.log(`找到 ${claudeAccounts.length} 个 Claude OAuth 账号`) + + // 分类显示 + const claudeDedicated = claudeAccounts.filter((a) => a.accountType === 'dedicated') + const claudeGroup = claudeAccounts.filter((a) => a.accountType === 'group') + const claudeShared = claudeAccounts.filter((a) => a.accountType === 'shared') + + console.log(`- 专属账号: ${claudeDedicated.length} 个`) + console.log(`- 分组账号: ${claudeGroup.length} 个`) + console.log(`- 共享账号: ${claudeShared.length} 个`) + + // 检查 platform 字段 + console.log('\n检查 platform 字段:') + claudeAccounts.slice(0, 3).forEach((acc) => { + console.log(`- ${acc.name}: platform=${acc.platform}, accountType=${acc.accountType}`) + }) + + // 获取 Claude Console 账号 + console.log('\n📋 获取 Claude Console 账号...') + const consoleResp = await axios.get(`${baseURL}/admin/claude-console-accounts`, { headers }) + const consoleAccounts = consoleResp.data.data || [] + + console.log(`找到 ${consoleAccounts.length} 个 Claude Console 账号`) + + // 分类显示 + const consoleDedicated = consoleAccounts.filter((a) => a.accountType === 'dedicated') + const consoleGroup = consoleAccounts.filter((a) => a.accountType === 'group') + const consoleShared = consoleAccounts.filter((a) => a.accountType === 'shared') + + console.log(`- 专属账号: ${consoleDedicated.length} 个`) + console.log(`- 分组账号: ${consoleGroup.length} 个`) + console.log(`- 共享账号: ${consoleShared.length} 个`) + + // 检查 platform 字段 + console.log('\n检查 platform 字段:') + consoleAccounts.slice(0, 3).forEach((acc) => { + console.log(`- ${acc.name}: platform=${acc.platform}, accountType=${acc.accountType}`) + }) + + // 获取账号分组 + console.log('\n📋 获取账号分组...') + const groupsResp = await axios.get(`${baseURL}/admin/account-groups`, { headers }) + const groups = groupsResp.data.data || [] + + console.log(`找到 ${groups.length} 个账号分组`) + + const claudeGroups = groups.filter((g) => g.platform === 'claude') + const geminiGroups = groups.filter((g) => g.platform === 'gemini') + + console.log(`- Claude 分组: ${claudeGroups.length} 个`) + console.log(`- Gemini 分组: ${geminiGroups.length} 个`) + + // 测试结果总结 + console.log('\n📊 测试结果总结:') + console.log('✅ Claude OAuth 账号已包含 platform 字段') + console.log('✅ Claude Console 账号已包含 platform 字段') + console.log('✅ 账号分组功能正常') + + const totalDedicated = claudeDedicated.length + consoleDedicated.length + const totalGroups = claudeGroups.length + + if (totalDedicated > 0) { + console.log(`\n✅ 共有 ${totalDedicated} 个专属账号应该显示在下拉框中`) + } else { + console.log('\n⚠️ 没有找到专属账号,请在账号管理页面设置账号类型为"专属账户"') + } + + if (totalGroups > 0) { + console.log(`✅ 共有 ${totalGroups} 个分组应该显示在下拉框中`) + } + + console.log('\n💡 请在浏览器中测试创建/编辑 API Key,检查下拉框是否正确显示三个类别:') + console.log(' 1. 调度分组') + console.log(' 2. Claude OAuth 账号') + console.log(' 3. Claude Console 账号') + } catch (error) { + console.error('❌ 测试失败:', error.message) + if (error.response) { + console.error('响应数据:', error.response.data) + } + } finally { + process.exit(0) + } +} + +testAccountDisplay() diff --git a/scripts/test-api-response.js b/scripts/test-api-response.js new file mode 100644 index 0000000000000000000000000000000000000000..8131e0f9662a2a88a780dfd4b473a4f294c10f29 --- /dev/null +++ b/scripts/test-api-response.js @@ -0,0 +1,141 @@ +/** + * 测试 API 响应中的账号数据 + */ + +const redis = require('../src/models/redis') +const claudeAccountService = require('../src/services/claudeAccountService') +const claudeConsoleAccountService = require('../src/services/claudeConsoleAccountService') +const accountGroupService = require('../src/services/accountGroupService') + +async function testApiResponse() { + console.log('🔍 测试 API 响应数据...\n') + + try { + // 确保 Redis 已连接 + await redis.connect() + + // 1. 测试 Claude OAuth 账号服务 + console.log('📋 测试 Claude OAuth 账号服务...') + const claudeAccounts = await claudeAccountService.getAllAccounts() + console.log(`找到 ${claudeAccounts.length} 个 Claude OAuth 账号`) + + // 检查前3个账号的数据结构 + console.log('\n账号数据结构示例:') + claudeAccounts.slice(0, 3).forEach((acc) => { + console.log(`\n账号: ${acc.name}`) + console.log(` - ID: ${acc.id}`) + console.log(` - accountType: ${acc.accountType}`) + console.log(` - platform: ${acc.platform}`) + console.log(` - status: ${acc.status}`) + console.log(` - isActive: ${acc.isActive}`) + }) + + // 统计专属账号 + const claudeDedicated = claudeAccounts.filter((a) => a.accountType === 'dedicated') + const claudeGroup = claudeAccounts.filter((a) => a.accountType === 'group') + + console.log('\n统计结果:') + console.log(` - 专属账号: ${claudeDedicated.length} 个`) + console.log(` - 分组账号: ${claudeGroup.length} 个`) + + // 2. 测试 Claude Console 账号服务 + console.log('\n\n📋 测试 Claude Console 账号服务...') + const consoleAccounts = await claudeConsoleAccountService.getAllAccounts() + console.log(`找到 ${consoleAccounts.length} 个 Claude Console 账号`) + + // 检查前3个账号的数据结构 + console.log('\n账号数据结构示例:') + consoleAccounts.slice(0, 3).forEach((acc) => { + console.log(`\n账号: ${acc.name}`) + console.log(` - ID: ${acc.id}`) + console.log(` - accountType: ${acc.accountType}`) + console.log(` - platform: ${acc.platform}`) + console.log(` - status: ${acc.status}`) + console.log(` - isActive: ${acc.isActive}`) + }) + + // 统计专属账号 + const consoleDedicated = consoleAccounts.filter((a) => a.accountType === 'dedicated') + const consoleGroup = consoleAccounts.filter((a) => a.accountType === 'group') + + console.log('\n统计结果:') + console.log(` - 专属账号: ${consoleDedicated.length} 个`) + console.log(` - 分组账号: ${consoleGroup.length} 个`) + + // 3. 测试账号分组服务 + console.log('\n\n📋 测试账号分组服务...') + const groups = await accountGroupService.getAllGroups() + console.log(`找到 ${groups.length} 个账号分组`) + + // 显示分组信息 + groups.forEach((group) => { + console.log(`\n分组: ${group.name}`) + console.log(` - ID: ${group.id}`) + console.log(` - platform: ${group.platform}`) + console.log(` - memberCount: ${group.memberCount}`) + }) + + // 4. 验证结果 + console.log('\n\n📊 验证结果:') + + // 检查 platform 字段 + const claudeWithPlatform = claudeAccounts.filter((a) => a.platform === 'claude') + const consoleWithPlatform = consoleAccounts.filter((a) => a.platform === 'claude-console') + + if (claudeWithPlatform.length === claudeAccounts.length) { + console.log('✅ 所有 Claude OAuth 账号都有正确的 platform 字段') + } else { + console.log( + `⚠️ 只有 ${claudeWithPlatform.length}/${claudeAccounts.length} 个 Claude OAuth 账号有正确的 platform 字段` + ) + } + + if (consoleWithPlatform.length === consoleAccounts.length) { + console.log('✅ 所有 Claude Console 账号都有正确的 platform 字段') + } else { + console.log( + `⚠️ 只有 ${consoleWithPlatform.length}/${consoleAccounts.length} 个 Claude Console 账号有正确的 platform 字段` + ) + } + + // 总结 + const totalDedicated = claudeDedicated.length + consoleDedicated.length + const totalGroup = claudeGroup.length + consoleGroup.length + const totalGroups = groups.filter((g) => g.platform === 'claude').length + + console.log('\n📈 总结:') + console.log( + `- 专属账号总数: ${totalDedicated} 个 (Claude OAuth: ${claudeDedicated.length}, Console: ${consoleDedicated.length})` + ) + console.log( + `- 分组账号总数: ${totalGroup} 个 (Claude OAuth: ${claudeGroup.length}, Console: ${consoleGroup.length})` + ) + console.log(`- 账号分组总数: ${totalGroups} 个`) + + if (totalDedicated + totalGroups > 0) { + console.log('\n✅ 前端下拉框应该能够显示:') + if (totalGroups > 0) { + console.log(' - 调度分组') + } + if (claudeDedicated.length > 0) { + console.log(' - Claude OAuth 专属账号 (仅 dedicated 类型)') + } + if (consoleDedicated.length > 0) { + console.log(' - Claude Console 专属账号 (仅 dedicated 类型)') + } + } else { + console.log('\n⚠️ 没有找到任何专属账号或分组,请检查账号配置') + } + + console.log('\n💡 说明:') + console.log('- 专属账号下拉框只显示 accountType="dedicated" 的账号') + console.log('- accountType="group" 的账号通过分组调度,不在专属账号中显示') + } catch (error) { + console.error('❌ 测试失败:', error) + console.error(error.stack) + } finally { + process.exit(0) + } +} + +testApiResponse() diff --git a/scripts/test-bedrock-models.js b/scripts/test-bedrock-models.js new file mode 100644 index 0000000000000000000000000000000000000000..e0d62effe4e05eba80622cc0a6f4e51a62ff111a --- /dev/null +++ b/scripts/test-bedrock-models.js @@ -0,0 +1,33 @@ +#!/usr/bin/env node + +const bedrockRelayService = require('../src/services/bedrockRelayService') + +async function testBedrockModels() { + try { + console.log('🧪 测试Bedrock模型配置...') + + // 测试可用模型列表 + const models = await bedrockRelayService.getAvailableModels() + console.log(`📋 找到 ${models.length} 个可用模型:`) + models.forEach((model) => { + console.log(` - ${model.id} (${model.name})`) + }) + + // 测试默认模型 + console.log(`\n🎯 系统默认模型: ${bedrockRelayService.defaultModel}`) + console.log(`🎯 系统默认小模型: ${bedrockRelayService.defaultSmallModel}`) + + console.log('\n✅ Bedrock模型配置测试完成') + process.exit(0) + } catch (error) { + console.error('❌ Bedrock模型测试失败:', error) + process.exit(1) + } +} + +// 如果直接运行此脚本 +if (require.main === module) { + testBedrockModels() +} + +module.exports = { testBedrockModels } diff --git a/scripts/test-dedicated-accounts.js b/scripts/test-dedicated-accounts.js new file mode 100644 index 0000000000000000000000000000000000000000..7b22202298152f6c5c0cc45a70d6f098531bb5d3 --- /dev/null +++ b/scripts/test-dedicated-accounts.js @@ -0,0 +1,133 @@ +/** + * 测试专属账号显示问题 + */ + +const redis = require('../src/models/redis') + +async function testDedicatedAccounts() { + console.log('🔍 检查专属账号...\n') + + try { + // 确保 Redis 已连接 + await redis.connect() + + // 获取所有 Claude 账号 + const claudeKeys = await redis.client.keys('claude:account:*') + console.log(`找到 ${claudeKeys.length} 个 Claude 账号\n`) + + const dedicatedAccounts = [] + const groupAccounts = [] + const sharedAccounts = [] + + for (const key of claudeKeys) { + const account = await redis.client.hgetall(key) + const accountType = account.accountType || 'shared' + + const accountInfo = { + id: account.id, + name: account.name, + accountType, + status: account.status, + isActive: account.isActive, + createdAt: account.createdAt + } + + if (accountType === 'dedicated') { + dedicatedAccounts.push(accountInfo) + } else if (accountType === 'group') { + groupAccounts.push(accountInfo) + } else { + sharedAccounts.push(accountInfo) + } + } + + console.log('📊 账号统计:') + console.log(`- 专属账号: ${dedicatedAccounts.length} 个`) + console.log(`- 分组账号: ${groupAccounts.length} 个`) + console.log(`- 共享账号: ${sharedAccounts.length} 个`) + console.log('') + + if (dedicatedAccounts.length > 0) { + console.log('✅ 专属账号列表:') + dedicatedAccounts.forEach((acc) => { + console.log(` - ${acc.name} (ID: ${acc.id}, 状态: ${acc.status})`) + }) + console.log('') + } else { + console.log('⚠️ 没有找到专属账号!') + console.log('💡 提示: 请确保在账号管理页面将账号类型设置为"专属账户"') + console.log('') + } + + if (groupAccounts.length > 0) { + console.log('📁 分组账号列表:') + groupAccounts.forEach((acc) => { + console.log(` - ${acc.name} (ID: ${acc.id}, 状态: ${acc.status})`) + }) + console.log('') + } + + // 检查分组 + const groupKeys = await redis.client.keys('account_group:*') + console.log(`\n找到 ${groupKeys.length} 个账号分组`) + + if (groupKeys.length > 0) { + console.log('📋 分组列表:') + for (const key of groupKeys) { + const group = await redis.client.hgetall(key) + console.log( + ` - ${group.name} (平台: ${group.platform}, 成员数: ${group.memberCount || 0})` + ) + } + } + + // 检查 Claude Console 账号 + const consoleKeys = await redis.client.keys('claude_console_account:*') + console.log(`\n找到 ${consoleKeys.length} 个 Claude Console 账号`) + + const dedicatedConsoleAccounts = [] + const groupConsoleAccounts = [] + + for (const key of consoleKeys) { + const account = await redis.client.hgetall(key) + const accountType = account.accountType || 'shared' + + if (accountType === 'dedicated') { + dedicatedConsoleAccounts.push({ + id: account.id, + name: account.name, + accountType, + status: account.status + }) + } else if (accountType === 'group') { + groupConsoleAccounts.push({ + id: account.id, + name: account.name, + accountType, + status: account.status + }) + } + } + + if (dedicatedConsoleAccounts.length > 0) { + console.log('\n✅ Claude Console 专属账号:') + dedicatedConsoleAccounts.forEach((acc) => { + console.log(` - ${acc.name} (ID: ${acc.id}, 状态: ${acc.status})`) + }) + } + + if (groupConsoleAccounts.length > 0) { + console.log('\n📁 Claude Console 分组账号:') + groupConsoleAccounts.forEach((acc) => { + console.log(` - ${acc.name} (ID: ${acc.id}, 状态: ${acc.status})`) + }) + } + } catch (error) { + console.error('❌ 错误:', error) + console.error(error.stack) + } finally { + process.exit(0) + } +} + +testDedicatedAccounts() diff --git a/scripts/test-gemini-refresh.js b/scripts/test-gemini-refresh.js new file mode 100644 index 0000000000000000000000000000000000000000..6de3ee789e12e682574e896f5f1dc3c2d2f74915 --- /dev/null +++ b/scripts/test-gemini-refresh.js @@ -0,0 +1,145 @@ +#!/usr/bin/env node + +/** + * 测试 Gemini token 刷新功能 + */ + +const path = require('path') +const dotenv = require('dotenv') + +// 加载环境变量 +dotenv.config({ path: path.join(__dirname, '..', '.env') }) + +const redis = require('../src/models/redis') +const geminiAccountService = require('../src/services/geminiAccountService') +const crypto = require('crypto') +const config = require('../config/config') + +// 加密相关常量(与 geminiAccountService 保持一致) +const ALGORITHM = 'aes-256-cbc' +const ENCRYPTION_SALT = 'gemini-account-salt' // 注意:是 'gemini-account-salt' 不是其他值! + +// 生成加密密钥 +function generateEncryptionKey() { + return crypto.scryptSync(config.security.encryptionKey, ENCRYPTION_SALT, 32) +} + +// 解密函数(用于调试) +function debugDecrypt(text) { + if (!text) { + return { success: false, error: 'Empty text' } + } + try { + const key = generateEncryptionKey() + const ivHex = text.substring(0, 32) + const encryptedHex = text.substring(33) + + const iv = Buffer.from(ivHex, 'hex') + const encryptedText = Buffer.from(encryptedHex, 'hex') + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv) + let decrypted = decipher.update(encryptedText) + decrypted = Buffer.concat([decrypted, decipher.final()]) + return { success: true, value: decrypted.toString() } + } catch (error) { + return { success: false, error: error.message } + } +} + +async function testGeminiTokenRefresh() { + try { + console.log('🚀 开始测试 Gemini token 刷新功能...\n') + + // 显示配置信息 + console.log('📋 配置信息:') + console.log(` 加密密钥: ${config.security.encryptionKey}`) + console.log(` 加密盐值: ${ENCRYPTION_SALT}`) + console.log() + + // 1. 连接 Redis + console.log('📡 连接 Redis...') + await redis.connect() + console.log('✅ Redis 连接成功\n') + + // 2. 获取所有 Gemini 账户 + console.log('🔍 获取 Gemini 账户列表...') + const accounts = await geminiAccountService.getAllAccounts() + const geminiAccounts = accounts.filter((acc) => acc.platform === 'gemini') + + if (geminiAccounts.length === 0) { + console.log('❌ 没有找到 Gemini 账户') + process.exit(1) + } + + console.log(`✅ 找到 ${geminiAccounts.length} 个 Gemini 账户\n`) + + // 3. 测试每个账户的 token 刷新 + for (const account of geminiAccounts) { + console.log(`\n📋 测试账户: ${account.name} (${account.id})`) + console.log(` 状态: ${account.status}`) + + try { + // 获取原始账户数据(用于调试) + const client = redis.getClient() + const rawData = await client.hgetall(`gemini_account:${account.id}`) + + console.log(' 📊 原始数据检查:') + console.log(` refreshToken 存在: ${rawData.refreshToken ? '是' : '否'}`) + if (rawData.refreshToken) { + console.log(` refreshToken 长度: ${rawData.refreshToken.length}`) + console.log(` refreshToken 前50字符: ${rawData.refreshToken.substring(0, 50)}...`) + + // 尝试手动解密 + const decryptResult = debugDecrypt(rawData.refreshToken) + if (decryptResult.success) { + console.log(' ✅ 手动解密成功') + console.log(` 解密后前20字符: ${decryptResult.value.substring(0, 20)}...`) + } else { + console.log(` ❌ 手动解密失败: ${decryptResult.error}`) + } + } + + // 获取完整账户信息(包括解密的 token) + const fullAccount = await geminiAccountService.getAccount(account.id) + + if (!fullAccount.refreshToken) { + console.log(' ⚠️ 跳过:该账户无 refresh token\n') + continue + } + + console.log(' ✅ 找到 refresh token') + console.log( + ` 📝 解密后的 refresh token 前20字符: ${fullAccount.refreshToken.substring(0, 20)}...` + ) + + console.log(' 🔄 开始刷新 token...') + const startTime = Date.now() + + // 执行 token 刷新 + const newTokens = await geminiAccountService.refreshAccountToken(account.id) + + const duration = Date.now() - startTime + console.log(` ✅ Token 刷新成功!耗时: ${duration}ms`) + console.log(` 📅 新的过期时间: ${new Date(newTokens.expiry_date).toLocaleString()}`) + console.log(` 🔑 Access Token: ${newTokens.access_token.substring(0, 20)}...`) + + // 验证账户状态已更新 + const updatedAccount = await geminiAccountService.getAccount(account.id) + console.log(` 📊 账户状态: ${updatedAccount.status}`) + } catch (error) { + console.log(` ❌ Token 刷新失败: ${error.message}`) + console.log(' 🔍 错误详情:', error) + } + } + + console.log('\n✅ 测试完成!') + } catch (error) { + console.error('❌ 测试失败:', error) + } finally { + // 断开 Redis 连接 + await redis.disconnect() + process.exit(0) + } +} + +// 运行测试 +testGeminiTokenRefresh() diff --git a/scripts/test-group-scheduling.js b/scripts/test-group-scheduling.js new file mode 100644 index 0000000000000000000000000000000000000000..e22a20e1c8f83cef02bef198288645c2283b781f --- /dev/null +++ b/scripts/test-group-scheduling.js @@ -0,0 +1,549 @@ +/** + * 分组调度功能测试脚本 + * 用于测试账户分组管理和调度逻辑的正确性 + */ + +require('dotenv').config() +const { v4: uuidv4 } = require('uuid') +const redis = require('../src/models/redis') +const accountGroupService = require('../src/services/accountGroupService') +const claudeAccountService = require('../src/services/claudeAccountService') +const claudeConsoleAccountService = require('../src/services/claudeConsoleAccountService') +const apiKeyService = require('../src/services/apiKeyService') +const unifiedClaudeScheduler = require('../src/services/unifiedClaudeScheduler') + +// 测试配置 +const TEST_PREFIX = 'test_group_' +const CLEANUP_ON_FINISH = true // 测试完成后是否清理数据 + +// 测试数据存储 +const testData = { + groups: [], + accounts: [], + apiKeys: [] +} + +// 颜色输出 +const colors = { + green: '\x1b[32m', + red: '\x1b[31m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + reset: '\x1b[0m' +} + +function log(message, type = 'info') { + const color = + { + success: colors.green, + error: colors.red, + warning: colors.yellow, + info: colors.blue + }[type] || colors.reset + + console.log(`${color}${message}${colors.reset}`) +} + +async function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +// 清理测试数据 +async function cleanup() { + log('\n🧹 清理测试数据...', 'info') + + // 删除测试API Keys + for (const apiKey of testData.apiKeys) { + try { + await apiKeyService.deleteApiKey(apiKey.id) + log(`✅ 删除测试API Key: ${apiKey.name}`, 'success') + } catch (error) { + log(`❌ 删除API Key失败: ${error.message}`, 'error') + } + } + + // 删除测试账户 + for (const account of testData.accounts) { + try { + if (account.type === 'claude') { + await claudeAccountService.deleteAccount(account.id) + } else if (account.type === 'claude-console') { + await claudeConsoleAccountService.deleteAccount(account.id) + } + log(`✅ 删除测试账户: ${account.name}`, 'success') + } catch (error) { + log(`❌ 删除账户失败: ${error.message}`, 'error') + } + } + + // 删除测试分组 + for (const group of testData.groups) { + try { + await accountGroupService.deleteGroup(group.id) + log(`✅ 删除测试分组: ${group.name}`, 'success') + } catch (error) { + // 可能因为还有成员而删除失败,先移除所有成员 + if (error.message.includes('分组内还有账户')) { + const members = await accountGroupService.getGroupMembers(group.id) + for (const memberId of members) { + await accountGroupService.removeAccountFromGroup(memberId, group.id) + } + // 重试删除 + await accountGroupService.deleteGroup(group.id) + log(`✅ 删除测试分组: ${group.name} (清空成员后)`, 'success') + } else { + log(`❌ 删除分组失败: ${error.message}`, 'error') + } + } + } +} + +// 测试1: 创建分组 +async function test1_createGroups() { + log('\n📝 测试1: 创建账户分组', 'info') + + try { + // 创建Claude分组 + const claudeGroup = await accountGroupService.createGroup({ + name: `${TEST_PREFIX}Claude组`, + platform: 'claude', + description: '测试用Claude账户分组' + }) + testData.groups.push(claudeGroup) + log(`✅ 创建Claude分组成功: ${claudeGroup.name} (ID: ${claudeGroup.id})`, 'success') + + // 创建Gemini分组 + const geminiGroup = await accountGroupService.createGroup({ + name: `${TEST_PREFIX}Gemini组`, + platform: 'gemini', + description: '测试用Gemini账户分组' + }) + testData.groups.push(geminiGroup) + log(`✅ 创建Gemini分组成功: ${geminiGroup.name} (ID: ${geminiGroup.id})`, 'success') + + // 验证分组信息 + const allGroups = await accountGroupService.getAllGroups() + const testGroups = allGroups.filter((g) => g.name.startsWith(TEST_PREFIX)) + + if (testGroups.length === 2) { + log(`✅ 分组创建验证通过,共创建 ${testGroups.length} 个测试分组`, 'success') + } else { + throw new Error(`分组数量不正确,期望2个,实际${testGroups.length}个`) + } + } catch (error) { + log(`❌ 测试1失败: ${error.message}`, 'error') + throw error + } +} + +// 测试2: 创建账户并添加到分组 +async function test2_createAccountsAndAddToGroup() { + log('\n📝 测试2: 创建账户并添加到分组', 'info') + + try { + const claudeGroup = testData.groups.find((g) => g.platform === 'claude') + + // 创建Claude OAuth账户 + const claudeAccount1 = await claudeAccountService.createAccount({ + name: `${TEST_PREFIX}Claude账户1`, + email: 'test1@example.com', + refreshToken: 'test_refresh_token_1', + accountType: 'group' + }) + testData.accounts.push({ ...claudeAccount1, type: 'claude' }) + log(`✅ 创建Claude OAuth账户1成功: ${claudeAccount1.name}`, 'success') + + const claudeAccount2 = await claudeAccountService.createAccount({ + name: `${TEST_PREFIX}Claude账户2`, + email: 'test2@example.com', + refreshToken: 'test_refresh_token_2', + accountType: 'group' + }) + testData.accounts.push({ ...claudeAccount2, type: 'claude' }) + log(`✅ 创建Claude OAuth账户2成功: ${claudeAccount2.name}`, 'success') + + // 创建Claude Console账户 + const consoleAccount = await claudeConsoleAccountService.createAccount({ + name: `${TEST_PREFIX}Console账户`, + apiUrl: 'https://api.example.com', + apiKey: 'test_api_key', + accountType: 'group' + }) + testData.accounts.push({ ...consoleAccount, type: 'claude-console' }) + log(`✅ 创建Claude Console账户成功: ${consoleAccount.name}`, 'success') + + // 添加账户到分组 + await accountGroupService.addAccountToGroup(claudeAccount1.id, claudeGroup.id, 'claude') + log('✅ 添加账户1到分组成功', 'success') + + await accountGroupService.addAccountToGroup(claudeAccount2.id, claudeGroup.id, 'claude') + log('✅ 添加账户2到分组成功', 'success') + + await accountGroupService.addAccountToGroup(consoleAccount.id, claudeGroup.id, 'claude') + log('✅ 添加Console账户到分组成功', 'success') + + // 验证分组成员 + const members = await accountGroupService.getGroupMembers(claudeGroup.id) + if (members.length === 3) { + log(`✅ 分组成员验证通过,共有 ${members.length} 个成员`, 'success') + } else { + throw new Error(`分组成员数量不正确,期望3个,实际${members.length}个`) + } + } catch (error) { + log(`❌ 测试2失败: ${error.message}`, 'error') + throw error + } +} + +// 测试3: 平台一致性验证 +async function test3_platformConsistency() { + log('\n📝 测试3: 平台一致性验证', 'info') + + try { + const geminiGroup = testData.groups.find((g) => g.platform === 'gemini') + + // 尝试将Claude账户添加到Gemini分组(应该失败) + const claudeAccount = testData.accounts.find((a) => a.type === 'claude') + + try { + await accountGroupService.addAccountToGroup(claudeAccount.id, geminiGroup.id, 'claude') + throw new Error('平台验证失败:Claude账户不应该能添加到Gemini分组') + } catch (error) { + if (error.message.includes('平台与分组平台不匹配')) { + log(`✅ 平台一致性验证通过:${error.message}`, 'success') + } else { + throw error + } + } + } catch (error) { + log(`❌ 测试3失败: ${error.message}`, 'error') + throw error + } +} + +// 测试4: API Key绑定分组 +async function test4_apiKeyBindGroup() { + log('\n📝 测试4: API Key绑定分组', 'info') + + try { + const claudeGroup = testData.groups.find((g) => g.platform === 'claude') + + // 创建绑定到分组的API Key + const apiKey = await apiKeyService.generateApiKey({ + name: `${TEST_PREFIX}API Key`, + description: '测试分组调度的API Key', + claudeAccountId: `group:${claudeGroup.id}`, + permissions: 'claude' + }) + testData.apiKeys.push(apiKey) + log(`✅ 创建API Key成功: ${apiKey.name} (绑定到分组: ${claudeGroup.name})`, 'success') + + // 验证API Key信息 + const keyInfo = await redis.getApiKey(apiKey.id) + if (keyInfo && keyInfo.claudeAccountId === `group:${claudeGroup.id}`) { + log('✅ API Key分组绑定验证通过', 'success') + } else { + throw new Error('API Key分组绑定信息不正确') + } + } catch (error) { + log(`❌ 测试4失败: ${error.message}`, 'error') + throw error + } +} + +// 测试5: 分组调度负载均衡 +async function test5_groupSchedulingLoadBalance() { + log('\n📝 测试5: 分组调度负载均衡', 'info') + + try { + const apiKey = testData.apiKeys[0] + + // 记录每个账户被选中的次数 + const selectionCount = {} + const totalSelections = 30 + + for (let i = 0; i < totalSelections; i++) { + // 模拟不同的会话 + const sessionHash = uuidv4() + + const result = await unifiedClaudeScheduler.selectAccountForApiKey( + { + id: apiKey.id, + claudeAccountId: apiKey.claudeAccountId, + name: apiKey.name + }, + sessionHash + ) + + if (!selectionCount[result.accountId]) { + selectionCount[result.accountId] = 0 + } + selectionCount[result.accountId]++ + + // 短暂延迟,模拟真实请求间隔 + await sleep(50) + } + + // 分析选择分布 + log(`\n📊 负载均衡分布统计 (共${totalSelections}次选择):`, 'info') + const accounts = Object.keys(selectionCount) + + for (const accountId of accounts) { + const count = selectionCount[accountId] + const percentage = ((count / totalSelections) * 100).toFixed(1) + const accountInfo = testData.accounts.find((a) => a.id === accountId) + log(` ${accountInfo.name}: ${count}次 (${percentage}%)`, 'info') + } + + // 验证是否实现了负载均衡 + const counts = Object.values(selectionCount) + const avgCount = totalSelections / accounts.length + const variance = + counts.reduce((sum, count) => sum + Math.pow(count - avgCount, 2), 0) / counts.length + const stdDev = Math.sqrt(variance) + + log(`\n 平均选择次数: ${avgCount.toFixed(1)}`, 'info') + log(` 标准差: ${stdDev.toFixed(1)}`, 'info') + + // 如果标准差小于平均值的50%,认为负载均衡效果良好 + if (stdDev < avgCount * 0.5) { + log('✅ 负载均衡验证通过,分布相对均匀', 'success') + } else { + log('⚠️ 负载分布不够均匀,但这可能是正常的随机波动', 'warning') + } + } catch (error) { + log(`❌ 测试5失败: ${error.message}`, 'error') + throw error + } +} + +// 测试6: 会话粘性测试 +async function test6_stickySession() { + log('\n📝 测试6: 会话粘性(Sticky Session)测试', 'info') + + try { + const apiKey = testData.apiKeys[0] + const sessionHash = `test_session_${uuidv4()}` + + // 第一次选择 + const firstSelection = await unifiedClaudeScheduler.selectAccountForApiKey( + { + id: apiKey.id, + claudeAccountId: apiKey.claudeAccountId, + name: apiKey.name + }, + sessionHash + ) + + log(` 首次选择账户: ${firstSelection.accountId}`, 'info') + + // 使用相同的sessionHash多次请求 + let consistentCount = 0 + const testCount = 10 + + for (let i = 0; i < testCount; i++) { + const selection = await unifiedClaudeScheduler.selectAccountForApiKey( + { + id: apiKey.id, + claudeAccountId: apiKey.claudeAccountId, + name: apiKey.name + }, + sessionHash + ) + + if (selection.accountId === firstSelection.accountId) { + consistentCount++ + } + + await sleep(100) + } + + log(` 会话一致性: ${consistentCount}/${testCount} 次选择了相同账户`, 'info') + + if (consistentCount === testCount) { + log('✅ 会话粘性验证通过,同一会话始终选择相同账户', 'success') + } else { + throw new Error(`会话粘性失败,只有${consistentCount}/${testCount}次选择了相同账户`) + } + } catch (error) { + log(`❌ 测试6失败: ${error.message}`, 'error') + throw error + } +} + +// 测试7: 账户可用性检查 +async function test7_accountAvailability() { + log('\n📝 测试7: 账户可用性检查', 'info') + + try { + const apiKey = testData.apiKeys[0] + const accounts = testData.accounts.filter( + (a) => a.type === 'claude' || a.type === 'claude-console' + ) + + // 禁用第一个账户 + const firstAccount = accounts[0] + if (firstAccount.type === 'claude') { + await claudeAccountService.updateAccount(firstAccount.id, { isActive: false }) + } else { + await claudeConsoleAccountService.updateAccount(firstAccount.id, { isActive: false }) + } + log(` 已禁用账户: ${firstAccount.name}`, 'info') + + // 多次选择,验证不会选择到禁用的账户 + const selectionResults = [] + for (let i = 0; i < 20; i++) { + const sessionHash = uuidv4() // 每次使用新会话 + const result = await unifiedClaudeScheduler.selectAccountForApiKey( + { + id: apiKey.id, + claudeAccountId: apiKey.claudeAccountId, + name: apiKey.name + }, + sessionHash + ) + + selectionResults.push(result.accountId) + } + + // 检查是否选择了禁用的账户 + const selectedDisabled = selectionResults.includes(firstAccount.id) + + if (!selectedDisabled) { + log('✅ 账户可用性验证通过,未选择禁用的账户', 'success') + } else { + throw new Error('错误:选择了已禁用的账户') + } + + // 重新启用账户 + if (firstAccount.type === 'claude') { + await claudeAccountService.updateAccount(firstAccount.id, { isActive: true }) + } else { + await claudeConsoleAccountService.updateAccount(firstAccount.id, { isActive: true }) + } + } catch (error) { + log(`❌ 测试7失败: ${error.message}`, 'error') + throw error + } +} + +// 测试8: 分组成员管理 +async function test8_groupMemberManagement() { + log('\n📝 测试8: 分组成员管理', 'info') + + try { + const claudeGroup = testData.groups.find((g) => g.platform === 'claude') + const account = testData.accounts.find((a) => a.type === 'claude') + + // 获取账户所属分组 + const accountGroups = await accountGroupService.getAccountGroup(account.id) + const hasTargetGroup = accountGroups.some((group) => group.id === claudeGroup.id) + if (hasTargetGroup) { + log('✅ 账户分组查询验证通过', 'success') + } else { + throw new Error('账户分组查询结果不正确') + } + + // 从分组移除账户 + await accountGroupService.removeAccountFromGroup(account.id, claudeGroup.id) + log(` 从分组移除账户: ${account.name}`, 'info') + + // 验证账户已不在分组中 + const membersAfterRemove = await accountGroupService.getGroupMembers(claudeGroup.id) + if (!membersAfterRemove.includes(account.id)) { + log('✅ 账户移除验证通过', 'success') + } else { + throw new Error('账户移除失败') + } + + // 重新添加账户 + await accountGroupService.addAccountToGroup(account.id, claudeGroup.id, 'claude') + log(' 重新添加账户到分组', 'info') + } catch (error) { + log(`❌ 测试8失败: ${error.message}`, 'error') + throw error + } +} + +// 测试9: 空分组处理 +async function test9_emptyGroupHandling() { + log('\n📝 测试9: 空分组处理', 'info') + + try { + // 创建一个空分组 + const emptyGroup = await accountGroupService.createGroup({ + name: `${TEST_PREFIX}空分组`, + platform: 'claude', + description: '测试空分组' + }) + testData.groups.push(emptyGroup) + + // 创建绑定到空分组的API Key + const apiKey = await apiKeyService.generateApiKey({ + name: `${TEST_PREFIX}空分组API Key`, + claudeAccountId: `group:${emptyGroup.id}`, + permissions: 'claude' + }) + testData.apiKeys.push(apiKey) + + // 尝试从空分组选择账户(应该失败) + try { + await unifiedClaudeScheduler.selectAccountForApiKey({ + id: apiKey.id, + claudeAccountId: apiKey.claudeAccountId, + name: apiKey.name + }) + throw new Error('空分组选择账户应该失败') + } catch (error) { + if (error.message.includes('has no members')) { + log(`✅ 空分组处理验证通过:${error.message}`, 'success') + } else { + throw error + } + } + } catch (error) { + log(`❌ 测试9失败: ${error.message}`, 'error') + throw error + } +} + +// 主测试函数 +async function runTests() { + log('\n🚀 开始分组调度功能测试\n', 'info') + + try { + // 连接Redis + await redis.connect() + log('✅ Redis连接成功', 'success') + + // 执行测试 + await test1_createGroups() + await test2_createAccountsAndAddToGroup() + await test3_platformConsistency() + await test4_apiKeyBindGroup() + await test5_groupSchedulingLoadBalance() + await test6_stickySession() + await test7_accountAvailability() + await test8_groupMemberManagement() + await test9_emptyGroupHandling() + + log('\n🎉 所有测试通过!分组调度功能工作正常', 'success') + } catch (error) { + log(`\n❌ 测试失败: ${error.message}`, 'error') + console.error(error) + } finally { + // 清理测试数据 + if (CLEANUP_ON_FINISH) { + await cleanup() + } else { + log('\n⚠️ 测试数据未清理,请手动清理', 'warning') + } + + // 关闭Redis连接 + await redis.disconnect() + process.exit(0) + } +} + +// 运行测试 +runTests() diff --git a/scripts/test-model-mapping.js b/scripts/test-model-mapping.js new file mode 100644 index 0000000000000000000000000000000000000000..7719f9d527cb6429bb070863bf33abc717fe1045 --- /dev/null +++ b/scripts/test-model-mapping.js @@ -0,0 +1,47 @@ +#!/usr/bin/env node + +const bedrockRelayService = require('../src/services/bedrockRelayService') + +function testModelMapping() { + console.log('🧪 测试模型映射功能...') + + // 测试用例 + const testCases = [ + // 标准Claude模型名 + 'claude-3-5-haiku-20241022', + 'claude-3-5-sonnet-20241022', + 'claude-3-5-sonnet', + 'claude-3-5-haiku', + 'claude-sonnet-4', + 'claude-opus-4-1', + 'claude-3-7-sonnet', + + // 已经是Bedrock格式的 + 'us.anthropic.claude-sonnet-4-20250514-v1:0', + 'anthropic.claude-3-5-haiku-20241022-v1:0', + + // 未知模型 + 'unknown-model' + ] + + console.log('\n📋 模型映射测试结果:') + testCases.forEach((testModel) => { + const mappedModel = bedrockRelayService._mapToBedrockModel(testModel) + const isChanged = mappedModel !== testModel + const status = isChanged ? '🔄' : '✅' + + console.log(`${status} ${testModel}`) + if (isChanged) { + console.log(` → ${mappedModel}`) + } + }) + + console.log('\n✅ 模型映射测试完成') +} + +// 如果直接运行此脚本 +if (require.main === module) { + testModelMapping() +} + +module.exports = { testModelMapping } diff --git a/scripts/test-pricing-fallback.js b/scripts/test-pricing-fallback.js new file mode 100644 index 0000000000000000000000000000000000000000..039d00e8e94dc6acc5a539b8f821a62034d94eb0 --- /dev/null +++ b/scripts/test-pricing-fallback.js @@ -0,0 +1,91 @@ +#!/usr/bin/env node + +const fs = require('fs') +const path = require('path') + +// 测试定价服务的fallback机制 +async function testPricingFallback() { + console.log('🧪 Testing pricing service fallback mechanism...\n') + + // 备份现有的模型定价文件 + const dataDir = path.join(process.cwd(), 'data') + const pricingFile = path.join(dataDir, 'model_pricing.json') + const backupFile = path.join(dataDir, 'model_pricing.backup.json') + + // 1. 备份现有文件 + if (fs.existsSync(pricingFile)) { + console.log('📦 Backing up existing pricing file...') + fs.copyFileSync(pricingFile, backupFile) + } + + try { + // 2. 删除现有定价文件以触发fallback + if (fs.existsSync(pricingFile)) { + console.log('🗑️ Removing existing pricing file to test fallback...') + fs.unlinkSync(pricingFile) + } + + // 3. 初始化定价服务 + console.log('🚀 Initializing pricing service...\n') + + // 清除require缓存以确保重新加载 + delete require.cache[require.resolve('../src/services/pricingService')] + const pricingService = require('../src/services/pricingService') + + // 模拟网络失败,强制使用fallback + const originalDownload = pricingService._downloadFromRemote + pricingService._downloadFromRemote = function () { + return Promise.reject(new Error('Simulated network failure for testing')) + } + + // 初始化服务 + await pricingService.initialize() + + // 4. 验证fallback是否工作 + console.log('\n📊 Verifying fallback data...') + const status = pricingService.getStatus() + console.log(` - Initialized: ${status.initialized}`) + console.log(` - Model count: ${status.modelCount}`) + console.log(` - Last updated: ${status.lastUpdated}`) + + // 5. 测试获取模型定价 + const testModels = ['claude-3-opus-20240229', 'gpt-4', 'gemini-pro'] + console.log('\n💰 Testing model pricing retrieval:') + + for (const model of testModels) { + const pricing = pricingService.getModelPricing(model) + if (pricing) { + console.log(` ✅ ${model}: Found pricing data`) + } else { + console.log(` ❌ ${model}: No pricing data`) + } + } + + // 6. 验证文件是否被创建 + if (fs.existsSync(pricingFile)) { + console.log('\n✅ Fallback successfully created pricing file in data directory') + const fileStats = fs.statSync(pricingFile) + console.log(` - File size: ${(fileStats.size / 1024).toFixed(2)} KB`) + } else { + console.log('\n❌ Fallback failed to create pricing file') + } + + // 恢复原始下载函数 + pricingService._downloadFromRemote = originalDownload + } finally { + // 7. 恢复备份文件 + if (fs.existsSync(backupFile)) { + console.log('\n📦 Restoring original pricing file...') + fs.copyFileSync(backupFile, pricingFile) + fs.unlinkSync(backupFile) + } + } + + console.log('\n✨ Fallback mechanism test completed!') +} + +// 运行测试 +testPricingFallback().catch((error) => { + console.error('❌ Test failed:', error) + process.exit(1) +}) diff --git a/scripts/test-web-dist.sh b/scripts/test-web-dist.sh new file mode 100644 index 0000000000000000000000000000000000000000..48964fcf98f1dfa853458fc3c12468c122a53b80 --- /dev/null +++ b/scripts/test-web-dist.sh @@ -0,0 +1,227 @@ +#!/bin/bash + +# 测试 web-dist 分支构建和获取流程 +# 用于验证 CI/CD 流程和 manage.sh 的修改 + +# 颜色定义 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;36m' +NC='\033[0m' # No Color + +print_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +print_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +# 测试构建并推送到 web-dist 分支 +test_build_and_push() { + print_info "开始测试构建和推送流程..." + + # 检查是否在项目根目录 + if [ ! -f "package.json" ] || [ ! -d "web/admin-spa" ]; then + print_error "请在项目根目录运行此脚本" + return 1 + fi + + # 构建前端 + print_info "构建前端..." + cd web/admin-spa + + # 检查 node_modules + if [ ! -d "node_modules" ]; then + print_info "安装前端依赖..." + npm install + fi + + # 执行构建 + npm run build + + if [ ! -d "dist" ]; then + print_error "构建失败,dist 目录不存在" + cd ../.. + return 1 + fi + + print_success "前端构建成功" + cd ../.. + + # 创建临时目录保存构建产物 + TEMP_DIR=$(mktemp -d) + print_info "复制构建产物到临时目录: $TEMP_DIR" + cp -r web/admin-spa/dist/* "$TEMP_DIR/" + + # 配置 git + git config user.name "Test User" + git config user.email "test@example.com" + + # 检查 web-dist 分支是否存在 + print_info "检查 web-dist 分支..." + if git ls-remote --heads origin web-dist | grep -q web-dist; then + print_info "web-dist 分支已存在,获取最新版本" + git fetch origin web-dist:web-dist + git checkout web-dist + else + print_info "创建新的 web-dist 分支" + git checkout --orphan web-dist + fi + + # 清空当前目录(保留 .git) + git rm -rf . 2>/dev/null || true + + # 复制构建产物 + cp -r "$TEMP_DIR"/* . + + # 添加 README + cat > README.md << EOF +# Claude Relay Service - Web Frontend Build + +This branch contains the pre-built frontend assets for Claude Relay Service. + +**DO NOT EDIT FILES IN THIS BRANCH DIRECTLY** + +These files are automatically generated by the CI/CD pipeline. + +Test Build Date: $(date -u +"%Y-%m-%d %H:%M:%S UTC") +EOF + + # 提交 + git add -A + git commit -m "test: frontend build test $(date +%Y%m%d%H%M%S)" + + print_success "本地 web-dist 分支创建成功" + print_warning "注意:这只是本地测试,没有推送到远程仓库" + print_info "如需推送,请运行: git push origin web-dist --force" + + # 切换回主分支 + git checkout main + + # 清理临时目录 + rm -rf "$TEMP_DIR" + + print_success "测试完成" +} + +# 测试从 web-dist 分支获取文件 +test_fetch_from_web_dist() { + print_info "测试从 web-dist 分支获取文件..." + + # 创建测试目录 + TEST_DIR="test-web-dist-fetch" + rm -rf "$TEST_DIR" + mkdir -p "$TEST_DIR" + + # 检查远程 web-dist 分支 + if ! git ls-remote --heads origin web-dist | grep -q web-dist; then + print_warning "远程 web-dist 分支不存在" + print_info "尝试使用本地 web-dist 分支..." + + # 检查本地分支 + if ! git branch | grep -q web-dist; then + print_error "本地和远程都没有 web-dist 分支" + rm -rf "$TEST_DIR" + return 1 + fi + fi + + print_info "克隆 web-dist 分支到测试目录..." + + # 创建临时目录用于 clone + TEMP_CLONE_DIR=$(mktemp -d) + + # 获取仓库 URL + REPO_URL=$(git config --get remote.origin.url) + + # 克隆 web-dist 分支 + if git clone --depth 1 --branch web-dist --single-branch "$REPO_URL" "$TEMP_CLONE_DIR" 2>/dev/null; then + print_success "成功克隆 web-dist 分支" + + # 复制文件(排除 .git 和 README.md) + if command -v rsync >/dev/null 2>&1; then + rsync -av --exclude='.git' --exclude='README.md' "$TEMP_CLONE_DIR/" "$TEST_DIR/" + else + cp -r "$TEMP_CLONE_DIR"/* "$TEST_DIR/" 2>/dev/null + rm -rf "$TEST_DIR/.git" 2>/dev/null + rm -f "$TEST_DIR/README.md" 2>/dev/null + fi + + print_success "文件复制成功" + print_info "测试目录内容:" + ls -la "$TEST_DIR" | head -10 + + # 验证关键文件 + if [ -f "$TEST_DIR/index.html" ]; then + print_success "✓ index.html 文件存在" + else + print_error "✗ index.html 文件不存在" + fi + + if [ -d "$TEST_DIR/assets" ]; then + print_success "✓ assets 目录存在" + else + print_error "✗ assets 目录不存在" + fi + + else + print_error "克隆 web-dist 分支失败" + print_info "可能需要先运行: test_build_and_push" + fi + + # 清理 + rm -rf "$TEMP_CLONE_DIR" + rm -rf "$TEST_DIR" + + print_success "获取测试完成" +} + +# 显示帮助 +show_help() { + echo "用法: $0 [命令]" + echo "" + echo "命令:" + echo " build - 测试构建并创建本地 web-dist 分支" + echo " fetch - 测试从 web-dist 分支获取文件" + echo " all - 运行所有测试" + echo " help - 显示帮助" + echo "" +} + +# 主函数 +main() { + case "$1" in + build) + test_build_and_push + ;; + fetch) + test_fetch_from_web_dist + ;; + all) + test_build_and_push + echo "" + test_fetch_from_web_dist + ;; + help) + show_help + ;; + *) + print_error "未知命令: $1" + echo "" + show_help + ;; + esac +} + +# 运行主函数 +main "$@" \ No newline at end of file diff --git a/scripts/test-window-remaining.js b/scripts/test-window-remaining.js new file mode 100644 index 0000000000000000000000000000000000000000..79551eea9c65c02516cca155c6e213f3b65310a9 --- /dev/null +++ b/scripts/test-window-remaining.js @@ -0,0 +1,78 @@ +const axios = require('axios') + +const BASE_URL = 'http://localhost:3312' + +// 你需要替换为一个有效的 API Key +const API_KEY = 'cr_your_api_key_here' + +async function testWindowRemaining() { + try { + console.log('🔍 测试时间窗口剩余时间功能...\n') + + // 第一步:获取 API Key ID + console.log('1. 获取 API Key ID...') + const idResponse = await axios.post(`${BASE_URL}/api-stats/api/get-key-id`, { + apiKey: API_KEY + }) + + if (!idResponse.data.success) { + throw new Error('Failed to get API Key ID') + } + + const apiId = idResponse.data.data.id + console.log(` ✅ API Key ID: ${apiId}\n`) + + // 第二步:查询统计数据 + console.log('2. 查询统计数据(包含时间窗口信息)...') + const statsResponse = await axios.post(`${BASE_URL}/api-stats/api/user-stats`, { + apiId + }) + + if (!statsResponse.data.success) { + throw new Error('Failed to get stats data') + } + + const stats = statsResponse.data.data + console.log(` ✅ 成功获取统计数据\n`) + + // 第三步:检查时间窗口信息 + console.log('3. 时间窗口信息:') + console.log(` - 窗口时长: ${stats.limits.rateLimitWindow} 分钟`) + console.log(` - 请求限制: ${stats.limits.rateLimitRequests || '无限制'}`) + console.log(` - Token限制: ${stats.limits.tokenLimit || '无限制'}`) + console.log(` - 当前请求数: ${stats.limits.currentWindowRequests}`) + console.log(` - 当前Token数: ${stats.limits.currentWindowTokens}`) + + if (stats.limits.windowStartTime) { + const startTime = new Date(stats.limits.windowStartTime) + const endTime = new Date(stats.limits.windowEndTime) + + console.log(`\n ⏰ 时间窗口状态:`) + console.log(` - 窗口开始时间: ${startTime.toLocaleString()}`) + console.log(` - 窗口结束时间: ${endTime.toLocaleString()}`) + console.log(` - 剩余时间: ${stats.limits.windowRemainingSeconds} 秒`) + + if (stats.limits.windowRemainingSeconds > 0) { + const minutes = Math.floor(stats.limits.windowRemainingSeconds / 60) + const seconds = stats.limits.windowRemainingSeconds % 60 + console.log(` - 格式化剩余时间: ${minutes}分${seconds}秒`) + console.log(` - 窗口状态: 🟢 活跃中`) + } else { + console.log(` - 窗口状态: 🔴 已过期(下次请求时重置)`) + } + } else { + console.log(`\n ⏰ 时间窗口状态: ⚪ 未启动(还没有任何请求)`) + } + + console.log('\n✅ 测试完成!时间窗口剩余时间功能正常工作。') + } catch (error) { + console.error('❌ 测试失败:', error.message) + if (error.response) { + console.error(' 响应数据:', error.response.data) + } + process.exit(1) + } +} + +// 运行测试 +testWindowRemaining() diff --git a/scripts/update-model-pricing.js b/scripts/update-model-pricing.js new file mode 100644 index 0000000000000000000000000000000000000000..0b1e23233ba0d9dbb320aa6a7fd9fe31e6052b86 --- /dev/null +++ b/scripts/update-model-pricing.js @@ -0,0 +1,272 @@ +#!/usr/bin/env node + +/** + * 手动更新模型价格数据脚本 + * 从 LiteLLM 仓库下载最新的模型价格和上下文窗口信息 + */ + +const fs = require('fs') +const path = require('path') +const https = require('https') + +// 颜色输出 +const colors = { + reset: '\x1b[0m', + bright: '\x1b[1m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[36m', + magenta: '\x1b[35m' +} + +// 日志函数 +const log = { + info: (msg) => console.log(`${colors.blue}[INFO]${colors.reset} ${msg}`), + success: (msg) => console.log(`${colors.green}[SUCCESS]${colors.reset} ${msg}`), + error: (msg) => console.error(`${colors.red}[ERROR]${colors.reset} ${msg}`), + warn: (msg) => console.warn(`${colors.yellow}[WARNING]${colors.reset} ${msg}`) +} + +// 配置 +const config = { + dataDir: path.join(process.cwd(), 'data'), + pricingFile: path.join(process.cwd(), 'data', 'model_pricing.json'), + pricingUrl: + 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json', + fallbackFile: path.join( + process.cwd(), + 'resources', + 'model-pricing', + 'model_prices_and_context_window.json' + ), + backupFile: path.join(process.cwd(), 'data', 'model_pricing.backup.json'), + timeout: 30000 // 30秒超时 +} + +// 创建数据目录 +function ensureDataDir() { + if (!fs.existsSync(config.dataDir)) { + fs.mkdirSync(config.dataDir, { recursive: true }) + log.info('Created data directory') + } +} + +// 备份现有文件 +function backupExistingFile() { + if (fs.existsSync(config.pricingFile)) { + try { + fs.copyFileSync(config.pricingFile, config.backupFile) + log.info('Backed up existing pricing file') + return true + } catch (error) { + log.warn(`Failed to backup existing file: ${error.message}`) + return false + } + } + return false +} + +// 恢复备份 +function restoreBackup() { + if (fs.existsSync(config.backupFile)) { + try { + fs.copyFileSync(config.backupFile, config.pricingFile) + log.info('Restored from backup') + return true + } catch (error) { + log.error(`Failed to restore backup: ${error.message}`) + return false + } + } + return false +} + +// 下载价格数据 +function downloadPricingData() { + return new Promise((resolve, reject) => { + log.info('Downloading model pricing data from LiteLLM...') + log.info(`URL: ${config.pricingUrl}`) + + const request = https.get(config.pricingUrl, (response) => { + if (response.statusCode !== 200) { + reject(new Error(`HTTP ${response.statusCode}: ${response.statusMessage}`)) + return + } + + let data = '' + let downloadedBytes = 0 + + response.on('data', (chunk) => { + data += chunk + downloadedBytes += chunk.length + // 显示下载进度 + process.stdout.write(`\rDownloading... ${Math.round(downloadedBytes / 1024)}KB`) + }) + + response.on('end', () => { + process.stdout.write('\n') // 换行 + try { + const jsonData = JSON.parse(data) + + // 验证数据结构 + if (typeof jsonData !== 'object' || Object.keys(jsonData).length === 0) { + throw new Error('Invalid pricing data structure') + } + + // 保存到文件 + fs.writeFileSync(config.pricingFile, JSON.stringify(jsonData, null, 2)) + + const modelCount = Object.keys(jsonData).length + const fileSize = Math.round(fs.statSync(config.pricingFile).size / 1024) + + log.success(`Downloaded pricing data for ${modelCount} models (${fileSize}KB)`) + + // 显示一些统计信息 + const claudeModels = Object.keys(jsonData).filter((k) => k.includes('claude')).length + const gptModels = Object.keys(jsonData).filter((k) => k.includes('gpt')).length + const geminiModels = Object.keys(jsonData).filter((k) => k.includes('gemini')).length + + log.info('Model breakdown:') + log.info(` - Claude models: ${claudeModels}`) + log.info(` - GPT models: ${gptModels}`) + log.info(` - Gemini models: ${geminiModels}`) + log.info(` - Other models: ${modelCount - claudeModels - gptModels - geminiModels}`) + + resolve(jsonData) + } catch (error) { + reject(new Error(`Failed to parse pricing data: ${error.message}`)) + } + }) + }) + + request.on('error', (error) => { + reject(new Error(`Network error: ${error.message}`)) + }) + + request.setTimeout(config.timeout, () => { + request.destroy() + reject(new Error(`Download timeout after ${config.timeout / 1000} seconds`)) + }) + }) +} + +// 使用 fallback 文件 +function useFallback() { + log.warn('Attempting to use fallback pricing data...') + + if (!fs.existsSync(config.fallbackFile)) { + log.error(`Fallback file not found: ${config.fallbackFile}`) + return false + } + + try { + const fallbackData = fs.readFileSync(config.fallbackFile, 'utf8') + const jsonData = JSON.parse(fallbackData) + + // 保存到data目录 + fs.writeFileSync(config.pricingFile, JSON.stringify(jsonData, null, 2)) + + const modelCount = Object.keys(jsonData).length + log.warn(`Using fallback pricing data for ${modelCount} models`) + log.info('Note: Fallback data may be outdated. Try updating again later.') + + return true + } catch (error) { + log.error(`Failed to use fallback: ${error.message}`) + return false + } +} + +// 显示当前状态 +function showCurrentStatus() { + if (fs.existsSync(config.pricingFile)) { + const stats = fs.statSync(config.pricingFile) + const fileAge = Date.now() - stats.mtime.getTime() + const ageInHours = Math.round(fileAge / (60 * 60 * 1000)) + const ageInDays = Math.floor(ageInHours / 24) + + let ageString = '' + if (ageInDays > 0) { + ageString = `${ageInDays} day${ageInDays > 1 ? 's' : ''} and ${ageInHours % 24} hour${ageInHours % 24 !== 1 ? 's' : ''}` + } else { + ageString = `${ageInHours} hour${ageInHours !== 1 ? 's' : ''}` + } + + log.info(`Current pricing file age: ${ageString}`) + + try { + const data = JSON.parse(fs.readFileSync(config.pricingFile, 'utf8')) + log.info(`Current file contains ${Object.keys(data).length} models`) + } catch (error) { + log.warn('Current file exists but could not be parsed') + } + } else { + log.info('No existing pricing file found') + } +} + +// 主函数 +async function main() { + console.log(`${colors.bright}${colors.blue}======================================${colors.reset}`) + console.log(`${colors.bright} Model Pricing Update Tool${colors.reset}`) + console.log( + `${colors.bright}${colors.blue}======================================${colors.reset}\n` + ) + + // 显示当前状态 + showCurrentStatus() + console.log('') + + // 确保数据目录存在 + ensureDataDir() + + // 备份现有文件 + const hasBackup = backupExistingFile() + + try { + // 尝试下载最新数据 + await downloadPricingData() + + // 清理备份文件(成功下载后) + if (hasBackup && fs.existsSync(config.backupFile)) { + fs.unlinkSync(config.backupFile) + log.info('Cleaned up backup file') + } + + console.log(`\n${colors.green}✅ Model pricing updated successfully!${colors.reset}`) + process.exit(0) + } catch (error) { + log.error(`Download failed: ${error.message}`) + + // 尝试恢复备份 + if (hasBackup) { + if (restoreBackup()) { + log.info('Original file restored') + } + } + + // 尝试使用 fallback + if (useFallback()) { + console.log( + `\n${colors.yellow}⚠️ Using fallback data (update completed with warnings)${colors.reset}` + ) + process.exit(0) + } else { + console.log(`\n${colors.red}❌ Failed to update model pricing${colors.reset}`) + process.exit(1) + } + } +} + +// 处理未捕获的错误 +process.on('unhandledRejection', (error) => { + log.error(`Unhandled error: ${error.message}`) + process.exit(1) +}) + +// 运行主函数 +main().catch((error) => { + log.error(`Fatal error: ${error.message}`) + process.exit(1) +}) diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000000000000000000000000000000000000..42e8ce267c1a713526b87ba7447f14d23215f53d --- /dev/null +++ b/src/app.js @@ -0,0 +1,703 @@ +const express = require('express') +const cors = require('cors') +const helmet = require('helmet') +const compression = require('compression') +const path = require('path') +const fs = require('fs') +const bcrypt = require('bcryptjs') + +const config = require('../config/config') +const logger = require('./utils/logger') +const redis = require('./models/redis') +const pricingService = require('./services/pricingService') +const cacheMonitor = require('./utils/cacheMonitor') + +// Import routes +const apiRoutes = require('./routes/api') +const adminRoutes = require('./routes/admin') +const webRoutes = require('./routes/web') +const apiStatsRoutes = require('./routes/apiStats') +const geminiRoutes = require('./routes/geminiRoutes') +const openaiGeminiRoutes = require('./routes/openaiGeminiRoutes') +const standardGeminiRoutes = require('./routes/standardGeminiRoutes') +const openaiClaudeRoutes = require('./routes/openaiClaudeRoutes') +const openaiRoutes = require('./routes/openaiRoutes') +const droidRoutes = require('./routes/droidRoutes') +const userRoutes = require('./routes/userRoutes') +const azureOpenaiRoutes = require('./routes/azureOpenaiRoutes') +const webhookRoutes = require('./routes/webhook') + +// Import middleware +const { + corsMiddleware, + requestLogger, + securityMiddleware, + errorHandler, + globalRateLimit, + requestSizeLimit +} = require('./middleware/auth') +const { browserFallbackMiddleware } = require('./middleware/browserFallback') + +class Application { + constructor() { + this.app = express() + this.server = null + } + + async initialize() { + try { + // 🔗 连接Redis + logger.info('🔄 Connecting to Redis...') + await redis.connect() + logger.success('✅ Redis connected successfully') + + // 💰 初始化价格服务 + logger.info('🔄 Initializing pricing service...') + await pricingService.initialize() + + // 📊 初始化缓存监控 + await this.initializeCacheMonitoring() + + // 🔧 初始化管理员凭据 + logger.info('🔄 Initializing admin credentials...') + await this.initializeAdmin() + + // 💰 初始化费用数据 + logger.info('💰 Checking cost data initialization...') + const costInitService = require('./services/costInitService') + const needsInit = await costInitService.needsInitialization() + if (needsInit) { + logger.info('💰 Initializing cost data for all API Keys...') + const result = await costInitService.initializeAllCosts() + logger.info( + `💰 Cost initialization completed: ${result.processed} processed, ${result.errors} errors` + ) + } + + // 🕐 初始化Claude账户会话窗口 + logger.info('🕐 Initializing Claude account session windows...') + const claudeAccountService = require('./services/claudeAccountService') + await claudeAccountService.initializeSessionWindows() + + // 超早期拦截 /admin-next/ 请求 - 在所有中间件之前 + this.app.use((req, res, next) => { + if (req.path === '/admin-next/' && req.method === 'GET') { + logger.warn('🚨 INTERCEPTING /admin-next/ request at the very beginning!') + const adminSpaPath = path.join(__dirname, '..', 'web', 'admin-spa', 'dist') + const indexPath = path.join(adminSpaPath, 'index.html') + + if (fs.existsSync(indexPath)) { + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate') + return res.sendFile(indexPath) + } else { + logger.error('❌ index.html not found at:', indexPath) + return res.status(404).send('index.html not found') + } + } + next() + }) + + // 🛡️ 安全中间件 + this.app.use( + helmet({ + contentSecurityPolicy: false, // 允许内联样式和脚本 + crossOriginEmbedderPolicy: false + }) + ) + + // 🌐 CORS + if (config.web.enableCors) { + this.app.use(cors()) + } else { + this.app.use(corsMiddleware) + } + + // 🆕 兜底中间件:处理Chrome插件兼容性(必须在认证之前) + this.app.use(browserFallbackMiddleware) + + // 📦 压缩 - 排除流式响应(SSE) + this.app.use( + compression({ + filter: (req, res) => { + // 不压缩 Server-Sent Events + if (res.getHeader('Content-Type') === 'text/event-stream') { + return false + } + // 使用默认的压缩判断 + return compression.filter(req, res) + } + }) + ) + + // 🚦 全局速率限制(仅在生产环境启用) + if (process.env.NODE_ENV === 'production') { + this.app.use(globalRateLimit) + } + + // 📏 请求大小限制 + this.app.use(requestSizeLimit) + + // 📝 请求日志(使用自定义logger而不是morgan) + this.app.use(requestLogger) + + // 🐛 HTTP调试拦截器(仅在启用调试时生效) + if (process.env.DEBUG_HTTP_TRAFFIC === 'true') { + try { + const { debugInterceptor } = require('./middleware/debugInterceptor') + this.app.use(debugInterceptor) + logger.info('🐛 HTTP调试拦截器已启用 - 日志输出到 logs/http-debug-*.log') + } catch (error) { + logger.warn('⚠️ 无法加载HTTP调试拦截器:', error.message) + } + } + + // 🔧 基础中间件 + this.app.use( + express.json({ + limit: '10mb', + verify: (req, res, buf, encoding) => { + // 验证JSON格式 + if (buf && buf.length && !buf.toString(encoding || 'utf8').trim()) { + throw new Error('Invalid JSON: empty body') + } + } + }) + ) + this.app.use(express.urlencoded({ extended: true, limit: '10mb' })) + this.app.use(securityMiddleware) + + // 🎯 信任代理 + if (config.server.trustProxy) { + this.app.set('trust proxy', 1) + } + + // 调试中间件 - 拦截所有 /admin-next 请求 + this.app.use((req, res, next) => { + if (req.path.startsWith('/admin-next')) { + logger.info( + `🔍 DEBUG: Incoming request - method: ${req.method}, path: ${req.path}, originalUrl: ${req.originalUrl}` + ) + } + next() + }) + + // 🎨 新版管理界面静态文件服务(必须在其他路由之前) + const adminSpaPath = path.join(__dirname, '..', 'web', 'admin-spa', 'dist') + if (fs.existsSync(adminSpaPath)) { + // 处理不带斜杠的路径,重定向到带斜杠的路径 + this.app.get('/admin-next', (req, res) => { + res.redirect(301, '/admin-next/') + }) + + // 使用 all 方法确保捕获所有 HTTP 方法 + this.app.all('/admin-next/', (req, res) => { + logger.info('🎯 HIT: /admin-next/ route handler triggered!') + logger.info(`Method: ${req.method}, Path: ${req.path}, URL: ${req.url}`) + + if (req.method !== 'GET' && req.method !== 'HEAD') { + return res.status(405).send('Method Not Allowed') + } + + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate') + res.sendFile(path.join(adminSpaPath, 'index.html')) + }) + + // 处理所有其他 /admin-next/* 路径(但排除根路径) + this.app.get('/admin-next/*', (req, res) => { + // 如果是根路径,跳过(应该由上面的路由处理) + if (req.path === '/admin-next/') { + logger.error('❌ ERROR: /admin-next/ should not reach here!') + return res.status(500).send('Route configuration error') + } + + const requestPath = req.path.replace('/admin-next/', '') + + // 安全检查 + if ( + requestPath.includes('..') || + requestPath.includes('//') || + requestPath.includes('\\') + ) { + return res.status(400).json({ error: 'Invalid path' }) + } + + // 检查是否为静态资源 + const filePath = path.join(adminSpaPath, requestPath) + + // 如果文件存在且是静态资源 + if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { + // 设置缓存头 + if (filePath.endsWith('.js') || filePath.endsWith('.css')) { + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable') + } else if (filePath.endsWith('.html')) { + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate') + } + return res.sendFile(filePath) + } + + // 如果是静态资源但文件不存在 + if (requestPath.match(/\.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf)$/i)) { + return res.status(404).send('Not found') + } + + // 其他所有路径返回 index.html(SPA 路由) + res.sendFile(path.join(adminSpaPath, 'index.html')) + }) + + logger.info('✅ Admin SPA (next) static files mounted at /admin-next/') + } else { + logger.warn('⚠️ Admin SPA dist directory not found, skipping /admin-next route') + } + + // 🛣️ 路由 + this.app.use('/api', apiRoutes) + this.app.use('/claude', apiRoutes) // /claude 路由别名,与 /api 功能相同 + this.app.use('/admin', adminRoutes) + this.app.use('/users', userRoutes) + // 使用 web 路由(包含 auth 和页面重定向) + this.app.use('/web', webRoutes) + this.app.use('/apiStats', apiStatsRoutes) + // Gemini 路由:同时支持标准格式和原有格式 + this.app.use('/gemini', standardGeminiRoutes) // 标准 Gemini API 格式路由 + this.app.use('/gemini', geminiRoutes) // 保留原有路径以保持向后兼容 + this.app.use('/openai/gemini', openaiGeminiRoutes) + this.app.use('/openai/claude', openaiClaudeRoutes) + this.app.use('/openai', openaiRoutes) + // Droid 路由:支持多种 Factory.ai 端点 + this.app.use('/droid', droidRoutes) // Droid (Factory.ai) API 转发 + this.app.use('/azure', azureOpenaiRoutes) + this.app.use('/admin/webhook', webhookRoutes) + + // 🏠 根路径重定向到新版管理界面 + this.app.get('/', (req, res) => { + res.redirect('/admin-next/api-stats') + }) + + // 🏥 增强的健康检查端点 + this.app.get('/health', async (req, res) => { + try { + const timer = logger.timer('health-check') + + // 检查各个组件健康状态 + const [redisHealth, loggerHealth] = await Promise.all([ + this.checkRedisHealth(), + this.checkLoggerHealth() + ]) + + const memory = process.memoryUsage() + + // 获取版本号:优先使用环境变量,其次VERSION文件,再次package.json,最后使用默认值 + let version = process.env.APP_VERSION || process.env.VERSION + if (!version) { + try { + const versionFile = path.join(__dirname, '..', 'VERSION') + if (fs.existsSync(versionFile)) { + version = fs.readFileSync(versionFile, 'utf8').trim() + } + } catch (error) { + // 忽略错误,继续尝试其他方式 + } + } + if (!version) { + try { + const { version: pkgVersion } = require('../package.json') + version = pkgVersion + } catch (error) { + version = '1.0.0' + } + } + + const health = { + status: 'healthy', + service: 'claude-relay-service', + version, + timestamp: new Date().toISOString(), + uptime: process.uptime(), + memory: { + used: `${Math.round(memory.heapUsed / 1024 / 1024)}MB`, + total: `${Math.round(memory.heapTotal / 1024 / 1024)}MB`, + external: `${Math.round(memory.external / 1024 / 1024)}MB` + }, + components: { + redis: redisHealth, + logger: loggerHealth + }, + stats: logger.getStats() + } + + timer.end('completed') + res.json(health) + } catch (error) { + logger.error('❌ Health check failed:', { error: error.message, stack: error.stack }) + res.status(503).json({ + status: 'unhealthy', + error: error.message, + timestamp: new Date().toISOString() + }) + } + }) + + // 📊 指标端点 + this.app.get('/metrics', async (req, res) => { + try { + const stats = await redis.getSystemStats() + const metrics = { + ...stats, + uptime: process.uptime(), + memory: process.memoryUsage(), + timestamp: new Date().toISOString() + } + + res.json(metrics) + } catch (error) { + logger.error('❌ Metrics collection failed:', error) + res.status(500).json({ error: 'Failed to collect metrics' }) + } + }) + + // 🚫 404 处理 + this.app.use('*', (req, res) => { + res.status(404).json({ + error: 'Not Found', + message: `Route ${req.originalUrl} not found`, + timestamp: new Date().toISOString() + }) + }) + + // 🚨 错误处理 + this.app.use(errorHandler) + + logger.success('✅ Application initialized successfully') + } catch (error) { + logger.error('💥 Application initialization failed:', error) + throw error + } + } + + // 🔧 初始化管理员凭据(总是从 init.json 加载,确保数据一致性) + async initializeAdmin() { + try { + const initFilePath = path.join(__dirname, '..', 'data', 'init.json') + + if (!fs.existsSync(initFilePath)) { + logger.warn('⚠️ No admin credentials found. Please run npm run setup first.') + return + } + + // 从 init.json 读取管理员凭据(作为唯一真实数据源) + const initData = JSON.parse(fs.readFileSync(initFilePath, 'utf8')) + + // 将明文密码哈希化 + const saltRounds = 10 + const passwordHash = await bcrypt.hash(initData.adminPassword, saltRounds) + + // 存储到Redis(每次启动都覆盖,确保与 init.json 同步) + const adminCredentials = { + username: initData.adminUsername, + passwordHash, + createdAt: initData.initializedAt || new Date().toISOString(), + lastLogin: null, + updatedAt: initData.updatedAt || null + } + + await redis.setSession('admin_credentials', adminCredentials) + + logger.success('✅ Admin credentials loaded from init.json (single source of truth)') + logger.info(`📋 Admin username: ${adminCredentials.username}`) + } catch (error) { + logger.error('❌ Failed to initialize admin credentials:', { + error: error.message, + stack: error.stack + }) + throw error + } + } + + // 🔍 Redis健康检查 + async checkRedisHealth() { + try { + const start = Date.now() + await redis.getClient().ping() + const latency = Date.now() - start + + return { + status: 'healthy', + connected: redis.isConnected, + latency: `${latency}ms` + } + } catch (error) { + return { + status: 'unhealthy', + connected: false, + error: error.message + } + } + } + + // 📝 Logger健康检查 + async checkLoggerHealth() { + try { + const health = logger.healthCheck() + return { + status: health.healthy ? 'healthy' : 'unhealthy', + ...health + } + } catch (error) { + return { + status: 'unhealthy', + error: error.message + } + } + } + + async start() { + try { + await this.initialize() + + this.server = this.app.listen(config.server.port, config.server.host, () => { + logger.start( + `🚀 Claude Relay Service started on ${config.server.host}:${config.server.port}` + ) + logger.info( + `🌐 Web interface: http://${config.server.host}:${config.server.port}/admin-next/api-stats` + ) + logger.info( + `🔗 API endpoint: http://${config.server.host}:${config.server.port}/api/v1/messages` + ) + logger.info(`⚙️ Admin API: http://${config.server.host}:${config.server.port}/admin`) + logger.info(`🏥 Health check: http://${config.server.host}:${config.server.port}/health`) + logger.info(`📊 Metrics: http://${config.server.host}:${config.server.port}/metrics`) + }) + + const serverTimeout = 600000 // 默认10分钟 + this.server.timeout = serverTimeout + this.server.keepAliveTimeout = serverTimeout + 5000 // keepAlive 稍长一点 + logger.info(`⏱️ Server timeout set to ${serverTimeout}ms (${serverTimeout / 1000}s)`) + + // 🔄 定期清理任务 + this.startCleanupTasks() + + // 🛑 优雅关闭 + this.setupGracefulShutdown() + } catch (error) { + logger.error('💥 Failed to start server:', error) + process.exit(1) + } + } + + // 📊 初始化缓存监控 + async initializeCacheMonitoring() { + try { + logger.info('🔄 Initializing cache monitoring...') + + // 注册各个服务的缓存实例 + const services = [ + { name: 'claudeAccount', service: require('./services/claudeAccountService') }, + { name: 'claudeConsole', service: require('./services/claudeConsoleAccountService') }, + { name: 'bedrockAccount', service: require('./services/bedrockAccountService') } + ] + + // 注册已加载的服务缓存 + for (const { name, service } of services) { + if (service && (service._decryptCache || service.decryptCache)) { + const cache = service._decryptCache || service.decryptCache + cacheMonitor.registerCache(`${name}_decrypt`, cache) + logger.info(`✅ Registered ${name} decrypt cache for monitoring`) + } + } + + // 初始化时打印一次统计 + setTimeout(() => { + const stats = cacheMonitor.getGlobalStats() + logger.info(`📊 Cache System - Registered: ${stats.cacheCount} caches`) + }, 5000) + + logger.success('✅ Cache monitoring initialized') + } catch (error) { + logger.error('❌ Failed to initialize cache monitoring:', error) + // 不阻止应用启动 + } + } + + startCleanupTasks() { + // 🧹 每小时清理一次过期数据 + setInterval(async () => { + try { + logger.info('🧹 Starting scheduled cleanup...') + + const apiKeyService = require('./services/apiKeyService') + const claudeAccountService = require('./services/claudeAccountService') + + const [expiredKeys, errorAccounts] = await Promise.all([ + apiKeyService.cleanupExpiredKeys(), + claudeAccountService.cleanupErrorAccounts(), + claudeAccountService.cleanupTempErrorAccounts() // 新增:清理临时错误账户 + ]) + + await redis.cleanup() + + logger.success( + `🧹 Cleanup completed: ${expiredKeys} expired keys, ${errorAccounts} error accounts reset` + ) + } catch (error) { + logger.error('❌ Cleanup task failed:', error) + } + }, config.system.cleanupInterval) + + logger.info( + `🔄 Cleanup tasks scheduled every ${config.system.cleanupInterval / 1000 / 60} minutes` + ) + + // 🚨 启动限流状态自动清理服务 + // 每5分钟检查一次过期的限流状态,确保账号能及时恢复调度 + const rateLimitCleanupService = require('./services/rateLimitCleanupService') + const cleanupIntervalMinutes = config.system.rateLimitCleanupInterval || 5 // 默认5分钟 + rateLimitCleanupService.start(cleanupIntervalMinutes) + logger.info( + `🚨 Rate limit cleanup service started (checking every ${cleanupIntervalMinutes} minutes)` + ) + + // 🔢 启动并发计数自动清理任务(Phase 1 修复:解决并发泄漏问题) + // 每分钟主动清理所有过期的并发项,不依赖请求触发 + setInterval(async () => { + try { + const keys = await redis.keys('concurrency:*') + if (keys.length === 0) { + return + } + + const now = Date.now() + let totalCleaned = 0 + + // 使用 Lua 脚本批量清理所有过期项 + for (const key of keys) { + try { + const cleaned = await redis.client.eval( + ` + local key = KEYS[1] + local now = tonumber(ARGV[1]) + + -- 清理过期项 + redis.call('ZREMRANGEBYSCORE', key, '-inf', now) + + -- 获取剩余计数 + local count = redis.call('ZCARD', key) + + -- 如果计数为0,删除键 + if count <= 0 then + redis.call('DEL', key) + return 1 + end + + return 0 + `, + 1, + key, + now + ) + if (cleaned === 1) { + totalCleaned++ + } + } catch (error) { + logger.error(`❌ Failed to clean concurrency key ${key}:`, error) + } + } + + if (totalCleaned > 0) { + logger.info(`🔢 Concurrency cleanup: cleaned ${totalCleaned} expired keys`) + } + } catch (error) { + logger.error('❌ Concurrency cleanup task failed:', error) + } + }, 60000) // 每分钟执行一次 + + logger.info('🔢 Concurrency cleanup task started (running every 1 minute)') + } + + setupGracefulShutdown() { + const shutdown = async (signal) => { + logger.info(`🛑 Received ${signal}, starting graceful shutdown...`) + + if (this.server) { + this.server.close(async () => { + logger.info('🚪 HTTP server closed') + + // 清理 pricing service 的文件监听器 + try { + pricingService.cleanup() + logger.info('💰 Pricing service cleaned up') + } catch (error) { + logger.error('❌ Error cleaning up pricing service:', error) + } + + // 停止限流清理服务 + try { + const rateLimitCleanupService = require('./services/rateLimitCleanupService') + rateLimitCleanupService.stop() + logger.info('🚨 Rate limit cleanup service stopped') + } catch (error) { + logger.error('❌ Error stopping rate limit cleanup service:', error) + } + + // 🔢 清理所有并发计数(Phase 1 修复:防止重启泄漏) + try { + logger.info('🔢 Cleaning up all concurrency counters...') + const keys = await redis.keys('concurrency:*') + if (keys.length > 0) { + await redis.client.del(...keys) + logger.info(`✅ Cleaned ${keys.length} concurrency keys`) + } else { + logger.info('✅ No concurrency keys to clean') + } + } catch (error) { + logger.error('❌ Error cleaning up concurrency counters:', error) + // 不阻止退出流程 + } + + try { + await redis.disconnect() + logger.info('👋 Redis disconnected') + } catch (error) { + logger.error('❌ Error disconnecting Redis:', error) + } + + logger.success('✅ Graceful shutdown completed') + process.exit(0) + }) + + // 强制关闭超时 + setTimeout(() => { + logger.warn('⚠️ Forced shutdown due to timeout') + process.exit(1) + }, 10000) + } else { + process.exit(0) + } + } + + process.on('SIGTERM', () => shutdown('SIGTERM')) + process.on('SIGINT', () => shutdown('SIGINT')) + + // 处理未捕获异常 + process.on('uncaughtException', (error) => { + logger.error('💥 Uncaught exception:', error) + shutdown('uncaughtException') + }) + + process.on('unhandledRejection', (reason, promise) => { + logger.error('💥 Unhandled rejection at:', promise, 'reason:', reason) + shutdown('unhandledRejection') + }) + } +} + +// 启动应用 +if (require.main === module) { + const app = new Application() + app.start().catch((error) => { + logger.error('💥 Application startup failed:', error) + process.exit(1) + }) +} + +module.exports = Application diff --git a/src/cli/initCosts.js b/src/cli/initCosts.js new file mode 100644 index 0000000000000000000000000000000000000000..35ab75d477aafb78ef19a563860be3cfb4414255 --- /dev/null +++ b/src/cli/initCosts.js @@ -0,0 +1,35 @@ +#!/usr/bin/env node + +const costInitService = require('../services/costInitService') +const logger = require('../utils/logger') +const redis = require('../models/redis') + +async function main() { + try { + // 连接Redis + await redis.connect() + + console.log('💰 Starting cost data initialization...\n') + + // 执行初始化 + const result = await costInitService.initializeAllCosts() + + console.log('\n✅ Cost initialization completed!') + console.log(` Processed: ${result.processed} API Keys`) + console.log(` Errors: ${result.errors}`) + + // 断开连接 + await redis.disconnect() + throw new Error('INIT_COSTS_SUCCESS') + } catch (error) { + if (error.message === 'INIT_COSTS_SUCCESS') { + return + } + console.error('\n❌ Cost initialization failed:', error.message) + logger.error('Cost initialization failed:', error) + throw error + } +} + +// 运行主函数 +main() diff --git a/src/middleware/auth.js b/src/middleware/auth.js new file mode 100644 index 0000000000000000000000000000000000000000..378f15651fee48b1e5b6979e6c62f0c40c6af796 --- /dev/null +++ b/src/middleware/auth.js @@ -0,0 +1,1330 @@ +const { v4: uuidv4 } = require('uuid') +const config = require('../../config/config') +const apiKeyService = require('../services/apiKeyService') +const userService = require('../services/userService') +const logger = require('../utils/logger') +const redis = require('../models/redis') +// const { RateLimiterRedis } = require('rate-limiter-flexible') // 暂时未使用 +const ClientValidator = require('../validators/clientValidator') + +const FALLBACK_CONCURRENCY_CONFIG = { + leaseSeconds: 300, + renewIntervalSeconds: 30, + cleanupGraceSeconds: 30 +} + +const resolveConcurrencyConfig = () => { + if (typeof redis._getConcurrencyConfig === 'function') { + return redis._getConcurrencyConfig() + } + + const raw = { + ...FALLBACK_CONCURRENCY_CONFIG, + ...(config.concurrency || {}) + } + + const toNumber = (value, fallback) => { + const parsed = Number(value) + if (!Number.isFinite(parsed)) { + return fallback + } + return parsed + } + + const leaseSeconds = Math.max( + toNumber(raw.leaseSeconds, FALLBACK_CONCURRENCY_CONFIG.leaseSeconds), + 30 + ) + + let renewIntervalSeconds + if (raw.renewIntervalSeconds === 0 || raw.renewIntervalSeconds === '0') { + renewIntervalSeconds = 0 + } else { + renewIntervalSeconds = Math.max( + toNumber(raw.renewIntervalSeconds, FALLBACK_CONCURRENCY_CONFIG.renewIntervalSeconds), + 0 + ) + } + + const cleanupGraceSeconds = Math.max( + toNumber(raw.cleanupGraceSeconds, FALLBACK_CONCURRENCY_CONFIG.cleanupGraceSeconds), + 0 + ) + + return { + leaseSeconds, + renewIntervalSeconds, + cleanupGraceSeconds + } +} + +const TOKEN_COUNT_PATHS = new Set([ + '/v1/messages/count_tokens', + '/api/v1/messages/count_tokens', + '/claude/v1/messages/count_tokens', + '/droid/claude/v1/messages/count_tokens' +]) + +function extractApiKey(req) { + const candidates = [ + req.headers['x-api-key'], + req.headers['x-goog-api-key'], + req.headers['authorization'], + req.headers['api-key'], + req.query?.key + ] + + for (const candidate of candidates) { + let value = candidate + + if (Array.isArray(value)) { + value = value.find((item) => typeof item === 'string' && item.trim()) + } + + if (typeof value !== 'string') { + continue + } + + let trimmed = value.trim() + if (!trimmed) { + continue + } + + if (/^Bearer\s+/i.test(trimmed)) { + trimmed = trimmed.replace(/^Bearer\s+/i, '').trim() + if (!trimmed) { + continue + } + } + + return trimmed + } + + return '' +} + +function normalizeRequestPath(value) { + if (!value) { + return '/' + } + const lower = value.split('?')[0].toLowerCase() + const collapsed = lower.replace(/\/{2,}/g, '/') + if (collapsed.length > 1 && collapsed.endsWith('/')) { + return collapsed.slice(0, -1) + } + return collapsed || '/' +} + +function isTokenCountRequest(req) { + const combined = normalizeRequestPath(`${req.baseUrl || ''}${req.path || ''}`) + if (TOKEN_COUNT_PATHS.has(combined)) { + return true + } + const original = normalizeRequestPath(req.originalUrl || '') + if (TOKEN_COUNT_PATHS.has(original)) { + return true + } + return false +} + +// 🔑 API Key验证中间件(优化版) +const authenticateApiKey = async (req, res, next) => { + const startTime = Date.now() + + try { + // 安全提取API Key,支持多种格式(包括Gemini CLI支持) + const apiKey = extractApiKey(req) + + if (apiKey) { + req.headers['x-api-key'] = apiKey + } + + if (!apiKey) { + logger.security(`🔒 Missing API key attempt from ${req.ip || 'unknown'}`) + return res.status(401).json({ + error: 'Missing API key', + message: + 'Please provide an API key in the x-api-key, x-goog-api-key, or Authorization header' + }) + } + + // 基本API Key格式验证 + if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) { + logger.security(`🔒 Invalid API key format from ${req.ip || 'unknown'}`) + return res.status(401).json({ + error: 'Invalid API key format', + message: 'API key format is invalid' + }) + } + + // 验证API Key(带缓存优化) + const validation = await apiKeyService.validateApiKey(apiKey) + + if (!validation.valid) { + const clientIP = req.ip || req.connection?.remoteAddress || 'unknown' + logger.security(`🔒 Invalid API key attempt: ${validation.error} from ${clientIP}`) + return res.status(401).json({ + error: 'Invalid API key', + message: validation.error + }) + } + + const skipKeyRestrictions = isTokenCountRequest(req) + + // 🔒 检查客户端限制(使用新的验证器) + if ( + !skipKeyRestrictions && + validation.keyData.enableClientRestriction && + validation.keyData.allowedClients?.length > 0 + ) { + // 使用新的 ClientValidator 进行验证 + const validationResult = ClientValidator.validateRequest( + validation.keyData.allowedClients, + req + ) + + if (!validationResult.allowed) { + const clientIP = req.ip || req.connection?.remoteAddress || 'unknown' + logger.security( + `🚫 Client restriction failed for key: ${validation.keyData.id} (${validation.keyData.name}) from ${clientIP}` + ) + return res.status(403).json({ + error: 'Client not allowed', + message: 'Your client is not authorized to use this API key', + allowedClients: validation.keyData.allowedClients, + userAgent: validationResult.userAgent + }) + } + + // 验证通过 + logger.api( + `✅ Client validated: ${validationResult.clientName} (${validationResult.matchedClient}) for key: ${validation.keyData.id} (${validation.keyData.name})` + ) + } + + // 检查并发限制 + const concurrencyLimit = validation.keyData.concurrencyLimit || 0 + if (!skipKeyRestrictions && concurrencyLimit > 0) { + const { leaseSeconds: configLeaseSeconds, renewIntervalSeconds: configRenewIntervalSeconds } = + resolveConcurrencyConfig() + const leaseSeconds = Math.max(Number(configLeaseSeconds) || 300, 30) + let renewIntervalSeconds = configRenewIntervalSeconds + if (renewIntervalSeconds > 0) { + const maxSafeRenew = Math.max(leaseSeconds - 5, 15) + renewIntervalSeconds = Math.min(Math.max(renewIntervalSeconds, 15), maxSafeRenew) + } else { + renewIntervalSeconds = 0 + } + const requestId = uuidv4() + + const currentConcurrency = await redis.incrConcurrency( + validation.keyData.id, + requestId, + leaseSeconds + ) + logger.api( + `📈 Incremented concurrency for key: ${validation.keyData.id} (${validation.keyData.name}), current: ${currentConcurrency}, limit: ${concurrencyLimit}` + ) + + if (currentConcurrency > concurrencyLimit) { + // 如果超过限制,立即减少计数 + await redis.decrConcurrency(validation.keyData.id, requestId) + logger.security( + `🚦 Concurrency limit exceeded for key: ${validation.keyData.id} (${ + validation.keyData.name + }), current: ${currentConcurrency - 1}, limit: ${concurrencyLimit}` + ) + return res.status(429).json({ + error: 'Concurrency limit exceeded', + message: `Too many concurrent requests. Limit: ${concurrencyLimit} concurrent requests`, + currentConcurrency: currentConcurrency - 1, + concurrencyLimit + }) + } + + const renewIntervalMs = + renewIntervalSeconds > 0 ? Math.max(renewIntervalSeconds * 1000, 15000) : 0 + + // 使用标志位确保只减少一次 + let concurrencyDecremented = false + let leaseRenewInterval = null + + if (renewIntervalMs > 0) { + leaseRenewInterval = setInterval(() => { + redis + .refreshConcurrencyLease(validation.keyData.id, requestId, leaseSeconds) + .catch((error) => { + logger.error( + `Failed to refresh concurrency lease for key ${validation.keyData.id}:`, + error + ) + }) + }, renewIntervalMs) + + if (typeof leaseRenewInterval.unref === 'function') { + leaseRenewInterval.unref() + } + } + + const decrementConcurrency = async () => { + if (!concurrencyDecremented) { + concurrencyDecremented = true + if (leaseRenewInterval) { + clearInterval(leaseRenewInterval) + leaseRenewInterval = null + } + try { + const newCount = await redis.decrConcurrency(validation.keyData.id, requestId) + logger.api( + `📉 Decremented concurrency for key: ${validation.keyData.id} (${validation.keyData.name}), new count: ${newCount}` + ) + } catch (error) { + logger.error(`Failed to decrement concurrency for key ${validation.keyData.id}:`, error) + } + } + } + + // 监听最可靠的事件(避免重复监听) + // res.on('close') 是最可靠的,会在连接关闭时触发 + res.once('close', () => { + logger.api( + `🔌 Response closed for key: ${validation.keyData.id} (${validation.keyData.name})` + ) + decrementConcurrency() + }) + + // req.on('close') 作为备用,处理请求端断开 + req.once('close', () => { + logger.api( + `🔌 Request closed for key: ${validation.keyData.id} (${validation.keyData.name})` + ) + decrementConcurrency() + }) + + req.once('aborted', () => { + logger.warn( + `⚠️ Request aborted for key: ${validation.keyData.id} (${validation.keyData.name})` + ) + decrementConcurrency() + }) + + req.once('error', (error) => { + logger.error( + `❌ Request error for key ${validation.keyData.id} (${validation.keyData.name}):`, + error + ) + decrementConcurrency() + }) + + res.once('error', (error) => { + logger.error( + `❌ Response error for key ${validation.keyData.id} (${validation.keyData.name}):`, + error + ) + decrementConcurrency() + }) + + // res.on('finish') 处理正常完成的情况 + res.once('finish', () => { + logger.api( + `✅ Response finished for key: ${validation.keyData.id} (${validation.keyData.name})` + ) + decrementConcurrency() + }) + + // 存储并发信息到请求对象,便于后续处理 + req.concurrencyInfo = { + apiKeyId: validation.keyData.id, + apiKeyName: validation.keyData.name, + requestId, + decrementConcurrency + } + } + + // 检查时间窗口限流 + const rateLimitWindow = validation.keyData.rateLimitWindow || 0 + const rateLimitRequests = validation.keyData.rateLimitRequests || 0 + const rateLimitCost = validation.keyData.rateLimitCost || 0 // 新增:费用限制 + + // 兼容性检查:如果tokenLimit仍有值,使用tokenLimit;否则使用rateLimitCost + const hasRateLimits = + rateLimitWindow > 0 && + (rateLimitRequests > 0 || validation.keyData.tokenLimit > 0 || rateLimitCost > 0) + + if (hasRateLimits) { + const windowStartKey = `rate_limit:window_start:${validation.keyData.id}` + const requestCountKey = `rate_limit:requests:${validation.keyData.id}` + const tokenCountKey = `rate_limit:tokens:${validation.keyData.id}` + const costCountKey = `rate_limit:cost:${validation.keyData.id}` // 新增:费用计数器 + + const now = Date.now() + const windowDuration = rateLimitWindow * 60 * 1000 // 转换为毫秒 + + // 获取窗口开始时间 + let windowStart = await redis.getClient().get(windowStartKey) + + if (!windowStart) { + // 第一次请求,设置窗口开始时间 + await redis.getClient().set(windowStartKey, now, 'PX', windowDuration) + await redis.getClient().set(requestCountKey, 0, 'PX', windowDuration) + await redis.getClient().set(tokenCountKey, 0, 'PX', windowDuration) + await redis.getClient().set(costCountKey, 0, 'PX', windowDuration) // 新增:重置费用 + windowStart = now + } else { + windowStart = parseInt(windowStart) + + // 检查窗口是否已过期 + if (now - windowStart >= windowDuration) { + // 窗口已过期,重置 + await redis.getClient().set(windowStartKey, now, 'PX', windowDuration) + await redis.getClient().set(requestCountKey, 0, 'PX', windowDuration) + await redis.getClient().set(tokenCountKey, 0, 'PX', windowDuration) + await redis.getClient().set(costCountKey, 0, 'PX', windowDuration) // 新增:重置费用 + windowStart = now + } + } + + // 获取当前计数 + const currentRequests = parseInt((await redis.getClient().get(requestCountKey)) || '0') + const currentTokens = parseInt((await redis.getClient().get(tokenCountKey)) || '0') + const currentCost = parseFloat((await redis.getClient().get(costCountKey)) || '0') // 新增:当前费用 + + // 检查请求次数限制 + if (rateLimitRequests > 0 && currentRequests >= rateLimitRequests) { + const resetTime = new Date(windowStart + windowDuration) + const remainingMinutes = Math.ceil((resetTime - now) / 60000) + + logger.security( + `🚦 Rate limit exceeded (requests) for key: ${validation.keyData.id} (${validation.keyData.name}), requests: ${currentRequests}/${rateLimitRequests}` + ) + + return res.status(429).json({ + error: 'Rate limit exceeded', + message: `已达到请求次数限制 (${rateLimitRequests} 次),将在 ${remainingMinutes} 分钟后重置`, + currentRequests, + requestLimit: rateLimitRequests, + resetAt: resetTime.toISOString(), + remainingMinutes + }) + } + + // 兼容性检查:优先使用Token限制(历史数据),否则使用费用限制 + const tokenLimit = parseInt(validation.keyData.tokenLimit) + if (tokenLimit > 0) { + // 使用Token限制(向后兼容) + if (currentTokens >= tokenLimit) { + const resetTime = new Date(windowStart + windowDuration) + const remainingMinutes = Math.ceil((resetTime - now) / 60000) + + logger.security( + `🚦 Rate limit exceeded (tokens) for key: ${validation.keyData.id} (${validation.keyData.name}), tokens: ${currentTokens}/${tokenLimit}` + ) + + return res.status(429).json({ + error: 'Rate limit exceeded', + message: `已达到 Token 使用限制 (${tokenLimit} tokens),将在 ${remainingMinutes} 分钟后重置`, + currentTokens, + tokenLimit, + resetAt: resetTime.toISOString(), + remainingMinutes + }) + } + } else if (rateLimitCost > 0) { + // 使用费用限制(新功能) + if (currentCost >= rateLimitCost) { + const resetTime = new Date(windowStart + windowDuration) + const remainingMinutes = Math.ceil((resetTime - now) / 60000) + + logger.security( + `💰 Rate limit exceeded (cost) for key: ${validation.keyData.id} (${ + validation.keyData.name + }), cost: $${currentCost.toFixed(2)}/$${rateLimitCost}` + ) + + return res.status(429).json({ + error: 'Rate limit exceeded', + message: `已达到费用限制 ($${rateLimitCost}),将在 ${remainingMinutes} 分钟后重置`, + currentCost, + costLimit: rateLimitCost, + resetAt: resetTime.toISOString(), + remainingMinutes + }) + } + } + + // 增加请求计数 + await redis.getClient().incr(requestCountKey) + + // 存储限流信息到请求对象 + req.rateLimitInfo = { + windowStart, + windowDuration, + requestCountKey, + tokenCountKey, + costCountKey, // 新增:费用计数器 + currentRequests: currentRequests + 1, + currentTokens, + currentCost, // 新增:当前费用 + rateLimitRequests, + tokenLimit, + rateLimitCost // 新增:费用限制 + } + } + + // 检查每日费用限制 + const dailyCostLimit = validation.keyData.dailyCostLimit || 0 + if (dailyCostLimit > 0) { + const dailyCost = validation.keyData.dailyCost || 0 + + if (dailyCost >= dailyCostLimit) { + logger.security( + `💰 Daily cost limit exceeded for key: ${validation.keyData.id} (${ + validation.keyData.name + }), cost: $${dailyCost.toFixed(2)}/$${dailyCostLimit}` + ) + + return res.status(429).json({ + error: 'Daily cost limit exceeded', + message: `已达到每日费用限制 ($${dailyCostLimit})`, + currentCost: dailyCost, + costLimit: dailyCostLimit, + resetAt: new Date(new Date().setHours(24, 0, 0, 0)).toISOString() // 明天0点重置 + }) + } + + // 记录当前费用使用情况 + logger.api( + `💰 Cost usage for key: ${validation.keyData.id} (${ + validation.keyData.name + }), current: $${dailyCost.toFixed(2)}/$${dailyCostLimit}` + ) + } + + // 检查总费用限制 + const totalCostLimit = validation.keyData.totalCostLimit || 0 + if (totalCostLimit > 0) { + const totalCost = validation.keyData.totalCost || 0 + + if (totalCost >= totalCostLimit) { + logger.security( + `💰 Total cost limit exceeded for key: ${validation.keyData.id} (${ + validation.keyData.name + }), cost: $${totalCost.toFixed(2)}/$${totalCostLimit}` + ) + + return res.status(429).json({ + error: 'Total cost limit exceeded', + message: `已达到总费用限制 ($${totalCostLimit})`, + currentCost: totalCost, + costLimit: totalCostLimit + }) + } + + logger.api( + `💰 Total cost usage for key: ${validation.keyData.id} (${ + validation.keyData.name + }), current: $${totalCost.toFixed(2)}/$${totalCostLimit}` + ) + } + + // 检查 Opus 周费用限制(仅对 Opus 模型生效) + const weeklyOpusCostLimit = validation.keyData.weeklyOpusCostLimit || 0 + if (weeklyOpusCostLimit > 0) { + // 从请求中获取模型信息 + const requestBody = req.body || {} + const model = requestBody.model || '' + + // 判断是否为 Opus 模型 + if (model && model.toLowerCase().includes('claude-opus')) { + const weeklyOpusCost = validation.keyData.weeklyOpusCost || 0 + + if (weeklyOpusCost >= weeklyOpusCostLimit) { + logger.security( + `💰 Weekly Opus cost limit exceeded for key: ${validation.keyData.id} (${ + validation.keyData.name + }), cost: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}` + ) + + // 计算下周一的重置时间 + const now = new Date() + const dayOfWeek = now.getDay() + const daysUntilMonday = dayOfWeek === 0 ? 1 : (8 - dayOfWeek) % 7 || 7 + const resetDate = new Date(now) + resetDate.setDate(now.getDate() + daysUntilMonday) + resetDate.setHours(0, 0, 0, 0) + + return res.status(429).json({ + error: 'Weekly Opus cost limit exceeded', + message: `已达到 Opus 模型周费用限制 ($${weeklyOpusCostLimit})`, + currentCost: weeklyOpusCost, + costLimit: weeklyOpusCostLimit, + resetAt: resetDate.toISOString() // 下周一重置 + }) + } + + // 记录当前 Opus 费用使用情况 + logger.api( + `💰 Opus weekly cost usage for key: ${validation.keyData.id} (${ + validation.keyData.name + }), current: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}` + ) + } + } + + // 将验证信息添加到请求对象(只包含必要信息) + req.apiKey = { + id: validation.keyData.id, + name: validation.keyData.name, + tokenLimit: validation.keyData.tokenLimit, + claudeAccountId: validation.keyData.claudeAccountId, + claudeConsoleAccountId: validation.keyData.claudeConsoleAccountId, // 添加 Claude Console 账号ID + geminiAccountId: validation.keyData.geminiAccountId, + openaiAccountId: validation.keyData.openaiAccountId, // 添加 OpenAI 账号ID + bedrockAccountId: validation.keyData.bedrockAccountId, // 添加 Bedrock 账号ID + droidAccountId: validation.keyData.droidAccountId, + permissions: validation.keyData.permissions, + concurrencyLimit: validation.keyData.concurrencyLimit, + rateLimitWindow: validation.keyData.rateLimitWindow, + rateLimitRequests: validation.keyData.rateLimitRequests, + rateLimitCost: validation.keyData.rateLimitCost, // 新增:费用限制 + enableModelRestriction: validation.keyData.enableModelRestriction, + restrictedModels: validation.keyData.restrictedModels, + enableClientRestriction: validation.keyData.enableClientRestriction, + allowedClients: validation.keyData.allowedClients, + dailyCostLimit: validation.keyData.dailyCostLimit, + dailyCost: validation.keyData.dailyCost, + totalCostLimit: validation.keyData.totalCostLimit, + totalCost: validation.keyData.totalCost, + usage: validation.keyData.usage + } + req.usage = validation.keyData.usage + + const authDuration = Date.now() - startTime + const userAgent = req.headers['user-agent'] || 'No User-Agent' + logger.api( + `🔓 Authenticated request from key: ${validation.keyData.name} (${validation.keyData.id}) in ${authDuration}ms` + ) + logger.api(` User-Agent: "${userAgent}"`) + + return next() + } catch (error) { + const authDuration = Date.now() - startTime + logger.error(`❌ Authentication middleware error (${authDuration}ms):`, { + error: error.message, + stack: error.stack, + ip: req.ip, + userAgent: req.get('User-Agent'), + url: req.originalUrl + }) + + return res.status(500).json({ + error: 'Authentication error', + message: 'Internal server error during authentication' + }) + } +} + +// 🛡️ 管理员验证中间件(优化版) +const authenticateAdmin = async (req, res, next) => { + const startTime = Date.now() + + try { + // 安全提取token,支持多种方式 + const token = + req.headers['authorization']?.replace(/^Bearer\s+/i, '') || + req.cookies?.adminToken || + req.headers['x-admin-token'] + + if (!token) { + logger.security(`🔒 Missing admin token attempt from ${req.ip || 'unknown'}`) + return res.status(401).json({ + error: 'Missing admin token', + message: 'Please provide an admin token' + }) + } + + // 基本token格式验证 + if (typeof token !== 'string' || token.length < 32 || token.length > 512) { + logger.security(`🔒 Invalid admin token format from ${req.ip || 'unknown'}`) + return res.status(401).json({ + error: 'Invalid admin token format', + message: 'Admin token format is invalid' + }) + } + + // 获取管理员会话(带超时处理) + const adminSession = await Promise.race([ + redis.getSession(token), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Session lookup timeout')), 5000) + ) + ]) + + if (!adminSession || Object.keys(adminSession).length === 0) { + logger.security(`🔒 Invalid admin token attempt from ${req.ip || 'unknown'}`) + return res.status(401).json({ + error: 'Invalid admin token', + message: 'Invalid or expired admin session' + }) + } + + // 检查会话活跃性(可选:检查最后活动时间) + const now = new Date() + const lastActivity = new Date(adminSession.lastActivity || adminSession.loginTime) + const inactiveDuration = now - lastActivity + const maxInactivity = 24 * 60 * 60 * 1000 // 24小时 + + if (inactiveDuration > maxInactivity) { + logger.security( + `🔒 Expired admin session for ${adminSession.username} from ${req.ip || 'unknown'}` + ) + await redis.deleteSession(token) // 清理过期会话 + return res.status(401).json({ + error: 'Session expired', + message: 'Admin session has expired due to inactivity' + }) + } + + // 更新最后活动时间(异步,不阻塞请求) + redis + .setSession( + token, + { + ...adminSession, + lastActivity: now.toISOString() + }, + 86400 + ) + .catch((error) => { + logger.error('Failed to update admin session activity:', error) + }) + + // 设置管理员信息(只包含必要信息) + req.admin = { + id: adminSession.adminId || 'admin', + username: adminSession.username, + sessionId: token, + loginTime: adminSession.loginTime + } + + const authDuration = Date.now() - startTime + logger.security(`🔐 Admin authenticated: ${adminSession.username} in ${authDuration}ms`) + + return next() + } catch (error) { + const authDuration = Date.now() - startTime + logger.error(`❌ Admin authentication error (${authDuration}ms):`, { + error: error.message, + ip: req.ip, + userAgent: req.get('User-Agent'), + url: req.originalUrl + }) + + return res.status(500).json({ + error: 'Authentication error', + message: 'Internal server error during admin authentication' + }) + } +} + +// 👤 用户验证中间件 +const authenticateUser = async (req, res, next) => { + const startTime = Date.now() + + try { + // 安全提取用户session token,支持多种方式 + const sessionToken = + req.headers['authorization']?.replace(/^Bearer\s+/i, '') || + req.cookies?.userToken || + req.headers['x-user-token'] + + if (!sessionToken) { + logger.security(`🔒 Missing user session token attempt from ${req.ip || 'unknown'}`) + return res.status(401).json({ + error: 'Missing user session token', + message: 'Please login to access this resource' + }) + } + + // 基本token格式验证 + if (typeof sessionToken !== 'string' || sessionToken.length < 32 || sessionToken.length > 128) { + logger.security(`🔒 Invalid user session token format from ${req.ip || 'unknown'}`) + return res.status(401).json({ + error: 'Invalid session token format', + message: 'Session token format is invalid' + }) + } + + // 验证用户会话 + const sessionValidation = await userService.validateUserSession(sessionToken) + + if (!sessionValidation) { + logger.security(`🔒 Invalid user session token attempt from ${req.ip || 'unknown'}`) + return res.status(401).json({ + error: 'Invalid session token', + message: 'Invalid or expired user session' + }) + } + + const { session, user } = sessionValidation + + // 检查用户是否被禁用 + if (!user.isActive) { + logger.security( + `🔒 Disabled user login attempt: ${user.username} from ${req.ip || 'unknown'}` + ) + return res.status(403).json({ + error: 'Account disabled', + message: 'Your account has been disabled. Please contact administrator.' + }) + } + + // 设置用户信息(只包含必要信息) + req.user = { + id: user.id, + username: user.username, + email: user.email, + displayName: user.displayName, + firstName: user.firstName, + lastName: user.lastName, + role: user.role, + sessionToken, + sessionCreatedAt: session.createdAt + } + + const authDuration = Date.now() - startTime + logger.info(`👤 User authenticated: ${user.username} (${user.id}) in ${authDuration}ms`) + + return next() + } catch (error) { + const authDuration = Date.now() - startTime + logger.error(`❌ User authentication error (${authDuration}ms):`, { + error: error.message, + ip: req.ip, + userAgent: req.get('User-Agent'), + url: req.originalUrl + }) + + return res.status(500).json({ + error: 'Authentication error', + message: 'Internal server error during user authentication' + }) + } +} + +// 👤 用户或管理员验证中间件(支持两种身份) +const authenticateUserOrAdmin = async (req, res, next) => { + const startTime = Date.now() + + try { + // 检查是否有管理员token + const adminToken = + req.headers['authorization']?.replace(/^Bearer\s+/i, '') || + req.cookies?.adminToken || + req.headers['x-admin-token'] + + // 检查是否有用户session token + const userToken = + req.headers['x-user-token'] || + req.cookies?.userToken || + (!adminToken ? req.headers['authorization']?.replace(/^Bearer\s+/i, '') : null) + + // 优先尝试管理员认证 + if (adminToken) { + try { + const adminSession = await redis.getSession(adminToken) + if (adminSession && Object.keys(adminSession).length > 0) { + req.admin = { + id: adminSession.adminId || 'admin', + username: adminSession.username, + sessionId: adminToken, + loginTime: adminSession.loginTime + } + req.userType = 'admin' + + const authDuration = Date.now() - startTime + logger.security(`🔐 Admin authenticated: ${adminSession.username} in ${authDuration}ms`) + return next() + } + } catch (error) { + logger.debug('Admin authentication failed, trying user authentication:', error.message) + } + } + + // 尝试用户认证 + if (userToken) { + try { + const sessionValidation = await userService.validateUserSession(userToken) + if (sessionValidation) { + const { session, user } = sessionValidation + + if (user.isActive) { + req.user = { + id: user.id, + username: user.username, + email: user.email, + displayName: user.displayName, + firstName: user.firstName, + lastName: user.lastName, + role: user.role, + sessionToken: userToken, + sessionCreatedAt: session.createdAt + } + req.userType = 'user' + + const authDuration = Date.now() - startTime + logger.info(`👤 User authenticated: ${user.username} (${user.id}) in ${authDuration}ms`) + return next() + } + } + } catch (error) { + logger.debug('User authentication failed:', error.message) + } + } + + // 如果都失败了,返回未授权 + logger.security(`🔒 Authentication failed from ${req.ip || 'unknown'}`) + return res.status(401).json({ + error: 'Authentication required', + message: 'Please login as user or admin to access this resource' + }) + } catch (error) { + const authDuration = Date.now() - startTime + logger.error(`❌ User/Admin authentication error (${authDuration}ms):`, { + error: error.message, + ip: req.ip, + userAgent: req.get('User-Agent'), + url: req.originalUrl + }) + + return res.status(500).json({ + error: 'Authentication error', + message: 'Internal server error during authentication' + }) + } +} + +// 🛡️ 权限检查中间件 +const requireRole = (allowedRoles) => (req, res, next) => { + // 管理员始终有权限 + if (req.admin) { + return next() + } + + // 检查用户角色 + if (req.user) { + const userRole = req.user.role + const allowed = Array.isArray(allowedRoles) ? allowedRoles : [allowedRoles] + + if (allowed.includes(userRole)) { + return next() + } else { + logger.security( + `🚫 Access denied for user ${req.user.username} (role: ${userRole}) to ${req.originalUrl}` + ) + return res.status(403).json({ + error: 'Insufficient permissions', + message: `This resource requires one of the following roles: ${allowed.join(', ')}` + }) + } + } + + return res.status(401).json({ + error: 'Authentication required', + message: 'Please login to access this resource' + }) +} + +// 🔒 管理员权限检查中间件 +const requireAdmin = (req, res, next) => { + if (req.admin) { + return next() + } + + // 检查是否是admin角色的用户 + if (req.user && req.user.role === 'admin') { + return next() + } + + logger.security( + `🚫 Admin access denied for ${req.user?.username || 'unknown'} from ${req.ip || 'unknown'}` + ) + return res.status(403).json({ + error: 'Admin access required', + message: 'This resource requires administrator privileges' + }) +} + +// 注意:使用统计现在直接在/api/v1/messages路由中处理, +// 以便从Claude API响应中提取真实的usage数据 + +// 🚦 CORS中间件(优化版,支持Chrome插件) +const corsMiddleware = (req, res, next) => { + const { origin } = req.headers + + // 允许的源(可以从配置文件读取) + const allowedOrigins = [ + 'http://localhost:3000', + 'https://localhost:3000', + 'http://127.0.0.1:3000', + 'https://127.0.0.1:3000' + ] + + // 🆕 检查是否为Chrome插件请求 + const isChromeExtension = origin && origin.startsWith('chrome-extension://') + + // 设置CORS头 + if (allowedOrigins.includes(origin) || !origin || isChromeExtension) { + res.header('Access-Control-Allow-Origin', origin || '*') + } + + res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') + res.header( + 'Access-Control-Allow-Headers', + [ + 'Origin', + 'X-Requested-With', + 'Content-Type', + 'Accept', + 'Authorization', + 'x-api-key', + 'x-goog-api-key', + 'api-key', + 'x-admin-token', + 'anthropic-version', + 'anthropic-dangerous-direct-browser-access' + ].join(', ') + ) + + res.header('Access-Control-Expose-Headers', ['X-Request-ID', 'Content-Type'].join(', ')) + + res.header('Access-Control-Max-Age', '86400') // 24小时预检缓存 + res.header('Access-Control-Allow-Credentials', 'true') + + if (req.method === 'OPTIONS') { + res.status(204).end() + } else { + next() + } +} + +// 📝 请求日志中间件(优化版) +const requestLogger = (req, res, next) => { + const start = Date.now() + const requestId = Math.random().toString(36).substring(2, 15) + + // 添加请求ID到请求对象 + req.requestId = requestId + res.setHeader('X-Request-ID', requestId) + + // 获取客户端信息 + const clientIP = req.ip || req.connection?.remoteAddress || req.socket?.remoteAddress || 'unknown' + const userAgent = req.get('User-Agent') || 'unknown' + const referer = req.get('Referer') || 'none' + + // 记录请求开始 + if (req.originalUrl !== '/health') { + // 避免健康检查日志过多 + logger.info(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`) + } + + res.on('finish', () => { + const duration = Date.now() - start + const contentLength = res.get('Content-Length') || '0' + + // 构建日志元数据 + const logMetadata = { + requestId, + method: req.method, + url: req.originalUrl, + status: res.statusCode, + duration, + contentLength, + ip: clientIP, + userAgent, + referer + } + + // 根据状态码选择日志级别 + if (res.statusCode >= 500) { + logger.error( + `◀️ [${requestId}] ${req.method} ${req.originalUrl} | ${res.statusCode} | ${duration}ms | ${contentLength}B`, + logMetadata + ) + } else if (res.statusCode >= 400) { + logger.warn( + `◀️ [${requestId}] ${req.method} ${req.originalUrl} | ${res.statusCode} | ${duration}ms | ${contentLength}B`, + logMetadata + ) + } else if (req.originalUrl !== '/health') { + logger.request(req.method, req.originalUrl, res.statusCode, duration, logMetadata) + } + + // API Key相关日志 + if (req.apiKey) { + logger.api( + `📱 [${requestId}] Request from ${req.apiKey.name} (${req.apiKey.id}) | ${duration}ms` + ) + } + + // 慢请求警告 + if (duration > 5000) { + logger.warn( + `🐌 [${requestId}] Slow request detected: ${duration}ms for ${req.method} ${req.originalUrl}` + ) + } + }) + + res.on('error', (error) => { + const duration = Date.now() - start + logger.error(`💥 [${requestId}] Response error after ${duration}ms:`, error) + }) + + next() +} + +// 🛡️ 安全中间件(增强版) +const securityMiddleware = (req, res, next) => { + // 设置基础安全头 + res.setHeader('X-Content-Type-Options', 'nosniff') + res.setHeader('X-Frame-Options', 'DENY') + res.setHeader('X-XSS-Protection', '1; mode=block') + res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin') + + // 添加更多安全头 + res.setHeader('X-DNS-Prefetch-Control', 'off') + res.setHeader('X-Download-Options', 'noopen') + res.setHeader('X-Permitted-Cross-Domain-Policies', 'none') + + // Cross-Origin-Opener-Policy (仅对可信来源设置) + const host = req.get('host') || '' + const isLocalhost = + host.includes('localhost') || host.includes('127.0.0.1') || host.includes('0.0.0.0') + const isHttps = req.secure || req.headers['x-forwarded-proto'] === 'https' + + if (isLocalhost || isHttps) { + res.setHeader('Cross-Origin-Opener-Policy', 'same-origin') + res.setHeader('Cross-Origin-Resource-Policy', 'same-origin') + res.setHeader('Origin-Agent-Cluster', '?1') + } + + // Content Security Policy (适用于web界面) + if (req.path.startsWith('/web') || req.path === '/') { + res.setHeader( + 'Content-Security-Policy', + [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://unpkg.com https://cdn.tailwindcss.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://cdn.bootcdn.net", + "style-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com https://cdnjs.cloudflare.com https://cdn.bootcdn.net", + "font-src 'self' https://cdnjs.cloudflare.com https://cdn.bootcdn.net", + "img-src 'self' data:", + "connect-src 'self'", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'" + ].join('; ') + ) + } + + // Strict Transport Security (HTTPS) + if (req.secure || req.headers['x-forwarded-proto'] === 'https') { + res.setHeader('Strict-Transport-Security', 'max-age=15552000; includeSubDomains') + } + + // 移除泄露服务器信息的头 + res.removeHeader('X-Powered-By') + res.removeHeader('Server') + + // 防止信息泄露 + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate') + res.setHeader('Pragma', 'no-cache') + res.setHeader('Expires', '0') + + next() +} + +// 🚨 错误处理中间件(增强版) +const errorHandler = (error, req, res, _next) => { + const requestId = req.requestId || 'unknown' + const isDevelopment = process.env.NODE_ENV === 'development' + + // 记录详细错误信息 + logger.error(`💥 [${requestId}] Unhandled error:`, { + error: error.message, + stack: error.stack, + url: req.originalUrl, + method: req.method, + ip: req.ip || 'unknown', + userAgent: req.get('User-Agent') || 'unknown', + apiKey: req.apiKey ? req.apiKey.id : 'none', + admin: req.admin ? req.admin.username : 'none' + }) + + // 确定HTTP状态码 + let statusCode = 500 + let errorMessage = 'Internal Server Error' + let userMessage = 'Something went wrong' + + if (error.status && error.status >= 400 && error.status < 600) { + statusCode = error.status + } + + // 根据错误类型提供友好的错误消息 + switch (error.name) { + case 'ValidationError': + statusCode = 400 + errorMessage = 'Validation Error' + userMessage = 'Invalid input data' + break + case 'CastError': + statusCode = 400 + errorMessage = 'Cast Error' + userMessage = 'Invalid data format' + break + case 'MongoError': + case 'RedisError': + statusCode = 503 + errorMessage = 'Database Error' + userMessage = 'Database temporarily unavailable' + break + case 'TimeoutError': + statusCode = 408 + errorMessage = 'Request Timeout' + userMessage = 'Request took too long to process' + break + default: + if (error.message && !isDevelopment) { + // 在生产环境中,只显示安全的错误消息 + if (error.message.includes('ECONNREFUSED')) { + userMessage = 'Service temporarily unavailable' + } else if (error.message.includes('timeout')) { + userMessage = 'Request timeout' + } + } + } + + // 设置响应头 + res.setHeader('X-Request-ID', requestId) + + // 构建错误响应 + const errorResponse = { + error: errorMessage, + message: isDevelopment ? error.message : userMessage, + requestId, + timestamp: new Date().toISOString() + } + + // 在开发环境中包含更多调试信息 + if (isDevelopment) { + errorResponse.stack = error.stack + errorResponse.url = req.originalUrl + errorResponse.method = req.method + } + + res.status(statusCode).json(errorResponse) +} + +// 🌐 全局速率限制中间件(延迟初始化) +// const rateLimiter = null // 暂时未使用 + +// 暂时注释掉未使用的函数 +// const getRateLimiter = () => { +// if (!rateLimiter) { +// try { +// const client = redis.getClient() +// if (!client) { +// logger.warn('⚠️ Redis client not available for rate limiter') +// return null +// } +// +// rateLimiter = new RateLimiterRedis({ +// storeClient: client, +// keyPrefix: 'global_rate_limit', +// points: 1000, // 请求数量 +// duration: 900, // 15分钟 (900秒) +// blockDuration: 900 // 阻塞时间15分钟 +// }) +// +// logger.info('✅ Rate limiter initialized successfully') +// } catch (error) { +// logger.warn('⚠️ Rate limiter initialization failed, using fallback', { error: error.message }) +// return null +// } +// } +// return rateLimiter +// } + +const globalRateLimit = async (req, res, next) => + // 已禁用全局IP限流 - 直接跳过所有请求 + next() + +// 以下代码已被禁用 +/* + // 跳过健康检查和内部请求 + if (req.path === '/health' || req.path === '/api/health') { + return next() + } + + const limiter = getRateLimiter() + if (!limiter) { + // 如果Redis不可用,直接跳过速率限制 + return next() + } + + const clientIP = req.ip || req.connection?.remoteAddress || 'unknown' + + try { + await limiter.consume(clientIP) + return next() + } catch (rejRes) { + const remainingPoints = rejRes.remainingPoints || 0 + const msBeforeNext = rejRes.msBeforeNext || 900000 + + logger.security(`🚦 Global rate limit exceeded for IP: ${clientIP}`) + + res.set({ + 'Retry-After': Math.round(msBeforeNext / 1000) || 900, + 'X-RateLimit-Limit': 1000, + 'X-RateLimit-Remaining': remainingPoints, + 'X-RateLimit-Reset': new Date(Date.now() + msBeforeNext).toISOString() + }) + + return res.status(429).json({ + error: 'Too Many Requests', + message: 'Too many requests from this IP, please try again later.', + retryAfter: Math.round(msBeforeNext / 1000) + }) + } + */ + +// 📊 请求大小限制中间件 +const requestSizeLimit = (req, res, next) => { + const maxSize = 60 * 1024 * 1024 // 60MB + const contentLength = parseInt(req.headers['content-length'] || '0') + + if (contentLength > maxSize) { + logger.security(`🚨 Request too large: ${contentLength} bytes from ${req.ip}`) + return res.status(413).json({ + error: 'Payload Too Large', + message: 'Request body size exceeds limit', + limit: '10MB' + }) + } + + return next() +} + +module.exports = { + authenticateApiKey, + authenticateAdmin, + authenticateUser, + authenticateUserOrAdmin, + requireRole, + requireAdmin, + corsMiddleware, + requestLogger, + securityMiddleware, + errorHandler, + globalRateLimit, + requestSizeLimit +} diff --git a/src/middleware/browserFallback.js b/src/middleware/browserFallback.js new file mode 100644 index 0000000000000000000000000000000000000000..ed82532c204526bd84d5cdbacc815688ee92a655 --- /dev/null +++ b/src/middleware/browserFallback.js @@ -0,0 +1,78 @@ +const logger = require('../utils/logger') + +/** + * 浏览器/Chrome插件兜底中间件 + * 专门处理第三方插件的兼容性问题 + */ +const browserFallbackMiddleware = (req, res, next) => { + const userAgent = req.headers['user-agent'] || '' + const origin = req.headers['origin'] || '' + + const extractHeader = (value) => { + let candidate = value + + if (Array.isArray(candidate)) { + candidate = candidate.find((item) => typeof item === 'string' && item.trim()) + } + + if (typeof candidate !== 'string') { + return '' + } + + let trimmed = candidate.trim() + if (!trimmed) { + return '' + } + + if (/^Bearer\s+/i.test(trimmed)) { + trimmed = trimmed.replace(/^Bearer\s+/i, '').trim() + } + + return trimmed + } + + const apiKeyHeader = + extractHeader(req.headers['x-api-key']) || extractHeader(req.headers['x-goog-api-key']) + const normalizedKey = extractHeader(req.headers['authorization']) || apiKeyHeader + + // 检查是否为Chrome插件或浏览器请求 + const isChromeExtension = origin.startsWith('chrome-extension://') + const isBrowserRequest = userAgent.includes('Mozilla/') && userAgent.includes('Chrome/') + const hasApiKey = normalizedKey.startsWith('cr_') // 我们的API Key格式 + + if ((isChromeExtension || isBrowserRequest) && hasApiKey) { + // 为Chrome插件请求添加特殊标记 + req.isBrowserFallback = true + req.originalUserAgent = userAgent + + // 🆕 关键修改:伪装成claude-cli请求以绕过客户端限制 + req.headers['user-agent'] = 'claude-cli/1.0.110 (external, cli, browser-fallback)' + + // 确保设置正确的认证头 + if (!req.headers['authorization'] && apiKeyHeader) { + req.headers['authorization'] = `Bearer ${apiKeyHeader}` + } + + // 添加必要的Anthropic头 + if (!req.headers['anthropic-version']) { + req.headers['anthropic-version'] = '2023-06-01' + } + + if (!req.headers['anthropic-dangerous-direct-browser-access']) { + req.headers['anthropic-dangerous-direct-browser-access'] = 'true' + } + + logger.api( + `🔧 Browser fallback activated for ${isChromeExtension ? 'Chrome extension' : 'browser'} request` + ) + logger.api(` Original User-Agent: "${req.originalUserAgent}"`) + logger.api(` Origin: "${origin}"`) + logger.api(` Modified User-Agent: "${req.headers['user-agent']}"`) + } + + next() +} + +module.exports = { + browserFallbackMiddleware +} diff --git a/src/models/redis.js b/src/models/redis.js new file mode 100644 index 0000000000000000000000000000000000000000..ea917c28c983da466f6b95b002aadbf084cefffb --- /dev/null +++ b/src/models/redis.js @@ -0,0 +1,1998 @@ +const Redis = require('ioredis') +const config = require('../../config/config') +const logger = require('../utils/logger') + +// 时区辅助函数 +// 注意:这个函数的目的是获取某个时间点在目标时区的"本地"表示 +// 例如:UTC时间 2025-07-30 01:00:00 在 UTC+8 时区表示为 2025-07-30 09:00:00 +function getDateInTimezone(date = new Date()) { + const offset = config.system.timezoneOffset || 8 // 默认UTC+8 + + // 方法:创建一个偏移后的Date对象,使其getUTCXXX方法返回目标时区的值 + // 这样我们可以用getUTCFullYear()等方法获取目标时区的年月日时分秒 + const offsetMs = offset * 3600000 // 时区偏移的毫秒数 + const adjustedTime = new Date(date.getTime() + offsetMs) + + return adjustedTime +} + +// 获取配置时区的日期字符串 (YYYY-MM-DD) +function getDateStringInTimezone(date = new Date()) { + const tzDate = getDateInTimezone(date) + // 使用UTC方法获取偏移后的日期部分 + return `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}-${String( + tzDate.getUTCDate() + ).padStart(2, '0')}` +} + +// 获取配置时区的小时 (0-23) +function getHourInTimezone(date = new Date()) { + const tzDate = getDateInTimezone(date) + return tzDate.getUTCHours() +} + +// 获取配置时区的 ISO 周(YYYY-Wxx 格式,周一到周日) +function getWeekStringInTimezone(date = new Date()) { + const tzDate = getDateInTimezone(date) + + // 获取年份 + const year = tzDate.getUTCFullYear() + + // 计算 ISO 周数(周一为第一天) + const dateObj = new Date(tzDate) + const dayOfWeek = dateObj.getUTCDay() || 7 // 将周日(0)转换为7 + const firstThursday = new Date(dateObj) + firstThursday.setUTCDate(dateObj.getUTCDate() + 4 - dayOfWeek) // 找到这周的周四 + + const yearStart = new Date(firstThursday.getUTCFullYear(), 0, 1) + const weekNumber = Math.ceil(((firstThursday - yearStart) / 86400000 + 1) / 7) + + return `${year}-W${String(weekNumber).padStart(2, '0')}` +} + +class RedisClient { + constructor() { + this.client = null + this.isConnected = false + } + + async connect() { + try { + this.client = new Redis({ + host: config.redis.host, + port: config.redis.port, + password: config.redis.password, + db: config.redis.db, + retryDelayOnFailover: config.redis.retryDelayOnFailover, + maxRetriesPerRequest: config.redis.maxRetriesPerRequest, + lazyConnect: config.redis.lazyConnect, + tls: config.redis.enableTLS ? {} : false + }) + + this.client.on('connect', () => { + this.isConnected = true + logger.info('🔗 Redis connected successfully') + }) + + this.client.on('error', (err) => { + this.isConnected = false + logger.error('❌ Redis connection error:', err) + }) + + this.client.on('close', () => { + this.isConnected = false + logger.warn('⚠️ Redis connection closed') + }) + + await this.client.connect() + return this.client + } catch (error) { + logger.error('💥 Failed to connect to Redis:', error) + throw error + } + } + + async disconnect() { + if (this.client) { + await this.client.quit() + this.isConnected = false + logger.info('👋 Redis disconnected') + } + } + + getClient() { + if (!this.client || !this.isConnected) { + logger.warn('⚠️ Redis client is not connected') + return null + } + return this.client + } + + // 安全获取客户端(用于关键操作) + getClientSafe() { + if (!this.client || !this.isConnected) { + throw new Error('Redis client is not connected') + } + return this.client + } + + // 🔑 API Key 相关操作 + async setApiKey(keyId, keyData, hashedKey = null) { + const key = `apikey:${keyId}` + const client = this.getClientSafe() + + // 维护哈希映射表(用于快速查找) + // hashedKey参数是实际的哈希值,用于建立映射 + if (hashedKey) { + await client.hset('apikey:hash_map', hashedKey, keyId) + } + + await client.hset(key, keyData) + await client.expire(key, 86400 * 365) // 1年过期 + } + + async getApiKey(keyId) { + const key = `apikey:${keyId}` + return await this.client.hgetall(key) + } + + async deleteApiKey(keyId) { + const key = `apikey:${keyId}` + + // 获取要删除的API Key哈希值,以便从映射表中移除 + const keyData = await this.client.hgetall(key) + if (keyData && keyData.apiKey) { + // keyData.apiKey现在存储的是哈希值,直接从映射表删除 + await this.client.hdel('apikey:hash_map', keyData.apiKey) + } + + return await this.client.del(key) + } + + async getAllApiKeys() { + const keys = await this.client.keys('apikey:*') + const apiKeys = [] + for (const key of keys) { + // 过滤掉hash_map,它不是真正的API Key + if (key === 'apikey:hash_map') { + continue + } + + const keyData = await this.client.hgetall(key) + if (keyData && Object.keys(keyData).length > 0) { + apiKeys.push({ id: key.replace('apikey:', ''), ...keyData }) + } + } + return apiKeys + } + + // 🔍 通过哈希值查找API Key(性能优化) + async findApiKeyByHash(hashedKey) { + // 使用反向映射表:hash -> keyId + const keyId = await this.client.hget('apikey:hash_map', hashedKey) + if (!keyId) { + return null + } + + const keyData = await this.client.hgetall(`apikey:${keyId}`) + if (keyData && Object.keys(keyData).length > 0) { + return { id: keyId, ...keyData } + } + + // 如果数据不存在,清理映射表 + await this.client.hdel('apikey:hash_map', hashedKey) + return null + } + + // 📊 使用统计相关操作(支持缓存token统计和模型信息) + // 标准化模型名称,用于统计聚合 + _normalizeModelName(model) { + if (!model || model === 'unknown') { + return model + } + + // 对于Bedrock模型,去掉区域前缀进行统一 + if (model.includes('.anthropic.') || model.includes('.claude')) { + // 匹配所有AWS区域格式:region.anthropic.model-name-v1:0 -> claude-model-name + // 支持所有AWS区域格式,如:us-east-1, eu-west-1, ap-southeast-1, ca-central-1等 + let normalized = model.replace(/^[a-z0-9-]+\./, '') // 去掉任何区域前缀(更通用) + normalized = normalized.replace('anthropic.', '') // 去掉anthropic前缀 + normalized = normalized.replace(/-v\d+:\d+$/, '') // 去掉版本后缀(如-v1:0, -v2:1等) + return normalized + } + + // 对于其他模型,去掉常见的版本后缀 + return model.replace(/-v\d+:\d+$|:latest$/, '') + } + + async incrementTokenUsage( + keyId, + tokens, + inputTokens = 0, + outputTokens = 0, + cacheCreateTokens = 0, + cacheReadTokens = 0, + model = 'unknown', + ephemeral5mTokens = 0, // 新增:5分钟缓存 tokens + ephemeral1hTokens = 0, // 新增:1小时缓存 tokens + isLongContextRequest = false // 新增:是否为 1M 上下文请求(超过200k) + ) { + const key = `usage:${keyId}` + const now = new Date() + const today = getDateStringInTimezone(now) + const tzDate = getDateInTimezone(now) + const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( + 2, + '0' + )}` + const currentHour = `${today}:${String(getHourInTimezone(now)).padStart(2, '0')}` // 新增小时级别 + + const daily = `usage:daily:${keyId}:${today}` + const monthly = `usage:monthly:${keyId}:${currentMonth}` + const hourly = `usage:hourly:${keyId}:${currentHour}` // 新增小时级别key + + // 标准化模型名用于统计聚合 + const normalizedModel = this._normalizeModelName(model) + + // 按模型统计的键 + const modelDaily = `usage:model:daily:${normalizedModel}:${today}` + const modelMonthly = `usage:model:monthly:${normalizedModel}:${currentMonth}` + const modelHourly = `usage:model:hourly:${normalizedModel}:${currentHour}` // 新增模型小时级别 + + // API Key级别的模型统计 + const keyModelDaily = `usage:${keyId}:model:daily:${normalizedModel}:${today}` + const keyModelMonthly = `usage:${keyId}:model:monthly:${normalizedModel}:${currentMonth}` + const keyModelHourly = `usage:${keyId}:model:hourly:${normalizedModel}:${currentHour}` // 新增API Key模型小时级别 + + // 新增:系统级分钟统计 + const minuteTimestamp = Math.floor(now.getTime() / 60000) + const systemMinuteKey = `system:metrics:minute:${minuteTimestamp}` + + // 智能处理输入输出token分配 + const finalInputTokens = inputTokens || 0 + const finalOutputTokens = outputTokens || (finalInputTokens > 0 ? 0 : tokens) + const finalCacheCreateTokens = cacheCreateTokens || 0 + const finalCacheReadTokens = cacheReadTokens || 0 + + // 重新计算真实的总token数(包括缓存token) + const totalTokens = + finalInputTokens + finalOutputTokens + finalCacheCreateTokens + finalCacheReadTokens + // 核心token(不包括缓存)- 用于与历史数据兼容 + const coreTokens = finalInputTokens + finalOutputTokens + + // 使用Pipeline优化性能 + const pipeline = this.client.pipeline() + + // 现有的统计保持不变 + // 核心token统计(保持向后兼容) + pipeline.hincrby(key, 'totalTokens', coreTokens) + pipeline.hincrby(key, 'totalInputTokens', finalInputTokens) + pipeline.hincrby(key, 'totalOutputTokens', finalOutputTokens) + // 缓存token统计(新增) + pipeline.hincrby(key, 'totalCacheCreateTokens', finalCacheCreateTokens) + pipeline.hincrby(key, 'totalCacheReadTokens', finalCacheReadTokens) + pipeline.hincrby(key, 'totalAllTokens', totalTokens) // 包含所有类型的总token + // 详细缓存类型统计(新增) + pipeline.hincrby(key, 'totalEphemeral5mTokens', ephemeral5mTokens) + pipeline.hincrby(key, 'totalEphemeral1hTokens', ephemeral1hTokens) + // 1M 上下文请求统计(新增) + if (isLongContextRequest) { + pipeline.hincrby(key, 'totalLongContextInputTokens', finalInputTokens) + pipeline.hincrby(key, 'totalLongContextOutputTokens', finalOutputTokens) + pipeline.hincrby(key, 'totalLongContextRequests', 1) + } + // 请求计数 + pipeline.hincrby(key, 'totalRequests', 1) + + // 每日统计 + pipeline.hincrby(daily, 'tokens', coreTokens) + pipeline.hincrby(daily, 'inputTokens', finalInputTokens) + pipeline.hincrby(daily, 'outputTokens', finalOutputTokens) + pipeline.hincrby(daily, 'cacheCreateTokens', finalCacheCreateTokens) + pipeline.hincrby(daily, 'cacheReadTokens', finalCacheReadTokens) + pipeline.hincrby(daily, 'allTokens', totalTokens) + pipeline.hincrby(daily, 'requests', 1) + // 详细缓存类型统计 + pipeline.hincrby(daily, 'ephemeral5mTokens', ephemeral5mTokens) + pipeline.hincrby(daily, 'ephemeral1hTokens', ephemeral1hTokens) + // 1M 上下文请求统计 + if (isLongContextRequest) { + pipeline.hincrby(daily, 'longContextInputTokens', finalInputTokens) + pipeline.hincrby(daily, 'longContextOutputTokens', finalOutputTokens) + pipeline.hincrby(daily, 'longContextRequests', 1) + } + + // 每月统计 + pipeline.hincrby(monthly, 'tokens', coreTokens) + pipeline.hincrby(monthly, 'inputTokens', finalInputTokens) + pipeline.hincrby(monthly, 'outputTokens', finalOutputTokens) + pipeline.hincrby(monthly, 'cacheCreateTokens', finalCacheCreateTokens) + pipeline.hincrby(monthly, 'cacheReadTokens', finalCacheReadTokens) + pipeline.hincrby(monthly, 'allTokens', totalTokens) + pipeline.hincrby(monthly, 'requests', 1) + // 详细缓存类型统计 + pipeline.hincrby(monthly, 'ephemeral5mTokens', ephemeral5mTokens) + pipeline.hincrby(monthly, 'ephemeral1hTokens', ephemeral1hTokens) + + // 按模型统计 - 每日 + pipeline.hincrby(modelDaily, 'inputTokens', finalInputTokens) + pipeline.hincrby(modelDaily, 'outputTokens', finalOutputTokens) + pipeline.hincrby(modelDaily, 'cacheCreateTokens', finalCacheCreateTokens) + pipeline.hincrby(modelDaily, 'cacheReadTokens', finalCacheReadTokens) + pipeline.hincrby(modelDaily, 'allTokens', totalTokens) + pipeline.hincrby(modelDaily, 'requests', 1) + + // 按模型统计 - 每月 + pipeline.hincrby(modelMonthly, 'inputTokens', finalInputTokens) + pipeline.hincrby(modelMonthly, 'outputTokens', finalOutputTokens) + pipeline.hincrby(modelMonthly, 'cacheCreateTokens', finalCacheCreateTokens) + pipeline.hincrby(modelMonthly, 'cacheReadTokens', finalCacheReadTokens) + pipeline.hincrby(modelMonthly, 'allTokens', totalTokens) + pipeline.hincrby(modelMonthly, 'requests', 1) + + // API Key级别的模型统计 - 每日 + pipeline.hincrby(keyModelDaily, 'inputTokens', finalInputTokens) + pipeline.hincrby(keyModelDaily, 'outputTokens', finalOutputTokens) + pipeline.hincrby(keyModelDaily, 'cacheCreateTokens', finalCacheCreateTokens) + pipeline.hincrby(keyModelDaily, 'cacheReadTokens', finalCacheReadTokens) + pipeline.hincrby(keyModelDaily, 'allTokens', totalTokens) + pipeline.hincrby(keyModelDaily, 'requests', 1) + // 详细缓存类型统计 + pipeline.hincrby(keyModelDaily, 'ephemeral5mTokens', ephemeral5mTokens) + pipeline.hincrby(keyModelDaily, 'ephemeral1hTokens', ephemeral1hTokens) + + // API Key级别的模型统计 - 每月 + pipeline.hincrby(keyModelMonthly, 'inputTokens', finalInputTokens) + pipeline.hincrby(keyModelMonthly, 'outputTokens', finalOutputTokens) + pipeline.hincrby(keyModelMonthly, 'cacheCreateTokens', finalCacheCreateTokens) + pipeline.hincrby(keyModelMonthly, 'cacheReadTokens', finalCacheReadTokens) + pipeline.hincrby(keyModelMonthly, 'allTokens', totalTokens) + pipeline.hincrby(keyModelMonthly, 'requests', 1) + // 详细缓存类型统计 + pipeline.hincrby(keyModelMonthly, 'ephemeral5mTokens', ephemeral5mTokens) + pipeline.hincrby(keyModelMonthly, 'ephemeral1hTokens', ephemeral1hTokens) + + // 小时级别统计 + pipeline.hincrby(hourly, 'tokens', coreTokens) + pipeline.hincrby(hourly, 'inputTokens', finalInputTokens) + pipeline.hincrby(hourly, 'outputTokens', finalOutputTokens) + pipeline.hincrby(hourly, 'cacheCreateTokens', finalCacheCreateTokens) + pipeline.hincrby(hourly, 'cacheReadTokens', finalCacheReadTokens) + pipeline.hincrby(hourly, 'allTokens', totalTokens) + pipeline.hincrby(hourly, 'requests', 1) + + // 按模型统计 - 每小时 + pipeline.hincrby(modelHourly, 'inputTokens', finalInputTokens) + pipeline.hincrby(modelHourly, 'outputTokens', finalOutputTokens) + pipeline.hincrby(modelHourly, 'cacheCreateTokens', finalCacheCreateTokens) + pipeline.hincrby(modelHourly, 'cacheReadTokens', finalCacheReadTokens) + pipeline.hincrby(modelHourly, 'allTokens', totalTokens) + pipeline.hincrby(modelHourly, 'requests', 1) + + // API Key级别的模型统计 - 每小时 + pipeline.hincrby(keyModelHourly, 'inputTokens', finalInputTokens) + pipeline.hincrby(keyModelHourly, 'outputTokens', finalOutputTokens) + pipeline.hincrby(keyModelHourly, 'cacheCreateTokens', finalCacheCreateTokens) + pipeline.hincrby(keyModelHourly, 'cacheReadTokens', finalCacheReadTokens) + pipeline.hincrby(keyModelHourly, 'allTokens', totalTokens) + pipeline.hincrby(keyModelHourly, 'requests', 1) + + // 新增:系统级分钟统计 + pipeline.hincrby(systemMinuteKey, 'requests', 1) + pipeline.hincrby(systemMinuteKey, 'totalTokens', totalTokens) + pipeline.hincrby(systemMinuteKey, 'inputTokens', finalInputTokens) + pipeline.hincrby(systemMinuteKey, 'outputTokens', finalOutputTokens) + pipeline.hincrby(systemMinuteKey, 'cacheCreateTokens', finalCacheCreateTokens) + pipeline.hincrby(systemMinuteKey, 'cacheReadTokens', finalCacheReadTokens) + + // 设置过期时间 + pipeline.expire(daily, 86400 * 32) // 32天过期 + pipeline.expire(monthly, 86400 * 365) // 1年过期 + pipeline.expire(hourly, 86400 * 7) // 小时统计7天过期 + pipeline.expire(modelDaily, 86400 * 32) // 模型每日统计32天过期 + pipeline.expire(modelMonthly, 86400 * 365) // 模型每月统计1年过期 + pipeline.expire(modelHourly, 86400 * 7) // 模型小时统计7天过期 + pipeline.expire(keyModelDaily, 86400 * 32) // API Key模型每日统计32天过期 + pipeline.expire(keyModelMonthly, 86400 * 365) // API Key模型每月统计1年过期 + pipeline.expire(keyModelHourly, 86400 * 7) // API Key模型小时统计7天过期 + + // 系统级分钟统计的过期时间(窗口时间的2倍) + const configLocal = require('../../config/config') + const { metricsWindow } = configLocal.system + pipeline.expire(systemMinuteKey, metricsWindow * 60 * 2) + + // 执行Pipeline + await pipeline.exec() + } + + // 📊 记录账户级别的使用统计 + async incrementAccountUsage( + accountId, + totalTokens, + inputTokens = 0, + outputTokens = 0, + cacheCreateTokens = 0, + cacheReadTokens = 0, + model = 'unknown', + isLongContextRequest = false + ) { + const now = new Date() + const today = getDateStringInTimezone(now) + const tzDate = getDateInTimezone(now) + const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( + 2, + '0' + )}` + const currentHour = `${today}:${String(getHourInTimezone(now)).padStart(2, '0')}` + + // 账户级别统计的键 + const accountKey = `account_usage:${accountId}` + const accountDaily = `account_usage:daily:${accountId}:${today}` + const accountMonthly = `account_usage:monthly:${accountId}:${currentMonth}` + const accountHourly = `account_usage:hourly:${accountId}:${currentHour}` + + // 标准化模型名用于统计聚合 + const normalizedModel = this._normalizeModelName(model) + + // 账户按模型统计的键 + const accountModelDaily = `account_usage:model:daily:${accountId}:${normalizedModel}:${today}` + const accountModelMonthly = `account_usage:model:monthly:${accountId}:${normalizedModel}:${currentMonth}` + const accountModelHourly = `account_usage:model:hourly:${accountId}:${normalizedModel}:${currentHour}` + + // 处理token分配 + const finalInputTokens = inputTokens || 0 + const finalOutputTokens = outputTokens || 0 + const finalCacheCreateTokens = cacheCreateTokens || 0 + const finalCacheReadTokens = cacheReadTokens || 0 + const actualTotalTokens = + finalInputTokens + finalOutputTokens + finalCacheCreateTokens + finalCacheReadTokens + const coreTokens = finalInputTokens + finalOutputTokens + + // 构建统计操作数组 + const operations = [ + // 账户总体统计 + this.client.hincrby(accountKey, 'totalTokens', coreTokens), + this.client.hincrby(accountKey, 'totalInputTokens', finalInputTokens), + this.client.hincrby(accountKey, 'totalOutputTokens', finalOutputTokens), + this.client.hincrby(accountKey, 'totalCacheCreateTokens', finalCacheCreateTokens), + this.client.hincrby(accountKey, 'totalCacheReadTokens', finalCacheReadTokens), + this.client.hincrby(accountKey, 'totalAllTokens', actualTotalTokens), + this.client.hincrby(accountKey, 'totalRequests', 1), + + // 账户每日统计 + this.client.hincrby(accountDaily, 'tokens', coreTokens), + this.client.hincrby(accountDaily, 'inputTokens', finalInputTokens), + this.client.hincrby(accountDaily, 'outputTokens', finalOutputTokens), + this.client.hincrby(accountDaily, 'cacheCreateTokens', finalCacheCreateTokens), + this.client.hincrby(accountDaily, 'cacheReadTokens', finalCacheReadTokens), + this.client.hincrby(accountDaily, 'allTokens', actualTotalTokens), + this.client.hincrby(accountDaily, 'requests', 1), + + // 账户每月统计 + this.client.hincrby(accountMonthly, 'tokens', coreTokens), + this.client.hincrby(accountMonthly, 'inputTokens', finalInputTokens), + this.client.hincrby(accountMonthly, 'outputTokens', finalOutputTokens), + this.client.hincrby(accountMonthly, 'cacheCreateTokens', finalCacheCreateTokens), + this.client.hincrby(accountMonthly, 'cacheReadTokens', finalCacheReadTokens), + this.client.hincrby(accountMonthly, 'allTokens', actualTotalTokens), + this.client.hincrby(accountMonthly, 'requests', 1), + + // 账户每小时统计 + this.client.hincrby(accountHourly, 'tokens', coreTokens), + this.client.hincrby(accountHourly, 'inputTokens', finalInputTokens), + this.client.hincrby(accountHourly, 'outputTokens', finalOutputTokens), + this.client.hincrby(accountHourly, 'cacheCreateTokens', finalCacheCreateTokens), + this.client.hincrby(accountHourly, 'cacheReadTokens', finalCacheReadTokens), + this.client.hincrby(accountHourly, 'allTokens', actualTotalTokens), + this.client.hincrby(accountHourly, 'requests', 1), + + // 添加模型级别的数据到hourly键中,以支持会话窗口的统计 + this.client.hincrby(accountHourly, `model:${normalizedModel}:inputTokens`, finalInputTokens), + this.client.hincrby( + accountHourly, + `model:${normalizedModel}:outputTokens`, + finalOutputTokens + ), + this.client.hincrby( + accountHourly, + `model:${normalizedModel}:cacheCreateTokens`, + finalCacheCreateTokens + ), + this.client.hincrby( + accountHourly, + `model:${normalizedModel}:cacheReadTokens`, + finalCacheReadTokens + ), + this.client.hincrby(accountHourly, `model:${normalizedModel}:allTokens`, actualTotalTokens), + this.client.hincrby(accountHourly, `model:${normalizedModel}:requests`, 1), + + // 账户按模型统计 - 每日 + this.client.hincrby(accountModelDaily, 'inputTokens', finalInputTokens), + this.client.hincrby(accountModelDaily, 'outputTokens', finalOutputTokens), + this.client.hincrby(accountModelDaily, 'cacheCreateTokens', finalCacheCreateTokens), + this.client.hincrby(accountModelDaily, 'cacheReadTokens', finalCacheReadTokens), + this.client.hincrby(accountModelDaily, 'allTokens', actualTotalTokens), + this.client.hincrby(accountModelDaily, 'requests', 1), + + // 账户按模型统计 - 每月 + this.client.hincrby(accountModelMonthly, 'inputTokens', finalInputTokens), + this.client.hincrby(accountModelMonthly, 'outputTokens', finalOutputTokens), + this.client.hincrby(accountModelMonthly, 'cacheCreateTokens', finalCacheCreateTokens), + this.client.hincrby(accountModelMonthly, 'cacheReadTokens', finalCacheReadTokens), + this.client.hincrby(accountModelMonthly, 'allTokens', actualTotalTokens), + this.client.hincrby(accountModelMonthly, 'requests', 1), + + // 账户按模型统计 - 每小时 + this.client.hincrby(accountModelHourly, 'inputTokens', finalInputTokens), + this.client.hincrby(accountModelHourly, 'outputTokens', finalOutputTokens), + this.client.hincrby(accountModelHourly, 'cacheCreateTokens', finalCacheCreateTokens), + this.client.hincrby(accountModelHourly, 'cacheReadTokens', finalCacheReadTokens), + this.client.hincrby(accountModelHourly, 'allTokens', actualTotalTokens), + this.client.hincrby(accountModelHourly, 'requests', 1), + + // 设置过期时间 + this.client.expire(accountDaily, 86400 * 32), // 32天过期 + this.client.expire(accountMonthly, 86400 * 365), // 1年过期 + this.client.expire(accountHourly, 86400 * 7), // 7天过期 + this.client.expire(accountModelDaily, 86400 * 32), // 32天过期 + this.client.expire(accountModelMonthly, 86400 * 365), // 1年过期 + this.client.expire(accountModelHourly, 86400 * 7) // 7天过期 + ] + + // 如果是 1M 上下文请求,添加额外的统计 + if (isLongContextRequest) { + operations.push( + this.client.hincrby(accountKey, 'totalLongContextInputTokens', finalInputTokens), + this.client.hincrby(accountKey, 'totalLongContextOutputTokens', finalOutputTokens), + this.client.hincrby(accountKey, 'totalLongContextRequests', 1), + this.client.hincrby(accountDaily, 'longContextInputTokens', finalInputTokens), + this.client.hincrby(accountDaily, 'longContextOutputTokens', finalOutputTokens), + this.client.hincrby(accountDaily, 'longContextRequests', 1) + ) + } + + await Promise.all(operations) + } + + async getUsageStats(keyId) { + const totalKey = `usage:${keyId}` + const today = getDateStringInTimezone() + const dailyKey = `usage:daily:${keyId}:${today}` + const tzDate = getDateInTimezone() + const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( + 2, + '0' + )}` + const monthlyKey = `usage:monthly:${keyId}:${currentMonth}` + + const [total, daily, monthly] = await Promise.all([ + this.client.hgetall(totalKey), + this.client.hgetall(dailyKey), + this.client.hgetall(monthlyKey) + ]) + + // 获取API Key的创建时间来计算平均值 + const keyData = await this.client.hgetall(`apikey:${keyId}`) + const createdAt = keyData.createdAt ? new Date(keyData.createdAt) : new Date() + const now = new Date() + const daysSinceCreated = Math.max(1, Math.ceil((now - createdAt) / (1000 * 60 * 60 * 24))) + + const totalTokens = parseInt(total.totalTokens) || 0 + const totalRequests = parseInt(total.totalRequests) || 0 + + // 计算平均RPM (requests per minute) 和 TPM (tokens per minute) + const totalMinutes = Math.max(1, daysSinceCreated * 24 * 60) + const avgRPM = totalRequests / totalMinutes + const avgTPM = totalTokens / totalMinutes + + // 处理旧数据兼容性(支持缓存token) + const handleLegacyData = (data) => { + // 优先使用total*字段(存储时使用的字段) + const tokens = parseInt(data.totalTokens) || parseInt(data.tokens) || 0 + const inputTokens = parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0 + const outputTokens = parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0 + const requests = parseInt(data.totalRequests) || parseInt(data.requests) || 0 + + // 新增缓存token字段 + const cacheCreateTokens = + parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0 + const cacheReadTokens = + parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0 + const allTokens = parseInt(data.totalAllTokens) || parseInt(data.allTokens) || 0 + + const totalFromSeparate = inputTokens + outputTokens + // 计算实际的总tokens(包含所有类型) + const actualAllTokens = + allTokens || inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + + if (totalFromSeparate === 0 && tokens > 0) { + // 旧数据:没有输入输出分离 + return { + tokens, // 保持兼容性,但统一使用allTokens + inputTokens: Math.round(tokens * 0.3), // 假设30%为输入 + outputTokens: Math.round(tokens * 0.7), // 假设70%为输出 + cacheCreateTokens: 0, // 旧数据没有缓存token + cacheReadTokens: 0, + allTokens: tokens, // 对于旧数据,allTokens等于tokens + requests + } + } else { + // 新数据或无数据 - 统一使用allTokens作为tokens的值 + return { + tokens: actualAllTokens, // 统一使用allTokens作为总数 + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens, + allTokens: actualAllTokens, + requests + } + } + } + + const totalData = handleLegacyData(total) + const dailyData = handleLegacyData(daily) + const monthlyData = handleLegacyData(monthly) + + return { + total: totalData, + daily: dailyData, + monthly: monthlyData, + averages: { + rpm: Math.round(avgRPM * 100) / 100, // 保留2位小数 + tpm: Math.round(avgTPM * 100) / 100, + dailyRequests: Math.round((totalRequests / daysSinceCreated) * 100) / 100, + dailyTokens: Math.round((totalTokens / daysSinceCreated) * 100) / 100 + } + } + } + + async addUsageRecord(keyId, record, maxRecords = 200) { + const listKey = `usage:records:${keyId}` + const client = this.getClientSafe() + + try { + await client + .multi() + .lpush(listKey, JSON.stringify(record)) + .ltrim(listKey, 0, Math.max(0, maxRecords - 1)) + .expire(listKey, 86400 * 90) // 默认保留90天 + .exec() + } catch (error) { + logger.error(`❌ Failed to append usage record for key ${keyId}:`, error) + } + } + + async getUsageRecords(keyId, limit = 50) { + const listKey = `usage:records:${keyId}` + const client = this.getClient() + + if (!client) { + return [] + } + + try { + const rawRecords = await client.lrange(listKey, 0, Math.max(0, limit - 1)) + return rawRecords + .map((entry) => { + try { + return JSON.parse(entry) + } catch (error) { + logger.warn('⚠️ Failed to parse usage record entry:', error) + return null + } + }) + .filter(Boolean) + } catch (error) { + logger.error(`❌ Failed to load usage records for key ${keyId}:`, error) + return [] + } + } + + // 💰 获取当日费用 + async getDailyCost(keyId) { + const today = getDateStringInTimezone() + const costKey = `usage:cost:daily:${keyId}:${today}` + const cost = await this.client.get(costKey) + const result = parseFloat(cost || 0) + logger.debug( + `💰 Getting daily cost for ${keyId}, date: ${today}, key: ${costKey}, value: ${cost}, result: ${result}` + ) + return result + } + + // 💰 增加当日费用 + async incrementDailyCost(keyId, amount) { + const today = getDateStringInTimezone() + const tzDate = getDateInTimezone() + const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( + 2, + '0' + )}` + const currentHour = `${today}:${String(getHourInTimezone(new Date())).padStart(2, '0')}` + + const dailyKey = `usage:cost:daily:${keyId}:${today}` + const monthlyKey = `usage:cost:monthly:${keyId}:${currentMonth}` + const hourlyKey = `usage:cost:hourly:${keyId}:${currentHour}` + const totalKey = `usage:cost:total:${keyId}` + + logger.debug( + `💰 Incrementing cost for ${keyId}, amount: $${amount}, date: ${today}, dailyKey: ${dailyKey}` + ) + + const results = await Promise.all([ + this.client.incrbyfloat(dailyKey, amount), + this.client.incrbyfloat(monthlyKey, amount), + this.client.incrbyfloat(hourlyKey, amount), + this.client.incrbyfloat(totalKey, amount), + // 设置过期时间 + this.client.expire(dailyKey, 86400 * 30), // 30天 + this.client.expire(monthlyKey, 86400 * 90), // 90天 + this.client.expire(hourlyKey, 86400 * 7) // 7天 + ]) + + logger.debug(`💰 Cost incremented successfully, new daily total: $${results[0]}`) + } + + // 💰 获取费用统计 + async getCostStats(keyId) { + const today = getDateStringInTimezone() + const tzDate = getDateInTimezone() + const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( + 2, + '0' + )}` + const currentHour = `${today}:${String(getHourInTimezone(new Date())).padStart(2, '0')}` + + const [daily, monthly, hourly, total] = await Promise.all([ + this.client.get(`usage:cost:daily:${keyId}:${today}`), + this.client.get(`usage:cost:monthly:${keyId}:${currentMonth}`), + this.client.get(`usage:cost:hourly:${keyId}:${currentHour}`), + this.client.get(`usage:cost:total:${keyId}`) + ]) + + return { + daily: parseFloat(daily || 0), + monthly: parseFloat(monthly || 0), + hourly: parseFloat(hourly || 0), + total: parseFloat(total || 0) + } + } + + // 💰 获取本周 Opus 费用 + async getWeeklyOpusCost(keyId) { + const currentWeek = getWeekStringInTimezone() + const costKey = `usage:opus:weekly:${keyId}:${currentWeek}` + const cost = await this.client.get(costKey) + const result = parseFloat(cost || 0) + logger.debug( + `💰 Getting weekly Opus cost for ${keyId}, week: ${currentWeek}, key: ${costKey}, value: ${cost}, result: ${result}` + ) + return result + } + + // 💰 增加本周 Opus 费用 + async incrementWeeklyOpusCost(keyId, amount) { + const currentWeek = getWeekStringInTimezone() + const weeklyKey = `usage:opus:weekly:${keyId}:${currentWeek}` + const totalKey = `usage:opus:total:${keyId}` + + logger.debug( + `💰 Incrementing weekly Opus cost for ${keyId}, week: ${currentWeek}, amount: $${amount}` + ) + + // 使用 pipeline 批量执行,提高性能 + const pipeline = this.client.pipeline() + pipeline.incrbyfloat(weeklyKey, amount) + pipeline.incrbyfloat(totalKey, amount) + // 设置周费用键的过期时间为 2 周 + pipeline.expire(weeklyKey, 14 * 24 * 3600) + + const results = await pipeline.exec() + logger.debug(`💰 Opus cost incremented successfully, new weekly total: $${results[0][1]}`) + } + + // 💰 计算账户的每日费用(基于模型使用) + async getAccountDailyCost(accountId) { + const CostCalculator = require('../utils/costCalculator') + const today = getDateStringInTimezone() + + // 获取账户今日所有模型的使用数据 + const pattern = `account_usage:model:daily:${accountId}:*:${today}` + const modelKeys = await this.client.keys(pattern) + + if (!modelKeys || modelKeys.length === 0) { + return 0 + } + + let totalCost = 0 + + for (const key of modelKeys) { + // 从key中解析模型名称 + // 格式:account_usage:model:daily:{accountId}:{model}:{date} + const parts = key.split(':') + const model = parts[4] // 模型名在第5个位置(索引4) + + // 获取该模型的使用数据 + const modelUsage = await this.client.hgetall(key) + + if (modelUsage && (modelUsage.inputTokens || modelUsage.outputTokens)) { + const usage = { + input_tokens: parseInt(modelUsage.inputTokens || 0), + output_tokens: parseInt(modelUsage.outputTokens || 0), + cache_creation_input_tokens: parseInt(modelUsage.cacheCreateTokens || 0), + cache_read_input_tokens: parseInt(modelUsage.cacheReadTokens || 0) + } + + // 使用CostCalculator计算费用 + const costResult = CostCalculator.calculateCost(usage, model) + totalCost += costResult.costs.total + + logger.debug( + `💰 Account ${accountId} daily cost for model ${model}: $${costResult.costs.total}` + ) + } + } + + logger.debug(`💰 Account ${accountId} total daily cost: $${totalCost}`) + return totalCost + } + + // 📊 获取账户使用统计 + async getAccountUsageStats(accountId, accountType = null) { + const accountKey = `account_usage:${accountId}` + const today = getDateStringInTimezone() + const accountDailyKey = `account_usage:daily:${accountId}:${today}` + const tzDate = getDateInTimezone() + const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( + 2, + '0' + )}` + const accountMonthlyKey = `account_usage:monthly:${accountId}:${currentMonth}` + + const [total, daily, monthly] = await Promise.all([ + this.client.hgetall(accountKey), + this.client.hgetall(accountDailyKey), + this.client.hgetall(accountMonthlyKey) + ]) + + // 获取账户创建时间来计算平均值 - 支持不同类型的账号 + let accountData = {} + if (accountType === 'droid') { + accountData = await this.client.hgetall(`droid:account:${accountId}`) + } else if (accountType === 'openai') { + accountData = await this.client.hgetall(`openai:account:${accountId}`) + } else if (accountType === 'openai-responses') { + accountData = await this.client.hgetall(`openai_responses_account:${accountId}`) + } else { + // 尝试多个前缀 + accountData = await this.client.hgetall(`claude_account:${accountId}`) + if (!accountData.createdAt) { + accountData = await this.client.hgetall(`openai:account:${accountId}`) + } + if (!accountData.createdAt) { + accountData = await this.client.hgetall(`openai_responses_account:${accountId}`) + } + if (!accountData.createdAt) { + accountData = await this.client.hgetall(`openai_account:${accountId}`) + } + if (!accountData.createdAt) { + accountData = await this.client.hgetall(`droid:account:${accountId}`) + } + } + const createdAt = accountData.createdAt ? new Date(accountData.createdAt) : new Date() + const now = new Date() + const daysSinceCreated = Math.max(1, Math.ceil((now - createdAt) / (1000 * 60 * 60 * 24))) + + const totalTokens = parseInt(total.totalTokens) || 0 + const totalRequests = parseInt(total.totalRequests) || 0 + + // 计算平均RPM和TPM + const totalMinutes = Math.max(1, daysSinceCreated * 24 * 60) + const avgRPM = totalRequests / totalMinutes + const avgTPM = totalTokens / totalMinutes + + // 处理账户统计数据 + const handleAccountData = (data) => { + const tokens = parseInt(data.totalTokens) || parseInt(data.tokens) || 0 + const inputTokens = parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0 + const outputTokens = parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0 + const requests = parseInt(data.totalRequests) || parseInt(data.requests) || 0 + const cacheCreateTokens = + parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0 + const cacheReadTokens = + parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0 + const allTokens = parseInt(data.totalAllTokens) || parseInt(data.allTokens) || 0 + + const actualAllTokens = + allTokens || inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + + return { + tokens, + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens, + allTokens: actualAllTokens, + requests + } + } + + const totalData = handleAccountData(total) + const dailyData = handleAccountData(daily) + const monthlyData = handleAccountData(monthly) + + // 获取每日费用(基于模型使用) + const dailyCost = await this.getAccountDailyCost(accountId) + + return { + accountId, + total: totalData, + daily: { + ...dailyData, + cost: dailyCost + }, + monthly: monthlyData, + averages: { + rpm: Math.round(avgRPM * 100) / 100, + tpm: Math.round(avgTPM * 100) / 100, + dailyRequests: Math.round((totalRequests / daysSinceCreated) * 100) / 100, + dailyTokens: Math.round((totalTokens / daysSinceCreated) * 100) / 100 + } + } + } + + // 📈 获取所有账户的使用统计 + async getAllAccountsUsageStats() { + try { + // 获取所有Claude账户 + const accountKeys = await this.client.keys('claude_account:*') + const accountStats = [] + + for (const accountKey of accountKeys) { + const accountId = accountKey.replace('claude_account:', '') + const accountData = await this.client.hgetall(accountKey) + + if (accountData.name) { + const stats = await this.getAccountUsageStats(accountId) + accountStats.push({ + id: accountId, + name: accountData.name, + email: accountData.email || '', + status: accountData.status || 'unknown', + isActive: accountData.isActive === 'true', + ...stats + }) + } + } + + // 按当日token使用量排序 + accountStats.sort((a, b) => (b.daily.allTokens || 0) - (a.daily.allTokens || 0)) + + return accountStats + } catch (error) { + logger.error('❌ Failed to get all accounts usage stats:', error) + return [] + } + } + + // 🧹 清空所有API Key的使用统计数据 + async resetAllUsageStats() { + const client = this.getClientSafe() + const stats = { + deletedKeys: 0, + deletedDailyKeys: 0, + deletedMonthlyKeys: 0, + resetApiKeys: 0 + } + + try { + // 获取所有API Key ID + const apiKeyIds = [] + const apiKeyKeys = await client.keys('apikey:*') + + for (const key of apiKeyKeys) { + if (key === 'apikey:hash_map') { + continue + } // 跳过哈希映射表 + const keyId = key.replace('apikey:', '') + apiKeyIds.push(keyId) + } + + // 清空每个API Key的使用统计 + for (const keyId of apiKeyIds) { + // 删除总体使用统计 + const usageKey = `usage:${keyId}` + const deleted = await client.del(usageKey) + if (deleted > 0) { + stats.deletedKeys++ + } + + // 删除该API Key的每日统计(使用精确的keyId匹配) + const dailyKeys = await client.keys(`usage:daily:${keyId}:*`) + if (dailyKeys.length > 0) { + await client.del(...dailyKeys) + stats.deletedDailyKeys += dailyKeys.length + } + + // 删除该API Key的每月统计(使用精确的keyId匹配) + const monthlyKeys = await client.keys(`usage:monthly:${keyId}:*`) + if (monthlyKeys.length > 0) { + await client.del(...monthlyKeys) + stats.deletedMonthlyKeys += monthlyKeys.length + } + + // 重置API Key的lastUsedAt字段 + const keyData = await client.hgetall(`apikey:${keyId}`) + if (keyData && Object.keys(keyData).length > 0) { + keyData.lastUsedAt = '' + await client.hset(`apikey:${keyId}`, keyData) + stats.resetApiKeys++ + } + } + + // 额外清理:删除所有可能遗漏的usage相关键 + const allUsageKeys = await client.keys('usage:*') + if (allUsageKeys.length > 0) { + await client.del(...allUsageKeys) + stats.deletedKeys += allUsageKeys.length + } + + return stats + } catch (error) { + throw new Error(`Failed to reset usage stats: ${error.message}`) + } + } + + // 🏢 Claude 账户管理 + async setClaudeAccount(accountId, accountData) { + const key = `claude:account:${accountId}` + await this.client.hset(key, accountData) + } + + async getClaudeAccount(accountId) { + const key = `claude:account:${accountId}` + return await this.client.hgetall(key) + } + + async getAllClaudeAccounts() { + const keys = await this.client.keys('claude:account:*') + const accounts = [] + for (const key of keys) { + const accountData = await this.client.hgetall(key) + if (accountData && Object.keys(accountData).length > 0) { + accounts.push({ id: key.replace('claude:account:', ''), ...accountData }) + } + } + return accounts + } + + async deleteClaudeAccount(accountId) { + const key = `claude:account:${accountId}` + return await this.client.del(key) + } + + // 🤖 Droid 账户相关操作 + async setDroidAccount(accountId, accountData) { + const key = `droid:account:${accountId}` + await this.client.hset(key, accountData) + } + + async getDroidAccount(accountId) { + const key = `droid:account:${accountId}` + return await this.client.hgetall(key) + } + + async getAllDroidAccounts() { + const keys = await this.client.keys('droid:account:*') + const accounts = [] + for (const key of keys) { + const accountData = await this.client.hgetall(key) + if (accountData && Object.keys(accountData).length > 0) { + accounts.push({ id: key.replace('droid:account:', ''), ...accountData }) + } + } + return accounts + } + + async deleteDroidAccount(accountId) { + const key = `droid:account:${accountId}` + return await this.client.del(key) + } + + async setOpenAiAccount(accountId, accountData) { + const key = `openai:account:${accountId}` + await this.client.hset(key, accountData) + } + async getOpenAiAccount(accountId) { + const key = `openai:account:${accountId}` + return await this.client.hgetall(key) + } + async deleteOpenAiAccount(accountId) { + const key = `openai:account:${accountId}` + return await this.client.del(key) + } + + async getAllOpenAIAccounts() { + const keys = await this.client.keys('openai:account:*') + const accounts = [] + for (const key of keys) { + const accountData = await this.client.hgetall(key) + if (accountData && Object.keys(accountData).length > 0) { + accounts.push({ id: key.replace('openai:account:', ''), ...accountData }) + } + } + return accounts + } + + // 🔐 会话管理(用于管理员登录等) + async setSession(sessionId, sessionData, ttl = 86400) { + const key = `session:${sessionId}` + await this.client.hset(key, sessionData) + await this.client.expire(key, ttl) + } + + async getSession(sessionId) { + const key = `session:${sessionId}` + return await this.client.hgetall(key) + } + + async deleteSession(sessionId) { + const key = `session:${sessionId}` + return await this.client.del(key) + } + + // 🗝️ API Key哈希索引管理 + async setApiKeyHash(hashedKey, keyData, ttl = 0) { + const key = `apikey_hash:${hashedKey}` + await this.client.hset(key, keyData) + if (ttl > 0) { + await this.client.expire(key, ttl) + } + } + + async getApiKeyHash(hashedKey) { + const key = `apikey_hash:${hashedKey}` + return await this.client.hgetall(key) + } + + async deleteApiKeyHash(hashedKey) { + const key = `apikey_hash:${hashedKey}` + return await this.client.del(key) + } + + // 🔗 OAuth会话管理 + async setOAuthSession(sessionId, sessionData, ttl = 600) { + // 10分钟过期 + const key = `oauth:${sessionId}` + + // 序列化复杂对象,特别是 proxy 配置 + const serializedData = {} + for (const [dataKey, value] of Object.entries(sessionData)) { + if (typeof value === 'object' && value !== null) { + serializedData[dataKey] = JSON.stringify(value) + } else { + serializedData[dataKey] = value + } + } + + await this.client.hset(key, serializedData) + await this.client.expire(key, ttl) + } + + async getOAuthSession(sessionId) { + const key = `oauth:${sessionId}` + const data = await this.client.hgetall(key) + + // 反序列化 proxy 字段 + if (data.proxy) { + try { + data.proxy = JSON.parse(data.proxy) + } catch (error) { + // 如果解析失败,设置为 null + data.proxy = null + } + } + + return data + } + + async deleteOAuthSession(sessionId) { + const key = `oauth:${sessionId}` + return await this.client.del(key) + } + + // 📈 系统统计 + async getSystemStats() { + const keys = await Promise.all([ + this.client.keys('apikey:*'), + this.client.keys('claude:account:*'), + this.client.keys('usage:*') + ]) + + return { + totalApiKeys: keys[0].length, + totalClaudeAccounts: keys[1].length, + totalUsageRecords: keys[2].length + } + } + + // 📊 获取今日系统统计 + async getTodayStats() { + try { + const today = getDateStringInTimezone() + const dailyKeys = await this.client.keys(`usage:daily:*:${today}`) + + let totalRequestsToday = 0 + let totalTokensToday = 0 + let totalInputTokensToday = 0 + let totalOutputTokensToday = 0 + let totalCacheCreateTokensToday = 0 + let totalCacheReadTokensToday = 0 + + // 批量获取所有今日数据,提高性能 + if (dailyKeys.length > 0) { + const pipeline = this.client.pipeline() + dailyKeys.forEach((key) => pipeline.hgetall(key)) + const results = await pipeline.exec() + + for (const [error, dailyData] of results) { + if (error || !dailyData) { + continue + } + + totalRequestsToday += parseInt(dailyData.requests) || 0 + const currentDayTokens = parseInt(dailyData.tokens) || 0 + totalTokensToday += currentDayTokens + + // 处理旧数据兼容性:如果有总token但没有输入输出分离,则使用总token作为输出token + const inputTokens = parseInt(dailyData.inputTokens) || 0 + const outputTokens = parseInt(dailyData.outputTokens) || 0 + const cacheCreateTokens = parseInt(dailyData.cacheCreateTokens) || 0 + const cacheReadTokens = parseInt(dailyData.cacheReadTokens) || 0 + const totalTokensFromSeparate = inputTokens + outputTokens + + if (totalTokensFromSeparate === 0 && currentDayTokens > 0) { + // 旧数据:没有输入输出分离,假设70%为输出,30%为输入(基于一般对话比例) + totalOutputTokensToday += Math.round(currentDayTokens * 0.7) + totalInputTokensToday += Math.round(currentDayTokens * 0.3) + } else { + // 新数据:使用实际的输入输出分离 + totalInputTokensToday += inputTokens + totalOutputTokensToday += outputTokens + } + + // 添加cache token统计 + totalCacheCreateTokensToday += cacheCreateTokens + totalCacheReadTokensToday += cacheReadTokens + } + } + + // 获取今日创建的API Key数量(批量优化) + const allApiKeys = await this.client.keys('apikey:*') + let apiKeysCreatedToday = 0 + + if (allApiKeys.length > 0) { + const pipeline = this.client.pipeline() + allApiKeys.forEach((key) => pipeline.hget(key, 'createdAt')) + const results = await pipeline.exec() + + for (const [error, createdAt] of results) { + if (!error && createdAt && createdAt.startsWith(today)) { + apiKeysCreatedToday++ + } + } + } + + return { + requestsToday: totalRequestsToday, + tokensToday: totalTokensToday, + inputTokensToday: totalInputTokensToday, + outputTokensToday: totalOutputTokensToday, + cacheCreateTokensToday: totalCacheCreateTokensToday, + cacheReadTokensToday: totalCacheReadTokensToday, + apiKeysCreatedToday + } + } catch (error) { + console.error('Error getting today stats:', error) + return { + requestsToday: 0, + tokensToday: 0, + inputTokensToday: 0, + outputTokensToday: 0, + cacheCreateTokensToday: 0, + cacheReadTokensToday: 0, + apiKeysCreatedToday: 0 + } + } + } + + // 📈 获取系统总的平均RPM和TPM + async getSystemAverages() { + try { + const allApiKeys = await this.client.keys('apikey:*') + let totalRequests = 0 + let totalTokens = 0 + let totalInputTokens = 0 + let totalOutputTokens = 0 + let oldestCreatedAt = new Date() + + // 批量获取所有usage数据和key数据,提高性能 + const usageKeys = allApiKeys.map((key) => `usage:${key.replace('apikey:', '')}`) + const pipeline = this.client.pipeline() + + // 添加所有usage查询 + usageKeys.forEach((key) => pipeline.hgetall(key)) + // 添加所有key数据查询 + allApiKeys.forEach((key) => pipeline.hgetall(key)) + + const results = await pipeline.exec() + const usageResults = results.slice(0, usageKeys.length) + const keyResults = results.slice(usageKeys.length) + + for (let i = 0; i < allApiKeys.length; i++) { + const totalData = usageResults[i][1] || {} + const keyData = keyResults[i][1] || {} + + totalRequests += parseInt(totalData.totalRequests) || 0 + totalTokens += parseInt(totalData.totalTokens) || 0 + totalInputTokens += parseInt(totalData.totalInputTokens) || 0 + totalOutputTokens += parseInt(totalData.totalOutputTokens) || 0 + + const createdAt = keyData.createdAt ? new Date(keyData.createdAt) : new Date() + if (createdAt < oldestCreatedAt) { + oldestCreatedAt = createdAt + } + } + + const now = new Date() + // 保持与个人API Key计算一致的算法:按天计算然后转换为分钟 + const daysSinceOldest = Math.max( + 1, + Math.ceil((now - oldestCreatedAt) / (1000 * 60 * 60 * 24)) + ) + const totalMinutes = daysSinceOldest * 24 * 60 + + return { + systemRPM: Math.round((totalRequests / totalMinutes) * 100) / 100, + systemTPM: Math.round((totalTokens / totalMinutes) * 100) / 100, + totalInputTokens, + totalOutputTokens, + totalTokens + } + } catch (error) { + console.error('Error getting system averages:', error) + return { + systemRPM: 0, + systemTPM: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalTokens: 0 + } + } + } + + // 📊 获取实时系统指标(基于滑动窗口) + async getRealtimeSystemMetrics() { + try { + const configLocal = require('../../config/config') + const windowMinutes = configLocal.system.metricsWindow || 5 + + const now = new Date() + const currentMinute = Math.floor(now.getTime() / 60000) + + // 调试:打印当前时间和分钟时间戳 + logger.debug( + `🔍 Realtime metrics - Current time: ${now.toISOString()}, Minute timestamp: ${currentMinute}` + ) + + // 使用Pipeline批量获取窗口内的所有分钟数据 + const pipeline = this.client.pipeline() + const minuteKeys = [] + for (let i = 0; i < windowMinutes; i++) { + const minuteKey = `system:metrics:minute:${currentMinute - i}` + minuteKeys.push(minuteKey) + pipeline.hgetall(minuteKey) + } + + logger.debug(`🔍 Realtime metrics - Checking keys: ${minuteKeys.join(', ')}`) + + const results = await pipeline.exec() + + // 聚合计算 + let totalRequests = 0 + let totalTokens = 0 + let totalInputTokens = 0 + let totalOutputTokens = 0 + let totalCacheCreateTokens = 0 + let totalCacheReadTokens = 0 + let validDataCount = 0 + + results.forEach(([err, data], index) => { + if (!err && data && Object.keys(data).length > 0) { + validDataCount++ + totalRequests += parseInt(data.requests || 0) + totalTokens += parseInt(data.totalTokens || 0) + totalInputTokens += parseInt(data.inputTokens || 0) + totalOutputTokens += parseInt(data.outputTokens || 0) + totalCacheCreateTokens += parseInt(data.cacheCreateTokens || 0) + totalCacheReadTokens += parseInt(data.cacheReadTokens || 0) + + logger.debug(`🔍 Realtime metrics - Key ${minuteKeys[index]} data:`, { + requests: data.requests, + totalTokens: data.totalTokens + }) + } + }) + + logger.debug( + `🔍 Realtime metrics - Valid data count: ${validDataCount}/${windowMinutes}, Total requests: ${totalRequests}, Total tokens: ${totalTokens}` + ) + + // 计算平均值(每分钟) + const realtimeRPM = + windowMinutes > 0 ? Math.round((totalRequests / windowMinutes) * 100) / 100 : 0 + const realtimeTPM = + windowMinutes > 0 ? Math.round((totalTokens / windowMinutes) * 100) / 100 : 0 + + const result = { + realtimeRPM, + realtimeTPM, + windowMinutes, + totalRequests, + totalTokens, + totalInputTokens, + totalOutputTokens, + totalCacheCreateTokens, + totalCacheReadTokens + } + + logger.debug('🔍 Realtime metrics - Final result:', result) + + return result + } catch (error) { + console.error('Error getting realtime system metrics:', error) + // 如果出错,返回历史平均值作为降级方案 + const historicalMetrics = await this.getSystemAverages() + return { + realtimeRPM: historicalMetrics.systemRPM, + realtimeTPM: historicalMetrics.systemTPM, + windowMinutes: 0, // 标识使用了历史数据 + totalRequests: 0, + totalTokens: historicalMetrics.totalTokens, + totalInputTokens: historicalMetrics.totalInputTokens, + totalOutputTokens: historicalMetrics.totalOutputTokens, + totalCacheCreateTokens: 0, + totalCacheReadTokens: 0 + } + } + } + + // 🔗 会话sticky映射管理 + async setSessionAccountMapping(sessionHash, accountId, ttl = null) { + const appConfig = require('../../config/config') + // 从配置读取TTL(小时),转换为秒,默认1小时 + const defaultTTL = ttl !== null ? ttl : (appConfig.session?.stickyTtlHours || 1) * 60 * 60 + const key = `sticky_session:${sessionHash}` + await this.client.set(key, accountId, 'EX', defaultTTL) + } + + async getSessionAccountMapping(sessionHash) { + const key = `sticky_session:${sessionHash}` + return await this.client.get(key) + } + + // 🚀 智能会话TTL续期:剩余时间少于阈值时自动续期 + async extendSessionAccountMappingTTL(sessionHash) { + const appConfig = require('../../config/config') + const key = `sticky_session:${sessionHash}` + + // 📊 从配置获取参数 + const ttlHours = appConfig.session?.stickyTtlHours || 1 // 小时,默认1小时 + const thresholdMinutes = appConfig.session?.renewalThresholdMinutes || 0 // 分钟,默认0(不续期) + + // 如果阈值为0,不执行续期 + if (thresholdMinutes === 0) { + return true + } + + const fullTTL = ttlHours * 60 * 60 // 转换为秒 + const renewalThreshold = thresholdMinutes * 60 // 转换为秒 + + try { + // 获取当前剩余TTL(秒) + const remainingTTL = await this.client.ttl(key) + + // 键不存在或已过期 + if (remainingTTL === -2) { + return false + } + + // 键存在但没有TTL(永不过期,不需要处理) + if (remainingTTL === -1) { + return true + } + + // 🎯 智能续期策略:仅在剩余时间少于阈值时才续期 + if (remainingTTL < renewalThreshold) { + await this.client.expire(key, fullTTL) + logger.debug( + `🔄 Renewed sticky session TTL: ${sessionHash} (was ${Math.round( + remainingTTL / 60 + )}min, renewed to ${ttlHours}h)` + ) + return true + } + + // 剩余时间充足,无需续期 + logger.debug( + `✅ Sticky session TTL sufficient: ${sessionHash} (remaining ${Math.round( + remainingTTL / 60 + )}min)` + ) + return true + } catch (error) { + logger.error('❌ Failed to extend session TTL:', error) + return false + } + } + + async deleteSessionAccountMapping(sessionHash) { + const key = `sticky_session:${sessionHash}` + return await this.client.del(key) + } + + // 🧹 清理过期数据 + async cleanup() { + try { + const patterns = ['usage:daily:*', 'ratelimit:*', 'session:*', 'sticky_session:*', 'oauth:*'] + + for (const pattern of patterns) { + const keys = await this.client.keys(pattern) + const pipeline = this.client.pipeline() + + for (const key of keys) { + const ttl = await this.client.ttl(key) + if (ttl === -1) { + // 没有设置过期时间的键 + if (key.startsWith('oauth:')) { + pipeline.expire(key, 600) // OAuth会话设置10分钟过期 + } else { + pipeline.expire(key, 86400) // 其他设置1天过期 + } + } + } + + await pipeline.exec() + } + + logger.info('🧹 Redis cleanup completed') + } catch (error) { + logger.error('❌ Redis cleanup failed:', error) + } + } + + // 获取并发配置 + _getConcurrencyConfig() { + const defaults = { + leaseSeconds: 300, + renewIntervalSeconds: 30, + cleanupGraceSeconds: 30 + } + + const configValues = { + ...defaults, + ...(config.concurrency || {}) + } + + const normalizeNumber = (value, fallback, options = {}) => { + const parsed = Number(value) + if (!Number.isFinite(parsed)) { + return fallback + } + + if (options.allowZero && parsed === 0) { + return 0 + } + + if (options.min !== undefined && parsed < options.min) { + return options.min + } + + return parsed + } + + return { + leaseSeconds: normalizeNumber(configValues.leaseSeconds, defaults.leaseSeconds, { + min: 30 + }), + renewIntervalSeconds: normalizeNumber( + configValues.renewIntervalSeconds, + defaults.renewIntervalSeconds, + { + allowZero: true, + min: 0 + } + ), + cleanupGraceSeconds: normalizeNumber( + configValues.cleanupGraceSeconds, + defaults.cleanupGraceSeconds, + { + min: 0 + } + ) + } + } + + // 增加并发计数(基于租约的有序集合) + async incrConcurrency(apiKeyId, requestId, leaseSeconds = null) { + if (!requestId) { + throw new Error('Request ID is required for concurrency tracking') + } + + try { + const { leaseSeconds: defaultLeaseSeconds, cleanupGraceSeconds } = + this._getConcurrencyConfig() + const lease = leaseSeconds || defaultLeaseSeconds + const key = `concurrency:${apiKeyId}` + const now = Date.now() + const expireAt = now + lease * 1000 + const ttl = Math.max((lease + cleanupGraceSeconds) * 1000, 60000) + + const luaScript = ` + local key = KEYS[1] + local member = ARGV[1] + local expireAt = tonumber(ARGV[2]) + local now = tonumber(ARGV[3]) + local ttl = tonumber(ARGV[4]) + + redis.call('ZREMRANGEBYSCORE', key, '-inf', now) + redis.call('ZADD', key, expireAt, member) + + if ttl > 0 then + redis.call('PEXPIRE', key, ttl) + end + + local count = redis.call('ZCARD', key) + return count + ` + + const count = await this.client.eval(luaScript, 1, key, requestId, expireAt, now, ttl) + logger.database( + `🔢 Incremented concurrency for key ${apiKeyId}: ${count} (request ${requestId})` + ) + return count + } catch (error) { + logger.error('❌ Failed to increment concurrency:', error) + throw error + } + } + + // 刷新并发租约,防止长连接提前过期 + async refreshConcurrencyLease(apiKeyId, requestId, leaseSeconds = null) { + if (!requestId) { + return 0 + } + + try { + const { leaseSeconds: defaultLeaseSeconds, cleanupGraceSeconds } = + this._getConcurrencyConfig() + const lease = leaseSeconds || defaultLeaseSeconds + const key = `concurrency:${apiKeyId}` + const now = Date.now() + const expireAt = now + lease * 1000 + const ttl = Math.max((lease + cleanupGraceSeconds) * 1000, 60000) + + const luaScript = ` + local key = KEYS[1] + local member = ARGV[1] + local expireAt = tonumber(ARGV[2]) + local now = tonumber(ARGV[3]) + local ttl = tonumber(ARGV[4]) + + redis.call('ZREMRANGEBYSCORE', key, '-inf', now) + + local exists = redis.call('ZSCORE', key, member) + + if exists then + redis.call('ZADD', key, expireAt, member) + if ttl > 0 then + redis.call('PEXPIRE', key, ttl) + end + return 1 + end + + return 0 + ` + + const refreshed = await this.client.eval(luaScript, 1, key, requestId, expireAt, now, ttl) + if (refreshed === 1) { + logger.debug(`🔄 Refreshed concurrency lease for key ${apiKeyId} (request ${requestId})`) + } + return refreshed + } catch (error) { + logger.error('❌ Failed to refresh concurrency lease:', error) + return 0 + } + } + + // 减少并发计数 + async decrConcurrency(apiKeyId, requestId) { + try { + const key = `concurrency:${apiKeyId}` + const now = Date.now() + + const luaScript = ` + local key = KEYS[1] + local member = ARGV[1] + local now = tonumber(ARGV[2]) + + if member then + redis.call('ZREM', key, member) + end + + redis.call('ZREMRANGEBYSCORE', key, '-inf', now) + + local count = redis.call('ZCARD', key) + if count <= 0 then + redis.call('DEL', key) + return 0 + end + + return count + ` + + const count = await this.client.eval(luaScript, 1, key, requestId || '', now) + logger.database( + `🔢 Decremented concurrency for key ${apiKeyId}: ${count} (request ${requestId || 'n/a'})` + ) + return count + } catch (error) { + logger.error('❌ Failed to decrement concurrency:', error) + throw error + } + } + + // 获取当前并发数 + async getConcurrency(apiKeyId) { + try { + const key = `concurrency:${apiKeyId}` + const now = Date.now() + + const luaScript = ` + local key = KEYS[1] + local now = tonumber(ARGV[1]) + + redis.call('ZREMRANGEBYSCORE', key, '-inf', now) + return redis.call('ZCARD', key) + ` + + const count = await this.client.eval(luaScript, 1, key, now) + return parseInt(count || 0) + } catch (error) { + logger.error('❌ Failed to get concurrency:', error) + return 0 + } + } + + // 🔧 Basic Redis operations wrapper methods for convenience + async get(key) { + const client = this.getClientSafe() + return await client.get(key) + } + + async set(key, value, ...args) { + const client = this.getClientSafe() + return await client.set(key, value, ...args) + } + + async setex(key, ttl, value) { + const client = this.getClientSafe() + return await client.setex(key, ttl, value) + } + + async del(...keys) { + const client = this.getClientSafe() + return await client.del(...keys) + } + + async keys(pattern) { + const client = this.getClientSafe() + return await client.keys(pattern) + } + + // 📊 获取账户会话窗口内的使用统计(包含模型细分) + async getAccountSessionWindowUsage(accountId, windowStart, windowEnd) { + try { + if (!windowStart || !windowEnd) { + return { + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheCreateTokens: 0, + totalCacheReadTokens: 0, + totalAllTokens: 0, + totalRequests: 0, + modelUsage: {} + } + } + + const startDate = new Date(windowStart) + const endDate = new Date(windowEnd) + + // 添加日志以调试时间窗口 + logger.debug(`📊 Getting session window usage for account ${accountId}`) + logger.debug(` Window: ${windowStart} to ${windowEnd}`) + logger.debug(` Start UTC: ${startDate.toISOString()}, End UTC: ${endDate.toISOString()}`) + + // 获取窗口内所有可能的小时键 + // 重要:需要使用配置的时区来构建键名,因为数据存储时使用的是配置时区 + const hourlyKeys = [] + const currentHour = new Date(startDate) + currentHour.setMinutes(0) + currentHour.setSeconds(0) + currentHour.setMilliseconds(0) + + while (currentHour <= endDate) { + // 使用时区转换函数来获取正确的日期和小时 + const tzDateStr = getDateStringInTimezone(currentHour) + const tzHour = String(getHourInTimezone(currentHour)).padStart(2, '0') + const key = `account_usage:hourly:${accountId}:${tzDateStr}:${tzHour}` + + logger.debug(` Adding hourly key: ${key}`) + hourlyKeys.push(key) + currentHour.setHours(currentHour.getHours() + 1) + } + + // 批量获取所有小时的数据 + const pipeline = this.client.pipeline() + for (const key of hourlyKeys) { + pipeline.hgetall(key) + } + const results = await pipeline.exec() + + // 聚合所有数据 + let totalInputTokens = 0 + let totalOutputTokens = 0 + let totalCacheCreateTokens = 0 + let totalCacheReadTokens = 0 + let totalAllTokens = 0 + let totalRequests = 0 + const modelUsage = {} + + logger.debug(` Processing ${results.length} hourly results`) + + for (const [error, data] of results) { + if (error || !data || Object.keys(data).length === 0) { + continue + } + + // 处理总计数据 + const hourInputTokens = parseInt(data.inputTokens || 0) + const hourOutputTokens = parseInt(data.outputTokens || 0) + const hourCacheCreateTokens = parseInt(data.cacheCreateTokens || 0) + const hourCacheReadTokens = parseInt(data.cacheReadTokens || 0) + const hourAllTokens = parseInt(data.allTokens || 0) + const hourRequests = parseInt(data.requests || 0) + + totalInputTokens += hourInputTokens + totalOutputTokens += hourOutputTokens + totalCacheCreateTokens += hourCacheCreateTokens + totalCacheReadTokens += hourCacheReadTokens + totalAllTokens += hourAllTokens + totalRequests += hourRequests + + if (hourAllTokens > 0) { + logger.debug(` Hour data: allTokens=${hourAllTokens}, requests=${hourRequests}`) + } + + // 处理每个模型的数据 + for (const [key, value] of Object.entries(data)) { + // 查找模型相关的键(格式: model:{modelName}:{metric}) + if (key.startsWith('model:')) { + const parts = key.split(':') + if (parts.length >= 3) { + const modelName = parts[1] + const metric = parts.slice(2).join(':') + + if (!modelUsage[modelName]) { + modelUsage[modelName] = { + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + allTokens: 0, + requests: 0 + } + } + + if (metric === 'inputTokens') { + modelUsage[modelName].inputTokens += parseInt(value || 0) + } else if (metric === 'outputTokens') { + modelUsage[modelName].outputTokens += parseInt(value || 0) + } else if (metric === 'cacheCreateTokens') { + modelUsage[modelName].cacheCreateTokens += parseInt(value || 0) + } else if (metric === 'cacheReadTokens') { + modelUsage[modelName].cacheReadTokens += parseInt(value || 0) + } else if (metric === 'allTokens') { + modelUsage[modelName].allTokens += parseInt(value || 0) + } else if (metric === 'requests') { + modelUsage[modelName].requests += parseInt(value || 0) + } + } + } + } + } + + logger.debug(`📊 Session window usage summary:`) + logger.debug(` Total allTokens: ${totalAllTokens}`) + logger.debug(` Total requests: ${totalRequests}`) + logger.debug(` Input: ${totalInputTokens}, Output: ${totalOutputTokens}`) + logger.debug( + ` Cache Create: ${totalCacheCreateTokens}, Cache Read: ${totalCacheReadTokens}` + ) + + return { + totalInputTokens, + totalOutputTokens, + totalCacheCreateTokens, + totalCacheReadTokens, + totalAllTokens, + totalRequests, + modelUsage + } + } catch (error) { + logger.error(`❌ Failed to get session window usage for account ${accountId}:`, error) + return { + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheCreateTokens: 0, + totalCacheReadTokens: 0, + totalAllTokens: 0, + totalRequests: 0, + modelUsage: {} + } + } + } +} + +const redisClient = new RedisClient() + +// 分布式锁相关方法 +redisClient.setAccountLock = async function (lockKey, lockValue, ttlMs) { + try { + // 使用SET NX PX实现原子性的锁获取 + // ioredis语法: set(key, value, 'PX', milliseconds, 'NX') + const result = await this.client.set(lockKey, lockValue, 'PX', ttlMs, 'NX') + return result === 'OK' + } catch (error) { + logger.error(`Failed to acquire lock ${lockKey}:`, error) + return false + } +} + +redisClient.releaseAccountLock = async function (lockKey, lockValue) { + try { + // 使用Lua脚本确保只有持有锁的进程才能释放锁 + const script = ` + if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("del", KEYS[1]) + else + return 0 + end + ` + // ioredis语法: eval(script, numberOfKeys, key1, key2, ..., arg1, arg2, ...) + const result = await this.client.eval(script, 1, lockKey, lockValue) + return result === 1 + } catch (error) { + logger.error(`Failed to release lock ${lockKey}:`, error) + return false + } +} + +// 导出时区辅助函数 +redisClient.getDateInTimezone = getDateInTimezone +redisClient.getDateStringInTimezone = getDateStringInTimezone +redisClient.getHourInTimezone = getHourInTimezone +redisClient.getWeekStringInTimezone = getWeekStringInTimezone + +module.exports = redisClient diff --git a/src/routes/admin.js b/src/routes/admin.js new file mode 100644 index 0000000000000000000000000000000000000000..fe49e72e471da0f7e1ca5e1a5f60b0c0c9883095 --- /dev/null +++ b/src/routes/admin.js @@ -0,0 +1,9188 @@ +const express = require('express') +const apiKeyService = require('../services/apiKeyService') +const claudeAccountService = require('../services/claudeAccountService') +const claudeConsoleAccountService = require('../services/claudeConsoleAccountService') +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 azureOpenaiAccountService = require('../services/azureOpenaiAccountService') +const accountGroupService = require('../services/accountGroupService') +const redis = require('../models/redis') +const { authenticateAdmin } = require('../middleware/auth') +const logger = require('../utils/logger') +const oauthHelper = require('../utils/oauthHelper') +const { + startDeviceAuthorization, + pollDeviceAuthorization, + WorkOSDeviceAuthError +} = require('../utils/workosOAuthHelper') +const CostCalculator = require('../utils/costCalculator') +const pricingService = require('../services/pricingService') +const claudeCodeHeadersService = require('../services/claudeCodeHeadersService') +const webhookNotifier = require('../utils/webhookNotifier') +const axios = require('axios') +const crypto = require('crypto') +const fs = require('fs') +const path = require('path') +const config = require('../../config/config') +const ProxyHelper = require('../utils/proxyHelper') + +const router = express.Router() + +function normalizeNullableDate(value) { + if (value === undefined || value === null) { + return null + } + if (typeof value === 'string') { + const trimmed = value.trim() + return trimmed === '' ? null : trimmed + } + return value +} + +function formatSubscriptionExpiry(account) { + if (!account || typeof account !== 'object') { + return account + } + + const rawSubscription = account.subscriptionExpiresAt + const rawToken = account.tokenExpiresAt !== undefined ? account.tokenExpiresAt : account.expiresAt + + const subscriptionExpiresAt = normalizeNullableDate(rawSubscription) + const tokenExpiresAt = normalizeNullableDate(rawToken) + + return { + ...account, + subscriptionExpiresAt, + tokenExpiresAt, + expiresAt: subscriptionExpiresAt + } +} + +// 👥 用户管理 + +// 获取所有用户列表(用于API Key分配) +router.get('/users', authenticateAdmin, async (req, res) => { + try { + const userService = require('../services/userService') + + // Extract query parameters for filtering + const { role, isActive } = req.query + const options = { limit: 1000 } + + // Apply role filter if provided + if (role) { + options.role = role + } + + // Apply isActive filter if provided, otherwise default to active users only + if (isActive !== undefined) { + options.isActive = isActive === 'true' + } else { + options.isActive = true // Default to active users for backwards compatibility + } + + const result = await userService.getAllUsers(options) + + // Extract users array from the paginated result + const allUsers = result.users || [] + + // Map to the format needed for the dropdown + const activeUsers = allUsers.map((user) => ({ + id: user.id, + username: user.username, + displayName: user.displayName || user.username, + email: user.email, + role: user.role + })) + + // 添加Admin选项作为第一个 + const usersWithAdmin = [ + { + id: 'admin', + username: 'admin', + displayName: 'Admin', + email: '', + role: 'admin' + }, + ...activeUsers + ] + + return res.json({ + success: true, + data: usersWithAdmin + }) + } catch (error) { + logger.error('❌ Failed to get users list:', error) + return res.status(500).json({ + error: 'Failed to get users list', + message: error.message + }) + } +}) + +// 🔑 API Keys 管理 + +// 调试:获取API Key费用详情 +router.get('/api-keys/:keyId/cost-debug', authenticateAdmin, async (req, res) => { + try { + const { keyId } = req.params + const costStats = await redis.getCostStats(keyId) + const dailyCost = await redis.getDailyCost(keyId) + const today = redis.getDateStringInTimezone() + const client = redis.getClientSafe() + + // 获取所有相关的Redis键 + const costKeys = await client.keys(`usage:cost:*:${keyId}:*`) + const keyValues = {} + + for (const key of costKeys) { + keyValues[key] = await client.get(key) + } + + return res.json({ + keyId, + today, + dailyCost, + costStats, + redisKeys: keyValues, + timezone: config.system.timezoneOffset || 8 + }) + } catch (error) { + logger.error('❌ Failed to get cost debug info:', error) + return res.status(500).json({ error: 'Failed to get cost debug info', message: error.message }) + } +}) + +// 获取所有API Keys +router.get('/api-keys', authenticateAdmin, async (req, res) => { + try { + const { timeRange = 'all', startDate, endDate } = req.query // all, 7days, monthly, custom + const apiKeys = await apiKeyService.getAllApiKeys() + + // 获取用户服务来补充owner信息 + const userService = require('../services/userService') + + // 根据时间范围计算查询模式 + const now = new Date() + const searchPatterns = [] + + if (timeRange === 'custom' && startDate && endDate) { + // 自定义日期范围 + const redisClient = require('../models/redis') + const start = new Date(startDate) + const end = new Date(endDate) + + // 确保日期范围有效 + if (start > end) { + return res.status(400).json({ error: 'Start date must be before or equal to end date' }) + } + + // 限制最大范围为365天 + const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1 + if (daysDiff > 365) { + return res.status(400).json({ error: 'Date range cannot exceed 365 days' }) + } + + // 生成日期范围内每天的搜索模式 + const currentDate = new Date(start) + while (currentDate <= end) { + const tzDate = redisClient.getDateInTimezone(currentDate) + const dateStr = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( + 2, + '0' + )}-${String(tzDate.getUTCDate()).padStart(2, '0')}` + searchPatterns.push(`usage:daily:*:${dateStr}`) + currentDate.setDate(currentDate.getDate() + 1) + } + } else if (timeRange === 'today') { + // 今日 - 使用时区日期 + const redisClient = require('../models/redis') + const tzDate = redisClient.getDateInTimezone(now) + const dateStr = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( + 2, + '0' + )}-${String(tzDate.getUTCDate()).padStart(2, '0')}` + searchPatterns.push(`usage:daily:*:${dateStr}`) + } else if (timeRange === '7days') { + // 最近7天 + const redisClient = require('../models/redis') + for (let i = 0; i < 7; i++) { + const date = new Date(now) + date.setDate(date.getDate() - i) + const tzDate = redisClient.getDateInTimezone(date) + const dateStr = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( + 2, + '0' + )}-${String(tzDate.getUTCDate()).padStart(2, '0')}` + searchPatterns.push(`usage:daily:*:${dateStr}`) + } + } else if (timeRange === 'monthly') { + // 本月 + const redisClient = require('../models/redis') + const tzDate = redisClient.getDateInTimezone(now) + const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( + 2, + '0' + )}` + searchPatterns.push(`usage:monthly:*:${currentMonth}`) + } + + // 为每个API Key计算准确的费用和统计数据 + for (const apiKey of apiKeys) { + const client = redis.getClientSafe() + + if (timeRange === 'all') { + // 全部时间:保持原有逻辑 + if (apiKey.usage && apiKey.usage.total) { + // 使用与展开模型统计相同的数据源 + // 获取所有时间的模型统计数据 + const monthlyKeys = await client.keys(`usage:${apiKey.id}:model:monthly:*:*`) + const modelStatsMap = new Map() + + // 汇总所有月份的数据 + for (const key of monthlyKeys) { + const match = key.match(/usage:.+:model:monthly:(.+):\d{4}-\d{2}$/) + if (!match) { + continue + } + + const model = match[1] + const data = await client.hgetall(key) + + if (data && Object.keys(data).length > 0) { + if (!modelStatsMap.has(model)) { + modelStatsMap.set(model, { + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0 + }) + } + + const stats = modelStatsMap.get(model) + stats.inputTokens += + parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0 + stats.outputTokens += + parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0 + stats.cacheCreateTokens += + parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0 + stats.cacheReadTokens += + parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0 + } + } + + let totalCost = 0 + + // 计算每个模型的费用 + for (const [model, stats] of modelStatsMap) { + const usage = { + input_tokens: stats.inputTokens, + output_tokens: stats.outputTokens, + cache_creation_input_tokens: stats.cacheCreateTokens, + cache_read_input_tokens: stats.cacheReadTokens + } + + const costResult = CostCalculator.calculateCost(usage, model) + totalCost += costResult.costs.total + } + + // 如果没有详细的模型数据,使用总量数据和默认模型计算 + if (modelStatsMap.size === 0) { + const usage = { + input_tokens: apiKey.usage.total.inputTokens || 0, + output_tokens: apiKey.usage.total.outputTokens || 0, + cache_creation_input_tokens: apiKey.usage.total.cacheCreateTokens || 0, + cache_read_input_tokens: apiKey.usage.total.cacheReadTokens || 0 + } + + const costResult = CostCalculator.calculateCost(usage, 'claude-3-5-haiku-20241022') + totalCost = costResult.costs.total + } + + // 添加格式化的费用到响应数据 + apiKey.usage.total.cost = totalCost + apiKey.usage.total.formattedCost = CostCalculator.formatCost(totalCost) + } + } else { + // 7天、本月或自定义日期范围:重新计算统计数据 + const tempUsage = { + requests: 0, + tokens: 0, + allTokens: 0, // 添加allTokens字段 + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0 + } + + // 获取指定时间范围的统计数据 + for (const pattern of searchPatterns) { + const keys = await client.keys(pattern.replace('*', apiKey.id)) + + for (const key of keys) { + const data = await client.hgetall(key) + if (data && Object.keys(data).length > 0) { + // 使用与 redis.js incrementTokenUsage 中相同的字段名 + tempUsage.requests += parseInt(data.totalRequests) || parseInt(data.requests) || 0 + tempUsage.tokens += parseInt(data.totalTokens) || parseInt(data.tokens) || 0 + tempUsage.allTokens += parseInt(data.totalAllTokens) || parseInt(data.allTokens) || 0 // 读取包含所有Token的字段 + tempUsage.inputTokens += + parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0 + tempUsage.outputTokens += + parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0 + tempUsage.cacheCreateTokens += + parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0 + tempUsage.cacheReadTokens += + parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0 + } + } + } + + // 计算指定时间范围的费用 + let totalCost = 0 + const redisClient = require('../models/redis') + const tzToday = redisClient.getDateStringInTimezone(now) + const tzDate = redisClient.getDateInTimezone(now) + const tzMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( + 2, + '0' + )}` + + let modelKeys = [] + if (timeRange === 'custom' && startDate && endDate) { + // 自定义日期范围:获取范围内所有日期的模型统计 + const start = new Date(startDate) + const end = new Date(endDate) + const currentDate = new Date(start) + + while (currentDate <= end) { + const tzDateForKey = redisClient.getDateInTimezone(currentDate) + const dateStr = `${tzDateForKey.getUTCFullYear()}-${String( + tzDateForKey.getUTCMonth() + 1 + ).padStart(2, '0')}-${String(tzDateForKey.getUTCDate()).padStart(2, '0')}` + const dayKeys = await client.keys(`usage:${apiKey.id}:model:daily:*:${dateStr}`) + modelKeys = modelKeys.concat(dayKeys) + currentDate.setDate(currentDate.getDate() + 1) + } + } else { + modelKeys = + timeRange === 'today' + ? await client.keys(`usage:${apiKey.id}:model:daily:*:${tzToday}`) + : timeRange === '7days' + ? await client.keys(`usage:${apiKey.id}:model:daily:*:*`) + : await client.keys(`usage:${apiKey.id}:model:monthly:*:${tzMonth}`) + } + + const modelStatsMap = new Map() + + // 过滤和汇总相应时间范围的模型数据 + for (const key of modelKeys) { + if (timeRange === '7days') { + // 检查是否在最近7天内 + const dateMatch = key.match(/\d{4}-\d{2}-\d{2}$/) + if (dateMatch) { + const keyDate = new Date(dateMatch[0]) + const daysDiff = Math.floor((now - keyDate) / (1000 * 60 * 60 * 24)) + if (daysDiff > 6) { + continue + } + } + } else if (timeRange === 'today' || timeRange === 'custom') { + // today和custom选项已经在查询时过滤了,不需要额外处理 + } + + const modelMatch = key.match( + /usage:.+:model:(?:daily|monthly):(.+):\d{4}-\d{2}(?:-\d{2})?$/ + ) + if (!modelMatch) { + continue + } + + const model = modelMatch[1] + const data = await client.hgetall(key) + + if (data && Object.keys(data).length > 0) { + if (!modelStatsMap.has(model)) { + modelStatsMap.set(model, { + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0 + }) + } + + const stats = modelStatsMap.get(model) + stats.inputTokens += parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0 + stats.outputTokens += + parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0 + stats.cacheCreateTokens += + parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0 + stats.cacheReadTokens += + parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0 + } + } + + // 计算费用 + for (const [model, stats] of modelStatsMap) { + const usage = { + input_tokens: stats.inputTokens, + output_tokens: stats.outputTokens, + cache_creation_input_tokens: stats.cacheCreateTokens, + cache_read_input_tokens: stats.cacheReadTokens + } + + const costResult = CostCalculator.calculateCost(usage, model) + totalCost += costResult.costs.total + } + + // 如果没有模型数据,使用临时统计数据计算 + if (modelStatsMap.size === 0 && tempUsage.tokens > 0) { + const usage = { + input_tokens: tempUsage.inputTokens, + output_tokens: tempUsage.outputTokens, + cache_creation_input_tokens: tempUsage.cacheCreateTokens, + cache_read_input_tokens: tempUsage.cacheReadTokens + } + + const costResult = CostCalculator.calculateCost(usage, 'claude-3-5-haiku-20241022') + totalCost = costResult.costs.total + } + + // 使用从Redis读取的allTokens,如果没有则计算 + const allTokens = + tempUsage.allTokens || + tempUsage.inputTokens + + tempUsage.outputTokens + + tempUsage.cacheCreateTokens + + tempUsage.cacheReadTokens + + // 更新API Key的usage数据为指定时间范围的数据 + apiKey.usage[timeRange] = { + ...tempUsage, + tokens: allTokens, // 使用包含所有Token的总数 + allTokens, + cost: totalCost, + formattedCost: CostCalculator.formatCost(totalCost) + } + + // 为了保持兼容性,也更新total字段 + apiKey.usage.total = apiKey.usage[timeRange] + } + } + + // 为每个API Key添加owner的displayName + for (const apiKey of apiKeys) { + // 如果API Key有关联的用户ID,获取用户信息 + if (apiKey.userId) { + try { + const user = await userService.getUserById(apiKey.userId, false) + if (user) { + apiKey.ownerDisplayName = user.displayName || user.username || 'Unknown User' + } else { + apiKey.ownerDisplayName = 'Unknown User' + } + } catch (error) { + logger.debug(`无法获取用户 ${apiKey.userId} 的信息:`, error) + apiKey.ownerDisplayName = 'Unknown User' + } + } else { + // 如果没有userId,使用createdBy字段或默认为Admin + apiKey.ownerDisplayName = + apiKey.createdBy === 'admin' ? 'Admin' : apiKey.createdBy || 'Admin' + } + } + + return res.json({ success: true, data: apiKeys }) + } catch (error) { + logger.error('❌ Failed to get API keys:', error) + return res.status(500).json({ error: 'Failed to get API keys', message: error.message }) + } +}) + +// 获取支持的客户端列表(使用新的验证器) +router.get('/supported-clients', authenticateAdmin, async (req, res) => { + try { + // 使用新的 ClientValidator 获取所有可用客户端 + const ClientValidator = require('../validators/clientValidator') + const availableClients = ClientValidator.getAvailableClients() + + // 格式化返回数据 + const clients = availableClients.map((client) => ({ + id: client.id, + name: client.name, + description: client.description, + icon: client.icon + })) + + logger.info(`📱 Returning ${clients.length} supported clients`) + return res.json({ success: true, data: clients }) + } catch (error) { + logger.error('❌ Failed to get supported clients:', error) + return res + .status(500) + .json({ error: 'Failed to get supported clients', message: error.message }) + } +}) + +// 获取已存在的标签列表 +router.get('/api-keys/tags', authenticateAdmin, async (req, res) => { + try { + const apiKeys = await apiKeyService.getAllApiKeys() + const tagSet = new Set() + + // 收集所有API Keys的标签 + for (const apiKey of apiKeys) { + if (apiKey.tags && Array.isArray(apiKey.tags)) { + apiKey.tags.forEach((tag) => { + if (tag && tag.trim()) { + tagSet.add(tag.trim()) + } + }) + } + } + + // 转换为数组并排序 + const tags = Array.from(tagSet).sort() + + logger.info(`📋 Retrieved ${tags.length} unique tags from API keys`) + return res.json({ success: true, data: tags }) + } catch (error) { + logger.error('❌ Failed to get API key tags:', error) + return res.status(500).json({ error: 'Failed to get API key tags', message: error.message }) + } +}) + +// 创建新的API Key +router.post('/api-keys', authenticateAdmin, async (req, res) => { + try { + const { + name, + description, + tokenLimit, + expiresAt, + claudeAccountId, + claudeConsoleAccountId, + geminiAccountId, + openaiAccountId, + bedrockAccountId, + droidAccountId, + permissions, + concurrencyLimit, + rateLimitWindow, + rateLimitRequests, + rateLimitCost, + enableModelRestriction, + restrictedModels, + enableClientRestriction, + allowedClients, + dailyCostLimit, + totalCostLimit, + weeklyOpusCostLimit, + tags, + activationDays, // 新增:激活后有效天数 + activationUnit, // 新增:激活时间单位 (hours/days) + expirationMode, // 新增:过期模式 + icon // 新增:图标 + } = req.body + + // 输入验证 + if (!name || typeof name !== 'string' || name.trim().length === 0) { + return res.status(400).json({ error: 'Name is required and must be a non-empty string' }) + } + + if (name.length > 100) { + return res.status(400).json({ error: 'Name must be less than 100 characters' }) + } + + if (description && (typeof description !== 'string' || description.length > 500)) { + return res + .status(400) + .json({ error: 'Description must be a string with less than 500 characters' }) + } + + if (tokenLimit && (!Number.isInteger(Number(tokenLimit)) || Number(tokenLimit) < 0)) { + return res.status(400).json({ error: 'Token limit must be a non-negative integer' }) + } + + if ( + concurrencyLimit !== undefined && + concurrencyLimit !== null && + concurrencyLimit !== '' && + (!Number.isInteger(Number(concurrencyLimit)) || Number(concurrencyLimit) < 0) + ) { + return res.status(400).json({ error: 'Concurrency limit must be a non-negative integer' }) + } + + if ( + rateLimitWindow !== undefined && + rateLimitWindow !== null && + rateLimitWindow !== '' && + (!Number.isInteger(Number(rateLimitWindow)) || Number(rateLimitWindow) < 1) + ) { + return res + .status(400) + .json({ error: 'Rate limit window must be a positive integer (minutes)' }) + } + + if ( + rateLimitRequests !== undefined && + rateLimitRequests !== null && + rateLimitRequests !== '' && + (!Number.isInteger(Number(rateLimitRequests)) || Number(rateLimitRequests) < 1) + ) { + return res.status(400).json({ error: 'Rate limit requests must be a positive integer' }) + } + + // 验证模型限制字段 + if (enableModelRestriction !== undefined && typeof enableModelRestriction !== 'boolean') { + return res.status(400).json({ error: 'Enable model restriction must be a boolean' }) + } + + if (restrictedModels !== undefined && !Array.isArray(restrictedModels)) { + return res.status(400).json({ error: 'Restricted models must be an array' }) + } + + // 验证客户端限制字段 + if (enableClientRestriction !== undefined && typeof enableClientRestriction !== 'boolean') { + return res.status(400).json({ error: 'Enable client restriction must be a boolean' }) + } + + if (allowedClients !== undefined && !Array.isArray(allowedClients)) { + return res.status(400).json({ error: 'Allowed clients must be an array' }) + } + + // 验证标签字段 + if (tags !== undefined && !Array.isArray(tags)) { + return res.status(400).json({ error: 'Tags must be an array' }) + } + + if (tags && tags.some((tag) => typeof tag !== 'string' || tag.trim().length === 0)) { + return res.status(400).json({ error: 'All tags must be non-empty strings' }) + } + + if ( + totalCostLimit !== undefined && + totalCostLimit !== null && + totalCostLimit !== '' && + (Number.isNaN(Number(totalCostLimit)) || Number(totalCostLimit) < 0) + ) { + return res.status(400).json({ error: 'Total cost limit must be a non-negative number' }) + } + + // 验证激活相关字段 + if (expirationMode && !['fixed', 'activation'].includes(expirationMode)) { + return res + .status(400) + .json({ error: 'Expiration mode must be either "fixed" or "activation"' }) + } + + if (expirationMode === 'activation') { + // 验证激活时间单位 + if (!activationUnit || !['hours', 'days'].includes(activationUnit)) { + return res.status(400).json({ + error: 'Activation unit must be either "hours" or "days" when using activation mode' + }) + } + + // 验证激活时间数值 + if ( + !activationDays || + !Number.isInteger(Number(activationDays)) || + Number(activationDays) < 1 + ) { + const unitText = activationUnit === 'hours' ? 'hours' : 'days' + return res.status(400).json({ + error: `Activation ${unitText} must be a positive integer when using activation mode` + }) + } + // 激活模式下不应该设置固定过期时间 + if (expiresAt) { + return res + .status(400) + .json({ error: 'Cannot set fixed expiration date when using activation mode' }) + } + } + + // 验证服务权限字段 + 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 newKey = await apiKeyService.generateApiKey({ + name, + description, + tokenLimit, + expiresAt, + claudeAccountId, + claudeConsoleAccountId, + geminiAccountId, + openaiAccountId, + bedrockAccountId, + droidAccountId, + permissions, + concurrencyLimit, + rateLimitWindow, + rateLimitRequests, + rateLimitCost, + enableModelRestriction, + restrictedModels, + enableClientRestriction, + allowedClients, + dailyCostLimit, + totalCostLimit, + weeklyOpusCostLimit, + tags, + activationDays, + activationUnit, + expirationMode, + icon + }) + + logger.success(`🔑 Admin created new API key: ${name}`) + return res.json({ success: true, data: newKey }) + } catch (error) { + logger.error('❌ Failed to create API key:', error) + return res.status(500).json({ error: 'Failed to create API key', message: error.message }) + } +}) + +// 批量创建API Keys +router.post('/api-keys/batch', authenticateAdmin, async (req, res) => { + try { + const { + baseName, + count, + description, + tokenLimit, + expiresAt, + claudeAccountId, + claudeConsoleAccountId, + geminiAccountId, + openaiAccountId, + bedrockAccountId, + droidAccountId, + permissions, + concurrencyLimit, + rateLimitWindow, + rateLimitRequests, + rateLimitCost, + enableModelRestriction, + restrictedModels, + enableClientRestriction, + allowedClients, + dailyCostLimit, + totalCostLimit, + weeklyOpusCostLimit, + tags, + activationDays, + activationUnit, + expirationMode, + icon + } = req.body + + // 输入验证 + if (!baseName || typeof baseName !== 'string' || baseName.trim().length === 0) { + return res.status(400).json({ error: 'Base name is required and must be a non-empty string' }) + } + + if (!count || !Number.isInteger(count) || count < 2 || count > 500) { + return res.status(400).json({ error: 'Count must be an integer between 2 and 500' }) + } + + if (baseName.length > 90) { + return res + .status(400) + .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' + }) + } + + // 生成批量API Keys + const createdKeys = [] + const errors = [] + + for (let i = 1; i <= count; i++) { + try { + const name = `${baseName}_${i}` + const newKey = await apiKeyService.generateApiKey({ + name, + description, + tokenLimit, + expiresAt, + claudeAccountId, + claudeConsoleAccountId, + geminiAccountId, + openaiAccountId, + bedrockAccountId, + droidAccountId, + permissions, + concurrencyLimit, + rateLimitWindow, + rateLimitRequests, + rateLimitCost, + enableModelRestriction, + restrictedModels, + enableClientRestriction, + allowedClients, + dailyCostLimit, + totalCostLimit, + weeklyOpusCostLimit, + tags, + activationDays, + activationUnit, + expirationMode, + icon + }) + + // 保留原始 API Key 供返回 + createdKeys.push({ + ...newKey, + apiKey: newKey.apiKey + }) + } catch (error) { + errors.push({ + index: i, + name: `${baseName}_${i}`, + error: error.message + }) + } + } + + // 如果有部分失败,返回部分成功的结果 + if (errors.length > 0 && createdKeys.length === 0) { + return res.status(400).json({ + success: false, + error: 'Failed to create any API keys', + errors + }) + } + + // 返回创建的keys(包含完整的apiKey) + return res.json({ + success: true, + data: createdKeys, + errors: errors.length > 0 ? errors : undefined, + summary: { + requested: count, + created: createdKeys.length, + failed: errors.length + } + }) + } catch (error) { + logger.error('Failed to batch create API keys:', error) + return res.status(500).json({ + success: false, + error: 'Failed to batch create API keys', + message: error.message + }) + } +}) + +// 批量编辑API Keys +router.put('/api-keys/batch', authenticateAdmin, async (req, res) => { + try { + const { keyIds, updates } = req.body + + if (!keyIds || !Array.isArray(keyIds) || keyIds.length === 0) { + return res.status(400).json({ + error: 'Invalid input', + message: 'keyIds must be a non-empty array' + }) + } + + if (!updates || typeof updates !== 'object') { + return res.status(400).json({ + error: 'Invalid input', + message: 'updates must be an object' + }) + } + + 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' + }) + } + + logger.info( + `🔄 Admin batch editing ${keyIds.length} API keys with updates: ${JSON.stringify(updates)}` + ) + logger.info(`🔍 Debug: keyIds received: ${JSON.stringify(keyIds)}`) + + const results = { + successCount: 0, + failedCount: 0, + errors: [] + } + + // 处理每个API Key + for (const keyId of keyIds) { + try { + // 获取当前API Key信息 + const currentKey = await redis.getApiKey(keyId) + if (!currentKey || Object.keys(currentKey).length === 0) { + results.failedCount++ + results.errors.push(`API key ${keyId} not found`) + continue + } + + // 构建最终更新数据 + const finalUpdates = {} + + // 处理普通字段 + if (updates.name) { + finalUpdates.name = updates.name + } + if (updates.tokenLimit !== undefined) { + finalUpdates.tokenLimit = updates.tokenLimit + } + if (updates.rateLimitCost !== undefined) { + finalUpdates.rateLimitCost = updates.rateLimitCost + } + if (updates.concurrencyLimit !== undefined) { + finalUpdates.concurrencyLimit = updates.concurrencyLimit + } + if (updates.rateLimitWindow !== undefined) { + finalUpdates.rateLimitWindow = updates.rateLimitWindow + } + if (updates.rateLimitRequests !== undefined) { + finalUpdates.rateLimitRequests = updates.rateLimitRequests + } + if (updates.dailyCostLimit !== undefined) { + finalUpdates.dailyCostLimit = updates.dailyCostLimit + } + if (updates.totalCostLimit !== undefined) { + finalUpdates.totalCostLimit = updates.totalCostLimit + } + if (updates.weeklyOpusCostLimit !== undefined) { + finalUpdates.weeklyOpusCostLimit = updates.weeklyOpusCostLimit + } + if (updates.permissions !== undefined) { + finalUpdates.permissions = updates.permissions + } + if (updates.isActive !== undefined) { + finalUpdates.isActive = updates.isActive + } + if (updates.monthlyLimit !== undefined) { + finalUpdates.monthlyLimit = updates.monthlyLimit + } + if (updates.priority !== undefined) { + finalUpdates.priority = updates.priority + } + if (updates.enabled !== undefined) { + finalUpdates.enabled = updates.enabled + } + + // 处理账户绑定 + if (updates.claudeAccountId !== undefined) { + finalUpdates.claudeAccountId = updates.claudeAccountId + } + if (updates.claudeConsoleAccountId !== undefined) { + finalUpdates.claudeConsoleAccountId = updates.claudeConsoleAccountId + } + if (updates.geminiAccountId !== undefined) { + finalUpdates.geminiAccountId = updates.geminiAccountId + } + if (updates.openaiAccountId !== undefined) { + finalUpdates.openaiAccountId = updates.openaiAccountId + } + if (updates.bedrockAccountId !== undefined) { + finalUpdates.bedrockAccountId = updates.bedrockAccountId + } + if (updates.droidAccountId !== undefined) { + finalUpdates.droidAccountId = updates.droidAccountId || '' + } + + // 处理标签操作 + if (updates.tags !== undefined) { + if (updates.tagOperation) { + const currentTags = currentKey.tags ? JSON.parse(currentKey.tags) : [] + const operationTags = updates.tags + + switch (updates.tagOperation) { + case 'replace': { + finalUpdates.tags = operationTags + break + } + case 'add': { + const newTags = [...currentTags] + operationTags.forEach((tag) => { + if (!newTags.includes(tag)) { + newTags.push(tag) + } + }) + finalUpdates.tags = newTags + break + } + case 'remove': { + finalUpdates.tags = currentTags.filter((tag) => !operationTags.includes(tag)) + break + } + } + } else { + // 如果没有指定操作类型,默认为替换 + finalUpdates.tags = updates.tags + } + } + + // 执行更新 + await apiKeyService.updateApiKey(keyId, finalUpdates) + results.successCount++ + logger.success(`✅ Batch edit: API key ${keyId} updated successfully`) + } catch (error) { + results.failedCount++ + results.errors.push(`Failed to update key ${keyId}: ${error.message}`) + logger.error(`❌ Batch edit failed for key ${keyId}:`, error) + } + } + + // 记录批量编辑结果 + if (results.successCount > 0) { + logger.success( + `🎉 Batch edit completed: ${results.successCount} successful, ${results.failedCount} failed` + ) + } else { + logger.warn( + `⚠️ Batch edit completed with no successful updates: ${results.failedCount} failed` + ) + } + + return res.json({ + success: true, + message: `批量编辑完成`, + data: results + }) + } catch (error) { + logger.error('❌ Failed to batch edit API keys:', error) + return res.status(500).json({ + error: 'Batch edit failed', + message: error.message + }) + } +}) + +// 更新API Key +router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { + try { + const { keyId } = req.params + const { + name, // 添加名称字段 + tokenLimit, + concurrencyLimit, + rateLimitWindow, + rateLimitRequests, + rateLimitCost, + isActive, + claudeAccountId, + claudeConsoleAccountId, + geminiAccountId, + openaiAccountId, + bedrockAccountId, + droidAccountId, + permissions, + enableModelRestriction, + restrictedModels, + enableClientRestriction, + allowedClients, + expiresAt, + dailyCostLimit, + totalCostLimit, + weeklyOpusCostLimit, + tags, + ownerId // 新增:所有者ID字段 + } = req.body + + // 只允许更新指定字段 + const updates = {} + + // 处理名称字段 + if (name !== undefined && name !== null && name !== '') { + const trimmedName = name.toString().trim() + if (trimmedName.length === 0) { + return res.status(400).json({ error: 'API Key name cannot be empty' }) + } + if (trimmedName.length > 100) { + return res.status(400).json({ error: 'API Key name must be less than 100 characters' }) + } + updates.name = trimmedName + } + + if (tokenLimit !== undefined && tokenLimit !== null && tokenLimit !== '') { + if (!Number.isInteger(Number(tokenLimit)) || Number(tokenLimit) < 0) { + return res.status(400).json({ error: 'Token limit must be a non-negative integer' }) + } + updates.tokenLimit = Number(tokenLimit) + } + + if (concurrencyLimit !== undefined && concurrencyLimit !== null && concurrencyLimit !== '') { + if (!Number.isInteger(Number(concurrencyLimit)) || Number(concurrencyLimit) < 0) { + return res.status(400).json({ error: 'Concurrency limit must be a non-negative integer' }) + } + updates.concurrencyLimit = Number(concurrencyLimit) + } + + if (rateLimitWindow !== undefined && rateLimitWindow !== null && rateLimitWindow !== '') { + if (!Number.isInteger(Number(rateLimitWindow)) || Number(rateLimitWindow) < 0) { + return res + .status(400) + .json({ error: 'Rate limit window must be a non-negative integer (minutes)' }) + } + updates.rateLimitWindow = Number(rateLimitWindow) + } + + if (rateLimitRequests !== undefined && rateLimitRequests !== null && rateLimitRequests !== '') { + if (!Number.isInteger(Number(rateLimitRequests)) || Number(rateLimitRequests) < 0) { + return res.status(400).json({ error: 'Rate limit requests must be a non-negative integer' }) + } + updates.rateLimitRequests = Number(rateLimitRequests) + } + + if (rateLimitCost !== undefined && rateLimitCost !== null && rateLimitCost !== '') { + const cost = Number(rateLimitCost) + if (isNaN(cost) || cost < 0) { + return res.status(400).json({ error: 'Rate limit cost must be a non-negative number' }) + } + updates.rateLimitCost = cost + } + + if (claudeAccountId !== undefined) { + // 空字符串表示解绑,null或空字符串都设置为空字符串 + updates.claudeAccountId = claudeAccountId || '' + } + + if (claudeConsoleAccountId !== undefined) { + // 空字符串表示解绑,null或空字符串都设置为空字符串 + updates.claudeConsoleAccountId = claudeConsoleAccountId || '' + } + + if (geminiAccountId !== undefined) { + // 空字符串表示解绑,null或空字符串都设置为空字符串 + updates.geminiAccountId = geminiAccountId || '' + } + + if (openaiAccountId !== undefined) { + // 空字符串表示解绑,null或空字符串都设置为空字符串 + updates.openaiAccountId = openaiAccountId || '' + } + + if (bedrockAccountId !== undefined) { + // 空字符串表示解绑,null或空字符串都设置为空字符串 + updates.bedrockAccountId = bedrockAccountId || '' + } + + if (droidAccountId !== undefined) { + // 空字符串表示解绑,null或空字符串都设置为空字符串 + updates.droidAccountId = droidAccountId || '' + } + + 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' + }) + } + updates.permissions = permissions + } + + // 处理模型限制字段 + if (enableModelRestriction !== undefined) { + if (typeof enableModelRestriction !== 'boolean') { + return res.status(400).json({ error: 'Enable model restriction must be a boolean' }) + } + updates.enableModelRestriction = enableModelRestriction + } + + if (restrictedModels !== undefined) { + if (!Array.isArray(restrictedModels)) { + return res.status(400).json({ error: 'Restricted models must be an array' }) + } + updates.restrictedModels = restrictedModels + } + + // 处理客户端限制字段 + if (enableClientRestriction !== undefined) { + if (typeof enableClientRestriction !== 'boolean') { + return res.status(400).json({ error: 'Enable client restriction must be a boolean' }) + } + updates.enableClientRestriction = enableClientRestriction + } + + if (allowedClients !== undefined) { + if (!Array.isArray(allowedClients)) { + return res.status(400).json({ error: 'Allowed clients must be an array' }) + } + updates.allowedClients = allowedClients + } + + // 处理过期时间字段 + if (expiresAt !== undefined) { + if (expiresAt === null) { + // null 表示永不过期 + updates.expiresAt = null + updates.isActive = true + } else { + // 验证日期格式 + const expireDate = new Date(expiresAt) + if (isNaN(expireDate.getTime())) { + return res.status(400).json({ error: 'Invalid expiration date format' }) + } + updates.expiresAt = expiresAt + updates.isActive = expireDate > new Date() // 如果过期时间在当前时间之后,则设置为激活状态 + } + } + + // 处理每日费用限制 + if (dailyCostLimit !== undefined && dailyCostLimit !== null && dailyCostLimit !== '') { + const costLimit = Number(dailyCostLimit) + if (isNaN(costLimit) || costLimit < 0) { + return res.status(400).json({ error: 'Daily cost limit must be a non-negative number' }) + } + updates.dailyCostLimit = costLimit + } + + if (totalCostLimit !== undefined && totalCostLimit !== null && totalCostLimit !== '') { + const costLimit = Number(totalCostLimit) + if (isNaN(costLimit) || costLimit < 0) { + return res.status(400).json({ error: 'Total cost limit must be a non-negative number' }) + } + updates.totalCostLimit = costLimit + } + + // 处理 Opus 周费用限制 + if ( + weeklyOpusCostLimit !== undefined && + weeklyOpusCostLimit !== null && + weeklyOpusCostLimit !== '' + ) { + const costLimit = Number(weeklyOpusCostLimit) + // 明确验证非负数(0 表示禁用,负数无意义) + if (isNaN(costLimit) || costLimit < 0) { + return res + .status(400) + .json({ error: 'Weekly Opus cost limit must be a non-negative number' }) + } + updates.weeklyOpusCostLimit = costLimit + } + + // 处理标签 + if (tags !== undefined) { + if (!Array.isArray(tags)) { + return res.status(400).json({ error: 'Tags must be an array' }) + } + if (tags.some((tag) => typeof tag !== 'string' || tag.trim().length === 0)) { + return res.status(400).json({ error: 'All tags must be non-empty strings' }) + } + updates.tags = tags + } + + // 处理活跃/禁用状态状态, 放在过期处理后,以确保后续增加禁用key功能 + if (isActive !== undefined) { + if (typeof isActive !== 'boolean') { + return res.status(400).json({ error: 'isActive must be a boolean' }) + } + updates.isActive = isActive + } + + // 处理所有者变更 + if (ownerId !== undefined) { + const userService = require('../services/userService') + + if (ownerId === 'admin') { + // 分配给Admin + updates.userId = '' + updates.userUsername = '' + updates.createdBy = 'admin' + } else if (ownerId) { + // 分配给用户 + try { + const user = await userService.getUserById(ownerId, false) + if (!user) { + return res.status(400).json({ error: 'Invalid owner: User not found' }) + } + if (!user.isActive) { + return res.status(400).json({ error: 'Cannot assign to inactive user' }) + } + + // 设置新的所有者信息 + updates.userId = ownerId + updates.userUsername = user.username + updates.createdBy = user.username + + // 管理员重新分配时,不检查用户的API Key数量限制 + logger.info(`🔄 Admin reassigning API key ${keyId} to user ${user.username}`) + } catch (error) { + logger.error('Error fetching user for owner reassignment:', error) + return res.status(400).json({ error: 'Invalid owner ID' }) + } + } else { + // 清空所有者(分配给Admin) + updates.userId = '' + updates.userUsername = '' + updates.createdBy = 'admin' + } + } + + await apiKeyService.updateApiKey(keyId, updates) + + logger.success(`📝 Admin updated API key: ${keyId}`) + return res.json({ success: true, message: 'API key updated successfully' }) + } catch (error) { + logger.error('❌ Failed to update API key:', error) + return res.status(500).json({ error: 'Failed to update API key', message: error.message }) + } +}) + +// 修改API Key过期时间(包括手动激活功能) +router.patch('/api-keys/:keyId/expiration', authenticateAdmin, async (req, res) => { + try { + const { keyId } = req.params + const { expiresAt, activateNow } = req.body + + // 获取当前API Key信息 + const keyData = await redis.getApiKey(keyId) + if (!keyData || Object.keys(keyData).length === 0) { + return res.status(404).json({ error: 'API key not found' }) + } + + const updates = {} + + // 如果是激活操作(用于未激活的key) + if (activateNow === true) { + if (keyData.expirationMode === 'activation' && keyData.isActivated !== 'true') { + const now = new Date() + const activationDays = parseInt(keyData.activationDays || 30) + const newExpiresAt = new Date(now.getTime() + activationDays * 24 * 60 * 60 * 1000) + + updates.isActivated = 'true' + updates.activatedAt = now.toISOString() + updates.expiresAt = newExpiresAt.toISOString() + + logger.success( + `🔓 API key manually activated by admin: ${keyId} (${ + keyData.name + }), expires at ${newExpiresAt.toISOString()}` + ) + } else { + return res.status(400).json({ + error: 'Cannot activate', + message: 'Key is either already activated or not in activation mode' + }) + } + } + + // 如果提供了新的过期时间(但不是激活操作) + if (expiresAt !== undefined && activateNow !== true) { + // 验证过期时间格式 + if (expiresAt && isNaN(Date.parse(expiresAt))) { + return res.status(400).json({ error: 'Invalid expiration date format' }) + } + + // 如果设置了过期时间,确保key是激活状态 + if (expiresAt) { + updates.expiresAt = new Date(expiresAt).toISOString() + // 如果之前是未激活状态,现在激活它 + if (keyData.isActivated !== 'true') { + updates.isActivated = 'true' + updates.activatedAt = new Date().toISOString() + } + } else { + // 清除过期时间(永不过期) + updates.expiresAt = '' + } + } + + if (Object.keys(updates).length === 0) { + return res.status(400).json({ error: 'No valid updates provided' }) + } + + // 更新API Key + await apiKeyService.updateApiKey(keyId, updates) + + logger.success(`📝 Updated API key expiration: ${keyId} (${keyData.name})`) + return res.json({ + success: true, + message: 'API key expiration updated successfully', + updates + }) + } catch (error) { + logger.error('❌ Failed to update API key expiration:', error) + return res.status(500).json({ + error: 'Failed to update API key expiration', + message: error.message + }) + } +}) + +// 批量删除API Keys(必须在 :keyId 路由之前定义) +router.delete('/api-keys/batch', authenticateAdmin, async (req, res) => { + try { + const { keyIds } = req.body + + // 调试信息 + logger.info(`🐛 Batch delete request body: ${JSON.stringify(req.body)}`) + logger.info(`🐛 keyIds type: ${typeof keyIds}, value: ${JSON.stringify(keyIds)}`) + + // 参数验证 + if (!keyIds || !Array.isArray(keyIds) || keyIds.length === 0) { + logger.warn( + `🚨 Invalid keyIds: ${JSON.stringify({ + keyIds, + type: typeof keyIds, + isArray: Array.isArray(keyIds) + })}` + ) + return res.status(400).json({ + error: 'Invalid request', + message: 'keyIds 必须是一个非空数组' + }) + } + + if (keyIds.length > 100) { + return res.status(400).json({ + error: 'Too many keys', + message: '每次最多只能删除100个API Keys' + }) + } + + // 验证keyIds格式 + const invalidKeys = keyIds.filter((id) => !id || typeof id !== 'string') + if (invalidKeys.length > 0) { + return res.status(400).json({ + error: 'Invalid key IDs', + message: '包含无效的API Key ID' + }) + } + + logger.info( + `🗑️ Admin attempting batch delete of ${keyIds.length} API keys: ${JSON.stringify(keyIds)}` + ) + + const results = { + successCount: 0, + failedCount: 0, + errors: [] + } + + // 逐个删除,记录成功和失败情况 + for (const keyId of keyIds) { + try { + // 检查API Key是否存在 + const apiKey = await redis.getApiKey(keyId) + if (!apiKey || Object.keys(apiKey).length === 0) { + results.failedCount++ + results.errors.push({ keyId, error: 'API Key 不存在' }) + continue + } + + // 执行删除 + await apiKeyService.deleteApiKey(keyId) + results.successCount++ + + logger.success(`✅ Batch delete: API key ${keyId} deleted successfully`) + } catch (error) { + results.failedCount++ + results.errors.push({ + keyId, + error: error.message || '删除失败' + }) + + logger.error(`❌ Batch delete failed for key ${keyId}:`, error) + } + } + + // 记录批量删除结果 + if (results.successCount > 0) { + logger.success( + `🎉 Batch delete completed: ${results.successCount} successful, ${results.failedCount} failed` + ) + } else { + logger.warn( + `⚠️ Batch delete completed with no successful deletions: ${results.failedCount} failed` + ) + } + + return res.json({ + success: true, + message: `批量删除完成`, + data: results + }) + } catch (error) { + logger.error('❌ Failed to batch delete API keys:', error) + return res.status(500).json({ + error: 'Batch delete failed', + message: error.message + }) + } +}) + +// 删除单个API Key(必须在批量删除路由之后定义) +router.delete('/api-keys/:keyId', authenticateAdmin, async (req, res) => { + try { + const { keyId } = req.params + + await apiKeyService.deleteApiKey(keyId, req.admin.username, 'admin') + + logger.success(`🗑️ Admin deleted API key: ${keyId}`) + return res.json({ success: true, message: 'API key deleted successfully' }) + } catch (error) { + logger.error('❌ Failed to delete API key:', error) + return res.status(500).json({ error: 'Failed to delete API key', message: error.message }) + } +}) + +// 📋 获取已删除的API Keys +router.get('/api-keys/deleted', authenticateAdmin, async (req, res) => { + try { + const deletedApiKeys = await apiKeyService.getAllApiKeys(true) // Include deleted + const onlyDeleted = deletedApiKeys.filter((key) => key.isDeleted === 'true') + + // Add additional metadata for deleted keys + const enrichedKeys = onlyDeleted.map((key) => ({ + ...key, + isDeleted: key.isDeleted === 'true', + deletedAt: key.deletedAt, + deletedBy: key.deletedBy, + deletedByType: key.deletedByType, + canRestore: true // 已删除的API Key可以恢复 + })) + + logger.success(`📋 Admin retrieved ${enrichedKeys.length} deleted API keys`) + return res.json({ success: true, apiKeys: enrichedKeys, total: enrichedKeys.length }) + } catch (error) { + logger.error('❌ Failed to get deleted API keys:', error) + return res + .status(500) + .json({ error: 'Failed to retrieve deleted API keys', message: error.message }) + } +}) + +// 🔄 恢复已删除的API Key +router.post('/api-keys/:keyId/restore', authenticateAdmin, async (req, res) => { + try { + const { keyId } = req.params + const adminUsername = req.session?.admin?.username || 'unknown' + + // 调用服务层的恢复方法 + const result = await apiKeyService.restoreApiKey(keyId, adminUsername, 'admin') + + if (result.success) { + logger.success(`✅ Admin ${adminUsername} restored API key: ${keyId}`) + return res.json({ + success: true, + message: 'API Key 已成功恢复', + apiKey: result.apiKey + }) + } else { + return res.status(400).json({ + success: false, + error: 'Failed to restore API key' + }) + } + } catch (error) { + logger.error('❌ Failed to restore API key:', error) + + // 根据错误类型返回适当的响应 + if (error.message === 'API key not found') { + return res.status(404).json({ + success: false, + error: 'API Key 不存在' + }) + } else if (error.message === 'API key is not deleted') { + return res.status(400).json({ + success: false, + error: '该 API Key 未被删除,无需恢复' + }) + } + + return res.status(500).json({ + success: false, + error: '恢复 API Key 失败', + message: error.message + }) + } +}) + +// 🗑️ 彻底删除API Key(物理删除) +router.delete('/api-keys/:keyId/permanent', authenticateAdmin, async (req, res) => { + try { + const { keyId } = req.params + const adminUsername = req.session?.admin?.username || 'unknown' + + // 调用服务层的彻底删除方法 + const result = await apiKeyService.permanentDeleteApiKey(keyId) + + if (result.success) { + logger.success(`🗑️ Admin ${adminUsername} permanently deleted API key: ${keyId}`) + return res.json({ + success: true, + message: 'API Key 已彻底删除' + }) + } + } catch (error) { + logger.error('❌ Failed to permanently delete API key:', error) + + if (error.message === 'API key not found') { + return res.status(404).json({ + success: false, + error: 'API Key 不存在' + }) + } else if (error.message === '只能彻底删除已经删除的API Key') { + return res.status(400).json({ + success: false, + error: '只能彻底删除已经删除的API Key' + }) + } + + return res.status(500).json({ + success: false, + error: '彻底删除 API Key 失败', + message: error.message + }) + } +}) + +// 🧹 清空所有已删除的API Keys +router.delete('/api-keys/deleted/clear-all', authenticateAdmin, async (req, res) => { + try { + const adminUsername = req.session?.admin?.username || 'unknown' + + // 调用服务层的清空方法 + const result = await apiKeyService.clearAllDeletedApiKeys() + + logger.success( + `🧹 Admin ${adminUsername} cleared deleted API keys: ${result.successCount}/${result.total}` + ) + + return res.json({ + success: true, + message: `成功清空 ${result.successCount} 个已删除的 API Keys`, + details: { + total: result.total, + successCount: result.successCount, + failedCount: result.failedCount, + errors: result.errors + } + }) + } catch (error) { + logger.error('❌ Failed to clear all deleted API keys:', error) + return res.status(500).json({ + success: false, + error: '清空已删除的 API Keys 失败', + message: error.message + }) + } +}) + +// 👥 账户分组管理 + +// 创建账户分组 +router.post('/account-groups', authenticateAdmin, async (req, res) => { + try { + const { name, platform, description } = req.body + + const group = await accountGroupService.createGroup({ + name, + platform, + description + }) + + return res.json({ success: true, data: group }) + } catch (error) { + logger.error('❌ Failed to create account group:', error) + return res.status(400).json({ error: error.message }) + } +}) + +// 获取所有分组 +router.get('/account-groups', authenticateAdmin, async (req, res) => { + try { + const { platform } = req.query + const groups = await accountGroupService.getAllGroups(platform) + return res.json({ success: true, data: groups }) + } catch (error) { + logger.error('❌ Failed to get account groups:', error) + return res.status(500).json({ error: error.message }) + } +}) + +// 获取分组详情 +router.get('/account-groups/:groupId', authenticateAdmin, async (req, res) => { + try { + const { groupId } = req.params + const group = await accountGroupService.getGroup(groupId) + + if (!group) { + return res.status(404).json({ error: '分组不存在' }) + } + + return res.json({ success: true, data: group }) + } catch (error) { + logger.error('❌ Failed to get account group:', error) + return res.status(500).json({ error: error.message }) + } +}) + +// 更新分组 +router.put('/account-groups/:groupId', authenticateAdmin, async (req, res) => { + try { + const { groupId } = req.params + const updates = req.body + + const updatedGroup = await accountGroupService.updateGroup(groupId, updates) + return res.json({ success: true, data: updatedGroup }) + } catch (error) { + logger.error('❌ Failed to update account group:', error) + return res.status(400).json({ error: error.message }) + } +}) + +// 删除分组 +router.delete('/account-groups/:groupId', authenticateAdmin, async (req, res) => { + try { + const { groupId } = req.params + await accountGroupService.deleteGroup(groupId) + return res.json({ success: true, message: '分组删除成功' }) + } catch (error) { + logger.error('❌ Failed to delete account group:', error) + return res.status(400).json({ error: error.message }) + } +}) + +// 获取分组成员 +router.get('/account-groups/:groupId/members', authenticateAdmin, async (req, res) => { + try { + const { groupId } = req.params + const group = await accountGroupService.getGroup(groupId) + + if (!group) { + return res.status(404).json({ error: '分组不存在' }) + } + + const memberIds = await accountGroupService.getGroupMembers(groupId) + + // 获取成员详细信息 + const members = [] + for (const memberId of memberIds) { + // 根据分组平台优先查找对应账户 + let account = null + switch (group.platform) { + case 'droid': + account = await droidAccountService.getAccount(memberId) + break + case 'gemini': + account = await geminiAccountService.getAccount(memberId) + break + case 'openai': + account = await openaiAccountService.getAccount(memberId) + break + case 'claude': + default: + account = await claudeAccountService.getAccount(memberId) + if (!account) { + account = await claudeConsoleAccountService.getAccount(memberId) + } + break + } + + // 兼容旧数据:若按平台未找到,则继续尝试其他平台 + if (!account) { + account = await claudeAccountService.getAccount(memberId) + } + if (!account) { + account = await claudeConsoleAccountService.getAccount(memberId) + } + if (!account) { + account = await geminiAccountService.getAccount(memberId) + } + if (!account) { + account = await openaiAccountService.getAccount(memberId) + } + if (!account && group.platform !== 'droid') { + account = await droidAccountService.getAccount(memberId) + } + + if (account) { + members.push(account) + } + } + + return res.json({ success: true, data: members }) + } catch (error) { + logger.error('❌ Failed to get group members:', error) + return res.status(500).json({ error: error.message }) + } +}) + +// 🏢 Claude 账户管理 + +// 生成OAuth授权URL +router.post('/claude-accounts/generate-auth-url', authenticateAdmin, async (req, res) => { + try { + const { proxy } = req.body // 接收代理配置 + const oauthParams = await oauthHelper.generateOAuthParams() + + // 将codeVerifier和state临时存储到Redis,用于后续验证 + const sessionId = require('crypto').randomUUID() + await redis.setOAuthSession(sessionId, { + codeVerifier: oauthParams.codeVerifier, + state: oauthParams.state, + codeChallenge: oauthParams.codeChallenge, + proxy: proxy || null, // 存储代理配置 + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString() // 10分钟过期 + }) + + logger.success('🔗 Generated OAuth authorization URL with proxy support') + return res.json({ + success: true, + data: { + authUrl: oauthParams.authUrl, + sessionId, + instructions: [ + '1. 复制上面的链接到浏览器中打开', + '2. 登录您的 Anthropic 账户', + '3. 同意应用权限', + '4. 复制浏览器地址栏中的完整 URL', + '5. 在添加账户表单中粘贴完整的回调 URL 和授权码' + ] + } + }) + } catch (error) { + logger.error('❌ Failed to generate OAuth URL:', error) + return res.status(500).json({ error: 'Failed to generate OAuth URL', message: error.message }) + } +}) + +// 验证授权码并获取token +router.post('/claude-accounts/exchange-code', authenticateAdmin, async (req, res) => { + try { + const { sessionId, authorizationCode, callbackUrl } = req.body + + if (!sessionId || (!authorizationCode && !callbackUrl)) { + return res + .status(400) + .json({ error: 'Session ID and authorization code (or callback URL) are required' }) + } + + // 从Redis获取OAuth会话信息 + const oauthSession = await redis.getOAuthSession(sessionId) + if (!oauthSession) { + return res.status(400).json({ error: 'Invalid or expired OAuth session' }) + } + + // 检查会话是否过期 + if (new Date() > new Date(oauthSession.expiresAt)) { + await redis.deleteOAuthSession(sessionId) + return res + .status(400) + .json({ error: 'OAuth session has expired, please generate a new authorization URL' }) + } + + // 统一处理授权码输入(可能是直接的code或完整的回调URL) + let finalAuthCode + const inputValue = callbackUrl || authorizationCode + + try { + finalAuthCode = oauthHelper.parseCallbackUrl(inputValue) + } catch (parseError) { + return res + .status(400) + .json({ error: 'Failed to parse authorization input', message: parseError.message }) + } + + // 交换访问令牌 + const tokenData = await oauthHelper.exchangeCodeForTokens( + finalAuthCode, + oauthSession.codeVerifier, + oauthSession.state, + oauthSession.proxy // 传递代理配置 + ) + + // 清理OAuth会话 + await redis.deleteOAuthSession(sessionId) + + logger.success('🎉 Successfully exchanged authorization code for tokens') + return res.json({ + success: true, + data: { + claudeAiOauth: tokenData + } + }) + } catch (error) { + logger.error('❌ Failed to exchange authorization code:', { + error: error.message, + sessionId: req.body.sessionId, + // 不记录完整的授权码,只记录长度和前几个字符 + codeLength: req.body.callbackUrl + ? req.body.callbackUrl.length + : req.body.authorizationCode + ? req.body.authorizationCode.length + : 0, + codePrefix: req.body.callbackUrl + ? `${req.body.callbackUrl.substring(0, 10)}...` + : req.body.authorizationCode + ? `${req.body.authorizationCode.substring(0, 10)}...` + : 'N/A' + }) + return res + .status(500) + .json({ error: 'Failed to exchange authorization code', message: error.message }) + } +}) + +// 生成Claude setup-token授权URL +router.post('/claude-accounts/generate-setup-token-url', authenticateAdmin, async (req, res) => { + try { + const { proxy } = req.body // 接收代理配置 + const setupTokenParams = await oauthHelper.generateSetupTokenParams() + + // 将codeVerifier和state临时存储到Redis,用于后续验证 + const sessionId = require('crypto').randomUUID() + await redis.setOAuthSession(sessionId, { + type: 'setup-token', // 标记为setup-token类型 + codeVerifier: setupTokenParams.codeVerifier, + state: setupTokenParams.state, + codeChallenge: setupTokenParams.codeChallenge, + proxy: proxy || null, // 存储代理配置 + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString() // 10分钟过期 + }) + + logger.success('🔗 Generated Setup Token authorization URL with proxy support') + return res.json({ + success: true, + data: { + authUrl: setupTokenParams.authUrl, + sessionId, + instructions: [ + '1. 复制上面的链接到浏览器中打开', + '2. 登录您的 Claude 账户并授权 Claude Code', + '3. 完成授权后,从返回页面复制 Authorization Code', + '4. 在添加账户表单中粘贴 Authorization Code' + ] + } + }) + } catch (error) { + logger.error('❌ Failed to generate Setup Token URL:', error) + return res + .status(500) + .json({ error: 'Failed to generate Setup Token URL', message: error.message }) + } +}) + +// 验证setup-token授权码并获取token +router.post('/claude-accounts/exchange-setup-token-code', authenticateAdmin, async (req, res) => { + try { + const { sessionId, authorizationCode, callbackUrl } = req.body + + if (!sessionId || (!authorizationCode && !callbackUrl)) { + return res + .status(400) + .json({ error: 'Session ID and authorization code (or callback URL) are required' }) + } + + // 从Redis获取OAuth会话信息 + const oauthSession = await redis.getOAuthSession(sessionId) + if (!oauthSession) { + return res.status(400).json({ error: 'Invalid or expired OAuth session' }) + } + + // 检查是否是setup-token类型 + if (oauthSession.type !== 'setup-token') { + return res.status(400).json({ error: 'Invalid session type for setup token exchange' }) + } + + // 检查会话是否过期 + if (new Date() > new Date(oauthSession.expiresAt)) { + await redis.deleteOAuthSession(sessionId) + return res + .status(400) + .json({ error: 'OAuth session has expired, please generate a new authorization URL' }) + } + + // 统一处理授权码输入(可能是直接的code或完整的回调URL) + let finalAuthCode + const inputValue = callbackUrl || authorizationCode + + try { + finalAuthCode = oauthHelper.parseCallbackUrl(inputValue) + } catch (parseError) { + return res + .status(400) + .json({ error: 'Failed to parse authorization input', message: parseError.message }) + } + + // 交换Setup Token + const tokenData = await oauthHelper.exchangeSetupTokenCode( + finalAuthCode, + oauthSession.codeVerifier, + oauthSession.state, + oauthSession.proxy // 传递代理配置 + ) + + // 清理OAuth会话 + await redis.deleteOAuthSession(sessionId) + + logger.success('🎉 Successfully exchanged setup token authorization code for tokens') + return res.json({ + success: true, + data: { + claudeAiOauth: tokenData + } + }) + } catch (error) { + logger.error('❌ Failed to exchange setup token authorization code:', { + error: error.message, + sessionId: req.body.sessionId, + // 不记录完整的授权码,只记录长度和前几个字符 + codeLength: req.body.callbackUrl + ? req.body.callbackUrl.length + : req.body.authorizationCode + ? req.body.authorizationCode.length + : 0, + codePrefix: req.body.callbackUrl + ? `${req.body.callbackUrl.substring(0, 10)}...` + : req.body.authorizationCode + ? `${req.body.authorizationCode.substring(0, 10)}...` + : 'N/A' + }) + return res + .status(500) + .json({ error: 'Failed to exchange setup token authorization code', message: error.message }) + } +}) + +// 获取所有Claude账户 +router.get('/claude-accounts', authenticateAdmin, async (req, res) => { + try { + const { platform, groupId } = req.query + let accounts = await claudeAccountService.getAllAccounts() + + // 根据查询参数进行筛选 + if (platform && platform !== 'all' && platform !== 'claude') { + // 如果指定了其他平台,返回空数组 + accounts = [] + } + + // 如果指定了分组筛选 + if (groupId && groupId !== 'all') { + if (groupId === 'ungrouped') { + // 筛选未分组账户 + const filteredAccounts = [] + for (const account of accounts) { + const groups = await accountGroupService.getAccountGroups(account.id) + if (!groups || groups.length === 0) { + filteredAccounts.push(account) + } + } + accounts = filteredAccounts + } else { + // 筛选特定分组的账户 + const groupMembers = await accountGroupService.getGroupMembers(groupId) + accounts = accounts.filter((account) => groupMembers.includes(account.id)) + } + } + + // 为每个账户添加使用统计信息 + const accountsWithStats = await Promise.all( + accounts.map(async (account) => { + try { + const usageStats = await redis.getAccountUsageStats(account.id, 'openai') + const groupInfos = await accountGroupService.getAccountGroups(account.id) + const formattedAccount = formatSubscriptionExpiry(account) + + // 获取会话窗口使用统计(仅对有活跃窗口的账户) + let sessionWindowUsage = null + if (account.sessionWindow && account.sessionWindow.hasActiveWindow) { + const windowUsage = await redis.getAccountSessionWindowUsage( + account.id, + account.sessionWindow.windowStart, + account.sessionWindow.windowEnd + ) + + // 计算会话窗口的总费用 + let totalCost = 0 + const modelCosts = {} + + for (const [modelName, usage] of Object.entries(windowUsage.modelUsage)) { + const usageData = { + input_tokens: usage.inputTokens, + output_tokens: usage.outputTokens, + cache_creation_input_tokens: usage.cacheCreateTokens, + cache_read_input_tokens: usage.cacheReadTokens + } + + logger.debug(`💰 Calculating cost for model ${modelName}:`, JSON.stringify(usageData)) + const costResult = CostCalculator.calculateCost(usageData, modelName) + logger.debug(`💰 Cost result for ${modelName}: total=${costResult.costs.total}`) + + modelCosts[modelName] = { + ...usage, + cost: costResult.costs.total + } + totalCost += costResult.costs.total + } + + sessionWindowUsage = { + totalTokens: windowUsage.totalAllTokens, + totalRequests: windowUsage.totalRequests, + totalCost, + modelUsage: modelCosts + } + } + + return { + ...formattedAccount, + // 转换schedulable为布尔值 + schedulable: account.schedulable === 'true' || account.schedulable === true, + groupInfos, + usage: { + daily: usageStats.daily, + total: usageStats.total, + averages: usageStats.averages, + sessionWindow: sessionWindowUsage + } + } + } catch (statsError) { + logger.warn(`⚠️ Failed to get usage stats for account ${account.id}:`, statsError.message) + // 如果获取统计失败,返回空统计 + try { + const groupInfos = await accountGroupService.getAccountGroups(account.id) + const formattedAccount = formatSubscriptionExpiry(account) + return { + ...formattedAccount, + groupInfos, + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 }, + sessionWindow: null + } + } + } catch (groupError) { + logger.warn( + `⚠️ Failed to get group info for account ${account.id}:`, + groupError.message + ) + const formattedAccount = formatSubscriptionExpiry(account) + return { + ...formattedAccount, + groupInfos: [], + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 }, + sessionWindow: null + } + } + } + } + }) + ) + + const formattedAccounts = accountsWithStats.map(formatSubscriptionExpiry) + return res.json({ success: true, data: formattedAccounts }) + } catch (error) { + logger.error('❌ Failed to get Claude accounts:', error) + return res.status(500).json({ error: 'Failed to get Claude accounts', message: error.message }) + } +}) + +// 批量获取 Claude 账户的 OAuth Usage 数据 +router.get('/claude-accounts/usage', authenticateAdmin, async (req, res) => { + try { + const accounts = await redis.getAllClaudeAccounts() + const now = Date.now() + const usageCacheTtlMs = 300 * 1000 + + // 批量并发获取所有活跃 OAuth 账户的 Usage + const usagePromises = accounts.map(async (account) => { + // 检查是否为 OAuth 账户:scopes 包含 OAuth 相关权限 + const scopes = account.scopes && account.scopes.trim() ? account.scopes.split(' ') : [] + const isOAuth = scopes.includes('user:profile') && scopes.includes('user:inference') + + // 仅为 OAuth 授权的活跃账户调用 usage API + if ( + isOAuth && + account.isActive === 'true' && + account.accessToken && + account.status === 'active' + ) { + // 若快照在 300 秒内更新,直接使用缓存避免频繁请求 + const cachedUsage = claudeAccountService.buildClaudeUsageSnapshot(account) + const lastUpdatedAt = account.claudeUsageUpdatedAt + ? new Date(account.claudeUsageUpdatedAt).getTime() + : 0 + const isCacheFresh = cachedUsage && lastUpdatedAt && now - lastUpdatedAt < usageCacheTtlMs + if (isCacheFresh) { + return { + accountId: account.id, + claudeUsage: cachedUsage + } + } + + try { + const usageData = await claudeAccountService.fetchOAuthUsage(account.id) + if (usageData) { + await claudeAccountService.updateClaudeUsageSnapshot(account.id, usageData) + } + // 重新读取更新后的数据 + const updatedAccount = await redis.getClaudeAccount(account.id) + return { + accountId: account.id, + claudeUsage: claudeAccountService.buildClaudeUsageSnapshot(updatedAccount) + } + } catch (error) { + logger.debug(`Failed to fetch OAuth usage for ${account.id}:`, error.message) + return { accountId: account.id, claudeUsage: null } + } + } + // Setup Token 账户不调用 usage API,直接返回 null + return { accountId: account.id, claudeUsage: null } + }) + + const results = await Promise.allSettled(usagePromises) + + // 转换为 { accountId: usage } 映射 + const usageMap = {} + results.forEach((result) => { + if (result.status === 'fulfilled' && result.value) { + usageMap[result.value.accountId] = result.value.claudeUsage + } + }) + + res.json({ success: true, data: usageMap }) + } catch (error) { + logger.error('❌ Failed to fetch Claude accounts usage:', error) + res.status(500).json({ error: 'Failed to fetch usage data', message: error.message }) + } +}) + +// 创建新的Claude账户 +router.post('/claude-accounts', authenticateAdmin, async (req, res) => { + try { + const { + name, + description, + email, + password, + refreshToken, + claudeAiOauth, + proxy, + accountType, + platform = 'claude', + priority, + groupId, + groupIds, + autoStopOnWarning, + useUnifiedUserAgent, + useUnifiedClientId, + unifiedClientId, + expiresAt, + subscriptionExpiresAt + } = req.body + + if (!name) { + return res.status(400).json({ error: 'Name is required' }) + } + + // 验证accountType的有效性 + if (accountType && !['shared', 'dedicated', 'group'].includes(accountType)) { + return res + .status(400) + .json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' }) + } + + // 如果是分组类型,验证groupId或groupIds + if (accountType === 'group' && !groupId && (!groupIds || groupIds.length === 0)) { + return res + .status(400) + .json({ error: 'Group ID or Group IDs are required for group type accounts' }) + } + + // 验证priority的有效性 + if ( + priority !== undefined && + (typeof priority !== 'number' || priority < 1 || priority > 100) + ) { + return res.status(400).json({ error: 'Priority must be a number between 1 and 100' }) + } + + const newAccount = await claudeAccountService.createAccount({ + name, + description, + email, + password, + refreshToken, + claudeAiOauth, + proxy, + accountType: accountType || 'shared', // 默认为共享类型 + platform, + priority: priority || 50, // 默认优先级为50 + autoStopOnWarning: autoStopOnWarning === true, // 默认为false + useUnifiedUserAgent: useUnifiedUserAgent === true, // 默认为false + useUnifiedClientId: useUnifiedClientId === true, // 默认为false + unifiedClientId: unifiedClientId || '', // 统一的客户端标识 + expiresAt: subscriptionExpiresAt ?? expiresAt ?? null // 账户订阅到期时间 + }) + + // 如果是分组类型,将账户添加到分组 + if (accountType === 'group') { + if (groupIds && groupIds.length > 0) { + // 使用多分组设置 + await accountGroupService.setAccountGroups(newAccount.id, groupIds, newAccount.platform) + } else if (groupId) { + // 兼容单分组模式 + await accountGroupService.addAccountToGroup(newAccount.id, groupId, newAccount.platform) + } + } + + logger.success(`🏢 Admin created new Claude account: ${name} (${accountType || 'shared'})`) + const responseAccount = formatSubscriptionExpiry(newAccount) + return res.json({ success: true, data: responseAccount }) + } catch (error) { + logger.error('❌ Failed to create Claude account:', error) + return res + .status(500) + .json({ error: 'Failed to create Claude account', message: error.message }) + } +}) + +// 更新Claude账户 +router.put('/claude-accounts/:accountId', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + const updates = req.body + + // 验证priority的有效性 + if ( + updates.priority !== undefined && + (typeof updates.priority !== 'number' || updates.priority < 1 || updates.priority > 100) + ) { + return res.status(400).json({ error: 'Priority must be a number between 1 and 100' }) + } + + // 验证accountType的有效性 + if (updates.accountType && !['shared', 'dedicated', 'group'].includes(updates.accountType)) { + return res + .status(400) + .json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' }) + } + + // 如果更新为分组类型,验证groupId或groupIds + if ( + updates.accountType === 'group' && + !updates.groupId && + (!updates.groupIds || updates.groupIds.length === 0) + ) { + return res + .status(400) + .json({ error: 'Group ID or Group IDs are required for group type accounts' }) + } + + // 获取账户当前信息以处理分组变更 + const currentAccount = await claudeAccountService.getAccount(accountId) + if (!currentAccount) { + return res.status(404).json({ error: 'Account not found' }) + } + + // 处理分组的变更 + if (updates.accountType !== undefined) { + // 如果之前是分组类型,需要从所有分组中移除 + if (currentAccount.accountType === 'group') { + await accountGroupService.removeAccountFromAllGroups(accountId) + } + + // 如果新类型是分组,添加到新分组 + if (updates.accountType === 'group') { + // 处理多分组/单分组的兼容性 + if (Object.prototype.hasOwnProperty.call(updates, 'groupIds')) { + if (updates.groupIds && updates.groupIds.length > 0) { + // 使用多分组设置 + await accountGroupService.setAccountGroups(accountId, updates.groupIds, 'claude') + } else { + // groupIds 为空数组,从所有分组中移除 + await accountGroupService.removeAccountFromAllGroups(accountId) + } + } else if (updates.groupId) { + // 兼容单分组模式 + await accountGroupService.addAccountToGroup(accountId, updates.groupId, 'claude') + } + } + } + + // 映射字段名:前端的expiresAt -> 后端的subscriptionExpiresAt + const mappedUpdates = { ...updates } + if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) { + mappedUpdates.subscriptionExpiresAt = updates.subscriptionExpiresAt + } else if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'expiresAt')) { + mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt + } + if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'subscriptionExpiresAt')) { + delete mappedUpdates.expiresAt + } + + await claudeAccountService.updateAccount(accountId, mappedUpdates) + + logger.success(`📝 Admin updated Claude account: ${accountId}`) + return res.json({ success: true, message: 'Claude account updated successfully' }) + } catch (error) { + logger.error('❌ Failed to update Claude account:', error) + return res + .status(500) + .json({ error: 'Failed to update Claude account', message: error.message }) + } +}) + +// 删除Claude账户 +router.delete('/claude-accounts/:accountId', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + + // 自动解绑所有绑定的 API Keys + const unboundCount = await apiKeyService.unbindAccountFromAllKeys(accountId, 'claude') + + // 获取账户信息以检查是否在分组中 + const account = await claudeAccountService.getAccount(accountId) + if (account && account.accountType === 'group') { + const groups = await accountGroupService.getAccountGroups(accountId) + for (const group of groups) { + await accountGroupService.removeAccountFromGroup(accountId, group.id) + } + } + + await claudeAccountService.deleteAccount(accountId) + + let message = 'Claude账号已成功删除' + if (unboundCount > 0) { + message += `,${unboundCount} 个 API Key 已切换为共享池模式` + } + + logger.success(`🗑️ Admin deleted Claude account: ${accountId}, unbound ${unboundCount} keys`) + return res.json({ + success: true, + message, + unboundKeys: unboundCount + }) + } catch (error) { + logger.error('❌ Failed to delete Claude account:', error) + return res + .status(500) + .json({ error: 'Failed to delete Claude account', message: error.message }) + } +}) + +// 更新单个Claude账户的Profile信息 +router.post('/claude-accounts/:accountId/update-profile', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + + const profileInfo = await claudeAccountService.fetchAndUpdateAccountProfile(accountId) + + logger.success(`✅ Updated profile for Claude account: ${accountId}`) + return res.json({ + success: true, + message: 'Account profile updated successfully', + data: profileInfo + }) + } catch (error) { + logger.error('❌ Failed to update account profile:', error) + return res + .status(500) + .json({ error: 'Failed to update account profile', message: error.message }) + } +}) + +// 批量更新所有Claude账户的Profile信息 +router.post('/claude-accounts/update-all-profiles', authenticateAdmin, async (req, res) => { + try { + const result = await claudeAccountService.updateAllAccountProfiles() + + logger.success('✅ Batch profile update completed') + return res.json({ + success: true, + message: 'Batch profile update completed', + data: result + }) + } catch (error) { + logger.error('❌ Failed to update all account profiles:', error) + return res + .status(500) + .json({ error: 'Failed to update all account profiles', message: error.message }) + } +}) + +// 刷新Claude账户token +router.post('/claude-accounts/:accountId/refresh', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + + const result = await claudeAccountService.refreshAccountToken(accountId) + + logger.success(`🔄 Admin refreshed token for Claude account: ${accountId}`) + return res.json({ success: true, data: result }) + } catch (error) { + logger.error('❌ Failed to refresh Claude account token:', error) + return res.status(500).json({ error: 'Failed to refresh token', message: error.message }) + } +}) + +// 重置Claude账户状态(清除所有异常状态) +router.post('/claude-accounts/:accountId/reset-status', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + + const result = await claudeAccountService.resetAccountStatus(accountId) + + logger.success(`✅ Admin reset status for Claude account: ${accountId}`) + return res.json({ success: true, data: result }) + } catch (error) { + logger.error('❌ Failed to reset Claude account status:', error) + return res.status(500).json({ error: 'Failed to reset status', message: error.message }) + } +}) + +// 切换Claude账户调度状态 +router.put( + '/claude-accounts/:accountId/toggle-schedulable', + authenticateAdmin, + async (req, res) => { + try { + const { accountId } = req.params + + const accounts = await claudeAccountService.getAllAccounts() + const account = accounts.find((acc) => acc.id === accountId) + + if (!account) { + return res.status(404).json({ error: 'Account not found' }) + } + + const newSchedulable = !account.schedulable + await claudeAccountService.updateAccount(accountId, { schedulable: newSchedulable }) + + // 如果账号被禁用,发送webhook通知 + if (!newSchedulable) { + await webhookNotifier.sendAccountAnomalyNotification({ + accountId: account.id, + accountName: account.name || account.claudeAiOauth?.email || 'Claude Account', + platform: 'claude-oauth', + status: 'disabled', + errorCode: 'CLAUDE_OAUTH_MANUALLY_DISABLED', + reason: '账号已被管理员手动禁用调度', + timestamp: new Date().toISOString() + }) + } + + logger.success( + `🔄 Admin toggled Claude account schedulable status: ${accountId} -> ${ + newSchedulable ? 'schedulable' : 'not schedulable' + }` + ) + return res.json({ success: true, schedulable: newSchedulable }) + } catch (error) { + logger.error('❌ Failed to toggle Claude account schedulable status:', error) + return res + .status(500) + .json({ error: 'Failed to toggle schedulable status', message: error.message }) + } + } +) + +// 🎮 Claude Console 账户管理 + +// 获取所有Claude Console账户 +router.get('/claude-console-accounts', authenticateAdmin, async (req, res) => { + try { + const { platform, groupId } = req.query + let accounts = await claudeConsoleAccountService.getAllAccounts() + + // 根据查询参数进行筛选 + if (platform && platform !== 'all' && platform !== 'claude-console') { + // 如果指定了其他平台,返回空数组 + accounts = [] + } + + // 如果指定了分组筛选 + if (groupId && groupId !== 'all') { + if (groupId === 'ungrouped') { + // 筛选未分组账户 + const filteredAccounts = [] + for (const account of accounts) { + const groups = await accountGroupService.getAccountGroups(account.id) + if (!groups || groups.length === 0) { + filteredAccounts.push(account) + } + } + accounts = filteredAccounts + } else { + // 筛选特定分组的账户 + const groupMembers = await accountGroupService.getGroupMembers(groupId) + accounts = accounts.filter((account) => groupMembers.includes(account.id)) + } + } + + // 为每个账户添加使用统计信息 + + const accountsWithStats = await Promise.all( + accounts.map(async (account) => { + const formattedAccount = formatSubscriptionExpiry(account) + try { + const usageStats = await redis.getAccountUsageStats(account.id, 'openai') + const groupInfos = await accountGroupService.getAccountGroups(account.id) + + return { + ...formattedAccount, + // 转换schedulable为布尔值 + schedulable: account.schedulable === 'true' || account.schedulable === true, + groupInfos, + usage: { + daily: usageStats.daily, + total: usageStats.total, + averages: usageStats.averages + } + } + } catch (statsError) { + logger.warn( + `⚠️ Failed to get usage stats for Claude Console account ${account.id}:`, + statsError.message + ) + try { + const groupInfos = await accountGroupService.getAccountGroups(account.id) + return { + ...formattedAccount, + // 转换schedulable为布尔值 + schedulable: account.schedulable === 'true' || account.schedulable === true, + groupInfos, + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } + } + } catch (groupError) { + logger.warn( + `⚠️ Failed to get group info for Claude Console account ${account.id}:`, + groupError.message + ) + return { + ...formattedAccount, + groupInfos: [], + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } + } + } + } + }) + ) + + const formattedAccounts = accountsWithStats.map(formatSubscriptionExpiry) + return res.json({ success: true, data: formattedAccounts }) + } catch (error) { + logger.error('❌ Failed to get Claude Console accounts:', error) + return res + .status(500) + .json({ error: 'Failed to get Claude Console accounts', message: error.message }) + } +}) + +// 创建新的Claude Console账户 +router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => { + try { + const { + name, + description, + apiUrl, + apiKey, + priority, + supportedModels, + userAgent, + rateLimitDuration, + proxy, + accountType, + groupId, + dailyQuota, + quotaResetTime + } = req.body + + if (!name || !apiUrl || !apiKey) { + return res.status(400).json({ error: 'Name, API URL and API Key are required' }) + } + + // 验证priority的有效性(1-100) + if (priority !== undefined && (priority < 1 || priority > 100)) { + return res.status(400).json({ error: 'Priority must be between 1 and 100' }) + } + + // 验证accountType的有效性 + if (accountType && !['shared', 'dedicated', 'group'].includes(accountType)) { + return res + .status(400) + .json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' }) + } + + // 如果是分组类型,验证groupId + if (accountType === 'group' && !groupId) { + return res.status(400).json({ error: 'Group ID is required for group type accounts' }) + } + + const newAccount = await claudeConsoleAccountService.createAccount({ + name, + description, + apiUrl, + apiKey, + priority: priority || 50, + supportedModels: supportedModels || [], + userAgent, + rateLimitDuration: + rateLimitDuration !== undefined && rateLimitDuration !== null ? rateLimitDuration : 60, + proxy, + accountType: accountType || 'shared', + dailyQuota: dailyQuota || 0, + quotaResetTime: quotaResetTime || '00:00' + }) + + // 如果是分组类型,将账户添加到分组(CCR 归属 Claude 平台分组) + if (accountType === 'group' && groupId) { + await accountGroupService.addAccountToGroup(newAccount.id, groupId, 'claude') + } + + logger.success(`🎮 Admin created Claude Console account: ${name}`) + const responseAccount = formatSubscriptionExpiry(newAccount) + return res.json({ success: true, data: responseAccount }) + } catch (error) { + logger.error('❌ Failed to create Claude Console account:', error) + return res + .status(500) + .json({ error: 'Failed to create Claude Console account', message: error.message }) + } +}) + +// 更新Claude Console账户 +router.put('/claude-console-accounts/:accountId', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + const updates = req.body + + // 验证priority的有效性(1-100) + if (updates.priority !== undefined && (updates.priority < 1 || updates.priority > 100)) { + return res.status(400).json({ error: 'Priority must be between 1 and 100' }) + } + + // 验证accountType的有效性 + if (updates.accountType && !['shared', 'dedicated', 'group'].includes(updates.accountType)) { + return res + .status(400) + .json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' }) + } + + // 如果更新为分组类型,验证groupId + if (updates.accountType === 'group' && !updates.groupId) { + return res.status(400).json({ error: 'Group ID is required for group type accounts' }) + } + + // 获取账户当前信息以处理分组变更 + const currentAccount = await claudeConsoleAccountService.getAccount(accountId) + if (!currentAccount) { + return res.status(404).json({ error: 'Account not found' }) + } + + // 处理分组的变更 + if (updates.accountType !== undefined) { + // 如果之前是分组类型,需要从所有分组中移除 + if (currentAccount.accountType === 'group') { + const oldGroups = await accountGroupService.getAccountGroups(accountId) + for (const oldGroup of oldGroups) { + await accountGroupService.removeAccountFromGroup(accountId, oldGroup.id) + } + } + // 如果新类型是分组,处理多分组支持 + if (updates.accountType === 'group') { + if (Object.prototype.hasOwnProperty.call(updates, 'groupIds')) { + // 如果明确提供了 groupIds 参数(包括空数组) + if (updates.groupIds && updates.groupIds.length > 0) { + // 设置新的多分组 + await accountGroupService.setAccountGroups(accountId, updates.groupIds, 'claude') + } else { + // groupIds 为空数组,从所有分组中移除 + await accountGroupService.removeAccountFromAllGroups(accountId) + } + } else if (updates.groupId) { + // 向后兼容:仅当没有 groupIds 但有 groupId 时使用单分组逻辑 + await accountGroupService.addAccountToGroup(accountId, updates.groupId, 'claude') + } + } + } + + // 映射字段名:前端的expiresAt -> 后端的subscriptionExpiresAt + const mappedUpdates = { ...updates } + if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) { + mappedUpdates.subscriptionExpiresAt = updates.subscriptionExpiresAt + } else if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'expiresAt')) { + mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt + } + if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'subscriptionExpiresAt')) { + delete mappedUpdates.expiresAt + } + + await claudeConsoleAccountService.updateAccount(accountId, mappedUpdates) + + logger.success(`📝 Admin updated Claude Console account: ${accountId}`) + return res.json({ success: true, message: 'Claude Console account updated successfully' }) + } catch (error) { + logger.error('❌ Failed to update Claude Console account:', error) + return res + .status(500) + .json({ error: 'Failed to update Claude Console account', message: error.message }) + } +}) + +// 删除Claude Console账户 +router.delete('/claude-console-accounts/:accountId', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + + // 自动解绑所有绑定的 API Keys + const unboundCount = await apiKeyService.unbindAccountFromAllKeys(accountId, 'claude-console') + + // 获取账户信息以检查是否在分组中 + const account = await claudeConsoleAccountService.getAccount(accountId) + if (account && account.accountType === 'group') { + const groups = await accountGroupService.getAccountGroups(accountId) + for (const group of groups) { + await accountGroupService.removeAccountFromGroup(accountId, group.id) + } + } + + await claudeConsoleAccountService.deleteAccount(accountId) + + let message = 'Claude Console账号已成功删除' + if (unboundCount > 0) { + message += `,${unboundCount} 个 API Key 已切换为共享池模式` + } + + logger.success( + `🗑️ Admin deleted Claude Console account: ${accountId}, unbound ${unboundCount} keys` + ) + return res.json({ + success: true, + message, + unboundKeys: unboundCount + }) + } catch (error) { + logger.error('❌ Failed to delete Claude Console account:', error) + return res + .status(500) + .json({ error: 'Failed to delete Claude Console account', message: error.message }) + } +}) + +// 切换Claude Console账户状态 +router.put('/claude-console-accounts/:accountId/toggle', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + + const account = await claudeConsoleAccountService.getAccount(accountId) + if (!account) { + return res.status(404).json({ error: 'Account not found' }) + } + + const newStatus = !account.isActive + await claudeConsoleAccountService.updateAccount(accountId, { isActive: newStatus }) + + logger.success( + `🔄 Admin toggled Claude Console account status: ${accountId} -> ${ + newStatus ? 'active' : 'inactive' + }` + ) + return res.json({ success: true, isActive: newStatus }) + } catch (error) { + logger.error('❌ Failed to toggle Claude Console account status:', error) + return res + .status(500) + .json({ error: 'Failed to toggle account status', message: error.message }) + } +}) + +// 切换Claude Console账户调度状态 +router.put( + '/claude-console-accounts/:accountId/toggle-schedulable', + authenticateAdmin, + async (req, res) => { + try { + const { accountId } = req.params + + const account = await claudeConsoleAccountService.getAccount(accountId) + if (!account) { + return res.status(404).json({ error: 'Account not found' }) + } + + const newSchedulable = !account.schedulable + await claudeConsoleAccountService.updateAccount(accountId, { schedulable: newSchedulable }) + + // 如果账号被禁用,发送webhook通知 + if (!newSchedulable) { + await webhookNotifier.sendAccountAnomalyNotification({ + accountId: account.id, + accountName: account.name || 'Claude Console Account', + platform: 'claude-console', + status: 'disabled', + errorCode: 'CLAUDE_CONSOLE_MANUALLY_DISABLED', + reason: '账号已被管理员手动禁用调度', + timestamp: new Date().toISOString() + }) + } + + logger.success( + `🔄 Admin toggled Claude Console account schedulable status: ${accountId} -> ${ + newSchedulable ? 'schedulable' : 'not schedulable' + }` + ) + return res.json({ success: true, schedulable: newSchedulable }) + } catch (error) { + logger.error('❌ Failed to toggle Claude Console account schedulable status:', error) + return res + .status(500) + .json({ error: 'Failed to toggle schedulable status', message: error.message }) + } + } +) + +// 获取Claude Console账户的使用统计 +router.get('/claude-console-accounts/:accountId/usage', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + const usageStats = await claudeConsoleAccountService.getAccountUsageStats(accountId) + + if (!usageStats) { + return res.status(404).json({ error: 'Account not found' }) + } + + return res.json(usageStats) + } catch (error) { + logger.error('❌ Failed to get Claude Console account usage stats:', error) + return res.status(500).json({ error: 'Failed to get usage stats', message: error.message }) + } +}) + +// 手动重置Claude Console账户的每日使用量 +router.post( + '/claude-console-accounts/:accountId/reset-usage', + authenticateAdmin, + async (req, res) => { + try { + const { accountId } = req.params + await claudeConsoleAccountService.resetDailyUsage(accountId) + + logger.success(`✅ Admin manually reset daily usage for Claude Console account: ${accountId}`) + return res.json({ success: true, message: 'Daily usage reset successfully' }) + } catch (error) { + logger.error('❌ Failed to reset Claude Console account daily usage:', error) + return res.status(500).json({ error: 'Failed to reset daily usage', message: error.message }) + } + } +) + +// 重置Claude Console账户状态(清除所有异常状态) +router.post( + '/claude-console-accounts/:accountId/reset-status', + authenticateAdmin, + async (req, res) => { + try { + const { accountId } = req.params + const result = await claudeConsoleAccountService.resetAccountStatus(accountId) + logger.success(`✅ Admin reset status for Claude Console account: ${accountId}`) + return res.json({ success: true, data: result }) + } catch (error) { + logger.error('❌ Failed to reset Claude Console account status:', error) + return res.status(500).json({ error: 'Failed to reset status', message: error.message }) + } + } +) + +// 手动重置所有Claude Console账户的每日使用量 +router.post('/claude-console-accounts/reset-all-usage', authenticateAdmin, async (req, res) => { + try { + await claudeConsoleAccountService.resetAllDailyUsage() + + logger.success('✅ Admin manually reset daily usage for all Claude Console accounts') + return res.json({ success: true, message: 'All daily usage reset successfully' }) + } catch (error) { + logger.error('❌ Failed to reset all Claude Console accounts daily usage:', error) + return res + .status(500) + .json({ error: 'Failed to reset all daily usage', message: error.message }) + } +}) + +// 🔧 CCR 账户管理 + +// 获取所有CCR账户 +router.get('/ccr-accounts', authenticateAdmin, async (req, res) => { + try { + const { platform, groupId } = req.query + let accounts = await ccrAccountService.getAllAccounts() + + // 根据查询参数进行筛选 + if (platform && platform !== 'all' && platform !== 'ccr') { + // 如果指定了其他平台,返回空数组 + accounts = [] + } + + // 如果指定了分组筛选 + if (groupId && groupId !== 'all') { + if (groupId === 'ungrouped') { + // 筛选未分组账户 + const filteredAccounts = [] + for (const account of accounts) { + const groups = await accountGroupService.getAccountGroups(account.id) + if (!groups || groups.length === 0) { + filteredAccounts.push(account) + } + } + accounts = filteredAccounts + } else { + // 筛选特定分组的账户 + const groupMembers = await accountGroupService.getGroupMembers(groupId) + accounts = accounts.filter((account) => groupMembers.includes(account.id)) + } + } + + // 为每个账户添加使用统计信息 + const accountsWithStats = await Promise.all( + accounts.map(async (account) => { + const formattedAccount = formatSubscriptionExpiry(account) + try { + const usageStats = await redis.getAccountUsageStats(account.id) + const groupInfos = await accountGroupService.getAccountGroups(account.id) + + return { + ...formattedAccount, + // 转换schedulable为布尔值 + schedulable: account.schedulable === 'true' || account.schedulable === true, + groupInfos, + usage: { + daily: usageStats.daily, + total: usageStats.total, + averages: usageStats.averages + } + } + } catch (statsError) { + logger.warn( + `⚠️ Failed to get usage stats for CCR account ${account.id}:`, + statsError.message + ) + try { + const groupInfos = await accountGroupService.getAccountGroups(account.id) + return { + ...formattedAccount, + // 转换schedulable为布尔值 + schedulable: account.schedulable === 'true' || account.schedulable === true, + groupInfos, + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } + } + } catch (groupError) { + logger.warn( + `⚠️ Failed to get group info for CCR account ${account.id}:`, + groupError.message + ) + return { + ...formattedAccount, + groupInfos: [], + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } + } + } + } + }) + ) + + const formattedAccounts = accountsWithStats.map(formatSubscriptionExpiry) + return res.json({ success: true, data: formattedAccounts }) + } catch (error) { + logger.error('❌ Failed to get CCR accounts:', error) + return res.status(500).json({ error: 'Failed to get CCR accounts', message: error.message }) + } +}) + +// 创建新的CCR账户 +router.post('/ccr-accounts', authenticateAdmin, async (req, res) => { + try { + const { + name, + description, + apiUrl, + apiKey, + priority, + supportedModels, + userAgent, + rateLimitDuration, + proxy, + accountType, + groupId, + dailyQuota, + quotaResetTime + } = req.body + + if (!name || !apiUrl || !apiKey) { + return res.status(400).json({ error: 'Name, API URL and API Key are required' }) + } + + // 验证priority的有效性(1-100) + if (priority !== undefined && (priority < 1 || priority > 100)) { + return res.status(400).json({ error: 'Priority must be between 1 and 100' }) + } + + // 验证accountType的有效性 + if (accountType && !['shared', 'dedicated', 'group'].includes(accountType)) { + return res + .status(400) + .json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' }) + } + + // 如果是分组类型,验证groupId + if (accountType === 'group' && !groupId) { + return res.status(400).json({ error: 'Group ID is required for group type accounts' }) + } + + const newAccount = await ccrAccountService.createAccount({ + name, + description, + apiUrl, + apiKey, + priority: priority || 50, + supportedModels: supportedModels || [], + userAgent, + rateLimitDuration: + rateLimitDuration !== undefined && rateLimitDuration !== null ? rateLimitDuration : 60, + proxy, + accountType: accountType || 'shared', + dailyQuota: dailyQuota || 0, + quotaResetTime: quotaResetTime || '00:00' + }) + + // 如果是分组类型,将账户添加到分组 + if (accountType === 'group' && groupId) { + await accountGroupService.addAccountToGroup(newAccount.id, groupId) + } + + logger.success(`🔧 Admin created CCR account: ${name}`) + const responseAccount = formatSubscriptionExpiry(newAccount) + return res.json({ success: true, data: responseAccount }) + } catch (error) { + logger.error('❌ Failed to create CCR account:', error) + return res.status(500).json({ error: 'Failed to create CCR account', message: error.message }) + } +}) + +// 更新CCR账户 +router.put('/ccr-accounts/:accountId', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + const updates = req.body + + // 验证priority的有效性(1-100) + if (updates.priority !== undefined && (updates.priority < 1 || updates.priority > 100)) { + return res.status(400).json({ error: 'Priority must be between 1 and 100' }) + } + + // 验证accountType的有效性 + if (updates.accountType && !['shared', 'dedicated', 'group'].includes(updates.accountType)) { + return res + .status(400) + .json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' }) + } + + // 如果更新为分组类型,验证groupId + if (updates.accountType === 'group' && !updates.groupId) { + return res.status(400).json({ error: 'Group ID is required for group type accounts' }) + } + + // 获取账户当前信息以处理分组变更 + const currentAccount = await ccrAccountService.getAccount(accountId) + if (!currentAccount) { + return res.status(404).json({ error: 'Account not found' }) + } + + // 处理分组的变更 + if (updates.accountType !== undefined) { + // 如果之前是分组类型,需要从所有分组中移除 + if (currentAccount.accountType === 'group') { + const oldGroups = await accountGroupService.getAccountGroups(accountId) + for (const oldGroup of oldGroups) { + await accountGroupService.removeAccountFromGroup(accountId, oldGroup.id) + } + } + // 如果新类型是分组,处理多分组支持 + if (updates.accountType === 'group') { + if (Object.prototype.hasOwnProperty.call(updates, 'groupIds')) { + // 如果明确提供了 groupIds 参数(包括空数组) + if (updates.groupIds && updates.groupIds.length > 0) { + // 设置新的多分组 + await accountGroupService.setAccountGroups(accountId, updates.groupIds, 'claude') + } else { + // groupIds 为空数组,从所有分组中移除 + await accountGroupService.removeAccountFromAllGroups(accountId) + } + } else if (updates.groupId) { + // 向后兼容:仅当没有 groupIds 但有 groupId 时使用单分组逻辑 + await accountGroupService.addAccountToGroup(accountId, updates.groupId, 'claude') + } + } + } + + // 映射字段名:前端的expiresAt -> 后端的subscriptionExpiresAt + const mappedUpdates = { ...updates } + if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) { + mappedUpdates.subscriptionExpiresAt = updates.subscriptionExpiresAt + } else if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'expiresAt')) { + mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt + } + if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'subscriptionExpiresAt')) { + delete mappedUpdates.expiresAt + } + + await ccrAccountService.updateAccount(accountId, mappedUpdates) + + logger.success(`📝 Admin updated CCR account: ${accountId}`) + return res.json({ success: true, message: 'CCR account updated successfully' }) + } catch (error) { + logger.error('❌ Failed to update CCR account:', error) + return res.status(500).json({ error: 'Failed to update CCR account', message: error.message }) + } +}) + +// 删除CCR账户 +router.delete('/ccr-accounts/:accountId', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + + // 尝试自动解绑(CCR账户实际上不会绑定API Key,但保持代码一致性) + const unboundCount = await apiKeyService.unbindAccountFromAllKeys(accountId, 'ccr') + + // 获取账户信息以检查是否在分组中 + const account = await ccrAccountService.getAccount(accountId) + if (account && account.accountType === 'group') { + const groups = await accountGroupService.getAccountGroups(accountId) + for (const group of groups) { + await accountGroupService.removeAccountFromGroup(accountId, group.id) + } + } + + await ccrAccountService.deleteAccount(accountId) + + let message = 'CCR账号已成功删除' + if (unboundCount > 0) { + // 理论上不会发生,但保持消息格式一致 + message += `,${unboundCount} 个 API Key 已切换为共享池模式` + } + + logger.success(`🗑️ Admin deleted CCR account: ${accountId}`) + return res.json({ + success: true, + message, + unboundKeys: unboundCount + }) + } catch (error) { + logger.error('❌ Failed to delete CCR account:', error) + return res.status(500).json({ error: 'Failed to delete CCR account', message: error.message }) + } +}) + +// 切换CCR账户状态 +router.put('/ccr-accounts/:accountId/toggle', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + + const account = await ccrAccountService.getAccount(accountId) + if (!account) { + return res.status(404).json({ error: 'Account not found' }) + } + + const newStatus = !account.isActive + await ccrAccountService.updateAccount(accountId, { isActive: newStatus }) + + logger.success( + `🔄 Admin toggled CCR account status: ${accountId} -> ${newStatus ? 'active' : 'inactive'}` + ) + return res.json({ success: true, isActive: newStatus }) + } catch (error) { + logger.error('❌ Failed to toggle CCR account status:', error) + return res + .status(500) + .json({ error: 'Failed to toggle account status', message: error.message }) + } +}) + +// 切换CCR账户调度状态 +router.put('/ccr-accounts/:accountId/toggle-schedulable', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + + const account = await ccrAccountService.getAccount(accountId) + if (!account) { + return res.status(404).json({ error: 'Account not found' }) + } + + const newSchedulable = !account.schedulable + await ccrAccountService.updateAccount(accountId, { schedulable: newSchedulable }) + + // 如果账号被禁用,发送webhook通知 + if (!newSchedulable) { + await webhookNotifier.sendAccountAnomalyNotification({ + accountId: account.id, + accountName: account.name || 'CCR Account', + platform: 'ccr', + status: 'disabled', + errorCode: 'CCR_MANUALLY_DISABLED', + reason: '账号已被管理员手动禁用调度', + timestamp: new Date().toISOString() + }) + } + + logger.success( + `🔄 Admin toggled CCR account schedulable status: ${accountId} -> ${ + newSchedulable ? 'schedulable' : 'not schedulable' + }` + ) + return res.json({ success: true, schedulable: newSchedulable }) + } catch (error) { + logger.error('❌ Failed to toggle CCR account schedulable status:', error) + return res + .status(500) + .json({ error: 'Failed to toggle schedulable status', message: error.message }) + } +}) + +// 获取CCR账户的使用统计 +router.get('/ccr-accounts/:accountId/usage', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + const usageStats = await ccrAccountService.getAccountUsageStats(accountId) + + if (!usageStats) { + return res.status(404).json({ error: 'Account not found' }) + } + + return res.json(usageStats) + } catch (error) { + logger.error('❌ Failed to get CCR account usage stats:', error) + return res.status(500).json({ error: 'Failed to get usage stats', message: error.message }) + } +}) + +// 手动重置CCR账户的每日使用量 +router.post('/ccr-accounts/:accountId/reset-usage', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + await ccrAccountService.resetDailyUsage(accountId) + + logger.success(`✅ Admin manually reset daily usage for CCR account: ${accountId}`) + return res.json({ success: true, message: 'Daily usage reset successfully' }) + } catch (error) { + logger.error('❌ Failed to reset CCR account daily usage:', error) + return res.status(500).json({ error: 'Failed to reset daily usage', message: error.message }) + } +}) + +// 重置CCR账户状态(清除所有异常状态) +router.post('/ccr-accounts/:accountId/reset-status', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + const result = await ccrAccountService.resetAccountStatus(accountId) + logger.success(`✅ Admin reset status for CCR account: ${accountId}`) + return res.json({ success: true, data: result }) + } catch (error) { + logger.error('❌ Failed to reset CCR account status:', error) + return res.status(500).json({ error: 'Failed to reset status', message: error.message }) + } +}) + +// 手动重置所有CCR账户的每日使用量 +router.post('/ccr-accounts/reset-all-usage', authenticateAdmin, async (req, res) => { + try { + await ccrAccountService.resetAllDailyUsage() + + logger.success('✅ Admin manually reset daily usage for all CCR accounts') + return res.json({ success: true, message: 'All daily usage reset successfully' }) + } catch (error) { + logger.error('❌ Failed to reset all CCR accounts daily usage:', error) + return res + .status(500) + .json({ error: 'Failed to reset all daily usage', message: error.message }) + } +}) + +// ☁️ Bedrock 账户管理 + +// 获取所有Bedrock账户 +router.get('/bedrock-accounts', authenticateAdmin, async (req, res) => { + try { + const { platform, groupId } = req.query + const result = await bedrockAccountService.getAllAccounts() + if (!result.success) { + return res + .status(500) + .json({ error: 'Failed to get Bedrock accounts', message: result.error }) + } + + let accounts = result.data + + // 根据查询参数进行筛选 + if (platform && platform !== 'all' && platform !== 'bedrock') { + // 如果指定了其他平台,返回空数组 + accounts = [] + } + + // 如果指定了分组筛选 + if (groupId && groupId !== 'all') { + if (groupId === 'ungrouped') { + // 筛选未分组账户 + const filteredAccounts = [] + for (const account of accounts) { + const groups = await accountGroupService.getAccountGroups(account.id) + if (!groups || groups.length === 0) { + filteredAccounts.push(account) + } + } + accounts = filteredAccounts + } else { + // 筛选特定分组的账户 + const groupMembers = await accountGroupService.getGroupMembers(groupId) + accounts = accounts.filter((account) => groupMembers.includes(account.id)) + } + } + + // 为每个账户添加使用统计信息 + const accountsWithStats = await Promise.all( + accounts.map(async (account) => { + const formattedAccount = formatSubscriptionExpiry(account) + try { + const usageStats = await redis.getAccountUsageStats(account.id, 'openai') + const groupInfos = await accountGroupService.getAccountGroups(account.id) + + return { + ...formattedAccount, + groupInfos, + usage: { + daily: usageStats.daily, + total: usageStats.total, + averages: usageStats.averages + } + } + } catch (statsError) { + logger.warn( + `⚠️ Failed to get usage stats for Bedrock account ${account.id}:`, + statsError.message + ) + try { + const groupInfos = await accountGroupService.getAccountGroups(account.id) + return { + ...formattedAccount, + groupInfos, + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } + } + } catch (groupError) { + logger.warn( + `⚠️ Failed to get group info for account ${account.id}:`, + groupError.message + ) + return { + ...formattedAccount, + groupInfos: [], + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } + } + } + } + }) + ) + + const formattedAccounts = accountsWithStats.map(formatSubscriptionExpiry) + return res.json({ success: true, data: formattedAccounts }) + } catch (error) { + logger.error('❌ Failed to get Bedrock accounts:', error) + return res.status(500).json({ error: 'Failed to get Bedrock accounts', message: error.message }) + } +}) + +// 创建新的Bedrock账户 +router.post('/bedrock-accounts', authenticateAdmin, async (req, res) => { + try { + const { + name, + description, + region, + awsCredentials, + defaultModel, + priority, + accountType, + credentialType + } = req.body + + if (!name) { + return res.status(400).json({ error: 'Name is required' }) + } + + // 验证priority的有效性(1-100) + if (priority !== undefined && (priority < 1 || priority > 100)) { + return res.status(400).json({ error: 'Priority must be between 1 and 100' }) + } + + // 验证accountType的有效性 + if (accountType && !['shared', 'dedicated'].includes(accountType)) { + return res + .status(400) + .json({ error: 'Invalid account type. Must be "shared" or "dedicated"' }) + } + + // 验证credentialType的有效性 + if (credentialType && !['default', 'access_key', 'bearer_token'].includes(credentialType)) { + return res.status(400).json({ + error: 'Invalid credential type. Must be "default", "access_key", or "bearer_token"' + }) + } + + const result = await bedrockAccountService.createAccount({ + name, + description: description || '', + region: region || 'us-east-1', + awsCredentials, + defaultModel, + priority: priority || 50, + accountType: accountType || 'shared', + credentialType: credentialType || 'default' + }) + + if (!result.success) { + return res + .status(500) + .json({ error: 'Failed to create Bedrock account', message: result.error }) + } + + logger.success(`☁️ Admin created Bedrock account: ${name}`) + const responseAccount = formatSubscriptionExpiry(result.data) + return res.json({ success: true, data: responseAccount }) + } catch (error) { + logger.error('❌ Failed to create Bedrock account:', error) + return res + .status(500) + .json({ error: 'Failed to create Bedrock account', message: error.message }) + } +}) + +// 更新Bedrock账户 +router.put('/bedrock-accounts/:accountId', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + const updates = req.body + + // 验证priority的有效性(1-100) + if (updates.priority !== undefined && (updates.priority < 1 || updates.priority > 100)) { + return res.status(400).json({ error: 'Priority must be between 1 and 100' }) + } + + // 验证accountType的有效性 + if (updates.accountType && !['shared', 'dedicated'].includes(updates.accountType)) { + return res + .status(400) + .json({ error: 'Invalid account type. Must be "shared" or "dedicated"' }) + } + + // 验证credentialType的有效性 + if ( + updates.credentialType && + !['default', 'access_key', 'bearer_token'].includes(updates.credentialType) + ) { + return res.status(400).json({ + error: 'Invalid credential type. Must be "default", "access_key", or "bearer_token"' + }) + } + + // 映射字段名:前端的expiresAt -> 后端的subscriptionExpiresAt + const mappedUpdates = { ...updates } + if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) { + mappedUpdates.subscriptionExpiresAt = updates.subscriptionExpiresAt + } else if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'expiresAt')) { + mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt + } + if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'subscriptionExpiresAt')) { + delete mappedUpdates.expiresAt + } + + const result = await bedrockAccountService.updateAccount(accountId, mappedUpdates) + + if (!result.success) { + return res + .status(500) + .json({ error: 'Failed to update Bedrock account', message: result.error }) + } + + logger.success(`📝 Admin updated Bedrock account: ${accountId}`) + return res.json({ success: true, message: 'Bedrock account updated successfully' }) + } catch (error) { + logger.error('❌ Failed to update Bedrock account:', error) + return res + .status(500) + .json({ error: 'Failed to update Bedrock account', message: error.message }) + } +}) + +// 删除Bedrock账户 +router.delete('/bedrock-accounts/:accountId', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + + // 自动解绑所有绑定的 API Keys + const unboundCount = await apiKeyService.unbindAccountFromAllKeys(accountId, 'bedrock') + + const result = await bedrockAccountService.deleteAccount(accountId) + + if (!result.success) { + return res + .status(500) + .json({ error: 'Failed to delete Bedrock account', message: result.error }) + } + + let message = 'Bedrock账号已成功删除' + if (unboundCount > 0) { + message += `,${unboundCount} 个 API Key 已切换为共享池模式` + } + + logger.success(`🗑️ Admin deleted Bedrock account: ${accountId}, unbound ${unboundCount} keys`) + return res.json({ + success: true, + message, + unboundKeys: unboundCount + }) + } catch (error) { + logger.error('❌ Failed to delete Bedrock account:', error) + return res + .status(500) + .json({ error: 'Failed to delete Bedrock account', message: error.message }) + } +}) + +// 切换Bedrock账户状态 +router.put('/bedrock-accounts/:accountId/toggle', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + + const accountResult = await bedrockAccountService.getAccount(accountId) + if (!accountResult.success) { + return res.status(404).json({ error: 'Account not found' }) + } + + const newStatus = !accountResult.data.isActive + const updateResult = await bedrockAccountService.updateAccount(accountId, { + isActive: newStatus + }) + + if (!updateResult.success) { + return res + .status(500) + .json({ error: 'Failed to toggle account status', message: updateResult.error }) + } + + logger.success( + `🔄 Admin toggled Bedrock account status: ${accountId} -> ${ + newStatus ? 'active' : 'inactive' + }` + ) + return res.json({ success: true, isActive: newStatus }) + } catch (error) { + logger.error('❌ Failed to toggle Bedrock account status:', error) + return res + .status(500) + .json({ error: 'Failed to toggle account status', message: error.message }) + } +}) + +// 切换Bedrock账户调度状态 +router.put( + '/bedrock-accounts/:accountId/toggle-schedulable', + authenticateAdmin, + async (req, res) => { + try { + const { accountId } = req.params + + const accountResult = await bedrockAccountService.getAccount(accountId) + if (!accountResult.success) { + return res.status(404).json({ error: 'Account not found' }) + } + + const newSchedulable = !accountResult.data.schedulable + const updateResult = await bedrockAccountService.updateAccount(accountId, { + schedulable: newSchedulable + }) + + if (!updateResult.success) { + return res + .status(500) + .json({ error: 'Failed to toggle schedulable status', message: updateResult.error }) + } + + // 如果账号被禁用,发送webhook通知 + if (!newSchedulable) { + await webhookNotifier.sendAccountAnomalyNotification({ + accountId: accountResult.data.id, + accountName: accountResult.data.name || 'Bedrock Account', + platform: 'bedrock', + status: 'disabled', + errorCode: 'BEDROCK_MANUALLY_DISABLED', + reason: '账号已被管理员手动禁用调度', + timestamp: new Date().toISOString() + }) + } + + logger.success( + `🔄 Admin toggled Bedrock account schedulable status: ${accountId} -> ${ + newSchedulable ? 'schedulable' : 'not schedulable' + }` + ) + return res.json({ success: true, schedulable: newSchedulable }) + } catch (error) { + logger.error('❌ Failed to toggle Bedrock account schedulable status:', error) + return res + .status(500) + .json({ error: 'Failed to toggle schedulable status', message: error.message }) + } + } +) + +// 测试Bedrock账户连接 +router.post('/bedrock-accounts/:accountId/test', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + + const result = await bedrockAccountService.testAccount(accountId) + + if (!result.success) { + return res.status(500).json({ error: 'Account test failed', message: result.error }) + } + + logger.success(`🧪 Admin tested Bedrock account: ${accountId} - ${result.data.status}`) + return res.json({ success: true, data: result.data }) + } catch (error) { + logger.error('❌ Failed to test Bedrock account:', error) + return res.status(500).json({ error: 'Failed to test Bedrock account', message: error.message }) + } +}) + +// 🤖 Gemini 账户管理 + +// 生成 Gemini OAuth 授权 URL +router.post('/gemini-accounts/generate-auth-url', authenticateAdmin, async (req, res) => { + try { + const { state, proxy } = req.body // 接收代理配置 + + // 使用新的 codeassist.google.com 回调地址 + const redirectUri = 'https://codeassist.google.com/authcode' + + logger.info(`Generating Gemini OAuth URL with redirect_uri: ${redirectUri}`) + + const { + authUrl, + state: authState, + codeVerifier, + redirectUri: finalRedirectUri + } = await geminiAccountService.generateAuthUrl(state, redirectUri, proxy) + + // 创建 OAuth 会话,包含 codeVerifier 和代理配置 + const sessionId = authState + await redis.setOAuthSession(sessionId, { + state: authState, + type: 'gemini', + redirectUri: finalRedirectUri, + codeVerifier, // 保存 PKCE code verifier + proxy: proxy || null, // 保存代理配置 + createdAt: new Date().toISOString() + }) + + logger.info(`Generated Gemini OAuth URL with session: ${sessionId}`) + return res.json({ + success: true, + data: { + authUrl, + sessionId + } + }) + } catch (error) { + logger.error('❌ Failed to generate Gemini auth URL:', error) + return res.status(500).json({ error: 'Failed to generate auth URL', message: error.message }) + } +}) + +// 轮询 Gemini OAuth 授权状态 +router.post('/gemini-accounts/poll-auth-status', authenticateAdmin, async (req, res) => { + try { + const { sessionId } = req.body + + if (!sessionId) { + return res.status(400).json({ error: 'Session ID is required' }) + } + + const result = await geminiAccountService.pollAuthorizationStatus(sessionId) + + if (result.success) { + logger.success(`✅ Gemini OAuth authorization successful for session: ${sessionId}`) + return res.json({ success: true, data: { tokens: result.tokens } }) + } else { + return res.json({ success: false, error: result.error }) + } + } catch (error) { + logger.error('❌ Failed to poll Gemini auth status:', error) + return res.status(500).json({ error: 'Failed to poll auth status', message: error.message }) + } +}) + +// 交换 Gemini 授权码 +router.post('/gemini-accounts/exchange-code', authenticateAdmin, async (req, res) => { + try { + const { code, sessionId, proxy: requestProxy } = req.body + + if (!code) { + return res.status(400).json({ error: 'Authorization code is required' }) + } + + let redirectUri = 'https://codeassist.google.com/authcode' + let codeVerifier = null + let proxyConfig = null + + // 如果提供了 sessionId,从 OAuth 会话中获取信息 + if (sessionId) { + const sessionData = await redis.getOAuthSession(sessionId) + if (sessionData) { + const { + redirectUri: sessionRedirectUri, + codeVerifier: sessionCodeVerifier, + proxy + } = sessionData + redirectUri = sessionRedirectUri || redirectUri + codeVerifier = sessionCodeVerifier + proxyConfig = proxy // 获取代理配置 + logger.info( + `Using session redirect_uri: ${redirectUri}, has codeVerifier: ${!!codeVerifier}, has proxy from session: ${!!proxyConfig}` + ) + } + } + + // 如果请求体中直接提供了代理配置,优先使用它 + if (requestProxy) { + proxyConfig = requestProxy + logger.info( + `Using proxy from request body: ${proxyConfig ? JSON.stringify(proxyConfig) : 'none'}` + ) + } + + const tokens = await geminiAccountService.exchangeCodeForTokens( + code, + redirectUri, + codeVerifier, + proxyConfig // 传递代理配置 + ) + + // 清理 OAuth 会话 + if (sessionId) { + await redis.deleteOAuthSession(sessionId) + } + + logger.success('✅ Successfully exchanged Gemini authorization code') + return res.json({ success: true, data: { tokens } }) + } catch (error) { + logger.error('❌ Failed to exchange Gemini authorization code:', error) + return res.status(500).json({ error: 'Failed to exchange code', message: error.message }) + } +}) + +// 获取所有 Gemini 账户 +router.get('/gemini-accounts', authenticateAdmin, async (req, res) => { + try { + const { platform, groupId } = req.query + let accounts = await geminiAccountService.getAllAccounts() + + // 根据查询参数进行筛选 + if (platform && platform !== 'all' && platform !== 'gemini') { + // 如果指定了其他平台,返回空数组 + accounts = [] + } + + // 如果指定了分组筛选 + if (groupId && groupId !== 'all') { + if (groupId === 'ungrouped') { + // 筛选未分组账户 + const filteredAccounts = [] + for (const account of accounts) { + const groups = await accountGroupService.getAccountGroups(account.id) + if (!groups || groups.length === 0) { + filteredAccounts.push(account) + } + } + accounts = filteredAccounts + } else { + // 筛选特定分组的账户 + const groupMembers = await accountGroupService.getGroupMembers(groupId) + accounts = accounts.filter((account) => groupMembers.includes(account.id)) + } + } + + // 为每个账户添加使用统计信息(与Claude账户相同的逻辑) + const accountsWithStats = await Promise.all( + accounts.map(async (account) => { + const formattedAccount = formatSubscriptionExpiry(account) + try { + const usageStats = await redis.getAccountUsageStats(account.id, 'openai') + const groupInfos = await accountGroupService.getAccountGroups(account.id) + + return { + ...formattedAccount, + groupInfos, + usage: { + daily: usageStats.daily, + total: usageStats.total, + averages: usageStats.averages + } + } + } catch (statsError) { + logger.warn( + `⚠️ Failed to get usage stats for Gemini account ${account.id}:`, + statsError.message + ) + // 如果获取统计失败,返回空统计 + try { + const groupInfos = await accountGroupService.getAccountGroups(account.id) + return { + ...formattedAccount, + groupInfos, + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } + } + } catch (groupError) { + logger.warn( + `⚠️ Failed to get group info for account ${account.id}:`, + groupError.message + ) + return { + ...formattedAccount, + groupInfos: [], + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } + } + } + } + }) + ) + + const formattedAccounts = accountsWithStats.map(formatSubscriptionExpiry) + return res.json({ success: true, data: formattedAccounts }) + } catch (error) { + logger.error('❌ Failed to get Gemini accounts:', error) + return res.status(500).json({ error: 'Failed to get accounts', message: error.message }) + } +}) + +// 创建新的 Gemini 账户 +router.post('/gemini-accounts', authenticateAdmin, async (req, res) => { + try { + const accountData = req.body + + // 输入验证 + if (!accountData.name) { + return res.status(400).json({ error: 'Account name is required' }) + } + + // 验证accountType的有效性 + if ( + accountData.accountType && + !['shared', 'dedicated', 'group'].includes(accountData.accountType) + ) { + return res + .status(400) + .json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' }) + } + + // 如果是分组类型,验证groupId + if (accountData.accountType === 'group' && !accountData.groupId) { + return res.status(400).json({ error: 'Group ID is required for group type accounts' }) + } + + const newAccount = await geminiAccountService.createAccount(accountData) + + // 如果是分组类型,将账户添加到分组 + if (accountData.accountType === 'group' && accountData.groupId) { + await accountGroupService.addAccountToGroup(newAccount.id, accountData.groupId, 'gemini') + } + + logger.success(`🏢 Admin created new Gemini account: ${accountData.name}`) + const responseAccount = formatSubscriptionExpiry(newAccount) + return res.json({ success: true, data: responseAccount }) + } catch (error) { + logger.error('❌ Failed to create Gemini account:', error) + return res.status(500).json({ error: 'Failed to create account', message: error.message }) + } +}) + +// 更新 Gemini 账户 +router.put('/gemini-accounts/:accountId', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + const updates = req.body + + // 验证accountType的有效性 + if (updates.accountType && !['shared', 'dedicated', 'group'].includes(updates.accountType)) { + return res + .status(400) + .json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' }) + } + + // 如果更新为分组类型,验证groupId + if (updates.accountType === 'group' && !updates.groupId) { + return res.status(400).json({ error: 'Group ID is required for group type accounts' }) + } + + // 获取账户当前信息以处理分组变更 + const currentAccount = await geminiAccountService.getAccount(accountId) + if (!currentAccount) { + return res.status(404).json({ error: 'Account not found' }) + } + + // 处理分组的变更 + if (updates.accountType !== undefined) { + // 如果之前是分组类型,需要从所有分组中移除 + if (currentAccount.accountType === 'group') { + const oldGroups = await accountGroupService.getAccountGroups(accountId) + for (const oldGroup of oldGroups) { + await accountGroupService.removeAccountFromGroup(accountId, oldGroup.id) + } + } + // 如果新类型是分组,处理多分组支持 + if (updates.accountType === 'group') { + if (Object.prototype.hasOwnProperty.call(updates, 'groupIds')) { + // 如果明确提供了 groupIds 参数(包括空数组) + if (updates.groupIds && updates.groupIds.length > 0) { + // 设置新的多分组 + await accountGroupService.setAccountGroups(accountId, updates.groupIds, 'gemini') + } else { + // groupIds 为空数组,从所有分组中移除 + await accountGroupService.removeAccountFromAllGroups(accountId) + } + } else if (updates.groupId) { + // 向后兼容:仅当没有 groupIds 但有 groupId 时使用单分组逻辑 + await accountGroupService.addAccountToGroup(accountId, updates.groupId, 'gemini') + } + } + } + + // 映射字段名:前端的expiresAt -> 后端的subscriptionExpiresAt + const mappedUpdates = { ...updates } + if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) { + mappedUpdates.subscriptionExpiresAt = updates.subscriptionExpiresAt + } else if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'expiresAt')) { + mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt + } + if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'subscriptionExpiresAt')) { + delete mappedUpdates.expiresAt + } + + const updatedAccount = await geminiAccountService.updateAccount(accountId, mappedUpdates) + + logger.success(`📝 Admin updated Gemini account: ${accountId}`) + const responseAccount = formatSubscriptionExpiry(updatedAccount) + return res.json({ success: true, data: responseAccount }) + } catch (error) { + logger.error('❌ Failed to update Gemini account:', error) + return res.status(500).json({ error: 'Failed to update account', message: error.message }) + } +}) + +// 删除 Gemini 账户 +router.delete('/gemini-accounts/:accountId', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + + // 自动解绑所有绑定的 API Keys + const unboundCount = await apiKeyService.unbindAccountFromAllKeys(accountId, 'gemini') + + // 获取账户信息以检查是否在分组中 + const account = await geminiAccountService.getAccount(accountId) + if (account && account.accountType === 'group') { + const groups = await accountGroupService.getAccountGroups(accountId) + for (const group of groups) { + await accountGroupService.removeAccountFromGroup(accountId, group.id) + } + } + + await geminiAccountService.deleteAccount(accountId) + + let message = 'Gemini账号已成功删除' + if (unboundCount > 0) { + message += `,${unboundCount} 个 API Key 已切换为共享池模式` + } + + logger.success(`🗑️ Admin deleted Gemini account: ${accountId}, unbound ${unboundCount} keys`) + return res.json({ + success: true, + message, + unboundKeys: unboundCount + }) + } catch (error) { + logger.error('❌ Failed to delete Gemini account:', error) + return res.status(500).json({ error: 'Failed to delete account', message: error.message }) + } +}) + +// 刷新 Gemini 账户 token +router.post('/gemini-accounts/:accountId/refresh', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + + const result = await geminiAccountService.refreshAccountToken(accountId) + + logger.success(`🔄 Admin refreshed token for Gemini account: ${accountId}`) + return res.json({ success: true, data: result }) + } catch (error) { + logger.error('❌ Failed to refresh Gemini account token:', error) + return res.status(500).json({ error: 'Failed to refresh token', message: error.message }) + } +}) + +// 切换 Gemini 账户调度状态 +router.put( + '/gemini-accounts/:accountId/toggle-schedulable', + authenticateAdmin, + async (req, res) => { + try { + const { accountId } = req.params + + const account = await geminiAccountService.getAccount(accountId) + if (!account) { + return res.status(404).json({ error: 'Account not found' }) + } + + // 现在 account.schedulable 已经是布尔值了,直接取反即可 + const newSchedulable = !account.schedulable + + await geminiAccountService.updateAccount(accountId, { schedulable: String(newSchedulable) }) + + // 验证更新是否成功,重新获取账户信息 + const updatedAccount = await geminiAccountService.getAccount(accountId) + const actualSchedulable = updatedAccount ? updatedAccount.schedulable : newSchedulable + + // 如果账号被禁用,发送webhook通知 + if (!actualSchedulable) { + await webhookNotifier.sendAccountAnomalyNotification({ + accountId: account.id, + accountName: account.accountName || 'Gemini Account', + platform: 'gemini', + status: 'disabled', + errorCode: 'GEMINI_MANUALLY_DISABLED', + reason: '账号已被管理员手动禁用调度', + timestamp: new Date().toISOString() + }) + } + + logger.success( + `🔄 Admin toggled Gemini account schedulable status: ${accountId} -> ${ + actualSchedulable ? 'schedulable' : 'not schedulable' + }` + ) + + // 返回实际的数据库值,确保前端状态与后端一致 + return res.json({ success: true, schedulable: actualSchedulable }) + } catch (error) { + logger.error('❌ Failed to toggle Gemini account schedulable status:', error) + return res + .status(500) + .json({ error: 'Failed to toggle schedulable status', message: error.message }) + } + } +) + +// 📊 账户使用统计 + +// 获取所有账户的使用统计 +router.get('/accounts/usage-stats', authenticateAdmin, async (req, res) => { + try { + const accountsStats = await redis.getAllAccountsUsageStats() + + return res.json({ + success: true, + data: accountsStats, + summary: { + totalAccounts: accountsStats.length, + activeToday: accountsStats.filter((account) => account.daily.requests > 0).length, + totalDailyTokens: accountsStats.reduce( + (sum, account) => sum + (account.daily.allTokens || 0), + 0 + ), + totalDailyRequests: accountsStats.reduce( + (sum, account) => sum + (account.daily.requests || 0), + 0 + ) + }, + timestamp: new Date().toISOString() + }) + } catch (error) { + logger.error('❌ Failed to get accounts usage stats:', error) + return res.status(500).json({ + success: false, + error: 'Failed to get accounts usage stats', + message: error.message + }) + } +}) + +// 获取单个账户的使用统计 +router.get('/accounts/:accountId/usage-stats', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + const accountStats = await redis.getAccountUsageStats(accountId) + + // 获取账户基本信息 + const accountData = await claudeAccountService.getAccount(accountId) + if (!accountData) { + return res.status(404).json({ + success: false, + error: 'Account not found' + }) + } + + return res.json({ + success: true, + data: { + ...accountStats, + accountInfo: { + name: accountData.name, + email: accountData.email, + status: accountData.status, + isActive: accountData.isActive, + createdAt: accountData.createdAt + } + }, + timestamp: new Date().toISOString() + }) + } catch (error) { + logger.error('❌ Failed to get account usage stats:', error) + return res.status(500).json({ + success: false, + error: 'Failed to get account usage stats', + message: error.message + }) + } +}) + +// 获取账号近30天使用历史 +router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + const { platform = 'claude', days = 30 } = req.query + + const allowedPlatforms = [ + 'claude', + 'claude-console', + 'openai', + 'openai-responses', + 'gemini', + 'droid' + ] + if (!allowedPlatforms.includes(platform)) { + return res.status(400).json({ + success: false, + error: 'Unsupported account platform' + }) + } + + const accountTypeMap = { + openai: 'openai', + 'openai-responses': 'openai-responses', + droid: 'droid' + } + + const fallbackModelMap = { + claude: 'claude-3-5-sonnet-20241022', + 'claude-console': 'claude-3-5-sonnet-20241022', + openai: 'gpt-4o-mini-2024-07-18', + 'openai-responses': 'gpt-4o-mini-2024-07-18', + gemini: 'gemini-1.5-flash', + droid: 'unknown' + } + + // 获取账户信息以获取创建时间 + let accountData = null + let accountCreatedAt = null + + try { + switch (platform) { + case 'claude': + accountData = await claudeAccountService.getAccount(accountId) + break + case 'claude-console': + accountData = await claudeConsoleAccountService.getAccount(accountId) + break + case 'openai': + accountData = await openaiAccountService.getAccount(accountId) + break + case 'openai-responses': + accountData = await openaiResponsesAccountService.getAccount(accountId) + break + case 'gemini': + accountData = await geminiAccountService.getAccount(accountId) + break + case 'droid': + accountData = await droidAccountService.getAccount(accountId) + break + } + + if (accountData && accountData.createdAt) { + accountCreatedAt = new Date(accountData.createdAt) + } + } catch (error) { + logger.warn(`Failed to get account data for avgDailyCost calculation: ${error.message}`) + } + + const client = redis.getClientSafe() + const fallbackModel = fallbackModelMap[platform] || 'unknown' + const daysCount = Math.min(Math.max(parseInt(days, 10) || 30, 1), 60) + + // 获取概览统计数据 + const accountUsageStats = await redis.getAccountUsageStats( + accountId, + accountTypeMap[platform] || null + ) + + const history = [] + let totalCost = 0 + let totalRequests = 0 + let totalTokens = 0 + + let highestCostDay = null + let highestRequestDay = null + + const sumModelCostsForDay = async (dateKey) => { + const modelPattern = `account_usage:model:daily:${accountId}:*:${dateKey}` + const modelKeys = await client.keys(modelPattern) + let summedCost = 0 + + if (modelKeys.length === 0) { + return summedCost + } + + for (const modelKey of modelKeys) { + const modelParts = modelKey.split(':') + const modelName = modelParts[4] || 'unknown' + const modelData = await client.hgetall(modelKey) + if (!modelData || Object.keys(modelData).length === 0) { + continue + } + + const usage = { + input_tokens: parseInt(modelData.inputTokens) || 0, + output_tokens: parseInt(modelData.outputTokens) || 0, + cache_creation_input_tokens: parseInt(modelData.cacheCreateTokens) || 0, + cache_read_input_tokens: parseInt(modelData.cacheReadTokens) || 0 + } + + const costResult = CostCalculator.calculateCost(usage, modelName) + summedCost += costResult.costs.total + } + + return summedCost + } + + const today = new Date() + + for (let offset = daysCount - 1; offset >= 0; offset--) { + const date = new Date(today) + date.setDate(date.getDate() - offset) + + const tzDate = redis.getDateInTimezone(date) + const dateKey = redis.getDateStringInTimezone(date) + const monthLabel = String(tzDate.getUTCMonth() + 1).padStart(2, '0') + const dayLabel = String(tzDate.getUTCDate()).padStart(2, '0') + const label = `${monthLabel}/${dayLabel}` + + const dailyKey = `account_usage:daily:${accountId}:${dateKey}` + const dailyData = await client.hgetall(dailyKey) + + const inputTokens = parseInt(dailyData?.inputTokens) || 0 + const outputTokens = parseInt(dailyData?.outputTokens) || 0 + const cacheCreateTokens = parseInt(dailyData?.cacheCreateTokens) || 0 + const cacheReadTokens = parseInt(dailyData?.cacheReadTokens) || 0 + const allTokens = + parseInt(dailyData?.allTokens) || + inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + const requests = parseInt(dailyData?.requests) || 0 + + let cost = await sumModelCostsForDay(dateKey) + + if (cost === 0 && allTokens > 0) { + const fallbackUsage = { + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_creation_input_tokens: cacheCreateTokens, + cache_read_input_tokens: cacheReadTokens + } + const fallbackResult = CostCalculator.calculateCost(fallbackUsage, fallbackModel) + cost = fallbackResult.costs.total + } + + const normalizedCost = Math.round(cost * 1_000_000) / 1_000_000 + + totalCost += normalizedCost + totalRequests += requests + totalTokens += allTokens + + if (!highestCostDay || normalizedCost > highestCostDay.cost) { + highestCostDay = { + date: dateKey, + label, + cost: normalizedCost, + formattedCost: CostCalculator.formatCost(normalizedCost) + } + } + + if (!highestRequestDay || requests > highestRequestDay.requests) { + highestRequestDay = { + date: dateKey, + label, + requests + } + } + + history.push({ + date: dateKey, + label, + cost: normalizedCost, + formattedCost: CostCalculator.formatCost(normalizedCost), + requests, + tokens: allTokens + }) + } + + // 计算实际使用天数(从账户创建到现在) + let actualDaysForAvg = daysCount + if (accountCreatedAt) { + const now = new Date() + const diffTime = Math.abs(now - accountCreatedAt) + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + // 使用实际使用天数,但不超过请求的天数范围 + actualDaysForAvg = Math.min(diffDays, daysCount) + // 至少为1天,避免除零 + actualDaysForAvg = Math.max(actualDaysForAvg, 1) + } + + // 使用实际天数计算日均值 + const avgDailyCost = actualDaysForAvg > 0 ? totalCost / actualDaysForAvg : 0 + const avgDailyRequests = actualDaysForAvg > 0 ? totalRequests / actualDaysForAvg : 0 + const avgDailyTokens = actualDaysForAvg > 0 ? totalTokens / actualDaysForAvg : 0 + + const todayData = history.length > 0 ? history[history.length - 1] : null + + return res.json({ + success: true, + data: { + history, + summary: { + days: daysCount, + actualDaysUsed: actualDaysForAvg, // 实际使用的天数(用于计算日均值) + accountCreatedAt: accountCreatedAt ? accountCreatedAt.toISOString() : null, + totalCost, + totalCostFormatted: CostCalculator.formatCost(totalCost), + totalRequests, + totalTokens, + avgDailyCost, + avgDailyCostFormatted: CostCalculator.formatCost(avgDailyCost), + avgDailyRequests, + avgDailyTokens, + today: todayData + ? { + date: todayData.date, + cost: todayData.cost, + costFormatted: todayData.formattedCost, + requests: todayData.requests, + tokens: todayData.tokens + } + : null, + highestCostDay, + highestRequestDay + }, + overview: accountUsageStats, + generatedAt: new Date().toISOString() + } + }) + } catch (error) { + logger.error('❌ Failed to get account usage history:', error) + return res.status(500).json({ + success: false, + error: 'Failed to get account usage history', + message: error.message + }) + } +}) + +// 📊 系统统计 + +// 获取系统概览 +router.get('/dashboard', authenticateAdmin, async (req, res) => { + try { + const [ + , + apiKeys, + claudeAccounts, + claudeConsoleAccounts, + geminiAccounts, + bedrockAccountsResult, + openaiAccounts, + ccrAccounts, + openaiResponsesAccounts, + droidAccounts, + todayStats, + systemAverages, + realtimeMetrics + ] = await Promise.all([ + redis.getSystemStats(), + apiKeyService.getAllApiKeys(), + claudeAccountService.getAllAccounts(), + claudeConsoleAccountService.getAllAccounts(), + geminiAccountService.getAllAccounts(), + bedrockAccountService.getAllAccounts(), + redis.getAllOpenAIAccounts(), + ccrAccountService.getAllAccounts(), + openaiResponsesAccountService.getAllAccounts(true), + droidAccountService.getAllAccounts(), + redis.getTodayStats(), + redis.getSystemAverages(), + redis.getRealtimeSystemMetrics() + ]) + + // 处理Bedrock账户数据 + const bedrockAccounts = bedrockAccountsResult.success ? bedrockAccountsResult.data : [] + const normalizeBoolean = (value) => value === true || value === 'true' + const isRateLimitedFlag = (status) => { + if (!status) { + return false + } + if (typeof status === 'string') { + return status === 'limited' + } + if (typeof status === 'object') { + return status.isRateLimited === true + } + return false + } + + const normalDroidAccounts = droidAccounts.filter( + (acc) => + normalizeBoolean(acc.isActive) && + acc.status !== 'blocked' && + acc.status !== 'unauthorized' && + normalizeBoolean(acc.schedulable) && + !isRateLimitedFlag(acc.rateLimitStatus) + ).length + const abnormalDroidAccounts = droidAccounts.filter( + (acc) => + !normalizeBoolean(acc.isActive) || acc.status === 'blocked' || acc.status === 'unauthorized' + ).length + const pausedDroidAccounts = droidAccounts.filter( + (acc) => + !normalizeBoolean(acc.schedulable) && + normalizeBoolean(acc.isActive) && + acc.status !== 'blocked' && + acc.status !== 'unauthorized' + ).length + const rateLimitedDroidAccounts = droidAccounts.filter((acc) => + isRateLimitedFlag(acc.rateLimitStatus) + ).length + + // 计算使用统计(统一使用allTokens) + const totalTokensUsed = apiKeys.reduce( + (sum, key) => sum + (key.usage?.total?.allTokens || 0), + 0 + ) + const totalRequestsUsed = apiKeys.reduce( + (sum, key) => sum + (key.usage?.total?.requests || 0), + 0 + ) + const totalInputTokensUsed = apiKeys.reduce( + (sum, key) => sum + (key.usage?.total?.inputTokens || 0), + 0 + ) + const totalOutputTokensUsed = apiKeys.reduce( + (sum, key) => sum + (key.usage?.total?.outputTokens || 0), + 0 + ) + const totalCacheCreateTokensUsed = apiKeys.reduce( + (sum, key) => sum + (key.usage?.total?.cacheCreateTokens || 0), + 0 + ) + const totalCacheReadTokensUsed = apiKeys.reduce( + (sum, key) => sum + (key.usage?.total?.cacheReadTokens || 0), + 0 + ) + const totalAllTokensUsed = apiKeys.reduce( + (sum, key) => sum + (key.usage?.total?.allTokens || 0), + 0 + ) + + const activeApiKeys = apiKeys.filter((key) => key.isActive).length + + // Claude账户统计 - 根据账户管理页面的判断逻辑 + const normalClaudeAccounts = claudeAccounts.filter( + (acc) => + acc.isActive && + acc.status !== 'blocked' && + acc.status !== 'unauthorized' && + acc.schedulable !== false && + !(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited) + ).length + const abnormalClaudeAccounts = claudeAccounts.filter( + (acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized' + ).length + const pausedClaudeAccounts = claudeAccounts.filter( + (acc) => + acc.schedulable === false && + acc.isActive && + acc.status !== 'blocked' && + acc.status !== 'unauthorized' + ).length + const rateLimitedClaudeAccounts = claudeAccounts.filter( + (acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited + ).length + + // Claude Console账户统计 + const normalClaudeConsoleAccounts = claudeConsoleAccounts.filter( + (acc) => + acc.isActive && + acc.status !== 'blocked' && + acc.status !== 'unauthorized' && + acc.schedulable !== false && + !(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited) + ).length + const abnormalClaudeConsoleAccounts = claudeConsoleAccounts.filter( + (acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized' + ).length + const pausedClaudeConsoleAccounts = claudeConsoleAccounts.filter( + (acc) => + acc.schedulable === false && + acc.isActive && + acc.status !== 'blocked' && + acc.status !== 'unauthorized' + ).length + const rateLimitedClaudeConsoleAccounts = claudeConsoleAccounts.filter( + (acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited + ).length + + // Gemini账户统计 + const normalGeminiAccounts = geminiAccounts.filter( + (acc) => + acc.isActive && + acc.status !== 'blocked' && + acc.status !== 'unauthorized' && + acc.schedulable !== false && + !( + acc.rateLimitStatus === 'limited' || + (acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited) + ) + ).length + const abnormalGeminiAccounts = geminiAccounts.filter( + (acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized' + ).length + const pausedGeminiAccounts = geminiAccounts.filter( + (acc) => + acc.schedulable === false && + acc.isActive && + acc.status !== 'blocked' && + acc.status !== 'unauthorized' + ).length + const rateLimitedGeminiAccounts = geminiAccounts.filter( + (acc) => + acc.rateLimitStatus === 'limited' || + (acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited) + ).length + + // Bedrock账户统计 + const normalBedrockAccounts = bedrockAccounts.filter( + (acc) => + acc.isActive && + acc.status !== 'blocked' && + acc.status !== 'unauthorized' && + acc.schedulable !== false && + !(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited) + ).length + const abnormalBedrockAccounts = bedrockAccounts.filter( + (acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized' + ).length + const pausedBedrockAccounts = bedrockAccounts.filter( + (acc) => + acc.schedulable === false && + acc.isActive && + acc.status !== 'blocked' && + acc.status !== 'unauthorized' + ).length + const rateLimitedBedrockAccounts = bedrockAccounts.filter( + (acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited + ).length + + // OpenAI账户统计 + // 注意:OpenAI账户的isActive和schedulable是字符串类型,默认值为'true' + const normalOpenAIAccounts = openaiAccounts.filter( + (acc) => + (acc.isActive === 'true' || + acc.isActive === true || + (!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)) && + acc.status !== 'blocked' && + acc.status !== 'unauthorized' && + acc.schedulable !== 'false' && + acc.schedulable !== false && // 包括'true'、true和undefined + !(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited) + ).length + const abnormalOpenAIAccounts = openaiAccounts.filter( + (acc) => + acc.isActive === 'false' || + acc.isActive === false || + acc.status === 'blocked' || + acc.status === 'unauthorized' + ).length + const pausedOpenAIAccounts = openaiAccounts.filter( + (acc) => + (acc.schedulable === 'false' || acc.schedulable === false) && + (acc.isActive === 'true' || + acc.isActive === true || + (!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)) && + acc.status !== 'blocked' && + acc.status !== 'unauthorized' + ).length + const rateLimitedOpenAIAccounts = openaiAccounts.filter( + (acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited + ).length + + // CCR账户统计 + const normalCcrAccounts = ccrAccounts.filter( + (acc) => + acc.isActive && + acc.status !== 'blocked' && + acc.status !== 'unauthorized' && + acc.schedulable !== false && + !(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited) + ).length + const abnormalCcrAccounts = ccrAccounts.filter( + (acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized' + ).length + const pausedCcrAccounts = ccrAccounts.filter( + (acc) => + acc.schedulable === false && + acc.isActive && + acc.status !== 'blocked' && + acc.status !== 'unauthorized' + ).length + const rateLimitedCcrAccounts = ccrAccounts.filter( + (acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited + ).length + + // OpenAI-Responses账户统计 + // 注意:OpenAI-Responses账户的isActive和schedulable也是字符串类型 + const normalOpenAIResponsesAccounts = openaiResponsesAccounts.filter( + (acc) => + (acc.isActive === 'true' || + acc.isActive === true || + (!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)) && + acc.status !== 'blocked' && + acc.status !== 'unauthorized' && + acc.schedulable !== 'false' && + acc.schedulable !== false && + !(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited) + ).length + const abnormalOpenAIResponsesAccounts = openaiResponsesAccounts.filter( + (acc) => + acc.isActive === 'false' || + acc.isActive === false || + acc.status === 'blocked' || + acc.status === 'unauthorized' + ).length + const pausedOpenAIResponsesAccounts = openaiResponsesAccounts.filter( + (acc) => + (acc.schedulable === 'false' || acc.schedulable === false) && + (acc.isActive === 'true' || + acc.isActive === true || + (!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)) && + acc.status !== 'blocked' && + acc.status !== 'unauthorized' + ).length + const rateLimitedOpenAIResponsesAccounts = openaiResponsesAccounts.filter( + (acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited + ).length + + const dashboard = { + overview: { + totalApiKeys: apiKeys.length, + activeApiKeys, + // 总账户统计(所有平台) + totalAccounts: + claudeAccounts.length + + claudeConsoleAccounts.length + + geminiAccounts.length + + bedrockAccounts.length + + openaiAccounts.length + + openaiResponsesAccounts.length + + ccrAccounts.length, + normalAccounts: + normalClaudeAccounts + + normalClaudeConsoleAccounts + + normalGeminiAccounts + + normalBedrockAccounts + + normalOpenAIAccounts + + normalOpenAIResponsesAccounts + + normalCcrAccounts, + abnormalAccounts: + abnormalClaudeAccounts + + abnormalClaudeConsoleAccounts + + abnormalGeminiAccounts + + abnormalBedrockAccounts + + abnormalOpenAIAccounts + + abnormalOpenAIResponsesAccounts + + abnormalCcrAccounts + + abnormalDroidAccounts, + pausedAccounts: + pausedClaudeAccounts + + pausedClaudeConsoleAccounts + + pausedGeminiAccounts + + pausedBedrockAccounts + + pausedOpenAIAccounts + + pausedOpenAIResponsesAccounts + + pausedCcrAccounts + + pausedDroidAccounts, + rateLimitedAccounts: + rateLimitedClaudeAccounts + + rateLimitedClaudeConsoleAccounts + + rateLimitedGeminiAccounts + + rateLimitedBedrockAccounts + + rateLimitedOpenAIAccounts + + rateLimitedOpenAIResponsesAccounts + + rateLimitedCcrAccounts + + rateLimitedDroidAccounts, + // 各平台详细统计 + accountsByPlatform: { + claude: { + total: claudeAccounts.length, + normal: normalClaudeAccounts, + abnormal: abnormalClaudeAccounts, + paused: pausedClaudeAccounts, + rateLimited: rateLimitedClaudeAccounts + }, + 'claude-console': { + total: claudeConsoleAccounts.length, + normal: normalClaudeConsoleAccounts, + abnormal: abnormalClaudeConsoleAccounts, + paused: pausedClaudeConsoleAccounts, + rateLimited: rateLimitedClaudeConsoleAccounts + }, + gemini: { + total: geminiAccounts.length, + normal: normalGeminiAccounts, + abnormal: abnormalGeminiAccounts, + paused: pausedGeminiAccounts, + rateLimited: rateLimitedGeminiAccounts + }, + bedrock: { + total: bedrockAccounts.length, + normal: normalBedrockAccounts, + abnormal: abnormalBedrockAccounts, + paused: pausedBedrockAccounts, + rateLimited: rateLimitedBedrockAccounts + }, + openai: { + total: openaiAccounts.length, + normal: normalOpenAIAccounts, + abnormal: abnormalOpenAIAccounts, + paused: pausedOpenAIAccounts, + rateLimited: rateLimitedOpenAIAccounts + }, + ccr: { + total: ccrAccounts.length, + normal: normalCcrAccounts, + abnormal: abnormalCcrAccounts, + paused: pausedCcrAccounts, + rateLimited: rateLimitedCcrAccounts + }, + 'openai-responses': { + total: openaiResponsesAccounts.length, + normal: normalOpenAIResponsesAccounts, + abnormal: abnormalOpenAIResponsesAccounts, + paused: pausedOpenAIResponsesAccounts, + rateLimited: rateLimitedOpenAIResponsesAccounts + }, + droid: { + total: droidAccounts.length, + normal: normalDroidAccounts, + abnormal: abnormalDroidAccounts, + paused: pausedDroidAccounts, + rateLimited: rateLimitedDroidAccounts + } + }, + // 保留旧字段以兼容 + activeAccounts: + normalClaudeAccounts + + normalClaudeConsoleAccounts + + normalGeminiAccounts + + normalBedrockAccounts + + normalOpenAIAccounts + + normalOpenAIResponsesAccounts + + normalCcrAccounts + + normalDroidAccounts, + totalClaudeAccounts: claudeAccounts.length + claudeConsoleAccounts.length, + activeClaudeAccounts: normalClaudeAccounts + normalClaudeConsoleAccounts, + rateLimitedClaudeAccounts: rateLimitedClaudeAccounts + rateLimitedClaudeConsoleAccounts, + totalGeminiAccounts: geminiAccounts.length, + activeGeminiAccounts: normalGeminiAccounts, + rateLimitedGeminiAccounts, + totalTokensUsed, + totalRequestsUsed, + totalInputTokensUsed, + totalOutputTokensUsed, + totalCacheCreateTokensUsed, + totalCacheReadTokensUsed, + totalAllTokensUsed + }, + recentActivity: { + apiKeysCreatedToday: todayStats.apiKeysCreatedToday, + requestsToday: todayStats.requestsToday, + tokensToday: todayStats.tokensToday, + inputTokensToday: todayStats.inputTokensToday, + outputTokensToday: todayStats.outputTokensToday, + cacheCreateTokensToday: todayStats.cacheCreateTokensToday || 0, + cacheReadTokensToday: todayStats.cacheReadTokensToday || 0 + }, + systemAverages: { + rpm: systemAverages.systemRPM, + tpm: systemAverages.systemTPM + }, + realtimeMetrics: { + rpm: realtimeMetrics.realtimeRPM, + tpm: realtimeMetrics.realtimeTPM, + windowMinutes: realtimeMetrics.windowMinutes, + isHistorical: realtimeMetrics.windowMinutes === 0 // 标识是否使用了历史数据 + }, + systemHealth: { + redisConnected: redis.isConnected, + claudeAccountsHealthy: normalClaudeAccounts + normalClaudeConsoleAccounts > 0, + geminiAccountsHealthy: normalGeminiAccounts > 0, + droidAccountsHealthy: normalDroidAccounts > 0, + uptime: process.uptime() + }, + systemTimezone: config.system.timezoneOffset || 8 + } + + return res.json({ success: true, data: dashboard }) + } catch (error) { + logger.error('❌ Failed to get dashboard data:', error) + return res.status(500).json({ error: 'Failed to get dashboard data', message: error.message }) + } +}) + +// 获取使用统计 +router.get('/usage-stats', authenticateAdmin, async (req, res) => { + try { + const { period = 'daily' } = req.query // daily, monthly + + // 获取基础API Key统计 + const apiKeys = await apiKeyService.getAllApiKeys() + + const stats = apiKeys.map((key) => ({ + keyId: key.id, + keyName: key.name, + usage: key.usage + })) + + return res.json({ success: true, data: { period, stats } }) + } catch (error) { + logger.error('❌ Failed to get usage stats:', error) + return res.status(500).json({ error: 'Failed to get usage stats', message: error.message }) + } +}) + +// 获取按模型的使用统计和费用 +router.get('/model-stats', authenticateAdmin, async (req, res) => { + try { + const { period = 'daily', startDate, endDate } = req.query // daily, monthly, 支持自定义时间范围 + const today = redis.getDateStringInTimezone() + const tzDate = redis.getDateInTimezone() + const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( + 2, + '0' + )}` + + logger.info( + `📊 Getting global model stats, period: ${period}, startDate: ${startDate}, endDate: ${endDate}, today: ${today}, currentMonth: ${currentMonth}` + ) + + const client = redis.getClientSafe() + + // 获取所有模型的统计数据 + let searchPatterns = [] + + if (startDate && endDate) { + // 自定义日期范围,生成多个日期的搜索模式 + const start = new Date(startDate) + const end = new Date(endDate) + + // 确保日期范围有效 + if (start > end) { + return res.status(400).json({ error: 'Start date must be before or equal to end date' }) + } + + // 限制最大范围为365天 + const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1 + if (daysDiff > 365) { + return res.status(400).json({ error: 'Date range cannot exceed 365 days' }) + } + + // 生成日期范围内所有日期的搜索模式 + const currentDate = new Date(start) + while (currentDate <= end) { + const dateStr = redis.getDateStringInTimezone(currentDate) + searchPatterns.push(`usage:model:daily:*:${dateStr}`) + currentDate.setDate(currentDate.getDate() + 1) + } + + logger.info(`📊 Generated ${searchPatterns.length} search patterns for date range`) + } else { + // 使用默认的period + const pattern = + period === 'daily' + ? `usage:model:daily:*:${today}` + : `usage:model:monthly:*:${currentMonth}` + searchPatterns = [pattern] + } + + logger.info('📊 Searching patterns:', searchPatterns) + + // 获取所有匹配的keys + const allKeys = [] + for (const pattern of searchPatterns) { + const keys = await client.keys(pattern) + allKeys.push(...keys) + } + + logger.info(`📊 Found ${allKeys.length} matching keys in total`) + + // 模型名标准化函数(与redis.js保持一致) + const normalizeModelName = (model) => { + if (!model || model === 'unknown') { + return model + } + + // 对于Bedrock模型,去掉区域前缀进行统一 + if (model.includes('.anthropic.') || model.includes('.claude')) { + // 匹配所有AWS区域格式:region.anthropic.model-name-v1:0 -> claude-model-name + // 支持所有AWS区域格式,如:us-east-1, eu-west-1, ap-southeast-1, ca-central-1等 + let normalized = model.replace(/^[a-z0-9-]+\./, '') // 去掉任何区域前缀(更通用) + normalized = normalized.replace('anthropic.', '') // 去掉anthropic前缀 + normalized = normalized.replace(/-v\d+:\d+$/, '') // 去掉版本后缀(如-v1:0, -v2:1等) + return normalized + } + + // 对于其他模型,去掉常见的版本后缀 + return model.replace(/-v\d+:\d+$|:latest$/, '') + } + + // 聚合相同模型的数据 + const modelStatsMap = new Map() + + for (const key of allKeys) { + const match = key.match(/usage:model:daily:(.+):\d{4}-\d{2}-\d{2}$/) + + if (!match) { + logger.warn(`📊 Pattern mismatch for key: ${key}`) + continue + } + + const rawModel = match[1] + const normalizedModel = normalizeModelName(rawModel) + const data = await client.hgetall(key) + + if (data && Object.keys(data).length > 0) { + const stats = modelStatsMap.get(normalizedModel) || { + requests: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + allTokens: 0 + } + + stats.requests += parseInt(data.requests) || 0 + stats.inputTokens += parseInt(data.inputTokens) || 0 + stats.outputTokens += parseInt(data.outputTokens) || 0 + stats.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0 + stats.cacheReadTokens += parseInt(data.cacheReadTokens) || 0 + stats.allTokens += parseInt(data.allTokens) || 0 + + modelStatsMap.set(normalizedModel, stats) + } + } + + // 转换为数组并计算费用 + const modelStats = [] + + for (const [model, stats] of modelStatsMap) { + const usage = { + input_tokens: stats.inputTokens, + output_tokens: stats.outputTokens, + cache_creation_input_tokens: stats.cacheCreateTokens, + cache_read_input_tokens: stats.cacheReadTokens + } + + // 计算费用 + const costData = CostCalculator.calculateCost(usage, model) + + modelStats.push({ + model, + period: startDate && endDate ? 'custom' : period, + requests: stats.requests, + inputTokens: usage.input_tokens, + outputTokens: usage.output_tokens, + cacheCreateTokens: usage.cache_creation_input_tokens, + cacheReadTokens: usage.cache_read_input_tokens, + allTokens: stats.allTokens, + usage: { + requests: stats.requests, + inputTokens: usage.input_tokens, + outputTokens: usage.output_tokens, + cacheCreateTokens: usage.cache_creation_input_tokens, + cacheReadTokens: usage.cache_read_input_tokens, + totalTokens: + usage.input_tokens + + usage.output_tokens + + usage.cache_creation_input_tokens + + usage.cache_read_input_tokens + }, + costs: costData.costs, + formatted: costData.formatted, + pricing: costData.pricing + }) + } + + // 按总费用排序 + modelStats.sort((a, b) => b.costs.total - a.costs.total) + + logger.info( + `📊 Returning ${modelStats.length} global model stats for period ${period}:`, + modelStats + ) + + return res.json({ success: true, data: modelStats }) + } catch (error) { + logger.error('❌ Failed to get model stats:', error) + return res.status(500).json({ error: 'Failed to get model stats', message: error.message }) + } +}) + +// 🔧 系统管理 + +// 清理过期数据 +router.post('/cleanup', authenticateAdmin, async (req, res) => { + try { + const [expiredKeys, errorAccounts] = await Promise.all([ + apiKeyService.cleanupExpiredKeys(), + claudeAccountService.cleanupErrorAccounts() + ]) + + await redis.cleanup() + + logger.success( + `🧹 Admin triggered cleanup: ${expiredKeys} expired keys, ${errorAccounts} error accounts` + ) + + return res.json({ + success: true, + message: 'Cleanup completed', + data: { + expiredKeysRemoved: expiredKeys, + errorAccountsReset: errorAccounts + } + }) + } catch (error) { + logger.error('❌ Cleanup failed:', error) + return res.status(500).json({ error: 'Cleanup failed', message: error.message }) + } +}) + +// 获取使用趋势数据 +router.get('/usage-trend', authenticateAdmin, async (req, res) => { + try { + const { days = 7, granularity = 'day', startDate, endDate } = req.query + const client = redis.getClientSafe() + + const trendData = [] + + if (granularity === 'hour') { + // 小时粒度统计 + let startTime, endTime + + if (startDate && endDate) { + // 使用自定义时间范围 + startTime = new Date(startDate) + endTime = new Date(endDate) + + // 调试日志 + logger.info('📊 Usage trend hour granularity - received times:') + logger.info(` startDate (raw): ${startDate}`) + logger.info(` endDate (raw): ${endDate}`) + logger.info(` startTime (parsed): ${startTime.toISOString()}`) + logger.info(` endTime (parsed): ${endTime.toISOString()}`) + logger.info(` System timezone offset: ${config.system.timezoneOffset || 8}`) + } else { + // 默认最近24小时 + endTime = new Date() + startTime = new Date(endTime.getTime() - 24 * 60 * 60 * 1000) + } + + // 确保时间范围不超过24小时 + const timeDiff = endTime - startTime + if (timeDiff > 24 * 60 * 60 * 1000) { + return res.status(400).json({ + error: '小时粒度查询时间范围不能超过24小时' + }) + } + + // 按小时遍历 + const currentHour = new Date(startTime) + currentHour.setMinutes(0, 0, 0) + + while (currentHour <= endTime) { + // 注意:前端发送的时间已经是UTC时间,不需要再次转换 + // 直接从currentHour生成对应系统时区的日期和小时 + const tzCurrentHour = redis.getDateInTimezone(currentHour) + const dateStr = redis.getDateStringInTimezone(currentHour) + const hour = String(tzCurrentHour.getUTCHours()).padStart(2, '0') + const hourKey = `${dateStr}:${hour}` + + // 获取当前小时的模型统计数据 + const modelPattern = `usage:model:hourly:*:${hourKey}` + const modelKeys = await client.keys(modelPattern) + + let hourInputTokens = 0 + let hourOutputTokens = 0 + let hourRequests = 0 + let hourCacheCreateTokens = 0 + let hourCacheReadTokens = 0 + let hourCost = 0 + + for (const modelKey of modelKeys) { + const modelMatch = modelKey.match(/usage:model:hourly:(.+):\d{4}-\d{2}-\d{2}:\d{2}$/) + if (!modelMatch) { + continue + } + + const model = modelMatch[1] + const data = await client.hgetall(modelKey) + + if (data && Object.keys(data).length > 0) { + const modelInputTokens = parseInt(data.inputTokens) || 0 + const modelOutputTokens = parseInt(data.outputTokens) || 0 + const modelCacheCreateTokens = parseInt(data.cacheCreateTokens) || 0 + const modelCacheReadTokens = parseInt(data.cacheReadTokens) || 0 + const modelRequests = parseInt(data.requests) || 0 + + hourInputTokens += modelInputTokens + hourOutputTokens += modelOutputTokens + hourCacheCreateTokens += modelCacheCreateTokens + hourCacheReadTokens += modelCacheReadTokens + hourRequests += modelRequests + + const modelUsage = { + input_tokens: modelInputTokens, + output_tokens: modelOutputTokens, + cache_creation_input_tokens: modelCacheCreateTokens, + cache_read_input_tokens: modelCacheReadTokens + } + const modelCostResult = CostCalculator.calculateCost(modelUsage, model) + hourCost += modelCostResult.costs.total + } + } + + // 如果没有模型级别的数据,尝试API Key级别的数据 + if (modelKeys.length === 0) { + const pattern = `usage:hourly:*:${hourKey}` + const keys = await client.keys(pattern) + + for (const key of keys) { + const data = await client.hgetall(key) + if (data) { + hourInputTokens += parseInt(data.inputTokens) || 0 + hourOutputTokens += parseInt(data.outputTokens) || 0 + hourRequests += parseInt(data.requests) || 0 + hourCacheCreateTokens += parseInt(data.cacheCreateTokens) || 0 + hourCacheReadTokens += parseInt(data.cacheReadTokens) || 0 + } + } + + const usage = { + input_tokens: hourInputTokens, + output_tokens: hourOutputTokens, + cache_creation_input_tokens: hourCacheCreateTokens, + cache_read_input_tokens: hourCacheReadTokens + } + const costResult = CostCalculator.calculateCost(usage, 'unknown') + hourCost = costResult.costs.total + } + + // 格式化时间标签 - 使用系统时区的显示 + const tzDateForLabel = redis.getDateInTimezone(currentHour) + const month = String(tzDateForLabel.getUTCMonth() + 1).padStart(2, '0') + const day = String(tzDateForLabel.getUTCDate()).padStart(2, '0') + const hourStr = String(tzDateForLabel.getUTCHours()).padStart(2, '0') + + trendData.push({ + // 对于小时粒度,只返回hour字段,不返回date字段 + hour: currentHour.toISOString(), // 保留原始ISO时间用于排序 + label: `${month}/${day} ${hourStr}:00`, // 添加格式化的标签 + inputTokens: hourInputTokens, + outputTokens: hourOutputTokens, + requests: hourRequests, + cacheCreateTokens: hourCacheCreateTokens, + cacheReadTokens: hourCacheReadTokens, + totalTokens: + hourInputTokens + hourOutputTokens + hourCacheCreateTokens + hourCacheReadTokens, + cost: hourCost + }) + + // 移到下一个小时 + currentHour.setHours(currentHour.getHours() + 1) + } + } else { + // 天粒度统计(保持原有逻辑) + const daysCount = parseInt(days) || 7 + const today = new Date() + + // 获取过去N天的数据 + for (let i = 0; i < daysCount; i++) { + const date = new Date(today) + date.setDate(date.getDate() - i) + const dateStr = redis.getDateStringInTimezone(date) + + // 汇总当天所有API Key的使用数据 + const pattern = `usage:daily:*:${dateStr}` + const keys = await client.keys(pattern) + + let dayInputTokens = 0 + let dayOutputTokens = 0 + let dayRequests = 0 + let dayCacheCreateTokens = 0 + let dayCacheReadTokens = 0 + let dayCost = 0 + + // 按模型统计使用量 + // const modelUsageMap = new Map(); + + // 获取当天所有模型的使用数据 + const modelPattern = `usage:model:daily:*:${dateStr}` + const modelKeys = await client.keys(modelPattern) + + for (const modelKey of modelKeys) { + // 解析模型名称 + const modelMatch = modelKey.match(/usage:model:daily:(.+):\d{4}-\d{2}-\d{2}$/) + if (!modelMatch) { + continue + } + + const model = modelMatch[1] + const data = await client.hgetall(modelKey) + + if (data && Object.keys(data).length > 0) { + const modelInputTokens = parseInt(data.inputTokens) || 0 + const modelOutputTokens = parseInt(data.outputTokens) || 0 + const modelCacheCreateTokens = parseInt(data.cacheCreateTokens) || 0 + const modelCacheReadTokens = parseInt(data.cacheReadTokens) || 0 + const modelRequests = parseInt(data.requests) || 0 + + // 累加总数 + dayInputTokens += modelInputTokens + dayOutputTokens += modelOutputTokens + dayCacheCreateTokens += modelCacheCreateTokens + dayCacheReadTokens += modelCacheReadTokens + dayRequests += modelRequests + + // 按模型计算费用 + const modelUsage = { + input_tokens: modelInputTokens, + output_tokens: modelOutputTokens, + cache_creation_input_tokens: modelCacheCreateTokens, + cache_read_input_tokens: modelCacheReadTokens + } + const modelCostResult = CostCalculator.calculateCost(modelUsage, model) + dayCost += modelCostResult.costs.total + } + } + + // 如果没有模型级别的数据,回退到原始方法 + if (modelKeys.length === 0 && keys.length > 0) { + for (const key of keys) { + const data = await client.hgetall(key) + if (data) { + dayInputTokens += parseInt(data.inputTokens) || 0 + dayOutputTokens += parseInt(data.outputTokens) || 0 + dayRequests += parseInt(data.requests) || 0 + dayCacheCreateTokens += parseInt(data.cacheCreateTokens) || 0 + dayCacheReadTokens += parseInt(data.cacheReadTokens) || 0 + } + } + + // 使用默认模型价格计算 + const usage = { + input_tokens: dayInputTokens, + output_tokens: dayOutputTokens, + cache_creation_input_tokens: dayCacheCreateTokens, + cache_read_input_tokens: dayCacheReadTokens + } + const costResult = CostCalculator.calculateCost(usage, 'unknown') + dayCost = costResult.costs.total + } + + trendData.push({ + date: dateStr, + inputTokens: dayInputTokens, + outputTokens: dayOutputTokens, + requests: dayRequests, + cacheCreateTokens: dayCacheCreateTokens, + cacheReadTokens: dayCacheReadTokens, + totalTokens: dayInputTokens + dayOutputTokens + dayCacheCreateTokens + dayCacheReadTokens, + cost: dayCost, + formattedCost: CostCalculator.formatCost(dayCost) + }) + } + } + + // 按日期正序排列 + if (granularity === 'hour') { + trendData.sort((a, b) => new Date(a.hour) - new Date(b.hour)) + } else { + trendData.sort((a, b) => new Date(a.date) - new Date(b.date)) + } + + return res.json({ success: true, data: trendData, granularity }) + } catch (error) { + logger.error('❌ Failed to get usage trend:', error) + return res.status(500).json({ error: 'Failed to get usage trend', message: error.message }) + } +}) + +// 获取单个API Key的模型统计 +router.get('/api-keys/:keyId/model-stats', authenticateAdmin, async (req, res) => { + try { + const { keyId } = req.params + const { period = 'monthly', startDate, endDate } = req.query + + logger.info( + `📊 Getting model stats for API key: ${keyId}, period: ${period}, startDate: ${startDate}, endDate: ${endDate}` + ) + + const client = redis.getClientSafe() + const today = redis.getDateStringInTimezone() + const tzDate = redis.getDateInTimezone() + const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( + 2, + '0' + )}` + + let searchPatterns = [] + + if (period === 'custom' && startDate && endDate) { + // 自定义日期范围,生成多个日期的搜索模式 + const start = new Date(startDate) + const end = new Date(endDate) + + // 确保日期范围有效 + if (start > end) { + return res.status(400).json({ error: 'Start date must be before or equal to end date' }) + } + + // 限制最大范围为365天 + const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1 + if (daysDiff > 365) { + return res.status(400).json({ error: 'Date range cannot exceed 365 days' }) + } + + // 生成日期范围内所有日期的搜索模式 + for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) { + const dateStr = redis.getDateStringInTimezone(d) + searchPatterns.push(`usage:${keyId}:model:daily:*:${dateStr}`) + } + + logger.info( + `📊 Custom date range patterns: ${searchPatterns.length} days from ${startDate} to ${endDate}` + ) + } else { + // 原有的预设期间逻辑 + const pattern = + period === 'daily' + ? `usage:${keyId}:model:daily:*:${today}` + : `usage:${keyId}:model:monthly:*:${currentMonth}` + searchPatterns = [pattern] + logger.info(`📊 Preset period pattern: ${pattern}`) + } + + // 汇总所有匹配的数据 + const modelStatsMap = new Map() + const modelStats = [] // 定义结果数组 + + for (const pattern of searchPatterns) { + const keys = await client.keys(pattern) + logger.info(`📊 Pattern ${pattern} found ${keys.length} keys`) + + for (const key of keys) { + const match = + key.match(/usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/) || + key.match(/usage:.+:model:monthly:(.+):\d{4}-\d{2}$/) + + if (!match) { + logger.warn(`📊 Pattern mismatch for key: ${key}`) + continue + } + + const model = match[1] + const data = await client.hgetall(key) + + if (data && Object.keys(data).length > 0) { + // 累加同一模型的数据 + if (!modelStatsMap.has(model)) { + modelStatsMap.set(model, { + requests: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + allTokens: 0 + }) + } + + const stats = modelStatsMap.get(model) + stats.requests += parseInt(data.requests) || 0 + stats.inputTokens += parseInt(data.inputTokens) || 0 + stats.outputTokens += parseInt(data.outputTokens) || 0 + stats.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0 + stats.cacheReadTokens += parseInt(data.cacheReadTokens) || 0 + stats.allTokens += parseInt(data.allTokens) || 0 + } + } + } + + // 将汇总的数据转换为最终结果 + for (const [model, stats] of modelStatsMap) { + logger.info(`📊 Model ${model} aggregated data:`, stats) + + const usage = { + input_tokens: stats.inputTokens, + output_tokens: stats.outputTokens, + cache_creation_input_tokens: stats.cacheCreateTokens, + cache_read_input_tokens: stats.cacheReadTokens + } + + // 使用CostCalculator计算费用 + const costData = CostCalculator.calculateCost(usage, model) + + modelStats.push({ + model, + requests: stats.requests, + inputTokens: stats.inputTokens, + outputTokens: stats.outputTokens, + cacheCreateTokens: stats.cacheCreateTokens, + cacheReadTokens: stats.cacheReadTokens, + allTokens: stats.allTokens, + // 添加费用信息 + costs: costData.costs, + formatted: costData.formatted, + pricing: costData.pricing, + usingDynamicPricing: costData.usingDynamicPricing + }) + } + + // 如果没有找到模型级别的详细数据,尝试从汇总数据中生成展示 + if (modelStats.length === 0) { + logger.info( + `📊 No detailed model stats found, trying to get aggregate data for API key ${keyId}` + ) + + // 尝试从API Keys列表中获取usage数据作为备选方案 + try { + const apiKeys = await apiKeyService.getAllApiKeys() + const targetApiKey = apiKeys.find((key) => key.id === keyId) + + if (targetApiKey && targetApiKey.usage) { + logger.info( + `📊 Found API key usage data from getAllApiKeys for ${keyId}:`, + targetApiKey.usage + ) + + // 从汇总数据创建展示条目 + let usageData + if (period === 'custom' || period === 'daily') { + // 对于自定义或日统计,使用daily数据或total数据 + usageData = targetApiKey.usage.daily || targetApiKey.usage.total + } else { + // 对于月统计,使用monthly数据或total数据 + usageData = targetApiKey.usage.monthly || targetApiKey.usage.total + } + + if (usageData && usageData.allTokens > 0) { + const usage = { + input_tokens: usageData.inputTokens || 0, + output_tokens: usageData.outputTokens || 0, + cache_creation_input_tokens: usageData.cacheCreateTokens || 0, + cache_read_input_tokens: usageData.cacheReadTokens || 0 + } + + // 对于汇总数据,使用默认模型计算费用 + const costData = CostCalculator.calculateCost(usage, 'claude-3-5-sonnet-20241022') + + modelStats.push({ + model: '总体使用 (历史数据)', + requests: usageData.requests || 0, + inputTokens: usageData.inputTokens || 0, + outputTokens: usageData.outputTokens || 0, + cacheCreateTokens: usageData.cacheCreateTokens || 0, + cacheReadTokens: usageData.cacheReadTokens || 0, + allTokens: usageData.allTokens || 0, + // 添加费用信息 + costs: costData.costs, + formatted: costData.formatted, + pricing: costData.pricing, + usingDynamicPricing: costData.usingDynamicPricing + }) + + logger.info('📊 Generated display data from API key usage stats') + } else { + logger.info(`📊 No usage data found for period ${period} in API key data`) + } + } else { + logger.info(`📊 API key ${keyId} not found or has no usage data`) + } + } catch (error) { + logger.error('❌ Error fetching API key usage data:', error) + } + } + + // 按总token数降序排列 + modelStats.sort((a, b) => b.allTokens - a.allTokens) + + logger.info(`📊 Returning ${modelStats.length} model stats for API key ${keyId}:`, modelStats) + + return res.json({ success: true, data: modelStats }) + } catch (error) { + logger.error('❌ Failed to get API key model stats:', error) + return res + .status(500) + .json({ error: 'Failed to get API key model stats', message: error.message }) + } +}) + +// 获取按账号分组的使用趋势 +router.get('/account-usage-trend', authenticateAdmin, async (req, res) => { + try { + const { granularity = 'day', group = 'claude', days = 7, startDate, endDate } = req.query + + const allowedGroups = ['claude', 'openai', 'gemini'] + if (!allowedGroups.includes(group)) { + return res.status(400).json({ + success: false, + error: 'Invalid account group' + }) + } + + const groupLabels = { + claude: 'Claude账户', + openai: 'OpenAI账户', + gemini: 'Gemini账户' + } + + // 拉取各平台账号列表 + let accounts = [] + if (group === 'claude') { + const [claudeAccounts, claudeConsoleAccounts] = await Promise.all([ + claudeAccountService.getAllAccounts(), + claudeConsoleAccountService.getAllAccounts() + ]) + + accounts = [ + ...claudeAccounts.map((account) => { + const id = String(account.id || '') + const shortId = id ? id.slice(0, 8) : '未知' + return { + id, + name: account.name || account.email || `Claude账号 ${shortId}`, + platform: 'claude' + } + }), + ...claudeConsoleAccounts.map((account) => { + const id = String(account.id || '') + const shortId = id ? id.slice(0, 8) : '未知' + return { + id, + name: account.name || `Console账号 ${shortId}`, + platform: 'claude-console' + } + }) + ] + } else if (group === 'openai') { + const [openaiAccounts, openaiResponsesAccounts] = await Promise.all([ + openaiAccountService.getAllAccounts(), + openaiResponsesAccountService.getAllAccounts(true) + ]) + + accounts = [ + ...openaiAccounts.map((account) => { + const id = String(account.id || '') + const shortId = id ? id.slice(0, 8) : '未知' + return { + id, + name: account.name || account.email || `OpenAI账号 ${shortId}`, + platform: 'openai' + } + }), + ...openaiResponsesAccounts.map((account) => { + const id = String(account.id || '') + const shortId = id ? id.slice(0, 8) : '未知' + return { + id, + name: account.name || `Responses账号 ${shortId}`, + platform: 'openai-responses' + } + }) + ] + } else if (group === 'gemini') { + const geminiAccounts = await geminiAccountService.getAllAccounts() + accounts = geminiAccounts.map((account) => { + const id = String(account.id || '') + const shortId = id ? id.slice(0, 8) : '未知' + return { + id, + name: account.name || account.email || `Gemini账号 ${shortId}`, + platform: 'gemini' + } + }) + } + + if (!accounts || accounts.length === 0) { + return res.json({ + success: true, + data: [], + granularity, + group, + groupLabel: groupLabels[group], + topAccounts: [], + totalAccounts: 0 + }) + } + + const accountMap = new Map() + const accountIdSet = new Set() + for (const account of accounts) { + accountMap.set(account.id, { + name: account.name, + platform: account.platform + }) + accountIdSet.add(account.id) + } + + const fallbackModelByGroup = { + claude: 'claude-3-5-sonnet-20241022', + openai: 'gpt-4o-mini-2024-07-18', + gemini: 'gemini-1.5-flash' + } + const fallbackModel = fallbackModelByGroup[group] || 'unknown' + + const client = redis.getClientSafe() + const trendData = [] + const accountCostTotals = new Map() + + const sumModelCosts = async (accountId, period, timeKey) => { + const modelPattern = `account_usage:model:${period}:${accountId}:*:${timeKey}` + const modelKeys = await client.keys(modelPattern) + let totalCost = 0 + + for (const modelKey of modelKeys) { + const modelData = await client.hgetall(modelKey) + if (!modelData) { + continue + } + + const parts = modelKey.split(':') + if (parts.length < 5) { + continue + } + + const modelName = parts[4] + const usage = { + input_tokens: parseInt(modelData.inputTokens) || 0, + output_tokens: parseInt(modelData.outputTokens) || 0, + cache_creation_input_tokens: parseInt(modelData.cacheCreateTokens) || 0, + cache_read_input_tokens: parseInt(modelData.cacheReadTokens) || 0 + } + + const costResult = CostCalculator.calculateCost(usage, modelName) + totalCost += costResult.costs.total + } + + return totalCost + } + + if (granularity === 'hour') { + let startTime + let endTime + + if (startDate && endDate) { + startTime = new Date(startDate) + endTime = new Date(endDate) + } else { + endTime = new Date() + startTime = new Date(endTime.getTime() - 24 * 60 * 60 * 1000) + } + + const currentHour = new Date(startTime) + currentHour.setMinutes(0, 0, 0) + + while (currentHour <= endTime) { + const tzCurrentHour = redis.getDateInTimezone(currentHour) + const dateStr = redis.getDateStringInTimezone(currentHour) + const hour = String(tzCurrentHour.getUTCHours()).padStart(2, '0') + const hourKey = `${dateStr}:${hour}` + + const tzDateForLabel = redis.getDateInTimezone(currentHour) + const monthLabel = String(tzDateForLabel.getUTCMonth() + 1).padStart(2, '0') + const dayLabel = String(tzDateForLabel.getUTCDate()).padStart(2, '0') + const hourLabel = String(tzDateForLabel.getUTCHours()).padStart(2, '0') + + const hourData = { + hour: currentHour.toISOString(), + label: `${monthLabel}/${dayLabel} ${hourLabel}:00`, + accounts: {} + } + + const pattern = `account_usage:hourly:*:${hourKey}` + const keys = await client.keys(pattern) + + for (const key of keys) { + const match = key.match(/account_usage:hourly:(.+?):\d{4}-\d{2}-\d{2}:\d{2}/) + if (!match) { + continue + } + + const accountId = match[1] + if (!accountIdSet.has(accountId)) { + continue + } + + const data = await client.hgetall(key) + if (!data) { + continue + } + + const inputTokens = parseInt(data.inputTokens) || 0 + const outputTokens = parseInt(data.outputTokens) || 0 + const cacheCreateTokens = parseInt(data.cacheCreateTokens) || 0 + const cacheReadTokens = parseInt(data.cacheReadTokens) || 0 + const allTokens = + parseInt(data.allTokens) || + inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + const requests = parseInt(data.requests) || 0 + + let cost = await sumModelCosts(accountId, 'hourly', hourKey) + + if (cost === 0 && allTokens > 0) { + const fallbackUsage = { + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_creation_input_tokens: cacheCreateTokens, + cache_read_input_tokens: cacheReadTokens + } + const fallbackResult = CostCalculator.calculateCost(fallbackUsage, fallbackModel) + cost = fallbackResult.costs.total + } + + const formattedCost = CostCalculator.formatCost(cost) + const accountInfo = accountMap.get(accountId) + + hourData.accounts[accountId] = { + name: accountInfo ? accountInfo.name : `账号 ${accountId.slice(0, 8)}`, + cost, + formattedCost, + requests + } + + accountCostTotals.set(accountId, (accountCostTotals.get(accountId) || 0) + cost) + } + + trendData.push(hourData) + currentHour.setHours(currentHour.getHours() + 1) + } + } else { + const daysCount = parseInt(days) || 7 + const today = new Date() + + for (let i = 0; i < daysCount; i++) { + const date = new Date(today) + date.setDate(date.getDate() - i) + const dateStr = redis.getDateStringInTimezone(date) + + const dayData = { + date: dateStr, + accounts: {} + } + + const pattern = `account_usage:daily:*:${dateStr}` + const keys = await client.keys(pattern) + + for (const key of keys) { + const match = key.match(/account_usage:daily:(.+?):\d{4}-\d{2}-\d{2}/) + if (!match) { + continue + } + + const accountId = match[1] + if (!accountIdSet.has(accountId)) { + continue + } + + const data = await client.hgetall(key) + if (!data) { + continue + } + + const inputTokens = parseInt(data.inputTokens) || 0 + const outputTokens = parseInt(data.outputTokens) || 0 + const cacheCreateTokens = parseInt(data.cacheCreateTokens) || 0 + const cacheReadTokens = parseInt(data.cacheReadTokens) || 0 + const allTokens = + parseInt(data.allTokens) || + inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + const requests = parseInt(data.requests) || 0 + + let cost = await sumModelCosts(accountId, 'daily', dateStr) + + if (cost === 0 && allTokens > 0) { + const fallbackUsage = { + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_creation_input_tokens: cacheCreateTokens, + cache_read_input_tokens: cacheReadTokens + } + const fallbackResult = CostCalculator.calculateCost(fallbackUsage, fallbackModel) + cost = fallbackResult.costs.total + } + + const formattedCost = CostCalculator.formatCost(cost) + const accountInfo = accountMap.get(accountId) + + dayData.accounts[accountId] = { + name: accountInfo ? accountInfo.name : `账号 ${accountId.slice(0, 8)}`, + cost, + formattedCost, + requests + } + + accountCostTotals.set(accountId, (accountCostTotals.get(accountId) || 0) + cost) + } + + trendData.push(dayData) + } + } + + if (granularity === 'hour') { + trendData.sort((a, b) => new Date(a.hour) - new Date(b.hour)) + } else { + trendData.sort((a, b) => new Date(a.date) - new Date(b.date)) + } + + const topAccounts = Array.from(accountCostTotals.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 20) + .map(([accountId]) => accountId) + + return res.json({ + success: true, + data: trendData, + granularity, + group, + groupLabel: groupLabels[group], + topAccounts, + totalAccounts: accountCostTotals.size + }) + } catch (error) { + logger.error('❌ Failed to get account usage trend:', error) + return res + .status(500) + .json({ error: 'Failed to get account usage trend', message: error.message }) + } +}) + +// 获取按API Key分组的使用趋势 +router.get('/api-keys-usage-trend', authenticateAdmin, async (req, res) => { + try { + const { granularity = 'day', days = 7, startDate, endDate } = req.query + + logger.info(`📊 Getting API keys usage trend, granularity: ${granularity}, days: ${days}`) + + const client = redis.getClientSafe() + const trendData = [] + + // 获取所有API Keys + const apiKeys = await apiKeyService.getAllApiKeys() + const apiKeyMap = new Map(apiKeys.map((key) => [key.id, key])) + + if (granularity === 'hour') { + // 小时粒度统计 + let endTime, startTime + + if (startDate && endDate) { + // 自定义时间范围 + startTime = new Date(startDate) + endTime = new Date(endDate) + } else { + // 默认近24小时 + endTime = new Date() + startTime = new Date(endTime.getTime() - 24 * 60 * 60 * 1000) + } + + // 按小时遍历 + const currentHour = new Date(startTime) + currentHour.setMinutes(0, 0, 0) + + while (currentHour <= endTime) { + // 使用时区转换后的时间来生成键 + const tzCurrentHour = redis.getDateInTimezone(currentHour) + const dateStr = redis.getDateStringInTimezone(currentHour) + const hour = String(tzCurrentHour.getUTCHours()).padStart(2, '0') + const hourKey = `${dateStr}:${hour}` + + // 获取这个小时所有API Key的数据 + const pattern = `usage:hourly:*:${hourKey}` + const keys = await client.keys(pattern) + + // 格式化时间标签 + const tzDateForLabel = redis.getDateInTimezone(currentHour) + const monthLabel = String(tzDateForLabel.getUTCMonth() + 1).padStart(2, '0') + const dayLabel = String(tzDateForLabel.getUTCDate()).padStart(2, '0') + const hourLabel = String(tzDateForLabel.getUTCHours()).padStart(2, '0') + + const hourData = { + hour: currentHour.toISOString(), // 使用原始时间,不进行时区转换 + label: `${monthLabel}/${dayLabel} ${hourLabel}:00`, // 添加格式化的标签 + apiKeys: {} + } + + // 先收集基础数据 + const apiKeyDataMap = new Map() + for (const key of keys) { + const match = key.match(/usage:hourly:(.+?):\d{4}-\d{2}-\d{2}:\d{2}/) + if (!match) { + continue + } + + const apiKeyId = match[1] + const data = await client.hgetall(key) + + if (data && apiKeyMap.has(apiKeyId)) { + const inputTokens = parseInt(data.inputTokens) || 0 + const outputTokens = parseInt(data.outputTokens) || 0 + const cacheCreateTokens = parseInt(data.cacheCreateTokens) || 0 + const cacheReadTokens = parseInt(data.cacheReadTokens) || 0 + const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + + apiKeyDataMap.set(apiKeyId, { + name: apiKeyMap.get(apiKeyId).name, + tokens: totalTokens, + requests: parseInt(data.requests) || 0, + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens + }) + } + } + + // 获取该小时的模型级别数据来计算准确费用 + const modelPattern = `usage:*:model:hourly:*:${hourKey}` + const modelKeys = await client.keys(modelPattern) + const apiKeyCostMap = new Map() + + for (const modelKey of modelKeys) { + const match = modelKey.match(/usage:(.+?):model:hourly:(.+?):\d{4}-\d{2}-\d{2}:\d{2}/) + if (!match) { + continue + } + + const apiKeyId = match[1] + const model = match[2] + const modelData = await client.hgetall(modelKey) + + if (modelData && apiKeyDataMap.has(apiKeyId)) { + const usage = { + input_tokens: parseInt(modelData.inputTokens) || 0, + output_tokens: parseInt(modelData.outputTokens) || 0, + cache_creation_input_tokens: parseInt(modelData.cacheCreateTokens) || 0, + cache_read_input_tokens: parseInt(modelData.cacheReadTokens) || 0 + } + + const costResult = CostCalculator.calculateCost(usage, model) + const currentCost = apiKeyCostMap.get(apiKeyId) || 0 + apiKeyCostMap.set(apiKeyId, currentCost + costResult.costs.total) + } + } + + // 组合数据 + for (const [apiKeyId, data] of apiKeyDataMap) { + const cost = apiKeyCostMap.get(apiKeyId) || 0 + + // 如果没有模型级别数据,使用默认模型计算(降级方案) + let finalCost = cost + let formattedCost = CostCalculator.formatCost(cost) + + if (cost === 0 && data.tokens > 0) { + const usage = { + input_tokens: data.inputTokens, + output_tokens: data.outputTokens, + cache_creation_input_tokens: data.cacheCreateTokens, + cache_read_input_tokens: data.cacheReadTokens + } + const fallbackResult = CostCalculator.calculateCost(usage, 'claude-3-5-sonnet-20241022') + finalCost = fallbackResult.costs.total + formattedCost = fallbackResult.formatted.total + } + + hourData.apiKeys[apiKeyId] = { + name: data.name, + tokens: data.tokens, + requests: data.requests, + cost: finalCost, + formattedCost + } + } + + trendData.push(hourData) + currentHour.setHours(currentHour.getHours() + 1) + } + } else { + // 天粒度统计 + const daysCount = parseInt(days) || 7 + const today = new Date() + + // 获取过去N天的数据 + for (let i = 0; i < daysCount; i++) { + const date = new Date(today) + date.setDate(date.getDate() - i) + const dateStr = redis.getDateStringInTimezone(date) + + // 获取这一天所有API Key的数据 + const pattern = `usage:daily:*:${dateStr}` + const keys = await client.keys(pattern) + + const dayData = { + date: dateStr, + apiKeys: {} + } + + // 先收集基础数据 + const apiKeyDataMap = new Map() + for (const key of keys) { + const match = key.match(/usage:daily:(.+?):\d{4}-\d{2}-\d{2}/) + if (!match) { + continue + } + + const apiKeyId = match[1] + const data = await client.hgetall(key) + + if (data && apiKeyMap.has(apiKeyId)) { + const inputTokens = parseInt(data.inputTokens) || 0 + const outputTokens = parseInt(data.outputTokens) || 0 + const cacheCreateTokens = parseInt(data.cacheCreateTokens) || 0 + const cacheReadTokens = parseInt(data.cacheReadTokens) || 0 + const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + + apiKeyDataMap.set(apiKeyId, { + name: apiKeyMap.get(apiKeyId).name, + tokens: totalTokens, + requests: parseInt(data.requests) || 0, + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens + }) + } + } + + // 获取该天的模型级别数据来计算准确费用 + const modelPattern = `usage:*:model:daily:*:${dateStr}` + const modelKeys = await client.keys(modelPattern) + const apiKeyCostMap = new Map() + + for (const modelKey of modelKeys) { + const match = modelKey.match(/usage:(.+?):model:daily:(.+?):\d{4}-\d{2}-\d{2}/) + if (!match) { + continue + } + + const apiKeyId = match[1] + const model = match[2] + const modelData = await client.hgetall(modelKey) + + if (modelData && apiKeyDataMap.has(apiKeyId)) { + const usage = { + input_tokens: parseInt(modelData.inputTokens) || 0, + output_tokens: parseInt(modelData.outputTokens) || 0, + cache_creation_input_tokens: parseInt(modelData.cacheCreateTokens) || 0, + cache_read_input_tokens: parseInt(modelData.cacheReadTokens) || 0 + } + + const costResult = CostCalculator.calculateCost(usage, model) + const currentCost = apiKeyCostMap.get(apiKeyId) || 0 + apiKeyCostMap.set(apiKeyId, currentCost + costResult.costs.total) + } + } + + // 组合数据 + for (const [apiKeyId, data] of apiKeyDataMap) { + const cost = apiKeyCostMap.get(apiKeyId) || 0 + + // 如果没有模型级别数据,使用默认模型计算(降级方案) + let finalCost = cost + let formattedCost = CostCalculator.formatCost(cost) + + if (cost === 0 && data.tokens > 0) { + const usage = { + input_tokens: data.inputTokens, + output_tokens: data.outputTokens, + cache_creation_input_tokens: data.cacheCreateTokens, + cache_read_input_tokens: data.cacheReadTokens + } + const fallbackResult = CostCalculator.calculateCost(usage, 'claude-3-5-sonnet-20241022') + finalCost = fallbackResult.costs.total + formattedCost = fallbackResult.formatted.total + } + + dayData.apiKeys[apiKeyId] = { + name: data.name, + tokens: data.tokens, + requests: data.requests, + cost: finalCost, + formattedCost + } + } + + trendData.push(dayData) + } + } + + // 按时间正序排列 + if (granularity === 'hour') { + trendData.sort((a, b) => new Date(a.hour) - new Date(b.hour)) + } else { + trendData.sort((a, b) => new Date(a.date) - new Date(b.date)) + } + + // 计算每个API Key的总token数,用于排序 + const apiKeyTotals = new Map() + for (const point of trendData) { + for (const [apiKeyId, data] of Object.entries(point.apiKeys)) { + apiKeyTotals.set(apiKeyId, (apiKeyTotals.get(apiKeyId) || 0) + data.tokens) + } + } + + // 获取前10个使用量最多的API Key + const topApiKeys = Array.from(apiKeyTotals.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([apiKeyId]) => apiKeyId) + + return res.json({ + success: true, + data: trendData, + granularity, + topApiKeys, + totalApiKeys: apiKeyTotals.size + }) + } catch (error) { + logger.error('❌ Failed to get API keys usage trend:', error) + return res + .status(500) + .json({ error: 'Failed to get API keys usage trend', message: error.message }) + } +}) + +// 计算总体使用费用 +router.get('/usage-costs', authenticateAdmin, async (req, res) => { + try { + const { period = 'all' } = req.query // all, today, monthly, 7days + + logger.info(`💰 Calculating usage costs for period: ${period}`) + + // 模型名标准化函数(与redis.js保持一致) + const normalizeModelName = (model) => { + if (!model || model === 'unknown') { + return model + } + + // 对于Bedrock模型,去掉区域前缀进行统一 + if (model.includes('.anthropic.') || model.includes('.claude')) { + // 匹配所有AWS区域格式:region.anthropic.model-name-v1:0 -> claude-model-name + // 支持所有AWS区域格式,如:us-east-1, eu-west-1, ap-southeast-1, ca-central-1等 + let normalized = model.replace(/^[a-z0-9-]+\./, '') // 去掉任何区域前缀(更通用) + normalized = normalized.replace('anthropic.', '') // 去掉anthropic前缀 + normalized = normalized.replace(/-v\d+:\d+$/, '') // 去掉版本后缀(如-v1:0, -v2:1等) + return normalized + } + + // 对于其他模型,去掉常见的版本后缀 + return model.replace(/-v\d+:\d+$|:latest$/, '') + } + + // 获取所有API Keys的使用统计 + const apiKeys = await apiKeyService.getAllApiKeys() + + const totalCosts = { + inputCost: 0, + outputCost: 0, + cacheCreateCost: 0, + cacheReadCost: 0, + totalCost: 0 + } + + const modelCosts = {} + + // 按模型统计费用 + const client = redis.getClientSafe() + const today = redis.getDateStringInTimezone() + const tzDate = redis.getDateInTimezone() + const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( + 2, + '0' + )}` + + let pattern + if (period === 'today') { + pattern = `usage:model:daily:*:${today}` + } else if (period === 'monthly') { + pattern = `usage:model:monthly:*:${currentMonth}` + } else if (period === '7days') { + // 最近7天:汇总daily数据 + const modelUsageMap = new Map() + + // 获取最近7天的所有daily统计数据 + for (let i = 0; i < 7; i++) { + const date = new Date() + date.setDate(date.getDate() - i) + const currentTzDate = redis.getDateInTimezone(date) + const dateStr = `${currentTzDate.getUTCFullYear()}-${String( + currentTzDate.getUTCMonth() + 1 + ).padStart(2, '0')}-${String(currentTzDate.getUTCDate()).padStart(2, '0')}` + const dayPattern = `usage:model:daily:*:${dateStr}` + + const dayKeys = await client.keys(dayPattern) + + for (const key of dayKeys) { + const modelMatch = key.match(/usage:model:daily:(.+):\d{4}-\d{2}-\d{2}$/) + if (!modelMatch) { + continue + } + + const rawModel = modelMatch[1] + const normalizedModel = normalizeModelName(rawModel) + const data = await client.hgetall(key) + + if (data && Object.keys(data).length > 0) { + if (!modelUsageMap.has(normalizedModel)) { + modelUsageMap.set(normalizedModel, { + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0 + }) + } + + const modelUsage = modelUsageMap.get(normalizedModel) + modelUsage.inputTokens += parseInt(data.inputTokens) || 0 + modelUsage.outputTokens += parseInt(data.outputTokens) || 0 + modelUsage.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0 + modelUsage.cacheReadTokens += parseInt(data.cacheReadTokens) || 0 + } + } + } + + // 计算7天统计的费用 + logger.info(`💰 Processing ${modelUsageMap.size} unique models for 7days cost calculation`) + + for (const [model, usage] of modelUsageMap) { + const usageData = { + input_tokens: usage.inputTokens, + output_tokens: usage.outputTokens, + cache_creation_input_tokens: usage.cacheCreateTokens, + cache_read_input_tokens: usage.cacheReadTokens + } + + const costResult = CostCalculator.calculateCost(usageData, model) + totalCosts.inputCost += costResult.costs.input + totalCosts.outputCost += costResult.costs.output + totalCosts.cacheCreateCost += costResult.costs.cacheWrite + totalCosts.cacheReadCost += costResult.costs.cacheRead + totalCosts.totalCost += costResult.costs.total + + logger.info( + `💰 Model ${model} (7days): ${ + usage.inputTokens + usage.outputTokens + usage.cacheCreateTokens + usage.cacheReadTokens + } tokens, cost: ${costResult.formatted.total}` + ) + + // 记录模型费用 + modelCosts[model] = { + model, + requests: 0, // 7天汇总数据没有请求数统计 + usage: usageData, + costs: costResult.costs, + formatted: costResult.formatted, + usingDynamicPricing: costResult.usingDynamicPricing + } + } + + // 返回7天统计结果 + return res.json({ + success: true, + data: { + period, + totalCosts: { + ...totalCosts, + formatted: { + inputCost: CostCalculator.formatCost(totalCosts.inputCost), + outputCost: CostCalculator.formatCost(totalCosts.outputCost), + cacheCreateCost: CostCalculator.formatCost(totalCosts.cacheCreateCost), + cacheReadCost: CostCalculator.formatCost(totalCosts.cacheReadCost), + totalCost: CostCalculator.formatCost(totalCosts.totalCost) + } + }, + modelCosts: Object.values(modelCosts) + } + }) + } else { + // 全部时间,先尝试从Redis获取所有历史模型统计数据(只使用monthly数据避免重复计算) + const allModelKeys = await client.keys('usage:model:monthly:*:*') + logger.info(`💰 Total period calculation: found ${allModelKeys.length} monthly model keys`) + + if (allModelKeys.length > 0) { + // 如果有详细的模型统计数据,使用模型级别的计算 + const modelUsageMap = new Map() + + for (const key of allModelKeys) { + // 解析模型名称(只处理monthly数据) + const modelMatch = key.match(/usage:model:monthly:(.+):(\d{4}-\d{2})$/) + if (!modelMatch) { + continue + } + + const model = modelMatch[1] + const data = await client.hgetall(key) + + if (data && Object.keys(data).length > 0) { + if (!modelUsageMap.has(model)) { + modelUsageMap.set(model, { + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0 + }) + } + + const modelUsage = modelUsageMap.get(model) + modelUsage.inputTokens += parseInt(data.inputTokens) || 0 + modelUsage.outputTokens += parseInt(data.outputTokens) || 0 + modelUsage.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0 + modelUsage.cacheReadTokens += parseInt(data.cacheReadTokens) || 0 + } + } + + // 使用模型级别的数据计算费用 + logger.info(`💰 Processing ${modelUsageMap.size} unique models for total cost calculation`) + + for (const [model, usage] of modelUsageMap) { + const usageData = { + input_tokens: usage.inputTokens, + output_tokens: usage.outputTokens, + cache_creation_input_tokens: usage.cacheCreateTokens, + cache_read_input_tokens: usage.cacheReadTokens + } + + const costResult = CostCalculator.calculateCost(usageData, model) + totalCosts.inputCost += costResult.costs.input + totalCosts.outputCost += costResult.costs.output + totalCosts.cacheCreateCost += costResult.costs.cacheWrite + totalCosts.cacheReadCost += costResult.costs.cacheRead + totalCosts.totalCost += costResult.costs.total + + logger.info( + `💰 Model ${model}: ${ + usage.inputTokens + + usage.outputTokens + + usage.cacheCreateTokens + + usage.cacheReadTokens + } tokens, cost: ${costResult.formatted.total}` + ) + + // 记录模型费用 + modelCosts[model] = { + model, + requests: 0, // 历史汇总数据没有请求数 + usage: usageData, + costs: costResult.costs, + formatted: costResult.formatted, + usingDynamicPricing: costResult.usingDynamicPricing + } + } + } else { + // 如果没有详细的模型统计数据,回退到API Key汇总数据 + logger.warn('No detailed model statistics found, falling back to API Key aggregated data') + + for (const apiKey of apiKeys) { + if (apiKey.usage && apiKey.usage.total) { + const usage = { + input_tokens: apiKey.usage.total.inputTokens || 0, + output_tokens: apiKey.usage.total.outputTokens || 0, + cache_creation_input_tokens: apiKey.usage.total.cacheCreateTokens || 0, + cache_read_input_tokens: apiKey.usage.total.cacheReadTokens || 0 + } + + // 使用加权平均价格计算(基于当前活跃模型的价格分布) + const costResult = CostCalculator.calculateCost(usage, 'claude-3-5-haiku-20241022') + totalCosts.inputCost += costResult.costs.input + totalCosts.outputCost += costResult.costs.output + totalCosts.cacheCreateCost += costResult.costs.cacheWrite + totalCosts.cacheReadCost += costResult.costs.cacheRead + totalCosts.totalCost += costResult.costs.total + } + } + } + + return res.json({ + success: true, + data: { + period, + totalCosts: { + ...totalCosts, + formatted: { + inputCost: CostCalculator.formatCost(totalCosts.inputCost), + outputCost: CostCalculator.formatCost(totalCosts.outputCost), + cacheCreateCost: CostCalculator.formatCost(totalCosts.cacheCreateCost), + cacheReadCost: CostCalculator.formatCost(totalCosts.cacheReadCost), + totalCost: CostCalculator.formatCost(totalCosts.totalCost) + } + }, + modelCosts: Object.values(modelCosts).sort((a, b) => b.costs.total - a.costs.total), + pricingServiceStatus: pricingService.getStatus() + } + }) + } + + // 对于今日或本月,从Redis获取详细的模型统计 + const keys = await client.keys(pattern) + + for (const key of keys) { + const match = key.match( + period === 'today' + ? /usage:model:daily:(.+):\d{4}-\d{2}-\d{2}$/ + : /usage:model:monthly:(.+):\d{4}-\d{2}$/ + ) + + if (!match) { + continue + } + + const model = match[1] + const data = await client.hgetall(key) + + if (data && Object.keys(data).length > 0) { + 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) + + // 累加总费用 + totalCosts.inputCost += costResult.costs.input + totalCosts.outputCost += costResult.costs.output + totalCosts.cacheCreateCost += costResult.costs.cacheWrite + totalCosts.cacheReadCost += costResult.costs.cacheRead + totalCosts.totalCost += costResult.costs.total + + // 记录模型费用 + modelCosts[model] = { + model, + requests: parseInt(data.requests) || 0, + usage, + costs: costResult.costs, + formatted: costResult.formatted, + usingDynamicPricing: costResult.usingDynamicPricing + } + } + } + + return res.json({ + success: true, + data: { + period, + totalCosts: { + ...totalCosts, + formatted: { + inputCost: CostCalculator.formatCost(totalCosts.inputCost), + outputCost: CostCalculator.formatCost(totalCosts.outputCost), + cacheCreateCost: CostCalculator.formatCost(totalCosts.cacheCreateCost), + cacheReadCost: CostCalculator.formatCost(totalCosts.cacheReadCost), + totalCost: CostCalculator.formatCost(totalCosts.totalCost) + } + }, + modelCosts: Object.values(modelCosts).sort((a, b) => b.costs.total - a.costs.total), + pricingServiceStatus: pricingService.getStatus() + } + }) + } catch (error) { + logger.error('❌ Failed to calculate usage costs:', error) + return res + .status(500) + .json({ error: 'Failed to calculate usage costs', message: error.message }) + } +}) + +// 📋 获取所有账号的 Claude Code headers 信息 +router.get('/claude-code-headers', authenticateAdmin, async (req, res) => { + try { + const allHeaders = await claudeCodeHeadersService.getAllAccountHeaders() + + // 获取所有 Claude 账号信息 + const accounts = await claudeAccountService.getAllAccounts() + const accountMap = {} + accounts.forEach((account) => { + accountMap[account.id] = account.name + }) + + // 格式化输出 + const formattedData = Object.entries(allHeaders).map(([accountId, data]) => ({ + accountId, + accountName: accountMap[accountId] || 'Unknown', + version: data.version, + userAgent: data.headers['user-agent'], + updatedAt: data.updatedAt, + headers: data.headers + })) + + return res.json({ + success: true, + data: formattedData + }) + } catch (error) { + logger.error('❌ Failed to get Claude Code headers:', error) + return res + .status(500) + .json({ error: 'Failed to get Claude Code headers', message: error.message }) + } +}) + +// 🗑️ 清除指定账号的 Claude Code headers +router.delete('/claude-code-headers/:accountId', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + await claudeCodeHeadersService.clearAccountHeaders(accountId) + + return res.json({ + success: true, + message: `Claude Code headers cleared for account ${accountId}` + }) + } catch (error) { + logger.error('❌ Failed to clear Claude Code headers:', error) + return res + .status(500) + .json({ error: 'Failed to clear Claude Code headers', message: error.message }) + } +}) + +// 🔄 版本检查 +router.get('/check-updates', authenticateAdmin, async (req, res) => { + // 读取当前版本 + const versionPath = path.join(__dirname, '../../VERSION') + let currentVersion = '1.0.0' + try { + currentVersion = fs.readFileSync(versionPath, 'utf8').trim() + } catch (err) { + logger.warn('⚠️ Could not read VERSION file:', err.message) + } + + try { + // 从缓存获取 + const cacheKey = 'version_check_cache' + const cached = await redis.getClient().get(cacheKey) + + if (cached && !req.query.force) { + const cachedData = JSON.parse(cached) + const cacheAge = Date.now() - cachedData.timestamp + + // 缓存有效期1小时 + if (cacheAge < 3600000) { + // 实时计算 hasUpdate,不使用缓存的值 + const hasUpdate = compareVersions(currentVersion, cachedData.latest) < 0 + + return res.json({ + success: true, + data: { + current: currentVersion, + latest: cachedData.latest, + hasUpdate, // 实时计算,不用缓存 + releaseInfo: cachedData.releaseInfo, + cached: true + } + }) + } + } + + // 请求 GitHub API + const githubRepo = 'wei-shaw/claude-relay-service' + const response = await axios.get(`https://api.github.com/repos/${githubRepo}/releases/latest`, { + headers: { + Accept: 'application/vnd.github.v3+json', + 'User-Agent': 'Claude-Relay-Service' + }, + timeout: 10000 + }) + + const release = response.data + const latestVersion = release.tag_name.replace(/^v/, '') + + // 比较版本 + const hasUpdate = compareVersions(currentVersion, latestVersion) < 0 + + const releaseInfo = { + name: release.name, + body: release.body, + publishedAt: release.published_at, + htmlUrl: release.html_url + } + + // 缓存结果(不缓存 hasUpdate,因为它应该实时计算) + await redis.getClient().set( + cacheKey, + JSON.stringify({ + latest: latestVersion, + releaseInfo, + timestamp: Date.now() + }), + 'EX', + 3600 + ) // 1小时过期 + + return res.json({ + success: true, + data: { + current: currentVersion, + latest: latestVersion, + hasUpdate, + releaseInfo, + cached: false + } + }) + } catch (error) { + // 改进错误日志记录 + const errorDetails = { + message: error.message || 'Unknown error', + code: error.code, + response: error.response + ? { + status: error.response.status, + statusText: error.response.statusText, + data: error.response.data + } + : null, + request: error.request ? 'Request was made but no response received' : null + } + + logger.error('❌ Failed to check for updates:', errorDetails.message) + + // 处理 404 错误 - 仓库或版本不存在 + if (error.response && error.response.status === 404) { + return res.json({ + success: true, + data: { + current: currentVersion, + latest: currentVersion, + hasUpdate: false, + releaseInfo: { + name: 'No releases found', + body: 'The GitHub repository has no releases yet.', + publishedAt: new Date().toISOString(), + htmlUrl: '#' + }, + warning: 'GitHub repository has no releases' + } + }) + } + + // 如果是网络错误,尝试返回缓存的数据 + if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT' || error.code === 'ENOTFOUND') { + const cacheKey = 'version_check_cache' + const cached = await redis.getClient().get(cacheKey) + + if (cached) { + const cachedData = JSON.parse(cached) + // 实时计算 hasUpdate + const hasUpdate = compareVersions(currentVersion, cachedData.latest) < 0 + + return res.json({ + success: true, + data: { + current: currentVersion, + latest: cachedData.latest, + hasUpdate, // 实时计算 + releaseInfo: cachedData.releaseInfo, + cached: true, + warning: 'Using cached data due to network error' + } + }) + } + } + + // 其他错误返回当前版本信息 + return res.json({ + success: true, + data: { + current: currentVersion, + latest: currentVersion, + hasUpdate: false, + releaseInfo: { + name: 'Update check failed', + body: `Unable to check for updates: ${error.message || 'Unknown error'}`, + publishedAt: new Date().toISOString(), + htmlUrl: '#' + }, + error: true, + warning: error.message || 'Failed to check for updates' + } + }) + } +}) + +// 版本比较函数 +function compareVersions(current, latest) { + const parseVersion = (v) => { + const parts = v.split('.').map(Number) + return { + major: parts[0] || 0, + minor: parts[1] || 0, + patch: parts[2] || 0 + } + } + + const currentV = parseVersion(current) + const latestV = parseVersion(latest) + + if (currentV.major !== latestV.major) { + return currentV.major - latestV.major + } + if (currentV.minor !== latestV.minor) { + return currentV.minor - latestV.minor + } + return currentV.patch - latestV.patch +} + +// 🎨 OEM设置管理 + +// 获取OEM设置(公开接口,用于显示) +router.get('/oem-settings', async (req, res) => { + try { + const client = redis.getClient() + const oemSettings = await client.get('oem:settings') + + // 默认设置 + const defaultSettings = { + siteName: 'Claude Relay Service', + siteIcon: '', + siteIconData: '', // Base64编码的图标数据 + showAdminButton: true, // 是否显示管理后台按钮 + updatedAt: new Date().toISOString() + } + + let settings = defaultSettings + if (oemSettings) { + try { + settings = { ...defaultSettings, ...JSON.parse(oemSettings) } + } catch (err) { + logger.warn('⚠️ Failed to parse OEM settings, using defaults:', err.message) + } + } + + // 添加 LDAP 启用状态到响应中 + return res.json({ + success: true, + data: { + ...settings, + ldapEnabled: config.ldap && config.ldap.enabled === true + } + }) + } catch (error) { + logger.error('❌ Failed to get OEM settings:', error) + return res.status(500).json({ error: 'Failed to get OEM settings', message: error.message }) + } +}) + +// 更新OEM设置 +router.put('/oem-settings', authenticateAdmin, async (req, res) => { + try { + const { siteName, siteIcon, siteIconData, showAdminButton } = req.body + + // 验证输入 + if (!siteName || typeof siteName !== 'string' || siteName.trim().length === 0) { + return res.status(400).json({ error: 'Site name is required' }) + } + + if (siteName.length > 100) { + return res.status(400).json({ error: 'Site name must be less than 100 characters' }) + } + + // 验证图标数据大小(如果是base64) + if (siteIconData && siteIconData.length > 500000) { + // 约375KB + return res.status(400).json({ error: 'Icon file must be less than 350KB' }) + } + + // 验证图标URL(如果提供) + if (siteIcon && !siteIconData) { + // 简单验证URL格式 + try { + new URL(siteIcon) + } catch (err) { + return res.status(400).json({ error: 'Invalid icon URL format' }) + } + } + + const settings = { + siteName: siteName.trim(), + siteIcon: (siteIcon || '').trim(), + siteIconData: (siteIconData || '').trim(), // Base64数据 + showAdminButton: showAdminButton !== false, // 默认为true + updatedAt: new Date().toISOString() + } + + const client = redis.getClient() + await client.set('oem:settings', JSON.stringify(settings)) + + logger.info(`✅ OEM settings updated: ${siteName}`) + + return res.json({ + success: true, + message: 'OEM settings updated successfully', + data: settings + }) + } catch (error) { + logger.error('❌ Failed to update OEM settings:', error) + return res.status(500).json({ error: 'Failed to update OEM settings', message: error.message }) + } +}) + +// 🤖 OpenAI 账户管理 + +// OpenAI OAuth 配置 +const OPENAI_CONFIG = { + BASE_URL: 'https://auth.openai.com', + CLIENT_ID: 'app_EMoamEEZ73f0CkXaXp7hrann', + REDIRECT_URI: 'http://localhost:1455/auth/callback', + SCOPE: 'openid profile email offline_access' +} + +// 生成 PKCE 参数 +function generateOpenAIPKCE() { + const codeVerifier = crypto.randomBytes(64).toString('hex') + const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url') + + return { + codeVerifier, + codeChallenge + } +} + +// 生成 OpenAI OAuth 授权 URL +router.post('/openai-accounts/generate-auth-url', authenticateAdmin, async (req, res) => { + try { + const { proxy } = req.body + + // 生成 PKCE 参数 + const pkce = generateOpenAIPKCE() + + // 生成随机 state + const state = crypto.randomBytes(32).toString('hex') + + // 创建会话 ID + const sessionId = crypto.randomUUID() + + // 将 PKCE 参数和代理配置存储到 Redis + await redis.setOAuthSession(sessionId, { + codeVerifier: pkce.codeVerifier, + codeChallenge: pkce.codeChallenge, + state, + proxy: proxy || null, + platform: 'openai', + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString() + }) + + // 构建授权 URL 参数 + const params = new URLSearchParams({ + response_type: 'code', + client_id: OPENAI_CONFIG.CLIENT_ID, + redirect_uri: OPENAI_CONFIG.REDIRECT_URI, + scope: OPENAI_CONFIG.SCOPE, + code_challenge: pkce.codeChallenge, + code_challenge_method: 'S256', + state, + id_token_add_organizations: 'true', + codex_cli_simplified_flow: 'true' + }) + + const authUrl = `${OPENAI_CONFIG.BASE_URL}/oauth/authorize?${params.toString()}` + + logger.success('🔗 Generated OpenAI OAuth authorization URL') + + return res.json({ + success: true, + data: { + authUrl, + sessionId, + instructions: [ + '1. 复制上面的链接到浏览器中打开', + '2. 登录您的 OpenAI 账户', + '3. 同意应用权限', + '4. 复制浏览器地址栏中的完整 URL(包含 code 参数)', + '5. 在添加账户表单中粘贴完整的回调 URL' + ] + } + }) + } catch (error) { + logger.error('生成 OpenAI OAuth URL 失败:', error) + return res.status(500).json({ + success: false, + message: '生成授权链接失败', + error: error.message + }) + } +}) + +// 交换 OpenAI 授权码 +router.post('/openai-accounts/exchange-code', authenticateAdmin, async (req, res) => { + try { + const { code, sessionId } = req.body + + if (!code || !sessionId) { + return res.status(400).json({ + success: false, + message: '缺少必要参数' + }) + } + + // 从 Redis 获取会话数据 + const sessionData = await redis.getOAuthSession(sessionId) + if (!sessionData) { + return res.status(400).json({ + success: false, + message: '会话已过期或无效' + }) + } + + // 准备 token 交换请求 + const tokenData = { + grant_type: 'authorization_code', + code: code.trim(), + redirect_uri: OPENAI_CONFIG.REDIRECT_URI, + client_id: OPENAI_CONFIG.CLIENT_ID, + code_verifier: sessionData.codeVerifier + } + + logger.info('Exchanging OpenAI authorization code:', { + sessionId, + codeLength: code.length, + hasCodeVerifier: !!sessionData.codeVerifier + }) + + // 配置代理(如果有) + const axiosConfig = { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + } + + // 配置代理(如果有) + const proxyAgent = ProxyHelper.createProxyAgent(sessionData.proxy) + if (proxyAgent) { + axiosConfig.httpsAgent = proxyAgent + axiosConfig.proxy = false + } + + // 交换 authorization code 获取 tokens + const tokenResponse = await axios.post( + `${OPENAI_CONFIG.BASE_URL}/oauth/token`, + new URLSearchParams(tokenData).toString(), + axiosConfig + ) + + const { id_token, access_token, refresh_token, expires_in } = tokenResponse.data + + // 解析 ID token 获取用户信息 + const idTokenParts = id_token.split('.') + if (idTokenParts.length !== 3) { + throw new Error('Invalid ID token format') + } + + // 解码 JWT payload + const payload = JSON.parse(Buffer.from(idTokenParts[1], 'base64url').toString()) + + // 获取 OpenAI 特定的声明 + const authClaims = payload['https://api.openai.com/auth'] || {} + const accountId = authClaims.chatgpt_account_id || '' + const chatgptUserId = authClaims.chatgpt_user_id || authClaims.user_id || '' + const planType = authClaims.chatgpt_plan_type || '' + + // 获取组织信息 + const organizations = authClaims.organizations || [] + const defaultOrg = organizations.find((org) => org.is_default) || organizations[0] || {} + const organizationId = defaultOrg.id || '' + const organizationRole = defaultOrg.role || '' + const organizationTitle = defaultOrg.title || '' + + // 清理 Redis 会话 + await redis.deleteOAuthSession(sessionId) + + logger.success('✅ OpenAI OAuth token exchange successful') + + return res.json({ + success: true, + data: { + tokens: { + idToken: id_token, + accessToken: access_token, + refreshToken: refresh_token, + expires_in + }, + accountInfo: { + accountId, + chatgptUserId, + organizationId, + organizationRole, + organizationTitle, + planType, + email: payload.email || '', + name: payload.name || '', + emailVerified: payload.email_verified || false, + organizations + } + } + }) + } catch (error) { + logger.error('OpenAI OAuth token exchange failed:', error) + return res.status(500).json({ + success: false, + message: '交换授权码失败', + error: error.message + }) + } +}) + +// 获取所有 OpenAI 账户 +router.get('/openai-accounts', authenticateAdmin, async (req, res) => { + try { + const { platform, groupId } = req.query + let accounts = await openaiAccountService.getAllAccounts() + + // 缓存账户所属分组,避免重复查询 + const accountGroupCache = new Map() + const fetchAccountGroups = async (accountId) => { + if (!accountGroupCache.has(accountId)) { + const groups = await accountGroupService.getAccountGroups(accountId) + accountGroupCache.set(accountId, groups || []) + } + return accountGroupCache.get(accountId) + } + + // 根据查询参数进行筛选 + if (platform && platform !== 'all' && platform !== 'openai') { + // 如果指定了其他平台,返回空数组 + accounts = [] + } + + // 如果指定了分组筛选 + if (groupId && groupId !== 'all') { + if (groupId === 'ungrouped') { + // 筛选未分组账户 + const filteredAccounts = [] + for (const account of accounts) { + const groups = await fetchAccountGroups(account.id) + if (!groups || groups.length === 0) { + filteredAccounts.push(account) + } + } + accounts = filteredAccounts + } else { + // 筛选特定分组的账户 + const groupMembers = await accountGroupService.getGroupMembers(groupId) + accounts = accounts.filter((account) => groupMembers.includes(account.id)) + } + } + + // 为每个账户添加使用统计信息 + const accountsWithStats = await Promise.all( + accounts.map(async (account) => { + try { + const usageStats = await redis.getAccountUsageStats(account.id, 'openai') + const groupInfos = await fetchAccountGroups(account.id) + const formattedAccount = formatSubscriptionExpiry(account) + return { + ...formattedAccount, + groupInfos, + usage: { + daily: usageStats.daily, + total: usageStats.total, + monthly: usageStats.monthly + } + } + } catch (error) { + logger.debug(`Failed to get usage stats for OpenAI account ${account.id}:`, error) + const groupInfos = await fetchAccountGroups(account.id) + const formattedAccount = formatSubscriptionExpiry(account) + return { + ...formattedAccount, + groupInfos, + usage: { + daily: { requests: 0, tokens: 0, allTokens: 0 }, + total: { requests: 0, tokens: 0, allTokens: 0 }, + monthly: { requests: 0, tokens: 0, allTokens: 0 } + } + } + } + }) + ) + + logger.info(`获取 OpenAI 账户列表: ${accountsWithStats.length} 个账户`) + + const formattedAccounts = accountsWithStats.map(formatSubscriptionExpiry) + + return res.json({ + success: true, + data: formattedAccounts + }) + } catch (error) { + logger.error('获取 OpenAI 账户列表失败:', error) + return res.status(500).json({ + success: false, + message: '获取账户列表失败', + error: error.message + }) + } +}) + +// 创建 OpenAI 账户 +router.post('/openai-accounts', authenticateAdmin, async (req, res) => { + try { + const { + name, + description, + openaiOauth, + accountInfo, + proxy, + accountType, + groupId, + rateLimitDuration, + priority, + needsImmediateRefresh, // 是否需要立即刷新 + requireRefreshSuccess, // 是否必须刷新成功才能创建 + subscriptionExpiresAt + } = req.body + + if (!name) { + return res.status(400).json({ + success: false, + message: '账户名称不能为空' + }) + } + + // 准备账户数据 + const accountData = { + name, + description: description || '', + accountType: accountType || 'shared', + priority: priority || 50, + rateLimitDuration: + rateLimitDuration !== undefined && rateLimitDuration !== null ? rateLimitDuration : 60, + openaiOauth: openaiOauth || {}, + accountInfo: accountInfo || {}, + proxy: proxy || null, + isActive: true, + schedulable: true, + subscriptionExpiresAt: subscriptionExpiresAt || null + } + + // 如果需要立即刷新且必须成功(OpenAI 手动模式) + if (needsImmediateRefresh && requireRefreshSuccess) { + // 先创建临时账户以测试刷新 + const tempAccount = await openaiAccountService.createAccount(accountData) + + try { + logger.info(`🔄 测试刷新 OpenAI 账户以获取完整 token 信息`) + + // 尝试刷新 token(会自动使用账户配置的代理) + await openaiAccountService.refreshAccountToken(tempAccount.id) + + // 刷新成功,获取更新后的账户信息 + const refreshedAccount = await openaiAccountService.getAccount(tempAccount.id) + + // 检查是否获取到了 ID Token + if (!refreshedAccount.idToken || refreshedAccount.idToken === '') { + // 没有获取到 ID Token,删除账户 + await openaiAccountService.deleteAccount(tempAccount.id) + throw new Error('无法获取 ID Token,请检查 Refresh Token 是否有效') + } + + // 如果是分组类型,添加到分组 + if (accountType === 'group' && groupId) { + await accountGroupService.addAccountToGroup(tempAccount.id, groupId, 'openai') + } + + // 清除敏感信息后返回 + delete refreshedAccount.idToken + delete refreshedAccount.accessToken + delete refreshedAccount.refreshToken + + logger.success(`✅ 创建并验证 OpenAI 账户成功: ${name} (ID: ${tempAccount.id})`) + + const responseAccount = formatSubscriptionExpiry(refreshedAccount) + + return res.json({ + success: true, + data: responseAccount, + message: '账户创建成功,并已获取完整 token 信息' + }) + } catch (refreshError) { + // 刷新失败,删除临时创建的账户 + logger.warn(`❌ 刷新失败,删除临时账户: ${refreshError.message}`) + await openaiAccountService.deleteAccount(tempAccount.id) + + // 构建详细的错误信息 + const errorResponse = { + success: false, + message: '账户创建失败', + error: refreshError.message + } + + // 添加更详细的错误信息 + if (refreshError.status) { + errorResponse.errorCode = refreshError.status + } + if (refreshError.details) { + errorResponse.errorDetails = refreshError.details + } + if (refreshError.code) { + errorResponse.networkError = refreshError.code + } + + // 提供更友好的错误提示 + if (refreshError.message.includes('Refresh Token 无效')) { + errorResponse.suggestion = '请检查 Refresh Token 是否正确,或重新通过 OAuth 授权获取' + } else if (refreshError.message.includes('代理')) { + errorResponse.suggestion = '请检查代理配置是否正确,包括地址、端口和认证信息' + } else if (refreshError.message.includes('过于频繁')) { + errorResponse.suggestion = '请稍后再试,或更换代理 IP' + } else if (refreshError.message.includes('连接')) { + errorResponse.suggestion = '请检查网络连接和代理设置' + } + + return res.status(400).json(errorResponse) + } + } + + // 不需要强制刷新的情况(OAuth 模式或其他平台) + const createdAccount = await openaiAccountService.createAccount(accountData) + + // 如果是分组类型,添加到分组 + if (accountType === 'group' && groupId) { + await accountGroupService.addAccountToGroup(createdAccount.id, groupId, 'openai') + } + + // 如果需要刷新但不强制成功(OAuth 模式可能已有完整信息) + if (needsImmediateRefresh && !requireRefreshSuccess) { + try { + logger.info(`🔄 尝试刷新 OpenAI 账户 ${createdAccount.id}`) + await openaiAccountService.refreshAccountToken(createdAccount.id) + logger.info(`✅ 刷新成功`) + } catch (refreshError) { + logger.warn(`⚠️ 刷新失败,但账户已创建: ${refreshError.message}`) + } + } + + logger.success(`✅ 创建 OpenAI 账户成功: ${name} (ID: ${createdAccount.id})`) + + const responseAccount = formatSubscriptionExpiry(createdAccount) + + return res.json({ + success: true, + data: responseAccount + }) + } catch (error) { + logger.error('创建 OpenAI 账户失败:', error) + return res.status(500).json({ + success: false, + message: '创建账户失败', + error: error.message + }) + } +}) + +// 更新 OpenAI 账户 +router.put('/openai-accounts/:id', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + const updates = req.body + const { needsImmediateRefresh, requireRefreshSuccess } = updates + + // 验证accountType的有效性 + if (updates.accountType && !['shared', 'dedicated', 'group'].includes(updates.accountType)) { + return res + .status(400) + .json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' }) + } + + // 如果更新为分组类型,验证groupId + if (updates.accountType === 'group' && !updates.groupId) { + return res.status(400).json({ error: 'Group ID is required for group type accounts' }) + } + + // 获取账户当前信息以处理分组变更 + const currentAccount = await openaiAccountService.getAccount(id) + if (!currentAccount) { + return res.status(404).json({ error: 'Account not found' }) + } + + // 如果更新了 Refresh Token,需要验证其有效性 + if (updates.openaiOauth?.refreshToken && needsImmediateRefresh && requireRefreshSuccess) { + // 先更新 token 信息 + const tempUpdateData = {} + if (updates.openaiOauth.refreshToken) { + tempUpdateData.refreshToken = updates.openaiOauth.refreshToken + } + if (updates.openaiOauth.accessToken) { + tempUpdateData.accessToken = updates.openaiOauth.accessToken + } + // 更新代理配置(如果有) + if (updates.proxy !== undefined) { + tempUpdateData.proxy = updates.proxy + } + + // 临时更新账户以测试新的 token + await openaiAccountService.updateAccount(id, tempUpdateData) + + try { + logger.info(`🔄 验证更新的 OpenAI token (账户: ${id})`) + + // 尝试刷新 token(会使用账户配置的代理) + await openaiAccountService.refreshAccountToken(id) + + // 获取刷新后的账户信息 + const refreshedAccount = await openaiAccountService.getAccount(id) + + // 检查是否获取到了 ID Token + if (!refreshedAccount.idToken || refreshedAccount.idToken === '') { + // 恢复原始 token + await openaiAccountService.updateAccount(id, { + refreshToken: currentAccount.refreshToken, + accessToken: currentAccount.accessToken, + idToken: currentAccount.idToken + }) + + return res.status(400).json({ + success: false, + message: '无法获取 ID Token,请检查 Refresh Token 是否有效', + error: 'Invalid refresh token' + }) + } + + logger.success(`✅ Token 验证成功,继续更新账户信息`) + } catch (refreshError) { + // 刷新失败,恢复原始 token + logger.warn(`❌ Token 验证失败,恢复原始配置: ${refreshError.message}`) + await openaiAccountService.updateAccount(id, { + refreshToken: currentAccount.refreshToken, + accessToken: currentAccount.accessToken, + idToken: currentAccount.idToken, + proxy: currentAccount.proxy + }) + + // 构建详细的错误信息 + const errorResponse = { + success: false, + message: '更新失败', + error: refreshError.message + } + + // 添加更详细的错误信息 + if (refreshError.status) { + errorResponse.errorCode = refreshError.status + } + if (refreshError.details) { + errorResponse.errorDetails = refreshError.details + } + if (refreshError.code) { + errorResponse.networkError = refreshError.code + } + + // 提供更友好的错误提示 + if (refreshError.message.includes('Refresh Token 无效')) { + errorResponse.suggestion = '请检查 Refresh Token 是否正确,或重新通过 OAuth 授权获取' + } else if (refreshError.message.includes('代理')) { + errorResponse.suggestion = '请检查代理配置是否正确,包括地址、端口和认证信息' + } else if (refreshError.message.includes('过于频繁')) { + errorResponse.suggestion = '请稍后再试,或更换代理 IP' + } else if (refreshError.message.includes('连接')) { + errorResponse.suggestion = '请检查网络连接和代理设置' + } + + return res.status(400).json(errorResponse) + } + } + + // 处理分组的变更 + if (updates.accountType !== undefined) { + // 如果之前是分组类型,需要从原分组中移除 + if (currentAccount.accountType === 'group') { + const oldGroup = await accountGroupService.getAccountGroup(id) + if (oldGroup) { + await accountGroupService.removeAccountFromGroup(id, oldGroup.id) + } + } + // 如果新类型是分组,添加到新分组 + if (updates.accountType === 'group' && updates.groupId) { + await accountGroupService.addAccountToGroup(id, updates.groupId, 'openai') + } + } + + // 准备更新数据 + const updateData = { ...updates } + + // 处理敏感数据加密 + if (updates.openaiOauth) { + updateData.openaiOauth = updates.openaiOauth + // 编辑时不允许直接输入 ID Token,只能通过刷新获取 + if (updates.openaiOauth.accessToken) { + updateData.accessToken = updates.openaiOauth.accessToken + } + if (updates.openaiOauth.refreshToken) { + updateData.refreshToken = updates.openaiOauth.refreshToken + } + if (updates.openaiOauth.expires_in) { + updateData.expiresAt = new Date( + Date.now() + updates.openaiOauth.expires_in * 1000 + ).toISOString() + } + } + + // 更新账户信息 + if (updates.accountInfo) { + updateData.accountId = updates.accountInfo.accountId || currentAccount.accountId + updateData.chatgptUserId = updates.accountInfo.chatgptUserId || currentAccount.chatgptUserId + updateData.organizationId = + updates.accountInfo.organizationId || currentAccount.organizationId + updateData.organizationRole = + updates.accountInfo.organizationRole || currentAccount.organizationRole + updateData.organizationTitle = + updates.accountInfo.organizationTitle || currentAccount.organizationTitle + updateData.planType = updates.accountInfo.planType || currentAccount.planType + updateData.email = updates.accountInfo.email || currentAccount.email + updateData.emailVerified = + updates.accountInfo.emailVerified !== undefined + ? updates.accountInfo.emailVerified + : currentAccount.emailVerified + } + + const hasOauthExpiry = Boolean(updates.openaiOauth?.expires_in) + + // 处理订阅过期时间字段:优先使用 subscriptionExpiresAt,兼容旧版的 expiresAt + if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) { + updateData.subscriptionExpiresAt = updates.subscriptionExpiresAt + } else if (Object.prototype.hasOwnProperty.call(updates, 'expiresAt') && !hasOauthExpiry) { + updateData.subscriptionExpiresAt = updates.expiresAt + } + + if ( + !hasOauthExpiry && + Object.prototype.hasOwnProperty.call(updateData, 'subscriptionExpiresAt') + ) { + delete updateData.expiresAt + } + + const updatedAccount = await openaiAccountService.updateAccount(id, updateData) + + // 如果需要刷新但不强制成功(非关键更新) + if (needsImmediateRefresh && !requireRefreshSuccess) { + try { + logger.info(`🔄 尝试刷新 OpenAI 账户 ${id}`) + await openaiAccountService.refreshAccountToken(id) + logger.info(`✅ 刷新成功`) + } catch (refreshError) { + logger.warn(`⚠️ 刷新失败,但账户信息已更新: ${refreshError.message}`) + } + } + + logger.success(`📝 Admin updated OpenAI account: ${id}`) + const responseAccount = formatSubscriptionExpiry(updatedAccount) + return res.json({ success: true, data: responseAccount }) + } catch (error) { + logger.error('❌ Failed to update OpenAI account:', error) + return res.status(500).json({ error: 'Failed to update account', message: error.message }) + } +}) + +// 删除 OpenAI 账户 +router.delete('/openai-accounts/:id', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + + const account = await openaiAccountService.getAccount(id) + if (!account) { + return res.status(404).json({ + success: false, + message: '账户不存在' + }) + } + + // 自动解绑所有绑定的 API Keys + const unboundCount = await apiKeyService.unbindAccountFromAllKeys(id, 'openai') + + // 如果账户在分组中,从分组中移除 + if (account.accountType === 'group') { + const group = await accountGroupService.getAccountGroup(id) + if (group) { + await accountGroupService.removeAccountFromGroup(id, group.id) + } + } + + await openaiAccountService.deleteAccount(id) + + let message = 'OpenAI账号已成功删除' + if (unboundCount > 0) { + message += `,${unboundCount} 个 API Key 已切换为共享池模式` + } + + logger.success( + `✅ 删除 OpenAI 账户成功: ${account.name} (ID: ${id}), unbound ${unboundCount} keys` + ) + + return res.json({ + success: true, + message, + unboundKeys: unboundCount + }) + } catch (error) { + logger.error('删除 OpenAI 账户失败:', error) + return res.status(500).json({ + success: false, + message: '删除账户失败', + error: error.message + }) + } +}) + +// 切换 OpenAI 账户状态 +router.put('/openai-accounts/:id/toggle', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + + const account = await redis.getOpenAiAccount(id) + if (!account) { + return res.status(404).json({ + success: false, + message: '账户不存在' + }) + } + + // 切换启用状态 + account.enabled = !account.enabled + account.updatedAt = new Date().toISOString() + + // TODO: 更新方法 + // await redis.updateOpenAiAccount(id, account) + + logger.success( + `✅ ${account.enabled ? '启用' : '禁用'} OpenAI 账户: ${account.name} (ID: ${id})` + ) + + const responseAccount = formatSubscriptionExpiry(account) + + return res.json({ + success: true, + data: responseAccount + }) + } catch (error) { + logger.error('切换 OpenAI 账户状态失败:', error) + return res.status(500).json({ + success: false, + message: '切换账户状态失败', + error: error.message + }) + } +}) + +// 重置 OpenAI 账户状态(清除所有异常状态) +router.post('/openai-accounts/:accountId/reset-status', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + + const result = await openaiAccountService.resetAccountStatus(accountId) + + logger.success(`✅ Admin reset status for OpenAI account: ${accountId}`) + return res.json({ success: true, data: result }) + } catch (error) { + logger.error('❌ Failed to reset OpenAI account status:', error) + return res.status(500).json({ error: 'Failed to reset status', message: error.message }) + } +}) + +// 切换 OpenAI 账户调度状态 +router.put( + '/openai-accounts/:accountId/toggle-schedulable', + authenticateAdmin, + async (req, res) => { + try { + const { accountId } = req.params + + const result = await openaiAccountService.toggleSchedulable(accountId) + + // 如果账号被禁用,发送webhook通知 + if (!result.schedulable) { + // 获取账号信息 + const account = await redis.getOpenAiAccount(accountId) + if (account) { + await webhookNotifier.sendAccountAnomalyNotification({ + accountId: account.id, + accountName: account.name || 'OpenAI Account', + platform: 'openai', + status: 'disabled', + errorCode: 'OPENAI_MANUALLY_DISABLED', + reason: '账号已被管理员手动禁用调度', + timestamp: new Date().toISOString() + }) + } + } + + return res.json({ + success: result.success, + schedulable: result.schedulable, + message: result.schedulable ? '已启用调度' : '已禁用调度' + }) + } catch (error) { + logger.error('切换 OpenAI 账户调度状态失败:', error) + return res.status(500).json({ + success: false, + message: '切换调度状态失败', + error: error.message + }) + } + } +) + +// 🌐 Azure OpenAI 账户管理 + +// 获取所有 Azure OpenAI 账户 +router.get('/azure-openai-accounts', authenticateAdmin, async (req, res) => { + try { + const { platform, groupId } = req.query + let accounts = await azureOpenaiAccountService.getAllAccounts() + + // 根据查询参数进行筛选 + if (platform && platform !== 'all' && platform !== 'azure_openai') { + // 如果指定了其他平台,返回空数组 + accounts = [] + } + + // 如果指定了分组筛选 + if (groupId && groupId !== 'all') { + if (groupId === 'ungrouped') { + // 筛选未分组账户 + const filteredAccounts = [] + for (const account of accounts) { + const groups = await accountGroupService.getAccountGroups(account.id) + if (!groups || groups.length === 0) { + filteredAccounts.push(account) + } + } + accounts = filteredAccounts + } else { + // 筛选特定分组的账户 + const groupMembers = await accountGroupService.getGroupMembers(groupId) + accounts = accounts.filter((account) => groupMembers.includes(account.id)) + } + } + + // 为每个账户添加使用统计信息和分组信息 + const accountsWithStats = await Promise.all( + accounts.map(async (account) => { + const formattedAccount = formatSubscriptionExpiry(account) + try { + const usageStats = await redis.getAccountUsageStats(account.id, 'openai') + const groupInfos = await accountGroupService.getAccountGroups(account.id) + return { + ...formattedAccount, + groupInfos, + usage: { + daily: usageStats.daily, + total: usageStats.total, + averages: usageStats.averages + } + } + } catch (error) { + logger.debug(`Failed to get usage stats for Azure OpenAI account ${account.id}:`, error) + try { + const groupInfos = await accountGroupService.getAccountGroups(account.id) + return { + ...formattedAccount, + groupInfos, + usage: { + daily: { requests: 0, tokens: 0, allTokens: 0 }, + total: { requests: 0, tokens: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } + } + } catch (groupError) { + logger.debug(`Failed to get group info for account ${account.id}:`, groupError) + return { + ...formattedAccount, + groupInfos: [], + usage: { + daily: { requests: 0, tokens: 0, allTokens: 0 }, + total: { requests: 0, tokens: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } + } + } + } + }) + ) + + const formattedAccounts = accountsWithStats.map(formatSubscriptionExpiry) + + res.json({ + success: true, + data: formattedAccounts + }) + } catch (error) { + logger.error('Failed to fetch Azure OpenAI accounts:', error) + res.status(500).json({ + success: false, + message: 'Failed to fetch accounts', + error: error.message + }) + } +}) + +// 创建 Azure OpenAI 账户 +router.post('/azure-openai-accounts', authenticateAdmin, async (req, res) => { + try { + const { + name, + description, + accountType, + azureEndpoint, + apiVersion, + deploymentName, + apiKey, + supportedModels, + proxy, + groupId, + groupIds, + priority, + isActive, + schedulable + } = req.body + + // 验证必填字段 + if (!name) { + return res.status(400).json({ + success: false, + message: 'Account name is required' + }) + } + + if (!azureEndpoint) { + return res.status(400).json({ + success: false, + message: 'Azure endpoint is required' + }) + } + + if (!apiKey) { + return res.status(400).json({ + success: false, + message: 'API key is required' + }) + } + + if (!deploymentName) { + return res.status(400).json({ + success: false, + message: 'Deployment name is required' + }) + } + + // 验证 Azure endpoint 格式 + if (!azureEndpoint.match(/^https:\/\/[\w-]+\.openai\.azure\.com$/)) { + return res.status(400).json({ + success: false, + message: + 'Invalid Azure OpenAI endpoint format. Expected: https://your-resource.openai.azure.com' + }) + } + + // 测试连接 + try { + const testUrl = `${azureEndpoint}/openai/deployments/${deploymentName}?api-version=${ + apiVersion || '2024-02-01' + }` + await axios.get(testUrl, { + headers: { + 'api-key': apiKey + }, + timeout: 5000 + }) + } catch (testError) { + if (testError.response?.status === 404) { + logger.warn('Azure OpenAI deployment not found, but continuing with account creation') + } else if (testError.response?.status === 401) { + return res.status(400).json({ + success: false, + message: 'Invalid API key or unauthorized access' + }) + } + } + + const account = await azureOpenaiAccountService.createAccount({ + name, + description, + accountType: accountType || 'shared', + azureEndpoint, + apiVersion: apiVersion || '2024-02-01', + deploymentName, + apiKey, + supportedModels, + proxy, + groupId, + priority: priority || 50, + isActive: isActive !== false, + schedulable: schedulable !== false + }) + + // 如果是分组类型,将账户添加到分组 + if (accountType === 'group') { + if (groupIds && groupIds.length > 0) { + // 使用多分组设置 + await accountGroupService.setAccountGroups(account.id, groupIds, 'azure_openai') + } else if (groupId) { + // 兼容单分组模式 + await accountGroupService.addAccountToGroup(account.id, groupId, 'azure_openai') + } + } + + const responseAccount = formatSubscriptionExpiry(account) + + res.json({ + success: true, + data: responseAccount, + message: 'Azure OpenAI account created successfully' + }) + } catch (error) { + logger.error('Failed to create Azure OpenAI account:', error) + res.status(500).json({ + success: false, + message: 'Failed to create account', + error: error.message + }) + } +}) + +// 更新 Azure OpenAI 账户 +router.put('/azure-openai-accounts/:id', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + const updates = req.body + + // 映射字段名:前端的expiresAt -> 后端的subscriptionExpiresAt + const mappedUpdates = { ...updates } + if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) { + mappedUpdates.subscriptionExpiresAt = updates.subscriptionExpiresAt + } else if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'expiresAt')) { + mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt + } + if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'subscriptionExpiresAt')) { + delete mappedUpdates.expiresAt + } + + const account = await azureOpenaiAccountService.updateAccount(id, mappedUpdates) + const responseAccount = formatSubscriptionExpiry(account) + + res.json({ + success: true, + data: responseAccount, + message: 'Azure OpenAI account updated successfully' + }) + } catch (error) { + logger.error('Failed to update Azure OpenAI account:', error) + res.status(500).json({ + success: false, + message: 'Failed to update account', + error: error.message + }) + } +}) + +// 删除 Azure OpenAI 账户 +router.delete('/azure-openai-accounts/:id', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + + // 自动解绑所有绑定的 API Keys + const unboundCount = await apiKeyService.unbindAccountFromAllKeys(id, 'azure_openai') + + await azureOpenaiAccountService.deleteAccount(id) + + let message = 'Azure OpenAI账号已成功删除' + if (unboundCount > 0) { + message += `,${unboundCount} 个 API Key 已切换为共享池模式` + } + + logger.success(`🗑️ Admin deleted Azure OpenAI account: ${id}, unbound ${unboundCount} keys`) + + res.json({ + success: true, + message, + unboundKeys: unboundCount + }) + } catch (error) { + logger.error('Failed to delete Azure OpenAI account:', error) + res.status(500).json({ + success: false, + message: 'Failed to delete account', + error: error.message + }) + } +}) + +// 切换 Azure OpenAI 账户状态 +router.put('/azure-openai-accounts/:id/toggle', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + + const account = await azureOpenaiAccountService.getAccount(id) + if (!account) { + return res.status(404).json({ + success: false, + message: 'Account not found' + }) + } + + const newStatus = account.isActive === 'true' ? 'false' : 'true' + await azureOpenaiAccountService.updateAccount(id, { isActive: newStatus }) + + res.json({ + success: true, + message: `Account ${newStatus === 'true' ? 'activated' : 'deactivated'} successfully`, + isActive: newStatus === 'true' + }) + } catch (error) { + logger.error('Failed to toggle Azure OpenAI account status:', error) + res.status(500).json({ + success: false, + message: 'Failed to toggle account status', + error: error.message + }) + } +}) + +// 切换 Azure OpenAI 账户调度状态 +router.put( + '/azure-openai-accounts/:accountId/toggle-schedulable', + authenticateAdmin, + async (req, res) => { + try { + const { accountId } = req.params + + const result = await azureOpenaiAccountService.toggleSchedulable(accountId) + + // 如果账号被禁用,发送webhook通知 + if (!result.schedulable) { + // 获取账号信息 + const account = await azureOpenaiAccountService.getAccount(accountId) + if (account) { + await webhookNotifier.sendAccountAnomalyNotification({ + accountId: account.id, + accountName: account.name || 'Azure OpenAI Account', + platform: 'azure-openai', + status: 'disabled', + errorCode: 'AZURE_OPENAI_MANUALLY_DISABLED', + reason: '账号已被管理员手动禁用调度', + timestamp: new Date().toISOString() + }) + } + } + + return res.json({ + success: true, + schedulable: result.schedulable, + message: result.schedulable ? '已启用调度' : '已禁用调度' + }) + } catch (error) { + logger.error('切换 Azure OpenAI 账户调度状态失败:', error) + return res.status(500).json({ + success: false, + message: '切换调度状态失败', + error: error.message + }) + } + } +) + +// 健康检查单个 Azure OpenAI 账户 +router.post('/azure-openai-accounts/:id/health-check', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + const healthResult = await azureOpenaiAccountService.healthCheckAccount(id) + + res.json({ + success: true, + data: healthResult + }) + } catch (error) { + logger.error('Failed to perform health check:', error) + res.status(500).json({ + success: false, + message: 'Failed to perform health check', + error: error.message + }) + } +}) + +// 批量健康检查所有 Azure OpenAI 账户 +router.post('/azure-openai-accounts/health-check-all', authenticateAdmin, async (req, res) => { + try { + const healthResults = await azureOpenaiAccountService.performHealthChecks() + + res.json({ + success: true, + data: healthResults + }) + } catch (error) { + logger.error('Failed to perform batch health check:', error) + res.status(500).json({ + success: false, + message: 'Failed to perform batch health check', + error: error.message + }) + } +}) + +// 迁移 API Keys 以支持 Azure OpenAI +router.post('/migrate-api-keys-azure', authenticateAdmin, async (req, res) => { + try { + const migratedCount = await azureOpenaiAccountService.migrateApiKeysForAzureSupport() + + res.json({ + success: true, + message: `Successfully migrated ${migratedCount} API keys for Azure OpenAI support` + }) + } catch (error) { + logger.error('Failed to migrate API keys:', error) + res.status(500).json({ + success: false, + message: 'Failed to migrate API keys', + error: error.message + }) + } +}) + +// 📋 获取统一Claude Code User-Agent信息 +router.get('/claude-code-version', authenticateAdmin, async (req, res) => { + try { + const CACHE_KEY = 'claude_code_user_agent:daily' + + // 获取缓存的统一User-Agent + const unifiedUserAgent = await redis.client.get(CACHE_KEY) + const ttl = unifiedUserAgent ? await redis.client.ttl(CACHE_KEY) : 0 + + res.json({ + success: true, + userAgent: unifiedUserAgent, + isActive: !!unifiedUserAgent, + ttlSeconds: ttl, + lastUpdated: unifiedUserAgent ? new Date().toISOString() : null + }) + } catch (error) { + logger.error('❌ Get unified Claude Code User-Agent error:', error) + res.status(500).json({ + success: false, + message: 'Failed to get User-Agent information', + error: error.message + }) + } +}) + +// 🗑️ 清除统一Claude Code User-Agent缓存 +router.post('/claude-code-version/clear', authenticateAdmin, async (req, res) => { + try { + const CACHE_KEY = 'claude_code_user_agent:daily' + + // 删除缓存的统一User-Agent + await redis.client.del(CACHE_KEY) + + logger.info(`🗑️ Admin manually cleared unified Claude Code User-Agent cache`) + + res.json({ + success: true, + message: 'Unified User-Agent cache cleared successfully' + }) + } catch (error) { + logger.error('❌ Clear unified User-Agent cache error:', error) + res.status(500).json({ + success: false, + message: 'Failed to clear cache', + error: error.message + }) + } +}) + +// ==================== OpenAI-Responses 账户管理 API ==================== + +// 获取所有 OpenAI-Responses 账户 +router.get('/openai-responses-accounts', authenticateAdmin, async (req, res) => { + try { + const { platform, groupId } = req.query + let accounts = await openaiResponsesAccountService.getAllAccounts(true) + + // 根据查询参数进行筛选 + if (platform && platform !== 'openai-responses') { + accounts = [] + } + + // 根据分组ID筛选 + if (groupId) { + const group = await accountGroupService.getGroup(groupId) + if (group && group.platform === 'openai' && group.memberIds && group.memberIds.length > 0) { + accounts = accounts.filter((account) => group.memberIds.includes(account.id)) + } else { + accounts = [] + } + } + + // 处理额度信息、使用统计和绑定的 API Key 数量 + const accountsWithStats = await Promise.all( + accounts.map(async (account) => { + const formattedAccount = formatSubscriptionExpiry(account) + try { + // 检查是否需要重置额度 + const today = redis.getDateStringInTimezone() + if (account.lastResetDate !== today) { + // 今天还没重置过,需要重置 + await openaiResponsesAccountService.updateAccount(account.id, { + dailyUsage: '0', + lastResetDate: today, + quotaStoppedAt: '' + }) + account.dailyUsage = '0' + account.lastResetDate = today + account.quotaStoppedAt = '' + } + + // 检查并清除过期的限流状态 + await openaiResponsesAccountService.checkAndClearRateLimit(account.id) + + // 获取使用统计信息 + let usageStats + try { + usageStats = await redis.getAccountUsageStats(account.id, 'openai-responses') + } catch (error) { + logger.debug( + `Failed to get usage stats for OpenAI-Responses account ${account.id}:`, + error + ) + usageStats = { + daily: { requests: 0, tokens: 0, allTokens: 0 }, + total: { requests: 0, tokens: 0, allTokens: 0 }, + monthly: { requests: 0, tokens: 0, allTokens: 0 } + } + } + + // 计算绑定的API Key数量(支持 responses: 前缀) + const allKeys = await redis.getAllApiKeys() + let boundCount = 0 + + for (const key of allKeys) { + // 检查是否绑定了该账户(包括 responses: 前缀) + if ( + key.openaiAccountId === account.id || + key.openaiAccountId === `responses:${account.id}` + ) { + boundCount++ + } + } + + // 调试日志:检查绑定计数 + if (boundCount > 0) { + logger.info(`OpenAI-Responses account ${account.id} has ${boundCount} bound API keys`) + } + + return { + ...formattedAccount, + boundApiKeysCount: boundCount, + usage: { + daily: usageStats.daily, + total: usageStats.total, + monthly: usageStats.monthly + } + } + } catch (error) { + logger.error(`Failed to process OpenAI-Responses account ${account.id}:`, error) + return { + ...formattedAccount, + boundApiKeysCount: 0, + usage: { + daily: { requests: 0, tokens: 0, allTokens: 0 }, + total: { requests: 0, tokens: 0, allTokens: 0 }, + monthly: { requests: 0, tokens: 0, allTokens: 0 } + } + } + } + }) + ) + + const formattedAccounts = accountsWithStats.map(formatSubscriptionExpiry) + + res.json({ success: true, data: formattedAccounts }) + } catch (error) { + logger.error('Failed to get OpenAI-Responses accounts:', error) + res.status(500).json({ success: false, message: error.message }) + } +}) + +// 创建 OpenAI-Responses 账户 +router.post('/openai-responses-accounts', authenticateAdmin, async (req, res) => { + try { + const account = await openaiResponsesAccountService.createAccount(req.body) + const responseAccount = formatSubscriptionExpiry(account) + res.json({ success: true, data: responseAccount }) + } catch (error) { + logger.error('Failed to create OpenAI-Responses account:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// 更新 OpenAI-Responses 账户 +router.put('/openai-responses-accounts/:id', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + const updates = req.body + + // 验证priority的有效性(1-100) + if (updates.priority !== undefined) { + const priority = parseInt(updates.priority) + if (isNaN(priority) || priority < 1 || priority > 100) { + return res.status(400).json({ + success: false, + message: 'Priority must be a number between 1 and 100' + }) + } + updates.priority = priority.toString() + } + + // 映射字段名:前端的expiresAt -> 后端的subscriptionExpiresAt + const mappedUpdates = { ...updates } + if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) { + mappedUpdates.subscriptionExpiresAt = updates.subscriptionExpiresAt + } else if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'expiresAt')) { + mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt + } + if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'subscriptionExpiresAt')) { + delete mappedUpdates.expiresAt + } + + const result = await openaiResponsesAccountService.updateAccount(id, mappedUpdates) + + if (!result.success) { + return res.status(400).json(result) + } + + const updatedAccountData = await openaiResponsesAccountService.getAccount(id) + if (updatedAccountData) { + updatedAccountData.apiKey = '***' + } + const responseAccount = formatSubscriptionExpiry(updatedAccountData) + + res.json({ success: true, data: responseAccount }) + } catch (error) { + logger.error('Failed to update OpenAI-Responses account:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// 删除 OpenAI-Responses 账户 +router.delete('/openai-responses-accounts/:id', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + + const account = await openaiResponsesAccountService.getAccount(id) + if (!account) { + return res.status(404).json({ + success: false, + message: 'Account not found' + }) + } + + // 自动解绑所有绑定的 API Keys + const unboundCount = await apiKeyService.unbindAccountFromAllKeys(id, 'openai-responses') + + // 检查是否在分组中 + const groups = await accountGroupService.getAllGroups() + for (const group of groups) { + if (group.platform === 'openai' && group.memberIds && group.memberIds.includes(id)) { + await accountGroupService.removeMemberFromGroup(group.id, id) + logger.info(`Removed OpenAI-Responses account ${id} from group ${group.id}`) + } + } + + const result = await openaiResponsesAccountService.deleteAccount(id) + + let message = 'OpenAI-Responses账号已成功删除' + if (unboundCount > 0) { + message += `,${unboundCount} 个 API Key 已切换为共享池模式` + } + + logger.success(`🗑️ Admin deleted OpenAI-Responses account: ${id}, unbound ${unboundCount} keys`) + + res.json({ + success: true, + ...result, + message, + unboundKeys: unboundCount + }) + } catch (error) { + logger.error('Failed to delete OpenAI-Responses account:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// 切换 OpenAI-Responses 账户调度状态 +router.put( + '/openai-responses-accounts/:id/toggle-schedulable', + authenticateAdmin, + async (req, res) => { + try { + const { id } = req.params + + const result = await openaiResponsesAccountService.toggleSchedulable(id) + + if (!result.success) { + return res.status(400).json(result) + } + + // 仅在停止调度时发送通知 + if (!result.schedulable) { + await webhookNotifier.sendAccountEvent('account.status_changed', { + accountId: id, + platform: 'openai-responses', + schedulable: result.schedulable, + changedBy: 'admin', + action: 'stopped_scheduling' + }) + } + + res.json(result) + } catch (error) { + logger.error('Failed to toggle OpenAI-Responses account schedulable status:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } + } +) + +// 切换 OpenAI-Responses 账户激活状态 +router.put('/openai-responses-accounts/:id/toggle', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + + const account = await openaiResponsesAccountService.getAccount(id) + if (!account) { + return res.status(404).json({ + success: false, + message: 'Account not found' + }) + } + + const newActiveStatus = account.isActive === 'true' ? 'false' : 'true' + await openaiResponsesAccountService.updateAccount(id, { + isActive: newActiveStatus + }) + + res.json({ + success: true, + isActive: newActiveStatus === 'true' + }) + } catch (error) { + logger.error('Failed to toggle OpenAI-Responses account status:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// 重置 OpenAI-Responses 账户限流状态 +router.post( + '/openai-responses-accounts/:id/reset-rate-limit', + authenticateAdmin, + async (req, res) => { + try { + const { id } = req.params + + await openaiResponsesAccountService.updateAccount(id, { + rateLimitedAt: '', + rateLimitStatus: '', + status: 'active', + errorMessage: '' + }) + + logger.info(`🔄 Admin manually reset rate limit for OpenAI-Responses account ${id}`) + + res.json({ + success: true, + message: 'Rate limit reset successfully' + }) + } catch (error) { + logger.error('Failed to reset OpenAI-Responses account rate limit:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } + } +) + +// 重置 OpenAI-Responses 账户状态(清除所有异常状态) +router.post('/openai-responses-accounts/:id/reset-status', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + + const result = await openaiResponsesAccountService.resetAccountStatus(id) + + logger.success(`✅ Admin reset status for OpenAI-Responses account: ${id}`) + return res.json({ success: true, data: result }) + } catch (error) { + logger.error('❌ Failed to reset OpenAI-Responses account status:', error) + return res.status(500).json({ error: 'Failed to reset status', message: error.message }) + } +}) + +// 手动重置 OpenAI-Responses 账户的每日使用量 +router.post('/openai-responses-accounts/:id/reset-usage', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + + await openaiResponsesAccountService.updateAccount(id, { + dailyUsage: '0', + lastResetDate: redis.getDateStringInTimezone(), + quotaStoppedAt: '' + }) + + logger.success(`✅ Admin manually reset daily usage for OpenAI-Responses account ${id}`) + + res.json({ + success: true, + message: 'Daily usage reset successfully' + }) + } catch (error) { + logger.error('Failed to reset OpenAI-Responses account usage:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// 🤖 Droid 账户管理 + +// 生成 Droid OAuth 授权链接 +router.post('/droid-accounts/generate-auth-url', authenticateAdmin, async (req, res) => { + try { + const { proxy } = req.body || {} + const deviceAuth = await startDeviceAuthorization(proxy || null) + + const sessionId = crypto.randomUUID() + const expiresAt = new Date(Date.now() + deviceAuth.expiresIn * 1000).toISOString() + + await redis.setOAuthSession(sessionId, { + deviceCode: deviceAuth.deviceCode, + userCode: deviceAuth.userCode, + verificationUri: deviceAuth.verificationUri, + verificationUriComplete: deviceAuth.verificationUriComplete, + interval: deviceAuth.interval, + proxy: proxy || null, + createdAt: new Date().toISOString(), + expiresAt + }) + + logger.success('🤖 生成 Droid 设备码授权信息成功', { sessionId }) + return res.json({ + success: true, + data: { + sessionId, + userCode: deviceAuth.userCode, + verificationUri: deviceAuth.verificationUri, + verificationUriComplete: deviceAuth.verificationUriComplete, + expiresIn: deviceAuth.expiresIn, + interval: deviceAuth.interval, + instructions: [ + '1. 使用下方验证码进入授权页面并确认访问权限。', + '2. 在授权页面登录 Factory / Droid 账户并点击允许。', + '3. 回到此处点击“完成授权”完成凭证获取。' + ] + } + }) + } catch (error) { + const message = + error instanceof WorkOSDeviceAuthError ? error.message : error.message || '未知错误' + logger.error('❌ 生成 Droid 设备码授权失败:', message) + return res.status(500).json({ error: 'Failed to start Droid device authorization', message }) + } +}) + +// 交换 Droid 授权码 +router.post('/droid-accounts/exchange-code', authenticateAdmin, async (req, res) => { + const { sessionId, proxy } = req.body || {} + try { + if (!sessionId) { + return res.status(400).json({ error: 'Session ID is required' }) + } + + const oauthSession = await redis.getOAuthSession(sessionId) + if (!oauthSession) { + return res.status(400).json({ error: 'Invalid or expired OAuth session' }) + } + + if (oauthSession.expiresAt && new Date() > new Date(oauthSession.expiresAt)) { + await redis.deleteOAuthSession(sessionId) + return res + .status(400) + .json({ error: 'OAuth session has expired, please generate a new authorization URL' }) + } + + if (!oauthSession.deviceCode) { + await redis.deleteOAuthSession(sessionId) + return res.status(400).json({ error: 'OAuth session missing device code, please retry' }) + } + + const proxyConfig = proxy || oauthSession.proxy || null + const tokens = await pollDeviceAuthorization(oauthSession.deviceCode, proxyConfig) + + await redis.deleteOAuthSession(sessionId) + + logger.success('🤖 成功获取 Droid 访问令牌', { sessionId }) + return res.json({ success: true, data: { tokens } }) + } catch (error) { + if (error instanceof WorkOSDeviceAuthError) { + if (error.code === 'authorization_pending' || error.code === 'slow_down') { + const oauthSession = await redis.getOAuthSession(sessionId) + const expiresAt = oauthSession?.expiresAt ? new Date(oauthSession.expiresAt) : null + const remainingSeconds = + expiresAt instanceof Date && !Number.isNaN(expiresAt.getTime()) + ? Math.max(0, Math.floor((expiresAt.getTime() - Date.now()) / 1000)) + : null + + return res.json({ + success: false, + pending: true, + error: error.code, + message: error.message, + retryAfter: error.retryAfter || Number(oauthSession?.interval) || 5, + expiresIn: remainingSeconds + }) + } + + if (error.code === 'expired_token') { + await redis.deleteOAuthSession(sessionId) + return res.status(400).json({ + error: 'Device code expired', + message: '授权已过期,请重新生成设备码并再次授权' + }) + } + + logger.error('❌ Droid 授权失败:', error.message) + return res.status(500).json({ + error: 'Failed to exchange Droid authorization code', + message: error.message, + errorCode: error.code + }) + } + + logger.error('❌ 交换 Droid 授权码失败:', error) + return res.status(500).json({ + error: 'Failed to exchange Droid authorization code', + message: error.message + }) + } +}) + +// 获取所有 Droid 账户 +router.get('/droid-accounts', authenticateAdmin, async (req, res) => { + try { + const accounts = await droidAccountService.getAllAccounts() + const allApiKeys = await redis.getAllApiKeys() + + // 添加使用统计 + const accountsWithStats = await Promise.all( + accounts.map(async (account) => { + const formattedAccount = formatSubscriptionExpiry(account) + try { + const usageStats = await redis.getAccountUsageStats(account.id, 'droid') + let groupInfos = [] + try { + groupInfos = await accountGroupService.getAccountGroups(account.id) + } catch (groupError) { + logger.debug(`Failed to get group infos for Droid account ${account.id}:`, groupError) + groupInfos = [] + } + + const groupIds = groupInfos.map((group) => group.id) + const boundApiKeysCount = allApiKeys.reduce((count, key) => { + const binding = key.droidAccountId + if (!binding) { + return count + } + if (binding === account.id) { + return count + 1 + } + if (binding.startsWith('group:')) { + const groupId = binding.substring('group:'.length) + if (groupIds.includes(groupId)) { + return count + 1 + } + } + return count + }, 0) + + return { + ...formattedAccount, + schedulable: account.schedulable === 'true', + boundApiKeysCount, + groupInfos, + usage: { + daily: usageStats.daily, + total: usageStats.total, + averages: usageStats.averages + } + } + } catch (error) { + logger.warn(`Failed to get stats for Droid account ${account.id}:`, error.message) + return { + ...formattedAccount, + boundApiKeysCount: 0, + groupInfos: [], + usage: { + daily: { tokens: 0, requests: 0 }, + total: { tokens: 0, requests: 0 }, + averages: { rpm: 0, tpm: 0 } + } + } + } + }) + ) + + const formattedAccounts = accountsWithStats.map(formatSubscriptionExpiry) + + return res.json({ success: true, data: formattedAccounts }) + } catch (error) { + logger.error('Failed to get Droid accounts:', error) + return res.status(500).json({ error: 'Failed to get Droid accounts', message: error.message }) + } +}) + +// 创建 Droid 账户 +router.post('/droid-accounts', authenticateAdmin, async (req, res) => { + try { + const { accountType: rawAccountType = 'shared', groupId, groupIds } = req.body + + const normalizedAccountType = rawAccountType || 'shared' + + if (!['shared', 'dedicated', 'group'].includes(normalizedAccountType)) { + return res.status(400).json({ error: '账户类型必须是 shared、dedicated 或 group' }) + } + + const normalizedGroupIds = Array.isArray(groupIds) + ? groupIds.filter((id) => typeof id === 'string' && id.trim()) + : [] + + if ( + normalizedAccountType === 'group' && + normalizedGroupIds.length === 0 && + (!groupId || typeof groupId !== 'string' || !groupId.trim()) + ) { + return res.status(400).json({ error: '分组调度账户必须至少选择一个分组' }) + } + + const accountPayload = { + ...req.body, + accountType: normalizedAccountType + } + + delete accountPayload.groupId + delete accountPayload.groupIds + + const account = await droidAccountService.createAccount(accountPayload) + + if (normalizedAccountType === 'group') { + try { + if (normalizedGroupIds.length > 0) { + await accountGroupService.setAccountGroups(account.id, normalizedGroupIds, 'droid') + } else if (typeof groupId === 'string' && groupId.trim()) { + await accountGroupService.addAccountToGroup(account.id, groupId, 'droid') + } + } catch (groupError) { + logger.error(`Failed to attach Droid account ${account.id} to groups:`, groupError) + return res.status(500).json({ + error: 'Failed to bind Droid account to groups', + message: groupError.message + }) + } + } + + logger.success(`Created Droid account: ${account.name} (${account.id})`) + const responseAccount = formatSubscriptionExpiry(account) + return res.json({ success: true, data: responseAccount }) + } catch (error) { + logger.error('Failed to create Droid account:', error) + return res.status(500).json({ error: 'Failed to create Droid account', message: error.message }) + } +}) + +// 更新 Droid 账户 +router.put('/droid-accounts/:id', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + const updates = { ...req.body } + const { accountType: rawAccountType, groupId, groupIds } = updates + + if (rawAccountType && !['shared', 'dedicated', 'group'].includes(rawAccountType)) { + return res.status(400).json({ error: '账户类型必须是 shared、dedicated 或 group' }) + } + + if ( + rawAccountType === 'group' && + (!groupId || typeof groupId !== 'string' || !groupId.trim()) && + (!Array.isArray(groupIds) || groupIds.length === 0) + ) { + return res.status(400).json({ error: '分组调度账户必须至少选择一个分组' }) + } + + const currentAccount = await droidAccountService.getAccount(id) + if (!currentAccount) { + return res.status(404).json({ error: 'Droid account not found' }) + } + + const normalizedGroupIds = Array.isArray(groupIds) + ? groupIds.filter((gid) => typeof gid === 'string' && gid.trim()) + : [] + const hasGroupIdsField = Object.prototype.hasOwnProperty.call(updates, 'groupIds') + const hasGroupIdField = Object.prototype.hasOwnProperty.call(updates, 'groupId') + const targetAccountType = rawAccountType || currentAccount.accountType || 'shared' + + delete updates.groupId + delete updates.groupIds + + if (rawAccountType) { + updates.accountType = targetAccountType + } + + // 映射字段名:前端的expiresAt -> 后端的subscriptionExpiresAt + const mappedUpdates = { ...updates } + if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) { + mappedUpdates.subscriptionExpiresAt = updates.subscriptionExpiresAt + } else if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'expiresAt')) { + mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt + } + if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'subscriptionExpiresAt')) { + delete mappedUpdates.expiresAt + } + + const account = await droidAccountService.updateAccount(id, mappedUpdates) + + try { + if (currentAccount.accountType === 'group' && targetAccountType !== 'group') { + await accountGroupService.removeAccountFromAllGroups(id) + } else if (targetAccountType === 'group') { + if (hasGroupIdsField) { + if (normalizedGroupIds.length > 0) { + await accountGroupService.setAccountGroups(id, normalizedGroupIds, 'droid') + } else { + await accountGroupService.removeAccountFromAllGroups(id) + } + } else if (hasGroupIdField && typeof groupId === 'string' && groupId.trim()) { + await accountGroupService.setAccountGroups(id, [groupId], 'droid') + } + } + } catch (groupError) { + logger.error(`Failed to update Droid account ${id} groups:`, groupError) + return res.status(500).json({ + error: 'Failed to update Droid account groups', + message: groupError.message + }) + } + + if (targetAccountType === 'group') { + try { + account.groupInfos = await accountGroupService.getAccountGroups(id) + } catch (groupFetchError) { + logger.debug(`Failed to fetch group infos for Droid account ${id}:`, groupFetchError) + } + } + + const responseAccount = formatSubscriptionExpiry(account) + return res.json({ success: true, data: responseAccount }) + } catch (error) { + logger.error(`Failed to update Droid account ${req.params.id}:`, error) + return res.status(500).json({ error: 'Failed to update Droid account', message: error.message }) + } +}) + +// 切换 Droid 账户调度状态 +router.put('/droid-accounts/:id/toggle-schedulable', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + + const account = await droidAccountService.getAccount(id) + if (!account) { + return res.status(404).json({ error: 'Droid account not found' }) + } + + const currentSchedulable = account.schedulable === true || account.schedulable === 'true' + const newSchedulable = !currentSchedulable + + await droidAccountService.updateAccount(id, { schedulable: newSchedulable ? 'true' : 'false' }) + + const updatedAccount = await droidAccountService.getAccount(id) + const actualSchedulable = updatedAccount + ? updatedAccount.schedulable === true || updatedAccount.schedulable === 'true' + : newSchedulable + + if (!actualSchedulable) { + await webhookNotifier.sendAccountAnomalyNotification({ + accountId: account.id, + accountName: account.name || 'Droid Account', + platform: 'droid', + status: 'disabled', + errorCode: 'DROID_MANUALLY_DISABLED', + reason: '账号已被管理员手动禁用调度', + timestamp: new Date().toISOString() + }) + } + + logger.success( + `🔄 Admin toggled Droid account schedulable status: ${id} -> ${ + actualSchedulable ? 'schedulable' : 'not schedulable' + }` + ) + + return res.json({ success: true, schedulable: actualSchedulable }) + } catch (error) { + logger.error('❌ Failed to toggle Droid account schedulable status:', error) + return res + .status(500) + .json({ error: 'Failed to toggle schedulable status', message: error.message }) + } +}) + +// 获取单个 Droid 账户详细信息 +router.get('/droid-accounts/:id', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + + // 获取账户基本信息 + const account = await droidAccountService.getAccount(id) + if (!account) { + return res.status(404).json({ + error: 'Not Found', + message: 'Droid account not found' + }) + } + + // 获取使用统计信息 + let usageStats + try { + usageStats = await redis.getAccountUsageStats(account.id, 'droid') + } catch (error) { + logger.debug(`Failed to get usage stats for Droid account ${account.id}:`, error) + usageStats = { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } + } + + // 获取分组信息 + let groupInfos = [] + try { + groupInfos = await accountGroupService.getAccountGroups(account.id) + } catch (error) { + logger.debug(`Failed to get group infos for Droid account ${account.id}:`, error) + groupInfos = [] + } + + // 获取绑定的 API Key 数量 + const allApiKeys = await redis.getAllApiKeys() + const groupIds = groupInfos.map((group) => group.id) + const boundApiKeysCount = allApiKeys.reduce((count, key) => { + const binding = key.droidAccountId + if (!binding) { + return count + } + if (binding === account.id) { + return count + 1 + } + if (binding.startsWith('group:')) { + const groupId = binding.substring('group:'.length) + if (groupIds.includes(groupId)) { + return count + 1 + } + } + return count + }, 0) + + // 获取解密的 API Keys(用于管理界面) + let decryptedApiKeys = [] + try { + decryptedApiKeys = await droidAccountService.getDecryptedApiKeyEntries(id) + } catch (error) { + logger.debug(`Failed to get decrypted API keys for Droid account ${account.id}:`, error) + decryptedApiKeys = [] + } + + // 返回完整的账户信息,包含实际的 API Keys + const accountDetails = { + ...account, + // 映射字段:使用 subscriptionExpiresAt 作为前端显示的 expiresAt + expiresAt: account.subscriptionExpiresAt || null, + schedulable: account.schedulable === 'true', + boundApiKeysCount, + groupInfos, + // 包含实际的 API Keys(用于管理界面) + apiKeys: decryptedApiKeys.map((entry) => ({ + key: entry.key, + id: entry.id, + usageCount: entry.usageCount || 0, + lastUsedAt: entry.lastUsedAt || null, + status: entry.status || 'active', // 使用实际的状态,默认为 active + errorMessage: entry.errorMessage || '', // 包含错误信息 + createdAt: entry.createdAt || null + })), + usage: { + daily: usageStats.daily, + total: usageStats.total, + averages: usageStats.averages + } + } + + return res.json({ + success: true, + data: accountDetails + }) + } catch (error) { + logger.error(`Failed to get Droid account ${req.params.id}:`, error) + return res.status(500).json({ + error: 'Failed to get Droid account', + message: error.message + }) + } +}) + +// 删除 Droid 账户 +router.delete('/droid-accounts/:id', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + await droidAccountService.deleteAccount(id) + return res.json({ success: true, message: 'Droid account deleted successfully' }) + } catch (error) { + logger.error(`Failed to delete Droid account ${req.params.id}:`, error) + return res.status(500).json({ error: 'Failed to delete Droid account', message: error.message }) + } +}) + +// 刷新 Droid 账户 token +router.post('/droid-accounts/:id/refresh-token', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + const result = await droidAccountService.refreshAccessToken(id) + return res.json({ success: true, data: result }) + } catch (error) { + logger.error(`Failed to refresh Droid account token ${req.params.id}:`, error) + return res.status(500).json({ error: 'Failed to refresh token', message: error.message }) + } +}) + +module.exports = router diff --git a/src/routes/api.js b/src/routes/api.js new file mode 100644 index 0000000000000000000000000000000000000000..f784cae68360440d732b3e0a584babbbfa528dd4 --- /dev/null +++ b/src/routes/api.js @@ -0,0 +1,986 @@ +const express = require('express') +const claudeRelayService = require('../services/claudeRelayService') +const claudeConsoleRelayService = require('../services/claudeConsoleRelayService') +const bedrockRelayService = require('../services/bedrockRelayService') +const ccrRelayService = require('../services/ccrRelayService') +const bedrockAccountService = require('../services/bedrockAccountService') +const unifiedClaudeScheduler = require('../services/unifiedClaudeScheduler') +const apiKeyService = require('../services/apiKeyService') +const { authenticateApiKey } = require('../middleware/auth') +const logger = require('../utils/logger') +const { getEffectiveModel, parseVendorPrefixedModel } = require('../utils/modelHelper') +const sessionHelper = require('../utils/sessionHelper') +const { updateRateLimitCounters } = require('../utils/rateLimitHelper') + +const router = express.Router() + +function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') { + if (!rateLimitInfo) { + return Promise.resolve({ totalTokens: 0, totalCost: 0 }) + } + + const label = context ? ` (${context})` : '' + + return updateRateLimitCounters(rateLimitInfo, usageSummary, model) + .then(({ totalTokens, totalCost }) => { + 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)}`) + } + return { totalTokens, totalCost } + }) + .catch((error) => { + logger.error(`❌ Failed to update rate limit counters${label}:`, error) + return { totalTokens: 0, totalCost: 0 } + }) +} + +// 🔧 共享的消息处理函数 +async function handleMessagesRequest(req, res) { + try { + const startTime = Date.now() + + // Claude 服务权限校验,阻止未授权的 Key + if ( + req.apiKey.permissions && + req.apiKey.permissions !== 'all' && + req.apiKey.permissions !== 'claude' + ) { + return res.status(403).json({ + error: { + type: 'permission_error', + message: '此 API Key 无权访问 Claude 服务' + } + }) + } + + // 严格的输入验证 + if (!req.body || typeof req.body !== 'object') { + return res.status(400).json({ + error: 'Invalid request', + message: 'Request body must be a valid JSON object' + }) + } + + if (!req.body.messages || !Array.isArray(req.body.messages)) { + return res.status(400).json({ + error: 'Invalid request', + message: 'Missing or invalid field: messages (must be an array)' + }) + } + + if (req.body.messages.length === 0) { + return res.status(400).json({ + error: 'Invalid request', + message: 'Messages array cannot be empty' + }) + } + + // 模型限制(黑名单)校验:统一在此处处理(去除供应商前缀) + if ( + req.apiKey.enableModelRestriction && + Array.isArray(req.apiKey.restrictedModels) && + req.apiKey.restrictedModels.length > 0 + ) { + const effectiveModel = getEffectiveModel(req.body.model || '') + if (req.apiKey.restrictedModels.includes(effectiveModel)) { + return res.status(403).json({ + error: { + type: 'forbidden', + message: '暂无该模型访问权限' + } + }) + } + } + + // 检查是否为流式请求 + const isStream = req.body.stream === true + + logger.api( + `🚀 Processing ${isStream ? 'stream' : 'non-stream'} request for key: ${req.apiKey.name}` + ) + + if (isStream) { + // 流式响应 - 只使用官方真实usage数据 + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('X-Accel-Buffering', 'no') // 禁用 Nginx 缓冲 + + // 禁用 Nagle 算法,确保数据立即发送 + if (res.socket && typeof res.socket.setNoDelay === 'function') { + res.socket.setNoDelay(true) + } + + // 流式响应不需要额外处理,中间件已经设置了监听器 + + let usageDataCaptured = false + + // 生成会话哈希用于sticky会话 + const sessionHash = sessionHelper.generateSessionHash(req.body) + + // 使用统一调度选择账号(传递请求的模型) + const requestedModel = req.body.model + let accountId + let accountType + try { + const selection = await unifiedClaudeScheduler.selectAccountForApiKey( + req.apiKey, + sessionHash, + requestedModel + ) + ;({ accountId, accountType } = selection) + } catch (error) { + if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') { + const limitMessage = claudeRelayService._buildStandardRateLimitMessage( + error.rateLimitEndAt + ) + res.status(403) + res.setHeader('Content-Type', 'application/json') + res.end( + JSON.stringify({ + error: 'upstream_rate_limited', + message: limitMessage + }) + ) + return + } + throw error + } + + // 根据账号类型选择对应的转发服务并调用 + if (accountType === 'claude-official') { + // 官方Claude账号使用原有的转发服务(会自己选择账号) + await claudeRelayService.relayStreamRequestWithUsageCapture( + req.body, + req.apiKey, + res, + req.headers, + (usageData) => { + // 回调函数:当检测到完整usage数据时记录真实token使用量 + logger.info( + '🎯 Usage callback triggered with complete data:', + JSON.stringify(usageData, null, 2) + ) + + if ( + usageData && + usageData.input_tokens !== undefined && + usageData.output_tokens !== undefined + ) { + const inputTokens = usageData.input_tokens || 0 + const outputTokens = usageData.output_tokens || 0 + // 兼容处理:如果有详细的 cache_creation 对象,使用它;否则使用总的 cache_creation_input_tokens + let cacheCreateTokens = usageData.cache_creation_input_tokens || 0 + let ephemeral5mTokens = 0 + let ephemeral1hTokens = 0 + + if (usageData.cache_creation && typeof usageData.cache_creation === 'object') { + ephemeral5mTokens = usageData.cache_creation.ephemeral_5m_input_tokens || 0 + ephemeral1hTokens = usageData.cache_creation.ephemeral_1h_input_tokens || 0 + // 总的缓存创建 tokens 是两者之和 + cacheCreateTokens = ephemeral5mTokens + ephemeral1hTokens + } + + const cacheReadTokens = usageData.cache_read_input_tokens || 0 + const model = usageData.model || 'unknown' + + // 记录真实的token使用量(包含模型信息和所有4种token以及账户ID) + const { accountId: usageAccountId } = usageData + + // 构建 usage 对象以传递给 recordUsage + const usageObject = { + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_creation_input_tokens: cacheCreateTokens, + cache_read_input_tokens: cacheReadTokens + } + + // 如果有详细的缓存创建数据,添加到 usage 对象中 + if (ephemeral5mTokens > 0 || ephemeral1hTokens > 0) { + usageObject.cache_creation = { + ephemeral_5m_input_tokens: ephemeral5mTokens, + ephemeral_1h_input_tokens: ephemeral1hTokens + } + } + + apiKeyService + .recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId, 'claude') + .catch((error) => { + logger.error('❌ Failed to record stream usage:', error) + }) + + queueRateLimitUpdate( + req.rateLimitInfo, + { + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens + }, + model, + 'claude-stream' + ) + + usageDataCaptured = true + logger.api( + `📊 Stream usage recorded (real) - Model: ${model}, Input: ${inputTokens}, Output: ${outputTokens}, Cache Create: ${cacheCreateTokens}, Cache Read: ${cacheReadTokens}, Total: ${inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens} tokens` + ) + } else { + logger.warn( + '⚠️ Usage callback triggered but data is incomplete:', + JSON.stringify(usageData) + ) + } + } + ) + } else if (accountType === 'claude-console') { + // Claude Console账号使用Console转发服务(需要传递accountId) + await claudeConsoleRelayService.relayStreamRequestWithUsageCapture( + req.body, + req.apiKey, + res, + req.headers, + (usageData) => { + // 回调函数:当检测到完整usage数据时记录真实token使用量 + logger.info( + '🎯 Usage callback triggered with complete data:', + JSON.stringify(usageData, null, 2) + ) + + if ( + usageData && + usageData.input_tokens !== undefined && + usageData.output_tokens !== undefined + ) { + const inputTokens = usageData.input_tokens || 0 + const outputTokens = usageData.output_tokens || 0 + // 兼容处理:如果有详细的 cache_creation 对象,使用它;否则使用总的 cache_creation_input_tokens + let cacheCreateTokens = usageData.cache_creation_input_tokens || 0 + let ephemeral5mTokens = 0 + let ephemeral1hTokens = 0 + + if (usageData.cache_creation && typeof usageData.cache_creation === 'object') { + ephemeral5mTokens = usageData.cache_creation.ephemeral_5m_input_tokens || 0 + ephemeral1hTokens = usageData.cache_creation.ephemeral_1h_input_tokens || 0 + // 总的缓存创建 tokens 是两者之和 + cacheCreateTokens = ephemeral5mTokens + ephemeral1hTokens + } + + const cacheReadTokens = usageData.cache_read_input_tokens || 0 + const model = usageData.model || 'unknown' + + // 记录真实的token使用量(包含模型信息和所有4种token以及账户ID) + const usageAccountId = usageData.accountId + + // 构建 usage 对象以传递给 recordUsage + const usageObject = { + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_creation_input_tokens: cacheCreateTokens, + cache_read_input_tokens: cacheReadTokens + } + + // 如果有详细的缓存创建数据,添加到 usage 对象中 + if (ephemeral5mTokens > 0 || ephemeral1hTokens > 0) { + usageObject.cache_creation = { + ephemeral_5m_input_tokens: ephemeral5mTokens, + ephemeral_1h_input_tokens: ephemeral1hTokens + } + } + + apiKeyService + .recordUsageWithDetails( + req.apiKey.id, + usageObject, + model, + usageAccountId, + 'claude-console' + ) + .catch((error) => { + logger.error('❌ Failed to record stream usage:', error) + }) + + queueRateLimitUpdate( + req.rateLimitInfo, + { + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens + }, + model, + 'claude-console-stream' + ) + + usageDataCaptured = true + logger.api( + `📊 Stream usage recorded (real) - Model: ${model}, Input: ${inputTokens}, Output: ${outputTokens}, Cache Create: ${cacheCreateTokens}, Cache Read: ${cacheReadTokens}, Total: ${inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens} tokens` + ) + } else { + logger.warn( + '⚠️ Usage callback triggered but data is incomplete:', + JSON.stringify(usageData) + ) + } + }, + accountId + ) + } else if (accountType === 'bedrock') { + // Bedrock账号使用Bedrock转发服务 + try { + const bedrockAccountResult = await bedrockAccountService.getAccount(accountId) + if (!bedrockAccountResult.success) { + throw new Error('Failed to get Bedrock account details') + } + + const result = await bedrockRelayService.handleStreamRequest( + req.body, + bedrockAccountResult.data, + res + ) + + // 记录Bedrock使用统计 + if (result.usage) { + const inputTokens = result.usage.input_tokens || 0 + const outputTokens = result.usage.output_tokens || 0 + + apiKeyService + .recordUsage(req.apiKey.id, inputTokens, outputTokens, 0, 0, result.model, accountId) + .catch((error) => { + logger.error('❌ Failed to record Bedrock stream usage:', error) + }) + + queueRateLimitUpdate( + req.rateLimitInfo, + { + inputTokens, + outputTokens, + cacheCreateTokens: 0, + cacheReadTokens: 0 + }, + result.model, + 'bedrock-stream' + ) + + usageDataCaptured = true + logger.api( + `📊 Bedrock stream usage recorded - Model: ${result.model}, Input: ${inputTokens}, Output: ${outputTokens}, Total: ${inputTokens + outputTokens} tokens` + ) + } + } catch (error) { + logger.error('❌ Bedrock stream request failed:', error) + if (!res.headersSent) { + return res.status(500).json({ error: 'Bedrock service error', message: error.message }) + } + return undefined + } + } else if (accountType === 'ccr') { + // CCR账号使用CCR转发服务(需要传递accountId) + await ccrRelayService.relayStreamRequestWithUsageCapture( + req.body, + req.apiKey, + res, + req.headers, + (usageData) => { + // 回调函数:当检测到完整usage数据时记录真实token使用量 + logger.info( + '🎯 CCR usage callback triggered with complete data:', + JSON.stringify(usageData, null, 2) + ) + + if ( + usageData && + usageData.input_tokens !== undefined && + usageData.output_tokens !== undefined + ) { + const inputTokens = usageData.input_tokens || 0 + const outputTokens = usageData.output_tokens || 0 + // 兼容处理:如果有详细的 cache_creation 对象,使用它;否则使用总的 cache_creation_input_tokens + let cacheCreateTokens = usageData.cache_creation_input_tokens || 0 + let ephemeral5mTokens = 0 + let ephemeral1hTokens = 0 + + if (usageData.cache_creation && typeof usageData.cache_creation === 'object') { + ephemeral5mTokens = usageData.cache_creation.ephemeral_5m_input_tokens || 0 + ephemeral1hTokens = usageData.cache_creation.ephemeral_1h_input_tokens || 0 + // 总的缓存创建 tokens 是两者之和 + cacheCreateTokens = ephemeral5mTokens + ephemeral1hTokens + } + + const cacheReadTokens = usageData.cache_read_input_tokens || 0 + const model = usageData.model || 'unknown' + + // 记录真实的token使用量(包含模型信息和所有4种token以及账户ID) + const usageAccountId = usageData.accountId + + // 构建 usage 对象以传递给 recordUsage + const usageObject = { + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_creation_input_tokens: cacheCreateTokens, + cache_read_input_tokens: cacheReadTokens + } + + // 如果有详细的缓存创建数据,添加到 usage 对象中 + if (ephemeral5mTokens > 0 || ephemeral1hTokens > 0) { + usageObject.cache_creation = { + ephemeral_5m_input_tokens: ephemeral5mTokens, + ephemeral_1h_input_tokens: ephemeral1hTokens + } + } + + apiKeyService + .recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId, 'ccr') + .catch((error) => { + logger.error('❌ Failed to record CCR stream usage:', error) + }) + + queueRateLimitUpdate( + req.rateLimitInfo, + { + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens + }, + model, + 'ccr-stream' + ) + + usageDataCaptured = true + logger.api( + `📊 CCR stream usage recorded (real) - Model: ${model}, Input: ${inputTokens}, Output: ${outputTokens}, Cache Create: ${cacheCreateTokens}, Cache Read: ${cacheReadTokens}, Total: ${inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens} tokens` + ) + } else { + logger.warn( + '⚠️ CCR usage callback triggered but data is incomplete:', + JSON.stringify(usageData) + ) + } + }, + accountId + ) + } + + // 流式请求完成后 - 如果没有捕获到usage数据,记录警告但不进行估算 + setTimeout(() => { + if (!usageDataCaptured) { + logger.warn( + '⚠️ No usage data captured from SSE stream - no statistics recorded (official data only)' + ) + } + }, 1000) // 1秒后检查 + } else { + // 非流式响应 - 只使用官方真实usage数据 + logger.info('📄 Starting non-streaming request', { + apiKeyId: req.apiKey.id, + apiKeyName: req.apiKey.name + }) + + // 生成会话哈希用于sticky会话 + const sessionHash = sessionHelper.generateSessionHash(req.body) + + // 使用统一调度选择账号(传递请求的模型) + const requestedModel = req.body.model + let accountId + let accountType + try { + const selection = await unifiedClaudeScheduler.selectAccountForApiKey( + req.apiKey, + sessionHash, + requestedModel + ) + ;({ accountId, accountType } = selection) + } catch (error) { + if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') { + const limitMessage = claudeRelayService._buildStandardRateLimitMessage( + error.rateLimitEndAt + ) + return res.status(403).json({ + error: 'upstream_rate_limited', + message: limitMessage + }) + } + throw error + } + + // 根据账号类型选择对应的转发服务 + let response + logger.debug(`[DEBUG] Request query params: ${JSON.stringify(req.query)}`) + logger.debug(`[DEBUG] Request URL: ${req.url}`) + logger.debug(`[DEBUG] Request path: ${req.path}`) + + if (accountType === 'claude-official') { + // 官方Claude账号使用原有的转发服务 + response = await claudeRelayService.relayRequest( + req.body, + req.apiKey, + req, + res, + req.headers + ) + } else if (accountType === 'claude-console') { + // Claude Console账号使用Console转发服务 + logger.debug( + `[DEBUG] Calling claudeConsoleRelayService.relayRequest with accountId: ${accountId}` + ) + response = await claudeConsoleRelayService.relayRequest( + req.body, + req.apiKey, + req, + res, + req.headers, + accountId + ) + } else if (accountType === 'bedrock') { + // Bedrock账号使用Bedrock转发服务 + try { + const bedrockAccountResult = await bedrockAccountService.getAccount(accountId) + if (!bedrockAccountResult.success) { + throw new Error('Failed to get Bedrock account details') + } + + const result = await bedrockRelayService.handleNonStreamRequest( + req.body, + bedrockAccountResult.data, + req.headers + ) + + // 构建标准响应格式 + response = { + statusCode: result.success ? 200 : 500, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(result.success ? result.data : { error: result.error }), + accountId + } + + // 如果成功,添加使用统计到响应数据中 + if (result.success && result.usage) { + const responseData = JSON.parse(response.body) + responseData.usage = result.usage + response.body = JSON.stringify(responseData) + } + } catch (error) { + logger.error('❌ Bedrock non-stream request failed:', error) + response = { + statusCode: 500, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ error: 'Bedrock service error', message: error.message }), + accountId + } + } + } else if (accountType === 'ccr') { + // CCR账号使用CCR转发服务 + logger.debug(`[DEBUG] Calling ccrRelayService.relayRequest with accountId: ${accountId}`) + response = await ccrRelayService.relayRequest( + req.body, + req.apiKey, + req, + res, + req.headers, + accountId + ) + } + + logger.info('📡 Claude API response received', { + statusCode: response.statusCode, + headers: JSON.stringify(response.headers), + bodyLength: response.body ? response.body.length : 0 + }) + + res.status(response.statusCode) + + // 设置响应头,避免 Content-Length 和 Transfer-Encoding 冲突 + const skipHeaders = ['content-encoding', 'transfer-encoding', 'content-length'] + Object.keys(response.headers).forEach((key) => { + if (!skipHeaders.includes(key.toLowerCase())) { + res.setHeader(key, response.headers[key]) + } + }) + + let usageRecorded = false + + // 尝试解析JSON响应并提取usage信息 + try { + const jsonData = JSON.parse(response.body) + + logger.info('📊 Parsed Claude API response:', JSON.stringify(jsonData, null, 2)) + + // 从Claude API响应中提取usage信息(完整的token分类体系) + if ( + jsonData.usage && + jsonData.usage.input_tokens !== undefined && + jsonData.usage.output_tokens !== undefined + ) { + const inputTokens = jsonData.usage.input_tokens || 0 + const outputTokens = jsonData.usage.output_tokens || 0 + const cacheCreateTokens = jsonData.usage.cache_creation_input_tokens || 0 + const cacheReadTokens = jsonData.usage.cache_read_input_tokens || 0 + // Parse the model to remove vendor prefix if present (e.g., "ccr,gemini-2.5-pro" -> "gemini-2.5-pro") + const rawModel = jsonData.model || req.body.model || 'unknown' + const { baseModel } = parseVendorPrefixedModel(rawModel) + const model = baseModel || rawModel + + // 记录真实的token使用量(包含模型信息和所有4种token以及账户ID) + const { accountId: responseAccountId } = response + await apiKeyService.recordUsage( + req.apiKey.id, + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens, + model, + responseAccountId + ) + + await queueRateLimitUpdate( + req.rateLimitInfo, + { + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens + }, + model, + 'claude-non-stream' + ) + + usageRecorded = true + logger.api( + `📊 Non-stream usage recorded (real) - Model: ${model}, Input: ${inputTokens}, Output: ${outputTokens}, Cache Create: ${cacheCreateTokens}, Cache Read: ${cacheReadTokens}, Total: ${inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens} tokens` + ) + } else { + logger.warn('⚠️ No usage data found in Claude API JSON response') + } + + res.json(jsonData) + } catch (parseError) { + logger.warn('⚠️ Failed to parse Claude API response as JSON:', parseError.message) + logger.info('📄 Raw response body:', response.body) + res.send(response.body) + } + + // 如果没有记录usage,只记录警告,不进行估算 + if (!usageRecorded) { + logger.warn( + '⚠️ No usage data recorded for non-stream request - no statistics recorded (official data only)' + ) + } + } + + const duration = Date.now() - startTime + logger.api(`✅ Request completed in ${duration}ms for key: ${req.apiKey.name}`) + return undefined + } catch (error) { + logger.error('❌ Claude relay error:', error.message, { + code: error.code, + stack: error.stack + }) + + // 确保在任何情况下都能返回有效的JSON响应 + if (!res.headersSent) { + // 根据错误类型设置适当的状态码 + let statusCode = 500 + let errorType = 'Relay service error' + + if (error.message.includes('Connection reset') || error.message.includes('socket hang up')) { + statusCode = 502 + errorType = 'Upstream connection error' + } else if (error.message.includes('Connection refused')) { + statusCode = 502 + errorType = 'Upstream service unavailable' + } else if (error.message.includes('timeout')) { + statusCode = 504 + errorType = 'Upstream timeout' + } else if (error.message.includes('resolve') || error.message.includes('ENOTFOUND')) { + statusCode = 502 + errorType = 'Upstream hostname resolution failed' + } + + return res.status(statusCode).json({ + error: errorType, + message: error.message || 'An unexpected error occurred', + timestamp: new Date().toISOString() + }) + } else { + // 如果响应头已经发送,尝试结束响应 + if (!res.destroyed && !res.finished) { + res.end() + } + return undefined + } + } +} + +// 🚀 Claude API messages 端点 - /api/v1/messages +router.post('/v1/messages', authenticateApiKey, handleMessagesRequest) + +// 🚀 Claude API messages 端点 - /claude/v1/messages (别名) +router.post('/claude/v1/messages', authenticateApiKey, handleMessagesRequest) + +// 📋 模型列表端点 - Claude Code 客户端需要 +router.get('/v1/models', authenticateApiKey, async (req, res) => { + try { + // 返回支持的模型列表 + const models = [ + { + id: 'claude-3-5-sonnet-20241022', + object: 'model', + created: 1669599635, + owned_by: 'anthropic' + }, + { + id: 'claude-3-5-haiku-20241022', + object: 'model', + created: 1669599635, + owned_by: 'anthropic' + }, + { + id: 'claude-3-opus-20240229', + object: 'model', + created: 1669599635, + owned_by: 'anthropic' + }, + { + id: 'claude-sonnet-4-20250514', + object: 'model', + created: 1669599635, + owned_by: 'anthropic' + } + ] + + res.json({ + object: 'list', + data: models + }) + } catch (error) { + logger.error('❌ Models list error:', error) + res.status(500).json({ + error: 'Failed to get models list', + message: error.message + }) + } +}) + +// 🏥 健康检查端点 +router.get('/health', async (req, res) => { + try { + const healthStatus = await claudeRelayService.healthCheck() + + res.status(healthStatus.healthy ? 200 : 503).json({ + status: healthStatus.healthy ? 'healthy' : 'unhealthy', + service: 'claude-relay-service', + version: '1.0.0', + ...healthStatus + }) + } catch (error) { + logger.error('❌ Health check error:', error) + res.status(503).json({ + status: 'unhealthy', + service: 'claude-relay-service', + error: error.message, + timestamp: new Date().toISOString() + }) + } +}) + +// 📊 API Key状态检查端点 - /api/v1/key-info +router.get('/v1/key-info', authenticateApiKey, async (req, res) => { + try { + const usage = await apiKeyService.getUsageStats(req.apiKey.id) + + res.json({ + keyInfo: { + id: req.apiKey.id, + name: req.apiKey.name, + tokenLimit: req.apiKey.tokenLimit, + usage + }, + timestamp: new Date().toISOString() + }) + } catch (error) { + logger.error('❌ Key info error:', error) + res.status(500).json({ + error: 'Failed to get key info', + message: error.message + }) + } +}) + +// 📈 使用统计端点 - /api/v1/usage +router.get('/v1/usage', authenticateApiKey, async (req, res) => { + try { + const usage = await apiKeyService.getUsageStats(req.apiKey.id) + + res.json({ + usage, + limits: { + tokens: req.apiKey.tokenLimit, + requests: 0 // 请求限制已移除 + }, + timestamp: new Date().toISOString() + }) + } catch (error) { + logger.error('❌ Usage stats error:', error) + res.status(500).json({ + error: 'Failed to get usage stats', + message: error.message + }) + } +}) + +// 👤 用户信息端点 - Claude Code 客户端需要 +router.get('/v1/me', authenticateApiKey, async (req, res) => { + try { + // 返回基础用户信息 + res.json({ + id: `user_${req.apiKey.id}`, + type: 'user', + display_name: req.apiKey.name || 'API User', + created_at: new Date().toISOString() + }) + } catch (error) { + logger.error('❌ User info error:', error) + res.status(500).json({ + error: 'Failed to get user info', + message: error.message + }) + } +}) + +// 💰 余额/限制端点 - Claude Code 客户端需要 +router.get('/v1/organizations/:org_id/usage', authenticateApiKey, async (req, res) => { + try { + const usage = await apiKeyService.getUsageStats(req.apiKey.id) + + res.json({ + object: 'usage', + data: [ + { + type: 'credit_balance', + credit_balance: req.apiKey.tokenLimit - (usage.totalTokens || 0) + } + ] + }) + } catch (error) { + logger.error('❌ Organization usage error:', error) + res.status(500).json({ + error: 'Failed to get usage info', + message: error.message + }) + } +}) + +// 🔢 Token计数端点 - count_tokens beta API +router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) => { + try { + // 检查权限 + if ( + req.apiKey.permissions && + req.apiKey.permissions !== 'all' && + req.apiKey.permissions !== 'claude' + ) { + return res.status(403).json({ + error: { + type: 'permission_error', + message: 'This API key does not have permission to access Claude' + } + }) + } + + logger.info(`🔢 Processing token count request for key: ${req.apiKey.name}`) + + // 生成会话哈希用于sticky会话 + const sessionHash = sessionHelper.generateSessionHash(req.body) + + // 选择可用的Claude账户 + const requestedModel = req.body.model + const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey( + req.apiKey, + sessionHash, + requestedModel + ) + + let response + if (accountType === 'claude-official') { + // 使用官方Claude账号转发count_tokens请求 + response = await claudeRelayService.relayRequest( + req.body, + req.apiKey, + req, + res, + req.headers, + { + skipUsageRecord: true, // 跳过usage记录,这只是计数请求 + customPath: '/v1/messages/count_tokens' // 指定count_tokens路径 + } + ) + } else if (accountType === 'claude-console') { + // 使用Console Claude账号转发count_tokens请求 + response = await claudeConsoleRelayService.relayRequest( + req.body, + req.apiKey, + req, + res, + req.headers, + accountId, + { + skipUsageRecord: true, // 跳过usage记录,这只是计数请求 + customPath: '/v1/messages/count_tokens' // 指定count_tokens路径 + } + ) + } else if (accountType === 'ccr') { + // CCR不支持count_tokens + return res.status(501).json({ + error: { + type: 'not_supported', + message: 'Token counting is not supported for CCR accounts' + } + }) + } else { + // Bedrock不支持count_tokens + return res.status(501).json({ + error: { + type: 'not_supported', + message: 'Token counting is not supported for Bedrock accounts' + } + }) + } + + // 直接返回响应,不记录token使用量 + res.status(response.statusCode) + + // 设置响应头 + const skipHeaders = ['content-encoding', 'transfer-encoding', 'content-length'] + Object.keys(response.headers).forEach((key) => { + if (!skipHeaders.includes(key.toLowerCase())) { + res.setHeader(key, response.headers[key]) + } + }) + + // 尝试解析并返回JSON响应 + try { + const jsonData = JSON.parse(response.body) + res.json(jsonData) + } catch (parseError) { + res.send(response.body) + } + + logger.info(`✅ Token count request completed for key: ${req.apiKey.name}`) + } catch (error) { + logger.error('❌ Token count error:', error) + res.status(500).json({ + error: { + type: 'server_error', + message: 'Failed to count tokens' + } + }) + } +}) + +module.exports = router +module.exports.handleMessagesRequest = handleMessagesRequest diff --git a/src/routes/apiStats.js b/src/routes/apiStats.js new file mode 100644 index 0000000000000000000000000000000000000000..322a9e3ca91a890c9e704dac0c166ed2f768dde9 --- /dev/null +++ b/src/routes/apiStats.js @@ -0,0 +1,943 @@ +const express = require('express') +const redis = require('../models/redis') +const logger = require('../utils/logger') +const apiKeyService = require('../services/apiKeyService') +const CostCalculator = require('../utils/costCalculator') +const claudeAccountService = require('../services/claudeAccountService') +const openaiAccountService = require('../services/openaiAccountService') + +const router = express.Router() + +// 🏠 重定向页面请求到新版 admin-spa +router.get('/', (req, res) => { + res.redirect(301, '/admin-next/api-stats') +}) + +// 🔑 获取 API Key 对应的 ID +router.post('/api/get-key-id', async (req, res) => { + try { + const { apiKey } = req.body + + if (!apiKey) { + return res.status(400).json({ + error: 'API Key is required', + message: 'Please provide your API Key' + }) + } + + // 基本API Key格式验证 + if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) { + return res.status(400).json({ + error: 'Invalid API key format', + message: 'API key format is invalid' + }) + } + + // 验证API Key(使用不触发激活的验证方法) + const validation = await apiKeyService.validateApiKeyForStats(apiKey) + + if (!validation.valid) { + const clientIP = req.ip || req.connection?.remoteAddress || 'unknown' + logger.security(`🔒 Invalid API key in get-key-id: ${validation.error} from ${clientIP}`) + return res.status(401).json({ + error: 'Invalid API key', + message: validation.error + }) + } + + const { keyData } = validation + + return res.json({ + success: true, + data: { + id: keyData.id + } + }) + } catch (error) { + logger.error('❌ Failed to get API key ID:', error) + return res.status(500).json({ + error: 'Internal server error', + message: 'Failed to retrieve API key ID' + }) + } +}) + +// 📊 用户API Key统计查询接口 - 安全的自查询接口 +router.post('/api/user-stats', async (req, res) => { + try { + const { apiKey, apiId } = req.body + + let keyData + let keyId + + if (apiId) { + // 通过 apiId 查询 + if ( + typeof apiId !== 'string' || + !apiId.match(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i) + ) { + return res.status(400).json({ + error: 'Invalid API ID format', + message: 'API ID must be a valid UUID' + }) + } + + // 直接通过 ID 获取 API Key 数据 + keyData = await redis.getApiKey(apiId) + + if (!keyData || Object.keys(keyData).length === 0) { + logger.security(`🔒 API key not found for ID: ${apiId} from ${req.ip || 'unknown'}`) + return res.status(404).json({ + error: 'API key not found', + message: 'The specified API key does not exist' + }) + } + + // 检查是否激活 + if (keyData.isActive !== 'true') { + return res.status(403).json({ + error: 'API key is disabled', + message: 'This API key has been disabled' + }) + } + + // 检查是否过期 + if (keyData.expiresAt && new Date() > new Date(keyData.expiresAt)) { + return res.status(403).json({ + error: 'API key has expired', + message: 'This API key has expired' + }) + } + + keyId = apiId + + // 获取使用统计 + const usage = await redis.getUsageStats(keyId) + + // 获取当日费用统计 + const dailyCost = await redis.getDailyCost(keyId) + const costStats = await redis.getCostStats(keyId) + + // 处理数据格式,与 validateApiKey 返回的格式保持一致 + // 解析限制模型数据 + let restrictedModels = [] + try { + restrictedModels = keyData.restrictedModels ? JSON.parse(keyData.restrictedModels) : [] + } catch (e) { + restrictedModels = [] + } + + // 解析允许的客户端数据 + let allowedClients = [] + try { + allowedClients = keyData.allowedClients ? JSON.parse(keyData.allowedClients) : [] + } catch (e) { + allowedClients = [] + } + + // 格式化 keyData + keyData = { + ...keyData, + tokenLimit: parseInt(keyData.tokenLimit) || 0, + concurrencyLimit: parseInt(keyData.concurrencyLimit) || 0, + rateLimitWindow: parseInt(keyData.rateLimitWindow) || 0, + rateLimitRequests: parseInt(keyData.rateLimitRequests) || 0, + dailyCostLimit: parseFloat(keyData.dailyCostLimit) || 0, + totalCostLimit: parseFloat(keyData.totalCostLimit) || 0, + dailyCost: dailyCost || 0, + totalCost: costStats.total || 0, + enableModelRestriction: keyData.enableModelRestriction === 'true', + restrictedModels, + enableClientRestriction: keyData.enableClientRestriction === 'true', + allowedClients, + permissions: keyData.permissions || 'all', + // 添加激活相关字段 + expirationMode: keyData.expirationMode || 'fixed', + isActivated: keyData.isActivated === 'true', + activationDays: parseInt(keyData.activationDays || 0), + activatedAt: keyData.activatedAt || null, + usage // 使用完整的 usage 数据,而不是只有 total + } + } else if (apiKey) { + // 通过 apiKey 查询(保持向后兼容) + if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) { + logger.security(`🔒 Invalid API key format in user stats query from ${req.ip || 'unknown'}`) + return res.status(400).json({ + error: 'Invalid API key format', + message: 'API key format is invalid' + }) + } + + // 验证API Key(使用不触发激活的验证方法) + const validation = await apiKeyService.validateApiKeyForStats(apiKey) + + if (!validation.valid) { + const clientIP = req.ip || req.connection?.remoteAddress || 'unknown' + logger.security( + `🔒 Invalid API key in user stats query: ${validation.error} from ${clientIP}` + ) + return res.status(401).json({ + error: 'Invalid API key', + message: validation.error + }) + } + + const { keyData: validatedKeyData } = validation + keyData = validatedKeyData + keyId = keyData.id + } else { + logger.security(`🔒 Missing API key or ID in user stats query from ${req.ip || 'unknown'}`) + return res.status(400).json({ + error: 'API Key or ID is required', + message: 'Please provide your API Key or API ID' + }) + } + + // 记录合法查询 + logger.api( + `📊 User stats query from key: ${keyData.name} (${keyId}) from ${req.ip || 'unknown'}` + ) + + // 获取验证结果中的完整keyData(包含isActive状态和cost信息) + const fullKeyData = keyData + + // 计算总费用 - 使用与模型统计相同的逻辑(按模型分别计算) + let totalCost = 0 + let formattedCost = '$0.000000' + + try { + const client = redis.getClientSafe() + + // 获取所有月度模型统计(与model-stats接口相同的逻辑) + const allModelKeys = await client.keys(`usage:${keyId}:model:monthly:*:*`) + const modelUsageMap = new Map() + + for (const key of allModelKeys) { + const modelMatch = key.match(/usage:.+:model:monthly:(.+):(\d{4}-\d{2})$/) + if (!modelMatch) { + continue + } + + const model = modelMatch[1] + const data = await client.hgetall(key) + + if (data && Object.keys(data).length > 0) { + if (!modelUsageMap.has(model)) { + modelUsageMap.set(model, { + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0 + }) + } + + const modelUsage = modelUsageMap.get(model) + modelUsage.inputTokens += parseInt(data.inputTokens) || 0 + modelUsage.outputTokens += parseInt(data.outputTokens) || 0 + modelUsage.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0 + modelUsage.cacheReadTokens += parseInt(data.cacheReadTokens) || 0 + } + } + + // 按模型计算费用并汇总 + for (const [model, usage] of modelUsageMap) { + const usageData = { + input_tokens: usage.inputTokens, + output_tokens: usage.outputTokens, + cache_creation_input_tokens: usage.cacheCreateTokens, + cache_read_input_tokens: usage.cacheReadTokens + } + + const costResult = CostCalculator.calculateCost(usageData, model) + totalCost += costResult.costs.total + } + + // 如果没有模型级别的详细数据,回退到总体数据计算 + if (modelUsageMap.size === 0 && fullKeyData.usage?.total?.allTokens > 0) { + const usage = fullKeyData.usage.total + const costUsage = { + input_tokens: usage.inputTokens || 0, + output_tokens: usage.outputTokens || 0, + cache_creation_input_tokens: usage.cacheCreateTokens || 0, + cache_read_input_tokens: usage.cacheReadTokens || 0 + } + + const costResult = CostCalculator.calculateCost(costUsage, 'claude-3-5-sonnet-20241022') + totalCost = costResult.costs.total + } + + formattedCost = CostCalculator.formatCost(totalCost) + } catch (error) { + logger.warn(`Failed to calculate detailed cost for key ${keyId}:`, error) + // 回退到简单计算 + if (fullKeyData.usage?.total?.allTokens > 0) { + const usage = fullKeyData.usage.total + const costUsage = { + input_tokens: usage.inputTokens || 0, + output_tokens: usage.outputTokens || 0, + cache_creation_input_tokens: usage.cacheCreateTokens || 0, + cache_read_input_tokens: usage.cacheReadTokens || 0 + } + + const costResult = CostCalculator.calculateCost(costUsage, 'claude-3-5-sonnet-20241022') + totalCost = costResult.costs.total + formattedCost = costResult.formatted.total + } + } + + // 获取当前使用量 + let currentWindowRequests = 0 + let currentWindowTokens = 0 + let currentWindowCost = 0 // 新增:当前窗口费用 + let currentDailyCost = 0 + let windowStartTime = null + let windowEndTime = null + let windowRemainingSeconds = null + + try { + // 获取当前时间窗口的请求次数、Token使用量和费用 + if (fullKeyData.rateLimitWindow > 0) { + const client = redis.getClientSafe() + const requestCountKey = `rate_limit:requests:${keyId}` + const tokenCountKey = `rate_limit:tokens:${keyId}` + const costCountKey = `rate_limit:cost:${keyId}` // 新增:费用计数key + const windowStartKey = `rate_limit:window_start:${keyId}` + + currentWindowRequests = parseInt((await client.get(requestCountKey)) || '0') + currentWindowTokens = parseInt((await client.get(tokenCountKey)) || '0') + currentWindowCost = parseFloat((await client.get(costCountKey)) || '0') // 新增:获取当前窗口费用 + + // 获取窗口开始时间和计算剩余时间 + const windowStart = await client.get(windowStartKey) + if (windowStart) { + const now = Date.now() + windowStartTime = parseInt(windowStart) + const windowDuration = fullKeyData.rateLimitWindow * 60 * 1000 // 转换为毫秒 + windowEndTime = windowStartTime + windowDuration + + // 如果窗口还有效 + if (now < windowEndTime) { + windowRemainingSeconds = Math.max(0, Math.floor((windowEndTime - now) / 1000)) + } else { + // 窗口已过期,下次请求会重置 + windowStartTime = null + windowEndTime = null + windowRemainingSeconds = 0 + // 重置计数为0,因为窗口已过期 + currentWindowRequests = 0 + currentWindowTokens = 0 + currentWindowCost = 0 // 新增:重置窗口费用 + } + } + } + + // 获取当日费用 + currentDailyCost = (await redis.getDailyCost(keyId)) || 0 + } catch (error) { + logger.warn(`Failed to get current usage for key ${keyId}:`, error) + } + + const boundAccountDetails = {} + + const accountDetailTasks = [] + + if (fullKeyData.claudeAccountId) { + accountDetailTasks.push( + (async () => { + try { + const overview = await claudeAccountService.getAccountOverview( + fullKeyData.claudeAccountId + ) + + if (overview && overview.accountType === 'dedicated') { + boundAccountDetails.claude = overview + } + } catch (error) { + logger.warn(`⚠️ Failed to load Claude account overview for key ${keyId}:`, error) + } + })() + ) + } + + if (fullKeyData.openaiAccountId) { + accountDetailTasks.push( + (async () => { + try { + const overview = await openaiAccountService.getAccountOverview( + fullKeyData.openaiAccountId + ) + + if (overview && overview.accountType === 'dedicated') { + boundAccountDetails.openai = overview + } + } catch (error) { + logger.warn(`⚠️ Failed to load OpenAI account overview for key ${keyId}:`, error) + } + })() + ) + } + + if (accountDetailTasks.length > 0) { + await Promise.allSettled(accountDetailTasks) + } + + // 构建响应数据(只返回该API Key自己的信息,确保不泄露其他信息) + const responseData = { + id: keyId, + name: fullKeyData.name, + description: fullKeyData.description || keyData.description || '', + isActive: true, // 如果能通过validateApiKey验证,说明一定是激活的 + createdAt: fullKeyData.createdAt || keyData.createdAt, + expiresAt: fullKeyData.expiresAt || keyData.expiresAt, + // 添加激活相关字段 + expirationMode: fullKeyData.expirationMode || 'fixed', + isActivated: fullKeyData.isActivated === true || fullKeyData.isActivated === 'true', + activationDays: parseInt(fullKeyData.activationDays || 0), + activatedAt: fullKeyData.activatedAt || null, + permissions: fullKeyData.permissions, + + // 使用统计(使用验证结果中的完整数据) + usage: { + total: { + ...(fullKeyData.usage?.total || { + requests: 0, + tokens: 0, + allTokens: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0 + }), + cost: totalCost, + formattedCost + } + }, + + // 限制信息(显示配置和当前使用量) + limits: { + tokenLimit: fullKeyData.tokenLimit || 0, + concurrencyLimit: fullKeyData.concurrencyLimit || 0, + rateLimitWindow: fullKeyData.rateLimitWindow || 0, + rateLimitRequests: fullKeyData.rateLimitRequests || 0, + rateLimitCost: parseFloat(fullKeyData.rateLimitCost) || 0, // 新增:费用限制 + dailyCostLimit: fullKeyData.dailyCostLimit || 0, + totalCostLimit: fullKeyData.totalCostLimit || 0, + weeklyOpusCostLimit: parseFloat(fullKeyData.weeklyOpusCostLimit) || 0, // Opus 周费用限制 + // 当前使用量 + currentWindowRequests, + currentWindowTokens, + currentWindowCost, // 新增:当前窗口费用 + currentDailyCost, + currentTotalCost: totalCost, + weeklyOpusCost: (await redis.getWeeklyOpusCost(keyId)) || 0, // 当前 Opus 周费用 + // 时间窗口信息 + windowStartTime, + windowEndTime, + windowRemainingSeconds + }, + + // 绑定的账户信息(只显示ID,不显示敏感信息) + accounts: { + claudeAccountId: + fullKeyData.claudeAccountId && fullKeyData.claudeAccountId !== '' + ? fullKeyData.claudeAccountId + : null, + geminiAccountId: + fullKeyData.geminiAccountId && fullKeyData.geminiAccountId !== '' + ? fullKeyData.geminiAccountId + : null, + openaiAccountId: + fullKeyData.openaiAccountId && fullKeyData.openaiAccountId !== '' + ? fullKeyData.openaiAccountId + : null, + details: Object.keys(boundAccountDetails).length > 0 ? boundAccountDetails : null + }, + + // 模型和客户端限制信息 + restrictions: { + enableModelRestriction: fullKeyData.enableModelRestriction || false, + restrictedModels: fullKeyData.restrictedModels || [], + enableClientRestriction: fullKeyData.enableClientRestriction || false, + allowedClients: fullKeyData.allowedClients || [] + } + } + + return res.json({ + success: true, + data: responseData + }) + } catch (error) { + logger.error('❌ Failed to process user stats query:', error) + return res.status(500).json({ + error: 'Internal server error', + message: 'Failed to retrieve API key statistics' + }) + } +}) + +// 📊 批量查询统计数据接口 +router.post('/api/batch-stats', async (req, res) => { + try { + const { apiIds } = req.body + + // 验证输入 + if (!apiIds || !Array.isArray(apiIds) || apiIds.length === 0) { + return res.status(400).json({ + error: 'Invalid input', + message: 'API IDs array is required' + }) + } + + // 限制最多查询 30 个 + if (apiIds.length > 30) { + return res.status(400).json({ + error: 'Too many keys', + message: 'Maximum 30 API keys can be queried at once' + }) + } + + // 验证所有 ID 格式 + const uuidRegex = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i + const invalidIds = apiIds.filter((id) => !uuidRegex.test(id)) + if (invalidIds.length > 0) { + return res.status(400).json({ + error: 'Invalid API ID format', + message: `Invalid API IDs: ${invalidIds.join(', ')}` + }) + } + + const individualStats = [] + const aggregated = { + totalKeys: apiIds.length, + activeKeys: 0, + usage: { + requests: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + allTokens: 0, + cost: 0, + formattedCost: '$0.000000' + }, + dailyUsage: { + requests: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + allTokens: 0, + cost: 0, + formattedCost: '$0.000000' + }, + monthlyUsage: { + requests: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + allTokens: 0, + cost: 0, + formattedCost: '$0.000000' + } + } + + // 并行查询所有 API Key 数据(复用单key查询逻辑) + const results = await Promise.allSettled( + apiIds.map(async (apiId) => { + const keyData = await redis.getApiKey(apiId) + + if (!keyData || Object.keys(keyData).length === 0) { + return { error: 'Not found', apiId } + } + + // 检查是否激活 + if (keyData.isActive !== 'true') { + return { error: 'Disabled', apiId } + } + + // 检查是否过期 + if (keyData.expiresAt && new Date() > new Date(keyData.expiresAt)) { + return { error: 'Expired', apiId } + } + + // 复用单key查询的逻辑:获取使用统计 + const usage = await redis.getUsageStats(apiId) + + // 获取费用统计(与单key查询一致) + const costStats = await redis.getCostStats(apiId) + + return { + apiId, + name: keyData.name, + description: keyData.description || '', + isActive: true, + createdAt: keyData.createdAt, + usage: usage.total || {}, + dailyStats: { + ...usage.daily, + cost: costStats.daily + }, + monthlyStats: { + ...usage.monthly, + cost: costStats.monthly + }, + totalCost: costStats.total + } + }) + ) + + // 处理结果并聚合 + results.forEach((result) => { + if (result.status === 'fulfilled' && result.value && !result.value.error) { + const stats = result.value + aggregated.activeKeys++ + + // 聚合总使用量 + if (stats.usage) { + aggregated.usage.requests += stats.usage.requests || 0 + aggregated.usage.inputTokens += stats.usage.inputTokens || 0 + aggregated.usage.outputTokens += stats.usage.outputTokens || 0 + aggregated.usage.cacheCreateTokens += stats.usage.cacheCreateTokens || 0 + aggregated.usage.cacheReadTokens += stats.usage.cacheReadTokens || 0 + aggregated.usage.allTokens += stats.usage.allTokens || 0 + } + + // 聚合总费用 + aggregated.usage.cost += stats.totalCost || 0 + + // 聚合今日使用量 + aggregated.dailyUsage.requests += stats.dailyStats.requests || 0 + aggregated.dailyUsage.inputTokens += stats.dailyStats.inputTokens || 0 + aggregated.dailyUsage.outputTokens += stats.dailyStats.outputTokens || 0 + aggregated.dailyUsage.cacheCreateTokens += stats.dailyStats.cacheCreateTokens || 0 + aggregated.dailyUsage.cacheReadTokens += stats.dailyStats.cacheReadTokens || 0 + aggregated.dailyUsage.allTokens += stats.dailyStats.allTokens || 0 + aggregated.dailyUsage.cost += stats.dailyStats.cost || 0 + + // 聚合本月使用量 + aggregated.monthlyUsage.requests += stats.monthlyStats.requests || 0 + aggregated.monthlyUsage.inputTokens += stats.monthlyStats.inputTokens || 0 + aggregated.monthlyUsage.outputTokens += stats.monthlyStats.outputTokens || 0 + aggregated.monthlyUsage.cacheCreateTokens += stats.monthlyStats.cacheCreateTokens || 0 + aggregated.monthlyUsage.cacheReadTokens += stats.monthlyStats.cacheReadTokens || 0 + aggregated.monthlyUsage.allTokens += stats.monthlyStats.allTokens || 0 + aggregated.monthlyUsage.cost += stats.monthlyStats.cost || 0 + + // 添加到个体统计 + individualStats.push({ + apiId: stats.apiId, + name: stats.name, + isActive: true, + usage: stats.usage, + dailyUsage: { + ...stats.dailyStats, + formattedCost: CostCalculator.formatCost(stats.dailyStats.cost || 0) + }, + monthlyUsage: { + ...stats.monthlyStats, + formattedCost: CostCalculator.formatCost(stats.monthlyStats.cost || 0) + } + }) + } + }) + + // 格式化费用显示 + aggregated.usage.formattedCost = CostCalculator.formatCost(aggregated.usage.cost) + aggregated.dailyUsage.formattedCost = CostCalculator.formatCost(aggregated.dailyUsage.cost) + aggregated.monthlyUsage.formattedCost = CostCalculator.formatCost(aggregated.monthlyUsage.cost) + + logger.api(`📊 Batch stats query for ${apiIds.length} keys from ${req.ip || 'unknown'}`) + + return res.json({ + success: true, + data: { + aggregated, + individual: individualStats + } + }) + } catch (error) { + logger.error('❌ Failed to process batch stats query:', error) + return res.status(500).json({ + error: 'Internal server error', + message: 'Failed to retrieve batch statistics' + }) + } +}) + +// 📊 批量模型统计查询接口 +router.post('/api/batch-model-stats', async (req, res) => { + try { + const { apiIds, period = 'daily' } = req.body + + // 验证输入 + if (!apiIds || !Array.isArray(apiIds) || apiIds.length === 0) { + return res.status(400).json({ + error: 'Invalid input', + message: 'API IDs array is required' + }) + } + + // 限制最多查询 30 个 + if (apiIds.length > 30) { + return res.status(400).json({ + error: 'Too many keys', + message: 'Maximum 30 API keys can be queried at once' + }) + } + + const client = redis.getClientSafe() + const tzDate = redis.getDateInTimezone() + const today = redis.getDateStringInTimezone() + const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}` + + const modelUsageMap = new Map() + + // 并行查询所有 API Key 的模型统计 + await Promise.all( + apiIds.map(async (apiId) => { + const pattern = + period === 'daily' + ? `usage:${apiId}:model:daily:*:${today}` + : `usage:${apiId}:model:monthly:*:${currentMonth}` + + const keys = await client.keys(pattern) + + for (const key of keys) { + const match = key.match( + period === 'daily' + ? /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/ + : /usage:.+:model:monthly:(.+):\d{4}-\d{2}$/ + ) + + if (!match) { + continue + } + + const model = match[1] + const data = await client.hgetall(key) + + if (data && Object.keys(data).length > 0) { + if (!modelUsageMap.has(model)) { + modelUsageMap.set(model, { + requests: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + allTokens: 0 + }) + } + + const modelUsage = modelUsageMap.get(model) + modelUsage.requests += parseInt(data.requests) || 0 + modelUsage.inputTokens += parseInt(data.inputTokens) || 0 + modelUsage.outputTokens += parseInt(data.outputTokens) || 0 + modelUsage.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0 + modelUsage.cacheReadTokens += parseInt(data.cacheReadTokens) || 0 + modelUsage.allTokens += parseInt(data.allTokens) || 0 + } + } + }) + ) + + // 转换为数组并计算费用 + const modelStats = [] + for (const [model, usage] of modelUsageMap) { + const usageData = { + input_tokens: usage.inputTokens, + output_tokens: usage.outputTokens, + cache_creation_input_tokens: usage.cacheCreateTokens, + cache_read_input_tokens: usage.cacheReadTokens + } + + const costData = CostCalculator.calculateCost(usageData, model) + + modelStats.push({ + model, + requests: usage.requests, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + cacheCreateTokens: usage.cacheCreateTokens, + cacheReadTokens: usage.cacheReadTokens, + allTokens: usage.allTokens, + costs: costData.costs, + formatted: costData.formatted, + pricing: costData.pricing + }) + } + + // 按总 token 数降序排列 + modelStats.sort((a, b) => b.allTokens - a.allTokens) + + logger.api(`📊 Batch model stats query for ${apiIds.length} keys, period: ${period}`) + + return res.json({ + success: true, + data: modelStats, + period + }) + } catch (error) { + logger.error('❌ Failed to process batch model stats query:', error) + return res.status(500).json({ + error: 'Internal server error', + message: 'Failed to retrieve batch model statistics' + }) + } +}) + +// 📊 用户模型统计查询接口 - 安全的自查询接口 +router.post('/api/user-model-stats', async (req, res) => { + try { + const { apiKey, apiId, period = 'monthly' } = req.body + + let keyData + let keyId + + if (apiId) { + // 通过 apiId 查询 + if ( + typeof apiId !== 'string' || + !apiId.match(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i) + ) { + return res.status(400).json({ + error: 'Invalid API ID format', + message: 'API ID must be a valid UUID' + }) + } + + // 直接通过 ID 获取 API Key 数据 + keyData = await redis.getApiKey(apiId) + + if (!keyData || Object.keys(keyData).length === 0) { + logger.security(`🔒 API key not found for ID: ${apiId} from ${req.ip || 'unknown'}`) + return res.status(404).json({ + error: 'API key not found', + message: 'The specified API key does not exist' + }) + } + + // 检查是否激活 + if (keyData.isActive !== 'true') { + return res.status(403).json({ + error: 'API key is disabled', + message: 'This API key has been disabled' + }) + } + + keyId = apiId + + // 获取使用统计 + const usage = await redis.getUsageStats(keyId) + keyData.usage = { total: usage.total } + } else if (apiKey) { + // 通过 apiKey 查询(保持向后兼容) + // 验证API Key + const validation = await apiKeyService.validateApiKey(apiKey) + + if (!validation.valid) { + const clientIP = req.ip || req.connection?.remoteAddress || 'unknown' + logger.security( + `🔒 Invalid API key in user model stats query: ${validation.error} from ${clientIP}` + ) + return res.status(401).json({ + error: 'Invalid API key', + message: validation.error + }) + } + + const { keyData: validatedKeyData } = validation + keyData = validatedKeyData + keyId = keyData.id + } else { + logger.security( + `🔒 Missing API key or ID in user model stats query from ${req.ip || 'unknown'}` + ) + return res.status(400).json({ + error: 'API Key or ID is required', + message: 'Please provide your API Key or API ID' + }) + } + + logger.api( + `📊 User model stats query from key: ${keyData.name} (${keyId}) for period: ${period}` + ) + + // 重用管理后台的模型统计逻辑,但只返回该API Key的数据 + const client = redis.getClientSafe() + // 使用与管理页面相同的时区处理逻辑 + const tzDate = redis.getDateInTimezone() + const today = redis.getDateStringInTimezone() + const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}` + + const pattern = + period === 'daily' + ? `usage:${keyId}:model:daily:*:${today}` + : `usage:${keyId}:model:monthly:*:${currentMonth}` + + const keys = await client.keys(pattern) + const modelStats = [] + + for (const key of keys) { + const match = key.match( + period === 'daily' + ? /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/ + : /usage:.+:model:monthly:(.+):\d{4}-\d{2}$/ + ) + + if (!match) { + continue + } + + const model = match[1] + const data = await client.hgetall(key) + + if (data && Object.keys(data).length > 0) { + 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 costData = CostCalculator.calculateCost(usage, model) + + modelStats.push({ + model, + requests: parseInt(data.requests) || 0, + inputTokens: usage.input_tokens, + outputTokens: usage.output_tokens, + cacheCreateTokens: usage.cache_creation_input_tokens, + cacheReadTokens: usage.cache_read_input_tokens, + allTokens: parseInt(data.allTokens) || 0, + costs: costData.costs, + formatted: costData.formatted, + pricing: costData.pricing + }) + } + } + + // 如果没有详细的模型数据,不显示历史数据以避免混淆 + // 只有在查询特定时间段时返回空数组,表示该时间段确实没有数据 + if (modelStats.length === 0) { + logger.info(`📊 No model stats found for key ${keyId} in period ${period}`) + } + + // 按总token数降序排列 + modelStats.sort((a, b) => b.allTokens - a.allTokens) + + return res.json({ + success: true, + data: modelStats, + period + }) + } catch (error) { + logger.error('❌ Failed to process user model stats query:', error) + return res.status(500).json({ + error: 'Internal server error', + message: 'Failed to retrieve model statistics' + }) + } +}) + +module.exports = router diff --git a/src/routes/azureOpenaiRoutes.js b/src/routes/azureOpenaiRoutes.js new file mode 100644 index 0000000000000000000000000000000000000000..ca0aa8fe93fbe4ee4f524a24cfc00c393e9c77f7 --- /dev/null +++ b/src/routes/azureOpenaiRoutes.js @@ -0,0 +1,414 @@ +const express = require('express') +const router = express.Router() +const logger = require('../utils/logger') +const { authenticateApiKey } = require('../middleware/auth') +const azureOpenaiAccountService = require('../services/azureOpenaiAccountService') +const azureOpenaiRelayService = require('../services/azureOpenaiRelayService') +const apiKeyService = require('../services/apiKeyService') +const crypto = require('crypto') + +// 支持的模型列表 - 基于真实的 Azure OpenAI 模型 +const ALLOWED_MODELS = { + CHAT_MODELS: [ + 'gpt-4', + 'gpt-4-turbo', + 'gpt-4o', + 'gpt-4o-mini', + 'gpt-5', + 'gpt-5-mini', + 'gpt-35-turbo', + 'gpt-35-turbo-16k', + 'codex-mini' + ], + EMBEDDING_MODELS: ['text-embedding-ada-002', 'text-embedding-3-small', 'text-embedding-3-large'] +} + +const ALL_ALLOWED_MODELS = [...ALLOWED_MODELS.CHAT_MODELS, ...ALLOWED_MODELS.EMBEDDING_MODELS] + +// Azure OpenAI 稳定 API 版本 +// const AZURE_API_VERSION = '2024-02-01' // 当前未使用,保留以备后用 + +// 原子使用统计报告器 +class AtomicUsageReporter { + constructor() { + this.reportedUsage = new Set() + this.pendingReports = new Map() + } + + async reportOnce(requestId, usageData, apiKeyId, modelToRecord, accountId) { + if (this.reportedUsage.has(requestId)) { + logger.debug(`Usage already reported for request: ${requestId}`) + return false + } + + // 防止并发重复报告 + if (this.pendingReports.has(requestId)) { + return this.pendingReports.get(requestId) + } + + const reportPromise = this._performReport( + requestId, + usageData, + apiKeyId, + modelToRecord, + accountId + ) + this.pendingReports.set(requestId, reportPromise) + + try { + const result = await reportPromise + this.reportedUsage.add(requestId) + return result + } finally { + this.pendingReports.delete(requestId) + // 清理过期的已报告记录 + setTimeout(() => this.reportedUsage.delete(requestId), 60 * 1000) // 1分钟后清理 + } + } + + async _performReport(requestId, usageData, apiKeyId, modelToRecord, accountId) { + try { + const inputTokens = usageData.prompt_tokens || usageData.input_tokens || 0 + const outputTokens = usageData.completion_tokens || usageData.output_tokens || 0 + const cacheCreateTokens = + usageData.prompt_tokens_details?.cache_creation_tokens || + usageData.input_tokens_details?.cache_creation_tokens || + 0 + const cacheReadTokens = + usageData.prompt_tokens_details?.cached_tokens || + usageData.input_tokens_details?.cached_tokens || + 0 + + await apiKeyService.recordUsage( + apiKeyId, + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens, + modelToRecord, + accountId + ) + + // 同步更新 Azure 账户的 lastUsedAt 和累计使用量 + try { + const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + if (accountId) { + await azureOpenaiAccountService.updateAccountUsage(accountId, totalTokens) + } + } catch (acctErr) { + logger.warn(`Failed to update Azure account usage for ${accountId}: ${acctErr.message}`) + } + + logger.info( + `📊 Azure OpenAI Usage recorded for ${requestId}: ` + + `model=${modelToRecord}, ` + + `input=${inputTokens}, output=${outputTokens}, ` + + `cache_create=${cacheCreateTokens}, cache_read=${cacheReadTokens}` + ) + return true + } catch (error) { + logger.error('Failed to report Azure OpenAI usage:', error) + return false + } + } +} + +const usageReporter = new AtomicUsageReporter() + +// 健康检查 +router.get('/health', (req, res) => { + res.status(200).json({ + status: 'healthy', + service: 'azure-openai-relay', + timestamp: new Date().toISOString() + }) +}) + +// 获取可用模型列表(兼容 OpenAI API) +router.get('/models', authenticateApiKey, async (req, res) => { + try { + const models = ALL_ALLOWED_MODELS.map((model) => ({ + id: `azure/${model}`, + object: 'model', + created: Date.now(), + owned_by: 'azure-openai' + })) + + res.json({ + object: 'list', + data: models + }) + } catch (error) { + logger.error('Error fetching Azure OpenAI models:', error) + res.status(500).json({ error: { message: 'Failed to fetch models' } }) + } +}) + +// 处理聊天完成请求 +router.post('/chat/completions', authenticateApiKey, async (req, res) => { + const requestId = `azure_req_${Date.now()}_${crypto.randomBytes(8).toString('hex')}` + const sessionId = req.sessionId || req.headers['x-session-id'] || null + + logger.info(`🚀 Azure OpenAI Chat Request ${requestId}`, { + apiKeyId: req.apiKey?.id, + sessionId, + model: req.body.model, + stream: req.body.stream || false, + messages: req.body.messages?.length || 0 + }) + + try { + // 获取绑定的 Azure OpenAI 账户 + let account = null + if (req.apiKey?.azureOpenaiAccountId) { + account = await azureOpenaiAccountService.getAccount(req.apiKey.azureOpenaiAccountId) + if (!account) { + logger.warn(`Bound Azure OpenAI account not found: ${req.apiKey.azureOpenaiAccountId}`) + } + } + + // 如果没有绑定账户或账户不可用,选择一个可用账户 + if (!account || account.isActive !== 'true') { + account = await azureOpenaiAccountService.selectAvailableAccount(sessionId) + } + + // 发送请求到 Azure OpenAI + const response = await azureOpenaiRelayService.handleAzureOpenAIRequest({ + account, + requestBody: req.body, + headers: req.headers, + isStream: req.body.stream || false, + endpoint: 'chat/completions' + }) + + // 处理流式响应 + if (req.body.stream) { + await azureOpenaiRelayService.handleStreamResponse(response, res, { + onEnd: async ({ usageData, actualModel }) => { + if (usageData) { + const modelToRecord = actualModel || req.body.model || 'unknown' + await usageReporter.reportOnce( + requestId, + usageData, + req.apiKey.id, + modelToRecord, + account.id + ) + } + }, + onError: (error) => { + logger.error(`Stream error for request ${requestId}:`, error) + } + }) + } else { + // 处理非流式响应 + const { usageData, actualModel } = azureOpenaiRelayService.handleNonStreamResponse( + response, + res + ) + + if (usageData) { + const modelToRecord = actualModel || req.body.model || 'unknown' + await usageReporter.reportOnce( + requestId, + usageData, + req.apiKey.id, + modelToRecord, + account.id + ) + } + } + } catch (error) { + logger.error(`Azure OpenAI request failed ${requestId}:`, error) + + if (!res.headersSent) { + const statusCode = error.response?.status || 500 + const errorMessage = + error.response?.data?.error?.message || error.message || 'Internal server error' + + res.status(statusCode).json({ + error: { + message: errorMessage, + type: 'azure_openai_error', + code: error.code || 'unknown' + } + }) + } + } +}) + +// 处理响应请求 (gpt-5, gpt-5-mini, codex-mini models) +router.post('/responses', authenticateApiKey, async (req, res) => { + const requestId = `azure_resp_${Date.now()}_${crypto.randomBytes(8).toString('hex')}` + const sessionId = req.sessionId || req.headers['x-session-id'] || null + + logger.info(`🚀 Azure OpenAI Responses Request ${requestId}`, { + apiKeyId: req.apiKey?.id, + sessionId, + model: req.body.model, + stream: req.body.stream || false, + messages: req.body.messages?.length || 0 + }) + + try { + // 获取绑定的 Azure OpenAI 账户 + let account = null + if (req.apiKey?.azureOpenaiAccountId) { + account = await azureOpenaiAccountService.getAccount(req.apiKey.azureOpenaiAccountId) + if (!account) { + logger.warn(`Bound Azure OpenAI account not found: ${req.apiKey.azureOpenaiAccountId}`) + } + } + + // 如果没有绑定账户或账户不可用,选择一个可用账户 + if (!account || account.isActive !== 'true') { + account = await azureOpenaiAccountService.selectAvailableAccount(sessionId) + } + + // 发送请求到 Azure OpenAI + const response = await azureOpenaiRelayService.handleAzureOpenAIRequest({ + account, + requestBody: req.body, + headers: req.headers, + isStream: req.body.stream || false, + endpoint: 'responses' + }) + + // 处理流式响应 + if (req.body.stream) { + await azureOpenaiRelayService.handleStreamResponse(response, res, { + onEnd: async ({ usageData, actualModel }) => { + if (usageData) { + const modelToRecord = actualModel || req.body.model || 'unknown' + await usageReporter.reportOnce( + requestId, + usageData, + req.apiKey.id, + modelToRecord, + account.id + ) + } + }, + onError: (error) => { + logger.error(`Stream error for request ${requestId}:`, error) + } + }) + } else { + // 处理非流式响应 + const { usageData, actualModel } = azureOpenaiRelayService.handleNonStreamResponse( + response, + res + ) + + if (usageData) { + const modelToRecord = actualModel || req.body.model || 'unknown' + await usageReporter.reportOnce( + requestId, + usageData, + req.apiKey.id, + modelToRecord, + account.id + ) + } + } + } catch (error) { + logger.error(`Azure OpenAI responses request failed ${requestId}:`, error) + + if (!res.headersSent) { + const statusCode = error.response?.status || 500 + const errorMessage = + error.response?.data?.error?.message || error.message || 'Internal server error' + + res.status(statusCode).json({ + error: { + message: errorMessage, + type: 'azure_openai_error', + code: error.code || 'unknown' + } + }) + } + } +}) + +// 处理嵌入请求 +router.post('/embeddings', authenticateApiKey, async (req, res) => { + const requestId = `azure_embed_${Date.now()}_${crypto.randomBytes(8).toString('hex')}` + const sessionId = req.sessionId || req.headers['x-session-id'] || null + + logger.info(`🚀 Azure OpenAI Embeddings Request ${requestId}`, { + apiKeyId: req.apiKey?.id, + sessionId, + model: req.body.model, + input: Array.isArray(req.body.input) ? req.body.input.length : 1 + }) + + try { + // 获取绑定的 Azure OpenAI 账户 + let account = null + if (req.apiKey?.azureOpenaiAccountId) { + account = await azureOpenaiAccountService.getAccount(req.apiKey.azureOpenaiAccountId) + if (!account) { + logger.warn(`Bound Azure OpenAI account not found: ${req.apiKey.azureOpenaiAccountId}`) + } + } + + // 如果没有绑定账户或账户不可用,选择一个可用账户 + if (!account || account.isActive !== 'true') { + account = await azureOpenaiAccountService.selectAvailableAccount(sessionId) + } + + // 发送请求到 Azure OpenAI + const response = await azureOpenaiRelayService.handleAzureOpenAIRequest({ + account, + requestBody: req.body, + headers: req.headers, + isStream: false, + endpoint: 'embeddings' + }) + + // 处理响应 + const { usageData, actualModel } = azureOpenaiRelayService.handleNonStreamResponse( + response, + res + ) + + if (usageData) { + const modelToRecord = actualModel || req.body.model || 'unknown' + await usageReporter.reportOnce(requestId, usageData, req.apiKey.id, modelToRecord, account.id) + } + } catch (error) { + logger.error(`Azure OpenAI embeddings request failed ${requestId}:`, error) + + if (!res.headersSent) { + const statusCode = error.response?.status || 500 + const errorMessage = + error.response?.data?.error?.message || error.message || 'Internal server error' + + res.status(statusCode).json({ + error: { + message: errorMessage, + type: 'azure_openai_error', + code: error.code || 'unknown' + } + }) + } + } +}) + +// 获取使用统计 +router.get('/usage', authenticateApiKey, async (req, res) => { + try { + const { start_date, end_date } = req.query + const usage = await apiKeyService.getUsageStats(req.apiKey.id, start_date, end_date) + + res.json({ + object: 'usage', + data: usage + }) + } catch (error) { + logger.error('Error fetching Azure OpenAI usage:', error) + res.status(500).json({ error: { message: 'Failed to fetch usage data' } }) + } +}) + +module.exports = router diff --git a/src/routes/droidRoutes.js b/src/routes/droidRoutes.js new file mode 100644 index 0000000000000000000000000000000000000000..5ae6c4188b4640d2a9d373f9ceadcb362944902b --- /dev/null +++ b/src/routes/droidRoutes.js @@ -0,0 +1,191 @@ +const crypto = require('crypto') +const express = require('express') +const { authenticateApiKey } = require('../middleware/auth') +const droidRelayService = require('../services/droidRelayService') +const sessionHelper = require('../utils/sessionHelper') +const logger = require('../utils/logger') + +const router = express.Router() + +function hasDroidPermission(apiKeyData) { + const permissions = apiKeyData?.permissions || 'all' + return permissions === 'all' || permissions === 'droid' +} + +/** + * Droid API 转发路由 + * + * 支持的 Factory.ai 端点: + * - /droid/claude - Anthropic (Claude) Messages API + * - /droid/openai - OpenAI Responses API + */ + +// Claude (Anthropic) 端点 - /v1/messages +router.post('/claude/v1/messages', authenticateApiKey, async (req, res) => { + try { + const sessionHash = sessionHelper.generateSessionHash(req.body) + + if (!hasDroidPermission(req.apiKey)) { + logger.security( + `🚫 API Key ${req.apiKey?.id || 'unknown'} 缺少 Droid 权限,拒绝访问 ${req.originalUrl}` + ) + return res.status(403).json({ + error: 'permission_denied', + message: '此 API Key 未启用 Droid 权限' + }) + } + + const result = await droidRelayService.relayRequest( + req.body, + req.apiKey, + req, + res, + req.headers, + { endpointType: 'anthropic', sessionHash } + ) + + // 如果是流式响应,已经在 relayService 中处理了 + if (result.streaming) { + return + } + + // 非流式响应 + res.status(result.statusCode).set(result.headers).send(result.body) + } catch (error) { + logger.error('Droid Claude relay error:', error) + res.status(500).json({ + error: 'internal_server_error', + message: error.message + }) + } +}) + +router.post('/claude/v1/messages/count_tokens', authenticateApiKey, async (req, res) => { + try { + const requestBody = { ...req.body } + if ('stream' in requestBody) { + delete requestBody.stream + } + const sessionHash = sessionHelper.generateSessionHash(requestBody) + + if (!hasDroidPermission(req.apiKey)) { + logger.security( + `🚫 API Key ${req.apiKey?.id || 'unknown'} 缺少 Droid 权限,拒绝访问 ${req.originalUrl}` + ) + return res.status(403).json({ + error: 'permission_denied', + message: '此 API Key 未启用 Droid 权限' + }) + } + + const result = await droidRelayService.relayRequest( + requestBody, + req.apiKey, + req, + res, + req.headers, + { + endpointType: 'anthropic', + sessionHash, + customPath: '/a/v1/messages/count_tokens', + skipUsageRecord: true, + disableStreaming: true + } + ) + + res.status(result.statusCode).set(result.headers).send(result.body) + } catch (error) { + logger.error('Droid Claude count_tokens relay error:', error) + res.status(500).json({ + error: 'internal_server_error', + message: error.message + }) + } +}) + +// OpenAI 端点 - /v1/responses +router.post(['/openai/v1/responses', '/openai/responses'], authenticateApiKey, async (req, res) => { + try { + const sessionId = + req.headers['session_id'] || + req.headers['x-session-id'] || + req.body?.session_id || + req.body?.conversation_id || + null + + const sessionHash = sessionId + ? crypto.createHash('sha256').update(String(sessionId)).digest('hex') + : null + + if (!hasDroidPermission(req.apiKey)) { + logger.security( + `🚫 API Key ${req.apiKey?.id || 'unknown'} 缺少 Droid 权限,拒绝访问 ${req.originalUrl}` + ) + return res.status(403).json({ + error: 'permission_denied', + message: '此 API Key 未启用 Droid 权限' + }) + } + + const result = await droidRelayService.relayRequest( + req.body, + req.apiKey, + req, + res, + req.headers, + { endpointType: 'openai', sessionHash } + ) + + if (result.streaming) { + return + } + + res.status(result.statusCode).set(result.headers).send(result.body) + } catch (error) { + logger.error('Droid OpenAI relay error:', error) + res.status(500).json({ + error: 'internal_server_error', + message: error.message + }) + } +}) + +// 模型列表端点(兼容性) +router.get('/*/v1/models', authenticateApiKey, async (req, res) => { + try { + // 返回可用的模型列表 + const models = [ + { + id: 'claude-opus-4-1-20250805', + object: 'model', + created: Date.now(), + owned_by: 'anthropic' + }, + { + id: 'claude-sonnet-4-5-20250929', + object: 'model', + created: Date.now(), + owned_by: 'anthropic' + }, + { + id: 'gpt-5-2025-08-07', + object: 'model', + created: Date.now(), + owned_by: 'openai' + } + ] + + res.json({ + object: 'list', + data: models + }) + } catch (error) { + logger.error('Droid models list error:', error) + res.status(500).json({ + error: 'internal_server_error', + message: error.message + }) + } +}) + +module.exports = router diff --git a/src/routes/geminiRoutes.js b/src/routes/geminiRoutes.js new file mode 100644 index 0000000000000000000000000000000000000000..8ece60fda57498d881402448d09d13d30beb0831 --- /dev/null +++ b/src/routes/geminiRoutes.js @@ -0,0 +1,1065 @@ +const express = require('express') +const router = express.Router() +const logger = require('../utils/logger') +const { authenticateApiKey } = require('../middleware/auth') +const geminiAccountService = require('../services/geminiAccountService') +const { sendGeminiRequest, getAvailableModels } = require('../services/geminiRelayService') +const crypto = require('crypto') +const sessionHelper = require('../utils/sessionHelper') +const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler') +const apiKeyService = require('../services/apiKeyService') +const { updateRateLimitCounters } = require('../utils/rateLimitHelper') +// const { OAuth2Client } = require('google-auth-library'); // OAuth2Client is not used in this file + +// 生成会话哈希 +function generateSessionHash(req) { + const apiKeyPrefix = + req.headers['x-api-key']?.substring(0, 10) || req.headers['x-goog-api-key']?.substring(0, 10) + + const sessionData = [req.headers['user-agent'], req.ip, apiKeyPrefix].filter(Boolean).join(':') + + return crypto.createHash('sha256').update(sessionData).digest('hex') +} + +// 检查 API Key 权限 +function checkPermissions(apiKeyData, requiredPermission = 'gemini') { + const permissions = apiKeyData.permissions || 'all' + return permissions === 'all' || permissions === requiredPermission +} + +// 确保请求具有 Gemini 访问权限 +function ensureGeminiPermission(req, res) { + const apiKeyData = req.apiKey || {} + if (checkPermissions(apiKeyData, 'gemini')) { + return true + } + + logger.security( + `🚫 API Key ${apiKeyData.id || 'unknown'} 缺少 Gemini 权限,拒绝访问 ${req.originalUrl}` + ) + + res.status(403).json({ + error: { + message: 'This API key does not have permission to access Gemini', + type: 'permission_denied' + } + }) + return false +} + +async function applyRateLimitTracking(req, usageSummary, model, context = '') { + if (!req.rateLimitInfo) { + return + } + + const label = context ? ` (${context})` : '' + + try { + const { totalTokens, totalCost } = await updateRateLimitCounters( + req.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) + } +} + +// Gemini 消息处理端点 +router.post('/messages', authenticateApiKey, async (req, res) => { + const startTime = Date.now() + let abortController = null + + try { + const apiKeyData = req.apiKey + + // 检查权限 + if (!checkPermissions(apiKeyData, 'gemini')) { + return res.status(403).json({ + error: { + message: 'This API key does not have permission to access Gemini', + type: 'permission_denied' + } + }) + } + + // 提取请求参数 + const { + messages, + model = 'gemini-2.5-flash', + temperature = 0.7, + max_tokens = 4096, + stream = false + } = req.body + + // 验证必需参数 + if (!messages || !Array.isArray(messages) || messages.length === 0) { + return res.status(400).json({ + error: { + message: 'Messages array is required', + type: 'invalid_request_error' + } + }) + } + + // 生成会话哈希用于粘性会话 + const sessionHash = generateSessionHash(req) + + // 使用统一调度选择可用的 Gemini 账户(传递请求的模型) + let accountId + try { + const schedulerResult = await unifiedGeminiScheduler.selectAccountForApiKey( + apiKeyData, + sessionHash, + model // 传递请求的模型进行过滤 + ) + const { accountId: selectedAccountId } = schedulerResult + accountId = selectedAccountId + } catch (error) { + logger.error('Failed to select Gemini account:', error) + return res.status(503).json({ + error: { + message: error.message || 'No available Gemini accounts', + type: 'service_unavailable' + } + }) + } + + // 获取账户详情 + const account = await geminiAccountService.getAccount(accountId) + if (!account) { + return res.status(503).json({ + error: { + message: 'Selected account not found', + type: 'service_unavailable' + } + }) + } + + logger.info(`Using Gemini account: ${account.id} for API key: ${apiKeyData.id}`) + + // 标记账户被使用 + await geminiAccountService.markAccountUsed(account.id) + + // 创建中止控制器 + abortController = new AbortController() + + // 处理客户端断开连接 + req.on('close', () => { + if (abortController && !abortController.signal.aborted) { + logger.info('Client disconnected, aborting Gemini request') + abortController.abort() + } + }) + + // 发送请求到 Gemini + const geminiResponse = await sendGeminiRequest({ + messages, + model, + temperature, + maxTokens: max_tokens, + stream, + accessToken: account.accessToken, + proxy: account.proxy, + apiKeyId: apiKeyData.id, + signal: abortController.signal, + projectId: account.projectId, + accountId: account.id + }) + + if (stream) { + // 设置流式响应头 + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + res.setHeader('X-Accel-Buffering', 'no') + + // 流式传输响应 + for await (const chunk of geminiResponse) { + if (abortController.signal.aborted) { + break + } + res.write(chunk) + } + + res.end() + } else { + // 非流式响应 + res.json(geminiResponse) + } + + const duration = Date.now() - startTime + logger.info(`Gemini request completed in ${duration}ms`) + } catch (error) { + logger.error('Gemini request error:', error) + + // 处理速率限制 + if (error.status === 429) { + if (req.apiKey && req.account) { + await geminiAccountService.setAccountRateLimited(req.account.id, true) + } + } + + // 返回错误响应 + const status = error.status || 500 + const errorResponse = { + error: error.error || { + message: error.message || 'Internal server error', + type: 'api_error' + } + } + + res.status(status).json(errorResponse) + } finally { + // 清理资源 + if (abortController) { + abortController = null + } + } + return undefined +}) + +// 获取可用模型列表 +router.get('/models', authenticateApiKey, async (req, res) => { + try { + const apiKeyData = req.apiKey + + // 检查权限 + if (!checkPermissions(apiKeyData, 'gemini')) { + return res.status(403).json({ + error: { + message: 'This API key does not have permission to access Gemini', + type: 'permission_denied' + } + }) + } + + // 选择账户获取模型列表 + let account = null + try { + const accountSelection = await unifiedGeminiScheduler.selectAccountForApiKey( + apiKeyData, + null, + null + ) + account = await geminiAccountService.getAccount(accountSelection.accountId) + } catch (error) { + logger.warn('Failed to select Gemini account for models endpoint:', error) + } + + if (!account) { + // 返回默认模型列表 + return res.json({ + object: 'list', + data: [ + { + id: 'gemini-2.5-flash', + object: 'model', + created: Date.now() / 1000, + owned_by: 'google' + } + ] + }) + } + + // 获取模型列表 + const models = await getAvailableModels(account.accessToken, account.proxy) + + res.json({ + object: 'list', + data: models + }) + } catch (error) { + logger.error('Failed to get Gemini models:', error) + res.status(500).json({ + error: { + message: 'Failed to retrieve models', + type: 'api_error' + } + }) + } + return undefined +}) + +// 使用情况统计(与 Claude 共用) +router.get('/usage', authenticateApiKey, async (req, res) => { + try { + const { usage } = req.apiKey + + res.json({ + object: 'usage', + total_tokens: usage.total.tokens, + total_requests: usage.total.requests, + daily_tokens: usage.daily.tokens, + daily_requests: usage.daily.requests, + monthly_tokens: usage.monthly.tokens, + monthly_requests: usage.monthly.requests + }) + } catch (error) { + logger.error('Failed to get usage stats:', error) + res.status(500).json({ + error: { + message: 'Failed to retrieve usage statistics', + type: 'api_error' + } + }) + } +}) + +// API Key 信息(与 Claude 共用) +router.get('/key-info', authenticateApiKey, async (req, res) => { + try { + const keyData = req.apiKey + + res.json({ + id: keyData.id, + name: keyData.name, + permissions: keyData.permissions || 'all', + token_limit: keyData.tokenLimit, + tokens_used: keyData.usage.total.tokens, + tokens_remaining: + keyData.tokenLimit > 0 + ? Math.max(0, keyData.tokenLimit - keyData.usage.total.tokens) + : null, + rate_limit: { + window: keyData.rateLimitWindow, + requests: keyData.rateLimitRequests + }, + concurrency_limit: keyData.concurrencyLimit, + model_restrictions: { + enabled: keyData.enableModelRestriction, + models: keyData.restrictedModels + } + }) + } catch (error) { + logger.error('Failed to get key info:', error) + res.status(500).json({ + error: { + message: 'Failed to retrieve API key information', + type: 'api_error' + } + }) + } +}) + +// 共用的 loadCodeAssist 处理函数 +async function handleLoadCodeAssist(req, res) { + try { + if (!ensureGeminiPermission(req, res)) { + return undefined + } + + const sessionHash = sessionHelper.generateSessionHash(req.body) + + // 从路径参数或请求体中获取模型名 + const requestedModel = req.body.model || req.params.modelName || 'gemini-2.5-flash' + const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey( + req.apiKey, + sessionHash, + requestedModel + ) + const account = await geminiAccountService.getAccount(accountId) + const { accessToken, refreshToken, projectId } = account + + const { metadata, cloudaicompanionProject } = req.body + + const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal' + logger.info(`LoadCodeAssist request (${version})`, { + metadata: metadata || {}, + requestedProject: cloudaicompanionProject || null, + accountProject: projectId || null, + apiKeyId: req.apiKey?.id || 'unknown' + }) + + // 解析账户的代理配置 + 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(accessToken, refreshToken, proxyConfig) + + // 智能处理项目ID: + // 1. 如果账户配置了项目ID -> 使用账户的项目ID(覆盖请求中的) + // 2. 如果账户没有项目ID -> 使用请求中的cloudaicompanionProject + // 3. 都没有 -> 传null + const effectiveProjectId = projectId || cloudaicompanionProject || null + + logger.info('📋 loadCodeAssist项目ID处理逻辑', { + accountProjectId: projectId, + requestProjectId: cloudaicompanionProject, + effectiveProjectId, + decision: projectId + ? '使用账户配置' + : cloudaicompanionProject + ? '使用请求参数' + : '不使用项目ID' + }) + + const response = await geminiAccountService.loadCodeAssist( + client, + effectiveProjectId, + proxyConfig + ) + + // 如果响应中包含 cloudaicompanionProject,保存到账户作为临时项目 ID + if (response.cloudaicompanionProject && !account.projectId) { + await geminiAccountService.updateTempProjectId(accountId, response.cloudaicompanionProject) + logger.info( + `📋 Cached temporary projectId from loadCodeAssist: ${response.cloudaicompanionProject}` + ) + } + + res.json(response) + } catch (error) { + const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal' + logger.error(`Error in loadCodeAssist endpoint (${version})`, { error: error.message }) + res.status(500).json({ + error: 'Internal server error', + message: error.message + }) + } +} + +// 共用的 onboardUser 处理函数 +async function handleOnboardUser(req, res) { + try { + if (!ensureGeminiPermission(req, res)) { + return undefined + } + + // 提取请求参数 + const { tierId, cloudaicompanionProject, metadata } = req.body + const sessionHash = sessionHelper.generateSessionHash(req.body) + + // 从路径参数或请求体中获取模型名 + const requestedModel = req.body.model || req.params.modelName || 'gemini-2.5-flash' + const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey( + req.apiKey, + sessionHash, + requestedModel + ) + const account = await geminiAccountService.getAccount(accountId) + const { accessToken, refreshToken, projectId } = account + + const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal' + logger.info(`OnboardUser request (${version})`, { + tierId: tierId || 'not provided', + requestedProject: cloudaicompanionProject || null, + accountProject: projectId || null, + metadata: metadata || {}, + apiKeyId: req.apiKey?.id || 'unknown' + }) + + // 解析账户的代理配置 + 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(accessToken, refreshToken, proxyConfig) + + // 智能处理项目ID: + // 1. 如果账户配置了项目ID -> 使用账户的项目ID(覆盖请求中的) + // 2. 如果账户没有项目ID -> 使用请求中的cloudaicompanionProject + // 3. 都没有 -> 传null + const effectiveProjectId = projectId || cloudaicompanionProject || null + + logger.info('📋 onboardUser项目ID处理逻辑', { + accountProjectId: projectId, + requestProjectId: cloudaicompanionProject, + effectiveProjectId, + decision: projectId + ? '使用账户配置' + : cloudaicompanionProject + ? '使用请求参数' + : '不使用项目ID' + }) + + // 如果提供了 tierId,直接调用 onboardUser + if (tierId) { + const response = await geminiAccountService.onboardUser( + client, + tierId, + effectiveProjectId, // 使用处理后的项目ID + metadata, + proxyConfig + ) + + res.json(response) + } else { + // 否则执行完整的 setupUser 流程 + const response = await geminiAccountService.setupUser( + client, + effectiveProjectId, // 使用处理后的项目ID + metadata, + proxyConfig + ) + + res.json(response) + } + } catch (error) { + const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal' + logger.error(`Error in onboardUser endpoint (${version})`, { error: error.message }) + res.status(500).json({ + error: 'Internal server error', + message: error.message + }) + } +} + +// 共用的 countTokens 处理函数 +async function handleCountTokens(req, res) { + try { + if (!ensureGeminiPermission(req, res)) { + return undefined + } + + // 处理请求体结构,支持直接 contents 或 request.contents + const requestData = req.body.request || req.body + const { contents } = requestData + // 从路径参数或请求体中获取模型名 + const model = requestData.model || req.params.modelName || 'gemini-2.5-flash' + const sessionHash = sessionHelper.generateSessionHash(req.body) + + // 验证必需参数 + if (!contents || !Array.isArray(contents)) { + return res.status(400).json({ + error: { + message: 'Contents array is required', + type: 'invalid_request_error' + } + }) + } + + // 使用统一调度选择账号 + const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey( + req.apiKey, + sessionHash, + model + ) + const account = await geminiAccountService.getAccount(accountId) + const { accessToken, refreshToken } = account + + const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal' + logger.info(`CountTokens request (${version})`, { + model, + contentsLength: contents.length, + apiKeyId: req.apiKey?.id || 'unknown' + }) + + // 解析账户的代理配置 + 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(accessToken, refreshToken, proxyConfig) + const response = await geminiAccountService.countTokens(client, contents, model, proxyConfig) + + res.json(response) + } catch (error) { + const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal' + logger.error(`Error in countTokens endpoint (${version})`, { error: error.message }) + res.status(500).json({ + error: { + message: error.message || 'Internal server error', + type: 'api_error' + } + }) + } + return undefined +} + +// 共用的 generateContent 处理函数 +async function handleGenerateContent(req, res) { + try { + if (!ensureGeminiPermission(req, res)) { + return undefined + } + + const { project, user_prompt_id, request: requestData } = req.body + // 从路径参数或请求体中获取模型名 + const model = req.body.model || req.params.modelName || 'gemini-2.5-flash' + const sessionHash = sessionHelper.generateSessionHash(req.body) + + // 处理不同格式的请求 + let actualRequestData = requestData + if (!requestData) { + if (req.body.messages) { + // 这是 OpenAI 格式的请求,构建 Gemini 格式的 request 对象 + actualRequestData = { + contents: req.body.messages.map((msg) => ({ + role: msg.role === 'assistant' ? 'model' : msg.role, + parts: [{ text: msg.content }] + })), + generationConfig: { + temperature: req.body.temperature !== undefined ? req.body.temperature : 0.7, + maxOutputTokens: req.body.max_tokens !== undefined ? req.body.max_tokens : 4096, + topP: req.body.top_p !== undefined ? req.body.top_p : 0.95, + topK: req.body.top_k !== undefined ? req.body.top_k : 40 + } + } + } else if (req.body.contents) { + // 直接的 Gemini 格式请求(没有 request 包装) + actualRequestData = req.body + } + } + + // 验证必需参数 + if (!actualRequestData || !actualRequestData.contents) { + return res.status(400).json({ + error: { + message: 'Request contents are required', + type: 'invalid_request_error' + } + }) + } + + // 使用统一调度选择账号 + const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey( + req.apiKey, + sessionHash, + model + ) + const account = await geminiAccountService.getAccount(accountId) + const { accessToken, refreshToken } = account + + const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal' + logger.info(`GenerateContent request (${version})`, { + model, + userPromptId: user_prompt_id, + projectId: project || account.projectId, + apiKeyId: req.apiKey?.id || 'unknown' + }) + + // 解析账户的代理配置 + 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(accessToken, refreshToken, proxyConfig) + + // 智能处理项目ID: + // 1. 如果账户配置了项目ID -> 使用账户的项目ID(覆盖请求中的) + // 2. 如果账户没有项目ID -> 使用请求中的项目ID(如果有的话) + // 3. 都没有 -> 传null + const effectiveProjectId = account.projectId || project || null + + logger.info('📋 项目ID处理逻辑', { + accountProjectId: account.projectId, + requestProjectId: project, + effectiveProjectId, + decision: account.projectId ? '使用账户配置' : project ? '使用请求参数' : '不使用项目ID' + }) + + const response = await geminiAccountService.generateContent( + client, + { model, request: actualRequestData }, + user_prompt_id, + effectiveProjectId, // 使用智能决策的项目ID + req.apiKey?.id, // 使用 API Key ID 作为 session ID + proxyConfig // 传递代理配置 + ) + + // 记录使用统计 + if (response?.response?.usageMetadata) { + try { + const usage = response.response.usageMetadata + await apiKeyService.recordUsage( + req.apiKey.id, + usage.promptTokenCount || 0, + usage.candidatesTokenCount || 0, + 0, // cacheCreateTokens + 0, // cacheReadTokens + model, + account.id + ) + logger.info( + `📊 Recorded Gemini usage - Input: ${usage.promptTokenCount}, Output: ${usage.candidatesTokenCount}, Total: ${usage.totalTokenCount}` + ) + + await applyRateLimitTracking( + req, + { + inputTokens: usage.promptTokenCount || 0, + outputTokens: usage.candidatesTokenCount || 0, + cacheCreateTokens: 0, + cacheReadTokens: 0 + }, + model, + 'gemini-non-stream' + ) + } catch (error) { + logger.error('Failed to record Gemini usage:', error) + } + } + + res.json(version === 'v1beta' ? response.response : response) + } catch (error) { + const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal' + // 打印详细的错误信息 + logger.error(`Error in generateContent endpoint (${version})`, { + message: error.message, + status: error.response?.status, + statusText: error.response?.statusText, + responseData: error.response?.data, + requestUrl: error.config?.url, + requestMethod: error.config?.method, + stack: error.stack + }) + res.status(500).json({ + error: { + message: error.message || 'Internal server error', + type: 'api_error' + } + }) + } + return undefined +} + +// 共用的 streamGenerateContent 处理函数 +async function handleStreamGenerateContent(req, res) { + let abortController = null + + try { + if (!ensureGeminiPermission(req, res)) { + return undefined + } + + const { project, user_prompt_id, request: requestData } = req.body + // 从路径参数或请求体中获取模型名 + const model = req.body.model || req.params.modelName || 'gemini-2.5-flash' + const sessionHash = sessionHelper.generateSessionHash(req.body) + + // 处理不同格式的请求 + let actualRequestData = requestData + if (!requestData) { + if (req.body.messages) { + // 这是 OpenAI 格式的请求,构建 Gemini 格式的 request 对象 + actualRequestData = { + contents: req.body.messages.map((msg) => ({ + role: msg.role === 'assistant' ? 'model' : msg.role, + parts: [{ text: msg.content }] + })), + generationConfig: { + temperature: req.body.temperature !== undefined ? req.body.temperature : 0.7, + maxOutputTokens: req.body.max_tokens !== undefined ? req.body.max_tokens : 4096, + topP: req.body.top_p !== undefined ? req.body.top_p : 0.95, + topK: req.body.top_k !== undefined ? req.body.top_k : 40 + } + } + } else if (req.body.contents) { + // 直接的 Gemini 格式请求(没有 request 包装) + actualRequestData = req.body + } + } + + // 验证必需参数 + if (!actualRequestData || !actualRequestData.contents) { + return res.status(400).json({ + error: { + message: 'Request contents are required', + type: 'invalid_request_error' + } + }) + } + + // 使用统一调度选择账号 + const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey( + req.apiKey, + sessionHash, + model + ) + const account = await geminiAccountService.getAccount(accountId) + const { accessToken, refreshToken } = account + + const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal' + logger.info(`StreamGenerateContent request (${version})`, { + model, + userPromptId: user_prompt_id, + projectId: project || account.projectId, + apiKeyId: req.apiKey?.id || 'unknown' + }) + + // 创建中止控制器 + abortController = new AbortController() + + // 处理客户端断开连接 + req.on('close', () => { + if (abortController && !abortController.signal.aborted) { + logger.info('Client disconnected, aborting stream request') + abortController.abort() + } + }) + + // 解析账户的代理配置 + 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(accessToken, refreshToken, proxyConfig) + + // 智能处理项目ID: + // 1. 如果账户配置了项目ID -> 使用账户的项目ID(覆盖请求中的) + // 2. 如果账户没有项目ID -> 使用请求中的项目ID(如果有的话) + // 3. 都没有 -> 传null + const effectiveProjectId = account.projectId || project || null + + logger.info('📋 流式请求项目ID处理逻辑', { + accountProjectId: account.projectId, + requestProjectId: project, + effectiveProjectId, + decision: account.projectId ? '使用账户配置' : project ? '使用请求参数' : '不使用项目ID' + }) + + const streamResponse = await geminiAccountService.generateContentStream( + client, + { model, request: actualRequestData }, + user_prompt_id, + effectiveProjectId, // 使用智能决策的项目ID + req.apiKey?.id, // 使用 API Key ID 作为 session ID + abortController.signal, // 传递中止信号 + proxyConfig // 传递代理配置 + ) + + // 设置 SSE 响应头 + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + res.setHeader('X-Accel-Buffering', 'no') + + // SSE 解析函数 + const parseSSELine = (line) => { + if (!line.startsWith('data: ')) { + return { type: 'other', line, data: null } + } + + const jsonStr = line.substring(6).trim() + + if (!jsonStr || jsonStr === '[DONE]') { + return { type: 'control', line, data: null, jsonStr } + } + + try { + const data = JSON.parse(jsonStr) + return { type: 'data', line, data, jsonStr } + } catch (e) { + return { type: 'invalid', line, data: null, jsonStr, error: e } + } + } + + // 处理流式响应并捕获usage数据 + let streamBuffer = '' // 统一的流处理缓冲区 + let totalUsage = { + promptTokenCount: 0, + candidatesTokenCount: 0, + totalTokenCount: 0 + } + const usageReported = false + + streamResponse.on('data', (chunk) => { + try { + const chunkStr = chunk.toString() + + if (!chunkStr.trim()) { + return + } + + // 使用统一缓冲区处理不完整的行 + streamBuffer += chunkStr + const lines = streamBuffer.split('\n') + streamBuffer = lines.pop() || '' // 保留最后一个不完整的行 + + const processedLines = [] + + for (const line of lines) { + if (!line.trim()) { + continue // 跳过空行,不添加到处理队列 + } + + // 解析 SSE 行 + const parsed = parseSSELine(line) + + // 提取 usage 数据(适用于所有版本) + if (parsed.type === 'data' && parsed.data.response?.usageMetadata) { + totalUsage = parsed.data.response.usageMetadata + logger.debug('📊 Captured Gemini usage data:', totalUsage) + } + + // 根据版本处理输出 + if (version === 'v1beta') { + if (parsed.type === 'data') { + if (parsed.data.response) { + // 有 response 字段,只返回 response 的内容 + processedLines.push(`data: ${JSON.stringify(parsed.data.response)}`) + } else { + // 没有 response 字段,返回整个数据对象 + processedLines.push(`data: ${JSON.stringify(parsed.data)}`) + } + } else if (parsed.type === 'control') { + // 控制消息(如 [DONE])保持原样 + processedLines.push(line) + } + // 跳过其他类型的行('other', 'invalid') + } + } + + // 发送数据到客户端 + if (version === 'v1beta') { + for (const line of processedLines) { + if (!res.destroyed) { + res.write(`${line}\n\n`) + } + } + } else { + // v1internal 直接转发原始数据 + if (!res.destroyed) { + res.write(chunkStr) + } + } + } catch (error) { + logger.error('Error processing stream chunk:', error) + } + }) + + streamResponse.on('end', async () => { + logger.info('Stream completed successfully') + + // 记录使用统计 + if (!usageReported && totalUsage.totalTokenCount > 0) { + try { + await apiKeyService.recordUsage( + req.apiKey.id, + totalUsage.promptTokenCount || 0, + totalUsage.candidatesTokenCount || 0, + 0, // cacheCreateTokens + 0, // cacheReadTokens + model, + account.id + ) + logger.info( + `📊 Recorded Gemini stream usage - Input: ${totalUsage.promptTokenCount}, Output: ${totalUsage.candidatesTokenCount}, Total: ${totalUsage.totalTokenCount}` + ) + + await applyRateLimitTracking( + req, + { + inputTokens: totalUsage.promptTokenCount || 0, + outputTokens: totalUsage.candidatesTokenCount || 0, + cacheCreateTokens: 0, + cacheReadTokens: 0 + }, + model, + 'gemini-stream' + ) + } catch (error) { + logger.error('Failed to record Gemini usage:', error) + } + } + + res.end() + }) + + streamResponse.on('error', (error) => { + logger.error('Stream error:', error) + if (!res.headersSent) { + res.status(500).json({ + error: { + message: error.message || 'Stream error', + type: 'api_error' + } + }) + } else { + res.end() + } + }) + } catch (error) { + const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal' + // 打印详细的错误信息 + logger.error(`Error in streamGenerateContent endpoint (${version})`, { + message: error.message, + status: error.response?.status, + statusText: error.response?.statusText, + responseData: error.response?.data, + requestUrl: error.config?.url, + requestMethod: error.config?.method, + stack: error.stack + }) + + if (!res.headersSent) { + res.status(500).json({ + error: { + message: error.message || 'Internal server error', + type: 'api_error' + } + }) + } + } finally { + // 清理资源 + if (abortController) { + abortController = null + } + } + return undefined +} + +// 注册所有路由端点 +// v1internal 版本的端点 +router.post('/v1internal\\:loadCodeAssist', authenticateApiKey, handleLoadCodeAssist) +router.post('/v1internal\\:onboardUser', authenticateApiKey, handleOnboardUser) +router.post('/v1internal\\:countTokens', authenticateApiKey, handleCountTokens) +router.post('/v1internal\\:generateContent', authenticateApiKey, handleGenerateContent) +router.post('/v1internal\\:streamGenerateContent', authenticateApiKey, handleStreamGenerateContent) + +// v1beta 版本的端点 - 支持动态模型名称 +router.post('/v1beta/models/:modelName\\:loadCodeAssist', authenticateApiKey, handleLoadCodeAssist) +router.post('/v1beta/models/:modelName\\:onboardUser', authenticateApiKey, handleOnboardUser) +router.post('/v1beta/models/:modelName\\:countTokens', authenticateApiKey, handleCountTokens) +router.post( + '/v1beta/models/:modelName\\:generateContent', + authenticateApiKey, + handleGenerateContent +) +router.post( + '/v1beta/models/:modelName\\:streamGenerateContent', + authenticateApiKey, + handleStreamGenerateContent +) + +// 导出处理函数供标准路由使用 +module.exports = router +module.exports.handleLoadCodeAssist = handleLoadCodeAssist +module.exports.handleOnboardUser = handleOnboardUser +module.exports.handleCountTokens = handleCountTokens +module.exports.handleGenerateContent = handleGenerateContent +module.exports.handleStreamGenerateContent = handleStreamGenerateContent diff --git a/src/routes/openaiClaudeRoutes.js b/src/routes/openaiClaudeRoutes.js new file mode 100644 index 0000000000000000000000000000000000000000..f5db5665ee451519fde16065a4b3e487705fd722 --- /dev/null +++ b/src/routes/openaiClaudeRoutes.js @@ -0,0 +1,492 @@ +/** + * OpenAI 兼容的 Claude API 路由 + * 提供 OpenAI 格式的 API 接口,内部转发到 Claude + */ + +const express = require('express') +const router = express.Router() +const fs = require('fs') +const path = require('path') +const logger = require('../utils/logger') +const { authenticateApiKey } = require('../middleware/auth') +const claudeRelayService = require('../services/claudeRelayService') +const openaiToClaude = require('../services/openaiToClaude') +const apiKeyService = require('../services/apiKeyService') +const unifiedClaudeScheduler = require('../services/unifiedClaudeScheduler') +const claudeCodeHeadersService = require('../services/claudeCodeHeadersService') +const sessionHelper = require('../utils/sessionHelper') +const { updateRateLimitCounters } = require('../utils/rateLimitHelper') + +// 加载模型定价数据 +let modelPricingData = {} +try { + const pricingPath = path.join(__dirname, '../../data/model_pricing.json') + const pricingContent = fs.readFileSync(pricingPath, 'utf8') + modelPricingData = JSON.parse(pricingContent) + logger.info('✅ Model pricing data loaded successfully') +} catch (error) { + logger.error('❌ Failed to load model pricing data:', error) +} + +// 🔧 辅助函数:检查 API Key 权限 +function checkPermissions(apiKeyData, requiredPermission = 'claude') { + const permissions = apiKeyData.permissions || 'all' + return permissions === 'all' || permissions === requiredPermission +} + +function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') { + if (!rateLimitInfo) { + return + } + + const label = context ? ` (${context})` : '' + + updateRateLimitCounters(rateLimitInfo, usageSummary, model) + .then(({ totalTokens, totalCost }) => { + 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) + }) +} + +// 📋 OpenAI 兼容的模型列表端点 +router.get('/v1/models', authenticateApiKey, async (req, res) => { + try { + const apiKeyData = req.apiKey + + // 检查权限 + if (!checkPermissions(apiKeyData, 'claude')) { + return res.status(403).json({ + error: { + message: 'This API key does not have permission to access Claude', + type: 'permission_denied', + code: 'permission_denied' + } + }) + } + + // Claude 模型列表 - 只返回 opus-4 和 sonnet-4 + let models = [ + { + id: 'claude-opus-4-20250514', + object: 'model', + created: 1736726400, // 2025-01-13 + owned_by: 'anthropic' + }, + { + id: 'claude-sonnet-4-20250514', + object: 'model', + created: 1736726400, // 2025-01-13 + owned_by: 'anthropic' + } + ] + + // 如果启用了模型限制,过滤模型列表 + if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) { + models = models.filter((model) => apiKeyData.restrictedModels.includes(model.id)) + } + + res.json({ + object: 'list', + data: models + }) + } catch (error) { + logger.error('❌ Failed to get OpenAI-Claude models:', error) + res.status(500).json({ + error: { + message: 'Failed to retrieve models', + type: 'server_error', + code: 'internal_error' + } + }) + } + return undefined +}) + +// 📄 OpenAI 兼容的模型详情端点 +router.get('/v1/models/:model', authenticateApiKey, async (req, res) => { + try { + const apiKeyData = req.apiKey + const modelId = req.params.model + + // 检查权限 + if (!checkPermissions(apiKeyData, 'claude')) { + return res.status(403).json({ + error: { + message: 'This API key does not have permission to access Claude', + type: 'permission_denied', + code: 'permission_denied' + } + }) + } + + // 检查模型限制 + if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) { + if (!apiKeyData.restrictedModels.includes(modelId)) { + return res.status(404).json({ + error: { + message: `Model '${modelId}' not found`, + type: 'invalid_request_error', + code: 'model_not_found' + } + }) + } + } + + // 从 model_pricing.json 获取模型信息 + const modelData = modelPricingData[modelId] + + // 构建标准 OpenAI 格式的模型响应 + let modelInfo + + if (modelData) { + // 如果在 pricing 文件中找到了模型 + modelInfo = { + id: modelId, + object: 'model', + created: 1736726400, // 2025-01-13 + owned_by: 'anthropic', + permission: [], + root: modelId, + parent: null + } + } else { + // 如果没找到,返回默认信息(但仍保持正确格式) + modelInfo = { + id: modelId, + object: 'model', + created: Math.floor(Date.now() / 1000), + owned_by: 'anthropic', + permission: [], + root: modelId, + parent: null + } + } + + res.json(modelInfo) + } catch (error) { + logger.error('❌ Failed to get model details:', error) + res.status(500).json({ + error: { + message: 'Failed to retrieve model details', + type: 'server_error', + code: 'internal_error' + } + }) + } + return undefined +}) + +// 🔧 处理聊天完成请求的核心函数 +async function handleChatCompletion(req, res, apiKeyData) { + const startTime = Date.now() + let abortController = null + + try { + // 检查权限 + if (!checkPermissions(apiKeyData, 'claude')) { + return res.status(403).json({ + error: { + message: 'This API key does not have permission to access Claude', + type: 'permission_denied', + code: 'permission_denied' + } + }) + } + + // 记录原始请求 + logger.debug('📥 Received OpenAI format request:', { + model: req.body.model, + messageCount: req.body.messages?.length, + stream: req.body.stream, + maxTokens: req.body.max_tokens + }) + + // 转换 OpenAI 请求为 Claude 格式 + const claudeRequest = openaiToClaude.convertRequest(req.body) + + // 检查模型限制 + if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) { + if (!apiKeyData.restrictedModels.includes(claudeRequest.model)) { + return res.status(403).json({ + error: { + message: `Model ${req.body.model} is not allowed for this API key`, + type: 'invalid_request_error', + code: 'model_not_allowed' + } + }) + } + } + + // 生成会话哈希用于sticky会话 + const sessionHash = sessionHelper.generateSessionHash(claudeRequest) + + // 选择可用的Claude账户 + let accountSelection + try { + accountSelection = await unifiedClaudeScheduler.selectAccountForApiKey( + apiKeyData, + sessionHash, + claudeRequest.model + ) + } catch (error) { + if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') { + const limitMessage = claudeRelayService._buildStandardRateLimitMessage(error.rateLimitEndAt) + return res.status(403).json({ + error: 'upstream_rate_limited', + message: limitMessage + }) + } + throw error + } + const { accountId } = accountSelection + + // 获取该账号存储的 Claude Code headers + const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId) + + logger.debug(`📋 Using Claude Code headers for account ${accountId}:`, { + userAgent: claudeCodeHeaders['user-agent'] + }) + + // 处理流式请求 + if (claudeRequest.stream) { + logger.info(`🌊 Processing OpenAI stream request for model: ${req.body.model}`) + + // 设置 SSE 响应头 + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + res.setHeader('X-Accel-Buffering', 'no') + + // 创建中止控制器 + abortController = new AbortController() + + // 处理客户端断开 + req.on('close', () => { + if (abortController && !abortController.signal.aborted) { + logger.info('🔌 Client disconnected, aborting Claude request') + abortController.abort() + } + }) + + // 使用转换后的响应流 (使用 OAuth-only beta header,添加 Claude Code 必需的 headers) + await claudeRelayService.relayStreamRequestWithUsageCapture( + claudeRequest, + apiKeyData, + res, + claudeCodeHeaders, + (usage) => { + // 记录使用统计 + if (usage && usage.input_tokens !== undefined && usage.output_tokens !== undefined) { + const model = usage.model || claudeRequest.model + const cacheCreateTokens = + (usage.cache_creation && typeof usage.cache_creation === 'object' + ? (usage.cache_creation.ephemeral_5m_input_tokens || 0) + + (usage.cache_creation.ephemeral_1h_input_tokens || 0) + : usage.cache_creation_input_tokens || 0) || 0 + const cacheReadTokens = usage.cache_read_input_tokens || 0 + + // 使用新的 recordUsageWithDetails 方法来支持详细的缓存数据 + apiKeyService + .recordUsageWithDetails( + apiKeyData.id, + usage, // 直接传递整个 usage 对象,包含可能的 cache_creation 详细数据 + model, + accountId + ) + .catch((error) => { + logger.error('❌ Failed to record usage:', error) + }) + + queueRateLimitUpdate( + req.rateLimitInfo, + { + inputTokens: usage.input_tokens || 0, + outputTokens: usage.output_tokens || 0, + cacheCreateTokens, + cacheReadTokens + }, + model, + 'openai-claude-stream' + ) + } + }, + // 流转换器 + (() => { + // 为每个请求创建独立的会话ID + const sessionId = `chatcmpl-${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}` + return (chunk) => openaiToClaude.convertStreamChunk(chunk, req.body.model, sessionId) + })(), + { + betaHeader: + 'oauth-2025-04-20,claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14' + } + ) + } else { + // 非流式请求 + logger.info(`📄 Processing OpenAI non-stream request for model: ${req.body.model}`) + + // 发送请求到 Claude (使用 OAuth-only beta header,添加 Claude Code 必需的 headers) + const claudeResponse = await claudeRelayService.relayRequest( + claudeRequest, + apiKeyData, + req, + res, + claudeCodeHeaders, + { betaHeader: 'oauth-2025-04-20' } + ) + + // 解析 Claude 响应 + let claudeData + try { + claudeData = JSON.parse(claudeResponse.body) + } catch (error) { + logger.error('❌ Failed to parse Claude response:', error) + return res.status(502).json({ + error: { + message: 'Invalid response from Claude API', + type: 'api_error', + code: 'invalid_response' + } + }) + } + + // 处理错误响应 + if (claudeResponse.statusCode >= 400) { + return res.status(claudeResponse.statusCode).json({ + error: { + message: claudeData.error?.message || 'Claude API error', + type: claudeData.error?.type || 'api_error', + code: claudeData.error?.code || 'unknown_error' + } + }) + } + + // 转换为 OpenAI 格式 + const openaiResponse = openaiToClaude.convertResponse(claudeData, req.body.model) + + // 记录使用统计 + if (claudeData.usage) { + const { usage } = claudeData + const cacheCreateTokens = + (usage.cache_creation && typeof usage.cache_creation === 'object' + ? (usage.cache_creation.ephemeral_5m_input_tokens || 0) + + (usage.cache_creation.ephemeral_1h_input_tokens || 0) + : usage.cache_creation_input_tokens || 0) || 0 + const cacheReadTokens = usage.cache_read_input_tokens || 0 + // 使用新的 recordUsageWithDetails 方法来支持详细的缓存数据 + apiKeyService + .recordUsageWithDetails( + apiKeyData.id, + usage, // 直接传递整个 usage 对象,包含可能的 cache_creation 详细数据 + claudeRequest.model, + accountId + ) + .catch((error) => { + logger.error('❌ Failed to record usage:', error) + }) + + queueRateLimitUpdate( + req.rateLimitInfo, + { + inputTokens: usage.input_tokens || 0, + outputTokens: usage.output_tokens || 0, + cacheCreateTokens, + cacheReadTokens + }, + claudeRequest.model, + 'openai-claude-non-stream' + ) + } + + // 返回 OpenAI 格式响应 + res.json(openaiResponse) + } + + const duration = Date.now() - startTime + logger.info(`✅ OpenAI-Claude request completed in ${duration}ms`) + } catch (error) { + logger.error('❌ OpenAI-Claude request error:', error) + + const status = error.status || 500 + res.status(status).json({ + error: { + message: error.message || 'Internal server error', + type: 'server_error', + code: 'internal_error' + } + }) + } finally { + // 清理资源 + if (abortController) { + abortController = null + } + } + return undefined +} + +// 🚀 OpenAI 兼容的聊天完成端点 +router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => { + await handleChatCompletion(req, res, req.apiKey) +}) + +// 🔧 OpenAI 兼容的 completions 端点(传统格式,转换为 chat 格式) +router.post('/v1/completions', authenticateApiKey, async (req, res) => { + try { + const apiKeyData = req.apiKey + + // 验证必需参数 + if (!req.body.prompt) { + return res.status(400).json({ + error: { + message: 'Prompt is required', + type: 'invalid_request_error', + code: 'invalid_request' + } + }) + } + + // 将传统 completions 格式转换为 chat 格式 + const originalBody = req.body + req.body = { + model: originalBody.model, + messages: [ + { + role: 'user', + content: originalBody.prompt + } + ], + max_tokens: originalBody.max_tokens, + temperature: originalBody.temperature, + top_p: originalBody.top_p, + stream: originalBody.stream, + stop: originalBody.stop, + n: originalBody.n || 1, + presence_penalty: originalBody.presence_penalty, + frequency_penalty: originalBody.frequency_penalty, + logit_bias: originalBody.logit_bias, + user: originalBody.user + } + + // 使用共享的处理函数 + await handleChatCompletion(req, res, apiKeyData) + } catch (error) { + logger.error('❌ OpenAI completions error:', error) + res.status(500).json({ + error: { + message: 'Failed to process completion request', + type: 'server_error', + code: 'internal_error' + } + }) + } + return undefined +}) + +module.exports = router diff --git a/src/routes/openaiGeminiRoutes.js b/src/routes/openaiGeminiRoutes.js new file mode 100644 index 0000000000000000000000000000000000000000..a718aad29c1df936c4df6b6577640a4c2efb3e35 --- /dev/null +++ b/src/routes/openaiGeminiRoutes.js @@ -0,0 +1,739 @@ +const express = require('express') +const router = express.Router() +const logger = require('../utils/logger') +const { authenticateApiKey } = require('../middleware/auth') +const geminiAccountService = require('../services/geminiAccountService') +const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler') +const { getAvailableModels } = require('../services/geminiRelayService') +const crypto = require('crypto') + +// 生成会话哈希 +function generateSessionHash(req) { + const authSource = + req.headers['authorization'] || req.headers['x-api-key'] || req.headers['x-goog-api-key'] + + const sessionData = [req.headers['user-agent'], req.ip, authSource?.substring(0, 20)] + .filter(Boolean) + .join(':') + + return crypto.createHash('sha256').update(sessionData).digest('hex') +} + +// 检查 API Key 权限 +function checkPermissions(apiKeyData, requiredPermission = 'gemini') { + const permissions = apiKeyData.permissions || 'all' + return permissions === 'all' || permissions === requiredPermission +} + +// 转换 OpenAI 消息格式到 Gemini 格式 +function convertMessagesToGemini(messages) { + const contents = [] + let systemInstruction = '' + + // 辅助函数:提取文本内容 + function extractTextContent(content) { + // 处理 null 或 undefined + if (content === null || content === undefined) { + return '' + } + + // 处理字符串 + if (typeof content === 'string') { + return content + } + + // 处理数组格式的内容 + if (Array.isArray(content)) { + return content + .map((item) => { + if (item === null || item === undefined) { + return '' + } + if (typeof item === 'string') { + return item + } + if (typeof item === 'object') { + // 处理 {type: 'text', text: '...'} 格式 + if (item.type === 'text' && item.text) { + return item.text + } + // 处理 {text: '...'} 格式 + if (item.text) { + return item.text + } + // 处理嵌套的对象或数组 + if (item.content) { + return extractTextContent(item.content) + } + } + return '' + }) + .join('') + } + + // 处理对象格式的内容 + if (typeof content === 'object') { + // 处理 {text: '...'} 格式 + if (content.text) { + return content.text + } + // 处理 {content: '...'} 格式 + if (content.content) { + return extractTextContent(content.content) + } + // 处理 {parts: [{text: '...'}]} 格式 + if (content.parts && Array.isArray(content.parts)) { + return content.parts + .map((part) => { + if (part && part.text) { + return part.text + } + return '' + }) + .join('') + } + } + + // 最后的后备选项:只有在内容确实不为空且有意义时才转换为字符串 + if ( + content !== undefined && + content !== null && + content !== '' && + typeof content !== 'object' + ) { + return String(content) + } + + return '' + } + + for (const message of messages) { + const textContent = extractTextContent(message.content) + + if (message.role === 'system') { + systemInstruction += (systemInstruction ? '\n\n' : '') + textContent + } else if (message.role === 'user') { + contents.push({ + role: 'user', + parts: [{ text: textContent }] + }) + } else if (message.role === 'assistant') { + contents.push({ + role: 'model', + parts: [{ text: textContent }] + }) + } + } + + return { contents, systemInstruction } +} + +// 转换 Gemini 响应到 OpenAI 格式 +function convertGeminiResponseToOpenAI(geminiResponse, model, stream = false) { + if (stream) { + // 处理流式响应 - 原样返回 SSE 数据 + return geminiResponse + } else { + // 非流式响应转换 + // 处理嵌套的 response 结构 + const actualResponse = geminiResponse.response || geminiResponse + + if (actualResponse.candidates && actualResponse.candidates.length > 0) { + const candidate = actualResponse.candidates[0] + const content = candidate.content?.parts?.[0]?.text || '' + const finishReason = candidate.finishReason?.toLowerCase() || 'stop' + + // 计算 token 使用量 + const usage = actualResponse.usageMetadata || { + promptTokenCount: 0, + candidatesTokenCount: 0, + totalTokenCount: 0 + } + + return { + id: `chatcmpl-${Date.now()}`, + object: 'chat.completion', + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + message: { + role: 'assistant', + content + }, + finish_reason: finishReason + } + ], + usage: { + prompt_tokens: usage.promptTokenCount, + completion_tokens: usage.candidatesTokenCount, + total_tokens: usage.totalTokenCount + } + } + } else { + throw new Error('No response from Gemini') + } + } +} + +// OpenAI 兼容的聊天完成端点 +router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => { + const startTime = Date.now() + let abortController = null + let account = null // Declare account outside try block for error handling + let accountSelection = null // Declare accountSelection for error handling + let sessionHash = null // Declare sessionHash for error handling + + try { + const apiKeyData = req.apiKey + + // 检查权限 + if (!checkPermissions(apiKeyData, 'gemini')) { + return res.status(403).json({ + error: { + message: 'This API key does not have permission to access Gemini', + type: 'permission_denied', + code: 'permission_denied' + } + }) + } + // 处理请求体结构 - 支持多种格式 + let requestBody = req.body + + // 如果请求体被包装在 body 字段中,解包它 + if (req.body.body && typeof req.body.body === 'object') { + requestBody = req.body.body + } + + // 从 URL 路径中提取模型信息(如果存在) + let urlModel = null + const urlPath = req.body?.config?.url || req.originalUrl || req.url + const modelMatch = urlPath.match(/\/([^/]+):(?:stream)?[Gg]enerateContent/) + if (modelMatch) { + urlModel = modelMatch[1] + logger.debug(`Extracted model from URL: ${urlModel}`) + } + + // 提取请求参数 + const { + messages: requestMessages, + contents: requestContents, + model: bodyModel = 'gemini-2.0-flash-exp', + temperature = 0.7, + max_tokens = 4096, + stream = false + } = requestBody + + // 检查URL中是否包含stream标识 + const isStreamFromUrl = urlPath && urlPath.includes('streamGenerateContent') + const actualStream = stream || isStreamFromUrl + + // 优先使用 URL 中的模型,其次是请求体中的模型 + const model = urlModel || bodyModel + + // 支持两种格式: OpenAI 的 messages 或 Gemini 的 contents + let messages = requestMessages + if (requestContents && Array.isArray(requestContents)) { + messages = requestContents + } + + // 验证必需参数 + if (!messages || !Array.isArray(messages) || messages.length === 0) { + return res.status(400).json({ + error: { + message: 'Messages array is required', + type: 'invalid_request_error', + code: 'invalid_request' + } + }) + } + + // 检查模型限制 + if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels.length > 0) { + if (!apiKeyData.restrictedModels.includes(model)) { + return res.status(403).json({ + error: { + message: `Model ${model} is not allowed for this API key`, + type: 'invalid_request_error', + code: 'model_not_allowed' + } + }) + } + } + + // 转换消息格式 + const { contents: geminiContents, systemInstruction } = convertMessagesToGemini(messages) + + // 构建 Gemini 请求体 + const geminiRequestBody = { + contents: geminiContents, + generationConfig: { + temperature, + maxOutputTokens: max_tokens, + candidateCount: 1 + } + } + + if (systemInstruction) { + geminiRequestBody.systemInstruction = { parts: [{ text: systemInstruction }] } + } + + // 生成会话哈希用于粘性会话 + sessionHash = generateSessionHash(req) + + // 选择可用的 Gemini 账户 + try { + accountSelection = await unifiedGeminiScheduler.selectAccountForApiKey( + apiKeyData, + sessionHash, + model + ) + account = await geminiAccountService.getAccount(accountSelection.accountId) + } catch (error) { + logger.error('Failed to select Gemini account:', error) + account = null + } + + if (!account) { + return res.status(503).json({ + error: { + message: 'No available Gemini accounts', + type: 'service_unavailable', + code: 'service_unavailable' + } + }) + } + + logger.info(`Using Gemini account: ${account.id} for API key: ${apiKeyData.id}`) + + // 标记账户被使用 + 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) + } + } + + // 创建中止控制器 + abortController = new AbortController() + + // 处理客户端断开连接 + req.on('close', () => { + if (abortController && !abortController.signal.aborted) { + logger.info('Client disconnected, aborting Gemini request') + abortController.abort() + } + }) + + // 获取OAuth客户端 + const client = await geminiAccountService.getOauthClient( + account.accessToken, + account.refreshToken, + proxyConfig + ) + if (actualStream) { + // 流式响应 + logger.info('StreamGenerateContent request', { + model, + projectId: account.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 // 传递代理配置 + ) + + // 设置流式响应头 + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + res.setHeader('X-Accel-Buffering', 'no') + + // 处理流式响应,转换为 OpenAI 格式 + let buffer = '' + + // 发送初始的空消息,符合 OpenAI 流式格式 + const initialChunk = { + id: `chatcmpl-${Date.now()}`, + object: 'chat.completion.chunk', + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + delta: { role: 'assistant' }, + finish_reason: null + } + ] + } + res.write(`data: ${JSON.stringify(initialChunk)}\n\n`) + + // 用于收集usage数据 + let totalUsage = { + promptTokenCount: 0, + candidatesTokenCount: 0, + totalTokenCount: 0 + } + const usageReported = false + + streamResponse.on('data', (chunk) => { + try { + const chunkStr = chunk.toString() + + if (!chunkStr.trim()) { + return + } + + buffer += chunkStr + const lines = buffer.split('\n') + buffer = lines.pop() || '' // 保留最后一个不完整的行 + + for (const line of lines) { + if (!line.trim()) { + continue + } + + // 处理 SSE 格式 + let jsonData = line + if (line.startsWith('data: ')) { + jsonData = line.substring(6).trim() + } + + if (!jsonData || jsonData === '[DONE]') { + continue + } + + try { + const data = JSON.parse(jsonData) + + // 捕获usage数据 + if (data.response?.usageMetadata) { + totalUsage = data.response.usageMetadata + logger.debug('📊 Captured Gemini usage data:', totalUsage) + } + + // 转换为 OpenAI 流式格式 + if (data.response?.candidates && data.response.candidates.length > 0) { + const candidate = data.response.candidates[0] + const content = candidate.content?.parts?.[0]?.text || '' + const { finishReason } = candidate + + // 只有当有内容或者是结束标记时才发送数据 + if (content || finishReason === 'STOP') { + const openaiChunk = { + id: `chatcmpl-${Date.now()}`, + object: 'chat.completion.chunk', + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + delta: content ? { content } : {}, + finish_reason: finishReason === 'STOP' ? 'stop' : null + } + ] + } + + res.write(`data: ${JSON.stringify(openaiChunk)}\n\n`) + + // 如果结束了,添加 usage 信息并发送最终的 [DONE] + if (finishReason === 'STOP') { + // 如果有 usage 数据,添加到最后一个 chunk + if (data.response.usageMetadata) { + const usageChunk = { + id: `chatcmpl-${Date.now()}`, + object: 'chat.completion.chunk', + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + delta: {}, + finish_reason: 'stop' + } + ], + usage: { + prompt_tokens: data.response.usageMetadata.promptTokenCount || 0, + completion_tokens: data.response.usageMetadata.candidatesTokenCount || 0, + total_tokens: data.response.usageMetadata.totalTokenCount || 0 + } + } + res.write(`data: ${JSON.stringify(usageChunk)}\n\n`) + } + res.write('data: [DONE]\n\n') + } + } + } + } catch (e) { + logger.debug('Error parsing JSON line:', e.message) + } + } + } catch (error) { + logger.error('Stream processing error:', error) + if (!res.headersSent) { + res.status(500).json({ + error: { + message: error.message || 'Stream error', + type: 'api_error' + } + }) + } + } + }) + + streamResponse.on('end', async () => { + logger.info('Stream completed successfully') + + // 记录使用统计 + if (!usageReported && totalUsage.totalTokenCount > 0) { + try { + const apiKeyService = require('../services/apiKeyService') + await apiKeyService.recordUsage( + apiKeyData.id, + totalUsage.promptTokenCount || 0, + totalUsage.candidatesTokenCount || 0, + 0, // cacheCreateTokens + 0, // cacheReadTokens + model, + account.id + ) + logger.info( + `📊 Recorded Gemini stream usage - Input: ${totalUsage.promptTokenCount}, Output: ${totalUsage.candidatesTokenCount}, Total: ${totalUsage.totalTokenCount}` + ) + } catch (error) { + logger.error('Failed to record Gemini usage:', error) + } + } + + if (!res.headersSent) { + res.write('data: [DONE]\n\n') + } + res.end() + }) + + streamResponse.on('error', (error) => { + logger.error('Stream error:', error) + if (!res.headersSent) { + res.status(500).json({ + error: { + message: error.message || 'Stream error', + type: 'api_error' + } + }) + } else { + // 如果已经开始发送流数据,发送错误事件 + res.write(`data: {"error": {"message": "${error.message || 'Stream error'}"}}\n\n`) + res.write('data: [DONE]\n\n') + res.end() + } + }) + } else { + // 非流式响应 + logger.info('GenerateContent request', { + model, + projectId: account.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 // 传递代理配置 + ) + + // 转换为 OpenAI 格式并返回 + const openaiResponse = convertGeminiResponseToOpenAI(response, model, false) + + // 记录使用统计 + if (openaiResponse.usage) { + try { + const apiKeyService = require('../services/apiKeyService') + await apiKeyService.recordUsage( + apiKeyData.id, + openaiResponse.usage.prompt_tokens || 0, + openaiResponse.usage.completion_tokens || 0, + 0, // cacheCreateTokens + 0, // cacheReadTokens + model, + account.id + ) + logger.info( + `📊 Recorded Gemini usage - Input: ${openaiResponse.usage.prompt_tokens}, Output: ${openaiResponse.usage.completion_tokens}, Total: ${openaiResponse.usage.total_tokens}` + ) + } catch (error) { + logger.error('Failed to record Gemini usage:', error) + } + } + + res.json(openaiResponse) + } + + const duration = Date.now() - startTime + logger.info(`OpenAI-Gemini request completed in ${duration}ms`) + } catch (error) { + logger.error('OpenAI-Gemini request error:', error) + + // 处理速率限制 + if (error.status === 429) { + if (req.apiKey && account && accountSelection) { + await unifiedGeminiScheduler.markAccountRateLimited(account.id, 'gemini', sessionHash) + } + } + + // 返回 OpenAI 格式的错误响应 + const status = error.status || 500 + const errorResponse = { + error: error.error || { + message: error.message || 'Internal server error', + type: 'server_error', + code: 'internal_error' + } + } + + res.status(status).json(errorResponse) + } finally { + // 清理资源 + if (abortController) { + abortController = null + } + } + return undefined +}) + +// OpenAI 兼容的模型列表端点 +router.get('/v1/models', authenticateApiKey, async (req, res) => { + try { + const apiKeyData = req.apiKey + + // 检查权限 + if (!checkPermissions(apiKeyData, 'gemini')) { + return res.status(403).json({ + error: { + message: 'This API key does not have permission to access Gemini', + type: 'permission_denied', + code: 'permission_denied' + } + }) + } + + // 选择账户获取模型列表 + let account = null + try { + const accountSelection = await unifiedGeminiScheduler.selectAccountForApiKey( + apiKeyData, + null, + null + ) + account = await geminiAccountService.getAccount(accountSelection.accountId) + } catch (error) { + logger.warn('Failed to select Gemini account for models endpoint:', error) + } + + let models = [] + + if (account) { + // 获取实际的模型列表 + models = await getAvailableModels(account.accessToken, account.proxy) + } else { + // 返回默认模型列表 + 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)) + } + + res.json({ + object: 'list', + data: models + }) + } catch (error) { + logger.error('Failed to get OpenAI-Gemini models:', error) + res.status(500).json({ + error: { + message: 'Failed to retrieve models', + type: 'server_error', + code: 'internal_error' + } + }) + } + return undefined +}) + +// OpenAI 兼容的模型详情端点 +router.get('/v1/models/:model', authenticateApiKey, async (req, res) => { + try { + const apiKeyData = req.apiKey + const modelId = req.params.model + + // 检查权限 + if (!checkPermissions(apiKeyData, 'gemini')) { + return res.status(403).json({ + error: { + message: 'This API key does not have permission to access Gemini', + type: 'permission_denied', + code: 'permission_denied' + } + }) + } + + // 检查模型限制 + if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels.length > 0) { + if (!apiKeyData.restrictedModels.includes(modelId)) { + return res.status(404).json({ + error: { + message: `Model '${modelId}' not found`, + type: 'invalid_request_error', + code: 'model_not_found' + } + }) + } + } + + // 返回模型信息 + res.json({ + id: modelId, + object: 'model', + created: Math.floor(Date.now() / 1000), + owned_by: 'google', + permission: [], + root: modelId, + parent: null + }) + } catch (error) { + logger.error('Failed to get model details:', error) + res.status(500).json({ + error: { + message: 'Failed to retrieve model details', + type: 'server_error', + code: 'internal_error' + } + }) + } + return undefined +}) + +module.exports = router diff --git a/src/routes/openaiRoutes.js b/src/routes/openaiRoutes.js new file mode 100644 index 0000000000000000000000000000000000000000..13776c8d895a3910f377bb8cb6036595c70f5f60 --- /dev/null +++ b/src/routes/openaiRoutes.js @@ -0,0 +1,921 @@ +const express = require('express') +const axios = require('axios') +const router = express.Router() +const logger = require('../utils/logger') +const config = require('../../config/config') +const { authenticateApiKey } = require('../middleware/auth') +const unifiedOpenAIScheduler = require('../services/unifiedOpenAIScheduler') +const openaiAccountService = require('../services/openaiAccountService') +const openaiResponsesAccountService = require('../services/openaiResponsesAccountService') +const openaiResponsesRelayService = require('../services/openaiResponsesRelayService') +const apiKeyService = require('../services/apiKeyService') +const crypto = require('crypto') +const ProxyHelper = require('../utils/proxyHelper') +const { updateRateLimitCounters } = require('../utils/rateLimitHelper') + +// 创建代理 Agent(使用统一的代理工具) +function createProxyAgent(proxy) { + return ProxyHelper.createProxyAgent(proxy) +} + +// 检查 API Key 是否具备 OpenAI 权限 +function checkOpenAIPermissions(apiKeyData) { + const permissions = apiKeyData?.permissions || 'all' + return permissions === 'all' || permissions === 'openai' +} + +function normalizeHeaders(headers = {}) { + if (!headers || typeof headers !== 'object') { + return {} + } + const normalized = {} + for (const [key, value] of Object.entries(headers)) { + if (!key) { + continue + } + normalized[key.toLowerCase()] = Array.isArray(value) ? value[0] : value + } + return normalized +} + +function toNumberSafe(value) { + if (value === undefined || value === null || value === '') { + return null + } + const num = Number(value) + return Number.isFinite(num) ? num : null +} + +function extractCodexUsageHeaders(headers) { + const normalized = normalizeHeaders(headers) + if (!normalized || Object.keys(normalized).length === 0) { + return null + } + + const snapshot = { + primaryUsedPercent: toNumberSafe(normalized['x-codex-primary-used-percent']), + primaryResetAfterSeconds: toNumberSafe(normalized['x-codex-primary-reset-after-seconds']), + primaryWindowMinutes: toNumberSafe(normalized['x-codex-primary-window-minutes']), + secondaryUsedPercent: toNumberSafe(normalized['x-codex-secondary-used-percent']), + secondaryResetAfterSeconds: toNumberSafe(normalized['x-codex-secondary-reset-after-seconds']), + secondaryWindowMinutes: toNumberSafe(normalized['x-codex-secondary-window-minutes']), + primaryOverSecondaryPercent: toNumberSafe( + normalized['x-codex-primary-over-secondary-limit-percent'] + ) + } + + const hasData = Object.values(snapshot).some((value) => value !== null) + return hasData ? snapshot : null +} + +async function applyRateLimitTracking(req, usageSummary, model, context = '') { + if (!req.rateLimitInfo) { + return + } + + const label = context ? ` (${context})` : '' + + try { + const { totalTokens, totalCost } = await updateRateLimitCounters( + req.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) + } +} + +// 使用统一调度器选择 OpenAI 账户 +async function getOpenAIAuthToken(apiKeyData, sessionId = null, requestedModel = null) { + try { + // 生成会话哈希(如果有会话ID) + const sessionHash = sessionId + ? crypto.createHash('sha256').update(sessionId).digest('hex') + : null + + // 使用统一调度器选择账户 + const result = await unifiedOpenAIScheduler.selectAccountForApiKey( + apiKeyData, + sessionHash, + requestedModel + ) + + if (!result || !result.accountId) { + const error = new Error('No available OpenAI account found') + error.statusCode = 402 // Payment Required - 资源耗尽 + throw error + } + + // 根据账户类型获取账户详情 + let account, + accessToken, + proxy = null + + if (result.accountType === 'openai-responses') { + // 处理 OpenAI-Responses 账户 + account = await openaiResponsesAccountService.getAccount(result.accountId) + if (!account || !account.apiKey) { + const error = new Error(`OpenAI-Responses account ${result.accountId} has no valid apiKey`) + error.statusCode = 403 // Forbidden - 账户配置错误 + throw error + } + + // OpenAI-Responses 账户不需要 accessToken,直接返回账户信息 + accessToken = null // OpenAI-Responses 使用账户内的 apiKey + + // 解析代理配置 + if (account.proxy) { + try { + proxy = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy + } catch (e) { + logger.warn('Failed to parse proxy configuration:', e) + } + } + + logger.info(`Selected OpenAI-Responses account: ${account.name} (${result.accountId})`) + } else { + // 处理普通 OpenAI 账户 + account = await openaiAccountService.getAccount(result.accountId) + if (!account || !account.accessToken) { + const error = new Error(`OpenAI account ${result.accountId} has no valid accessToken`) + error.statusCode = 403 // Forbidden - 账户配置错误 + throw error + } + + // 检查 token 是否过期并自动刷新(双重保护) + if (openaiAccountService.isTokenExpired(account)) { + if (account.refreshToken) { + logger.info(`🔄 Token expired, auto-refreshing for account ${account.name} (fallback)`) + try { + await openaiAccountService.refreshAccountToken(result.accountId) + // 重新获取更新后的账户 + account = await openaiAccountService.getAccount(result.accountId) + logger.info(`✅ Token refreshed successfully in route handler`) + } catch (refreshError) { + logger.error(`Failed to refresh token for ${account.name}:`, refreshError) + const error = new Error(`Token expired and refresh failed: ${refreshError.message}`) + error.statusCode = 403 // Forbidden - 认证失败 + throw error + } + } else { + const error = new Error( + `Token expired and no refresh token available for account ${account.name}` + ) + error.statusCode = 403 // Forbidden - 认证失败 + throw error + } + } + + // 解密 accessToken(account.accessToken 是加密的) + accessToken = openaiAccountService.decrypt(account.accessToken) + if (!accessToken) { + const error = new Error('Failed to decrypt OpenAI accessToken') + error.statusCode = 403 // Forbidden - 配置/权限错误 + throw error + } + + // 解析代理配置 + if (account.proxy) { + try { + proxy = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy + } catch (e) { + logger.warn('Failed to parse proxy configuration:', e) + } + } + + logger.info(`Selected OpenAI account: ${account.name} (${result.accountId})`) + } + + return { + accessToken, + accountId: result.accountId, + accountName: account.name, + accountType: result.accountType, + proxy, + account + } + } catch (error) { + logger.error('Failed to get OpenAI auth token:', error) + throw error + } +} + +// 主处理函数,供两个路由共享 +const handleResponses = async (req, res) => { + let upstream = null + let accountId = null + let accountType = 'openai' + let sessionHash = null + let account = null + let proxy = null + let accessToken = null + + try { + // 从中间件获取 API Key 数据 + const apiKeyData = req.apiKey || {} + + if (!checkOpenAIPermissions(apiKeyData)) { + logger.security( + `🚫 API Key ${apiKeyData.id || 'unknown'} 缺少 OpenAI 权限,拒绝访问 ${req.originalUrl}` + ) + return res.status(403).json({ + error: { + message: 'This API key does not have permission to access OpenAI', + type: 'permission_denied', + code: 'permission_denied' + } + }) + } + + // 从请求头或请求体中提取会话 ID + const sessionId = + req.headers['session_id'] || + req.headers['x-session-id'] || + req.body?.session_id || + req.body?.conversation_id || + null + + sessionHash = sessionId ? crypto.createHash('sha256').update(sessionId).digest('hex') : null + + // 从请求体中提取模型和流式标志 + let requestedModel = req.body?.model || null + + // 如果模型是 gpt-5 开头且后面还有内容(如 gpt-5-2025-08-07),则覆盖为 gpt-5 + if (requestedModel && requestedModel.startsWith('gpt-5-') && requestedModel !== 'gpt-5-codex') { + logger.info(`📝 Model ${requestedModel} detected, normalizing to gpt-5 for Codex API`) + requestedModel = 'gpt-5' + req.body.model = 'gpt-5' // 同时更新请求体中的模型 + } + + const isStream = req.body?.stream !== false // 默认为流式(兼容现有行为) + + // 判断是否为 Codex CLI 的请求 + const isCodexCLI = req.body?.instructions?.startsWith( + 'You are a coding agent running in the Codex CLI' + ) + + // 如果不是 Codex CLI 请求,则进行适配 + if (!isCodexCLI) { + // 移除不需要的请求体字段 + const fieldsToRemove = [ + 'temperature', + 'top_p', + 'max_output_tokens', + 'user', + 'text_formatting', + 'truncation', + 'text', + 'service_tier' + ] + fieldsToRemove.forEach((field) => { + delete req.body[field] + }) + + // 设置固定的 Codex CLI instructions + req.body.instructions = + 'You are a coding agent running in the Codex CLI, a terminal-based coding assistant. Codex CLI is an open source project led by OpenAI. You are expected to be precise, safe, and helpful.\n\nYour capabilities:\n- Receive user prompts and other context provided by the harness, such as files in the workspace.\n- Communicate with the user by streaming thinking & responses, and by making & updating plans.\n- Emit function calls to run terminal commands and apply patches. Depending on how this specific run is configured, you can request that these function calls be escalated to the user for approval before running. More on this in the "Sandbox and approvals" section.\n\nWithin this context, Codex refers to the open-source agentic coding interface (not the old Codex language model built by OpenAI).\n\n# How you work\n\n## Personality\n\nYour default personality and tone is concise, direct, and friendly. You communicate efficiently, always keeping the user clearly informed about ongoing actions without unnecessary detail. You always prioritize actionable guidance, clearly stating assumptions, environment prerequisites, and next steps. Unless explicitly asked, you avoid excessively verbose explanations about your work.\n\n## Responsiveness\n\n### Preamble messages\n\nBefore making tool calls, send a brief preamble to the user explaining what you’re about to do. When sending preamble messages, follow these principles and examples:\n\n- **Logically group related actions**: if you’re about to run several related commands, describe them together in one preamble rather than sending a separate note for each.\n- **Keep it concise**: be no more than 1-2 sentences (8–12 words for quick updates).\n- **Build on prior context**: if this is not your first tool call, use the preamble message to connect the dots with what’s been done so far and create a sense of momentum and clarity for the user to understand your next actions.\n- **Keep your tone light, friendly and curious**: add small touches of personality in preambles feel collaborative and engaging.\n\n**Examples:**\n- “I’ve explored the repo; now checking the API route definitions.”\n- “Next, I’ll patch the config and update the related tests.”\n- “I’m about to scaffold the CLI commands and helper functions.”\n- “Ok cool, so I’ve wrapped my head around the repo. Now digging into the API routes.”\n- “Config’s looking tidy. Next up is patching helpers to keep things in sync.”\n- “Finished poking at the DB gateway. I will now chase down error handling.”\n- “Alright, build pipeline order is interesting. Checking how it reports failures.”\n- “Spotted a clever caching util; now hunting where it gets used.”\n\n**Avoiding a preamble for every trivial read (e.g., `cat` a single file) unless it’s part of a larger grouped action.\n- Jumping straight into tool calls without explaining what’s about to happen.\n- Writing overly long or speculative preambles — focus on immediate, tangible next steps.\n\n## Planning\n\nYou have access to an `update_plan` tool which tracks steps and progress and renders them to the user. Using the tool helps demonstrate that you\'ve understood the task and convey how you\'re approaching it. Plans can help to make complex, ambiguous, or multi-phase work clearer and more collaborative for the user. A good plan should break the task into meaningful, logically ordered steps that are easy to verify as you go. Note that plans are not for padding out simple work with filler steps or stating the obvious. Do not repeat the full contents of the plan after an `update_plan` call — the harness already displays it. Instead, summarize the change made and highlight any important context or next step.\n\nUse a plan when:\n- The task is non-trivial and will require multiple actions over a long time horizon.\n- There are logical phases or dependencies where sequencing matters.\n- The work has ambiguity that benefits from outlining high-level goals.\n- You want intermediate checkpoints for feedback and validation.\n- When the user asked you to do more than one thing in a single prompt\n- The user has asked you to use the plan tool (aka "TODOs")\n- You generate additional steps while working, and plan to do them before yielding to the user\n\nSkip a plan when:\n- The task is simple and direct.\n- Breaking it down would only produce literal or trivial steps.\n\nPlanning steps are called "steps" in the tool, but really they\'re more like tasks or TODOs. As such they should be very concise descriptions of non-obvious work that an engineer might do like "Write the API spec", then "Update the backend", then "Implement the frontend". On the other hand, it\'s obvious that you\'ll usually have to "Explore the codebase" or "Implement the changes", so those are not worth tracking in your plan.\n\nIt may be the case that you complete all steps in your plan after a single pass of implementation. If this is the case, you can simply mark all the planned steps as completed. The content of your plan should not involve doing anything that you aren\'t capable of doing (i.e. don\'t try to test things that you can\'t test). Do not use plans for simple or single-step queries that you can just do or answer immediately.\n\n### Examples\n\n**High-quality plans**\n\nExample 1:\n\n1. Add CLI entry with file args\n2. Parse Markdown via CommonMark library\n3. Apply semantic HTML template\n4. Handle code blocks, images, links\n5. Add error handling for invalid files\n\nExample 2:\n\n1. Define CSS variables for colors\n2. Add toggle with localStorage state\n3. Refactor components to use variables\n4. Verify all views for readability\n5. Add smooth theme-change transition\n\nExample 3:\n\n1. Set up Node.js + WebSocket server\n2. Add join/leave broadcast events\n3. Implement messaging with timestamps\n4. Add usernames + mention highlighting\n5. Persist messages in lightweight DB\n6. Add typing indicators + unread count\n\n**Low-quality plans**\n\nExample 1:\n\n1. Create CLI tool\n2. Add Markdown parser\n3. Convert to HTML\n\nExample 2:\n\n1. Add dark mode toggle\n2. Save preference\n3. Make styles look good\n\nExample 3:\n\n1. Create single-file HTML game\n2. Run quick sanity check\n3. Summarize usage instructions\n\nIf you need to write a plan, only write high quality plans, not low quality ones.\n\n## Task execution\n\nYou are a coding agent. Please keep going until the query is completely resolved, before ending your turn and yielding back to the user. Only terminate your turn when you are sure that the problem is solved. Autonomously resolve the query to the best of your ability, using the tools available to you, before coming back to the user. Do NOT guess or make up an answer.\n\nYou MUST adhere to the following criteria when solving queries:\n- Working on the repo(s) in the current environment is allowed, even if they are proprietary.\n- Analyzing code for vulnerabilities is allowed.\n- Showing user code and tool call details is allowed.\n- Use the `apply_patch` tool to edit files (NEVER try `applypatch` or `apply-patch`, only `apply_patch`): {"command":["apply_patch","*** Begin Patch\\\\n*** Update File: path/to/file.py\\\\n@@ def example():\\\\n- pass\\\\n+ return 123\\\\n*** End Patch"]}\n\nIf completing the user\'s task requires writing or modifying files, your code and final answer should follow these coding guidelines, though user instructions (i.e. AGENTS.md) may override these guidelines:\n\n- Fix the problem at the root cause rather than applying surface-level patches, when possible.\n- Avoid unneeded complexity in your solution.\n- Do not attempt to fix unrelated bugs or broken tests. It is not your responsibility to fix them. (You may mention them to the user in your final message though.)\n- Update documentation as necessary.\n- Keep changes consistent with the style of the existing codebase. Changes should be minimal and focused on the task.\n- Use `git log` and `git blame` to search the history of the codebase if additional context is required.\n- NEVER add copyright or license headers unless specifically requested.\n- Do not waste tokens by re-reading files after calling `apply_patch` on them. The tool call will fail if it didn\'t work. The same goes for making folders, deleting folders, etc.\n- Do not `git commit` your changes or create new git branches unless explicitly requested.\n- Do not add inline comments within code unless explicitly requested.\n- Do not use one-letter variable names unless explicitly requested.\n- NEVER output inline citations like "【F:README.md†L5-L14】" in your outputs. The CLI is not able to render these so they will just be broken in the UI. Instead, if you output valid filepaths, users will be able to click on them to open the files in their editor.\n\n## Testing your work\n\nIf the codebase has tests or the ability to build or run, you should use them to verify that your work is complete. Generally, your testing philosophy should be to start as specific as possible to the code you changed so that you can catch issues efficiently, then make your way to broader tests as you build confidence. If there\'s no test for the code you changed, and if the adjacent patterns in the codebases show that there\'s a logical place for you to add a test, you may do so. However, do not add tests to codebases with no tests, or where the patterns don\'t indicate so.\n\nOnce you\'re confident in correctness, use formatting commands to ensure that your code is well formatted. These commands can take time so you should run them on as precise a target as possible. If there are issues you can iterate up to 3 times to get formatting right, but if you still can\'t manage it\'s better to save the user time and present them a correct solution where you call out the formatting in your final message. If the codebase does not have a formatter configured, do not add one.\n\nFor all of testing, running, building, and formatting, do not attempt to fix unrelated bugs. It is not your responsibility to fix them. (You may mention them to the user in your final message though.)\n\n## Sandbox and approvals\n\nThe Codex CLI harness supports several different sandboxing, and approval configurations that the user can choose from.\n\nFilesystem sandboxing prevents you from editing files without user approval. The options are:\n- *read-only*: You can only read files.\n- *workspace-write*: You can read files. You can write to files in your workspace folder, but not outside it.\n- *danger-full-access*: No filesystem sandboxing.\n\nNetwork sandboxing prevents you from accessing network without approval. Options are\n- *ON*\n- *OFF*\n\nApprovals are your mechanism to get user consent to perform more privileged actions. Although they introduce friction to the user because your work is paused until the user responds, you should leverage them to accomplish your important work. Do not let these settings or the sandbox deter you from attempting to accomplish the user\'s task. Approval options are\n- *untrusted*: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands.\n- *on-failure*: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox.\n- *on-request*: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you\'ll see parameters for it in the `shell` command description.)\n- *never*: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is pared with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don\'t see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding.\n\nWhen you are running with approvals `on-request`, and sandboxing enabled, here are scenarios where you\'ll need to request approval:\n- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /tmp)\n- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files.\n- You are running sandboxed and need to run a command that requires network access (e.g. installing packages)\n- If you run a command that is important to solving the user\'s query, but it fails because of sandboxing, rerun the command with approval.\n- You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for\n- (For all of these, you should weigh alternative paths that do not require approval.)\n\nNote that when sandboxing is set to read-only, you\'ll need to request approval for any command that isn\'t a read.\n\nYou will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing ON, and approval on-failure.\n\n## Ambition vs. precision\n\nFor tasks that have no prior context (i.e. the user is starting something brand new), you should feel free to be ambitious and demonstrate creativity with your implementation.\n\nIf you\'re operating in an existing codebase, you should make sure you do exactly what the user asks with surgical precision. Treat the surrounding codebase with respect, and don\'t overstep (i.e. changing filenames or variables unnecessarily). You should balance being sufficiently ambitious and proactive when completing tasks of this nature.\n\nYou should use judicious initiative to decide on the right level of detail and complexity to deliver based on the user\'s needs. This means showing good judgment that you\'re capable of doing the right extras without gold-plating. This might be demonstrated by high-value, creative touches when scope of the task is vague; while being surgical and targeted when scope is tightly specified.\n\n## Sharing progress updates\n\nFor especially longer tasks that you work on (i.e. requiring many tool calls, or a plan with multiple steps), you should provide progress updates back to the user at reasonable intervals. These updates should be structured as a concise sentence or two (no more than 8-10 words long) recapping progress so far in plain language: this update demonstrates your understanding of what needs to be done, progress so far (i.e. files explores, subtasks complete), and where you\'re going next.\n\nBefore doing large chunks of work that may incur latency as experienced by the user (i.e. writing a new file), you should send a concise message to the user with an update indicating what you\'re about to do to ensure they know what you\'re spending time on. Don\'t start editing or writing large files before informing the user what you are doing and why.\n\nThe messages you send before tool calls should describe what is immediately about to be done next in very concise language. If there was previous work done, this preamble message should also include a note about the work done so far to bring the user along.\n\n## Presenting your work and final message\n\nYour final message should read naturally, like an update from a concise teammate. For casual conversation, brainstorming tasks, or quick questions from the user, respond in a friendly, conversational tone. You should ask questions, suggest ideas, and adapt to the user’s style. If you\'ve finished a large amount of work, when describing what you\'ve done to the user, you should follow the final answer formatting guidelines to communicate substantive changes. You don\'t need to add structured formatting for one-word answers, greetings, or purely conversational exchanges.\n\nYou can skip heavy formatting for single, simple actions or confirmations. In these cases, respond in plain sentences with any relevant next step or quick option. Reserve multi-section structured responses for results that need grouping or explanation.\n\nThe user is working on the same computer as you, and has access to your work. As such there\'s no need to show the full contents of large files you have already written unless the user explicitly asks for them. Similarly, if you\'ve created or modified files using `apply_patch`, there\'s no need to tell users to "save the file" or "copy the code into a file"—just reference the file path.\n\nIf there\'s something that you think you could help with as a logical next step, concisely ask the user if they want you to do so. Good examples of this are running tests, committing changes, or building out the next logical component. If there’s something that you couldn\'t do (even with approval) but that the user might want to do (such as verifying changes by running the app), include those instructions succinctly.\n\nBrevity is very important as a default. You should be very concise (i.e. no more than 10 lines), but can relax this requirement for tasks where additional detail and comprehensiveness is important for the user\'s understanding.\n\n### Final answer structure and style guidelines\n\nYou are producing plain text that will later be styled by the CLI. Follow these rules exactly. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value.\n\n**Section Headers**\n- Use only when they improve clarity — they are not mandatory for every answer.\n- Choose descriptive names that fit the content\n- Keep headers short (1–3 words) and in `**Title Case**`. Always start headers with `**` and end with `**`\n- Leave no blank line before the first bullet under a header.\n- Section headers should only be used where they genuinely improve scanability; avoid fragmenting the answer.\n\n**Bullets**\n- Use `-` followed by a space for every bullet.\n- Bold the keyword, then colon + concise description.\n- Merge related points when possible; avoid a bullet for every trivial detail.\n- Keep bullets to one line unless breaking for clarity is unavoidable.\n- Group into short lists (4–6 bullets) ordered by importance.\n- Use consistent keyword phrasing and formatting across sections.\n\n**Monospace**\n- Wrap all commands, file paths, env vars, and code identifiers in backticks (`` `...` ``).\n- Apply to inline examples and to bullet keywords if the keyword itself is a literal file/command.\n- Never mix monospace and bold markers; choose one based on whether it’s a keyword (`**`) or inline code/path (`` ` ``).\n\n**Structure**\n- Place related bullets together; don’t mix unrelated concepts in the same section.\n- Order sections from general → specific → supporting info.\n- For subsections (e.g., “Binaries” under “Rust Workspace”), introduce with a bolded keyword bullet, then list items under it.\n- Match structure to complexity:\n - Multi-part or detailed results → use clear headers and grouped bullets.\n - Simple results → minimal headers, possibly just a short list or paragraph.\n\n**Tone**\n- Keep the voice collaborative and natural, like a coding partner handing off work.\n- Be concise and factual — no filler or conversational commentary and avoid unnecessary repetition\n- Use present tense and active voice (e.g., “Runs tests” not “This will run tests”).\n- Keep descriptions self-contained; don’t refer to “above” or “below”.\n- Use parallel structure in lists for consistency.\n\n**Don’t**\n- Don’t use literal words “bold” or “monospace” in the content.\n- Don’t nest bullets or create deep hierarchies.\n- Don’t output ANSI escape codes directly — the CLI renderer applies them.\n- Don’t cram unrelated keywords into a single bullet; split for clarity.\n- Don’t let keyword lists run long — wrap or reformat for scanability.\n\nGenerally, ensure your final answers adapt their shape and depth to the request. For example, answers to code explanations should have a precise, structured explanation with code references that answer the question directly. For tasks with a simple implementation, lead with the outcome and supplement only with what’s needed for clarity. Larger changes can be presented as a logical walkthrough of your approach, grouping related steps, explaining rationale where it adds value, and highlighting next actions to accelerate the user. Your answers should provide the right level of detail while being easily scannable.\n\nFor casual greetings, acknowledgements, or other one-off conversational messages that are not delivering substantive information or structured results, respond naturally without section headers or bullet formatting.\n\n# Tools\n\n## `apply_patch`\n\nYour patch language is a stripped‑down, file‑oriented diff format designed to be easy to parse and safe to apply. You can think of it as a high‑level envelope:\n\n**_ Begin Patch\n[ one or more file sections ]\n_** End Patch\n\nWithin that envelope, you get a sequence of file operations.\nYou MUST include a header to specify the action you are taking.\nEach operation starts with one of three headers:\n\n**_ Add File: - create a new file. Every following line is a + line (the initial contents).\n_** Delete File: - remove an existing file. Nothing follows.\n\\*\\*\\* Update File: - patch an existing file in place (optionally with a rename).\n\nMay be immediately followed by \\*\\*\\* Move to: if you want to rename the file.\nThen one or more “hunks”, each introduced by @@ (optionally followed by a hunk header).\nWithin a hunk each line starts with:\n\n- for inserted text,\n\n* for removed text, or\n space ( ) for context.\n At the end of a truncated hunk you can emit \\*\\*\\* End of File.\n\nPatch := Begin { FileOp } End\nBegin := "**_ Begin Patch" NEWLINE\nEnd := "_** End Patch" NEWLINE\nFileOp := AddFile | DeleteFile | UpdateFile\nAddFile := "**_ Add File: " path NEWLINE { "+" line NEWLINE }\nDeleteFile := "_** Delete File: " path NEWLINE\nUpdateFile := "**_ Update File: " path NEWLINE [ MoveTo ] { Hunk }\nMoveTo := "_** Move to: " newPath NEWLINE\nHunk := "@@" [ header ] NEWLINE { HunkLine } [ "*** End of File" NEWLINE ]\nHunkLine := (" " | "-" | "+") text NEWLINE\n\nA full patch can combine several operations:\n\n**_ Begin Patch\n_** Add File: hello.txt\n+Hello world\n**_ Update File: src/app.py\n_** Move to: src/main.py\n@@ def greet():\n-print("Hi")\n+print("Hello, world!")\n**_ Delete File: obsolete.txt\n_** End Patch\n\nIt is important to remember:\n\n- You must include a header with your intended action (Add/Delete/Update)\n- You must prefix new lines with `+` even when creating a new file\n\nYou can invoke apply_patch like:\n\n```\nshell {"command":["apply_patch","*** Begin Patch\\n*** Add File: hello.txt\\n+Hello, world!\\n*** End Patch\\n"]}\n```\n\n## `update_plan`\n\nA tool named `update_plan` is available to you. You can use it to keep an up‑to‑date, step‑by‑step plan for the task.\n\nTo create a new plan, call `update_plan` with a short list of 1‑sentence steps (no more than 5-7 words each) with a `status` for each step (`pending`, `in_progress`, or `completed`).\n\nWhen steps have been completed, use `update_plan` to mark each finished step as `completed` and the next step you are working on as `in_progress`. There should always be exactly one `in_progress` step until everything is done. You can mark multiple items as complete in a single `update_plan` call.\n\nIf all steps are complete, ensure you call `update_plan` to mark all steps as `completed`.\n' + + logger.info('📝 Non-Codex CLI request detected, applying Codex CLI adaptation') + } else { + logger.info('✅ Codex CLI request detected, forwarding as-is') + } + + // 使用调度器选择账户 + ;({ accessToken, accountId, accountType, proxy, account } = await getOpenAIAuthToken( + apiKeyData, + sessionId, + requestedModel + )) + + // 如果是 OpenAI-Responses 账户,使用专门的中继服务处理 + if (accountType === 'openai-responses') { + logger.info(`🔀 Using OpenAI-Responses relay service for account: ${account.name}`) + return await openaiResponsesRelayService.handleRequest(req, res, account, apiKeyData) + } + // 基于白名单构造上游所需的请求头,确保键为小写且值受控 + const incoming = req.headers || {} + + const allowedKeys = ['version', 'openai-beta', 'session_id'] + + const headers = {} + for (const key of allowedKeys) { + if (incoming[key] !== undefined) { + headers[key] = incoming[key] + } + } + + // 覆盖或新增必要头部 + headers['authorization'] = `Bearer ${accessToken}` + headers['chatgpt-account-id'] = account.accountId || account.chatgptUserId || accountId + headers['host'] = 'chatgpt.com' + headers['accept'] = isStream ? 'text/event-stream' : 'application/json' + headers['content-type'] = 'application/json' + req.body['store'] = false + + // 创建代理 agent + const proxyAgent = createProxyAgent(proxy) + + // 配置请求选项 + const axiosConfig = { + headers, + timeout: config.requestTimeout || 600000, + validateStatus: () => true + } + + // 如果有代理,添加代理配置 + if (proxyAgent) { + axiosConfig.httpsAgent = proxyAgent + axiosConfig.proxy = false + logger.info(`🌐 Using proxy for OpenAI request: ${ProxyHelper.getProxyDescription(proxy)}`) + } else { + logger.debug('🌐 No proxy configured for OpenAI request') + } + + // 根据 stream 参数决定请求类型 + if (isStream) { + // 流式请求 + upstream = await axios.post('https://chatgpt.com/backend-api/codex/responses', req.body, { + ...axiosConfig, + responseType: 'stream' + }) + } else { + // 非流式请求 + upstream = await axios.post( + 'https://chatgpt.com/backend-api/codex/responses', + req.body, + axiosConfig + ) + } + + const codexUsageSnapshot = extractCodexUsageHeaders(upstream.headers) + if (codexUsageSnapshot) { + try { + await openaiAccountService.updateCodexUsageSnapshot(accountId, codexUsageSnapshot) + } catch (codexError) { + logger.error('⚠️ 更新 Codex 使用统计失败:', codexError) + } + } + + // 处理 429 限流错误 + if (upstream.status === 429) { + logger.warn(`🚫 Rate limit detected for OpenAI account ${accountId} (Codex API)`) + + // 解析响应体中的限流信息 + let resetsInSeconds = null + let errorData = null + + try { + // 对于429错误,无论是否是流式请求,响应都会是完整的JSON错误对象 + if (isStream && upstream.data) { + // 流式响应需要先收集数据 + const chunks = [] + await new Promise((resolve, reject) => { + upstream.data.on('data', (chunk) => chunks.push(chunk)) + upstream.data.on('end', resolve) + upstream.data.on('error', reject) + // 设置超时防止无限等待 + setTimeout(resolve, 5000) + }) + + const fullResponse = Buffer.concat(chunks).toString() + try { + errorData = JSON.parse(fullResponse) + } catch (e) { + logger.error('Failed to parse 429 error response:', e) + logger.debug('Raw response:', fullResponse) + } + } else { + // 非流式响应直接使用data + errorData = upstream.data + } + + // 提取重置时间 + if (errorData && errorData.error && errorData.error.resets_in_seconds) { + resetsInSeconds = errorData.error.resets_in_seconds + logger.info( + `🕐 Codex rate limit will reset in ${resetsInSeconds} seconds (${Math.ceil(resetsInSeconds / 60)} minutes / ${Math.ceil(resetsInSeconds / 3600)} hours)` + ) + } else { + logger.warn( + '⚠️ Could not extract resets_in_seconds from 429 response, using default 60 minutes' + ) + } + } catch (e) { + logger.error('⚠️ Failed to parse rate limit error:', e) + } + + // 标记账户为限流状态 + await unifiedOpenAIScheduler.markAccountRateLimited( + accountId, + 'openai', + sessionHash, + resetsInSeconds + ) + + // 返回错误响应给客户端 + const errorResponse = errorData || { + error: { + type: 'usage_limit_reached', + message: 'The usage limit has been reached', + resets_in_seconds: resetsInSeconds + } + } + + if (isStream) { + // 流式响应也需要设置正确的状态码 + res.status(429) + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + res.write(`data: ${JSON.stringify(errorResponse)}\n\n`) + res.end() + } else { + res.status(429).json(errorResponse) + } + + return + } else if (upstream.status === 401 || upstream.status === 402) { + const unauthorizedStatus = upstream.status + const statusDescription = unauthorizedStatus === 401 ? 'Unauthorized' : 'Payment required' + logger.warn( + `🔐 ${statusDescription} error detected for OpenAI account ${accountId} (Codex API)` + ) + + let errorData = null + + try { + if (isStream && upstream.data && typeof upstream.data.on === 'function') { + const chunks = [] + await new Promise((resolve, reject) => { + upstream.data.on('data', (chunk) => chunks.push(chunk)) + upstream.data.on('end', resolve) + upstream.data.on('error', reject) + setTimeout(resolve, 5000) + }) + + const fullResponse = Buffer.concat(chunks).toString() + try { + errorData = JSON.parse(fullResponse) + } catch (parseError) { + logger.error(`Failed to parse ${unauthorizedStatus} error response:`, parseError) + logger.debug(`Raw ${unauthorizedStatus} response:`, fullResponse) + errorData = { error: { message: fullResponse || 'Unauthorized' } } + } + } else { + errorData = upstream.data + } + } catch (parseError) { + logger.error(`⚠️ Failed to handle ${unauthorizedStatus} error response:`, parseError) + } + + const statusLabel = unauthorizedStatus === 401 ? '401错误' : '402错误' + const extraHint = unauthorizedStatus === 402 ? ',可能欠费' : '' + let reason = `OpenAI账号认证失败(${statusLabel}${extraHint})` + if (errorData) { + const messageCandidate = + errorData.error && + typeof errorData.error.message === 'string' && + errorData.error.message.trim() + ? errorData.error.message.trim() + : typeof errorData.message === 'string' && errorData.message.trim() + ? errorData.message.trim() + : null + if (messageCandidate) { + reason = `OpenAI账号认证失败(${statusLabel}${extraHint}):${messageCandidate}` + } + } + + try { + await unifiedOpenAIScheduler.markAccountUnauthorized( + accountId, + 'openai', + sessionHash, + reason + ) + } catch (markError) { + logger.error( + `❌ Failed to mark OpenAI account unauthorized after ${unauthorizedStatus}:`, + markError + ) + } + + let errorResponse = errorData + if (!errorResponse || typeof errorResponse !== 'object' || Buffer.isBuffer(errorResponse)) { + const fallbackMessage = + typeof errorData === 'string' && errorData.trim() ? errorData.trim() : 'Unauthorized' + errorResponse = { + error: { + message: fallbackMessage, + type: 'unauthorized', + code: 'unauthorized' + } + } + } + + res.status(unauthorizedStatus).json(errorResponse) + return + } else if (upstream.status === 200 || upstream.status === 201) { + // 请求成功,检查并移除限流状态 + const isRateLimited = await unifiedOpenAIScheduler.isAccountRateLimited(accountId) + if (isRateLimited) { + logger.info( + `✅ Removing rate limit for OpenAI account ${accountId} after successful request` + ) + await unifiedOpenAIScheduler.removeAccountRateLimit(accountId, 'openai') + } + } + + res.status(upstream.status) + + if (isStream) { + // 流式响应头 + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + res.setHeader('X-Accel-Buffering', 'no') + } else { + // 非流式响应头 + res.setHeader('Content-Type', 'application/json') + } + + // 透传关键诊断头,避免传递不安全或与传输相关的头 + const passThroughHeaderKeys = ['openai-version', 'x-request-id', 'openai-processing-ms'] + for (const key of passThroughHeaderKeys) { + const val = upstream.headers?.[key] + if (val !== undefined) { + res.setHeader(key, val) + } + } + + if (isStream) { + // 立即刷新响应头,开始 SSE + if (typeof res.flushHeaders === 'function') { + res.flushHeaders() + } + } + + // 处理响应并捕获 usage 数据和真实的 model + let buffer = '' + let usageData = null + let actualModel = null + let usageReported = false + let rateLimitDetected = false + let rateLimitResetsInSeconds = null + + if (!isStream) { + // 非流式响应处理 + try { + logger.info(`📄 Processing OpenAI non-stream response for model: ${requestedModel}`) + + // 直接获取完整响应 + const responseData = upstream.data + + // 从响应中获取实际的 model 和 usage + actualModel = responseData.model || requestedModel || 'gpt-4' + usageData = responseData.usage + + logger.debug(`📊 Non-stream response - Model: ${actualModel}, Usage:`, usageData) + + // 记录使用统计 + if (usageData) { + const totalInputTokens = usageData.input_tokens || usageData.prompt_tokens || 0 + const outputTokens = usageData.output_tokens || usageData.completion_tokens || 0 + const cacheReadTokens = usageData.input_tokens_details?.cached_tokens || 0 + // 计算实际输入token(总输入减去缓存部分) + const actualInputTokens = Math.max(0, totalInputTokens - cacheReadTokens) + + await apiKeyService.recordUsage( + apiKeyData.id, + actualInputTokens, // 传递实际输入(不含缓存) + outputTokens, + 0, // OpenAI没有cache_creation_tokens + cacheReadTokens, + actualModel, + accountId + ) + + logger.info( + `📊 Recorded OpenAI non-stream usage - Input: ${totalInputTokens}(actual:${actualInputTokens}+cached:${cacheReadTokens}), Output: ${outputTokens}, Total: ${usageData.total_tokens || totalInputTokens + outputTokens}, Model: ${actualModel}` + ) + + await applyRateLimitTracking( + req, + { + inputTokens: actualInputTokens, + outputTokens, + cacheCreateTokens: 0, + cacheReadTokens + }, + actualModel, + 'openai-non-stream' + ) + } + + // 返回响应 + res.json(responseData) + return + } catch (error) { + logger.error('Failed to process non-stream response:', error) + if (!res.headersSent) { + res.status(500).json({ error: { message: 'Failed to process response' } }) + } + return + } + } + + // 解析 SSE 事件以捕获 usage 数据和 model + const parseSSEForUsage = (data) => { + const lines = data.split('\n') + + for (const line of lines) { + if (line.startsWith('event: response.completed')) { + // 下一行应该是数据 + continue + } + + if (line.startsWith('data: ')) { + try { + const jsonStr = line.slice(6) // 移除 'data: ' 前缀 + const eventData = JSON.parse(jsonStr) + + // 检查是否是 response.completed 事件 + if (eventData.type === 'response.completed' && eventData.response) { + // 从响应中获取真实的 model + if (eventData.response.model) { + actualModel = eventData.response.model + logger.debug(`📊 Captured actual model: ${actualModel}`) + } + + // 获取 usage 数据 + if (eventData.response.usage) { + usageData = eventData.response.usage + logger.debug('📊 Captured OpenAI usage data:', usageData) + } + } + + // 检查是否有限流错误 + if (eventData.error && eventData.error.type === 'usage_limit_reached') { + rateLimitDetected = true + if (eventData.error.resets_in_seconds) { + rateLimitResetsInSeconds = eventData.error.resets_in_seconds + logger.warn( + `🚫 Rate limit detected in stream, resets in ${rateLimitResetsInSeconds} seconds` + ) + } + } + } catch (e) { + // 忽略解析错误 + } + } + } + } + + upstream.data.on('data', (chunk) => { + try { + const chunkStr = chunk.toString() + + // 转发数据给客户端 + if (!res.destroyed) { + res.write(chunk) + } + + // 同时解析数据以捕获 usage 信息 + buffer += chunkStr + + // 处理完整的 SSE 事件 + if (buffer.includes('\n\n')) { + const events = buffer.split('\n\n') + buffer = events.pop() || '' // 保留最后一个可能不完整的事件 + + for (const event of events) { + if (event.trim()) { + parseSSEForUsage(event) + } + } + } + } catch (error) { + logger.error('Error processing OpenAI stream chunk:', error) + } + }) + + upstream.data.on('end', async () => { + // 处理剩余的 buffer + if (buffer.trim()) { + parseSSEForUsage(buffer) + } + + // 记录使用统计 + if (!usageReported && usageData) { + try { + const totalInputTokens = usageData.input_tokens || 0 + const outputTokens = usageData.output_tokens || 0 + const cacheReadTokens = usageData.input_tokens_details?.cached_tokens || 0 + // 计算实际输入token(总输入减去缓存部分) + const actualInputTokens = Math.max(0, totalInputTokens - cacheReadTokens) + + // 使用响应中的真实 model,如果没有则使用请求中的 model,最后回退到默认值 + const modelToRecord = actualModel || requestedModel || 'gpt-4' + + await apiKeyService.recordUsage( + apiKeyData.id, + actualInputTokens, // 传递实际输入(不含缓存) + outputTokens, + 0, // OpenAI没有cache_creation_tokens + cacheReadTokens, + modelToRecord, + accountId + ) + + logger.info( + `📊 Recorded OpenAI usage - Input: ${totalInputTokens}(actual:${actualInputTokens}+cached:${cacheReadTokens}), Output: ${outputTokens}, Total: ${usageData.total_tokens || totalInputTokens + outputTokens}, Model: ${modelToRecord} (actual: ${actualModel}, requested: ${requestedModel})` + ) + usageReported = true + + await applyRateLimitTracking( + req, + { + inputTokens: actualInputTokens, + outputTokens, + cacheCreateTokens: 0, + cacheReadTokens + }, + modelToRecord, + 'openai-stream' + ) + } catch (error) { + logger.error('Failed to record OpenAI usage:', error) + } + } + + // 如果在流式响应中检测到限流 + if (rateLimitDetected) { + logger.warn(`🚫 Processing rate limit for OpenAI account ${accountId} from stream`) + await unifiedOpenAIScheduler.markAccountRateLimited( + accountId, + 'openai', + sessionHash, + rateLimitResetsInSeconds + ) + } else if (upstream.status === 200) { + // 流式请求成功,检查并移除限流状态 + const isRateLimited = await unifiedOpenAIScheduler.isAccountRateLimited(accountId) + if (isRateLimited) { + logger.info( + `✅ Removing rate limit for OpenAI account ${accountId} after successful stream` + ) + await unifiedOpenAIScheduler.removeAccountRateLimit(accountId, 'openai') + } + } + + res.end() + }) + + upstream.data.on('error', (err) => { + logger.error('Upstream stream error:', err) + if (!res.headersSent) { + res.status(502).json({ error: { message: 'Upstream stream error' } }) + } else { + res.end() + } + }) + + // 客户端断开时清理上游流 + const cleanup = () => { + try { + upstream.data?.unpipe?.(res) + upstream.data?.destroy?.() + } catch (_) { + // + } + } + req.on('close', cleanup) + req.on('aborted', cleanup) + } catch (error) { + logger.error('Proxy to ChatGPT codex/responses failed:', error) + // 优先使用主动设置的 statusCode,然后是上游响应的状态码,最后默认 500 + const status = error.statusCode || error.response?.status || 500 + + if ((status === 401 || status === 402) && accountId) { + const statusLabel = status === 401 ? '401错误' : '402错误' + const extraHint = status === 402 ? ',可能欠费' : '' + let reason = `OpenAI账号认证失败(${statusLabel}${extraHint})` + const errorData = error.response?.data + if (errorData) { + if (typeof errorData === 'string' && errorData.trim()) { + reason = `OpenAI账号认证失败(${statusLabel}${extraHint}):${errorData.trim()}` + } else if ( + errorData.error && + typeof errorData.error.message === 'string' && + errorData.error.message.trim() + ) { + reason = `OpenAI账号认证失败(${statusLabel}${extraHint}):${errorData.error.message.trim()}` + } else if (typeof errorData.message === 'string' && errorData.message.trim()) { + reason = `OpenAI账号认证失败(${statusLabel}${extraHint}):${errorData.message.trim()}` + } + } else if (error.message) { + reason = `OpenAI账号认证失败(${statusLabel}${extraHint}):${error.message}` + } + + try { + await unifiedOpenAIScheduler.markAccountUnauthorized( + accountId, + accountType || 'openai', + sessionHash, + reason + ) + } catch (markError) { + logger.error('❌ Failed to mark OpenAI account unauthorized in catch handler:', markError) + } + } + + let responsePayload = error.response?.data + if (!responsePayload) { + responsePayload = { error: { message: error.message || 'Internal server error' } } + } else if (typeof responsePayload === 'string') { + responsePayload = { error: { message: responsePayload } } + } else if (typeof responsePayload === 'object' && !responsePayload.error) { + responsePayload = { + error: { message: responsePayload.message || error.message || 'Internal server error' } + } + } + + if (!res.headersSent) { + res.status(status).json(responsePayload) + } + } +} + +// 注册两个路由路径,都使用相同的处理函数 +router.post('/responses', authenticateApiKey, handleResponses) +router.post('/v1/responses', authenticateApiKey, handleResponses) + +// 使用情况统计端点 +router.get('/usage', authenticateApiKey, async (req, res) => { + try { + const { usage } = req.apiKey + + res.json({ + object: 'usage', + total_tokens: usage.total.tokens, + total_requests: usage.total.requests, + daily_tokens: usage.daily.tokens, + daily_requests: usage.daily.requests, + monthly_tokens: usage.monthly.tokens, + monthly_requests: usage.monthly.requests + }) + } catch (error) { + logger.error('Failed to get usage stats:', error) + res.status(500).json({ + error: { + message: 'Failed to retrieve usage statistics', + type: 'api_error' + } + }) + } +}) + +// API Key 信息端点 +router.get('/key-info', authenticateApiKey, async (req, res) => { + try { + const keyData = req.apiKey + res.json({ + id: keyData.id, + name: keyData.name, + description: keyData.description, + permissions: keyData.permissions || 'all', + token_limit: keyData.tokenLimit, + tokens_used: keyData.usage.total.tokens, + tokens_remaining: + keyData.tokenLimit > 0 + ? Math.max(0, keyData.tokenLimit - keyData.usage.total.tokens) + : null, + rate_limit: { + window: keyData.rateLimitWindow, + requests: keyData.rateLimitRequests + }, + usage: { + total: keyData.usage.total, + daily: keyData.usage.daily, + monthly: keyData.usage.monthly + } + }) + } catch (error) { + logger.error('Failed to get key info:', error) + res.status(500).json({ + error: { + message: 'Failed to retrieve API key information', + type: 'api_error' + } + }) + } +}) + +module.exports = router diff --git a/src/routes/standardGeminiRoutes.js b/src/routes/standardGeminiRoutes.js new file mode 100644 index 0000000000000000000000000000000000000000..afcb98dc1c1d875429e9c79e777d7f34f53b8d71 --- /dev/null +++ b/src/routes/standardGeminiRoutes.js @@ -0,0 +1,810 @@ +const express = require('express') +const router = express.Router() +const { authenticateApiKey } = require('../middleware/auth') +const logger = require('../utils/logger') +const geminiAccountService = require('../services/geminiAccountService') +const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler') +const apiKeyService = require('../services/apiKeyService') +const sessionHelper = require('../utils/sessionHelper') + +// 导入 geminiRoutes 中导出的处理函数 +const { handleLoadCodeAssist, handleOnboardUser, handleCountTokens } = require('./geminiRoutes') + +// 检查 API Key 是否具备 Gemini 权限 +function hasGeminiPermission(apiKeyData, requiredPermission = 'gemini') { + const permissions = apiKeyData?.permissions || 'all' + return permissions === 'all' || permissions === requiredPermission +} + +// 确保请求拥有 Gemini 权限 +function ensureGeminiPermission(req, res) { + const apiKeyData = req.apiKey || {} + if (hasGeminiPermission(apiKeyData, 'gemini')) { + return true + } + + logger.security( + `🚫 API Key ${apiKeyData.id || 'unknown'} 缺少 Gemini 权限,拒绝访问 ${req.originalUrl}` + ) + + res.status(403).json({ + error: { + message: 'This API key does not have permission to access Gemini', + type: 'permission_denied' + } + }) + return false +} + +// 供路由中间件复用的权限检查 +function ensureGeminiPermissionMiddleware(req, res, next) { + if (ensureGeminiPermission(req, res)) { + return next() + } + return undefined +} + +// 标准 Gemini API 路由处理器 +// 这些路由将挂载在 /gemini 路径下,处理标准 Gemini API 格式的请求 +// 标准格式: /gemini/v1beta/models/{model}:generateContent + +// 专门处理标准 Gemini API 格式的 generateContent +async function handleStandardGenerateContent(req, res) { + try { + if (!ensureGeminiPermission(req, res)) { + return undefined + } + + // 从路径参数中获取模型名 + const model = req.params.modelName || 'gemini-2.0-flash-exp' + const sessionHash = sessionHelper.generateSessionHash(req.body) + + // 标准 Gemini API 请求体直接包含 contents 等字段 + const { contents, generationConfig, safetySettings, systemInstruction } = req.body + + // 验证必需参数 + if (!contents || !Array.isArray(contents) || contents.length === 0) { + return res.status(400).json({ + error: { + message: 'Contents array is required', + type: 'invalid_request_error' + } + }) + } + + // 构建内部 API 需要的请求格式 + const actualRequestData = { + contents, + generationConfig: generationConfig || { + temperature: 0.7, + maxOutputTokens: 4096, + topP: 0.95, + topK: 40 + } + } + + // 只有在 safetySettings 存在且非空时才添加 + if (safetySettings && safetySettings.length > 0) { + actualRequestData.safetySettings = safetySettings + } + + // 如果有 system instruction,修正格式并添加到请求体 + // Gemini CLI 的内部 API 需要 role: "user" 字段 + if (systemInstruction) { + // 确保 systemInstruction 格式正确 + if (typeof systemInstruction === 'string' && systemInstruction.trim()) { + actualRequestData.systemInstruction = { + role: 'user', // Gemini CLI 内部 API 需要这个字段 + parts: [{ text: systemInstruction }] + } + } else if (systemInstruction.parts && systemInstruction.parts.length > 0) { + // 检查是否有实际内容 + const hasContent = systemInstruction.parts.some( + (part) => part.text && part.text.trim() !== '' + ) + if (hasContent) { + // 添加 role 字段(Gemini CLI 格式) + actualRequestData.systemInstruction = { + role: 'user', // Gemini CLI 内部 API 需要这个字段 + parts: systemInstruction.parts + } + } + } + } + + // 使用统一调度选择账号 + const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey( + req.apiKey, + sessionHash, + model + ) + const account = await geminiAccountService.getAccount(accountId) + const { accessToken, refreshToken } = account + + const version = req.path.includes('v1beta') ? 'v1beta' : 'v1' + logger.info(`Standard Gemini API generateContent request (${version})`, { + model, + projectId: account.projectId, + apiKeyId: req.apiKey?.id || 'unknown' + }) + + // 解析账户的代理配置 + 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(accessToken, refreshToken, proxyConfig) + + // 项目ID优先级:账户配置的项目ID > 临时项目ID > 尝试获取 + let effectiveProjectId = account.projectId || account.tempProjectId || null + + // 如果没有任何项目ID,尝试调用 loadCodeAssist 获取 + if (!effectiveProjectId) { + try { + logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...') + const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig) + + if (loadResponse.cloudaicompanionProject) { + effectiveProjectId = loadResponse.cloudaicompanionProject + // 保存临时项目ID + await geminiAccountService.updateTempProjectId(accountId, effectiveProjectId) + logger.info(`📋 Fetched and cached temporary projectId: ${effectiveProjectId}`) + } + } catch (loadError) { + logger.warn('Failed to fetch projectId from loadCodeAssist:', loadError.message) + } + } + + // 如果还是没有项目ID,返回错误 + if (!effectiveProjectId) { + return res.status(403).json({ + error: { + message: + 'This account requires a project ID to be configured. Please configure a project ID in the account settings.', + type: 'configuration_required' + } + }) + } + + logger.info('📋 Standard API 项目ID处理逻辑', { + accountProjectId: account.projectId, + tempProjectId: account.tempProjectId, + effectiveProjectId, + decision: account.projectId + ? '使用账户配置' + : account.tempProjectId + ? '使用临时项目ID' + : '从loadCodeAssist获取' + }) + + // 生成一个符合 Gemini CLI 格式的 user_prompt_id + const userPromptId = `${require('crypto').randomUUID()}########0` + + // 调用内部 API(cloudcode-pa) + const response = await geminiAccountService.generateContent( + client, + { model, request: actualRequestData }, + userPromptId, // 使用生成的 user_prompt_id + effectiveProjectId, // 使用处理后的项目ID + req.apiKey?.id, // 使用 API Key ID 作为 session ID + proxyConfig + ) + + // 记录使用统计 + if (response?.response?.usageMetadata) { + try { + const usage = response.response.usageMetadata + await apiKeyService.recordUsage( + req.apiKey.id, + usage.promptTokenCount || 0, + usage.candidatesTokenCount || 0, + 0, // cacheCreateTokens + 0, // cacheReadTokens + model, + account.id + ) + logger.info( + `📊 Recorded Gemini usage - Input: ${usage.promptTokenCount}, Output: ${usage.candidatesTokenCount}, Total: ${usage.totalTokenCount}` + ) + } catch (error) { + logger.error('Failed to record Gemini usage:', error) + } + } + + // 返回标准 Gemini API 格式的响应 + // 内部 API 返回的是 { response: {...} } 格式,需要提取并过滤 + if (response.response) { + // 过滤掉 thought 部分(这是内部 API 特有的) + const standardResponse = { ...response.response } + if (standardResponse.candidates) { + standardResponse.candidates = standardResponse.candidates.map((candidate) => { + if (candidate.content && candidate.content.parts) { + // 过滤掉 thought: true 的 parts + const filteredParts = candidate.content.parts.filter((part) => !part.thought) + return { + ...candidate, + content: { + ...candidate.content, + parts: filteredParts + } + } + } + return candidate + }) + } + res.json(standardResponse) + } else { + res.json(response) + } + } catch (error) { + logger.error(`Error in standard generateContent endpoint`, { + message: error.message, + status: error.response?.status, + statusText: error.response?.statusText, + responseData: error.response?.data, + stack: error.stack + }) + res.status(500).json({ + error: { + message: error.message || 'Internal server error', + type: 'api_error' + } + }) + } +} + +// 专门处理标准 Gemini API 格式的 streamGenerateContent +async function handleStandardStreamGenerateContent(req, res) { + let abortController = null + + try { + if (!ensureGeminiPermission(req, res)) { + return undefined + } + + // 从路径参数中获取模型名 + const model = req.params.modelName || 'gemini-2.0-flash-exp' + const sessionHash = sessionHelper.generateSessionHash(req.body) + + // 标准 Gemini API 请求体直接包含 contents 等字段 + const { contents, generationConfig, safetySettings, systemInstruction } = req.body + + // 验证必需参数 + if (!contents || !Array.isArray(contents) || contents.length === 0) { + return res.status(400).json({ + error: { + message: 'Contents array is required', + type: 'invalid_request_error' + } + }) + } + + // 构建内部 API 需要的请求格式 + const actualRequestData = { + contents, + generationConfig: generationConfig || { + temperature: 0.7, + maxOutputTokens: 4096, + topP: 0.95, + topK: 40 + } + } + + // 只有在 safetySettings 存在且非空时才添加 + if (safetySettings && safetySettings.length > 0) { + actualRequestData.safetySettings = safetySettings + } + + // 如果有 system instruction,修正格式并添加到请求体 + // Gemini CLI 的内部 API 需要 role: "user" 字段 + if (systemInstruction) { + // 确保 systemInstruction 格式正确 + if (typeof systemInstruction === 'string' && systemInstruction.trim()) { + actualRequestData.systemInstruction = { + role: 'user', // Gemini CLI 内部 API 需要这个字段 + parts: [{ text: systemInstruction }] + } + } else if (systemInstruction.parts && systemInstruction.parts.length > 0) { + // 检查是否有实际内容 + const hasContent = systemInstruction.parts.some( + (part) => part.text && part.text.trim() !== '' + ) + if (hasContent) { + // 添加 role 字段(Gemini CLI 格式) + actualRequestData.systemInstruction = { + role: 'user', // Gemini CLI 内部 API 需要这个字段 + parts: systemInstruction.parts + } + } + } + } + + // 使用统一调度选择账号 + const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey( + req.apiKey, + sessionHash, + model + ) + const account = await geminiAccountService.getAccount(accountId) + const { accessToken, refreshToken } = account + + const version = req.path.includes('v1beta') ? 'v1beta' : 'v1' + logger.info(`Standard Gemini API streamGenerateContent request (${version})`, { + model, + projectId: account.projectId, + apiKeyId: req.apiKey?.id || 'unknown' + }) + + // 创建中止控制器 + abortController = new AbortController() + + // 处理客户端断开连接 + req.on('close', () => { + if (abortController && !abortController.signal.aborted) { + logger.info('Client disconnected, aborting stream request') + abortController.abort() + } + }) + + // 解析账户的代理配置 + 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(accessToken, refreshToken, proxyConfig) + + // 项目ID优先级:账户配置的项目ID > 临时项目ID > 尝试获取 + let effectiveProjectId = account.projectId || account.tempProjectId || null + + // 如果没有任何项目ID,尝试调用 loadCodeAssist 获取 + if (!effectiveProjectId) { + try { + logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...') + const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig) + + if (loadResponse.cloudaicompanionProject) { + effectiveProjectId = loadResponse.cloudaicompanionProject + // 保存临时项目ID + await geminiAccountService.updateTempProjectId(accountId, effectiveProjectId) + logger.info(`📋 Fetched and cached temporary projectId: ${effectiveProjectId}`) + } + } catch (loadError) { + logger.warn('Failed to fetch projectId from loadCodeAssist:', loadError.message) + } + } + + // 如果还是没有项目ID,返回错误 + if (!effectiveProjectId) { + return res.status(403).json({ + error: { + message: + 'This account requires a project ID to be configured. Please configure a project ID in the account settings.', + type: 'configuration_required' + } + }) + } + + logger.info('📋 Standard API 流式项目ID处理逻辑', { + accountProjectId: account.projectId, + tempProjectId: account.tempProjectId, + effectiveProjectId, + decision: account.projectId + ? '使用账户配置' + : account.tempProjectId + ? '使用临时项目ID' + : '从loadCodeAssist获取' + }) + + // 生成一个符合 Gemini CLI 格式的 user_prompt_id + const userPromptId = `${require('crypto').randomUUID()}########0` + + // 调用内部 API(cloudcode-pa)的流式接口 + const streamResponse = await geminiAccountService.generateContentStream( + client, + { model, request: actualRequestData }, + userPromptId, // 使用生成的 user_prompt_id + effectiveProjectId, // 使用处理后的项目ID + req.apiKey?.id, // 使用 API Key ID 作为 session ID + abortController.signal, + proxyConfig + ) + + // 设置 SSE 响应头 + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + res.setHeader('X-Accel-Buffering', 'no') + + // 处理流式响应并捕获usage数据 + let totalUsage = { + promptTokenCount: 0, + candidatesTokenCount: 0, + totalTokenCount: 0 + } + + streamResponse.on('data', (chunk) => { + try { + if (!res.destroyed) { + const chunkStr = chunk.toString() + + // 处理 SSE 格式的数据 + const lines = chunkStr.split('\n') + for (const line of lines) { + if (line.startsWith('data: ')) { + const jsonStr = line.substring(6).trim() + if (jsonStr && jsonStr !== '[DONE]') { + try { + const data = JSON.parse(jsonStr) + + // 捕获 usage 数据 + if (data.response?.usageMetadata) { + totalUsage = data.response.usageMetadata + } + + // 转换格式:移除 response 包装,直接返回标准 Gemini API 格式 + if (data.response) { + // 过滤掉 thought 部分(这是内部 API 特有的) + if (data.response.candidates) { + const filteredCandidates = data.response.candidates + .map((candidate) => { + if (candidate.content && candidate.content.parts) { + // 过滤掉 thought: true 的 parts + const filteredParts = candidate.content.parts.filter( + (part) => !part.thought + ) + if (filteredParts.length > 0) { + return { + ...candidate, + content: { + ...candidate.content, + parts: filteredParts + } + } + } + return null + } + return candidate + }) + .filter(Boolean) + + // 只有当有有效内容时才发送 + if (filteredCandidates.length > 0 || data.response.usageMetadata) { + const standardResponse = { + candidates: filteredCandidates, + ...(data.response.usageMetadata && { + usageMetadata: data.response.usageMetadata + }), + ...(data.response.modelVersion && { + modelVersion: data.response.modelVersion + }), + ...(data.response.createTime && { createTime: data.response.createTime }), + ...(data.response.responseId && { responseId: data.response.responseId }) + } + res.write(`data: ${JSON.stringify(standardResponse)}\n\n`) + } + } + } else { + // 如果没有 response 包装,直接发送 + res.write(`data: ${JSON.stringify(data)}\n\n`) + } + } catch (e) { + // 忽略解析错误 + } + } else if (jsonStr === '[DONE]') { + // 保持 [DONE] 标记 + res.write(`${line}\n\n`) + } + } + } + } + } catch (error) { + logger.error('Error processing stream chunk:', error) + } + }) + + streamResponse.on('end', async () => { + logger.info('Stream completed successfully') + + // 记录使用统计 + if (totalUsage.totalTokenCount > 0) { + try { + await apiKeyService.recordUsage( + req.apiKey.id, + totalUsage.promptTokenCount || 0, + totalUsage.candidatesTokenCount || 0, + 0, // cacheCreateTokens + 0, // cacheReadTokens + model, + account.id + ) + logger.info( + `📊 Recorded Gemini stream usage - Input: ${totalUsage.promptTokenCount}, Output: ${totalUsage.candidatesTokenCount}, Total: ${totalUsage.totalTokenCount}` + ) + } catch (error) { + logger.error('Failed to record Gemini usage:', error) + } + } + + res.end() + }) + + streamResponse.on('error', (error) => { + logger.error('Stream error:', error) + if (!res.headersSent) { + res.status(500).json({ + error: { + message: error.message || 'Stream error', + type: 'api_error' + } + }) + } else { + res.end() + } + }) + } catch (error) { + logger.error(`Error in standard streamGenerateContent endpoint`, { + message: error.message, + status: error.response?.status, + statusText: error.response?.statusText, + responseData: error.response?.data, + stack: error.stack + }) + + if (!res.headersSent) { + res.status(500).json({ + error: { + message: error.message || 'Internal server error', + type: 'api_error' + } + }) + } + } finally { + // 清理资源 + if (abortController) { + abortController = null + } + } +} + +// v1beta 版本的标准路由 - 支持动态模型名称 +router.post( + '/v1beta/models/:modelName\\:loadCodeAssist', + authenticateApiKey, + ensureGeminiPermissionMiddleware, + (req, res, next) => { + logger.info(`Standard Gemini API request: ${req.method} ${req.originalUrl}`) + handleLoadCodeAssist(req, res, next) + } +) + +router.post( + '/v1beta/models/:modelName\\:onboardUser', + authenticateApiKey, + ensureGeminiPermissionMiddleware, + (req, res, next) => { + logger.info(`Standard Gemini API request: ${req.method} ${req.originalUrl}`) + handleOnboardUser(req, res, next) + } +) + +router.post( + '/v1beta/models/:modelName\\:countTokens', + authenticateApiKey, + ensureGeminiPermissionMiddleware, + (req, res, next) => { + logger.info(`Standard Gemini API request: ${req.method} ${req.originalUrl}`) + handleCountTokens(req, res, next) + } +) + +// 使用专门的处理函数处理标准 Gemini API 格式 +router.post( + '/v1beta/models/:modelName\\:generateContent', + authenticateApiKey, + ensureGeminiPermissionMiddleware, + handleStandardGenerateContent +) + +router.post( + '/v1beta/models/:modelName\\:streamGenerateContent', + authenticateApiKey, + ensureGeminiPermissionMiddleware, + handleStandardStreamGenerateContent +) + +// v1 版本的标准路由(为了完整性,虽然 Gemini 主要使用 v1beta) +router.post( + '/v1/models/:modelName\\:generateContent', + authenticateApiKey, + ensureGeminiPermissionMiddleware, + handleStandardGenerateContent +) + +router.post( + '/v1/models/:modelName\\:streamGenerateContent', + authenticateApiKey, + ensureGeminiPermissionMiddleware, + handleStandardStreamGenerateContent +) + +router.post( + '/v1/models/:modelName\\:countTokens', + authenticateApiKey, + ensureGeminiPermissionMiddleware, + (req, res, next) => { + logger.info(`Standard Gemini API request (v1): ${req.method} ${req.originalUrl}`) + handleCountTokens(req, res, next) + } +) + +// v1internal 版本的标准路由(这些使用原有的处理函数,因为格式不同) +router.post( + '/v1internal\\:loadCodeAssist', + authenticateApiKey, + ensureGeminiPermissionMiddleware, + (req, res, next) => { + logger.info(`Standard Gemini API request (v1internal): ${req.method} ${req.originalUrl}`) + handleLoadCodeAssist(req, res, next) + } +) + +router.post( + '/v1internal\\:onboardUser', + authenticateApiKey, + ensureGeminiPermissionMiddleware, + (req, res, next) => { + logger.info(`Standard Gemini API request (v1internal): ${req.method} ${req.originalUrl}`) + handleOnboardUser(req, res, next) + } +) + +router.post( + '/v1internal\\:countTokens', + authenticateApiKey, + ensureGeminiPermissionMiddleware, + (req, res, next) => { + logger.info(`Standard Gemini API request (v1internal): ${req.method} ${req.originalUrl}`) + handleCountTokens(req, res, next) + } +) + +// v1internal 使用不同的处理逻辑,因为它们不包含模型在 URL 中 +router.post( + '/v1internal\\:generateContent', + authenticateApiKey, + ensureGeminiPermissionMiddleware, + (req, res, next) => { + logger.info(`Standard Gemini API request (v1internal): ${req.method} ${req.originalUrl}`) + // v1internal 格式不同,使用原有的处理函数 + const { handleGenerateContent } = require('./geminiRoutes') + handleGenerateContent(req, res, next) + } +) + +router.post( + '/v1internal\\:streamGenerateContent', + authenticateApiKey, + ensureGeminiPermissionMiddleware, + (req, res, next) => { + logger.info(`Standard Gemini API request (v1internal): ${req.method} ${req.originalUrl}`) + // v1internal 格式不同,使用原有的处理函数 + const { handleStreamGenerateContent } = require('./geminiRoutes') + handleStreamGenerateContent(req, res, next) + } +) + +// 添加标准 Gemini API 的模型列表端点 +router.get( + '/v1beta/models', + authenticateApiKey, + ensureGeminiPermissionMiddleware, + async (req, res) => { + try { + logger.info('Standard Gemini API models request') + // 直接调用 geminiRoutes 中的模型处理逻辑 + const geminiRoutes = require('./geminiRoutes') + const modelHandler = geminiRoutes.stack.find( + (layer) => layer.route && layer.route.path === '/models' && layer.route.methods.get + ) + if (modelHandler && modelHandler.route.stack[1]) { + // 调用处理函数(跳过第一个 authenticateApiKey 中间件) + modelHandler.route.stack[1].handle(req, res) + } else { + res.status(500).json({ error: 'Models handler not found' }) + } + } catch (error) { + logger.error('Error in standard models endpoint:', error) + res.status(500).json({ + error: { + message: 'Failed to retrieve models', + type: 'api_error' + } + }) + } + } +) + +router.get('/v1/models', authenticateApiKey, ensureGeminiPermissionMiddleware, async (req, res) => { + try { + logger.info('Standard Gemini API models request (v1)') + // 直接调用 geminiRoutes 中的模型处理逻辑 + const geminiRoutes = require('./geminiRoutes') + const modelHandler = geminiRoutes.stack.find( + (layer) => layer.route && layer.route.path === '/models' && layer.route.methods.get + ) + if (modelHandler && modelHandler.route.stack[1]) { + modelHandler.route.stack[1].handle(req, res) + } else { + res.status(500).json({ error: 'Models handler not found' }) + } + } catch (error) { + logger.error('Error in standard models endpoint (v1):', error) + res.status(500).json({ + error: { + message: 'Failed to retrieve models', + type: 'api_error' + } + }) + } +}) + +// 添加模型详情端点 +router.get( + '/v1beta/models/:modelName', + authenticateApiKey, + ensureGeminiPermissionMiddleware, + (req, res) => { + const { modelName } = req.params + logger.info(`Standard Gemini API model details request: ${modelName}`) + + res.json({ + name: `models/${modelName}`, + version: '001', + displayName: modelName, + description: `Gemini model: ${modelName}`, + inputTokenLimit: 1048576, + outputTokenLimit: 8192, + supportedGenerationMethods: ['generateContent', 'streamGenerateContent', 'countTokens'], + temperature: 1.0, + topP: 0.95, + topK: 40 + }) + } +) + +router.get( + '/v1/models/:modelName', + authenticateApiKey, + ensureGeminiPermissionMiddleware, + (req, res) => { + const { modelName } = req.params + logger.info(`Standard Gemini API model details request (v1): ${modelName}`) + + res.json({ + name: `models/${modelName}`, + version: '001', + displayName: modelName, + description: `Gemini model: ${modelName}`, + inputTokenLimit: 1048576, + outputTokenLimit: 8192, + supportedGenerationMethods: ['generateContent', 'streamGenerateContent', 'countTokens'], + temperature: 1.0, + topP: 0.95, + topK: 40 + }) + } +) + +logger.info('Standard Gemini API routes initialized') + +module.exports = router diff --git a/src/routes/userRoutes.js b/src/routes/userRoutes.js new file mode 100644 index 0000000000000000000000000000000000000000..131880ef6c36558672fdbc3b7a885aa937f88969 --- /dev/null +++ b/src/routes/userRoutes.js @@ -0,0 +1,764 @@ +const express = require('express') +const router = express.Router() +const ldapService = require('../services/ldapService') +const userService = require('../services/userService') +const apiKeyService = require('../services/apiKeyService') +const logger = require('../utils/logger') +const config = require('../../config/config') +const inputValidator = require('../utils/inputValidator') +const { RateLimiterRedis } = require('rate-limiter-flexible') +const redis = require('../models/redis') +const { authenticateUser, authenticateUserOrAdmin, requireAdmin } = require('../middleware/auth') + +// 🚦 配置登录速率限制 +// 只基于IP地址限制,避免攻击者恶意锁定特定账户 + +// 延迟初始化速率限制器,确保 Redis 已连接 +let ipRateLimiter = null +let strictIpRateLimiter = null + +// 初始化速率限制器函数 +function initRateLimiters() { + if (!ipRateLimiter) { + try { + const redisClient = redis.getClientSafe() + + // IP地址速率限制 - 正常限制 + ipRateLimiter = new RateLimiterRedis({ + storeClient: redisClient, + keyPrefix: 'login_ip_limiter', + points: 30, // 每个IP允许30次尝试 + duration: 900, // 15分钟窗口期 + blockDuration: 900 // 超限后封禁15分钟 + }) + + // IP地址速率限制 - 严格限制(用于检测暴力破解) + strictIpRateLimiter = new RateLimiterRedis({ + storeClient: redisClient, + keyPrefix: 'login_ip_strict', + points: 100, // 每个IP允许100次尝试 + duration: 3600, // 1小时窗口期 + blockDuration: 3600 // 超限后封禁1小时 + }) + } catch (error) { + logger.error('❌ 初始化速率限制器失败:', error) + // 速率限制器初始化失败时继续运行,但记录错误 + } + } + return { ipRateLimiter, strictIpRateLimiter } +} + +// 🔐 用户登录端点 +router.post('/login', async (req, res) => { + try { + const { username, password } = req.body + const clientIp = req.ip || req.connection.remoteAddress || 'unknown' + + // 初始化速率限制器(如果尚未初始化) + const limiters = initRateLimiters() + + // 检查IP速率限制 - 基础限制 + if (limiters.ipRateLimiter) { + try { + await limiters.ipRateLimiter.consume(clientIp) + } catch (rateLimiterRes) { + const retryAfter = Math.round(rateLimiterRes.msBeforeNext / 1000) || 900 + logger.security(`🚫 Login rate limit exceeded for IP: ${clientIp}`) + res.set('Retry-After', String(retryAfter)) + return res.status(429).json({ + error: 'Too many requests', + message: `Too many login attempts from this IP. Please try again later.` + }) + } + } + + // 检查IP速率限制 - 严格限制(防止暴力破解) + if (limiters.strictIpRateLimiter) { + try { + await limiters.strictIpRateLimiter.consume(clientIp) + } catch (rateLimiterRes) { + const retryAfter = Math.round(rateLimiterRes.msBeforeNext / 1000) || 3600 + logger.security(`🚫 Strict rate limit exceeded for IP: ${clientIp} - possible brute force`) + res.set('Retry-After', String(retryAfter)) + return res.status(429).json({ + error: 'Too many requests', + message: 'Too many login attempts detected. Access temporarily blocked.' + }) + } + } + + if (!username || !password) { + return res.status(400).json({ + error: 'Missing credentials', + message: 'Username and password are required' + }) + } + + // 验证输入格式 + let validatedUsername + try { + validatedUsername = inputValidator.validateUsername(username) + inputValidator.validatePassword(password) + } catch (validationError) { + return res.status(400).json({ + error: 'Invalid input', + message: validationError.message + }) + } + + // 检查用户管理是否启用 + if (!config.userManagement.enabled) { + return res.status(503).json({ + error: 'Service unavailable', + message: 'User management is not enabled' + }) + } + + // 检查LDAP是否启用 + if (!config.ldap || !config.ldap.enabled) { + return res.status(503).json({ + error: 'Service unavailable', + message: 'LDAP authentication is not enabled' + }) + } + + // 尝试LDAP认证 + const authResult = await ldapService.authenticateUserCredentials(validatedUsername, password) + + if (!authResult.success) { + // 登录失败 + logger.info(`🚫 Failed login attempt for user: ${validatedUsername} from IP: ${clientIp}`) + return res.status(401).json({ + error: 'Authentication failed', + message: authResult.message + }) + } + + // 登录成功 + logger.info(`✅ User login successful: ${validatedUsername} from IP: ${clientIp}`) + + res.json({ + success: true, + message: 'Login successful', + user: { + id: authResult.user.id, + username: authResult.user.username, + email: authResult.user.email, + displayName: authResult.user.displayName, + firstName: authResult.user.firstName, + lastName: authResult.user.lastName, + role: authResult.user.role + }, + sessionToken: authResult.sessionToken + }) + } catch (error) { + logger.error('❌ User login error:', error) + res.status(500).json({ + error: 'Login error', + message: 'Internal server error during login' + }) + } +}) + +// 🚪 用户登出端点 +router.post('/logout', authenticateUser, async (req, res) => { + try { + await userService.invalidateUserSession(req.user.sessionToken) + + logger.info(`👋 User logout: ${req.user.username}`) + + res.json({ + success: true, + message: 'Logout successful' + }) + } catch (error) { + logger.error('❌ User logout error:', error) + res.status(500).json({ + error: 'Logout error', + message: 'Internal server error during logout' + }) + } +}) + +// 👤 获取当前用户信息 +router.get('/profile', authenticateUser, async (req, res) => { + try { + const user = await userService.getUserById(req.user.id) + if (!user) { + return res.status(404).json({ + error: 'User not found', + message: 'User profile not found' + }) + } + + res.json({ + success: true, + user: { + id: user.id, + username: user.username, + email: user.email, + displayName: user.displayName, + firstName: user.firstName, + lastName: user.lastName, + role: user.role, + isActive: user.isActive, + createdAt: user.createdAt, + lastLoginAt: user.lastLoginAt, + apiKeyCount: user.apiKeyCount, + totalUsage: user.totalUsage + }, + config: { + maxApiKeysPerUser: config.userManagement.maxApiKeysPerUser, + allowUserDeleteApiKeys: config.userManagement.allowUserDeleteApiKeys + } + }) + } catch (error) { + logger.error('❌ Get user profile error:', error) + res.status(500).json({ + error: 'Profile error', + message: 'Failed to retrieve user profile' + }) + } +}) + +// 🔑 获取用户的API Keys +router.get('/api-keys', authenticateUser, async (req, res) => { + try { + const { includeDeleted = 'false' } = req.query + const apiKeys = await apiKeyService.getUserApiKeys(req.user.id, includeDeleted === 'true') + + // 移除敏感信息并格式化usage数据 + const safeApiKeys = apiKeys.map((key) => { + // Flatten usage structure for frontend compatibility + let flatUsage = { + requests: 0, + inputTokens: 0, + outputTokens: 0, + totalCost: 0 + } + + if (key.usage && key.usage.total) { + flatUsage = { + requests: key.usage.total.requests || 0, + inputTokens: key.usage.total.inputTokens || 0, + outputTokens: key.usage.total.outputTokens || 0, + totalCost: key.totalCost || 0 + } + } + + return { + id: key.id, + name: key.name, + description: key.description, + tokenLimit: key.tokenLimit, + isActive: key.isActive, + createdAt: key.createdAt, + lastUsedAt: key.lastUsedAt, + expiresAt: key.expiresAt, + usage: flatUsage, + dailyCost: key.dailyCost, + dailyCostLimit: key.dailyCostLimit, + totalCost: key.totalCost, + totalCostLimit: key.totalCostLimit, + // 不返回实际的key值,只返回前缀和后几位 + keyPreview: key.key + ? `${key.key.substring(0, 8)}...${key.key.substring(key.key.length - 4)}` + : null, + // Include deletion fields for deleted keys + isDeleted: key.isDeleted, + deletedAt: key.deletedAt, + deletedBy: key.deletedBy, + deletedByType: key.deletedByType + } + }) + + res.json({ + success: true, + apiKeys: safeApiKeys, + total: safeApiKeys.length + }) + } catch (error) { + logger.error('❌ Get user API keys error:', error) + res.status(500).json({ + error: 'API Keys error', + message: 'Failed to retrieve API keys' + }) + } +}) + +// 🔑 创建新的API Key +router.post('/api-keys', authenticateUser, async (req, res) => { + try { + const { name, description, tokenLimit, expiresAt, dailyCostLimit, totalCostLimit } = req.body + + if (!name || !name.trim()) { + return res.status(400).json({ + error: 'Missing name', + message: 'API key name is required' + }) + } + + if ( + totalCostLimit !== undefined && + totalCostLimit !== null && + totalCostLimit !== '' && + (Number.isNaN(Number(totalCostLimit)) || Number(totalCostLimit) < 0) + ) { + return res.status(400).json({ + error: 'Invalid total cost limit', + message: 'Total cost limit must be a non-negative number' + }) + } + + // 检查用户API Key数量限制 + const userApiKeys = await apiKeyService.getUserApiKeys(req.user.id) + if (userApiKeys.length >= config.userManagement.maxApiKeysPerUser) { + return res.status(400).json({ + error: 'API key limit exceeded', + message: `You can only have up to ${config.userManagement.maxApiKeysPerUser} API keys` + }) + } + + // 创建API Key数据 + const apiKeyData = { + name: name.trim(), + description: description?.trim() || '', + userId: req.user.id, + userUsername: req.user.username, + tokenLimit: tokenLimit || null, + expiresAt: expiresAt || null, + dailyCostLimit: dailyCostLimit || null, + totalCostLimit: totalCostLimit || null, + createdBy: 'user', + // 设置服务权限为全部服务,确保前端显示“服务权限”为“全部服务”且具备完整访问权限 + permissions: 'all' + } + + const newApiKey = await apiKeyService.createApiKey(apiKeyData) + + // 更新用户API Key数量 + await userService.updateUserApiKeyCount(req.user.id, userApiKeys.length + 1) + + logger.info(`🔑 User ${req.user.username} created API key: ${name}`) + + res.status(201).json({ + success: true, + message: 'API key created successfully', + apiKey: { + id: newApiKey.id, + name: newApiKey.name, + description: newApiKey.description, + key: newApiKey.apiKey, // 只在创建时返回完整key + tokenLimit: newApiKey.tokenLimit, + expiresAt: newApiKey.expiresAt, + dailyCostLimit: newApiKey.dailyCostLimit, + totalCostLimit: newApiKey.totalCostLimit, + createdAt: newApiKey.createdAt + } + }) + } catch (error) { + logger.error('❌ Create user API key error:', error) + res.status(500).json({ + error: 'API Key creation error', + message: 'Failed to create API key' + }) + } +}) + +// 🗑️ 删除API Key +router.delete('/api-keys/:keyId', authenticateUser, async (req, res) => { + try { + const { keyId } = req.params + + // 检查是否允许用户删除自己的API Keys + if (!config.userManagement.allowUserDeleteApiKeys) { + return res.status(403).json({ + error: 'Operation not allowed', + message: + 'Users are not allowed to delete their own API keys. Please contact an administrator.' + }) + } + + // 检查API Key是否属于当前用户 + const existingKey = await apiKeyService.getApiKeyById(keyId) + if (!existingKey || existingKey.userId !== req.user.id) { + return res.status(404).json({ + error: 'API key not found', + message: 'API key not found or you do not have permission to access it' + }) + } + + await apiKeyService.deleteApiKey(keyId, req.user.username, 'user') + + // 更新用户API Key数量 + const userApiKeys = await apiKeyService.getUserApiKeys(req.user.id) + await userService.updateUserApiKeyCount(req.user.id, userApiKeys.length) + + logger.info(`🗑️ User ${req.user.username} deleted API key: ${existingKey.name}`) + + res.json({ + success: true, + message: 'API key deleted successfully' + }) + } catch (error) { + logger.error('❌ Delete user API key error:', error) + res.status(500).json({ + error: 'API Key deletion error', + message: 'Failed to delete API key' + }) + } +}) + +// 📊 获取用户使用统计 +router.get('/usage-stats', authenticateUser, async (req, res) => { + try { + const { period = 'week', model } = req.query + + // 获取用户的API Keys (including deleted ones for complete usage stats) + const userApiKeys = await apiKeyService.getUserApiKeys(req.user.id, true) + const apiKeyIds = userApiKeys.map((key) => key.id) + + if (apiKeyIds.length === 0) { + return res.json({ + success: true, + stats: { + totalRequests: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCost: 0, + dailyStats: [], + modelStats: [] + } + }) + } + + // 获取使用统计 + const stats = await apiKeyService.getAggregatedUsageStats(apiKeyIds, { period, model }) + + res.json({ + success: true, + stats + }) + } catch (error) { + logger.error('❌ Get user usage stats error:', error) + res.status(500).json({ + error: 'Usage stats error', + message: 'Failed to retrieve usage statistics' + }) + } +}) + +// === 管理员用户管理端点 === + +// 📋 获取用户列表(管理员) +router.get('/', authenticateUserOrAdmin, requireAdmin, async (req, res) => { + try { + const { page = 1, limit = 20, role, isActive, search } = req.query + + const options = { + page: parseInt(page), + limit: parseInt(limit), + role, + isActive: isActive === 'true' ? true : isActive === 'false' ? false : undefined + } + + const result = await userService.getAllUsers(options) + + // 如果有搜索条件,进行过滤 + let filteredUsers = result.users + if (search) { + const searchLower = search.toLowerCase() + filteredUsers = result.users.filter( + (user) => + user.username.toLowerCase().includes(searchLower) || + user.displayName.toLowerCase().includes(searchLower) || + user.email.toLowerCase().includes(searchLower) + ) + } + + res.json({ + success: true, + users: filteredUsers, + pagination: { + total: result.total, + page: result.page, + limit: result.limit, + totalPages: result.totalPages + } + }) + } catch (error) { + logger.error('❌ Get users list error:', error) + res.status(500).json({ + error: 'Users list error', + message: 'Failed to retrieve users list' + }) + } +}) + +// 👤 获取特定用户信息(管理员) +router.get('/:userId', authenticateUserOrAdmin, requireAdmin, async (req, res) => { + try { + const { userId } = req.params + + const user = await userService.getUserById(userId) + if (!user) { + return res.status(404).json({ + error: 'User not found', + message: 'User not found' + }) + } + + // 获取用户的API Keys(包括已删除的以保留统计数据) + const apiKeys = await apiKeyService.getUserApiKeys(userId, true) + + res.json({ + success: true, + user: { + ...user, + apiKeys: apiKeys.map((key) => { + // Flatten usage structure for frontend compatibility + let flatUsage = { + requests: 0, + inputTokens: 0, + outputTokens: 0, + totalCost: 0 + } + + if (key.usage && key.usage.total) { + flatUsage = { + requests: key.usage.total.requests || 0, + inputTokens: key.usage.total.inputTokens || 0, + outputTokens: key.usage.total.outputTokens || 0, + totalCost: key.totalCost || 0 + } + } + + return { + id: key.id, + name: key.name, + description: key.description, + isActive: key.isActive, + createdAt: key.createdAt, + lastUsedAt: key.lastUsedAt, + usage: flatUsage, + keyPreview: key.key + ? `${key.key.substring(0, 8)}...${key.key.substring(key.key.length - 4)}` + : null + } + }) + } + }) + } catch (error) { + logger.error('❌ Get user details error:', error) + res.status(500).json({ + error: 'User details error', + message: 'Failed to retrieve user details' + }) + } +}) + +// 🔄 更新用户状态(管理员) +router.patch('/:userId/status', authenticateUserOrAdmin, requireAdmin, async (req, res) => { + try { + const { userId } = req.params + const { isActive } = req.body + + if (typeof isActive !== 'boolean') { + return res.status(400).json({ + error: 'Invalid status', + message: 'isActive must be a boolean value' + }) + } + + const updatedUser = await userService.updateUserStatus(userId, isActive) + + const adminUser = req.admin?.username || req.user?.username + logger.info( + `🔄 Admin ${adminUser} ${isActive ? 'enabled' : 'disabled'} user: ${updatedUser.username}` + ) + + res.json({ + success: true, + message: `User ${isActive ? 'enabled' : 'disabled'} successfully`, + user: { + id: updatedUser.id, + username: updatedUser.username, + isActive: updatedUser.isActive, + updatedAt: updatedUser.updatedAt + } + }) + } catch (error) { + logger.error('❌ Update user status error:', error) + res.status(500).json({ + error: 'Update status error', + message: error.message || 'Failed to update user status' + }) + } +}) + +// 🔄 更新用户角色(管理员) +router.patch('/:userId/role', authenticateUserOrAdmin, requireAdmin, async (req, res) => { + try { + const { userId } = req.params + const { role } = req.body + + const validRoles = ['user', 'admin'] + if (!role || !validRoles.includes(role)) { + return res.status(400).json({ + error: 'Invalid role', + message: `Role must be one of: ${validRoles.join(', ')}` + }) + } + + const updatedUser = await userService.updateUserRole(userId, role) + + const adminUser = req.admin?.username || req.user?.username + logger.info(`🔄 Admin ${adminUser} changed user ${updatedUser.username} role to: ${role}`) + + res.json({ + success: true, + message: `User role updated to ${role} successfully`, + user: { + id: updatedUser.id, + username: updatedUser.username, + role: updatedUser.role, + updatedAt: updatedUser.updatedAt + } + }) + } catch (error) { + logger.error('❌ Update user role error:', error) + res.status(500).json({ + error: 'Update role error', + message: error.message || 'Failed to update user role' + }) + } +}) + +// 🔑 禁用用户的所有API Keys(管理员) +router.post('/:userId/disable-keys', authenticateUserOrAdmin, requireAdmin, async (req, res) => { + try { + const { userId } = req.params + + const user = await userService.getUserById(userId) + if (!user) { + return res.status(404).json({ + error: 'User not found', + message: 'User not found' + }) + } + + const result = await apiKeyService.disableUserApiKeys(userId) + + const adminUser = req.admin?.username || req.user?.username + logger.info(`🔑 Admin ${adminUser} disabled all API keys for user: ${user.username}`) + + res.json({ + success: true, + message: `Disabled ${result.count} API keys for user ${user.username}`, + disabledCount: result.count + }) + } catch (error) { + logger.error('❌ Disable user API keys error:', error) + res.status(500).json({ + error: 'Disable keys error', + message: 'Failed to disable user API keys' + }) + } +}) + +// 📊 获取用户使用统计(管理员) +router.get('/:userId/usage-stats', authenticateUserOrAdmin, requireAdmin, async (req, res) => { + try { + const { userId } = req.params + const { period = 'week', model } = req.query + + const user = await userService.getUserById(userId) + if (!user) { + return res.status(404).json({ + error: 'User not found', + message: 'User not found' + }) + } + + // 获取用户的API Keys(包括已删除的以保留统计数据) + const userApiKeys = await apiKeyService.getUserApiKeys(userId, true) + const apiKeyIds = userApiKeys.map((key) => key.id) + + if (apiKeyIds.length === 0) { + return res.json({ + success: true, + user: { + id: user.id, + username: user.username, + displayName: user.displayName + }, + stats: { + totalRequests: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCost: 0, + dailyStats: [], + modelStats: [] + } + }) + } + + // 获取使用统计 + const stats = await apiKeyService.getAggregatedUsageStats(apiKeyIds, { period, model }) + + res.json({ + success: true, + user: { + id: user.id, + username: user.username, + displayName: user.displayName + }, + stats + }) + } catch (error) { + logger.error('❌ Get user usage stats (admin) error:', error) + res.status(500).json({ + error: 'Usage stats error', + message: 'Failed to retrieve user usage statistics' + }) + } +}) + +// 📊 获取用户管理统计(管理员) +router.get('/stats/overview', authenticateUserOrAdmin, requireAdmin, async (req, res) => { + try { + const stats = await userService.getUserStats() + + res.json({ + success: true, + stats + }) + } catch (error) { + logger.error('❌ Get user stats overview error:', error) + res.status(500).json({ + error: 'Stats error', + message: 'Failed to retrieve user statistics' + }) + } +}) + +// 🔧 测试LDAP连接(管理员) +router.get('/admin/ldap-test', authenticateUserOrAdmin, requireAdmin, async (req, res) => { + try { + const testResult = await ldapService.testConnection() + + res.json({ + success: true, + ldapTest: testResult, + config: ldapService.getConfigInfo() + }) + } catch (error) { + logger.error('❌ LDAP test error:', error) + res.status(500).json({ + error: 'LDAP test error', + message: 'Failed to test LDAP connection' + }) + } +}) + +module.exports = router diff --git a/src/routes/web.js b/src/routes/web.js new file mode 100644 index 0000000000000000000000000000000000000000..8bbdd4358d55dc73f04fd31c013e637889e080e1 --- /dev/null +++ b/src/routes/web.js @@ -0,0 +1,344 @@ +const express = require('express') +const bcrypt = require('bcryptjs') +const crypto = require('crypto') +const path = require('path') +const fs = require('fs') +const redis = require('../models/redis') +const logger = require('../utils/logger') +const config = require('../../config/config') + +const router = express.Router() + +// 🏠 服务静态文件 +router.use('/assets', express.static(path.join(__dirname, '../../web/assets'))) + +// 🌐 页面路由重定向到新版 admin-spa +router.get('/', (req, res) => { + res.redirect(301, '/admin-next/api-stats') +}) + +// 🔐 管理员登录 +router.post('/auth/login', async (req, res) => { + try { + const { username, password } = req.body + + if (!username || !password) { + return res.status(400).json({ + error: 'Missing credentials', + message: 'Username and password are required' + }) + } + + // 从Redis获取管理员信息 + let adminData = await redis.getSession('admin_credentials') + + // 如果Redis中没有管理员凭据,尝试从init.json重新加载 + if (!adminData || Object.keys(adminData).length === 0) { + const initFilePath = path.join(__dirname, '../../data/init.json') + + if (fs.existsSync(initFilePath)) { + try { + const initData = JSON.parse(fs.readFileSync(initFilePath, 'utf8')) + const saltRounds = 10 + const passwordHash = await bcrypt.hash(initData.adminPassword, saltRounds) + + adminData = { + username: initData.adminUsername, + passwordHash, + createdAt: initData.initializedAt || new Date().toISOString(), + lastLogin: null, + updatedAt: initData.updatedAt || null + } + + // 重新存储到Redis,不设置过期时间 + await redis.getClient().hset('session:admin_credentials', adminData) + + logger.info('✅ Admin credentials reloaded from init.json') + } catch (error) { + logger.error('❌ Failed to reload admin credentials:', error) + return res.status(401).json({ + error: 'Invalid credentials', + message: 'Invalid username or password' + }) + } + } else { + return res.status(401).json({ + error: 'Invalid credentials', + message: 'Invalid username or password' + }) + } + } + + // 验证用户名和密码 + const isValidUsername = adminData.username === username + const isValidPassword = await bcrypt.compare(password, adminData.passwordHash) + + if (!isValidUsername || !isValidPassword) { + logger.security(`🔒 Failed login attempt for username: ${username}`) + return res.status(401).json({ + error: 'Invalid credentials', + message: 'Invalid username or password' + }) + } + + // 生成会话token + const sessionId = crypto.randomBytes(32).toString('hex') + + // 存储会话 + const sessionData = { + username: adminData.username, + loginTime: new Date().toISOString(), + lastActivity: new Date().toISOString() + } + + await redis.setSession(sessionId, sessionData, config.security.adminSessionTimeout) + + // 不再更新 Redis 中的最后登录时间,因为 Redis 只是缓存 + // init.json 是唯一真实数据源 + + logger.success(`🔐 Admin login successful: ${username}`) + + return res.json({ + success: true, + token: sessionId, + expiresIn: config.security.adminSessionTimeout, + username: adminData.username // 返回真实用户名 + }) + } catch (error) { + logger.error('❌ Login error:', error) + return res.status(500).json({ + error: 'Login failed', + message: 'Internal server error' + }) + } +}) + +// 🚪 管理员登出 +router.post('/auth/logout', async (req, res) => { + try { + const token = req.headers['authorization']?.replace('Bearer ', '') || req.cookies?.adminToken + + if (token) { + await redis.deleteSession(token) + logger.success('🚪 Admin logout successful') + } + + return res.json({ success: true, message: 'Logout successful' }) + } catch (error) { + logger.error('❌ Logout error:', error) + return res.status(500).json({ + error: 'Logout failed', + message: 'Internal server error' + }) + } +}) + +// 🔑 修改账户信息 +router.post('/auth/change-password', async (req, res) => { + try { + const token = req.headers['authorization']?.replace('Bearer ', '') || req.cookies?.adminToken + + if (!token) { + return res.status(401).json({ + error: 'No token provided', + message: 'Authentication required' + }) + } + + const { newUsername, currentPassword, newPassword } = req.body + + if (!currentPassword || !newPassword) { + return res.status(400).json({ + error: 'Missing required fields', + message: 'Current password and new password are required' + }) + } + + // 验证新密码长度 + if (newPassword.length < 8) { + return res.status(400).json({ + error: 'Password too short', + message: 'New password must be at least 8 characters long' + }) + } + + // 获取当前会话 + const sessionData = await redis.getSession(token) + if (!sessionData) { + return res.status(401).json({ + error: 'Invalid token', + message: 'Session expired or invalid' + }) + } + + // 获取当前管理员信息 + const adminData = await redis.getSession('admin_credentials') + if (!adminData) { + return res.status(500).json({ + error: 'Admin data not found', + message: 'Administrator credentials not found' + }) + } + + // 验证当前密码 + const isValidPassword = await bcrypt.compare(currentPassword, adminData.passwordHash) + if (!isValidPassword) { + logger.security(`🔒 Invalid current password attempt for user: ${sessionData.username}`) + return res.status(401).json({ + error: 'Invalid current password', + message: 'Current password is incorrect' + }) + } + + // 准备更新的数据 + const updatedUsername = + newUsername && newUsername.trim() ? newUsername.trim() : adminData.username + + // 先更新 init.json(唯一真实数据源) + const initFilePath = path.join(__dirname, '../../data/init.json') + if (!fs.existsSync(initFilePath)) { + return res.status(500).json({ + error: 'Configuration file not found', + message: 'init.json file is missing' + }) + } + + try { + const initData = JSON.parse(fs.readFileSync(initFilePath, 'utf8')) + // const oldData = { ...initData }; // 备份旧数据 + + // 更新 init.json + initData.adminUsername = updatedUsername + initData.adminPassword = newPassword // 保存明文密码到init.json + initData.updatedAt = new Date().toISOString() + + // 先写入文件(如果失败则不会影响 Redis) + fs.writeFileSync(initFilePath, JSON.stringify(initData, null, 2)) + + // 文件写入成功后,更新 Redis 缓存 + const saltRounds = 10 + const newPasswordHash = await bcrypt.hash(newPassword, saltRounds) + + const updatedAdminData = { + username: updatedUsername, + passwordHash: newPasswordHash, + createdAt: adminData.createdAt, + lastLogin: adminData.lastLogin, + updatedAt: new Date().toISOString() + } + + await redis.setSession('admin_credentials', updatedAdminData) + } catch (fileError) { + logger.error('❌ Failed to update init.json:', fileError) + return res.status(500).json({ + error: 'Update failed', + message: 'Failed to update configuration file' + }) + } + + // 清除当前会话(强制用户重新登录) + await redis.deleteSession(token) + + logger.success(`🔐 Admin password changed successfully for user: ${updatedUsername}`) + + return res.json({ + success: true, + message: 'Password changed successfully. Please login again.', + newUsername: updatedUsername + }) + } catch (error) { + logger.error('❌ Change password error:', error) + return res.status(500).json({ + error: 'Change password failed', + message: 'Internal server error' + }) + } +}) + +// 👤 获取当前用户信息 +router.get('/auth/user', async (req, res) => { + try { + const token = req.headers['authorization']?.replace('Bearer ', '') || req.cookies?.adminToken + + if (!token) { + return res.status(401).json({ + error: 'No token provided', + message: 'Authentication required' + }) + } + + // 获取当前会话 + const sessionData = await redis.getSession(token) + if (!sessionData) { + return res.status(401).json({ + error: 'Invalid token', + message: 'Session expired or invalid' + }) + } + + // 获取管理员信息 + const adminData = await redis.getSession('admin_credentials') + if (!adminData) { + return res.status(500).json({ + error: 'Admin data not found', + message: 'Administrator credentials not found' + }) + } + + return res.json({ + success: true, + user: { + username: adminData.username, + loginTime: sessionData.loginTime, + lastActivity: sessionData.lastActivity + } + }) + } catch (error) { + logger.error('❌ Get user info error:', error) + return res.status(500).json({ + error: 'Get user info failed', + message: 'Internal server error' + }) + } +}) + +// 🔄 刷新token +router.post('/auth/refresh', async (req, res) => { + try { + const token = req.headers['authorization']?.replace('Bearer ', '') || req.cookies?.adminToken + + if (!token) { + return res.status(401).json({ + error: 'No token provided', + message: 'Authentication required' + }) + } + + const sessionData = await redis.getSession(token) + + if (!sessionData) { + return res.status(401).json({ + error: 'Invalid token', + message: 'Session expired or invalid' + }) + } + + // 更新最后活动时间 + sessionData.lastActivity = new Date().toISOString() + await redis.setSession(token, sessionData, config.security.adminSessionTimeout) + + return res.json({ + success: true, + token, + expiresIn: config.security.adminSessionTimeout + }) + } catch (error) { + logger.error('❌ Token refresh error:', error) + return res.status(500).json({ + error: 'Token refresh failed', + message: 'Internal server error' + }) + } +}) + +module.exports = router diff --git a/src/routes/webhook.js b/src/routes/webhook.js new file mode 100644 index 0000000000000000000000000000000000000000..98cd3d44105709d0bd62685e6f68006c4f62184b --- /dev/null +++ b/src/routes/webhook.js @@ -0,0 +1,439 @@ +const express = require('express') +const router = express.Router() +const logger = require('../utils/logger') +const webhookService = require('../services/webhookService') +const webhookConfigService = require('../services/webhookConfigService') +const { authenticateAdmin } = require('../middleware/auth') +const { getISOStringWithTimezone } = require('../utils/dateHelper') + +// 获取webhook配置 +router.get('/config', authenticateAdmin, async (req, res) => { + try { + const config = await webhookConfigService.getConfig() + res.json({ + success: true, + config + }) + } catch (error) { + logger.error('获取webhook配置失败:', error) + res.status(500).json({ + error: 'Internal server error', + message: '获取webhook配置失败' + }) + } +}) + +// 保存webhook配置 +router.post('/config', authenticateAdmin, async (req, res) => { + try { + const config = await webhookConfigService.saveConfig(req.body) + res.json({ + success: true, + message: 'Webhook配置已保存', + config + }) + } catch (error) { + logger.error('保存webhook配置失败:', error) + res.status(500).json({ + error: 'Internal server error', + message: error.message || '保存webhook配置失败' + }) + } +}) + +// 添加webhook平台 +router.post('/platforms', authenticateAdmin, async (req, res) => { + try { + const platform = await webhookConfigService.addPlatform(req.body) + res.json({ + success: true, + message: 'Webhook平台已添加', + platform + }) + } catch (error) { + logger.error('添加webhook平台失败:', error) + res.status(500).json({ + error: 'Internal server error', + message: error.message || '添加webhook平台失败' + }) + } +}) + +// 更新webhook平台 +router.put('/platforms/:id', authenticateAdmin, async (req, res) => { + try { + const platform = await webhookConfigService.updatePlatform(req.params.id, req.body) + res.json({ + success: true, + message: 'Webhook平台已更新', + platform + }) + } catch (error) { + logger.error('更新webhook平台失败:', error) + res.status(500).json({ + error: 'Internal server error', + message: error.message || '更新webhook平台失败' + }) + } +}) + +// 删除webhook平台 +router.delete('/platforms/:id', authenticateAdmin, async (req, res) => { + try { + await webhookConfigService.deletePlatform(req.params.id) + res.json({ + success: true, + message: 'Webhook平台已删除' + }) + } catch (error) { + logger.error('删除webhook平台失败:', error) + res.status(500).json({ + error: 'Internal server error', + message: error.message || '删除webhook平台失败' + }) + } +}) + +// 切换webhook平台启用状态 +router.post('/platforms/:id/toggle', authenticateAdmin, async (req, res) => { + try { + const platform = await webhookConfigService.togglePlatform(req.params.id) + res.json({ + success: true, + message: `Webhook平台已${platform.enabled ? '启用' : '禁用'}`, + platform + }) + } catch (error) { + logger.error('切换webhook平台状态失败:', error) + res.status(500).json({ + error: 'Internal server error', + message: error.message || '切换webhook平台状态失败' + }) + } +}) + +// 测试Webhook连通性 +router.post('/test', authenticateAdmin, async (req, res) => { + try { + const { + url, + type = 'custom', + secret, + enableSign, + deviceKey, + serverUrl, + level, + sound, + group, + // SMTP 相关字段 + host, + port, + secure, + user, + pass, + from, + to, + ignoreTLS, + botToken, + chatId, + apiBaseUrl, + proxyUrl + } = req.body + + // Bark平台特殊处理 + if (type === 'bark') { + if (!deviceKey) { + return res.status(400).json({ + error: 'Missing device key', + message: '请提供Bark设备密钥' + }) + } + + // 验证服务器URL(如果提供) + if (serverUrl) { + try { + new URL(serverUrl) + } catch (urlError) { + return res.status(400).json({ + error: 'Invalid server URL format', + message: '请提供有效的Bark服务器URL' + }) + } + } + + logger.info(`🧪 测试webhook: ${type} - Device Key: ${deviceKey.substring(0, 8)}...`) + } else if (type === 'smtp') { + // SMTP平台验证 + if (!host) { + return res.status(400).json({ + error: 'Missing SMTP host', + message: '请提供SMTP服务器地址' + }) + } + if (!user) { + return res.status(400).json({ + error: 'Missing SMTP user', + message: '请提供SMTP用户名' + }) + } + if (!pass) { + return res.status(400).json({ + error: 'Missing SMTP password', + message: '请提供SMTP密码' + }) + } + if (!to) { + return res.status(400).json({ + error: 'Missing recipient email', + message: '请提供收件人邮箱' + }) + } + + logger.info(`🧪 测试webhook: ${type} - ${host}:${port || 587} -> ${to}`) + } else if (type === 'telegram') { + if (!botToken) { + return res.status(400).json({ + error: 'Missing Telegram bot token', + message: '请提供 Telegram 机器人 Token' + }) + } + if (!chatId) { + return res.status(400).json({ + error: 'Missing Telegram chat id', + message: '请提供 Telegram Chat ID' + }) + } + + if (apiBaseUrl) { + try { + const parsed = new URL(apiBaseUrl) + if (!['http:', 'https:'].includes(parsed.protocol)) { + return res.status(400).json({ + error: 'Invalid Telegram API base url protocol', + message: 'Telegram API 基础地址仅支持 http 或 https' + }) + } + } catch (urlError) { + return res.status(400).json({ + error: 'Invalid Telegram API base url', + message: '请提供有效的 Telegram API 基础地址' + }) + } + } + + if (proxyUrl) { + try { + const parsed = new URL(proxyUrl) + const supportedProtocols = ['http:', 'https:', 'socks4:', 'socks4a:', 'socks5:'] + if (!supportedProtocols.includes(parsed.protocol)) { + return res.status(400).json({ + error: 'Unsupported proxy protocol', + message: 'Telegram 代理仅支持 http/https/socks 协议' + }) + } + } catch (urlError) { + return res.status(400).json({ + error: 'Invalid proxy url', + message: '请提供有效的代理地址' + }) + } + } + + logger.info(`🧪 测试webhook: ${type} - Chat ID: ${chatId}`) + } else { + // 其他平台验证URL + if (!url) { + return res.status(400).json({ + error: 'Missing webhook URL', + message: '请提供webhook URL' + }) + } + + // 验证URL格式 + try { + new URL(url) + } catch (urlError) { + return res.status(400).json({ + error: 'Invalid URL format', + message: '请提供有效的webhook URL' + }) + } + + logger.info(`🧪 测试webhook: ${type} - ${url}`) + } + + // 创建临时平台配置 + const platform = { + type, + url, + secret, + enableSign, + enabled: true, + timeout: 10000 + } + + // 添加Bark特有字段 + if (type === 'bark') { + platform.deviceKey = deviceKey + platform.serverUrl = serverUrl + platform.level = level + platform.sound = sound + platform.group = group + } else if (type === 'smtp') { + // 添加SMTP特有字段 + platform.host = host + platform.port = port || 587 + platform.secure = secure || false + platform.user = user + platform.pass = pass + platform.from = from + platform.to = to + platform.ignoreTLS = ignoreTLS || false + } else if (type === 'telegram') { + platform.botToken = botToken + platform.chatId = chatId + platform.apiBaseUrl = apiBaseUrl + platform.proxyUrl = proxyUrl + } + + const result = await webhookService.testWebhook(platform) + + const identifier = (() => { + if (type === 'bark') { + return `Device: ${deviceKey.substring(0, 8)}...` + } + if (type === 'smtp') { + const recipients = Array.isArray(to) ? to.join(', ') : to + return `${host}:${port || 587} -> ${recipients}` + } + if (type === 'telegram') { + return `Chat ID: ${chatId}` + } + return url + })() + + if (result.success) { + logger.info(`✅ Webhook测试成功: ${identifier}`) + res.json({ + success: true, + message: 'Webhook测试成功', + url: type === 'bark' ? undefined : url, + deviceKey: type === 'bark' ? `${deviceKey.substring(0, 8)}...` : undefined + }) + } else { + logger.warn(`❌ Webhook测试失败: ${identifier} - ${result.error}`) + res.status(400).json({ + success: false, + message: 'Webhook测试失败', + url: type === 'bark' ? undefined : url, + deviceKey: type === 'bark' ? `${deviceKey.substring(0, 8)}...` : undefined, + error: result.error + }) + } + } catch (error) { + logger.error('❌ Webhook测试错误:', error) + res.status(500).json({ + error: 'Internal server error', + message: '测试webhook失败' + }) + } +}) + +// 手动触发测试通知 +router.post('/test-notification', authenticateAdmin, async (req, res) => { + try { + const { + type = 'test', + accountId = 'test-account-id', + accountName = '测试账号', + platform = 'claude-oauth', + status = 'test', + errorCode = 'TEST_NOTIFICATION', + reason = '手动测试通知', + message = '这是一条测试通知消息,用于验证 Webhook 通知功能是否正常工作' + } = req.body + + logger.info(`🧪 发送测试通知: ${type}`) + + // 先检查webhook配置 + const config = await webhookConfigService.getConfig() + logger.debug( + `Webhook配置: enabled=${config.enabled}, platforms=${config.platforms?.length || 0}` + ) + if (!config.enabled) { + return res.status(400).json({ + success: false, + message: 'Webhook通知未启用,请先在设置中启用通知功能' + }) + } + + const enabledPlatforms = await webhookConfigService.getEnabledPlatforms() + logger.info(`找到 ${enabledPlatforms.length} 个启用的通知平台`) + + if (enabledPlatforms.length === 0) { + return res.status(400).json({ + success: false, + message: '没有启用的通知平台,请先添加并启用至少一个通知平台' + }) + } + + const testData = { + accountId, + accountName, + platform, + status, + errorCode, + reason, + message, + timestamp: getISOStringWithTimezone(new Date()) + } + + const result = await webhookService.sendNotification(type, testData) + + // 如果没有返回结果,说明可能是配置问题 + if (!result) { + return res.status(400).json({ + success: false, + message: 'Webhook服务未返回结果,请检查配置和日志', + enabledPlatforms: enabledPlatforms.length + }) + } + + // 如果没有成功和失败的记录 + if (result.succeeded === 0 && result.failed === 0) { + return res.status(400).json({ + success: false, + message: '没有发送任何通知,请检查通知类型配置', + result, + enabledPlatforms: enabledPlatforms.length + }) + } + + if (result.failed > 0) { + logger.warn(`⚠️ 测试通知部分失败: ${result.succeeded}成功, ${result.failed}失败`) + return res.json({ + success: true, + message: `测试通知部分成功: ${result.succeeded}个平台成功, ${result.failed}个平台失败`, + data: testData, + result + }) + } + + logger.info(`✅ 测试通知发送成功到 ${result.succeeded} 个平台`) + + res.json({ + success: true, + message: `测试通知已成功发送到 ${result.succeeded} 个平台`, + data: testData, + result + }) + } catch (error) { + logger.error('❌ 发送测试通知失败:', error) + res.status(500).json({ + error: 'Internal server error', + message: `发送测试通知失败: ${error.message}` + }) + } +}) + +module.exports = router diff --git a/src/services/accountGroupService.js b/src/services/accountGroupService.js new file mode 100644 index 0000000000000000000000000000000000000000..23293a18731fcd243e5953df6edddd2fa438c732 --- /dev/null +++ b/src/services/accountGroupService.js @@ -0,0 +1,430 @@ +const { v4: uuidv4 } = require('uuid') +const logger = require('../utils/logger') +const redis = require('../models/redis') + +class AccountGroupService { + constructor() { + this.GROUPS_KEY = 'account_groups' + this.GROUP_PREFIX = 'account_group:' + this.GROUP_MEMBERS_PREFIX = 'account_group_members:' + } + + /** + * 创建账户分组 + * @param {Object} groupData - 分组数据 + * @param {string} groupData.name - 分组名称 + * @param {string} groupData.platform - 平台类型 (claude/gemini/openai) + * @param {string} groupData.description - 分组描述 + * @returns {Object} 创建的分组 + */ + async createGroup(groupData) { + try { + const { name, platform, description = '' } = groupData + + // 验证必填字段 + if (!name || !platform) { + throw new Error('分组名称和平台类型为必填项') + } + + // 验证平台类型 + if (!['claude', 'gemini', 'openai', 'droid'].includes(platform)) { + throw new Error('平台类型必须是 claude、gemini、openai 或 droid') + } + + const client = redis.getClientSafe() + const groupId = uuidv4() + const now = new Date().toISOString() + + const group = { + id: groupId, + name, + platform, + description, + createdAt: now, + updatedAt: now + } + + // 保存分组数据 + await client.hmset(`${this.GROUP_PREFIX}${groupId}`, group) + + // 添加到分组集合 + await client.sadd(this.GROUPS_KEY, groupId) + + logger.success(`✅ 创建账户分组成功: ${name} (${platform})`) + + return group + } catch (error) { + logger.error('❌ 创建账户分组失败:', error) + throw error + } + } + + /** + * 更新分组信息 + * @param {string} groupId - 分组ID + * @param {Object} updates - 更新的字段 + * @returns {Object} 更新后的分组 + */ + async updateGroup(groupId, updates) { + try { + const client = redis.getClientSafe() + const groupKey = `${this.GROUP_PREFIX}${groupId}` + + // 检查分组是否存在 + const exists = await client.exists(groupKey) + if (!exists) { + throw new Error('分组不存在') + } + + // 获取现有分组数据 + const existingGroup = await client.hgetall(groupKey) + + // 不允许修改平台类型 + if (updates.platform && updates.platform !== existingGroup.platform) { + throw new Error('不能修改分组的平台类型') + } + + // 准备更新数据 + const updateData = { + ...updates, + updatedAt: new Date().toISOString() + } + + // 移除不允许修改的字段 + delete updateData.id + delete updateData.platform + delete updateData.createdAt + + // 更新分组 + await client.hmset(groupKey, updateData) + + // 返回更新后的完整数据 + const updatedGroup = await client.hgetall(groupKey) + + logger.success(`✅ 更新账户分组成功: ${updatedGroup.name}`) + + return updatedGroup + } catch (error) { + logger.error('❌ 更新账户分组失败:', error) + throw error + } + } + + /** + * 删除分组 + * @param {string} groupId - 分组ID + */ + async deleteGroup(groupId) { + try { + const client = redis.getClientSafe() + + // 检查分组是否存在 + const group = await this.getGroup(groupId) + if (!group) { + throw new Error('分组不存在') + } + + // 检查分组是否为空 + const members = await this.getGroupMembers(groupId) + if (members.length > 0) { + throw new Error('分组内还有账户,无法删除') + } + + // 检查是否有API Key绑定此分组 + const boundApiKeys = await this.getApiKeysUsingGroup(groupId) + if (boundApiKeys.length > 0) { + throw new Error('还有API Key使用此分组,无法删除') + } + + // 删除分组数据 + await client.del(`${this.GROUP_PREFIX}${groupId}`) + await client.del(`${this.GROUP_MEMBERS_PREFIX}${groupId}`) + + // 从分组集合中移除 + await client.srem(this.GROUPS_KEY, groupId) + + logger.success(`✅ 删除账户分组成功: ${group.name}`) + } catch (error) { + logger.error('❌ 删除账户分组失败:', error) + throw error + } + } + + /** + * 获取分组详情 + * @param {string} groupId - 分组ID + * @returns {Object|null} 分组信息 + */ + async getGroup(groupId) { + try { + const client = redis.getClientSafe() + const groupData = await client.hgetall(`${this.GROUP_PREFIX}${groupId}`) + + if (!groupData || Object.keys(groupData).length === 0) { + return null + } + + // 获取成员数量 + const memberCount = await client.scard(`${this.GROUP_MEMBERS_PREFIX}${groupId}`) + + return { + ...groupData, + memberCount: memberCount || 0 + } + } catch (error) { + logger.error('❌ 获取分组详情失败:', error) + throw error + } + } + + /** + * 获取所有分组 + * @param {string} platform - 平台筛选 (可选) + * @returns {Array} 分组列表 + */ + async getAllGroups(platform = null) { + try { + const client = redis.getClientSafe() + const groupIds = await client.smembers(this.GROUPS_KEY) + + const groups = [] + for (const groupId of groupIds) { + const group = await this.getGroup(groupId) + if (group) { + // 如果指定了平台,进行筛选 + if (!platform || group.platform === platform) { + groups.push(group) + } + } + } + + // 按创建时间倒序排序 + groups.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) + + return groups + } catch (error) { + logger.error('❌ 获取分组列表失败:', error) + throw error + } + } + + /** + * 添加账户到分组 + * @param {string} accountId - 账户ID + * @param {string} groupId - 分组ID + * @param {string} accountPlatform - 账户平台 + */ + async addAccountToGroup(accountId, groupId, accountPlatform) { + try { + const client = redis.getClientSafe() + + // 获取分组信息 + const group = await this.getGroup(groupId) + if (!group) { + throw new Error('分组不存在') + } + + // 验证平台一致性 (Claude和Claude Console视为同一平台) + const normalizedAccountPlatform = + accountPlatform === 'claude-console' ? 'claude' : accountPlatform + if (normalizedAccountPlatform !== group.platform) { + throw new Error('账户平台与分组平台不匹配') + } + + // 添加到分组成员集合 + await client.sadd(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId) + + logger.success(`✅ 添加账户到分组成功: ${accountId} -> ${group.name}`) + } catch (error) { + logger.error('❌ 添加账户到分组失败:', error) + throw error + } + } + + /** + * 从分组移除账户 + * @param {string} accountId - 账户ID + * @param {string} groupId - 分组ID + */ + async removeAccountFromGroup(accountId, groupId) { + try { + const client = redis.getClientSafe() + + // 从分组成员集合中移除 + await client.srem(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId) + + logger.success(`✅ 从分组移除账户成功: ${accountId}`) + } catch (error) { + logger.error('❌ 从分组移除账户失败:', error) + throw error + } + } + + /** + * 获取分组成员 + * @param {string} groupId - 分组ID + * @returns {Array} 成员ID列表 + */ + async getGroupMembers(groupId) { + try { + const client = redis.getClientSafe() + const members = await client.smembers(`${this.GROUP_MEMBERS_PREFIX}${groupId}`) + return members || [] + } catch (error) { + logger.error('❌ 获取分组成员失败:', error) + throw error + } + } + + /** + * 检查分组是否为空 + * @param {string} groupId - 分组ID + * @returns {boolean} 是否为空 + */ + async isGroupEmpty(groupId) { + try { + const members = await this.getGroupMembers(groupId) + return members.length === 0 + } catch (error) { + logger.error('❌ 检查分组是否为空失败:', error) + throw error + } + } + + /** + * 获取使用指定分组的API Key列表 + * @param {string} groupId - 分组ID + * @returns {Array} API Key列表 + */ + async getApiKeysUsingGroup(groupId) { + try { + const client = redis.getClientSafe() + const groupKey = `group:${groupId}` + + // 获取所有API Key + const apiKeyIds = await client.smembers('api_keys') + const boundApiKeys = [] + + for (const keyId of apiKeyIds) { + const keyData = await client.hgetall(`api_key:${keyId}`) + if ( + keyData && + (keyData.claudeAccountId === groupKey || + keyData.geminiAccountId === groupKey || + keyData.openaiAccountId === groupKey || + keyData.droidAccountId === groupKey) + ) { + boundApiKeys.push({ + id: keyId, + name: keyData.name + }) + } + } + + return boundApiKeys + } catch (error) { + logger.error('❌ 获取使用分组的API Key失败:', error) + throw error + } + } + + /** + * 根据账户ID获取其所属的分组(兼容性方法,返回单个分组) + * @param {string} accountId - 账户ID + * @returns {Object|null} 分组信息 + */ + async getAccountGroup(accountId) { + try { + const client = redis.getClientSafe() + const allGroupIds = await client.smembers(this.GROUPS_KEY) + + for (const groupId of allGroupIds) { + const isMember = await client.sismember(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId) + if (isMember) { + return await this.getGroup(groupId) + } + } + + return null + } catch (error) { + logger.error('❌ 获取账户所属分组失败:', error) + throw error + } + } + + /** + * 根据账户ID获取其所属的所有分组 + * @param {string} accountId - 账户ID + * @returns {Array} 分组信息数组 + */ + async getAccountGroups(accountId) { + try { + const client = redis.getClientSafe() + const allGroupIds = await client.smembers(this.GROUPS_KEY) + const memberGroups = [] + + for (const groupId of allGroupIds) { + const isMember = await client.sismember(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId) + if (isMember) { + const group = await this.getGroup(groupId) + if (group) { + memberGroups.push(group) + } + } + } + + // 按创建时间倒序排序 + memberGroups.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) + + return memberGroups + } catch (error) { + logger.error('❌ 获取账户所属分组列表失败:', error) + throw error + } + } + + /** + * 批量设置账户的分组 + * @param {string} accountId - 账户ID + * @param {Array} groupIds - 分组ID数组 + * @param {string} accountPlatform - 账户平台 + */ + async setAccountGroups(accountId, groupIds, accountPlatform) { + try { + // 首先移除账户的所有现有分组 + await this.removeAccountFromAllGroups(accountId) + + // 然后添加到新的分组中 + for (const groupId of groupIds) { + await this.addAccountToGroup(accountId, groupId, accountPlatform) + } + + logger.success(`✅ 批量设置账户分组成功: ${accountId} -> [${groupIds.join(', ')}]`) + } catch (error) { + logger.error('❌ 批量设置账户分组失败:', error) + throw error + } + } + + /** + * 从所有分组中移除账户 + * @param {string} accountId - 账户ID + */ + async removeAccountFromAllGroups(accountId) { + try { + const client = redis.getClientSafe() + const allGroupIds = await client.smembers(this.GROUPS_KEY) + + for (const groupId of allGroupIds) { + await client.srem(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId) + } + + logger.success(`✅ 从所有分组移除账户成功: ${accountId}`) + } catch (error) { + logger.error('❌ 从所有分组移除账户失败:', error) + throw error + } + } +} + +module.exports = new AccountGroupService() diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js new file mode 100644 index 0000000000000000000000000000000000000000..d187d54c41852a2937a5d78e0772cc87b857e8de --- /dev/null +++ b/src/services/apiKeyService.js @@ -0,0 +1,1500 @@ +const crypto = require('crypto') +const { v4: uuidv4 } = require('uuid') +const config = require('../../config/config') +const redis = require('../models/redis') +const logger = require('../utils/logger') + +class ApiKeyService { + constructor() { + this.prefix = config.security.apiKeyPrefix + } + + // 🔑 生成新的API Key + async generateApiKey(options = {}) { + const { + name = 'Unnamed Key', + description = '', + tokenLimit = 0, // 默认为0,不再使用token限制 + expiresAt = null, + claudeAccountId = null, + claudeConsoleAccountId = null, + geminiAccountId = null, + openaiAccountId = null, + azureOpenaiAccountId = null, + bedrockAccountId = null, // 添加 Bedrock 账号ID支持 + droidAccountId = null, + permissions = 'all', // 可选值:'claude'、'gemini'、'openai'、'droid' 或 'all' + isActive = true, + concurrencyLimit = 0, + rateLimitWindow = null, + rateLimitRequests = null, + rateLimitCost = null, // 新增:速率限制费用字段 + enableModelRestriction = false, + restrictedModels = [], + enableClientRestriction = false, + allowedClients = [], + dailyCostLimit = 0, + totalCostLimit = 0, + weeklyOpusCostLimit = 0, + tags = [], + activationDays = 0, // 新增:激活后有效天数(0表示不使用此功能) + activationUnit = 'days', // 新增:激活时间单位 'hours' 或 'days' + expirationMode = 'fixed', // 新增:过期模式 'fixed'(固定时间) 或 'activation'(首次使用后激活) + icon = '' // 新增:图标(base64编码) + } = options + + // 生成简单的API Key (64字符十六进制) + const apiKey = `${this.prefix}${this._generateSecretKey()}` + const keyId = uuidv4() + const hashedKey = this._hashApiKey(apiKey) + + const keyData = { + id: keyId, + name, + description, + apiKey: hashedKey, + tokenLimit: String(tokenLimit ?? 0), + concurrencyLimit: String(concurrencyLimit ?? 0), + rateLimitWindow: String(rateLimitWindow ?? 0), + rateLimitRequests: String(rateLimitRequests ?? 0), + rateLimitCost: String(rateLimitCost ?? 0), // 新增:速率限制费用字段 + isActive: String(isActive), + claudeAccountId: claudeAccountId || '', + claudeConsoleAccountId: claudeConsoleAccountId || '', + geminiAccountId: geminiAccountId || '', + openaiAccountId: openaiAccountId || '', + azureOpenaiAccountId: azureOpenaiAccountId || '', + bedrockAccountId: bedrockAccountId || '', // 添加 Bedrock 账号ID + droidAccountId: droidAccountId || '', + permissions: permissions || 'all', + enableModelRestriction: String(enableModelRestriction), + restrictedModels: JSON.stringify(restrictedModels || []), + enableClientRestriction: String(enableClientRestriction || false), + allowedClients: JSON.stringify(allowedClients || []), + dailyCostLimit: String(dailyCostLimit || 0), + totalCostLimit: String(totalCostLimit || 0), + weeklyOpusCostLimit: String(weeklyOpusCostLimit || 0), + tags: JSON.stringify(tags || []), + activationDays: String(activationDays || 0), // 新增:激活后有效天数 + activationUnit: activationUnit || 'days', // 新增:激活时间单位 + expirationMode: expirationMode || 'fixed', // 新增:过期模式 + isActivated: expirationMode === 'fixed' ? 'true' : 'false', // 根据模式决定激活状态 + activatedAt: expirationMode === 'fixed' ? new Date().toISOString() : '', // 激活时间 + createdAt: new Date().toISOString(), + lastUsedAt: '', + expiresAt: expirationMode === 'fixed' ? expiresAt || '' : '', // 固定模式才设置过期时间 + createdBy: options.createdBy || 'admin', + userId: options.userId || '', + userUsername: options.userUsername || '', + icon: icon || '' // 新增:图标(base64编码) + } + + // 保存API Key数据并建立哈希映射 + await redis.setApiKey(keyId, keyData, hashedKey) + + logger.success(`🔑 Generated new API key: ${name} (${keyId})`) + + return { + id: keyId, + apiKey, // 只在创建时返回完整的key + name: keyData.name, + description: keyData.description, + tokenLimit: parseInt(keyData.tokenLimit), + concurrencyLimit: parseInt(keyData.concurrencyLimit), + rateLimitWindow: parseInt(keyData.rateLimitWindow || 0), + rateLimitRequests: parseInt(keyData.rateLimitRequests || 0), + rateLimitCost: parseFloat(keyData.rateLimitCost || 0), // 新增:速率限制费用字段 + isActive: keyData.isActive === 'true', + claudeAccountId: keyData.claudeAccountId, + claudeConsoleAccountId: keyData.claudeConsoleAccountId, + geminiAccountId: keyData.geminiAccountId, + openaiAccountId: keyData.openaiAccountId, + azureOpenaiAccountId: keyData.azureOpenaiAccountId, + bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID + droidAccountId: keyData.droidAccountId, + permissions: keyData.permissions, + enableModelRestriction: keyData.enableModelRestriction === 'true', + restrictedModels: JSON.parse(keyData.restrictedModels), + enableClientRestriction: keyData.enableClientRestriction === 'true', + allowedClients: JSON.parse(keyData.allowedClients || '[]'), + dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0), + totalCostLimit: parseFloat(keyData.totalCostLimit || 0), + weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0), + tags: JSON.parse(keyData.tags || '[]'), + activationDays: parseInt(keyData.activationDays || 0), + activationUnit: keyData.activationUnit || 'days', + expirationMode: keyData.expirationMode || 'fixed', + isActivated: keyData.isActivated === 'true', + activatedAt: keyData.activatedAt, + createdAt: keyData.createdAt, + expiresAt: keyData.expiresAt, + createdBy: keyData.createdBy + } + } + + // 🔍 验证API Key + async validateApiKey(apiKey) { + try { + if (!apiKey || !apiKey.startsWith(this.prefix)) { + return { valid: false, error: 'Invalid API key format' } + } + + // 计算API Key的哈希值 + const hashedKey = this._hashApiKey(apiKey) + + // 通过哈希值直接查找API Key(性能优化) + const keyData = await redis.findApiKeyByHash(hashedKey) + + if (!keyData) { + return { valid: false, error: 'API key not found' } + } + + // 检查是否激活 + if (keyData.isActive !== 'true') { + return { valid: false, error: 'API key is disabled' } + } + + // 处理激活逻辑(仅在 activation 模式下) + if (keyData.expirationMode === 'activation' && keyData.isActivated !== 'true') { + // 首次使用,需要激活 + const now = new Date() + const activationPeriod = parseInt(keyData.activationDays || 30) // 默认30 + const activationUnit = keyData.activationUnit || 'days' // 默认天 + + // 根据单位计算过期时间 + let milliseconds + if (activationUnit === 'hours') { + milliseconds = activationPeriod * 60 * 60 * 1000 // 小时转毫秒 + } else { + milliseconds = activationPeriod * 24 * 60 * 60 * 1000 // 天转毫秒 + } + + const expiresAt = new Date(now.getTime() + milliseconds) + + // 更新激活状态和过期时间 + keyData.isActivated = 'true' + keyData.activatedAt = now.toISOString() + keyData.expiresAt = expiresAt.toISOString() + keyData.lastUsedAt = now.toISOString() + + // 保存到Redis + await redis.setApiKey(keyData.id, keyData) + + logger.success( + `🔓 API key activated: ${keyData.id} (${ + keyData.name + }), will expire in ${activationPeriod} ${activationUnit} at ${expiresAt.toISOString()}` + ) + } + + // 检查是否过期 + if (keyData.expiresAt && new Date() > new Date(keyData.expiresAt)) { + return { valid: false, error: 'API key has expired' } + } + + // 如果API Key属于某个用户,检查用户是否被禁用 + if (keyData.userId) { + try { + const userService = require('./userService') + const user = await userService.getUserById(keyData.userId, false) + if (!user || !user.isActive) { + return { valid: false, error: 'User account is disabled' } + } + } catch (error) { + logger.error('❌ Error checking user status during API key validation:', error) + return { valid: false, error: 'Unable to validate user status' } + } + } + + // 获取使用统计(供返回数据使用) + const usage = await redis.getUsageStats(keyData.id) + + // 获取费用统计 + const [dailyCost, costStats] = await Promise.all([ + redis.getDailyCost(keyData.id), + redis.getCostStats(keyData.id) + ]) + const totalCost = costStats?.total || 0 + + // 更新最后使用时间(优化:只在实际API调用时更新,而不是验证时) + // 注意:lastUsedAt的更新已移至recordUsage方法中 + + logger.api(`🔓 API key validated successfully: ${keyData.id}`) + + // 解析限制模型数据 + let restrictedModels = [] + try { + restrictedModels = keyData.restrictedModels ? JSON.parse(keyData.restrictedModels) : [] + } catch (e) { + restrictedModels = [] + } + + // 解析允许的客户端 + let allowedClients = [] + try { + allowedClients = keyData.allowedClients ? JSON.parse(keyData.allowedClients) : [] + } catch (e) { + allowedClients = [] + } + + // 解析标签 + let tags = [] + try { + tags = keyData.tags ? JSON.parse(keyData.tags) : [] + } catch (e) { + tags = [] + } + + return { + valid: true, + keyData: { + id: keyData.id, + name: keyData.name, + description: keyData.description, + createdAt: keyData.createdAt, + expiresAt: keyData.expiresAt, + claudeAccountId: keyData.claudeAccountId, + claudeConsoleAccountId: keyData.claudeConsoleAccountId, + geminiAccountId: keyData.geminiAccountId, + openaiAccountId: keyData.openaiAccountId, + azureOpenaiAccountId: keyData.azureOpenaiAccountId, + bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID + droidAccountId: keyData.droidAccountId, + permissions: keyData.permissions || 'all', + tokenLimit: parseInt(keyData.tokenLimit), + concurrencyLimit: parseInt(keyData.concurrencyLimit || 0), + rateLimitWindow: parseInt(keyData.rateLimitWindow || 0), + rateLimitRequests: parseInt(keyData.rateLimitRequests || 0), + rateLimitCost: parseFloat(keyData.rateLimitCost || 0), // 新增:速率限制费用字段 + enableModelRestriction: keyData.enableModelRestriction === 'true', + restrictedModels, + enableClientRestriction: keyData.enableClientRestriction === 'true', + allowedClients, + dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0), + totalCostLimit: parseFloat(keyData.totalCostLimit || 0), + weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0), + dailyCost: dailyCost || 0, + totalCost, + weeklyOpusCost: (await redis.getWeeklyOpusCost(keyData.id)) || 0, + tags, + usage + } + } + } catch (error) { + logger.error('❌ API key validation error:', error) + return { valid: false, error: 'Internal validation error' } + } + } + + // 🔍 验证API Key(仅用于统计查询,不触发激活) + async validateApiKeyForStats(apiKey) { + try { + if (!apiKey || !apiKey.startsWith(this.prefix)) { + return { valid: false, error: 'Invalid API key format' } + } + + // 计算API Key的哈希值 + const hashedKey = this._hashApiKey(apiKey) + + // 通过哈希值直接查找API Key(性能优化) + const keyData = await redis.findApiKeyByHash(hashedKey) + + if (!keyData) { + return { valid: false, error: 'API key not found' } + } + + // 检查是否激活 + if (keyData.isActive !== 'true') { + return { valid: false, error: 'API key is disabled' } + } + + // 注意:这里不处理激活逻辑,保持 API Key 的未激活状态 + + // 检查是否过期(仅对已激活的 Key 检查) + if ( + keyData.isActivated === 'true' && + keyData.expiresAt && + new Date() > new Date(keyData.expiresAt) + ) { + return { valid: false, error: 'API key has expired' } + } + + // 如果API Key属于某个用户,检查用户是否被禁用 + if (keyData.userId) { + try { + const userService = require('./userService') + const user = await userService.getUserById(keyData.userId, false) + if (!user || !user.isActive) { + return { valid: false, error: 'User account is disabled' } + } + } catch (userError) { + // 如果用户服务出错,记录但不影响API Key验证 + logger.warn(`Failed to check user status for API key ${keyData.id}:`, userError) + } + } + + // 获取当日费用 + const [dailyCost, costStats] = await Promise.all([ + redis.getDailyCost(keyData.id), + redis.getCostStats(keyData.id) + ]) + + // 获取使用统计 + const usage = await redis.getUsageStats(keyData.id) + + // 解析限制模型数据 + let restrictedModels = [] + try { + restrictedModels = keyData.restrictedModels ? JSON.parse(keyData.restrictedModels) : [] + } catch (e) { + restrictedModels = [] + } + + // 解析允许的客户端 + let allowedClients = [] + try { + allowedClients = keyData.allowedClients ? JSON.parse(keyData.allowedClients) : [] + } catch (e) { + allowedClients = [] + } + + // 解析标签 + let tags = [] + try { + tags = keyData.tags ? JSON.parse(keyData.tags) : [] + } catch (e) { + tags = [] + } + + return { + valid: true, + keyData: { + id: keyData.id, + name: keyData.name, + description: keyData.description, + createdAt: keyData.createdAt, + expiresAt: keyData.expiresAt, + // 添加激活相关字段 + expirationMode: keyData.expirationMode || 'fixed', + isActivated: keyData.isActivated === 'true', + activationDays: parseInt(keyData.activationDays || 0), + activationUnit: keyData.activationUnit || 'days', + activatedAt: keyData.activatedAt || null, + claudeAccountId: keyData.claudeAccountId, + claudeConsoleAccountId: keyData.claudeConsoleAccountId, + geminiAccountId: keyData.geminiAccountId, + openaiAccountId: keyData.openaiAccountId, + azureOpenaiAccountId: keyData.azureOpenaiAccountId, + bedrockAccountId: keyData.bedrockAccountId, + droidAccountId: keyData.droidAccountId, + permissions: keyData.permissions || 'all', + tokenLimit: parseInt(keyData.tokenLimit), + concurrencyLimit: parseInt(keyData.concurrencyLimit || 0), + rateLimitWindow: parseInt(keyData.rateLimitWindow || 0), + rateLimitRequests: parseInt(keyData.rateLimitRequests || 0), + rateLimitCost: parseFloat(keyData.rateLimitCost || 0), + enableModelRestriction: keyData.enableModelRestriction === 'true', + restrictedModels, + enableClientRestriction: keyData.enableClientRestriction === 'true', + allowedClients, + dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0), + totalCostLimit: parseFloat(keyData.totalCostLimit || 0), + weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0), + dailyCost: dailyCost || 0, + totalCost: costStats?.total || 0, + weeklyOpusCost: (await redis.getWeeklyOpusCost(keyData.id)) || 0, + tags, + usage + } + } + } catch (error) { + logger.error('❌ API key validation error (stats):', error) + return { valid: false, error: 'Internal validation error' } + } + } + + // 📋 获取所有API Keys + async getAllApiKeys(includeDeleted = false) { + try { + let apiKeys = await redis.getAllApiKeys() + const client = redis.getClientSafe() + + // 默认过滤掉已删除的API Keys + if (!includeDeleted) { + apiKeys = apiKeys.filter((key) => key.isDeleted !== 'true') + } + + // 为每个key添加使用统计和当前并发数 + for (const key of apiKeys) { + key.usage = await redis.getUsageStats(key.id) + const costStats = await redis.getCostStats(key.id) + // Add cost information to usage object for frontend compatibility + if (key.usage && costStats) { + key.usage.total = key.usage.total || {} + key.usage.total.cost = costStats.total + key.usage.totalCost = costStats.total + } + key.totalCost = costStats ? costStats.total : 0 + key.tokenLimit = parseInt(key.tokenLimit) + key.concurrencyLimit = parseInt(key.concurrencyLimit || 0) + key.rateLimitWindow = parseInt(key.rateLimitWindow || 0) + key.rateLimitRequests = parseInt(key.rateLimitRequests || 0) + key.rateLimitCost = parseFloat(key.rateLimitCost || 0) // 新增:速率限制费用字段 + key.currentConcurrency = await redis.getConcurrency(key.id) + key.isActive = key.isActive === 'true' + key.enableModelRestriction = key.enableModelRestriction === 'true' + key.enableClientRestriction = key.enableClientRestriction === 'true' + key.permissions = key.permissions || 'all' // 兼容旧数据 + key.dailyCostLimit = parseFloat(key.dailyCostLimit || 0) + key.totalCostLimit = parseFloat(key.totalCostLimit || 0) + key.weeklyOpusCostLimit = parseFloat(key.weeklyOpusCostLimit || 0) + key.dailyCost = (await redis.getDailyCost(key.id)) || 0 + key.weeklyOpusCost = (await redis.getWeeklyOpusCost(key.id)) || 0 + key.activationDays = parseInt(key.activationDays || 0) + key.activationUnit = key.activationUnit || 'days' + key.expirationMode = key.expirationMode || 'fixed' + key.isActivated = key.isActivated === 'true' + key.activatedAt = key.activatedAt || null + + // 获取当前时间窗口的请求次数、Token使用量和费用 + if (key.rateLimitWindow > 0) { + const requestCountKey = `rate_limit:requests:${key.id}` + const tokenCountKey = `rate_limit:tokens:${key.id}` + const costCountKey = `rate_limit:cost:${key.id}` // 新增:费用计数器 + const windowStartKey = `rate_limit:window_start:${key.id}` + + key.currentWindowRequests = parseInt((await client.get(requestCountKey)) || '0') + key.currentWindowTokens = parseInt((await client.get(tokenCountKey)) || '0') + key.currentWindowCost = parseFloat((await client.get(costCountKey)) || '0') // 新增:当前窗口费用 + + // 获取窗口开始时间和计算剩余时间 + const windowStart = await client.get(windowStartKey) + if (windowStart) { + const now = Date.now() + const windowStartTime = parseInt(windowStart) + const windowDuration = key.rateLimitWindow * 60 * 1000 // 转换为毫秒 + const windowEndTime = windowStartTime + windowDuration + + // 如果窗口还有效 + if (now < windowEndTime) { + key.windowStartTime = windowStartTime + key.windowEndTime = windowEndTime + key.windowRemainingSeconds = Math.max(0, Math.floor((windowEndTime - now) / 1000)) + } else { + // 窗口已过期,下次请求会重置 + key.windowStartTime = null + key.windowEndTime = null + key.windowRemainingSeconds = 0 + // 重置计数为0,因为窗口已过期 + key.currentWindowRequests = 0 + key.currentWindowTokens = 0 + key.currentWindowCost = 0 // 新增:重置费用 + } + } else { + // 窗口还未开始(没有任何请求) + key.windowStartTime = null + key.windowEndTime = null + key.windowRemainingSeconds = null + } + } else { + key.currentWindowRequests = 0 + key.currentWindowTokens = 0 + key.currentWindowCost = 0 // 新增:重置费用 + key.windowStartTime = null + key.windowEndTime = null + key.windowRemainingSeconds = null + } + + try { + key.restrictedModels = key.restrictedModels ? JSON.parse(key.restrictedModels) : [] + } catch (e) { + key.restrictedModels = [] + } + try { + key.allowedClients = key.allowedClients ? JSON.parse(key.allowedClients) : [] + } catch (e) { + key.allowedClients = [] + } + try { + key.tags = key.tags ? JSON.parse(key.tags) : [] + } catch (e) { + key.tags = [] + } + // 不暴露已弃用字段 + if (Object.prototype.hasOwnProperty.call(key, 'ccrAccountId')) { + delete key.ccrAccountId + } + delete key.apiKey // 不返回哈希后的key + } + + return apiKeys + } catch (error) { + logger.error('❌ Failed to get API keys:', error) + throw error + } + } + + // 📝 更新API Key + async updateApiKey(keyId, updates) { + try { + const keyData = await redis.getApiKey(keyId) + if (!keyData || Object.keys(keyData).length === 0) { + throw new Error('API key not found') + } + + // 允许更新的字段 + const allowedUpdates = [ + 'name', + 'description', + 'tokenLimit', + 'concurrencyLimit', + 'rateLimitWindow', + 'rateLimitRequests', + 'rateLimitCost', // 新增:速率限制费用字段 + 'isActive', + 'claudeAccountId', + 'claudeConsoleAccountId', + 'geminiAccountId', + 'openaiAccountId', + 'azureOpenaiAccountId', + 'bedrockAccountId', // 添加 Bedrock 账号ID + 'droidAccountId', + 'permissions', + 'expiresAt', + 'activationDays', // 新增:激活后有效天数 + 'activationUnit', // 新增:激活时间单位 + 'expirationMode', // 新增:过期模式 + 'isActivated', // 新增:是否已激活 + 'activatedAt', // 新增:激活时间 + 'enableModelRestriction', + 'restrictedModels', + 'enableClientRestriction', + 'allowedClients', + 'dailyCostLimit', + 'totalCostLimit', + 'weeklyOpusCostLimit', + 'tags', + 'userId', // 新增:用户ID(所有者变更) + 'userUsername', // 新增:用户名(所有者变更) + 'createdBy' // 新增:创建者(所有者变更) + ] + const updatedData = { ...keyData } + + for (const [field, value] of Object.entries(updates)) { + if (allowedUpdates.includes(field)) { + if (field === 'restrictedModels' || field === 'allowedClients' || field === 'tags') { + // 特殊处理数组字段 + updatedData[field] = JSON.stringify(value || []) + } else if ( + field === 'enableModelRestriction' || + field === 'enableClientRestriction' || + field === 'isActivated' + ) { + // 布尔值转字符串 + updatedData[field] = String(value) + } else if (field === 'expiresAt' || field === 'activatedAt') { + // 日期字段保持原样,不要toString() + updatedData[field] = value || '' + } else { + updatedData[field] = (value !== null && value !== undefined ? value : '').toString() + } + } + } + + updatedData.updatedAt = new Date().toISOString() + + // 更新时不需要重新建立哈希映射,因为API Key本身没有变化 + await redis.setApiKey(keyId, updatedData) + + logger.success(`📝 Updated API key: ${keyId}`) + + return { success: true } + } catch (error) { + logger.error('❌ Failed to update API key:', error) + throw error + } + } + + // 🗑️ 软删除API Key (保留使用统计) + async deleteApiKey(keyId, deletedBy = 'system', deletedByType = 'system') { + try { + const keyData = await redis.getApiKey(keyId) + if (!keyData || Object.keys(keyData).length === 0) { + throw new Error('API key not found') + } + + // 标记为已删除,保留所有数据和统计信息 + const updatedData = { + ...keyData, + isDeleted: 'true', + deletedAt: new Date().toISOString(), + deletedBy, + deletedByType, // 'user', 'admin', 'system' + isActive: 'false' // 同时禁用 + } + + await redis.setApiKey(keyId, updatedData) + + // 从哈希映射中移除(这样就不能再使用这个key进行API调用) + if (keyData.apiKey) { + await redis.deleteApiKeyHash(keyData.apiKey) + } + + logger.success(`🗑️ Soft deleted API key: ${keyId} by ${deletedBy} (${deletedByType})`) + + return { success: true } + } catch (error) { + logger.error('❌ Failed to delete API key:', error) + throw error + } + } + + // 🔄 恢复已删除的API Key + async restoreApiKey(keyId, restoredBy = 'system', restoredByType = 'system') { + try { + const keyData = await redis.getApiKey(keyId) + if (!keyData || Object.keys(keyData).length === 0) { + throw new Error('API key not found') + } + + // 检查是否确实是已删除的key + if (keyData.isDeleted !== 'true') { + throw new Error('API key is not deleted') + } + + // 准备更新的数据 + const updatedData = { ...keyData } + updatedData.isActive = 'true' + updatedData.restoredAt = new Date().toISOString() + updatedData.restoredBy = restoredBy + updatedData.restoredByType = restoredByType + + // 从更新的数据中移除删除相关的字段 + delete updatedData.isDeleted + delete updatedData.deletedAt + delete updatedData.deletedBy + delete updatedData.deletedByType + + // 保存更新后的数据 + await redis.setApiKey(keyId, updatedData) + + // 使用Redis的hdel命令删除不需要的字段 + const keyName = `apikey:${keyId}` + await redis.client.hdel(keyName, 'isDeleted', 'deletedAt', 'deletedBy', 'deletedByType') + + // 重新建立哈希映射(恢复API Key的使用能力) + if (keyData.apiKey) { + await redis.setApiKeyHash(keyData.apiKey, { + id: keyId, + name: keyData.name, + isActive: 'true' + }) + } + + logger.success(`✅ Restored API key: ${keyId} by ${restoredBy} (${restoredByType})`) + + return { success: true, apiKey: updatedData } + } catch (error) { + logger.error('❌ Failed to restore API key:', error) + throw error + } + } + + // 🗑️ 彻底删除API Key(物理删除) + async permanentDeleteApiKey(keyId) { + try { + const keyData = await redis.getApiKey(keyId) + if (!keyData || Object.keys(keyData).length === 0) { + throw new Error('API key not found') + } + + // 确保只能彻底删除已经软删除的key + if (keyData.isDeleted !== 'true') { + throw new Error('只能彻底删除已经删除的API Key') + } + + // 删除所有相关的使用统计数据 + const today = new Date().toISOString().split('T')[0] + const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0] + + // 删除每日统计 + await redis.client.del(`usage:daily:${today}:${keyId}`) + await redis.client.del(`usage:daily:${yesterday}:${keyId}`) + + // 删除月度统计 + const currentMonth = today.substring(0, 7) + await redis.client.del(`usage:monthly:${currentMonth}:${keyId}`) + + // 删除所有相关的统计键(通过模式匹配) + const usageKeys = await redis.client.keys(`usage:*:${keyId}*`) + if (usageKeys.length > 0) { + await redis.client.del(...usageKeys) + } + + // 删除API Key本身 + await redis.deleteApiKey(keyId) + + logger.success(`🗑️ Permanently deleted API key: ${keyId}`) + + return { success: true } + } catch (error) { + logger.error('❌ Failed to permanently delete API key:', error) + throw error + } + } + + // 🧹 清空所有已删除的API Keys + async clearAllDeletedApiKeys() { + try { + const allKeys = await this.getAllApiKeys(true) + const deletedKeys = allKeys.filter((key) => key.isDeleted === 'true') + + let successCount = 0 + let failedCount = 0 + const errors = [] + + for (const key of deletedKeys) { + try { + await this.permanentDeleteApiKey(key.id) + successCount++ + } catch (error) { + failedCount++ + errors.push({ + keyId: key.id, + keyName: key.name, + error: error.message + }) + } + } + + logger.success(`🧹 Cleared deleted API keys: ${successCount} success, ${failedCount} failed`) + + return { + success: true, + total: deletedKeys.length, + successCount, + failedCount, + errors + } + } catch (error) { + logger.error('❌ Failed to clear all deleted API keys:', error) + throw error + } + } + + // 📊 记录使用情况(支持缓存token和账户级别统计) + async recordUsage( + keyId, + inputTokens = 0, + outputTokens = 0, + cacheCreateTokens = 0, + cacheReadTokens = 0, + model = 'unknown', + accountId = null + ) { + try { + const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + + // 计算费用 + const CostCalculator = require('../utils/costCalculator') + const costInfo = CostCalculator.calculateCost( + { + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_creation_input_tokens: cacheCreateTokens, + cache_read_input_tokens: cacheReadTokens + }, + model + ) + + // 检查是否为 1M 上下文请求 + let isLongContextRequest = false + if (model && model.includes('[1m]')) { + const totalInputTokens = inputTokens + cacheCreateTokens + cacheReadTokens + isLongContextRequest = totalInputTokens > 200000 + } + + // 记录API Key级别的使用统计 + await redis.incrementTokenUsage( + keyId, + totalTokens, + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens, + model, + 0, // ephemeral5mTokens - 暂时为0,后续处理 + 0, // ephemeral1hTokens - 暂时为0,后续处理 + isLongContextRequest + ) + + // 记录费用统计 + if (costInfo.costs.total > 0) { + await redis.incrementDailyCost(keyId, costInfo.costs.total) + logger.database( + `💰 Recorded cost for ${keyId}: $${costInfo.costs.total.toFixed(6)}, model: ${model}` + ) + } else { + logger.debug(`💰 No cost recorded for ${keyId} - zero cost for model: ${model}`) + } + + // 获取API Key数据以确定关联的账户 + const keyData = await redis.getApiKey(keyId) + if (keyData && Object.keys(keyData).length > 0) { + // 更新最后使用时间 + keyData.lastUsedAt = new Date().toISOString() + await redis.setApiKey(keyId, keyData) + + // 记录账户级别的使用统计(只统计实际处理请求的账户) + if (accountId) { + await redis.incrementAccountUsage( + accountId, + totalTokens, + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens, + model, + isLongContextRequest + ) + logger.database( + `📊 Recorded account usage: ${accountId} - ${totalTokens} tokens (API Key: ${keyId})` + ) + } else { + logger.debug( + '⚠️ No accountId provided for usage recording, skipping account-level statistics' + ) + } + } + + // 记录单次请求的使用详情 + const usageCost = costInfo && costInfo.costs ? costInfo.costs.total || 0 : 0 + await redis.addUsageRecord(keyId, { + timestamp: new Date().toISOString(), + model, + accountId: accountId || null, + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens, + totalTokens, + cost: Number(usageCost.toFixed(6)), + costBreakdown: costInfo && costInfo.costs ? costInfo.costs : undefined + }) + + const logParts = [`Model: ${model}`, `Input: ${inputTokens}`, `Output: ${outputTokens}`] + if (cacheCreateTokens > 0) { + logParts.push(`Cache Create: ${cacheCreateTokens}`) + } + if (cacheReadTokens > 0) { + logParts.push(`Cache Read: ${cacheReadTokens}`) + } + logParts.push(`Total: ${totalTokens} tokens`) + + logger.database(`📊 Recorded usage: ${keyId} - ${logParts.join(', ')}`) + } catch (error) { + logger.error('❌ Failed to record usage:', error) + } + } + + // 📊 记录 Opus 模型费用(仅限 claude 和 claude-console 账户) + async recordOpusCost(keyId, cost, model, accountType) { + try { + // 判断是否为 Opus 模型 + if (!model || !model.toLowerCase().includes('claude-opus')) { + return // 不是 Opus 模型,直接返回 + } + + // 判断是否为 claude、claude-console 或 ccr 账户 + if ( + !accountType || + (accountType !== 'claude' && accountType !== 'claude-console' && accountType !== 'ccr') + ) { + logger.debug(`⚠️ Skipping Opus cost recording for non-Claude account type: ${accountType}`) + return // 不是 claude 账户,直接返回 + } + + // 记录 Opus 周费用 + await redis.incrementWeeklyOpusCost(keyId, cost) + logger.database( + `💰 Recorded Opus weekly cost for ${keyId}: $${cost.toFixed( + 6 + )}, model: ${model}, account type: ${accountType}` + ) + } catch (error) { + logger.error('❌ Failed to record Opus cost:', error) + } + } + + // 📊 记录使用情况(新版本,支持详细的缓存类型) + async recordUsageWithDetails( + keyId, + usageObject, + model = 'unknown', + accountId = null, + accountType = null + ) { + try { + // 提取 token 数量 + const inputTokens = usageObject.input_tokens || 0 + const outputTokens = usageObject.output_tokens || 0 + const cacheCreateTokens = usageObject.cache_creation_input_tokens || 0 + const cacheReadTokens = usageObject.cache_read_input_tokens || 0 + + const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + + // 计算费用(支持详细的缓存类型)- 添加错误处理 + let costInfo = { totalCost: 0, ephemeral5mCost: 0, ephemeral1hCost: 0 } + try { + const pricingService = require('./pricingService') + // 确保 pricingService 已初始化 + if (!pricingService.pricingData) { + logger.warn('⚠️ PricingService not initialized, initializing now...') + await pricingService.initialize() + } + costInfo = pricingService.calculateCost(usageObject, model) + + // 验证计算结果 + if (!costInfo || typeof costInfo.totalCost !== 'number') { + logger.error(`❌ Invalid cost calculation result for model ${model}:`, costInfo) + // 使用 CostCalculator 作为后备 + const CostCalculator = require('../utils/costCalculator') + const fallbackCost = CostCalculator.calculateCost(usageObject, model) + if (fallbackCost && fallbackCost.costs && fallbackCost.costs.total > 0) { + logger.warn( + `⚠️ Using fallback cost calculation for ${model}: $${fallbackCost.costs.total}` + ) + costInfo = { + totalCost: fallbackCost.costs.total, + ephemeral5mCost: 0, + ephemeral1hCost: 0 + } + } else { + costInfo = { totalCost: 0, ephemeral5mCost: 0, ephemeral1hCost: 0 } + } + } + } catch (pricingError) { + logger.error(`❌ Failed to calculate cost for model ${model}:`, pricingError) + logger.error(` Usage object:`, JSON.stringify(usageObject)) + // 使用 CostCalculator 作为后备 + try { + const CostCalculator = require('../utils/costCalculator') + const fallbackCost = CostCalculator.calculateCost(usageObject, model) + if (fallbackCost && fallbackCost.costs && fallbackCost.costs.total > 0) { + logger.warn( + `⚠️ Using fallback cost calculation for ${model}: $${fallbackCost.costs.total}` + ) + costInfo = { + totalCost: fallbackCost.costs.total, + ephemeral5mCost: 0, + ephemeral1hCost: 0 + } + } + } catch (fallbackError) { + logger.error(`❌ Fallback cost calculation also failed:`, fallbackError) + } + } + + // 提取详细的缓存创建数据 + let ephemeral5mTokens = 0 + let ephemeral1hTokens = 0 + + if (usageObject.cache_creation && typeof usageObject.cache_creation === 'object') { + ephemeral5mTokens = usageObject.cache_creation.ephemeral_5m_input_tokens || 0 + ephemeral1hTokens = usageObject.cache_creation.ephemeral_1h_input_tokens || 0 + } + + // 记录API Key级别的使用统计 - 这个必须执行 + await redis.incrementTokenUsage( + keyId, + totalTokens, + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens, + model, + ephemeral5mTokens, // 传递5分钟缓存 tokens + ephemeral1hTokens, // 传递1小时缓存 tokens + costInfo.isLongContextRequest || false // 传递 1M 上下文请求标记 + ) + + // 记录费用统计 + if (costInfo.totalCost > 0) { + await redis.incrementDailyCost(keyId, costInfo.totalCost) + logger.database( + `💰 Recorded cost for ${keyId}: $${costInfo.totalCost.toFixed(6)}, model: ${model}` + ) + + // 记录 Opus 周费用(如果适用) + await this.recordOpusCost(keyId, costInfo.totalCost, model, accountType) + + // 记录详细的缓存费用(如果有) + if (costInfo.ephemeral5mCost > 0 || costInfo.ephemeral1hCost > 0) { + logger.database( + `💰 Cache costs - 5m: $${costInfo.ephemeral5mCost.toFixed( + 6 + )}, 1h: $${costInfo.ephemeral1hCost.toFixed(6)}` + ) + } + } else { + // 如果有 token 使用但费用为 0,记录警告 + if (totalTokens > 0) { + logger.warn( + `⚠️ No cost recorded for ${keyId} - zero cost for model: ${model} (tokens: ${totalTokens})` + ) + logger.warn(` This may indicate a pricing issue or model not found in pricing data`) + } else { + logger.debug(`💰 No cost recorded for ${keyId} - zero tokens for model: ${model}`) + } + } + + // 获取API Key数据以确定关联的账户 + const keyData = await redis.getApiKey(keyId) + if (keyData && Object.keys(keyData).length > 0) { + // 更新最后使用时间 + keyData.lastUsedAt = new Date().toISOString() + await redis.setApiKey(keyId, keyData) + + // 记录账户级别的使用统计(只统计实际处理请求的账户) + if (accountId) { + await redis.incrementAccountUsage( + accountId, + totalTokens, + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens, + model, + costInfo.isLongContextRequest || false + ) + logger.database( + `📊 Recorded account usage: ${accountId} - ${totalTokens} tokens (API Key: ${keyId})` + ) + } else { + logger.debug( + '⚠️ No accountId provided for usage recording, skipping account-level statistics' + ) + } + } + + const usageRecord = { + timestamp: new Date().toISOString(), + model, + accountId: accountId || null, + accountType: accountType || null, + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens, + ephemeral5mTokens, + ephemeral1hTokens, + totalTokens, + cost: Number((costInfo.totalCost || 0).toFixed(6)), + costBreakdown: { + input: costInfo.inputCost || 0, + output: costInfo.outputCost || 0, + cacheCreate: costInfo.cacheCreateCost || 0, + cacheRead: costInfo.cacheReadCost || 0, + ephemeral5m: costInfo.ephemeral5mCost || 0, + ephemeral1h: costInfo.ephemeral1hCost || 0 + }, + isLongContext: costInfo.isLongContextRequest || false + } + + await redis.addUsageRecord(keyId, usageRecord) + + const logParts = [`Model: ${model}`, `Input: ${inputTokens}`, `Output: ${outputTokens}`] + if (cacheCreateTokens > 0) { + logParts.push(`Cache Create: ${cacheCreateTokens}`) + + // 如果有详细的缓存创建数据,也记录它们 + if (usageObject.cache_creation) { + const { ephemeral_5m_input_tokens, ephemeral_1h_input_tokens } = + usageObject.cache_creation + if (ephemeral_5m_input_tokens > 0) { + logParts.push(`5m: ${ephemeral_5m_input_tokens}`) + } + if (ephemeral_1h_input_tokens > 0) { + logParts.push(`1h: ${ephemeral_1h_input_tokens}`) + } + } + } + if (cacheReadTokens > 0) { + logParts.push(`Cache Read: ${cacheReadTokens}`) + } + logParts.push(`Total: ${totalTokens} tokens`) + + logger.database(`📊 Recorded usage: ${keyId} - ${logParts.join(', ')}`) + } catch (error) { + logger.error('❌ Failed to record usage:', error) + } + } + + // 🔐 生成密钥 + _generateSecretKey() { + return crypto.randomBytes(32).toString('hex') + } + + // 🔒 哈希API Key + _hashApiKey(apiKey) { + return crypto + .createHash('sha256') + .update(apiKey + config.security.encryptionKey) + .digest('hex') + } + + // 📈 获取使用统计 + async getUsageStats(keyId, options = {}) { + const usageStats = await redis.getUsageStats(keyId) + + // options 可能是字符串(兼容旧接口),仅当为对象时才解析 + const optionObject = + options && typeof options === 'object' && !Array.isArray(options) ? options : {} + + if (optionObject.includeRecords === false) { + return usageStats + } + + const recordLimit = optionObject.recordLimit || 20 + const recentRecords = await redis.getUsageRecords(keyId, recordLimit) + + return { + ...usageStats, + recentRecords + } + } + + // 📊 获取账户使用统计 + async getAccountUsageStats(accountId) { + return await redis.getAccountUsageStats(accountId) + } + + // 📈 获取所有账户使用统计 + async getAllAccountsUsageStats() { + return await redis.getAllAccountsUsageStats() + } + + // === 用户相关方法 === + + // 🔑 创建API Key(支持用户) + async createApiKey(options = {}) { + return await this.generateApiKey(options) + } + + // 👤 获取用户的API Keys + async getUserApiKeys(userId, includeDeleted = false) { + try { + const allKeys = await redis.getAllApiKeys() + let userKeys = allKeys.filter((key) => key.userId === userId) + + // 默认过滤掉已删除的API Keys + if (!includeDeleted) { + userKeys = userKeys.filter((key) => key.isDeleted !== 'true') + } + + // Populate usage stats for each user's API key (same as getAllApiKeys does) + const userKeysWithUsage = [] + for (const key of userKeys) { + const usage = await redis.getUsageStats(key.id) + const dailyCost = (await redis.getDailyCost(key.id)) || 0 + const costStats = await redis.getCostStats(key.id) + + userKeysWithUsage.push({ + id: key.id, + name: key.name, + description: key.description, + key: key.apiKey ? `${this.prefix}****${key.apiKey.slice(-4)}` : null, // 只显示前缀和后4位 + tokenLimit: parseInt(key.tokenLimit || 0), + isActive: key.isActive === 'true', + createdAt: key.createdAt, + lastUsedAt: key.lastUsedAt, + expiresAt: key.expiresAt, + usage, + dailyCost, + totalCost: costStats.total, + dailyCostLimit: parseFloat(key.dailyCostLimit || 0), + totalCostLimit: parseFloat(key.totalCostLimit || 0), + userId: key.userId, + userUsername: key.userUsername, + createdBy: key.createdBy, + droidAccountId: key.droidAccountId, + // Include deletion fields for deleted keys + isDeleted: key.isDeleted, + deletedAt: key.deletedAt, + deletedBy: key.deletedBy, + deletedByType: key.deletedByType + }) + } + + return userKeysWithUsage + } catch (error) { + logger.error('❌ Failed to get user API keys:', error) + return [] + } + } + + // 🔍 通过ID获取API Key(检查权限) + async getApiKeyById(keyId, userId = null) { + try { + const keyData = await redis.getApiKey(keyId) + if (!keyData) { + return null + } + + // 如果指定了用户ID,检查权限 + if (userId && keyData.userId !== userId) { + return null + } + + return { + id: keyData.id, + name: keyData.name, + description: keyData.description, + key: keyData.apiKey, + tokenLimit: parseInt(keyData.tokenLimit || 0), + isActive: keyData.isActive === 'true', + createdAt: keyData.createdAt, + lastUsedAt: keyData.lastUsedAt, + expiresAt: keyData.expiresAt, + userId: keyData.userId, + userUsername: keyData.userUsername, + createdBy: keyData.createdBy, + permissions: keyData.permissions, + dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0), + totalCostLimit: parseFloat(keyData.totalCostLimit || 0), + droidAccountId: keyData.droidAccountId + } + } catch (error) { + logger.error('❌ Failed to get API key by ID:', error) + return null + } + } + + // 🔄 重新生成API Key + async regenerateApiKey(keyId) { + try { + const existingKey = await redis.getApiKey(keyId) + if (!existingKey) { + throw new Error('API key not found') + } + + // 生成新的key + const newApiKey = `${this.prefix}${this._generateSecretKey()}` + const newHashedKey = this._hashApiKey(newApiKey) + + // 删除旧的哈希映射 + const oldHashedKey = existingKey.apiKey + await redis.deleteApiKeyHash(oldHashedKey) + + // 更新key数据 + const updatedKeyData = { + ...existingKey, + apiKey: newHashedKey, + updatedAt: new Date().toISOString() + } + + // 保存新数据并建立新的哈希映射 + await redis.setApiKey(keyId, updatedKeyData, newHashedKey) + + logger.info(`🔄 Regenerated API key: ${existingKey.name} (${keyId})`) + + return { + id: keyId, + name: existingKey.name, + key: newApiKey, // 返回完整的新key + updatedAt: updatedKeyData.updatedAt + } + } catch (error) { + logger.error('❌ Failed to regenerate API key:', error) + throw error + } + } + + // 🗑️ 硬删除API Key (完全移除) + async hardDeleteApiKey(keyId) { + try { + const keyData = await redis.getApiKey(keyId) + if (!keyData) { + throw new Error('API key not found') + } + + // 删除key数据和哈希映射 + await redis.deleteApiKey(keyId) + await redis.deleteApiKeyHash(keyData.apiKey) + + logger.info(`🗑️ Deleted API key: ${keyData.name} (${keyId})`) + return true + } catch (error) { + logger.error('❌ Failed to delete API key:', error) + throw error + } + } + + // 🚫 禁用用户的所有API Keys + async disableUserApiKeys(userId) { + try { + const userKeys = await this.getUserApiKeys(userId) + let disabledCount = 0 + + for (const key of userKeys) { + if (key.isActive) { + await this.updateApiKey(key.id, { isActive: false }) + disabledCount++ + } + } + + logger.info(`🚫 Disabled ${disabledCount} API keys for user: ${userId}`) + return { count: disabledCount } + } catch (error) { + logger.error('❌ Failed to disable user API keys:', error) + throw error + } + } + + // 📊 获取聚合使用统计(支持多个API Key) + async getAggregatedUsageStats(keyIds, options = {}) { + try { + if (!Array.isArray(keyIds)) { + keyIds = [keyIds] + } + + const { period: _period = 'week', model: _model } = options + const stats = { + totalRequests: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCost: 0, + dailyStats: [], + modelStats: [] + } + + // 汇总所有API Key的统计数据 + for (const keyId of keyIds) { + const keyStats = await redis.getUsageStats(keyId) + const costStats = await redis.getCostStats(keyId) + if (keyStats && keyStats.total) { + stats.totalRequests += keyStats.total.requests || 0 + stats.totalInputTokens += keyStats.total.inputTokens || 0 + stats.totalOutputTokens += keyStats.total.outputTokens || 0 + stats.totalCost += costStats?.total || 0 + } + } + + // TODO: 实现日期范围和模型统计 + // 这里可以根据需要添加更详细的统计逻辑 + + return stats + } catch (error) { + logger.error('❌ Failed to get usage stats:', error) + return { + totalRequests: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCost: 0, + dailyStats: [], + modelStats: [] + } + } + } + + // 🔓 解绑账号从所有API Keys + async unbindAccountFromAllKeys(accountId, accountType) { + try { + // 账号类型与字段的映射关系 + const fieldMap = { + claude: 'claudeAccountId', + 'claude-console': 'claudeConsoleAccountId', + gemini: 'geminiAccountId', + openai: 'openaiAccountId', + 'openai-responses': 'openaiAccountId', // 特殊处理,带 responses: 前缀 + azure_openai: 'azureOpenaiAccountId', + bedrock: 'bedrockAccountId', + droid: 'droidAccountId', + ccr: null // CCR 账号没有对应的 API Key 字段 + } + + const field = fieldMap[accountType] + if (!field) { + logger.info(`账号类型 ${accountType} 不需要解绑 API Key`) + return 0 + } + + // 获取所有API Keys + const allKeys = await this.getAllApiKeys() + + // 筛选绑定到此账号的 API Keys + let boundKeys = [] + if (accountType === 'openai-responses') { + // OpenAI-Responses 特殊处理:查找 openaiAccountId 字段中带 responses: 前缀的 + boundKeys = allKeys.filter((key) => key.openaiAccountId === `responses:${accountId}`) + } else { + // 其他账号类型正常匹配 + boundKeys = allKeys.filter((key) => key[field] === accountId) + } + + // 批量解绑 + for (const key of boundKeys) { + const updates = {} + if (accountType === 'openai-responses') { + updates.openaiAccountId = null + } else if (accountType === 'claude-console') { + updates.claudeConsoleAccountId = null + } else { + updates[field] = null + } + + await this.updateApiKey(key.id, updates) + logger.info( + `✅ 自动解绑 API Key ${key.id} (${key.name}) 从 ${accountType} 账号 ${accountId}` + ) + } + + if (boundKeys.length > 0) { + logger.success( + `🔓 成功解绑 ${boundKeys.length} 个 API Key 从 ${accountType} 账号 ${accountId}` + ) + } + + return boundKeys.length + } catch (error) { + logger.error(`❌ 解绑 API Keys 失败 (${accountType} 账号 ${accountId}):`, error) + return 0 + } + } + + // 🧹 清理过期的API Keys + async cleanupExpiredKeys() { + try { + const apiKeys = await redis.getAllApiKeys() + const now = new Date() + let cleanedCount = 0 + + for (const key of apiKeys) { + // 检查是否已过期且仍处于激活状态 + if (key.expiresAt && new Date(key.expiresAt) < now && key.isActive === 'true') { + // 将过期的 API Key 标记为禁用状态,而不是直接删除 + await this.updateApiKey(key.id, { isActive: false }) + logger.info(`🔒 API Key ${key.id} (${key.name}) has expired and been disabled`) + cleanedCount++ + } + } + + if (cleanedCount > 0) { + logger.success(`🧹 Disabled ${cleanedCount} expired API keys`) + } + + return cleanedCount + } catch (error) { + logger.error('❌ Failed to cleanup expired keys:', error) + return 0 + } + } +} + +// 导出实例和单独的方法 +const apiKeyService = new ApiKeyService() + +// 为了方便其他服务调用,导出 recordUsage 方法 +apiKeyService.recordUsageMetrics = apiKeyService.recordUsage.bind(apiKeyService) + +module.exports = apiKeyService diff --git a/src/services/azureOpenaiAccountService.js b/src/services/azureOpenaiAccountService.js new file mode 100644 index 0000000000000000000000000000000000000000..daf947dbea828acef7f27b6efa87d0f670eb9667 --- /dev/null +++ b/src/services/azureOpenaiAccountService.js @@ -0,0 +1,521 @@ +const redisClient = require('../models/redis') +const { v4: uuidv4 } = require('uuid') +const crypto = require('crypto') +const config = require('../../config/config') +const logger = require('../utils/logger') + +// 加密相关常量 +const ALGORITHM = 'aes-256-cbc' +const IV_LENGTH = 16 + +// 🚀 安全的加密密钥生成,支持动态salt +const ENCRYPTION_SALT = config.security?.azureOpenaiSalt || 'azure-openai-account-default-salt' + +class EncryptionKeyManager { + constructor() { + this.keyCache = new Map() + this.keyRotationInterval = 24 * 60 * 60 * 1000 // 24小时 + } + + getKey(version = 'current') { + const cached = this.keyCache.get(version) + if (cached && Date.now() - cached.timestamp < this.keyRotationInterval) { + return cached.key + } + + // 生成新密钥 + const key = crypto.scryptSync(config.security.encryptionKey, ENCRYPTION_SALT, 32) + this.keyCache.set(version, { + key, + timestamp: Date.now() + }) + + logger.debug('🔑 Azure OpenAI encryption key generated/refreshed') + return key + } + + // 清理过期密钥 + cleanup() { + const now = Date.now() + for (const [version, cached] of this.keyCache.entries()) { + if (now - cached.timestamp > this.keyRotationInterval) { + this.keyCache.delete(version) + } + } + } +} + +const encryptionKeyManager = new EncryptionKeyManager() + +// 定期清理过期密钥 +setInterval( + () => { + encryptionKeyManager.cleanup() + }, + 60 * 60 * 1000 +) // 每小时清理一次 + +// 生成加密密钥 - 使用安全的密钥管理器 +function generateEncryptionKey() { + return encryptionKeyManager.getKey() +} + +// Azure OpenAI 账户键前缀 +const AZURE_OPENAI_ACCOUNT_KEY_PREFIX = 'azure_openai:account:' +const SHARED_AZURE_OPENAI_ACCOUNTS_KEY = 'shared_azure_openai_accounts' +const ACCOUNT_SESSION_MAPPING_PREFIX = 'azure_openai_session_account_mapping:' + +function normalizeSubscriptionExpiresAt(value) { + if (value === undefined || value === null || value === '') { + return '' + } + + const date = value instanceof Date ? value : new Date(value) + if (Number.isNaN(date.getTime())) { + return '' + } + + return date.toISOString() +} + +// 加密函数 +function encrypt(text) { + if (!text) { + return '' + } + const key = generateEncryptionKey() + const iv = crypto.randomBytes(IV_LENGTH) + const cipher = crypto.createCipheriv(ALGORITHM, key, iv) + let encrypted = cipher.update(text) + encrypted = Buffer.concat([encrypted, cipher.final()]) + return `${iv.toString('hex')}:${encrypted.toString('hex')}` +} + +// 解密函数 - 移除缓存以提高安全性 +function decrypt(text) { + if (!text) { + return '' + } + + try { + const key = generateEncryptionKey() + // IV 是固定长度的 32 个十六进制字符(16 字节) + const ivHex = text.substring(0, 32) + const encryptedHex = text.substring(33) // 跳过冒号 + + if (ivHex.length !== 32 || !encryptedHex) { + throw new Error('Invalid encrypted text format') + } + + const iv = Buffer.from(ivHex, 'hex') + const encryptedText = Buffer.from(encryptedHex, 'hex') + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv) + let decrypted = decipher.update(encryptedText) + decrypted = Buffer.concat([decrypted, decipher.final()]) + const result = decrypted.toString() + + return result + } catch (error) { + logger.error('Azure OpenAI decryption error:', error.message) + return '' + } +} + +// 创建账户 +async function createAccount(accountData) { + const accountId = uuidv4() + const now = new Date().toISOString() + + const account = { + id: accountId, + name: accountData.name, + description: accountData.description || '', + accountType: accountData.accountType || 'shared', + groupId: accountData.groupId || null, + priority: accountData.priority || 50, + // Azure OpenAI 特有字段 + azureEndpoint: accountData.azureEndpoint || '', + apiVersion: accountData.apiVersion || '2024-02-01', // 使用稳定版本 + deploymentName: accountData.deploymentName || 'gpt-4', // 使用默认部署名称 + apiKey: encrypt(accountData.apiKey || ''), + // 支持的模型 + supportedModels: JSON.stringify( + accountData.supportedModels || ['gpt-4', 'gpt-4-turbo', 'gpt-35-turbo', 'gpt-35-turbo-16k'] + ), + // 状态字段 + isActive: accountData.isActive !== false ? 'true' : 'false', + status: 'active', + schedulable: accountData.schedulable !== false ? 'true' : 'false', + subscriptionExpiresAt: normalizeSubscriptionExpiresAt(accountData.subscriptionExpiresAt || ''), + createdAt: now, + updatedAt: now + } + + // 代理配置 + if (accountData.proxy) { + account.proxy = + typeof accountData.proxy === 'string' ? accountData.proxy : JSON.stringify(accountData.proxy) + } + + const client = redisClient.getClientSafe() + await client.hset(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, account) + + // 如果是共享账户,添加到共享账户集合 + if (account.accountType === 'shared') { + await client.sadd(SHARED_AZURE_OPENAI_ACCOUNTS_KEY, accountId) + } + + logger.info(`Created Azure OpenAI account: ${accountId}`) + return { + ...account, + subscriptionExpiresAt: account.subscriptionExpiresAt || null + } +} + +// 获取账户 +async function getAccount(accountId) { + const client = redisClient.getClientSafe() + const accountData = await client.hgetall(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`) + + if (!accountData || Object.keys(accountData).length === 0) { + return null + } + + // 解密敏感数据(仅用于内部处理,不返回给前端) + if (accountData.apiKey) { + accountData.apiKey = decrypt(accountData.apiKey) + } + + // 解析代理配置 + if (accountData.proxy && typeof accountData.proxy === 'string') { + try { + accountData.proxy = JSON.parse(accountData.proxy) + } catch (e) { + accountData.proxy = null + } + } + + // 解析支持的模型 + if (accountData.supportedModels && typeof accountData.supportedModels === 'string') { + try { + accountData.supportedModels = JSON.parse(accountData.supportedModels) + } catch (e) { + accountData.supportedModels = ['gpt-4', 'gpt-35-turbo'] + } + } + + accountData.subscriptionExpiresAt = + accountData.subscriptionExpiresAt && accountData.subscriptionExpiresAt !== '' + ? accountData.subscriptionExpiresAt + : null + + return accountData +} + +// 更新账户 +async function updateAccount(accountId, updates) { + const existingAccount = await getAccount(accountId) + if (!existingAccount) { + throw new Error('Account not found') + } + + updates.updatedAt = new Date().toISOString() + + // 加密敏感数据 + if (updates.apiKey) { + updates.apiKey = encrypt(updates.apiKey) + } + + // 处理代理配置 + if (updates.proxy) { + updates.proxy = + typeof updates.proxy === 'string' ? updates.proxy : JSON.stringify(updates.proxy) + } + + // 处理支持的模型 + if (updates.supportedModels) { + updates.supportedModels = + typeof updates.supportedModels === 'string' + ? updates.supportedModels + : JSON.stringify(updates.supportedModels) + } + + if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) { + updates.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(updates.subscriptionExpiresAt) + } else if (Object.prototype.hasOwnProperty.call(updates, 'expiresAt')) { + updates.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(updates.expiresAt) + delete updates.expiresAt + } + + // 更新账户类型时处理共享账户集合 + const client = redisClient.getClientSafe() + if (updates.accountType && updates.accountType !== existingAccount.accountType) { + if (updates.accountType === 'shared') { + await client.sadd(SHARED_AZURE_OPENAI_ACCOUNTS_KEY, accountId) + } else { + await client.srem(SHARED_AZURE_OPENAI_ACCOUNTS_KEY, accountId) + } + } + + await client.hset(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, updates) + + logger.info(`Updated Azure OpenAI account: ${accountId}`) + + // 合并更新后的账户数据 + const updatedAccount = { ...existingAccount, ...updates } + + // 返回时解析代理配置 + if (updatedAccount.proxy && typeof updatedAccount.proxy === 'string') { + try { + updatedAccount.proxy = JSON.parse(updatedAccount.proxy) + } catch (e) { + updatedAccount.proxy = null + } + } + + if (!updatedAccount.subscriptionExpiresAt) { + updatedAccount.subscriptionExpiresAt = null + } + + return updatedAccount +} + +// 删除账户 +async function deleteAccount(accountId) { + // 首先从所有分组中移除此账户 + const accountGroupService = require('./accountGroupService') + await accountGroupService.removeAccountFromAllGroups(accountId) + + const client = redisClient.getClientSafe() + const accountKey = `${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}` + + // 从Redis中删除账户数据 + await client.del(accountKey) + + // 从共享账户集合中移除 + await client.srem(SHARED_AZURE_OPENAI_ACCOUNTS_KEY, accountId) + + logger.info(`Deleted Azure OpenAI account: ${accountId}`) + return true +} + +// 获取所有账户 +async function getAllAccounts() { + const client = redisClient.getClientSafe() + const keys = await client.keys(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}*`) + + if (!keys || keys.length === 0) { + return [] + } + + const accounts = [] + for (const key of keys) { + const accountData = await client.hgetall(key) + if (accountData && Object.keys(accountData).length > 0) { + // 不返回敏感数据给前端 + delete accountData.apiKey + + // 解析代理配置 + if (accountData.proxy && typeof accountData.proxy === 'string') { + try { + accountData.proxy = JSON.parse(accountData.proxy) + } catch (e) { + accountData.proxy = null + } + } + + // 解析支持的模型 + if (accountData.supportedModels && typeof accountData.supportedModels === 'string') { + try { + accountData.supportedModels = JSON.parse(accountData.supportedModels) + } catch (e) { + accountData.supportedModels = ['gpt-4', 'gpt-35-turbo'] + } + } + + accounts.push({ + ...accountData, + isActive: accountData.isActive === 'true', + schedulable: accountData.schedulable !== 'false', + subscriptionExpiresAt: accountData.subscriptionExpiresAt || null + }) + } + } + + return accounts +} + +// 获取共享账户 +async function getSharedAccounts() { + const client = redisClient.getClientSafe() + const accountIds = await client.smembers(SHARED_AZURE_OPENAI_ACCOUNTS_KEY) + + if (!accountIds || accountIds.length === 0) { + return [] + } + + const accounts = [] + for (const accountId of accountIds) { + const account = await getAccount(accountId) + if (account && account.isActive === 'true') { + accounts.push(account) + } + } + + return accounts +} + +// 选择可用账户 +async function selectAvailableAccount(sessionId = null) { + // 如果有会话ID,尝试获取之前分配的账户 + if (sessionId) { + const client = redisClient.getClientSafe() + const mappingKey = `${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionId}` + const accountId = await client.get(mappingKey) + + if (accountId) { + const account = await getAccount(accountId) + if (account && account.isActive === 'true' && account.schedulable === 'true') { + logger.debug(`Reusing Azure OpenAI account ${accountId} for session ${sessionId}`) + return account + } + } + } + + // 获取所有共享账户 + const sharedAccounts = await getSharedAccounts() + + // 过滤出可用的账户 + const availableAccounts = sharedAccounts.filter( + (acc) => acc.isActive === 'true' && acc.schedulable === 'true' + ) + + if (availableAccounts.length === 0) { + throw new Error('No available Azure OpenAI accounts') + } + + // 按优先级排序并选择 + availableAccounts.sort((a, b) => (b.priority || 50) - (a.priority || 50)) + const selectedAccount = availableAccounts[0] + + // 如果有会话ID,保存映射关系 + if (sessionId && selectedAccount) { + const client = redisClient.getClientSafe() + const mappingKey = `${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionId}` + await client.setex(mappingKey, 3600, selectedAccount.id) // 1小时过期 + } + + logger.debug(`Selected Azure OpenAI account: ${selectedAccount.id}`) + return selectedAccount +} + +// 更新账户使用量 +async function updateAccountUsage(accountId, tokens) { + const client = redisClient.getClientSafe() + const now = new Date().toISOString() + + // 使用 HINCRBY 原子操作更新使用量 + await client.hincrby(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, 'totalTokensUsed', tokens) + await client.hset(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, 'lastUsedAt', now) + + logger.debug(`Updated Azure OpenAI account ${accountId} usage: ${tokens} tokens`) +} + +// 健康检查单个账户 +async function healthCheckAccount(accountId) { + try { + const account = await getAccount(accountId) + if (!account) { + return { id: accountId, status: 'error', message: 'Account not found' } + } + + // 简单检查配置是否完整 + if (!account.azureEndpoint || !account.apiKey || !account.deploymentName) { + return { + id: accountId, + status: 'error', + message: 'Incomplete configuration' + } + } + + // 可以在这里添加实际的API调用测试 + // 暂时返回成功状态 + return { + id: accountId, + status: 'healthy', + message: 'Account is configured correctly' + } + } catch (error) { + logger.error(`Health check failed for Azure OpenAI account ${accountId}:`, error) + return { + id: accountId, + status: 'error', + message: error.message + } + } +} + +// 批量健康检查 +async function performHealthChecks() { + const accounts = await getAllAccounts() + const results = [] + + for (const account of accounts) { + const result = await healthCheckAccount(account.id) + results.push(result) + } + + return results +} + +// 切换账户的可调度状态 +async function toggleSchedulable(accountId) { + const account = await getAccount(accountId) + if (!account) { + throw new Error('Account not found') + } + + const newSchedulable = account.schedulable === 'true' ? 'false' : 'true' + await updateAccount(accountId, { schedulable: newSchedulable }) + + return { + id: accountId, + schedulable: newSchedulable === 'true' + } +} + +// 迁移 API Keys 以支持 Azure OpenAI +async function migrateApiKeysForAzureSupport() { + const client = redisClient.getClientSafe() + const apiKeyIds = await client.smembers('api_keys') + + let migratedCount = 0 + for (const keyId of apiKeyIds) { + const keyData = await client.hgetall(`api_key:${keyId}`) + if (keyData && !keyData.azureOpenaiAccountId) { + // 添加 Azure OpenAI 账户ID字段(初始为空) + await client.hset(`api_key:${keyId}`, 'azureOpenaiAccountId', '') + migratedCount++ + } + } + + logger.info(`Migrated ${migratedCount} API keys for Azure OpenAI support`) + return migratedCount +} + +module.exports = { + createAccount, + getAccount, + updateAccount, + deleteAccount, + getAllAccounts, + getSharedAccounts, + selectAvailableAccount, + updateAccountUsage, + healthCheckAccount, + performHealthChecks, + toggleSchedulable, + migrateApiKeysForAzureSupport, + encrypt, + decrypt +} diff --git a/src/services/azureOpenaiRelayService.js b/src/services/azureOpenaiRelayService.js new file mode 100644 index 0000000000000000000000000000000000000000..b6e9ecd5a5716a0498f1537e2edb9c99df57f0bf --- /dev/null +++ b/src/services/azureOpenaiRelayService.js @@ -0,0 +1,761 @@ +const axios = require('axios') +const ProxyHelper = require('../utils/proxyHelper') +const logger = require('../utils/logger') +const config = require('../../config/config') + +// 转换模型名称(去掉 azure/ 前缀) +function normalizeModelName(model) { + if (model && model.startsWith('azure/')) { + return model.replace('azure/', '') + } + return model +} + +// 处理 Azure OpenAI 请求 +async function handleAzureOpenAIRequest({ + account, + requestBody, + headers: _headers = {}, // 前缀下划线表示未使用 + isStream = false, + endpoint = 'chat/completions' +}) { + // 声明变量在函数顶部,确保在 catch 块中也能访问 + let requestUrl = '' + let proxyAgent = null + let deploymentName = '' + + try { + // 构建 Azure OpenAI 请求 URL + const baseUrl = account.azureEndpoint + deploymentName = account.deploymentName || 'default' + // Azure Responses API requires preview versions; fall back appropriately + const apiVersion = + account.apiVersion || (endpoint === 'responses' ? '2025-04-01-preview' : '2024-02-01') + if (endpoint === 'chat/completions') { + requestUrl = `${baseUrl}/openai/deployments/${deploymentName}/chat/completions?api-version=${apiVersion}` + } else if (endpoint === 'responses') { + requestUrl = `${baseUrl}/openai/responses?api-version=${apiVersion}` + } else { + requestUrl = `${baseUrl}/openai/deployments/${deploymentName}/${endpoint}?api-version=${apiVersion}` + } + + // 准备请求头 + const requestHeaders = { + 'Content-Type': 'application/json', + 'api-key': account.apiKey + } + + // 移除不需要的头部 + delete requestHeaders['anthropic-version'] + delete requestHeaders['x-api-key'] + delete requestHeaders['host'] + + // 处理请求体 + const processedBody = { ...requestBody } + + // 标准化模型名称 + if (endpoint === 'responses') { + processedBody.model = deploymentName + } else if (processedBody.model) { + processedBody.model = normalizeModelName(processedBody.model) + } else { + processedBody.model = 'gpt-4' + } + + // 使用统一的代理创建工具 + proxyAgent = ProxyHelper.createProxyAgent(account.proxy) + + // 配置请求选项 + const axiosConfig = { + method: 'POST', + url: requestUrl, + headers: requestHeaders, + data: processedBody, + timeout: config.requestTimeout || 600000, + validateStatus: () => true, + // 添加连接保活选项 + keepAlive: true, + maxRedirects: 5, + // 防止socket hang up + socketKeepAlive: true + } + + // 如果有代理,添加代理配置 + if (proxyAgent) { + axiosConfig.httpsAgent = proxyAgent + // 为代理添加额外的keep-alive设置 + if (proxyAgent.options) { + proxyAgent.options.keepAlive = true + proxyAgent.options.keepAliveMsecs = 1000 + } + logger.debug( + `Using proxy for Azure OpenAI request: ${ProxyHelper.getProxyDescription(account.proxy)}` + ) + } + + // 流式请求特殊处理 + if (isStream) { + axiosConfig.responseType = 'stream' + requestHeaders.accept = 'text/event-stream' + } else { + requestHeaders.accept = 'application/json' + } + + logger.debug(`Making Azure OpenAI request`, { + requestUrl, + method: 'POST', + endpoint, + deploymentName, + apiVersion, + hasProxy: !!proxyAgent, + proxyInfo: ProxyHelper.maskProxyInfo(account.proxy), + isStream, + requestBodySize: JSON.stringify(processedBody).length + }) + + logger.debug('Azure OpenAI request headers', { + 'content-type': requestHeaders['Content-Type'], + 'user-agent': requestHeaders['user-agent'] || 'not-set', + customHeaders: Object.keys(requestHeaders).filter( + (key) => !['Content-Type', 'user-agent'].includes(key) + ) + }) + + logger.debug('Azure OpenAI request body', { + model: processedBody.model, + messages: processedBody.messages?.length || 0, + otherParams: Object.keys(processedBody).filter((key) => !['model', 'messages'].includes(key)) + }) + + const requestStartTime = Date.now() + logger.debug(`🔄 Starting Azure OpenAI HTTP request at ${new Date().toISOString()}`) + + // 发送请求 + const response = await axios(axiosConfig) + + const requestDuration = Date.now() - requestStartTime + logger.debug(`✅ Azure OpenAI HTTP request completed at ${new Date().toISOString()}`) + + logger.debug(`Azure OpenAI response received`, { + status: response.status, + statusText: response.statusText, + duration: `${requestDuration}ms`, + responseHeaders: Object.keys(response.headers || {}), + hasData: !!response.data, + contentType: response.headers?.['content-type'] || 'unknown' + }) + + return response + } catch (error) { + const errorDetails = { + message: error.message, + code: error.code, + status: error.response?.status, + statusText: error.response?.statusText, + responseData: error.response?.data, + requestUrl: requestUrl || 'unknown', + endpoint, + deploymentName: deploymentName || account?.deploymentName || 'unknown', + hasProxy: !!proxyAgent, + proxyType: account?.proxy?.type || 'none', + isTimeout: error.code === 'ECONNABORTED', + isNetworkError: !error.response, + stack: error.stack + } + + // 特殊错误类型的详细日志 + if (error.code === 'ENOTFOUND') { + logger.error('DNS Resolution Failed for Azure OpenAI', { + ...errorDetails, + hostname: requestUrl && requestUrl !== 'unknown' ? new URL(requestUrl).hostname : 'unknown', + suggestion: 'Check if Azure endpoint URL is correct and accessible' + }) + } else if (error.code === 'ECONNREFUSED') { + logger.error('Connection Refused by Azure OpenAI', { + ...errorDetails, + suggestion: 'Check if proxy settings are correct or Azure service is accessible' + }) + } else if (error.code === 'ECONNRESET' || error.message.includes('socket hang up')) { + logger.error('🚨 Azure OpenAI Connection Reset / Socket Hang Up', { + ...errorDetails, + suggestion: + 'Connection was dropped by Azure OpenAI or proxy. This might be due to long request processing time, proxy timeout, or network instability. Try reducing request complexity or check proxy settings.' + }) + } else if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') { + logger.error('🚨 Azure OpenAI Request Timeout', { + ...errorDetails, + timeoutMs: 600000, + suggestion: + 'Request exceeded 10-minute timeout. Consider reducing model complexity or check if Azure service is responding slowly.' + }) + } else if ( + error.code === 'CERT_AUTHORITY_INVALID' || + error.code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' + ) { + logger.error('SSL Certificate Error for Azure OpenAI', { + ...errorDetails, + suggestion: 'SSL certificate validation failed - check proxy SSL settings' + }) + } else if (error.response?.status === 401) { + logger.error('Azure OpenAI Authentication Failed', { + ...errorDetails, + suggestion: 'Check if Azure OpenAI API key is valid and not expired' + }) + } else if (error.response?.status === 404) { + logger.error('Azure OpenAI Deployment Not Found', { + ...errorDetails, + suggestion: 'Check if deployment name and Azure endpoint are correct' + }) + } else { + logger.error('Azure OpenAI Request Failed', errorDetails) + } + + throw error + } +} + +// 安全的流管理器 +class StreamManager { + constructor() { + this.activeStreams = new Set() + this.cleanupCallbacks = new Map() + } + + registerStream(streamId, cleanup) { + this.activeStreams.add(streamId) + this.cleanupCallbacks.set(streamId, cleanup) + } + + cleanup(streamId) { + if (this.activeStreams.has(streamId)) { + try { + const cleanup = this.cleanupCallbacks.get(streamId) + if (cleanup) { + cleanup() + } + } catch (error) { + logger.warn(`Stream cleanup error for ${streamId}:`, error.message) + } finally { + this.activeStreams.delete(streamId) + this.cleanupCallbacks.delete(streamId) + } + } + } + + getActiveStreamCount() { + return this.activeStreams.size + } +} + +const streamManager = new StreamManager() + +// SSE 缓冲区大小限制 +const MAX_BUFFER_SIZE = 64 * 1024 // 64KB +const MAX_EVENT_SIZE = 16 * 1024 // 16KB 单个事件最大大小 + +// 处理流式响应 +function handleStreamResponse(upstreamResponse, clientResponse, options = {}) { + const { onData, onEnd, onError } = options + const streamId = `stream_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` + + logger.info(`Starting Azure OpenAI stream handling`, { + streamId, + upstreamStatus: upstreamResponse.status, + upstreamHeaders: Object.keys(upstreamResponse.headers || {}), + clientRemoteAddress: clientResponse.req?.connection?.remoteAddress, + hasOnData: !!onData, + hasOnEnd: !!onEnd, + hasOnError: !!onError + }) + + return new Promise((resolve, reject) => { + let buffer = '' + let usageData = null + let actualModel = null + let hasEnded = false + let eventCount = 0 + const maxEvents = 10000 // 最大事件数量限制 + + // 专门用于保存最后几个chunks以提取usage数据 + let finalChunksBuffer = '' + const FINAL_CHUNKS_SIZE = 32 * 1024 // 32KB保留最终chunks + const allParsedEvents = [] // 存储所有解析的事件用于最终usage提取 + + // 设置响应头 + clientResponse.setHeader('Content-Type', 'text/event-stream') + clientResponse.setHeader('Cache-Control', 'no-cache') + clientResponse.setHeader('Connection', 'keep-alive') + clientResponse.setHeader('X-Accel-Buffering', 'no') + + // 透传某些头部 + const passThroughHeaders = [ + 'x-request-id', + 'x-ratelimit-remaining-requests', + 'x-ratelimit-remaining-tokens' + ] + passThroughHeaders.forEach((header) => { + const value = upstreamResponse.headers[header] + if (value) { + clientResponse.setHeader(header, value) + } + }) + + // 立即刷新响应头 + if (typeof clientResponse.flushHeaders === 'function') { + clientResponse.flushHeaders() + } + + // 强化的SSE事件解析,保存所有事件用于最终处理 + const parseSSEForUsage = (data, isFromFinalBuffer = false) => { + const lines = data.split('\n') + + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const jsonStr = line.slice(6) // 移除 'data: ' 前缀 + if (jsonStr.trim() === '[DONE]') { + continue + } + const eventData = JSON.parse(jsonStr) + + // 保存所有成功解析的事件 + allParsedEvents.push(eventData) + + // 获取模型信息 + if (eventData.model) { + actualModel = eventData.model + } + + // 使用强化的usage提取函数 + const { usageData: extractedUsage, actualModel: extractedModel } = + extractUsageDataRobust( + eventData, + `stream-event-${isFromFinalBuffer ? 'final' : 'normal'}` + ) + + if (extractedUsage && !usageData) { + usageData = extractedUsage + if (extractedModel) { + actualModel = extractedModel + } + logger.debug(`🎯 Stream usage captured via robust extraction`, { + isFromFinalBuffer, + usageData, + actualModel + }) + } + + // 原有的简单提取作为备用 + if (!usageData) { + // 获取使用统计(Responses API: response.completed -> response.usage) + if (eventData.type === 'response.completed' && eventData.response) { + if (eventData.response.model) { + actualModel = eventData.response.model + } + if (eventData.response.usage) { + usageData = eventData.response.usage + logger.debug('🎯 Stream usage (backup method - response.usage):', usageData) + } + } + + // 兼容 Chat Completions 风格(顶层 usage) + if (!usageData && eventData.usage) { + usageData = eventData.usage + logger.debug('🎯 Stream usage (backup method - top-level):', usageData) + } + } + } catch (e) { + logger.debug('SSE parsing error (expected for incomplete chunks):', e.message) + } + } + } + } + + // 注册流清理 + const cleanup = () => { + if (!hasEnded) { + hasEnded = true + try { + upstreamResponse.data?.removeAllListeners?.() + upstreamResponse.data?.destroy?.() + + if (!clientResponse.headersSent) { + clientResponse.status(502).end() + } else if (!clientResponse.destroyed) { + clientResponse.end() + } + } catch (error) { + logger.warn('Stream cleanup error:', error.message) + } + } + } + + streamManager.registerStream(streamId, cleanup) + + upstreamResponse.data.on('data', (chunk) => { + try { + if (hasEnded || clientResponse.destroyed) { + return + } + + eventCount++ + if (eventCount > maxEvents) { + logger.warn(`Stream ${streamId} exceeded max events limit`) + cleanup() + return + } + + const chunkStr = chunk.toString() + + // 转发数据给客户端 + if (!clientResponse.destroyed) { + clientResponse.write(chunk) + } + + // 同时解析数据以捕获 usage 信息,带缓冲区大小限制 + buffer += chunkStr + + // 保留最后的chunks用于最终usage提取(不被truncate影响) + finalChunksBuffer += chunkStr + if (finalChunksBuffer.length > FINAL_CHUNKS_SIZE) { + finalChunksBuffer = finalChunksBuffer.slice(-FINAL_CHUNKS_SIZE) + } + + // 防止主缓冲区过大 - 但保持最后部分用于usage解析 + if (buffer.length > MAX_BUFFER_SIZE) { + logger.warn( + `Stream ${streamId} buffer exceeded limit, truncating main buffer but preserving final chunks` + ) + // 保留最后1/4而不是1/2,为usage数据留更多空间 + buffer = buffer.slice(-MAX_BUFFER_SIZE / 4) + } + + // 处理完整的 SSE 事件 + if (buffer.includes('\n\n')) { + const events = buffer.split('\n\n') + buffer = events.pop() || '' // 保留最后一个可能不完整的事件 + + for (const event of events) { + if (event.trim() && event.length <= MAX_EVENT_SIZE) { + parseSSEForUsage(event) + } + } + } + + if (onData) { + onData(chunk, { usageData, actualModel }) + } + } catch (error) { + logger.error('Error processing Azure OpenAI stream chunk:', error) + if (!hasEnded) { + cleanup() + reject(error) + } + } + }) + + upstreamResponse.data.on('end', () => { + if (hasEnded) { + return + } + + streamManager.cleanup(streamId) + hasEnded = true + + try { + logger.debug(`🔚 Stream ended, performing comprehensive usage extraction for ${streamId}`, { + mainBufferSize: buffer.length, + finalChunksBufferSize: finalChunksBuffer.length, + parsedEventsCount: allParsedEvents.length, + hasUsageData: !!usageData + }) + + // 多层次的最终usage提取策略 + if (!usageData) { + logger.debug('🔍 No usage found during stream, trying final extraction methods...') + + // 方法1: 解析剩余的主buffer + if (buffer.trim() && buffer.length <= MAX_EVENT_SIZE) { + parseSSEForUsage(buffer, false) + } + + // 方法2: 解析保留的final chunks buffer + if (!usageData && finalChunksBuffer.trim()) { + logger.debug('🔍 Trying final chunks buffer for usage extraction...') + parseSSEForUsage(finalChunksBuffer, true) + } + + // 方法3: 从所有解析的事件中重新搜索usage + if (!usageData && allParsedEvents.length > 0) { + logger.debug('🔍 Searching through all parsed events for usage...') + + // 倒序查找,因为usage通常在最后 + for (let i = allParsedEvents.length - 1; i >= 0; i--) { + const { usageData: foundUsage, actualModel: foundModel } = extractUsageDataRobust( + allParsedEvents[i], + `final-event-scan-${i}` + ) + if (foundUsage) { + usageData = foundUsage + if (foundModel) { + actualModel = foundModel + } + logger.debug(`🎯 Usage found in event ${i} during final scan!`) + break + } + } + } + + // 方法4: 尝试合并所有事件并搜索 + if (!usageData && allParsedEvents.length > 0) { + logger.debug('🔍 Trying combined events analysis...') + const combinedData = { + events: allParsedEvents, + lastEvent: allParsedEvents[allParsedEvents.length - 1], + eventCount: allParsedEvents.length + } + + const { usageData: combinedUsage } = extractUsageDataRobust( + combinedData, + 'combined-events' + ) + if (combinedUsage) { + usageData = combinedUsage + logger.debug('🎯 Usage found via combined events analysis!') + } + } + } + + // 最终usage状态报告 + if (usageData) { + logger.debug('✅ Final stream usage extraction SUCCESS', { + streamId, + usageData, + actualModel, + totalEvents: allParsedEvents.length, + finalBufferSize: finalChunksBuffer.length + }) + } else { + logger.warn('❌ Final stream usage extraction FAILED', { + streamId, + totalEvents: allParsedEvents.length, + finalBufferSize: finalChunksBuffer.length, + mainBufferSize: buffer.length, + lastFewEvents: allParsedEvents.slice(-3).map((e) => ({ + type: e.type, + hasUsage: !!e.usage, + hasResponse: !!e.response, + keys: Object.keys(e) + })) + }) + } + + if (onEnd) { + onEnd({ usageData, actualModel }) + } + + if (!clientResponse.destroyed) { + clientResponse.end() + } + + resolve({ usageData, actualModel }) + } catch (error) { + logger.error('Stream end handling error:', error) + reject(error) + } + }) + + upstreamResponse.data.on('error', (error) => { + if (hasEnded) { + return + } + + streamManager.cleanup(streamId) + hasEnded = true + + logger.error('Upstream stream error:', error) + + try { + if (onError) { + onError(error) + } + + if (!clientResponse.headersSent) { + clientResponse.status(502).json({ error: { message: 'Upstream stream error' } }) + } else if (!clientResponse.destroyed) { + clientResponse.end() + } + } catch (cleanupError) { + logger.warn('Error during stream error cleanup:', cleanupError.message) + } + + reject(error) + }) + + // 客户端断开时清理 + const clientCleanup = () => { + streamManager.cleanup(streamId) + } + + clientResponse.on('close', clientCleanup) + clientResponse.on('aborted', clientCleanup) + clientResponse.on('error', clientCleanup) + }) +} + +// 强化的用量数据提取函数 +function extractUsageDataRobust(responseData, context = 'unknown') { + logger.debug(`🔍 Attempting usage extraction for ${context}`, { + responseDataKeys: Object.keys(responseData || {}), + responseDataType: typeof responseData, + hasUsage: !!responseData?.usage, + hasResponse: !!responseData?.response + }) + + let usageData = null + let actualModel = null + + try { + // 策略 1: 顶层 usage (标准 Chat Completions) + if (responseData?.usage) { + usageData = responseData.usage + actualModel = responseData.model + logger.debug('✅ Usage extracted via Strategy 1 (top-level)', { usageData, actualModel }) + } + + // 策略 2: response.usage (Responses API) + else if (responseData?.response?.usage) { + usageData = responseData.response.usage + actualModel = responseData.response.model || responseData.model + logger.debug('✅ Usage extracted via Strategy 2 (response.usage)', { usageData, actualModel }) + } + + // 策略 3: 嵌套搜索 - 深度查找 usage 字段 + else { + const findUsageRecursive = (obj, path = '') => { + if (!obj || typeof obj !== 'object') { + return null + } + + for (const [key, value] of Object.entries(obj)) { + const currentPath = path ? `${path}.${key}` : key + + if (key === 'usage' && value && typeof value === 'object') { + logger.debug(`✅ Usage found at path: ${currentPath}`, value) + return { usage: value, path: currentPath } + } + + if (typeof value === 'object' && value !== null) { + const nested = findUsageRecursive(value, currentPath) + if (nested) { + return nested + } + } + } + return null + } + + const found = findUsageRecursive(responseData) + if (found) { + usageData = found.usage + // Try to find model in the same parent object + const pathParts = found.path.split('.') + pathParts.pop() // remove 'usage' + let modelParent = responseData + for (const part of pathParts) { + modelParent = modelParent?.[part] + } + actualModel = modelParent?.model || responseData?.model + logger.debug('✅ Usage extracted via Strategy 3 (recursive)', { + usageData, + actualModel, + foundPath: found.path + }) + } + } + + // 策略 4: 特殊响应格式处理 + if (!usageData) { + // 检查是否有 choices 数组,usage 可能在最后一个 choice 中 + if (responseData?.choices?.length > 0) { + const lastChoice = responseData.choices[responseData.choices.length - 1] + if (lastChoice?.usage) { + usageData = lastChoice.usage + actualModel = responseData.model || lastChoice.model + logger.debug('✅ Usage extracted via Strategy 4 (choices)', { usageData, actualModel }) + } + } + } + + // 最终验证和记录 + if (usageData) { + logger.debug('🎯 Final usage extraction result', { + context, + usageData, + actualModel, + inputTokens: usageData.prompt_tokens || usageData.input_tokens || 0, + outputTokens: usageData.completion_tokens || usageData.output_tokens || 0, + totalTokens: usageData.total_tokens || 0 + }) + } else { + logger.warn('❌ Failed to extract usage data', { + context, + responseDataStructure: `${JSON.stringify(responseData, null, 2).substring(0, 1000)}...`, + availableKeys: Object.keys(responseData || {}), + responseSize: JSON.stringify(responseData || {}).length + }) + } + } catch (extractionError) { + logger.error('🚨 Error during usage extraction', { + context, + error: extractionError.message, + stack: extractionError.stack, + responseDataType: typeof responseData + }) + } + + return { usageData, actualModel } +} + +// 处理非流式响应 +function handleNonStreamResponse(upstreamResponse, clientResponse) { + try { + // 设置状态码 + clientResponse.status(upstreamResponse.status) + + // 设置响应头 + clientResponse.setHeader('Content-Type', 'application/json') + + // 透传某些头部 + const passThroughHeaders = [ + 'x-request-id', + 'x-ratelimit-remaining-requests', + 'x-ratelimit-remaining-tokens' + ] + passThroughHeaders.forEach((header) => { + const value = upstreamResponse.headers[header] + if (value) { + clientResponse.setHeader(header, value) + } + }) + + // 返回响应数据 + const responseData = upstreamResponse.data + clientResponse.json(responseData) + + // 使用强化的用量提取 + const { usageData, actualModel } = extractUsageDataRobust(responseData, 'non-stream') + + return { usageData, actualModel, responseData } + } catch (error) { + logger.error('Error handling Azure OpenAI non-stream response:', error) + throw error + } +} + +module.exports = { + handleAzureOpenAIRequest, + handleStreamResponse, + handleNonStreamResponse, + normalizeModelName +} diff --git a/src/services/bedrockAccountService.js b/src/services/bedrockAccountService.js new file mode 100644 index 0000000000000000000000000000000000000000..6345f523d554c1520064ebcd8b2509330f3d95d5 --- /dev/null +++ b/src/services/bedrockAccountService.js @@ -0,0 +1,513 @@ +const { v4: uuidv4 } = require('uuid') +const crypto = require('crypto') +const redis = require('../models/redis') +const logger = require('../utils/logger') +const config = require('../../config/config') +const bedrockRelayService = require('./bedrockRelayService') +const LRUCache = require('../utils/lruCache') + +function normalizeSubscriptionExpiresAt(value) { + if (value === undefined || value === null || value === '') { + return '' + } + + const date = value instanceof Date ? value : new Date(value) + if (Number.isNaN(date.getTime())) { + return '' + } + + return date.toISOString() +} + +class BedrockAccountService { + constructor() { + // 加密相关常量 + this.ENCRYPTION_ALGORITHM = 'aes-256-cbc' + this.ENCRYPTION_SALT = 'salt' + + // 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算 + this._encryptionKeyCache = null + + // 🔄 解密结果缓存,提高解密性能 + this._decryptCache = new LRUCache(500) + + // 🧹 定期清理缓存(每10分钟) + setInterval( + () => { + this._decryptCache.cleanup() + logger.info('🧹 Bedrock decrypt cache cleanup completed', this._decryptCache.getStats()) + }, + 10 * 60 * 1000 + ) + } + + // 🏢 创建Bedrock账户 + async createAccount(options = {}) { + const { + name = 'Unnamed Bedrock Account', + description = '', + region = process.env.AWS_REGION || 'us-east-1', + awsCredentials = null, // { accessKeyId, secretAccessKey, sessionToken } + defaultModel = 'us.anthropic.claude-sonnet-4-20250514-v1:0', + isActive = true, + accountType = 'shared', // 'dedicated' or 'shared' + priority = 50, // 调度优先级 (1-100,数字越小优先级越高) + schedulable = true, // 是否可被调度 + credentialType = 'default', // 'default', 'access_key', 'bearer_token' + subscriptionExpiresAt = null + } = options + + const accountId = uuidv4() + + const accountData = { + id: accountId, + name, + description, + region, + defaultModel, + isActive, + accountType, + priority, + schedulable, + credentialType, + subscriptionExpiresAt: normalizeSubscriptionExpiresAt(subscriptionExpiresAt), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + type: 'bedrock' // 标识这是Bedrock账户 + } + + // 加密存储AWS凭证 + if (awsCredentials) { + accountData.awsCredentials = this._encryptAwsCredentials(awsCredentials) + } + + const client = redis.getClientSafe() + await client.set(`bedrock_account:${accountId}`, JSON.stringify(accountData)) + + logger.info(`✅ 创建Bedrock账户成功 - ID: ${accountId}, 名称: ${name}, 区域: ${region}`) + + return { + success: true, + data: { + id: accountId, + name, + description, + region, + defaultModel, + isActive, + accountType, + priority, + schedulable, + credentialType, + subscriptionExpiresAt: accountData.subscriptionExpiresAt || null, + createdAt: accountData.createdAt, + type: 'bedrock' + } + } + } + + // 🔍 获取账户信息 + async getAccount(accountId) { + try { + const client = redis.getClientSafe() + const accountData = await client.get(`bedrock_account:${accountId}`) + if (!accountData) { + return { success: false, error: 'Account not found' } + } + + const account = JSON.parse(accountData) + + // 解密AWS凭证用于内部使用 + if (account.awsCredentials) { + account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials) + } + + account.subscriptionExpiresAt = + account.subscriptionExpiresAt && account.subscriptionExpiresAt !== '' + ? account.subscriptionExpiresAt + : null + + logger.debug(`🔍 获取Bedrock账户 - ID: ${accountId}, 名称: ${account.name}`) + + return { + success: true, + data: account + } + } catch (error) { + logger.error(`❌ 获取Bedrock账户失败 - ID: ${accountId}`, error) + return { success: false, error: error.message } + } + } + + // 📋 获取所有账户列表 + async getAllAccounts() { + try { + const client = redis.getClientSafe() + const keys = await client.keys('bedrock_account:*') + const accounts = [] + + for (const key of keys) { + const accountData = await client.get(key) + if (accountData) { + const account = JSON.parse(accountData) + + // 返回给前端时,不包含敏感信息,只显示掩码 + accounts.push({ + id: account.id, + name: account.name, + description: account.description, + region: account.region, + defaultModel: account.defaultModel, + isActive: account.isActive, + accountType: account.accountType, + priority: account.priority, + schedulable: account.schedulable, + credentialType: account.credentialType, + createdAt: account.createdAt, + updatedAt: account.updatedAt, + type: 'bedrock', + hasCredentials: !!account.awsCredentials, + expiresAt: account.expiresAt || null, + subscriptionExpiresAt: account.subscriptionExpiresAt || null + }) + } + } + + // 按优先级和名称排序 + accounts.sort((a, b) => { + if (a.priority !== b.priority) { + return a.priority - b.priority + } + return a.name.localeCompare(b.name) + }) + + logger.debug(`📋 获取所有Bedrock账户 - 共 ${accounts.length} 个`) + + return { + success: true, + data: accounts + } + } catch (error) { + logger.error('❌ 获取Bedrock账户列表失败', error) + return { success: false, error: error.message } + } + } + + // ✏️ 更新账户信息 + async updateAccount(accountId, updates = {}) { + try { + // 获取原始账户数据(不解密凭证) + const client = redis.getClientSafe() + const accountData = await client.get(`bedrock_account:${accountId}`) + if (!accountData) { + return { success: false, error: 'Account not found' } + } + + const account = JSON.parse(accountData) + + // 更新字段 + if (updates.name !== undefined) { + account.name = updates.name + } + if (updates.description !== undefined) { + account.description = updates.description + } + if (updates.region !== undefined) { + account.region = updates.region + } + if (updates.defaultModel !== undefined) { + account.defaultModel = updates.defaultModel + } + if (updates.isActive !== undefined) { + account.isActive = updates.isActive + } + if (updates.accountType !== undefined) { + account.accountType = updates.accountType + } + if (updates.priority !== undefined) { + account.priority = updates.priority + } + if (updates.schedulable !== undefined) { + account.schedulable = updates.schedulable + } + if (updates.credentialType !== undefined) { + account.credentialType = updates.credentialType + } + + if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) { + account.subscriptionExpiresAt = normalizeSubscriptionExpiresAt( + updates.subscriptionExpiresAt + ) + } else if (Object.prototype.hasOwnProperty.call(updates, 'expiresAt')) { + account.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(updates.expiresAt) + } + + // 更新AWS凭证 + if (updates.awsCredentials !== undefined) { + if (updates.awsCredentials) { + account.awsCredentials = this._encryptAwsCredentials(updates.awsCredentials) + } else { + delete account.awsCredentials + } + } else if (account.awsCredentials && account.awsCredentials.accessKeyId) { + // 如果没有提供新凭证但现有凭证是明文格式,重新加密 + const plainCredentials = account.awsCredentials + account.awsCredentials = this._encryptAwsCredentials(plainCredentials) + logger.info(`🔐 重新加密Bedrock账户凭证 - ID: ${accountId}`) + } + + account.updatedAt = new Date().toISOString() + + await client.set(`bedrock_account:${accountId}`, JSON.stringify(account)) + + logger.info(`✅ 更新Bedrock账户成功 - ID: ${accountId}, 名称: ${account.name}`) + + return { + success: true, + data: { + id: account.id, + name: account.name, + description: account.description, + region: account.region, + defaultModel: account.defaultModel, + isActive: account.isActive, + accountType: account.accountType, + priority: account.priority, + schedulable: account.schedulable, + credentialType: account.credentialType, + updatedAt: account.updatedAt, + type: 'bedrock', + expiresAt: account.expiresAt || null, + subscriptionExpiresAt: account.subscriptionExpiresAt || null + } + } + } catch (error) { + logger.error(`❌ 更新Bedrock账户失败 - ID: ${accountId}`, error) + return { success: false, error: error.message } + } + } + + // 🗑️ 删除账户 + async deleteAccount(accountId) { + try { + const accountResult = await this.getAccount(accountId) + if (!accountResult.success) { + return accountResult + } + + const client = redis.getClientSafe() + await client.del(`bedrock_account:${accountId}`) + + logger.info(`✅ 删除Bedrock账户成功 - ID: ${accountId}`) + + return { success: true } + } catch (error) { + logger.error(`❌ 删除Bedrock账户失败 - ID: ${accountId}`, error) + return { success: false, error: error.message } + } + } + + // 🎯 选择可用的Bedrock账户 (用于请求转发) + async selectAvailableAccount() { + try { + const accountsResult = await this.getAllAccounts() + if (!accountsResult.success) { + return { success: false, error: 'Failed to get accounts' } + } + + const availableAccounts = accountsResult.data.filter( + (account) => account.isActive && account.schedulable + ) + + if (availableAccounts.length === 0) { + return { success: false, error: 'No available Bedrock accounts' } + } + + // 简单的轮询选择策略 - 选择优先级最高的账户 + const selectedAccount = availableAccounts[0] + + // 获取完整账户信息(包含解密的凭证) + const fullAccountResult = await this.getAccount(selectedAccount.id) + if (!fullAccountResult.success) { + return { success: false, error: 'Failed to get selected account details' } + } + + logger.debug(`🎯 选择Bedrock账户 - ID: ${selectedAccount.id}, 名称: ${selectedAccount.name}`) + + return { + success: true, + data: fullAccountResult.data + } + } catch (error) { + logger.error('❌ 选择Bedrock账户失败', error) + return { success: false, error: error.message } + } + } + + // 🧪 测试账户连接 + async testAccount(accountId) { + try { + const accountResult = await this.getAccount(accountId) + if (!accountResult.success) { + return accountResult + } + + const account = accountResult.data + + logger.info(`🧪 测试Bedrock账户连接 - ID: ${accountId}, 名称: ${account.name}`) + + // 尝试获取模型列表来测试连接 + const models = await bedrockRelayService.getAvailableModels(account) + + if (models && models.length > 0) { + logger.info(`✅ Bedrock账户测试成功 - ID: ${accountId}, 发现 ${models.length} 个模型`) + return { + success: true, + data: { + status: 'connected', + modelsCount: models.length, + region: account.region, + credentialType: account.credentialType + } + } + } else { + return { + success: false, + error: 'Unable to retrieve models from Bedrock' + } + } + } catch (error) { + logger.error(`❌ 测试Bedrock账户失败 - ID: ${accountId}`, error) + return { + success: false, + error: error.message + } + } + } + + // 🔑 生成加密密钥(缓存优化) + _generateEncryptionKey() { + if (!this._encryptionKeyCache) { + this._encryptionKeyCache = crypto + .createHash('sha256') + .update(config.security.encryptionKey) + .digest() + logger.info('🔑 Bedrock encryption key derived and cached for performance optimization') + } + return this._encryptionKeyCache + } + + // 🔐 加密AWS凭证 + _encryptAwsCredentials(credentials) { + try { + const key = this._generateEncryptionKey() + const iv = crypto.randomBytes(16) + const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv) + + const credentialsString = JSON.stringify(credentials) + let encrypted = cipher.update(credentialsString, 'utf8', 'hex') + encrypted += cipher.final('hex') + + return { + encrypted, + iv: iv.toString('hex') + } + } catch (error) { + logger.error('❌ AWS凭证加密失败', error) + throw new Error('Credentials encryption failed') + } + } + + // 🔓 解密AWS凭证 + _decryptAwsCredentials(encryptedData) { + try { + // 检查数据格式 + if (!encryptedData || typeof encryptedData !== 'object') { + logger.error('❌ 无效的加密数据格式:', encryptedData) + throw new Error('Invalid encrypted data format') + } + + // 检查是否为加密格式 (有 encrypted 和 iv 字段) + if (encryptedData.encrypted && encryptedData.iv) { + // 🎯 检查缓存 + const cacheKey = crypto + .createHash('sha256') + .update(JSON.stringify(encryptedData)) + .digest('hex') + const cached = this._decryptCache.get(cacheKey) + if (cached !== undefined) { + return cached + } + + // 加密数据 - 进行解密 + const key = this._generateEncryptionKey() + const iv = Buffer.from(encryptedData.iv, 'hex') + const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv) + + let decrypted = decipher.update(encryptedData.encrypted, 'hex', 'utf8') + decrypted += decipher.final('utf8') + + const result = JSON.parse(decrypted) + + // 💾 存入缓存(5分钟过期) + this._decryptCache.set(cacheKey, result, 5 * 60 * 1000) + + // 📊 定期打印缓存统计 + if ((this._decryptCache.hits + this._decryptCache.misses) % 1000 === 0) { + this._decryptCache.printStats() + } + + return result + } else if (encryptedData.accessKeyId) { + // 纯文本数据 - 直接返回 (向后兼容) + logger.warn('⚠️ 发现未加密的AWS凭证,建议更新账户以启用加密') + return encryptedData + } else { + // 既不是加密格式也不是有效的凭证格式 + logger.error('❌ 缺少加密数据字段:', { + hasEncrypted: !!encryptedData.encrypted, + hasIv: !!encryptedData.iv, + hasAccessKeyId: !!encryptedData.accessKeyId + }) + throw new Error('Missing encrypted data fields or valid credentials') + } + } catch (error) { + logger.error('❌ AWS凭证解密失败', error) + throw new Error('Credentials decryption failed') + } + } + + // 🔍 获取账户统计信息 + async getAccountStats() { + try { + const accountsResult = await this.getAllAccounts() + if (!accountsResult.success) { + return { success: false, error: accountsResult.error } + } + + const accounts = accountsResult.data + const stats = { + total: accounts.length, + active: accounts.filter((acc) => acc.isActive).length, + inactive: accounts.filter((acc) => !acc.isActive).length, + schedulable: accounts.filter((acc) => acc.schedulable).length, + byRegion: {}, + byCredentialType: {} + } + + // 按区域统计 + accounts.forEach((acc) => { + stats.byRegion[acc.region] = (stats.byRegion[acc.region] || 0) + 1 + stats.byCredentialType[acc.credentialType] = + (stats.byCredentialType[acc.credentialType] || 0) + 1 + }) + + return { success: true, data: stats } + } catch (error) { + logger.error('❌ 获取Bedrock账户统计失败', error) + return { success: false, error: error.message } + } + } +} + +module.exports = new BedrockAccountService() diff --git a/src/services/bedrockRelayService.js b/src/services/bedrockRelayService.js new file mode 100644 index 0000000000000000000000000000000000000000..e27dfd5c0532e0bfc049230c9268fb82322f5f6c --- /dev/null +++ b/src/services/bedrockRelayService.js @@ -0,0 +1,478 @@ +const { + BedrockRuntimeClient, + InvokeModelCommand, + InvokeModelWithResponseStreamCommand +} = require('@aws-sdk/client-bedrock-runtime') +const { fromEnv } = require('@aws-sdk/credential-providers') +const logger = require('../utils/logger') +const config = require('../../config/config') + +class BedrockRelayService { + constructor() { + this.defaultRegion = process.env.AWS_REGION || config.bedrock?.defaultRegion || 'us-east-1' + this.smallFastModelRegion = + process.env.ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION || this.defaultRegion + + // 默认模型配置 + this.defaultModel = process.env.ANTHROPIC_MODEL || 'us.anthropic.claude-sonnet-4-20250514-v1:0' + this.defaultSmallModel = + process.env.ANTHROPIC_SMALL_FAST_MODEL || 'us.anthropic.claude-3-5-haiku-20241022-v1:0' + + // Token配置 + this.maxOutputTokens = parseInt(process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS) || 4096 + this.maxThinkingTokens = parseInt(process.env.MAX_THINKING_TOKENS) || 1024 + this.enablePromptCaching = process.env.DISABLE_PROMPT_CACHING !== '1' + + // 创建Bedrock客户端 + this.clients = new Map() // 缓存不同区域的客户端 + } + + // 获取或创建Bedrock客户端 + _getBedrockClient(region = null, bedrockAccount = null) { + const targetRegion = region || this.defaultRegion + const clientKey = `${targetRegion}-${bedrockAccount?.id || 'default'}` + + if (this.clients.has(clientKey)) { + return this.clients.get(clientKey) + } + + const clientConfig = { + region: targetRegion + } + + // 如果账户配置了特定的AWS凭证,使用它们 + if (bedrockAccount?.awsCredentials) { + clientConfig.credentials = { + accessKeyId: bedrockAccount.awsCredentials.accessKeyId, + secretAccessKey: bedrockAccount.awsCredentials.secretAccessKey, + sessionToken: bedrockAccount.awsCredentials.sessionToken + } + } else { + // 检查是否有环境变量凭证 + if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) { + clientConfig.credentials = fromEnv() + } else { + throw new Error( + 'AWS凭证未配置。请在Bedrock账户中配置AWS访问密钥,或设置环境变量AWS_ACCESS_KEY_ID和AWS_SECRET_ACCESS_KEY' + ) + } + } + + const client = new BedrockRuntimeClient(clientConfig) + this.clients.set(clientKey, client) + + logger.debug( + `🔧 Created Bedrock client for region: ${targetRegion}, account: ${bedrockAccount?.name || 'default'}` + ) + return client + } + + // 处理非流式请求 + async handleNonStreamRequest(requestBody, bedrockAccount = null) { + try { + const modelId = this._selectModel(requestBody, bedrockAccount) + const region = this._selectRegion(modelId, bedrockAccount) + const client = this._getBedrockClient(region, bedrockAccount) + + // 转换请求格式为Bedrock格式 + const bedrockPayload = this._convertToBedrockFormat(requestBody) + + const command = new InvokeModelCommand({ + modelId, + body: JSON.stringify(bedrockPayload), + contentType: 'application/json', + accept: 'application/json' + }) + + logger.debug(`🚀 Bedrock非流式请求 - 模型: ${modelId}, 区域: ${region}`) + + const startTime = Date.now() + const response = await client.send(command) + const duration = Date.now() - startTime + + // 解析响应 + const responseBody = JSON.parse(new TextDecoder().decode(response.body)) + const claudeResponse = this._convertFromBedrockFormat(responseBody) + + logger.info(`✅ Bedrock请求完成 - 模型: ${modelId}, 耗时: ${duration}ms`) + + return { + success: true, + data: claudeResponse, + usage: claudeResponse.usage, + model: modelId, + duration + } + } catch (error) { + logger.error('❌ Bedrock非流式请求失败:', error) + throw this._handleBedrockError(error) + } + } + + // 处理流式请求 + async handleStreamRequest(requestBody, bedrockAccount = null, res) { + try { + const modelId = this._selectModel(requestBody, bedrockAccount) + const region = this._selectRegion(modelId, bedrockAccount) + const client = this._getBedrockClient(region, bedrockAccount) + + // 转换请求格式为Bedrock格式 + const bedrockPayload = this._convertToBedrockFormat(requestBody) + + const command = new InvokeModelWithResponseStreamCommand({ + modelId, + body: JSON.stringify(bedrockPayload), + contentType: 'application/json', + accept: 'application/json' + }) + + logger.debug(`🌊 Bedrock流式请求 - 模型: ${modelId}, 区域: ${region}`) + + const startTime = Date.now() + const response = await client.send(command) + + // 设置SSE响应头 + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization' + }) + + let totalUsage = null + let isFirstChunk = true + + // 处理流式响应 + for await (const chunk of response.body) { + if (chunk.chunk) { + const chunkData = JSON.parse(new TextDecoder().decode(chunk.chunk.bytes)) + const claudeEvent = this._convertBedrockStreamToClaudeFormat(chunkData, isFirstChunk) + + if (claudeEvent) { + // 发送SSE事件 + res.write(`event: ${claudeEvent.type}\n`) + res.write(`data: ${JSON.stringify(claudeEvent.data)}\n\n`) + + // 提取使用统计 + if (claudeEvent.type === 'message_stop' && claudeEvent.data.usage) { + totalUsage = claudeEvent.data.usage + } + + isFirstChunk = false + } + } + } + + const duration = Date.now() - startTime + logger.info(`✅ Bedrock流式请求完成 - 模型: ${modelId}, 耗时: ${duration}ms`) + + // 发送结束事件 + res.write('event: done\n') + res.write('data: [DONE]\n\n') + res.end() + + return { + success: true, + usage: totalUsage, + model: modelId, + duration + } + } catch (error) { + logger.error('❌ Bedrock流式请求失败:', error) + + // 发送错误事件 + if (!res.headersSent) { + res.writeHead(500, { 'Content-Type': 'application/json' }) + } + + res.write('event: error\n') + res.write(`data: ${JSON.stringify({ error: this._handleBedrockError(error).message })}\n\n`) + res.end() + + throw this._handleBedrockError(error) + } + } + + // 选择使用的模型 + _selectModel(requestBody, bedrockAccount) { + let selectedModel + + // 优先使用账户配置的模型 + if (bedrockAccount?.defaultModel) { + selectedModel = bedrockAccount.defaultModel + logger.info(`🎯 使用账户配置的模型: ${selectedModel}`, { + metadata: { source: 'account', accountId: bedrockAccount.id } + }) + } + // 检查请求中指定的模型 + else if (requestBody.model) { + selectedModel = requestBody.model + logger.info(`🎯 使用请求指定的模型: ${selectedModel}`, { metadata: { source: 'request' } }) + } + // 使用默认模型 + else { + selectedModel = this.defaultModel + logger.info(`🎯 使用系统默认模型: ${selectedModel}`, { metadata: { source: 'default' } }) + } + + // 如果是标准Claude模型名,需要映射为Bedrock格式 + const bedrockModel = this._mapToBedrockModel(selectedModel) + if (bedrockModel !== selectedModel) { + logger.info(`🔄 模型映射: ${selectedModel} → ${bedrockModel}`, { + metadata: { originalModel: selectedModel, bedrockModel } + }) + } + + return bedrockModel + } + + // 将标准Claude模型名映射为Bedrock格式 + _mapToBedrockModel(modelName) { + // 标准Claude模型名到Bedrock模型名的映射表 + const modelMapping = { + // Claude Sonnet 4 + 'claude-sonnet-4': 'us.anthropic.claude-sonnet-4-20250514-v1:0', + 'claude-sonnet-4-20250514': 'us.anthropic.claude-sonnet-4-20250514-v1:0', + + // Claude Opus 4.1 + 'claude-opus-4': 'us.anthropic.claude-opus-4-1-20250805-v1:0', + 'claude-opus-4-1': 'us.anthropic.claude-opus-4-1-20250805-v1:0', + 'claude-opus-4-1-20250805': 'us.anthropic.claude-opus-4-1-20250805-v1:0', + + // Claude 3.7 Sonnet + 'claude-3-7-sonnet': 'us.anthropic.claude-3-7-sonnet-20250219-v1:0', + 'claude-3-7-sonnet-20250219': 'us.anthropic.claude-3-7-sonnet-20250219-v1:0', + + // Claude 3.5 Sonnet v2 + 'claude-3-5-sonnet': 'us.anthropic.claude-3-5-sonnet-20241022-v2:0', + 'claude-3-5-sonnet-20241022': 'us.anthropic.claude-3-5-sonnet-20241022-v2:0', + + // Claude 3.5 Haiku + 'claude-3-5-haiku': 'us.anthropic.claude-3-5-haiku-20241022-v1:0', + 'claude-3-5-haiku-20241022': 'us.anthropic.claude-3-5-haiku-20241022-v1:0', + + // Claude 3 Sonnet + 'claude-3-sonnet': 'us.anthropic.claude-3-sonnet-20240229-v1:0', + 'claude-3-sonnet-20240229': 'us.anthropic.claude-3-sonnet-20240229-v1:0', + + // Claude 3 Haiku + 'claude-3-haiku': 'us.anthropic.claude-3-haiku-20240307-v1:0', + 'claude-3-haiku-20240307': 'us.anthropic.claude-3-haiku-20240307-v1:0' + } + + // 如果已经是Bedrock格式,直接返回 + // Bedrock模型格式:{region}.anthropic.{model-name} 或 anthropic.{model-name} + if (modelName.includes('.anthropic.') || modelName.startsWith('anthropic.')) { + return modelName + } + + // 查找映射 + const mappedModel = modelMapping[modelName] + if (mappedModel) { + return mappedModel + } + + // 如果没有找到映射,返回原始模型名(可能会导致错误,但保持向后兼容) + logger.warn(`⚠️ 未找到模型映射: ${modelName},使用原始模型名`, { + metadata: { originalModel: modelName } + }) + return modelName + } + + // 选择使用的区域 + _selectRegion(modelId, bedrockAccount) { + // 优先使用账户配置的区域 + if (bedrockAccount?.region) { + return bedrockAccount.region + } + + // 对于小模型,使用专门的区域配置 + if (modelId.includes('haiku')) { + return this.smallFastModelRegion + } + + return this.defaultRegion + } + + // 转换Claude格式请求到Bedrock格式 + _convertToBedrockFormat(requestBody) { + const bedrockPayload = { + anthropic_version: 'bedrock-2023-05-31', + max_tokens: Math.min(requestBody.max_tokens || this.maxOutputTokens, this.maxOutputTokens), + messages: requestBody.messages || [] + } + + // 添加系统提示词 + if (requestBody.system) { + bedrockPayload.system = requestBody.system + } + + // 添加其他参数 + if (requestBody.temperature !== undefined) { + bedrockPayload.temperature = requestBody.temperature + } + + if (requestBody.top_p !== undefined) { + bedrockPayload.top_p = requestBody.top_p + } + + if (requestBody.top_k !== undefined) { + bedrockPayload.top_k = requestBody.top_k + } + + if (requestBody.stop_sequences) { + bedrockPayload.stop_sequences = requestBody.stop_sequences + } + + // 工具调用支持 + if (requestBody.tools) { + bedrockPayload.tools = requestBody.tools + } + + if (requestBody.tool_choice) { + bedrockPayload.tool_choice = requestBody.tool_choice + } + + return bedrockPayload + } + + // 转换Bedrock响应到Claude格式 + _convertFromBedrockFormat(bedrockResponse) { + return { + id: `msg_${Date.now()}_bedrock`, + type: 'message', + role: 'assistant', + content: bedrockResponse.content || [], + model: bedrockResponse.model || this.defaultModel, + stop_reason: bedrockResponse.stop_reason || 'end_turn', + stop_sequence: bedrockResponse.stop_sequence || null, + usage: bedrockResponse.usage || { + input_tokens: 0, + output_tokens: 0 + } + } + } + + // 转换Bedrock流事件到Claude SSE格式 + _convertBedrockStreamToClaudeFormat(bedrockChunk) { + if (bedrockChunk.type === 'message_start') { + return { + type: 'message_start', + data: { + type: 'message', + id: `msg_${Date.now()}_bedrock`, + role: 'assistant', + content: [], + model: this.defaultModel, + stop_reason: null, + stop_sequence: null, + usage: bedrockChunk.message?.usage || { input_tokens: 0, output_tokens: 0 } + } + } + } + + if (bedrockChunk.type === 'content_block_delta') { + return { + type: 'content_block_delta', + data: { + index: bedrockChunk.index || 0, + delta: bedrockChunk.delta || {} + } + } + } + + if (bedrockChunk.type === 'message_delta') { + return { + type: 'message_delta', + data: { + delta: bedrockChunk.delta || {}, + usage: bedrockChunk.usage || {} + } + } + } + + if (bedrockChunk.type === 'message_stop') { + return { + type: 'message_stop', + data: { + usage: bedrockChunk.usage || {} + } + } + } + + return null + } + + // 处理Bedrock错误 + _handleBedrockError(error) { + const errorMessage = error.message || 'Unknown Bedrock error' + + if (error.name === 'ValidationException') { + return new Error(`Bedrock参数验证失败: ${errorMessage}`) + } + + if (error.name === 'ThrottlingException') { + return new Error('Bedrock请求限流,请稍后重试') + } + + if (error.name === 'AccessDeniedException') { + return new Error('Bedrock访问被拒绝,请检查IAM权限') + } + + if (error.name === 'ModelNotReadyException') { + return new Error('Bedrock模型未就绪,请稍后重试') + } + + return new Error(`Bedrock服务错误: ${errorMessage}`) + } + + // 获取可用模型列表 + async getAvailableModels(bedrockAccount = null) { + try { + const region = bedrockAccount?.region || this.defaultRegion + + // Bedrock暂不支持列出推理配置文件的API,返回预定义的模型列表 + const models = [ + { + id: 'us.anthropic.claude-sonnet-4-20250514-v1:0', + name: 'Claude Sonnet 4', + provider: 'anthropic', + type: 'bedrock' + }, + { + id: 'us.anthropic.claude-opus-4-1-20250805-v1:0', + name: 'Claude Opus 4.1', + provider: 'anthropic', + type: 'bedrock' + }, + { + id: 'us.anthropic.claude-3-7-sonnet-20250219-v1:0', + name: 'Claude 3.7 Sonnet', + provider: 'anthropic', + type: 'bedrock' + }, + { + id: 'us.anthropic.claude-3-5-sonnet-20241022-v2:0', + name: 'Claude 3.5 Sonnet v2', + provider: 'anthropic', + type: 'bedrock' + }, + { + id: 'us.anthropic.claude-3-5-haiku-20241022-v1:0', + name: 'Claude 3.5 Haiku', + provider: 'anthropic', + type: 'bedrock' + } + ] + + logger.debug(`📋 返回Bedrock可用模型 ${models.length} 个, 区域: ${region}`) + return models + } catch (error) { + logger.error('❌ 获取Bedrock模型列表失败:', error) + return [] + } + } +} + +module.exports = new BedrockRelayService() diff --git a/src/services/ccrAccountService.js b/src/services/ccrAccountService.js new file mode 100644 index 0000000000000000000000000000000000000000..3f4967fe22e221338fc347112c8d1ca8803b940b --- /dev/null +++ b/src/services/ccrAccountService.js @@ -0,0 +1,934 @@ +const { v4: uuidv4 } = require('uuid') +const crypto = require('crypto') +const ProxyHelper = require('../utils/proxyHelper') +const redis = require('../models/redis') +const logger = require('../utils/logger') +const config = require('../../config/config') +const LRUCache = require('../utils/lruCache') + +function normalizeSubscriptionExpiresAt(value) { + if (value === undefined || value === null || value === '') { + return '' + } + + const date = value instanceof Date ? value : new Date(value) + if (Number.isNaN(date.getTime())) { + return '' + } + + return date.toISOString() +} + +class CcrAccountService { + constructor() { + // 加密相关常量 + this.ENCRYPTION_ALGORITHM = 'aes-256-cbc' + this.ENCRYPTION_SALT = 'ccr-account-salt' + + // Redis键前缀 + this.ACCOUNT_KEY_PREFIX = 'ccr_account:' + this.SHARED_ACCOUNTS_KEY = 'shared_ccr_accounts' + + // 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算 + // scryptSync 是 CPU 密集型操作,缓存可以减少 95%+ 的 CPU 密集型操作 + this._encryptionKeyCache = null + + // 🔄 解密结果缓存,提高解密性能 + this._decryptCache = new LRUCache(500) + + // 🧹 定期清理缓存(每10分钟) + setInterval( + () => { + this._decryptCache.cleanup() + logger.info('🧹 CCR account decrypt cache cleanup completed', this._decryptCache.getStats()) + }, + 10 * 60 * 1000 + ) + } + + // 🏢 创建CCR账户 + async createAccount(options = {}) { + const { + name = 'CCR Account', + description = '', + apiUrl = '', + apiKey = '', + priority = 50, // 默认优先级50(1-100) + supportedModels = [], // 支持的模型列表或映射表,空数组/对象表示支持所有 + userAgent = 'claude-relay-service/1.0.0', + rateLimitDuration = 60, // 限流时间(分钟) + proxy = null, + isActive = true, + accountType = 'shared', // 'dedicated' or 'shared' + schedulable = true, // 是否可被调度 + dailyQuota = 0, // 每日额度限制(美元),0表示不限制 + quotaResetTime = '00:00', // 额度重置时间(HH:mm格式) + subscriptionExpiresAt = null + } = options + + // 验证必填字段 + if (!apiUrl || !apiKey) { + throw new Error('API URL and API Key are required for CCR account') + } + + const accountId = uuidv4() + + // 处理 supportedModels,确保向后兼容 + const processedModels = this._processModelMapping(supportedModels) + + const accountData = { + id: accountId, + platform: 'ccr', + name, + description, + apiUrl, + apiKey: this._encryptSensitiveData(apiKey), + priority: priority.toString(), + supportedModels: JSON.stringify(processedModels), + userAgent, + rateLimitDuration: rateLimitDuration.toString(), + proxy: proxy ? JSON.stringify(proxy) : '', + isActive: isActive.toString(), + accountType, + createdAt: new Date().toISOString(), + lastUsedAt: '', + status: 'active', + errorMessage: '', + // 限流相关 + rateLimitedAt: '', + rateLimitStatus: '', + // 调度控制 + schedulable: schedulable.toString(), + // 额度管理相关 + dailyQuota: dailyQuota.toString(), // 每日额度限制(美元) + dailyUsage: '0', // 当日使用金额(美元) + // 使用与统计一致的时区日期,避免边界问题 + lastResetDate: redis.getDateStringInTimezone(), // 最后重置日期(按配置时区) + quotaResetTime, // 额度重置时间 + quotaStoppedAt: '', // 因额度停用的时间 + subscriptionExpiresAt: normalizeSubscriptionExpiresAt(subscriptionExpiresAt) + } + + const client = redis.getClientSafe() + logger.debug( + `[DEBUG] Saving CCR account data to Redis with key: ${this.ACCOUNT_KEY_PREFIX}${accountId}` + ) + logger.debug(`[DEBUG] CCR Account data to save: ${JSON.stringify(accountData, null, 2)}`) + + await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, accountData) + + // 如果是共享账户,添加到共享账户集合 + if (accountType === 'shared') { + await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId) + } + + logger.success(`🏢 Created CCR account: ${name} (${accountId})`) + + return { + id: accountId, + name, + description, + apiUrl, + priority, + supportedModels, + userAgent, + rateLimitDuration, + isActive, + proxy, + accountType, + status: 'active', + createdAt: accountData.createdAt, + dailyQuota, + dailyUsage: 0, + lastResetDate: accountData.lastResetDate, + quotaResetTime, + quotaStoppedAt: null, + subscriptionExpiresAt: accountData.subscriptionExpiresAt || null + } + } + + // 📋 获取所有CCR账户 + async getAllAccounts() { + try { + const client = redis.getClientSafe() + const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`) + const accounts = [] + + for (const key of keys) { + const accountData = await client.hgetall(key) + if (accountData && Object.keys(accountData).length > 0) { + // 获取限流状态信息 + const rateLimitInfo = this._getRateLimitInfo(accountData) + + accounts.push({ + id: accountData.id, + platform: accountData.platform, + name: accountData.name, + description: accountData.description, + apiUrl: accountData.apiUrl, + priority: parseInt(accountData.priority) || 50, + supportedModels: JSON.parse(accountData.supportedModels || '[]'), + userAgent: accountData.userAgent, + rateLimitDuration: Number.isNaN(parseInt(accountData.rateLimitDuration)) + ? 60 + : parseInt(accountData.rateLimitDuration), + isActive: accountData.isActive === 'true', + proxy: accountData.proxy ? JSON.parse(accountData.proxy) : null, + accountType: accountData.accountType || 'shared', + createdAt: accountData.createdAt, + lastUsedAt: accountData.lastUsedAt, + status: accountData.status || 'active', + errorMessage: accountData.errorMessage, + rateLimitInfo, + schedulable: accountData.schedulable !== 'false', // 默认为true,只有明确设置为false才不可调度 + // 额度管理相关 + dailyQuota: parseFloat(accountData.dailyQuota || '0'), + dailyUsage: parseFloat(accountData.dailyUsage || '0'), + lastResetDate: accountData.lastResetDate || '', + quotaResetTime: accountData.quotaResetTime || '00:00', + quotaStoppedAt: accountData.quotaStoppedAt || null, + expiresAt: accountData.expiresAt || null, + subscriptionExpiresAt: accountData.subscriptionExpiresAt || null + }) + } + } + + return accounts + } catch (error) { + logger.error('❌ Failed to get CCR accounts:', error) + throw error + } + } + + // 🔍 获取单个账户(内部使用,包含敏感信息) + async getAccount(accountId) { + const client = redis.getClientSafe() + logger.debug(`[DEBUG] Getting CCR account data for ID: ${accountId}`) + const accountData = await client.hgetall(`${this.ACCOUNT_KEY_PREFIX}${accountId}`) + + if (!accountData || Object.keys(accountData).length === 0) { + logger.debug(`[DEBUG] No CCR account data found for ID: ${accountId}`) + return null + } + + logger.debug(`[DEBUG] Raw CCR account data keys: ${Object.keys(accountData).join(', ')}`) + logger.debug(`[DEBUG] Raw supportedModels value: ${accountData.supportedModels}`) + + // 解密敏感字段(只解密apiKey,apiUrl不加密) + const decryptedKey = this._decryptSensitiveData(accountData.apiKey) + logger.debug( + `[DEBUG] URL exists: ${!!accountData.apiUrl}, Decrypted key exists: ${!!decryptedKey}` + ) + + accountData.apiKey = decryptedKey + + // 解析JSON字段 + const parsedModels = JSON.parse(accountData.supportedModels || '[]') + logger.debug(`[DEBUG] Parsed supportedModels: ${JSON.stringify(parsedModels)}`) + + accountData.supportedModels = parsedModels + accountData.priority = parseInt(accountData.priority) || 50 + { + const _parsedDuration = parseInt(accountData.rateLimitDuration) + accountData.rateLimitDuration = Number.isNaN(_parsedDuration) ? 60 : _parsedDuration + } + accountData.isActive = accountData.isActive === 'true' + accountData.schedulable = accountData.schedulable !== 'false' // 默认为true + + if (accountData.proxy) { + accountData.proxy = JSON.parse(accountData.proxy) + } + + logger.debug( + `[DEBUG] Final CCR account data - name: ${accountData.name}, hasApiUrl: ${!!accountData.apiUrl}, hasApiKey: ${!!accountData.apiKey}, supportedModels: ${JSON.stringify(accountData.supportedModels)}` + ) + + accountData.subscriptionExpiresAt = + accountData.subscriptionExpiresAt && accountData.subscriptionExpiresAt !== '' + ? accountData.subscriptionExpiresAt + : null + + return accountData + } + + // 📝 更新账户 + async updateAccount(accountId, updates) { + try { + const existingAccount = await this.getAccount(accountId) + if (!existingAccount) { + throw new Error('CCR Account not found') + } + + const client = redis.getClientSafe() + const updatedData = {} + + // 处理各个字段的更新 + logger.debug( + `[DEBUG] CCR update request received with fields: ${Object.keys(updates).join(', ')}` + ) + logger.debug(`[DEBUG] CCR Updates content: ${JSON.stringify(updates, null, 2)}`) + + if (updates.name !== undefined) { + updatedData.name = updates.name + } + if (updates.description !== undefined) { + updatedData.description = updates.description + } + if (updates.apiUrl !== undefined) { + updatedData.apiUrl = updates.apiUrl + } + if (updates.apiKey !== undefined) { + updatedData.apiKey = this._encryptSensitiveData(updates.apiKey) + } + if (updates.priority !== undefined) { + updatedData.priority = updates.priority.toString() + } + if (updates.supportedModels !== undefined) { + logger.debug(`[DEBUG] Updating supportedModels: ${JSON.stringify(updates.supportedModels)}`) + // 处理 supportedModels,确保向后兼容 + const processedModels = this._processModelMapping(updates.supportedModels) + updatedData.supportedModels = JSON.stringify(processedModels) + } + if (updates.userAgent !== undefined) { + updatedData.userAgent = updates.userAgent + } + if (updates.rateLimitDuration !== undefined) { + updatedData.rateLimitDuration = updates.rateLimitDuration.toString() + } + if (updates.proxy !== undefined) { + updatedData.proxy = updates.proxy ? JSON.stringify(updates.proxy) : '' + } + if (updates.isActive !== undefined) { + updatedData.isActive = updates.isActive.toString() + } + if (updates.schedulable !== undefined) { + updatedData.schedulable = updates.schedulable.toString() + } + if (updates.dailyQuota !== undefined) { + updatedData.dailyQuota = updates.dailyQuota.toString() + } + if (updates.quotaResetTime !== undefined) { + updatedData.quotaResetTime = updates.quotaResetTime + } + + if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) { + updatedData.subscriptionExpiresAt = normalizeSubscriptionExpiresAt( + updates.subscriptionExpiresAt + ) + } else if (Object.prototype.hasOwnProperty.call(updates, 'expiresAt')) { + updatedData.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(updates.expiresAt) + } + + await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updatedData) + + // 处理共享账户集合变更 + if (updates.accountType !== undefined) { + updatedData.accountType = updates.accountType + if (updates.accountType === 'shared') { + await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId) + } else { + await client.srem(this.SHARED_ACCOUNTS_KEY, accountId) + } + } + + logger.success(`📝 Updated CCR account: ${accountId}`) + return await this.getAccount(accountId) + } catch (error) { + logger.error(`❌ Failed to update CCR account ${accountId}:`, error) + throw error + } + } + + // 🗑️ 删除账户 + async deleteAccount(accountId) { + try { + const client = redis.getClientSafe() + + // 从共享账户集合中移除 + await client.srem(this.SHARED_ACCOUNTS_KEY, accountId) + + // 删除账户数据 + const result = await client.del(`${this.ACCOUNT_KEY_PREFIX}${accountId}`) + + if (result === 0) { + throw new Error('CCR Account not found or already deleted') + } + + logger.success(`🗑️ Deleted CCR account: ${accountId}`) + return { success: true } + } catch (error) { + logger.error(`❌ Failed to delete CCR account ${accountId}:`, error) + throw error + } + } + + // 🚫 标记账户为限流状态 + async markAccountRateLimited(accountId) { + try { + const client = redis.getClientSafe() + const account = await this.getAccount(accountId) + if (!account) { + throw new Error('CCR Account not found') + } + + // 如果限流时间设置为 0,表示不启用限流机制,直接返回 + if (account.rateLimitDuration === 0) { + logger.info( + `ℹ️ CCR account ${account.name} (${accountId}) has rate limiting disabled, skipping rate limit` + ) + return { success: true, skipped: true } + } + + const now = new Date().toISOString() + await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, { + status: 'rate_limited', + rateLimitedAt: now, + rateLimitStatus: 'active', + errorMessage: 'Rate limited by upstream service' + }) + + logger.warn(`⏱️ Marked CCR account as rate limited: ${account.name} (${accountId})`) + return { success: true, rateLimitedAt: now } + } catch (error) { + logger.error(`❌ Failed to mark CCR account as rate limited: ${accountId}`, error) + throw error + } + } + + // ✅ 移除账户限流状态 + async removeAccountRateLimit(accountId) { + try { + const client = redis.getClientSafe() + const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}` + + // 获取账户当前状态和额度信息 + const [, quotaStoppedAt] = await client.hmget(accountKey, 'status', 'quotaStoppedAt') + + // 删除限流相关字段 + await client.hdel(accountKey, 'rateLimitedAt', 'rateLimitStatus') + + // 根据不同情况决定是否恢复账户 + let newStatus = 'active' + let errorMessage = '' + + // 如果因额度问题停用,不要自动激活 + if (quotaStoppedAt) { + newStatus = 'quota_exceeded' + errorMessage = 'Account stopped due to quota exceeded' + logger.info( + `ℹ️ CCR account ${accountId} rate limit removed but remains stopped due to quota exceeded` + ) + } else { + logger.success(`✅ Removed rate limit for CCR account: ${accountId}`) + } + + await client.hmset(accountKey, { + status: newStatus, + errorMessage + }) + + return { success: true, newStatus } + } catch (error) { + logger.error(`❌ Failed to remove rate limit for CCR account: ${accountId}`, error) + throw error + } + } + + // 🔍 检查账户是否被限流 + async isAccountRateLimited(accountId) { + try { + const client = redis.getClientSafe() + const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}` + const [rateLimitedAt, rateLimitDuration] = await client.hmget( + accountKey, + 'rateLimitedAt', + 'rateLimitDuration' + ) + + if (rateLimitedAt) { + const limitTime = new Date(rateLimitedAt) + const duration = parseInt(rateLimitDuration) || 60 + const now = new Date() + const expireTime = new Date(limitTime.getTime() + duration * 60 * 1000) + + if (now < expireTime) { + return true + } else { + // 限流时间已过,自动移除限流状态 + await this.removeAccountRateLimit(accountId) + return false + } + } + return false + } catch (error) { + logger.error(`❌ Failed to check rate limit status for CCR account: ${accountId}`, error) + return false + } + } + + // 🔥 标记账户为过载状态 + async markAccountOverloaded(accountId) { + try { + const client = redis.getClientSafe() + const account = await this.getAccount(accountId) + if (!account) { + throw new Error('CCR Account not found') + } + + const now = new Date().toISOString() + await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, { + status: 'overloaded', + overloadedAt: now, + errorMessage: 'Account overloaded' + }) + + logger.warn(`🔥 Marked CCR account as overloaded: ${account.name} (${accountId})`) + return { success: true, overloadedAt: now } + } catch (error) { + logger.error(`❌ Failed to mark CCR account as overloaded: ${accountId}`, error) + throw error + } + } + + // ✅ 移除账户过载状态 + async removeAccountOverload(accountId) { + try { + const client = redis.getClientSafe() + const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}` + + // 删除过载相关字段 + await client.hdel(accountKey, 'overloadedAt') + + await client.hmset(accountKey, { + status: 'active', + errorMessage: '' + }) + + logger.success(`✅ Removed overload status for CCR account: ${accountId}`) + return { success: true } + } catch (error) { + logger.error(`❌ Failed to remove overload status for CCR account: ${accountId}`, error) + throw error + } + } + + // 🔍 检查账户是否过载 + async isAccountOverloaded(accountId) { + try { + const client = redis.getClientSafe() + const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}` + const status = await client.hget(accountKey, 'status') + return status === 'overloaded' + } catch (error) { + logger.error(`❌ Failed to check overload status for CCR account: ${accountId}`, error) + return false + } + } + + // 🚫 标记账户为未授权状态 + async markAccountUnauthorized(accountId) { + try { + const client = redis.getClientSafe() + const account = await this.getAccount(accountId) + if (!account) { + throw new Error('CCR Account not found') + } + + await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, { + status: 'unauthorized', + errorMessage: 'API key invalid or unauthorized' + }) + + logger.warn(`🚫 Marked CCR account as unauthorized: ${account.name} (${accountId})`) + return { success: true } + } catch (error) { + logger.error(`❌ Failed to mark CCR account as unauthorized: ${accountId}`, error) + throw error + } + } + + // 🔄 处理模型映射 + _processModelMapping(supportedModels) { + // 如果是空值,返回空对象(支持所有模型) + if (!supportedModels || (Array.isArray(supportedModels) && supportedModels.length === 0)) { + return {} + } + + // 如果已经是对象格式(新的映射表格式),直接返回 + if (typeof supportedModels === 'object' && !Array.isArray(supportedModels)) { + return supportedModels + } + + // 如果是数组格式(旧格式),转换为映射表 + if (Array.isArray(supportedModels)) { + const mapping = {} + supportedModels.forEach((model) => { + if (model && typeof model === 'string') { + mapping[model] = model // 默认映射:原模型名 -> 原模型名 + } + }) + return mapping + } + + return {} + } + + // 🔍 检查模型是否被支持 + isModelSupported(modelMapping, requestedModel) { + // 如果映射表为空,支持所有模型 + if (!modelMapping || Object.keys(modelMapping).length === 0) { + return true + } + // 检查请求的模型是否在映射表的键中 + return Object.prototype.hasOwnProperty.call(modelMapping, requestedModel) + } + + // 🔄 获取映射后的模型名称 + getMappedModel(modelMapping, requestedModel) { + // 如果映射表为空,返回原模型 + if (!modelMapping || Object.keys(modelMapping).length === 0) { + return requestedModel + } + + // 返回映射后的模型名,如果不存在映射则返回原模型名 + return modelMapping[requestedModel] || requestedModel + } + + // 🔐 加密敏感数据 + _encryptSensitiveData(data) { + if (!data) { + return '' + } + try { + const key = this._generateEncryptionKey() + const iv = crypto.randomBytes(16) + const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv) + let encrypted = cipher.update(data, 'utf8', 'hex') + encrypted += cipher.final('hex') + return `${iv.toString('hex')}:${encrypted}` + } catch (error) { + logger.error('❌ CCR encryption error:', error) + return data + } + } + + // 🔓 解密敏感数据 + _decryptSensitiveData(encryptedData) { + if (!encryptedData) { + return '' + } + + // 🎯 检查缓存 + const cacheKey = crypto.createHash('sha256').update(encryptedData).digest('hex') + const cached = this._decryptCache.get(cacheKey) + if (cached !== undefined) { + return cached + } + + try { + const parts = encryptedData.split(':') + if (parts.length === 2) { + const key = this._generateEncryptionKey() + const iv = Buffer.from(parts[0], 'hex') + const encrypted = parts[1] + const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv) + let decrypted = decipher.update(encrypted, 'hex', 'utf8') + decrypted += decipher.final('utf8') + + // 💾 存入缓存(5分钟过期) + this._decryptCache.set(cacheKey, decrypted, 5 * 60 * 1000) + + return decrypted + } else { + logger.error('❌ Invalid CCR encrypted data format') + return encryptedData + } + } catch (error) { + logger.error('❌ CCR decryption error:', error) + return encryptedData + } + } + + // 🔑 生成加密密钥 + _generateEncryptionKey() { + // 性能优化:缓存密钥派生结果,避免重复的 CPU 密集计算 + if (!this._encryptionKeyCache) { + this._encryptionKeyCache = crypto.scryptSync( + config.security.encryptionKey, + this.ENCRYPTION_SALT, + 32 + ) + } + return this._encryptionKeyCache + } + + // 🔍 获取限流状态信息 + _getRateLimitInfo(accountData) { + const { rateLimitedAt } = accountData + const rateLimitDuration = parseInt(accountData.rateLimitDuration) || 60 + + if (rateLimitedAt) { + const limitTime = new Date(rateLimitedAt) + const now = new Date() + const expireTime = new Date(limitTime.getTime() + rateLimitDuration * 60 * 1000) + const remainingMs = expireTime.getTime() - now.getTime() + + return { + isRateLimited: remainingMs > 0, + rateLimitedAt, + rateLimitExpireAt: expireTime.toISOString(), + remainingTimeMs: Math.max(0, remainingMs), + remainingTimeMinutes: Math.max(0, Math.ceil(remainingMs / (60 * 1000))) + } + } + + return { + isRateLimited: false, + rateLimitedAt: null, + rateLimitExpireAt: null, + remainingTimeMs: 0, + remainingTimeMinutes: 0 + } + } + + // 🔧 创建代理客户端 + _createProxyAgent(proxy) { + return ProxyHelper.createProxyAgent(proxy) + } + + // 💰 检查配额使用情况(可选实现) + async checkQuotaUsage(accountId) { + try { + const account = await this.getAccount(accountId) + if (!account) { + return false + } + + const dailyQuota = parseFloat(account.dailyQuota || '0') + // 如果未设置额度限制,则不限制 + if (dailyQuota <= 0) { + return false + } + + // 检查是否需要重置每日使用量 + const today = redis.getDateStringInTimezone() + if (account.lastResetDate !== today) { + await this.resetDailyUsage(accountId) + return false // 刚重置,不会超额 + } + + // 获取当日使用统计 + const usageStats = await this.getAccountUsageStats(accountId) + if (!usageStats) { + return false + } + + const dailyUsage = usageStats.dailyUsage || 0 + const isExceeded = dailyUsage >= dailyQuota + + if (isExceeded) { + // 标记账户因额度停用 + const client = redis.getClientSafe() + await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, { + status: 'quota_exceeded', + errorMessage: `Daily quota exceeded: $${dailyUsage.toFixed(2)} / $${dailyQuota.toFixed(2)}`, + quotaStoppedAt: new Date().toISOString() + }) + logger.warn( + `💰 CCR account ${account.name} (${accountId}) quota exceeded: $${dailyUsage.toFixed(2)} / $${dailyQuota.toFixed(2)}` + ) + + // 发送 Webhook 通知 + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: account.name || accountId, + platform: 'ccr', + status: 'quota_exceeded', + errorCode: 'QUOTA_EXCEEDED', + reason: `Daily quota exceeded: $${dailyUsage.toFixed(2)} / $${dailyQuota.toFixed(2)}`, + timestamp: new Date().toISOString() + }) + } catch (webhookError) { + logger.warn('Failed to send webhook notification for CCR quota exceeded:', webhookError) + } + } + + return isExceeded + } catch (error) { + logger.error(`❌ Failed to check quota usage for CCR account ${accountId}:`, error) + return false + } + } + + // 🔄 重置每日使用量(可选实现) + async resetDailyUsage(accountId) { + try { + const client = redis.getClientSafe() + await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, { + dailyUsage: '0', + lastResetDate: redis.getDateStringInTimezone(), + quotaStoppedAt: '' + }) + return { success: true } + } catch (error) { + logger.error(`❌ Failed to reset daily usage for CCR account: ${accountId}`, error) + throw error + } + } + + // 🚫 检查账户是否超额 + async isAccountQuotaExceeded(accountId) { + try { + const account = await this.getAccount(accountId) + if (!account) { + return false + } + + const dailyQuota = parseFloat(account.dailyQuota || '0') + // 如果未设置额度限制,则不限制 + if (dailyQuota <= 0) { + return false + } + + // 获取当日使用统计 + const usageStats = await this.getAccountUsageStats(accountId) + if (!usageStats) { + return false + } + + const dailyUsage = usageStats.dailyUsage || 0 + const isExceeded = dailyUsage >= dailyQuota + + if (isExceeded && !account.quotaStoppedAt) { + // 标记账户因额度停用 + const client = redis.getClientSafe() + await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, { + status: 'quota_exceeded', + errorMessage: `Daily quota exceeded: $${dailyUsage.toFixed(2)} / $${dailyQuota.toFixed(2)}`, + quotaStoppedAt: new Date().toISOString() + }) + logger.warn(`💰 CCR account ${account.name} (${accountId}) quota exceeded`) + } + + return isExceeded + } catch (error) { + logger.error(`❌ Failed to check quota for CCR account ${accountId}:`, error) + return false + } + } + + // 🔄 重置所有CCR账户的每日使用量 + async resetAllDailyUsage() { + try { + const accounts = await this.getAllAccounts() + const today = redis.getDateStringInTimezone() + let resetCount = 0 + + for (const account of accounts) { + if (account.lastResetDate !== today) { + await this.resetDailyUsage(account.id) + resetCount += 1 + } + } + + logger.success(`✅ Reset daily usage for ${resetCount} CCR accounts`) + return { success: true, resetCount } + } catch (error) { + logger.error('❌ Failed to reset all CCR daily usage:', error) + throw error + } + } + + // 📊 获取CCR账户使用统计(含每日费用) + async getAccountUsageStats(accountId) { + try { + // 使用统一的 Redis 统计 + const usageStats = await redis.getAccountUsageStats(accountId) + + // 叠加账户自身的额度配置 + const accountData = await this.getAccount(accountId) + if (!accountData) { + return null + } + + const dailyQuota = parseFloat(accountData.dailyQuota || '0') + const currentDailyCost = usageStats?.daily?.cost || 0 + + return { + dailyQuota, + dailyUsage: currentDailyCost, + remainingQuota: dailyQuota > 0 ? Math.max(0, dailyQuota - currentDailyCost) : null, + usagePercentage: dailyQuota > 0 ? (currentDailyCost / dailyQuota) * 100 : 0, + lastResetDate: accountData.lastResetDate, + quotaResetTime: accountData.quotaResetTime, + quotaStoppedAt: accountData.quotaStoppedAt, + isQuotaExceeded: dailyQuota > 0 && currentDailyCost >= dailyQuota, + fullUsageStats: usageStats + } + } catch (error) { + logger.error('❌ Failed to get CCR account usage stats:', error) + return null + } + } + + // 🔄 重置CCR账户所有异常状态 + async resetAccountStatus(accountId) { + try { + const accountData = await this.getAccount(accountId) + if (!accountData) { + throw new Error('Account not found') + } + + const client = redis.getClientSafe() + const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}` + + const updates = { + status: 'active', + errorMessage: '', + schedulable: 'true', + isActive: 'true' + } + + const fieldsToDelete = [ + 'rateLimitedAt', + 'rateLimitStatus', + 'unauthorizedAt', + 'unauthorizedCount', + 'overloadedAt', + 'overloadStatus', + 'blockedAt', + 'quotaStoppedAt' + ] + + await client.hset(accountKey, updates) + await client.hdel(accountKey, ...fieldsToDelete) + + logger.success(`✅ Reset all error status for CCR account ${accountId}`) + + // 异步发送 Webhook 通知(忽略错误) + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: accountData.name || accountId, + platform: 'ccr', + status: 'recovered', + errorCode: 'STATUS_RESET', + reason: 'Account status manually reset', + timestamp: new Date().toISOString() + }) + } catch (webhookError) { + logger.warn('Failed to send webhook notification for CCR status reset:', webhookError) + } + + return { success: true, accountId } + } catch (error) { + logger.error(`❌ Failed to reset CCR account status: ${accountId}`, error) + throw error + } + } +} + +module.exports = new CcrAccountService() diff --git a/src/services/ccrRelayService.js b/src/services/ccrRelayService.js new file mode 100644 index 0000000000000000000000000000000000000000..8fca408d26fb44454147a606e5a14067e6eabdfe --- /dev/null +++ b/src/services/ccrRelayService.js @@ -0,0 +1,641 @@ +const axios = require('axios') +const ccrAccountService = require('./ccrAccountService') +const logger = require('../utils/logger') +const config = require('../../config/config') +const { parseVendorPrefixedModel } = require('../utils/modelHelper') + +class CcrRelayService { + constructor() { + this.defaultUserAgent = 'claude-relay-service/1.0.0' + } + + // 🚀 转发请求到CCR API + async relayRequest( + requestBody, + apiKeyData, + clientRequest, + clientResponse, + clientHeaders, + accountId, + options = {} + ) { + let abortController = null + let account = null + + try { + // 获取账户信息 + account = await ccrAccountService.getAccount(accountId) + if (!account) { + throw new Error('CCR account not found') + } + + logger.info( + `📤 Processing CCR API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${account.name} (${accountId})` + ) + logger.debug(`🌐 Account API URL: ${account.apiUrl}`) + logger.debug(`🔍 Account supportedModels: ${JSON.stringify(account.supportedModels)}`) + logger.debug(`🔑 Account has apiKey: ${!!account.apiKey}`) + logger.debug(`📝 Request model: ${requestBody.model}`) + + // 处理模型前缀解析和映射 + const { baseModel } = parseVendorPrefixedModel(requestBody.model) + logger.debug(`🔄 Parsed base model: ${baseModel} from original: ${requestBody.model}`) + + let mappedModel = baseModel + if ( + account.supportedModels && + typeof account.supportedModels === 'object' && + !Array.isArray(account.supportedModels) + ) { + const newModel = ccrAccountService.getMappedModel(account.supportedModels, baseModel) + if (newModel !== baseModel) { + logger.info(`🔄 Mapping model from ${baseModel} to ${newModel}`) + mappedModel = newModel + } + } + + // 创建修改后的请求体,使用去前缀后的模型名 + const modifiedRequestBody = { + ...requestBody, + model: mappedModel + } + + // 创建代理agent + const proxyAgent = ccrAccountService._createProxyAgent(account.proxy) + + // 创建AbortController用于取消请求 + abortController = new AbortController() + + // 设置客户端断开监听器 + const handleClientDisconnect = () => { + logger.info('🔌 Client disconnected, aborting CCR request') + if (abortController && !abortController.signal.aborted) { + abortController.abort() + } + } + + // 监听客户端断开事件 + if (clientRequest) { + clientRequest.once('close', handleClientDisconnect) + } + if (clientResponse) { + clientResponse.once('close', handleClientDisconnect) + } + + // 构建完整的API URL + const cleanUrl = account.apiUrl.replace(/\/$/, '') // 移除末尾斜杠 + let apiEndpoint + + if (options.customPath) { + // 如果指定了自定义路径(如 count_tokens),使用它 + const baseUrl = cleanUrl.replace(/\/v1\/messages$/, '') // 移除已有的 /v1/messages + apiEndpoint = `${baseUrl}${options.customPath}` + } else { + // 默认使用 messages 端点 + apiEndpoint = cleanUrl.endsWith('/v1/messages') ? cleanUrl : `${cleanUrl}/v1/messages` + } + + logger.debug(`🎯 Final API endpoint: ${apiEndpoint}`) + logger.debug(`[DEBUG] Options passed to relayRequest: ${JSON.stringify(options)}`) + logger.debug(`[DEBUG] Client headers received: ${JSON.stringify(clientHeaders)}`) + + // 过滤客户端请求头 + const filteredHeaders = this._filterClientHeaders(clientHeaders) + logger.debug(`[DEBUG] Filtered client headers: ${JSON.stringify(filteredHeaders)}`) + + // 决定使用的 User-Agent:优先使用账户自定义的,否则透传客户端的,最后才使用默认值 + const userAgent = + account.userAgent || + clientHeaders?.['user-agent'] || + clientHeaders?.['User-Agent'] || + this.defaultUserAgent + + // 准备请求配置 + const requestConfig = { + method: 'POST', + url: apiEndpoint, + data: modifiedRequestBody, + headers: { + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + 'User-Agent': userAgent, + ...filteredHeaders + }, + httpsAgent: proxyAgent, + timeout: config.requestTimeout || 600000, + signal: abortController.signal, + validateStatus: () => true // 接受所有状态码 + } + + // 根据 API Key 格式选择认证方式 + if (account.apiKey && account.apiKey.startsWith('sk-ant-')) { + // Anthropic 官方 API Key 使用 x-api-key + requestConfig.headers['x-api-key'] = account.apiKey + logger.debug('[DEBUG] Using x-api-key authentication for sk-ant-* API key') + } else { + // 其他 API Key (包括CCR API Key) 使用 Authorization Bearer + requestConfig.headers['Authorization'] = `Bearer ${account.apiKey}` + logger.debug('[DEBUG] Using Authorization Bearer authentication') + } + + logger.debug( + `[DEBUG] Initial headers before beta: ${JSON.stringify(requestConfig.headers, null, 2)}` + ) + + // 添加beta header如果需要 + if (options.betaHeader) { + logger.debug(`[DEBUG] Adding beta header: ${options.betaHeader}`) + requestConfig.headers['anthropic-beta'] = options.betaHeader + } else { + logger.debug('[DEBUG] No beta header to add') + } + + // 发送请求 + logger.debug( + '📤 Sending request to CCR API with headers:', + JSON.stringify(requestConfig.headers, null, 2) + ) + const response = await axios(requestConfig) + + // 移除监听器(请求成功完成) + if (clientRequest) { + clientRequest.removeListener('close', handleClientDisconnect) + } + if (clientResponse) { + clientResponse.removeListener('close', handleClientDisconnect) + } + + logger.debug(`🔗 CCR API response: ${response.status}`) + logger.debug(`[DEBUG] Response headers: ${JSON.stringify(response.headers)}`) + logger.debug(`[DEBUG] Response data type: ${typeof response.data}`) + logger.debug( + `[DEBUG] Response data length: ${response.data ? (typeof response.data === 'string' ? response.data.length : JSON.stringify(response.data).length) : 0}` + ) + logger.debug( + `[DEBUG] Response data preview: ${typeof response.data === 'string' ? response.data.substring(0, 200) : JSON.stringify(response.data).substring(0, 200)}` + ) + + // 检查错误状态并相应处理 + if (response.status === 401) { + logger.warn(`🚫 Unauthorized error detected for CCR account ${accountId}`) + await ccrAccountService.markAccountUnauthorized(accountId) + } else if (response.status === 429) { + logger.warn(`🚫 Rate limit detected for CCR account ${accountId}`) + // 收到429先检查是否因为超过了手动配置的每日额度 + await ccrAccountService.checkQuotaUsage(accountId).catch((err) => { + logger.error('❌ Failed to check quota after 429 error:', err) + }) + + await ccrAccountService.markAccountRateLimited(accountId) + } else if (response.status === 529) { + logger.warn(`🚫 Overload error detected for CCR account ${accountId}`) + await ccrAccountService.markAccountOverloaded(accountId) + } else if (response.status === 200 || response.status === 201) { + // 如果请求成功,检查并移除错误状态 + const isRateLimited = await ccrAccountService.isAccountRateLimited(accountId) + if (isRateLimited) { + await ccrAccountService.removeAccountRateLimit(accountId) + } + const isOverloaded = await ccrAccountService.isAccountOverloaded(accountId) + if (isOverloaded) { + await ccrAccountService.removeAccountOverload(accountId) + } + } + + // 更新最后使用时间 + await this._updateLastUsedTime(accountId) + + const responseBody = + typeof response.data === 'string' ? response.data : JSON.stringify(response.data) + logger.debug(`[DEBUG] Final response body to return: ${responseBody}`) + + return { + statusCode: response.status, + headers: response.headers, + body: responseBody, + accountId + } + } catch (error) { + // 处理特定错误 + if (error.name === 'AbortError' || error.code === 'ECONNABORTED') { + logger.info('Request aborted due to client disconnect') + throw new Error('Client disconnected') + } + + logger.error( + `❌ CCR relay request failed (Account: ${account?.name || accountId}):`, + error.message + ) + + throw error + } + } + + // 🌊 处理流式响应 + async relayStreamRequestWithUsageCapture( + requestBody, + apiKeyData, + responseStream, + clientHeaders, + usageCallback, + accountId, + streamTransformer = null, + options = {} + ) { + let account = null + try { + // 获取账户信息 + account = await ccrAccountService.getAccount(accountId) + if (!account) { + throw new Error('CCR account not found') + } + + logger.info( + `📡 Processing streaming CCR API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${account.name} (${accountId})` + ) + logger.debug(`🌐 Account API URL: ${account.apiUrl}`) + + // 处理模型前缀解析和映射 + const { baseModel } = parseVendorPrefixedModel(requestBody.model) + logger.debug(`🔄 Parsed base model: ${baseModel} from original: ${requestBody.model}`) + + let mappedModel = baseModel + if ( + account.supportedModels && + typeof account.supportedModels === 'object' && + !Array.isArray(account.supportedModels) + ) { + const newModel = ccrAccountService.getMappedModel(account.supportedModels, baseModel) + if (newModel !== baseModel) { + logger.info(`🔄 [Stream] Mapping model from ${baseModel} to ${newModel}`) + mappedModel = newModel + } + } + + // 创建修改后的请求体,使用去前缀后的模型名 + const modifiedRequestBody = { + ...requestBody, + model: mappedModel + } + + // 创建代理agent + const proxyAgent = ccrAccountService._createProxyAgent(account.proxy) + + // 发送流式请求 + await this._makeCcrStreamRequest( + modifiedRequestBody, + account, + proxyAgent, + clientHeaders, + responseStream, + accountId, + usageCallback, + streamTransformer, + options + ) + + // 更新最后使用时间 + await this._updateLastUsedTime(accountId) + } catch (error) { + logger.error(`❌ CCR stream relay failed (Account: ${account?.name || accountId}):`, error) + throw error + } + } + + // 🌊 发送流式请求到CCR API + async _makeCcrStreamRequest( + body, + account, + proxyAgent, + clientHeaders, + responseStream, + accountId, + usageCallback, + streamTransformer = null, + requestOptions = {} + ) { + return new Promise((resolve, reject) => { + let aborted = false + + // 构建完整的API URL + const cleanUrl = account.apiUrl.replace(/\/$/, '') // 移除末尾斜杠 + const apiEndpoint = cleanUrl.endsWith('/v1/messages') ? cleanUrl : `${cleanUrl}/v1/messages` + + logger.debug(`🎯 Final API endpoint for stream: ${apiEndpoint}`) + + // 过滤客户端请求头 + const filteredHeaders = this._filterClientHeaders(clientHeaders) + logger.debug(`[DEBUG] Filtered client headers: ${JSON.stringify(filteredHeaders)}`) + + // 决定使用的 User-Agent:优先使用账户自定义的,否则透传客户端的,最后才使用默认值 + const userAgent = + account.userAgent || + clientHeaders?.['user-agent'] || + clientHeaders?.['User-Agent'] || + this.defaultUserAgent + + // 准备请求配置 + const requestConfig = { + method: 'POST', + url: apiEndpoint, + data: body, + headers: { + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + 'User-Agent': userAgent, + ...filteredHeaders + }, + httpsAgent: proxyAgent, + timeout: config.requestTimeout || 600000, + responseType: 'stream', + validateStatus: () => true // 接受所有状态码 + } + + // 根据 API Key 格式选择认证方式 + if (account.apiKey && account.apiKey.startsWith('sk-ant-')) { + // Anthropic 官方 API Key 使用 x-api-key + requestConfig.headers['x-api-key'] = account.apiKey + logger.debug('[DEBUG] Using x-api-key authentication for sk-ant-* API key') + } else { + // 其他 API Key (包括CCR API Key) 使用 Authorization Bearer + requestConfig.headers['Authorization'] = `Bearer ${account.apiKey}` + logger.debug('[DEBUG] Using Authorization Bearer authentication') + } + + // 添加beta header如果需要 + if (requestOptions.betaHeader) { + requestConfig.headers['anthropic-beta'] = requestOptions.betaHeader + } + + // 发送请求 + const request = axios(requestConfig) + + request + .then((response) => { + logger.debug(`🌊 CCR stream response status: ${response.status}`) + + // 错误响应处理 + if (response.status !== 200) { + logger.error( + `❌ CCR API returned error status: ${response.status} | Account: ${account?.name || accountId}` + ) + + if (response.status === 401) { + ccrAccountService.markAccountUnauthorized(accountId) + } else if (response.status === 429) { + ccrAccountService.markAccountRateLimited(accountId) + // 检查是否因为超过每日额度 + ccrAccountService.checkQuotaUsage(accountId).catch((err) => { + logger.error('❌ Failed to check quota after 429 error:', err) + }) + } else if (response.status === 529) { + ccrAccountService.markAccountOverloaded(accountId) + } + + // 设置错误响应的状态码和响应头 + if (!responseStream.headersSent) { + const errorHeaders = { + 'Content-Type': response.headers['content-type'] || 'application/json', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive' + } + // 避免 Transfer-Encoding 冲突,让 Express 自动处理 + delete errorHeaders['Transfer-Encoding'] + delete errorHeaders['Content-Length'] + responseStream.writeHead(response.status, errorHeaders) + } + + // 直接透传错误数据,不进行包装 + response.data.on('data', (chunk) => { + if (!responseStream.destroyed) { + responseStream.write(chunk) + } + }) + + response.data.on('end', () => { + if (!responseStream.destroyed) { + responseStream.end() + } + resolve() // 不抛出异常,正常完成流处理 + }) + return + } + + // 成功响应,检查并移除错误状态 + ccrAccountService.isAccountRateLimited(accountId).then((isRateLimited) => { + if (isRateLimited) { + ccrAccountService.removeAccountRateLimit(accountId) + } + }) + ccrAccountService.isAccountOverloaded(accountId).then((isOverloaded) => { + if (isOverloaded) { + ccrAccountService.removeAccountOverload(accountId) + } + }) + + // 设置响应头 + if (!responseStream.headersSent) { + const headers = { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Cache-Control' + } + responseStream.writeHead(200, headers) + } + + // 处理流数据和使用统计收集 + let rawBuffer = '' + const collectedUsage = {} + + response.data.on('data', (chunk) => { + if (aborted || responseStream.destroyed) { + return + } + + try { + const chunkStr = chunk.toString('utf8') + rawBuffer += chunkStr + + // 按行分割处理 SSE 数据 + const lines = rawBuffer.split('\n') + rawBuffer = lines.pop() // 保留最后一个可能不完整的行 + + for (const line of lines) { + if (line.trim()) { + // 解析 SSE 数据并收集使用统计 + const usageData = this._parseSSELineForUsage(line) + if (usageData) { + Object.assign(collectedUsage, usageData) + } + + // 应用流转换器(如果提供) + let outputLine = line + if (streamTransformer && typeof streamTransformer === 'function') { + outputLine = streamTransformer(line) + } + + // 写入到响应流 + if (outputLine && !responseStream.destroyed) { + responseStream.write(`${outputLine}\n`) + } + } else { + // 空行也需要传递 + if (!responseStream.destroyed) { + responseStream.write('\n') + } + } + } + } catch (err) { + logger.error('❌ Error processing SSE chunk:', err) + } + }) + + response.data.on('end', () => { + if (!responseStream.destroyed) { + responseStream.end() + } + + // 如果收集到使用统计数据,调用回调 + if (usageCallback && Object.keys(collectedUsage).length > 0) { + try { + logger.debug(`📊 Collected usage data: ${JSON.stringify(collectedUsage)}`) + // 在 usage 回调中包含模型信息 + usageCallback({ ...collectedUsage, accountId, model: body.model }) + } catch (err) { + logger.error('❌ Error in usage callback:', err) + } + } + + resolve() + }) + + response.data.on('error', (err) => { + logger.error('❌ Stream data error:', err) + if (!responseStream.destroyed) { + responseStream.end() + } + reject(err) + }) + + // 客户端断开处理 + responseStream.on('close', () => { + logger.info('🔌 Client disconnected from CCR stream') + aborted = true + if (response.data && typeof response.data.destroy === 'function') { + response.data.destroy() + } + }) + + responseStream.on('error', (err) => { + logger.error('❌ Response stream error:', err) + aborted = true + }) + }) + .catch((error) => { + if (!responseStream.headersSent) { + responseStream.writeHead(500, { 'Content-Type': 'application/json' }) + } + + const errorResponse = { + error: { + type: 'internal_error', + message: 'CCR API request failed' + } + } + + if (!responseStream.destroyed) { + responseStream.write(`data: ${JSON.stringify(errorResponse)}\n\n`) + responseStream.end() + } + + reject(error) + }) + }) + } + + // 📊 解析SSE行以提取使用统计信息 + _parseSSELineForUsage(line) { + try { + if (line.startsWith('data: ')) { + const data = line.substring(6).trim() + if (data === '[DONE]') { + return null + } + + const jsonData = JSON.parse(data) + + // 检查是否包含使用统计信息 + if (jsonData.usage) { + return { + input_tokens: jsonData.usage.input_tokens || 0, + output_tokens: jsonData.usage.output_tokens || 0, + cache_creation_input_tokens: jsonData.usage.cache_creation_input_tokens || 0, + cache_read_input_tokens: jsonData.usage.cache_read_input_tokens || 0, + // 支持 ephemeral cache 字段 + cache_creation_input_tokens_ephemeral_5m: + jsonData.usage.cache_creation_input_tokens_ephemeral_5m || 0, + cache_creation_input_tokens_ephemeral_1h: + jsonData.usage.cache_creation_input_tokens_ephemeral_1h || 0 + } + } + + // 检查 message_delta 事件中的使用统计 + if (jsonData.type === 'message_delta' && jsonData.delta && jsonData.delta.usage) { + return { + input_tokens: jsonData.delta.usage.input_tokens || 0, + output_tokens: jsonData.delta.usage.output_tokens || 0, + cache_creation_input_tokens: jsonData.delta.usage.cache_creation_input_tokens || 0, + cache_read_input_tokens: jsonData.delta.usage.cache_read_input_tokens || 0, + cache_creation_input_tokens_ephemeral_5m: + jsonData.delta.usage.cache_creation_input_tokens_ephemeral_5m || 0, + cache_creation_input_tokens_ephemeral_1h: + jsonData.delta.usage.cache_creation_input_tokens_ephemeral_1h || 0 + } + } + } + } catch (err) { + // 忽略解析错误,不是所有行都包含 JSON + } + + return null + } + + // 🔍 过滤客户端请求头 + _filterClientHeaders(clientHeaders) { + if (!clientHeaders) { + return {} + } + + const filteredHeaders = {} + const allowedHeaders = [ + 'accept-language', + 'anthropic-beta', + 'anthropic-dangerous-direct-browser-access' + ] + + // 只保留允许的头部信息 + for (const [key, value] of Object.entries(clientHeaders)) { + const lowerKey = key.toLowerCase() + if (allowedHeaders.includes(lowerKey)) { + filteredHeaders[key] = value + } + } + + return filteredHeaders + } + + // ⏰ 更新账户最后使用时间 + async _updateLastUsedTime(accountId) { + try { + const redis = require('../models/redis') + const client = redis.getClientSafe() + await client.hset(`ccr_account:${accountId}`, 'lastUsedAt', new Date().toISOString()) + } catch (error) { + logger.error(`❌ Failed to update last used time for CCR account ${accountId}:`, error) + } + } +} + +module.exports = new CcrRelayService() diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js new file mode 100644 index 0000000000000000000000000000000000000000..66f4736911573819393dfdf4fdd2dc074ee2b6f4 --- /dev/null +++ b/src/services/claudeAccountService.js @@ -0,0 +1,3048 @@ +const { v4: uuidv4 } = require('uuid') +const crypto = require('crypto') +const ProxyHelper = require('../utils/proxyHelper') +const axios = require('axios') +const redis = require('../models/redis') +const config = require('../../config/config') +const logger = require('../utils/logger') +const { maskToken } = require('../utils/tokenMask') +const { + logRefreshStart, + logRefreshSuccess, + logRefreshError, + logTokenUsage, + logRefreshSkipped +} = require('../utils/tokenRefreshLogger') +const tokenRefreshService = require('./tokenRefreshService') +const LRUCache = require('../utils/lruCache') +const { formatDateWithTimezone, getISOStringWithTimezone } = require('../utils/dateHelper') + +class ClaudeAccountService { + constructor() { + this.claudeApiUrl = 'https://console.anthropic.com/v1/oauth/token' + this.claudeOauthClientId = '9d1c250a-e61b-44d9-88ed-5944d1962f5e' + let maxWarnings = parseInt(process.env.CLAUDE_5H_WARNING_MAX_NOTIFICATIONS || '', 10) + + if (Number.isNaN(maxWarnings) && config.claude?.fiveHourWarning) { + maxWarnings = parseInt(config.claude.fiveHourWarning.maxNotificationsPerWindow, 10) + } + + if (Number.isNaN(maxWarnings) || maxWarnings < 1) { + maxWarnings = 1 + } + + this.maxFiveHourWarningsPerWindow = Math.min(maxWarnings, 10) + + // 加密相关常量 + this.ENCRYPTION_ALGORITHM = 'aes-256-cbc' + this.ENCRYPTION_SALT = 'salt' + + // 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算 + // scryptSync 是 CPU 密集型操作,缓存可以减少 95%+ 的 CPU 占用 + this._encryptionKeyCache = null + + // 🔄 解密结果缓存,提高解密性能 + this._decryptCache = new LRUCache(500) + + // 🧹 定期清理缓存(每10分钟) + setInterval( + () => { + this._decryptCache.cleanup() + logger.info('🧹 Claude decrypt cache cleanup completed', this._decryptCache.getStats()) + }, + 10 * 60 * 1000 + ) + } + + // 🏢 创建Claude账户 + async createAccount(options = {}) { + const { + name = 'Unnamed Account', + description = '', + email = '', + password = '', + refreshToken = '', + claudeAiOauth = null, // Claude标准格式的OAuth数据 + proxy = null, // { type: 'socks5', host: 'localhost', port: 1080, username: '', password: '' } + isActive = true, + accountType = 'shared', // 'dedicated' or 'shared' + platform = 'claude', + priority = 50, // 调度优先级 (1-100,数字越小优先级越高) + schedulable = true, // 是否可被调度 + subscriptionInfo = null, // 手动设置的订阅信息 + autoStopOnWarning = false, // 5小时使用量接近限制时自动停止调度 + useUnifiedUserAgent = false, // 是否使用统一Claude Code版本的User-Agent + useUnifiedClientId = false, // 是否使用统一的客户端标识 + unifiedClientId = '', // 统一的客户端标识 + expiresAt = null // 账户订阅到期时间 + } = options + + const accountId = uuidv4() + + let accountData + + if (claudeAiOauth) { + // 使用Claude标准格式的OAuth数据 + accountData = { + id: accountId, + name, + description, + email: this._encryptSensitiveData(email), + password: this._encryptSensitiveData(password), + claudeAiOauth: this._encryptSensitiveData(JSON.stringify(claudeAiOauth)), + accessToken: this._encryptSensitiveData(claudeAiOauth.accessToken), + refreshToken: this._encryptSensitiveData(claudeAiOauth.refreshToken), + expiresAt: claudeAiOauth.expiresAt.toString(), + scopes: claudeAiOauth.scopes.join(' '), + proxy: proxy ? JSON.stringify(proxy) : '', + isActive: isActive.toString(), + accountType, // 账号类型:'dedicated' 或 'shared' 或 'group' + platform, + priority: priority.toString(), // 调度优先级 + createdAt: new Date().toISOString(), + lastUsedAt: '', + lastRefreshAt: '', + status: 'active', // 有OAuth数据的账户直接设为active + errorMessage: '', + schedulable: schedulable.toString(), // 是否可被调度 + autoStopOnWarning: autoStopOnWarning.toString(), // 5小时使用量接近限制时自动停止调度 + useUnifiedUserAgent: useUnifiedUserAgent.toString(), // 是否使用统一Claude Code版本的User-Agent + useUnifiedClientId: useUnifiedClientId.toString(), // 是否使用统一的客户端标识 + unifiedClientId: unifiedClientId || '', // 统一的客户端标识 + // 优先使用手动设置的订阅信息,否则使用OAuth数据中的,否则默认为空 + subscriptionInfo: subscriptionInfo + ? JSON.stringify(subscriptionInfo) + : claudeAiOauth.subscriptionInfo + ? JSON.stringify(claudeAiOauth.subscriptionInfo) + : '', + // 账户订阅到期时间 + subscriptionExpiresAt: expiresAt || '' + } + } else { + // 兼容旧格式 + accountData = { + id: accountId, + name, + description, + email: this._encryptSensitiveData(email), + password: this._encryptSensitiveData(password), + refreshToken: this._encryptSensitiveData(refreshToken), + accessToken: '', + expiresAt: '', + scopes: '', + proxy: proxy ? JSON.stringify(proxy) : '', + isActive: isActive.toString(), + accountType, // 账号类型:'dedicated' 或 'shared' 或 'group' + platform, + priority: priority.toString(), // 调度优先级 + createdAt: new Date().toISOString(), + lastUsedAt: '', + lastRefreshAt: '', + status: 'created', // created, active, expired, error + errorMessage: '', + schedulable: schedulable.toString(), // 是否可被调度 + autoStopOnWarning: autoStopOnWarning.toString(), // 5小时使用量接近限制时自动停止调度 + useUnifiedUserAgent: useUnifiedUserAgent.toString(), // 是否使用统一Claude Code版本的User-Agent + // 手动设置的订阅信息 + subscriptionInfo: subscriptionInfo ? JSON.stringify(subscriptionInfo) : '', + // 账户订阅到期时间 + subscriptionExpiresAt: expiresAt || '' + } + } + + await redis.setClaudeAccount(accountId, accountData) + + logger.success(`🏢 Created Claude account: ${name} (${accountId})`) + + // 如果有 OAuth 数据和 accessToken,且包含 user:profile 权限,尝试获取 profile 信息 + if (claudeAiOauth && claudeAiOauth.accessToken) { + // 检查是否有 user:profile 权限(标准 OAuth 有,Setup Token 没有) + const hasProfileScope = claudeAiOauth.scopes && claudeAiOauth.scopes.includes('user:profile') + + if (hasProfileScope) { + try { + const agent = this._createProxyAgent(proxy) + await this.fetchAndUpdateAccountProfile(accountId, claudeAiOauth.accessToken, agent) + logger.info(`📊 Successfully fetched profile info for new account: ${name}`) + } catch (profileError) { + logger.warn(`⚠️ Failed to fetch profile info for new account: ${profileError.message}`) + } + } else { + logger.info(`⏩ Skipping profile fetch for account ${name} (no user:profile scope)`) + } + } + + return { + id: accountId, + name, + description, + email, + isActive, + proxy, + accountType, + platform, + priority, + status: accountData.status, + createdAt: accountData.createdAt, + expiresAt: accountData.expiresAt, + subscriptionExpiresAt: + accountData.subscriptionExpiresAt && accountData.subscriptionExpiresAt !== '' + ? accountData.subscriptionExpiresAt + : null, + scopes: claudeAiOauth ? claudeAiOauth.scopes : [], + autoStopOnWarning, + useUnifiedUserAgent, + useUnifiedClientId, + unifiedClientId + } + } + + // 🔄 刷新Claude账户token + async refreshAccountToken(accountId) { + let lockAcquired = false + + try { + const accountData = await redis.getClaudeAccount(accountId) + + if (!accountData || Object.keys(accountData).length === 0) { + throw new Error('Account not found') + } + + const refreshToken = this._decryptSensitiveData(accountData.refreshToken) + + if (!refreshToken) { + throw new Error('No refresh token available - manual token update required') + } + + // 尝试获取分布式锁 + lockAcquired = await tokenRefreshService.acquireRefreshLock(accountId, 'claude') + + if (!lockAcquired) { + // 如果无法获取锁,说明另一个进程正在刷新 + logger.info( + `🔒 Token refresh already in progress for account: ${accountData.name} (${accountId})` + ) + logRefreshSkipped(accountId, accountData.name, 'claude', 'already_locked') + + // 等待一段时间后返回,期望其他进程已完成刷新 + await new Promise((resolve) => setTimeout(resolve, 2000)) + + // 重新获取账户数据(可能已被其他进程刷新) + const updatedData = await redis.getClaudeAccount(accountId) + if (updatedData && updatedData.accessToken) { + const accessToken = this._decryptSensitiveData(updatedData.accessToken) + return { + success: true, + accessToken, + expiresAt: updatedData.expiresAt + } + } + + throw new Error('Token refresh in progress by another process') + } + + // 记录开始刷新 + logRefreshStart(accountId, accountData.name, 'claude', 'manual_refresh') + logger.info(`🔄 Starting token refresh for account: ${accountData.name} (${accountId})`) + + // 创建代理agent + const agent = this._createProxyAgent(accountData.proxy) + + const response = await axios.post( + this.claudeApiUrl, + { + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: this.claudeOauthClientId + }, + { + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/plain, */*', + 'User-Agent': 'claude-cli/1.0.56 (external, cli)', + 'Accept-Language': 'en-US,en;q=0.9', + Referer: 'https://claude.ai/', + Origin: 'https://claude.ai' + }, + httpsAgent: agent, + timeout: 30000 + } + ) + + if (response.status === 200) { + // 记录完整的响应数据到专门的认证详细日志 + logger.authDetail('Token refresh response', response.data) + + // 记录简化版本到主日志 + logger.info('📊 Token refresh response (analyzing for subscription info):', { + status: response.status, + hasData: !!response.data, + dataKeys: response.data ? Object.keys(response.data) : [] + }) + + const { access_token, refresh_token, expires_in } = response.data + + // 检查是否有套餐信息 + if ( + response.data.subscription || + response.data.plan || + response.data.tier || + response.data.account_type + ) { + const subscriptionInfo = { + subscription: response.data.subscription, + plan: response.data.plan, + tier: response.data.tier, + accountType: response.data.account_type, + features: response.data.features, + limits: response.data.limits + } + logger.info('🎯 Found subscription info in refresh response:', subscriptionInfo) + + // 将套餐信息存储在账户数据中 + accountData.subscriptionInfo = JSON.stringify(subscriptionInfo) + } + + // 更新账户数据 + accountData.accessToken = this._encryptSensitiveData(access_token) + accountData.refreshToken = this._encryptSensitiveData(refresh_token) + accountData.expiresAt = (Date.now() + expires_in * 1000).toString() + accountData.lastRefreshAt = new Date().toISOString() + accountData.status = 'active' + accountData.errorMessage = '' + + await redis.setClaudeAccount(accountId, accountData) + + // 刷新成功后,如果有 user:profile 权限,尝试获取账号 profile 信息 + // 检查账户的 scopes 是否包含 user:profile(标准 OAuth 有,Setup Token 没有) + const hasProfileScope = accountData.scopes && accountData.scopes.includes('user:profile') + + if (hasProfileScope) { + try { + await this.fetchAndUpdateAccountProfile(accountId, access_token, agent) + } catch (profileError) { + logger.warn(`⚠️ Failed to fetch profile info after refresh: ${profileError.message}`) + } + } else { + logger.debug( + `⏩ Skipping profile fetch after refresh for account ${accountId} (no user:profile scope)` + ) + } + + // 记录刷新成功 + logRefreshSuccess(accountId, accountData.name, 'claude', { + accessToken: access_token, + refreshToken: refresh_token, + expiresAt: accountData.expiresAt, + scopes: accountData.scopes + }) + + logger.success( + `🔄 Refreshed token for account: ${accountData.name} (${accountId}) - Access Token: ${maskToken(access_token)}` + ) + + return { + success: true, + accessToken: access_token, + expiresAt: accountData.expiresAt + } + } else { + throw new Error(`Token refresh failed with status: ${response.status}`) + } + } catch (error) { + // 记录刷新失败 + const accountData = await redis.getClaudeAccount(accountId) + if (accountData) { + logRefreshError(accountId, accountData.name, 'claude', error) + accountData.status = 'error' + accountData.errorMessage = error.message + await redis.setClaudeAccount(accountId, accountData) + + // 发送Webhook通知 + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: accountData.name, + platform: 'claude-oauth', + status: 'error', + errorCode: 'CLAUDE_OAUTH_ERROR', + reason: `Token refresh failed: ${error.message}` + }) + } catch (webhookError) { + logger.error('Failed to send webhook notification:', webhookError) + } + } + + logger.error(`❌ Failed to refresh token for account ${accountId}:`, error) + + throw error + } finally { + // 释放锁 + if (lockAcquired) { + await tokenRefreshService.releaseRefreshLock(accountId, 'claude') + } + } + } + + // 🔍 获取账户信息 + async getAccount(accountId) { + try { + const accountData = await redis.getClaudeAccount(accountId) + + if (!accountData || Object.keys(accountData).length === 0) { + return null + } + + return accountData + } catch (error) { + logger.error('❌ Failed to get Claude account:', error) + return null + } + } + + // 🎯 获取有效的访问token + async getValidAccessToken(accountId) { + try { + const accountData = await redis.getClaudeAccount(accountId) + + if (!accountData || Object.keys(accountData).length === 0) { + throw new Error('Account not found') + } + + if (accountData.isActive !== 'true') { + throw new Error('Account is disabled') + } + + // 检查token是否过期 + const expiresAt = parseInt(accountData.expiresAt) + const now = Date.now() + const isExpired = !expiresAt || now >= expiresAt - 60000 // 60秒提前刷新 + + // 记录token使用情况 + logTokenUsage(accountId, accountData.name, 'claude', accountData.expiresAt, isExpired) + + if (isExpired) { + logger.info(`🔄 Token expired/expiring for account ${accountId}, attempting refresh...`) + try { + const refreshResult = await this.refreshAccountToken(accountId) + return refreshResult.accessToken + } catch (refreshError) { + logger.warn(`⚠️ Token refresh failed for account ${accountId}: ${refreshError.message}`) + // 如果刷新失败,仍然尝试使用当前token(可能是手动添加的长期有效token) + const currentToken = this._decryptSensitiveData(accountData.accessToken) + if (currentToken) { + logger.info(`🔄 Using current token for account ${accountId} (refresh failed)`) + return currentToken + } + throw refreshError + } + } + + const accessToken = this._decryptSensitiveData(accountData.accessToken) + + if (!accessToken) { + throw new Error('No access token available') + } + + // 更新最后使用时间和会话窗口 + accountData.lastUsedAt = new Date().toISOString() + await this.updateSessionWindow(accountId, accountData) + await redis.setClaudeAccount(accountId, accountData) + + return accessToken + } catch (error) { + logger.error(`❌ Failed to get valid access token for account ${accountId}:`, error) + throw error + } + } + + // 📋 获取所有Claude账户 + async getAllAccounts() { + try { + const accounts = await redis.getAllClaudeAccounts() + + // 处理返回数据,移除敏感信息并添加限流状态和会话窗口信息 + const processedAccounts = await Promise.all( + accounts.map(async (account) => { + // 获取限流状态信息 + const rateLimitInfo = await this.getAccountRateLimitInfo(account.id) + + // 获取会话窗口信息 + const sessionWindowInfo = await this.getSessionWindowInfo(account.id) + + // 构建 Claude Usage 快照(从 Redis 读取) + const claudeUsage = this.buildClaudeUsageSnapshot(account) + + // 判断授权类型:检查 scopes 是否包含 OAuth 相关权限 + const scopes = account.scopes && account.scopes.trim() ? account.scopes.split(' ') : [] + const isOAuth = scopes.includes('user:profile') && scopes.includes('user:inference') + const authType = isOAuth ? 'oauth' : 'setup-token' + + return { + id: account.id, + name: account.name, + description: account.description, + email: account.email ? this._maskEmail(this._decryptSensitiveData(account.email)) : '', + isActive: account.isActive === 'true', + proxy: account.proxy ? JSON.parse(account.proxy) : null, + status: account.status, + errorMessage: account.errorMessage, + accountType: account.accountType || 'shared', // 兼容旧数据,默认为共享 + priority: parseInt(account.priority) || 50, // 兼容旧数据,默认优先级50 + platform: account.platform || 'claude', // 添加平台标识,用于前端区分 + authType, // OAuth 或 Setup Token + createdAt: account.createdAt, + lastUsedAt: account.lastUsedAt, + lastRefreshAt: account.lastRefreshAt, + expiresAt: account.expiresAt || null, + subscriptionExpiresAt: + account.subscriptionExpiresAt && account.subscriptionExpiresAt !== '' + ? account.subscriptionExpiresAt + : null, + // 添加 scopes 字段用于判断认证方式 + // 处理空字符串的情况,避免返回 [''] + scopes: account.scopes && account.scopes.trim() ? account.scopes.split(' ') : [], + // 添加 refreshToken 是否存在的标记(不返回实际值) + hasRefreshToken: !!account.refreshToken, + // 添加套餐信息(如果存在) + subscriptionInfo: account.subscriptionInfo + ? JSON.parse(account.subscriptionInfo) + : null, + // 添加限流状态信息 + rateLimitStatus: rateLimitInfo + ? { + isRateLimited: rateLimitInfo.isRateLimited, + rateLimitedAt: rateLimitInfo.rateLimitedAt, + minutesRemaining: rateLimitInfo.minutesRemaining + } + : null, + // 添加会话窗口信息 + sessionWindow: sessionWindowInfo || { + hasActiveWindow: false, + windowStart: null, + windowEnd: null, + progress: 0, + remainingTime: null, + lastRequestTime: null + }, + // 添加 Claude Usage 信息(三窗口) + claudeUsage: claudeUsage || null, + // 添加调度状态 + schedulable: account.schedulable !== 'false', // 默认为true,兼容历史数据 + // 添加自动停止调度设置 + autoStopOnWarning: account.autoStopOnWarning === 'true', // 默认为false + // 添加5小时自动停止状态 + fiveHourAutoStopped: account.fiveHourAutoStopped === 'true', + fiveHourStoppedAt: account.fiveHourStoppedAt || null, + // 添加统一User-Agent设置 + useUnifiedUserAgent: account.useUnifiedUserAgent === 'true', // 默认为false + // 添加统一客户端标识设置 + useUnifiedClientId: account.useUnifiedClientId === 'true', // 默认为false + unifiedClientId: account.unifiedClientId || '', // 统一的客户端标识 + // 添加停止原因 + stoppedReason: account.stoppedReason || null + } + }) + ) + + return processedAccounts + } catch (error) { + logger.error('❌ Failed to get Claude accounts:', error) + throw error + } + } + + // 📋 获取单个账号的概要信息(用于前端展示会话窗口等状态) + async getAccountOverview(accountId) { + try { + const accountData = await redis.getClaudeAccount(accountId) + + if (!accountData || Object.keys(accountData).length === 0) { + return null + } + + const [sessionWindowInfo, rateLimitInfo] = await Promise.all([ + this.getSessionWindowInfo(accountId), + this.getAccountRateLimitInfo(accountId) + ]) + + const sessionWindow = sessionWindowInfo || { + hasActiveWindow: false, + windowStart: null, + windowEnd: null, + progress: 0, + remainingTime: null, + lastRequestTime: accountData.lastRequestTime || null, + sessionWindowStatus: accountData.sessionWindowStatus || null + } + + const rateLimitStatus = rateLimitInfo + ? { + isRateLimited: !!rateLimitInfo.isRateLimited, + rateLimitedAt: rateLimitInfo.rateLimitedAt || null, + minutesRemaining: rateLimitInfo.minutesRemaining || 0, + rateLimitEndAt: rateLimitInfo.rateLimitEndAt || null + } + : { + isRateLimited: false, + rateLimitedAt: null, + minutesRemaining: 0, + rateLimitEndAt: null + } + + return { + id: accountData.id, + accountType: accountData.accountType || 'shared', + platform: accountData.platform || 'claude', + isActive: accountData.isActive === 'true', + schedulable: accountData.schedulable !== 'false', + sessionWindow, + rateLimitStatus + } + } catch (error) { + logger.error(`❌ Failed to build Claude account overview for ${accountId}:`, error) + return null + } + } + + // 📝 更新Claude账户 + async updateAccount(accountId, updates) { + try { + const accountData = await redis.getClaudeAccount(accountId) + + if (!accountData || Object.keys(accountData).length === 0) { + throw new Error('Account not found') + } + + const allowedUpdates = [ + 'name', + 'description', + 'email', + 'password', + 'refreshToken', + 'proxy', + 'isActive', + 'claudeAiOauth', + 'accountType', + 'priority', + 'schedulable', + 'subscriptionInfo', + 'autoStopOnWarning', + 'useUnifiedUserAgent', + 'useUnifiedClientId', + 'unifiedClientId', + 'subscriptionExpiresAt' + ] + const updatedData = { ...accountData } + let shouldClearAutoStopFields = false + + // 检查是否新增了 refresh token + const oldRefreshToken = this._decryptSensitiveData(accountData.refreshToken) + + for (const [field, value] of Object.entries(updates)) { + if (allowedUpdates.includes(field)) { + if (['email', 'password', 'refreshToken'].includes(field)) { + updatedData[field] = this._encryptSensitiveData(value) + } else if (field === 'proxy') { + updatedData[field] = value ? JSON.stringify(value) : '' + } else if (field === 'priority') { + updatedData[field] = value.toString() + } else if (field === 'subscriptionInfo') { + // 处理订阅信息更新 + updatedData[field] = typeof value === 'string' ? value : JSON.stringify(value) + } else if (field === 'subscriptionExpiresAt') { + // 处理订阅到期时间,允许 null 值(永不过期) + updatedData[field] = value ? value.toString() : '' + } else if (field === 'claudeAiOauth') { + // 更新 Claude AI OAuth 数据 + if (value) { + updatedData.claudeAiOauth = this._encryptSensitiveData(JSON.stringify(value)) + updatedData.accessToken = this._encryptSensitiveData(value.accessToken) + updatedData.refreshToken = this._encryptSensitiveData(value.refreshToken) + updatedData.expiresAt = value.expiresAt.toString() + updatedData.scopes = value.scopes.join(' ') + updatedData.status = 'active' + updatedData.errorMessage = '' + updatedData.lastRefreshAt = new Date().toISOString() + } + } else { + updatedData[field] = value !== null && value !== undefined ? value.toString() : '' + } + } + } + + // 如果新增了 refresh token(之前没有,现在有了),更新过期时间为10分钟 + if (updates.refreshToken && !oldRefreshToken && updates.refreshToken.trim()) { + const newExpiresAt = Date.now() + 10 * 60 * 1000 // 10分钟 + updatedData.expiresAt = newExpiresAt.toString() + logger.info( + `🔄 New refresh token added for account ${accountId}, setting expiry to 10 minutes` + ) + } + + // 如果通过 claudeAiOauth 更新,也要检查是否新增了 refresh token + if (updates.claudeAiOauth && updates.claudeAiOauth.refreshToken && !oldRefreshToken) { + // 如果 expiresAt 设置的时间过长(超过1小时),调整为10分钟 + const providedExpiry = parseInt(updates.claudeAiOauth.expiresAt) + const now = Date.now() + const oneHour = 60 * 60 * 1000 + + if (providedExpiry - now > oneHour) { + const newExpiresAt = now + 10 * 60 * 1000 // 10分钟 + updatedData.expiresAt = newExpiresAt.toString() + logger.info( + `🔄 Adjusted expiry time to 10 minutes for account ${accountId} with refresh token` + ) + } + } + + updatedData.updatedAt = new Date().toISOString() + + // 如果是手动修改调度状态,清除所有自动停止相关的字段 + if (Object.prototype.hasOwnProperty.call(updates, 'schedulable')) { + // 清除所有自动停止的标记,防止自动恢复 + delete updatedData.rateLimitAutoStopped + delete updatedData.fiveHourAutoStopped + delete updatedData.fiveHourStoppedAt + delete updatedData.tempErrorAutoStopped + // 兼容旧的标记(逐步迁移) + delete updatedData.autoStoppedAt + delete updatedData.stoppedReason + shouldClearAutoStopFields = true + + await this._clearFiveHourWarningMetadata(accountId, updatedData) + + // 如果是手动启用调度,记录日志 + if (updates.schedulable === true || updates.schedulable === 'true') { + logger.info(`✅ Manually enabled scheduling for account ${accountId}`) + } else { + logger.info(`⛔ Manually disabled scheduling for account ${accountId}`) + } + } + + // 检查是否手动禁用了账号,如果是则发送webhook通知 + if (updates.isActive === 'false' && accountData.isActive === 'true') { + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: updatedData.name || 'Unknown Account', + platform: 'claude-oauth', + status: 'disabled', + errorCode: 'CLAUDE_OAUTH_MANUALLY_DISABLED', + reason: 'Account manually disabled by administrator' + }) + } catch (webhookError) { + logger.error( + 'Failed to send webhook notification for manual account disable:', + webhookError + ) + } + } + + await redis.setClaudeAccount(accountId, updatedData) + + if (shouldClearAutoStopFields) { + const fieldsToRemove = [ + 'rateLimitAutoStopped', + 'fiveHourAutoStopped', + 'fiveHourStoppedAt', + 'tempErrorAutoStopped', + 'autoStoppedAt', + 'stoppedReason' + ] + await this._removeAccountFields(accountId, fieldsToRemove, 'manual_schedule_update') + } + + logger.success(`📝 Updated Claude account: ${accountId}`) + + return { success: true } + } catch (error) { + logger.error('❌ Failed to update Claude account:', error) + throw error + } + } + + // 🗑️ 删除Claude账户 + async deleteAccount(accountId) { + try { + // 首先从所有分组中移除此账户 + const accountGroupService = require('./accountGroupService') + await accountGroupService.removeAccountFromAllGroups(accountId) + + const result = await redis.deleteClaudeAccount(accountId) + + if (result === 0) { + throw new Error('Account not found') + } + + logger.success(`🗑️ Deleted Claude account: ${accountId}`) + + return { success: true } + } catch (error) { + logger.error('❌ Failed to delete Claude account:', error) + throw error + } + } + + /** + * 检查账户是否未过期 + * @param {Object} account - 账户对象 + * @returns {boolean} - 如果未设置过期时间或未过期返回 true + */ + isAccountNotExpired(account) { + if (!account.subscriptionExpiresAt) { + return true // 未设置过期时间,视为永不过期 + } + + const expiryDate = new Date(account.subscriptionExpiresAt) + const now = new Date() + + if (expiryDate <= now) { + logger.debug( + `⏰ Account ${account.name} (${account.id}) expired at ${account.subscriptionExpiresAt}` + ) + return false + } + + return true + } + + // 🎯 智能选择可用账户(支持sticky会话和模型过滤) + async selectAvailableAccount(sessionHash = null, modelName = null) { + try { + const accounts = await redis.getAllClaudeAccounts() + + let activeAccounts = accounts.filter( + (account) => + account.isActive === 'true' && + account.status !== 'error' && + account.schedulable !== 'false' && + this.isAccountNotExpired(account) + ) + + // 如果请求的是 Opus 模型,过滤掉 Pro 和 Free 账号 + if (modelName && modelName.toLowerCase().includes('opus')) { + activeAccounts = activeAccounts.filter((account) => { + // 检查账号的订阅信息 + if (account.subscriptionInfo) { + try { + const info = JSON.parse(account.subscriptionInfo) + // Pro 和 Free 账号不支持 Opus + if (info.hasClaudePro === true && info.hasClaudeMax !== true) { + return false // Claude Pro 不支持 Opus + } + if (info.accountType === 'claude_pro' || info.accountType === 'claude_free') { + return false // 明确标记为 Pro 或 Free 的账号不支持 + } + } catch (e) { + // 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max) + return true + } + } + // 没有订阅信息的账号,默认当作支持(兼容旧数据) + return true + }) + + if (activeAccounts.length === 0) { + throw new Error('No Claude accounts available that support Opus model') + } + } + + if (activeAccounts.length === 0) { + throw new Error('No active Claude accounts available') + } + + // 如果有会话哈希,检查是否有已映射的账户 + if (sessionHash) { + const mappedAccountId = await redis.getSessionAccountMapping(sessionHash) + if (mappedAccountId) { + // 验证映射的账户是否仍然可用 + const mappedAccount = activeAccounts.find((acc) => acc.id === mappedAccountId) + if (mappedAccount) { + // 🚀 智能会话续期:剩余时间少于14天时自动续期到15天 + await redis.extendSessionAccountMappingTTL(sessionHash) + logger.info( + `🎯 Using sticky session account: ${mappedAccount.name} (${mappedAccountId}) for session ${sessionHash}` + ) + return mappedAccountId + } else { + logger.warn( + `⚠️ Mapped account ${mappedAccountId} is no longer available, selecting new account` + ) + // 清理无效的映射 + await redis.deleteSessionAccountMapping(sessionHash) + } + } + } + + // 如果没有映射或映射无效,选择新账户 + // 优先选择最久未使用的账户(负载均衡) + const sortedAccounts = activeAccounts.sort((a, b) => { + const aLastUsed = new Date(a.lastUsedAt || 0).getTime() + const bLastUsed = new Date(b.lastUsedAt || 0).getTime() + return aLastUsed - bLastUsed // 最久未使用的优先 + }) + + const selectedAccountId = sortedAccounts[0].id + + // 如果有会话哈希,建立新的映射 + if (sessionHash) { + // 从配置获取TTL(小时),转换为秒 + const ttlSeconds = (config.session?.stickyTtlHours || 1) * 60 * 60 + await redis.setSessionAccountMapping(sessionHash, selectedAccountId, ttlSeconds) + logger.info( + `🎯 Created new sticky session mapping: ${sortedAccounts[0].name} (${selectedAccountId}) for session ${sessionHash}` + ) + } + + return selectedAccountId + } catch (error) { + logger.error('❌ Failed to select available account:', error) + throw error + } + } + + // 🎯 基于API Key选择账户(支持专属绑定、共享池和模型过滤) + async selectAccountForApiKey(apiKeyData, sessionHash = null, modelName = null) { + try { + // 如果API Key绑定了专属账户,优先使用 + if (apiKeyData.claudeAccountId) { + const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId) + if ( + boundAccount && + boundAccount.isActive === 'true' && + boundAccount.status !== 'error' && + boundAccount.schedulable !== 'false' && + this.isAccountNotExpired(boundAccount) + ) { + logger.info( + `🎯 Using bound dedicated account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}` + ) + return apiKeyData.claudeAccountId + } else { + logger.warn( + `⚠️ Bound account ${apiKeyData.claudeAccountId} is not available, falling back to shared pool` + ) + } + } + + // 如果没有绑定账户或绑定账户不可用,从共享池选择 + const accounts = await redis.getAllClaudeAccounts() + + let sharedAccounts = accounts.filter( + (account) => + account.isActive === 'true' && + account.status !== 'error' && + account.schedulable !== 'false' && + (account.accountType === 'shared' || !account.accountType) && // 兼容旧数据 + this.isAccountNotExpired(account) + ) + + // 如果请求的是 Opus 模型,过滤掉 Pro 和 Free 账号 + if (modelName && modelName.toLowerCase().includes('opus')) { + sharedAccounts = sharedAccounts.filter((account) => { + // 检查账号的订阅信息 + if (account.subscriptionInfo) { + try { + const info = JSON.parse(account.subscriptionInfo) + // Pro 和 Free 账号不支持 Opus + if (info.hasClaudePro === true && info.hasClaudeMax !== true) { + return false // Claude Pro 不支持 Opus + } + if (info.accountType === 'claude_pro' || info.accountType === 'claude_free') { + return false // 明确标记为 Pro 或 Free 的账号不支持 + } + } catch (e) { + // 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max) + return true + } + } + // 没有订阅信息的账号,默认当作支持(兼容旧数据) + return true + }) + + if (sharedAccounts.length === 0) { + throw new Error('No shared Claude accounts available that support Opus model') + } + } + + if (sharedAccounts.length === 0) { + throw new Error('No active shared Claude accounts available') + } + + // 如果有会话哈希,检查是否有已映射的账户 + if (sessionHash) { + const mappedAccountId = await redis.getSessionAccountMapping(sessionHash) + if (mappedAccountId) { + // 验证映射的账户是否仍然在共享池中且可用 + const mappedAccount = sharedAccounts.find((acc) => acc.id === mappedAccountId) + if (mappedAccount) { + // 如果映射的账户被限流了,删除映射并重新选择 + const isRateLimited = await this.isAccountRateLimited(mappedAccountId) + if (isRateLimited) { + logger.warn( + `⚠️ Mapped account ${mappedAccountId} is rate limited, selecting new account` + ) + await redis.deleteSessionAccountMapping(sessionHash) + } else { + // 🚀 智能会话续期:剩余时间少于14天时自动续期到15天 + await redis.extendSessionAccountMappingTTL(sessionHash) + logger.info( + `🎯 Using sticky session shared account: ${mappedAccount.name} (${mappedAccountId}) for session ${sessionHash}` + ) + return mappedAccountId + } + } else { + logger.warn( + `⚠️ Mapped shared account ${mappedAccountId} is no longer available, selecting new account` + ) + // 清理无效的映射 + await redis.deleteSessionAccountMapping(sessionHash) + } + } + } + + // 将账户分为限流和非限流两组 + const nonRateLimitedAccounts = [] + const rateLimitedAccounts = [] + + for (const account of sharedAccounts) { + const isRateLimited = await this.isAccountRateLimited(account.id) + if (isRateLimited) { + const rateLimitInfo = await this.getAccountRateLimitInfo(account.id) + account._rateLimitInfo = rateLimitInfo // 临时存储限流信息 + rateLimitedAccounts.push(account) + } else { + nonRateLimitedAccounts.push(account) + } + } + + // 优先从非限流账户中选择 + let candidateAccounts = nonRateLimitedAccounts + + // 如果没有非限流账户,则从限流账户中选择(按限流时间排序,最早限流的优先) + if (candidateAccounts.length === 0) { + logger.warn('⚠️ All shared accounts are rate limited, selecting from rate limited pool') + candidateAccounts = rateLimitedAccounts.sort((a, b) => { + const aRateLimitedAt = new Date(a._rateLimitInfo.rateLimitedAt).getTime() + const bRateLimitedAt = new Date(b._rateLimitInfo.rateLimitedAt).getTime() + return aRateLimitedAt - bRateLimitedAt // 最早限流的优先 + }) + } else { + // 非限流账户按最后使用时间排序(最久未使用的优先) + candidateAccounts = candidateAccounts.sort((a, b) => { + const aLastUsed = new Date(a.lastUsedAt || 0).getTime() + const bLastUsed = new Date(b.lastUsedAt || 0).getTime() + return aLastUsed - bLastUsed // 最久未使用的优先 + }) + } + + if (candidateAccounts.length === 0) { + throw new Error('No available shared Claude accounts') + } + + const selectedAccountId = candidateAccounts[0].id + + // 如果有会话哈希,建立新的映射 + if (sessionHash) { + // 从配置获取TTL(小时),转换为秒 + const ttlSeconds = (config.session?.stickyTtlHours || 1) * 60 * 60 + await redis.setSessionAccountMapping(sessionHash, selectedAccountId, ttlSeconds) + logger.info( + `🎯 Created new sticky session mapping for shared account: ${candidateAccounts[0].name} (${selectedAccountId}) for session ${sessionHash}` + ) + } + + logger.info( + `🎯 Selected shared account: ${candidateAccounts[0].name} (${selectedAccountId}) for API key ${apiKeyData.name}` + ) + return selectedAccountId + } catch (error) { + logger.error('❌ Failed to select account for API key:', error) + throw error + } + } + + // 🌐 创建代理agent(使用统一的代理工具) + _createProxyAgent(proxyConfig) { + const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig) + if (proxyAgent) { + logger.info( + `🌐 Using proxy for Claude request: ${ProxyHelper.getProxyDescription(proxyConfig)}` + ) + } else if (proxyConfig) { + logger.debug('🌐 Failed to create proxy agent for Claude') + } else { + logger.debug('🌐 No proxy configured for Claude request') + } + return proxyAgent + } + + // 🔐 加密敏感数据 + _encryptSensitiveData(data) { + if (!data) { + return '' + } + + try { + const key = this._generateEncryptionKey() + const iv = crypto.randomBytes(16) + + const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv) + let encrypted = cipher.update(data, 'utf8', 'hex') + encrypted += cipher.final('hex') + + // 将IV和加密数据一起返回,用:分隔 + return `${iv.toString('hex')}:${encrypted}` + } catch (error) { + logger.error('❌ Encryption error:', error) + return data + } + } + + // 🔓 解密敏感数据 + _decryptSensitiveData(encryptedData) { + if (!encryptedData) { + return '' + } + + // 🎯 检查缓存 + const cacheKey = crypto.createHash('sha256').update(encryptedData).digest('hex') + const cached = this._decryptCache.get(cacheKey) + if (cached !== undefined) { + return cached + } + + try { + let decrypted = '' + + // 检查是否是新格式(包含IV) + if (encryptedData.includes(':')) { + // 新格式:iv:encryptedData + const parts = encryptedData.split(':') + if (parts.length === 2) { + const key = this._generateEncryptionKey() + const iv = Buffer.from(parts[0], 'hex') + const encrypted = parts[1] + + const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv) + decrypted = decipher.update(encrypted, 'hex', 'utf8') + decrypted += decipher.final('utf8') + + // 💾 存入缓存(5分钟过期) + this._decryptCache.set(cacheKey, decrypted, 5 * 60 * 1000) + + // 📊 定期打印缓存统计 + if ((this._decryptCache.hits + this._decryptCache.misses) % 1000 === 0) { + this._decryptCache.printStats() + } + + return decrypted + } + } + + // 旧格式或格式错误,尝试旧方式解密(向后兼容) + // 注意:在新版本Node.js中这将失败,但我们会捕获错误 + try { + const decipher = crypto.createDecipher('aes-256-cbc', config.security.encryptionKey) + decrypted = decipher.update(encryptedData, 'hex', 'utf8') + decrypted += decipher.final('utf8') + + // 💾 旧格式也存入缓存 + this._decryptCache.set(cacheKey, decrypted, 5 * 60 * 1000) + + return decrypted + } catch (oldError) { + // 如果旧方式也失败,返回原数据 + logger.warn('⚠️ Could not decrypt data, returning as-is:', oldError.message) + return encryptedData + } + } catch (error) { + logger.error('❌ Decryption error:', error) + return encryptedData + } + } + + // 🔑 生成加密密钥(辅助方法) + _generateEncryptionKey() { + // 性能优化:缓存密钥派生结果,避免重复的 CPU 密集计算 + // scryptSync 是故意设计为慢速的密钥派生函数(防暴力破解) + // 但在高并发场景下,每次都重新计算会导致 CPU 100% 占用 + if (!this._encryptionKeyCache) { + // 只在第一次调用时计算,后续使用缓存 + // 由于输入参数固定,派生结果永远相同,不影响数据兼容性 + this._encryptionKeyCache = crypto.scryptSync( + config.security.encryptionKey, + this.ENCRYPTION_SALT, + 32 + ) + logger.info('🔑 Encryption key derived and cached for performance optimization') + } + return this._encryptionKeyCache + } + + // 🎭 掩码邮箱地址 + _maskEmail(email) { + if (!email || !email.includes('@')) { + return email + } + + const [username, domain] = email.split('@') + const maskedUsername = + username.length > 2 + ? `${username.slice(0, 2)}***${username.slice(-1)}` + : `${username.slice(0, 1)}***` + + return `${maskedUsername}@${domain}` + } + + // 🔢 安全转换为数字或null + _toNumberOrNull(value) { + if (value === undefined || value === null || value === '') { + return null + } + + const num = Number(value) + return Number.isFinite(num) ? num : null + } + + // 🧹 清理错误账户 + async cleanupErrorAccounts() { + try { + const accounts = await redis.getAllClaudeAccounts() + let cleanedCount = 0 + + for (const account of accounts) { + if (account.status === 'error' && account.lastRefreshAt) { + const lastRefresh = new Date(account.lastRefreshAt) + const now = new Date() + const hoursSinceLastRefresh = (now - lastRefresh) / (1000 * 60 * 60) + + // 如果错误状态超过24小时,尝试重新激活 + if (hoursSinceLastRefresh > 24) { + account.status = 'created' + account.errorMessage = '' + await redis.setClaudeAccount(account.id, account) + cleanedCount++ + } + } + } + + if (cleanedCount > 0) { + logger.success(`🧹 Reset ${cleanedCount} error accounts`) + } + + return cleanedCount + } catch (error) { + logger.error('❌ Failed to cleanup error accounts:', error) + return 0 + } + } + + // 🚫 标记账号为限流状态 + async markAccountRateLimited(accountId, sessionHash = null, rateLimitResetTimestamp = null) { + try { + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { + throw new Error('Account not found') + } + + // 设置限流状态和时间 + const updatedAccountData = { ...accountData } + updatedAccountData.rateLimitedAt = new Date().toISOString() + updatedAccountData.rateLimitStatus = 'limited' + // 限流时停止调度,与 OpenAI 账号保持一致 + updatedAccountData.schedulable = 'false' + // 使用独立的限流自动停止标记,避免与其他自动停止冲突 + updatedAccountData.rateLimitAutoStopped = 'true' + + // 如果提供了准确的限流重置时间戳(来自API响应头) + if (rateLimitResetTimestamp) { + // 将Unix时间戳(秒)转换为毫秒并创建Date对象 + const resetTime = new Date(rateLimitResetTimestamp * 1000) + updatedAccountData.rateLimitEndAt = resetTime.toISOString() + + // 计算当前会话窗口的开始时间(重置时间减去5小时) + const windowStartTime = new Date(resetTime.getTime() - 5 * 60 * 60 * 1000) + updatedAccountData.sessionWindowStart = windowStartTime.toISOString() + updatedAccountData.sessionWindowEnd = resetTime.toISOString() + + const now = new Date() + const minutesUntilEnd = Math.ceil((resetTime - now) / (1000 * 60)) + logger.warn( + `🚫 Account marked as rate limited with accurate reset time: ${accountData.name} (${accountId}) - ${minutesUntilEnd} minutes remaining until ${resetTime.toISOString()}` + ) + } else { + // 获取或创建会话窗口(预估方式) + const windowData = await this.updateSessionWindow(accountId, updatedAccountData) + Object.assign(updatedAccountData, windowData) + + // 限流结束时间 = 会话窗口结束时间 + if (updatedAccountData.sessionWindowEnd) { + updatedAccountData.rateLimitEndAt = updatedAccountData.sessionWindowEnd + const windowEnd = new Date(updatedAccountData.sessionWindowEnd) + const now = new Date() + const minutesUntilEnd = Math.ceil((windowEnd - now) / (1000 * 60)) + logger.warn( + `🚫 Account marked as rate limited until estimated session window ends: ${accountData.name} (${accountId}) - ${minutesUntilEnd} minutes remaining` + ) + } else { + // 如果没有会话窗口,使用默认1小时(兼容旧逻辑) + const oneHourLater = new Date(Date.now() + 60 * 60 * 1000) + updatedAccountData.rateLimitEndAt = oneHourLater.toISOString() + logger.warn( + `🚫 Account marked as rate limited (1 hour default): ${accountData.name} (${accountId})` + ) + } + } + + await redis.setClaudeAccount(accountId, updatedAccountData) + + // 如果有会话哈希,删除粘性会话映射 + if (sessionHash) { + await redis.deleteSessionAccountMapping(sessionHash) + logger.info(`🗑️ Deleted sticky session mapping for rate limited account: ${accountId}`) + } + + // 发送Webhook通知 + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: accountData.name || 'Claude Account', + platform: 'claude-oauth', + status: 'error', + errorCode: 'CLAUDE_OAUTH_RATE_LIMITED', + reason: `Account rate limited (429 error). ${rateLimitResetTimestamp ? `Reset at: ${formatDateWithTimezone(rateLimitResetTimestamp)}` : 'Estimated reset in 1-5 hours'}`, + timestamp: getISOStringWithTimezone(new Date()) + }) + } catch (webhookError) { + logger.error('Failed to send rate limit webhook notification:', webhookError) + } + + return { success: true } + } catch (error) { + logger.error(`❌ Failed to mark account as rate limited: ${accountId}`, error) + throw error + } + } + + // 🚫 标记账号的 Opus 限流状态(不影响其他模型调度) + async markAccountOpusRateLimited(accountId, rateLimitResetTimestamp = null) { + try { + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { + throw new Error('Account not found') + } + + const updatedAccountData = { ...accountData } + const now = new Date() + updatedAccountData.opusRateLimitedAt = now.toISOString() + + if (rateLimitResetTimestamp) { + const resetTime = new Date(rateLimitResetTimestamp * 1000) + updatedAccountData.opusRateLimitEndAt = resetTime.toISOString() + logger.warn( + `🚫 Account ${accountData.name} (${accountId}) reached Opus weekly cap, resets at ${resetTime.toISOString()}` + ) + } else { + // 如果缺少准确时间戳,保留现有值但记录警告,便于后续人工干预 + logger.warn( + `⚠️ Account ${accountData.name} (${accountId}) reported Opus limit without reset timestamp` + ) + } + + await redis.setClaudeAccount(accountId, updatedAccountData) + return { success: true } + } catch (error) { + logger.error(`❌ Failed to mark Opus rate limit for account: ${accountId}`, error) + throw error + } + } + + // ✅ 清除账号的 Opus 限流状态 + async clearAccountOpusRateLimit(accountId) { + try { + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { + return { success: true } + } + + const updatedAccountData = { ...accountData } + delete updatedAccountData.opusRateLimitedAt + delete updatedAccountData.opusRateLimitEndAt + + await redis.setClaudeAccount(accountId, updatedAccountData) + + const redisKey = `claude:account:${accountId}` + if (redis.client && typeof redis.client.hdel === 'function') { + await redis.client.hdel(redisKey, 'opusRateLimitedAt', 'opusRateLimitEndAt') + } + + logger.info(`✅ Cleared Opus rate limit state for account ${accountId}`) + return { success: true } + } catch (error) { + logger.error(`❌ Failed to clear Opus rate limit for account: ${accountId}`, error) + throw error + } + } + + // 🔍 检查账号是否处于 Opus 限流状态(自动清理过期标记) + async isAccountOpusRateLimited(accountId) { + try { + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { + return false + } + + if (!accountData.opusRateLimitEndAt) { + return false + } + + const resetTime = new Date(accountData.opusRateLimitEndAt) + if (Number.isNaN(resetTime.getTime())) { + await this.clearAccountOpusRateLimit(accountId) + return false + } + + const now = new Date() + if (now >= resetTime) { + await this.clearAccountOpusRateLimit(accountId) + return false + } + + return true + } catch (error) { + logger.error(`❌ Failed to check Opus rate limit status for account: ${accountId}`, error) + return false + } + } + + // ♻️ 检查并清理已过期的 Opus 限流标记 + async clearExpiredOpusRateLimit(accountId) { + try { + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { + return { success: true } + } + + if (!accountData.opusRateLimitEndAt) { + return { success: true } + } + + const resetTime = new Date(accountData.opusRateLimitEndAt) + if (Number.isNaN(resetTime.getTime()) || new Date() >= resetTime) { + await this.clearAccountOpusRateLimit(accountId) + } + + return { success: true } + } catch (error) { + logger.error(`❌ Failed to clear expired Opus rate limit for account: ${accountId}`, error) + throw error + } + } + + // ✅ 移除账号的限流状态 + async removeAccountRateLimit(accountId) { + try { + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { + throw new Error('Account not found') + } + + const accountKey = `claude:account:${accountId}` + + // 清除限流状态 + const redisKey = `claude:account:${accountId}` + await redis.client.hdel(redisKey, 'rateLimitedAt', 'rateLimitStatus', 'rateLimitEndAt') + delete accountData.rateLimitedAt + delete accountData.rateLimitStatus + delete accountData.rateLimitEndAt // 清除限流结束时间 + + const hadAutoStop = accountData.rateLimitAutoStopped === 'true' + + // 只恢复因限流而自动停止的账户 + if (hadAutoStop && accountData.schedulable === 'false') { + accountData.schedulable = 'true' + logger.info(`✅ Auto-resuming scheduling for account ${accountId} after rate limit cleared`) + logger.info( + `📊 Account ${accountId} state after recovery: schedulable=${accountData.schedulable}` + ) + } else { + logger.info( + `ℹ️ Account ${accountId} did not need auto-resume: autoStopped=${accountData.rateLimitAutoStopped}, schedulable=${accountData.schedulable}` + ) + } + + if (hadAutoStop) { + await redis.client.hdel(redisKey, 'rateLimitAutoStopped') + delete accountData.rateLimitAutoStopped + } + await redis.setClaudeAccount(accountId, accountData) + + // 显式删除Redis中的限流字段,避免旧标记阻止账号恢复调度 + await redis.client.hdel( + accountKey, + 'rateLimitedAt', + 'rateLimitStatus', + 'rateLimitEndAt', + 'rateLimitAutoStopped' + ) + + logger.success(`✅ Rate limit removed for account: ${accountData.name} (${accountId})`) + + return { success: true } + } catch (error) { + logger.error(`❌ Failed to remove rate limit for account: ${accountId}`, error) + throw error + } + } + + // 🔍 检查账号是否处于限流状态 + async isAccountRateLimited(accountId) { + try { + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { + return false + } + + const now = new Date() + + // 检查是否有限流状态(包括字段缺失但有自动停止标记的情况) + if ( + (accountData.rateLimitStatus === 'limited' && accountData.rateLimitedAt) || + (accountData.rateLimitAutoStopped === 'true' && accountData.rateLimitEndAt) + ) { + // 优先使用 rateLimitEndAt(基于会话窗口) + if (accountData.rateLimitEndAt) { + const rateLimitEndAt = new Date(accountData.rateLimitEndAt) + + // 如果当前时间超过限流结束时间,自动解除 + if (now >= rateLimitEndAt) { + await this.removeAccountRateLimit(accountId) + return false + } + + return true + } else if (accountData.rateLimitedAt) { + // 兼容旧数据:使用1小时限流 + const rateLimitedAt = new Date(accountData.rateLimitedAt) + const hoursSinceRateLimit = (now - rateLimitedAt) / (1000 * 60 * 60) + + // 如果限流超过1小时,自动解除 + if (hoursSinceRateLimit >= 1) { + await this.removeAccountRateLimit(accountId) + return false + } + + return true + } + } + + return false + } catch (error) { + logger.error(`❌ Failed to check rate limit status for account: ${accountId}`, error) + return false + } + } + + // 📊 获取账号的限流信息 + async getAccountRateLimitInfo(accountId) { + try { + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { + return null + } + + if (accountData.rateLimitStatus === 'limited' && accountData.rateLimitedAt) { + const rateLimitedAt = new Date(accountData.rateLimitedAt) + const now = new Date() + const minutesSinceRateLimit = Math.floor((now - rateLimitedAt) / (1000 * 60)) + + let minutesRemaining + let rateLimitEndAt + + // 优先使用 rateLimitEndAt(基于会话窗口) + if (accountData.rateLimitEndAt) { + ;({ rateLimitEndAt } = accountData) + const endTime = new Date(accountData.rateLimitEndAt) + minutesRemaining = Math.max(0, Math.ceil((endTime - now) / (1000 * 60))) + } else { + // 兼容旧数据:使用1小时限流 + minutesRemaining = Math.max(0, 60 - minutesSinceRateLimit) + // 计算预期的结束时间 + const endTime = new Date(rateLimitedAt.getTime() + 60 * 60 * 1000) + rateLimitEndAt = endTime.toISOString() + } + + return { + isRateLimited: minutesRemaining > 0, + rateLimitedAt: accountData.rateLimitedAt, + minutesSinceRateLimit, + minutesRemaining, + rateLimitEndAt // 新增:限流结束时间 + } + } + + return { + isRateLimited: false, + rateLimitedAt: null, + minutesSinceRateLimit: 0, + minutesRemaining: 0, + rateLimitEndAt: null + } + } catch (error) { + logger.error(`❌ Failed to get rate limit info for account: ${accountId}`, error) + return null + } + } + + // 🕐 更新会话窗口 + async updateSessionWindow(accountId, accountData = null) { + try { + // 如果没有传入accountData,从Redis获取 + if (!accountData) { + accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { + throw new Error('Account not found') + } + } + + const now = new Date() + const currentTime = now.getTime() + + let shouldClearSessionStatus = false + let shouldClearFiveHourFlags = false + + // 检查当前是否有活跃的会话窗口 + if (accountData.sessionWindowStart && accountData.sessionWindowEnd) { + const windowEnd = new Date(accountData.sessionWindowEnd).getTime() + + // 如果当前时间在窗口内,只更新最后请求时间 + if (currentTime < windowEnd) { + accountData.lastRequestTime = now.toISOString() + return accountData + } + + // 窗口已过期,记录日志 + const windowStart = new Date(accountData.sessionWindowStart) + logger.info( + `⏰ Session window expired for account ${accountData.name} (${accountId}): ${windowStart.toISOString()} - ${new Date(windowEnd).toISOString()}` + ) + } + + // 基于当前时间计算新的会话窗口 + const windowStart = this._calculateSessionWindowStart(now) + const windowEnd = this._calculateSessionWindowEnd(windowStart) + + // 更新会话窗口信息 + accountData.sessionWindowStart = windowStart.toISOString() + accountData.sessionWindowEnd = windowEnd.toISOString() + accountData.lastRequestTime = now.toISOString() + + // 清除会话窗口状态,因为进入了新窗口 + if (accountData.sessionWindowStatus) { + delete accountData.sessionWindowStatus + delete accountData.sessionWindowStatusUpdatedAt + await this._clearFiveHourWarningMetadata(accountId, accountData) + shouldClearSessionStatus = true + } + + // 如果账户因为5小时限制被自动停止,现在恢复调度 + if (accountData.fiveHourAutoStopped === 'true' && accountData.schedulable === 'false') { + logger.info( + `✅ Auto-resuming scheduling for account ${accountData.name} (${accountId}) - new session window started` + ) + accountData.schedulable = 'true' + delete accountData.fiveHourAutoStopped + delete accountData.fiveHourStoppedAt + await this._clearFiveHourWarningMetadata(accountId, accountData) + shouldClearFiveHourFlags = true + + // 发送Webhook通知 + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: accountData.name || 'Claude Account', + platform: 'claude', + status: 'resumed', + errorCode: 'CLAUDE_5H_LIMIT_RESUMED', + reason: '进入新的5小时窗口,已自动恢复调度', + timestamp: getISOStringWithTimezone(new Date()) + }) + } catch (webhookError) { + logger.error('Failed to send webhook notification:', webhookError) + } + } + + if (shouldClearSessionStatus || shouldClearFiveHourFlags) { + const fieldsToRemove = [] + if (shouldClearFiveHourFlags) { + fieldsToRemove.push('fiveHourAutoStopped', 'fiveHourStoppedAt') + } + if (shouldClearSessionStatus) { + fieldsToRemove.push('sessionWindowStatus', 'sessionWindowStatusUpdatedAt') + } + await this._removeAccountFields(accountId, fieldsToRemove, 'session_window_refresh') + } + + logger.info( + `🕐 Created new session window for account ${accountData.name} (${accountId}): ${windowStart.toISOString()} - ${windowEnd.toISOString()} (from current time)` + ) + + return accountData + } catch (error) { + logger.error(`❌ Failed to update session window for account ${accountId}:`, error) + throw error + } + } + + // 🕐 计算会话窗口开始时间 + _calculateSessionWindowStart(requestTime) { + // 从当前时间开始创建窗口,只将分钟取整到整点 + const windowStart = new Date(requestTime) + windowStart.setMinutes(0) + windowStart.setSeconds(0) + windowStart.setMilliseconds(0) + + return windowStart + } + + // 🕐 计算会话窗口结束时间 + _calculateSessionWindowEnd(startTime) { + const endTime = new Date(startTime) + endTime.setHours(endTime.getHours() + 5) // 加5小时 + return endTime + } + + async _clearFiveHourWarningMetadata(accountId, accountData = null) { + if (accountData) { + delete accountData.fiveHourWarningWindow + delete accountData.fiveHourWarningCount + delete accountData.fiveHourWarningLastSentAt + } + + try { + if (redis.client && typeof redis.client.hdel === 'function') { + await redis.client.hdel( + `claude:account:${accountId}`, + 'fiveHourWarningWindow', + 'fiveHourWarningCount', + 'fiveHourWarningLastSentAt' + ) + } + } catch (error) { + logger.warn( + `⚠️ Failed to clear five-hour warning metadata for account ${accountId}: ${error.message}` + ) + } + } + + // 📊 获取会话窗口信息 + async getSessionWindowInfo(accountId) { + try { + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { + return null + } + + // 如果没有会话窗口信息,返回null + if (!accountData.sessionWindowStart || !accountData.sessionWindowEnd) { + return { + hasActiveWindow: false, + windowStart: null, + windowEnd: null, + progress: 0, + remainingTime: null, + lastRequestTime: accountData.lastRequestTime || null, + sessionWindowStatus: accountData.sessionWindowStatus || null + } + } + + const now = new Date() + const windowStart = new Date(accountData.sessionWindowStart) + const windowEnd = new Date(accountData.sessionWindowEnd) + const currentTime = now.getTime() + + // 检查窗口是否已过期 + if (currentTime >= windowEnd.getTime()) { + return { + hasActiveWindow: false, + windowStart: accountData.sessionWindowStart, + windowEnd: accountData.sessionWindowEnd, + progress: 100, + remainingTime: 0, + lastRequestTime: accountData.lastRequestTime || null, + sessionWindowStatus: accountData.sessionWindowStatus || null + } + } + + // 计算进度百分比 + const totalDuration = windowEnd.getTime() - windowStart.getTime() + const elapsedTime = currentTime - windowStart.getTime() + const progress = Math.round((elapsedTime / totalDuration) * 100) + + // 计算剩余时间(分钟) + const remainingTime = Math.round((windowEnd.getTime() - currentTime) / (1000 * 60)) + + return { + hasActiveWindow: true, + windowStart: accountData.sessionWindowStart, + windowEnd: accountData.sessionWindowEnd, + progress, + remainingTime, + lastRequestTime: accountData.lastRequestTime || null, + sessionWindowStatus: accountData.sessionWindowStatus || null + } + } catch (error) { + logger.error(`❌ Failed to get session window info for account ${accountId}:`, error) + return null + } + } + + // 📊 获取 OAuth Usage 数据 + async fetchOAuthUsage(accountId, accessToken = null, agent = null) { + try { + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { + throw new Error('Account not found') + } + + // 如果没有提供 accessToken,使用 getValidAccessToken 自动检查过期并刷新 + if (!accessToken) { + accessToken = await this.getValidAccessToken(accountId) + } + + // 如果没有提供 agent,创建代理 + if (!agent) { + agent = this._createProxyAgent(accountData.proxy) + } + + logger.debug(`📊 Fetching OAuth usage for account: ${accountData.name} (${accountId})`) + + // 请求 OAuth usage 接口 + const response = await axios.get('https://api.anthropic.com/api/oauth/usage', { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + 'anthropic-beta': 'oauth-2025-04-20', + 'User-Agent': 'claude-cli/1.0.56 (external, cli)', + 'Accept-Language': 'en-US,en;q=0.9' + }, + httpsAgent: agent, + timeout: 15000 + }) + + if (response.status === 200 && response.data) { + logger.debug('✅ Successfully fetched OAuth usage data:', { + accountId, + fiveHour: response.data.five_hour?.utilization, + sevenDay: response.data.seven_day?.utilization, + sevenDayOpus: response.data.seven_day_opus?.utilization + }) + + return response.data + } + + logger.warn(`⚠️ Failed to fetch OAuth usage for account ${accountId}: ${response.status}`) + return null + } catch (error) { + // 403 错误通常表示使用的是 Setup Token 而非 OAuth + if (error.response?.status === 403) { + logger.debug( + `⚠️ OAuth usage API returned 403 for account ${accountId}. This account likely uses Setup Token instead of OAuth.` + ) + return null + } + + // 其他错误正常记录 + logger.error( + `❌ Failed to fetch OAuth usage for account ${accountId}:`, + error.response?.data || error.message + ) + return null + } + } + + // 📊 构建 Claude Usage 快照(从 Redis 数据) + buildClaudeUsageSnapshot(accountData) { + const updatedAt = accountData.claudeUsageUpdatedAt + + const fiveHourUtilization = this._toNumberOrNull(accountData.claudeFiveHourUtilization) + const fiveHourResetsAt = accountData.claudeFiveHourResetsAt + const sevenDayUtilization = this._toNumberOrNull(accountData.claudeSevenDayUtilization) + const sevenDayResetsAt = accountData.claudeSevenDayResetsAt + const sevenDayOpusUtilization = this._toNumberOrNull(accountData.claudeSevenDayOpusUtilization) + const sevenDayOpusResetsAt = accountData.claudeSevenDayOpusResetsAt + + const hasFiveHourData = fiveHourUtilization !== null || fiveHourResetsAt + const hasSevenDayData = sevenDayUtilization !== null || sevenDayResetsAt + const hasSevenDayOpusData = sevenDayOpusUtilization !== null || sevenDayOpusResetsAt + + if (!updatedAt && !hasFiveHourData && !hasSevenDayData && !hasSevenDayOpusData) { + return null + } + + const now = Date.now() + + return { + updatedAt, + fiveHour: { + utilization: fiveHourUtilization, + resetsAt: fiveHourResetsAt, + remainingSeconds: fiveHourResetsAt + ? Math.max(0, Math.floor((new Date(fiveHourResetsAt).getTime() - now) / 1000)) + : null + }, + sevenDay: { + utilization: sevenDayUtilization, + resetsAt: sevenDayResetsAt, + remainingSeconds: sevenDayResetsAt + ? Math.max(0, Math.floor((new Date(sevenDayResetsAt).getTime() - now) / 1000)) + : null + }, + sevenDayOpus: { + utilization: sevenDayOpusUtilization, + resetsAt: sevenDayOpusResetsAt, + remainingSeconds: sevenDayOpusResetsAt + ? Math.max(0, Math.floor((new Date(sevenDayOpusResetsAt).getTime() - now) / 1000)) + : null + } + } + } + + // 📊 更新 Claude Usage 快照到 Redis + async updateClaudeUsageSnapshot(accountId, usageData) { + if (!usageData || typeof usageData !== 'object') { + return + } + + const updates = {} + + // 5小时窗口 + if (usageData.five_hour) { + if (usageData.five_hour.utilization !== undefined) { + updates.claudeFiveHourUtilization = String(usageData.five_hour.utilization) + } + if (usageData.five_hour.resets_at) { + updates.claudeFiveHourResetsAt = usageData.five_hour.resets_at + } + } + + // 7天窗口 + if (usageData.seven_day) { + if (usageData.seven_day.utilization !== undefined) { + updates.claudeSevenDayUtilization = String(usageData.seven_day.utilization) + } + if (usageData.seven_day.resets_at) { + updates.claudeSevenDayResetsAt = usageData.seven_day.resets_at + } + } + + // 7天Opus窗口 + if (usageData.seven_day_opus) { + if (usageData.seven_day_opus.utilization !== undefined) { + updates.claudeSevenDayOpusUtilization = String(usageData.seven_day_opus.utilization) + } + if (usageData.seven_day_opus.resets_at) { + updates.claudeSevenDayOpusResetsAt = usageData.seven_day_opus.resets_at + } + } + + if (Object.keys(updates).length === 0) { + return + } + + updates.claudeUsageUpdatedAt = new Date().toISOString() + + const accountData = await redis.getClaudeAccount(accountId) + if (accountData && Object.keys(accountData).length > 0) { + Object.assign(accountData, updates) + await redis.setClaudeAccount(accountId, accountData) + logger.debug( + `📊 Updated Claude usage snapshot for account ${accountId}:`, + Object.keys(updates) + ) + } + } + + // 📊 获取账号 Profile 信息并更新账号类型 + async fetchAndUpdateAccountProfile(accountId, accessToken = null, agent = null) { + try { + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { + throw new Error('Account not found') + } + + // 检查账户是否有 user:profile 权限 + const hasProfileScope = accountData.scopes && accountData.scopes.includes('user:profile') + if (!hasProfileScope) { + logger.warn( + `⚠️ Account ${accountId} does not have user:profile scope, cannot fetch profile` + ) + throw new Error('Account does not have user:profile permission') + } + + // 如果没有提供 accessToken,使用账号存储的 token + if (!accessToken) { + accessToken = this._decryptSensitiveData(accountData.accessToken) + if (!accessToken) { + throw new Error('No access token available') + } + } + + // 如果没有提供 agent,创建代理 + if (!agent) { + agent = this._createProxyAgent(accountData.proxy) + } + + logger.info(`📊 Fetching profile info for account: ${accountData.name} (${accountId})`) + + // 请求 profile 接口 + const response = await axios.get('https://api.anthropic.com/api/oauth/profile', { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + 'User-Agent': 'claude-cli/1.0.56 (external, cli)', + 'Accept-Language': 'en-US,en;q=0.9' + }, + httpsAgent: agent, + timeout: 15000 + }) + + if (response.status === 200 && response.data) { + const profileData = response.data + + logger.info('✅ Successfully fetched profile data:', { + email: profileData.account?.email, + hasClaudeMax: profileData.account?.has_claude_max, + hasClaudePro: profileData.account?.has_claude_pro, + organizationType: profileData.organization?.organization_type + }) + + // 构建订阅信息 + const subscriptionInfo = { + // 账号信息 + email: profileData.account?.email, + fullName: profileData.account?.full_name, + displayName: profileData.account?.display_name, + hasClaudeMax: profileData.account?.has_claude_max || false, + hasClaudePro: profileData.account?.has_claude_pro || false, + accountUuid: profileData.account?.uuid, + + // 组织信息 + organizationName: profileData.organization?.name, + organizationUuid: profileData.organization?.uuid, + billingType: profileData.organization?.billing_type, + rateLimitTier: profileData.organization?.rate_limit_tier, + organizationType: profileData.organization?.organization_type, + + // 账号类型(基于 has_claude_max 和 has_claude_pro 判断) + accountType: + profileData.account?.has_claude_max === true + ? 'claude_max' + : profileData.account?.has_claude_pro === true + ? 'claude_pro' + : 'free', + + // 更新时间 + profileFetchedAt: new Date().toISOString() + } + + // 更新账户数据 + accountData.subscriptionInfo = JSON.stringify(subscriptionInfo) + accountData.profileUpdatedAt = new Date().toISOString() + + // 如果提供了邮箱,更新邮箱字段 + if (profileData.account?.email) { + accountData.email = this._encryptSensitiveData(profileData.account.email) + } + + await redis.setClaudeAccount(accountId, accountData) + + logger.success( + `✅ Updated account profile for ${accountData.name} (${accountId}) - Type: ${subscriptionInfo.accountType}` + ) + + return subscriptionInfo + } else { + throw new Error(`Failed to fetch profile with status: ${response.status}`) + } + } catch (error) { + if (error.response?.status === 401) { + logger.warn(`⚠️ Profile API returned 401 for account ${accountId} - token may be invalid`) + } else if (error.response?.status === 403) { + logger.warn( + `⚠️ Profile API returned 403 for account ${accountId} - insufficient permissions` + ) + } else { + logger.error(`❌ Failed to fetch profile for account ${accountId}:`, error.message) + } + throw error + } + } + + // 🔄 手动更新所有账号的 Profile 信息 + async updateAllAccountProfiles() { + try { + logger.info('🔄 Starting batch profile update for all accounts...') + + const accounts = await redis.getAllClaudeAccounts() + let successCount = 0 + let failureCount = 0 + const results = [] + + for (const account of accounts) { + // 跳过未激活或错误状态的账号 + if (account.isActive !== 'true' || account.status === 'error') { + logger.info(`⏩ Skipping inactive/error account: ${account.name} (${account.id})`) + continue + } + + // 跳过没有 user:profile 权限的账号(Setup Token 账号) + const hasProfileScope = account.scopes && account.scopes.includes('user:profile') + if (!hasProfileScope) { + logger.info( + `⏩ Skipping account without user:profile scope: ${account.name} (${account.id})` + ) + results.push({ + accountId: account.id, + accountName: account.name, + success: false, + error: 'No user:profile permission (Setup Token account)' + }) + continue + } + + try { + // 获取有效的 access token + const accessToken = await this.getValidAccessToken(account.id) + if (accessToken) { + const profileInfo = await this.fetchAndUpdateAccountProfile(account.id, accessToken) + successCount++ + results.push({ + accountId: account.id, + accountName: account.name, + success: true, + accountType: profileInfo.accountType + }) + } + } catch (error) { + failureCount++ + results.push({ + accountId: account.id, + accountName: account.name, + success: false, + error: error.message + }) + logger.warn( + `⚠️ Failed to update profile for account ${account.name} (${account.id}): ${error.message}` + ) + } + + // 添加延迟以避免触发限流 + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + + logger.success(`✅ Profile update completed: ${successCount} success, ${failureCount} failed`) + + return { + totalAccounts: accounts.length, + successCount, + failureCount, + results + } + } catch (error) { + logger.error('❌ Failed to update account profiles:', error) + throw error + } + } + + // 🔄 初始化所有账户的会话窗口(从历史数据恢复) + async initializeSessionWindows(forceRecalculate = false) { + try { + logger.info('🔄 Initializing session windows for all Claude accounts...') + + const accounts = await redis.getAllClaudeAccounts() + let validWindowCount = 0 + let expiredWindowCount = 0 + let noWindowCount = 0 + const now = new Date() + + for (const account of accounts) { + // 如果强制重算,清除现有窗口信息 + if (forceRecalculate && (account.sessionWindowStart || account.sessionWindowEnd)) { + logger.info(`🔄 Force recalculating window for account ${account.name} (${account.id})`) + delete account.sessionWindowStart + delete account.sessionWindowEnd + delete account.lastRequestTime + await redis.setClaudeAccount(account.id, account) + } + + // 检查现有会话窗口 + if (account.sessionWindowStart && account.sessionWindowEnd) { + const windowEnd = new Date(account.sessionWindowEnd) + const windowStart = new Date(account.sessionWindowStart) + const timeUntilExpires = Math.round((windowEnd.getTime() - now.getTime()) / (1000 * 60)) + + if (now.getTime() < windowEnd.getTime()) { + // 窗口仍然有效,保留它 + validWindowCount++ + logger.info( + `✅ Account ${account.name} (${account.id}) has valid window: ${windowStart.toISOString()} - ${windowEnd.toISOString()} (${timeUntilExpires} minutes remaining)` + ) + } else { + // 窗口已过期,清除它 + expiredWindowCount++ + logger.warn( + `⏰ Account ${account.name} (${account.id}) window expired: ${windowStart.toISOString()} - ${windowEnd.toISOString()}` + ) + + // 清除过期的窗口信息 + delete account.sessionWindowStart + delete account.sessionWindowEnd + delete account.lastRequestTime + await redis.setClaudeAccount(account.id, account) + } + } else { + noWindowCount++ + logger.info( + `📭 Account ${account.name} (${account.id}) has no session window - will create on next request` + ) + } + } + + logger.success('✅ Session window initialization completed:') + logger.success(` 📊 Total accounts: ${accounts.length}`) + logger.success(` ✅ Valid windows: ${validWindowCount}`) + logger.success(` ⏰ Expired windows: ${expiredWindowCount}`) + logger.success(` 📭 No windows: ${noWindowCount}`) + + return { + total: accounts.length, + validWindows: validWindowCount, + expiredWindows: expiredWindowCount, + noWindows: noWindowCount + } + } catch (error) { + logger.error('❌ Failed to initialize session windows:', error) + return { + total: 0, + validWindows: 0, + expiredWindows: 0, + noWindows: 0, + error: error.message + } + } + } + + // 🚫 通用的账户错误标记方法 + async markAccountError(accountId, errorType, sessionHash = null) { + const ERROR_CONFIG = { + unauthorized: { + status: 'unauthorized', + errorMessage: 'Account unauthorized (401 errors detected)', + timestampField: 'unauthorizedAt', + errorCode: 'CLAUDE_OAUTH_UNAUTHORIZED', + logMessage: 'unauthorized' + }, + blocked: { + status: 'blocked', + errorMessage: 'Account blocked (403 error detected - account may be suspended by Claude)', + timestampField: 'blockedAt', + errorCode: 'CLAUDE_OAUTH_BLOCKED', + logMessage: 'blocked' + } + } + + try { + const errorConfig = ERROR_CONFIG[errorType] + if (!errorConfig) { + throw new Error(`Unsupported error type: ${errorType}`) + } + + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { + throw new Error('Account not found') + } + + // 更新账户状态 + const updatedAccountData = { ...accountData } + updatedAccountData.status = errorConfig.status + updatedAccountData.schedulable = 'false' // 设置为不可调度 + updatedAccountData.errorMessage = errorConfig.errorMessage + updatedAccountData[errorConfig.timestampField] = new Date().toISOString() + + // 保存更新后的账户数据 + await redis.setClaudeAccount(accountId, updatedAccountData) + + // 如果有sessionHash,删除粘性会话映射 + if (sessionHash) { + await redis.client.del(`sticky_session:${sessionHash}`) + logger.info(`🗑️ Deleted sticky session mapping for hash: ${sessionHash}`) + } + + logger.warn( + `⚠️ Account ${accountData.name} (${accountId}) marked as ${errorConfig.logMessage} and disabled for scheduling` + ) + + // 发送Webhook通知 + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: accountData.name, + platform: 'claude-oauth', + status: errorConfig.status, + errorCode: errorConfig.errorCode, + reason: errorConfig.errorMessage, + timestamp: getISOStringWithTimezone(new Date()) + }) + } catch (webhookError) { + logger.error('Failed to send webhook notification:', webhookError) + } + + return { success: true } + } catch (error) { + logger.error(`❌ Failed to mark account ${accountId} as ${errorType}:`, error) + throw error + } + } + + // 🚫 标记账户为未授权状态(401错误) + async markAccountUnauthorized(accountId, sessionHash = null) { + return this.markAccountError(accountId, 'unauthorized', sessionHash) + } + + // 🚫 标记账户为被封锁状态(403错误) + async markAccountBlocked(accountId, sessionHash = null) { + return this.markAccountError(accountId, 'blocked', sessionHash) + } + + // 🔄 重置账户所有异常状态 + async resetAccountStatus(accountId) { + try { + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { + throw new Error('Account not found') + } + + // 重置账户状态 + const updatedAccountData = { ...accountData } + + // 根据是否有有效的accessToken来设置status + if (updatedAccountData.accessToken) { + updatedAccountData.status = 'active' + } else { + updatedAccountData.status = 'created' + } + + // 恢复可调度状态(管理员手动重置时恢复调度是合理的) + updatedAccountData.schedulable = 'true' + // 清除所有自动停止相关的标记 + delete updatedAccountData.rateLimitAutoStopped + delete updatedAccountData.fiveHourAutoStopped + delete updatedAccountData.fiveHourStoppedAt + delete updatedAccountData.tempErrorAutoStopped + delete updatedAccountData.fiveHourWarningWindow + delete updatedAccountData.fiveHourWarningCount + delete updatedAccountData.fiveHourWarningLastSentAt + // 兼容旧的标记 + delete updatedAccountData.autoStoppedAt + delete updatedAccountData.stoppedReason + + // 清除错误相关字段 + delete updatedAccountData.errorMessage + delete updatedAccountData.unauthorizedAt + delete updatedAccountData.blockedAt + delete updatedAccountData.rateLimitedAt + delete updatedAccountData.rateLimitStatus + delete updatedAccountData.rateLimitEndAt + delete updatedAccountData.tempErrorAt + delete updatedAccountData.sessionWindowStart + delete updatedAccountData.sessionWindowEnd + + // 保存更新后的账户数据 + await redis.setClaudeAccount(accountId, updatedAccountData) + + // 显式从 Redis 中删除这些字段(因为 HSET 不会删除现有字段) + const fieldsToDelete = [ + 'errorMessage', + 'unauthorizedAt', + 'blockedAt', + 'rateLimitedAt', + 'rateLimitStatus', + 'rateLimitEndAt', + 'tempErrorAt', + 'sessionWindowStart', + 'sessionWindowEnd', + // 新的独立标记 + 'rateLimitAutoStopped', + 'fiveHourAutoStopped', + 'fiveHourStoppedAt', + 'fiveHourWarningWindow', + 'fiveHourWarningCount', + 'fiveHourWarningLastSentAt', + 'tempErrorAutoStopped', + // 兼容旧的标记 + 'autoStoppedAt', + 'stoppedReason' + ] + await redis.client.hdel(`claude:account:${accountId}`, ...fieldsToDelete) + + // 清除401错误计数 + const errorKey = `claude_account:${accountId}:401_errors` + await redis.client.del(errorKey) + + // 清除限流状态(如果存在) + const rateLimitKey = `ratelimit:${accountId}` + await redis.client.del(rateLimitKey) + + // 清除5xx错误计数 + const serverErrorKey = `claude_account:${accountId}:5xx_errors` + await redis.client.del(serverErrorKey) + + logger.info( + `✅ Successfully reset all error states for account ${accountData.name} (${accountId})` + ) + + return { + success: true, + account: { + id: accountId, + name: accountData.name, + status: updatedAccountData.status, + schedulable: updatedAccountData.schedulable === 'true' + } + } + } catch (error) { + logger.error(`❌ Failed to reset account status for ${accountId}:`, error) + throw error + } + } + + // 🧹 清理临时错误账户 + async cleanupTempErrorAccounts() { + try { + const accounts = await redis.getAllClaudeAccounts() + let cleanedCount = 0 + const TEMP_ERROR_RECOVERY_MINUTES = 5 // 临时错误状态恢复时间(分钟) + + for (const account of accounts) { + if (account.status === 'temp_error' && account.tempErrorAt) { + const tempErrorAt = new Date(account.tempErrorAt) + const now = new Date() + const minutesSinceTempError = (now - tempErrorAt) / (1000 * 60) + + // 如果临时错误状态超过指定时间,尝试重新激活 + if (minutesSinceTempError > TEMP_ERROR_RECOVERY_MINUTES) { + account.status = 'active' // 恢复为 active 状态 + // 只恢复因临时错误而自动停止的账户 + if (account.tempErrorAutoStopped === 'true') { + account.schedulable = 'true' // 恢复为可调度 + delete account.tempErrorAutoStopped + } + delete account.errorMessage + delete account.tempErrorAt + await redis.setClaudeAccount(account.id, account) + + // 显式从 Redis 中删除这些字段(因为 HSET 不会删除现有字段) + await redis.client.hdel( + `claude:account:${account.id}`, + 'errorMessage', + 'tempErrorAt', + 'tempErrorAutoStopped' + ) + + // 同时清除500错误计数 + await this.clearInternalErrors(account.id) + cleanedCount++ + logger.success(`🧹 Reset temp_error status for account ${account.name} (${account.id})`) + } + } + } + + if (cleanedCount > 0) { + logger.success(`🧹 Reset ${cleanedCount} temp_error accounts`) + } + + return cleanedCount + } catch (error) { + logger.error('❌ Failed to cleanup temp_error accounts:', error) + return 0 + } + } + + // 记录5xx服务器错误 + async recordServerError(accountId, statusCode) { + try { + const key = `claude_account:${accountId}:5xx_errors` + + // 增加错误计数,设置5分钟过期时间 + await redis.client.incr(key) + await redis.client.expire(key, 300) // 5分钟 + + logger.info(`📝 Recorded ${statusCode} error for account ${accountId}`) + } catch (error) { + logger.error(`❌ Failed to record ${statusCode} error for account ${accountId}:`, error) + } + } + + // 记录500内部错误(保留以便向后兼容) + async recordInternalError(accountId) { + return this.recordServerError(accountId, 500) + } + + // 获取5xx错误计数 + async getServerErrorCount(accountId) { + try { + const key = `claude_account:${accountId}:5xx_errors` + + const count = await redis.client.get(key) + return parseInt(count) || 0 + } catch (error) { + logger.error(`❌ Failed to get 5xx error count for account ${accountId}:`, error) + return 0 + } + } + + // 获取500错误计数(保留以便向后兼容) + async getInternalErrorCount(accountId) { + return this.getServerErrorCount(accountId) + } + + // 清除500错误计数 + async clearInternalErrors(accountId) { + try { + const key = `claude_account:${accountId}:5xx_errors` + + await redis.client.del(key) + logger.info(`✅ Cleared 5xx error count for account ${accountId}`) + } catch (error) { + logger.error(`❌ Failed to clear 5xx errors for account ${accountId}:`, error) + } + } + + // 标记账号为临时错误状态 + async markAccountTempError(accountId, sessionHash = null) { + try { + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { + throw new Error('Account not found') + } + + // 更新账户状态 + const updatedAccountData = { ...accountData } + updatedAccountData.status = 'temp_error' // 新增的临时错误状态 + updatedAccountData.schedulable = 'false' // 设置为不可调度 + updatedAccountData.errorMessage = 'Account temporarily disabled due to consecutive 500 errors' + updatedAccountData.tempErrorAt = new Date().toISOString() + // 使用独立的临时错误自动停止标记 + updatedAccountData.tempErrorAutoStopped = 'true' + + // 保存更新后的账户数据 + await redis.setClaudeAccount(accountId, updatedAccountData) + + // 设置 5 分钟后自动恢复(一次性定时器) + setTimeout( + async () => { + try { + const account = await redis.getClaudeAccount(accountId) + if (account && account.status === 'temp_error' && account.tempErrorAt) { + // 验证是否确实过了 5 分钟(防止重复定时器) + const tempErrorAt = new Date(account.tempErrorAt) + const now = new Date() + const minutesSince = (now - tempErrorAt) / (1000 * 60) + + if (minutesSince >= 5) { + // 恢复账户 + account.status = 'active' + // 只恢复因临时错误而自动停止的账户 + if (account.tempErrorAutoStopped === 'true') { + account.schedulable = 'true' + delete account.tempErrorAutoStopped + } + delete account.errorMessage + delete account.tempErrorAt + + await redis.setClaudeAccount(accountId, account) + + // 显式删除 Redis 字段 + await redis.client.hdel( + `claude:account:${accountId}`, + 'errorMessage', + 'tempErrorAt', + 'tempErrorAutoStopped' + ) + + // 清除 500 错误计数 + await this.clearInternalErrors(accountId) + + logger.success( + `✅ Auto-recovered temp_error after 5 minutes: ${account.name} (${accountId})` + ) + } else { + logger.debug( + `⏰ Temp error timer triggered but only ${minutesSince.toFixed(1)} minutes passed for ${account.name} (${accountId})` + ) + } + } + } catch (error) { + logger.error(`❌ Failed to auto-recover temp_error account ${accountId}:`, error) + } + }, + 6 * 60 * 1000 + ) // 6 分钟后执行,确保已过 5 分钟 + + // 如果有sessionHash,删除粘性会话映射 + if (sessionHash) { + await redis.client.del(`sticky_session:${sessionHash}`) + logger.info(`🗑️ Deleted sticky session mapping for hash: ${sessionHash}`) + } + + logger.warn( + `⚠️ Account ${accountData.name} (${accountId}) marked as temp_error and disabled for scheduling` + ) + + // 发送Webhook通知 + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: accountData.name, + platform: 'claude-oauth', + status: 'temp_error', + errorCode: 'CLAUDE_OAUTH_TEMP_ERROR', + reason: 'Account temporarily disabled due to consecutive 500 errors' + }) + } catch (webhookError) { + logger.error('Failed to send webhook notification:', webhookError) + } + + return { success: true } + } catch (error) { + logger.error(`❌ Failed to mark account ${accountId} as temp_error:`, error) + throw error + } + } + + // 更新会话窗口状态(allowed, allowed_warning, rejected) + async updateSessionWindowStatus(accountId, status) { + try { + // 参数验证 + if (!accountId || !status) { + logger.warn( + `Invalid parameters for updateSessionWindowStatus: accountId=${accountId}, status=${status}` + ) + return + } + + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { + logger.warn(`Account not found: ${accountId}`) + return + } + + // 验证状态值是否有效 + const validStatuses = ['allowed', 'allowed_warning', 'rejected'] + if (!validStatuses.includes(status)) { + logger.warn(`Invalid session window status: ${status} for account ${accountId}`) + return + } + + const now = new Date() + const nowIso = now.toISOString() + + // 更新会话窗口状态 + accountData.sessionWindowStatus = status + accountData.sessionWindowStatusUpdatedAt = nowIso + + // 如果状态是 allowed_warning 且账户设置了自动停止调度 + if (status === 'allowed_warning' && accountData.autoStopOnWarning === 'true') { + const alreadyAutoStopped = + accountData.schedulable === 'false' && accountData.fiveHourAutoStopped === 'true' + + if (!alreadyAutoStopped) { + const windowIdentifier = + accountData.sessionWindowEnd || accountData.sessionWindowStart || 'unknown' + + let warningCount = 0 + if (accountData.fiveHourWarningWindow === windowIdentifier) { + const parsedCount = parseInt(accountData.fiveHourWarningCount || '0', 10) + warningCount = Number.isNaN(parsedCount) ? 0 : parsedCount + } + + const maxWarningsPerWindow = this.maxFiveHourWarningsPerWindow + + logger.warn( + `⚠️ Account ${accountData.name} (${accountId}) approaching 5h limit, auto-stopping scheduling` + ) + accountData.schedulable = 'false' + // 使用独立的5小时限制自动停止标记 + accountData.fiveHourAutoStopped = 'true' + accountData.fiveHourStoppedAt = nowIso + // 设置停止原因,供前端显示 + accountData.stoppedReason = '5小时使用量接近限制,已自动停止调度' + + const canSendWarning = warningCount < maxWarningsPerWindow + let updatedWarningCount = warningCount + + accountData.fiveHourWarningWindow = windowIdentifier + if (canSendWarning) { + updatedWarningCount += 1 + accountData.fiveHourWarningLastSentAt = nowIso + } + accountData.fiveHourWarningCount = updatedWarningCount.toString() + + if (canSendWarning) { + // 发送Webhook通知 + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: accountData.name || 'Claude Account', + platform: 'claude', + status: 'warning', + errorCode: 'CLAUDE_5H_LIMIT_WARNING', + reason: '5小时使用量接近限制,已自动停止调度', + timestamp: getISOStringWithTimezone(now) + }) + } catch (webhookError) { + logger.error('Failed to send webhook notification:', webhookError) + } + } else { + logger.debug( + `⚠️ Account ${accountData.name} (${accountId}) reached max ${maxWarningsPerWindow} warning notifications for current 5h window, skipping webhook` + ) + } + } else { + logger.debug( + `⚠️ Account ${accountData.name} (${accountId}) already auto-stopped for 5h limit, skipping duplicate warning` + ) + } + } + + await redis.setClaudeAccount(accountId, accountData) + + logger.info( + `📊 Updated session window status for account ${accountData.name} (${accountId}): ${status}` + ) + } catch (error) { + logger.error(`❌ Failed to update session window status for account ${accountId}:`, error) + } + } + + // 🚫 标记账号为过载状态(529错误) + async markAccountOverloaded(accountId) { + try { + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData) { + throw new Error('Account not found') + } + + // 获取配置的过载处理时间(分钟) + const overloadMinutes = config.overloadHandling?.enabled || 0 + + if (overloadMinutes === 0) { + logger.info('⏭️ 529 error handling is disabled') + return { success: false, error: '529 error handling is disabled' } + } + + const overloadKey = `account:overload:${accountId}` + const ttl = overloadMinutes * 60 // 转换为秒 + + await redis.setex( + overloadKey, + ttl, + JSON.stringify({ + accountId, + accountName: accountData.name, + markedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + ttl * 1000).toISOString() + }) + ) + + logger.warn( + `🚫 Account ${accountData.name} (${accountId}) marked as overloaded for ${overloadMinutes} minutes` + ) + + // 在账号上记录最后一次529错误 + const updates = { + lastOverloadAt: new Date().toISOString(), + errorMessage: `529错误 - 过载${overloadMinutes}分钟` + } + + const updatedAccountData = { ...accountData, ...updates } + await redis.setClaudeAccount(accountId, updatedAccountData) + + return { success: true, accountName: accountData.name, duration: overloadMinutes } + } catch (error) { + logger.error(`❌ Failed to mark account as overloaded: ${accountId}`, error) + // 不抛出错误,避免影响主请求流程 + return { success: false, error: error.message } + } + } + + // ✅ 检查账号是否过载 + async isAccountOverloaded(accountId) { + try { + // 如果529处理未启用,直接返回false + const overloadMinutes = config.overloadHandling?.enabled || 0 + if (overloadMinutes === 0) { + return false + } + + const overloadKey = `account:overload:${accountId}` + const overloadData = await redis.get(overloadKey) + + if (overloadData) { + // 账号处于过载状态 + return true + } + + // 账号未过载 + return false + } catch (error) { + logger.error(`❌ Failed to check if account is overloaded: ${accountId}`, error) + return false + } + } + + // 🔄 移除账号的过载状态 + async removeAccountOverload(accountId) { + try { + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData) { + throw new Error('Account not found') + } + + const overloadKey = `account:overload:${accountId}` + await redis.del(overloadKey) + + logger.info(`✅ Account ${accountData.name} (${accountId}) overload status removed`) + + // 清理账号上的错误信息 + if (accountData.errorMessage && accountData.errorMessage.includes('529错误')) { + const updatedAccountData = { ...accountData } + delete updatedAccountData.errorMessage + delete updatedAccountData.lastOverloadAt + await redis.setClaudeAccount(accountId, updatedAccountData) + } + } catch (error) { + logger.error(`❌ Failed to remove overload status for account: ${accountId}`, error) + // 不抛出错误,移除过载状态失败不应该影响主流程 + } + } + + /** + * 检查并恢复因5小时限制被自动停止的账号 + * 用于定时任务自动恢复 + * @returns {Promise<{checked: number, recovered: number, accounts: Array}>} + */ + async checkAndRecoverFiveHourStoppedAccounts() { + const result = { + checked: 0, + recovered: 0, + accounts: [] + } + + try { + const accounts = await this.getAllAccounts() + const now = new Date() + + for (const account of accounts) { + // 只检查因5小时限制被自动停止的账号 + // 重要:不恢复手动停止的账号(没有fiveHourAutoStopped标记的) + if (account.fiveHourAutoStopped === true && account.schedulable === false) { + result.checked++ + + // 使用分布式锁防止并发修改 + const lockKey = `lock:account:${account.id}:recovery` + const lockValue = `${Date.now()}_${Math.random()}` + const lockTTL = 5000 // 5秒锁超时 + + try { + // 尝试获取锁 + const lockAcquired = await redis.setAccountLock(lockKey, lockValue, lockTTL) + if (!lockAcquired) { + logger.debug( + `⏭️ Account ${account.name} (${account.id}) is being processed by another instance` + ) + continue + } + + // 重新获取账号数据,确保是最新的 + const latestAccount = await redis.getClaudeAccount(account.id) + if ( + !latestAccount || + latestAccount.fiveHourAutoStopped !== 'true' || + latestAccount.schedulable !== 'false' + ) { + // 账号状态已变化,跳过 + await redis.releaseAccountLock(lockKey, lockValue) + continue + } + + // 检查当前时间是否已经进入新的5小时窗口 + let shouldRecover = false + let newWindowStart = null + let newWindowEnd = null + + if (latestAccount.sessionWindowEnd) { + const windowEnd = new Date(latestAccount.sessionWindowEnd) + + // 使用严格的时间比较,添加1分钟缓冲避免边界问题 + if (now.getTime() > windowEnd.getTime() + 60000) { + shouldRecover = true + + // 计算新的窗口时间(基于窗口结束时间,而不是当前时间) + // 这样可以保证窗口时间的连续性 + newWindowStart = new Date(windowEnd) + newWindowStart.setMilliseconds(newWindowStart.getMilliseconds() + 1) + newWindowEnd = new Date(newWindowStart) + newWindowEnd.setHours(newWindowEnd.getHours() + 5) + + logger.info( + `🔄 Account ${latestAccount.name} (${latestAccount.id}) has entered new session window. ` + + `Old window: ${latestAccount.sessionWindowStart} - ${latestAccount.sessionWindowEnd}, ` + + `New window: ${newWindowStart.toISOString()} - ${newWindowEnd.toISOString()}` + ) + } + } else { + // 如果没有窗口结束时间,但有停止时间,检查是否已经过了5小时 + if (latestAccount.fiveHourStoppedAt) { + const stoppedAt = new Date(latestAccount.fiveHourStoppedAt) + const hoursSinceStopped = (now.getTime() - stoppedAt.getTime()) / (1000 * 60 * 60) + + // 使用严格的5小时判断,加上1分钟缓冲 + if (hoursSinceStopped > 5.017) { + // 5小时1分钟 + shouldRecover = true + newWindowStart = this._calculateSessionWindowStart(now) + newWindowEnd = this._calculateSessionWindowEnd(newWindowStart) + + logger.info( + `🔄 Account ${latestAccount.name} (${latestAccount.id}) stopped ${hoursSinceStopped.toFixed(2)} hours ago, recovering` + ) + } + } + } + + if (shouldRecover) { + // 恢复账号调度 + const updatedAccountData = { ...latestAccount } + + // 恢复调度状态 + updatedAccountData.schedulable = 'true' + delete updatedAccountData.fiveHourAutoStopped + delete updatedAccountData.fiveHourStoppedAt + await this._clearFiveHourWarningMetadata(account.id, updatedAccountData) + delete updatedAccountData.stoppedReason + + // 更新会话窗口(如果有新窗口) + if (newWindowStart && newWindowEnd) { + updatedAccountData.sessionWindowStart = newWindowStart.toISOString() + updatedAccountData.sessionWindowEnd = newWindowEnd.toISOString() + + // 清除会话窗口状态 + delete updatedAccountData.sessionWindowStatus + delete updatedAccountData.sessionWindowStatusUpdatedAt + } + + // 保存更新 + await redis.setClaudeAccount(account.id, updatedAccountData) + + const fieldsToRemove = ['fiveHourAutoStopped', 'fiveHourStoppedAt'] + if (newWindowStart && newWindowEnd) { + fieldsToRemove.push('sessionWindowStatus', 'sessionWindowStatusUpdatedAt') + } + await this._removeAccountFields(account.id, fieldsToRemove, 'five_hour_recovery_task') + + result.recovered++ + result.accounts.push({ + id: latestAccount.id, + name: latestAccount.name, + oldWindow: latestAccount.sessionWindowEnd + ? { + start: latestAccount.sessionWindowStart, + end: latestAccount.sessionWindowEnd + } + : null, + newWindow: + newWindowStart && newWindowEnd + ? { + start: newWindowStart.toISOString(), + end: newWindowEnd.toISOString() + } + : null + }) + + logger.info( + `✅ Auto-resumed scheduling for account ${latestAccount.name} (${latestAccount.id}) - 5-hour limit expired` + ) + } + + // 释放锁 + await redis.releaseAccountLock(lockKey, lockValue) + } catch (error) { + // 确保释放锁 + if (lockKey && lockValue) { + try { + await redis.releaseAccountLock(lockKey, lockValue) + } catch (unlockError) { + logger.error(`Failed to release lock for account ${account.id}:`, unlockError) + } + } + logger.error( + `❌ Failed to check/recover 5-hour stopped account ${account.name} (${account.id}):`, + error + ) + } + } + } + + if (result.recovered > 0) { + logger.info( + `🔄 5-hour limit recovery completed: ${result.recovered}/${result.checked} accounts recovered` + ) + } + + return result + } catch (error) { + logger.error('❌ Failed to check and recover 5-hour stopped accounts:', error) + throw error + } + } + + async _removeAccountFields(accountId, fields = [], context = 'general_cleanup') { + if (!Array.isArray(fields) || fields.length === 0) { + return + } + + const filteredFields = fields.filter((field) => typeof field === 'string' && field.trim()) + if (filteredFields.length === 0) { + return + } + + const accountKey = `claude:account:${accountId}` + + try { + await redis.client.hdel(accountKey, ...filteredFields) + logger.debug( + `🧹 已在 ${context} 阶段为账号 ${accountId} 删除字段 [${filteredFields.join(', ')}]` + ) + } catch (error) { + logger.error( + `❌ 无法在 ${context} 阶段为账号 ${accountId} 删除字段 [${filteredFields.join(', ')}]:`, + error + ) + } + } +} + +module.exports = new ClaudeAccountService() diff --git a/src/services/claudeCodeHeadersService.js b/src/services/claudeCodeHeadersService.js new file mode 100644 index 0000000000000000000000000000000000000000..3bbbbea0682c0072ae3cab77c46c308994b78a1e --- /dev/null +++ b/src/services/claudeCodeHeadersService.js @@ -0,0 +1,219 @@ +/** + * Claude Code Headers 管理服务 + * 负责存储和管理不同账号使用的 Claude Code headers + */ + +const redis = require('../models/redis') +const logger = require('../utils/logger') + +class ClaudeCodeHeadersService { + constructor() { + this.defaultHeaders = { + 'x-stainless-retry-count': '0', + 'x-stainless-timeout': '60', + 'x-stainless-lang': 'js', + 'x-stainless-package-version': '0.55.1', + 'x-stainless-os': 'Windows', + 'x-stainless-arch': 'x64', + 'x-stainless-runtime': 'node', + 'x-stainless-runtime-version': 'v20.19.2', + 'anthropic-dangerous-direct-browser-access': 'true', + 'x-app': 'cli', + 'user-agent': 'claude-cli/1.0.57 (external, cli)', + 'accept-language': '*', + 'sec-fetch-mode': 'cors' + } + + // 需要捕获的 Claude Code 特定 headers + this.claudeCodeHeaderKeys = [ + 'x-stainless-retry-count', + 'x-stainless-timeout', + 'x-stainless-lang', + 'x-stainless-package-version', + 'x-stainless-os', + 'x-stainless-arch', + 'x-stainless-runtime', + 'x-stainless-runtime-version', + 'anthropic-dangerous-direct-browser-access', + 'x-app', + 'user-agent', + 'accept-language', + 'sec-fetch-mode', + 'accept-encoding' + ] + } + + /** + * 从 user-agent 中提取版本号 + */ + extractVersionFromUserAgent(userAgent) { + if (!userAgent) { + return null + } + const match = userAgent.match(/claude-cli\/([\d.]+(?:[a-zA-Z0-9-]*)?)/i) + return match ? match[1] : null + } + + /** + * 比较版本号 + * @returns {number} 1 if v1 > v2, -1 if v1 < v2, 0 if equal + */ + compareVersions(v1, v2) { + if (!v1 || !v2) { + return 0 + } + + const parts1 = v1.split('.').map(Number) + const parts2 = v2.split('.').map(Number) + + for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) { + const p1 = parts1[i] || 0 + const p2 = parts2[i] || 0 + + if (p1 > p2) { + return 1 + } + if (p1 < p2) { + return -1 + } + } + + return 0 + } + + /** + * 从客户端 headers 中提取 Claude Code 相关的 headers + */ + extractClaudeCodeHeaders(clientHeaders) { + const headers = {} + + // 转换所有 header keys 为小写进行比较 + const lowerCaseHeaders = {} + Object.keys(clientHeaders || {}).forEach((key) => { + lowerCaseHeaders[key.toLowerCase()] = clientHeaders[key] + }) + + // 提取需要的 headers + this.claudeCodeHeaderKeys.forEach((key) => { + const lowerKey = key.toLowerCase() + if (lowerCaseHeaders[lowerKey]) { + headers[key] = lowerCaseHeaders[lowerKey] + } + }) + + return headers + } + + /** + * 存储账号的 Claude Code headers + */ + async storeAccountHeaders(accountId, clientHeaders) { + try { + const extractedHeaders = this.extractClaudeCodeHeaders(clientHeaders) + + // 检查是否有 user-agent + const userAgent = extractedHeaders['user-agent'] + if (!userAgent || !/^claude-cli\/[\d.]+\s+\(/i.test(userAgent)) { + // 不是 Claude Code 的请求,不存储 + return + } + + const version = this.extractVersionFromUserAgent(userAgent) + if (!version) { + logger.warn(`⚠️ Failed to extract version from user-agent: ${userAgent}`) + return + } + + // 获取当前存储的 headers + const key = `claude_code_headers:${accountId}` + const currentData = await redis.getClient().get(key) + + if (currentData) { + const current = JSON.parse(currentData) + const currentVersion = this.extractVersionFromUserAgent(current.headers['user-agent']) + + // 只有新版本更高时才更新 + if (this.compareVersions(version, currentVersion) <= 0) { + return + } + } + + // 存储新的 headers + const data = { + headers: extractedHeaders, + version, + updatedAt: new Date().toISOString() + } + + await redis.getClient().setex(key, 86400 * 7, JSON.stringify(data)) // 7天过期 + + logger.info(`✅ Stored Claude Code headers for account ${accountId}, version: ${version}`) + } catch (error) { + logger.error(`❌ Failed to store Claude Code headers for account ${accountId}:`, error) + } + } + + /** + * 获取账号的 Claude Code headers + */ + async getAccountHeaders(accountId) { + try { + const key = `claude_code_headers:${accountId}` + const data = await redis.getClient().get(key) + + if (data) { + const parsed = JSON.parse(data) + logger.debug( + `📋 Retrieved Claude Code headers for account ${accountId}, version: ${parsed.version}` + ) + return parsed.headers + } + + // 返回默认 headers + logger.debug(`📋 Using default Claude Code headers for account ${accountId}`) + return this.defaultHeaders + } catch (error) { + logger.error(`❌ Failed to get Claude Code headers for account ${accountId}:`, error) + return this.defaultHeaders + } + } + + /** + * 清除账号的 Claude Code headers + */ + async clearAccountHeaders(accountId) { + try { + const key = `claude_code_headers:${accountId}` + await redis.getClient().del(key) + logger.info(`🗑️ Cleared Claude Code headers for account ${accountId}`) + } catch (error) { + logger.error(`❌ Failed to clear Claude Code headers for account ${accountId}:`, error) + } + } + + /** + * 获取所有账号的 headers 信息 + */ + async getAllAccountHeaders() { + try { + const pattern = 'claude_code_headers:*' + const keys = await redis.getClient().keys(pattern) + + const results = {} + for (const key of keys) { + const accountId = key.replace('claude_code_headers:', '') + const data = await redis.getClient().get(key) + if (data) { + results[accountId] = JSON.parse(data) + } + } + + return results + } catch (error) { + logger.error('❌ Failed to get all account headers:', error) + return {} + } + } +} + +module.exports = new ClaudeCodeHeadersService() diff --git a/src/services/claudeConsoleAccountService.js b/src/services/claudeConsoleAccountService.js new file mode 100644 index 0000000000000000000000000000000000000000..2a70a5304496a4fa5569cced9d36394db34ff707 --- /dev/null +++ b/src/services/claudeConsoleAccountService.js @@ -0,0 +1,1275 @@ +const { v4: uuidv4 } = require('uuid') +const crypto = require('crypto') +const ProxyHelper = require('../utils/proxyHelper') +const redis = require('../models/redis') +const logger = require('../utils/logger') +const config = require('../../config/config') +const LRUCache = require('../utils/lruCache') + +function normalizeSubscriptionExpiresAt(value) { + if (value === undefined || value === null || value === '') { + return '' + } + + const date = value instanceof Date ? value : new Date(value) + if (Number.isNaN(date.getTime())) { + return '' + } + + return date.toISOString() +} + +class ClaudeConsoleAccountService { + constructor() { + // 加密相关常量 + this.ENCRYPTION_ALGORITHM = 'aes-256-cbc' + this.ENCRYPTION_SALT = 'claude-console-salt' + + // Redis键前缀 + this.ACCOUNT_KEY_PREFIX = 'claude_console_account:' + this.SHARED_ACCOUNTS_KEY = 'shared_claude_console_accounts' + + // 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算 + // scryptSync 是 CPU 密集型操作,缓存可以减少 95%+ 的 CPU 密集型操作 + this._encryptionKeyCache = null + + // 🔄 解密结果缓存,提高解密性能 + this._decryptCache = new LRUCache(500) + + // 🧹 定期清理缓存(每10分钟) + setInterval( + () => { + this._decryptCache.cleanup() + logger.info( + '🧹 Claude Console decrypt cache cleanup completed', + this._decryptCache.getStats() + ) + }, + 10 * 60 * 1000 + ) + } + + // 🏢 创建Claude Console账户 + async createAccount(options = {}) { + const { + name = 'Claude Console Account', + description = '', + apiUrl = '', + apiKey = '', + priority = 50, // 默认优先级50(1-100) + supportedModels = [], // 支持的模型列表或映射表,空数组/对象表示支持所有 + userAgent = 'claude-cli/1.0.69 (external, cli)', + rateLimitDuration = 60, // 限流时间(分钟) + proxy = null, + isActive = true, + accountType = 'shared', // 'dedicated' or 'shared' + schedulable = true, // 是否可被调度 + dailyQuota = 0, // 每日额度限制(美元),0表示不限制 + quotaResetTime = '00:00', // 额度重置时间(HH:mm格式) + subscriptionExpiresAt = null + } = options + + // 验证必填字段 + if (!apiUrl || !apiKey) { + throw new Error('API URL and API Key are required for Claude Console account') + } + + const accountId = uuidv4() + + // 处理 supportedModels,确保向后兼容 + const processedModels = this._processModelMapping(supportedModels) + + const accountData = { + id: accountId, + platform: 'claude-console', + name, + description, + apiUrl, + apiKey: this._encryptSensitiveData(apiKey), + priority: priority.toString(), + supportedModels: JSON.stringify(processedModels), + userAgent, + rateLimitDuration: rateLimitDuration.toString(), + proxy: proxy ? JSON.stringify(proxy) : '', + isActive: isActive.toString(), + accountType, + createdAt: new Date().toISOString(), + lastUsedAt: '', + status: 'active', + errorMessage: '', + // 限流相关 + rateLimitedAt: '', + rateLimitStatus: '', + // 调度控制 + schedulable: schedulable.toString(), + // 额度管理相关 + dailyQuota: dailyQuota.toString(), // 每日额度限制(美元) + dailyUsage: '0', // 当日使用金额(美元) + // 使用与统计一致的时区日期,避免边界问题 + lastResetDate: redis.getDateStringInTimezone(), // 最后重置日期(按配置时区) + quotaResetTime, // 额度重置时间 + quotaStoppedAt: '', // 因额度停用的时间 + subscriptionExpiresAt: normalizeSubscriptionExpiresAt(subscriptionExpiresAt) + } + + const client = redis.getClientSafe() + logger.debug( + `[DEBUG] Saving account data to Redis with key: ${this.ACCOUNT_KEY_PREFIX}${accountId}` + ) + logger.debug(`[DEBUG] Account data to save: ${JSON.stringify(accountData, null, 2)}`) + + await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, accountData) + + // 如果是共享账户,添加到共享账户集合 + if (accountType === 'shared') { + await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId) + } + + logger.success(`🏢 Created Claude Console account: ${name} (${accountId})`) + + return { + id: accountId, + name, + description, + apiUrl, + priority, + supportedModels, + userAgent, + rateLimitDuration, + isActive, + proxy, + accountType, + status: 'active', + createdAt: accountData.createdAt, + dailyQuota, + dailyUsage: 0, + lastResetDate: accountData.lastResetDate, + quotaResetTime, + quotaStoppedAt: null, + subscriptionExpiresAt: accountData.subscriptionExpiresAt || null + } + } + + // 📋 获取所有Claude Console账户 + async getAllAccounts() { + try { + const client = redis.getClientSafe() + const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`) + const accounts = [] + + for (const key of keys) { + const accountData = await client.hgetall(key) + if (accountData && Object.keys(accountData).length > 0) { + // 获取限流状态信息 + const rateLimitInfo = this._getRateLimitInfo(accountData) + + accounts.push({ + id: accountData.id, + platform: accountData.platform, + name: accountData.name, + description: accountData.description, + apiUrl: accountData.apiUrl, + priority: parseInt(accountData.priority) || 50, + supportedModels: JSON.parse(accountData.supportedModels || '[]'), + userAgent: accountData.userAgent, + rateLimitDuration: Number.isNaN(parseInt(accountData.rateLimitDuration)) + ? 60 + : parseInt(accountData.rateLimitDuration), + isActive: accountData.isActive === 'true', + proxy: accountData.proxy ? JSON.parse(accountData.proxy) : null, + accountType: accountData.accountType || 'shared', + createdAt: accountData.createdAt, + lastUsedAt: accountData.lastUsedAt, + status: accountData.status || 'active', + errorMessage: accountData.errorMessage, + rateLimitInfo, + schedulable: accountData.schedulable !== 'false', // 默认为true,只有明确设置为false才不可调度 + // 额度管理相关 + dailyQuota: parseFloat(accountData.dailyQuota || '0'), + dailyUsage: parseFloat(accountData.dailyUsage || '0'), + lastResetDate: accountData.lastResetDate || '', + quotaResetTime: accountData.quotaResetTime || '00:00', + quotaStoppedAt: accountData.quotaStoppedAt || null, + expiresAt: accountData.expiresAt || null, + subscriptionExpiresAt: accountData.subscriptionExpiresAt || null + }) + } + } + + return accounts + } catch (error) { + logger.error('❌ Failed to get Claude Console accounts:', error) + throw error + } + } + + // 🔍 获取单个账户(内部使用,包含敏感信息) + async getAccount(accountId) { + const client = redis.getClientSafe() + logger.debug(`[DEBUG] Getting account data for ID: ${accountId}`) + const accountData = await client.hgetall(`${this.ACCOUNT_KEY_PREFIX}${accountId}`) + + if (!accountData || Object.keys(accountData).length === 0) { + logger.debug(`[DEBUG] No account data found for ID: ${accountId}`) + return null + } + + logger.debug(`[DEBUG] Raw account data keys: ${Object.keys(accountData).join(', ')}`) + logger.debug(`[DEBUG] Raw supportedModels value: ${accountData.supportedModels}`) + + // 解密敏感字段(只解密apiKey,apiUrl不加密) + const decryptedKey = this._decryptSensitiveData(accountData.apiKey) + logger.debug( + `[DEBUG] URL exists: ${!!accountData.apiUrl}, Decrypted key exists: ${!!decryptedKey}` + ) + + accountData.apiKey = decryptedKey + + // 解析JSON字段 + const parsedModels = JSON.parse(accountData.supportedModels || '[]') + logger.debug(`[DEBUG] Parsed supportedModels: ${JSON.stringify(parsedModels)}`) + + accountData.supportedModels = parsedModels + accountData.priority = parseInt(accountData.priority) || 50 + { + const _parsedDuration = parseInt(accountData.rateLimitDuration) + accountData.rateLimitDuration = Number.isNaN(_parsedDuration) ? 60 : _parsedDuration + } + accountData.isActive = accountData.isActive === 'true' + accountData.schedulable = accountData.schedulable !== 'false' // 默认为true + + if (accountData.proxy) { + accountData.proxy = JSON.parse(accountData.proxy) + } + + accountData.subscriptionExpiresAt = + accountData.subscriptionExpiresAt && accountData.subscriptionExpiresAt !== '' + ? accountData.subscriptionExpiresAt + : null + + logger.debug( + `[DEBUG] Final account data - name: ${accountData.name}, hasApiUrl: ${!!accountData.apiUrl}, hasApiKey: ${!!accountData.apiKey}, supportedModels: ${JSON.stringify(accountData.supportedModels)}` + ) + + return accountData + } + + // 📝 更新账户 + async updateAccount(accountId, updates) { + try { + const existingAccount = await this.getAccount(accountId) + if (!existingAccount) { + throw new Error('Account not found') + } + + const client = redis.getClientSafe() + const updatedData = {} + + // 处理各个字段的更新 + logger.debug( + `[DEBUG] Update request received with fields: ${Object.keys(updates).join(', ')}` + ) + logger.debug(`[DEBUG] Updates content: ${JSON.stringify(updates, null, 2)}`) + + if (updates.name !== undefined) { + updatedData.name = updates.name + } + if (updates.description !== undefined) { + updatedData.description = updates.description + } + if (updates.apiUrl !== undefined) { + logger.debug(`[DEBUG] Updating apiUrl from frontend: ${updates.apiUrl}`) + updatedData.apiUrl = updates.apiUrl + } + if (updates.apiKey !== undefined) { + logger.debug(`[DEBUG] Updating apiKey (length: ${updates.apiKey?.length})`) + updatedData.apiKey = this._encryptSensitiveData(updates.apiKey) + } + if (updates.priority !== undefined) { + updatedData.priority = updates.priority.toString() + } + if (updates.supportedModels !== undefined) { + logger.debug(`[DEBUG] Updating supportedModels: ${JSON.stringify(updates.supportedModels)}`) + // 处理 supportedModels,确保向后兼容 + const processedModels = this._processModelMapping(updates.supportedModels) + updatedData.supportedModels = JSON.stringify(processedModels) + } + if (updates.userAgent !== undefined) { + updatedData.userAgent = updates.userAgent + } + if (updates.rateLimitDuration !== undefined) { + updatedData.rateLimitDuration = updates.rateLimitDuration.toString() + } + if (updates.proxy !== undefined) { + updatedData.proxy = updates.proxy ? JSON.stringify(updates.proxy) : '' + } + if (updates.isActive !== undefined) { + updatedData.isActive = updates.isActive.toString() + } + if (updates.schedulable !== undefined) { + updatedData.schedulable = updates.schedulable.toString() + // 如果是手动修改调度状态,清除所有自动停止相关的字段 + // 防止自动恢复 + updatedData.rateLimitAutoStopped = '' + updatedData.quotaAutoStopped = '' + // 兼容旧的标记 + updatedData.autoStoppedAt = '' + updatedData.stoppedReason = '' + + // 记录日志 + if (updates.schedulable === true || updates.schedulable === 'true') { + logger.info(`✅ Manually enabled scheduling for Claude Console account ${accountId}`) + } else { + logger.info(`⛔ Manually disabled scheduling for Claude Console account ${accountId}`) + } + } + + // 额度管理相关字段 + if (updates.dailyQuota !== undefined) { + updatedData.dailyQuota = updates.dailyQuota.toString() + } + if (updates.quotaResetTime !== undefined) { + updatedData.quotaResetTime = updates.quotaResetTime + } + if (updates.dailyUsage !== undefined) { + updatedData.dailyUsage = updates.dailyUsage.toString() + } + if (updates.lastResetDate !== undefined) { + updatedData.lastResetDate = updates.lastResetDate + } + if (updates.quotaStoppedAt !== undefined) { + updatedData.quotaStoppedAt = updates.quotaStoppedAt + } + + if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) { + updatedData.subscriptionExpiresAt = normalizeSubscriptionExpiresAt( + updates.subscriptionExpiresAt + ) + } else if (Object.prototype.hasOwnProperty.call(updates, 'expiresAt')) { + updatedData.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(updates.expiresAt) + } + + // 处理账户类型变更 + if (updates.accountType && updates.accountType !== existingAccount.accountType) { + updatedData.accountType = updates.accountType + + if (updates.accountType === 'shared') { + await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId) + } else { + await client.srem(this.SHARED_ACCOUNTS_KEY, accountId) + } + } + + updatedData.updatedAt = new Date().toISOString() + + // 检查是否手动禁用了账号,如果是则发送webhook通知 + if (updates.isActive === false && existingAccount.isActive === true) { + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: updatedData.name || existingAccount.name || 'Unknown Account', + platform: 'claude-console', + status: 'disabled', + errorCode: 'CLAUDE_CONSOLE_MANUALLY_DISABLED', + reason: 'Account manually disabled by administrator' + }) + } catch (webhookError) { + logger.error( + 'Failed to send webhook notification for manual account disable:', + webhookError + ) + } + } + + logger.debug(`[DEBUG] Final updatedData to save: ${JSON.stringify(updatedData, null, 2)}`) + logger.debug(`[DEBUG] Updating Redis key: ${this.ACCOUNT_KEY_PREFIX}${accountId}`) + + await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updatedData) + + logger.success(`📝 Updated Claude Console account: ${accountId}`) + + return { success: true } + } catch (error) { + logger.error('❌ Failed to update Claude Console account:', error) + throw error + } + } + + // 🗑️ 删除账户 + async deleteAccount(accountId) { + try { + const client = redis.getClientSafe() + const account = await this.getAccount(accountId) + + if (!account) { + throw new Error('Account not found') + } + + // 从Redis删除 + await client.del(`${this.ACCOUNT_KEY_PREFIX}${accountId}`) + + // 从共享账户集合中移除 + if (account.accountType === 'shared') { + await client.srem(this.SHARED_ACCOUNTS_KEY, accountId) + } + + logger.success(`🗑️ Deleted Claude Console account: ${accountId}`) + + return { success: true } + } catch (error) { + logger.error('❌ Failed to delete Claude Console account:', error) + throw error + } + } + + // 🚫 标记账号为限流状态 + async markAccountRateLimited(accountId) { + try { + const client = redis.getClientSafe() + const account = await this.getAccount(accountId) + + if (!account) { + throw new Error('Account not found') + } + + // 如果限流时间设置为 0,表示不启用限流机制,直接返回 + if (account.rateLimitDuration === 0) { + logger.info( + `ℹ️ Claude Console account ${account.name} (${accountId}) has rate limiting disabled, skipping rate limit` + ) + return { success: true, skipped: true } + } + + const updates = { + rateLimitedAt: new Date().toISOString(), + rateLimitStatus: 'limited', + isActive: 'false', // 禁用账户 + schedulable: 'false', // 停止调度,与其他平台保持一致 + errorMessage: `Rate limited at ${new Date().toISOString()}`, + // 使用独立的限流自动停止标记 + rateLimitAutoStopped: 'true' + } + + // 只有当前状态不是quota_exceeded时才设置为rate_limited + // 避免覆盖更重要的配额超限状态 + const currentStatus = await client.hget(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, 'status') + if (currentStatus !== 'quota_exceeded') { + updates.status = 'rate_limited' + } + + await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates) + + // 发送Webhook通知 + try { + const webhookNotifier = require('../utils/webhookNotifier') + const { getISOStringWithTimezone } = require('../utils/dateHelper') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: account.name || 'Claude Console Account', + platform: 'claude-console', + status: 'error', + errorCode: 'CLAUDE_CONSOLE_RATE_LIMITED', + reason: `Account rate limited (429 error) and has been disabled. ${account.rateLimitDuration ? `Will be automatically re-enabled after ${account.rateLimitDuration} minutes` : 'Manual intervention required to re-enable'}`, + timestamp: getISOStringWithTimezone(new Date()) + }) + } catch (webhookError) { + logger.error('Failed to send rate limit webhook notification:', webhookError) + } + + logger.warn( + `🚫 Claude Console account marked as rate limited: ${account.name} (${accountId})` + ) + return { success: true } + } catch (error) { + logger.error(`❌ Failed to mark Claude Console account as rate limited: ${accountId}`, error) + throw error + } + } + + // ✅ 移除账号的限流状态 + async removeAccountRateLimit(accountId) { + try { + const client = redis.getClientSafe() + const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}` + + // 获取账户当前状态和额度信息 + const [currentStatus, quotaStoppedAt] = await client.hmget( + accountKey, + 'status', + 'quotaStoppedAt' + ) + + // 删除限流相关字段 + await client.hdel(accountKey, 'rateLimitedAt', 'rateLimitStatus') + + // 根据不同情况决定是否恢复账户 + if (currentStatus === 'rate_limited') { + if (quotaStoppedAt) { + // 还有额度限制,改为quota_exceeded状态 + await client.hset(accountKey, { + status: 'quota_exceeded' + // isActive保持false + }) + logger.info(`⚠️ Rate limit removed but quota exceeded remains for account: ${accountId}`) + } else { + // 没有额度限制,完全恢复 + const accountData = await client.hgetall(accountKey) + const updateData = { + isActive: 'true', + status: 'active', + errorMessage: '' + } + + const hadAutoStop = accountData.rateLimitAutoStopped === 'true' + + // 只恢复因限流而自动停止的账户 + if (hadAutoStop && accountData.schedulable === 'false') { + updateData.schedulable = 'true' // 恢复调度 + logger.info( + `✅ Auto-resuming scheduling for Claude Console account ${accountId} after rate limit cleared` + ) + } + + if (hadAutoStop) { + await client.hdel(accountKey, 'rateLimitAutoStopped') + } + + await client.hset(accountKey, updateData) + logger.success(`✅ Rate limit removed and account re-enabled: ${accountId}`) + } + } else { + if (await client.hdel(accountKey, 'rateLimitAutoStopped')) { + logger.info( + `ℹ️ Removed stale auto-stop flag for Claude Console account ${accountId} during rate limit recovery` + ) + } + logger.success(`✅ Rate limit removed for Claude Console account: ${accountId}`) + } + + return { success: true } + } catch (error) { + logger.error(`❌ Failed to remove rate limit for Claude Console account: ${accountId}`, error) + throw error + } + } + + // 🔍 检查账号是否处于限流状态 + async isAccountRateLimited(accountId) { + try { + const account = await this.getAccount(accountId) + if (!account) { + return false + } + + // 如果限流时间设置为 0,表示不启用限流机制 + if (account.rateLimitDuration === 0) { + return false + } + + if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) { + const rateLimitedAt = new Date(account.rateLimitedAt) + const now = new Date() + const minutesSinceRateLimit = (now - rateLimitedAt) / (1000 * 60) + + // 使用账户配置的限流时间 + const rateLimitDuration = + typeof account.rateLimitDuration === 'number' && !Number.isNaN(account.rateLimitDuration) + ? account.rateLimitDuration + : 60 + + if (minutesSinceRateLimit >= rateLimitDuration) { + await this.removeAccountRateLimit(accountId) + return false + } + + return true + } + + return false + } catch (error) { + logger.error( + `❌ Failed to check rate limit status for Claude Console account: ${accountId}`, + error + ) + return false + } + } + + // 🔍 检查账号是否因额度超限而被停用(懒惰检查) + async isAccountQuotaExceeded(accountId) { + try { + const account = await this.getAccount(accountId) + if (!account) { + return false + } + + // 如果没有设置额度限制,不会超额 + const dailyQuota = parseFloat(account.dailyQuota || '0') + if (isNaN(dailyQuota) || dailyQuota <= 0) { + return false + } + + // 如果账户没有被额度停用,检查当前使用情况 + if (!account.quotaStoppedAt) { + return false + } + + // 检查是否应该重置额度(到了新的重置时间点) + if (this._shouldResetQuota(account)) { + await this.resetDailyUsage(accountId) + return false + } + + // 仍在额度超限状态 + return true + } catch (error) { + logger.error( + `❌ Failed to check quota exceeded status for Claude Console account: ${accountId}`, + error + ) + return false + } + } + + // 🔍 判断是否应该重置账户额度 + _shouldResetQuota(account) { + // 与 Redis 统计一致:按配置时区判断“今天”与时间点 + const tzNow = redis.getDateInTimezone(new Date()) + const today = redis.getDateStringInTimezone(tzNow) + + // 如果已经是今天重置过的,不需要重置 + if (account.lastResetDate === today) { + return false + } + + // 检查是否到了重置时间点(按配置时区的小时/分钟) + const resetTime = account.quotaResetTime || '00:00' + const [resetHour, resetMinute] = resetTime.split(':').map((n) => parseInt(n)) + + const currentHour = tzNow.getUTCHours() + const currentMinute = tzNow.getUTCMinutes() + + // 如果当前时间已过重置时间且不是同一天重置的,应该重置 + return currentHour > resetHour || (currentHour === resetHour && currentMinute >= resetMinute) + } + + // 🚫 标记账号为未授权状态(401错误) + async markAccountUnauthorized(accountId) { + try { + const client = redis.getClientSafe() + const account = await this.getAccount(accountId) + + if (!account) { + throw new Error('Account not found') + } + + const updates = { + schedulable: 'false', + status: 'unauthorized', + errorMessage: 'API Key无效或已过期(401错误)', + unauthorizedAt: new Date().toISOString(), + unauthorizedCount: String((parseInt(account.unauthorizedCount || '0') || 0) + 1) + } + + await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates) + + // 发送Webhook通知 + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: account.name || 'Claude Console Account', + platform: 'claude-console', + status: 'error', + errorCode: 'CLAUDE_CONSOLE_UNAUTHORIZED', + reason: 'API Key无效或已过期(401错误),账户已停止调度', + timestamp: new Date().toISOString() + }) + } catch (webhookError) { + logger.error('Failed to send unauthorized webhook notification:', webhookError) + } + + logger.warn( + `🚫 Claude Console account marked as unauthorized: ${account.name} (${accountId})` + ) + return { success: true } + } catch (error) { + logger.error(`❌ Failed to mark Claude Console account as unauthorized: ${accountId}`, error) + throw error + } + } + + // 🚫 标记账号为过载状态(529错误) + async markAccountOverloaded(accountId) { + try { + const client = redis.getClientSafe() + const account = await this.getAccount(accountId) + + if (!account) { + throw new Error('Account not found') + } + + const updates = { + overloadedAt: new Date().toISOString(), + overloadStatus: 'overloaded', + errorMessage: '服务过载(529错误)' + } + + await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates) + + // 发送Webhook通知 + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: account.name || 'Claude Console Account', + platform: 'claude-console', + status: 'error', + errorCode: 'CLAUDE_CONSOLE_OVERLOADED', + reason: '服务过载(529错误)。账户将暂时停止调度', + timestamp: new Date().toISOString() + }) + } catch (webhookError) { + logger.error('Failed to send overload webhook notification:', webhookError) + } + + logger.warn(`🚫 Claude Console account marked as overloaded: ${account.name} (${accountId})`) + return { success: true } + } catch (error) { + logger.error(`❌ Failed to mark Claude Console account as overloaded: ${accountId}`, error) + throw error + } + } + + // ✅ 移除账号的过载状态 + async removeAccountOverload(accountId) { + try { + const client = redis.getClientSafe() + + await client.hdel(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, 'overloadedAt', 'overloadStatus') + + logger.success(`✅ Overload status removed for Claude Console account: ${accountId}`) + return { success: true } + } catch (error) { + logger.error( + `❌ Failed to remove overload status for Claude Console account: ${accountId}`, + error + ) + throw error + } + } + + // 🔍 检查账号是否处于过载状态 + async isAccountOverloaded(accountId) { + try { + const account = await this.getAccount(accountId) + if (!account) { + return false + } + + if (account.overloadStatus === 'overloaded' && account.overloadedAt) { + const overloadedAt = new Date(account.overloadedAt) + const now = new Date() + const minutesSinceOverload = (now - overloadedAt) / (1000 * 60) + + // 过载状态持续10分钟后自动恢复 + if (minutesSinceOverload >= 10) { + await this.removeAccountOverload(accountId) + return false + } + + return true + } + + return false + } catch (error) { + logger.error( + `❌ Failed to check overload status for Claude Console account: ${accountId}`, + error + ) + return false + } + } + + // 🚫 标记账号为封锁状态(模型不支持等原因) + async blockAccount(accountId, reason) { + try { + const client = redis.getClientSafe() + + // 获取账户信息用于webhook通知 + const accountData = await client.hgetall(`${this.ACCOUNT_KEY_PREFIX}${accountId}`) + + const updates = { + status: 'blocked', + errorMessage: reason, + blockedAt: new Date().toISOString() + } + + await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates) + + logger.warn(`🚫 Claude Console account blocked: ${accountId} - ${reason}`) + + // 发送Webhook通知 + if (accountData && Object.keys(accountData).length > 0) { + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: accountData.name || 'Unknown Account', + platform: 'claude-console', + status: 'blocked', + errorCode: 'CLAUDE_CONSOLE_BLOCKED', + reason + }) + } catch (webhookError) { + logger.error('Failed to send webhook notification:', webhookError) + } + } + + return { success: true } + } catch (error) { + logger.error(`❌ Failed to block Claude Console account: ${accountId}`, error) + throw error + } + } + + // 🌐 创建代理agent(使用统一的代理工具) + _createProxyAgent(proxyConfig) { + const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig) + if (proxyAgent) { + logger.info( + `🌐 Using proxy for Claude Console request: ${ProxyHelper.getProxyDescription(proxyConfig)}` + ) + } else if (proxyConfig) { + logger.debug('🌐 Failed to create proxy agent for Claude Console') + } else { + logger.debug('🌐 No proxy configured for Claude Console request') + } + return proxyAgent + } + + // 🔐 加密敏感数据 + _encryptSensitiveData(data) { + if (!data) { + return '' + } + + try { + const key = this._generateEncryptionKey() + const iv = crypto.randomBytes(16) + + const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv) + let encrypted = cipher.update(data, 'utf8', 'hex') + encrypted += cipher.final('hex') + + return `${iv.toString('hex')}:${encrypted}` + } catch (error) { + logger.error('❌ Encryption error:', error) + return data + } + } + + // 🔓 解密敏感数据 + _decryptSensitiveData(encryptedData) { + if (!encryptedData) { + return '' + } + + // 🎯 检查缓存 + const cacheKey = crypto.createHash('sha256').update(encryptedData).digest('hex') + const cached = this._decryptCache.get(cacheKey) + if (cached !== undefined) { + return cached + } + + try { + if (encryptedData.includes(':')) { + const parts = encryptedData.split(':') + if (parts.length === 2) { + const key = this._generateEncryptionKey() + const iv = Buffer.from(parts[0], 'hex') + const encrypted = parts[1] + + const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv) + let decrypted = decipher.update(encrypted, 'hex', 'utf8') + decrypted += decipher.final('utf8') + + // 💾 存入缓存(5分钟过期) + this._decryptCache.set(cacheKey, decrypted, 5 * 60 * 1000) + + // 📊 定期打印缓存统计 + if ((this._decryptCache.hits + this._decryptCache.misses) % 1000 === 0) { + this._decryptCache.printStats() + } + + return decrypted + } + } + + return encryptedData + } catch (error) { + logger.error('❌ Decryption error:', error) + return encryptedData + } + } + + // 🔑 生成加密密钥 + _generateEncryptionKey() { + // 性能优化:缓存密钥派生结果,避免重复的 CPU 密集计算 + // scryptSync 是故意设计为慢速的密钥派生函数(防暴力破解) + // 但在高并发场景下,每次都重新计算会导致 CPU 100% 占用 + if (!this._encryptionKeyCache) { + // 只在第一次调用时计算,后续使用缓存 + // 由于输入参数固定,派生结果永远相同,不影响数据兼容性 + this._encryptionKeyCache = crypto.scryptSync( + config.security.encryptionKey, + this.ENCRYPTION_SALT, + 32 + ) + logger.info('🔑 Console encryption key derived and cached for performance optimization') + } + return this._encryptionKeyCache + } + + // 🎭 掩码API URL + _maskApiUrl(apiUrl) { + if (!apiUrl) { + return '' + } + + try { + const url = new URL(apiUrl) + return `${url.protocol}//${url.hostname}/***` + } catch { + return '***' + } + } + + // 📊 获取限流信息 + _getRateLimitInfo(accountData) { + if (accountData.rateLimitStatus === 'limited' && accountData.rateLimitedAt) { + const rateLimitedAt = new Date(accountData.rateLimitedAt) + const now = new Date() + const minutesSinceRateLimit = Math.floor((now - rateLimitedAt) / (1000 * 60)) + const __parsedDuration = parseInt(accountData.rateLimitDuration) + const rateLimitDuration = Number.isNaN(__parsedDuration) ? 60 : __parsedDuration + const minutesRemaining = Math.max(0, rateLimitDuration - minutesSinceRateLimit) + + return { + isRateLimited: minutesRemaining > 0, + rateLimitedAt: accountData.rateLimitedAt, + minutesSinceRateLimit, + minutesRemaining + } + } + + return { + isRateLimited: false, + rateLimitedAt: null, + minutesSinceRateLimit: 0, + minutesRemaining: 0 + } + } + + // 🔄 处理模型映射,确保向后兼容 + _processModelMapping(supportedModels) { + // 如果是空值,返回空对象(支持所有模型) + if (!supportedModels || (Array.isArray(supportedModels) && supportedModels.length === 0)) { + return {} + } + + // 如果已经是对象格式(新的映射表格式),直接返回 + if (typeof supportedModels === 'object' && !Array.isArray(supportedModels)) { + return supportedModels + } + + // 如果是数组格式(旧格式),转换为映射表 + if (Array.isArray(supportedModels)) { + const mapping = {} + supportedModels.forEach((model) => { + if (model && typeof model === 'string') { + mapping[model] = model // 映射到自身 + } + }) + return mapping + } + + // 其他情况返回空对象 + return {} + } + + // 🔍 检查模型是否支持(用于调度) + isModelSupported(modelMapping, requestedModel) { + // 如果映射表为空,支持所有模型 + if (!modelMapping || Object.keys(modelMapping).length === 0) { + return true + } + + // 检查请求的模型是否在映射表的键中 + return Object.prototype.hasOwnProperty.call(modelMapping, requestedModel) + } + + // 🔄 获取映射后的模型名称 + getMappedModel(modelMapping, requestedModel) { + // 如果映射表为空,返回原模型 + if (!modelMapping || Object.keys(modelMapping).length === 0) { + return requestedModel + } + + // 返回映射后的模型,如果不存在则返回原模型 + return modelMapping[requestedModel] || requestedModel + } + + // 💰 检查账户使用额度(基于实时统计数据) + async checkQuotaUsage(accountId) { + try { + // 获取实时的使用统计(包含费用) + const usageStats = await redis.getAccountUsageStats(accountId) + const currentDailyCost = usageStats.daily.cost || 0 + + // 获取账户配置 + const accountData = await this.getAccount(accountId) + if (!accountData) { + logger.warn(`Account not found: ${accountId}`) + return + } + + // 解析额度配置,确保数值有效 + const dailyQuota = parseFloat(accountData.dailyQuota || '0') + if (isNaN(dailyQuota) || dailyQuota <= 0) { + // 没有设置有效额度,无需检查 + return + } + + // 检查是否已经因额度停用(避免重复操作) + if (!accountData.isActive && accountData.quotaStoppedAt) { + return + } + + // 检查是否超过额度限制 + if (currentDailyCost >= dailyQuota) { + // 使用原子操作避免竞态条件 - 再次检查是否已设置quotaStoppedAt + const client = redis.getClientSafe() + const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}` + + // double-check locking pattern - 检查quotaStoppedAt而不是status + const existingQuotaStop = await client.hget(accountKey, 'quotaStoppedAt') + if (existingQuotaStop) { + return // 已经被其他进程处理 + } + + // 超过额度,停用账户 + const updates = { + isActive: false, + quotaStoppedAt: new Date().toISOString(), + errorMessage: `Daily quota exceeded: $${currentDailyCost.toFixed(2)} / $${dailyQuota.toFixed(2)}`, + schedulable: false, // 停止调度 + // 使用独立的额度超限自动停止标记 + quotaAutoStopped: 'true' + } + + // 只有当前状态是active时才改为quota_exceeded + // 如果是rate_limited等其他状态,保持原状态不变 + const currentStatus = await client.hget(accountKey, 'status') + if (currentStatus === 'active') { + updates.status = 'quota_exceeded' + } + + await this.updateAccount(accountId, updates) + + logger.warn( + `💰 Account ${accountId} exceeded daily quota: $${currentDailyCost.toFixed(2)} / $${dailyQuota.toFixed(2)}` + ) + + // 发送webhook通知 + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: accountData.name || 'Unknown Account', + platform: 'claude-console', + status: 'quota_exceeded', + errorCode: 'CLAUDE_CONSOLE_QUOTA_EXCEEDED', + reason: `Daily quota exceeded: $${currentDailyCost.toFixed(2)} / $${dailyQuota.toFixed(2)}` + }) + } catch (webhookError) { + logger.error('Failed to send webhook notification for quota exceeded:', webhookError) + } + } + + logger.debug( + `💰 Quota check for account ${accountId}: $${currentDailyCost.toFixed(4)} / $${dailyQuota.toFixed(2)}` + ) + } catch (error) { + logger.error('Failed to check quota usage:', error) + } + } + + // 🔄 重置账户每日使用量(恢复因额度停用的账户) + async resetDailyUsage(accountId) { + try { + const accountData = await this.getAccount(accountId) + if (!accountData) { + return + } + + const today = redis.getDateStringInTimezone() + const updates = { + lastResetDate: today + } + + // 如果账户是因为超额被停用的,恢复账户 + // 注意:状态可能是 quota_exceeded 或 rate_limited(如果429错误时也超额了) + if ( + accountData.quotaStoppedAt && + accountData.isActive === false && + (accountData.status === 'quota_exceeded' || accountData.status === 'rate_limited') + ) { + updates.isActive = true + updates.status = 'active' + updates.errorMessage = '' + updates.quotaStoppedAt = '' + + // 只恢复因额度超限而自动停止的账户 + if (accountData.quotaAutoStopped === 'true') { + updates.schedulable = true + updates.quotaAutoStopped = '' + } + + // 如果是rate_limited状态,也清除限流相关字段 + if (accountData.status === 'rate_limited') { + const client = redis.getClientSafe() + const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}` + await client.hdel(accountKey, 'rateLimitedAt', 'rateLimitStatus', 'rateLimitAutoStopped') + } + + logger.info( + `✅ Restored account ${accountId} after daily reset (was ${accountData.status})` + ) + } + + await this.updateAccount(accountId, updates) + + logger.debug(`🔄 Reset daily usage for account ${accountId}`) + } catch (error) { + logger.error('Failed to reset daily usage:', error) + } + } + + // 🔄 重置所有账户的每日使用量 + async resetAllDailyUsage() { + try { + const accounts = await this.getAllAccounts() + // 与统计一致使用配置时区日期 + const today = redis.getDateStringInTimezone() + let resetCount = 0 + + for (const account of accounts) { + // 只重置需要重置的账户 + if (account.lastResetDate !== today) { + await this.resetDailyUsage(account.id) + resetCount += 1 + } + } + + logger.success(`✅ Reset daily usage for ${resetCount} Claude Console accounts`) + } catch (error) { + logger.error('Failed to reset all daily usage:', error) + } + } + + // 📊 获取账户使用统计(基于实时数据) + async getAccountUsageStats(accountId) { + try { + // 获取实时的使用统计(包含费用) + const usageStats = await redis.getAccountUsageStats(accountId) + const currentDailyCost = usageStats.daily.cost || 0 + + // 获取账户配置 + const accountData = await this.getAccount(accountId) + if (!accountData) { + return null + } + + const dailyQuota = parseFloat(accountData.dailyQuota || '0') + + return { + dailyQuota, + dailyUsage: currentDailyCost, // 使用实时计算的费用 + remainingQuota: dailyQuota > 0 ? Math.max(0, dailyQuota - currentDailyCost) : null, + usagePercentage: dailyQuota > 0 ? (currentDailyCost / dailyQuota) * 100 : 0, + lastResetDate: accountData.lastResetDate, + quotaStoppedAt: accountData.quotaStoppedAt, + isQuotaExceeded: dailyQuota > 0 && currentDailyCost >= dailyQuota, + // 额外返回完整的使用统计 + fullUsageStats: usageStats + } + } catch (error) { + logger.error('Failed to get account usage stats:', error) + return null + } + } + + // 🔄 重置账户所有异常状态 + async resetAccountStatus(accountId) { + try { + const accountData = await this.getAccount(accountId) + if (!accountData) { + throw new Error('Account not found') + } + + const client = redis.getClientSafe() + const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}` + + // 准备要更新的字段 + const updates = { + status: 'active', + errorMessage: '', + schedulable: 'true', + isActive: 'true' // 重要:必须恢复isActive状态 + } + + // 删除所有异常状态相关的字段 + const fieldsToDelete = [ + 'rateLimitedAt', + 'rateLimitStatus', + 'unauthorizedAt', + 'unauthorizedCount', + 'overloadedAt', + 'overloadStatus', + 'blockedAt', + 'quotaStoppedAt' + ] + + // 执行更新 + await client.hset(accountKey, updates) + await client.hdel(accountKey, ...fieldsToDelete) + + logger.success(`✅ Reset all error status for Claude Console account ${accountId}`) + + // 发送 Webhook 通知 + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: accountData.name || accountId, + platform: 'claude-console', + status: 'recovered', + errorCode: 'STATUS_RESET', + reason: 'Account status manually reset', + timestamp: new Date().toISOString() + }) + } catch (webhookError) { + logger.warn('Failed to send webhook notification:', webhookError) + } + + return { success: true, accountId } + } catch (error) { + logger.error(`❌ Failed to reset Claude Console account status: ${accountId}`, error) + throw error + } + } +} + +module.exports = new ClaudeConsoleAccountService() diff --git a/src/services/claudeConsoleRelayService.js b/src/services/claudeConsoleRelayService.js new file mode 100644 index 0000000000000000000000000000000000000000..de919a86e43669b9c173a5e8e555af00bacd2e9c --- /dev/null +++ b/src/services/claudeConsoleRelayService.js @@ -0,0 +1,813 @@ +const axios = require('axios') +const claudeConsoleAccountService = require('./claudeConsoleAccountService') +const logger = require('../utils/logger') +const config = require('../../config/config') + +class ClaudeConsoleRelayService { + constructor() { + this.defaultUserAgent = 'claude-cli/1.0.69 (external, cli)' + } + + // 🚀 转发请求到Claude Console API + async relayRequest( + requestBody, + apiKeyData, + clientRequest, + clientResponse, + clientHeaders, + accountId, + options = {} + ) { + let abortController = null + let account = null + + try { + // 获取账户信息 + account = await claudeConsoleAccountService.getAccount(accountId) + if (!account) { + throw new Error('Claude Console Claude account not found') + } + + logger.info( + `📤 Processing Claude Console API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${account.name} (${accountId})` + ) + logger.debug(`🌐 Account API URL: ${account.apiUrl}`) + logger.debug(`🔍 Account supportedModels: ${JSON.stringify(account.supportedModels)}`) + logger.debug(`🔑 Account has apiKey: ${!!account.apiKey}`) + logger.debug(`📝 Request model: ${requestBody.model}`) + + // 处理模型映射 + let mappedModel = requestBody.model + if ( + account.supportedModels && + typeof account.supportedModels === 'object' && + !Array.isArray(account.supportedModels) + ) { + const newModel = claudeConsoleAccountService.getMappedModel( + account.supportedModels, + requestBody.model + ) + if (newModel !== requestBody.model) { + logger.info(`🔄 Mapping model from ${requestBody.model} to ${newModel}`) + mappedModel = newModel + } + } + + // 创建修改后的请求体 + const modifiedRequestBody = { + ...requestBody, + model: mappedModel + } + + // 模型兼容性检查已经在调度器中完成,这里不需要再检查 + + // 创建代理agent + const proxyAgent = claudeConsoleAccountService._createProxyAgent(account.proxy) + + // 创建AbortController用于取消请求 + abortController = new AbortController() + + // 设置客户端断开监听器 + const handleClientDisconnect = () => { + logger.info('🔌 Client disconnected, aborting Claude Console Claude request') + if (abortController && !abortController.signal.aborted) { + abortController.abort() + } + } + + // 监听客户端断开事件 + if (clientRequest) { + clientRequest.once('close', handleClientDisconnect) + } + if (clientResponse) { + clientResponse.once('close', handleClientDisconnect) + } + + // 构建完整的API URL + const cleanUrl = account.apiUrl.replace(/\/$/, '') // 移除末尾斜杠 + let apiEndpoint + + if (options.customPath) { + // 如果指定了自定义路径(如 count_tokens),使用它 + const baseUrl = cleanUrl.replace(/\/v1\/messages$/, '') // 移除已有的 /v1/messages + apiEndpoint = `${baseUrl}${options.customPath}` + } else { + // 默认使用 messages 端点 + apiEndpoint = cleanUrl.endsWith('/v1/messages') ? cleanUrl : `${cleanUrl}/v1/messages` + } + + logger.debug(`🎯 Final API endpoint: ${apiEndpoint}`) + logger.debug(`[DEBUG] Options passed to relayRequest: ${JSON.stringify(options)}`) + logger.debug(`[DEBUG] Client headers received: ${JSON.stringify(clientHeaders)}`) + + // 过滤客户端请求头 + const filteredHeaders = this._filterClientHeaders(clientHeaders) + logger.debug(`[DEBUG] Filtered client headers: ${JSON.stringify(filteredHeaders)}`) + + // 决定使用的 User-Agent:优先使用账户自定义的,否则透传客户端的,最后才使用默认值 + const userAgent = + account.userAgent || + clientHeaders?.['user-agent'] || + clientHeaders?.['User-Agent'] || + this.defaultUserAgent + + // 准备请求配置 + const requestConfig = { + method: 'POST', + url: apiEndpoint, + data: modifiedRequestBody, + headers: { + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + 'User-Agent': userAgent, + ...filteredHeaders + }, + httpsAgent: proxyAgent, + timeout: config.requestTimeout || 600000, + signal: abortController.signal, + validateStatus: () => true // 接受所有状态码 + } + + // 根据 API Key 格式选择认证方式 + if (account.apiKey && account.apiKey.startsWith('sk-ant-')) { + // Anthropic 官方 API Key 使用 x-api-key + requestConfig.headers['x-api-key'] = account.apiKey + logger.debug('[DEBUG] Using x-api-key authentication for sk-ant-* API key') + } else { + // 其他 API Key 使用 Authorization Bearer + requestConfig.headers['Authorization'] = `Bearer ${account.apiKey}` + logger.debug('[DEBUG] Using Authorization Bearer authentication') + } + + logger.debug( + `[DEBUG] Initial headers before beta: ${JSON.stringify(requestConfig.headers, null, 2)}` + ) + + // 添加beta header如果需要 + if (options.betaHeader) { + logger.debug(`[DEBUG] Adding beta header: ${options.betaHeader}`) + requestConfig.headers['anthropic-beta'] = options.betaHeader + } else { + logger.debug('[DEBUG] No beta header to add') + } + + // 发送请求 + logger.debug( + '📤 Sending request to Claude Console API with headers:', + JSON.stringify(requestConfig.headers, null, 2) + ) + const response = await axios(requestConfig) + + // 移除监听器(请求成功完成) + if (clientRequest) { + clientRequest.removeListener('close', handleClientDisconnect) + } + if (clientResponse) { + clientResponse.removeListener('close', handleClientDisconnect) + } + + logger.debug(`🔗 Claude Console API response: ${response.status}`) + logger.debug(`[DEBUG] Response headers: ${JSON.stringify(response.headers)}`) + logger.debug(`[DEBUG] Response data type: ${typeof response.data}`) + logger.debug( + `[DEBUG] Response data length: ${response.data ? (typeof response.data === 'string' ? response.data.length : JSON.stringify(response.data).length) : 0}` + ) + logger.debug( + `[DEBUG] Response data preview: ${typeof response.data === 'string' ? response.data.substring(0, 200) : JSON.stringify(response.data).substring(0, 200)}` + ) + + // 检查错误状态并相应处理 + if (response.status === 401) { + logger.warn(`🚫 Unauthorized error detected for Claude Console account ${accountId}`) + await claudeConsoleAccountService.markAccountUnauthorized(accountId) + } else if (response.status === 429) { + logger.warn(`🚫 Rate limit detected for Claude Console account ${accountId}`) + // 收到429先检查是否因为超过了手动配置的每日额度 + await claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => { + logger.error('❌ Failed to check quota after 429 error:', err) + }) + + await claudeConsoleAccountService.markAccountRateLimited(accountId) + } else if (response.status === 529) { + logger.warn(`🚫 Overload error detected for Claude Console account ${accountId}`) + await claudeConsoleAccountService.markAccountOverloaded(accountId) + } else if (response.status === 200 || response.status === 201) { + // 如果请求成功,检查并移除错误状态 + const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(accountId) + if (isRateLimited) { + await claudeConsoleAccountService.removeAccountRateLimit(accountId) + } + const isOverloaded = await claudeConsoleAccountService.isAccountOverloaded(accountId) + if (isOverloaded) { + await claudeConsoleAccountService.removeAccountOverload(accountId) + } + } + + // 更新最后使用时间 + await this._updateLastUsedTime(accountId) + + const responseBody = + typeof response.data === 'string' ? response.data : JSON.stringify(response.data) + logger.debug(`[DEBUG] Final response body to return: ${responseBody}`) + + return { + statusCode: response.status, + headers: response.headers, + body: responseBody, + accountId + } + } catch (error) { + // 处理特定错误 + if (error.name === 'AbortError' || error.code === 'ECONNABORTED') { + logger.info('Request aborted due to client disconnect') + throw new Error('Client disconnected') + } + + logger.error( + `❌ Claude Console relay request failed (Account: ${account?.name || accountId}):`, + error.message + ) + + // 不再因为模型不支持而block账号 + + throw error + } + } + + // 🌊 处理流式响应 + async relayStreamRequestWithUsageCapture( + requestBody, + apiKeyData, + responseStream, + clientHeaders, + usageCallback, + accountId, + streamTransformer = null, + options = {} + ) { + let account = null + try { + // 获取账户信息 + account = await claudeConsoleAccountService.getAccount(accountId) + if (!account) { + throw new Error('Claude Console Claude account not found') + } + + logger.info( + `📡 Processing streaming Claude Console API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${account.name} (${accountId})` + ) + logger.debug(`🌐 Account API URL: ${account.apiUrl}`) + + // 处理模型映射 + let mappedModel = requestBody.model + if ( + account.supportedModels && + typeof account.supportedModels === 'object' && + !Array.isArray(account.supportedModels) + ) { + const newModel = claudeConsoleAccountService.getMappedModel( + account.supportedModels, + requestBody.model + ) + if (newModel !== requestBody.model) { + logger.info(`🔄 [Stream] Mapping model from ${requestBody.model} to ${newModel}`) + mappedModel = newModel + } + } + + // 创建修改后的请求体 + const modifiedRequestBody = { + ...requestBody, + model: mappedModel + } + + // 模型兼容性检查已经在调度器中完成,这里不需要再检查 + + // 创建代理agent + const proxyAgent = claudeConsoleAccountService._createProxyAgent(account.proxy) + + // 发送流式请求 + await this._makeClaudeConsoleStreamRequest( + modifiedRequestBody, + account, + proxyAgent, + clientHeaders, + responseStream, + accountId, + usageCallback, + streamTransformer, + options + ) + + // 更新最后使用时间 + await this._updateLastUsedTime(accountId) + } catch (error) { + logger.error( + `❌ Claude Console stream relay failed (Account: ${account?.name || accountId}):`, + error + ) + throw error + } + } + + // 🌊 发送流式请求到Claude Console API + async _makeClaudeConsoleStreamRequest( + body, + account, + proxyAgent, + clientHeaders, + responseStream, + accountId, + usageCallback, + streamTransformer = null, + requestOptions = {} + ) { + return new Promise((resolve, reject) => { + let aborted = false + + // 构建完整的API URL + const cleanUrl = account.apiUrl.replace(/\/$/, '') // 移除末尾斜杠 + const apiEndpoint = cleanUrl.endsWith('/v1/messages') ? cleanUrl : `${cleanUrl}/v1/messages` + + logger.debug(`🎯 Final API endpoint for stream: ${apiEndpoint}`) + + // 过滤客户端请求头 + const filteredHeaders = this._filterClientHeaders(clientHeaders) + logger.debug(`[DEBUG] Filtered client headers: ${JSON.stringify(filteredHeaders)}`) + + // 决定使用的 User-Agent:优先使用账户自定义的,否则透传客户端的,最后才使用默认值 + const userAgent = + account.userAgent || + clientHeaders?.['user-agent'] || + clientHeaders?.['User-Agent'] || + this.defaultUserAgent + + // 准备请求配置 + const requestConfig = { + method: 'POST', + url: apiEndpoint, + data: body, + headers: { + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + 'User-Agent': userAgent, + ...filteredHeaders + }, + httpsAgent: proxyAgent, + timeout: config.requestTimeout || 600000, + responseType: 'stream', + validateStatus: () => true // 接受所有状态码 + } + + // 根据 API Key 格式选择认证方式 + if (account.apiKey && account.apiKey.startsWith('sk-ant-')) { + // Anthropic 官方 API Key 使用 x-api-key + requestConfig.headers['x-api-key'] = account.apiKey + logger.debug('[DEBUG] Using x-api-key authentication for sk-ant-* API key') + } else { + // 其他 API Key 使用 Authorization Bearer + requestConfig.headers['Authorization'] = `Bearer ${account.apiKey}` + logger.debug('[DEBUG] Using Authorization Bearer authentication') + } + + // 添加beta header如果需要 + if (requestOptions.betaHeader) { + requestConfig.headers['anthropic-beta'] = requestOptions.betaHeader + } + + // 发送请求 + const request = axios(requestConfig) + + request + .then((response) => { + logger.debug(`🌊 Claude Console Claude stream response status: ${response.status}`) + + // 错误响应处理 + if (response.status !== 200) { + logger.error( + `❌ Claude Console API returned error status: ${response.status} | Account: ${account?.name || accountId}` + ) + + if (response.status === 401) { + claudeConsoleAccountService.markAccountUnauthorized(accountId) + } else if (response.status === 429) { + claudeConsoleAccountService.markAccountRateLimited(accountId) + // 检查是否因为超过每日额度 + claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => { + logger.error('❌ Failed to check quota after 429 error:', err) + }) + } else if (response.status === 529) { + claudeConsoleAccountService.markAccountOverloaded(accountId) + } + + // 设置错误响应的状态码和响应头 + if (!responseStream.headersSent) { + const errorHeaders = { + 'Content-Type': response.headers['content-type'] || 'application/json', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive' + } + // 避免 Transfer-Encoding 冲突,让 Express 自动处理 + delete errorHeaders['Transfer-Encoding'] + delete errorHeaders['Content-Length'] + responseStream.writeHead(response.status, errorHeaders) + } + + // 直接透传错误数据,不进行包装 + response.data.on('data', (chunk) => { + if (!responseStream.destroyed) { + responseStream.write(chunk) + } + }) + + response.data.on('end', () => { + if (!responseStream.destroyed) { + responseStream.end() + } + resolve() // 不抛出异常,正常完成流处理 + }) + return + } + + // 成功响应,检查并移除错误状态 + claudeConsoleAccountService.isAccountRateLimited(accountId).then((isRateLimited) => { + if (isRateLimited) { + claudeConsoleAccountService.removeAccountRateLimit(accountId) + } + }) + claudeConsoleAccountService.isAccountOverloaded(accountId).then((isOverloaded) => { + if (isOverloaded) { + claudeConsoleAccountService.removeAccountOverload(accountId) + } + }) + + // 设置响应头 + if (!responseStream.headersSent) { + responseStream.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no' + }) + } + + let buffer = '' + let finalUsageReported = false + const collectedUsageData = { + model: body.model || account?.defaultModel || null + } + + // 处理流数据 + response.data.on('data', (chunk) => { + try { + if (aborted) { + return + } + + const chunkStr = chunk.toString() + buffer += chunkStr + + // 处理完整的SSE行 + const lines = buffer.split('\n') + buffer = lines.pop() || '' + + // 转发数据并解析usage + if (lines.length > 0 && !responseStream.destroyed) { + const linesToForward = lines.join('\n') + (lines.length > 0 ? '\n' : '') + + // 应用流转换器如果有 + if (streamTransformer) { + const transformed = streamTransformer(linesToForward) + if (transformed) { + responseStream.write(transformed) + } + } else { + responseStream.write(linesToForward) + } + + // 解析SSE数据寻找usage信息 + for (const line of lines) { + if (line.startsWith('data:')) { + const jsonStr = line.slice(5).trimStart() + if (!jsonStr || jsonStr === '[DONE]') { + continue + } + try { + const data = JSON.parse(jsonStr) + + // 收集usage数据 + if (data.type === 'message_start' && data.message && data.message.usage) { + collectedUsageData.input_tokens = data.message.usage.input_tokens || 0 + collectedUsageData.cache_creation_input_tokens = + data.message.usage.cache_creation_input_tokens || 0 + collectedUsageData.cache_read_input_tokens = + data.message.usage.cache_read_input_tokens || 0 + collectedUsageData.model = data.message.model + + // 检查是否有详细的 cache_creation 对象 + if ( + data.message.usage.cache_creation && + typeof data.message.usage.cache_creation === 'object' + ) { + collectedUsageData.cache_creation = { + ephemeral_5m_input_tokens: + data.message.usage.cache_creation.ephemeral_5m_input_tokens || 0, + ephemeral_1h_input_tokens: + data.message.usage.cache_creation.ephemeral_1h_input_tokens || 0 + } + logger.info( + '📊 Collected detailed cache creation data:', + JSON.stringify(collectedUsageData.cache_creation) + ) + } + } + + if (data.type === 'message_delta' && data.usage) { + // 提取所有usage字段,message_delta可能包含完整的usage信息 + if (data.usage.output_tokens !== undefined) { + collectedUsageData.output_tokens = data.usage.output_tokens || 0 + } + + // 提取input_tokens(如果存在) + if (data.usage.input_tokens !== undefined) { + collectedUsageData.input_tokens = data.usage.input_tokens || 0 + } + + // 提取cache相关的tokens + if (data.usage.cache_creation_input_tokens !== undefined) { + collectedUsageData.cache_creation_input_tokens = + data.usage.cache_creation_input_tokens || 0 + } + if (data.usage.cache_read_input_tokens !== undefined) { + collectedUsageData.cache_read_input_tokens = + data.usage.cache_read_input_tokens || 0 + } + + // 检查是否有详细的 cache_creation 对象 + if ( + data.usage.cache_creation && + typeof data.usage.cache_creation === 'object' + ) { + collectedUsageData.cache_creation = { + ephemeral_5m_input_tokens: + data.usage.cache_creation.ephemeral_5m_input_tokens || 0, + ephemeral_1h_input_tokens: + data.usage.cache_creation.ephemeral_1h_input_tokens || 0 + } + } + + logger.info( + '📊 [Console] Collected usage data from message_delta:', + JSON.stringify(collectedUsageData) + ) + + // 如果已经收集到了完整数据,触发回调 + if ( + collectedUsageData.input_tokens !== undefined && + collectedUsageData.output_tokens !== undefined && + !finalUsageReported + ) { + if (!collectedUsageData.model) { + collectedUsageData.model = body.model || account?.defaultModel || null + } + logger.info( + '🎯 [Console] Complete usage data collected:', + JSON.stringify(collectedUsageData) + ) + usageCallback({ ...collectedUsageData, accountId }) + finalUsageReported = true + } + } + + // 不再因为模型不支持而block账号 + } catch (e) { + // 忽略解析错误 + } + } + } + } + } catch (error) { + logger.error( + `❌ Error processing Claude Console stream data (Account: ${account?.name || accountId}):`, + error + ) + if (!responseStream.destroyed) { + responseStream.write('event: error\n') + responseStream.write( + `data: ${JSON.stringify({ + error: 'Stream processing error', + message: error.message, + timestamp: new Date().toISOString() + })}\n\n` + ) + } + } + }) + + response.data.on('end', () => { + try { + // 处理缓冲区中剩余的数据 + if (buffer.trim() && !responseStream.destroyed) { + if (streamTransformer) { + const transformed = streamTransformer(buffer) + if (transformed) { + responseStream.write(transformed) + } + } else { + responseStream.write(buffer) + } + } + + // 🔧 兜底逻辑:确保所有未保存的usage数据都不会丢失 + if (!finalUsageReported) { + if ( + collectedUsageData.input_tokens !== undefined || + collectedUsageData.output_tokens !== undefined + ) { + // 补全缺失的字段 + if (collectedUsageData.input_tokens === undefined) { + collectedUsageData.input_tokens = 0 + logger.warn( + '⚠️ [Console] message_delta missing input_tokens, setting to 0. This may indicate incomplete usage data.' + ) + } + if (collectedUsageData.output_tokens === undefined) { + collectedUsageData.output_tokens = 0 + logger.warn( + '⚠️ [Console] message_delta missing output_tokens, setting to 0. This may indicate incomplete usage data.' + ) + } + // 确保有 model 字段 + if (!collectedUsageData.model) { + collectedUsageData.model = body.model || account?.defaultModel || null + } + logger.info( + `📊 [Console] Saving incomplete usage data via fallback: ${JSON.stringify(collectedUsageData)}` + ) + usageCallback({ ...collectedUsageData, accountId }) + finalUsageReported = true + } else { + logger.warn( + '⚠️ [Console] Stream completed but no usage data was captured! This indicates a problem with SSE parsing or API response format.' + ) + } + } + + // 确保流正确结束 + if (!responseStream.destroyed) { + responseStream.end() + } + + logger.debug('🌊 Claude Console Claude stream response completed') + resolve() + } catch (error) { + logger.error('❌ Error processing stream end:', error) + reject(error) + } + }) + + response.data.on('error', (error) => { + logger.error( + `❌ Claude Console stream error (Account: ${account?.name || accountId}):`, + error + ) + if (!responseStream.destroyed) { + responseStream.write('event: error\n') + responseStream.write( + `data: ${JSON.stringify({ + error: 'Stream error', + message: error.message, + timestamp: new Date().toISOString() + })}\n\n` + ) + responseStream.end() + } + reject(error) + }) + }) + .catch((error) => { + if (aborted) { + return + } + + logger.error( + `❌ Claude Console stream request error (Account: ${account?.name || accountId}):`, + error.message + ) + + // 检查错误状态 + if (error.response) { + if (error.response.status === 401) { + claudeConsoleAccountService.markAccountUnauthorized(accountId) + } else if (error.response.status === 429) { + claudeConsoleAccountService.markAccountRateLimited(accountId) + // 检查是否因为超过每日额度 + claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => { + logger.error('❌ Failed to check quota after 429 error:', err) + }) + } else if (error.response.status === 529) { + claudeConsoleAccountService.markAccountOverloaded(accountId) + } + } + + // 发送错误响应 + if (!responseStream.headersSent) { + responseStream.writeHead(error.response?.status || 500, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive' + }) + } + + if (!responseStream.destroyed) { + responseStream.write('event: error\n') + responseStream.write( + `data: ${JSON.stringify({ + error: error.message, + code: error.code, + timestamp: new Date().toISOString() + })}\n\n` + ) + responseStream.end() + } + + reject(error) + }) + + // 处理客户端断开连接 + responseStream.on('close', () => { + logger.debug('🔌 Client disconnected, cleaning up Claude Console stream') + aborted = true + }) + }) + } + + // 🔧 过滤客户端请求头 + _filterClientHeaders(clientHeaders) { + const sensitiveHeaders = [ + 'content-type', + 'user-agent', + 'authorization', + 'x-api-key', + 'host', + 'content-length', + 'connection', + 'proxy-authorization', + 'content-encoding', + 'transfer-encoding', + 'anthropic-version' + ] + + const filteredHeaders = {} + + Object.keys(clientHeaders || {}).forEach((key) => { + const lowerKey = key.toLowerCase() + if (!sensitiveHeaders.includes(lowerKey)) { + filteredHeaders[key] = clientHeaders[key] + } + }) + + return filteredHeaders + } + + // 🕐 更新最后使用时间 + async _updateLastUsedTime(accountId) { + try { + const client = require('../models/redis').getClientSafe() + await client.hset( + `claude_console_account:${accountId}`, + 'lastUsedAt', + new Date().toISOString() + ) + } catch (error) { + logger.warn( + `⚠️ Failed to update last used time for Claude Console account ${accountId}:`, + error.message + ) + } + } + + // 🎯 健康检查 + async healthCheck() { + try { + const accounts = await claudeConsoleAccountService.getAllAccounts() + const activeAccounts = accounts.filter((acc) => acc.isActive && acc.status === 'active') + + return { + healthy: activeAccounts.length > 0, + activeAccounts: activeAccounts.length, + totalAccounts: accounts.length, + timestamp: new Date().toISOString() + } + } catch (error) { + logger.error('❌ Claude Console Claude health check failed:', error) + return { + healthy: false, + error: error.message, + timestamp: new Date().toISOString() + } + } + } +} + +module.exports = new ClaudeConsoleRelayService() diff --git a/src/services/claudeRelayService.js b/src/services/claudeRelayService.js new file mode 100644 index 0000000000000000000000000000000000000000..598c04a763e75b45fcb8c7a272f470fe0564d398 --- /dev/null +++ b/src/services/claudeRelayService.js @@ -0,0 +1,2234 @@ +const https = require('https') +const zlib = require('zlib') +const fs = require('fs') +const path = require('path') +const ProxyHelper = require('../utils/proxyHelper') +const claudeAccountService = require('./claudeAccountService') +const unifiedClaudeScheduler = require('./unifiedClaudeScheduler') +const sessionHelper = require('../utils/sessionHelper') +const logger = require('../utils/logger') +const config = require('../../config/config') +const claudeCodeHeadersService = require('./claudeCodeHeadersService') +const redis = require('../models/redis') +const ClaudeCodeValidator = require('../validators/clients/claudeCodeValidator') +const { formatDateWithTimezone } = require('../utils/dateHelper') + +class ClaudeRelayService { + constructor() { + this.claudeApiUrl = config.claude.apiUrl + this.apiVersion = config.claude.apiVersion + this.betaHeader = config.claude.betaHeader + this.systemPrompt = config.claude.systemPrompt + this.claudeCodeSystemPrompt = "You are Claude Code, Anthropic's official CLI for Claude." + } + + _buildStandardRateLimitMessage(resetTime) { + if (!resetTime) { + return '此专属账号已触发 Anthropic 限流控制。' + } + const formattedReset = formatDateWithTimezone(resetTime) + return `此专属账号已触发 Anthropic 限流控制,将于 ${formattedReset} 自动恢复。` + } + + _buildOpusLimitMessage(resetTime) { + if (!resetTime) { + return '此专属账号的Opus模型已达到周使用限制,请尝试切换其他模型后再试。' + } + const formattedReset = formatDateWithTimezone(resetTime) + return `此专属账号的Opus模型已达到周使用限制,将于 ${formattedReset} 自动恢复,请尝试切换其他模型后再试。` + } + + // 🧾 提取错误消息文本 + _extractErrorMessage(body) { + if (!body) { + return '' + } + + if (typeof body === 'string') { + const trimmed = body.trim() + if (!trimmed) { + return '' + } + try { + const parsed = JSON.parse(trimmed) + return this._extractErrorMessage(parsed) + } catch (error) { + return trimmed + } + } + + if (typeof body === 'object') { + if (typeof body.error === 'string') { + return body.error + } + if (body.error && typeof body.error === 'object') { + if (typeof body.error.message === 'string') { + return body.error.message + } + if (typeof body.error.error === 'string') { + return body.error.error + } + } + if (typeof body.message === 'string') { + return body.message + } + } + + return '' + } + + // 🚫 检查是否为组织被禁用错误 + _isOrganizationDisabledError(statusCode, body) { + if (statusCode !== 400) { + return false + } + const message = this._extractErrorMessage(body) + if (!message) { + return false + } + return message.toLowerCase().includes('this organization has been disabled') + } + + // 🔍 判断是否是真实的 Claude Code 请求 + isRealClaudeCodeRequest(requestBody) { + return ClaudeCodeValidator.includesClaudeCodeSystemPrompt(requestBody, 1) + } + + // 🚀 转发请求到Claude API + async relayRequest( + requestBody, + apiKeyData, + clientRequest, + clientResponse, + clientHeaders, + options = {} + ) { + let upstreamRequest = null + + try { + // 调试日志:查看API Key数据 + logger.info('🔍 API Key data received:', { + apiKeyName: apiKeyData.name, + enableModelRestriction: apiKeyData.enableModelRestriction, + restrictedModels: apiKeyData.restrictedModels, + requestedModel: requestBody.model + }) + + const isOpusModelRequest = + typeof requestBody?.model === 'string' && requestBody.model.toLowerCase().includes('opus') + + // 生成会话哈希用于sticky会话 + const sessionHash = sessionHelper.generateSessionHash(requestBody) + + // 选择可用的Claude账户(支持专属绑定和sticky会话) + let accountSelection + try { + accountSelection = await unifiedClaudeScheduler.selectAccountForApiKey( + apiKeyData, + sessionHash, + requestBody.model + ) + } catch (error) { + if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') { + const limitMessage = this._buildStandardRateLimitMessage(error.rateLimitEndAt) + logger.warn( + `🚫 Dedicated account ${error.accountId} is rate limited for API key ${apiKeyData.name}, returning 403` + ) + return { + statusCode: 403, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + error: 'upstream_rate_limited', + message: limitMessage + }), + accountId: error.accountId + } + } + throw error + } + const { accountId } = accountSelection + const { accountType } = accountSelection + + logger.info( + `📤 Processing API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${accountId} (${accountType})${sessionHash ? `, session: ${sessionHash}` : ''}` + ) + + // 获取账户信息 + let account = await claudeAccountService.getAccount(accountId) + + if (isOpusModelRequest) { + await claudeAccountService.clearExpiredOpusRateLimit(accountId) + account = await claudeAccountService.getAccount(accountId) + } + + const isDedicatedOfficialAccount = + accountType === 'claude-official' && + apiKeyData.claudeAccountId && + !apiKeyData.claudeAccountId.startsWith('group:') && + apiKeyData.claudeAccountId === accountId + + let opusRateLimitActive = false + let opusRateLimitEndAt = null + if (isOpusModelRequest) { + opusRateLimitActive = await claudeAccountService.isAccountOpusRateLimited(accountId) + opusRateLimitEndAt = account?.opusRateLimitEndAt || null + } + + if (isOpusModelRequest && isDedicatedOfficialAccount && opusRateLimitActive) { + const limitMessage = this._buildOpusLimitMessage(opusRateLimitEndAt) + logger.warn( + `🚫 Dedicated account ${account?.name || accountId} is under Opus weekly limit until ${opusRateLimitEndAt}` + ) + return { + statusCode: 403, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + error: 'opus_weekly_limit', + message: limitMessage + }), + accountId + } + } + + // 获取有效的访问token + const accessToken = await claudeAccountService.getValidAccessToken(accountId) + + const processedBody = this._processRequestBody(requestBody, account) + + // 获取代理配置 + const proxyAgent = await this._getProxyAgent(accountId) + + // 设置客户端断开监听器 + const handleClientDisconnect = () => { + logger.info('🔌 Client disconnected, aborting upstream request') + if (upstreamRequest && !upstreamRequest.destroyed) { + upstreamRequest.destroy() + } + } + + // 监听客户端断开事件 + if (clientRequest) { + clientRequest.once('close', handleClientDisconnect) + } + if (clientResponse) { + clientResponse.once('close', handleClientDisconnect) + } + + // 发送请求到Claude API(传入回调以获取请求对象) + const response = await this._makeClaudeRequest( + processedBody, + accessToken, + proxyAgent, + clientHeaders, + accountId, + (req) => { + upstreamRequest = req + }, + options + ) + + // 移除监听器(请求成功完成) + if (clientRequest) { + clientRequest.removeListener('close', handleClientDisconnect) + } + if (clientResponse) { + clientResponse.removeListener('close', handleClientDisconnect) + } + + // 检查响应是否为限流错误或认证错误 + if (response.statusCode !== 200 && response.statusCode !== 201) { + let isRateLimited = false + let rateLimitResetTimestamp = null + let dedicatedRateLimitMessage = null + const organizationDisabledError = this._isOrganizationDisabledError( + response.statusCode, + response.body + ) + + // 检查是否为401状态码(未授权) + if (response.statusCode === 401) { + logger.warn(`🔐 Unauthorized error (401) detected for account ${accountId}`) + + // 记录401错误 + await this.recordUnauthorizedError(accountId) + + // 检查是否需要标记为异常(遇到1次401就停止调度) + const errorCount = await this.getUnauthorizedErrorCount(accountId) + logger.info( + `🔐 Account ${accountId} has ${errorCount} consecutive 401 errors in the last 5 minutes` + ) + + if (errorCount >= 1) { + logger.error( + `❌ Account ${accountId} encountered 401 error (${errorCount} errors), marking as unauthorized` + ) + await unifiedClaudeScheduler.markAccountUnauthorized( + accountId, + accountType, + sessionHash + ) + } + } + // 检查是否为403状态码(禁止访问) + else if (response.statusCode === 403) { + logger.error( + `🚫 Forbidden error (403) detected for account ${accountId}, marking as blocked` + ) + await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash) + } + // 检查是否返回组织被禁用错误(400状态码) + else if (organizationDisabledError) { + logger.error( + `🚫 Organization disabled error (400) detected for account ${accountId}, marking as blocked` + ) + await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash) + } + // 检查是否为529状态码(服务过载) + else if (response.statusCode === 529) { + logger.warn(`🚫 Overload error (529) detected for account ${accountId}`) + + // 检查是否启用了529错误处理 + if (config.claude.overloadHandling.enabled > 0) { + try { + await claudeAccountService.markAccountOverloaded(accountId) + logger.info( + `🚫 Account ${accountId} marked as overloaded for ${config.claude.overloadHandling.enabled} minutes` + ) + } catch (overloadError) { + logger.error(`❌ Failed to mark account as overloaded: ${accountId}`, overloadError) + } + } else { + logger.info(`🚫 529 error handling is disabled, skipping account overload marking`) + } + } + // 检查是否为5xx状态码 + else if (response.statusCode >= 500 && response.statusCode < 600) { + logger.warn(`🔥 Server error (${response.statusCode}) detected for account ${accountId}`) + await this._handleServerError(accountId, response.statusCode, sessionHash) + } + // 检查是否为429状态码 + else if (response.statusCode === 429) { + const resetHeader = response.headers + ? response.headers['anthropic-ratelimit-unified-reset'] + : null + const parsedResetTimestamp = resetHeader ? parseInt(resetHeader, 10) : NaN + + if (isOpusModelRequest && !Number.isNaN(parsedResetTimestamp)) { + await claudeAccountService.markAccountOpusRateLimited(accountId, parsedResetTimestamp) + logger.warn( + `🚫 Account ${accountId} hit Opus limit, resets at ${new Date(parsedResetTimestamp * 1000).toISOString()}` + ) + + if (isDedicatedOfficialAccount) { + const limitMessage = this._buildOpusLimitMessage(parsedResetTimestamp) + return { + statusCode: 403, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + error: 'opus_weekly_limit', + message: limitMessage + }), + accountId + } + } + } else { + isRateLimited = true + if (!Number.isNaN(parsedResetTimestamp)) { + rateLimitResetTimestamp = parsedResetTimestamp + logger.info( + `🕐 Extracted rate limit reset timestamp: ${rateLimitResetTimestamp} (${new Date(rateLimitResetTimestamp * 1000).toISOString()})` + ) + } + if (isDedicatedOfficialAccount) { + dedicatedRateLimitMessage = this._buildStandardRateLimitMessage( + rateLimitResetTimestamp || account?.rateLimitEndAt + ) + } + } + } else { + // 检查响应体中的错误信息 + try { + const responseBody = + typeof response.body === 'string' ? JSON.parse(response.body) : response.body + if ( + responseBody && + responseBody.error && + responseBody.error.message && + responseBody.error.message.toLowerCase().includes("exceed your account's rate limit") + ) { + isRateLimited = true + } + } catch (e) { + // 如果解析失败,检查原始字符串 + if ( + response.body && + response.body.toLowerCase().includes("exceed your account's rate limit") + ) { + isRateLimited = true + } + } + } + + if (isRateLimited) { + if (isDedicatedOfficialAccount && !dedicatedRateLimitMessage) { + dedicatedRateLimitMessage = this._buildStandardRateLimitMessage( + rateLimitResetTimestamp || account?.rateLimitEndAt + ) + } + logger.warn( + `🚫 Rate limit detected for account ${accountId}, status: ${response.statusCode}` + ) + // 标记账号为限流状态并删除粘性会话映射,传递准确的重置时间戳 + await unifiedClaudeScheduler.markAccountRateLimited( + accountId, + accountType, + sessionHash, + rateLimitResetTimestamp + ) + + if (dedicatedRateLimitMessage) { + return { + statusCode: 403, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + error: 'upstream_rate_limited', + message: dedicatedRateLimitMessage + }), + accountId + } + } + } + } else if (response.statusCode === 200 || response.statusCode === 201) { + // 提取5小时会话窗口状态 + // 使用大小写不敏感的方式获取响应头 + const get5hStatus = (headers) => { + if (!headers) { + return null + } + // HTTP头部名称不区分大小写,需要处理不同情况 + return ( + headers['anthropic-ratelimit-unified-5h-status'] || + headers['Anthropic-Ratelimit-Unified-5h-Status'] || + headers['ANTHROPIC-RATELIMIT-UNIFIED-5H-STATUS'] + ) + } + + const sessionWindowStatus = get5hStatus(response.headers) + if (sessionWindowStatus) { + logger.info(`📊 Session window status for account ${accountId}: ${sessionWindowStatus}`) + // 保存会话窗口状态到账户数据 + await claudeAccountService.updateSessionWindowStatus(accountId, sessionWindowStatus) + } + + // 请求成功,清除401和500错误计数 + await this.clearUnauthorizedErrors(accountId) + await claudeAccountService.clearInternalErrors(accountId) + // 如果请求成功,检查并移除限流状态 + const isRateLimited = await unifiedClaudeScheduler.isAccountRateLimited( + accountId, + accountType + ) + if (isRateLimited) { + await unifiedClaudeScheduler.removeAccountRateLimit(accountId, accountType) + } + + // 如果请求成功,检查并移除过载状态 + try { + const isOverloaded = await claudeAccountService.isAccountOverloaded(accountId) + if (isOverloaded) { + await claudeAccountService.removeAccountOverload(accountId) + } + } catch (overloadError) { + logger.error( + `❌ Failed to check/remove overload status for account ${accountId}:`, + overloadError + ) + } + + // 只有真实的 Claude Code 请求才更新 headers + if ( + clientHeaders && + Object.keys(clientHeaders).length > 0 && + this.isRealClaudeCodeRequest(requestBody) + ) { + await claudeCodeHeadersService.storeAccountHeaders(accountId, clientHeaders) + } + } + + // 记录成功的API调用并打印详细的usage数据 + let responseBody = null + try { + responseBody = typeof response.body === 'string' ? JSON.parse(response.body) : response.body + } catch (e) { + logger.debug('Failed to parse response body for usage logging') + } + + if (responseBody && responseBody.usage) { + const { usage } = responseBody + // 打印原始usage数据为JSON字符串 + logger.info( + `📊 === Non-Stream Request Usage Summary === Model: ${requestBody.model}, Usage: ${JSON.stringify(usage)}` + ) + } else { + // 如果没有usage数据,使用估算值 + const inputTokens = requestBody.messages + ? requestBody.messages.reduce((sum, msg) => sum + (msg.content?.length || 0), 0) / 4 + : 0 + const outputTokens = response.content + ? response.content.reduce((sum, content) => sum + (content.text?.length || 0), 0) / 4 + : 0 + + logger.info( + `✅ API request completed - Key: ${apiKeyData.name}, Account: ${accountId}, Model: ${requestBody.model}, Input: ~${Math.round(inputTokens)} tokens (estimated), Output: ~${Math.round(outputTokens)} tokens (estimated)` + ) + } + + // 在响应中添加accountId,以便调用方记录账户级别统计 + response.accountId = accountId + return response + } catch (error) { + logger.error( + `❌ Claude relay request failed for key: ${apiKeyData.name || apiKeyData.id}:`, + error.message + ) + throw error + } + } + + // 🔄 处理请求体 + _processRequestBody(body, account = null) { + if (!body) { + return body + } + + // 深拷贝请求体 + const processedBody = JSON.parse(JSON.stringify(body)) + + // 验证并限制max_tokens参数 + this._validateAndLimitMaxTokens(processedBody) + + // 移除cache_control中的ttl字段 + this._stripTtlFromCacheControl(processedBody) + + // 判断是否是真实的 Claude Code 请求 + const isRealClaudeCode = this.isRealClaudeCodeRequest(processedBody) + + // 如果不是真实的 Claude Code 请求,需要设置 Claude Code 系统提示词 + if (!isRealClaudeCode) { + const claudeCodePrompt = { + type: 'text', + text: this.claudeCodeSystemPrompt, + cache_control: { + type: 'ephemeral' + } + } + + if (processedBody.system) { + if (typeof processedBody.system === 'string') { + // 字符串格式:转换为数组,Claude Code 提示词在第一位 + const userSystemPrompt = { + type: 'text', + text: processedBody.system + } + // 如果用户的提示词与 Claude Code 提示词相同,只保留一个 + if (processedBody.system.trim() === this.claudeCodeSystemPrompt) { + processedBody.system = [claudeCodePrompt] + } else { + processedBody.system = [claudeCodePrompt, userSystemPrompt] + } + } else if (Array.isArray(processedBody.system)) { + // 检查第一个元素是否是 Claude Code 系统提示词 + const firstItem = processedBody.system[0] + const isFirstItemClaudeCode = + firstItem && firstItem.type === 'text' && firstItem.text === this.claudeCodeSystemPrompt + + if (!isFirstItemClaudeCode) { + // 如果第一个不是 Claude Code 提示词,需要在开头插入 + // 同时检查数组中是否有其他位置包含 Claude Code 提示词,如果有则移除 + const filteredSystem = processedBody.system.filter( + (item) => !(item && item.type === 'text' && item.text === this.claudeCodeSystemPrompt) + ) + processedBody.system = [claudeCodePrompt, ...filteredSystem] + } + } else { + // 其他格式,记录警告但不抛出错误,尝试处理 + logger.warn('⚠️ Unexpected system field type:', typeof processedBody.system) + processedBody.system = [claudeCodePrompt] + } + } else { + // 用户没有传递 system,需要添加 Claude Code 提示词 + processedBody.system = [claudeCodePrompt] + } + } + + this._enforceCacheControlLimit(processedBody) + + // 处理原有的系统提示(如果配置了) + if (this.systemPrompt && this.systemPrompt.trim()) { + const systemPrompt = { + type: 'text', + text: this.systemPrompt + } + + // 经过上面的处理,system 现在应该总是数组格式 + if (processedBody.system && Array.isArray(processedBody.system)) { + // 不要重复添加相同的系统提示 + const hasSystemPrompt = processedBody.system.some( + (item) => item && item.text && item.text === this.systemPrompt + ) + if (!hasSystemPrompt) { + processedBody.system.push(systemPrompt) + } + } else { + // 理论上不应该走到这里,但为了安全起见 + processedBody.system = [systemPrompt] + } + } else { + // 如果没有配置系统提示,且system字段为空,则删除它 + if (processedBody.system && Array.isArray(processedBody.system)) { + const hasValidContent = processedBody.system.some( + (item) => item && item.text && item.text.trim() + ) + if (!hasValidContent) { + delete processedBody.system + } + } + } + + // Claude API只允许temperature或top_p其中之一,优先使用temperature + if (processedBody.top_p !== undefined && processedBody.top_p !== null) { + delete processedBody.top_p + } + + // 处理统一的客户端标识 + if (account && account.useUnifiedClientId === 'true' && account.unifiedClientId) { + this._replaceClientId(processedBody, account.unifiedClientId) + } + + return processedBody + } + + // 🔄 替换请求中的客户端标识 + _replaceClientId(body, unifiedClientId) { + if (!body || !body.metadata || !body.metadata.user_id || !unifiedClientId) { + return + } + + const userId = body.metadata.user_id + // user_id格式:user_{64位十六进制}_account__session_{uuid} + // 只替换第一个下划线后到_account之前的部分(客户端标识) + const match = userId.match(/^user_[a-f0-9]{64}(_account__session_[a-f0-9-]{36})$/) + if (match && match[1]) { + // 替换客户端标识部分 + body.metadata.user_id = `user_${unifiedClientId}${match[1]}` + logger.info(`🔄 Replaced client ID with unified ID: ${body.metadata.user_id}`) + } + } + + // 🔢 验证并限制max_tokens参数 + _validateAndLimitMaxTokens(body) { + if (!body || !body.max_tokens) { + return + } + + try { + // 读取模型定价配置文件 + const pricingFilePath = path.join(__dirname, '../../data/model_pricing.json') + + if (!fs.existsSync(pricingFilePath)) { + logger.warn('⚠️ Model pricing file not found, skipping max_tokens validation') + return + } + + const pricingData = JSON.parse(fs.readFileSync(pricingFilePath, 'utf8')) + const model = body.model || 'claude-sonnet-4-20250514' + + // 查找对应模型的配置 + const modelConfig = pricingData[model] + + if (!modelConfig) { + // 如果找不到模型配置,直接透传客户端参数,不进行任何干预 + logger.info( + `📝 Model ${model} not found in pricing file, passing through client parameters without modification` + ) + return + } + + // 获取模型的最大token限制 + const maxLimit = modelConfig.max_tokens || modelConfig.max_output_tokens + + if (!maxLimit) { + logger.debug(`🔍 No max_tokens limit found for model ${model}, skipping validation`) + return + } + + // 检查并调整max_tokens + if (body.max_tokens > maxLimit) { + logger.warn( + `⚠️ max_tokens ${body.max_tokens} exceeds limit ${maxLimit} for model ${model}, adjusting to ${maxLimit}` + ) + body.max_tokens = maxLimit + } + } catch (error) { + logger.error('❌ Failed to validate max_tokens from pricing file:', error) + // 如果文件读取失败,不进行校验,让请求继续处理 + } + } + + // 🧹 移除TTL字段 + _stripTtlFromCacheControl(body) { + if (!body || typeof body !== 'object') { + return + } + + const processContentArray = (contentArray) => { + if (!Array.isArray(contentArray)) { + return + } + + contentArray.forEach((item) => { + if (item && typeof item === 'object' && item.cache_control) { + if (item.cache_control.ttl) { + delete item.cache_control.ttl + logger.debug('🧹 Removed ttl from cache_control') + } + } + }) + } + + if (Array.isArray(body.system)) { + processContentArray(body.system) + } + + if (Array.isArray(body.messages)) { + body.messages.forEach((message) => { + if (message && Array.isArray(message.content)) { + processContentArray(message.content) + } + }) + } + } + + // ⚖️ 限制带缓存控制的内容数量 + _enforceCacheControlLimit(body) { + const MAX_CACHE_CONTROL_BLOCKS = 4 + + if (!body || typeof body !== 'object') { + return + } + + const countCacheControlBlocks = () => { + let total = 0 + + if (Array.isArray(body.messages)) { + body.messages.forEach((message) => { + if (!message || !Array.isArray(message.content)) { + return + } + message.content.forEach((item) => { + if (item && item.cache_control) { + total += 1 + } + }) + }) + } + + if (Array.isArray(body.system)) { + body.system.forEach((item) => { + if (item && item.cache_control) { + total += 1 + } + }) + } + + return total + } + + const removeFromMessages = () => { + if (!Array.isArray(body.messages)) { + return false + } + + for (let messageIndex = 0; messageIndex < body.messages.length; messageIndex += 1) { + const message = body.messages[messageIndex] + if (!message || !Array.isArray(message.content)) { + continue + } + + for (let contentIndex = 0; contentIndex < message.content.length; contentIndex += 1) { + const contentItem = message.content[contentIndex] + if (contentItem && contentItem.cache_control) { + message.content.splice(contentIndex, 1) + + if (message.content.length === 0) { + body.messages.splice(messageIndex, 1) + } + + return true + } + } + } + + return false + } + + const removeFromSystem = () => { + if (!Array.isArray(body.system)) { + return false + } + + for (let index = 0; index < body.system.length; index += 1) { + const systemItem = body.system[index] + if (systemItem && systemItem.cache_control) { + body.system.splice(index, 1) + + if (body.system.length === 0) { + delete body.system + } + + return true + } + } + + return false + } + + let total = countCacheControlBlocks() + + while (total > MAX_CACHE_CONTROL_BLOCKS) { + if (removeFromMessages()) { + total -= 1 + continue + } + + if (removeFromSystem()) { + total -= 1 + continue + } + + break + } + } + + // 🌐 获取代理Agent(使用统一的代理工具) + async _getProxyAgent(accountId) { + try { + const accountData = await claudeAccountService.getAllAccounts() + const account = accountData.find((acc) => acc.id === accountId) + + if (!account || !account.proxy) { + logger.debug('🌐 No proxy configured for Claude account') + return null + } + + const proxyAgent = ProxyHelper.createProxyAgent(account.proxy) + if (proxyAgent) { + logger.info( + `🌐 Using proxy for Claude request: ${ProxyHelper.getProxyDescription(account.proxy)}` + ) + } + return proxyAgent + } catch (error) { + logger.warn('⚠️ Failed to create proxy agent:', error) + return null + } + } + + // 🔧 过滤客户端请求头 + _filterClientHeaders(clientHeaders) { + // 需要移除的敏感 headers + const sensitiveHeaders = [ + 'content-type', + 'user-agent', + 'x-api-key', + 'authorization', + 'host', + 'content-length', + 'connection', + 'proxy-authorization', + 'content-encoding', + 'transfer-encoding' + ] + + // 🆕 需要移除的浏览器相关 headers(避免CORS问题) + const browserHeaders = [ + 'origin', + 'referer', + 'sec-fetch-mode', + 'sec-fetch-site', + 'sec-fetch-dest', + 'sec-ch-ua', + 'sec-ch-ua-mobile', + 'sec-ch-ua-platform', + 'accept-language', + 'accept-encoding', + 'accept', + 'cache-control', + 'pragma', + 'anthropic-dangerous-direct-browser-access' // 这个头可能触发CORS检查 + ] + + // 应该保留的 headers(用于会话一致性和追踪) + const allowedHeaders = [ + 'x-request-id', + 'anthropic-version', // 保留API版本 + 'anthropic-beta' // 保留beta功能 + ] + + const filteredHeaders = {} + + // 转发客户端的非敏感 headers + Object.keys(clientHeaders || {}).forEach((key) => { + const lowerKey = key.toLowerCase() + // 如果在允许列表中,直接保留 + if (allowedHeaders.includes(lowerKey)) { + filteredHeaders[key] = clientHeaders[key] + } + // 如果不在敏感列表和浏览器列表中,也保留 + else if (!sensitiveHeaders.includes(lowerKey) && !browserHeaders.includes(lowerKey)) { + filteredHeaders[key] = clientHeaders[key] + } + }) + + return filteredHeaders + } + + // 🔗 发送请求到Claude API + async _makeClaudeRequest( + body, + accessToken, + proxyAgent, + clientHeaders, + accountId, + onRequest, + requestOptions = {} + ) { + const url = new URL(this.claudeApiUrl) + + // 获取账户信息用于统一 User-Agent + const account = await claudeAccountService.getAccount(accountId) + + // 获取统一的 User-Agent + const unifiedUA = await this.captureAndGetUnifiedUserAgent(clientHeaders, account) + + // 获取过滤后的客户端 headers + const filteredHeaders = this._filterClientHeaders(clientHeaders) + + // 判断是否是真实的 Claude Code 请求 + const isRealClaudeCode = this.isRealClaudeCodeRequest(body) + + // 如果不是真实的 Claude Code 请求,需要使用从账户获取的 Claude Code headers + const finalHeaders = { ...filteredHeaders } + + if (!isRealClaudeCode) { + // 获取该账号存储的 Claude Code headers + const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId) + + // 只添加客户端没有提供的 headers + Object.keys(claudeCodeHeaders).forEach((key) => { + const lowerKey = key.toLowerCase() + if (!finalHeaders[key] && !finalHeaders[lowerKey]) { + finalHeaders[key] = claudeCodeHeaders[key] + } + }) + } + + return new Promise((resolve, reject) => { + // 支持自定义路径(如 count_tokens) + let requestPath = url.pathname + if (requestOptions.customPath) { + const baseUrl = new URL('https://api.anthropic.com') + const customUrl = new URL(requestOptions.customPath, baseUrl) + requestPath = customUrl.pathname + } + + const options = { + hostname: url.hostname, + port: url.port || 443, + path: requestPath, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + 'anthropic-version': this.apiVersion, + ...finalHeaders + }, + agent: proxyAgent, + timeout: config.requestTimeout || 600000 + } + + // 使用统一 User-Agent 或客户端提供的,最后使用默认值 + if (!options.headers['user-agent'] || unifiedUA !== null) { + const userAgent = unifiedUA || 'claude-cli/1.0.119 (external, cli)' + options.headers['user-agent'] = userAgent + } + + logger.info(`🔗 指纹是这个: ${options.headers['user-agent']}`) + + // 使用自定义的 betaHeader 或默认值 + const betaHeader = + requestOptions?.betaHeader !== undefined ? requestOptions.betaHeader : this.betaHeader + if (betaHeader) { + options.headers['anthropic-beta'] = betaHeader + } + + const req = https.request(options, (res) => { + let responseData = Buffer.alloc(0) + + res.on('data', (chunk) => { + responseData = Buffer.concat([responseData, chunk]) + }) + + res.on('end', () => { + try { + let bodyString = '' + + // 根据Content-Encoding处理响应数据 + const contentEncoding = res.headers['content-encoding'] + if (contentEncoding === 'gzip') { + try { + bodyString = zlib.gunzipSync(responseData).toString('utf8') + } catch (unzipError) { + logger.error('❌ Failed to decompress gzip response:', unzipError) + bodyString = responseData.toString('utf8') + } + } else if (contentEncoding === 'deflate') { + try { + bodyString = zlib.inflateSync(responseData).toString('utf8') + } catch (unzipError) { + logger.error('❌ Failed to decompress deflate response:', unzipError) + bodyString = responseData.toString('utf8') + } + } else { + bodyString = responseData.toString('utf8') + } + + const response = { + statusCode: res.statusCode, + headers: res.headers, + body: bodyString + } + + logger.debug(`🔗 Claude API response: ${res.statusCode}`) + + resolve(response) + } catch (error) { + logger.error(`❌ Failed to parse Claude API response (Account: ${accountId}):`, error) + reject(error) + } + }) + }) + + // 如果提供了 onRequest 回调,传递请求对象 + if (onRequest && typeof onRequest === 'function') { + onRequest(req) + } + + req.on('error', async (error) => { + console.error(': ❌ ', error) + logger.error(`❌ Claude API request error (Account: ${accountId}):`, error.message, { + code: error.code, + errno: error.errno, + syscall: error.syscall, + address: error.address, + port: error.port + }) + + // 根据错误类型提供更具体的错误信息 + let errorMessage = 'Upstream request failed' + if (error.code === 'ECONNRESET') { + errorMessage = 'Connection reset by Claude API server' + } else if (error.code === 'ENOTFOUND') { + errorMessage = 'Unable to resolve Claude API hostname' + } else if (error.code === 'ECONNREFUSED') { + errorMessage = 'Connection refused by Claude API server' + } else if (error.code === 'ETIMEDOUT') { + errorMessage = 'Connection timed out to Claude API server' + + await this._handleServerError(accountId, 504, null, 'Network') + } + + reject(new Error(errorMessage)) + }) + + req.on('timeout', async () => { + req.destroy() + logger.error(`❌ Claude API request timeout (Account: ${accountId})`) + + await this._handleServerError(accountId, 504, null, 'Request') + + reject(new Error('Request timeout')) + }) + + // 写入请求体 + req.write(JSON.stringify(body)) + req.end() + }) + } + + // 🌊 处理流式响应(带usage数据捕获) + async relayStreamRequestWithUsageCapture( + requestBody, + apiKeyData, + responseStream, + clientHeaders, + usageCallback, + streamTransformer = null, + options = {} + ) { + try { + // 调试日志:查看API Key数据(流式请求) + logger.info('🔍 [Stream] API Key data received:', { + apiKeyName: apiKeyData.name, + enableModelRestriction: apiKeyData.enableModelRestriction, + restrictedModels: apiKeyData.restrictedModels, + requestedModel: requestBody.model + }) + + const isOpusModelRequest = + typeof requestBody?.model === 'string' && requestBody.model.toLowerCase().includes('opus') + + // 生成会话哈希用于sticky会话 + const sessionHash = sessionHelper.generateSessionHash(requestBody) + + // 选择可用的Claude账户(支持专属绑定和sticky会话) + let accountSelection + try { + accountSelection = await unifiedClaudeScheduler.selectAccountForApiKey( + apiKeyData, + sessionHash, + requestBody.model + ) + } catch (error) { + if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') { + const limitMessage = this._buildStandardRateLimitMessage(error.rateLimitEndAt) + if (!responseStream.headersSent) { + responseStream.status(403) + responseStream.setHeader('Content-Type', 'application/json') + } + responseStream.write( + JSON.stringify({ + error: 'upstream_rate_limited', + message: limitMessage + }) + ) + responseStream.end() + return + } + throw error + } + const { accountId } = accountSelection + const { accountType } = accountSelection + + logger.info( + `📡 Processing streaming API request with usage capture for key: ${apiKeyData.name || apiKeyData.id}, account: ${accountId} (${accountType})${sessionHash ? `, session: ${sessionHash}` : ''}` + ) + + // 获取账户信息 + let account = await claudeAccountService.getAccount(accountId) + + if (isOpusModelRequest) { + await claudeAccountService.clearExpiredOpusRateLimit(accountId) + account = await claudeAccountService.getAccount(accountId) + } + + const isDedicatedOfficialAccount = + accountType === 'claude-official' && + apiKeyData.claudeAccountId && + !apiKeyData.claudeAccountId.startsWith('group:') && + apiKeyData.claudeAccountId === accountId + + let opusRateLimitActive = false + if (isOpusModelRequest) { + opusRateLimitActive = await claudeAccountService.isAccountOpusRateLimited(accountId) + } + + if (isOpusModelRequest && isDedicatedOfficialAccount && opusRateLimitActive) { + const limitMessage = this._buildOpusLimitMessage(account?.opusRateLimitEndAt) + if (!responseStream.headersSent) { + responseStream.status(403) + responseStream.setHeader('Content-Type', 'application/json') + } + responseStream.write( + JSON.stringify({ + error: 'opus_weekly_limit', + message: limitMessage + }) + ) + responseStream.end() + return + } + + // 获取有效的访问token + const accessToken = await claudeAccountService.getValidAccessToken(accountId) + + const processedBody = this._processRequestBody(requestBody, account) + + // 获取代理配置 + const proxyAgent = await this._getProxyAgent(accountId) + + // 发送流式请求并捕获usage数据 + await this._makeClaudeStreamRequestWithUsageCapture( + processedBody, + accessToken, + proxyAgent, + clientHeaders, + responseStream, + (usageData) => { + // 在usageCallback中添加accountId + usageCallback({ ...usageData, accountId }) + }, + accountId, + accountType, + sessionHash, + streamTransformer, + options, + isDedicatedOfficialAccount + ) + } catch (error) { + logger.error(`❌ Claude stream relay with usage capture failed:`, error) + throw error + } + } + + // 🌊 发送流式请求到Claude API(带usage数据捕获) + async _makeClaudeStreamRequestWithUsageCapture( + body, + accessToken, + proxyAgent, + clientHeaders, + responseStream, + usageCallback, + accountId, + accountType, + sessionHash, + streamTransformer = null, + requestOptions = {}, + isDedicatedOfficialAccount = false + ) { + // 获取账户信息用于统一 User-Agent + const account = await claudeAccountService.getAccount(accountId) + + const isOpusModelRequest = + typeof body?.model === 'string' && body.model.toLowerCase().includes('opus') + + // 获取统一的 User-Agent + const unifiedUA = await this.captureAndGetUnifiedUserAgent(clientHeaders, account) + + // 获取过滤后的客户端 headers + const filteredHeaders = this._filterClientHeaders(clientHeaders) + + // 判断是否是真实的 Claude Code 请求 + const isRealClaudeCode = this.isRealClaudeCodeRequest(body) + + // 如果不是真实的 Claude Code 请求,需要使用从账户获取的 Claude Code headers + const finalHeaders = { ...filteredHeaders } + + if (!isRealClaudeCode) { + // 获取该账号存储的 Claude Code headers + const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId) + + // 只添加客户端没有提供的 headers + Object.keys(claudeCodeHeaders).forEach((key) => { + const lowerKey = key.toLowerCase() + if (!finalHeaders[key] && !finalHeaders[lowerKey]) { + finalHeaders[key] = claudeCodeHeaders[key] + } + }) + } + + return new Promise((resolve, reject) => { + const url = new URL(this.claudeApiUrl) + + const options = { + hostname: url.hostname, + port: url.port || 443, + path: url.pathname, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + 'anthropic-version': this.apiVersion, + ...finalHeaders + }, + agent: proxyAgent, + timeout: config.requestTimeout || 600000 + } + + // 使用统一 User-Agent 或客户端提供的,最后使用默认值 + if (!options.headers['user-agent'] || unifiedUA !== null) { + const userAgent = unifiedUA || 'claude-cli/1.0.119 (external, cli)' + options.headers['user-agent'] = userAgent + } + + logger.info(`🔗 指纹是这个: ${options.headers['user-agent']}`) + // 使用自定义的 betaHeader 或默认值 + const betaHeader = + requestOptions?.betaHeader !== undefined ? requestOptions.betaHeader : this.betaHeader + if (betaHeader) { + options.headers['anthropic-beta'] = betaHeader + } + + const req = https.request(options, async (res) => { + logger.debug(`🌊 Claude stream response status: ${res.statusCode}`) + + // 错误响应处理 + if (res.statusCode !== 200) { + if (res.statusCode === 429) { + const resetHeader = res.headers + ? res.headers['anthropic-ratelimit-unified-reset'] + : null + const parsedResetTimestamp = resetHeader ? parseInt(resetHeader, 10) : NaN + + if (isOpusModelRequest) { + if (!Number.isNaN(parsedResetTimestamp)) { + await claudeAccountService.markAccountOpusRateLimited( + accountId, + parsedResetTimestamp + ) + logger.warn( + `🚫 [Stream] Account ${accountId} hit Opus limit, resets at ${new Date(parsedResetTimestamp * 1000).toISOString()}` + ) + } + + if (isDedicatedOfficialAccount) { + const limitMessage = this._buildOpusLimitMessage(parsedResetTimestamp) + if (!responseStream.headersSent) { + responseStream.status(403) + responseStream.setHeader('Content-Type', 'application/json') + } + responseStream.write( + JSON.stringify({ + error: 'opus_weekly_limit', + message: limitMessage + }) + ) + responseStream.end() + res.resume() + resolve() + return + } + } else { + const rateLimitResetTimestamp = Number.isNaN(parsedResetTimestamp) + ? null + : parsedResetTimestamp + await unifiedClaudeScheduler.markAccountRateLimited( + accountId, + accountType, + sessionHash, + rateLimitResetTimestamp + ) + logger.warn(`🚫 [Stream] Rate limit detected for account ${accountId}, status 429`) + + if (isDedicatedOfficialAccount) { + const limitMessage = this._buildStandardRateLimitMessage( + rateLimitResetTimestamp || account?.rateLimitEndAt + ) + if (!responseStream.headersSent) { + responseStream.status(403) + responseStream.setHeader('Content-Type', 'application/json') + } + responseStream.write( + JSON.stringify({ + error: 'upstream_rate_limited', + message: limitMessage + }) + ) + responseStream.end() + res.resume() + resolve() + return + } + } + } + + // 将错误处理逻辑封装在一个异步函数中 + const handleErrorResponse = async () => { + if (res.statusCode === 401) { + logger.warn(`🔐 [Stream] Unauthorized error (401) detected for account ${accountId}`) + + await this.recordUnauthorizedError(accountId) + + const errorCount = await this.getUnauthorizedErrorCount(accountId) + logger.info( + `🔐 [Stream] Account ${accountId} has ${errorCount} consecutive 401 errors in the last 5 minutes` + ) + + if (errorCount >= 1) { + logger.error( + `❌ [Stream] Account ${accountId} encountered 401 error (${errorCount} errors), marking as unauthorized` + ) + await unifiedClaudeScheduler.markAccountUnauthorized( + accountId, + accountType, + sessionHash + ) + } + } else if (res.statusCode === 403) { + logger.error( + `🚫 [Stream] Forbidden error (403) detected for account ${accountId}, marking as blocked` + ) + await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash) + } else if (res.statusCode === 529) { + logger.warn(`🚫 [Stream] Overload error (529) detected for account ${accountId}`) + + // 检查是否启用了529错误处理 + if (config.claude.overloadHandling.enabled > 0) { + try { + await claudeAccountService.markAccountOverloaded(accountId) + logger.info( + `🚫 [Stream] Account ${accountId} marked as overloaded for ${config.claude.overloadHandling.enabled} minutes` + ) + } catch (overloadError) { + logger.error( + `❌ [Stream] Failed to mark account as overloaded: ${accountId}`, + overloadError + ) + } + } else { + logger.info( + `🚫 [Stream] 529 error handling is disabled, skipping account overload marking` + ) + } + } else if (res.statusCode >= 500 && res.statusCode < 600) { + logger.warn( + `🔥 [Stream] Server error (${res.statusCode}) detected for account ${accountId}` + ) + await this._handleServerError(accountId, res.statusCode, sessionHash, '[Stream]') + } + } + + // 调用异步错误处理函数 + handleErrorResponse().catch((err) => { + logger.error('❌ Error in stream error handler:', err) + }) + + logger.error( + `❌ Claude API returned error status: ${res.statusCode} | Account: ${account?.name || accountId}` + ) + let errorData = '' + + res.on('data', (chunk) => { + errorData += chunk.toString() + }) + + res.on('end', () => { + console.error(': ❌ ', errorData) + logger.error( + `❌ Claude API error response (Account: ${account?.name || accountId}):`, + errorData + ) + if (this._isOrganizationDisabledError(res.statusCode, errorData)) { + ;(async () => { + try { + logger.error( + `🚫 [Stream] Organization disabled error (400) detected for account ${accountId}, marking as blocked` + ) + await unifiedClaudeScheduler.markAccountBlocked( + accountId, + accountType, + sessionHash + ) + } catch (markError) { + logger.error( + `❌ [Stream] Failed to mark account ${accountId} as blocked after organization disabled error:`, + markError + ) + } + })() + } + if (!responseStream.destroyed) { + // 发送错误事件 + responseStream.write('event: error\n') + responseStream.write( + `data: ${JSON.stringify({ + error: 'Claude API error', + status: res.statusCode, + details: errorData, + timestamp: new Date().toISOString() + })}\n\n` + ) + responseStream.end() + } + reject(new Error(`Claude API error: ${res.statusCode}`)) + }) + return + } + + let buffer = '' + const allUsageData = [] // 收集所有的usage事件 + let currentUsageData = {} // 当前正在收集的usage数据 + let rateLimitDetected = false // 限流检测标志 + + // 监听数据块,解析SSE并寻找usage信息 + res.on('data', (chunk) => { + try { + const chunkStr = chunk.toString() + + buffer += chunkStr + + // 处理完整的SSE行 + const lines = buffer.split('\n') + buffer = lines.pop() || '' // 保留最后的不完整行 + + // 转发已处理的完整行到客户端 + if (lines.length > 0 && !responseStream.destroyed) { + const linesToForward = lines.join('\n') + (lines.length > 0 ? '\n' : '') + // 如果有流转换器,应用转换 + if (streamTransformer) { + const transformed = streamTransformer(linesToForward) + if (transformed) { + responseStream.write(transformed) + } + } else { + responseStream.write(linesToForward) + } + } + + for (const line of lines) { + // 解析SSE数据寻找usage信息 + if (line.startsWith('data:')) { + const jsonStr = line.slice(5).trimStart() + if (!jsonStr || jsonStr === '[DONE]') { + continue + } + try { + const data = JSON.parse(jsonStr) + + // 收集来自不同事件的usage数据 + if (data.type === 'message_start' && data.message && data.message.usage) { + // 新的消息开始,如果之前有数据,先保存 + if ( + currentUsageData.input_tokens !== undefined && + currentUsageData.output_tokens !== undefined + ) { + allUsageData.push({ ...currentUsageData }) + currentUsageData = {} + } + + // message_start包含input tokens、cache tokens和模型信息 + currentUsageData.input_tokens = data.message.usage.input_tokens || 0 + currentUsageData.cache_creation_input_tokens = + data.message.usage.cache_creation_input_tokens || 0 + currentUsageData.cache_read_input_tokens = + data.message.usage.cache_read_input_tokens || 0 + currentUsageData.model = data.message.model + + // 检查是否有详细的 cache_creation 对象 + if ( + data.message.usage.cache_creation && + typeof data.message.usage.cache_creation === 'object' + ) { + currentUsageData.cache_creation = { + ephemeral_5m_input_tokens: + data.message.usage.cache_creation.ephemeral_5m_input_tokens || 0, + ephemeral_1h_input_tokens: + data.message.usage.cache_creation.ephemeral_1h_input_tokens || 0 + } + logger.debug( + '📊 Collected detailed cache creation data:', + JSON.stringify(currentUsageData.cache_creation) + ) + } + + logger.debug( + '📊 Collected input/cache data from message_start:', + JSON.stringify(currentUsageData) + ) + } + + // message_delta包含最终的output tokens + if ( + data.type === 'message_delta' && + data.usage && + data.usage.output_tokens !== undefined + ) { + currentUsageData.output_tokens = data.usage.output_tokens || 0 + + logger.debug( + '📊 Collected output data from message_delta:', + JSON.stringify(currentUsageData) + ) + + // 如果已经收集到了input数据和output数据,这是一个完整的usage + if (currentUsageData.input_tokens !== undefined) { + logger.debug( + '🎯 Complete usage data collected for model:', + currentUsageData.model, + '- Input:', + currentUsageData.input_tokens, + 'Output:', + currentUsageData.output_tokens + ) + // 保存到列表中,但不立即触发回调 + allUsageData.push({ ...currentUsageData }) + // 重置当前数据,准备接收下一个 + currentUsageData = {} + } + } + + // 检查是否有限流错误 + if ( + data.type === 'error' && + data.error && + data.error.message && + data.error.message.toLowerCase().includes("exceed your account's rate limit") + ) { + rateLimitDetected = true + logger.warn(`🚫 Rate limit detected in stream for account ${accountId}`) + } + } catch (parseError) { + // 忽略JSON解析错误,继续处理 + logger.debug('🔍 SSE line not JSON or no usage data:', line.slice(0, 100)) + } + } + } + } catch (error) { + logger.error('❌ Error processing stream data:', error) + // 发送错误但不破坏流,让它自然结束 + if (!responseStream.destroyed) { + responseStream.write('event: error\n') + responseStream.write( + `data: ${JSON.stringify({ + error: 'Stream processing error', + message: error.message, + timestamp: new Date().toISOString() + })}\n\n` + ) + } + } + }) + + res.on('end', async () => { + try { + // 处理缓冲区中剩余的数据 + if (buffer.trim() && !responseStream.destroyed) { + if (streamTransformer) { + const transformed = streamTransformer(buffer) + if (transformed) { + responseStream.write(transformed) + } + } else { + responseStream.write(buffer) + } + } + + // 确保流正确结束 + if (!responseStream.destroyed) { + responseStream.end() + } + } catch (error) { + logger.error('❌ Error processing stream end:', error) + } + + // 如果还有未完成的usage数据,尝试保存 + if (currentUsageData.input_tokens !== undefined) { + if (currentUsageData.output_tokens === undefined) { + currentUsageData.output_tokens = 0 // 如果没有output,设为0 + } + allUsageData.push(currentUsageData) + } + + // 检查是否捕获到usage数据 + if (allUsageData.length === 0) { + logger.warn( + '⚠️ Stream completed but no usage data was captured! This indicates a problem with SSE parsing or Claude API response format.' + ) + } else { + // 打印此次请求的所有usage数据汇总 + const totalUsage = allUsageData.reduce( + (acc, usage) => ({ + input_tokens: (acc.input_tokens || 0) + (usage.input_tokens || 0), + output_tokens: (acc.output_tokens || 0) + (usage.output_tokens || 0), + cache_creation_input_tokens: + (acc.cache_creation_input_tokens || 0) + (usage.cache_creation_input_tokens || 0), + cache_read_input_tokens: + (acc.cache_read_input_tokens || 0) + (usage.cache_read_input_tokens || 0), + models: [...(acc.models || []), usage.model].filter(Boolean) + }), + {} + ) + + // 打印原始的usage数据为JSON字符串,避免嵌套问题 + logger.info( + `📊 === Stream Request Usage Summary === Model: ${body.model}, Total Events: ${allUsageData.length}, Usage Data: ${JSON.stringify(allUsageData)}` + ) + + // 一般一个请求只会使用一个模型,即使有多个usage事件也应该合并 + // 计算总的usage + const finalUsage = { + input_tokens: totalUsage.input_tokens, + output_tokens: totalUsage.output_tokens, + cache_creation_input_tokens: totalUsage.cache_creation_input_tokens, + cache_read_input_tokens: totalUsage.cache_read_input_tokens, + model: allUsageData[allUsageData.length - 1].model || body.model // 使用最后一个模型或请求模型 + } + + // 如果有详细的cache_creation数据,合并它们 + let totalEphemeral5m = 0 + let totalEphemeral1h = 0 + allUsageData.forEach((usage) => { + if (usage.cache_creation && typeof usage.cache_creation === 'object') { + totalEphemeral5m += usage.cache_creation.ephemeral_5m_input_tokens || 0 + totalEphemeral1h += usage.cache_creation.ephemeral_1h_input_tokens || 0 + } + }) + + // 如果有详细的缓存数据,添加到finalUsage + if (totalEphemeral5m > 0 || totalEphemeral1h > 0) { + finalUsage.cache_creation = { + ephemeral_5m_input_tokens: totalEphemeral5m, + ephemeral_1h_input_tokens: totalEphemeral1h + } + logger.info( + '📊 Detailed cache creation breakdown:', + JSON.stringify(finalUsage.cache_creation) + ) + } + + // 调用一次usageCallback记录合并后的数据 + usageCallback(finalUsage) + } + + // 提取5小时会话窗口状态 + // 使用大小写不敏感的方式获取响应头 + const get5hStatus = (headers) => { + if (!headers) { + return null + } + // HTTP头部名称不区分大小写,需要处理不同情况 + return ( + headers['anthropic-ratelimit-unified-5h-status'] || + headers['Anthropic-Ratelimit-Unified-5h-Status'] || + headers['ANTHROPIC-RATELIMIT-UNIFIED-5H-STATUS'] + ) + } + + const sessionWindowStatus = get5hStatus(res.headers) + if (sessionWindowStatus) { + logger.info(`📊 Session window status for account ${accountId}: ${sessionWindowStatus}`) + // 保存会话窗口状态到账户数据 + await claudeAccountService.updateSessionWindowStatus(accountId, sessionWindowStatus) + } + + // 处理限流状态 + if (rateLimitDetected || res.statusCode === 429) { + const resetHeader = res.headers + ? res.headers['anthropic-ratelimit-unified-reset'] + : null + const parsedResetTimestamp = resetHeader ? parseInt(resetHeader, 10) : NaN + + if (isOpusModelRequest && !Number.isNaN(parsedResetTimestamp)) { + await claudeAccountService.markAccountOpusRateLimited(accountId, parsedResetTimestamp) + logger.warn( + `🚫 [Stream] Account ${accountId} hit Opus limit, resets at ${new Date(parsedResetTimestamp * 1000).toISOString()}` + ) + } else { + const rateLimitResetTimestamp = Number.isNaN(parsedResetTimestamp) + ? null + : parsedResetTimestamp + + if (!Number.isNaN(parsedResetTimestamp)) { + logger.info( + `🕐 Extracted rate limit reset timestamp from stream: ${parsedResetTimestamp} (${new Date(parsedResetTimestamp * 1000).toISOString()})` + ) + } + + await unifiedClaudeScheduler.markAccountRateLimited( + accountId, + accountType, + sessionHash, + rateLimitResetTimestamp + ) + } + } else if (res.statusCode === 200) { + // 请求成功,清除401和500错误计数 + await this.clearUnauthorizedErrors(accountId) + await claudeAccountService.clearInternalErrors(accountId) + // 如果请求成功,检查并移除限流状态 + const isRateLimited = await unifiedClaudeScheduler.isAccountRateLimited( + accountId, + accountType + ) + if (isRateLimited) { + await unifiedClaudeScheduler.removeAccountRateLimit(accountId, accountType) + } + + // 如果流式请求成功,检查并移除过载状态 + try { + const isOverloaded = await claudeAccountService.isAccountOverloaded(accountId) + if (isOverloaded) { + await claudeAccountService.removeAccountOverload(accountId) + } + } catch (overloadError) { + logger.error( + `❌ [Stream] Failed to check/remove overload status for account ${accountId}:`, + overloadError + ) + } + + // 只有真实的 Claude Code 请求才更新 headers(流式请求) + if ( + clientHeaders && + Object.keys(clientHeaders).length > 0 && + this.isRealClaudeCodeRequest(body) + ) { + await claudeCodeHeadersService.storeAccountHeaders(accountId, clientHeaders) + } + } + + logger.debug('🌊 Claude stream response with usage capture completed') + resolve() + }) + }) + + req.on('error', async (error) => { + logger.error( + `❌ Claude stream request error (Account: ${account?.name || accountId}):`, + error.message, + { + code: error.code, + errno: error.errno, + syscall: error.syscall + } + ) + + // 根据错误类型提供更具体的错误信息 + let errorMessage = 'Upstream request failed' + let statusCode = 500 + if (error.code === 'ECONNRESET') { + errorMessage = 'Connection reset by Claude API server' + statusCode = 502 + } else if (error.code === 'ENOTFOUND') { + errorMessage = 'Unable to resolve Claude API hostname' + statusCode = 502 + } else if (error.code === 'ECONNREFUSED') { + errorMessage = 'Connection refused by Claude API server' + statusCode = 502 + } else if (error.code === 'ETIMEDOUT') { + errorMessage = 'Connection timed out to Claude API server' + statusCode = 504 + } + + if (!responseStream.headersSent) { + responseStream.writeHead(statusCode, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive' + }) + } + + if (!responseStream.destroyed) { + // 发送 SSE 错误事件 + responseStream.write('event: error\n') + responseStream.write( + `data: ${JSON.stringify({ + error: errorMessage, + code: error.code, + timestamp: new Date().toISOString() + })}\n\n` + ) + responseStream.end() + } + reject(error) + }) + + req.on('timeout', async () => { + req.destroy() + logger.error(`❌ Claude stream request timeout | Account: ${account?.name || accountId}`) + + if (!responseStream.headersSent) { + responseStream.writeHead(504, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive' + }) + } + if (!responseStream.destroyed) { + // 发送 SSE 错误事件 + responseStream.write('event: error\n') + responseStream.write( + `data: ${JSON.stringify({ + error: 'Request timeout', + code: 'TIMEOUT', + timestamp: new Date().toISOString() + })}\n\n` + ) + responseStream.end() + } + reject(new Error('Request timeout')) + }) + + // 处理客户端断开连接 + responseStream.on('close', () => { + logger.debug('🔌 Client disconnected, cleaning up stream') + if (!req.destroyed) { + req.destroy() + } + }) + + // 写入请求体 + req.write(JSON.stringify(body)) + req.end() + }) + } + + // 🌊 发送流式请求到Claude API + async _makeClaudeStreamRequest( + body, + accessToken, + proxyAgent, + clientHeaders, + responseStream, + requestOptions = {} + ) { + return new Promise((resolve, reject) => { + const url = new URL(this.claudeApiUrl) + + // 获取过滤后的客户端 headers + const filteredHeaders = this._filterClientHeaders(clientHeaders) + + const options = { + hostname: url.hostname, + port: url.port || 443, + path: url.pathname, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + 'anthropic-version': this.apiVersion, + ...filteredHeaders + }, + agent: proxyAgent, + timeout: config.requestTimeout || 600000 + } + + // 如果客户端没有提供 User-Agent,使用默认值 + if (!filteredHeaders['User-Agent'] && !filteredHeaders['user-agent']) { + // 第三个方法不支持统一 User-Agent,使用简化逻辑 + const userAgent = + clientHeaders?.['user-agent'] || + clientHeaders?.['User-Agent'] || + 'claude-cli/1.0.102 (external, cli)' + options.headers['User-Agent'] = userAgent + } + + // 使用自定义的 betaHeader 或默认值 + const betaHeader = + requestOptions?.betaHeader !== undefined ? requestOptions.betaHeader : this.betaHeader + if (betaHeader) { + options.headers['anthropic-beta'] = betaHeader + } + + const req = https.request(options, (res) => { + // 设置响应头 + responseStream.statusCode = res.statusCode + Object.keys(res.headers).forEach((key) => { + responseStream.setHeader(key, res.headers[key]) + }) + + // 管道响应数据 + res.pipe(responseStream) + + res.on('end', () => { + logger.debug('🌊 Claude stream response completed') + resolve() + }) + }) + + req.on('error', async (error) => { + logger.error(`❌ Claude stream request error:`, error.message, { + code: error.code, + errno: error.errno, + syscall: error.syscall + }) + + // 根据错误类型提供更具体的错误信息 + let errorMessage = 'Upstream request failed' + let statusCode = 500 + if (error.code === 'ECONNRESET') { + errorMessage = 'Connection reset by Claude API server' + statusCode = 502 + } else if (error.code === 'ENOTFOUND') { + errorMessage = 'Unable to resolve Claude API hostname' + statusCode = 502 + } else if (error.code === 'ECONNREFUSED') { + errorMessage = 'Connection refused by Claude API server' + statusCode = 502 + } else if (error.code === 'ETIMEDOUT') { + errorMessage = 'Connection timed out to Claude API server' + statusCode = 504 + } + + if (!responseStream.headersSent) { + responseStream.writeHead(statusCode, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive' + }) + } + + if (!responseStream.destroyed) { + // 发送 SSE 错误事件 + responseStream.write('event: error\n') + responseStream.write( + `data: ${JSON.stringify({ + error: errorMessage, + code: error.code, + timestamp: new Date().toISOString() + })}\n\n` + ) + responseStream.end() + } + reject(error) + }) + + req.on('timeout', async () => { + req.destroy() + logger.error(`❌ Claude stream request timeout`) + + if (!responseStream.headersSent) { + responseStream.writeHead(504, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive' + }) + } + if (!responseStream.destroyed) { + // 发送 SSE 错误事件 + responseStream.write('event: error\n') + responseStream.write( + `data: ${JSON.stringify({ + error: 'Request timeout', + code: 'TIMEOUT', + timestamp: new Date().toISOString() + })}\n\n` + ) + responseStream.end() + } + reject(new Error('Request timeout')) + }) + + // 处理客户端断开连接 + responseStream.on('close', () => { + logger.debug('🔌 Client disconnected, cleaning up stream') + if (!req.destroyed) { + req.destroy() + } + }) + + // 写入请求体 + req.write(JSON.stringify(body)) + req.end() + }) + } + + // 🛠️ 统一的错误处理方法 + async _handleServerError(accountId, statusCode, _sessionHash = null, context = '') { + try { + await claudeAccountService.recordServerError(accountId, statusCode) + const errorCount = await claudeAccountService.getServerErrorCount(accountId) + + // 根据错误类型设置不同的阈值和日志前缀 + const isTimeout = statusCode === 504 + const threshold = 3 // 统一使用3次阈值 + const prefix = context ? `${context} ` : '' + + logger.warn( + `⏱️ ${prefix}${isTimeout ? 'Timeout' : 'Server'} error for account ${accountId}, error count: ${errorCount}/${threshold}` + ) + + if (errorCount > threshold) { + const errorTypeLabel = isTimeout ? 'timeout' : '5xx' + // ⚠️ 只记录5xx/504告警,不再自动停止调度,避免上游抖动导致误停 + logger.error( + `❌ ${prefix}Account ${accountId} exceeded ${errorTypeLabel} error threshold (${errorCount} errors), please investigate upstream stability` + ) + } + } catch (handlingError) { + logger.error(`❌ Failed to handle ${context} server error:`, handlingError) + } + } + + // 🔄 重试逻辑 + async _retryRequest(requestFunc, maxRetries = 3) { + let lastError + + for (let i = 0; i < maxRetries; i++) { + try { + return await requestFunc() + } catch (error) { + lastError = error + + if (i < maxRetries - 1) { + const delay = Math.pow(2, i) * 1000 // 指数退避 + logger.warn(`⏳ Retry ${i + 1}/${maxRetries} in ${delay}ms: ${error.message}`) + await new Promise((resolve) => setTimeout(resolve, delay)) + } + } + } + + throw lastError + } + + // 🔐 记录401未授权错误 + async recordUnauthorizedError(accountId) { + try { + const key = `claude_account:${accountId}:401_errors` + + // 增加错误计数,设置5分钟过期时间 + await redis.client.incr(key) + await redis.client.expire(key, 300) // 5分钟 + + logger.info(`📝 Recorded 401 error for account ${accountId}`) + } catch (error) { + logger.error(`❌ Failed to record 401 error for account ${accountId}:`, error) + } + } + + // 🔍 获取401错误计数 + async getUnauthorizedErrorCount(accountId) { + try { + const key = `claude_account:${accountId}:401_errors` + + const count = await redis.client.get(key) + return parseInt(count) || 0 + } catch (error) { + logger.error(`❌ Failed to get 401 error count for account ${accountId}:`, error) + return 0 + } + } + + // 🧹 清除401错误计数 + async clearUnauthorizedErrors(accountId) { + try { + const key = `claude_account:${accountId}:401_errors` + + await redis.client.del(key) + logger.info(`✅ Cleared 401 error count for account ${accountId}`) + } catch (error) { + logger.error(`❌ Failed to clear 401 errors for account ${accountId}:`, error) + } + } + + // 🔧 动态捕获并获取统一的 User-Agent + async captureAndGetUnifiedUserAgent(clientHeaders, account) { + if (account.useUnifiedUserAgent !== 'true') { + return null + } + + const CACHE_KEY = 'claude_code_user_agent:daily' + const TTL = 90000 // 25小时 + + // ⚠️ 重要:这里通过正则表达式判断是否为 Claude Code 客户端 + // 如果未来 Claude Code 的 User-Agent 格式发生变化,需要更新这个正则表达式 + // 当前已知格式:claude-cli/1.0.102 (external, cli) + const CLAUDE_CODE_UA_PATTERN = /^claude-cli\/[\d.]+\s+\(/i + + const clientUA = clientHeaders?.['user-agent'] || clientHeaders?.['User-Agent'] + let cachedUA = await redis.client.get(CACHE_KEY) + + if (clientUA && CLAUDE_CODE_UA_PATTERN.test(clientUA)) { + if (!cachedUA) { + // 没有缓存,直接存储 + await redis.client.setex(CACHE_KEY, TTL, clientUA) + logger.info(`📱 Captured unified Claude Code User-Agent: ${clientUA}`) + cachedUA = clientUA + } else { + // 有缓存,比较版本号,保存更新的版本 + const shouldUpdate = this.compareClaudeCodeVersions(clientUA, cachedUA) + if (shouldUpdate) { + await redis.client.setex(CACHE_KEY, TTL, clientUA) + logger.info(`🔄 Updated to newer Claude Code User-Agent: ${clientUA} (was: ${cachedUA})`) + cachedUA = clientUA + } else { + // 当前版本不比缓存版本新,仅刷新TTL + await redis.client.expire(CACHE_KEY, TTL) + } + } + } + + return cachedUA // 没有缓存返回 null + } + + // 🔄 比较Claude Code版本号,判断是否需要更新 + // 返回 true 表示 newUA 版本更新,需要更新缓存 + compareClaudeCodeVersions(newUA, cachedUA) { + try { + // 提取版本号:claude-cli/1.0.102 (external, cli) -> 1.0.102 + // 支持多段版本号格式,如 1.0.102、2.1.0.beta1 等 + const newVersionMatch = newUA.match(/claude-cli\/([\d.]+(?:[a-zA-Z0-9-]*)?)/i) + const cachedVersionMatch = cachedUA.match(/claude-cli\/([\d.]+(?:[a-zA-Z0-9-]*)?)/i) + + if (!newVersionMatch || !cachedVersionMatch) { + // 无法解析版本号,优先使用新的 + logger.warn(`⚠️ Unable to parse Claude Code versions: new=${newUA}, cached=${cachedUA}`) + return true + } + + const newVersion = newVersionMatch[1] + const cachedVersion = cachedVersionMatch[1] + + // 比较版本号 (semantic version) + const compareResult = this.compareSemanticVersions(newVersion, cachedVersion) + + logger.debug(`🔍 Version comparison: ${newVersion} vs ${cachedVersion} = ${compareResult}`) + + return compareResult > 0 // 新版本更大则返回 true + } catch (error) { + logger.warn(`⚠️ Error comparing Claude Code versions, defaulting to update: ${error.message}`) + return true // 出错时优先使用新的 + } + } + + // 🔢 比较版本号 + // 返回:1 表示 v1 > v2,-1 表示 v1 < v2,0 表示相等 + compareSemanticVersions(version1, version2) { + // 将版本号字符串按"."分割成数字数组 + const arr1 = version1.split('.') + const arr2 = version2.split('.') + + // 获取两个版本号数组中的最大长度 + const maxLength = Math.max(arr1.length, arr2.length) + + // 循环遍历,逐段比较版本号 + for (let i = 0; i < maxLength; i++) { + // 如果某个版本号的某一段不存在,则视为0 + const num1 = parseInt(arr1[i] || 0, 10) + const num2 = parseInt(arr2[i] || 0, 10) + + if (num1 > num2) { + return 1 // version1 大于 version2 + } + if (num1 < num2) { + return -1 // version1 小于 version2 + } + } + + return 0 // 两个版本号相等 + } + + // 🎯 健康检查 + async healthCheck() { + try { + const accounts = await claudeAccountService.getAllAccounts() + const activeAccounts = accounts.filter((acc) => acc.isActive && acc.status === 'active') + + return { + healthy: activeAccounts.length > 0, + activeAccounts: activeAccounts.length, + totalAccounts: accounts.length, + timestamp: new Date().toISOString() + } + } catch (error) { + logger.error('❌ Health check failed:', error) + return { + healthy: false, + error: error.message, + timestamp: new Date().toISOString() + } + } + } +} + +module.exports = new ClaudeRelayService() diff --git a/src/services/costInitService.js b/src/services/costInitService.js new file mode 100644 index 0000000000000000000000000000000000000000..ead54d46f4ec1500009388346ba673f77ab613be --- /dev/null +++ b/src/services/costInitService.js @@ -0,0 +1,196 @@ +const redis = require('../models/redis') +const apiKeyService = require('./apiKeyService') +const CostCalculator = require('../utils/costCalculator') +const logger = require('../utils/logger') + +class CostInitService { + /** + * 初始化所有API Key的费用数据 + * 扫描历史使用记录并计算费用 + */ + async initializeAllCosts() { + try { + logger.info('💰 Starting cost initialization for all API Keys...') + + const apiKeys = await apiKeyService.getAllApiKeys() + const client = redis.getClientSafe() + + let processedCount = 0 + let errorCount = 0 + + for (const apiKey of apiKeys) { + try { + await this.initializeApiKeyCosts(apiKey.id, client) + processedCount++ + + if (processedCount % 10 === 0) { + logger.info(`💰 Processed ${processedCount} API Keys...`) + } + } catch (error) { + errorCount++ + logger.error(`❌ Failed to initialize costs for API Key ${apiKey.id}:`, error) + } + } + + logger.success( + `💰 Cost initialization completed! Processed: ${processedCount}, Errors: ${errorCount}` + ) + return { processed: processedCount, errors: errorCount } + } catch (error) { + logger.error('❌ Failed to initialize costs:', error) + throw error + } + } + + /** + * 初始化单个API Key的费用数据 + */ + async initializeApiKeyCosts(apiKeyId, client) { + // 获取所有时间的模型使用统计 + const modelKeys = await client.keys(`usage:${apiKeyId}:model:*:*:*`) + + // 按日期分组统计 + const dailyCosts = new Map() // date -> cost + const monthlyCosts = new Map() // month -> cost + const hourlyCosts = new Map() // hour -> cost + + for (const key of modelKeys) { + // 解析key格式: usage:{keyId}:model:{period}:{model}:{date} + const match = key.match( + /usage:(.+):model:(daily|monthly|hourly):(.+):(\d{4}-\d{2}(?:-\d{2})?(?::\d{2})?)$/ + ) + if (!match) { + continue + } + + const [, , period, model, dateStr] = match + + // 获取使用数据 + const data = await client.hgetall(key) + if (!data || Object.keys(data).length === 0) { + continue + } + + // 计算费用 + const usage = { + input_tokens: parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0, + output_tokens: parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0, + cache_creation_input_tokens: + parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0, + cache_read_input_tokens: + parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0 + } + + const costResult = CostCalculator.calculateCost(usage, model) + const cost = costResult.costs.total + + // 根据period分组累加费用 + if (period === 'daily') { + const currentCost = dailyCosts.get(dateStr) || 0 + dailyCosts.set(dateStr, currentCost + cost) + } else if (period === 'monthly') { + const currentCost = monthlyCosts.get(dateStr) || 0 + monthlyCosts.set(dateStr, currentCost + cost) + } else if (period === 'hourly') { + const currentCost = hourlyCosts.get(dateStr) || 0 + hourlyCosts.set(dateStr, currentCost + cost) + } + } + + // 将计算出的费用写入Redis + const promises = [] + + // 写入每日费用 + for (const [date, cost] of dailyCosts) { + const key = `usage:cost:daily:${apiKeyId}:${date}` + promises.push( + client.set(key, cost.toString()), + client.expire(key, 86400 * 30) // 30天过期 + ) + } + + // 写入每月费用 + for (const [month, cost] of monthlyCosts) { + const key = `usage:cost:monthly:${apiKeyId}:${month}` + promises.push( + client.set(key, cost.toString()), + client.expire(key, 86400 * 90) // 90天过期 + ) + } + + // 写入每小时费用 + for (const [hour, cost] of hourlyCosts) { + const key = `usage:cost:hourly:${apiKeyId}:${hour}` + promises.push( + client.set(key, cost.toString()), + client.expire(key, 86400 * 7) // 7天过期 + ) + } + + // 计算总费用 + let totalCost = 0 + for (const cost of dailyCosts.values()) { + totalCost += cost + } + + // 写入总费用 + if (totalCost > 0) { + const totalKey = `usage:cost:total:${apiKeyId}` + promises.push(client.set(totalKey, totalCost.toString())) + } + + await Promise.all(promises) + + logger.debug( + `💰 Initialized costs for API Key ${apiKeyId}: Daily entries: ${dailyCosts.size}, Total cost: $${totalCost.toFixed(2)}` + ) + } + + /** + * 检查是否需要初始化费用数据 + */ + async needsInitialization() { + try { + const client = redis.getClientSafe() + + // 检查是否有任何费用数据 + const costKeys = await client.keys('usage:cost:*') + + // 如果没有费用数据,需要初始化 + if (costKeys.length === 0) { + logger.info('💰 No cost data found, initialization needed') + return true + } + + // 检查是否有使用数据但没有对应的费用数据 + const sampleKeys = await client.keys('usage:*:model:daily:*:*') + if (sampleKeys.length > 10) { + // 抽样检查 + const sampleSize = Math.min(10, sampleKeys.length) + for (let i = 0; i < sampleSize; i++) { + const usageKey = sampleKeys[Math.floor(Math.random() * sampleKeys.length)] + const match = usageKey.match(/usage:(.+):model:daily:(.+):(\d{4}-\d{2}-\d{2})$/) + if (match) { + const [, keyId, , date] = match + const costKey = `usage:cost:daily:${keyId}:${date}` + const hasCost = await client.exists(costKey) + if (!hasCost) { + logger.info( + `💰 Found usage without cost data for key ${keyId} on ${date}, initialization needed` + ) + return true + } + } + } + } + + logger.info('💰 Cost data appears to be up to date') + return false + } catch (error) { + logger.error('❌ Failed to check initialization status:', error) + return false + } + } +} + +module.exports = new CostInitService() diff --git a/src/services/droidAccountService.js b/src/services/droidAccountService.js new file mode 100644 index 0000000000000000000000000000000000000000..34fc2c5970c48172334ef89595f3ccd3ff415699 --- /dev/null +++ b/src/services/droidAccountService.js @@ -0,0 +1,1526 @@ +const { v4: uuidv4 } = require('uuid') +const crypto = require('crypto') +const axios = require('axios') +const redis = require('../models/redis') +const config = require('../../config/config') +const logger = require('../utils/logger') +const { maskToken } = require('../utils/tokenMask') +const ProxyHelper = require('../utils/proxyHelper') +const LRUCache = require('../utils/lruCache') + +/** + * Droid 账户管理服务 + * + * 支持 WorkOS OAuth 集成,管理 Droid (Factory.ai) 账户 + * 提供账户创建、token 刷新、代理配置等功能 + */ +class DroidAccountService { + constructor() { + // WorkOS OAuth 配置 + this.oauthTokenUrl = 'https://api.workos.com/user_management/authenticate' + this.factoryApiBaseUrl = 'https://app.factory.ai/api/llm' + + this.workosClientId = 'client_01HNM792M5G5G1A2THWPXKFMXB' + + // Token 刷新策略 + this.refreshIntervalHours = 6 // 每6小时刷新一次 + this.tokenValidHours = 8 // Token 有效期8小时 + + // 加密相关常量 + this.ENCRYPTION_ALGORITHM = 'aes-256-cbc' + this.ENCRYPTION_SALT = 'droid-account-salt' + + // 🚀 性能优化:缓存派生的加密密钥 + this._encryptionKeyCache = null + + // 🔄 解密结果缓存 + this._decryptCache = new LRUCache(500) + + // 🧹 定期清理缓存(每10分钟) + setInterval( + () => { + this._decryptCache.cleanup() + logger.info('🧹 Droid decrypt cache cleanup completed', this._decryptCache.getStats()) + }, + 10 * 60 * 1000 + ) + + this.supportedEndpointTypes = new Set(['anthropic', 'openai']) + } + + _sanitizeEndpointType(endpointType) { + if (!endpointType) { + return 'anthropic' + } + + const normalized = String(endpointType).toLowerCase() + if (normalized === 'openai' || normalized === 'common') { + return 'openai' + } + + if (this.supportedEndpointTypes.has(normalized)) { + return normalized + } + + return 'anthropic' + } + + _isTruthy(value) { + if (value === undefined || value === null) { + return false + } + if (typeof value === 'boolean') { + return value + } + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase() + if (normalized === 'true') { + return true + } + if (normalized === 'false') { + return false + } + return normalized.length > 0 && normalized !== '0' && normalized !== 'no' + } + return Boolean(value) + } + + /** + * 生成加密密钥(缓存优化) + */ + _generateEncryptionKey() { + if (!this._encryptionKeyCache) { + this._encryptionKeyCache = crypto.scryptSync( + config.security.encryptionKey, + this.ENCRYPTION_SALT, + 32 + ) + logger.info('🔑 Droid encryption key derived and cached for performance optimization') + } + return this._encryptionKeyCache + } + + /** + * 加密敏感数据 + */ + _encryptSensitiveData(text) { + if (!text) { + return '' + } + + const key = this._generateEncryptionKey() + const iv = crypto.randomBytes(16) + const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv) + + let encrypted = cipher.update(text, 'utf8', 'hex') + encrypted += cipher.final('hex') + + return `${iv.toString('hex')}:${encrypted}` + } + + /** + * 解密敏感数据(带缓存) + */ + _decryptSensitiveData(encryptedText) { + if (!encryptedText) { + return '' + } + + // 🎯 检查缓存 + const cacheKey = crypto.createHash('sha256').update(encryptedText).digest('hex') + const cached = this._decryptCache.get(cacheKey) + if (cached !== undefined) { + return cached + } + + try { + const key = this._generateEncryptionKey() + const parts = encryptedText.split(':') + const iv = Buffer.from(parts[0], 'hex') + const encrypted = parts[1] + + const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv) + let decrypted = decipher.update(encrypted, 'hex', 'utf8') + decrypted += decipher.final('utf8') + + // 💾 存入缓存(5分钟过期) + this._decryptCache.set(cacheKey, decrypted, 5 * 60 * 1000) + + return decrypted + } catch (error) { + logger.error('❌ Failed to decrypt Droid data:', error) + return '' + } + } + + _parseApiKeyEntries(rawEntries) { + if (!rawEntries) { + return [] + } + + if (Array.isArray(rawEntries)) { + return rawEntries + } + + if (typeof rawEntries === 'string') { + try { + const parsed = JSON.parse(rawEntries) + return Array.isArray(parsed) ? parsed : [] + } catch (error) { + logger.warn('⚠️ Failed to parse Droid API Key entries:', error.message) + return [] + } + } + + return [] + } + + _buildApiKeyEntries(apiKeys, existingEntries = [], clearExisting = false) { + const now = new Date().toISOString() + const normalizedExisting = Array.isArray(existingEntries) ? existingEntries : [] + + const entries = clearExisting + ? [] + : normalizedExisting + .filter((entry) => entry && entry.id && entry.encryptedKey) + .map((entry) => ({ + ...entry, + status: entry.status || 'active' // 确保有默认状态 + })) + + const hashSet = new Set(entries.map((entry) => entry.hash).filter(Boolean)) + + if (!Array.isArray(apiKeys) || apiKeys.length === 0) { + return entries + } + + for (const rawKey of apiKeys) { + if (typeof rawKey !== 'string') { + continue + } + + const trimmed = rawKey.trim() + if (!trimmed) { + continue + } + + const hash = crypto.createHash('sha256').update(trimmed).digest('hex') + if (hashSet.has(hash)) { + continue + } + + hashSet.add(hash) + + entries.push({ + id: uuidv4(), + hash, + encryptedKey: this._encryptSensitiveData(trimmed), + createdAt: now, + lastUsedAt: '', + usageCount: '0', + status: 'active', // 新增状态字段 + errorMessage: '' // 新增错误信息字段 + }) + } + + return entries + } + + _maskApiKeyEntries(entries) { + if (!Array.isArray(entries)) { + return [] + } + + return entries.map((entry) => ({ + id: entry.id, + createdAt: entry.createdAt || '', + lastUsedAt: entry.lastUsedAt || '', + usageCount: entry.usageCount || '0', + status: entry.status || 'active', // 新增状态字段 + errorMessage: entry.errorMessage || '' // 新增错误信息字段 + })) + } + + _decryptApiKeyEntry(entry) { + if (!entry || !entry.encryptedKey) { + return null + } + + const apiKey = this._decryptSensitiveData(entry.encryptedKey) + if (!apiKey) { + return null + } + + const usageCountNumber = Number(entry.usageCount) + + return { + id: entry.id, + key: apiKey, + hash: entry.hash || '', + createdAt: entry.createdAt || '', + lastUsedAt: entry.lastUsedAt || '', + usageCount: Number.isFinite(usageCountNumber) && usageCountNumber >= 0 ? usageCountNumber : 0, + status: entry.status || 'active', // 新增状态字段 + errorMessage: entry.errorMessage || '' // 新增错误信息字段 + } + } + + async getDecryptedApiKeyEntries(accountId) { + if (!accountId) { + return [] + } + + const accountData = await redis.getDroidAccount(accountId) + if (!accountData) { + return [] + } + + const entries = this._parseApiKeyEntries(accountData.apiKeys) + return entries + .map((entry) => this._decryptApiKeyEntry(entry)) + .filter((entry) => entry && entry.key) + } + + async touchApiKeyUsage(accountId, keyId) { + if (!accountId || !keyId) { + return + } + + try { + const accountData = await redis.getDroidAccount(accountId) + if (!accountData) { + return + } + + const entries = this._parseApiKeyEntries(accountData.apiKeys) + const index = entries.findIndex((entry) => entry.id === keyId) + + if (index === -1) { + return + } + + const updatedEntry = { ...entries[index] } + updatedEntry.lastUsedAt = new Date().toISOString() + const usageCount = Number(updatedEntry.usageCount) + updatedEntry.usageCount = String( + Number.isFinite(usageCount) && usageCount >= 0 ? usageCount + 1 : 1 + ) + + entries[index] = updatedEntry + + accountData.apiKeys = JSON.stringify(entries) + accountData.apiKeyCount = String(entries.length) + + await redis.setDroidAccount(accountId, accountData) + } catch (error) { + logger.warn(`⚠️ Failed to update API key usage for Droid account ${accountId}:`, error) + } + } + + /** + * 删除指定的 Droid API Key 条目 + */ + async removeApiKeyEntry(accountId, keyId) { + if (!accountId || !keyId) { + return { removed: false, remainingCount: 0 } + } + + try { + const accountData = await redis.getDroidAccount(accountId) + if (!accountData) { + return { removed: false, remainingCount: 0 } + } + + const entries = this._parseApiKeyEntries(accountData.apiKeys) + if (!entries || entries.length === 0) { + return { removed: false, remainingCount: 0 } + } + + const filtered = entries.filter((entry) => entry && entry.id !== keyId) + if (filtered.length === entries.length) { + return { removed: false, remainingCount: entries.length } + } + + accountData.apiKeys = filtered.length ? JSON.stringify(filtered) : '' + accountData.apiKeyCount = String(filtered.length) + + await redis.setDroidAccount(accountId, accountData) + + logger.warn( + `🚫 已删除 Droid API Key ${keyId}(Account: ${accountId}),剩余 ${filtered.length}` + ) + + return { removed: true, remainingCount: filtered.length } + } catch (error) { + logger.error(`❌ 删除 Droid API Key 失败:${keyId}(Account: ${accountId})`, error) + return { removed: false, remainingCount: 0, error } + } + } + + /** + * 标记指定的 Droid API Key 条目为异常状态 + */ + async markApiKeyAsError(accountId, keyId, errorMessage = '') { + if (!accountId || !keyId) { + return { marked: false, error: '参数无效' } + } + + try { + const accountData = await redis.getDroidAccount(accountId) + if (!accountData) { + return { marked: false, error: '账户不存在' } + } + + const entries = this._parseApiKeyEntries(accountData.apiKeys) + if (!entries || entries.length === 0) { + return { marked: false, error: '无API Key条目' } + } + + let marked = false + const updatedEntries = entries.map((entry) => { + if (entry && entry.id === keyId) { + marked = true + return { + ...entry, + status: 'error', + errorMessage: errorMessage || 'API Key异常' + } + } + return entry + }) + + if (!marked) { + return { marked: false, error: '未找到指定的API Key' } + } + + accountData.apiKeys = JSON.stringify(updatedEntries) + await redis.setDroidAccount(accountId, accountData) + + logger.warn( + `⚠️ 已标记 Droid API Key ${keyId} 为异常状态(Account: ${accountId}):${errorMessage}` + ) + + return { marked: true } + } catch (error) { + logger.error(`❌ 标记 Droid API Key 异常状态失败:${keyId}(Account: ${accountId})`, error) + return { marked: false, error: error.message } + } + } + + /** + * 使用 WorkOS Refresh Token 刷新并验证凭证 + */ + async _refreshTokensWithWorkOS(refreshToken, proxyConfig = null, organizationId = null) { + if (!refreshToken || typeof refreshToken !== 'string') { + throw new Error('Refresh Token 无效') + } + + const formData = new URLSearchParams() + formData.append('grant_type', 'refresh_token') + formData.append('refresh_token', refreshToken) + formData.append('client_id', this.workosClientId) + if (organizationId) { + formData.append('organization_id', organizationId) + } + + const requestOptions = { + method: 'POST', + url: this.oauthTokenUrl, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + data: formData.toString(), + timeout: 30000 + } + + if (proxyConfig) { + const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig) + if (proxyAgent) { + requestOptions.httpAgent = proxyAgent + requestOptions.httpsAgent = proxyAgent + logger.info( + `🌐 使用代理验证 Droid Refresh Token: ${ProxyHelper.getProxyDescription(proxyConfig)}` + ) + } + } + + const response = await axios(requestOptions) + if (!response.data || !response.data.access_token) { + throw new Error('WorkOS OAuth 返回数据无效') + } + + const { + access_token, + refresh_token, + user, + organization_id, + expires_in, + token_type, + authentication_method + } = response.data + + let expiresAt = response.data.expires_at || '' + if (!expiresAt) { + const expiresInSeconds = + typeof expires_in === 'number' && Number.isFinite(expires_in) + ? expires_in + : this.tokenValidHours * 3600 + expiresAt = new Date(Date.now() + expiresInSeconds * 1000).toISOString() + } + + return { + accessToken: access_token, + refreshToken: refresh_token || refreshToken, + expiresAt, + expiresIn: typeof expires_in === 'number' && Number.isFinite(expires_in) ? expires_in : null, + user: user || null, + organizationId: organization_id || '', + tokenType: token_type || 'Bearer', + authenticationMethod: authentication_method || '' + } + } + + /** + * 使用 Factory CLI 接口获取组织 ID 列表 + */ + async _fetchFactoryOrgIds(accessToken, proxyConfig = null) { + if (!accessToken) { + return [] + } + + const requestOptions = { + method: 'GET', + url: 'https://app.factory.ai/api/cli/org', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + 'x-factory-client': 'cli', + 'User-Agent': this.userAgent + }, + timeout: 15000 + } + + if (proxyConfig) { + const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig) + if (proxyAgent) { + requestOptions.httpAgent = proxyAgent + requestOptions.httpsAgent = proxyAgent + } + } + + try { + const response = await axios(requestOptions) + const data = response.data || {} + if (Array.isArray(data.workosOrgIds) && data.workosOrgIds.length > 0) { + return data.workosOrgIds + } + logger.warn('⚠️ 未从 Factory CLI 接口获取到 workosOrgIds') + return [] + } catch (error) { + logger.warn('⚠️ 获取 Factory 组织信息失败:', error.message) + return [] + } + } + + /** + * 创建 Droid 账户 + * + * @param {Object} options - 账户配置选项 + * @returns {Promise} 创建的账户信息 + */ + async createAccount(options = {}) { + const { + name = 'Unnamed Droid Account', + description = '', + refreshToken = '', // WorkOS refresh token + accessToken = '', // WorkOS access token (可选) + expiresAt = '', // Token 过期时间 + proxy = null, // { type: 'socks5', host: 'localhost', port: 1080, username: '', password: '' } + isActive = true, + accountType = 'shared', // 'dedicated' or 'shared' + platform = 'droid', + priority = 50, // 调度优先级 (1-100) + schedulable = true, // 是否可被调度 + endpointType = 'anthropic', // 默认端点类型: 'anthropic' 或 'openai' + organizationId = '', + ownerEmail = '', + ownerName = '', + userId = '', + tokenType = 'Bearer', + authenticationMethod = '', + expiresIn = null, + apiKeys = [] + } = options + + const accountId = uuidv4() + + const normalizedEndpointType = this._sanitizeEndpointType(endpointType) + + let normalizedRefreshToken = refreshToken + let normalizedAccessToken = accessToken + let normalizedExpiresAt = expiresAt || '' + let normalizedExpiresIn = expiresIn + let normalizedOrganizationId = organizationId || '' + let normalizedOwnerEmail = ownerEmail || '' + let normalizedOwnerName = ownerName || '' + let normalizedOwnerDisplayName = ownerName || ownerEmail || '' + let normalizedUserId = userId || '' + let normalizedTokenType = tokenType || 'Bearer' + let normalizedAuthenticationMethod = authenticationMethod || '' + let lastRefreshAt = accessToken ? new Date().toISOString() : '' + let status = accessToken ? 'active' : 'created' + + const apiKeyEntries = this._buildApiKeyEntries(apiKeys) + const hasApiKeys = apiKeyEntries.length > 0 + + if (hasApiKeys) { + normalizedAuthenticationMethod = 'api_key' + normalizedAccessToken = '' + normalizedRefreshToken = '' + normalizedExpiresAt = '' + normalizedExpiresIn = null + lastRefreshAt = '' + status = 'active' + } + + const normalizedAuthMethod = + typeof normalizedAuthenticationMethod === 'string' + ? normalizedAuthenticationMethod.toLowerCase().trim() + : '' + + const isApiKeyProvision = normalizedAuthMethod === 'api_key' + const isManualProvision = normalizedAuthMethod === 'manual' + + const provisioningMode = isApiKeyProvision ? 'api_key' : isManualProvision ? 'manual' : 'oauth' + + if (isApiKeyProvision) { + logger.info( + `🔍 [Droid api_key] 初始密钥 - AccountName: ${name}, KeyCount: ${apiKeyEntries.length}` + ) + } else { + logger.info( + `🔍 [Droid ${provisioningMode}] 初始令牌 - AccountName: ${name}, AccessToken: ${ + normalizedAccessToken || '[empty]' + }, RefreshToken: ${normalizedRefreshToken || '[empty]'}` + ) + } + + let proxyConfig = null + if (proxy && typeof proxy === 'object') { + proxyConfig = proxy + } else if (typeof proxy === 'string' && proxy.trim()) { + try { + proxyConfig = JSON.parse(proxy) + } catch (error) { + logger.warn('⚠️ Droid 代理配置解析失败,已忽略:', error.message) + proxyConfig = null + } + } + + if (!isApiKeyProvision && normalizedRefreshToken && isManualProvision) { + try { + const refreshed = await this._refreshTokensWithWorkOS(normalizedRefreshToken, proxyConfig) + + logger.info( + `🔍 [Droid manual] 刷新后令牌 - AccountName: ${name}, AccessToken: ${refreshed.accessToken || '[empty]'}, RefreshToken: ${refreshed.refreshToken || '[empty]'}, ExpiresAt: ${refreshed.expiresAt || '[empty]'}, ExpiresIn: ${ + refreshed.expiresIn !== null && refreshed.expiresIn !== undefined + ? refreshed.expiresIn + : '[empty]' + }` + ) + + normalizedAccessToken = refreshed.accessToken + normalizedRefreshToken = refreshed.refreshToken + normalizedExpiresAt = refreshed.expiresAt || normalizedExpiresAt + normalizedTokenType = refreshed.tokenType || normalizedTokenType + normalizedAuthenticationMethod = + refreshed.authenticationMethod || normalizedAuthenticationMethod + if (refreshed.expiresIn !== null) { + normalizedExpiresIn = refreshed.expiresIn + } + if (refreshed.organizationId) { + normalizedOrganizationId = refreshed.organizationId + } + + if (refreshed.user) { + const userInfo = refreshed.user + if (typeof userInfo.email === 'string' && userInfo.email.trim()) { + normalizedOwnerEmail = userInfo.email.trim() + } + const nameParts = [] + if (typeof userInfo.first_name === 'string' && userInfo.first_name.trim()) { + nameParts.push(userInfo.first_name.trim()) + } + if (typeof userInfo.last_name === 'string' && userInfo.last_name.trim()) { + nameParts.push(userInfo.last_name.trim()) + } + const derivedName = + nameParts.join(' ').trim() || + (typeof userInfo.name === 'string' ? userInfo.name.trim() : '') || + (typeof userInfo.display_name === 'string' ? userInfo.display_name.trim() : '') + + if (derivedName) { + normalizedOwnerName = derivedName + normalizedOwnerDisplayName = derivedName + } else if (normalizedOwnerEmail) { + normalizedOwnerName = normalizedOwnerName || normalizedOwnerEmail + normalizedOwnerDisplayName = + normalizedOwnerDisplayName || normalizedOwnerEmail || normalizedOwnerName + } + + if (typeof userInfo.id === 'string' && userInfo.id.trim()) { + normalizedUserId = userInfo.id.trim() + } + } + + lastRefreshAt = new Date().toISOString() + status = 'active' + logger.success(`✅ 使用 Refresh Token 成功验证并刷新 Droid 账户: ${name} (${accountId})`) + } catch (error) { + logger.error('❌ 使用 Refresh Token 验证 Droid 账户失败:', error) + throw new Error(`Refresh Token 验证失败:${error.message}`) + } + } else if (!isApiKeyProvision && normalizedRefreshToken && !isManualProvision) { + try { + const orgIds = await this._fetchFactoryOrgIds(normalizedAccessToken, proxyConfig) + const selectedOrgId = + normalizedOrganizationId || + (Array.isArray(orgIds) + ? orgIds.find((id) => typeof id === 'string' && id.trim()) + : null) || + '' + + if (!selectedOrgId) { + logger.warn(`⚠️ [Droid oauth] 未获取到组织ID,跳过 WorkOS 刷新: ${name} (${accountId})`) + } else { + const refreshed = await this._refreshTokensWithWorkOS( + normalizedRefreshToken, + proxyConfig, + selectedOrgId + ) + + logger.info( + `🔍 [Droid oauth] 组织刷新后令牌 - AccountName: ${name}, AccessToken: ${refreshed.accessToken || '[empty]'}, RefreshToken: ${refreshed.refreshToken || '[empty]'}, OrganizationId: ${ + refreshed.organizationId || selectedOrgId + }, ExpiresAt: ${refreshed.expiresAt || '[empty]'}` + ) + + normalizedAccessToken = refreshed.accessToken + normalizedRefreshToken = refreshed.refreshToken + normalizedExpiresAt = refreshed.expiresAt || normalizedExpiresAt + normalizedTokenType = refreshed.tokenType || normalizedTokenType + normalizedAuthenticationMethod = + refreshed.authenticationMethod || normalizedAuthenticationMethod + if (refreshed.expiresIn !== null && refreshed.expiresIn !== undefined) { + normalizedExpiresIn = refreshed.expiresIn + } + if (refreshed.organizationId) { + normalizedOrganizationId = refreshed.organizationId + } else { + normalizedOrganizationId = selectedOrgId + } + + if (refreshed.user) { + const userInfo = refreshed.user + if (typeof userInfo.email === 'string' && userInfo.email.trim()) { + normalizedOwnerEmail = userInfo.email.trim() + } + const nameParts = [] + if (typeof userInfo.first_name === 'string' && userInfo.first_name.trim()) { + nameParts.push(userInfo.first_name.trim()) + } + if (typeof userInfo.last_name === 'string' && userInfo.last_name.trim()) { + nameParts.push(userInfo.last_name.trim()) + } + const derivedName = + nameParts.join(' ').trim() || + (typeof userInfo.name === 'string' ? userInfo.name.trim() : '') || + (typeof userInfo.display_name === 'string' ? userInfo.display_name.trim() : '') + + if (derivedName) { + normalizedOwnerName = derivedName + normalizedOwnerDisplayName = derivedName + } else if (normalizedOwnerEmail) { + normalizedOwnerName = normalizedOwnerName || normalizedOwnerEmail + normalizedOwnerDisplayName = + normalizedOwnerDisplayName || normalizedOwnerEmail || normalizedOwnerName + } + + if (typeof userInfo.id === 'string' && userInfo.id.trim()) { + normalizedUserId = userInfo.id.trim() + } + } + + lastRefreshAt = new Date().toISOString() + status = 'active' + } + } catch (error) { + logger.warn(`⚠️ [Droid oauth] 初始化刷新失败: ${name} (${accountId}) - ${error.message}`) + } + } + + if (!isApiKeyProvision && !normalizedExpiresAt) { + let expiresInSeconds = null + if (typeof normalizedExpiresIn === 'number' && Number.isFinite(normalizedExpiresIn)) { + expiresInSeconds = normalizedExpiresIn + } else if ( + typeof normalizedExpiresIn === 'string' && + normalizedExpiresIn.trim() && + !Number.isNaN(Number(normalizedExpiresIn)) + ) { + expiresInSeconds = Number(normalizedExpiresIn) + } + + if (!Number.isFinite(expiresInSeconds) || expiresInSeconds <= 0) { + expiresInSeconds = this.tokenValidHours * 3600 + } + + normalizedExpiresAt = new Date(Date.now() + expiresInSeconds * 1000).toISOString() + normalizedExpiresIn = expiresInSeconds + } + + logger.info( + `🔍 [Droid ${provisioningMode}] 写入前令牌快照 - AccountName: ${name}, AccessToken: ${normalizedAccessToken || '[empty]'}, RefreshToken: ${normalizedRefreshToken || '[empty]'}, ExpiresAt: ${normalizedExpiresAt || '[empty]'}, ExpiresIn: ${ + normalizedExpiresIn !== null && normalizedExpiresIn !== undefined + ? normalizedExpiresIn + : '[empty]' + }` + ) + + const accountData = { + id: accountId, + name, + description, + refreshToken: this._encryptSensitiveData(normalizedRefreshToken), + accessToken: this._encryptSensitiveData(normalizedAccessToken), + expiresAt: normalizedExpiresAt || '', + proxy: proxy ? JSON.stringify(proxy) : '', + isActive: isActive.toString(), + accountType, + platform, + priority: priority.toString(), + createdAt: new Date().toISOString(), + lastUsedAt: '', + lastRefreshAt, + status, // created, active, expired, error + errorMessage: '', + schedulable: schedulable.toString(), + endpointType: normalizedEndpointType, // anthropic 或 openai + organizationId: normalizedOrganizationId || '', + owner: normalizedOwnerName || normalizedOwnerEmail || '', + ownerEmail: normalizedOwnerEmail || '', + ownerName: normalizedOwnerName || '', + ownerDisplayName: + normalizedOwnerDisplayName || normalizedOwnerName || normalizedOwnerEmail || '', + userId: normalizedUserId || '', + tokenType: normalizedTokenType || 'Bearer', + authenticationMethod: normalizedAuthenticationMethod || '', + expiresIn: + normalizedExpiresIn !== null && normalizedExpiresIn !== undefined + ? String(normalizedExpiresIn) + : '', + apiKeys: hasApiKeys ? JSON.stringify(apiKeyEntries) : '', + apiKeyCount: hasApiKeys ? String(apiKeyEntries.length) : '0', + apiKeyStrategy: hasApiKeys ? 'random_sticky' : '' + } + + await redis.setDroidAccount(accountId, accountData) + + logger.success( + `🏢 Created Droid account: ${name} (${accountId}) - Endpoint: ${normalizedEndpointType}` + ) + + try { + const verifyAccount = await this.getAccount(accountId) + logger.info( + `🔍 [Droid ${provisioningMode}] Redis 写入后验证 - AccountName: ${name}, AccessToken: ${verifyAccount?.accessToken || '[empty]'}, RefreshToken: ${verifyAccount?.refreshToken || '[empty]'}, ExpiresAt: ${verifyAccount?.expiresAt || '[empty]'}` + ) + } catch (verifyError) { + logger.warn( + `⚠️ [Droid ${provisioningMode}] 写入后验证失败: ${name} (${accountId}) - ${verifyError.message}` + ) + } + return { id: accountId, ...accountData } + } + + /** + * 获取 Droid 账户信息 + */ + async getAccount(accountId) { + const account = await redis.getDroidAccount(accountId) + if (!account || Object.keys(account).length === 0) { + return null + } + + // 解密敏感数据 + const apiKeyEntries = this._parseApiKeyEntries(account.apiKeys) + + return { + ...account, + id: accountId, + endpointType: this._sanitizeEndpointType(account.endpointType), + refreshToken: this._decryptSensitiveData(account.refreshToken), + accessToken: this._decryptSensitiveData(account.accessToken), + apiKeys: this._maskApiKeyEntries(apiKeyEntries), + apiKeyCount: apiKeyEntries.length + } + } + + /** + * 获取所有 Droid 账户 + */ + async getAllAccounts() { + const accounts = await redis.getAllDroidAccounts() + return accounts.map((account) => ({ + ...account, + endpointType: this._sanitizeEndpointType(account.endpointType), + // 不解密完整 token,只返回掩码 + refreshToken: account.refreshToken ? '***ENCRYPTED***' : '', + accessToken: account.accessToken + ? maskToken(this._decryptSensitiveData(account.accessToken)) + : '', + apiKeyCount: (() => { + const parsedCount = this._parseApiKeyEntries(account.apiKeys).length + if (account.apiKeyCount === undefined || account.apiKeyCount === null) { + return parsedCount + } + const numeric = Number(account.apiKeyCount) + return Number.isFinite(numeric) && numeric >= 0 ? numeric : parsedCount + })() + })) + } + + /** + * 更新 Droid 账户 + */ + async updateAccount(accountId, updates) { + const account = await this.getAccount(accountId) + if (!account) { + throw new Error(`Droid account not found: ${accountId}`) + } + + const storedAccount = await redis.getDroidAccount(accountId) + const hasStoredAccount = + storedAccount && typeof storedAccount === 'object' && Object.keys(storedAccount).length > 0 + const sanitizedUpdates = { ...updates } + + if (typeof sanitizedUpdates.accessToken === 'string') { + sanitizedUpdates.accessToken = sanitizedUpdates.accessToken.trim() + } + if (typeof sanitizedUpdates.refreshToken === 'string') { + sanitizedUpdates.refreshToken = sanitizedUpdates.refreshToken.trim() + } + + if (sanitizedUpdates.endpointType) { + sanitizedUpdates.endpointType = this._sanitizeEndpointType(sanitizedUpdates.endpointType) + } + + const parseProxyConfig = (value) => { + if (!value) { + return null + } + if (typeof value === 'object') { + return value + } + if (typeof value === 'string' && value.trim()) { + try { + return JSON.parse(value) + } catch (error) { + logger.warn('⚠️ Failed to parse stored Droid proxy config:', error.message) + } + } + return null + } + + let proxyConfig = null + if (updates.proxy !== undefined) { + if (updates.proxy && typeof updates.proxy === 'object') { + proxyConfig = updates.proxy + sanitizedUpdates.proxy = JSON.stringify(updates.proxy) + } else if (typeof updates.proxy === 'string' && updates.proxy.trim()) { + proxyConfig = parseProxyConfig(updates.proxy) + sanitizedUpdates.proxy = updates.proxy + } else { + sanitizedUpdates.proxy = '' + } + } else if (account.proxy) { + proxyConfig = parseProxyConfig(account.proxy) + } + + const hasNewRefreshToken = + typeof sanitizedUpdates.refreshToken === 'string' && sanitizedUpdates.refreshToken + + if (hasNewRefreshToken) { + try { + const refreshed = await this._refreshTokensWithWorkOS( + sanitizedUpdates.refreshToken, + proxyConfig + ) + + sanitizedUpdates.accessToken = refreshed.accessToken + sanitizedUpdates.refreshToken = refreshed.refreshToken || sanitizedUpdates.refreshToken + sanitizedUpdates.expiresAt = + refreshed.expiresAt || sanitizedUpdates.expiresAt || account.expiresAt || '' + + if (refreshed.expiresIn !== null && refreshed.expiresIn !== undefined) { + sanitizedUpdates.expiresIn = String(refreshed.expiresIn) + } + + sanitizedUpdates.tokenType = refreshed.tokenType || account.tokenType || 'Bearer' + sanitizedUpdates.authenticationMethod = + refreshed.authenticationMethod || account.authenticationMethod || '' + sanitizedUpdates.organizationId = + sanitizedUpdates.organizationId || + refreshed.organizationId || + account.organizationId || + '' + sanitizedUpdates.lastRefreshAt = new Date().toISOString() + sanitizedUpdates.status = 'active' + sanitizedUpdates.errorMessage = '' + + if (refreshed.user) { + const userInfo = refreshed.user + const email = typeof userInfo.email === 'string' ? userInfo.email.trim() : '' + if (email) { + sanitizedUpdates.ownerEmail = email + } + + const nameParts = [] + if (typeof userInfo.first_name === 'string' && userInfo.first_name.trim()) { + nameParts.push(userInfo.first_name.trim()) + } + if (typeof userInfo.last_name === 'string' && userInfo.last_name.trim()) { + nameParts.push(userInfo.last_name.trim()) + } + + const derivedName = + nameParts.join(' ').trim() || + (typeof userInfo.name === 'string' ? userInfo.name.trim() : '') || + (typeof userInfo.display_name === 'string' ? userInfo.display_name.trim() : '') + + if (derivedName) { + sanitizedUpdates.ownerName = derivedName + sanitizedUpdates.ownerDisplayName = derivedName + sanitizedUpdates.owner = derivedName + } else if (sanitizedUpdates.ownerEmail) { + sanitizedUpdates.ownerName = sanitizedUpdates.ownerName || sanitizedUpdates.ownerEmail + sanitizedUpdates.ownerDisplayName = + sanitizedUpdates.ownerDisplayName || sanitizedUpdates.ownerEmail + sanitizedUpdates.owner = sanitizedUpdates.owner || sanitizedUpdates.ownerEmail + } + + if (typeof userInfo.id === 'string' && userInfo.id.trim()) { + sanitizedUpdates.userId = userInfo.id.trim() + } + } + } catch (error) { + logger.error('❌ 使用新的 Refresh Token 更新 Droid 账户失败:', error) + throw new Error(`Refresh Token 验证失败:${error.message || '未知错误'}`) + } + } + + if (sanitizedUpdates.proxy === undefined) { + sanitizedUpdates.proxy = account.proxy || '' + } + + // 使用 Redis 中的原始数据获取加密的 API Key 条目 + const existingApiKeyEntries = this._parseApiKeyEntries( + hasStoredAccount && Object.prototype.hasOwnProperty.call(storedAccount, 'apiKeys') + ? storedAccount.apiKeys + : '' + ) + const newApiKeysInput = Array.isArray(updates.apiKeys) ? updates.apiKeys : [] + const removeApiKeysInput = Array.isArray(updates.removeApiKeys) ? updates.removeApiKeys : [] + const wantsClearApiKeys = Boolean(updates.clearApiKeys) + const rawApiKeyMode = + typeof updates.apiKeyUpdateMode === 'string' + ? updates.apiKeyUpdateMode.trim().toLowerCase() + : '' + + let apiKeyUpdateMode = ['append', 'replace', 'delete', 'update'].includes(rawApiKeyMode) + ? rawApiKeyMode + : '' + + if (!apiKeyUpdateMode) { + if (wantsClearApiKeys) { + apiKeyUpdateMode = 'replace' + } else if (removeApiKeysInput.length > 0) { + apiKeyUpdateMode = 'delete' + } else { + apiKeyUpdateMode = 'append' + } + } + + if (sanitizedUpdates.apiKeys !== undefined) { + delete sanitizedUpdates.apiKeys + } + if (sanitizedUpdates.clearApiKeys !== undefined) { + delete sanitizedUpdates.clearApiKeys + } + if (sanitizedUpdates.apiKeyUpdateMode !== undefined) { + delete sanitizedUpdates.apiKeyUpdateMode + } + if (sanitizedUpdates.removeApiKeys !== undefined) { + delete sanitizedUpdates.removeApiKeys + } + + let mergedApiKeys = existingApiKeyEntries + let apiKeysUpdated = false + let addedCount = 0 + let removedCount = 0 + + if (apiKeyUpdateMode === 'delete') { + const removalHashes = new Set() + + for (const candidate of removeApiKeysInput) { + if (typeof candidate !== 'string') { + continue + } + const trimmed = candidate.trim() + if (!trimmed) { + continue + } + const hash = crypto.createHash('sha256').update(trimmed).digest('hex') + removalHashes.add(hash) + } + + if (removalHashes.size > 0) { + mergedApiKeys = existingApiKeyEntries.filter( + (entry) => entry && entry.hash && !removalHashes.has(entry.hash) + ) + removedCount = existingApiKeyEntries.length - mergedApiKeys.length + apiKeysUpdated = removedCount > 0 + + if (!apiKeysUpdated) { + logger.warn( + `⚠️ 删除模式未匹配任何 Droid API Key: ${accountId} (提供 ${removalHashes.size} 条)` + ) + } + } else if (removeApiKeysInput.length > 0) { + logger.warn(`⚠️ 删除模式未收到有效的 Droid API Key: ${accountId}`) + } + } else if (apiKeyUpdateMode === 'update') { + // 更新模式:根据提供的 key 匹配现有条目并更新状态 + mergedApiKeys = [...existingApiKeyEntries] + const updatedHashes = new Set() + + for (const updateItem of newApiKeysInput) { + if (!updateItem || typeof updateItem !== 'object') { + continue + } + + const key = updateItem.key || updateItem.apiKey || '' + if (!key || typeof key !== 'string') { + continue + } + + const trimmed = key.trim() + if (!trimmed) { + continue + } + + const hash = crypto.createHash('sha256').update(trimmed).digest('hex') + updatedHashes.add(hash) + + // 查找现有条目 + const existingIndex = mergedApiKeys.findIndex((entry) => entry && entry.hash === hash) + + if (existingIndex !== -1) { + // 更新现有条目的状态信息 + const existingEntry = mergedApiKeys[existingIndex] + mergedApiKeys[existingIndex] = { + ...existingEntry, + status: updateItem.status || existingEntry.status || 'active', + errorMessage: + updateItem.errorMessage !== undefined + ? updateItem.errorMessage + : existingEntry.errorMessage || '', + lastUsedAt: + updateItem.lastUsedAt !== undefined + ? updateItem.lastUsedAt + : existingEntry.lastUsedAt || '', + usageCount: + updateItem.usageCount !== undefined + ? String(updateItem.usageCount) + : existingEntry.usageCount || '0' + } + apiKeysUpdated = true + } + } + + if (!apiKeysUpdated) { + logger.warn( + `⚠️ 更新模式未匹配任何 Droid API Key: ${accountId} (提供 ${updatedHashes.size} 个哈希)` + ) + } + } else { + const clearExisting = apiKeyUpdateMode === 'replace' || wantsClearApiKeys + const baselineCount = clearExisting ? 0 : existingApiKeyEntries.length + + mergedApiKeys = this._buildApiKeyEntries( + newApiKeysInput, + existingApiKeyEntries, + clearExisting + ) + + addedCount = Math.max(mergedApiKeys.length - baselineCount, 0) + apiKeysUpdated = clearExisting || addedCount > 0 + } + + if (apiKeysUpdated) { + sanitizedUpdates.apiKeys = mergedApiKeys.length ? JSON.stringify(mergedApiKeys) : '' + sanitizedUpdates.apiKeyCount = String(mergedApiKeys.length) + + if (apiKeyUpdateMode === 'delete') { + logger.info( + `🔑 删除模式更新 Droid API keys for ${accountId}: 已移除 ${removedCount} 条,剩余 ${mergedApiKeys.length}` + ) + } else if (apiKeyUpdateMode === 'update') { + logger.info( + `🔑 更新模式更新 Droid API keys for ${accountId}: 更新了 ${newApiKeysInput.length} 个 API Key 的状态信息` + ) + } else if (apiKeyUpdateMode === 'replace' || wantsClearApiKeys) { + logger.info( + `🔑 覆盖模式更新 Droid API keys for ${accountId}: 当前总数 ${mergedApiKeys.length},新增 ${addedCount}` + ) + } else { + logger.info( + `🔑 追加模式更新 Droid API keys for ${accountId}: 当前总数 ${mergedApiKeys.length},新增 ${addedCount}` + ) + } + + if (mergedApiKeys.length > 0) { + sanitizedUpdates.authenticationMethod = 'api_key' + sanitizedUpdates.status = sanitizedUpdates.status || 'active' + } else if (!sanitizedUpdates.accessToken && !account.accessToken) { + const shouldPreserveApiKeyMode = + account.authenticationMethod && + account.authenticationMethod.toLowerCase().trim() === 'api_key' && + (apiKeyUpdateMode === 'replace' || apiKeyUpdateMode === 'delete') + + sanitizedUpdates.authenticationMethod = shouldPreserveApiKeyMode + ? 'api_key' + : account.authenticationMethod === 'api_key' + ? '' + : account.authenticationMethod + } + } + + const encryptedUpdates = { ...sanitizedUpdates } + + if (sanitizedUpdates.refreshToken !== undefined) { + encryptedUpdates.refreshToken = this._encryptSensitiveData(sanitizedUpdates.refreshToken) + } + if (sanitizedUpdates.accessToken !== undefined) { + encryptedUpdates.accessToken = this._encryptSensitiveData(sanitizedUpdates.accessToken) + } + + const baseAccountData = hasStoredAccount ? { ...storedAccount } : { id: accountId } + + const updatedData = { + ...baseAccountData, + ...encryptedUpdates + } + + if (!Object.prototype.hasOwnProperty.call(updatedData, 'refreshToken')) { + updatedData.refreshToken = + hasStoredAccount && Object.prototype.hasOwnProperty.call(storedAccount, 'refreshToken') + ? storedAccount.refreshToken + : this._encryptSensitiveData(account.refreshToken) + } + + if (!Object.prototype.hasOwnProperty.call(updatedData, 'accessToken')) { + updatedData.accessToken = + hasStoredAccount && Object.prototype.hasOwnProperty.call(storedAccount, 'accessToken') + ? storedAccount.accessToken + : this._encryptSensitiveData(account.accessToken) + } + + if (!Object.prototype.hasOwnProperty.call(updatedData, 'proxy')) { + updatedData.proxy = hasStoredAccount ? storedAccount.proxy || '' : account.proxy || '' + } + + await redis.setDroidAccount(accountId, updatedData) + logger.info(`✅ Updated Droid account: ${accountId}`) + + return this.getAccount(accountId) + } + + /** + * 删除 Droid 账户 + */ + async deleteAccount(accountId) { + await redis.deleteDroidAccount(accountId) + logger.success(`🗑️ Deleted Droid account: ${accountId}`) + } + + /** + * 刷新 Droid 账户的 access token + * + * 使用 WorkOS OAuth refresh token 刷新 access token + */ + async refreshAccessToken(accountId, proxyConfig = null) { + const account = await this.getAccount(accountId) + if (!account) { + throw new Error(`Droid account not found: ${accountId}`) + } + + if (!account.refreshToken) { + throw new Error(`Droid account ${accountId} has no refresh token`) + } + + logger.info(`🔄 Refreshing Droid account token: ${account.name} (${accountId})`) + + try { + const proxy = proxyConfig || (account.proxy ? JSON.parse(account.proxy) : null) + const refreshed = await this._refreshTokensWithWorkOS( + account.refreshToken, + proxy, + account.organizationId || null + ) + + // 更新账户信息 + await this.updateAccount(accountId, { + accessToken: refreshed.accessToken, + refreshToken: refreshed.refreshToken || account.refreshToken, + expiresAt: refreshed.expiresAt, + expiresIn: + refreshed.expiresIn !== null && refreshed.expiresIn !== undefined + ? String(refreshed.expiresIn) + : account.expiresIn, + tokenType: refreshed.tokenType || account.tokenType || 'Bearer', + authenticationMethod: refreshed.authenticationMethod || account.authenticationMethod || '', + organizationId: refreshed.organizationId || account.organizationId, + lastRefreshAt: new Date().toISOString(), + status: 'active', + errorMessage: '' + }) + + // 记录用户信息 + if (refreshed.user) { + const { user } = refreshed + const updates = {} + logger.info( + `✅ Droid token refreshed for: ${user.email} (${user.first_name} ${user.last_name})` + ) + logger.info(` Organization ID: ${refreshed.organizationId || 'N/A'}`) + + if (typeof user.email === 'string' && user.email.trim()) { + updates.ownerEmail = user.email.trim() + } + const nameParts = [] + if (typeof user.first_name === 'string' && user.first_name.trim()) { + nameParts.push(user.first_name.trim()) + } + if (typeof user.last_name === 'string' && user.last_name.trim()) { + nameParts.push(user.last_name.trim()) + } + const derivedName = + nameParts.join(' ').trim() || + (typeof user.name === 'string' ? user.name.trim() : '') || + (typeof user.display_name === 'string' ? user.display_name.trim() : '') + + if (derivedName) { + updates.ownerName = derivedName + updates.ownerDisplayName = derivedName + updates.owner = derivedName + } else if (updates.ownerEmail) { + updates.owner = updates.ownerEmail + updates.ownerName = updates.ownerEmail + updates.ownerDisplayName = updates.ownerEmail + } + + if (typeof user.id === 'string' && user.id.trim()) { + updates.userId = user.id.trim() + } + + if (Object.keys(updates).length > 0) { + await this.updateAccount(accountId, updates) + } + } + + logger.success(`✅ Droid account token refreshed successfully: ${accountId}`) + + return { + accessToken: refreshed.accessToken, + refreshToken: refreshed.refreshToken || account.refreshToken, + expiresAt: refreshed.expiresAt + } + } catch (error) { + logger.error(`❌ Failed to refresh Droid account token: ${accountId}`, error) + + // 更新账户状态为错误 + await this.updateAccount(accountId, { + status: 'error', + errorMessage: error.message || 'Token refresh failed' + }) + + throw error + } + } + + /** + * 检查 token 是否需要刷新 + */ + shouldRefreshToken(account) { + if (!account.lastRefreshAt) { + return true // 从未刷新过 + } + + const lastRefreshTime = new Date(account.lastRefreshAt).getTime() + const hoursSinceRefresh = (Date.now() - lastRefreshTime) / (1000 * 60 * 60) + + return hoursSinceRefresh >= this.refreshIntervalHours + } + + /** + * 获取有效的 access token(自动刷新) + */ + async getValidAccessToken(accountId) { + let account = await this.getAccount(accountId) + if (!account) { + throw new Error(`Droid account not found: ${accountId}`) + } + + if ( + typeof account.authenticationMethod === 'string' && + account.authenticationMethod.toLowerCase().trim() === 'api_key' + ) { + throw new Error(`Droid account ${accountId} 已配置为 API Key 模式,不能获取 Access Token`) + } + + // 检查是否需要刷新 + if (this.shouldRefreshToken(account)) { + logger.info(`🔄 Droid account token needs refresh: ${accountId}`) + const proxyConfig = account.proxy ? JSON.parse(account.proxy) : null + await this.refreshAccessToken(accountId, proxyConfig) + account = await this.getAccount(accountId) + } + + if (!account.accessToken) { + throw new Error(`Droid account ${accountId} has no valid access token`) + } + + return account.accessToken + } + + /** + * 获取可调度的 Droid 账户列表 + */ + async getSchedulableAccounts(endpointType = null) { + const allAccounts = await redis.getAllDroidAccounts() + + const normalizedFilter = endpointType ? this._sanitizeEndpointType(endpointType) : null + + return allAccounts + .filter((account) => { + const isActive = this._isTruthy(account.isActive) + const isSchedulable = this._isTruthy(account.schedulable) + const status = typeof account.status === 'string' ? account.status.toLowerCase() : '' + + if (!isActive || !isSchedulable || status !== 'active') { + return false + } + + if (!normalizedFilter) { + return true + } + + const accountEndpoint = this._sanitizeEndpointType(account.endpointType) + + if (normalizedFilter === 'openai') { + return accountEndpoint === 'openai' || accountEndpoint === 'anthropic' + } + + if (normalizedFilter === 'anthropic') { + return accountEndpoint === 'anthropic' || accountEndpoint === 'openai' + } + + return accountEndpoint === normalizedFilter + }) + .map((account) => ({ + ...account, + endpointType: this._sanitizeEndpointType(account.endpointType), + priority: parseInt(account.priority, 10) || 50, + // 解密 accessToken 用于使用 + accessToken: this._decryptSensitiveData(account.accessToken) + })) + .sort((a, b) => a.priority - b.priority) // 按优先级排序 + } + + /** + * 选择一个可用的 Droid 账户(简单轮询) + */ + async selectAccount(endpointType = null) { + let accounts = await this.getSchedulableAccounts(endpointType) + + if (accounts.length === 0 && endpointType) { + logger.warn( + `No Droid accounts found for endpoint ${endpointType}, falling back to any available account` + ) + accounts = await this.getSchedulableAccounts(null) + } + + if (accounts.length === 0) { + throw new Error( + `No schedulable Droid accounts available${endpointType ? ` for endpoint type: ${endpointType}` : ''}` + ) + } + + // 简单轮询:选择最高优先级且最久未使用的账户 + let selectedAccount = accounts[0] + for (const account of accounts) { + if (account.priority < selectedAccount.priority) { + selectedAccount = account + } else if (account.priority === selectedAccount.priority) { + // 相同优先级,选择最久未使用的 + const selectedLastUsed = new Date(selectedAccount.lastUsedAt || 0).getTime() + const accountLastUsed = new Date(account.lastUsedAt || 0).getTime() + if (accountLastUsed < selectedLastUsed) { + selectedAccount = account + } + } + } + + // 更新最后使用时间 + await this.updateAccount(selectedAccount.id, { + lastUsedAt: new Date().toISOString() + }) + + logger.info( + `✅ Selected Droid account: ${selectedAccount.name} (${selectedAccount.id}) - Endpoint: ${this._sanitizeEndpointType(selectedAccount.endpointType)}` + ) + + return selectedAccount + } + + /** + * 获取 Factory.ai API 的完整 URL + */ + getFactoryApiUrl(endpointType, endpoint) { + const normalizedType = this._sanitizeEndpointType(endpointType) + const baseUrls = { + anthropic: `${this.factoryApiBaseUrl}/a${endpoint}`, + openai: `${this.factoryApiBaseUrl}/o${endpoint}` + } + + return baseUrls[normalizedType] || baseUrls.openai + } + + async touchLastUsedAt(accountId) { + if (!accountId) { + return + } + + try { + const client = redis.getClientSafe() + await client.hset(`droid:account:${accountId}`, 'lastUsedAt', new Date().toISOString()) + } catch (error) { + logger.warn(`⚠️ Failed to update lastUsedAt for Droid account ${accountId}:`, error) + } + } +} + +// 导出单例 +module.exports = new DroidAccountService() diff --git a/src/services/droidRelayService.js b/src/services/droidRelayService.js new file mode 100644 index 0000000000000000000000000000000000000000..38cd9a6b96ca159cc50fbf170838f83ddbf328a3 --- /dev/null +++ b/src/services/droidRelayService.js @@ -0,0 +1,1333 @@ +const https = require('https') +const axios = require('axios') +const ProxyHelper = require('../utils/proxyHelper') +const droidScheduler = require('./droidScheduler') +const droidAccountService = require('./droidAccountService') +const apiKeyService = require('./apiKeyService') +const redis = require('../models/redis') +const { updateRateLimitCounters } = require('../utils/rateLimitHelper') +const logger = require('../utils/logger') + +const SYSTEM_PROMPT = 'You are Droid, an AI software engineering agent built by Factory.' + +/** + * Droid API 转发服务 + */ + +class DroidRelayService { + constructor() { + this.factoryApiBaseUrl = 'https://app.factory.ai/api/llm' + + this.endpoints = { + anthropic: '/a/v1/messages', + openai: '/o/v1/responses' + } + + this.userAgent = 'factory-cli/0.19.4' + this.systemPrompt = SYSTEM_PROMPT + this.API_KEY_STICKY_PREFIX = 'droid_api_key' + } + + _normalizeEndpointType(endpointType) { + if (!endpointType) { + return 'anthropic' + } + + const normalized = String(endpointType).toLowerCase() + if (normalized === 'openai' || normalized === 'common') { + return 'openai' + } + + if (normalized === 'anthropic') { + return 'anthropic' + } + + return 'anthropic' + } + + _normalizeRequestBody(requestBody, endpointType) { + if (!requestBody || typeof requestBody !== 'object') { + return requestBody + } + + const normalizedBody = { ...requestBody } + + if (endpointType === 'anthropic' && typeof normalizedBody.model === 'string') { + const originalModel = normalizedBody.model + const trimmedModel = originalModel.trim() + const lowerModel = trimmedModel.toLowerCase() + + if (lowerModel.includes('haiku')) { + const mappedModel = 'claude-sonnet-4-20250514' + if (originalModel !== mappedModel) { + logger.info(`🔄 将请求模型从 ${originalModel} 映射为 ${mappedModel}`) + } + normalizedBody.model = mappedModel + } + } + + if (endpointType === 'openai' && typeof normalizedBody.model === 'string') { + const originalModel = normalizedBody.model + const trimmedModel = originalModel.trim() + const lowerModel = trimmedModel.toLowerCase() + + if (lowerModel === 'gpt-5') { + const mappedModel = 'gpt-5-2025-08-07' + if (originalModel !== mappedModel) { + logger.info(`🔄 将请求模型从 ${originalModel} 映射为 ${mappedModel}`) + } + normalizedBody.model = mappedModel + } + } + + return normalizedBody + } + + async _applyRateLimitTracking(rateLimitInfo, usageSummary, model, context = '') { + if (!rateLimitInfo) { + return + } + + try { + const { totalTokens, totalCost } = await updateRateLimitCounters( + rateLimitInfo, + usageSummary, + model + ) + + if (totalTokens > 0) { + logger.api(`📊 Updated rate limit token count${context}: +${totalTokens}`) + } + if (typeof totalCost === 'number' && totalCost > 0) { + logger.api(`💰 Updated rate limit cost count${context}: +$${totalCost.toFixed(6)}`) + } + } catch (error) { + logger.error(`❌ Failed to update rate limit counters${context}:`, error) + } + } + + _composeApiKeyStickyKey(accountId, endpointType, sessionHash) { + if (!accountId || !sessionHash) { + return null + } + + const normalizedEndpoint = this._normalizeEndpointType(endpointType) + return `${this.API_KEY_STICKY_PREFIX}:${accountId}:${normalizedEndpoint}:${sessionHash}` + } + + async _selectApiKey(account, endpointType, sessionHash) { + const entries = await droidAccountService.getDecryptedApiKeyEntries(account.id) + if (!entries || entries.length === 0) { + throw new Error(`Droid account ${account.id} 未配置任何 API Key`) + } + + // 过滤掉异常状态的API Key + const activeEntries = entries.filter((entry) => entry.status !== 'error') + if (!activeEntries || activeEntries.length === 0) { + throw new Error(`Droid account ${account.id} 没有可用的 API Key(所有API Key均已异常)`) + } + + const stickyKey = this._composeApiKeyStickyKey(account.id, endpointType, sessionHash) + + if (stickyKey) { + const mappedKeyId = await redis.getSessionAccountMapping(stickyKey) + if (mappedKeyId) { + const mappedEntry = activeEntries.find((entry) => entry.id === mappedKeyId) + if (mappedEntry) { + await redis.extendSessionAccountMappingTTL(stickyKey) + await droidAccountService.touchApiKeyUsage(account.id, mappedEntry.id) + logger.info(`🔐 使用已绑定的 Droid API Key ${mappedEntry.id}(Account: ${account.id})`) + return mappedEntry + } + + await redis.deleteSessionAccountMapping(stickyKey) + } + } + + const selectedEntry = activeEntries[Math.floor(Math.random() * activeEntries.length)] + if (!selectedEntry) { + throw new Error(`Droid account ${account.id} 没有可用的 API Key`) + } + + if (stickyKey) { + await redis.setSessionAccountMapping(stickyKey, selectedEntry.id) + } + + await droidAccountService.touchApiKeyUsage(account.id, selectedEntry.id) + + logger.info( + `🔐 随机选取 Droid API Key ${selectedEntry.id}(Account: ${account.id}, Active Keys: ${activeEntries.length}/${entries.length})` + ) + + return selectedEntry + } + + async relayRequest( + requestBody, + apiKeyData, + clientRequest, + clientResponse, + clientHeaders, + options = {} + ) { + const { + endpointType = 'anthropic', + sessionHash = null, + customPath = null, + skipUsageRecord = false, + disableStreaming = false + } = options + const keyInfo = apiKeyData || {} + const clientApiKeyId = keyInfo.id || null + const normalizedEndpoint = this._normalizeEndpointType(endpointType) + const normalizedRequestBody = this._normalizeRequestBody(requestBody, normalizedEndpoint) + let account = null + let selectedApiKey = null + let accessToken = null + + try { + logger.info( + `📤 Processing Droid API request for key: ${ + keyInfo.name || keyInfo.id || 'unknown' + }, endpoint: ${normalizedEndpoint}${sessionHash ? `, session: ${sessionHash}` : ''}` + ) + + // 选择一个可用的 Droid 账户(支持粘性会话和分组调度) + account = await droidScheduler.selectAccount(keyInfo, normalizedEndpoint, sessionHash) + + if (!account) { + throw new Error(`No available Droid account for endpoint type: ${normalizedEndpoint}`) + } + + // 获取认证凭据:支持 Access Token 和 API Key 两种模式 + if ( + typeof account.authenticationMethod === 'string' && + account.authenticationMethod.toLowerCase().trim() === 'api_key' + ) { + selectedApiKey = await this._selectApiKey(account, normalizedEndpoint, sessionHash) + accessToken = selectedApiKey.key + } else { + accessToken = await droidAccountService.getValidAccessToken(account.id) + } + + // 获取 Factory.ai API URL + let endpointPath = this.endpoints[normalizedEndpoint] + + if (typeof customPath === 'string' && customPath.trim()) { + endpointPath = customPath.startsWith('/') ? customPath : `/${customPath}` + } + + const apiUrl = `${this.factoryApiBaseUrl}${endpointPath}` + + logger.info(`🌐 Forwarding to Factory.ai: ${apiUrl}`) + + // 获取代理配置 + const proxyConfig = account.proxy ? JSON.parse(account.proxy) : null + const proxyAgent = proxyConfig ? ProxyHelper.createProxyAgent(proxyConfig) : null + + if (proxyAgent) { + logger.info(`🌐 Using proxy: ${ProxyHelper.getProxyDescription(proxyConfig)}`) + } + + // 构建请求头 + const headers = this._buildHeaders( + accessToken, + normalizedRequestBody, + normalizedEndpoint, + clientHeaders + ) + + if (selectedApiKey) { + logger.info( + `🔑 Forwarding request with Droid API Key ${selectedApiKey.id} (Account: ${account.id})` + ) + } + + // 处理请求体(注入 system prompt 等) + const streamRequested = !disableStreaming && this._isStreamRequested(normalizedRequestBody) + + const processedBody = this._processRequestBody(normalizedRequestBody, normalizedEndpoint, { + disableStreaming, + streamRequested + }) + + // 发送请求 + const isStreaming = streamRequested + + // 根据是否流式选择不同的处理方式 + if (isStreaming) { + // 流式响应:使用原生 https 模块以更好地控制流 + return await this._handleStreamRequest( + apiUrl, + headers, + processedBody, + proxyAgent, + clientRequest, + clientResponse, + account, + keyInfo, + normalizedRequestBody, + normalizedEndpoint, + skipUsageRecord, + selectedApiKey, + sessionHash, + clientApiKeyId + ) + } else { + // 非流式响应:使用 axios + const requestOptions = { + method: 'POST', + url: apiUrl, + headers, + data: processedBody, + timeout: 600 * 1000, // 10分钟超时 + responseType: 'json', + ...(proxyAgent && { + httpAgent: proxyAgent, + httpsAgent: proxyAgent + }) + } + + const response = await axios(requestOptions) + + logger.info(`✅ Factory.ai response status: ${response.status}`) + + // 处理非流式响应 + return this._handleNonStreamResponse( + response, + account, + keyInfo, + normalizedRequestBody, + clientRequest, + normalizedEndpoint, + skipUsageRecord + ) + } + } catch (error) { + logger.error(`❌ Droid relay error: ${error.message}`, error) + + const status = error?.response?.status + if (status >= 400 && status < 500) { + try { + await this._handleUpstreamClientError(status, { + account, + selectedAccountApiKey: selectedApiKey, + endpointType: normalizedEndpoint, + sessionHash, + clientApiKeyId + }) + } catch (handlingError) { + logger.error('❌ 处理 Droid 4xx 异常失败:', handlingError) + } + } + + if (error.response) { + // HTTP 错误响应 + return { + statusCode: error.response.status, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify( + error.response.data || { + error: 'upstream_error', + message: error.message + } + ) + } + } + + // 网络错误或其他错误(统一返回 4xx) + const mappedStatus = this._mapNetworkErrorStatus(error) + return { + statusCode: mappedStatus, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(this._buildNetworkErrorBody(error)) + } + } + } + + /** + * 处理流式请求 + */ + async _handleStreamRequest( + apiUrl, + headers, + processedBody, + proxyAgent, + clientRequest, + clientResponse, + account, + apiKeyData, + requestBody, + endpointType, + skipUsageRecord = false, + selectedAccountApiKey = null, + sessionHash = null, + clientApiKeyId = null + ) { + return new Promise((resolve, reject) => { + const url = new URL(apiUrl) + const bodyString = JSON.stringify(processedBody) + const contentLength = Buffer.byteLength(bodyString) + const requestHeaders = { + ...headers, + 'content-length': contentLength.toString() + } + + let responseStarted = false + let responseCompleted = false + let settled = false + let upstreamResponse = null + let completionWindow = '' + let hasForwardedData = false + + const resolveOnce = (value) => { + if (settled) { + return + } + settled = true + resolve(value) + } + + const rejectOnce = (error) => { + if (settled) { + return + } + settled = true + reject(error) + } + + const handleStreamError = (error) => { + if (responseStarted) { + const isConnectionReset = + error && (error.code === 'ECONNRESET' || error.message === 'aborted') + const upstreamComplete = + responseCompleted || upstreamResponse?.complete || clientResponse.writableEnded + + if (isConnectionReset && (upstreamComplete || hasForwardedData)) { + logger.debug('🔁 Droid stream连接在响应阶段被重置,视为正常结束:', { + message: error?.message, + code: error?.code + }) + if (!clientResponse.destroyed && !clientResponse.writableEnded) { + clientResponse.end() + } + resolveOnce({ statusCode: 200, streaming: true }) + return + } + + logger.error('❌ Droid stream error:', error) + const mappedStatus = this._mapNetworkErrorStatus(error) + const errorBody = this._buildNetworkErrorBody(error) + + if (!clientResponse.destroyed) { + if (!clientResponse.writableEnded) { + const canUseJson = + !hasForwardedData && + typeof clientResponse.status === 'function' && + typeof clientResponse.json === 'function' + + if (canUseJson) { + clientResponse.status(mappedStatus).json(errorBody) + } else { + const errorPayload = JSON.stringify(errorBody) + + if (!hasForwardedData) { + if (typeof clientResponse.setHeader === 'function') { + clientResponse.setHeader('Content-Type', 'application/json') + } + clientResponse.write(errorPayload) + clientResponse.end() + } else { + clientResponse.write(`event: error\ndata: ${errorPayload}\n\n`) + clientResponse.end() + } + } + } + } + + resolveOnce({ statusCode: mappedStatus, streaming: true, error }) + } else { + rejectOnce(error) + } + } + + const options = { + hostname: url.hostname, + port: url.port || 443, + path: url.pathname, + method: 'POST', + headers: requestHeaders, + agent: proxyAgent, + timeout: 600 * 1000 + } + + const req = https.request(options, (res) => { + upstreamResponse = res + logger.info(`✅ Factory.ai stream response status: ${res.statusCode}`) + + // 错误响应 + if (res.statusCode !== 200) { + const chunks = [] + + res.on('data', (chunk) => { + chunks.push(chunk) + logger.info(`📦 got ${chunk.length} bytes of data`) + }) + + res.on('end', () => { + logger.info('✅ res.end() reached') + const body = Buffer.concat(chunks).toString() + logger.error(`❌ Factory.ai error response body: ${body || '(empty)'}`) + if (res.statusCode >= 400 && res.statusCode < 500) { + this._handleUpstreamClientError(res.statusCode, { + account, + selectedAccountApiKey, + endpointType, + sessionHash, + clientApiKeyId + }).catch((handlingError) => { + logger.error('❌ 处理 Droid 流式4xx 异常失败:', handlingError) + }) + } + if (!clientResponse.headersSent) { + clientResponse.status(res.statusCode).json({ + error: 'upstream_error', + details: body + }) + } + resolveOnce({ statusCode: res.statusCode, streaming: true }) + }) + + res.on('close', () => { + logger.warn('⚠️ response closed before end event') + }) + + res.on('error', handleStreamError) + + return + } + + responseStarted = true + + // 设置流式响应头 + clientResponse.setHeader('Content-Type', 'text/event-stream') + clientResponse.setHeader('Cache-Control', 'no-cache') + clientResponse.setHeader('Connection', 'keep-alive') + + // Usage 数据收集 + let buffer = '' + const currentUsageData = {} + const model = requestBody.model || 'unknown' + + // 处理 SSE 流 + res.on('data', (chunk) => { + const chunkStr = chunk.toString() + completionWindow = (completionWindow + chunkStr).slice(-1024) + hasForwardedData = true + + // 转发数据到客户端 + clientResponse.write(chunk) + hasForwardedData = true + + // 解析 usage 数据(根据端点类型) + if (endpointType === 'anthropic') { + // Anthropic Messages API 格式 + this._parseAnthropicUsageFromSSE(chunkStr, buffer, currentUsageData) + } else if (endpointType === 'openai') { + // OpenAI Chat Completions 格式 + this._parseOpenAIUsageFromSSE(chunkStr, buffer, currentUsageData) + } + + if (!responseCompleted && this._detectStreamCompletion(completionWindow, endpointType)) { + responseCompleted = true + } + + buffer += chunkStr + }) + + res.on('end', async () => { + responseCompleted = true + clientResponse.end() + + // 记录 usage 数据 + if (!skipUsageRecord) { + const normalizedUsage = await this._recordUsageFromStreamData( + currentUsageData, + apiKeyData, + account, + model + ) + + const usageSummary = { + inputTokens: normalizedUsage.input_tokens || 0, + outputTokens: normalizedUsage.output_tokens || 0, + cacheCreateTokens: normalizedUsage.cache_creation_input_tokens || 0, + cacheReadTokens: normalizedUsage.cache_read_input_tokens || 0 + } + + await this._applyRateLimitTracking( + clientRequest?.rateLimitInfo, + usageSummary, + model, + ' [stream]' + ) + + logger.success(`✅ Droid stream completed - Account: ${account.name}`) + } else { + logger.success( + `✅ Droid stream completed - Account: ${account.name}, usage recording skipped` + ) + } + resolveOnce({ statusCode: 200, streaming: true }) + }) + + res.on('error', handleStreamError) + + res.on('close', () => { + if (settled) { + return + } + + if (responseCompleted) { + if (!clientResponse.destroyed && !clientResponse.writableEnded) { + clientResponse.end() + } + resolveOnce({ statusCode: 200, streaming: true }) + } else { + handleStreamError(new Error('Upstream stream closed unexpectedly')) + } + }) + }) + + // 客户端断开连接时清理 + clientResponse.on('close', () => { + if (req && !req.destroyed) { + req.destroy() + } + }) + + req.on('error', handleStreamError) + + req.on('timeout', () => { + req.destroy() + logger.error('❌ Droid request timeout') + handleStreamError(new Error('Request timeout')) + }) + + // 写入请求体 + req.end(bodyString) + }) + } + + /** + * 从 SSE 流中解析 Anthropic usage 数据 + */ + _parseAnthropicUsageFromSSE(chunkStr, buffer, currentUsageData) { + try { + // 分割成行 + const lines = (buffer + chunkStr).split('\n') + + for (const line of lines) { + if (line.startsWith('data: ') && line.length > 6) { + try { + const jsonStr = line.slice(6) + const data = JSON.parse(jsonStr) + + // message_start 包含 input tokens 和 cache tokens + if (data.type === 'message_start' && data.message && data.message.usage) { + currentUsageData.input_tokens = data.message.usage.input_tokens || 0 + currentUsageData.cache_creation_input_tokens = + data.message.usage.cache_creation_input_tokens || 0 + currentUsageData.cache_read_input_tokens = + data.message.usage.cache_read_input_tokens || 0 + + // 详细的缓存类型 + if (data.message.usage.cache_creation) { + currentUsageData.cache_creation = { + ephemeral_5m_input_tokens: + data.message.usage.cache_creation.ephemeral_5m_input_tokens || 0, + ephemeral_1h_input_tokens: + data.message.usage.cache_creation.ephemeral_1h_input_tokens || 0 + } + } + + logger.debug('📊 Droid Anthropic input usage:', currentUsageData) + } + + // message_delta 包含 output tokens + if (data.type === 'message_delta' && data.usage) { + currentUsageData.output_tokens = data.usage.output_tokens || 0 + logger.debug('📊 Droid Anthropic output usage:', currentUsageData.output_tokens) + } + } catch (parseError) { + // 忽略解析错误 + } + } + } + } catch (error) { + logger.debug('Error parsing Anthropic usage:', error) + } + } + + /** + * 从 SSE 流中解析 OpenAI usage 数据 + */ + _parseOpenAIUsageFromSSE(chunkStr, buffer, currentUsageData) { + try { + // OpenAI Chat Completions 流式格式 + const lines = (buffer + chunkStr).split('\n') + + for (const line of lines) { + if (line.startsWith('data: ') && line.length > 6) { + try { + const jsonStr = line.slice(6) + if (jsonStr === '[DONE]') { + continue + } + + const data = JSON.parse(jsonStr) + + // 兼容传统 Chat Completions usage 字段 + if (data.usage) { + currentUsageData.input_tokens = data.usage.prompt_tokens || 0 + currentUsageData.output_tokens = data.usage.completion_tokens || 0 + currentUsageData.total_tokens = data.usage.total_tokens || 0 + + logger.debug('📊 Droid OpenAI usage:', currentUsageData) + } + + // 新 Response API 在 response.usage 中返回统计 + if (data.response && data.response.usage) { + const { usage } = data.response + currentUsageData.input_tokens = + usage.input_tokens || usage.prompt_tokens || usage.total_tokens || 0 + currentUsageData.output_tokens = usage.output_tokens || usage.completion_tokens || 0 + currentUsageData.total_tokens = usage.total_tokens || 0 + + logger.debug('📊 Droid OpenAI response usage:', currentUsageData) + } + } catch (parseError) { + // 忽略解析错误 + } + } + } + } catch (error) { + logger.debug('Error parsing OpenAI usage:', error) + } + } + + /** + * 检测流式响应是否已经包含终止标记 + */ + _detectStreamCompletion(windowStr, endpointType) { + if (!windowStr) { + return false + } + + const lower = windowStr.toLowerCase() + const compact = lower.replace(/\s+/g, '') + + if (endpointType === 'anthropic') { + if (lower.includes('event: message_stop')) { + return true + } + if (compact.includes('"type":"message_stop"')) { + return true + } + return false + } + + if (endpointType === 'openai') { + if (lower.includes('data: [done]')) { + return true + } + + if (compact.includes('"finish_reason"')) { + return true + } + + if (lower.includes('event: response.done') || lower.includes('event: response.completed')) { + return true + } + + if ( + compact.includes('"type":"response.done"') || + compact.includes('"type":"response.completed"') + ) { + return true + } + } + + return false + } + + /** + * 记录从流中解析的 usage 数据 + */ + async _recordUsageFromStreamData(usageData, apiKeyData, account, model) { + const normalizedUsage = this._normalizeUsageSnapshot(usageData) + await this._recordUsage(apiKeyData, account, model, normalizedUsage) + return normalizedUsage + } + + /** + * 标准化 usage 数据,确保字段完整且为数字 + */ + _normalizeUsageSnapshot(usageData = {}) { + const toNumber = (value) => { + if (value === undefined || value === null || value === '') { + return 0 + } + const num = Number(value) + if (!Number.isFinite(num)) { + return 0 + } + return Math.max(0, num) + } + + const inputTokens = toNumber( + usageData.input_tokens ?? + usageData.prompt_tokens ?? + usageData.inputTokens ?? + usageData.total_input_tokens + ) + const outputTokens = toNumber( + usageData.output_tokens ?? usageData.completion_tokens ?? usageData.outputTokens + ) + const cacheReadTokens = toNumber( + usageData.cache_read_input_tokens ?? + usageData.cacheReadTokens ?? + usageData.input_tokens_details?.cached_tokens + ) + + const rawCacheCreateTokens = + usageData.cache_creation_input_tokens ?? + usageData.cacheCreateTokens ?? + usageData.cache_tokens ?? + 0 + let cacheCreateTokens = toNumber(rawCacheCreateTokens) + + const ephemeral5m = toNumber( + usageData.cache_creation?.ephemeral_5m_input_tokens ?? usageData.ephemeral_5m_input_tokens + ) + const ephemeral1h = toNumber( + usageData.cache_creation?.ephemeral_1h_input_tokens ?? usageData.ephemeral_1h_input_tokens + ) + + if (cacheCreateTokens === 0 && (ephemeral5m > 0 || ephemeral1h > 0)) { + cacheCreateTokens = ephemeral5m + ephemeral1h + } + + const normalized = { + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_creation_input_tokens: cacheCreateTokens, + cache_read_input_tokens: cacheReadTokens + } + + if (ephemeral5m > 0 || ephemeral1h > 0) { + normalized.cache_creation = { + ephemeral_5m_input_tokens: ephemeral5m, + ephemeral_1h_input_tokens: ephemeral1h + } + } + + return normalized + } + + /** + * 计算 usage 对象的总 token 数 + */ + _getTotalTokens(usageObject = {}) { + const toNumber = (value) => { + if (value === undefined || value === null || value === '') { + return 0 + } + const num = Number(value) + if (!Number.isFinite(num)) { + return 0 + } + return Math.max(0, num) + } + + return ( + toNumber(usageObject.input_tokens) + + toNumber(usageObject.output_tokens) + + toNumber(usageObject.cache_creation_input_tokens) + + toNumber(usageObject.cache_read_input_tokens) + ) + } + + /** + * 提取账户 ID + */ + _extractAccountId(account) { + if (!account || typeof account !== 'object') { + return null + } + return account.id || account.accountId || account.account_id || null + } + + /** + * 构建请求头 + */ + _buildHeaders(accessToken, requestBody, endpointType, clientHeaders = {}) { + const headers = { + 'content-type': 'application/json', + authorization: `Bearer ${accessToken}`, + 'user-agent': this.userAgent, + 'x-factory-client': 'cli', + connection: 'keep-alive' + } + + // Anthropic 特定头 + if (endpointType === 'anthropic') { + headers['accept'] = 'application/json' + headers['anthropic-version'] = '2023-06-01' + headers['x-api-key'] = 'placeholder' + headers['x-api-provider'] = 'anthropic' + + if (this._isThinkingRequested(requestBody)) { + headers['anthropic-beta'] = 'interleaved-thinking-2025-05-14' + } + } + + // OpenAI 特定头 + if (endpointType === 'openai') { + headers['x-api-provider'] = 'azure_openai' + } + + // 生成会话 ID(如果客户端没有提供) + headers['x-session-id'] = clientHeaders['x-session-id'] || this._generateUUID() + + return headers + } + + /** + * 判断请求是否要求流式响应 + */ + _isStreamRequested(requestBody) { + if (!requestBody || typeof requestBody !== 'object') { + return false + } + + const value = requestBody.stream + + if (value === true) { + return true + } + + if (typeof value === 'string') { + return value.toLowerCase() === 'true' + } + + return false + } + + /** + * 判断请求是否启用 Anthropic 推理模式 + */ + _isThinkingRequested(requestBody) { + const thinking = requestBody && typeof requestBody === 'object' ? requestBody.thinking : null + if (!thinking) { + return false + } + + if (thinking === true) { + return true + } + + if (typeof thinking === 'string') { + return thinking.trim().toLowerCase() === 'enabled' + } + + if (typeof thinking === 'object') { + if (thinking.enabled === true) { + return true + } + + if (typeof thinking.type === 'string') { + return thinking.type.trim().toLowerCase() === 'enabled' + } + } + + return false + } + + /** + * 处理请求体(注入 system prompt 等) + */ + _processRequestBody(requestBody, endpointType, options = {}) { + const { disableStreaming = false, streamRequested = false } = options + const processedBody = { ...requestBody } + + const hasStreamField = + requestBody && Object.prototype.hasOwnProperty.call(requestBody, 'stream') + + if (processedBody && Object.prototype.hasOwnProperty.call(processedBody, 'metadata')) { + delete processedBody.metadata + } + + if (disableStreaming || !streamRequested) { + if (hasStreamField) { + processedBody.stream = false + } else if ('stream' in processedBody) { + delete processedBody.stream + } + } else { + processedBody.stream = true + } + + // Anthropic 端点:仅注入系统提示 + if (endpointType === 'anthropic') { + if (this.systemPrompt) { + const promptBlock = { type: 'text', text: this.systemPrompt } + if (Array.isArray(processedBody.system)) { + const hasPrompt = processedBody.system.some( + (item) => item && item.type === 'text' && item.text === this.systemPrompt + ) + if (!hasPrompt) { + processedBody.system = [promptBlock, ...processedBody.system] + } + } else { + processedBody.system = [promptBlock] + } + } + } + + // OpenAI 端点:仅前置系统提示 + if (endpointType === 'openai') { + if (this.systemPrompt) { + if (processedBody.instructions) { + if (!processedBody.instructions.startsWith(this.systemPrompt)) { + processedBody.instructions = `${this.systemPrompt}${processedBody.instructions}` + } + } else { + processedBody.instructions = this.systemPrompt + } + } + } + + // 处理 temperature 和 top_p 参数 + const hasValidTemperature = + processedBody.temperature !== undefined && processedBody.temperature !== null + const hasValidTopP = processedBody.top_p !== undefined && processedBody.top_p !== null + + if (hasValidTemperature && hasValidTopP) { + // 仅允许 temperature 或 top_p 其一,同时优先保留 temperature + delete processedBody.top_p + } + + return processedBody + } + + /** + * 处理非流式响应 + */ + async _handleNonStreamResponse( + response, + account, + apiKeyData, + requestBody, + clientRequest, + endpointType, + skipUsageRecord = false + ) { + const { data } = response + + // 从响应中提取 usage 数据 + const usage = data.usage || {} + + const model = requestBody.model || 'unknown' + + const normalizedUsage = this._normalizeUsageSnapshot(usage) + + if (!skipUsageRecord) { + await this._recordUsage(apiKeyData, account, model, normalizedUsage) + + const totalTokens = this._getTotalTokens(normalizedUsage) + + const usageSummary = { + inputTokens: normalizedUsage.input_tokens || 0, + outputTokens: normalizedUsage.output_tokens || 0, + cacheCreateTokens: normalizedUsage.cache_creation_input_tokens || 0, + cacheReadTokens: normalizedUsage.cache_read_input_tokens || 0 + } + + await this._applyRateLimitTracking( + clientRequest?.rateLimitInfo, + usageSummary, + model, + endpointType === 'anthropic' ? ' [anthropic]' : ' [openai]' + ) + + logger.success( + `✅ Droid request completed - Account: ${account.name}, Tokens: ${totalTokens}` + ) + } else { + logger.success( + `✅ Droid request completed - Account: ${account.name}, usage recording skipped` + ) + } + + return { + statusCode: 200, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + } + } + + /** + * 记录使用统计 + */ + async _recordUsage(apiKeyData, account, model, usageObject = {}) { + const totalTokens = this._getTotalTokens(usageObject) + + if (totalTokens <= 0) { + logger.debug('🪙 Droid usage 数据为空,跳过记录') + return + } + + try { + const keyId = apiKeyData?.id + const accountId = this._extractAccountId(account) + + if (keyId) { + await apiKeyService.recordUsageWithDetails(keyId, usageObject, model, accountId, 'droid') + } else if (accountId) { + await redis.incrementAccountUsage( + accountId, + totalTokens, + usageObject.input_tokens || 0, + usageObject.output_tokens || 0, + usageObject.cache_creation_input_tokens || 0, + usageObject.cache_read_input_tokens || 0, + model, + false + ) + } else { + logger.warn('⚠️ 无法记录 Droid usage:缺少 API Key 和账户标识') + return + } + + logger.debug( + `📊 Droid usage recorded - Key: ${keyId || 'unknown'}, Account: ${accountId || 'unknown'}, Model: ${model}, Input: ${usageObject.input_tokens || 0}, Output: ${usageObject.output_tokens || 0}, Cache Create: ${usageObject.cache_creation_input_tokens || 0}, Cache Read: ${usageObject.cache_read_input_tokens || 0}, Total: ${totalTokens}` + ) + } catch (error) { + logger.error('❌ Failed to record Droid usage:', error) + } + } + + /** + * 处理上游 4xx 响应,移除问题 API Key 或停止账号调度 + */ + async _handleUpstreamClientError(statusCode, context = {}) { + if (!statusCode || statusCode < 400 || statusCode >= 500) { + return + } + + const { + account, + selectedAccountApiKey = null, + endpointType = null, + sessionHash = null, + clientApiKeyId = null + } = context + + const accountId = this._extractAccountId(account) + if (!accountId) { + logger.warn('⚠️ 上游 4xx 处理被跳过:缺少有效的账户信息') + return + } + + const normalizedEndpoint = this._normalizeEndpointType( + endpointType || account?.endpointType || 'anthropic' + ) + const authMethod = + typeof account?.authenticationMethod === 'string' + ? account.authenticationMethod.toLowerCase().trim() + : '' + + if (authMethod === 'api_key') { + if (selectedAccountApiKey?.id) { + let markResult = null + const errorMessage = `${statusCode}` + + try { + // 标记API Key为异常状态而不是删除 + markResult = await droidAccountService.markApiKeyAsError( + accountId, + selectedAccountApiKey.id, + errorMessage + ) + } catch (error) { + logger.error( + `❌ 标记 Droid API Key ${selectedAccountApiKey.id} 异常状态(Account: ${accountId})失败:`, + error + ) + } + + await this._clearApiKeyStickyMapping(accountId, normalizedEndpoint, sessionHash) + + if (markResult?.marked) { + logger.warn( + `⚠️ 上游返回 ${statusCode},已标记 Droid API Key ${selectedAccountApiKey.id} 为异常状态(Account: ${accountId})` + ) + } else { + logger.warn( + `⚠️ 上游返回 ${statusCode},但未能标记 Droid API Key ${selectedAccountApiKey.id} 异常状态(Account: ${accountId}):${markResult?.error || '未知错误'}` + ) + } + + // 检查是否还有可用的API Key + try { + const availableEntries = await droidAccountService.getDecryptedApiKeyEntries(accountId) + const activeEntries = availableEntries.filter((entry) => entry.status !== 'error') + + if (activeEntries.length === 0) { + await this._stopDroidAccountScheduling(accountId, statusCode, '所有API Key均已异常') + await this._clearAccountStickyMapping(normalizedEndpoint, sessionHash, clientApiKeyId) + } else { + logger.info(`ℹ️ Droid 账号 ${accountId} 仍有 ${activeEntries.length} 个可用 API Key`) + } + } catch (error) { + logger.error(`❌ 检查可用API Key失败(Account: ${accountId}):`, error) + await this._stopDroidAccountScheduling(accountId, statusCode, 'API Key检查失败') + await this._clearAccountStickyMapping(normalizedEndpoint, sessionHash, clientApiKeyId) + } + + return + } + + logger.warn( + `⚠️ 上游返回 ${statusCode},但未获取到对应的 Droid API Key(Account: ${accountId})` + ) + await this._stopDroidAccountScheduling(accountId, statusCode, '缺少可用 API Key') + await this._clearAccountStickyMapping(normalizedEndpoint, sessionHash, clientApiKeyId) + return + } + + await this._stopDroidAccountScheduling(accountId, statusCode, '凭证不可用') + await this._clearAccountStickyMapping(normalizedEndpoint, sessionHash, clientApiKeyId) + } + + /** + * 停止指定 Droid 账号的调度 + */ + async _stopDroidAccountScheduling(accountId, statusCode, reason = '') { + if (!accountId) { + return + } + + const message = reason ? `${reason}` : '上游返回 4xx 错误' + + try { + await droidAccountService.updateAccount(accountId, { + schedulable: 'false', + status: 'error', + errorMessage: `上游返回 ${statusCode}:${message}` + }) + logger.warn(`🚫 已停止调度 Droid 账号 ${accountId}(状态码 ${statusCode},原因:${message})`) + } catch (error) { + logger.error(`❌ 停止调度 Droid 账号失败:${accountId}`, error) + } + } + + /** + * 清理账号层面的粘性调度映射 + */ + async _clearAccountStickyMapping(endpointType, sessionHash, clientApiKeyId) { + if (!sessionHash) { + return + } + + const normalizedEndpoint = this._normalizeEndpointType(endpointType) + const apiKeyPart = clientApiKeyId || 'default' + const stickyKey = `droid:${normalizedEndpoint}:${apiKeyPart}:${sessionHash}` + + try { + await redis.deleteSessionAccountMapping(stickyKey) + logger.debug(`🧹 已清理 Droid 粘性会话映射:${stickyKey}`) + } catch (error) { + logger.warn(`⚠️ 清理 Droid 粘性会话映射失败:${stickyKey}`, error) + } + } + + /** + * 清理 API Key 级别的粘性映射 + */ + async _clearApiKeyStickyMapping(accountId, endpointType, sessionHash) { + if (!accountId || !sessionHash) { + return + } + + try { + const stickyKey = this._composeApiKeyStickyKey(accountId, endpointType, sessionHash) + if (stickyKey) { + await redis.deleteSessionAccountMapping(stickyKey) + logger.debug(`🧹 已清理 Droid API Key 粘性映射:${stickyKey}`) + } + } catch (error) { + logger.warn( + `⚠️ 清理 Droid API Key 粘性映射失败:${accountId}(endpoint: ${endpointType})`, + error + ) + } + } + + _mapNetworkErrorStatus(error) { + const code = (error && error.code ? String(error.code) : '').toUpperCase() + + if (code === 'ECONNABORTED' || code === 'ETIMEDOUT') { + return 408 + } + + if (code === 'ECONNRESET' || code === 'EPIPE') { + return 424 + } + + if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') { + return 424 + } + + if (typeof error === 'object' && error !== null) { + const message = (error.message || '').toLowerCase() + if (message.includes('timeout')) { + return 408 + } + } + + return 424 + } + + _buildNetworkErrorBody(error) { + const body = { + error: 'relay_upstream_failure', + message: error?.message || '上游请求失败' + } + + if (error?.code) { + body.code = error.code + } + + if (error?.config?.url) { + body.upstream = error.config.url + } + + return body + } + + /** + * 生成 UUID + */ + _generateUUID() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0 + const v = c === 'x' ? r : (r & 0x3) | 0x8 + return v.toString(16) + }) + } +} + +// 导出单例 +module.exports = new DroidRelayService() diff --git a/src/services/droidScheduler.js b/src/services/droidScheduler.js new file mode 100644 index 0000000000000000000000000000000000000000..67add5eaff9a6a18cbc73867857261013b346451 --- /dev/null +++ b/src/services/droidScheduler.js @@ -0,0 +1,218 @@ +const droidAccountService = require('./droidAccountService') +const accountGroupService = require('./accountGroupService') +const redis = require('../models/redis') +const logger = require('../utils/logger') + +class DroidScheduler { + constructor() { + this.STICKY_PREFIX = 'droid' + } + + _normalizeEndpointType(endpointType) { + if (!endpointType) { + return 'anthropic' + } + const normalized = String(endpointType).toLowerCase() + if (normalized === 'openai' || normalized === 'common') { + return 'openai' + } + return 'anthropic' + } + + _isTruthy(value) { + if (value === undefined || value === null) { + return false + } + if (typeof value === 'boolean') { + return value + } + if (typeof value === 'string') { + return value.toLowerCase() === 'true' + } + return Boolean(value) + } + + _isAccountActive(account) { + if (!account) { + return false + } + const isActive = this._isTruthy(account.isActive) + if (!isActive) { + return false + } + + const status = (account.status || 'active').toLowerCase() + const unhealthyStatuses = new Set(['error', 'unauthorized', 'blocked']) + return !unhealthyStatuses.has(status) + } + + _isAccountSchedulable(account) { + return this._isTruthy(account?.schedulable ?? true) + } + + _matchesEndpoint(account, endpointType) { + const normalizedEndpoint = this._normalizeEndpointType(endpointType) + const accountEndpoint = this._normalizeEndpointType(account?.endpointType) + if (normalizedEndpoint === accountEndpoint) { + return true + } + + const sharedEndpoints = new Set(['anthropic', 'openai']) + return sharedEndpoints.has(normalizedEndpoint) && sharedEndpoints.has(accountEndpoint) + } + + _sortCandidates(candidates) { + return [...candidates].sort((a, b) => { + const priorityA = parseInt(a.priority, 10) || 50 + const priorityB = parseInt(b.priority, 10) || 50 + + if (priorityA !== priorityB) { + return priorityA - priorityB + } + + const lastUsedA = a.lastUsedAt ? new Date(a.lastUsedAt).getTime() : 0 + const lastUsedB = b.lastUsedAt ? new Date(b.lastUsedAt).getTime() : 0 + + if (lastUsedA !== lastUsedB) { + return lastUsedA - lastUsedB + } + + const createdA = a.createdAt ? new Date(a.createdAt).getTime() : 0 + const createdB = b.createdAt ? new Date(b.createdAt).getTime() : 0 + return createdA - createdB + }) + } + + _composeStickySessionKey(endpointType, sessionHash, apiKeyId) { + if (!sessionHash) { + return null + } + const normalizedEndpoint = this._normalizeEndpointType(endpointType) + const apiKeyPart = apiKeyId || 'default' + return `${this.STICKY_PREFIX}:${normalizedEndpoint}:${apiKeyPart}:${sessionHash}` + } + + async _loadGroupAccounts(groupId) { + const memberIds = await accountGroupService.getGroupMembers(groupId) + if (!memberIds || memberIds.length === 0) { + return [] + } + + const accounts = await Promise.all( + memberIds.map(async (memberId) => { + try { + return await droidAccountService.getAccount(memberId) + } catch (error) { + logger.warn(`⚠️ 获取 Droid 分组成员账号失败: ${memberId}`, error) + return null + } + }) + ) + + return accounts.filter( + (account) => account && this._isAccountActive(account) && this._isAccountSchedulable(account) + ) + } + + async _ensureLastUsedUpdated(accountId) { + try { + await droidAccountService.touchLastUsedAt(accountId) + } catch (error) { + logger.warn(`⚠️ 更新 Droid 账号最后使用时间失败: ${accountId}`, error) + } + } + + async _cleanupStickyMapping(stickyKey) { + if (!stickyKey) { + return + } + try { + await redis.deleteSessionAccountMapping(stickyKey) + } catch (error) { + logger.warn(`⚠️ 清理 Droid 粘性会话映射失败: ${stickyKey}`, error) + } + } + + async selectAccount(apiKeyData, endpointType, sessionHash) { + const normalizedEndpoint = this._normalizeEndpointType(endpointType) + const stickyKey = this._composeStickySessionKey(normalizedEndpoint, sessionHash, apiKeyData?.id) + + let candidates = [] + let isDedicatedBinding = false + + if (apiKeyData?.droidAccountId) { + const binding = apiKeyData.droidAccountId + if (binding.startsWith('group:')) { + const groupId = binding.substring('group:'.length) + logger.info( + `🤖 API Key ${apiKeyData.name || apiKeyData.id} 绑定 Droid 分组 ${groupId},按分组调度` + ) + candidates = await this._loadGroupAccounts(groupId, normalizedEndpoint) + } else { + const account = await droidAccountService.getAccount(binding) + if (account) { + candidates = [account] + isDedicatedBinding = true + } + } + } + + if (!candidates || candidates.length === 0) { + candidates = await droidAccountService.getSchedulableAccounts(normalizedEndpoint) + } + + const filtered = candidates.filter( + (account) => + account && + this._isAccountActive(account) && + this._isAccountSchedulable(account) && + this._matchesEndpoint(account, normalizedEndpoint) + ) + + if (filtered.length === 0) { + throw new Error( + `No available Droid accounts for endpoint ${normalizedEndpoint}${apiKeyData?.droidAccountId ? ' (respecting binding)' : ''}` + ) + } + + if (stickyKey && !isDedicatedBinding) { + const mappedAccountId = await redis.getSessionAccountMapping(stickyKey) + if (mappedAccountId) { + const mappedAccount = filtered.find((account) => account.id === mappedAccountId) + if (mappedAccount) { + await redis.extendSessionAccountMappingTTL(stickyKey) + logger.info( + `🤖 命中 Droid 粘性会话: ${sessionHash} -> ${mappedAccount.name || mappedAccount.id}` + ) + await this._ensureLastUsedUpdated(mappedAccount.id) + return mappedAccount + } + + await this._cleanupStickyMapping(stickyKey) + } + } + + const sorted = this._sortCandidates(filtered) + const selected = sorted[0] + + if (!selected) { + throw new Error( + `No schedulable Droid account available after sorting (${normalizedEndpoint})` + ) + } + + if (stickyKey && !isDedicatedBinding) { + await redis.setSessionAccountMapping(stickyKey, selected.id) + } + + await this._ensureLastUsedUpdated(selected.id) + + logger.info( + `🤖 选择 Droid 账号 ${selected.name || selected.id}(endpoint: ${normalizedEndpoint}, priority: ${selected.priority || 50})` + ) + + return selected + } +} + +module.exports = new DroidScheduler() diff --git a/src/services/geminiAccountService.js b/src/services/geminiAccountService.js new file mode 100644 index 0000000000000000000000000000000000000000..c1a91acf978e8fbd0d13b9cfaaeebceea01d9a6e --- /dev/null +++ b/src/services/geminiAccountService.js @@ -0,0 +1,1521 @@ +const redisClient = require('../models/redis') +const { v4: uuidv4 } = require('uuid') +const crypto = require('crypto') +const config = require('../../config/config') +const logger = require('../utils/logger') +const { OAuth2Client } = require('google-auth-library') +const { maskToken } = require('../utils/tokenMask') +const ProxyHelper = require('../utils/proxyHelper') +const { + logRefreshStart, + logRefreshSuccess, + logRefreshError, + logTokenUsage, + logRefreshSkipped +} = require('../utils/tokenRefreshLogger') +const tokenRefreshService = require('./tokenRefreshService') +const LRUCache = require('../utils/lruCache') + +// 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'] + +// 加密相关常量 +const ALGORITHM = 'aes-256-cbc' +const ENCRYPTION_SALT = 'gemini-account-salt' +const IV_LENGTH = 16 + +// 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算 +// scryptSync 是 CPU 密集型操作,缓存可以减少 95%+ 的 CPU 占用 +let _encryptionKeyCache = null + +// 🔄 解密结果缓存,提高解密性能 +const decryptCache = new LRUCache(500) + +// 生成加密密钥(使用与 claudeAccountService 相同的方法) +function generateEncryptionKey() { + if (!_encryptionKeyCache) { + _encryptionKeyCache = crypto.scryptSync(config.security.encryptionKey, ENCRYPTION_SALT, 32) + logger.info('🔑 Gemini encryption key derived and cached for performance optimization') + } + return _encryptionKeyCache +} + +function normalizeSubscriptionExpiresAt(value) { + if (value === undefined || value === null || value === '') { + return '' + } + + const date = value instanceof Date ? value : new Date(value) + if (Number.isNaN(date.getTime())) { + return '' + } + + return date.toISOString() +} + +// Gemini 账户键前缀 +const GEMINI_ACCOUNT_KEY_PREFIX = 'gemini_account:' +const SHARED_GEMINI_ACCOUNTS_KEY = 'shared_gemini_accounts' +const ACCOUNT_SESSION_MAPPING_PREFIX = 'gemini_session_account_mapping:' + +// 加密函数 +function encrypt(text) { + if (!text) { + return '' + } + const key = generateEncryptionKey() + const iv = crypto.randomBytes(IV_LENGTH) + const cipher = crypto.createCipheriv(ALGORITHM, key, iv) + let encrypted = cipher.update(text) + encrypted = Buffer.concat([encrypted, cipher.final()]) + return `${iv.toString('hex')}:${encrypted.toString('hex')}` +} + +// 解密函数 +function decrypt(text) { + if (!text) { + return '' + } + + // 🎯 检查缓存 + const cacheKey = crypto.createHash('sha256').update(text).digest('hex') + const cached = decryptCache.get(cacheKey) + if (cached !== undefined) { + return cached + } + + try { + const key = generateEncryptionKey() + // IV 是固定长度的 32 个十六进制字符(16 字节) + const ivHex = text.substring(0, 32) + const encryptedHex = text.substring(33) // 跳过冒号 + + const iv = Buffer.from(ivHex, 'hex') + const encryptedText = Buffer.from(encryptedHex, 'hex') + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv) + let decrypted = decipher.update(encryptedText) + decrypted = Buffer.concat([decrypted, decipher.final()]) + const result = decrypted.toString() + + // 💾 存入缓存(5分钟过期) + decryptCache.set(cacheKey, result, 5 * 60 * 1000) + + // 📊 定期打印缓存统计 + if ((decryptCache.hits + decryptCache.misses) % 1000 === 0) { + decryptCache.printStats() + } + + return result + } catch (error) { + logger.error('Decryption error:', error) + return '' + } +} + +// 🧹 定期清理缓存(每10分钟) +setInterval( + () => { + decryptCache.cleanup() + logger.info('🧹 Gemini decrypt cache cleanup completed', decryptCache.getStats()) + }, + 10 * 60 * 1000 +) + +// 创建 OAuth2 客户端(支持代理配置) +function createOAuth2Client(redirectUri = null, proxyConfig = null) { + // 如果没有提供 redirectUri,使用默认值 + const uri = redirectUri || 'http://localhost:45462' + + // 准备客户端选项 + const clientOptions = { + clientId: OAUTH_CLIENT_ID, + clientSecret: OAUTH_CLIENT_SECRET, + redirectUri: uri + } + + // 如果有代理配置,设置 transporterOptions + if (proxyConfig) { + const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig) + if (proxyAgent) { + // 通过 transporterOptions 传递代理配置给底层的 Gaxios + clientOptions.transporterOptions = { + agent: proxyAgent, + httpsAgent: proxyAgent + } + logger.debug('Created OAuth2Client with proxy configuration') + } + } + + return new OAuth2Client(clientOptions) +} + +// 生成授权 URL (支持 PKCE 和代理) +async function generateAuthUrl(state = null, redirectUri = null, proxyConfig = null) { + // 使用新的 redirect URI + const finalRedirectUri = redirectUri || 'https://codeassist.google.com/authcode' + const oAuth2Client = createOAuth2Client(finalRedirectUri, proxyConfig) + + if (proxyConfig) { + logger.info( + `🌐 Using proxy for Gemini auth URL generation: ${ProxyHelper.getProxyDescription(proxyConfig)}` + ) + } else { + logger.debug('🌐 No proxy configured for Gemini auth URL generation') + } + + // 生成 PKCE code verifier + const codeVerifier = await oAuth2Client.generateCodeVerifierAsync() + const stateValue = state || crypto.randomBytes(32).toString('hex') + + const authUrl = oAuth2Client.generateAuthUrl({ + redirect_uri: finalRedirectUri, + access_type: 'offline', + scope: OAUTH_SCOPES, + code_challenge_method: 'S256', + code_challenge: codeVerifier.codeChallenge, + state: stateValue, + prompt: 'select_account' + }) + + return { + authUrl, + state: stateValue, + codeVerifier: codeVerifier.codeVerifier, + redirectUri: finalRedirectUri + } +} + +// 轮询检查 OAuth 授权状态 +async function pollAuthorizationStatus(sessionId, maxAttempts = 60, interval = 2000) { + let attempts = 0 + const client = redisClient.getClientSafe() + + while (attempts < maxAttempts) { + try { + const sessionData = await client.get(`oauth_session:${sessionId}`) + if (!sessionData) { + throw new Error('OAuth session not found') + } + + const session = JSON.parse(sessionData) + if (session.code) { + // 授权码已获取,交换 tokens + const tokens = await exchangeCodeForTokens(session.code) + + // 清理 session + await client.del(`oauth_session:${sessionId}`) + + return { + success: true, + tokens + } + } + + if (session.error) { + // 授权失败 + await client.del(`oauth_session:${sessionId}`) + return { + success: false, + error: session.error + } + } + + // 等待下一次轮询 + await new Promise((resolve) => setTimeout(resolve, interval)) + attempts++ + } catch (error) { + logger.error('Error polling authorization status:', error) + throw error + } + } + + // 超时 + await client.del(`oauth_session:${sessionId}`) + return { + success: false, + error: 'Authorization timeout' + } +} + +// 交换授权码获取 tokens (支持 PKCE 和代理) +async function exchangeCodeForTokens( + code, + redirectUri = null, + codeVerifier = null, + proxyConfig = null +) { + try { + // 创建带代理配置的 OAuth2Client + const oAuth2Client = createOAuth2Client(redirectUri, proxyConfig) + + if (proxyConfig) { + logger.info( + `🌐 Using proxy for Gemini token exchange: ${ProxyHelper.getProxyDescription(proxyConfig)}` + ) + } else { + logger.debug('🌐 No proxy configured for Gemini token exchange') + } + + const tokenParams = { + code, + redirect_uri: redirectUri + } + + // 如果提供了 codeVerifier,添加到参数中 + if (codeVerifier) { + tokenParams.codeVerifier = codeVerifier + } + + const { tokens } = await oAuth2Client.getToken(tokenParams) + + // 转换为兼容格式 + return { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + scope: tokens.scope || OAUTH_SCOPES.join(' '), + token_type: tokens.token_type || 'Bearer', + expiry_date: tokens.expiry_date || Date.now() + tokens.expires_in * 1000 + } + } catch (error) { + logger.error('Error exchanging code for tokens:', error) + throw new Error('Failed to exchange authorization code') + } +} + +// 刷新访问令牌 +async function refreshAccessToken(refreshToken, proxyConfig = null) { + // 创建带代理配置的 OAuth2Client + const oAuth2Client = createOAuth2Client(null, proxyConfig) + + try { + // 设置 refresh_token + oAuth2Client.setCredentials({ + refresh_token: refreshToken + }) + + if (proxyConfig) { + logger.info( + `🔄 Using proxy for Gemini token refresh: ${ProxyHelper.maskProxyInfo(proxyConfig)}` + ) + } else { + logger.debug('🔄 No proxy configured for Gemini token refresh') + } + + // 调用 refreshAccessToken 获取新的 tokens + const response = await oAuth2Client.refreshAccessToken() + const { credentials } = response + + // 检查是否成功获取了新的 access_token + if (!credentials || !credentials.access_token) { + throw new Error('No access token returned from refresh') + } + + logger.info( + `🔄 Successfully refreshed Gemini token. New expiry: ${new Date(credentials.expiry_date).toISOString()}` + ) + + return { + access_token: credentials.access_token, + refresh_token: credentials.refresh_token || refreshToken, // 保留原 refresh_token 如果没有返回新的 + scope: credentials.scope || OAUTH_SCOPES.join(' '), + token_type: credentials.token_type || 'Bearer', + expiry_date: credentials.expiry_date || Date.now() + 3600000 // 默认1小时过期 + } + } catch (error) { + logger.error('Error refreshing access token:', { + message: error.message, + code: error.code, + response: error.response?.data, + hasProxy: !!proxyConfig, + proxy: proxyConfig ? ProxyHelper.maskProxyInfo(proxyConfig) : 'No proxy' + }) + throw new Error(`Failed to refresh access token: ${error.message}`) + } +} + +// 创建 Gemini 账户 +async function createAccount(accountData) { + const id = uuidv4() + const now = new Date().toISOString() + + // 处理凭证数据 + let geminiOauth = null + let accessToken = '' + let refreshToken = '' + let expiresAt = '' + + const subscriptionExpiresAt = normalizeSubscriptionExpiresAt( + accountData.subscriptionExpiresAt || '' + ) + + if (accountData.geminiOauth || accountData.accessToken) { + // 如果提供了完整的 OAuth 数据 + if (accountData.geminiOauth) { + geminiOauth = + typeof accountData.geminiOauth === 'string' + ? accountData.geminiOauth + : JSON.stringify(accountData.geminiOauth) + + const oauthData = + typeof accountData.geminiOauth === 'string' + ? JSON.parse(accountData.geminiOauth) + : accountData.geminiOauth + + accessToken = oauthData.access_token || '' + refreshToken = oauthData.refresh_token || '' + expiresAt = oauthData.expiry_date ? new Date(oauthData.expiry_date).toISOString() : '' + } else { + // 如果只提供了 access token + ;({ accessToken } = accountData) + refreshToken = accountData.refreshToken || '' + + // 构造完整的 OAuth 数据 + geminiOauth = JSON.stringify({ + access_token: accessToken, + refresh_token: refreshToken, + scope: accountData.scope || OAUTH_SCOPES.join(' '), + token_type: accountData.tokenType || 'Bearer', + expiry_date: accountData.expiryDate || Date.now() + 3600000 // 默认1小时 + }) + + expiresAt = new Date(accountData.expiryDate || Date.now() + 3600000).toISOString() + } + } + + const account = { + id, + platform: 'gemini', // 标识为 Gemini 账户 + name: accountData.name || 'Gemini Account', + description: accountData.description || '', + accountType: accountData.accountType || 'shared', + isActive: 'true', + status: 'active', + + // 调度相关 + schedulable: accountData.schedulable !== undefined ? String(accountData.schedulable) : 'true', + priority: accountData.priority || 50, // 调度优先级 (1-100,数字越小优先级越高) + + // OAuth 相关字段(加密存储) + geminiOauth: geminiOauth ? encrypt(geminiOauth) : '', + accessToken: accessToken ? encrypt(accessToken) : '', + refreshToken: refreshToken ? encrypt(refreshToken) : '', + expiresAt, + // 只有OAuth方式才有scopes,手动添加的没有 + scopes: accountData.geminiOauth ? accountData.scopes || OAUTH_SCOPES.join(' ') : '', + + // 代理设置 + proxy: accountData.proxy ? JSON.stringify(accountData.proxy) : '', + + // 项目 ID(Google Cloud/Workspace 账号需要) + projectId: accountData.projectId || '', + + // 临时项目 ID(从 loadCodeAssist 接口自动获取) + tempProjectId: accountData.tempProjectId || '', + + // 支持的模型列表(可选) + supportedModels: accountData.supportedModels || [], // 空数组表示支持所有模型 + + // 时间戳 + createdAt: now, + updatedAt: now, + lastUsedAt: '', + lastRefreshAt: '', + subscriptionExpiresAt + } + + // 保存到 Redis + const client = redisClient.getClientSafe() + await client.hset(`${GEMINI_ACCOUNT_KEY_PREFIX}${id}`, account) + + // 如果是共享账户,添加到共享账户集合 + if (account.accountType === 'shared') { + await client.sadd(SHARED_GEMINI_ACCOUNTS_KEY, id) + } + + logger.info(`Created Gemini account: ${id}`) + + // 返回时解析代理配置 + const returnAccount = { ...account } + if (returnAccount.proxy) { + try { + returnAccount.proxy = JSON.parse(returnAccount.proxy) + } catch (e) { + returnAccount.proxy = null + } + } + + if (!returnAccount.subscriptionExpiresAt) { + returnAccount.subscriptionExpiresAt = null + } + + return returnAccount +} + +// 获取账户 +async function getAccount(accountId) { + const client = redisClient.getClientSafe() + const accountData = await client.hgetall(`${GEMINI_ACCOUNT_KEY_PREFIX}${accountId}`) + + if (!accountData || Object.keys(accountData).length === 0) { + return null + } + + // 解密敏感字段 + if (accountData.geminiOauth) { + accountData.geminiOauth = decrypt(accountData.geminiOauth) + } + if (accountData.accessToken) { + accountData.accessToken = decrypt(accountData.accessToken) + } + if (accountData.refreshToken) { + accountData.refreshToken = decrypt(accountData.refreshToken) + } + + // 解析代理配置 + if (accountData.proxy) { + try { + accountData.proxy = JSON.parse(accountData.proxy) + } catch (e) { + // 如果解析失败,保持原样或设置为null + accountData.proxy = null + } + } + + // 转换 schedulable 字符串为布尔值(与 claudeConsoleAccountService 保持一致) + accountData.schedulable = accountData.schedulable !== 'false' // 默认为true,只有明确设置为'false'才为false + + if (!accountData.subscriptionExpiresAt) { + accountData.subscriptionExpiresAt = null + } + + return accountData +} + +// 更新账户 +async function updateAccount(accountId, updates) { + const existingAccount = await getAccount(accountId) + if (!existingAccount) { + throw new Error('Account not found') + } + + const now = new Date().toISOString() + updates.updatedAt = now + + if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) { + updates.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(updates.subscriptionExpiresAt) + } + + // 检查是否新增了 refresh token + // existingAccount.refreshToken 已经是解密后的值了(从 getAccount 返回) + const oldRefreshToken = existingAccount.refreshToken || '' + let needUpdateExpiry = false + + // 处理代理设置 + if (updates.proxy !== undefined) { + updates.proxy = updates.proxy ? JSON.stringify(updates.proxy) : '' + } + + // 处理 schedulable 字段,确保正确转换为字符串存储 + if (updates.schedulable !== undefined) { + updates.schedulable = updates.schedulable.toString() + } + + // 加密敏感字段 + if (updates.geminiOauth) { + updates.geminiOauth = encrypt( + typeof updates.geminiOauth === 'string' + ? updates.geminiOauth + : JSON.stringify(updates.geminiOauth) + ) + } + if (updates.accessToken) { + updates.accessToken = encrypt(updates.accessToken) + } + if (updates.refreshToken) { + updates.refreshToken = encrypt(updates.refreshToken) + // 如果之前没有 refresh token,现在有了,标记需要更新过期时间 + if (!oldRefreshToken && updates.refreshToken) { + needUpdateExpiry = true + } + } + + // 更新账户类型时处理共享账户集合 + const client = redisClient.getClientSafe() + if (updates.accountType && updates.accountType !== existingAccount.accountType) { + if (updates.accountType === 'shared') { + await client.sadd(SHARED_GEMINI_ACCOUNTS_KEY, accountId) + } else { + await client.srem(SHARED_GEMINI_ACCOUNTS_KEY, accountId) + } + } + + // 如果新增了 refresh token,更新过期时间为10分钟 + if (needUpdateExpiry) { + const newExpiry = new Date(Date.now() + 10 * 60 * 1000).toISOString() + updates.expiresAt = newExpiry + logger.info( + `🔄 New refresh token added for Gemini account ${accountId}, setting expiry to 10 minutes` + ) + } + + // 如果通过 geminiOauth 更新,也要检查是否新增了 refresh token + if (updates.geminiOauth && !oldRefreshToken) { + const oauthData = + typeof updates.geminiOauth === 'string' + ? JSON.parse(decrypt(updates.geminiOauth)) + : updates.geminiOauth + + if (oauthData.refresh_token) { + // 如果 expiry_date 设置的时间过长(超过1小时),调整为10分钟 + const providedExpiry = oauthData.expiry_date || 0 + const currentTime = Date.now() + const oneHour = 60 * 60 * 1000 + + if (providedExpiry - currentTime > oneHour) { + const newExpiry = new Date(currentTime + 10 * 60 * 1000).toISOString() + updates.expiresAt = newExpiry + logger.info( + `🔄 Adjusted expiry time to 10 minutes for Gemini account ${accountId} with refresh token` + ) + } + } + } + + // 检查是否手动禁用了账号,如果是则发送webhook通知 + if (updates.isActive === 'false' && existingAccount.isActive !== 'false') { + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: updates.name || existingAccount.name || 'Unknown Account', + platform: 'gemini', + status: 'disabled', + errorCode: 'GEMINI_MANUALLY_DISABLED', + reason: 'Account manually disabled by administrator' + }) + } catch (webhookError) { + logger.error('Failed to send webhook notification for manual account disable:', webhookError) + } + } + + await client.hset(`${GEMINI_ACCOUNT_KEY_PREFIX}${accountId}`, updates) + + logger.info(`Updated Gemini account: ${accountId}`) + + // 合并更新后的账户数据 + const updatedAccount = { ...existingAccount, ...updates } + + // 返回时解析代理配置 + if (updatedAccount.proxy && typeof updatedAccount.proxy === 'string') { + try { + updatedAccount.proxy = JSON.parse(updatedAccount.proxy) + } catch (e) { + updatedAccount.proxy = null + } + } + + if (!updatedAccount.subscriptionExpiresAt) { + updatedAccount.subscriptionExpiresAt = null + } + + return updatedAccount +} + +// 删除账户 +async function deleteAccount(accountId) { + const account = await getAccount(accountId) + if (!account) { + throw new Error('Account not found') + } + + // 从 Redis 删除 + const client = redisClient.getClientSafe() + await client.del(`${GEMINI_ACCOUNT_KEY_PREFIX}${accountId}`) + + // 从共享账户集合中移除 + if (account.accountType === 'shared') { + await client.srem(SHARED_GEMINI_ACCOUNTS_KEY, accountId) + } + + // 清理会话映射 + const sessionMappings = await client.keys(`${ACCOUNT_SESSION_MAPPING_PREFIX}*`) + for (const key of sessionMappings) { + const mappedAccountId = await client.get(key) + if (mappedAccountId === accountId) { + await client.del(key) + } + } + + logger.info(`Deleted Gemini account: ${accountId}`) + return true +} + +// 获取所有账户 +async function getAllAccounts() { + const client = redisClient.getClientSafe() + const keys = await client.keys(`${GEMINI_ACCOUNT_KEY_PREFIX}*`) + const accounts = [] + + for (const key of keys) { + const accountData = await client.hgetall(key) + if (accountData && Object.keys(accountData).length > 0) { + // 获取限流状态信息 + const rateLimitInfo = await getAccountRateLimitInfo(accountData.id) + + // 解析代理配置 + if (accountData.proxy) { + try { + accountData.proxy = JSON.parse(accountData.proxy) + } catch (e) { + // 如果解析失败,设置为null + accountData.proxy = null + } + } + + // 转换 schedulable 字符串为布尔值(与 getAccount 保持一致) + accountData.schedulable = accountData.schedulable !== 'false' // 默认为true,只有明确设置为'false'才为false + + // 不解密敏感字段,只返回基本信息 + accounts.push({ + ...accountData, + geminiOauth: accountData.geminiOauth ? '[ENCRYPTED]' : '', + accessToken: accountData.accessToken ? '[ENCRYPTED]' : '', + refreshToken: accountData.refreshToken ? '[ENCRYPTED]' : '', + subscriptionExpiresAt: accountData.subscriptionExpiresAt || null, + // 添加 scopes 字段用于判断认证方式 + // 处理空字符串和默认值的情况 + scopes: + accountData.scopes && accountData.scopes.trim() ? accountData.scopes.split(' ') : [], + // 添加 hasRefreshToken 标记 + hasRefreshToken: !!accountData.refreshToken, + // 添加限流状态信息(统一格式) + rateLimitStatus: rateLimitInfo + ? { + isRateLimited: rateLimitInfo.isRateLimited, + rateLimitedAt: rateLimitInfo.rateLimitedAt, + minutesRemaining: rateLimitInfo.minutesRemaining + } + : { + isRateLimited: false, + rateLimitedAt: null, + minutesRemaining: 0 + } + }) + } + } + + return accounts +} + +// 选择可用账户(支持专属和共享账户) +async function selectAvailableAccount(apiKeyId, sessionHash = null) { + // 首先检查是否有粘性会话 + const client = redisClient.getClientSafe() + if (sessionHash) { + const mappedAccountId = await client.get(`${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionHash}`) + + if (mappedAccountId) { + const account = await getAccount(mappedAccountId) + if (account && account.isActive === 'true' && !isTokenExpired(account)) { + logger.debug(`Using sticky session account: ${mappedAccountId}`) + return account + } + } + } + + // 获取 API Key 信息 + const apiKeyData = await client.hgetall(`api_key:${apiKeyId}`) + + // 检查是否绑定了 Gemini 账户 + if (apiKeyData.geminiAccountId) { + const account = await getAccount(apiKeyData.geminiAccountId) + if (account && account.isActive === 'true') { + // 检查 token 是否过期 + const isExpired = isTokenExpired(account) + + // 记录token使用情况 + logTokenUsage(account.id, account.name, 'gemini', account.expiresAt, isExpired) + + if (isExpired) { + await refreshAccountToken(account.id) + return await getAccount(account.id) + } + + // 创建粘性会话映射 + if (sessionHash) { + await client.setex( + `${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionHash}`, + 3600, // 1小时过期 + account.id + ) + } + + return account + } + } + + // 从共享账户池选择 + const sharedAccountIds = await client.smembers(SHARED_GEMINI_ACCOUNTS_KEY) + const availableAccounts = [] + + for (const accountId of sharedAccountIds) { + const account = await getAccount(accountId) + if (account && account.isActive === 'true' && !isRateLimited(account)) { + availableAccounts.push(account) + } + } + + if (availableAccounts.length === 0) { + throw new Error('No available Gemini accounts') + } + + // 选择最少使用的账户 + availableAccounts.sort((a, b) => { + const aLastUsed = a.lastUsedAt ? new Date(a.lastUsedAt).getTime() : 0 + const bLastUsed = b.lastUsedAt ? new Date(b.lastUsedAt).getTime() : 0 + return aLastUsed - bLastUsed + }) + + const selectedAccount = availableAccounts[0] + + // 检查并刷新 token + const isExpired = isTokenExpired(selectedAccount) + + // 记录token使用情况 + logTokenUsage( + selectedAccount.id, + selectedAccount.name, + 'gemini', + selectedAccount.expiresAt, + isExpired + ) + + if (isExpired) { + await refreshAccountToken(selectedAccount.id) + return await getAccount(selectedAccount.id) + } + + // 创建粘性会话映射 + if (sessionHash) { + await client.setex(`${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionHash}`, 3600, selectedAccount.id) + } + + return selectedAccount +} + +// 检查 token 是否过期 +function isTokenExpired(account) { + if (!account.expiresAt) { + return true + } + + const expiryTime = new Date(account.expiresAt).getTime() + const now = Date.now() + const buffer = 10 * 1000 // 10秒缓冲 + + return now >= expiryTime - buffer +} + +// 检查账户是否被限流 +function isRateLimited(account) { + if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) { + const limitedAt = new Date(account.rateLimitedAt).getTime() + const now = Date.now() + const limitDuration = 60 * 60 * 1000 // 1小时 + + return now < limitedAt + limitDuration + } + return false +} + +// 刷新账户 token +async function refreshAccountToken(accountId) { + let lockAcquired = false + let account = null + + try { + account = await getAccount(accountId) + if (!account) { + throw new Error('Account not found') + } + + if (!account.refreshToken) { + throw new Error('No refresh token available') + } + + // 尝试获取分布式锁 + lockAcquired = await tokenRefreshService.acquireRefreshLock(accountId, 'gemini') + + if (!lockAcquired) { + // 如果无法获取锁,说明另一个进程正在刷新 + logger.info( + `🔒 Token refresh already in progress for Gemini account: ${account.name} (${accountId})` + ) + logRefreshSkipped(accountId, account.name, 'gemini', 'already_locked') + + // 等待一段时间后返回,期望其他进程已完成刷新 + await new Promise((resolve) => setTimeout(resolve, 2000)) + + // 重新获取账户数据(可能已被其他进程刷新) + const updatedAccount = await getAccount(accountId) + if (updatedAccount && updatedAccount.accessToken) { + 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(' '), + token_type: 'Bearer' + } + } + + throw new Error('Token refresh in progress by another process') + } + + // 记录开始刷新 + logRefreshStart(accountId, account.name, 'gemini', 'manual_refresh') + logger.info(`🔄 Starting token refresh for Gemini account: ${account.name} (${accountId})`) + + // account.refreshToken 已经是解密后的值(从 getAccount 返回) + // 传入账户的代理配置 + const newTokens = await refreshAccessToken(account.refreshToken, account.proxy) + + // 更新账户信息 + const updates = { + accessToken: newTokens.access_token, + refreshToken: newTokens.refresh_token || account.refreshToken, + expiresAt: new Date(newTokens.expiry_date).toISOString(), + lastRefreshAt: new Date().toISOString(), + geminiOauth: JSON.stringify(newTokens), + status: 'active', // 刷新成功后,将状态更新为 active + errorMessage: '' // 清空错误信息 + } + + await updateAccount(accountId, updates) + + // 记录刷新成功 + logRefreshSuccess(accountId, account.name, 'gemini', { + accessToken: newTokens.access_token, + refreshToken: newTokens.refresh_token, + expiresAt: newTokens.expiry_date, + scopes: newTokens.scope + }) + + logger.info( + `Refreshed token for Gemini account: ${accountId} - Access Token: ${maskToken(newTokens.access_token)}` + ) + + return newTokens + } catch (error) { + // 记录刷新失败 + logRefreshError(accountId, account ? account.name : 'Unknown', 'gemini', error) + + logger.error(`Failed to refresh token for account ${accountId}:`, error) + + // 标记账户为错误状态(只有在账户存在时) + if (account) { + try { + await updateAccount(accountId, { + status: 'error', + errorMessage: error.message + }) + + // 发送Webhook通知 + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: account.name, + platform: 'gemini', + status: 'error', + errorCode: 'GEMINI_ERROR', + reason: `Token refresh failed: ${error.message}` + }) + } catch (webhookError) { + logger.error('Failed to send webhook notification:', webhookError) + } + } catch (updateError) { + logger.error('Failed to update account status after refresh error:', updateError) + } + } + + throw error + } finally { + // 释放锁 + if (lockAcquired) { + await tokenRefreshService.releaseRefreshLock(accountId, 'gemini') + } + } +} + +// 标记账户被使用 +async function markAccountUsed(accountId) { + await updateAccount(accountId, { + lastUsedAt: new Date().toISOString() + }) +} + +// 设置账户限流状态 +async function setAccountRateLimited(accountId, isLimited = true) { + const updates = isLimited + ? { + rateLimitStatus: 'limited', + rateLimitedAt: new Date().toISOString() + } + : { + rateLimitStatus: '', + rateLimitedAt: '' + } + + await updateAccount(accountId, updates) +} + +// 获取账户的限流信息(参考 claudeAccountService 的实现) +async function getAccountRateLimitInfo(accountId) { + try { + const account = await getAccount(accountId) + if (!account) { + return null + } + + if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) { + const rateLimitedAt = new Date(account.rateLimitedAt) + const now = new Date() + const minutesSinceRateLimit = Math.floor((now - rateLimitedAt) / (1000 * 60)) + + // Gemini 限流持续时间为 1 小时 + const minutesRemaining = Math.max(0, 60 - minutesSinceRateLimit) + const rateLimitEndAt = new Date(rateLimitedAt.getTime() + 60 * 60 * 1000).toISOString() + + return { + isRateLimited: minutesRemaining > 0, + rateLimitedAt: account.rateLimitedAt, + minutesSinceRateLimit, + minutesRemaining, + rateLimitEndAt + } + } + + return { + isRateLimited: false, + rateLimitedAt: null, + minutesSinceRateLimit: 0, + minutesRemaining: 0, + rateLimitEndAt: null + } + } catch (error) { + logger.error(`❌ Failed to get rate limit info for Gemini account: ${accountId}`, error) + return null + } +} + +// 获取配置的OAuth客户端 - 参考GeminiCliSimulator的getOauthClient方法(支持代理) +async function getOauthClient(accessToken, refreshToken, proxyConfig = null) { + const client = createOAuth2Client(null, proxyConfig) + + 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', + token_type: 'Bearer', + expiry_date: 1754269905646 + } + + if (proxyConfig) { + logger.info( + `🌐 Using proxy for Gemini OAuth client: ${ProxyHelper.getProxyDescription(proxyConfig)}` + ) + } else { + logger.debug('🌐 No proxy configured for Gemini OAuth client') + } + + // 设置凭据 + client.setCredentials(creds) + + // 验证凭据本地有效性 + const { token } = await client.getAccessToken() + if (!token) { + return false + } + + // 验证服务器端token状态(检查是否被撤销) + await client.getTokenInfo(token) + + logger.info('✅ OAuth客户端已创建') + return client +} + +// 调用 Google Code Assist API 的 loadCodeAssist 方法(支持代理) +async function loadCodeAssist(client, projectId = null, proxyConfig = null) { + const axios = require('axios') + const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com' + const CODE_ASSIST_API_VERSION = 'v1internal' + + const { token } = await client.getAccessToken() + + // 创建ClientMetadata + const clientMetadata = { + ideType: 'IDE_UNSPECIFIED', + platform: 'PLATFORM_UNSPECIFIED', + pluginType: 'GEMINI' + } + + // 只有当projectId存在时才添加duetProject + if (projectId) { + clientMetadata.duetProject = projectId + } + + const request = { + metadata: clientMetadata + } + + // 只有当projectId存在时才添加cloudaicompanionProject + if (projectId) { + request.cloudaicompanionProject = projectId + } + + const axiosConfig = { + url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:loadCodeAssist`, + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + data: request, + timeout: 30000 + } + + // 添加代理配置 + const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig) + if (proxyAgent) { + axiosConfig.httpsAgent = proxyAgent + logger.info( + `🌐 Using proxy for Gemini loadCodeAssist: ${ProxyHelper.getProxyDescription(proxyConfig)}` + ) + } else { + logger.debug('🌐 No proxy configured for Gemini loadCodeAssist') + } + + const response = await axios(axiosConfig) + + logger.info('📋 loadCodeAssist API调用成功') + return response.data +} + +// 获取onboard层级 - 参考GeminiCliSimulator的getOnboardTier方法 +function getOnboardTier(loadRes) { + // 用户层级枚举 + const UserTierId = { + LEGACY: 'LEGACY', + FREE: 'FREE', + PRO: 'PRO' + } + + if (loadRes.currentTier) { + return loadRes.currentTier + } + + for (const tier of loadRes.allowedTiers || []) { + if (tier.isDefault) { + return tier + } + } + + return { + name: '', + description: '', + id: UserTierId.LEGACY, + userDefinedCloudaicompanionProject: true + } +} + +// 调用 Google Code Assist API 的 onboardUser 方法(包含轮询逻辑,支持代理) +async function onboardUser(client, tierId, projectId, clientMetadata, proxyConfig = null) { + const axios = require('axios') + const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com' + const CODE_ASSIST_API_VERSION = 'v1internal' + + const { token } = await client.getAccessToken() + + const onboardReq = { + tierId, + metadata: clientMetadata + } + + // 只有当projectId存在时才添加cloudaicompanionProject + if (projectId) { + onboardReq.cloudaicompanionProject = projectId + } + + // 创建基础axios配置 + const baseAxiosConfig = { + url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:onboardUser`, + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + data: onboardReq, + timeout: 30000 + } + + // 添加代理配置 + const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig) + if (proxyAgent) { + baseAxiosConfig.httpsAgent = proxyAgent + logger.info( + `🌐 Using proxy for Gemini onboardUser: ${ProxyHelper.getProxyDescription(proxyConfig)}` + ) + } else { + logger.debug('🌐 No proxy configured for Gemini onboardUser') + } + + logger.info('📋 开始onboardUser API调用', { + tierId, + projectId, + hasProjectId: !!projectId, + isFreeTier: tierId === 'free-tier' || tierId === 'FREE' + }) + + // 轮询onboardUser直到长运行操作完成 + let lroRes = await axios(baseAxiosConfig) + + let attempts = 0 + const maxAttempts = 12 // 最多等待1分钟(5秒 * 12次) + + while (!lroRes.data.done && attempts < maxAttempts) { + logger.info(`⏳ 等待onboardUser完成... (${attempts + 1}/${maxAttempts})`) + await new Promise((resolve) => setTimeout(resolve, 5000)) + + lroRes = await axios(baseAxiosConfig) + attempts++ + } + + if (!lroRes.data.done) { + throw new Error('onboardUser操作超时') + } + + logger.info('✅ onboardUser API调用完成') + return lroRes.data +} + +// 完整的用户设置流程 - 参考setup.ts的逻辑(支持代理) +async function setupUser( + client, + initialProjectId = null, + clientMetadata = null, + proxyConfig = null +) { + logger.info('🚀 setupUser 开始', { initialProjectId, hasClientMetadata: !!clientMetadata }) + + let projectId = initialProjectId || process.env.GOOGLE_CLOUD_PROJECT || null + logger.info('📋 初始项目ID', { projectId, fromEnv: !!process.env.GOOGLE_CLOUD_PROJECT }) + + // 默认的ClientMetadata + if (!clientMetadata) { + clientMetadata = { + ideType: 'IDE_UNSPECIFIED', + platform: 'PLATFORM_UNSPECIFIED', + pluginType: 'GEMINI', + duetProject: projectId + } + logger.info('🔧 使用默认 ClientMetadata') + } + + // 调用loadCodeAssist + logger.info('📞 调用 loadCodeAssist...') + const loadRes = await loadCodeAssist(client, projectId, proxyConfig) + logger.info('✅ loadCodeAssist 完成', { + hasCloudaicompanionProject: !!loadRes.cloudaicompanionProject + }) + + // 如果没有projectId,尝试从loadRes获取 + if (!projectId && loadRes.cloudaicompanionProject) { + projectId = loadRes.cloudaicompanionProject + logger.info('📋 从 loadCodeAssist 获取项目ID', { projectId }) + } + + const tier = getOnboardTier(loadRes) + logger.info('🎯 获取用户层级', { + tierId: tier.id, + userDefinedProject: tier.userDefinedCloudaicompanionProject + }) + + if (tier.userDefinedCloudaiCompanionProject && !projectId) { + throw new Error('此账号需要设置GOOGLE_CLOUD_PROJECT环境变量或提供projectId') + } + + // 调用onboardUser + logger.info('📞 调用 onboardUser...', { tierId: tier.id, projectId }) + const lroRes = await onboardUser(client, tier.id, projectId, clientMetadata, proxyConfig) + logger.info('✅ onboardUser 完成', { hasDone: !!lroRes.done, hasResponse: !!lroRes.response }) + + const result = { + projectId: lroRes.response?.cloudaicompanionProject?.id || projectId || '', + userTier: tier.id, + loadRes, + onboardRes: lroRes.response || {} + } + + logger.info('🎯 setupUser 完成', { resultProjectId: result.projectId, userTier: result.userTier }) + return result +} + +// 调用 Code Assist API 计算 token 数量(支持代理) +async function countTokens(client, contents, model = 'gemini-2.0-flash-exp', proxyConfig = null) { + const axios = require('axios') + const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com' + const CODE_ASSIST_API_VERSION = 'v1internal' + + const { token } = await client.getAccessToken() + + // 按照 gemini-cli 的转换格式构造请求 + const request = { + request: { + model: `models/${model}`, + contents + } + } + + logger.info('📊 countTokens API调用开始', { model, contentsLength: contents.length }) + + const axiosConfig = { + url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:countTokens`, + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + data: request, + timeout: 30000 + } + + // 添加代理配置 + const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig) + if (proxyAgent) { + axiosConfig.httpsAgent = proxyAgent + logger.info( + `🌐 Using proxy for Gemini countTokens: ${ProxyHelper.getProxyDescription(proxyConfig)}` + ) + } else { + logger.debug('🌐 No proxy configured for Gemini countTokens') + } + + const response = await axios(axiosConfig) + + logger.info('✅ countTokens API调用成功', { totalTokens: response.data.totalTokens }) + return response.data +} + +// 调用 Code Assist API 生成内容(非流式) +async function generateContent( + client, + requestData, + userPromptId, + projectId = null, + sessionId = null, + proxyConfig = null +) { + const axios = require('axios') + const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com' + const CODE_ASSIST_API_VERSION = 'v1internal' + + const { token } = await client.getAccessToken() + + // 按照 gemini-cli 的转换格式构造请求 + const request = { + model: requestData.model, + request: { + ...requestData.request, + session_id: sessionId + } + } + + // 只有当 userPromptId 存在时才添加 + if (userPromptId) { + request.user_prompt_id = userPromptId + } + + // 只有当projectId存在时才添加project字段 + if (projectId) { + request.project = projectId + } + + logger.info('🤖 generateContent API调用开始', { + model: requestData.model, + userPromptId, + projectId, + sessionId + }) + + // 添加详细的请求日志 + logger.info('📦 generateContent 请求详情', { + url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:generateContent`, + requestBody: JSON.stringify(request, null, 2) + }) + + const axiosConfig = { + url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:generateContent`, + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + data: request, + timeout: 60000 // 生成内容可能需要更长时间 + } + + // 添加代理配置 + const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig) + if (proxyAgent) { + axiosConfig.httpsAgent = proxyAgent + logger.info( + `🌐 Using proxy for Gemini generateContent: ${ProxyHelper.getProxyDescription(proxyConfig)}` + ) + } else { + logger.debug('🌐 No proxy configured for Gemini generateContent') + } + + const response = await axios(axiosConfig) + + logger.info('✅ generateContent API调用成功') + return response.data +} + +// 调用 Code Assist API 生成内容(流式) +async function generateContentStream( + client, + requestData, + userPromptId, + projectId = null, + sessionId = null, + signal = null, + proxyConfig = null +) { + const axios = require('axios') + const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com' + const CODE_ASSIST_API_VERSION = 'v1internal' + + const { token } = await client.getAccessToken() + + // 按照 gemini-cli 的转换格式构造请求 + const request = { + model: requestData.model, + request: { + ...requestData.request, + session_id: sessionId + } + } + + // 只有当 userPromptId 存在时才添加 + if (userPromptId) { + request.user_prompt_id = userPromptId + } + + // 只有当projectId存在时才添加project字段 + if (projectId) { + request.project = projectId + } + + logger.info('🌊 streamGenerateContent API调用开始', { + model: requestData.model, + userPromptId, + projectId, + sessionId + }) + + const axiosConfig = { + url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:streamGenerateContent`, + method: 'POST', + params: { + alt: 'sse' + }, + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + data: request, + responseType: 'stream', + timeout: 60000 + } + + // 添加代理配置 + const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig) + if (proxyAgent) { + axiosConfig.httpsAgent = proxyAgent + logger.info( + `🌐 Using proxy for Gemini streamGenerateContent: ${ProxyHelper.getProxyDescription(proxyConfig)}` + ) + } else { + logger.debug('🌐 No proxy configured for Gemini streamGenerateContent') + } + + // 如果提供了中止信号,添加到配置中 + if (signal) { + axiosConfig.signal = signal + } + + const response = await axios(axiosConfig) + + logger.info('✅ streamGenerateContent API调用成功,开始流式传输') + return response.data // 返回流对象 +} + +// 更新账户的临时项目 ID +async function updateTempProjectId(accountId, tempProjectId) { + if (!tempProjectId) { + return + } + + try { + const account = await getAccount(accountId) + if (!account) { + logger.warn(`Account ${accountId} not found when updating tempProjectId`) + return + } + + // 只有在没有固定项目 ID 的情况下才更新临时项目 ID + if (!account.projectId && tempProjectId !== account.tempProjectId) { + await updateAccount(accountId, { tempProjectId }) + logger.info(`Updated tempProjectId for account ${accountId}: ${tempProjectId}`) + } + } catch (error) { + logger.error(`Failed to update tempProjectId for account ${accountId}:`, error) + } +} + +module.exports = { + generateAuthUrl, + pollAuthorizationStatus, + exchangeCodeForTokens, + refreshAccessToken, + createAccount, + getAccount, + updateAccount, + deleteAccount, + getAllAccounts, + selectAvailableAccount, + refreshAccountToken, + markAccountUsed, + setAccountRateLimited, + getAccountRateLimitInfo, + isTokenExpired, + getOauthClient, + loadCodeAssist, + getOnboardTier, + onboardUser, + setupUser, + encrypt, + decrypt, + generateEncryptionKey, + decryptCache, // 暴露缓存对象以便测试和监控 + countTokens, + generateContent, + generateContentStream, + updateTempProjectId, + OAUTH_CLIENT_ID, + OAUTH_SCOPES +} diff --git a/src/services/geminiRelayService.js b/src/services/geminiRelayService.js new file mode 100644 index 0000000000000000000000000000000000000000..eec99a9cada4748389ffa3ef392dad9cb77d90ee --- /dev/null +++ b/src/services/geminiRelayService.js @@ -0,0 +1,560 @@ +const axios = require('axios') +const ProxyHelper = require('../utils/proxyHelper') +const logger = require('../utils/logger') +const config = require('../../config/config') +const apiKeyService = require('./apiKeyService') + +// Gemini API 配置 +const GEMINI_API_BASE = 'https://cloudcode.googleapis.com/v1' +const DEFAULT_MODEL = 'models/gemini-2.0-flash-exp' + +// 创建代理 agent(使用统一的代理工具) +function createProxyAgent(proxyConfig) { + return ProxyHelper.createProxyAgent(proxyConfig) +} + +// 转换 OpenAI 消息格式到 Gemini 格式 +function convertMessagesToGemini(messages) { + const contents = [] + let systemInstruction = '' + + for (const message of messages) { + if (message.role === 'system') { + systemInstruction += (systemInstruction ? '\n\n' : '') + message.content + } else if (message.role === 'user') { + contents.push({ + role: 'user', + parts: [{ text: message.content }] + }) + } else if (message.role === 'assistant') { + contents.push({ + role: 'model', + parts: [{ text: message.content }] + }) + } + } + + return { contents, systemInstruction } +} + +// 转换 Gemini 响应到 OpenAI 格式 +function convertGeminiResponse(geminiResponse, model, stream = false) { + if (stream) { + // 流式响应 + const candidate = geminiResponse.candidates?.[0] + if (!candidate) { + return null + } + + const content = candidate.content?.parts?.[0]?.text || '' + const finishReason = candidate.finishReason?.toLowerCase() + + return { + id: `chatcmpl-${Date.now()}`, + object: 'chat.completion.chunk', + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + delta: { + content + }, + finish_reason: finishReason === 'stop' ? 'stop' : null + } + ] + } + } else { + // 非流式响应 + const candidate = geminiResponse.candidates?.[0] + if (!candidate) { + throw new Error('No response from Gemini') + } + + const content = candidate.content?.parts?.[0]?.text || '' + const finishReason = candidate.finishReason?.toLowerCase() || 'stop' + + // 计算 token 使用量 + const usage = geminiResponse.usageMetadata || { + promptTokenCount: 0, + candidatesTokenCount: 0, + totalTokenCount: 0 + } + + return { + id: `chatcmpl-${Date.now()}`, + object: 'chat.completion', + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + message: { + role: 'assistant', + content + }, + finish_reason: finishReason + } + ], + usage: { + prompt_tokens: usage.promptTokenCount, + completion_tokens: usage.candidatesTokenCount, + total_tokens: usage.totalTokenCount + } + } + } +} + +// 处理流式响应 +async function* handleStreamResponse(response, model, apiKeyId, accountId = null) { + let buffer = '' + let totalUsage = { + promptTokenCount: 0, + candidatesTokenCount: 0, + totalTokenCount: 0 + } + + try { + for await (const chunk of response.data) { + buffer += chunk.toString() + + // 处理 SSE 格式的数据 + const lines = buffer.split('\n') + buffer = lines.pop() || '' // 保留最后一个不完整的行 + + for (const line of lines) { + if (!line.trim()) { + continue + } + + // 处理 SSE 格式: "data: {...}" + let jsonData = line + if (line.startsWith('data: ')) { + jsonData = line.substring(6).trim() + } + + if (!jsonData || jsonData === '[DONE]') { + continue + } + + try { + const data = JSON.parse(jsonData) + + // 更新使用量统计 + if (data.usageMetadata) { + totalUsage = data.usageMetadata + } + + // 转换并发送响应 + const openaiResponse = convertGeminiResponse(data, model, true) + if (openaiResponse) { + yield `data: ${JSON.stringify(openaiResponse)}\n\n` + } + + // 检查是否结束 + if (data.candidates?.[0]?.finishReason === 'STOP') { + // 记录使用量 + if (apiKeyId && totalUsage.totalTokenCount > 0) { + await apiKeyService + .recordUsage( + apiKeyId, + totalUsage.promptTokenCount || 0, // inputTokens + totalUsage.candidatesTokenCount || 0, // outputTokens + 0, // cacheCreateTokens (Gemini 没有这个概念) + 0, // cacheReadTokens (Gemini 没有这个概念) + model, + accountId + ) + .catch((error) => { + logger.error('❌ Failed to record Gemini usage:', error) + }) + } + + yield 'data: [DONE]\n\n' + return + } + } catch (e) { + logger.debug('Error parsing JSON line:', e.message, 'Line:', jsonData) + } + } + } + + // 处理剩余的 buffer + if (buffer.trim()) { + try { + let jsonData = buffer.trim() + if (jsonData.startsWith('data: ')) { + jsonData = jsonData.substring(6).trim() + } + + if (jsonData && jsonData !== '[DONE]') { + const data = JSON.parse(jsonData) + const openaiResponse = convertGeminiResponse(data, model, true) + if (openaiResponse) { + yield `data: ${JSON.stringify(openaiResponse)}\n\n` + } + } + } catch (e) { + logger.debug('Error parsing final buffer:', e.message) + } + } + + yield 'data: [DONE]\n\n' + } catch (error) { + // 检查是否是请求被中止 + if (error.name === 'CanceledError' || error.code === 'ECONNABORTED') { + logger.info('Stream request was aborted by client') + } else { + logger.error('Stream processing error:', error) + yield `data: ${JSON.stringify({ + error: { + message: error.message, + type: 'stream_error' + } + })}\n\n` + } + } +} + +// 发送请求到 Gemini +async function sendGeminiRequest({ + messages, + model = DEFAULT_MODEL, + temperature = 0.7, + maxTokens = 4096, + stream = false, + accessToken, + proxy, + apiKeyId, + signal, + projectId, + location = 'us-central1', + accountId = null +}) { + // 确保模型名称格式正确 + if (!model.startsWith('models/')) { + model = `models/${model}` + } + + // 转换消息格式 + const { contents, systemInstruction } = convertMessagesToGemini(messages) + + // 构建请求体 + const requestBody = { + contents, + generationConfig: { + temperature, + maxOutputTokens: maxTokens, + candidateCount: 1 + } + } + + if (systemInstruction) { + requestBody.systemInstruction = { parts: [{ text: systemInstruction }] } + } + + // 配置请求选项 + let apiUrl + if (projectId) { + // 使用项目特定的 URL 格式(Google Cloud/Workspace 账号) + apiUrl = `${GEMINI_API_BASE}/projects/${projectId}/locations/${location}/${model}:${stream ? 'streamGenerateContent' : 'generateContent'}?alt=sse` + logger.debug(`Using project-specific URL with projectId: ${projectId}, location: ${location}`) + } else { + // 使用标准 URL 格式(个人 Google 账号) + apiUrl = `${GEMINI_API_BASE}/${model}:${stream ? 'streamGenerateContent' : 'generateContent'}?alt=sse` + logger.debug('Using standard URL without projectId') + } + + const axiosConfig = { + method: 'POST', + url: apiUrl, + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json' + }, + data: requestBody, + timeout: config.requestTimeout || 600000 + } + + // 添加代理配置 + const proxyAgent = createProxyAgent(proxy) + if (proxyAgent) { + axiosConfig.httpsAgent = proxyAgent + logger.info(`🌐 Using proxy for Gemini API request: ${ProxyHelper.getProxyDescription(proxy)}`) + } else { + logger.debug('🌐 No proxy configured for Gemini API request') + } + + // 添加 AbortController 信号支持 + if (signal) { + axiosConfig.signal = signal + logger.debug('AbortController signal attached to request') + } + + if (stream) { + axiosConfig.responseType = 'stream' + } + + try { + logger.debug('Sending request to Gemini API') + const response = await axios(axiosConfig) + + if (stream) { + return handleStreamResponse(response, model, apiKeyId, accountId) + } else { + // 非流式响应 + const openaiResponse = convertGeminiResponse(response.data, model, false) + + // 记录使用量 + if (apiKeyId && openaiResponse.usage) { + await apiKeyService + .recordUsage( + apiKeyId, + openaiResponse.usage.prompt_tokens || 0, + openaiResponse.usage.completion_tokens || 0, + 0, // cacheCreateTokens + 0, // cacheReadTokens + model, + accountId + ) + .catch((error) => { + logger.error('❌ Failed to record Gemini usage:', error) + }) + } + + return openaiResponse + } + } catch (error) { + // 检查是否是请求被中止 + if (error.name === 'CanceledError' || error.code === 'ECONNABORTED') { + logger.info('Gemini request was aborted by client') + const err = new Error('Request canceled by client') + err.status = 499 + err.error = { + message: 'Request canceled by client', + type: 'canceled', + code: 'request_canceled' + } + throw err + } + + logger.error('Gemini API request failed:', error.response?.data || error.message) + + // 转换错误格式 + if (error.response) { + const geminiError = error.response.data?.error + const err = new Error(geminiError?.message || 'Gemini API request failed') + err.status = error.response.status + err.error = { + message: geminiError?.message || 'Gemini API request failed', + type: geminiError?.code || 'api_error', + code: geminiError?.code + } + throw err + } + + const err = new Error(error.message) + err.status = 500 + err.error = { + message: error.message, + type: 'network_error' + } + throw err + } +} + +// 获取可用模型列表 +async function getAvailableModels(accessToken, proxy, projectId, location = 'us-central1') { + let apiUrl + if (projectId) { + // 使用项目特定的 URL 格式 + apiUrl = `${GEMINI_API_BASE}/projects/${projectId}/locations/${location}/models` + logger.debug(`Fetching models with projectId: ${projectId}, location: ${location}`) + } else { + // 使用标准 URL 格式 + apiUrl = `${GEMINI_API_BASE}/models` + logger.debug('Fetching models without projectId') + } + + const axiosConfig = { + method: 'GET', + url: apiUrl, + headers: { + Authorization: `Bearer ${accessToken}` + }, + timeout: config.requestTimeout || 600000 + } + + const proxyAgent = createProxyAgent(proxy) + if (proxyAgent) { + axiosConfig.httpsAgent = proxyAgent + logger.info( + `🌐 Using proxy for Gemini models request: ${ProxyHelper.getProxyDescription(proxy)}` + ) + } else { + logger.debug('🌐 No proxy configured for Gemini models request') + } + + try { + const response = await axios(axiosConfig) + const models = response.data.models || [] + + // 转换为 OpenAI 格式 + return models + .filter((model) => model.supportedGenerationMethods?.includes('generateContent')) + .map((model) => ({ + id: model.name.replace('models/', ''), + object: 'model', + created: Date.now() / 1000, + owned_by: 'google' + })) + } catch (error) { + logger.error('Failed to get Gemini models:', error) + // 返回默认模型列表 + return [ + { + id: 'gemini-2.0-flash-exp', + object: 'model', + created: Date.now() / 1000, + owned_by: 'google' + } + ] + } +} + +// Count Tokens API - 用于Gemini CLI兼容性 +async function countTokens({ + model, + content, + accessToken, + proxy, + projectId, + location = 'us-central1' +}) { + // 确保模型名称格式正确 + if (!model.startsWith('models/')) { + model = `models/${model}` + } + + // 转换内容格式 - 支持多种输入格式 + let requestBody + if (Array.isArray(content)) { + // 如果content是数组,直接使用 + requestBody = { contents: content } + } else if (typeof content === 'string') { + // 如果是字符串,转换为Gemini格式 + requestBody = { + contents: [ + { + parts: [{ text: content }] + } + ] + } + } else if (content.parts || content.role) { + // 如果已经是Gemini格式的单个content + requestBody = { contents: [content] } + } else { + // 其他情况,尝试直接使用 + requestBody = { contents: content } + } + + // 构建API URL - countTokens需要使用generativelanguage API + const GENERATIVE_API_BASE = 'https://generativelanguage.googleapis.com/v1beta' + let apiUrl + if (projectId) { + // 使用项目特定的 URL 格式(Google Cloud/Workspace 账号) + apiUrl = `${GENERATIVE_API_BASE}/projects/${projectId}/locations/${location}/${model}:countTokens` + logger.debug( + `Using project-specific countTokens URL with projectId: ${projectId}, location: ${location}` + ) + } else { + // 使用标准 URL 格式(个人 Google 账号) + apiUrl = `${GENERATIVE_API_BASE}/${model}:countTokens` + logger.debug('Using standard countTokens URL without projectId') + } + + const axiosConfig = { + method: 'POST', + url: apiUrl, + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'X-Goog-User-Project': projectId || undefined + }, + data: requestBody, + timeout: config.requestTimeout || 600000 + } + + // 添加代理配置 + const proxyAgent = createProxyAgent(proxy) + if (proxyAgent) { + axiosConfig.httpsAgent = proxyAgent + logger.info( + `🌐 Using proxy for Gemini countTokens request: ${ProxyHelper.getProxyDescription(proxy)}` + ) + } else { + logger.debug('🌐 No proxy configured for Gemini countTokens request') + } + + try { + logger.debug(`Sending countTokens request to: ${apiUrl}`) + logger.debug(`Request body: ${JSON.stringify(requestBody, null, 2)}`) + const response = await axios(axiosConfig) + + // 返回符合Gemini API格式的响应 + return { + totalTokens: response.data.totalTokens || 0, + totalBillableCharacters: response.data.totalBillableCharacters || 0, + ...response.data + } + } catch (error) { + logger.error(`Gemini countTokens API request failed for URL: ${apiUrl}`) + logger.error( + 'Request config:', + JSON.stringify( + { + url: apiUrl, + headers: axiosConfig.headers, + data: requestBody + }, + null, + 2 + ) + ) + logger.error('Error details:', error.response?.data || error.message) + + // 转换错误格式 + if (error.response) { + const geminiError = error.response.data?.error + const errorObj = new Error( + geminiError?.message || + `Gemini countTokens API request failed (Status: ${error.response.status})` + ) + errorObj.status = error.response.status + errorObj.error = { + message: + geminiError?.message || + `Gemini countTokens API request failed (Status: ${error.response.status})`, + type: geminiError?.code || 'api_error', + code: geminiError?.code + } + throw errorObj + } + + const errorObj = new Error(error.message) + errorObj.status = 500 + errorObj.error = { + message: error.message, + type: 'network_error' + } + throw errorObj + } +} + +module.exports = { + sendGeminiRequest, + getAvailableModels, + convertMessagesToGemini, + convertGeminiResponse, + countTokens +} diff --git a/src/services/ldapService.js b/src/services/ldapService.js new file mode 100644 index 0000000000000000000000000000000000000000..86fdb88da53cca880cc2d73f2b9fd44841f4bdd3 --- /dev/null +++ b/src/services/ldapService.js @@ -0,0 +1,753 @@ +const ldap = require('ldapjs') +const logger = require('../utils/logger') +const config = require('../../config/config') +const userService = require('./userService') + +class LdapService { + constructor() { + this.config = config.ldap || {} + this.client = null + + // 验证配置 - 只有在 LDAP 配置存在且启用时才验证 + if (this.config && this.config.enabled) { + this.validateConfiguration() + } + } + + // 🔍 验证LDAP配置 + validateConfiguration() { + const errors = [] + + if (!this.config.server) { + errors.push('LDAP server configuration is missing') + } else { + if (!this.config.server.url || typeof this.config.server.url !== 'string') { + errors.push('LDAP server URL is not configured or invalid') + } + + if (!this.config.server.bindDN || typeof this.config.server.bindDN !== 'string') { + errors.push('LDAP bind DN is not configured or invalid') + } + + if ( + !this.config.server.bindCredentials || + typeof this.config.server.bindCredentials !== 'string' + ) { + errors.push('LDAP bind credentials are not configured or invalid') + } + + if (!this.config.server.searchBase || typeof this.config.server.searchBase !== 'string') { + errors.push('LDAP search base is not configured or invalid') + } + + if (!this.config.server.searchFilter || typeof this.config.server.searchFilter !== 'string') { + errors.push('LDAP search filter is not configured or invalid') + } + } + + if (errors.length > 0) { + logger.error('❌ LDAP configuration validation failed:', errors) + // Don't throw error during initialization, just log warnings + logger.warn('⚠️ LDAP authentication may not work properly due to configuration errors') + } else { + logger.info('✅ LDAP configuration validation passed') + } + } + + // 🔍 提取LDAP条目的DN + extractDN(ldapEntry) { + if (!ldapEntry) { + return null + } + + // Try different ways to get the DN + let dn = null + + // Method 1: Direct dn property + if (ldapEntry.dn) { + ;({ dn } = ldapEntry) + } + // Method 2: objectName property (common in some LDAP implementations) + else if (ldapEntry.objectName) { + dn = ldapEntry.objectName + } + // Method 3: distinguishedName property + else if (ldapEntry.distinguishedName) { + dn = ldapEntry.distinguishedName + } + // Method 4: Check if the entry itself is a DN string + else if (typeof ldapEntry === 'string' && ldapEntry.includes('=')) { + dn = ldapEntry + } + + // Convert DN to string if it's an object + if (dn && typeof dn === 'object') { + if (dn.toString && typeof dn.toString === 'function') { + dn = dn.toString() + } else if (dn.dn && typeof dn.dn === 'string') { + ;({ dn } = dn) + } + } + + // Validate the DN format + if (typeof dn === 'string' && dn.trim() !== '' && dn.includes('=')) { + return dn.trim() + } + + return null + } + + // 🌐 从DN中提取域名,用于Windows AD UPN格式认证 + extractDomainFromDN(dnString) { + try { + if (!dnString || typeof dnString !== 'string') { + return null + } + + // 提取所有DC组件:DC=test,DC=demo,DC=com + const dcMatches = dnString.match(/DC=([^,]+)/gi) + if (!dcMatches || dcMatches.length === 0) { + return null + } + + // 提取DC值并连接成域名 + const domainParts = dcMatches.map((match) => { + const value = match.replace(/DC=/i, '').trim() + return value + }) + + if (domainParts.length > 0) { + const domain = domainParts.join('.') + logger.debug(`🌐 从DN提取域名: ${domain}`) + return domain + } + + return null + } catch (error) { + logger.debug('⚠️ 域名提取失败:', error.message) + return null + } + } + + // 🔗 创建LDAP客户端连接 + createClient() { + try { + const clientOptions = { + url: this.config.server.url, + timeout: this.config.server.timeout, + connectTimeout: this.config.server.connectTimeout, + reconnect: true + } + + // 如果使用 LDAPS (SSL/TLS),添加 TLS 选项 + if (this.config.server.url.toLowerCase().startsWith('ldaps://')) { + const tlsOptions = {} + + // 证书验证设置 + if (this.config.server.tls) { + if (typeof this.config.server.tls.rejectUnauthorized === 'boolean') { + tlsOptions.rejectUnauthorized = this.config.server.tls.rejectUnauthorized + } + + // CA 证书 + if (this.config.server.tls.ca) { + tlsOptions.ca = this.config.server.tls.ca + } + + // 客户端证书和私钥 (双向认证) + if (this.config.server.tls.cert) { + tlsOptions.cert = this.config.server.tls.cert + } + + if (this.config.server.tls.key) { + tlsOptions.key = this.config.server.tls.key + } + + // 服务器名称 (SNI) + if (this.config.server.tls.servername) { + tlsOptions.servername = this.config.server.tls.servername + } + } + + clientOptions.tlsOptions = tlsOptions + + logger.debug('🔒 Creating LDAPS client with TLS options:', { + url: this.config.server.url, + rejectUnauthorized: tlsOptions.rejectUnauthorized, + hasCA: !!tlsOptions.ca, + hasCert: !!tlsOptions.cert, + hasKey: !!tlsOptions.key, + servername: tlsOptions.servername + }) + } + + const client = ldap.createClient(clientOptions) + + // 设置错误处理 + client.on('error', (err) => { + if (err.code === 'CERT_HAS_EXPIRED' || err.code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE') { + logger.error('🔒 LDAP TLS certificate error:', { + code: err.code, + message: err.message, + hint: 'Consider setting LDAP_TLS_REJECT_UNAUTHORIZED=false for self-signed certificates' + }) + } else { + logger.error('🔌 LDAP client error:', err) + } + }) + + client.on('connect', () => { + if (this.config.server.url.toLowerCase().startsWith('ldaps://')) { + logger.info('🔒 LDAPS client connected successfully') + } else { + logger.info('🔗 LDAP client connected successfully') + } + }) + + client.on('connectTimeout', () => { + logger.warn('⏱️ LDAP connection timeout') + }) + + return client + } catch (error) { + logger.error('❌ Failed to create LDAP client:', error) + throw error + } + } + + // 🔒 绑定LDAP连接(管理员认证) + async bindClient(client) { + return new Promise((resolve, reject) => { + // 验证绑定凭据 + const { bindDN } = this.config.server + const { bindCredentials } = this.config.server + + if (!bindDN || typeof bindDN !== 'string') { + const error = new Error('LDAP bind DN is not configured or invalid') + logger.error('❌ LDAP configuration error:', error.message) + reject(error) + return + } + + if (!bindCredentials || typeof bindCredentials !== 'string') { + const error = new Error('LDAP bind credentials are not configured or invalid') + logger.error('❌ LDAP configuration error:', error.message) + reject(error) + return + } + + client.bind(bindDN, bindCredentials, (err) => { + if (err) { + logger.error('❌ LDAP bind failed:', err) + reject(err) + } else { + logger.debug('🔑 LDAP bind successful') + resolve() + } + }) + }) + } + + // 🔍 搜索用户 + async searchUser(client, username) { + return new Promise((resolve, reject) => { + // 防止LDAP注入:转义特殊字符 + // 根据RFC 4515,需要转义的特殊字符:* ( ) \ NUL + const escapedUsername = username + .replace(/\\/g, '\\5c') // 反斜杠必须先转义 + .replace(/\*/g, '\\2a') // 星号 + .replace(/\(/g, '\\28') // 左括号 + .replace(/\)/g, '\\29') // 右括号 + .replace(/\0/g, '\\00') // NUL字符 + .replace(/\//g, '\\2f') // 斜杠 + + const searchFilter = this.config.server.searchFilter.replace('{{username}}', escapedUsername) + const searchOptions = { + scope: 'sub', + filter: searchFilter, + attributes: this.config.server.searchAttributes + } + + logger.debug(`🔍 Searching for user: ${username} with filter: ${searchFilter}`) + + const entries = [] + + client.search(this.config.server.searchBase, searchOptions, (err, res) => { + if (err) { + logger.error('❌ LDAP search error:', err) + reject(err) + return + } + + res.on('searchEntry', (entry) => { + logger.debug('🔍 LDAP search entry received:', { + dn: entry.dn, + objectName: entry.objectName, + type: typeof entry.dn, + entryType: typeof entry, + hasAttributes: !!entry.attributes, + attributeCount: entry.attributes ? entry.attributes.length : 0 + }) + entries.push(entry) + }) + + res.on('searchReference', (referral) => { + logger.debug('🔗 LDAP search referral:', referral.uris) + }) + + res.on('error', (error) => { + logger.error('❌ LDAP search result error:', error) + reject(error) + }) + + res.on('end', (result) => { + logger.debug( + `✅ LDAP search completed. Status: ${result.status}, Found ${entries.length} entries` + ) + + if (entries.length === 0) { + resolve(null) + } else { + // Log the structure of the first entry for debugging + if (entries[0]) { + logger.debug('🔍 Full LDAP entry structure:', { + entryType: typeof entries[0], + entryConstructor: entries[0].constructor?.name, + entryKeys: Object.keys(entries[0]), + entryStringified: JSON.stringify(entries[0], null, 2).substring(0, 500) + }) + } + + if (entries.length === 1) { + resolve(entries[0]) + } else { + logger.warn(`⚠️ Multiple LDAP entries found for username: ${username}`) + resolve(entries[0]) // 使用第一个结果 + } + } + }) + }) + }) + } + + // 🔐 验证用户密码 + async authenticateUser(userDN, password) { + return new Promise((resolve, reject) => { + // 验证输入参数 + if (!userDN || typeof userDN !== 'string') { + const error = new Error('User DN is not provided or invalid') + logger.error('❌ LDAP authentication error:', error.message) + reject(error) + return + } + + if (!password || typeof password !== 'string') { + logger.debug(`🚫 Invalid or empty password for DN: ${userDN}`) + resolve(false) + return + } + + const authClient = this.createClient() + + authClient.bind(userDN, password, (err) => { + authClient.unbind() // 立即关闭认证客户端 + + if (err) { + if (err.name === 'InvalidCredentialsError') { + logger.debug(`🚫 Invalid credentials for DN: ${userDN}`) + resolve(false) + } else { + logger.error('❌ LDAP authentication error:', err) + reject(err) + } + } else { + logger.debug(`✅ Authentication successful for DN: ${userDN}`) + resolve(true) + } + }) + }) + } + + // 🔐 Windows AD兼容认证 - 在DN认证失败时尝试多种格式 + async tryWindowsADAuthentication(username, password) { + if (!username || !password) { + return false + } + + // 从searchBase提取域名 + const domain = this.extractDomainFromDN(this.config.server.searchBase) + + const adFormats = [] + + if (domain) { + // UPN格式(Windows AD标准) + adFormats.push(`${username}@${domain}`) + + // 如果域名有多个部分,也尝试简化版本 + const domainParts = domain.split('.') + if (domainParts.length > 1) { + adFormats.push(`${username}@${domainParts.slice(-2).join('.')}`) // 只取后两部分 + } + + // 域\用户名格式 + const firstDomainPart = domainParts[0] + if (firstDomainPart) { + adFormats.push(`${firstDomainPart}\\${username}`) + adFormats.push(`${firstDomainPart.toUpperCase()}\\${username}`) + } + } + + // 纯用户名(最后尝试) + adFormats.push(username) + + logger.info(`🔄 尝试 ${adFormats.length} 种Windows AD认证格式...`) + + for (const format of adFormats) { + try { + logger.info(`🔍 尝试格式: ${format}`) + const result = await this.tryDirectBind(format, password) + if (result) { + logger.info(`✅ Windows AD认证成功: ${format}`) + return true + } + logger.debug(`❌ 认证失败: ${format}`) + } catch (error) { + logger.debug(`认证异常 ${format}:`, error.message) + } + } + + logger.info(`🚫 所有Windows AD格式认证都失败了`) + return false + } + + // 🔐 直接尝试绑定认证的辅助方法 + async tryDirectBind(identifier, password) { + return new Promise((resolve, reject) => { + const authClient = this.createClient() + + authClient.bind(identifier, password, (err) => { + authClient.unbind() + + if (err) { + if (err.name === 'InvalidCredentialsError') { + resolve(false) + } else { + reject(err) + } + } else { + resolve(true) + } + }) + }) + } + + // 📝 提取用户信息 + extractUserInfo(ldapEntry, username) { + try { + const attributes = ldapEntry.attributes || [] + const userInfo = { username } + + // 创建属性映射 + const attrMap = {} + attributes.forEach((attr) => { + const name = attr.type || attr.name + const values = Array.isArray(attr.values) ? attr.values : [attr.values] + attrMap[name] = values.length === 1 ? values[0] : values + }) + + // 根据配置映射用户属性 + const mapping = this.config.userMapping + + userInfo.displayName = attrMap[mapping.displayName] || username + userInfo.email = attrMap[mapping.email] || '' + userInfo.firstName = attrMap[mapping.firstName] || '' + userInfo.lastName = attrMap[mapping.lastName] || '' + + // 如果没有displayName,尝试组合firstName和lastName + if (!userInfo.displayName || userInfo.displayName === username) { + if (userInfo.firstName || userInfo.lastName) { + userInfo.displayName = `${userInfo.firstName || ''} ${userInfo.lastName || ''}`.trim() + } + } + + logger.debug('📋 Extracted user info:', { + username: userInfo.username, + displayName: userInfo.displayName, + email: userInfo.email + }) + + return userInfo + } catch (error) { + logger.error('❌ Error extracting user info:', error) + return { username } + } + } + + // 🔍 验证和清理用户名 + validateAndSanitizeUsername(username) { + if (!username || typeof username !== 'string' || username.trim() === '') { + throw new Error('Username is required and must be a non-empty string') + } + + const trimmedUsername = username.trim() + + // 用户名只能包含字母、数字、下划线和连字符 + const usernameRegex = /^[a-zA-Z0-9_-]+$/ + if (!usernameRegex.test(trimmedUsername)) { + throw new Error('Username can only contain letters, numbers, underscores, and hyphens') + } + + // 长度限制 (防止过长的输入) + if (trimmedUsername.length > 64) { + throw new Error('Username cannot exceed 64 characters') + } + + // 不能以连字符开头或结尾 + if (trimmedUsername.startsWith('-') || trimmedUsername.endsWith('-')) { + throw new Error('Username cannot start or end with a hyphen') + } + + return trimmedUsername + } + + // 🔐 主要的登录验证方法 + async authenticateUserCredentials(username, password) { + if (!this.config.enabled) { + throw new Error('LDAP authentication is not enabled') + } + + // 验证和清理用户名 (防止LDAP注入) + const sanitizedUsername = this.validateAndSanitizeUsername(username) + + if (!password || typeof password !== 'string' || password.trim() === '') { + throw new Error('Password is required and must be a non-empty string') + } + + // 验证LDAP服务器配置 + if (!this.config.server || !this.config.server.url) { + throw new Error('LDAP server URL is not configured') + } + + if (!this.config.server.bindDN || typeof this.config.server.bindDN !== 'string') { + throw new Error('LDAP bind DN is not configured') + } + + if ( + !this.config.server.bindCredentials || + typeof this.config.server.bindCredentials !== 'string' + ) { + throw new Error('LDAP bind credentials are not configured') + } + + if (!this.config.server.searchBase || typeof this.config.server.searchBase !== 'string') { + throw new Error('LDAP search base is not configured') + } + + const client = this.createClient() + + try { + // 1. 使用管理员凭据绑定 + await this.bindClient(client) + + // 2. 搜索用户 (使用已验证的用户名) + const ldapEntry = await this.searchUser(client, sanitizedUsername) + if (!ldapEntry) { + logger.info(`🚫 User not found in LDAP: ${sanitizedUsername}`) + return { success: false, message: 'Invalid username or password' } + } + + // 3. 获取用户DN + logger.debug('🔍 LDAP entry details for DN extraction:', { + hasEntry: !!ldapEntry, + entryType: typeof ldapEntry, + entryKeys: Object.keys(ldapEntry || {}), + dn: ldapEntry.dn, + objectName: ldapEntry.objectName, + dnType: typeof ldapEntry.dn, + objectNameType: typeof ldapEntry.objectName + }) + + // Use the helper method to extract DN + const userDN = this.extractDN(ldapEntry) + + logger.debug(`👤 Extracted user DN: ${userDN} (type: ${typeof userDN})`) + + // 验证用户DN + if (!userDN) { + logger.error(`❌ Invalid or missing DN for user: ${sanitizedUsername}`, { + ldapEntryDn: ldapEntry.dn, + ldapEntryObjectName: ldapEntry.objectName, + ldapEntryType: typeof ldapEntry, + extractedDN: userDN + }) + return { success: false, message: 'Authentication service error' } + } + + // 4. 验证用户密码 - 支持传统LDAP和Windows AD + let isPasswordValid = false + + // 首先尝试传统的DN认证(保持原有LDAP逻辑) + try { + isPasswordValid = await this.authenticateUser(userDN, password) + if (isPasswordValid) { + logger.info(`✅ DN authentication successful for user: ${sanitizedUsername}`) + } + } catch (error) { + logger.debug( + `DN authentication failed for user: ${sanitizedUsername}, error: ${error.message}` + ) + } + + // 如果DN认证失败,尝试Windows AD多格式认证 + if (!isPasswordValid) { + logger.debug(`🔄 Trying Windows AD authentication formats for user: ${sanitizedUsername}`) + isPasswordValid = await this.tryWindowsADAuthentication(sanitizedUsername, password) + if (isPasswordValid) { + logger.info(`✅ Windows AD authentication successful for user: ${sanitizedUsername}`) + } + } + + if (!isPasswordValid) { + logger.info(`🚫 All authentication methods failed for user: ${sanitizedUsername}`) + return { success: false, message: 'Invalid username or password' } + } + + // 5. 提取用户信息 + const userInfo = this.extractUserInfo(ldapEntry, sanitizedUsername) + + // 6. 创建或更新本地用户 + const user = await userService.createOrUpdateUser(userInfo) + + // 7. 检查用户是否被禁用 + if (!user.isActive) { + logger.security( + `🔒 Disabled user LDAP login attempt: ${sanitizedUsername} from LDAP authentication` + ) + return { + success: false, + message: 'Your account has been disabled. Please contact administrator.' + } + } + + // 8. 记录登录 + await userService.recordUserLogin(user.id) + + // 9. 创建用户会话 + const sessionToken = await userService.createUserSession(user.id) + + logger.info(`✅ LDAP authentication successful for user: ${sanitizedUsername}`) + + return { + success: true, + user, + sessionToken, + message: 'Authentication successful' + } + } catch (error) { + // 记录详细错误供调试,但不向用户暴露 + logger.error('❌ LDAP authentication error:', { + username: sanitizedUsername, + error: error.message, + stack: process.env.NODE_ENV === 'development' ? error.stack : undefined + }) + + // 返回通用错误消息,避免信息泄露 + // 不要尝试解析具体的错误信息,因为不同LDAP服务器返回的格式不同 + return { + success: false, + message: 'Authentication service unavailable' + } + } finally { + // 确保客户端连接被关闭 + if (client) { + client.unbind((err) => { + if (err) { + logger.debug('Error unbinding LDAP client:', err) + } + }) + } + } + } + + // 🔍 测试LDAP连接 + async testConnection() { + if (!this.config.enabled) { + return { success: false, message: 'LDAP is not enabled' } + } + + const client = this.createClient() + + try { + await this.bindClient(client) + + return { + success: true, + message: 'LDAP connection successful', + server: this.config.server.url, + searchBase: this.config.server.searchBase + } + } catch (error) { + logger.error('❌ LDAP connection test failed:', { + error: error.message, + server: this.config.server.url, + stack: process.env.NODE_ENV === 'development' ? error.stack : undefined + }) + + // 提供通用错误消息,避免泄露系统细节 + let userMessage = 'LDAP connection failed' + + // 对于某些已知错误类型,提供有用但不泄露细节的信息 + if (error.code === 'ECONNREFUSED') { + userMessage = 'Unable to connect to LDAP server' + } else if (error.code === 'ETIMEDOUT') { + userMessage = 'LDAP server connection timeout' + } else if (error.name === 'InvalidCredentialsError') { + userMessage = 'LDAP bind credentials are invalid' + } + + return { + success: false, + message: userMessage, + server: this.config.server.url.replace(/:[^:]*@/, ':***@') // 隐藏密码部分 + } + } finally { + if (client) { + client.unbind((err) => { + if (err) { + logger.debug('Error unbinding test LDAP client:', err) + } + }) + } + } + } + + // 📊 获取LDAP配置信息(不包含敏感信息) + getConfigInfo() { + const configInfo = { + enabled: this.config.enabled, + server: { + url: this.config.server.url, + searchBase: this.config.server.searchBase, + searchFilter: this.config.server.searchFilter, + timeout: this.config.server.timeout, + connectTimeout: this.config.server.connectTimeout + }, + userMapping: this.config.userMapping + } + + // 添加 TLS 配置信息(不包含敏感数据) + if (this.config.server.url.toLowerCase().startsWith('ldaps://') && this.config.server.tls) { + configInfo.server.tls = { + rejectUnauthorized: this.config.server.tls.rejectUnauthorized, + hasCA: !!this.config.server.tls.ca, + hasCert: !!this.config.server.tls.cert, + hasKey: !!this.config.server.tls.key, + servername: this.config.server.tls.servername + } + } + + return configInfo + } +} + +module.exports = new LdapService() diff --git a/src/services/openaiAccountService.js b/src/services/openaiAccountService.js new file mode 100644 index 0000000000000000000000000000000000000000..b85425f136fd818629d92ecb2f5df3ade6354376 --- /dev/null +++ b/src/services/openaiAccountService.js @@ -0,0 +1,1276 @@ +const redisClient = require('../models/redis') +const { v4: uuidv4 } = require('uuid') +const crypto = require('crypto') +const axios = require('axios') +const ProxyHelper = require('../utils/proxyHelper') +const config = require('../../config/config') +const logger = require('../utils/logger') +// const { maskToken } = require('../utils/tokenMask') +const { + logRefreshStart, + logRefreshSuccess, + logRefreshError, + logTokenUsage, + logRefreshSkipped +} = require('../utils/tokenRefreshLogger') +const LRUCache = require('../utils/lruCache') +const tokenRefreshService = require('./tokenRefreshService') + +// 加密相关常量 +const ALGORITHM = 'aes-256-cbc' +const ENCRYPTION_SALT = 'openai-account-salt' +const IV_LENGTH = 16 + +// 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算 +// scryptSync 是 CPU 密集型操作,缓存可以减少 95%+ 的 CPU 占用 +let _encryptionKeyCache = null + +// 🔄 解密结果缓存,提高解密性能 +const decryptCache = new LRUCache(500) + +// 生成加密密钥(使用与 claudeAccountService 相同的方法) +function generateEncryptionKey() { + if (!_encryptionKeyCache) { + _encryptionKeyCache = crypto.scryptSync(config.security.encryptionKey, ENCRYPTION_SALT, 32) + logger.info('🔑 OpenAI encryption key derived and cached for performance optimization') + } + return _encryptionKeyCache +} + +// OpenAI 账户键前缀 +const OPENAI_ACCOUNT_KEY_PREFIX = 'openai:account:' +const SHARED_OPENAI_ACCOUNTS_KEY = 'shared_openai_accounts' +const ACCOUNT_SESSION_MAPPING_PREFIX = 'openai_session_account_mapping:' + +// 加密函数 +function encrypt(text) { + if (!text) { + return '' + } + const key = generateEncryptionKey() + const iv = crypto.randomBytes(IV_LENGTH) + const cipher = crypto.createCipheriv(ALGORITHM, key, iv) + let encrypted = cipher.update(text) + encrypted = Buffer.concat([encrypted, cipher.final()]) + return `${iv.toString('hex')}:${encrypted.toString('hex')}` +} + +// 解密函数 +function decrypt(text) { + if (!text || text === '') { + return '' + } + + // 检查是否是有效的加密格式(至少需要 32 个字符的 IV + 冒号 + 加密文本) + if (text.length < 33 || text.charAt(32) !== ':') { + logger.warn('Invalid encrypted text format, returning empty string', { + textLength: text ? text.length : 0, + char32: text && text.length > 32 ? text.charAt(32) : 'N/A', + first50: text ? text.substring(0, 50) : 'N/A' + }) + return '' + } + + // 🎯 检查缓存 + const cacheKey = crypto.createHash('sha256').update(text).digest('hex') + const cached = decryptCache.get(cacheKey) + if (cached !== undefined) { + return cached + } + + try { + const key = generateEncryptionKey() + // IV 是固定长度的 32 个十六进制字符(16 字节) + const ivHex = text.substring(0, 32) + const encryptedHex = text.substring(33) // 跳过冒号 + + const iv = Buffer.from(ivHex, 'hex') + const encryptedText = Buffer.from(encryptedHex, 'hex') + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv) + let decrypted = decipher.update(encryptedText) + decrypted = Buffer.concat([decrypted, decipher.final()]) + const result = decrypted.toString() + + // 💾 存入缓存(5分钟过期) + decryptCache.set(cacheKey, result, 5 * 60 * 1000) + + // 📊 定期打印缓存统计 + if ((decryptCache.hits + decryptCache.misses) % 1000 === 0) { + decryptCache.printStats() + } + + return result + } catch (error) { + logger.error('Decryption error:', error) + return '' + } +} + +// 🧹 定期清理缓存(每10分钟) +setInterval( + () => { + decryptCache.cleanup() + logger.info('🧹 OpenAI decrypt cache cleanup completed', decryptCache.getStats()) + }, + 10 * 60 * 1000 +) + +function toNumberOrNull(value) { + if (value === undefined || value === null || value === '') { + return null + } + + const num = Number(value) + return Number.isFinite(num) ? num : null +} + +function computeResetMeta(updatedAt, resetAfterSeconds) { + if (!updatedAt || resetAfterSeconds === null || resetAfterSeconds === undefined) { + return { + resetAt: null, + remainingSeconds: null + } + } + + const updatedMs = Date.parse(updatedAt) + if (Number.isNaN(updatedMs)) { + return { + resetAt: null, + remainingSeconds: null + } + } + + const resetMs = updatedMs + resetAfterSeconds * 1000 + return { + resetAt: new Date(resetMs).toISOString(), + remainingSeconds: Math.max(0, Math.round((resetMs - Date.now()) / 1000)) + } +} + +function buildCodexUsageSnapshot(accountData) { + const updatedAt = accountData.codexUsageUpdatedAt + + const primaryUsedPercent = toNumberOrNull(accountData.codexPrimaryUsedPercent) + const primaryResetAfterSeconds = toNumberOrNull(accountData.codexPrimaryResetAfterSeconds) + const primaryWindowMinutes = toNumberOrNull(accountData.codexPrimaryWindowMinutes) + const secondaryUsedPercent = toNumberOrNull(accountData.codexSecondaryUsedPercent) + const secondaryResetAfterSeconds = toNumberOrNull(accountData.codexSecondaryResetAfterSeconds) + const secondaryWindowMinutes = toNumberOrNull(accountData.codexSecondaryWindowMinutes) + const overSecondaryPercent = toNumberOrNull(accountData.codexPrimaryOverSecondaryLimitPercent) + + const hasPrimaryData = + primaryUsedPercent !== null || + primaryResetAfterSeconds !== null || + primaryWindowMinutes !== null + const hasSecondaryData = + secondaryUsedPercent !== null || + secondaryResetAfterSeconds !== null || + secondaryWindowMinutes !== null + + if (!updatedAt && !hasPrimaryData && !hasSecondaryData) { + return null + } + + const primaryMeta = computeResetMeta(updatedAt, primaryResetAfterSeconds) + const secondaryMeta = computeResetMeta(updatedAt, secondaryResetAfterSeconds) + + return { + updatedAt, + primary: { + usedPercent: primaryUsedPercent, + resetAfterSeconds: primaryResetAfterSeconds, + windowMinutes: primaryWindowMinutes, + resetAt: primaryMeta.resetAt, + remainingSeconds: primaryMeta.remainingSeconds + }, + secondary: { + usedPercent: secondaryUsedPercent, + resetAfterSeconds: secondaryResetAfterSeconds, + windowMinutes: secondaryWindowMinutes, + resetAt: secondaryMeta.resetAt, + remainingSeconds: secondaryMeta.remainingSeconds + }, + primaryOverSecondaryPercent: overSecondaryPercent + } +} + +function normalizeSubscriptionExpiresAt(value) { + if (value === undefined || value === null || value === '') { + return '' + } + + const date = value instanceof Date ? value : new Date(value) + if (Number.isNaN(date.getTime())) { + return '' + } + + return date.toISOString() +} + +// 刷新访问令牌 +async function refreshAccessToken(refreshToken, proxy = null) { + try { + // Codex CLI 的官方 CLIENT_ID + const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann' + + // 准备请求数据 + const requestData = new URLSearchParams({ + grant_type: 'refresh_token', + client_id: CLIENT_ID, + refresh_token: refreshToken, + scope: 'openid profile email' + }).toString() + + // 配置请求选项 + const requestOptions = { + method: 'POST', + url: 'https://auth.openai.com/oauth/token', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Length': requestData.length + }, + data: requestData, + timeout: config.requestTimeout || 600000 // 使用统一的请求超时配置 + } + + // 配置代理(如果有) + const proxyAgent = ProxyHelper.createProxyAgent(proxy) + if (proxyAgent) { + requestOptions.httpsAgent = proxyAgent + requestOptions.proxy = false + logger.info( + `🌐 Using proxy for OpenAI token refresh: ${ProxyHelper.getProxyDescription(proxy)}` + ) + } else { + logger.debug('🌐 No proxy configured for OpenAI token refresh') + } + + // 发送请求 + logger.info('🔍 发送 token 刷新请求,使用代理:', !!requestOptions.httpsAgent) + const response = await axios(requestOptions) + + if (response.status === 200 && response.data) { + const result = response.data + + logger.info('✅ Successfully refreshed OpenAI token') + + // 返回新的 token 信息 + return { + access_token: result.access_token, + id_token: result.id_token, + refresh_token: result.refresh_token || refreshToken, // 如果没有返回新的,保留原来的 + expires_in: result.expires_in || 3600, + expiry_date: Date.now() + (result.expires_in || 3600) * 1000 // 计算过期时间 + } + } else { + throw new Error(`Failed to refresh token: ${response.status} ${response.statusText}`) + } + } catch (error) { + if (error.response) { + // 服务器响应了错误状态码 + const errorData = error.response.data || {} + logger.error('OpenAI token refresh failed:', { + status: error.response.status, + data: errorData, + headers: error.response.headers + }) + + // 构建详细的错误信息 + let errorMessage = `OpenAI 服务器返回错误 (${error.response.status})` + + if (error.response.status === 400) { + if (errorData.error === 'invalid_grant') { + errorMessage = 'Refresh Token 无效或已过期,请重新授权' + } else if (errorData.error === 'invalid_request') { + errorMessage = `请求参数错误:${errorData.error_description || errorData.error}` + } else { + errorMessage = `请求错误:${errorData.error_description || errorData.error || '未知错误'}` + } + } else if (error.response.status === 401) { + errorMessage = '认证失败:Refresh Token 无效' + } else if (error.response.status === 403) { + errorMessage = '访问被拒绝:可能是 IP 被封或账户被禁用' + } else if (error.response.status === 429) { + errorMessage = '请求过于频繁,请稍后重试' + } else if (error.response.status >= 500) { + errorMessage = 'OpenAI 服务器内部错误,请稍后重试' + } else if (errorData.error_description) { + errorMessage = errorData.error_description + } else if (errorData.error) { + errorMessage = errorData.error + } else if (errorData.message) { + errorMessage = errorData.message + } + + const fullError = new Error(errorMessage) + fullError.status = error.response.status + fullError.details = errorData + throw fullError + } else if (error.request) { + // 请求已发出但没有收到响应 + logger.error('OpenAI token refresh no response:', error.message) + + let errorMessage = '无法连接到 OpenAI 服务器' + if (proxy) { + errorMessage += `(代理: ${ProxyHelper.getProxyDescription(proxy)})` + } + if (error.code === 'ECONNREFUSED') { + errorMessage += ' - 连接被拒绝' + } else if (error.code === 'ETIMEDOUT') { + errorMessage += ' - 连接超时' + } else if (error.code === 'ENOTFOUND') { + errorMessage += ' - 无法解析域名' + } else if (error.code === 'EPROTO') { + errorMessage += ' - 协议错误(可能是代理配置问题)' + } else if (error.message) { + errorMessage += ` - ${error.message}` + } + + const fullError = new Error(errorMessage) + fullError.code = error.code + throw fullError + } else { + // 设置请求时发生错误 + logger.error('OpenAI token refresh error:', error.message) + const fullError = new Error(`请求设置错误: ${error.message}`) + fullError.originalError = error + throw fullError + } + } +} + +// 检查 token 是否过期 +function isTokenExpired(account) { + if (!account.expiresAt) { + return false + } + return new Date(account.expiresAt) <= new Date() +} + +// 刷新账户的 access token(带分布式锁) +async function refreshAccountToken(accountId) { + let lockAcquired = false + let account = null + let accountName = accountId + + try { + account = await getAccount(accountId) + if (!account) { + throw new Error('Account not found') + } + + accountName = account.name || accountId + + // 检查是否有 refresh token + // account.refreshToken 在 getAccount 中已经被解密了,直接使用即可 + const refreshToken = account.refreshToken || null + + if (!refreshToken) { + logRefreshSkipped(accountId, accountName, 'openai', 'No refresh token available') + throw new Error('No refresh token available') + } + + // 尝试获取分布式锁 + lockAcquired = await tokenRefreshService.acquireRefreshLock(accountId, 'openai') + + if (!lockAcquired) { + // 如果无法获取锁,说明另一个进程正在刷新 + logger.info( + `🔒 Token refresh already in progress for OpenAI account: ${accountName} (${accountId})` + ) + logRefreshSkipped(accountId, accountName, 'openai', 'already_locked') + + // 等待一段时间后返回,期望其他进程已完成刷新 + await new Promise((resolve) => setTimeout(resolve, 2000)) + + // 重新获取账户数据(可能已被其他进程刷新) + const updatedAccount = await getAccount(accountId) + if (updatedAccount && !isTokenExpired(updatedAccount)) { + return { + access_token: decrypt(updatedAccount.accessToken), + id_token: updatedAccount.idToken, + refresh_token: updatedAccount.refreshToken, + expires_in: 3600, + expiry_date: new Date(updatedAccount.expiresAt).getTime() + } + } + + throw new Error('Token refresh in progress by another process') + } + + // 获取锁成功,开始刷新 + logRefreshStart(accountId, accountName, 'openai') + logger.info(`🔄 Starting token refresh for OpenAI account: ${accountName} (${accountId})`) + + // 获取代理配置 + let proxy = null + if (account.proxy) { + try { + proxy = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy + } catch (e) { + logger.warn(`Failed to parse proxy config for account ${accountId}:`, e) + } + } + + const newTokens = await refreshAccessToken(refreshToken, proxy) + if (!newTokens) { + throw new Error('Failed to refresh token') + } + + // 准备更新数据 - 不要在这里加密,让 updateAccount 统一处理 + const updates = { + accessToken: newTokens.access_token, // 不加密,让 updateAccount 处理 + expiresAt: new Date(newTokens.expiry_date).toISOString() + } + + // 如果有新的 ID token,也更新它(这对于首次未提供 ID Token 的账户特别重要) + if (newTokens.id_token) { + updates.idToken = newTokens.id_token // 不加密,让 updateAccount 处理 + + // 如果之前没有 ID Token,尝试解析并更新用户信息 + if (!account.idToken || account.idToken === '') { + try { + const idTokenParts = newTokens.id_token.split('.') + if (idTokenParts.length === 3) { + const payload = JSON.parse(Buffer.from(idTokenParts[1], 'base64').toString()) + const authClaims = payload['https://api.openai.com/auth'] || {} + + // 更新账户信息 - 使用正确的字段名 + // OpenAI ID Token中用户ID在chatgpt_account_id、chatgpt_user_id和user_id字段 + if (authClaims.chatgpt_account_id) { + updates.accountId = authClaims.chatgpt_account_id + } + if (authClaims.chatgpt_user_id) { + updates.chatgptUserId = authClaims.chatgpt_user_id + } else if (authClaims.user_id) { + // 有些情况下可能只有user_id字段 + updates.chatgptUserId = authClaims.user_id + } + if (authClaims.organizations?.[0]?.id) { + updates.organizationId = authClaims.organizations[0].id + } + if (authClaims.organizations?.[0]?.role) { + updates.organizationRole = authClaims.organizations[0].role + } + if (authClaims.organizations?.[0]?.title) { + updates.organizationTitle = authClaims.organizations[0].title + } + if (payload.email) { + updates.email = payload.email // 不加密,让 updateAccount 处理 + } + if (payload.email_verified !== undefined) { + updates.emailVerified = payload.email_verified + } + + logger.info(`Updated user info from ID Token for account ${accountId}`) + } + } catch (e) { + logger.warn(`Failed to parse ID Token for account ${accountId}:`, e) + } + } + } + + // 如果返回了新的 refresh token,更新它 + if (newTokens.refresh_token && newTokens.refresh_token !== refreshToken) { + updates.refreshToken = newTokens.refresh_token // 不加密,让 updateAccount 处理 + logger.info(`Updated refresh token for account ${accountId}`) + } + + // 更新账户信息 + await updateAccount(accountId, updates) + + logRefreshSuccess(accountId, accountName, 'openai', newTokens) // 传入完整的 newTokens 对象 + return newTokens + } catch (error) { + logRefreshError(accountId, account?.name || accountName, 'openai', error.message) + + // 发送 Webhook 通知(如果启用) + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: account?.name || accountName, + platform: 'openai', + status: 'error', + errorCode: 'OPENAI_TOKEN_REFRESH_FAILED', + reason: `Token refresh failed: ${error.message}`, + timestamp: new Date().toISOString() + }) + logger.info( + `📢 Webhook notification sent for OpenAI account ${account?.name || accountName} refresh failure` + ) + } catch (webhookError) { + logger.error('Failed to send webhook notification:', webhookError) + } + + throw error + } finally { + // 确保释放锁 + if (lockAcquired) { + await tokenRefreshService.releaseRefreshLock(accountId, 'openai') + logger.debug(`🔓 Released refresh lock for OpenAI account ${accountId}`) + } + } +} + +// 创建账户 +async function createAccount(accountData) { + const accountId = uuidv4() + const now = new Date().toISOString() + + // 处理OAuth数据 + let oauthData = {} + if (accountData.openaiOauth) { + oauthData = + typeof accountData.openaiOauth === 'string' + ? JSON.parse(accountData.openaiOauth) + : accountData.openaiOauth + } + + // 处理账户信息 + const accountInfo = accountData.accountInfo || {} + + const tokenExpiresAt = oauthData.expires_in + ? new Date(Date.now() + oauthData.expires_in * 1000).toISOString() + : '' + const subscriptionExpiresAt = normalizeSubscriptionExpiresAt( + accountData.subscriptionExpiresAt || accountInfo.subscriptionExpiresAt || '' + ) + + // 检查邮箱是否已经是加密格式(包含冒号分隔的32位十六进制字符) + const isEmailEncrypted = + accountInfo.email && accountInfo.email.length >= 33 && accountInfo.email.charAt(32) === ':' + + const account = { + id: accountId, + name: accountData.name, + description: accountData.description || '', + accountType: accountData.accountType || 'shared', + groupId: accountData.groupId || null, + priority: accountData.priority || 50, + rateLimitDuration: + accountData.rateLimitDuration !== undefined && accountData.rateLimitDuration !== null + ? accountData.rateLimitDuration + : 60, + // OAuth相关字段(加密存储) + // ID Token 现在是可选的,如果没有提供会在首次刷新时自动获取 + idToken: oauthData.idToken && oauthData.idToken.trim() ? encrypt(oauthData.idToken) : '', + accessToken: + oauthData.accessToken && oauthData.accessToken.trim() ? encrypt(oauthData.accessToken) : '', + refreshToken: + oauthData.refreshToken && oauthData.refreshToken.trim() + ? encrypt(oauthData.refreshToken) + : '', + openaiOauth: encrypt(JSON.stringify(oauthData)), + // 账户信息字段 - 确保所有字段都被保存,即使是空字符串 + accountId: accountInfo.accountId || '', + chatgptUserId: accountInfo.chatgptUserId || '', + organizationId: accountInfo.organizationId || '', + organizationRole: accountInfo.organizationRole || '', + organizationTitle: accountInfo.organizationTitle || '', + planType: accountInfo.planType || '', + // 邮箱字段:检查是否已经加密,避免双重加密 + email: isEmailEncrypted ? accountInfo.email : encrypt(accountInfo.email || ''), + emailVerified: accountInfo.emailVerified === true ? 'true' : 'false', + // 过期时间 + expiresAt: tokenExpiresAt, + subscriptionExpiresAt, + // 状态字段 + isActive: accountData.isActive !== false ? 'true' : 'false', + status: 'active', + schedulable: accountData.schedulable !== false ? 'true' : 'false', + lastRefresh: now, + createdAt: now, + updatedAt: now + } + + // 代理配置 + if (accountData.proxy) { + account.proxy = + typeof accountData.proxy === 'string' ? accountData.proxy : JSON.stringify(accountData.proxy) + } + + const client = redisClient.getClientSafe() + await client.hset(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, account) + + // 如果是共享账户,添加到共享账户集合 + if (account.accountType === 'shared') { + await client.sadd(SHARED_OPENAI_ACCOUNTS_KEY, accountId) + } + + logger.info(`Created OpenAI account: ${accountId}`) + return { + ...account, + subscriptionExpiresAt: account.subscriptionExpiresAt || null + } +} + +// 获取账户 +async function getAccount(accountId) { + const client = redisClient.getClientSafe() + const accountData = await client.hgetall(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`) + + if (!accountData || Object.keys(accountData).length === 0) { + return null + } + + // 解密敏感数据(仅用于内部处理,不返回给前端) + if (accountData.idToken) { + accountData.idToken = decrypt(accountData.idToken) + } + // 注意:accessToken 在 openaiRoutes.js 中会被单独解密,这里不解密 + // if (accountData.accessToken) { + // accountData.accessToken = decrypt(accountData.accessToken) + // } + if (accountData.refreshToken) { + accountData.refreshToken = decrypt(accountData.refreshToken) + } + if (accountData.email) { + accountData.email = decrypt(accountData.email) + } + if (accountData.openaiOauth) { + try { + accountData.openaiOauth = JSON.parse(decrypt(accountData.openaiOauth)) + } catch (e) { + accountData.openaiOauth = null + } + } + + // 解析代理配置 + if (accountData.proxy && typeof accountData.proxy === 'string') { + try { + accountData.proxy = JSON.parse(accountData.proxy) + } catch (e) { + accountData.proxy = null + } + } + + accountData.subscriptionExpiresAt = + accountData.subscriptionExpiresAt && accountData.subscriptionExpiresAt !== '' + ? accountData.subscriptionExpiresAt + : null + + return accountData +} + +// 更新账户 +async function updateAccount(accountId, updates) { + const existingAccount = await getAccount(accountId) + if (!existingAccount) { + throw new Error('Account not found') + } + + updates.updatedAt = new Date().toISOString() + + // 加密敏感数据 + if (updates.openaiOauth) { + const oauthData = + typeof updates.openaiOauth === 'string' + ? updates.openaiOauth + : JSON.stringify(updates.openaiOauth) + updates.openaiOauth = encrypt(oauthData) + } + if (updates.idToken) { + updates.idToken = encrypt(updates.idToken) + } + if (updates.accessToken) { + updates.accessToken = encrypt(updates.accessToken) + } + if (updates.refreshToken && updates.refreshToken.trim()) { + updates.refreshToken = encrypt(updates.refreshToken) + } + if (updates.email) { + updates.email = encrypt(updates.email) + } + + if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) { + updates.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(updates.subscriptionExpiresAt) + } + + // 处理代理配置 + if (updates.proxy) { + updates.proxy = + typeof updates.proxy === 'string' ? updates.proxy : JSON.stringify(updates.proxy) + } + + // 更新账户类型时处理共享账户集合 + const client = redisClient.getClientSafe() + if (updates.accountType && updates.accountType !== existingAccount.accountType) { + if (updates.accountType === 'shared') { + await client.sadd(SHARED_OPENAI_ACCOUNTS_KEY, accountId) + } else { + await client.srem(SHARED_OPENAI_ACCOUNTS_KEY, accountId) + } + } + + await client.hset(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, updates) + + logger.info(`Updated OpenAI account: ${accountId}`) + + // 合并更新后的账户数据 + const updatedAccount = { ...existingAccount, ...updates } + + // 返回时解析代理配置 + if (updatedAccount.proxy && typeof updatedAccount.proxy === 'string') { + try { + updatedAccount.proxy = JSON.parse(updatedAccount.proxy) + } catch (e) { + updatedAccount.proxy = null + } + } + + if (!updatedAccount.subscriptionExpiresAt) { + updatedAccount.subscriptionExpiresAt = null + } + + return updatedAccount +} + +// 删除账户 +async function deleteAccount(accountId) { + const account = await getAccount(accountId) + if (!account) { + throw new Error('Account not found') + } + + // 从 Redis 删除 + const client = redisClient.getClientSafe() + await client.del(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`) + + // 从共享账户集合中移除 + if (account.accountType === 'shared') { + await client.srem(SHARED_OPENAI_ACCOUNTS_KEY, accountId) + } + + // 清理会话映射 + const sessionMappings = await client.keys(`${ACCOUNT_SESSION_MAPPING_PREFIX}*`) + for (const key of sessionMappings) { + const mappedAccountId = await client.get(key) + if (mappedAccountId === accountId) { + await client.del(key) + } + } + + logger.info(`Deleted OpenAI account: ${accountId}`) + return true +} + +// 获取所有账户 +async function getAllAccounts() { + const client = redisClient.getClientSafe() + const keys = await client.keys(`${OPENAI_ACCOUNT_KEY_PREFIX}*`) + const accounts = [] + + for (const key of keys) { + const accountData = await client.hgetall(key) + if (accountData && Object.keys(accountData).length > 0) { + const codexUsage = buildCodexUsageSnapshot(accountData) + + // 解密敏感数据(但不返回给前端) + if (accountData.email) { + accountData.email = decrypt(accountData.email) + } + + // 先保存 refreshToken 是否存在的标记 + const hasRefreshTokenFlag = !!accountData.refreshToken + const maskedAccessToken = accountData.accessToken ? '[ENCRYPTED]' : '' + const maskedRefreshToken = accountData.refreshToken ? '[ENCRYPTED]' : '' + const maskedOauth = accountData.openaiOauth ? '[ENCRYPTED]' : '' + + // 屏蔽敏感信息(token等不应该返回给前端) + delete accountData.idToken + delete accountData.accessToken + delete accountData.refreshToken + delete accountData.openaiOauth + delete accountData.codexPrimaryUsedPercent + delete accountData.codexPrimaryResetAfterSeconds + delete accountData.codexPrimaryWindowMinutes + delete accountData.codexSecondaryUsedPercent + delete accountData.codexSecondaryResetAfterSeconds + delete accountData.codexSecondaryWindowMinutes + delete accountData.codexPrimaryOverSecondaryLimitPercent + // 时间戳改由 codexUsage.updatedAt 暴露 + delete accountData.codexUsageUpdatedAt + + // 获取限流状态信息 + const rateLimitInfo = await getAccountRateLimitInfo(accountData.id) + + // 解析代理配置 + if (accountData.proxy) { + try { + accountData.proxy = JSON.parse(accountData.proxy) + } catch (e) { + // 如果解析失败,设置为null + accountData.proxy = null + } + } + + const subscriptionExpiresAt = accountData.subscriptionExpiresAt || null + + // 不解密敏感字段,只返回基本信息 + accounts.push({ + ...accountData, + isActive: accountData.isActive === 'true', + schedulable: accountData.schedulable !== 'false', + openaiOauth: maskedOauth, + accessToken: maskedAccessToken, + refreshToken: maskedRefreshToken, + // 添加 scopes 字段用于判断认证方式 + // 处理空字符串的情况 + scopes: + accountData.scopes && accountData.scopes.trim() ? accountData.scopes.split(' ') : [], + // 添加 hasRefreshToken 标记 + hasRefreshToken: hasRefreshTokenFlag, + subscriptionExpiresAt, + // 添加限流状态信息(统一格式) + rateLimitStatus: rateLimitInfo + ? { + status: rateLimitInfo.status, + isRateLimited: rateLimitInfo.isRateLimited, + rateLimitedAt: rateLimitInfo.rateLimitedAt, + rateLimitResetAt: rateLimitInfo.rateLimitResetAt, + minutesRemaining: rateLimitInfo.minutesRemaining + } + : { + status: 'normal', + isRateLimited: false, + rateLimitedAt: null, + rateLimitResetAt: null, + minutesRemaining: 0 + }, + codexUsage + }) + } + } + + return accounts +} + +// 获取单个账户的概要信息(用于外部展示基本状态) +async function getAccountOverview(accountId) { + const client = redisClient.getClientSafe() + const accountData = await client.hgetall(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`) + + if (!accountData || Object.keys(accountData).length === 0) { + return null + } + + const codexUsage = buildCodexUsageSnapshot(accountData) + const rateLimitInfo = await getAccountRateLimitInfo(accountId) + + if (accountData.proxy) { + try { + accountData.proxy = JSON.parse(accountData.proxy) + } catch (error) { + accountData.proxy = null + } + } + + const scopes = + accountData.scopes && accountData.scopes.trim() ? accountData.scopes.split(' ') : [] + + return { + id: accountData.id, + accountType: accountData.accountType || 'shared', + platform: accountData.platform || 'openai', + isActive: accountData.isActive === 'true', + schedulable: accountData.schedulable !== 'false', + rateLimitStatus: rateLimitInfo || { + status: 'normal', + isRateLimited: false, + rateLimitedAt: null, + rateLimitResetAt: null, + minutesRemaining: 0 + }, + codexUsage, + scopes + } +} + +// 选择可用账户(支持专属和共享账户) +async function selectAvailableAccount(apiKeyId, sessionHash = null) { + // 首先检查是否有粘性会话 + const client = redisClient.getClientSafe() + if (sessionHash) { + const mappedAccountId = await client.get(`${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionHash}`) + + if (mappedAccountId) { + const account = await getAccount(mappedAccountId) + if (account && account.isActive === 'true' && !isTokenExpired(account)) { + logger.debug(`Using sticky session account: ${mappedAccountId}`) + return account + } + } + } + + // 获取 API Key 信息 + const apiKeyData = await client.hgetall(`api_key:${apiKeyId}`) + + // 检查是否绑定了 OpenAI 账户 + if (apiKeyData.openaiAccountId) { + const account = await getAccount(apiKeyData.openaiAccountId) + if (account && account.isActive === 'true') { + // 检查 token 是否过期 + const isExpired = isTokenExpired(account) + + // 记录token使用情况 + logTokenUsage(account.id, account.name, 'openai', account.expiresAt, isExpired) + + if (isExpired) { + await refreshAccountToken(account.id) + return await getAccount(account.id) + } + + // 创建粘性会话映射 + if (sessionHash) { + await client.setex( + `${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionHash}`, + 3600, // 1小时过期 + account.id + ) + } + + return account + } + } + + // 从共享账户池选择 + const sharedAccountIds = await client.smembers(SHARED_OPENAI_ACCOUNTS_KEY) + const availableAccounts = [] + + for (const accountId of sharedAccountIds) { + const account = await getAccount(accountId) + if (account && account.isActive === 'true' && !isRateLimited(account)) { + availableAccounts.push(account) + } + } + + if (availableAccounts.length === 0) { + throw new Error('No available OpenAI accounts') + } + + // 选择使用最少的账户 + const selectedAccount = availableAccounts.reduce((prev, curr) => { + const prevUsage = parseInt(prev.totalUsage || 0) + const currUsage = parseInt(curr.totalUsage || 0) + return prevUsage <= currUsage ? prev : curr + }) + + // 检查 token 是否过期 + if (isTokenExpired(selectedAccount)) { + await refreshAccountToken(selectedAccount.id) + return await getAccount(selectedAccount.id) + } + + // 创建粘性会话映射 + if (sessionHash) { + await client.setex( + `${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionHash}`, + 3600, // 1小时过期 + selectedAccount.id + ) + } + + return selectedAccount +} + +// 检查账户是否被限流 +function isRateLimited(account) { + if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) { + const limitedAt = new Date(account.rateLimitedAt).getTime() + const now = Date.now() + const limitDuration = 60 * 60 * 1000 // 1小时 + + return now < limitedAt + limitDuration + } + return false +} + +// 设置账户限流状态 +async function setAccountRateLimited(accountId, isLimited, resetsInSeconds = null) { + const updates = { + rateLimitStatus: isLimited ? 'limited' : 'normal', + rateLimitedAt: isLimited ? new Date().toISOString() : null, + // 限流时停止调度,解除限流时恢复调度 + schedulable: isLimited ? 'false' : 'true' + } + + // 如果提供了重置时间(秒数),计算重置时间戳 + if (isLimited && resetsInSeconds !== null && resetsInSeconds > 0) { + const resetTime = new Date(Date.now() + resetsInSeconds * 1000).toISOString() + updates.rateLimitResetAt = resetTime + logger.info( + `🕐 Account ${accountId} will be reset at ${resetTime} (in ${resetsInSeconds} seconds / ${Math.ceil(resetsInSeconds / 60)} minutes)` + ) + } else if (isLimited) { + // 如果没有提供重置时间,使用默认的60分钟 + const defaultResetSeconds = 60 * 60 // 1小时 + const resetTime = new Date(Date.now() + defaultResetSeconds * 1000).toISOString() + updates.rateLimitResetAt = resetTime + logger.warn( + `⚠️ No reset time provided for account ${accountId}, using default 60 minutes. Reset at ${resetTime}` + ) + } else if (!isLimited) { + updates.rateLimitResetAt = null + } + + await updateAccount(accountId, updates) + logger.info( + `Set rate limit status for OpenAI account ${accountId}: ${updates.rateLimitStatus}, schedulable: ${updates.schedulable}` + ) + + // 如果被限流,发送 Webhook 通知 + if (isLimited) { + try { + const account = await getAccount(accountId) + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: account.name || accountId, + platform: 'openai', + status: 'blocked', + errorCode: 'OPENAI_RATE_LIMITED', + reason: resetsInSeconds + ? `Account rate limited (429 error). Reset in ${Math.ceil(resetsInSeconds / 60)} minutes` + : 'Account rate limited (429 error). Estimated reset in 1 hour', + timestamp: new Date().toISOString() + }) + logger.info(`📢 Webhook notification sent for OpenAI account ${account.name} rate limit`) + } catch (webhookError) { + logger.error('Failed to send rate limit webhook notification:', webhookError) + } + } +} + +// 🚫 标记账户为未授权状态(401错误) +async function markAccountUnauthorized(accountId, reason = 'OpenAI账号认证失败(401错误)') { + const account = await getAccount(accountId) + if (!account) { + throw new Error('Account not found') + } + + const now = new Date().toISOString() + const currentCount = parseInt(account.unauthorizedCount || '0', 10) + const unauthorizedCount = Number.isFinite(currentCount) ? currentCount + 1 : 1 + + const updates = { + status: 'unauthorized', + schedulable: 'false', + errorMessage: reason, + unauthorizedAt: now, + unauthorizedCount: unauthorizedCount.toString() + } + + await updateAccount(accountId, updates) + logger.warn( + `🚫 Marked OpenAI account ${account.name || accountId} as unauthorized due to 401 error` + ) + + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: account.name || accountId, + platform: 'openai', + status: 'unauthorized', + errorCode: 'OPENAI_UNAUTHORIZED', + reason, + timestamp: now + }) + logger.info( + `📢 Webhook notification sent for OpenAI account ${account.name} unauthorized state` + ) + } catch (webhookError) { + logger.error('Failed to send unauthorized webhook notification:', webhookError) + } +} + +// 🔄 重置账户所有异常状态 +async function resetAccountStatus(accountId) { + const account = await getAccount(accountId) + if (!account) { + throw new Error('Account not found') + } + + const updates = { + // 根据是否有有效的 accessToken 来设置 status + status: account.accessToken ? 'active' : 'created', + // 恢复可调度状态 + schedulable: 'true', + // 清除错误相关字段 + errorMessage: null, + rateLimitedAt: null, + rateLimitStatus: 'normal', + rateLimitResetAt: null + } + + await updateAccount(accountId, updates) + logger.info(`✅ Reset all error status for OpenAI account ${accountId}`) + + // 发送 Webhook 通知 + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: account.name || accountId, + platform: 'openai', + status: 'recovered', + errorCode: 'STATUS_RESET', + reason: 'Account status manually reset', + timestamp: new Date().toISOString() + }) + logger.info(`📢 Webhook notification sent for OpenAI account ${account.name} status reset`) + } catch (webhookError) { + logger.error('Failed to send status reset webhook notification:', webhookError) + } + + return { success: true, message: 'Account status reset successfully' } +} + +// 切换账户调度状态 +async function toggleSchedulable(accountId) { + const account = await getAccount(accountId) + if (!account) { + throw new Error('Account not found') + } + + // 切换调度状态 + const newSchedulable = account.schedulable === 'false' ? 'true' : 'false' + + await updateAccount(accountId, { + schedulable: newSchedulable + }) + + logger.info(`Toggled schedulable status for OpenAI account ${accountId}: ${newSchedulable}`) + + return { + success: true, + schedulable: newSchedulable === 'true' + } +} + +// 获取账户限流信息 +async function getAccountRateLimitInfo(accountId) { + const account = await getAccount(accountId) + if (!account) { + return null + } + + const status = account.rateLimitStatus || 'normal' + const rateLimitedAt = account.rateLimitedAt || null + const rateLimitResetAt = account.rateLimitResetAt || null + + if (status === 'limited') { + const now = Date.now() + let remainingTime = 0 + + if (rateLimitResetAt) { + const resetAt = new Date(rateLimitResetAt).getTime() + remainingTime = Math.max(0, resetAt - now) + } else if (rateLimitedAt) { + const limitedAt = new Date(rateLimitedAt).getTime() + const limitDuration = 60 * 60 * 1000 // 默认1小时 + remainingTime = Math.max(0, limitedAt + limitDuration - now) + } + + const minutesRemaining = remainingTime > 0 ? Math.ceil(remainingTime / (60 * 1000)) : 0 + + return { + status, + isRateLimited: minutesRemaining > 0, + rateLimitedAt, + rateLimitResetAt, + minutesRemaining + } + } + + return { + status, + isRateLimited: false, + rateLimitedAt, + rateLimitResetAt, + minutesRemaining: 0 + } +} + +// 更新账户使用统计(tokens参数可选,默认为0,仅更新最后使用时间) +async function updateAccountUsage(accountId, tokens = 0) { + const account = await getAccount(accountId) + if (!account) { + return + } + + const updates = { + lastUsedAt: new Date().toISOString() + } + + // 如果有 tokens 参数且大于0,同时更新使用统计 + if (tokens > 0) { + const totalUsage = parseInt(account.totalUsage || 0) + tokens + updates.totalUsage = totalUsage.toString() + } + + await updateAccount(accountId, updates) +} + +// 为了兼容性,保留recordUsage作为updateAccountUsage的别名 +const recordUsage = updateAccountUsage + +async function updateCodexUsageSnapshot(accountId, usageSnapshot) { + if (!usageSnapshot || typeof usageSnapshot !== 'object') { + return + } + + const fieldMap = { + primaryUsedPercent: 'codexPrimaryUsedPercent', + primaryResetAfterSeconds: 'codexPrimaryResetAfterSeconds', + primaryWindowMinutes: 'codexPrimaryWindowMinutes', + secondaryUsedPercent: 'codexSecondaryUsedPercent', + secondaryResetAfterSeconds: 'codexSecondaryResetAfterSeconds', + secondaryWindowMinutes: 'codexSecondaryWindowMinutes', + primaryOverSecondaryPercent: 'codexPrimaryOverSecondaryLimitPercent' + } + + const updates = {} + let hasPayload = false + + for (const [key, field] of Object.entries(fieldMap)) { + if (usageSnapshot[key] !== undefined && usageSnapshot[key] !== null) { + updates[field] = String(usageSnapshot[key]) + hasPayload = true + } + } + + if (!hasPayload) { + return + } + + updates.codexUsageUpdatedAt = new Date().toISOString() + + const client = redisClient.getClientSafe() + await client.hset(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, updates) +} + +module.exports = { + createAccount, + getAccount, + getAccountOverview, + updateAccount, + deleteAccount, + getAllAccounts, + selectAvailableAccount, + refreshAccountToken, + isTokenExpired, + setAccountRateLimited, + markAccountUnauthorized, + resetAccountStatus, + toggleSchedulable, + getAccountRateLimitInfo, + updateAccountUsage, + recordUsage, // 别名,指向updateAccountUsage + updateCodexUsageSnapshot, + encrypt, + decrypt, + generateEncryptionKey, + decryptCache // 暴露缓存对象以便测试和监控 +} diff --git a/src/services/openaiResponsesAccountService.js b/src/services/openaiResponsesAccountService.js new file mode 100644 index 0000000000000000000000000000000000000000..2a67f83db11968ddf6c3dcf8784581cccb5d95f8 --- /dev/null +++ b/src/services/openaiResponsesAccountService.js @@ -0,0 +1,648 @@ +const { v4: uuidv4 } = require('uuid') +const crypto = require('crypto') +const redis = require('../models/redis') +const logger = require('../utils/logger') +const config = require('../../config/config') +const LRUCache = require('../utils/lruCache') + +function normalizeSubscriptionExpiresAt(value) { + if (value === undefined || value === null || value === '') { + return '' + } + + const date = value instanceof Date ? value : new Date(value) + if (Number.isNaN(date.getTime())) { + return '' + } + + return date.toISOString() +} + +class OpenAIResponsesAccountService { + constructor() { + // 加密相关常量 + this.ENCRYPTION_ALGORITHM = 'aes-256-cbc' + this.ENCRYPTION_SALT = 'openai-responses-salt' + + // Redis 键前缀 + this.ACCOUNT_KEY_PREFIX = 'openai_responses_account:' + this.SHARED_ACCOUNTS_KEY = 'shared_openai_responses_accounts' + + // 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算 + this._encryptionKeyCache = null + + // 🔄 解密结果缓存,提高解密性能 + this._decryptCache = new LRUCache(500) + + // 🧹 定期清理缓存(每10分钟) + setInterval( + () => { + this._decryptCache.cleanup() + logger.info( + '🧹 OpenAI-Responses decrypt cache cleanup completed', + this._decryptCache.getStats() + ) + }, + 10 * 60 * 1000 + ) + } + + // 创建账户 + async createAccount(options = {}) { + const { + name = 'OpenAI Responses Account', + description = '', + baseApi = '', // 必填:API 基础地址 + apiKey = '', // 必填:API 密钥 + userAgent = '', // 可选:自定义 User-Agent,空则透传原始请求 + priority = 50, // 调度优先级 (1-100) + proxy = null, + isActive = true, + accountType = 'shared', // 'dedicated' or 'shared' + schedulable = true, // 是否可被调度 + dailyQuota = 0, // 每日额度限制(美元),0表示不限制 + quotaResetTime = '00:00', // 额度重置时间(HH:mm格式) + rateLimitDuration = 60, // 限流时间(分钟) + subscriptionExpiresAt = null + } = options + + // 验证必填字段 + if (!baseApi || !apiKey) { + throw new Error('Base API URL and API Key are required for OpenAI-Responses account') + } + + // 规范化 baseApi(确保不以 / 结尾) + const normalizedBaseApi = baseApi.endsWith('/') ? baseApi.slice(0, -1) : baseApi + + const accountId = uuidv4() + + const accountData = { + id: accountId, + platform: 'openai-responses', + name, + description, + baseApi: normalizedBaseApi, + apiKey: this._encryptSensitiveData(apiKey), + userAgent, + priority: priority.toString(), + proxy: proxy ? JSON.stringify(proxy) : '', + isActive: isActive.toString(), + accountType, + schedulable: schedulable.toString(), + createdAt: new Date().toISOString(), + lastUsedAt: '', + status: 'active', + errorMessage: '', + // 限流相关 + rateLimitedAt: '', + rateLimitStatus: '', + rateLimitDuration: rateLimitDuration.toString(), + // 额度管理 + dailyQuota: dailyQuota.toString(), + dailyUsage: '0', + lastResetDate: redis.getDateStringInTimezone(), + quotaResetTime, + quotaStoppedAt: '', + subscriptionExpiresAt: normalizeSubscriptionExpiresAt(subscriptionExpiresAt) + } + + // 保存到 Redis + await this._saveAccount(accountId, accountData) + + logger.success(`🚀 Created OpenAI-Responses account: ${name} (${accountId})`) + + return { + ...accountData, + subscriptionExpiresAt: accountData.subscriptionExpiresAt || null, + apiKey: '***' // 返回时隐藏敏感信息 + } + } + + // 获取账户 + async getAccount(accountId) { + const client = redis.getClientSafe() + const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}` + const accountData = await client.hgetall(key) + + if (!accountData || !accountData.id) { + return null + } + + // 解密敏感数据 + accountData.apiKey = this._decryptSensitiveData(accountData.apiKey) + + // 解析 JSON 字段 + if (accountData.proxy) { + try { + accountData.proxy = JSON.parse(accountData.proxy) + } catch (e) { + accountData.proxy = null + } + } + + accountData.subscriptionExpiresAt = + accountData.subscriptionExpiresAt && accountData.subscriptionExpiresAt !== '' + ? accountData.subscriptionExpiresAt + : null + + return accountData + } + + // 更新账户 + async updateAccount(accountId, updates) { + const account = await this.getAccount(accountId) + if (!account) { + throw new Error('Account not found') + } + + // 处理敏感字段加密 + if (updates.apiKey) { + updates.apiKey = this._encryptSensitiveData(updates.apiKey) + } + + // 处理 JSON 字段 + if (updates.proxy !== undefined) { + updates.proxy = updates.proxy ? JSON.stringify(updates.proxy) : '' + } + + // 规范化 baseApi + if (updates.baseApi) { + updates.baseApi = updates.baseApi.endsWith('/') + ? updates.baseApi.slice(0, -1) + : updates.baseApi + } + + if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) { + updates.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(updates.subscriptionExpiresAt) + } else if (Object.prototype.hasOwnProperty.call(updates, 'expiresAt')) { + updates.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(updates.expiresAt) + delete updates.expiresAt + } + + // 更新 Redis + const client = redis.getClientSafe() + const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}` + await client.hset(key, updates) + + logger.info(`📝 Updated OpenAI-Responses account: ${account.name}`) + + return { success: true } + } + + // 删除账户 + async deleteAccount(accountId) { + const client = redis.getClientSafe() + const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}` + + // 从共享账户列表中移除 + await client.srem(this.SHARED_ACCOUNTS_KEY, accountId) + + // 删除账户数据 + await client.del(key) + + logger.info(`🗑️ Deleted OpenAI-Responses account: ${accountId}`) + + return { success: true } + } + + // 获取所有账户 + async getAllAccounts(includeInactive = false) { + const client = redis.getClientSafe() + const accountIds = await client.smembers(this.SHARED_ACCOUNTS_KEY) + const accounts = [] + + for (const accountId of accountIds) { + const account = await this.getAccount(accountId) + if (account) { + // 过滤非活跃账户 + if (includeInactive || account.isActive === 'true') { + // 隐藏敏感信息 + account.apiKey = '***' + + // 获取限流状态信息(与普通OpenAI账号保持一致的格式) + const rateLimitInfo = this._getRateLimitInfo(account) + + // 格式化 rateLimitStatus 为对象(与普通 OpenAI 账号一致) + account.rateLimitStatus = rateLimitInfo.isRateLimited + ? { + isRateLimited: true, + rateLimitedAt: account.rateLimitedAt || null, + minutesRemaining: rateLimitInfo.remainingMinutes || 0 + } + : { + isRateLimited: false, + rateLimitedAt: null, + minutesRemaining: 0 + } + + // 转换 schedulable 字段为布尔值(前端需要布尔值来判断) + account.schedulable = account.schedulable !== 'false' + // 转换 isActive 字段为布尔值 + account.isActive = account.isActive === 'true' + + accounts.push(account) + } + } + } + + // 直接从 Redis 获取所有账户(包括非共享账户) + const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`) + for (const key of keys) { + const accountId = key.replace(this.ACCOUNT_KEY_PREFIX, '') + if (!accountIds.includes(accountId)) { + const accountData = await client.hgetall(key) + if (accountData && accountData.id) { + // 过滤非活跃账户 + if (includeInactive || accountData.isActive === 'true') { + // 隐藏敏感信息 + accountData.apiKey = '***' + // 解析 JSON 字段 + if (accountData.proxy) { + try { + accountData.proxy = JSON.parse(accountData.proxy) + } catch (e) { + accountData.proxy = null + } + } + + // 获取限流状态信息(与普通OpenAI账号保持一致的格式) + const rateLimitInfo = this._getRateLimitInfo(accountData) + + // 格式化 rateLimitStatus 为对象(与普通 OpenAI 账号一致) + accountData.rateLimitStatus = rateLimitInfo.isRateLimited + ? { + isRateLimited: true, + rateLimitedAt: accountData.rateLimitedAt || null, + minutesRemaining: rateLimitInfo.remainingMinutes || 0 + } + : { + isRateLimited: false, + rateLimitedAt: null, + minutesRemaining: 0 + } + + // 转换 schedulable 字段为布尔值(前端需要布尔值来判断) + accountData.schedulable = accountData.schedulable !== 'false' + // 转换 isActive 字段为布尔值 + accountData.isActive = accountData.isActive === 'true' + accountData.subscriptionExpiresAt = + accountData.subscriptionExpiresAt && accountData.subscriptionExpiresAt !== '' + ? accountData.subscriptionExpiresAt + : null + + accounts.push(accountData) + } + } + } + } + + return accounts + } + + // 标记账户限流 + async markAccountRateLimited(accountId, duration = null) { + const account = await this.getAccount(accountId) + if (!account) { + return + } + + const rateLimitDuration = duration || parseInt(account.rateLimitDuration) || 60 + const now = new Date() + const resetAt = new Date(now.getTime() + rateLimitDuration * 60000) + + await this.updateAccount(accountId, { + rateLimitedAt: now.toISOString(), + rateLimitStatus: 'limited', + rateLimitResetAt: resetAt.toISOString(), + rateLimitDuration: rateLimitDuration.toString(), + status: 'rateLimited', + schedulable: 'false', // 防止被调度 + errorMessage: `Rate limited until ${resetAt.toISOString()}` + }) + + logger.warn( + `⏳ Account ${account.name} marked as rate limited for ${rateLimitDuration} minutes (until ${resetAt.toISOString()})` + ) + } + + // 🚫 标记账户为未授权状态(401错误) + async markAccountUnauthorized(accountId, reason = 'OpenAI Responses账号认证失败(401错误)') { + const account = await this.getAccount(accountId) + if (!account) { + return + } + + const now = new Date().toISOString() + const currentCount = parseInt(account.unauthorizedCount || '0', 10) + const unauthorizedCount = Number.isFinite(currentCount) ? currentCount + 1 : 1 + + await this.updateAccount(accountId, { + status: 'unauthorized', + schedulable: 'false', + errorMessage: reason, + unauthorizedAt: now, + unauthorizedCount: unauthorizedCount.toString() + }) + + logger.warn( + `🚫 OpenAI-Responses account ${account.name || accountId} marked as unauthorized due to 401 error` + ) + + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: account.name || accountId, + platform: 'openai', + status: 'unauthorized', + errorCode: 'OPENAI_UNAUTHORIZED', + reason, + timestamp: now + }) + logger.info( + `📢 Webhook notification sent for OpenAI-Responses account ${account.name || accountId} unauthorized state` + ) + } catch (webhookError) { + logger.error('Failed to send unauthorized webhook notification:', webhookError) + } + } + + // 检查并清除过期的限流状态 + async checkAndClearRateLimit(accountId) { + const account = await this.getAccount(accountId) + if (!account || account.rateLimitStatus !== 'limited') { + return false + } + + const now = new Date() + let shouldClear = false + + // 优先使用 rateLimitResetAt 字段 + if (account.rateLimitResetAt) { + const resetAt = new Date(account.rateLimitResetAt) + shouldClear = now >= resetAt + } else { + // 如果没有 rateLimitResetAt,使用旧的逻辑 + const rateLimitedAt = new Date(account.rateLimitedAt) + const rateLimitDuration = parseInt(account.rateLimitDuration) || 60 + shouldClear = now - rateLimitedAt > rateLimitDuration * 60000 + } + + if (shouldClear) { + // 限流已过期,清除状态 + await this.updateAccount(accountId, { + rateLimitedAt: '', + rateLimitStatus: '', + rateLimitResetAt: '', + status: 'active', + schedulable: 'true', // 恢复调度 + errorMessage: '' + }) + + logger.info(`✅ Rate limit cleared for account ${account.name}`) + return true + } + + return false + } + + // 切换调度状态 + async toggleSchedulable(accountId) { + const account = await this.getAccount(accountId) + if (!account) { + throw new Error('Account not found') + } + + const newSchedulableStatus = account.schedulable === 'true' ? 'false' : 'true' + await this.updateAccount(accountId, { + schedulable: newSchedulableStatus + }) + + logger.info( + `🔄 Toggled schedulable status for account ${account.name}: ${newSchedulableStatus}` + ) + + return { + success: true, + schedulable: newSchedulableStatus === 'true' + } + } + + // 更新使用额度 + async updateUsageQuota(accountId, amount) { + const account = await this.getAccount(accountId) + if (!account) { + return + } + + // 检查是否需要重置额度 + const today = redis.getDateStringInTimezone() + if (account.lastResetDate !== today) { + // 重置额度 + await this.updateAccount(accountId, { + dailyUsage: amount.toString(), + lastResetDate: today, + quotaStoppedAt: '' + }) + } else { + // 累加使用额度 + const currentUsage = parseFloat(account.dailyUsage) || 0 + const newUsage = currentUsage + amount + const dailyQuota = parseFloat(account.dailyQuota) || 0 + + const updates = { + dailyUsage: newUsage.toString() + } + + // 检查是否超出额度 + if (dailyQuota > 0 && newUsage >= dailyQuota) { + updates.status = 'quotaExceeded' + updates.quotaStoppedAt = new Date().toISOString() + updates.errorMessage = `Daily quota exceeded: $${newUsage.toFixed(2)} / $${dailyQuota.toFixed(2)}` + logger.warn(`💸 Account ${account.name} exceeded daily quota`) + } + + await this.updateAccount(accountId, updates) + } + } + + // 更新账户使用统计(记录 token 使用量) + async updateAccountUsage(accountId, tokens = 0) { + const account = await this.getAccount(accountId) + if (!account) { + return + } + + const updates = { + lastUsedAt: new Date().toISOString() + } + + // 如果有 tokens 参数且大于0,同时更新使用统计 + if (tokens > 0) { + const currentTokens = parseInt(account.totalUsedTokens) || 0 + updates.totalUsedTokens = (currentTokens + tokens).toString() + } + + await this.updateAccount(accountId, updates) + } + + // 记录使用量(为了兼容性的别名) + async recordUsage(accountId, tokens = 0) { + return this.updateAccountUsage(accountId, tokens) + } + + // 重置账户状态(清除所有异常状态) + async resetAccountStatus(accountId) { + const account = await this.getAccount(accountId) + if (!account) { + throw new Error('Account not found') + } + + const updates = { + // 根据是否有有效的 apiKey 来设置 status + status: account.apiKey ? 'active' : 'created', + // 恢复可调度状态 + schedulable: 'true', + // 清除错误相关字段 + errorMessage: '', + rateLimitedAt: '', + rateLimitStatus: '', + rateLimitResetAt: '', + rateLimitDuration: '' + } + + await this.updateAccount(accountId, updates) + logger.info(`✅ Reset all error status for OpenAI-Responses account ${accountId}`) + + // 发送 Webhook 通知 + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: account.name || accountId, + platform: 'openai-responses', + status: 'recovered', + errorCode: 'STATUS_RESET', + reason: 'Account status manually reset', + timestamp: new Date().toISOString() + }) + logger.info( + `📢 Webhook notification sent for OpenAI-Responses account ${account.name} status reset` + ) + } catch (webhookError) { + logger.error('Failed to send status reset webhook notification:', webhookError) + } + + return { success: true, message: 'Account status reset successfully' } + } + + // 获取限流信息 + _getRateLimitInfo(accountData) { + if (accountData.rateLimitStatus !== 'limited') { + return { isRateLimited: false } + } + + const now = new Date() + let willBeAvailableAt + let remainingMinutes + + // 优先使用 rateLimitResetAt 字段 + if (accountData.rateLimitResetAt) { + willBeAvailableAt = new Date(accountData.rateLimitResetAt) + remainingMinutes = Math.max(0, Math.ceil((willBeAvailableAt - now) / 60000)) + } else { + // 如果没有 rateLimitResetAt,使用旧的逻辑 + const rateLimitedAt = new Date(accountData.rateLimitedAt) + const rateLimitDuration = parseInt(accountData.rateLimitDuration) || 60 + const elapsedMinutes = Math.floor((now - rateLimitedAt) / 60000) + remainingMinutes = Math.max(0, rateLimitDuration - elapsedMinutes) + willBeAvailableAt = new Date(rateLimitedAt.getTime() + rateLimitDuration * 60000) + } + + return { + isRateLimited: remainingMinutes > 0, + remainingMinutes, + willBeAvailableAt + } + } + + // 加密敏感数据 + _encryptSensitiveData(text) { + if (!text) { + return '' + } + + const key = this._getEncryptionKey() + const iv = crypto.randomBytes(16) + const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv) + + let encrypted = cipher.update(text) + encrypted = Buffer.concat([encrypted, cipher.final()]) + + return `${iv.toString('hex')}:${encrypted.toString('hex')}` + } + + // 解密敏感数据 + _decryptSensitiveData(text) { + if (!text || text === '') { + return '' + } + + // 检查缓存 + const cacheKey = crypto.createHash('sha256').update(text).digest('hex') + const cached = this._decryptCache.get(cacheKey) + if (cached !== undefined) { + return cached + } + + try { + const key = this._getEncryptionKey() + const [ivHex, encryptedHex] = text.split(':') + + const iv = Buffer.from(ivHex, 'hex') + const encryptedText = Buffer.from(encryptedHex, 'hex') + + const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv) + let decrypted = decipher.update(encryptedText) + decrypted = Buffer.concat([decrypted, decipher.final()]) + + const result = decrypted.toString() + + // 存入缓存(5分钟过期) + this._decryptCache.set(cacheKey, result, 5 * 60 * 1000) + + return result + } catch (error) { + logger.error('Decryption error:', error) + return '' + } + } + + // 获取加密密钥 + _getEncryptionKey() { + if (!this._encryptionKeyCache) { + this._encryptionKeyCache = crypto.scryptSync( + config.security.encryptionKey, + this.ENCRYPTION_SALT, + 32 + ) + } + return this._encryptionKeyCache + } + + // 保存账户到 Redis + async _saveAccount(accountId, accountData) { + const client = redis.getClientSafe() + const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}` + + // 保存账户数据 + await client.hset(key, accountData) + + // 添加到共享账户列表 + if (accountData.accountType === 'shared') { + await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId) + } + } +} + +module.exports = new OpenAIResponsesAccountService() diff --git a/src/services/openaiResponsesRelayService.js b/src/services/openaiResponsesRelayService.js new file mode 100644 index 0000000000000000000000000000000000000000..433242e600c4bd2c91bc89fe67e98298f303819f --- /dev/null +++ b/src/services/openaiResponsesRelayService.js @@ -0,0 +1,864 @@ +const axios = require('axios') +const ProxyHelper = require('../utils/proxyHelper') +const logger = require('../utils/logger') +const openaiResponsesAccountService = require('./openaiResponsesAccountService') +const apiKeyService = require('./apiKeyService') +const unifiedOpenAIScheduler = require('./unifiedOpenAIScheduler') +const config = require('../../config/config') +const crypto = require('crypto') + +// 抽取缓存写入 token,兼容多种字段命名 +function extractCacheCreationTokens(usageData) { + if (!usageData || typeof usageData !== 'object') { + return 0 + } + + const details = usageData.input_tokens_details || usageData.prompt_tokens_details || {} + const candidates = [ + details.cache_creation_input_tokens, + details.cache_creation_tokens, + usageData.cache_creation_input_tokens, + usageData.cache_creation_tokens + ] + + for (const value of candidates) { + if (value !== undefined && value !== null && value !== '') { + const parsed = Number(value) + if (!Number.isNaN(parsed)) { + return parsed + } + } + } + + return 0 +} + +class OpenAIResponsesRelayService { + constructor() { + this.defaultTimeout = config.requestTimeout || 600000 + } + + // 处理请求转发 + async handleRequest(req, res, account, apiKeyData) { + let abortController = null + // 获取会话哈希(如果有的话) + const sessionId = req.headers['session_id'] || req.body?.session_id + const sessionHash = sessionId + ? crypto.createHash('sha256').update(sessionId).digest('hex') + : null + + try { + // 获取完整的账户信息(包含解密的 API Key) + const fullAccount = await openaiResponsesAccountService.getAccount(account.id) + if (!fullAccount) { + throw new Error('Account not found') + } + + // 创建 AbortController 用于取消请求 + abortController = new AbortController() + + // 设置客户端断开监听器 + const handleClientDisconnect = () => { + logger.info('🔌 Client disconnected, aborting OpenAI-Responses request') + if (abortController && !abortController.signal.aborted) { + abortController.abort() + } + } + + // 监听客户端断开事件 + req.once('close', handleClientDisconnect) + res.once('close', handleClientDisconnect) + + // 构建目标 URL + const targetUrl = `${fullAccount.baseApi}${req.path}` + logger.info(`🎯 Forwarding to: ${targetUrl}`) + + // 构建请求头 + const headers = { + ...this._filterRequestHeaders(req.headers), + Authorization: `Bearer ${fullAccount.apiKey}`, + 'Content-Type': 'application/json' + } + + // 处理 User-Agent + if (fullAccount.userAgent) { + // 使用自定义 User-Agent + headers['User-Agent'] = fullAccount.userAgent + logger.debug(`📱 Using custom User-Agent: ${fullAccount.userAgent}`) + } else if (req.headers['user-agent']) { + // 透传原始 User-Agent + headers['User-Agent'] = req.headers['user-agent'] + logger.debug(`📱 Forwarding original User-Agent: ${req.headers['user-agent']}`) + } + + // 配置请求选项 + const requestOptions = { + method: req.method, + url: targetUrl, + headers, + data: req.body, + timeout: this.defaultTimeout, + responseType: req.body?.stream ? 'stream' : 'json', + validateStatus: () => true, // 允许处理所有状态码 + signal: abortController.signal + } + + // 配置代理(如果有) + if (fullAccount.proxy) { + const proxyAgent = ProxyHelper.createProxyAgent(fullAccount.proxy) + if (proxyAgent) { + requestOptions.httpsAgent = proxyAgent + requestOptions.proxy = false + logger.info( + `🌐 Using proxy for OpenAI-Responses: ${ProxyHelper.getProxyDescription(fullAccount.proxy)}` + ) + } + } + + // 记录请求信息 + logger.info('📤 OpenAI-Responses relay request', { + accountId: account.id, + accountName: account.name, + targetUrl, + method: req.method, + stream: req.body?.stream || false, + model: req.body?.model || 'unknown', + userAgent: headers['User-Agent'] || 'not set' + }) + + // 发送请求 + const response = await axios(requestOptions) + + // 处理 429 限流错误 + if (response.status === 429) { + const { resetsInSeconds, errorData } = await this._handle429Error( + account, + response, + req.body?.stream, + sessionHash + ) + + // 返回错误响应(使用处理后的数据,避免循环引用) + const errorResponse = errorData || { + error: { + message: 'Rate limit exceeded', + type: 'rate_limit_error', + code: 'rate_limit_exceeded', + resets_in_seconds: resetsInSeconds + } + } + return res.status(429).json(errorResponse) + } + + // 处理其他错误状态码 + if (response.status >= 400) { + // 处理流式错误响应 + let errorData = response.data + if (response.data && typeof response.data.pipe === 'function') { + // 流式响应需要先读取内容 + const chunks = [] + await new Promise((resolve) => { + response.data.on('data', (chunk) => chunks.push(chunk)) + response.data.on('end', resolve) + response.data.on('error', resolve) + setTimeout(resolve, 5000) // 超时保护 + }) + const fullResponse = Buffer.concat(chunks).toString() + + // 尝试解析错误响应 + try { + if (fullResponse.includes('data: ')) { + // SSE格式 + const lines = fullResponse.split('\n') + for (const line of lines) { + if (line.startsWith('data: ')) { + const jsonStr = line.slice(6).trim() + if (jsonStr && jsonStr !== '[DONE]') { + errorData = JSON.parse(jsonStr) + break + } + } + } + } else { + // 普通JSON + errorData = JSON.parse(fullResponse) + } + } catch (e) { + logger.error('Failed to parse error response:', e) + errorData = { error: { message: fullResponse || 'Unknown error' } } + } + } + + logger.error('OpenAI-Responses API error', { + status: response.status, + statusText: response.statusText, + errorData + }) + + if (response.status === 401) { + let reason = 'OpenAI Responses账号认证失败(401错误)' + if (errorData) { + if (typeof errorData === 'string' && errorData.trim()) { + reason = `OpenAI Responses账号认证失败(401错误):${errorData.trim()}` + } else if ( + errorData.error && + typeof errorData.error.message === 'string' && + errorData.error.message.trim() + ) { + reason = `OpenAI Responses账号认证失败(401错误):${errorData.error.message.trim()}` + } else if (typeof errorData.message === 'string' && errorData.message.trim()) { + reason = `OpenAI Responses账号认证失败(401错误):${errorData.message.trim()}` + } + } + + try { + await unifiedOpenAIScheduler.markAccountUnauthorized( + account.id, + 'openai-responses', + sessionHash, + reason + ) + } catch (markError) { + logger.error( + '❌ Failed to mark OpenAI-Responses account unauthorized after 401:', + markError + ) + } + + let unauthorizedResponse = errorData + if ( + !unauthorizedResponse || + typeof unauthorizedResponse !== 'object' || + unauthorizedResponse.pipe || + Buffer.isBuffer(unauthorizedResponse) + ) { + const fallbackMessage = + typeof errorData === 'string' && errorData.trim() ? errorData.trim() : 'Unauthorized' + unauthorizedResponse = { + error: { + message: fallbackMessage, + type: 'unauthorized', + code: 'unauthorized' + } + } + } + + // 清理监听器 + req.removeListener('close', handleClientDisconnect) + res.removeListener('close', handleClientDisconnect) + + return res.status(401).json(unauthorizedResponse) + } + + // 清理监听器 + req.removeListener('close', handleClientDisconnect) + res.removeListener('close', handleClientDisconnect) + + return res.status(response.status).json(errorData) + } + + // 更新最后使用时间 + await openaiResponsesAccountService.updateAccount(account.id, { + lastUsedAt: new Date().toISOString() + }) + + // 处理流式响应 + if (req.body?.stream && response.data && typeof response.data.pipe === 'function') { + return this._handleStreamResponse( + response, + res, + account, + apiKeyData, + req.body?.model, + handleClientDisconnect, + req + ) + } + + // 处理非流式响应 + return this._handleNormalResponse(response, res, account, apiKeyData, req.body?.model) + } catch (error) { + // 清理 AbortController + if (abortController && !abortController.signal.aborted) { + abortController.abort() + } + + // 安全地记录错误,避免循环引用 + const errorInfo = { + message: error.message, + code: error.code, + status: error.response?.status, + statusText: error.response?.statusText + } + logger.error('OpenAI-Responses relay error:', errorInfo) + + // 检查是否是网络错误 + if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT') { + await openaiResponsesAccountService.updateAccount(account.id, { + status: 'error', + errorMessage: `Connection error: ${error.code}` + }) + } + + // 如果已经发送了响应头,直接结束 + if (res.headersSent) { + return res.end() + } + + // 检查是否是axios错误并包含响应 + if (error.response) { + // 处理axios错误响应 + const status = error.response.status || 500 + let errorData = { + error: { + message: error.response.statusText || 'Request failed', + type: 'api_error', + code: error.code || 'unknown' + } + } + + // 如果响应包含数据,尝试使用它 + if (error.response.data) { + // 检查是否是流 + if (typeof error.response.data === 'object' && !error.response.data.pipe) { + errorData = error.response.data + } else if (typeof error.response.data === 'string') { + try { + errorData = JSON.parse(error.response.data) + } catch (e) { + errorData.error.message = error.response.data + } + } + } + + if (status === 401) { + let reason = 'OpenAI Responses账号认证失败(401错误)' + if (errorData) { + if (typeof errorData === 'string' && errorData.trim()) { + reason = `OpenAI Responses账号认证失败(401错误):${errorData.trim()}` + } else if ( + errorData.error && + typeof errorData.error.message === 'string' && + errorData.error.message.trim() + ) { + reason = `OpenAI Responses账号认证失败(401错误):${errorData.error.message.trim()}` + } else if (typeof errorData.message === 'string' && errorData.message.trim()) { + reason = `OpenAI Responses账号认证失败(401错误):${errorData.message.trim()}` + } + } + + try { + await unifiedOpenAIScheduler.markAccountUnauthorized( + account.id, + 'openai-responses', + sessionHash, + reason + ) + } catch (markError) { + logger.error( + '❌ Failed to mark OpenAI-Responses account unauthorized in catch handler:', + markError + ) + } + + let unauthorizedResponse = errorData + if ( + !unauthorizedResponse || + typeof unauthorizedResponse !== 'object' || + unauthorizedResponse.pipe || + Buffer.isBuffer(unauthorizedResponse) + ) { + const fallbackMessage = + typeof errorData === 'string' && errorData.trim() ? errorData.trim() : 'Unauthorized' + unauthorizedResponse = { + error: { + message: fallbackMessage, + type: 'unauthorized', + code: 'unauthorized' + } + } + } + + return res.status(401).json(unauthorizedResponse) + } + + return res.status(status).json(errorData) + } + + // 其他错误 + return res.status(500).json({ + error: { + message: 'Internal server error', + type: 'internal_error', + details: error.message + } + }) + } + } + + // 处理流式响应 + async _handleStreamResponse( + response, + res, + account, + apiKeyData, + requestedModel, + handleClientDisconnect, + req + ) { + // 设置 SSE 响应头 + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + res.setHeader('X-Accel-Buffering', 'no') + + let usageData = null + let actualModel = null + let buffer = '' + let rateLimitDetected = false + let rateLimitResetsInSeconds = null + let streamEnded = false + + // 解析 SSE 事件以捕获 usage 数据和 model + const parseSSEForUsage = (data) => { + const lines = data.split('\n') + + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const jsonStr = line.slice(6) + if (jsonStr === '[DONE]') { + continue + } + + const eventData = JSON.parse(jsonStr) + + // 检查是否是 response.completed 事件(OpenAI-Responses 格式) + if (eventData.type === 'response.completed' && eventData.response) { + // 从响应中获取真实的 model + if (eventData.response.model) { + actualModel = eventData.response.model + logger.debug(`📊 Captured actual model from response.completed: ${actualModel}`) + } + + // 获取 usage 数据 - OpenAI-Responses 格式在 response.usage 下 + if (eventData.response.usage) { + usageData = eventData.response.usage + logger.info('📊 Successfully captured usage data from OpenAI-Responses:', { + input_tokens: usageData.input_tokens, + output_tokens: usageData.output_tokens, + total_tokens: usageData.total_tokens + }) + } + } + + // 检查是否有限流错误 + if (eventData.error) { + // 检查多种可能的限流错误类型 + if ( + eventData.error.type === 'rate_limit_error' || + eventData.error.type === 'usage_limit_reached' || + eventData.error.type === 'rate_limit_exceeded' + ) { + rateLimitDetected = true + if (eventData.error.resets_in_seconds) { + rateLimitResetsInSeconds = eventData.error.resets_in_seconds + logger.warn( + `🚫 Rate limit detected in stream, resets in ${rateLimitResetsInSeconds} seconds (${Math.ceil(rateLimitResetsInSeconds / 60)} minutes)` + ) + } + } + } + } catch (e) { + // 忽略解析错误 + } + } + } + } + + // 监听数据流 + response.data.on('data', (chunk) => { + try { + const chunkStr = chunk.toString() + + // 转发数据给客户端 + if (!res.destroyed && !streamEnded) { + res.write(chunk) + } + + // 同时解析数据以捕获 usage 信息 + buffer += chunkStr + + // 处理完整的 SSE 事件 + if (buffer.includes('\n\n')) { + const events = buffer.split('\n\n') + buffer = events.pop() || '' + + for (const event of events) { + if (event.trim()) { + parseSSEForUsage(event) + } + } + } + } catch (error) { + logger.error('Error processing stream chunk:', error) + } + }) + + response.data.on('end', async () => { + streamEnded = true + + // 处理剩余的 buffer + if (buffer.trim()) { + parseSSEForUsage(buffer) + } + + // 记录使用统计 + if (usageData) { + try { + // OpenAI-Responses 使用 input_tokens/output_tokens,标准 OpenAI 使用 prompt_tokens/completion_tokens + const totalInputTokens = usageData.input_tokens || usageData.prompt_tokens || 0 + const outputTokens = usageData.output_tokens || usageData.completion_tokens || 0 + + // 提取缓存相关的 tokens(如果存在) + const cacheReadTokens = usageData.input_tokens_details?.cached_tokens || 0 + const cacheCreateTokens = extractCacheCreationTokens(usageData) + // 计算实际输入token(总输入减去缓存部分) + const actualInputTokens = Math.max(0, totalInputTokens - cacheReadTokens) + + const totalTokens = + usageData.total_tokens || totalInputTokens + outputTokens + cacheCreateTokens + const modelToRecord = actualModel || requestedModel || 'gpt-4' + + await apiKeyService.recordUsage( + apiKeyData.id, + actualInputTokens, // 传递实际输入(不含缓存) + outputTokens, + cacheCreateTokens, + cacheReadTokens, + modelToRecord, + account.id + ) + + logger.info( + `📊 Recorded usage - Input: ${totalInputTokens}(actual:${actualInputTokens}+cached:${cacheReadTokens}), CacheCreate: ${cacheCreateTokens}, Output: ${outputTokens}, Total: ${totalTokens}, Model: ${modelToRecord}` + ) + + // 更新账户的 token 使用统计 + await openaiResponsesAccountService.updateAccountUsage(account.id, totalTokens) + + // 更新账户使用额度(如果设置了额度限制) + if (parseFloat(account.dailyQuota) > 0) { + // 使用CostCalculator正确计算费用(考虑缓存token的不同价格) + const CostCalculator = require('../utils/costCalculator') + const costInfo = CostCalculator.calculateCost( + { + input_tokens: actualInputTokens, // 实际输入(不含缓存) + output_tokens: outputTokens, + cache_creation_input_tokens: cacheCreateTokens, + cache_read_input_tokens: cacheReadTokens + }, + modelToRecord + ) + await openaiResponsesAccountService.updateUsageQuota(account.id, costInfo.costs.total) + } + } catch (error) { + logger.error('Failed to record usage:', error) + } + } + + // 如果在流式响应中检测到限流 + if (rateLimitDetected) { + // 使用统一调度器处理限流(与非流式响应保持一致) + const sessionId = req.headers['session_id'] || req.body?.session_id + const sessionHash = sessionId + ? crypto.createHash('sha256').update(sessionId).digest('hex') + : null + + await unifiedOpenAIScheduler.markAccountRateLimited( + account.id, + 'openai-responses', + sessionHash, + rateLimitResetsInSeconds + ) + + logger.warn( + `🚫 Processing rate limit for OpenAI-Responses account ${account.id} from stream` + ) + } + + // 清理监听器 + req.removeListener('close', handleClientDisconnect) + res.removeListener('close', handleClientDisconnect) + + if (!res.destroyed) { + res.end() + } + + logger.info('Stream response completed', { + accountId: account.id, + hasUsage: !!usageData, + actualModel: actualModel || 'unknown' + }) + }) + + response.data.on('error', (error) => { + streamEnded = true + logger.error('Stream error:', error) + + // 清理监听器 + req.removeListener('close', handleClientDisconnect) + res.removeListener('close', handleClientDisconnect) + + if (!res.headersSent) { + res.status(502).json({ error: { message: 'Upstream stream error' } }) + } else if (!res.destroyed) { + res.end() + } + }) + + // 处理客户端断开连接 + const cleanup = () => { + streamEnded = true + try { + response.data?.unpipe?.(res) + response.data?.destroy?.() + } catch (_) { + // 忽略清理错误 + } + } + + req.on('close', cleanup) + req.on('aborted', cleanup) + } + + // 处理非流式响应 + async _handleNormalResponse(response, res, account, apiKeyData, requestedModel) { + const responseData = response.data + + // 提取 usage 数据和实际 model + // 支持两种格式:直接的 usage 或嵌套在 response 中的 usage + const usageData = responseData?.usage || responseData?.response?.usage + const actualModel = + responseData?.model || responseData?.response?.model || requestedModel || 'gpt-4' + + // 记录使用统计 + if (usageData) { + try { + // OpenAI-Responses 使用 input_tokens/output_tokens,标准 OpenAI 使用 prompt_tokens/completion_tokens + const totalInputTokens = usageData.input_tokens || usageData.prompt_tokens || 0 + const outputTokens = usageData.output_tokens || usageData.completion_tokens || 0 + + // 提取缓存相关的 tokens(如果存在) + const cacheReadTokens = usageData.input_tokens_details?.cached_tokens || 0 + const cacheCreateTokens = extractCacheCreationTokens(usageData) + // 计算实际输入token(总输入减去缓存部分) + const actualInputTokens = Math.max(0, totalInputTokens - cacheReadTokens) + + const totalTokens = + usageData.total_tokens || totalInputTokens + outputTokens + cacheCreateTokens + + await apiKeyService.recordUsage( + apiKeyData.id, + actualInputTokens, // 传递实际输入(不含缓存) + outputTokens, + cacheCreateTokens, + cacheReadTokens, + actualModel, + account.id + ) + + logger.info( + `📊 Recorded non-stream usage - Input: ${totalInputTokens}(actual:${actualInputTokens}+cached:${cacheReadTokens}), CacheCreate: ${cacheCreateTokens}, Output: ${outputTokens}, Total: ${totalTokens}, Model: ${actualModel}` + ) + + // 更新账户的 token 使用统计 + await openaiResponsesAccountService.updateAccountUsage(account.id, totalTokens) + + // 更新账户使用额度(如果设置了额度限制) + if (parseFloat(account.dailyQuota) > 0) { + // 使用CostCalculator正确计算费用(考虑缓存token的不同价格) + const CostCalculator = require('../utils/costCalculator') + const costInfo = CostCalculator.calculateCost( + { + input_tokens: actualInputTokens, // 实际输入(不含缓存) + output_tokens: outputTokens, + cache_creation_input_tokens: cacheCreateTokens, + cache_read_input_tokens: cacheReadTokens + }, + actualModel + ) + await openaiResponsesAccountService.updateUsageQuota(account.id, costInfo.costs.total) + } + } catch (error) { + logger.error('Failed to record usage:', error) + } + } + + // 返回响应 + res.status(response.status).json(responseData) + + logger.info('Normal response completed', { + accountId: account.id, + status: response.status, + hasUsage: !!usageData, + model: actualModel + }) + } + + // 处理 429 限流错误 + async _handle429Error(account, response, isStream = false, sessionHash = null) { + let resetsInSeconds = null + let errorData = null + + try { + // 对于429错误,响应可能是JSON或SSE格式 + if (isStream && response.data && typeof response.data.pipe === 'function') { + // 流式响应需要先收集数据 + const chunks = [] + await new Promise((resolve, reject) => { + response.data.on('data', (chunk) => chunks.push(chunk)) + response.data.on('end', resolve) + response.data.on('error', reject) + // 设置超时防止无限等待 + setTimeout(resolve, 5000) + }) + + const fullResponse = Buffer.concat(chunks).toString() + + // 尝试解析SSE格式的错误响应 + if (fullResponse.includes('data: ')) { + const lines = fullResponse.split('\n') + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const jsonStr = line.slice(6).trim() + if (jsonStr && jsonStr !== '[DONE]') { + errorData = JSON.parse(jsonStr) + break + } + } catch (e) { + // 继续尝试下一行 + } + } + } + } + + // 如果SSE解析失败,尝试直接解析为JSON + if (!errorData) { + try { + errorData = JSON.parse(fullResponse) + } catch (e) { + logger.error('Failed to parse 429 error response:', e) + logger.debug('Raw response:', fullResponse) + } + } + } else if (response.data && typeof response.data !== 'object') { + // 如果response.data是字符串,尝试解析为JSON + try { + errorData = JSON.parse(response.data) + } catch (e) { + logger.error('Failed to parse 429 error response as JSON:', e) + errorData = { error: { message: response.data } } + } + } else if (response.data && typeof response.data === 'object' && !response.data.pipe) { + // 非流式响应,且是对象,直接使用 + errorData = response.data + } + + // 从响应体中提取重置时间(OpenAI 标准格式) + if (errorData && errorData.error) { + if (errorData.error.resets_in_seconds) { + resetsInSeconds = errorData.error.resets_in_seconds + logger.info( + `🕐 Rate limit will reset in ${resetsInSeconds} seconds (${Math.ceil(resetsInSeconds / 60)} minutes / ${Math.ceil(resetsInSeconds / 3600)} hours)` + ) + } else if (errorData.error.resets_in) { + // 某些 API 可能使用不同的字段名 + resetsInSeconds = parseInt(errorData.error.resets_in) + logger.info( + `🕐 Rate limit will reset in ${resetsInSeconds} seconds (${Math.ceil(resetsInSeconds / 60)} minutes / ${Math.ceil(resetsInSeconds / 3600)} hours)` + ) + } + } + + if (!resetsInSeconds) { + logger.warn('⚠️ Could not extract reset time from 429 response, using default 60 minutes') + } + } catch (e) { + logger.error('⚠️ Failed to parse rate limit error:', e) + } + + // 使用统一调度器标记账户为限流状态(与普通OpenAI账号保持一致) + await unifiedOpenAIScheduler.markAccountRateLimited( + account.id, + 'openai-responses', + sessionHash, + resetsInSeconds + ) + + logger.warn('OpenAI-Responses account rate limited', { + accountId: account.id, + accountName: account.name, + resetsInSeconds: resetsInSeconds || 'unknown', + resetInMinutes: resetsInSeconds ? Math.ceil(resetsInSeconds / 60) : 60, + resetInHours: resetsInSeconds ? Math.ceil(resetsInSeconds / 3600) : 1 + }) + + // 返回处理后的数据,避免循环引用 + return { resetsInSeconds, errorData } + } + + // 过滤请求头 + _filterRequestHeaders(headers) { + const filtered = {} + const skipHeaders = [ + 'host', + 'content-length', + 'authorization', + 'x-api-key', + 'x-cr-api-key', + 'connection', + 'upgrade', + 'sec-websocket-key', + 'sec-websocket-version', + 'sec-websocket-extensions' + ] + + for (const [key, value] of Object.entries(headers)) { + if (!skipHeaders.includes(key.toLowerCase())) { + filtered[key] = value + } + } + + return filtered + } + + // 估算费用(简化版本,实际应该根据不同的定价模型) + _estimateCost(model, inputTokens, outputTokens) { + // 这是一个简化的费用估算,实际应该根据不同的 API 提供商和模型定价 + const rates = { + 'gpt-4': { input: 0.03, output: 0.06 }, // per 1K tokens + 'gpt-4-turbo': { input: 0.01, output: 0.03 }, + 'gpt-3.5-turbo': { input: 0.0005, output: 0.0015 }, + 'claude-3-opus': { input: 0.015, output: 0.075 }, + 'claude-3-sonnet': { input: 0.003, output: 0.015 }, + 'claude-3-haiku': { input: 0.00025, output: 0.00125 } + } + + // 查找匹配的模型定价 + let rate = rates['gpt-3.5-turbo'] // 默认使用 GPT-3.5 的价格 + for (const [modelKey, modelRate] of Object.entries(rates)) { + if (model.toLowerCase().includes(modelKey.toLowerCase())) { + rate = modelRate + break + } + } + + const inputCost = (inputTokens / 1000) * rate.input + const outputCost = (outputTokens / 1000) * rate.output + return inputCost + outputCost + } +} + +module.exports = new OpenAIResponsesRelayService() diff --git a/src/services/openaiToClaude.js b/src/services/openaiToClaude.js new file mode 100644 index 0000000000000000000000000000000000000000..10c8ae24785aa9ad9467cdcd105a744d49af8035 --- /dev/null +++ b/src/services/openaiToClaude.js @@ -0,0 +1,473 @@ +/** + * OpenAI 到 Claude 格式转换服务 + * 处理 OpenAI API 格式与 Claude API 格式之间的转换 + */ + +const logger = require('../utils/logger') + +class OpenAIToClaudeConverter { + constructor() { + // 停止原因映射 + this.stopReasonMapping = { + end_turn: 'stop', + max_tokens: 'length', + stop_sequence: 'stop', + tool_use: 'tool_calls' + } + } + + /** + * 将 OpenAI 请求格式转换为 Claude 格式 + * @param {Object} openaiRequest - OpenAI 格式的请求 + * @returns {Object} Claude 格式的请求 + */ + convertRequest(openaiRequest) { + const claudeRequest = { + model: openaiRequest.model, // 直接使用提供的模型名,不进行映射 + messages: this._convertMessages(openaiRequest.messages), + max_tokens: openaiRequest.max_tokens || 4096, + temperature: openaiRequest.temperature, + top_p: openaiRequest.top_p, + stream: openaiRequest.stream || false + } + + // Claude Code 必需的系统消息 + const claudeCodeSystemMessage = "You are Claude Code, Anthropic's official CLI for Claude." + + claudeRequest.system = claudeCodeSystemMessage + + // 处理停止序列 + if (openaiRequest.stop) { + claudeRequest.stop_sequences = Array.isArray(openaiRequest.stop) + ? openaiRequest.stop + : [openaiRequest.stop] + } + + // 处理工具调用 + if (openaiRequest.tools) { + claudeRequest.tools = this._convertTools(openaiRequest.tools) + if (openaiRequest.tool_choice) { + claudeRequest.tool_choice = this._convertToolChoice(openaiRequest.tool_choice) + } + } + + // OpenAI 特有的参数已在转换过程中被忽略 + // 包括: n, presence_penalty, frequency_penalty, logit_bias, user + + logger.debug('📝 Converted OpenAI request to Claude format:', { + model: claudeRequest.model, + messageCount: claudeRequest.messages.length, + hasSystem: !!claudeRequest.system, + stream: claudeRequest.stream + }) + + return claudeRequest + } + + /** + * 将 Claude 响应格式转换为 OpenAI 格式 + * @param {Object} claudeResponse - Claude 格式的响应 + * @param {String} requestModel - 原始请求的模型名 + * @returns {Object} OpenAI 格式的响应 + */ + convertResponse(claudeResponse, requestModel) { + const timestamp = Math.floor(Date.now() / 1000) + + const openaiResponse = { + id: `chatcmpl-${this._generateId()}`, + object: 'chat.completion', + created: timestamp, + model: requestModel || 'gpt-4', + choices: [ + { + index: 0, + message: this._convertClaudeMessage(claudeResponse), + finish_reason: this._mapStopReason(claudeResponse.stop_reason) + } + ], + usage: this._convertUsage(claudeResponse.usage) + } + + logger.debug('📝 Converted Claude response to OpenAI format:', { + responseId: openaiResponse.id, + finishReason: openaiResponse.choices[0].finish_reason, + usage: openaiResponse.usage + }) + + return openaiResponse + } + + /** + * 转换流式响应的单个数据块 + * @param {String} chunk - Claude SSE 数据块 + * @param {String} requestModel - 原始请求的模型名 + * @param {String} sessionId - 会话ID + * @returns {String} OpenAI 格式的 SSE 数据块 + */ + convertStreamChunk(chunk, requestModel, sessionId) { + if (!chunk || chunk.trim() === '') { + return '' + } + + // 解析 SSE 数据 + const lines = chunk.split('\n') + const convertedChunks = [] + let hasMessageStop = false + + for (const line of lines) { + if (line.startsWith('data: ')) { + const data = line.substring(6) + if (data === '[DONE]') { + convertedChunks.push('data: [DONE]\n\n') + continue + } + + try { + const claudeEvent = JSON.parse(data) + + // 检查是否是 message_stop 事件 + if (claudeEvent.type === 'message_stop') { + hasMessageStop = true + } + + const openaiChunk = this._convertStreamEvent(claudeEvent, requestModel, sessionId) + if (openaiChunk) { + convertedChunks.push(`data: ${JSON.stringify(openaiChunk)}\n\n`) + } + } catch (e) { + // 跳过无法解析的数据,不传递非JSON格式的行 + continue + } + } + // 忽略 event: 行和空行,OpenAI 格式不包含这些 + } + + // 如果收到 message_stop 事件,添加 [DONE] 标记 + if (hasMessageStop) { + convertedChunks.push('data: [DONE]\n\n') + } + + return convertedChunks.join('') + } + + /** + * 提取系统消息 + */ + _extractSystemMessage(messages) { + const systemMessages = messages.filter((msg) => msg.role === 'system') + if (systemMessages.length === 0) { + return null + } + + // 合并所有系统消息 + return systemMessages.map((msg) => msg.content).join('\n\n') + } + + /** + * 转换消息格式 + */ + _convertMessages(messages) { + const claudeMessages = [] + + for (const msg of messages) { + // 跳过系统消息(已经在 system 字段处理) + if (msg.role === 'system') { + continue + } + + // 转换角色名称 + const role = msg.role === 'user' ? 'user' : 'assistant' + + // 转换消息内容 + const { content: rawContent } = msg + let content + + if (typeof rawContent === 'string') { + content = rawContent + } else if (Array.isArray(rawContent)) { + // 处理多模态内容 + content = this._convertMultimodalContent(rawContent) + } else { + content = JSON.stringify(rawContent) + } + + const claudeMsg = { + role, + content + } + + // 处理工具调用 + if (msg.tool_calls) { + claudeMsg.content = this._convertToolCalls(msg.tool_calls) + } + + // 处理工具响应 + if (msg.role === 'tool') { + claudeMsg.role = 'user' + claudeMsg.content = [ + { + type: 'tool_result', + tool_use_id: msg.tool_call_id, + content: msg.content + } + ] + } + + claudeMessages.push(claudeMsg) + } + + return claudeMessages + } + + /** + * 转换多模态内容 + */ + _convertMultimodalContent(content) { + return content.map((item) => { + if (item.type === 'text') { + return { + type: 'text', + text: item.text + } + } else if (item.type === 'image_url') { + const imageUrl = item.image_url.url + + // 检查是否是 base64 格式的图片 + if (imageUrl.startsWith('data:')) { + // 解析 data URL: data:image/jpeg;base64,/9j/4AAQ... + const matches = imageUrl.match(/^data:([^;]+);base64,(.+)$/) + if (matches) { + const mediaType = matches[1] // e.g., 'image/jpeg', 'image/png' + const base64Data = matches[2] + + return { + type: 'image', + source: { + type: 'base64', + media_type: mediaType, + data: base64Data + } + } + } else { + // 如果格式不正确,尝试使用默认处理 + logger.warn('⚠️ Invalid base64 image format, using default parsing') + return { + type: 'image', + source: { + type: 'base64', + media_type: 'image/jpeg', + data: imageUrl.split(',')[1] || '' + } + } + } + } else { + // 如果是 URL 格式的图片,Claude 不支持直接 URL,需要报错 + logger.error( + '❌ URL images are not supported by Claude API, only base64 format is accepted' + ) + throw new Error( + 'Claude API only supports base64 encoded images, not URLs. Please convert the image to base64 format.' + ) + } + } + return item + }) + } + + /** + * 转换工具定义 + */ + _convertTools(tools) { + return tools.map((tool) => { + if (tool.type === 'function') { + return { + name: tool.function.name, + description: tool.function.description, + input_schema: tool.function.parameters + } + } + return tool + }) + } + + /** + * 转换工具选择 + */ + _convertToolChoice(toolChoice) { + if (toolChoice === 'none') { + return { type: 'none' } + } + if (toolChoice === 'auto') { + return { type: 'auto' } + } + if (toolChoice === 'required') { + return { type: 'any' } + } + if (toolChoice.type === 'function') { + return { + type: 'tool', + name: toolChoice.function.name + } + } + return { type: 'auto' } + } + + /** + * 转换工具调用 + */ + _convertToolCalls(toolCalls) { + return toolCalls.map((tc) => ({ + type: 'tool_use', + id: tc.id, + name: tc.function.name, + input: JSON.parse(tc.function.arguments) + })) + } + + /** + * 转换 Claude 消息为 OpenAI 格式 + */ + _convertClaudeMessage(claudeResponse) { + const message = { + role: 'assistant', + content: null + } + + // 处理内容 + if (claudeResponse.content) { + if (typeof claudeResponse.content === 'string') { + message.content = claudeResponse.content + } else if (Array.isArray(claudeResponse.content)) { + // 提取文本内容和工具调用 + const textParts = [] + const toolCalls = [] + + for (const item of claudeResponse.content) { + if (item.type === 'text') { + textParts.push(item.text) + } else if (item.type === 'tool_use') { + toolCalls.push({ + id: item.id, + type: 'function', + function: { + name: item.name, + arguments: JSON.stringify(item.input) + } + }) + } + } + + message.content = textParts.join('') || null + if (toolCalls.length > 0) { + message.tool_calls = toolCalls + } + } + } + + return message + } + + /** + * 转换停止原因 + */ + _mapStopReason(claudeReason) { + return this.stopReasonMapping[claudeReason] || 'stop' + } + + /** + * 转换使用统计 + */ + _convertUsage(claudeUsage) { + if (!claudeUsage) { + return undefined + } + + return { + prompt_tokens: claudeUsage.input_tokens || 0, + completion_tokens: claudeUsage.output_tokens || 0, + total_tokens: (claudeUsage.input_tokens || 0) + (claudeUsage.output_tokens || 0) + } + } + + /** + * 转换流式事件 + */ + _convertStreamEvent(event, requestModel, sessionId) { + const timestamp = Math.floor(Date.now() / 1000) + const baseChunk = { + id: sessionId, + object: 'chat.completion.chunk', + created: timestamp, + model: requestModel || 'gpt-4', + choices: [ + { + index: 0, + delta: {}, + finish_reason: null + } + ] + } + + // 根据事件类型处理 + if (event.type === 'message_start') { + // 处理消息开始事件,发送角色信息 + baseChunk.choices[0].delta.role = 'assistant' + return baseChunk + } else if (event.type === 'content_block_start' && event.content_block) { + if (event.content_block.type === 'text') { + baseChunk.choices[0].delta.content = event.content_block.text || '' + } else if (event.content_block.type === 'tool_use') { + // 开始工具调用 + baseChunk.choices[0].delta.tool_calls = [ + { + index: event.index || 0, + id: event.content_block.id, + type: 'function', + function: { + name: event.content_block.name, + arguments: '' + } + } + ] + } + } else if (event.type === 'content_block_delta' && event.delta) { + if (event.delta.type === 'text_delta') { + baseChunk.choices[0].delta.content = event.delta.text || '' + } else if (event.delta.type === 'input_json_delta') { + // 工具调用参数的增量更新 + baseChunk.choices[0].delta.tool_calls = [ + { + index: event.index || 0, + function: { + arguments: event.delta.partial_json || '' + } + } + ] + } + } else if (event.type === 'message_delta' && event.delta) { + if (event.delta.stop_reason) { + baseChunk.choices[0].finish_reason = this._mapStopReason(event.delta.stop_reason) + } + if (event.usage) { + baseChunk.usage = this._convertUsage(event.usage) + } + } else if (event.type === 'message_stop') { + // message_stop 事件不需要返回 chunk,[DONE] 标记会在 convertStreamChunk 中添加 + return null + } else { + // 忽略其他类型的事件 + return null + } + + return baseChunk + } + + /** + * 生成随机 ID + */ + _generateId() { + return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15) + } +} + +module.exports = new OpenAIToClaudeConverter() diff --git a/src/services/pricingService.js b/src/services/pricingService.js new file mode 100644 index 0000000000000000000000000000000000000000..4f5905802d25c60136c372d6f26c6852e9d1a963 --- /dev/null +++ b/src/services/pricingService.js @@ -0,0 +1,664 @@ +const fs = require('fs') +const path = require('path') +const https = require('https') +const logger = require('../utils/logger') + +class PricingService { + constructor() { + this.dataDir = path.join(process.cwd(), 'data') + this.pricingFile = path.join(this.dataDir, 'model_pricing.json') + this.pricingUrl = + 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json' + this.fallbackFile = path.join( + process.cwd(), + 'resources', + 'model-pricing', + 'model_prices_and_context_window.json' + ) + this.pricingData = null + this.lastUpdated = null + this.updateInterval = 24 * 60 * 60 * 1000 // 24小时 + this.fileWatcher = null // 文件监听器 + this.reloadDebounceTimer = null // 防抖定时器 + + // 硬编码的 1 小时缓存价格(美元/百万 token) + // ephemeral_5m 的价格使用 model_pricing.json 中的 cache_creation_input_token_cost + // ephemeral_1h 的价格需要硬编码 + this.ephemeral1hPricing = { + // Opus 系列: $30/MTok + 'claude-opus-4-1': 0.00003, + 'claude-opus-4-1-20250805': 0.00003, + 'claude-opus-4': 0.00003, + 'claude-opus-4-20250514': 0.00003, + 'claude-3-opus': 0.00003, + 'claude-3-opus-latest': 0.00003, + 'claude-3-opus-20240229': 0.00003, + + // Sonnet 系列: $6/MTok + 'claude-3-5-sonnet': 0.000006, + 'claude-3-5-sonnet-latest': 0.000006, + 'claude-3-5-sonnet-20241022': 0.000006, + 'claude-3-5-sonnet-20240620': 0.000006, + 'claude-3-sonnet': 0.000006, + 'claude-3-sonnet-20240307': 0.000006, + 'claude-sonnet-3': 0.000006, + 'claude-sonnet-3-5': 0.000006, + 'claude-sonnet-3-7': 0.000006, + 'claude-sonnet-4': 0.000006, + 'claude-sonnet-4-20250514': 0.000006, + + // Haiku 系列: $1.6/MTok + 'claude-3-5-haiku': 0.0000016, + 'claude-3-5-haiku-latest': 0.0000016, + 'claude-3-5-haiku-20241022': 0.0000016, + 'claude-3-haiku': 0.0000016, + 'claude-3-haiku-20240307': 0.0000016, + 'claude-haiku-3': 0.0000016, + 'claude-haiku-3-5': 0.0000016 + } + + // 硬编码的 1M 上下文模型价格(美元/token) + // 当总输入 tokens 超过 200k 时使用这些价格 + this.longContextPricing = { + // claude-sonnet-4-20250514[1m] 模型的 1M 上下文价格 + 'claude-sonnet-4-20250514[1m]': { + input: 0.000006, // $6/MTok + output: 0.0000225 // $22.50/MTok + } + // 未来可以添加更多 1M 模型的价格 + } + } + + // 初始化价格服务 + async initialize() { + try { + // 确保data目录存在 + if (!fs.existsSync(this.dataDir)) { + fs.mkdirSync(this.dataDir, { recursive: true }) + logger.info('📁 Created data directory') + } + + // 检查是否需要下载或更新价格数据 + await this.checkAndUpdatePricing() + + // 设置定时更新 + setInterval(() => { + this.checkAndUpdatePricing() + }, this.updateInterval) + + // 设置文件监听器 + this.setupFileWatcher() + + logger.success('💰 Pricing service initialized successfully') + } catch (error) { + logger.error('❌ Failed to initialize pricing service:', error) + } + } + + // 检查并更新价格数据 + async checkAndUpdatePricing() { + try { + const needsUpdate = this.needsUpdate() + + if (needsUpdate) { + logger.info('🔄 Updating model pricing data...') + await this.downloadPricingData() + } else { + // 如果不需要更新,加载现有数据 + await this.loadPricingData() + } + } catch (error) { + logger.error('❌ Failed to check/update pricing:', error) + // 如果更新失败,尝试使用fallback + await this.useFallbackPricing() + } + } + + // 检查是否需要更新 + needsUpdate() { + if (!fs.existsSync(this.pricingFile)) { + logger.info('📋 Pricing file not found, will download') + return true + } + + const stats = fs.statSync(this.pricingFile) + const fileAge = Date.now() - stats.mtime.getTime() + + if (fileAge > this.updateInterval) { + logger.info( + `📋 Pricing file is ${Math.round(fileAge / (60 * 60 * 1000))} hours old, will update` + ) + return true + } + + return false + } + + // 下载价格数据 + async downloadPricingData() { + try { + await this._downloadFromRemote() + } catch (downloadError) { + logger.warn(`⚠️ Failed to download pricing data: ${downloadError.message}`) + logger.info('📋 Using local fallback pricing data...') + await this.useFallbackPricing() + } + } + + // 实际的下载逻辑 + _downloadFromRemote() { + return new Promise((resolve, reject) => { + const request = https.get(this.pricingUrl, (response) => { + if (response.statusCode !== 200) { + reject(new Error(`HTTP ${response.statusCode}: ${response.statusMessage}`)) + return + } + + let data = '' + response.on('data', (chunk) => { + data += chunk + }) + + response.on('end', () => { + try { + const jsonData = JSON.parse(data) + + // 保存到文件 + fs.writeFileSync(this.pricingFile, JSON.stringify(jsonData, null, 2)) + + // 更新内存中的数据 + this.pricingData = jsonData + this.lastUpdated = new Date() + + logger.success(`💰 Downloaded pricing data for ${Object.keys(jsonData).length} models`) + + // 设置或重新设置文件监听器 + this.setupFileWatcher() + + resolve() + } catch (error) { + reject(new Error(`Failed to parse pricing data: ${error.message}`)) + } + }) + }) + + request.on('error', (error) => { + reject(new Error(`Network error: ${error.message}`)) + }) + + request.setTimeout(30000, () => { + request.destroy() + reject(new Error('Download timeout after 30 seconds')) + }) + }) + } + + // 加载本地价格数据 + async loadPricingData() { + try { + if (fs.existsSync(this.pricingFile)) { + const data = fs.readFileSync(this.pricingFile, 'utf8') + this.pricingData = JSON.parse(data) + + const stats = fs.statSync(this.pricingFile) + this.lastUpdated = stats.mtime + + logger.info( + `💰 Loaded pricing data for ${Object.keys(this.pricingData).length} models from cache` + ) + } else { + logger.warn('💰 No pricing data file found, will use fallback') + await this.useFallbackPricing() + } + } catch (error) { + logger.error('❌ Failed to load pricing data:', error) + await this.useFallbackPricing() + } + } + + // 使用fallback价格数据 + async useFallbackPricing() { + try { + if (fs.existsSync(this.fallbackFile)) { + logger.info('📋 Copying fallback pricing data to data directory...') + + // 读取fallback文件 + const fallbackData = fs.readFileSync(this.fallbackFile, 'utf8') + const jsonData = JSON.parse(fallbackData) + + // 保存到data目录 + fs.writeFileSync(this.pricingFile, JSON.stringify(jsonData, null, 2)) + + // 更新内存中的数据 + this.pricingData = jsonData + this.lastUpdated = new Date() + + // 设置或重新设置文件监听器 + this.setupFileWatcher() + + logger.warn(`⚠️ Using fallback pricing data for ${Object.keys(jsonData).length} models`) + logger.info( + '💡 Note: This fallback data may be outdated. The system will try to update from the remote source on next check.' + ) + } else { + logger.error('❌ Fallback pricing file not found at:', this.fallbackFile) + logger.error( + '❌ Please ensure the resources/model-pricing directory exists with the pricing file' + ) + this.pricingData = {} + } + } catch (error) { + logger.error('❌ Failed to use fallback pricing data:', error) + this.pricingData = {} + } + } + + // 获取模型价格信息 + getModelPricing(modelName) { + if (!this.pricingData || !modelName) { + return null + } + + // 尝试直接匹配 + if (this.pricingData[modelName]) { + logger.debug(`💰 Found exact pricing match for ${modelName}`) + return this.pricingData[modelName] + } + + // 特殊处理:gpt-5-codex 回退到 gpt-5 + if (modelName === 'gpt-5-codex' && !this.pricingData['gpt-5-codex']) { + const fallbackPricing = this.pricingData['gpt-5'] + if (fallbackPricing) { + logger.info(`💰 Using gpt-5 pricing as fallback for ${modelName}`) + return fallbackPricing + } + } + + // 对于Bedrock区域前缀模型(如 us.anthropic.claude-sonnet-4-20250514-v1:0), + // 尝试去掉区域前缀进行匹配 + if (modelName.includes('.anthropic.') || modelName.includes('.claude')) { + // 提取不带区域前缀的模型名 + const withoutRegion = modelName.replace(/^(us|eu|apac)\./, '') + if (this.pricingData[withoutRegion]) { + logger.debug( + `💰 Found pricing for ${modelName} by removing region prefix: ${withoutRegion}` + ) + return this.pricingData[withoutRegion] + } + } + + // 尝试模糊匹配(处理版本号等变化) + const normalizedModel = modelName.toLowerCase().replace(/[_-]/g, '') + + for (const [key, value] of Object.entries(this.pricingData)) { + const normalizedKey = key.toLowerCase().replace(/[_-]/g, '') + if (normalizedKey.includes(normalizedModel) || normalizedModel.includes(normalizedKey)) { + logger.debug(`💰 Found pricing for ${modelName} using fuzzy match: ${key}`) + return value + } + } + + // 对于Bedrock模型,尝试更智能的匹配 + if (modelName.includes('anthropic.claude')) { + // 提取核心模型名部分(去掉区域和前缀) + const coreModel = modelName.replace(/^(us|eu|apac)\./, '').replace('anthropic.', '') + + for (const [key, value] of Object.entries(this.pricingData)) { + if (key.includes(coreModel) || key.replace('anthropic.', '').includes(coreModel)) { + logger.debug(`💰 Found pricing for ${modelName} using Bedrock core model match: ${key}`) + return value + } + } + } + + logger.debug(`💰 No pricing found for model: ${modelName}`) + return null + } + + // 确保价格对象包含缓存价格 + ensureCachePricing(pricing) { + if (!pricing) { + return pricing + } + + // 如果缺少缓存价格,根据输入价格计算(缓存创建价格通常是输入价格的1.25倍,缓存读取是0.1倍) + if (!pricing.cache_creation_input_token_cost && pricing.input_cost_per_token) { + pricing.cache_creation_input_token_cost = pricing.input_cost_per_token * 1.25 + } + if (!pricing.cache_read_input_token_cost && pricing.input_cost_per_token) { + pricing.cache_read_input_token_cost = pricing.input_cost_per_token * 0.1 + } + return pricing + } + + // 获取 1 小时缓存价格 + getEphemeral1hPricing(modelName) { + if (!modelName) { + return 0 + } + + // 尝试直接匹配 + if (this.ephemeral1hPricing[modelName]) { + return this.ephemeral1hPricing[modelName] + } + + // 处理各种模型名称变体 + const modelLower = modelName.toLowerCase() + + // 检查是否是 Opus 系列 + if (modelLower.includes('opus')) { + return 0.00003 // $30/MTok + } + + // 检查是否是 Sonnet 系列 + if (modelLower.includes('sonnet')) { + return 0.000006 // $6/MTok + } + + // 检查是否是 Haiku 系列 + if (modelLower.includes('haiku')) { + return 0.0000016 // $1.6/MTok + } + + // 默认返回 0(未知模型) + logger.debug(`💰 No 1h cache pricing found for model: ${modelName}`) + return 0 + } + + // 计算使用费用 + calculateCost(usage, modelName) { + // 检查是否为 1M 上下文模型 + const isLongContextModel = modelName && modelName.includes('[1m]') + let isLongContextRequest = false + let useLongContextPricing = false + + if (isLongContextModel) { + // 计算总输入 tokens + const inputTokens = usage.input_tokens || 0 + const cacheCreationTokens = usage.cache_creation_input_tokens || 0 + const cacheReadTokens = usage.cache_read_input_tokens || 0 + const totalInputTokens = inputTokens + cacheCreationTokens + cacheReadTokens + + // 如果总输入超过 200k,使用 1M 上下文价格 + if (totalInputTokens > 200000) { + isLongContextRequest = true + // 检查是否有硬编码的 1M 价格 + if (this.longContextPricing[modelName]) { + useLongContextPricing = true + } else { + // 如果没有找到硬编码价格,使用第一个 1M 模型的价格作为默认 + const defaultLongContextModel = Object.keys(this.longContextPricing)[0] + if (defaultLongContextModel) { + useLongContextPricing = true + logger.warn( + `⚠️ No specific 1M pricing for ${modelName}, using default from ${defaultLongContextModel}` + ) + } + } + } + } + + const pricing = this.getModelPricing(modelName) + + if (!pricing && !useLongContextPricing) { + return { + inputCost: 0, + outputCost: 0, + cacheCreateCost: 0, + cacheReadCost: 0, + ephemeral5mCost: 0, + ephemeral1hCost: 0, + totalCost: 0, + hasPricing: false, + isLongContextRequest: false + } + } + + let inputCost = 0 + let outputCost = 0 + + if (useLongContextPricing) { + // 使用 1M 上下文特殊价格(仅输入和输出价格改变) + const longContextPrices = + this.longContextPricing[modelName] || + this.longContextPricing[Object.keys(this.longContextPricing)[0]] + + inputCost = (usage.input_tokens || 0) * longContextPrices.input + outputCost = (usage.output_tokens || 0) * longContextPrices.output + + logger.info( + `💰 Using 1M context pricing for ${modelName}: input=$${longContextPrices.input}/token, output=$${longContextPrices.output}/token` + ) + } else { + // 使用正常价格 + inputCost = (usage.input_tokens || 0) * (pricing?.input_cost_per_token || 0) + outputCost = (usage.output_tokens || 0) * (pricing?.output_cost_per_token || 0) + } + + // 缓存价格保持不变(即使对于 1M 模型) + const cacheReadCost = + (usage.cache_read_input_tokens || 0) * (pricing?.cache_read_input_token_cost || 0) + + // 处理缓存创建费用: + // 1. 如果有详细的 cache_creation 对象,使用它 + // 2. 否则使用总的 cache_creation_input_tokens(向后兼容) + let ephemeral5mCost = 0 + let ephemeral1hCost = 0 + let cacheCreateCost = 0 + + if (usage.cache_creation && typeof usage.cache_creation === 'object') { + // 有详细的缓存创建数据 + const ephemeral5mTokens = usage.cache_creation.ephemeral_5m_input_tokens || 0 + const ephemeral1hTokens = usage.cache_creation.ephemeral_1h_input_tokens || 0 + + // 5分钟缓存使用标准的 cache_creation_input_token_cost + ephemeral5mCost = ephemeral5mTokens * (pricing?.cache_creation_input_token_cost || 0) + + // 1小时缓存使用硬编码的价格 + const ephemeral1hPrice = this.getEphemeral1hPricing(modelName) + ephemeral1hCost = ephemeral1hTokens * ephemeral1hPrice + + // 总的缓存创建费用 + cacheCreateCost = ephemeral5mCost + ephemeral1hCost + } else if (usage.cache_creation_input_tokens) { + // 旧格式,所有缓存创建 tokens 都按 5 分钟价格计算(向后兼容) + cacheCreateCost = + (usage.cache_creation_input_tokens || 0) * (pricing?.cache_creation_input_token_cost || 0) + ephemeral5mCost = cacheCreateCost + } + + return { + inputCost, + outputCost, + cacheCreateCost, + cacheReadCost, + ephemeral5mCost, + ephemeral1hCost, + totalCost: inputCost + outputCost + cacheCreateCost + cacheReadCost, + hasPricing: true, + isLongContextRequest, + pricing: { + input: useLongContextPricing + ? ( + this.longContextPricing[modelName] || + this.longContextPricing[Object.keys(this.longContextPricing)[0]] + )?.input || 0 + : pricing?.input_cost_per_token || 0, + output: useLongContextPricing + ? ( + this.longContextPricing[modelName] || + this.longContextPricing[Object.keys(this.longContextPricing)[0]] + )?.output || 0 + : pricing?.output_cost_per_token || 0, + cacheCreate: pricing?.cache_creation_input_token_cost || 0, + cacheRead: pricing?.cache_read_input_token_cost || 0, + ephemeral1h: this.getEphemeral1hPricing(modelName) + } + } + } + + // 格式化价格显示 + formatCost(cost) { + if (cost === 0) { + return '$0.000000' + } + if (cost < 0.000001) { + return `$${cost.toExponential(2)}` + } + if (cost < 0.01) { + return `$${cost.toFixed(6)}` + } + if (cost < 1) { + return `$${cost.toFixed(4)}` + } + return `$${cost.toFixed(2)}` + } + + // 获取服务状态 + getStatus() { + return { + initialized: this.pricingData !== null, + lastUpdated: this.lastUpdated, + modelCount: this.pricingData ? Object.keys(this.pricingData).length : 0, + nextUpdate: this.lastUpdated + ? new Date(this.lastUpdated.getTime() + this.updateInterval) + : null + } + } + + // 强制更新价格数据 + async forceUpdate() { + try { + await this._downloadFromRemote() + return { success: true, message: 'Pricing data updated successfully' } + } catch (error) { + logger.error('❌ Force update failed:', error) + logger.info('📋 Force update failed, using fallback pricing data...') + await this.useFallbackPricing() + return { + success: false, + message: `Download failed: ${error.message}. Using fallback pricing data instead.` + } + } + } + + // 设置文件监听器 + setupFileWatcher() { + try { + // 如果已有监听器,先关闭 + if (this.fileWatcher) { + this.fileWatcher.close() + this.fileWatcher = null + } + + // 只有文件存在时才设置监听器 + if (!fs.existsSync(this.pricingFile)) { + logger.debug('💰 Pricing file does not exist yet, skipping file watcher setup') + return + } + + // 使用 fs.watchFile 作为更可靠的文件监听方式 + // 它使用轮询,虽然性能稍差,但更可靠 + const watchOptions = { + persistent: true, + interval: 60000 // 每60秒检查一次 + } + + // 记录初始的修改时间 + let lastMtime = fs.statSync(this.pricingFile).mtimeMs + + fs.watchFile(this.pricingFile, watchOptions, (curr, _prev) => { + // 检查文件是否真的被修改了(不仅仅是访问) + if (curr.mtimeMs !== lastMtime) { + lastMtime = curr.mtimeMs + logger.debug( + `💰 Detected change in pricing file (mtime: ${new Date(curr.mtime).toISOString()})` + ) + this.handleFileChange() + } + }) + + // 保存引用以便清理 + this.fileWatcher = { + close: () => fs.unwatchFile(this.pricingFile) + } + + logger.info('👁️ File watcher set up for model_pricing.json (polling every 60s)') + } catch (error) { + logger.error('❌ Failed to setup file watcher:', error) + } + } + + // 处理文件变化(带防抖) + handleFileChange() { + // 清除之前的定时器 + if (this.reloadDebounceTimer) { + clearTimeout(this.reloadDebounceTimer) + } + + // 设置新的定时器(防抖500ms) + this.reloadDebounceTimer = setTimeout(async () => { + logger.info('🔄 Reloading pricing data due to file change...') + await this.reloadPricingData() + }, 500) + } + + // 重新加载价格数据 + async reloadPricingData() { + try { + // 验证文件是否存在 + if (!fs.existsSync(this.pricingFile)) { + logger.warn('💰 Pricing file was deleted, using fallback') + await this.useFallbackPricing() + // 重新设置文件监听器(fallback会创建新文件) + this.setupFileWatcher() + return + } + + // 读取文件内容 + const data = fs.readFileSync(this.pricingFile, 'utf8') + + // 尝试解析JSON + const jsonData = JSON.parse(data) + + // 验证数据结构 + if (typeof jsonData !== 'object' || Object.keys(jsonData).length === 0) { + throw new Error('Invalid pricing data structure') + } + + // 更新内存中的数据 + this.pricingData = jsonData + this.lastUpdated = new Date() + + const modelCount = Object.keys(jsonData).length + logger.success(`💰 Reloaded pricing data for ${modelCount} models from file`) + + // 显示一些统计信息 + const claudeModels = Object.keys(jsonData).filter((k) => k.includes('claude')).length + const gptModels = Object.keys(jsonData).filter((k) => k.includes('gpt')).length + const geminiModels = Object.keys(jsonData).filter((k) => k.includes('gemini')).length + + logger.debug( + `💰 Model breakdown: Claude=${claudeModels}, GPT=${gptModels}, Gemini=${geminiModels}` + ) + } catch (error) { + logger.error('❌ Failed to reload pricing data:', error) + logger.warn('💰 Keeping existing pricing data in memory') + } + } + + // 清理资源 + cleanup() { + if (this.fileWatcher) { + this.fileWatcher.close() + this.fileWatcher = null + logger.debug('💰 File watcher closed') + } + if (this.reloadDebounceTimer) { + clearTimeout(this.reloadDebounceTimer) + this.reloadDebounceTimer = null + } + } +} + +module.exports = new PricingService() diff --git a/src/services/rateLimitCleanupService.js b/src/services/rateLimitCleanupService.js new file mode 100644 index 0000000000000000000000000000000000000000..ebf8f44b0c06b2dea43fad3e055dbdf9b13bdb29 --- /dev/null +++ b/src/services/rateLimitCleanupService.js @@ -0,0 +1,419 @@ +/** + * 限流状态自动清理服务 + * 定期检查并清理所有类型账号的过期限流状态 + */ + +const logger = require('../utils/logger') +const openaiAccountService = require('./openaiAccountService') +const claudeAccountService = require('./claudeAccountService') +const claudeConsoleAccountService = require('./claudeConsoleAccountService') +const unifiedOpenAIScheduler = require('./unifiedOpenAIScheduler') +const webhookService = require('./webhookService') + +class RateLimitCleanupService { + constructor() { + this.cleanupInterval = null + this.isRunning = false + // 默认每5分钟检查一次 + this.intervalMs = 5 * 60 * 1000 + // 存储已清理的账户信息,用于发送恢复通知 + this.clearedAccounts = [] + } + + /** + * 启动自动清理服务 + * @param {number} intervalMinutes - 检查间隔(分钟),默认5分钟 + */ + start(intervalMinutes = 5) { + if (this.cleanupInterval) { + logger.warn('⚠️ Rate limit cleanup service is already running') + return + } + + this.intervalMs = intervalMinutes * 60 * 1000 + + logger.info(`🧹 Starting rate limit cleanup service (interval: ${intervalMinutes} minutes)`) + + // 立即执行一次清理 + this.performCleanup() + + // 设置定期执行 + this.cleanupInterval = setInterval(() => { + this.performCleanup() + }, this.intervalMs) + } + + /** + * 停止自动清理服务 + */ + stop() { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval) + this.cleanupInterval = null + logger.info('🛑 Rate limit cleanup service stopped') + } + } + + /** + * 执行一次清理检查 + */ + async performCleanup() { + if (this.isRunning) { + logger.debug('⏭️ Cleanup already in progress, skipping this cycle') + return + } + + this.isRunning = true + const startTime = Date.now() + + try { + logger.debug('🔍 Starting rate limit cleanup check...') + + const results = { + openai: { checked: 0, cleared: 0, errors: [] }, + claude: { checked: 0, cleared: 0, errors: [] }, + claudeConsole: { checked: 0, cleared: 0, errors: [] } + } + + // 清理 OpenAI 账号 + await this.cleanupOpenAIAccounts(results.openai) + + // 清理 Claude 账号 + await this.cleanupClaudeAccounts(results.claude) + + // 清理 Claude Console 账号 + await this.cleanupClaudeConsoleAccounts(results.claudeConsole) + + const totalChecked = + results.openai.checked + results.claude.checked + results.claudeConsole.checked + const totalCleared = + results.openai.cleared + results.claude.cleared + results.claudeConsole.cleared + const duration = Date.now() - startTime + + if (totalCleared > 0) { + logger.info( + `✅ Rate limit cleanup completed: ${totalCleared} accounts cleared out of ${totalChecked} checked (${duration}ms)` + ) + logger.info(` OpenAI: ${results.openai.cleared}/${results.openai.checked}`) + logger.info(` Claude: ${results.claude.cleared}/${results.claude.checked}`) + logger.info( + ` Claude Console: ${results.claudeConsole.cleared}/${results.claudeConsole.checked}` + ) + + // 发送 webhook 恢复通知 + if (this.clearedAccounts.length > 0) { + await this.sendRecoveryNotifications() + } + } else { + logger.debug( + `🔍 Rate limit cleanup check completed: no expired limits found (${duration}ms)` + ) + } + + // 记录错误 + const allErrors = [ + ...results.openai.errors, + ...results.claude.errors, + ...results.claudeConsole.errors + ] + if (allErrors.length > 0) { + logger.warn(`⚠️ Encountered ${allErrors.length} errors during cleanup:`, allErrors) + } + } catch (error) { + logger.error('❌ Rate limit cleanup failed:', error) + } finally { + // 确保无论成功或失败都重置列表,避免重复通知 + this.clearedAccounts = [] + this.isRunning = false + } + } + + /** + * 清理 OpenAI 账号的过期限流 + */ + async cleanupOpenAIAccounts(result) { + try { + // 使用服务层获取账户数据 + const accounts = await openaiAccountService.getAllAccounts() + + for (const account of accounts) { + const { rateLimitStatus } = account + const isRateLimited = + rateLimitStatus === 'limited' || + (rateLimitStatus && + typeof rateLimitStatus === 'object' && + (rateLimitStatus.status === 'limited' || rateLimitStatus.isRateLimited === true)) + + if (isRateLimited) { + result.checked++ + + try { + // 使用 unifiedOpenAIScheduler 的检查方法,它会自动清除过期的限流 + const isStillLimited = await unifiedOpenAIScheduler.isAccountRateLimited(account.id) + + if (!isStillLimited) { + result.cleared++ + logger.info( + `🧹 Auto-cleared expired rate limit for OpenAI account: ${account.name} (${account.id})` + ) + + // 记录已清理的账户信息 + this.clearedAccounts.push({ + platform: 'OpenAI', + accountId: account.id, + accountName: account.name, + previousStatus: 'rate_limited', + currentStatus: 'active' + }) + } + } catch (error) { + result.errors.push({ + accountId: account.id, + accountName: account.name, + error: error.message + }) + } + } + } + } catch (error) { + logger.error('Failed to cleanup OpenAI accounts:', error) + result.errors.push({ error: error.message }) + } + } + + /** + * 清理 Claude 账号的过期限流 + */ + async cleanupClaudeAccounts(result) { + try { + // 使用 Redis 获取账户数据 + const redis = require('../models/redis') + const accounts = await redis.getAllClaudeAccounts() + + for (const account of accounts) { + // 检查是否处于限流状态(兼容对象和字符串格式) + const isRateLimited = + account.rateLimitStatus === 'limited' || + (account.rateLimitStatus && + typeof account.rateLimitStatus === 'object' && + account.rateLimitStatus.status === 'limited') + + const autoStopped = account.rateLimitAutoStopped === 'true' + const needsAutoStopRecovery = + autoStopped && (account.rateLimitEndAt || account.schedulable === 'false') + + // 检查所有可能处于限流状态的账号,包括自动停止的账号 + if (isRateLimited || account.rateLimitedAt || needsAutoStopRecovery) { + result.checked++ + + try { + // 使用 claudeAccountService 的检查方法,它会自动清除过期的限流 + const isStillLimited = await claudeAccountService.isAccountRateLimited(account.id) + + if (!isStillLimited) { + if (!isRateLimited && autoStopped) { + await claudeAccountService.removeAccountRateLimit(account.id) + } + result.cleared++ + logger.info( + `🧹 Auto-cleared expired rate limit for Claude account: ${account.name} (${account.id})` + ) + + // 记录已清理的账户信息 + this.clearedAccounts.push({ + platform: 'Claude', + accountId: account.id, + accountName: account.name, + previousStatus: 'rate_limited', + currentStatus: 'active' + }) + } + } catch (error) { + result.errors.push({ + accountId: account.id, + accountName: account.name, + error: error.message + }) + } + } + } + + // 检查并恢复因5小时限制被自动停止的账号 + try { + const fiveHourResult = await claudeAccountService.checkAndRecoverFiveHourStoppedAccounts() + + if (fiveHourResult.recovered > 0) { + // 将5小时限制恢复的账号也加入到已清理账户列表中,用于发送通知 + for (const account of fiveHourResult.accounts) { + this.clearedAccounts.push({ + platform: 'Claude', + accountId: account.id, + accountName: account.name, + previousStatus: '5hour_limited', + currentStatus: 'active', + windowInfo: account.newWindow + }) + } + + // 更新统计数据 + result.checked += fiveHourResult.checked + result.cleared += fiveHourResult.recovered + + logger.info( + `🕐 Claude 5-hour limit recovery: ${fiveHourResult.recovered}/${fiveHourResult.checked} accounts recovered` + ) + } + } catch (error) { + logger.error('Failed to check and recover 5-hour stopped Claude accounts:', error) + result.errors.push({ + type: '5hour_recovery', + error: error.message + }) + } + } catch (error) { + logger.error('Failed to cleanup Claude accounts:', error) + result.errors.push({ error: error.message }) + } + } + + /** + * 清理 Claude Console 账号的过期限流 + */ + async cleanupClaudeConsoleAccounts(result) { + try { + // 使用服务层获取账户数据 + const accounts = await claudeConsoleAccountService.getAllAccounts() + + for (const account of accounts) { + // 检查是否处于限流状态(兼容对象和字符串格式) + const isRateLimited = + account.rateLimitStatus === 'limited' || + (account.rateLimitStatus && + typeof account.rateLimitStatus === 'object' && + account.rateLimitStatus.status === 'limited') + + const autoStopped = account.rateLimitAutoStopped === 'true' + const needsAutoStopRecovery = autoStopped && account.schedulable === 'false' + + // 检查两种状态字段:rateLimitStatus 和 status + const hasStatusRateLimited = account.status === 'rate_limited' + + if (isRateLimited || hasStatusRateLimited || needsAutoStopRecovery) { + result.checked++ + + try { + // 使用 claudeConsoleAccountService 的检查方法,它会自动清除过期的限流 + const isStillLimited = await claudeConsoleAccountService.isAccountRateLimited( + account.id + ) + + if (!isStillLimited) { + if (!isRateLimited && autoStopped) { + await claudeConsoleAccountService.removeAccountRateLimit(account.id) + } + result.cleared++ + + // 如果 status 字段是 rate_limited,需要额外清理 + if (hasStatusRateLimited && !isRateLimited) { + await claudeConsoleAccountService.updateAccount(account.id, { + status: 'active' + }) + } + + logger.info( + `🧹 Auto-cleared expired rate limit for Claude Console account: ${account.name} (${account.id})` + ) + + // 记录已清理的账户信息 + this.clearedAccounts.push({ + platform: 'Claude Console', + accountId: account.id, + accountName: account.name, + previousStatus: 'rate_limited', + currentStatus: 'active' + }) + } + } catch (error) { + result.errors.push({ + accountId: account.id, + accountName: account.name, + error: error.message + }) + } + } + } + } catch (error) { + logger.error('Failed to cleanup Claude Console accounts:', error) + result.errors.push({ error: error.message }) + } + } + + /** + * 手动触发一次清理(供 API 或 CLI 调用) + */ + async manualCleanup() { + logger.info('🧹 Manual rate limit cleanup triggered') + await this.performCleanup() + } + + /** + * 发送限流恢复通知 + */ + async sendRecoveryNotifications() { + try { + // 按平台分组账户 + const groupedAccounts = {} + for (const account of this.clearedAccounts) { + if (!groupedAccounts[account.platform]) { + groupedAccounts[account.platform] = [] + } + groupedAccounts[account.platform].push(account) + } + + // 构建通知消息 + const platforms = Object.keys(groupedAccounts) + const totalAccounts = this.clearedAccounts.length + + let message = `🎉 共有 ${totalAccounts} 个账户的限流状态已恢复\n\n` + + for (const platform of platforms) { + const accounts = groupedAccounts[platform] + message += `**${platform}** (${accounts.length} 个):\n` + for (const account of accounts) { + message += `• ${account.accountName} (ID: ${account.accountId})\n` + } + message += '\n' + } + + // 发送 webhook 通知 + await webhookService.sendNotification('rateLimitRecovery', { + title: '限流恢复通知', + message, + totalAccounts, + platforms: Object.keys(groupedAccounts), + accounts: this.clearedAccounts, + timestamp: new Date().toISOString() + }) + + logger.info(`📢 已发送限流恢复通知,涉及 ${totalAccounts} 个账户`) + } catch (error) { + logger.error('❌ 发送限流恢复通知失败:', error) + } + } + + /** + * 获取服务状态 + */ + getStatus() { + return { + running: !!this.cleanupInterval, + intervalMinutes: this.intervalMs / (60 * 1000), + isProcessing: this.isRunning + } + } +} + +// 创建单例实例 +const rateLimitCleanupService = new RateLimitCleanupService() + +module.exports = rateLimitCleanupService diff --git a/src/services/tokenRefreshService.js b/src/services/tokenRefreshService.js new file mode 100644 index 0000000000000000000000000000000000000000..48e1cc7f1ccfad9ceee616b0bee4c3e741590692 --- /dev/null +++ b/src/services/tokenRefreshService.js @@ -0,0 +1,143 @@ +const redis = require('../models/redis') +const logger = require('../utils/logger') +const { v4: uuidv4 } = require('uuid') + +/** + * Token 刷新锁服务 + * 提供分布式锁机制,避免并发刷新问题 + */ +class TokenRefreshService { + constructor() { + this.lockTTL = 60 // 锁的TTL: 60秒(token刷新通常在30秒内完成) + this.lockValue = new Map() // 存储每个锁的唯一值 + } + + /** + * 获取分布式锁 + * 使用唯一标识符作为值,避免误释放其他进程的锁 + */ + async acquireLock(lockKey) { + try { + const client = redis.getClientSafe() + const lockId = uuidv4() + const result = await client.set(lockKey, lockId, 'NX', 'EX', this.lockTTL) + + if (result === 'OK') { + this.lockValue.set(lockKey, lockId) + logger.debug(`🔒 Acquired lock ${lockKey} with ID ${lockId}, TTL: ${this.lockTTL}s`) + return true + } + return false + } catch (error) { + logger.error(`Failed to acquire lock ${lockKey}:`, error) + return false + } + } + + /** + * 释放分布式锁 + * 使用 Lua 脚本确保只释放自己持有的锁 + */ + async releaseLock(lockKey) { + try { + const client = redis.getClientSafe() + const lockId = this.lockValue.get(lockKey) + + if (!lockId) { + logger.warn(`⚠️ No lock ID found for ${lockKey}, skipping release`) + return + } + + // Lua 脚本:只有当值匹配时才删除 + const luaScript = ` + if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("del", KEYS[1]) + else + return 0 + end + ` + + const result = await client.eval(luaScript, 1, lockKey, lockId) + + if (result === 1) { + this.lockValue.delete(lockKey) + logger.debug(`🔓 Released lock ${lockKey} with ID ${lockId}`) + } else { + logger.warn(`⚠️ Lock ${lockKey} was not released - value mismatch or already expired`) + } + } catch (error) { + logger.error(`Failed to release lock ${lockKey}:`, error) + } + } + + /** + * 获取刷新锁 + * @param {string} accountId - 账户ID + * @param {string} platform - 平台类型 (claude/gemini) + * @returns {Promise} 是否成功获取锁 + */ + async acquireRefreshLock(accountId, platform = 'claude') { + const lockKey = `token_refresh_lock:${platform}:${accountId}` + return await this.acquireLock(lockKey) + } + + /** + * 释放刷新锁 + * @param {string} accountId - 账户ID + * @param {string} platform - 平台类型 (claude/gemini) + */ + async releaseRefreshLock(accountId, platform = 'claude') { + const lockKey = `token_refresh_lock:${platform}:${accountId}` + await this.releaseLock(lockKey) + } + + /** + * 检查刷新锁状态 + * @param {string} accountId - 账户ID + * @param {string} platform - 平台类型 (claude/gemini) + * @returns {Promise} 锁是否存在 + */ + async isRefreshLocked(accountId, platform = 'claude') { + const lockKey = `token_refresh_lock:${platform}:${accountId}` + try { + const client = redis.getClientSafe() + const exists = await client.exists(lockKey) + return exists === 1 + } catch (error) { + logger.error(`Failed to check lock status ${lockKey}:`, error) + return false + } + } + + /** + * 获取锁的剩余TTL + * @param {string} accountId - 账户ID + * @param {string} platform - 平台类型 (claude/gemini) + * @returns {Promise} 剩余秒数,-1表示锁不存在 + */ + async getLockTTL(accountId, platform = 'claude') { + const lockKey = `token_refresh_lock:${platform}:${accountId}` + try { + const client = redis.getClientSafe() + const ttl = await client.ttl(lockKey) + return ttl + } catch (error) { + logger.error(`Failed to get lock TTL ${lockKey}:`, error) + return -1 + } + } + + /** + * 清理本地锁记录 + * 在进程退出时调用,避免内存泄漏 + */ + cleanup() { + this.lockValue.clear() + logger.info('🧹 Cleaned up local lock records') + } +} + +// 创建单例实例 +const tokenRefreshService = new TokenRefreshService() + +module.exports = tokenRefreshService diff --git a/src/services/unifiedClaudeScheduler.js b/src/services/unifiedClaudeScheduler.js new file mode 100644 index 0000000000000000000000000000000000000000..fcfb0453cd3be52310579e7bdbd453ca0b6f3fd0 --- /dev/null +++ b/src/services/unifiedClaudeScheduler.js @@ -0,0 +1,1391 @@ +const claudeAccountService = require('./claudeAccountService') +const claudeConsoleAccountService = require('./claudeConsoleAccountService') +const bedrockAccountService = require('./bedrockAccountService') +const ccrAccountService = require('./ccrAccountService') +const accountGroupService = require('./accountGroupService') +const redis = require('../models/redis') +const logger = require('../utils/logger') +const { parseVendorPrefixedModel } = require('../utils/modelHelper') + +class UnifiedClaudeScheduler { + constructor() { + this.SESSION_MAPPING_PREFIX = 'unified_claude_session_mapping:' + } + + // 🔧 辅助方法:检查账户是否可调度(兼容字符串和布尔值) + _isSchedulable(schedulable) { + // 如果是 undefined 或 null,默认为可调度 + if (schedulable === undefined || schedulable === null) { + return true + } + // 明确设置为 false(布尔值)或 'false'(字符串)时不可调度 + return schedulable !== false && schedulable !== 'false' + } + + // 🔍 检查账户是否支持请求的模型 + _isModelSupportedByAccount(account, accountType, requestedModel, context = '') { + if (!requestedModel) { + return true // 没有指定模型时,默认支持 + } + + // Claude OAuth 账户的模型检查 + if (accountType === 'claude-official') { + // 1. 首先检查是否为 Claude 官方支持的模型 + // Claude Official API 只支持 Anthropic 自己的模型,不支持第三方模型(如 deepseek-chat) + const isClaudeOfficialModel = + requestedModel.startsWith('claude-') || + requestedModel.includes('claude') || + requestedModel.includes('sonnet') || + requestedModel.includes('opus') || + requestedModel.includes('haiku') + + if (!isClaudeOfficialModel) { + logger.info( + `🚫 Claude official account ${account.name} does not support non-Claude model ${requestedModel}${context ? ` ${context}` : ''}` + ) + return false + } + + // 2. Opus 模型的订阅级别检查 + if (requestedModel.toLowerCase().includes('opus')) { + if (account.subscriptionInfo) { + try { + const info = + typeof account.subscriptionInfo === 'string' + ? JSON.parse(account.subscriptionInfo) + : account.subscriptionInfo + + // Pro 和 Free 账号不支持 Opus + if (info.hasClaudePro === true && info.hasClaudeMax !== true) { + logger.info( + `🚫 Claude account ${account.name} (Pro) does not support Opus model${context ? ` ${context}` : ''}` + ) + return false + } + if (info.accountType === 'claude_pro' || info.accountType === 'claude_free') { + logger.info( + `🚫 Claude account ${account.name} (${info.accountType}) does not support Opus model${context ? ` ${context}` : ''}` + ) + return false + } + } catch (e) { + // 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max) + logger.debug( + `Account ${account.name} has invalid subscriptionInfo${context ? ` ${context}` : ''}, assuming Max` + ) + } + } + // 没有订阅信息的账号,默认当作支持(兼容旧数据) + } + } + + // Claude Console 账户的模型支持检查 + if (accountType === 'claude-console' && account.supportedModels) { + // 兼容旧格式(数组)和新格式(对象) + if (Array.isArray(account.supportedModels)) { + // 旧格式:数组 + if ( + account.supportedModels.length > 0 && + !account.supportedModels.includes(requestedModel) + ) { + logger.info( + `🚫 Claude Console account ${account.name} does not support model ${requestedModel}${context ? ` ${context}` : ''}` + ) + return false + } + } else if (typeof account.supportedModels === 'object') { + // 新格式:映射表 + if ( + Object.keys(account.supportedModels).length > 0 && + !claudeConsoleAccountService.isModelSupported(account.supportedModels, requestedModel) + ) { + logger.info( + `🚫 Claude Console account ${account.name} does not support model ${requestedModel}${context ? ` ${context}` : ''}` + ) + return false + } + } + } + + // CCR 账户的模型支持检查 + if (accountType === 'ccr' && account.supportedModels) { + // 兼容旧格式(数组)和新格式(对象) + if (Array.isArray(account.supportedModels)) { + // 旧格式:数组 + if ( + account.supportedModels.length > 0 && + !account.supportedModels.includes(requestedModel) + ) { + logger.info( + `🚫 CCR account ${account.name} does not support model ${requestedModel}${context ? ` ${context}` : ''}` + ) + return false + } + } else if (typeof account.supportedModels === 'object') { + // 新格式:映射表 + if ( + Object.keys(account.supportedModels).length > 0 && + !ccrAccountService.isModelSupported(account.supportedModels, requestedModel) + ) { + logger.info( + `🚫 CCR account ${account.name} does not support model ${requestedModel}${context ? ` ${context}` : ''}` + ) + return false + } + } + } + + return true + } + + // 🎯 统一调度Claude账号(官方和Console) + async selectAccountForApiKey(apiKeyData, sessionHash = null, requestedModel = null) { + try { + // 解析供应商前缀 + const { vendor, baseModel } = parseVendorPrefixedModel(requestedModel) + const effectiveModel = vendor === 'ccr' ? baseModel : requestedModel + + logger.debug( + `🔍 Model parsing - Original: ${requestedModel}, Vendor: ${vendor}, Effective: ${effectiveModel}` + ) + const isOpusRequest = + effectiveModel && typeof effectiveModel === 'string' + ? effectiveModel.toLowerCase().includes('opus') + : false + + // 如果是 CCR 前缀,只在 CCR 账户池中选择 + if (vendor === 'ccr') { + logger.info(`🎯 CCR vendor prefix detected, routing to CCR accounts only`) + return await this._selectCcrAccount(apiKeyData, sessionHash, effectiveModel) + } + // 如果API Key绑定了专属账户或分组,优先使用 + if (apiKeyData.claudeAccountId) { + // 检查是否是分组 + if (apiKeyData.claudeAccountId.startsWith('group:')) { + const groupId = apiKeyData.claudeAccountId.replace('group:', '') + logger.info( + `🎯 API key ${apiKeyData.name} is bound to group ${groupId}, selecting from group` + ) + return await this.selectAccountFromGroup( + groupId, + sessionHash, + effectiveModel, + vendor === 'ccr' + ) + } + + // 普通专属账户 + const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId) + if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') { + const isRateLimited = await claudeAccountService.isAccountRateLimited(boundAccount.id) + if (isRateLimited) { + const rateInfo = await claudeAccountService.getAccountRateLimitInfo(boundAccount.id) + const error = new Error('Dedicated Claude account is rate limited') + error.code = 'CLAUDE_DEDICATED_RATE_LIMITED' + error.accountId = boundAccount.id + error.rateLimitEndAt = rateInfo?.rateLimitEndAt || boundAccount.rateLimitEndAt || null + throw error + } + + if (!this._isSchedulable(boundAccount.schedulable)) { + logger.warn( + `⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not schedulable (schedulable: ${boundAccount?.schedulable}), falling back to pool` + ) + } else { + if (isOpusRequest) { + await claudeAccountService.clearExpiredOpusRateLimit(boundAccount.id) + } + logger.info( + `🎯 Using bound dedicated Claude OAuth account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}` + ) + return { + accountId: apiKeyData.claudeAccountId, + accountType: 'claude-official' + } + } + } else { + logger.warn( + `⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not available (isActive: ${boundAccount?.isActive}, status: ${boundAccount?.status}), falling back to pool` + ) + } + } + + // 2. 检查Claude Console账户绑定 + if (apiKeyData.claudeConsoleAccountId) { + const boundConsoleAccount = await claudeConsoleAccountService.getAccount( + apiKeyData.claudeConsoleAccountId + ) + if ( + boundConsoleAccount && + boundConsoleAccount.isActive === true && + boundConsoleAccount.status === 'active' && + this._isSchedulable(boundConsoleAccount.schedulable) + ) { + logger.info( + `🎯 Using bound dedicated Claude Console account: ${boundConsoleAccount.name} (${apiKeyData.claudeConsoleAccountId}) for API key ${apiKeyData.name}` + ) + return { + accountId: apiKeyData.claudeConsoleAccountId, + accountType: 'claude-console' + } + } else { + logger.warn( + `⚠️ Bound Claude Console account ${apiKeyData.claudeConsoleAccountId} is not available (isActive: ${boundConsoleAccount?.isActive}, status: ${boundConsoleAccount?.status}, schedulable: ${boundConsoleAccount?.schedulable}), falling back to pool` + ) + } + } + + // 3. 检查Bedrock账户绑定 + if (apiKeyData.bedrockAccountId) { + const boundBedrockAccountResult = await bedrockAccountService.getAccount( + apiKeyData.bedrockAccountId + ) + if ( + boundBedrockAccountResult.success && + boundBedrockAccountResult.data.isActive === true && + this._isSchedulable(boundBedrockAccountResult.data.schedulable) + ) { + logger.info( + `🎯 Using bound dedicated Bedrock account: ${boundBedrockAccountResult.data.name} (${apiKeyData.bedrockAccountId}) for API key ${apiKeyData.name}` + ) + return { + accountId: apiKeyData.bedrockAccountId, + accountType: 'bedrock' + } + } else { + logger.warn( + `⚠️ Bound Bedrock account ${apiKeyData.bedrockAccountId} is not available (isActive: ${boundBedrockAccountResult?.data?.isActive}, schedulable: ${boundBedrockAccountResult?.data?.schedulable}), falling back to pool` + ) + } + } + + // CCR 账户不支持绑定(仅通过 ccr, 前缀进行 CCR 路由) + + // 如果有会话哈希,检查是否有已映射的账户 + if (sessionHash) { + const mappedAccount = await this._getSessionMapping(sessionHash) + if (mappedAccount) { + // 当本次请求不是 CCR 前缀时,不允许使用指向 CCR 的粘性会话映射 + if (vendor !== 'ccr' && mappedAccount.accountType === 'ccr') { + logger.info( + `ℹ️ Skipping CCR sticky session mapping for non-CCR request; removing mapping for session ${sessionHash}` + ) + await this._deleteSessionMapping(sessionHash) + } else { + // 验证映射的账户是否仍然可用 + const isAvailable = await this._isAccountAvailable( + mappedAccount.accountId, + mappedAccount.accountType, + effectiveModel + ) + if (isAvailable) { + // 🚀 智能会话续期:剩余时间少于14天时自动续期到15天(续期正确的 unified 映射键) + await this._extendSessionMappingTTL(sessionHash) + logger.info( + `🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}` + ) + return mappedAccount + } else { + logger.warn( + `⚠️ Mapped account ${mappedAccount.accountId} is no longer available, selecting new account` + ) + await this._deleteSessionMapping(sessionHash) + } + } + } + } + + // 获取所有可用账户(传递请求的模型进行过滤) + const availableAccounts = await this._getAllAvailableAccounts( + apiKeyData, + effectiveModel, + false // 仅前缀才走 CCR:默认池不包含 CCR 账户 + ) + + if (availableAccounts.length === 0) { + // 提供更详细的错误信息 + if (effectiveModel) { + throw new Error( + `No available Claude accounts support the requested model: ${effectiveModel}` + ) + } else { + throw new Error('No available Claude accounts (neither official nor console)') + } + } + + // 按优先级和最后使用时间排序 + const sortedAccounts = this._sortAccountsByPriority(availableAccounts) + + // 选择第一个账户 + const selectedAccount = sortedAccounts[0] + + // 如果有会话哈希,建立新的映射 + if (sessionHash) { + await this._setSessionMapping( + sessionHash, + selectedAccount.accountId, + selectedAccount.accountType + ) + logger.info( + `🎯 Created new sticky session mapping: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) for session ${sessionHash}` + ) + } + + logger.info( + `🎯 Selected account: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) with priority ${selectedAccount.priority} for API key ${apiKeyData.name}` + ) + + return { + accountId: selectedAccount.accountId, + accountType: selectedAccount.accountType + } + } catch (error) { + logger.error('❌ Failed to select account for API key:', error) + throw error + } + } + + // 📋 获取所有可用账户(合并官方和Console) + async _getAllAvailableAccounts(apiKeyData, requestedModel = null, includeCcr = false) { + const availableAccounts = [] + const isOpusRequest = + requestedModel && typeof requestedModel === 'string' + ? requestedModel.toLowerCase().includes('opus') + : false + + // 如果API Key绑定了专属账户,优先返回 + // 1. 检查Claude OAuth账户绑定 + if (apiKeyData.claudeAccountId) { + const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId) + if ( + boundAccount && + boundAccount.isActive === 'true' && + boundAccount.status !== 'error' && + boundAccount.status !== 'blocked' && + boundAccount.status !== 'temp_error' + ) { + const isRateLimited = await claudeAccountService.isAccountRateLimited(boundAccount.id) + if (isRateLimited) { + const rateInfo = await claudeAccountService.getAccountRateLimitInfo(boundAccount.id) + const error = new Error('Dedicated Claude account is rate limited') + error.code = 'CLAUDE_DEDICATED_RATE_LIMITED' + error.accountId = boundAccount.id + error.rateLimitEndAt = rateInfo?.rateLimitEndAt || boundAccount.rateLimitEndAt || null + throw error + } + + if (!this._isSchedulable(boundAccount.schedulable)) { + logger.warn( + `⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not schedulable (schedulable: ${boundAccount?.schedulable})` + ) + } else { + logger.info( + `🎯 Using bound dedicated Claude OAuth account: ${boundAccount.name} (${apiKeyData.claudeAccountId})` + ) + return [ + { + ...boundAccount, + accountId: boundAccount.id, + accountType: 'claude-official', + priority: parseInt(boundAccount.priority) || 50, + lastUsedAt: boundAccount.lastUsedAt || '0' + } + ] + } + } else { + logger.warn( + `⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not available (isActive: ${boundAccount?.isActive}, status: ${boundAccount?.status})` + ) + } + } + + // 2. 检查Claude Console账户绑定 + if (apiKeyData.claudeConsoleAccountId) { + const boundConsoleAccount = await claudeConsoleAccountService.getAccount( + apiKeyData.claudeConsoleAccountId + ) + if ( + boundConsoleAccount && + boundConsoleAccount.isActive === true && + boundConsoleAccount.status === 'active' && + this._isSchedulable(boundConsoleAccount.schedulable) + ) { + // 主动触发一次额度检查 + try { + await claudeConsoleAccountService.checkQuotaUsage(boundConsoleAccount.id) + } catch (e) { + logger.warn( + `Failed to check quota for bound Claude Console account ${boundConsoleAccount.name}: ${e.message}` + ) + // 继续使用该账号 + } + + // 检查限流状态和额度状态 + const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited( + boundConsoleAccount.id + ) + const isQuotaExceeded = await claudeConsoleAccountService.isAccountQuotaExceeded( + boundConsoleAccount.id + ) + + if (!isRateLimited && !isQuotaExceeded) { + logger.info( + `🎯 Using bound dedicated Claude Console account: ${boundConsoleAccount.name} (${apiKeyData.claudeConsoleAccountId})` + ) + return [ + { + ...boundConsoleAccount, + accountId: boundConsoleAccount.id, + accountType: 'claude-console', + priority: parseInt(boundConsoleAccount.priority) || 50, + lastUsedAt: boundConsoleAccount.lastUsedAt || '0' + } + ] + } + } else { + logger.warn( + `⚠️ Bound Claude Console account ${apiKeyData.claudeConsoleAccountId} is not available (isActive: ${boundConsoleAccount?.isActive}, status: ${boundConsoleAccount?.status}, schedulable: ${boundConsoleAccount?.schedulable})` + ) + } + } + + // 3. 检查Bedrock账户绑定 + if (apiKeyData.bedrockAccountId) { + const boundBedrockAccountResult = await bedrockAccountService.getAccount( + apiKeyData.bedrockAccountId + ) + if ( + boundBedrockAccountResult.success && + boundBedrockAccountResult.data.isActive === true && + this._isSchedulable(boundBedrockAccountResult.data.schedulable) + ) { + logger.info( + `🎯 Using bound dedicated Bedrock account: ${boundBedrockAccountResult.data.name} (${apiKeyData.bedrockAccountId})` + ) + return [ + { + ...boundBedrockAccountResult.data, + accountId: boundBedrockAccountResult.data.id, + accountType: 'bedrock', + priority: parseInt(boundBedrockAccountResult.data.priority) || 50, + lastUsedAt: boundBedrockAccountResult.data.lastUsedAt || '0' + } + ] + } else { + logger.warn( + `⚠️ Bound Bedrock account ${apiKeyData.bedrockAccountId} is not available (isActive: ${boundBedrockAccountResult?.data?.isActive}, schedulable: ${boundBedrockAccountResult?.data?.schedulable})` + ) + } + } + + // 获取官方Claude账户(共享池) + const claudeAccounts = await redis.getAllClaudeAccounts() + for (const account of claudeAccounts) { + if ( + account.isActive === 'true' && + account.status !== 'error' && + account.status !== 'blocked' && + account.status !== 'temp_error' && + (account.accountType === 'shared' || !account.accountType) && // 兼容旧数据 + this._isSchedulable(account.schedulable) + ) { + // 检查是否可调度 + + // 检查模型支持 + if (!this._isModelSupportedByAccount(account, 'claude-official', requestedModel)) { + continue + } + + // 检查是否被限流 + const isRateLimited = await claudeAccountService.isAccountRateLimited(account.id) + if (isRateLimited) { + continue + } + + if (isOpusRequest) { + const isOpusRateLimited = await claudeAccountService.isAccountOpusRateLimited(account.id) + if (isOpusRateLimited) { + logger.info( + `🚫 Skipping account ${account.name} (${account.id}) due to active Opus limit` + ) + continue + } + } + + availableAccounts.push({ + ...account, + accountId: account.id, + accountType: 'claude-official', + priority: parseInt(account.priority) || 50, // 默认优先级50 + lastUsedAt: account.lastUsedAt || '0' + }) + } + } + + // 获取Claude Console账户 + const consoleAccounts = await claudeConsoleAccountService.getAllAccounts() + logger.info(`📋 Found ${consoleAccounts.length} total Claude Console accounts`) + + for (const account of consoleAccounts) { + logger.info( + `🔍 Checking Claude Console account: ${account.name} - isActive: ${account.isActive}, status: ${account.status}, accountType: ${account.accountType}, schedulable: ${account.schedulable}` + ) + + // 注意:getAllAccounts返回的isActive是布尔值 + if ( + account.isActive === true && + account.status === 'active' && + account.accountType === 'shared' && + this._isSchedulable(account.schedulable) + ) { + // 检查是否可调度 + + // 检查模型支持 + if (!this._isModelSupportedByAccount(account, 'claude-console', requestedModel)) { + continue + } + + // 主动触发一次额度检查,确保状态即时生效 + try { + await claudeConsoleAccountService.checkQuotaUsage(account.id) + } catch (e) { + logger.warn( + `Failed to check quota for Claude Console account ${account.name}: ${e.message}` + ) + // 继续处理该账号 + } + + // 检查是否被限流 + const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(account.id) + const isQuotaExceeded = await claudeConsoleAccountService.isAccountQuotaExceeded(account.id) + + if (!isRateLimited && !isQuotaExceeded) { + availableAccounts.push({ + ...account, + accountId: account.id, + accountType: 'claude-console', + priority: parseInt(account.priority) || 50, + lastUsedAt: account.lastUsedAt || '0' + }) + logger.info( + `✅ Added Claude Console account to available pool: ${account.name} (priority: ${account.priority})` + ) + } else { + if (isRateLimited) { + logger.warn(`⚠️ Claude Console account ${account.name} is rate limited`) + } + if (isQuotaExceeded) { + logger.warn(`💰 Claude Console account ${account.name} quota exceeded`) + } + } + } else { + logger.info( + `❌ Claude Console account ${account.name} not eligible - isActive: ${account.isActive}, status: ${account.status}, accountType: ${account.accountType}, schedulable: ${account.schedulable}` + ) + } + } + + // 获取Bedrock账户(共享池) + const bedrockAccountsResult = await bedrockAccountService.getAllAccounts() + if (bedrockAccountsResult.success) { + const bedrockAccounts = bedrockAccountsResult.data + logger.info(`📋 Found ${bedrockAccounts.length} total Bedrock accounts`) + + for (const account of bedrockAccounts) { + logger.info( + `🔍 Checking Bedrock account: ${account.name} - isActive: ${account.isActive}, accountType: ${account.accountType}, schedulable: ${account.schedulable}` + ) + + if ( + account.isActive === true && + account.accountType === 'shared' && + this._isSchedulable(account.schedulable) + ) { + // 检查是否可调度 + + availableAccounts.push({ + ...account, + accountId: account.id, + accountType: 'bedrock', + priority: parseInt(account.priority) || 50, + lastUsedAt: account.lastUsedAt || '0' + }) + logger.info( + `✅ Added Bedrock account to available pool: ${account.name} (priority: ${account.priority})` + ) + } else { + logger.info( + `❌ Bedrock account ${account.name} not eligible - isActive: ${account.isActive}, accountType: ${account.accountType}, schedulable: ${account.schedulable}` + ) + } + } + } + + // 获取CCR账户(共享池)- 仅当明确要求包含时 + if (includeCcr) { + const ccrAccounts = await ccrAccountService.getAllAccounts() + logger.info(`📋 Found ${ccrAccounts.length} total CCR accounts`) + + for (const account of ccrAccounts) { + logger.info( + `🔍 Checking CCR account: ${account.name} - isActive: ${account.isActive}, status: ${account.status}, accountType: ${account.accountType}, schedulable: ${account.schedulable}` + ) + + if ( + account.isActive === true && + account.status === 'active' && + account.accountType === 'shared' && + this._isSchedulable(account.schedulable) + ) { + // 检查模型支持 + if (!this._isModelSupportedByAccount(account, 'ccr', requestedModel)) { + continue + } + + // 检查是否被限流 + const isRateLimited = await ccrAccountService.isAccountRateLimited(account.id) + const isQuotaExceeded = await ccrAccountService.isAccountQuotaExceeded(account.id) + + if (!isRateLimited && !isQuotaExceeded) { + availableAccounts.push({ + ...account, + accountId: account.id, + accountType: 'ccr', + priority: parseInt(account.priority) || 50, + lastUsedAt: account.lastUsedAt || '0' + }) + logger.info( + `✅ Added CCR account to available pool: ${account.name} (priority: ${account.priority})` + ) + } else { + if (isRateLimited) { + logger.warn(`⚠️ CCR account ${account.name} is rate limited`) + } + if (isQuotaExceeded) { + logger.warn(`💰 CCR account ${account.name} quota exceeded`) + } + } + } else { + logger.info( + `❌ CCR account ${account.name} not eligible - isActive: ${account.isActive}, status: ${account.status}, accountType: ${account.accountType}, schedulable: ${account.schedulable}` + ) + } + } + } + + logger.info( + `📊 Total available accounts: ${availableAccounts.length} (Claude: ${availableAccounts.filter((a) => a.accountType === 'claude-official').length}, Console: ${availableAccounts.filter((a) => a.accountType === 'claude-console').length}, Bedrock: ${availableAccounts.filter((a) => a.accountType === 'bedrock').length}, CCR: ${availableAccounts.filter((a) => a.accountType === 'ccr').length})` + ) + return availableAccounts + } + + // 🔢 按优先级和最后使用时间排序账户 + _sortAccountsByPriority(accounts) { + return accounts.sort((a, b) => { + // 首先按优先级排序(数字越小优先级越高) + if (a.priority !== b.priority) { + return a.priority - b.priority + } + + // 优先级相同时,按最后使用时间排序(最久未使用的优先) + const aLastUsed = new Date(a.lastUsedAt || 0).getTime() + const bLastUsed = new Date(b.lastUsedAt || 0).getTime() + return aLastUsed - bLastUsed + }) + } + + // 🔍 检查账户是否可用 + async _isAccountAvailable(accountId, accountType, requestedModel = null) { + try { + if (accountType === 'claude-official') { + const account = await redis.getClaudeAccount(accountId) + if ( + !account || + account.isActive !== 'true' || + account.status === 'error' || + account.status === 'temp_error' + ) { + return false + } + // 检查是否可调度 + if (!this._isSchedulable(account.schedulable)) { + logger.info(`🚫 Account ${accountId} is not schedulable`) + return false + } + + // 检查模型兼容性 + if ( + !this._isModelSupportedByAccount( + account, + 'claude-official', + requestedModel, + 'in session check' + ) + ) { + return false + } + + // 检查是否限流或过载 + const isRateLimited = await claudeAccountService.isAccountRateLimited(accountId) + const isOverloaded = await claudeAccountService.isAccountOverloaded(accountId) + if (isRateLimited || isOverloaded) { + return false + } + + if ( + requestedModel && + typeof requestedModel === 'string' && + requestedModel.toLowerCase().includes('opus') + ) { + const isOpusRateLimited = await claudeAccountService.isAccountOpusRateLimited(accountId) + if (isOpusRateLimited) { + logger.info(`🚫 Account ${accountId} skipped due to active Opus limit (session check)`) + return false + } + } + + return true + } else if (accountType === 'claude-console') { + const account = await claudeConsoleAccountService.getAccount(accountId) + if (!account || !account.isActive) { + return false + } + // 检查账户状态 + if ( + account.status !== 'active' && + account.status !== 'unauthorized' && + account.status !== 'overloaded' + ) { + return false + } + // 检查是否可调度 + if (!this._isSchedulable(account.schedulable)) { + logger.info(`🚫 Claude Console account ${accountId} is not schedulable`) + return false + } + // 检查模型支持 + if ( + !this._isModelSupportedByAccount( + account, + 'claude-console', + requestedModel, + 'in session check' + ) + ) { + return false + } + // 检查是否超额 + try { + await claudeConsoleAccountService.checkQuotaUsage(accountId) + } catch (e) { + logger.warn(`Failed to check quota for Claude Console account ${accountId}: ${e.message}`) + // 继续处理 + } + + // 检查是否被限流 + if (await claudeConsoleAccountService.isAccountRateLimited(accountId)) { + return false + } + if (await claudeConsoleAccountService.isAccountQuotaExceeded(accountId)) { + return false + } + // 检查是否未授权(401错误) + if (account.status === 'unauthorized') { + return false + } + // 检查是否过载(529错误) + if (await claudeConsoleAccountService.isAccountOverloaded(accountId)) { + return false + } + return true + } else if (accountType === 'bedrock') { + const accountResult = await bedrockAccountService.getAccount(accountId) + if (!accountResult.success || !accountResult.data.isActive) { + return false + } + // 检查是否可调度 + if (!this._isSchedulable(accountResult.data.schedulable)) { + logger.info(`🚫 Bedrock account ${accountId} is not schedulable`) + return false + } + // Bedrock账户暂不需要限流检查,因为AWS管理限流 + return true + } else if (accountType === 'ccr') { + const account = await ccrAccountService.getAccount(accountId) + if (!account || !account.isActive) { + return false + } + // 检查账户状态 + if ( + account.status !== 'active' && + account.status !== 'unauthorized' && + account.status !== 'overloaded' + ) { + return false + } + // 检查是否可调度 + if (!this._isSchedulable(account.schedulable)) { + logger.info(`🚫 CCR account ${accountId} is not schedulable`) + return false + } + // 检查模型支持 + if (!this._isModelSupportedByAccount(account, 'ccr', requestedModel, 'in session check')) { + return false + } + // 检查是否超额 + try { + await ccrAccountService.checkQuotaUsage(accountId) + } catch (e) { + logger.warn(`Failed to check quota for CCR account ${accountId}: ${e.message}`) + // 继续处理 + } + + // 检查是否被限流 + if (await ccrAccountService.isAccountRateLimited(accountId)) { + return false + } + if (await ccrAccountService.isAccountQuotaExceeded(accountId)) { + return false + } + // 检查是否未授权(401错误) + if (account.status === 'unauthorized') { + return false + } + // 检查是否过载(529错误) + if (await ccrAccountService.isAccountOverloaded(accountId)) { + return false + } + return true + } + return false + } catch (error) { + logger.warn(`⚠️ Failed to check account availability: ${accountId}`, error) + return false + } + } + + // 🔗 获取会话映射 + async _getSessionMapping(sessionHash) { + const client = redis.getClientSafe() + const mappingData = await client.get(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`) + + if (mappingData) { + try { + return JSON.parse(mappingData) + } catch (error) { + logger.warn('⚠️ Failed to parse session mapping:', error) + return null + } + } + + return null + } + + // 💾 设置会话映射 + async _setSessionMapping(sessionHash, accountId, accountType) { + 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) + } + + // 🗑️ 删除会话映射 + async _deleteSessionMapping(sessionHash) { + const client = redis.getClientSafe() + await client.del(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`) + } + + // 🔁 续期统一调度会话映射TTL(针对 unified_claude_session_mapping:* 键),遵循会话配置 + async _extendSessionMappingTTL(sessionHash) { + try { + const client = redis.getClientSafe() + const key = `${this.SESSION_MAPPING_PREFIX}${sessionHash}` + const remainingTTL = await client.ttl(key) + + // -2: key 不存在;-1: 无过期时间 + if (remainingTTL === -2) { + return false + } + if (remainingTTL === -1) { + return true + } + + const appConfig = require('../../config/config') + const ttlHours = appConfig.session?.stickyTtlHours || 1 + const renewalThresholdMinutes = appConfig.session?.renewalThresholdMinutes || 0 + + // 阈值为0则不续期 + if (!renewalThresholdMinutes) { + return true + } + + const fullTTL = Math.max(1, Math.floor(ttlHours * 60 * 60)) + const threshold = Math.max(0, Math.floor(renewalThresholdMinutes * 60)) + + if (remainingTTL < threshold) { + await client.expire(key, fullTTL) + logger.debug( + `🔄 Renewed unified session TTL: ${sessionHash} (was ${Math.round(remainingTTL / 60)}m, renewed to ${ttlHours}h)` + ) + } else { + logger.debug( + `✅ Unified session TTL sufficient: ${sessionHash} (remaining ${Math.round(remainingTTL / 60)}m)` + ) + } + return true + } catch (error) { + logger.error('❌ Failed to extend unified session TTL:', error) + return false + } + } + + // 🚫 标记账户为限流状态 + async markAccountRateLimited( + accountId, + accountType, + sessionHash = null, + rateLimitResetTimestamp = null + ) { + try { + if (accountType === 'claude-official') { + await claudeAccountService.markAccountRateLimited( + accountId, + sessionHash, + rateLimitResetTimestamp + ) + } else if (accountType === 'claude-console') { + await claudeConsoleAccountService.markAccountRateLimited(accountId) + } else if (accountType === 'ccr') { + await ccrAccountService.markAccountRateLimited(accountId) + } + + // 删除会话映射 + if (sessionHash) { + await this._deleteSessionMapping(sessionHash) + } + + return { success: true } + } catch (error) { + logger.error( + `❌ Failed to mark account as rate limited: ${accountId} (${accountType})`, + error + ) + throw error + } + } + + // ✅ 移除账户的限流状态 + async removeAccountRateLimit(accountId, accountType) { + try { + if (accountType === 'claude-official') { + await claudeAccountService.removeAccountRateLimit(accountId) + } else if (accountType === 'claude-console') { + await claudeConsoleAccountService.removeAccountRateLimit(accountId) + } else if (accountType === 'ccr') { + await ccrAccountService.removeAccountRateLimit(accountId) + } + + return { success: true } + } catch (error) { + logger.error( + `❌ Failed to remove rate limit for account: ${accountId} (${accountType})`, + error + ) + throw error + } + } + + // 🔍 检查账户是否处于限流状态 + async isAccountRateLimited(accountId, accountType) { + try { + if (accountType === 'claude-official') { + return await claudeAccountService.isAccountRateLimited(accountId) + } else if (accountType === 'claude-console') { + return await claudeConsoleAccountService.isAccountRateLimited(accountId) + } else if (accountType === 'ccr') { + return await ccrAccountService.isAccountRateLimited(accountId) + } + return false + } catch (error) { + logger.error(`❌ Failed to check rate limit status: ${accountId} (${accountType})`, error) + return false + } + } + + // 🚫 标记账户为未授权状态(401错误) + async markAccountUnauthorized(accountId, accountType, sessionHash = null) { + try { + // 只处理claude-official类型的账户,不处理claude-console和gemini + if (accountType === 'claude-official') { + await claudeAccountService.markAccountUnauthorized(accountId, sessionHash) + + // 删除会话映射 + if (sessionHash) { + await this._deleteSessionMapping(sessionHash) + } + + logger.warn(`🚫 Account ${accountId} marked as unauthorized due to consecutive 401 errors`) + } else { + logger.info( + `ℹ️ Skipping unauthorized marking for non-Claude OAuth account: ${accountId} (${accountType})` + ) + } + + return { success: true } + } catch (error) { + logger.error( + `❌ Failed to mark account as unauthorized: ${accountId} (${accountType})`, + error + ) + throw error + } + } + + // 🚫 标记账户为被封锁状态(403错误) + async markAccountBlocked(accountId, accountType, sessionHash = null) { + try { + // 只处理claude-official类型的账户,不处理claude-console和gemini + if (accountType === 'claude-official') { + await claudeAccountService.markAccountBlocked(accountId, sessionHash) + + // 删除会话映射 + if (sessionHash) { + await this._deleteSessionMapping(sessionHash) + } + + logger.warn(`🚫 Account ${accountId} marked as blocked due to 403 error`) + } else { + logger.info( + `ℹ️ Skipping blocked marking for non-Claude OAuth account: ${accountId} (${accountType})` + ) + } + + return { success: true } + } catch (error) { + logger.error(`❌ Failed to mark account as blocked: ${accountId} (${accountType})`, error) + throw error + } + } + + // 🚫 标记Claude Console账户为封锁状态(模型不支持) + async blockConsoleAccount(accountId, reason) { + try { + await claudeConsoleAccountService.blockAccount(accountId, reason) + return { success: true } + } catch (error) { + logger.error(`❌ Failed to block console account: ${accountId}`, error) + throw error + } + } + + // 👥 从分组中选择账户 + async selectAccountFromGroup( + groupId, + sessionHash = null, + requestedModel = null, + allowCcr = false + ) { + try { + // 获取分组信息 + const group = await accountGroupService.getGroup(groupId) + if (!group) { + throw new Error(`Group ${groupId} not found`) + } + + logger.info(`👥 Selecting account from group: ${group.name} (${group.platform})`) + + // 如果有会话哈希,检查是否有已映射的账户 + if (sessionHash) { + const mappedAccount = await this._getSessionMapping(sessionHash) + if (mappedAccount) { + // 验证映射的账户是否属于这个分组 + const memberIds = await accountGroupService.getGroupMembers(groupId) + if (memberIds.includes(mappedAccount.accountId)) { + // 非 CCR 请求时不允许 CCR 粘性映射 + if (!allowCcr && mappedAccount.accountType === 'ccr') { + await this._deleteSessionMapping(sessionHash) + } else { + const isAvailable = await this._isAccountAvailable( + mappedAccount.accountId, + mappedAccount.accountType, + requestedModel + ) + if (isAvailable) { + // 🚀 智能会话续期:续期 unified 映射键 + await this._extendSessionMappingTTL(sessionHash) + logger.info( + `🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}` + ) + return mappedAccount + } + } + } + // 如果映射的账户不可用或不在分组中,删除映射 + await this._deleteSessionMapping(sessionHash) + } + } + + // 获取分组内的所有账户 + const memberIds = await accountGroupService.getGroupMembers(groupId) + if (memberIds.length === 0) { + throw new Error(`Group ${group.name} has no members`) + } + + const availableAccounts = [] + const isOpusRequest = + requestedModel && typeof requestedModel === 'string' + ? requestedModel.toLowerCase().includes('opus') + : false + + // 获取所有成员账户的详细信息 + for (const memberId of memberIds) { + let account = null + let accountType = null + + // 根据平台类型获取账户 + if (group.platform === 'claude') { + // 先尝试官方账户 + account = await redis.getClaudeAccount(memberId) + if (account?.id) { + accountType = 'claude-official' + } else { + // 尝试Console账户 + account = await claudeConsoleAccountService.getAccount(memberId) + if (account) { + accountType = 'claude-console' + } else { + // 尝试CCR账户(仅允许在 allowCcr 为 true 时) + if (allowCcr) { + account = await ccrAccountService.getAccount(memberId) + if (account) { + accountType = 'ccr' + } + } + } + } + } else if (group.platform === 'gemini') { + // Gemini暂时不支持,预留接口 + logger.warn('⚠️ Gemini group scheduling not yet implemented') + continue + } + + if (!account) { + logger.warn(`⚠️ Account ${memberId} not found in group ${group.name}`) + continue + } + + // 检查账户是否可用 + const isActive = + accountType === 'claude-official' + ? account.isActive === 'true' + : account.isActive === true + + const status = + accountType === 'claude-official' + ? account.status !== 'error' && account.status !== 'blocked' + : accountType === 'ccr' + ? account.status === 'active' + : account.status === 'active' + + if (isActive && status && this._isSchedulable(account.schedulable)) { + // 检查模型支持 + if (!this._isModelSupportedByAccount(account, accountType, requestedModel, 'in group')) { + continue + } + + // 检查是否被限流 + const isRateLimited = await this.isAccountRateLimited(account.id, accountType) + if (isRateLimited) { + continue + } + + if (accountType === 'claude-official' && isOpusRequest) { + const isOpusRateLimited = await claudeAccountService.isAccountOpusRateLimited( + account.id + ) + if (isOpusRateLimited) { + logger.info( + `🚫 Skipping group member ${account.name} (${account.id}) due to active Opus limit` + ) + continue + } + } + + availableAccounts.push({ + ...account, + accountId: account.id, + accountType, + priority: parseInt(account.priority) || 50, + lastUsedAt: account.lastUsedAt || '0' + }) + } + } + + if (availableAccounts.length === 0) { + throw new Error(`No available accounts in group ${group.name}`) + } + + // 使用现有的优先级排序逻辑 + const sortedAccounts = this._sortAccountsByPriority(availableAccounts) + + // 选择第一个账户 + const selectedAccount = sortedAccounts[0] + + // 如果有会话哈希,建立新的映射 + if (sessionHash) { + await this._setSessionMapping( + sessionHash, + selectedAccount.accountId, + selectedAccount.accountType + ) + logger.info( + `🎯 Created new sticky session mapping in group: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) for session ${sessionHash}` + ) + } + + logger.info( + `🎯 Selected account from group ${group.name}: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) with priority ${selectedAccount.priority}` + ) + + return { + accountId: selectedAccount.accountId, + accountType: selectedAccount.accountType + } + } catch (error) { + logger.error(`❌ Failed to select account from group ${groupId}:`, error) + throw error + } + } + + // 🎯 专门选择CCR账户(仅限CCR前缀路由使用) + async _selectCcrAccount(apiKeyData, sessionHash = null, effectiveModel = null) { + try { + // 1. 检查会话粘性 + if (sessionHash) { + const mappedAccount = await this._getSessionMapping(sessionHash) + if (mappedAccount && mappedAccount.accountType === 'ccr') { + // 验证映射的CCR账户是否仍然可用 + const isAvailable = await this._isAccountAvailable( + mappedAccount.accountId, + mappedAccount.accountType, + effectiveModel + ) + if (isAvailable) { + // 🚀 智能会话续期:续期 unified 映射键 + await this._extendSessionMappingTTL(sessionHash) + logger.info( + `🎯 Using sticky CCR session account: ${mappedAccount.accountId} for session ${sessionHash}` + ) + return mappedAccount + } else { + logger.warn( + `⚠️ Mapped CCR account ${mappedAccount.accountId} is no longer available, selecting new account` + ) + await this._deleteSessionMapping(sessionHash) + } + } + } + + // 2. 获取所有可用的CCR账户 + const availableCcrAccounts = await this._getAvailableCcrAccounts(effectiveModel) + + if (availableCcrAccounts.length === 0) { + throw new Error( + `No available CCR accounts support the requested model: ${effectiveModel || 'unspecified'}` + ) + } + + // 3. 按优先级和最后使用时间排序 + const sortedAccounts = this._sortAccountsByPriority(availableCcrAccounts) + const selectedAccount = sortedAccounts[0] + + // 4. 建立会话映射 + if (sessionHash) { + await this._setSessionMapping( + sessionHash, + selectedAccount.accountId, + selectedAccount.accountType + ) + logger.info( + `🎯 Created new sticky CCR session mapping: ${selectedAccount.name} (${selectedAccount.accountId}) for session ${sessionHash}` + ) + } + + logger.info( + `🎯 Selected CCR account: ${selectedAccount.name} (${selectedAccount.accountId}) with priority ${selectedAccount.priority} for API key ${apiKeyData.name}` + ) + + return { + accountId: selectedAccount.accountId, + accountType: selectedAccount.accountType + } + } catch (error) { + logger.error('❌ Failed to select CCR account:', error) + throw error + } + } + + // 📋 获取所有可用的CCR账户 + async _getAvailableCcrAccounts(requestedModel = null) { + const availableAccounts = [] + + try { + const ccrAccounts = await ccrAccountService.getAllAccounts() + logger.info(`📋 Found ${ccrAccounts.length} total CCR accounts for CCR-only selection`) + + for (const account of ccrAccounts) { + logger.debug( + `🔍 Checking CCR account: ${account.name} - isActive: ${account.isActive}, status: ${account.status}, accountType: ${account.accountType}, schedulable: ${account.schedulable}` + ) + + if ( + account.isActive === true && + account.status === 'active' && + account.accountType === 'shared' && + this._isSchedulable(account.schedulable) + ) { + // 检查模型支持 + if (!this._isModelSupportedByAccount(account, 'ccr', requestedModel)) { + logger.debug(`CCR account ${account.name} does not support model ${requestedModel}`) + continue + } + + // 检查是否被限流或超额 + const isRateLimited = await ccrAccountService.isAccountRateLimited(account.id) + const isQuotaExceeded = await ccrAccountService.isAccountQuotaExceeded(account.id) + const isOverloaded = await ccrAccountService.isAccountOverloaded(account.id) + + if (!isRateLimited && !isQuotaExceeded && !isOverloaded) { + availableAccounts.push({ + ...account, + accountId: account.id, + accountType: 'ccr', + priority: parseInt(account.priority) || 50, + lastUsedAt: account.lastUsedAt || '0' + }) + logger.debug(`✅ Added CCR account to available pool: ${account.name}`) + } else { + logger.debug( + `❌ CCR account ${account.name} not available - rateLimited: ${isRateLimited}, quotaExceeded: ${isQuotaExceeded}, overloaded: ${isOverloaded}` + ) + } + } else { + logger.debug( + `❌ CCR account ${account.name} not eligible - isActive: ${account.isActive}, status: ${account.status}, accountType: ${account.accountType}, schedulable: ${account.schedulable}` + ) + } + } + + logger.info(`📊 Total available CCR accounts: ${availableAccounts.length}`) + return availableAccounts + } catch (error) { + logger.error('❌ Failed to get available CCR accounts:', error) + return [] + } + } +} + +module.exports = new UnifiedClaudeScheduler() diff --git a/src/services/unifiedGeminiScheduler.js b/src/services/unifiedGeminiScheduler.js new file mode 100644 index 0000000000000000000000000000000000000000..88f793bdcac17025211a7c7b98dfd98a52b3d6bf --- /dev/null +++ b/src/services/unifiedGeminiScheduler.js @@ -0,0 +1,546 @@ +const geminiAccountService = require('./geminiAccountService') +const accountGroupService = require('./accountGroupService') +const redis = require('../models/redis') +const logger = require('../utils/logger') + +class UnifiedGeminiScheduler { + constructor() { + this.SESSION_MAPPING_PREFIX = 'unified_gemini_session_mapping:' + } + + // 🔧 辅助方法:检查账户是否可调度(兼容字符串和布尔值) + _isSchedulable(schedulable) { + // 如果是 undefined 或 null,默认为可调度 + if (schedulable === undefined || schedulable === null) { + return true + } + // 明确设置为 false(布尔值)或 'false'(字符串)时不可调度 + return schedulable !== false && schedulable !== 'false' + } + + // 🎯 统一调度Gemini账号 + async selectAccountForApiKey(apiKeyData, sessionHash = null, requestedModel = null) { + try { + // 如果API Key绑定了专属账户或分组,优先使用 + if (apiKeyData.geminiAccountId) { + // 检查是否是分组 + if (apiKeyData.geminiAccountId.startsWith('group:')) { + const groupId = apiKeyData.geminiAccountId.replace('group:', '') + logger.info( + `🎯 API key ${apiKeyData.name} is bound to group ${groupId}, selecting from group` + ) + return await this.selectAccountFromGroup(groupId, sessionHash, requestedModel, apiKeyData) + } + + // 普通专属账户 + const boundAccount = await geminiAccountService.getAccount(apiKeyData.geminiAccountId) + if (boundAccount && boundAccount.isActive === 'true' && 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' + } + } else { + logger.warn( + `⚠️ Bound Gemini account ${apiKeyData.geminiAccountId} is not available, falling back to pool` + ) + } + } + + // 如果有会话哈希,检查是否有已映射的账户 + if (sessionHash) { + const mappedAccount = await this._getSessionMapping(sessionHash) + if (mappedAccount) { + // 验证映射的账户是否仍然可用 + const isAvailable = await this._isAccountAvailable( + mappedAccount.accountId, + mappedAccount.accountType + ) + if (isAvailable) { + // 🚀 智能会话续期(续期 unified 映射键,按配置) + await this._extendSessionMappingTTL(sessionHash) + logger.info( + `🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}` + ) + // 更新账户的最后使用时间 + await geminiAccountService.markAccountUsed(mappedAccount.accountId) + return mappedAccount + } else { + logger.warn( + `⚠️ Mapped account ${mappedAccount.accountId} is no longer available, selecting new account` + ) + await this._deleteSessionMapping(sessionHash) + } + } + } + + // 获取所有可用账户 + const availableAccounts = await this._getAllAvailableAccounts(apiKeyData, requestedModel) + + if (availableAccounts.length === 0) { + // 提供更详细的错误信息 + if (requestedModel) { + throw new Error( + `No available Gemini accounts support the requested model: ${requestedModel}` + ) + } else { + throw new Error('No available Gemini accounts') + } + } + + // 按优先级和最后使用时间排序 + const sortedAccounts = this._sortAccountsByPriority(availableAccounts) + + // 选择第一个账户 + const selectedAccount = sortedAccounts[0] + + // 如果有会话哈希,建立新的映射 + if (sessionHash) { + await this._setSessionMapping( + sessionHash, + selectedAccount.accountId, + selectedAccount.accountType + ) + logger.info( + `🎯 Created new sticky session mapping: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) for session ${sessionHash}` + ) + } + + logger.info( + `🎯 Selected account: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) with priority ${selectedAccount.priority} for API key ${apiKeyData.name}` + ) + + // 更新账户的最后使用时间 + await geminiAccountService.markAccountUsed(selectedAccount.accountId) + + return { + accountId: selectedAccount.accountId, + accountType: selectedAccount.accountType + } + } catch (error) { + logger.error('❌ Failed to select account for API key:', error) + throw error + } + } + + // 📋 获取所有可用账户 + async _getAllAvailableAccounts(apiKeyData, requestedModel = null) { + const availableAccounts = [] + + // 如果API Key绑定了专属账户,优先返回 + if (apiKeyData.geminiAccountId) { + const boundAccount = await geminiAccountService.getAccount(apiKeyData.geminiAccountId) + if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') { + const isRateLimited = await this.isAccountRateLimited(boundAccount.id) + if (!isRateLimited) { + // 检查模型支持 + if ( + requestedModel && + boundAccount.supportedModels && + boundAccount.supportedModels.length > 0 + ) { + // 处理可能带有 models/ 前缀的模型名 + const normalizedModel = requestedModel.replace('models/', '') + const modelSupported = boundAccount.supportedModels.some( + (model) => model.replace('models/', '') === normalizedModel + ) + if (!modelSupported) { + logger.warn( + `⚠️ Bound Gemini account ${boundAccount.name} does not support model ${requestedModel}` + ) + return availableAccounts + } + } + + logger.info( + `🎯 Using bound dedicated Gemini account: ${boundAccount.name} (${apiKeyData.geminiAccountId})` + ) + return [ + { + ...boundAccount, + accountId: boundAccount.id, + accountType: 'gemini', + priority: parseInt(boundAccount.priority) || 50, + lastUsedAt: boundAccount.lastUsedAt || '0' + } + ] + } + } else { + logger.warn(`⚠️ Bound Gemini account ${apiKeyData.geminiAccountId} is not available`) + } + } + + // 获取所有Gemini账户(共享池) + const geminiAccounts = await geminiAccountService.getAllAccounts() + for (const account of geminiAccounts) { + if ( + account.isActive === 'true' && + account.status !== 'error' && + (account.accountType === 'shared' || !account.accountType) && // 兼容旧数据 + this._isSchedulable(account.schedulable) + ) { + // 检查是否可调度 + + // 检查token是否过期 + const isExpired = geminiAccountService.isTokenExpired(account) + if (isExpired && !account.refreshToken) { + logger.warn( + `⚠️ Gemini account ${account.name} token expired and no refresh token available` + ) + continue + } + + // 检查模型支持 + if (requestedModel && account.supportedModels && account.supportedModels.length > 0) { + // 处理可能带有 models/ 前缀的模型名 + const normalizedModel = requestedModel.replace('models/', '') + const modelSupported = account.supportedModels.some( + (model) => model.replace('models/', '') === normalizedModel + ) + if (!modelSupported) { + logger.debug( + `⏭️ Skipping Gemini account ${account.name} - doesn't support model ${requestedModel}` + ) + continue + } + } + + // 检查是否被限流 + const isRateLimited = await this.isAccountRateLimited(account.id) + if (!isRateLimited) { + availableAccounts.push({ + ...account, + accountId: account.id, + accountType: 'gemini', + priority: parseInt(account.priority) || 50, // 默认优先级50 + lastUsedAt: account.lastUsedAt || '0' + }) + } + } + } + + logger.info(`📊 Total available Gemini accounts: ${availableAccounts.length}`) + return availableAccounts + } + + // 🔢 按优先级和最后使用时间排序账户 + _sortAccountsByPriority(accounts) { + return accounts.sort((a, b) => { + // 首先按优先级排序(数字越小优先级越高) + if (a.priority !== b.priority) { + return a.priority - b.priority + } + + // 优先级相同时,按最后使用时间排序(最久未使用的优先) + const aLastUsed = new Date(a.lastUsedAt || 0).getTime() + const bLastUsed = new Date(b.lastUsedAt || 0).getTime() + return aLastUsed - bLastUsed + }) + } + + // 🔍 检查账户是否可用 + async _isAccountAvailable(accountId, accountType) { + try { + if (accountType === 'gemini') { + const account = await geminiAccountService.getAccount(accountId) + if (!account || account.isActive !== 'true' || account.status === 'error') { + return false + } + // 检查是否可调度 + if (!this._isSchedulable(account.schedulable)) { + logger.info(`🚫 Gemini account ${accountId} is not schedulable`) + return false + } + return !(await this.isAccountRateLimited(accountId)) + } + return false + } catch (error) { + logger.warn(`⚠️ Failed to check account availability: ${accountId}`, error) + return false + } + } + + // 🔗 获取会话映射 + async _getSessionMapping(sessionHash) { + const client = redis.getClientSafe() + const mappingData = await client.get(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`) + + if (mappingData) { + try { + return JSON.parse(mappingData) + } catch (error) { + logger.warn('⚠️ Failed to parse session mapping:', error) + return null + } + } + + return null + } + + // 💾 设置会话映射 + async _setSessionMapping(sessionHash, accountId, accountType) { + 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) + } + + // 🗑️ 删除会话映射 + async _deleteSessionMapping(sessionHash) { + const client = redis.getClientSafe() + await client.del(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`) + } + + // 🔁 续期统一调度会话映射TTL(针对 unified_gemini_session_mapping:* 键),遵循会话配置 + async _extendSessionMappingTTL(sessionHash) { + try { + const client = redis.getClientSafe() + const key = `${this.SESSION_MAPPING_PREFIX}${sessionHash}` + const remainingTTL = await client.ttl(key) + + if (remainingTTL === -2) { + return false + } + if (remainingTTL === -1) { + return true + } + + const appConfig = require('../../config/config') + const ttlHours = appConfig.session?.stickyTtlHours || 1 + const renewalThresholdMinutes = appConfig.session?.renewalThresholdMinutes || 0 + if (!renewalThresholdMinutes) { + return true + } + + const fullTTL = Math.max(1, Math.floor(ttlHours * 60 * 60)) + const threshold = Math.max(0, Math.floor(renewalThresholdMinutes * 60)) + + if (remainingTTL < threshold) { + await client.expire(key, fullTTL) + logger.debug( + `🔄 Renewed unified Gemini session TTL: ${sessionHash} (was ${Math.round(remainingTTL / 60)}m, renewed to ${ttlHours}h)` + ) + } else { + logger.debug( + `✅ Unified Gemini session TTL sufficient: ${sessionHash} (remaining ${Math.round(remainingTTL / 60)}m)` + ) + } + return true + } catch (error) { + logger.error('❌ Failed to extend unified Gemini session TTL:', error) + return false + } + } + + // 🚫 标记账户为限流状态 + async markAccountRateLimited(accountId, accountType, sessionHash = null) { + try { + if (accountType === 'gemini') { + await geminiAccountService.setAccountRateLimited(accountId, true) + } + + // 删除会话映射 + if (sessionHash) { + await this._deleteSessionMapping(sessionHash) + } + + return { success: true } + } catch (error) { + logger.error( + `❌ Failed to mark account as rate limited: ${accountId} (${accountType})`, + error + ) + throw error + } + } + + // ✅ 移除账户的限流状态 + async removeAccountRateLimit(accountId, accountType) { + try { + if (accountType === 'gemini') { + await geminiAccountService.setAccountRateLimited(accountId, false) + } + + return { success: true } + } catch (error) { + logger.error( + `❌ Failed to remove rate limit for account: ${accountId} (${accountType})`, + error + ) + throw error + } + } + + // 🔍 检查账户是否处于限流状态 + async isAccountRateLimited(accountId) { + try { + const account = await geminiAccountService.getAccount(accountId) + if (!account) { + return false + } + + if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) { + const limitedAt = new Date(account.rateLimitedAt).getTime() + const now = Date.now() + const limitDuration = 60 * 60 * 1000 // 1小时 + + return now < limitedAt + limitDuration + } + return false + } catch (error) { + logger.error(`❌ Failed to check rate limit status: ${accountId}`, error) + return false + } + } + + // 👥 从分组中选择账户 + async selectAccountFromGroup(groupId, sessionHash = null, requestedModel = null) { + try { + // 获取分组信息 + const group = await accountGroupService.getGroup(groupId) + if (!group) { + throw new Error(`Group ${groupId} not found`) + } + + if (group.platform !== 'gemini') { + throw new Error(`Group ${group.name} is not a Gemini group`) + } + + logger.info(`👥 Selecting account from Gemini group: ${group.name}`) + + // 如果有会话哈希,检查是否有已映射的账户 + if (sessionHash) { + const mappedAccount = await this._getSessionMapping(sessionHash) + if (mappedAccount) { + // 验证映射的账户是否属于这个分组 + const memberIds = await accountGroupService.getGroupMembers(groupId) + if (memberIds.includes(mappedAccount.accountId)) { + const isAvailable = await this._isAccountAvailable( + mappedAccount.accountId, + mappedAccount.accountType + ) + if (isAvailable) { + // 🚀 智能会话续期(续期 unified 映射键,按配置) + await this._extendSessionMappingTTL(sessionHash) + logger.info( + `🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}` + ) + // 更新账户的最后使用时间 + await geminiAccountService.markAccountUsed(mappedAccount.accountId) + return mappedAccount + } + } + // 如果映射的账户不可用或不在分组中,删除映射 + await this._deleteSessionMapping(sessionHash) + } + } + + // 获取分组内的所有账户 + const memberIds = await accountGroupService.getGroupMembers(groupId) + if (memberIds.length === 0) { + throw new Error(`Group ${group.name} has no members`) + } + + const availableAccounts = [] + + // 获取所有成员账户的详细信息 + for (const memberId of memberIds) { + const account = await geminiAccountService.getAccount(memberId) + + if (!account) { + logger.warn(`⚠️ Gemini account ${memberId} not found in group ${group.name}`) + continue + } + + // 检查账户是否可用 + if ( + account.isActive === 'true' && + account.status !== 'error' && + this._isSchedulable(account.schedulable) + ) { + // 检查token是否过期 + const isExpired = geminiAccountService.isTokenExpired(account) + if (isExpired && !account.refreshToken) { + logger.warn( + `⚠️ Gemini account ${account.name} in group token expired and no refresh token available` + ) + continue + } + + // 检查模型支持 + if (requestedModel && account.supportedModels && account.supportedModels.length > 0) { + // 处理可能带有 models/ 前缀的模型名 + const normalizedModel = requestedModel.replace('models/', '') + const modelSupported = account.supportedModels.some( + (model) => model.replace('models/', '') === normalizedModel + ) + if (!modelSupported) { + logger.debug( + `⏭️ Skipping Gemini account ${account.name} in group - doesn't support model ${requestedModel}` + ) + continue + } + } + + // 检查是否被限流 + const isRateLimited = await this.isAccountRateLimited(account.id) + if (!isRateLimited) { + availableAccounts.push({ + ...account, + accountId: account.id, + accountType: 'gemini', + priority: parseInt(account.priority) || 50, + lastUsedAt: account.lastUsedAt || '0' + }) + } + } + } + + if (availableAccounts.length === 0) { + throw new Error(`No available accounts in Gemini group ${group.name}`) + } + + // 使用现有的优先级排序逻辑 + const sortedAccounts = this._sortAccountsByPriority(availableAccounts) + + // 选择第一个账户 + const selectedAccount = sortedAccounts[0] + + // 如果有会话哈希,建立新的映射 + if (sessionHash) { + await this._setSessionMapping( + sessionHash, + selectedAccount.accountId, + selectedAccount.accountType + ) + logger.info( + `🎯 Created new sticky session mapping in group: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) for session ${sessionHash}` + ) + } + + logger.info( + `🎯 Selected account from Gemini group ${group.name}: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) with priority ${selectedAccount.priority}` + ) + + // 更新账户的最后使用时间 + await geminiAccountService.markAccountUsed(selectedAccount.accountId) + + return { + accountId: selectedAccount.accountId, + accountType: selectedAccount.accountType + } + } catch (error) { + logger.error(`❌ Failed to select account from Gemini group ${groupId}:`, error) + throw error + } + } +} + +module.exports = new UnifiedGeminiScheduler() diff --git a/src/services/unifiedOpenAIScheduler.js b/src/services/unifiedOpenAIScheduler.js new file mode 100644 index 0000000000000000000000000000000000000000..bef1a68632258d8dd402e48661130a1ac4872a8c --- /dev/null +++ b/src/services/unifiedOpenAIScheduler.js @@ -0,0 +1,934 @@ +const openaiAccountService = require('./openaiAccountService') +const openaiResponsesAccountService = require('./openaiResponsesAccountService') +const accountGroupService = require('./accountGroupService') +const redis = require('../models/redis') +const logger = require('../utils/logger') + +class UnifiedOpenAIScheduler { + constructor() { + this.SESSION_MAPPING_PREFIX = 'unified_openai_session_mapping:' + } + + // 🔧 辅助方法:检查账户是否可调度(兼容字符串和布尔值) + _isSchedulable(schedulable) { + // 如果是 undefined 或 null,默认为可调度 + if (schedulable === undefined || schedulable === null) { + return true + } + // 明确设置为 false(布尔值)或 'false'(字符串)时不可调度 + return schedulable !== false && schedulable !== 'false' + } + + // 🔧 辅助方法:检查账户是否被限流(兼容字符串和对象格式) + _isRateLimited(rateLimitStatus) { + if (!rateLimitStatus) { + return false + } + + // 兼容字符串格式(Redis 原始数据) + if (typeof rateLimitStatus === 'string') { + return rateLimitStatus === 'limited' + } + + // 兼容对象格式(getAllAccounts 返回的数据) + if (typeof rateLimitStatus === 'object') { + if (rateLimitStatus.isRateLimited === false) { + return false + } + // 检查对象中的 status 字段 + return rateLimitStatus.status === 'limited' || rateLimitStatus.isRateLimited === true + } + + return false + } + + // 🔍 判断账号是否带有限流标记(即便已过期,用于自动恢复) + _hasRateLimitFlag(rateLimitStatus) { + if (!rateLimitStatus) { + return false + } + + if (typeof rateLimitStatus === 'string') { + return rateLimitStatus === 'limited' + } + + if (typeof rateLimitStatus === 'object') { + return rateLimitStatus.status === 'limited' || rateLimitStatus.isRateLimited === true + } + + return false + } + + // ✅ 确保账号在调度前完成限流恢复与 schedulable 校正 + async _ensureAccountReadyForScheduling(account, accountId, { sanitized = true } = {}) { + const hasRateLimitFlag = this._hasRateLimitFlag(account.rateLimitStatus) + let rateLimitChecked = false + let stillLimited = false + + let isSchedulable = this._isSchedulable(account.schedulable) + + if (!isSchedulable) { + if (!hasRateLimitFlag) { + return { canUse: false, reason: 'not_schedulable' } + } + + stillLimited = await this.isAccountRateLimited(accountId) + rateLimitChecked = true + if (stillLimited) { + return { canUse: false, reason: 'rate_limited' } + } + + // 限流已恢复,矫正本地状态 + if (sanitized) { + account.schedulable = true + } else { + account.schedulable = 'true' + } + isSchedulable = true + logger.info(`✅ OpenAI账号 ${account.name || accountId} 已解除限流,恢复调度权限`) + } + + if (hasRateLimitFlag) { + if (!rateLimitChecked) { + stillLimited = await this.isAccountRateLimited(accountId) + rateLimitChecked = true + } + if (stillLimited) { + return { canUse: false, reason: 'rate_limited' } + } + + // 更新本地限流状态,避免重复判定 + if (sanitized) { + account.rateLimitStatus = { + status: 'normal', + isRateLimited: false, + rateLimitedAt: null, + rateLimitResetAt: null, + minutesRemaining: 0 + } + } else { + account.rateLimitStatus = 'normal' + account.rateLimitedAt = null + account.rateLimitResetAt = null + } + + if (account.status === 'rateLimited') { + account.status = 'active' + } + } + + if (!rateLimitChecked) { + stillLimited = await this.isAccountRateLimited(accountId) + if (stillLimited) { + return { canUse: false, reason: 'rate_limited' } + } + } + + return { canUse: true } + } + + // 🎯 统一调度OpenAI账号 + async selectAccountForApiKey(apiKeyData, sessionHash = null, requestedModel = null) { + try { + // 如果API Key绑定了专属账户或分组,优先使用 + if (apiKeyData.openaiAccountId) { + // 检查是否是分组 + if (apiKeyData.openaiAccountId.startsWith('group:')) { + const groupId = apiKeyData.openaiAccountId.replace('group:', '') + logger.info( + `🎯 API key ${apiKeyData.name} is bound to group ${groupId}, selecting from group` + ) + return await this.selectAccountFromGroup(groupId, sessionHash, requestedModel, apiKeyData) + } + + // 普通专属账户 - 根据前缀判断是 OpenAI 还是 OpenAI-Responses 类型 + let boundAccount = null + let accountType = 'openai' + + // 检查是否有 responses: 前缀(用于区分 OpenAI-Responses 账户) + if (apiKeyData.openaiAccountId.startsWith('responses:')) { + const accountId = apiKeyData.openaiAccountId.replace('responses:', '') + boundAccount = await openaiResponsesAccountService.getAccount(accountId) + accountType = 'openai-responses' + } else { + // 普通 OpenAI 账户 + boundAccount = await openaiAccountService.getAccount(apiKeyData.openaiAccountId) + accountType = 'openai' + } + + const isActiveBoundAccount = + boundAccount && + (boundAccount.isActive === true || boundAccount.isActive === 'true') && + boundAccount.status !== 'error' && + boundAccount.status !== 'unauthorized' + + if (isActiveBoundAccount) { + if (accountType === 'openai') { + const readiness = await this._ensureAccountReadyForScheduling( + boundAccount, + boundAccount.id, + { sanitized: false } + ) + + if (!readiness.canUse) { + const isRateLimited = readiness.reason === 'rate_limited' + const errorMsg = isRateLimited + ? `Dedicated account ${boundAccount.name} is currently rate limited` + : `Dedicated account ${boundAccount.name} is not schedulable` + logger.warn(`⚠️ ${errorMsg}`) + const error = new Error(errorMsg) + error.statusCode = isRateLimited ? 429 : 403 + throw error + } + } else { + const hasRateLimitFlag = this._isRateLimited(boundAccount.rateLimitStatus) + if (hasRateLimitFlag) { + const isRateLimitCleared = await openaiResponsesAccountService.checkAndClearRateLimit( + boundAccount.id + ) + if (!isRateLimitCleared) { + const errorMsg = `Dedicated account ${boundAccount.name} is currently rate limited` + logger.warn(`⚠️ ${errorMsg}`) + const error = new Error(errorMsg) + error.statusCode = 429 // Too Many Requests - 限流 + throw error + } + // 限流已解除,刷新账户最新状态,确保后续调度信息准确 + boundAccount = await openaiResponsesAccountService.getAccount(boundAccount.id) + if (!boundAccount) { + const errorMsg = `Dedicated account ${apiKeyData.openaiAccountId} not found after rate limit reset` + logger.warn(`⚠️ ${errorMsg}`) + const error = new Error(errorMsg) + error.statusCode = 404 + throw error + } + } + + if (!this._isSchedulable(boundAccount.schedulable)) { + const errorMsg = `Dedicated account ${boundAccount.name} is not schedulable` + logger.warn(`⚠️ ${errorMsg}`) + const error = new Error(errorMsg) + error.statusCode = 403 // Forbidden - 调度被禁止 + throw error + } + } + + // 专属账户:可选的模型检查(只有明确配置了supportedModels且不为空才检查) + // OpenAI-Responses 账户默认支持所有模型 + if ( + accountType === 'openai' && + requestedModel && + boundAccount.supportedModels && + boundAccount.supportedModels.length > 0 + ) { + const modelSupported = boundAccount.supportedModels.includes(requestedModel) + if (!modelSupported) { + const errorMsg = `Dedicated account ${boundAccount.name} does not support model ${requestedModel}` + logger.warn(`⚠️ ${errorMsg}`) + const error = new Error(errorMsg) + error.statusCode = 400 // Bad Request - 请求参数错误 + throw error + } + } + + logger.info( + `🎯 Using bound dedicated ${accountType} account: ${boundAccount.name} (${boundAccount.id}) for API key ${apiKeyData.name}` + ) + // 更新账户的最后使用时间 + if (accountType === 'openai') { + await openaiAccountService.recordUsage(boundAccount.id, 0) + } else { + await openaiResponsesAccountService.updateAccount(boundAccount.id, { + lastUsedAt: new Date().toISOString() + }) + } + return { + accountId: boundAccount.id, + accountType + } + } else { + // 专属账户不可用时直接报错,不降级到共享池 + let errorMsg + if (!boundAccount) { + errorMsg = `Dedicated account ${apiKeyData.openaiAccountId} not found` + } else if (!(boundAccount.isActive === true || boundAccount.isActive === 'true')) { + errorMsg = `Dedicated account ${boundAccount.name} is not active` + } else if (boundAccount.status === 'unauthorized') { + errorMsg = `Dedicated account ${boundAccount.name} is unauthorized` + } else if (boundAccount.status === 'error') { + errorMsg = `Dedicated account ${boundAccount.name} is not available (error status)` + } else { + errorMsg = `Dedicated account ${boundAccount.name} is not available (inactive or forbidden)` + } + logger.warn(`⚠️ ${errorMsg}`) + const error = new Error(errorMsg) + error.statusCode = boundAccount ? 403 : 404 // Forbidden 或 Not Found + throw error + } + } + + // 如果有会话哈希,检查是否有已映射的账户 + if (sessionHash) { + const mappedAccount = await this._getSessionMapping(sessionHash) + if (mappedAccount) { + // 验证映射的账户是否仍然可用 + const isAvailable = await this._isAccountAvailable( + mappedAccount.accountId, + mappedAccount.accountType + ) + if (isAvailable) { + // 🚀 智能会话续期(续期 unified 映射键,按配置) + await this._extendSessionMappingTTL(sessionHash) + logger.info( + `🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}` + ) + // 更新账户的最后使用时间 + await openaiAccountService.recordUsage(mappedAccount.accountId, 0) + return mappedAccount + } else { + logger.warn( + `⚠️ Mapped account ${mappedAccount.accountId} is no longer available, selecting new account` + ) + await this._deleteSessionMapping(sessionHash) + } + } + } + + // 获取所有可用账户 + const availableAccounts = await this._getAllAvailableAccounts(apiKeyData, requestedModel) + + if (availableAccounts.length === 0) { + // 提供更详细的错误信息 + if (requestedModel) { + const error = new Error( + `No available OpenAI accounts support the requested model: ${requestedModel}` + ) + error.statusCode = 400 // Bad Request - 模型不支持 + throw error + } else { + const error = new Error('No available OpenAI accounts') + error.statusCode = 402 // Payment Required - 资源耗尽 + throw error + } + } + + // 按最后使用时间排序(最久未使用的优先,与 Claude 保持一致) + const sortedAccounts = availableAccounts.sort((a, b) => { + const aLastUsed = new Date(a.lastUsedAt || 0).getTime() + const bLastUsed = new Date(b.lastUsedAt || 0).getTime() + return aLastUsed - bLastUsed // 最久未使用的优先 + }) + + // 选择第一个账户 + const selectedAccount = sortedAccounts[0] + + // 如果有会话哈希,建立新的映射 + if (sessionHash) { + await this._setSessionMapping( + sessionHash, + selectedAccount.accountId, + selectedAccount.accountType + ) + logger.info( + `🎯 Created new sticky session mapping: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) for session ${sessionHash}` + ) + } + + logger.info( + `🎯 Selected account: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) for API key ${apiKeyData.name}` + ) + + // 更新账户的最后使用时间 + await openaiAccountService.recordUsage(selectedAccount.accountId, 0) + + return { + accountId: selectedAccount.accountId, + accountType: selectedAccount.accountType + } + } catch (error) { + logger.error('❌ Failed to select account for API key:', error) + throw error + } + } + + // 📋 获取所有可用账户(仅共享池) + async _getAllAvailableAccounts(apiKeyData, requestedModel = null) { + const availableAccounts = [] + + // 注意:专属账户的处理已经在 selectAccountForApiKey 中完成 + // 这里只处理共享池账户 + + // 获取所有OpenAI账户(共享池) + const openaiAccounts = await openaiAccountService.getAllAccounts() + for (let account of openaiAccounts) { + if ( + account.isActive && + account.status !== 'error' && + (account.accountType === 'shared' || !account.accountType) // 兼容旧数据 + ) { + const accountId = account.id || account.accountId + + const readiness = await this._ensureAccountReadyForScheduling(account, accountId, { + sanitized: true + }) + + if (!readiness.canUse) { + if (readiness.reason === 'rate_limited') { + logger.debug(`⏭️ 跳过 OpenAI 账号 ${account.name} - 仍处于限流状态`) + } else { + logger.debug(`⏭️ 跳过 OpenAI 账号 ${account.name} - 已被管理员禁用调度`) + } + continue + } + + // 检查token是否过期并自动刷新 + const isExpired = openaiAccountService.isTokenExpired(account) + if (isExpired) { + if (!account.refreshToken) { + logger.warn( + `⚠️ OpenAI account ${account.name} token expired and no refresh token available` + ) + continue + } + + // 自动刷新过期的 token + try { + logger.info(`🔄 Auto-refreshing expired token for OpenAI account ${account.name}`) + await openaiAccountService.refreshAccountToken(account.id) + // 重新获取更新后的账户信息 + account = await openaiAccountService.getAccount(account.id) + logger.info(`✅ Token refreshed successfully for ${account.name}`) + } catch (refreshError) { + logger.error(`❌ Failed to refresh token for ${account.name}:`, refreshError.message) + continue // 刷新失败,跳过此账户 + } + } + + // 检查模型支持(仅在明确设置了supportedModels且不为空时才检查) + // 如果没有设置supportedModels或为空数组,则支持所有模型 + if (requestedModel && account.supportedModels && account.supportedModels.length > 0) { + const modelSupported = account.supportedModels.includes(requestedModel) + if (!modelSupported) { + logger.debug( + `⏭️ Skipping OpenAI account ${account.name} - doesn't support model ${requestedModel}` + ) + continue + } + } + + availableAccounts.push({ + ...account, + accountId: account.id, + accountType: 'openai', + priority: parseInt(account.priority) || 50, + lastUsedAt: account.lastUsedAt || '0' + }) + } + } + + // 获取所有 OpenAI-Responses 账户(共享池) + const openaiResponsesAccounts = await openaiResponsesAccountService.getAllAccounts() + for (const account of openaiResponsesAccounts) { + if ( + (account.isActive === true || account.isActive === 'true') && + account.status !== 'error' && + account.status !== 'rateLimited' && + (account.accountType === 'shared' || !account.accountType) + ) { + const hasRateLimitFlag = this._hasRateLimitFlag(account.rateLimitStatus) + const schedulable = this._isSchedulable(account.schedulable) + + if (!schedulable && !hasRateLimitFlag) { + logger.debug(`⏭️ Skipping OpenAI-Responses account ${account.name} - not schedulable`) + continue + } + + let isRateLimitCleared = false + if (hasRateLimitFlag) { + isRateLimitCleared = await openaiResponsesAccountService.checkAndClearRateLimit( + account.id + ) + + if (!isRateLimitCleared) { + logger.debug(`⏭️ Skipping OpenAI-Responses account ${account.name} - rate limited`) + continue + } + + if (!schedulable) { + account.schedulable = 'true' + account.status = 'active' + logger.info(`✅ OpenAI-Responses账号 ${account.name} 已解除限流,恢复调度权限`) + } + } + + // OpenAI-Responses 账户默认支持所有模型 + // 因为它们是第三方兼容 API,模型支持由第三方决定 + + availableAccounts.push({ + ...account, + accountId: account.id, + accountType: 'openai-responses', + priority: parseInt(account.priority) || 50, + lastUsedAt: account.lastUsedAt || '0' + }) + } + } + + return availableAccounts + } + + // 🔢 按优先级和最后使用时间排序账户(已废弃,改为与 Claude 保持一致,只按最后使用时间排序) + // _sortAccountsByPriority(accounts) { + // return accounts.sort((a, b) => { + // // 首先按优先级排序(数字越小优先级越高) + // if (a.priority !== b.priority) { + // return a.priority - b.priority + // } + + // // 优先级相同时,按最后使用时间排序(最久未使用的优先) + // const aLastUsed = new Date(a.lastUsedAt || 0).getTime() + // const bLastUsed = new Date(b.lastUsedAt || 0).getTime() + // return aLastUsed - bLastUsed + // }) + // } + + // 🔍 检查账户是否可用 + async _isAccountAvailable(accountId, accountType) { + try { + if (accountType === 'openai') { + const account = await openaiAccountService.getAccount(accountId) + if ( + !account || + !account.isActive || + account.status === 'error' || + account.status === 'unauthorized' + ) { + return false + } + const readiness = await this._ensureAccountReadyForScheduling(account, accountId, { + sanitized: false + }) + + if (!readiness.canUse) { + if (readiness.reason === 'rate_limited') { + logger.debug( + `🚫 OpenAI account ${accountId} still rate limited when checking availability` + ) + } else { + logger.info(`🚫 OpenAI account ${accountId} is not schedulable`) + } + return false + } + + return true + } else if (accountType === 'openai-responses') { + const account = await openaiResponsesAccountService.getAccount(accountId) + if ( + !account || + (account.isActive !== true && account.isActive !== 'true') || + account.status === 'error' || + account.status === 'unauthorized' + ) { + return false + } + // 检查是否可调度 + if (!this._isSchedulable(account.schedulable)) { + logger.info(`🚫 OpenAI-Responses account ${accountId} is not schedulable`) + return false + } + // 检查并清除过期的限流状态 + const isRateLimitCleared = + await openaiResponsesAccountService.checkAndClearRateLimit(accountId) + return !this._isRateLimited(account.rateLimitStatus) || isRateLimitCleared + } + return false + } catch (error) { + logger.warn(`⚠️ Failed to check account availability: ${accountId}`, error) + return false + } + } + + // 🔗 获取会话映射 + async _getSessionMapping(sessionHash) { + const client = redis.getClientSafe() + const mappingData = await client.get(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`) + + if (mappingData) { + try { + return JSON.parse(mappingData) + } catch (error) { + logger.warn('⚠️ Failed to parse session mapping:', error) + return null + } + } + + return null + } + + // 💾 设置会话映射 + async _setSessionMapping(sessionHash, accountId, accountType) { + 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) + } + + // 🗑️ 删除会话映射 + async _deleteSessionMapping(sessionHash) { + const client = redis.getClientSafe() + await client.del(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`) + } + + // 🔁 续期统一调度会话映射TTL(针对 unified_openai_session_mapping:* 键),遵循会话配置 + async _extendSessionMappingTTL(sessionHash) { + try { + const client = redis.getClientSafe() + const key = `${this.SESSION_MAPPING_PREFIX}${sessionHash}` + const remainingTTL = await client.ttl(key) + + if (remainingTTL === -2) { + return false + } + if (remainingTTL === -1) { + return true + } + + const appConfig = require('../../config/config') + const ttlHours = appConfig.session?.stickyTtlHours || 1 + const renewalThresholdMinutes = appConfig.session?.renewalThresholdMinutes || 0 + if (!renewalThresholdMinutes) { + return true + } + + const fullTTL = Math.max(1, Math.floor(ttlHours * 60 * 60)) + const threshold = Math.max(0, Math.floor(renewalThresholdMinutes * 60)) + + if (remainingTTL < threshold) { + await client.expire(key, fullTTL) + logger.debug( + `🔄 Renewed unified OpenAI session TTL: ${sessionHash} (was ${Math.round(remainingTTL / 60)}m, renewed to ${ttlHours}h)` + ) + } else { + logger.debug( + `✅ Unified OpenAI session TTL sufficient: ${sessionHash} (remaining ${Math.round(remainingTTL / 60)}m)` + ) + } + return true + } catch (error) { + logger.error('❌ Failed to extend unified OpenAI session TTL:', error) + return false + } + } + + // 🚫 标记账户为限流状态 + async markAccountRateLimited(accountId, accountType, sessionHash = null, resetsInSeconds = null) { + try { + if (accountType === 'openai') { + await openaiAccountService.setAccountRateLimited(accountId, true, resetsInSeconds) + } else if (accountType === 'openai-responses') { + // 对于 OpenAI-Responses 账户,使用与普通 OpenAI 账户类似的处理方式 + const duration = resetsInSeconds ? Math.ceil(resetsInSeconds / 60) : null + await openaiResponsesAccountService.markAccountRateLimited(accountId, duration) + + // 同时更新调度状态,避免继续被调度 + await openaiResponsesAccountService.updateAccount(accountId, { + schedulable: 'false', + rateLimitResetAt: resetsInSeconds + ? new Date(Date.now() + resetsInSeconds * 1000).toISOString() + : new Date(Date.now() + 3600000).toISOString() // 默认1小时 + }) + } + + // 删除会话映射 + if (sessionHash) { + await this._deleteSessionMapping(sessionHash) + } + + return { success: true } + } catch (error) { + logger.error( + `❌ Failed to mark account as rate limited: ${accountId} (${accountType})`, + error + ) + throw error + } + } + + // 🚫 标记账户为未授权状态 + async markAccountUnauthorized( + accountId, + accountType, + sessionHash = null, + reason = 'OpenAI账号认证失败(401错误)' + ) { + try { + if (accountType === 'openai') { + await openaiAccountService.markAccountUnauthorized(accountId, reason) + } else if (accountType === 'openai-responses') { + await openaiResponsesAccountService.markAccountUnauthorized(accountId, reason) + } else { + logger.warn( + `⚠️ Unsupported account type ${accountType} when marking unauthorized for account ${accountId}` + ) + return { success: false } + } + + if (sessionHash) { + await this._deleteSessionMapping(sessionHash) + } + + return { success: true } + } catch (error) { + logger.error( + `❌ Failed to mark account as unauthorized: ${accountId} (${accountType})`, + error + ) + throw error + } + } + + // ✅ 移除账户的限流状态 + async removeAccountRateLimit(accountId, accountType) { + try { + if (accountType === 'openai') { + await openaiAccountService.setAccountRateLimited(accountId, false) + } else if (accountType === 'openai-responses') { + // 清除 OpenAI-Responses 账户的限流状态 + await openaiResponsesAccountService.updateAccount(accountId, { + rateLimitedAt: '', + rateLimitStatus: '', + rateLimitResetAt: '', + status: 'active', + errorMessage: '', + schedulable: 'true' + }) + logger.info(`✅ Rate limit cleared for OpenAI-Responses account ${accountId}`) + } + + return { success: true } + } catch (error) { + logger.error( + `❌ Failed to remove rate limit for account: ${accountId} (${accountType})`, + error + ) + throw error + } + } + + // 🔍 检查账户是否处于限流状态 + async isAccountRateLimited(accountId) { + try { + const account = await openaiAccountService.getAccount(accountId) + if (!account) { + return false + } + + if (this._isRateLimited(account.rateLimitStatus)) { + // 如果有具体的重置时间,使用它 + if (account.rateLimitResetAt) { + const resetTime = new Date(account.rateLimitResetAt).getTime() + const now = Date.now() + const isStillLimited = now < resetTime + + // 如果已经过了重置时间,自动清除限流状态 + if (!isStillLimited) { + logger.info(`✅ Auto-clearing rate limit for account ${accountId} (reset time reached)`) + await openaiAccountService.setAccountRateLimited(accountId, false) + return false + } + + return isStillLimited + } + + // 如果没有具体的重置时间,使用默认的1小时 + if (account.rateLimitedAt) { + const limitedAt = new Date(account.rateLimitedAt).getTime() + const now = Date.now() + const limitDuration = 60 * 60 * 1000 // 1小时 + return now < limitedAt + limitDuration + } + } + return false + } catch (error) { + logger.error(`❌ Failed to check rate limit status: ${accountId}`, error) + return false + } + } + + // 👥 从分组中选择账户 + async selectAccountFromGroup(groupId, sessionHash = null, requestedModel = null) { + try { + // 获取分组信息 + const group = await accountGroupService.getGroup(groupId) + if (!group) { + const error = new Error(`Group ${groupId} not found`) + error.statusCode = 404 // Not Found - 资源不存在 + throw error + } + + if (group.platform !== 'openai') { + const error = new Error(`Group ${group.name} is not an OpenAI group`) + error.statusCode = 400 // Bad Request - 请求参数错误 + throw error + } + + logger.info(`👥 Selecting account from OpenAI group: ${group.name}`) + + // 如果有会话哈希,检查是否有已映射的账户 + if (sessionHash) { + const mappedAccount = await this._getSessionMapping(sessionHash) + if (mappedAccount) { + // 验证映射的账户是否仍然可用并且在分组中 + const isInGroup = await this._isAccountInGroup(mappedAccount.accountId, groupId) + if (isInGroup) { + const isAvailable = await this._isAccountAvailable( + mappedAccount.accountId, + mappedAccount.accountType + ) + if (isAvailable) { + // 🚀 智能会话续期(续期 unified 映射键,按配置) + await this._extendSessionMappingTTL(sessionHash) + logger.info( + `🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType})` + ) + // 更新账户的最后使用时间 + await openaiAccountService.recordUsage(mappedAccount.accountId, 0) + return mappedAccount + } + } + // 如果账户不可用或不在分组中,删除映射 + await this._deleteSessionMapping(sessionHash) + } + } + + // 获取分组成员 + const memberIds = await accountGroupService.getGroupMembers(groupId) + if (memberIds.length === 0) { + const error = new Error(`Group ${group.name} has no members`) + error.statusCode = 402 // Payment Required - 资源耗尽 + throw error + } + + // 获取可用的分组成员账户 + const availableAccounts = [] + for (const memberId of memberIds) { + const account = await openaiAccountService.getAccount(memberId) + if (account && account.isActive && account.status !== 'error') { + const readiness = await this._ensureAccountReadyForScheduling(account, account.id, { + sanitized: false + }) + + if (!readiness.canUse) { + if (readiness.reason === 'rate_limited') { + logger.debug( + `⏭️ Skipping group member OpenAI account ${account.name} - still rate limited` + ) + } else { + logger.debug( + `⏭️ Skipping group member OpenAI account ${account.name} - not schedulable` + ) + } + continue + } + + // 检查token是否过期 + const isExpired = openaiAccountService.isTokenExpired(account) + if (isExpired && !account.refreshToken) { + logger.warn( + `⚠️ Group member OpenAI account ${account.name} token expired and no refresh token available` + ) + continue + } + + // 检查模型支持(仅在明确设置了supportedModels且不为空时才检查) + // 如果没有设置supportedModels或为空数组,则支持所有模型 + if (requestedModel && account.supportedModels && account.supportedModels.length > 0) { + const modelSupported = account.supportedModels.includes(requestedModel) + if (!modelSupported) { + logger.debug( + `⏭️ Skipping group member OpenAI account ${account.name} - doesn't support model ${requestedModel}` + ) + continue + } + } + + // 检查是否被限流 + availableAccounts.push({ + ...account, + accountId: account.id, + accountType: 'openai', + priority: parseInt(account.priority) || 50, + lastUsedAt: account.lastUsedAt || '0' + }) + } + } + + if (availableAccounts.length === 0) { + const error = new Error(`No available accounts in group ${group.name}`) + error.statusCode = 402 // Payment Required - 资源耗尽 + throw error + } + + // 按最后使用时间排序(最久未使用的优先,与 Claude 保持一致) + const sortedAccounts = availableAccounts.sort((a, b) => { + const aLastUsed = new Date(a.lastUsedAt || 0).getTime() + const bLastUsed = new Date(b.lastUsedAt || 0).getTime() + return aLastUsed - bLastUsed // 最久未使用的优先 + }) + + // 选择第一个账户 + const selectedAccount = sortedAccounts[0] + + // 如果有会话哈希,建立新的映射 + if (sessionHash) { + await this._setSessionMapping( + sessionHash, + selectedAccount.accountId, + selectedAccount.accountType + ) + logger.info( + `🎯 Created new sticky session mapping from group: ${selectedAccount.name} (${selectedAccount.accountId})` + ) + } + + logger.info( + `🎯 Selected account from group: ${selectedAccount.name} (${selectedAccount.accountId})` + ) + + // 更新账户的最后使用时间 + await openaiAccountService.recordUsage(selectedAccount.accountId, 0) + + return { + accountId: selectedAccount.accountId, + accountType: selectedAccount.accountType + } + } catch (error) { + logger.error(`❌ Failed to select account from group ${groupId}:`, error) + throw error + } + } + + // 🔍 检查账户是否在分组中 + async _isAccountInGroup(accountId, groupId) { + const members = await accountGroupService.getGroupMembers(groupId) + return members.includes(accountId) + } + + // 📊 更新账户最后使用时间 + async updateAccountLastUsed(accountId, accountType) { + try { + if (accountType === 'openai') { + await openaiAccountService.updateAccount(accountId, { + lastUsedAt: new Date().toISOString() + }) + } + } catch (error) { + logger.warn(`⚠️ Failed to update last used time for account ${accountId}:`, error) + } + } +} + +module.exports = new UnifiedOpenAIScheduler() diff --git a/src/services/userService.js b/src/services/userService.js new file mode 100644 index 0000000000000000000000000000000000000000..00f0665f3cb29bec5ed493983f690302ed1f6b43 --- /dev/null +++ b/src/services/userService.js @@ -0,0 +1,593 @@ +const redis = require('../models/redis') +const crypto = require('crypto') +const logger = require('../utils/logger') +const config = require('../../config/config') + +class UserService { + constructor() { + this.userPrefix = 'user:' + this.usernamePrefix = 'username:' + this.userSessionPrefix = 'user_session:' + } + + // 🔑 生成用户ID + generateUserId() { + return crypto.randomBytes(16).toString('hex') + } + + // 🔑 生成会话Token + generateSessionToken() { + return crypto.randomBytes(32).toString('hex') + } + + // 👤 创建或更新用户 + async createOrUpdateUser(userData) { + try { + const { + username, + email, + displayName, + firstName, + lastName, + role = config.userManagement.defaultUserRole, + isActive = true + } = userData + + // 检查用户是否已存在 + let user = await this.getUserByUsername(username) + const isNewUser = !user + + if (isNewUser) { + const userId = this.generateUserId() + user = { + id: userId, + username, + email, + displayName, + firstName, + lastName, + role, + isActive, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + lastLoginAt: null, + apiKeyCount: 0, + totalUsage: { + requests: 0, + inputTokens: 0, + outputTokens: 0, + totalCost: 0 + } + } + } else { + // 更新现有用户信息 + user = { + ...user, + email, + displayName, + firstName, + lastName, + updatedAt: new Date().toISOString() + } + } + + // 保存用户信息 + await redis.set(`${this.userPrefix}${user.id}`, JSON.stringify(user)) + await redis.set(`${this.usernamePrefix}${username}`, user.id) + + // 如果是新用户,尝试转移匹配的API Keys + if (isNewUser) { + await this.transferMatchingApiKeys(user) + } + + logger.info(`📝 ${isNewUser ? 'Created' : 'Updated'} user: ${username} (${user.id})`) + return user + } catch (error) { + logger.error('❌ Error creating/updating user:', error) + throw error + } + } + + // 👤 通过用户名获取用户 + async getUserByUsername(username) { + try { + const userId = await redis.get(`${this.usernamePrefix}${username}`) + if (!userId) { + return null + } + + const userData = await redis.get(`${this.userPrefix}${userId}`) + return userData ? JSON.parse(userData) : null + } catch (error) { + logger.error('❌ Error getting user by username:', error) + throw error + } + } + + // 👤 通过ID获取用户 + async getUserById(userId, calculateUsage = true) { + try { + const userData = await redis.get(`${this.userPrefix}${userId}`) + if (!userData) { + return null + } + + const user = JSON.parse(userData) + + // Calculate totalUsage by aggregating user's API keys usage (if requested) + if (calculateUsage) { + try { + const usageStats = await this.calculateUserUsageStats(userId) + user.totalUsage = usageStats.totalUsage + user.apiKeyCount = usageStats.apiKeyCount + } catch (error) { + logger.error('❌ Error calculating user usage stats:', error) + // Fallback to stored values if calculation fails + user.totalUsage = user.totalUsage || { + requests: 0, + inputTokens: 0, + outputTokens: 0, + totalCost: 0 + } + user.apiKeyCount = user.apiKeyCount || 0 + } + } + + return user + } catch (error) { + logger.error('❌ Error getting user by ID:', error) + throw error + } + } + + // 📊 计算用户使用统计(通过聚合API Keys) + async calculateUserUsageStats(userId) { + try { + // Use the existing apiKeyService method which already includes usage stats + const apiKeyService = require('./apiKeyService') + const userApiKeys = await apiKeyService.getUserApiKeys(userId, true) // Include deleted keys for stats + + const totalUsage = { + requests: 0, + inputTokens: 0, + outputTokens: 0, + totalCost: 0 + } + + for (const apiKey of userApiKeys) { + if (apiKey.usage && apiKey.usage.total) { + totalUsage.requests += apiKey.usage.total.requests || 0 + totalUsage.inputTokens += apiKey.usage.total.inputTokens || 0 + totalUsage.outputTokens += apiKey.usage.total.outputTokens || 0 + totalUsage.totalCost += apiKey.totalCost || 0 + } + } + + logger.debug( + `📊 Calculated user ${userId} usage: ${totalUsage.requests} requests, ${totalUsage.inputTokens} input tokens, $${totalUsage.totalCost.toFixed(4)} total cost from ${userApiKeys.length} API keys` + ) + + // Count only non-deleted API keys for the user's active count + const activeApiKeyCount = userApiKeys.filter((key) => key.isDeleted !== 'true').length + + return { + totalUsage, + apiKeyCount: activeApiKeyCount + } + } catch (error) { + logger.error('❌ Error calculating user usage stats:', error) + return { + totalUsage: { + requests: 0, + inputTokens: 0, + outputTokens: 0, + totalCost: 0 + }, + apiKeyCount: 0 + } + } + } + + // 📋 获取所有用户列表(管理员功能) + async getAllUsers(options = {}) { + try { + const client = redis.getClientSafe() + const { page = 1, limit = 20, role, isActive } = options + const pattern = `${this.userPrefix}*` + const keys = await client.keys(pattern) + + const users = [] + for (const key of keys) { + const userData = await client.get(key) + if (userData) { + const user = JSON.parse(userData) + + // 应用过滤条件 + if (role && user.role !== role) { + continue + } + if (typeof isActive === 'boolean' && user.isActive !== isActive) { + continue + } + + // Calculate dynamic usage stats for each user + try { + const usageStats = await this.calculateUserUsageStats(user.id) + user.totalUsage = usageStats.totalUsage + user.apiKeyCount = usageStats.apiKeyCount + } catch (error) { + logger.error(`❌ Error calculating usage for user ${user.id}:`, error) + // Fallback to stored values + user.totalUsage = user.totalUsage || { + requests: 0, + inputTokens: 0, + outputTokens: 0, + totalCost: 0 + } + user.apiKeyCount = user.apiKeyCount || 0 + } + + users.push(user) + } + } + + // 排序和分页 + users.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) + const startIndex = (page - 1) * limit + const endIndex = startIndex + limit + const paginatedUsers = users.slice(startIndex, endIndex) + + return { + users: paginatedUsers, + total: users.length, + page, + limit, + totalPages: Math.ceil(users.length / limit) + } + } catch (error) { + logger.error('❌ Error getting all users:', error) + throw error + } + } + + // 🔄 更新用户状态 + async updateUserStatus(userId, isActive) { + try { + const user = await this.getUserById(userId, false) // Skip usage calculation + if (!user) { + throw new Error('User not found') + } + + user.isActive = isActive + user.updatedAt = new Date().toISOString() + + await redis.set(`${this.userPrefix}${userId}`, JSON.stringify(user)) + logger.info(`🔄 Updated user status: ${user.username} -> ${isActive ? 'active' : 'disabled'}`) + + // 如果禁用用户,删除所有会话并禁用其所有API Keys + if (!isActive) { + await this.invalidateUserSessions(userId) + + // Disable all user's API keys when user is disabled + try { + const apiKeyService = require('./apiKeyService') + const result = await apiKeyService.disableUserApiKeys(userId) + logger.info(`🔑 Disabled ${result.count} API keys for disabled user: ${user.username}`) + } catch (error) { + logger.error('❌ Error disabling user API keys during user disable:', error) + } + } + + return user + } catch (error) { + logger.error('❌ Error updating user status:', error) + throw error + } + } + + // 🔄 更新用户角色 + async updateUserRole(userId, role) { + try { + const user = await this.getUserById(userId, false) // Skip usage calculation + if (!user) { + throw new Error('User not found') + } + + user.role = role + user.updatedAt = new Date().toISOString() + + await redis.set(`${this.userPrefix}${userId}`, JSON.stringify(user)) + logger.info(`🔄 Updated user role: ${user.username} -> ${role}`) + + return user + } catch (error) { + logger.error('❌ Error updating user role:', error) + throw error + } + } + + // 📊 更新用户API Key数量 (已废弃,现在通过聚合计算) + async updateUserApiKeyCount(userId, _count) { + // This method is deprecated since apiKeyCount is now calculated dynamically + // in getUserById by aggregating the user's API keys + logger.debug( + `📊 updateUserApiKeyCount called for ${userId} but is now deprecated (count auto-calculated)` + ) + } + + // 📝 记录用户登录 + async recordUserLogin(userId) { + try { + const user = await this.getUserById(userId, false) // Skip usage calculation + if (!user) { + return + } + + user.lastLoginAt = new Date().toISOString() + await redis.set(`${this.userPrefix}${userId}`, JSON.stringify(user)) + } catch (error) { + logger.error('❌ Error recording user login:', error) + } + } + + // 🎫 创建用户会话 + async createUserSession(userId, sessionData = {}) { + try { + const sessionToken = this.generateSessionToken() + const session = { + token: sessionToken, + userId, + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + config.userManagement.userSessionTimeout).toISOString(), + ...sessionData + } + + const ttl = Math.floor(config.userManagement.userSessionTimeout / 1000) + await redis.setex(`${this.userSessionPrefix}${sessionToken}`, ttl, JSON.stringify(session)) + + logger.info(`🎫 Created session for user: ${userId}`) + return sessionToken + } catch (error) { + logger.error('❌ Error creating user session:', error) + throw error + } + } + + // 🎫 验证用户会话 + async validateUserSession(sessionToken) { + try { + const sessionData = await redis.get(`${this.userSessionPrefix}${sessionToken}`) + if (!sessionData) { + return null + } + + const session = JSON.parse(sessionData) + + // 检查会话是否过期 + if (new Date() > new Date(session.expiresAt)) { + await this.invalidateUserSession(sessionToken) + return null + } + + // 获取用户信息 + const user = await this.getUserById(session.userId, false) // Skip usage calculation for validation + if (!user || !user.isActive) { + await this.invalidateUserSession(sessionToken) + return null + } + + return { session, user } + } catch (error) { + logger.error('❌ Error validating user session:', error) + return null + } + } + + // 🚫 使用户会话失效 + async invalidateUserSession(sessionToken) { + try { + await redis.del(`${this.userSessionPrefix}${sessionToken}`) + logger.info(`🚫 Invalidated session: ${sessionToken}`) + } catch (error) { + logger.error('❌ Error invalidating user session:', error) + } + } + + // 🚫 使用户所有会话失效 + async invalidateUserSessions(userId) { + try { + const client = redis.getClientSafe() + const pattern = `${this.userSessionPrefix}*` + const keys = await client.keys(pattern) + + for (const key of keys) { + const sessionData = await client.get(key) + if (sessionData) { + const session = JSON.parse(sessionData) + if (session.userId === userId) { + await client.del(key) + } + } + } + + logger.info(`🚫 Invalidated all sessions for user: ${userId}`) + } catch (error) { + logger.error('❌ Error invalidating user sessions:', error) + } + } + + // 🗑️ 删除用户(软删除,标记为不活跃) + async deleteUser(userId) { + try { + const user = await this.getUserById(userId, false) // Skip usage calculation + if (!user) { + throw new Error('User not found') + } + + // 软删除:标记为不活跃并添加删除时间戳 + user.isActive = false + user.deletedAt = new Date().toISOString() + user.updatedAt = new Date().toISOString() + + await redis.set(`${this.userPrefix}${userId}`, JSON.stringify(user)) + + // 删除所有会话 + await this.invalidateUserSessions(userId) + + // Disable all user's API keys when user is deleted + try { + const apiKeyService = require('./apiKeyService') + const result = await apiKeyService.disableUserApiKeys(userId) + logger.info(`🔑 Disabled ${result.count} API keys for deleted user: ${user.username}`) + } catch (error) { + logger.error('❌ Error disabling user API keys during user deletion:', error) + } + + logger.info(`🗑️ Soft deleted user: ${user.username} (${userId})`) + return user + } catch (error) { + logger.error('❌ Error deleting user:', error) + throw error + } + } + + // 📊 获取用户统计信息 + async getUserStats() { + try { + const client = redis.getClientSafe() + const pattern = `${this.userPrefix}*` + const keys = await client.keys(pattern) + + const stats = { + totalUsers: 0, + activeUsers: 0, + adminUsers: 0, + regularUsers: 0, + totalApiKeys: 0, + totalUsage: { + requests: 0, + inputTokens: 0, + outputTokens: 0, + totalCost: 0 + } + } + + for (const key of keys) { + const userData = await client.get(key) + if (userData) { + const user = JSON.parse(userData) + stats.totalUsers++ + + if (user.isActive) { + stats.activeUsers++ + } + + if (user.role === 'admin') { + stats.adminUsers++ + } else { + stats.regularUsers++ + } + + // Calculate dynamic usage stats for each user + try { + const usageStats = await this.calculateUserUsageStats(user.id) + stats.totalApiKeys += usageStats.apiKeyCount + stats.totalUsage.requests += usageStats.totalUsage.requests + stats.totalUsage.inputTokens += usageStats.totalUsage.inputTokens + stats.totalUsage.outputTokens += usageStats.totalUsage.outputTokens + stats.totalUsage.totalCost += usageStats.totalUsage.totalCost + } catch (error) { + logger.error(`❌ Error calculating usage for user ${user.id} in stats:`, error) + // Fallback to stored values if calculation fails + stats.totalApiKeys += user.apiKeyCount || 0 + stats.totalUsage.requests += user.totalUsage?.requests || 0 + stats.totalUsage.inputTokens += user.totalUsage?.inputTokens || 0 + stats.totalUsage.outputTokens += user.totalUsage?.outputTokens || 0 + stats.totalUsage.totalCost += user.totalUsage?.totalCost || 0 + } + } + } + + return stats + } catch (error) { + logger.error('❌ Error getting user stats:', error) + throw error + } + } + + // 🔄 转移匹配的API Keys给新用户 + async transferMatchingApiKeys(user) { + try { + const apiKeyService = require('./apiKeyService') + const { displayName, username, email } = user + + // 获取所有API Keys + const allApiKeys = await apiKeyService.getAllApiKeys() + + // 找到没有用户ID的API Keys(即由Admin创建的) + const unownedApiKeys = allApiKeys.filter((key) => !key.userId || key.userId === '') + + if (unownedApiKeys.length === 0) { + logger.debug(`📝 No unowned API keys found for potential transfer to user: ${username}`) + return + } + + // 构建匹配字符串数组(只考虑displayName、username、email,去除空值和重复值) + const matchStrings = new Set() + if (displayName) { + matchStrings.add(displayName.toLowerCase().trim()) + } + if (username) { + matchStrings.add(username.toLowerCase().trim()) + } + if (email) { + matchStrings.add(email.toLowerCase().trim()) + } + + const matchingKeys = [] + + // 查找名称匹配的API Keys(只进行完全匹配) + for (const apiKey of unownedApiKeys) { + const keyName = apiKey.name ? apiKey.name.toLowerCase().trim() : '' + + // 检查API Key名称是否与用户信息完全匹配 + for (const matchString of matchStrings) { + if (keyName === matchString) { + matchingKeys.push(apiKey) + break // 找到匹配后跳出内层循环 + } + } + } + + // 转移匹配的API Keys + let transferredCount = 0 + for (const apiKey of matchingKeys) { + try { + await apiKeyService.updateApiKey(apiKey.id, { + userId: user.id, + userUsername: user.username, + createdBy: user.username + }) + + transferredCount++ + logger.info(`🔄 Transferred API key "${apiKey.name}" (${apiKey.id}) to user: ${username}`) + } catch (error) { + logger.error(`❌ Failed to transfer API key ${apiKey.id} to user ${username}:`, error) + } + } + + if (transferredCount > 0) { + logger.success( + `🎉 Successfully transferred ${transferredCount} API key(s) to new user: ${username} (${displayName})` + ) + } else if (matchingKeys.length === 0) { + logger.debug(`📝 No matching API keys found for user: ${username} (${displayName})`) + } + } catch (error) { + logger.error('❌ Error transferring matching API keys:', error) + // Don't throw error to prevent blocking user creation + } + } +} + +module.exports = new UserService() diff --git a/src/services/webhookConfigService.js b/src/services/webhookConfigService.js new file mode 100644 index 0000000000000000000000000000000000000000..ea689853058e2501d9fab6cbe439860e20b51799 --- /dev/null +++ b/src/services/webhookConfigService.js @@ -0,0 +1,467 @@ +const redis = require('../models/redis') +const logger = require('../utils/logger') +const { v4: uuidv4 } = require('uuid') + +class WebhookConfigService { + constructor() { + this.KEY_PREFIX = 'webhook_config' + this.DEFAULT_CONFIG_KEY = `${this.KEY_PREFIX}:default` + } + + /** + * 获取webhook配置 + */ + async getConfig() { + try { + const configStr = await redis.client.get(this.DEFAULT_CONFIG_KEY) + if (!configStr) { + // 返回默认配置 + return this.getDefaultConfig() + } + + const storedConfig = JSON.parse(configStr) + const defaultConfig = this.getDefaultConfig() + + // 合并默认通知类型,确保新增类型有默认值 + storedConfig.notificationTypes = { + ...defaultConfig.notificationTypes, + ...(storedConfig.notificationTypes || {}) + } + + return storedConfig + } catch (error) { + logger.error('获取webhook配置失败:', error) + return this.getDefaultConfig() + } + } + + /** + * 保存webhook配置 + */ + async saveConfig(config) { + try { + const defaultConfig = this.getDefaultConfig() + + config.notificationTypes = { + ...defaultConfig.notificationTypes, + ...(config.notificationTypes || {}) + } + + // 验证配置 + this.validateConfig(config) + + // 添加更新时间 + config.updatedAt = new Date().toISOString() + + await redis.client.set(this.DEFAULT_CONFIG_KEY, JSON.stringify(config)) + logger.info('✅ Webhook配置已保存') + + return config + } catch (error) { + logger.error('保存webhook配置失败:', error) + throw error + } + } + + /** + * 验证配置 + */ + validateConfig(config) { + if (!config || typeof config !== 'object') { + throw new Error('无效的配置格式') + } + + // 验证平台配置 + if (config.platforms) { + const validPlatforms = [ + 'wechat_work', + 'dingtalk', + 'feishu', + 'slack', + 'discord', + 'telegram', + 'custom', + 'bark', + 'smtp' + ] + + for (const platform of config.platforms) { + if (!validPlatforms.includes(platform.type)) { + throw new Error(`不支持的平台类型: ${platform.type}`) + } + + // Bark和SMTP平台不使用标准URL + if (!['bark', 'smtp', 'telegram'].includes(platform.type)) { + if (!platform.url || !this.isValidUrl(platform.url)) { + throw new Error(`无效的webhook URL: ${platform.url}`) + } + } + + // 验证平台特定的配置 + this.validatePlatformConfig(platform) + } + } + } + + /** + * 验证平台特定配置 + */ + validatePlatformConfig(platform) { + switch (platform.type) { + case 'wechat_work': + // 企业微信不需要额外配置 + break + case 'dingtalk': + // 钉钉可能需要secret用于签名 + if (platform.enableSign && !platform.secret) { + throw new Error('钉钉启用签名时必须提供secret') + } + break + case 'feishu': + // 飞书可能需要签名 + if (platform.enableSign && !platform.secret) { + throw new Error('飞书启用签名时必须提供secret') + } + break + case 'slack': + // Slack webhook URL通常包含token + if (!platform.url.includes('hooks.slack.com')) { + logger.warn('⚠️ Slack webhook URL格式可能不正确') + } + break + case 'discord': + // Discord webhook URL格式检查 + if (!platform.url.includes('discord.com/api/webhooks')) { + logger.warn('⚠️ Discord webhook URL格式可能不正确') + } + break + case 'telegram': + if (!platform.botToken) { + throw new Error('Telegram 平台必须提供机器人 Token') + } + if (!platform.chatId) { + throw new Error('Telegram 平台必须提供 Chat ID') + } + + if (!platform.botToken.includes(':')) { + logger.warn('⚠️ Telegram 机器人 Token 格式可能不正确') + } + + if (!/^[-\d]+$/.test(String(platform.chatId))) { + logger.warn('⚠️ Telegram Chat ID 应该是数字,如为频道请确认已获取正确ID') + } + + if (platform.apiBaseUrl) { + if (!this.isValidUrl(platform.apiBaseUrl)) { + throw new Error('Telegram API 基础地址格式无效') + } + const { protocol } = new URL(platform.apiBaseUrl) + if (!['http:', 'https:'].includes(protocol)) { + throw new Error('Telegram API 基础地址仅支持 http 或 https 协议') + } + } + + if (platform.proxyUrl) { + if (!this.isValidUrl(platform.proxyUrl)) { + throw new Error('Telegram 代理地址格式无效') + } + const proxyProtocol = new URL(platform.proxyUrl).protocol + const supportedProtocols = ['http:', 'https:', 'socks4:', 'socks4a:', 'socks5:'] + if (!supportedProtocols.includes(proxyProtocol)) { + throw new Error('Telegram 代理仅支持 http/https/socks 协议') + } + } + break + case 'custom': + // 自定义webhook,用户自行负责格式 + break + case 'bark': + // 验证设备密钥 + if (!platform.deviceKey) { + throw new Error('Bark平台必须提供设备密钥') + } + + // 验证设备密钥格式(通常是22-24位字符) + if (platform.deviceKey.length < 20 || platform.deviceKey.length > 30) { + logger.warn('⚠️ Bark设备密钥长度可能不正确,请检查是否完整复制') + } + + // 验证服务器URL(如果提供) + if (platform.serverUrl) { + if (!this.isValidUrl(platform.serverUrl)) { + throw new Error('Bark服务器URL格式无效') + } + if (!platform.serverUrl.includes('/push')) { + logger.warn('⚠️ Bark服务器URL应该以/push结尾') + } + } + + // 验证声音参数(如果提供) + if (platform.sound) { + const validSounds = [ + 'default', + 'alarm', + 'anticipate', + 'bell', + 'birdsong', + 'bloom', + 'calypso', + 'chime', + 'choo', + 'descent', + 'electronic', + 'fanfare', + 'glass', + 'gotosleep', + 'healthnotification', + 'horn', + 'ladder', + 'mailsent', + 'minuet', + 'multiwayinvitation', + 'newmail', + 'newsflash', + 'noir', + 'paymentsuccess', + 'shake', + 'sherwoodforest', + 'silence', + 'spell', + 'suspense', + 'telegraph', + 'tiptoes', + 'typewriters', + 'update', + 'alert' + ] + if (!validSounds.includes(platform.sound)) { + logger.warn(`⚠️ 未知的Bark声音: ${platform.sound}`) + } + } + + // 验证级别参数 + if (platform.level) { + const validLevels = ['active', 'timeSensitive', 'passive', 'critical'] + if (!validLevels.includes(platform.level)) { + throw new Error(`无效的Bark通知级别: ${platform.level}`) + } + } + + // 验证图标URL(如果提供) + if (platform.icon && !this.isValidUrl(platform.icon)) { + logger.warn('⚠️ Bark图标URL格式可能不正确') + } + + // 验证点击跳转URL(如果提供) + if (platform.clickUrl && !this.isValidUrl(platform.clickUrl)) { + logger.warn('⚠️ Bark点击跳转URL格式可能不正确') + } + break + case 'smtp': { + // 验证SMTP必需配置 + if (!platform.host) { + throw new Error('SMTP平台必须提供主机地址') + } + if (!platform.user) { + throw new Error('SMTP平台必须提供用户名') + } + if (!platform.pass) { + throw new Error('SMTP平台必须提供密码') + } + if (!platform.to) { + throw new Error('SMTP平台必须提供接收邮箱') + } + + // 验证端口 + if (platform.port && (platform.port < 1 || platform.port > 65535)) { + throw new Error('SMTP端口必须在1-65535之间') + } + + // 验证邮箱格式 + // 支持两种格式:1. 纯邮箱 user@domain.com 2. 带名称 Name + const simpleEmailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + + // 验证接收邮箱 + const toEmails = Array.isArray(platform.to) ? platform.to : [platform.to] + for (const email of toEmails) { + // 提取实际邮箱地址(如果是 Name 格式) + const actualEmail = email.includes('<') ? email.match(/<([^>]+)>/)?.[1] : email + if (!actualEmail || !simpleEmailRegex.test(actualEmail)) { + throw new Error(`无效的接收邮箱格式: ${email}`) + } + } + + // 验证发送邮箱(支持 Name 格式) + if (platform.from) { + const actualFromEmail = platform.from.includes('<') + ? platform.from.match(/<([^>]+)>/)?.[1] + : platform.from + if (!actualFromEmail || !simpleEmailRegex.test(actualFromEmail)) { + throw new Error(`无效的发送邮箱格式: ${platform.from}`) + } + } + break + } + } + } + + /** + * 验证URL格式 + */ + isValidUrl(url) { + try { + new URL(url) + return true + } catch { + return false + } + } + + /** + * 获取默认配置 + */ + getDefaultConfig() { + return { + enabled: false, + platforms: [], + notificationTypes: { + accountAnomaly: true, // 账号异常 + quotaWarning: true, // 配额警告 + systemError: true, // 系统错误 + securityAlert: true, // 安全警报 + rateLimitRecovery: true, // 限流恢复 + test: true // 测试通知 + }, + retrySettings: { + maxRetries: 3, + retryDelay: 1000, // 毫秒 + timeout: 10000 // 毫秒 + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + } + } + + /** + * 添加webhook平台 + */ + async addPlatform(platform) { + try { + const config = await this.getConfig() + + // 生成唯一ID + platform.id = platform.id || uuidv4() + platform.enabled = platform.enabled !== false + platform.createdAt = new Date().toISOString() + + // 验证平台配置 + this.validatePlatformConfig(platform) + + // 添加到配置 + config.platforms = config.platforms || [] + config.platforms.push(platform) + + await this.saveConfig(config) + + return platform + } catch (error) { + logger.error('添加webhook平台失败:', error) + throw error + } + } + + /** + * 更新webhook平台 + */ + async updatePlatform(platformId, updates) { + try { + const config = await this.getConfig() + + const index = config.platforms.findIndex((p) => p.id === platformId) + if (index === -1) { + throw new Error('找不到指定的webhook平台') + } + + // 合并更新 + config.platforms[index] = { + ...config.platforms[index], + ...updates, + updatedAt: new Date().toISOString() + } + + // 验证更新后的配置 + this.validatePlatformConfig(config.platforms[index]) + + await this.saveConfig(config) + + return config.platforms[index] + } catch (error) { + logger.error('更新webhook平台失败:', error) + throw error + } + } + + /** + * 删除webhook平台 + */ + async deletePlatform(platformId) { + try { + const config = await this.getConfig() + + config.platforms = config.platforms.filter((p) => p.id !== platformId) + + await this.saveConfig(config) + + logger.info(`✅ 已删除webhook平台: ${platformId}`) + return true + } catch (error) { + logger.error('删除webhook平台失败:', error) + throw error + } + } + + /** + * 切换webhook平台启用状态 + */ + async togglePlatform(platformId) { + try { + const config = await this.getConfig() + + const platform = config.platforms.find((p) => p.id === platformId) + if (!platform) { + throw new Error('找不到指定的webhook平台') + } + + platform.enabled = !platform.enabled + platform.updatedAt = new Date().toISOString() + + await this.saveConfig(config) + + logger.info(`✅ Webhook平台 ${platformId} 已${platform.enabled ? '启用' : '禁用'}`) + return platform + } catch (error) { + logger.error('切换webhook平台状态失败:', error) + throw error + } + } + + /** + * 获取启用的平台列表 + */ + async getEnabledPlatforms() { + try { + const config = await this.getConfig() + + if (!config.enabled || !config.platforms) { + return [] + } + + return config.platforms.filter((p) => p.enabled) + } catch (error) { + logger.error('获取启用的webhook平台失败:', error) + return [] + } + } +} + +module.exports = new WebhookConfigService() diff --git a/src/services/webhookService.js b/src/services/webhookService.js new file mode 100644 index 0000000000000000000000000000000000000000..d380b32990333fbd7f342248c46479e64e447190 --- /dev/null +++ b/src/services/webhookService.js @@ -0,0 +1,892 @@ +const axios = require('axios') +const crypto = require('crypto') +const nodemailer = require('nodemailer') +const { HttpsProxyAgent } = require('https-proxy-agent') +const { SocksProxyAgent } = require('socks-proxy-agent') +const logger = require('../utils/logger') +const webhookConfigService = require('./webhookConfigService') +const { getISOStringWithTimezone } = require('../utils/dateHelper') +const appConfig = require('../../config/config') + +class WebhookService { + constructor() { + this.platformHandlers = { + wechat_work: this.sendToWechatWork.bind(this), + dingtalk: this.sendToDingTalk.bind(this), + feishu: this.sendToFeishu.bind(this), + slack: this.sendToSlack.bind(this), + discord: this.sendToDiscord.bind(this), + telegram: this.sendToTelegram.bind(this), + custom: this.sendToCustom.bind(this), + bark: this.sendToBark.bind(this), + smtp: this.sendToSMTP.bind(this) + } + this.timezone = appConfig.system.timezone || 'Asia/Shanghai' + } + + /** + * 发送通知到所有启用的平台 + */ + async sendNotification(type, data) { + try { + const config = await webhookConfigService.getConfig() + + // 检查是否启用webhook + if (!config.enabled) { + logger.debug('Webhook通知已禁用') + return + } + + // 检查通知类型是否启用(test类型始终允许发送) + if (type !== 'test' && config.notificationTypes && !config.notificationTypes[type]) { + logger.debug(`通知类型 ${type} 已禁用`) + return + } + + // 获取启用的平台 + const enabledPlatforms = await webhookConfigService.getEnabledPlatforms() + if (enabledPlatforms.length === 0) { + logger.debug('没有启用的webhook平台') + return + } + + logger.info(`📢 发送 ${type} 通知到 ${enabledPlatforms.length} 个平台`) + + // 并发发送到所有平台 + const promises = enabledPlatforms.map((platform) => + this.sendToPlatform(platform, type, data, config.retrySettings) + ) + + const results = await Promise.allSettled(promises) + + // 记录结果 + const succeeded = results.filter((r) => r.status === 'fulfilled').length + const failed = results.filter((r) => r.status === 'rejected').length + + if (failed > 0) { + logger.warn(`⚠️ Webhook通知: ${succeeded}成功, ${failed}失败`) + } else { + logger.info(`✅ 所有webhook通知发送成功`) + } + + return { succeeded, failed } + } catch (error) { + logger.error('发送webhook通知失败:', error) + throw error + } + } + + /** + * 发送到特定平台 + */ + async sendToPlatform(platform, type, data, retrySettings) { + try { + const handler = this.platformHandlers[platform.type] + if (!handler) { + throw new Error(`不支持的平台类型: ${platform.type}`) + } + + // 使用平台特定的处理器 + await this.retryWithBackoff( + () => handler(platform, type, data), + retrySettings?.maxRetries || 3, + retrySettings?.retryDelay || 1000 + ) + + logger.info(`✅ 成功发送到 ${platform.name || platform.type}`) + } catch (error) { + logger.error(`❌ 发送到 ${platform.name || platform.type} 失败:`, error.message) + throw error + } + } + + /** + * 企业微信webhook + */ + async sendToWechatWork(platform, type, data) { + const content = this.formatMessageForWechatWork(type, data) + + const payload = { + msgtype: 'markdown', + markdown: { + content + } + } + + await this.sendHttpRequest(platform.url, payload, platform.timeout || 10000) + } + + /** + * 钉钉webhook + */ + async sendToDingTalk(platform, type, data) { + const content = this.formatMessageForDingTalk(type, data) + + let { url } = platform + const payload = { + msgtype: 'markdown', + markdown: { + title: this.getNotificationTitle(type), + text: content + } + } + + // 如果启用签名 + if (platform.enableSign && platform.secret) { + const timestamp = Date.now() + const sign = this.generateDingTalkSign(platform.secret, timestamp) + url = `${url}×tamp=${timestamp}&sign=${encodeURIComponent(sign)}` + } + + await this.sendHttpRequest(url, payload, platform.timeout || 10000) + } + + /** + * 飞书webhook + */ + async sendToFeishu(platform, type, data) { + const content = this.formatMessageForFeishu(type, data) + + const payload = { + msg_type: 'interactive', + card: { + elements: [ + { + tag: 'markdown', + content + } + ], + header: { + title: { + tag: 'plain_text', + content: this.getNotificationTitle(type) + }, + template: this.getFeishuCardColor(type) + } + } + } + + // 如果启用签名 + if (platform.enableSign && platform.secret) { + const timestamp = Math.floor(Date.now() / 1000) + const sign = this.generateFeishuSign(platform.secret, timestamp) + payload.timestamp = timestamp.toString() + payload.sign = sign + } + + await this.sendHttpRequest(platform.url, payload, platform.timeout || 10000) + } + + /** + * Slack webhook + */ + async sendToSlack(platform, type, data) { + const text = this.formatMessageForSlack(type, data) + + const payload = { + text, + username: 'Claude Relay Service', + icon_emoji: this.getSlackEmoji(type) + } + + await this.sendHttpRequest(platform.url, payload, platform.timeout || 10000) + } + + /** + * Discord webhook + */ + async sendToDiscord(platform, type, data) { + const embed = this.formatMessageForDiscord(type, data) + + const payload = { + username: 'Claude Relay Service', + embeds: [embed] + } + + await this.sendHttpRequest(platform.url, payload, platform.timeout || 10000) + } + + /** + * 自定义webhook + */ + async sendToCustom(platform, type, data) { + // 使用通用格式 + const payload = { + type, + service: 'claude-relay-service', + timestamp: getISOStringWithTimezone(new Date()), + data + } + + await this.sendHttpRequest(platform.url, payload, platform.timeout || 10000) + } + + /** + * Telegram Bot 通知 + */ + async sendToTelegram(platform, type, data) { + if (!platform.botToken) { + throw new Error('缺少 Telegram 机器人 Token') + } + if (!platform.chatId) { + throw new Error('缺少 Telegram Chat ID') + } + + const baseUrl = this.normalizeTelegramApiBase(platform.apiBaseUrl) + const apiUrl = `${baseUrl}/bot${platform.botToken}/sendMessage` + const payload = { + chat_id: platform.chatId, + text: this.formatMessageForTelegram(type, data), + disable_web_page_preview: true + } + + const axiosOptions = this.buildTelegramAxiosOptions(platform) + + const response = await this.sendHttpRequest( + apiUrl, + payload, + platform.timeout || 10000, + axiosOptions + ) + if (!response || response.ok !== true) { + throw new Error(`Telegram API 错误: ${response?.description || '未知错误'}`) + } + } + + /** + * Bark webhook + */ + async sendToBark(platform, type, data) { + const payload = { + device_key: platform.deviceKey, + title: this.getNotificationTitle(type), + body: this.formatMessageForBark(type, data), + level: platform.level || this.getBarkLevel(type), + sound: platform.sound || this.getBarkSound(type), + group: platform.group || 'claude-relay', + badge: 1 + } + + // 添加可选参数 + if (platform.icon) { + payload.icon = platform.icon + } + + if (platform.clickUrl) { + payload.url = platform.clickUrl + } + + const url = platform.serverUrl || 'https://api.day.app/push' + await this.sendHttpRequest(url, payload, platform.timeout || 10000) + } + + /** + * SMTP邮件通知 + */ + async sendToSMTP(platform, type, data) { + try { + // 创建SMTP传输器 + const transporter = nodemailer.createTransport({ + host: platform.host, + port: platform.port || 587, + secure: platform.secure || false, // true for 465, false for other ports + auth: { + user: platform.user, + pass: platform.pass + }, + // 可选的TLS配置 + tls: platform.ignoreTLS ? { rejectUnauthorized: false } : undefined, + // 连接超时 + connectionTimeout: platform.timeout || 10000 + }) + + // 构造邮件内容 + const subject = this.getNotificationTitle(type) + const htmlContent = this.formatMessageForEmail(type, data) + const textContent = this.formatMessageForEmailText(type, data) + + // 邮件选项 + const mailOptions = { + from: platform.from || platform.user, // 发送者 + to: platform.to, // 接收者(必填) + subject: `[Claude Relay Service] ${subject}`, + text: textContent, + html: htmlContent + } + + // 发送邮件 + const info = await transporter.sendMail(mailOptions) + logger.info(`✅ 邮件发送成功: ${info.messageId}`) + + return info + } catch (error) { + logger.error('SMTP邮件发送失败:', error) + throw error + } + } + + /** + * 发送HTTP请求 + */ + async sendHttpRequest(url, payload, timeout, axiosOptions = {}) { + const headers = { + 'Content-Type': 'application/json', + 'User-Agent': 'claude-relay-service/2.0', + ...(axiosOptions.headers || {}) + } + + const response = await axios.post(url, payload, { + timeout, + ...axiosOptions, + headers + }) + + if (response.status < 200 || response.status >= 300) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + return response.data + } + + /** + * 重试机制 + */ + async retryWithBackoff(fn, maxRetries, baseDelay) { + let lastError + + for (let i = 0; i < maxRetries; i++) { + try { + return await fn() + } catch (error) { + lastError = error + + if (i < maxRetries - 1) { + const delay = baseDelay * Math.pow(2, i) // 指数退避 + logger.debug(`🔄 重试 ${i + 1}/${maxRetries},等待 ${delay}ms`) + await new Promise((resolve) => setTimeout(resolve, delay)) + } + } + } + + throw lastError + } + + /** + * 生成钉钉签名 + */ + generateDingTalkSign(secret, timestamp) { + const stringToSign = `${timestamp}\n${secret}` + const hmac = crypto.createHmac('sha256', secret) + hmac.update(stringToSign) + return hmac.digest('base64') + } + + /** + * 生成飞书签名 + */ + generateFeishuSign(secret, timestamp) { + const stringToSign = `${timestamp}\n${secret}` + const hmac = crypto.createHmac('sha256', stringToSign) + hmac.update('') + return hmac.digest('base64') + } + + /** + * 格式化企业微信消息 + */ + formatMessageForWechatWork(type, data) { + const title = this.getNotificationTitle(type) + const details = this.formatNotificationDetails(data) + return ( + `## ${title}\n\n` + + `> **服务**: Claude Relay Service\n` + + `> **时间**: ${new Date().toLocaleString('zh-CN', { timeZone: this.timezone })}\n\n${details}` + ) + } + + /** + * 格式化钉钉消息 + */ + formatMessageForDingTalk(type, data) { + const details = this.formatNotificationDetails(data) + + return ( + `#### 服务: Claude Relay Service\n` + + `#### 时间: ${new Date().toLocaleString('zh-CN', { timeZone: this.timezone })}\n\n${details}` + ) + } + + /** + * 格式化飞书消息 + */ + formatMessageForFeishu(type, data) { + return this.formatNotificationDetails(data) + } + + /** + * 格式化Slack消息 + */ + formatMessageForSlack(type, data) { + const title = this.getNotificationTitle(type) + const details = this.formatNotificationDetails(data) + + return `*${title}*\n${details}` + } + + /** + * 规范化Telegram基础地址 + */ + normalizeTelegramApiBase(baseUrl) { + const defaultBase = 'https://api.telegram.org' + if (!baseUrl) { + return defaultBase + } + + try { + const parsed = new URL(baseUrl) + if (!['http:', 'https:'].includes(parsed.protocol)) { + throw new Error('Telegram API 基础地址必须使用 http 或 https 协议') + } + + // 移除结尾的 / + return parsed.href.replace(/\/$/, '') + } catch (error) { + logger.warn(`⚠️ Telegram API 基础地址无效,将使用默认值: ${error.message}`) + return defaultBase + } + } + + /** + * 构建 Telegram 请求的 axios 选项(代理等) + */ + buildTelegramAxiosOptions(platform) { + const options = {} + + if (platform.proxyUrl) { + try { + const proxyUrl = new URL(platform.proxyUrl) + const { protocol } = proxyUrl + + if (protocol.startsWith('socks')) { + const agent = new SocksProxyAgent(proxyUrl.toString()) + options.httpAgent = agent + options.httpsAgent = agent + options.proxy = false + } else if (protocol === 'http:' || protocol === 'https:') { + const agent = new HttpsProxyAgent(proxyUrl.toString()) + options.httpAgent = agent + options.httpsAgent = agent + options.proxy = false + } else { + logger.warn(`⚠️ 不支持的Telegram代理协议: ${protocol}`) + } + } catch (error) { + logger.warn(`⚠️ Telegram代理配置无效,将忽略: ${error.message}`) + } + } + + return options + } + + /** + * 格式化 Telegram 消息 + */ + formatMessageForTelegram(type, data) { + const title = this.getNotificationTitle(type) + const timestamp = new Date().toLocaleString('zh-CN', { timeZone: this.timezone }) + const details = this.buildNotificationDetails(data) + + const lines = [`${title}`, '服务: Claude Relay Service'] + + if (details.length > 0) { + lines.push('') + for (const detail of details) { + lines.push(`${detail.label}: ${detail.value}`) + } + } + + lines.push('', `时间: ${timestamp}`) + + return lines.join('\n') + } + + /** + * 格式化Discord消息 + */ + formatMessageForDiscord(type, data) { + const title = this.getNotificationTitle(type) + const color = this.getDiscordColor(type) + const fields = this.formatNotificationFields(data) + + return { + title, + color, + fields, + timestamp: getISOStringWithTimezone(new Date()), + footer: { + text: 'Claude Relay Service' + } + } + } + + /** + * 获取通知标题 + */ + getNotificationTitle(type) { + const titles = { + accountAnomaly: '⚠️ 账号异常通知', + quotaWarning: '📊 配额警告', + systemError: '❌ 系统错误', + securityAlert: '🔒 安全警报', + rateLimitRecovery: '🎉 限流恢复通知', + test: '🧪 测试通知' + } + + return titles[type] || '📢 系统通知' + } + + /** + * 获取Bark通知级别 + */ + getBarkLevel(type) { + const levels = { + accountAnomaly: 'timeSensitive', + quotaWarning: 'active', + systemError: 'critical', + securityAlert: 'critical', + rateLimitRecovery: 'active', + test: 'passive' + } + + return levels[type] || 'active' + } + + /** + * 获取Bark声音 + */ + getBarkSound(type) { + const sounds = { + accountAnomaly: 'alarm', + quotaWarning: 'bell', + systemError: 'alert', + securityAlert: 'alarm', + rateLimitRecovery: 'success', + test: 'default' + } + + return sounds[type] || 'default' + } + + /** + * 格式化Bark消息 + */ + formatMessageForBark(type, data) { + const lines = [] + + if (data.accountName) { + lines.push(`账号: ${data.accountName}`) + } + + if (data.platform) { + lines.push(`平台: ${data.platform}`) + } + + if (data.status) { + lines.push(`状态: ${data.status}`) + } + + if (data.errorCode) { + lines.push(`错误: ${data.errorCode}`) + } + + if (data.reason) { + lines.push(`原因: ${data.reason}`) + } + + if (data.message) { + lines.push(`消息: ${data.message}`) + } + + if (data.quota) { + lines.push(`剩余配额: ${data.quota.remaining}/${data.quota.total}`) + } + + if (data.usage) { + lines.push(`使用率: ${data.usage}%`) + } + + // 添加服务标识和时间戳 + lines.push(`\n服务: Claude Relay Service`) + lines.push(`时间: ${new Date().toLocaleString('zh-CN', { timeZone: this.timezone })}`) + + return lines.join('\n') + } + + /** + * 构建通知详情数据 + */ + buildNotificationDetails(data) { + const details = [] + + if (data.accountName) { + details.push({ label: '账号', value: data.accountName }) + } + if (data.platform) { + details.push({ label: '平台', value: data.platform }) + } + if (data.status) { + details.push({ label: '状态', value: data.status, color: this.getStatusColor(data.status) }) + } + if (data.errorCode) { + details.push({ label: '错误代码', value: data.errorCode, isCode: true }) + } + if (data.reason) { + details.push({ label: '原因', value: data.reason }) + } + if (data.message) { + details.push({ label: '消息', value: data.message }) + } + if (data.quota) { + details.push({ label: '配额', value: `${data.quota.remaining}/${data.quota.total}` }) + } + if (data.usage) { + details.push({ label: '使用率', value: `${data.usage}%` }) + } + + return details + } + + /** + * 格式化邮件HTML内容 + */ + formatMessageForEmail(type, data) { + const title = this.getNotificationTitle(type) + const timestamp = new Date().toLocaleString('zh-CN', { timeZone: this.timezone }) + const details = this.buildNotificationDetails(data) + + let content = ` +
+
+

${title}

+

Claude Relay Service

+
+
+
+ ` + + // 使用统一的详情数据渲染 + details.forEach((detail) => { + if (detail.isCode) { + content += `

${detail.label}: ${detail.value}

` + } else if (detail.color) { + content += `

${detail.label}: ${detail.value}

` + } else { + content += `

${detail.label}: ${detail.value}

` + } + }) + + content += ` +
+
+

发送时间: ${timestamp}

+

此邮件由 Claude Relay Service 自动发送

+
+
+
+ ` + + return content + } + + /** + * 格式化邮件纯文本内容 + */ + formatMessageForEmailText(type, data) { + const title = this.getNotificationTitle(type) + const timestamp = new Date().toLocaleString('zh-CN', { timeZone: this.timezone }) + const details = this.buildNotificationDetails(data) + + let content = `${title}\n` + content += `=====================================\n\n` + + // 使用统一的详情数据渲染 + details.forEach((detail) => { + content += `${detail.label}: ${detail.value}\n` + }) + + content += `\n发送时间: ${timestamp}\n` + content += `服务: Claude Relay Service\n` + content += `=====================================\n` + content += `此邮件由系统自动发送,请勿回复。` + + return content + } + + /** + * 获取状态颜色 + */ + getStatusColor(status) { + const colors = { + error: '#dc3545', + unauthorized: '#fd7e14', + blocked: '#6f42c1', + disabled: '#6c757d', + active: '#28a745', + warning: '#ffc107' + } + return colors[status] || '#007bff' + } + + /** + * 格式化通知详情 + */ + formatNotificationDetails(data) { + const lines = [] + + if (data.accountName) { + lines.push(`**账号**: ${data.accountName}`) + } + + if (data.platform) { + lines.push(`**平台**: ${data.platform}`) + } + + if (data.platforms) { + lines.push(`**涉及平台**: ${data.platforms.join(', ')}`) + } + + if (data.totalAccounts) { + lines.push(`**恢复账户数**: ${data.totalAccounts}`) + } + + if (data.status) { + lines.push(`**状态**: ${data.status}`) + } + + if (data.errorCode) { + lines.push(`**错误代码**: ${data.errorCode}`) + } + + if (data.reason) { + lines.push(`**原因**: ${data.reason}`) + } + + if (data.message) { + lines.push(`**消息**: ${data.message}`) + } + + if (data.quota) { + lines.push(`**剩余配额**: ${data.quota.remaining}/${data.quota.total}`) + } + + if (data.usage) { + lines.push(`**使用率**: ${data.usage}%`) + } + + return lines.join('\n') + } + + /** + * 格式化Discord字段 + */ + formatNotificationFields(data) { + const fields = [] + + if (data.accountName) { + fields.push({ name: '账号', value: data.accountName, inline: true }) + } + + if (data.platform) { + fields.push({ name: '平台', value: data.platform, inline: true }) + } + + if (data.status) { + fields.push({ name: '状态', value: data.status, inline: true }) + } + + if (data.errorCode) { + fields.push({ name: '错误代码', value: data.errorCode, inline: false }) + } + + if (data.reason) { + fields.push({ name: '原因', value: data.reason, inline: false }) + } + + if (data.message) { + fields.push({ name: '消息', value: data.message, inline: false }) + } + + return fields + } + + /** + * 获取飞书卡片颜色 + */ + getFeishuCardColor(type) { + const colors = { + accountAnomaly: 'orange', + quotaWarning: 'yellow', + systemError: 'red', + securityAlert: 'red', + rateLimitRecovery: 'green', + test: 'blue' + } + + return colors[type] || 'blue' + } + + /** + * 获取Slack emoji + */ + getSlackEmoji(type) { + const emojis = { + accountAnomaly: ':warning:', + quotaWarning: ':chart_with_downwards_trend:', + systemError: ':x:', + securityAlert: ':lock:', + rateLimitRecovery: ':tada:', + test: ':test_tube:' + } + + return emojis[type] || ':bell:' + } + + /** + * 获取Discord颜色 + */ + getDiscordColor(type) { + const colors = { + accountAnomaly: 0xff9800, // 橙色 + quotaWarning: 0xffeb3b, // 黄色 + systemError: 0xf44336, // 红色 + securityAlert: 0xf44336, // 红色 + rateLimitRecovery: 0x4caf50, // 绿色 + test: 0x2196f3 // 蓝色 + } + + return colors[type] || 0x9e9e9e // 灰色 + } + + /** + * 测试webhook连接 + */ + async testWebhook(platform) { + try { + const testData = { + message: 'Claude Relay Service webhook测试', + timestamp: getISOStringWithTimezone(new Date()) + } + + await this.sendToPlatform(platform, 'test', testData, { maxRetries: 1, retryDelay: 1000 }) + + return { success: true } + } catch (error) { + return { + success: false, + error: error.message + } + } + } +} + +module.exports = new WebhookService() diff --git a/src/utils/cacheMonitor.js b/src/utils/cacheMonitor.js new file mode 100644 index 0000000000000000000000000000000000000000..ece5e478d1b0bfafd1e2e6d6d623d6516efa2b0f --- /dev/null +++ b/src/utils/cacheMonitor.js @@ -0,0 +1,294 @@ +/** + * 缓存监控和管理工具 + * 提供统一的缓存监控、统计和安全清理功能 + */ + +const logger = require('./logger') +const crypto = require('crypto') + +class CacheMonitor { + constructor() { + this.monitors = new Map() // 存储所有被监控的缓存实例 + this.startTime = Date.now() + this.totalHits = 0 + this.totalMisses = 0 + this.totalEvictions = 0 + + // 🔒 安全配置 + this.securityConfig = { + maxCacheAge: 15 * 60 * 1000, // 最大缓存年龄 15 分钟 + forceCleanupInterval: 30 * 60 * 1000, // 强制清理间隔 30 分钟 + memoryThreshold: 100 * 1024 * 1024, // 内存阈值 100MB + sensitiveDataPatterns: [/password/i, /token/i, /secret/i, /key/i, /credential/i] + } + + // 🧹 定期执行安全清理 + this.setupSecurityCleanup() + + // 📊 定期报告统计信息 + this.setupPeriodicReporting() + } + + /** + * 注册缓存实例进行监控 + * @param {string} name - 缓存名称 + * @param {LRUCache} cache - 缓存实例 + */ + registerCache(name, cache) { + if (this.monitors.has(name)) { + logger.warn(`⚠️ Cache ${name} is already registered, updating reference`) + } + + this.monitors.set(name, { + cache, + registeredAt: Date.now(), + lastCleanup: Date.now(), + totalCleanups: 0 + }) + + logger.info(`📦 Registered cache for monitoring: ${name}`) + } + + /** + * 获取所有缓存的综合统计 + */ + getGlobalStats() { + const stats = { + uptime: Math.floor((Date.now() - this.startTime) / 1000), // 秒 + cacheCount: this.monitors.size, + totalSize: 0, + totalHits: 0, + totalMisses: 0, + totalEvictions: 0, + averageHitRate: 0, + caches: {} + } + + for (const [name, monitor] of this.monitors) { + const cacheStats = monitor.cache.getStats() + stats.totalSize += cacheStats.size + stats.totalHits += cacheStats.hits + stats.totalMisses += cacheStats.misses + stats.totalEvictions += cacheStats.evictions + + stats.caches[name] = { + ...cacheStats, + lastCleanup: new Date(monitor.lastCleanup).toISOString(), + totalCleanups: monitor.totalCleanups, + age: Math.floor((Date.now() - monitor.registeredAt) / 1000) // 秒 + } + } + + const totalRequests = stats.totalHits + stats.totalMisses + stats.averageHitRate = + totalRequests > 0 ? `${((stats.totalHits / totalRequests) * 100).toFixed(2)}%` : '0%' + + return stats + } + + /** + * 🔒 执行安全清理 + * 清理过期数据和潜在的敏感信息 + */ + performSecurityCleanup() { + logger.info('🔒 Starting security cleanup for all caches') + + for (const [name, monitor] of this.monitors) { + try { + const { cache } = monitor + const beforeSize = cache.cache.size + + // 执行常规清理 + cache.cleanup() + + // 检查缓存年龄,如果太老则完全清空 + const cacheAge = Date.now() - monitor.registeredAt + if (cacheAge > this.securityConfig.maxCacheAge * 2) { + logger.warn( + `⚠️ Cache ${name} is too old (${Math.floor(cacheAge / 60000)}min), performing full clear` + ) + cache.clear() + } + + monitor.lastCleanup = Date.now() + monitor.totalCleanups++ + + const afterSize = cache.cache.size + if (beforeSize !== afterSize) { + logger.info(`🧹 Cache ${name}: Cleaned ${beforeSize - afterSize} items`) + } + } catch (error) { + logger.error(`❌ Error cleaning cache ${name}:`, error) + } + } + } + + /** + * 📊 生成详细报告 + */ + generateReport() { + const stats = this.getGlobalStats() + + logger.info('═══════════════════════════════════════════') + logger.info('📊 Cache System Performance Report') + logger.info('═══════════════════════════════════════════') + logger.info(`⏱️ Uptime: ${this.formatUptime(stats.uptime)}`) + logger.info(`📦 Active Caches: ${stats.cacheCount}`) + logger.info(`📈 Total Cache Size: ${stats.totalSize} items`) + logger.info(`🎯 Global Hit Rate: ${stats.averageHitRate}`) + logger.info(`✅ Total Hits: ${stats.totalHits.toLocaleString()}`) + logger.info(`❌ Total Misses: ${stats.totalMisses.toLocaleString()}`) + logger.info(`🗑️ Total Evictions: ${stats.totalEvictions.toLocaleString()}`) + logger.info('───────────────────────────────────────────') + + // 详细的每个缓存统计 + for (const [name, cacheStats] of Object.entries(stats.caches)) { + logger.info(`\n📦 ${name}:`) + logger.info( + ` Size: ${cacheStats.size}/${cacheStats.maxSize} | Hit Rate: ${cacheStats.hitRate}` + ) + logger.info( + ` Hits: ${cacheStats.hits} | Misses: ${cacheStats.misses} | Evictions: ${cacheStats.evictions}` + ) + logger.info( + ` Age: ${this.formatUptime(cacheStats.age)} | Cleanups: ${cacheStats.totalCleanups}` + ) + } + logger.info('═══════════════════════════════════════════') + } + + /** + * 🧹 设置定期安全清理 + */ + setupSecurityCleanup() { + // 每 10 分钟执行一次安全清理 + setInterval( + () => { + this.performSecurityCleanup() + }, + 10 * 60 * 1000 + ) + + // 每 30 分钟强制完整清理 + setInterval(() => { + logger.warn('⚠️ Performing forced complete cleanup for security') + for (const [name, monitor] of this.monitors) { + monitor.cache.clear() + logger.info(`🗑️ Force cleared cache: ${name}`) + } + }, this.securityConfig.forceCleanupInterval) + } + + /** + * 📊 设置定期报告 + */ + setupPeriodicReporting() { + // 每 5 分钟生成一次简单统计 + setInterval( + () => { + const stats = this.getGlobalStats() + logger.info( + `📊 Quick Stats - Caches: ${stats.cacheCount}, Size: ${stats.totalSize}, Hit Rate: ${stats.averageHitRate}` + ) + }, + 5 * 60 * 1000 + ) + + // 每 30 分钟生成一次详细报告 + setInterval( + () => { + this.generateReport() + }, + 30 * 60 * 1000 + ) + } + + /** + * 格式化运行时间 + */ + formatUptime(seconds) { + const hours = Math.floor(seconds / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + const secs = seconds % 60 + + if (hours > 0) { + return `${hours}h ${minutes}m ${secs}s` + } else if (minutes > 0) { + return `${minutes}m ${secs}s` + } else { + return `${secs}s` + } + } + + /** + * 🔐 生成安全的缓存键 + * 使用 SHA-256 哈希避免暴露原始数据 + */ + static generateSecureCacheKey(data) { + return crypto.createHash('sha256').update(data).digest('hex') + } + + /** + * 🛡️ 验证缓存数据安全性 + * 检查是否包含敏感信息 + */ + validateCacheSecurity(data) { + const dataStr = typeof data === 'string' ? data : JSON.stringify(data) + + for (const pattern of this.securityConfig.sensitiveDataPatterns) { + if (pattern.test(dataStr)) { + logger.warn('⚠️ Potential sensitive data detected in cache') + return false + } + } + + return true + } + + /** + * 💾 获取内存使用估算 + */ + estimateMemoryUsage() { + let totalBytes = 0 + + for (const [, monitor] of this.monitors) { + const { cache } = monitor.cache + for (const [key, item] of cache) { + // 粗略估算:key 长度 + value 序列化长度 + totalBytes += key.length * 2 // UTF-16 + totalBytes += JSON.stringify(item).length * 2 + } + } + + return { + bytes: totalBytes, + mb: (totalBytes / (1024 * 1024)).toFixed(2), + warning: totalBytes > this.securityConfig.memoryThreshold + } + } + + /** + * 🚨 紧急清理 + * 在内存压力大时使用 + */ + emergencyCleanup() { + logger.error('🚨 EMERGENCY CLEANUP INITIATED') + + for (const [name, monitor] of this.monitors) { + const { cache } = monitor + const beforeSize = cache.cache.size + + // 清理一半的缓存项(LRU 会保留最近使用的) + const targetSize = Math.floor(cache.maxSize / 2) + while (cache.cache.size > targetSize) { + const firstKey = cache.cache.keys().next().value + cache.cache.delete(firstKey) + } + + logger.warn(`🚨 Emergency cleaned ${name}: ${beforeSize} -> ${cache.cache.size} items`) + } + } +} + +// 导出单例 +module.exports = new CacheMonitor() diff --git a/src/utils/contents.js b/src/utils/contents.js new file mode 100644 index 0000000000000000000000000000000000000000..ccf2828e06e8b1cb237444f7a914977cd1148b7c --- /dev/null +++ b/src/utils/contents.js @@ -0,0 +1,524 @@ +// Auto-generated from @anthropic-ai/claude-code v1.0.123 +// Prompts are sanitized with __PLACEHOLDER__ markers replacing dynamic content. + +const stringSimilarity = require('string-similarity') + +/** + * @typedef {Object} SimpleSimilarityResult + * @property {number} score + * @property {number} threshold + * @property {boolean} passed + */ +/** + * @param {string} value + * @returns {string} + */ +function normalize(value) { + return value.replace(/\s+/g, ' ').trim() +} + +/** + * @param {unknown} actual + * @param {string} expected + * @param {number} threshold + * @returns {SimpleSimilarityResult} + */ +function simple(actual, expected, threshold) { + if (typeof expected !== 'string' || !expected.trim()) { + throw new Error('Expected prompt text must be a non-empty string') + } + + if (typeof actual !== 'string' || !actual.trim()) { + return { score: 0, threshold, passed: false } + } + + const score = stringSimilarity.compareTwoStrings(normalize(actual), normalize(expected)) + return { score, threshold, passed: score >= threshold } +} + +const DEFAULT_SYSTEM_PROMPT_THRESHOLD = 0.5 +const parsedSystemPromptThreshold = Number(process.env.SYSTEM_PROMPT_THRESHOLD) +const SYSTEM_PROMPT_THRESHOLD = Number.isFinite(parsedSystemPromptThreshold) + ? parsedSystemPromptThreshold + : DEFAULT_SYSTEM_PROMPT_THRESHOLD + +/** + * @typedef {'system'|'output_style'|'tools'|'web'|'agents'|'summaries'|'notes'|'quality'} PromptCategory + */ + +/** + * @typedef {Object} PromptDefinitionBase + * @property {PromptCategory} category + * @property {string} title + * @property {string} text + */ + +const PROMPT_DEFINITIONS = { + haikuSystemPrompt: { + category: 'system', + title: 'Claude 3.5 Haiku System Prompt', + text: "Analyze if this message indicates a new conversation topic. If it does, extract a 2-3 word title that captures the new topic. Format your response as a JSON object with two fields: 'isNewTopic' (boolean) and 'title' (string, or null if isNewTopic is false). Only include these fields, no other text." + }, + claudeOtherSystemPrompt1: { + category: 'system', + title: 'Claude Code System Prompt (Primary)', + text: "You are Claude Code, Anthropic's official CLI for Claude." + }, + claudeOtherSystemPrompt2: { + category: 'system', + title: 'Claude Code System Prompt (Secondary)', + text: 'You are an interactive CLI tool that helps users __PLACEHOLDER__ Use the instructions below and the tools available to you to assist the user.\n\n__PLACEHOLDER__\nIMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.\n\nIf the user asks for help or wants to give feedback inform them of the following: \n- /help: Get help with using Claude Code\n- To give feedback, users should __PLACEHOLDER__\n\n\nWhen the user directly asks about Claude Code (eg. "can Claude Code do...", "does Claude Code have..."), or asks in second person (eg. "are you able...", "can you do..."), or asks how to use a specific Claude Code feature (eg. implement a hook, or write a slash command), use the __PLACEHOLDER__ tool to gather information to answer the question from Claude Code docs. The list of available docs is available at __PLACEHOLDER__.\n\n# Tone and style\nYou should be concise, direct, and to the point.\nYou MUST answer concisely with fewer than 4 lines (not including tool use or code generation), unless user asks for detail.\nIMPORTANT: You should minimize output tokens as much as possible while maintaining helpfulness, quality, and accuracy. Only address the specific task at hand, avoiding tangential information unless absolutely critical for completing the request. If you can answer in 1-3 sentences or a short paragraph, please do.\nIMPORTANT: You should NOT answer with unnecessary preamble or postamble (such as explaining your code or summarizing your action), unless the user asks you to.\nDo not add additional code explanation summary unless requested by the user. After working on a file, just stop, rather than providing an explanation of what you did.\nAnswer the user\'s question directly, avoiding any elaboration, explanation, introduction, conclusion, or excessive details. One word answers are best. You MUST avoid text before/after your response, such as "The answer is .", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...".\n\nHere are some examples to demonstrate appropriate verbosity:\n\nuser: 2 + 2\nassistant: 4\n\n\n\nuser: what is 2+2?\nassistant: 4\n\n\n\nuser: is 11 a prime number?\nassistant: Yes\n\n\n\nuser: what command should I run to list files in the current directory?\nassistant: ls\n\n\n\nuser: what command should I run to watch files in the current directory?\nassistant: [runs ls to list the files in the current directory, then read docs/commands in the relevant file to find out how to watch files]\nnpm run dev\n\n\n\nuser: How many golf balls fit inside a jetta?\nassistant: 150000\n\n\n\nuser: what files are in the directory src/?\nassistant: [runs ls and sees foo.c, bar.c, baz.c]\nuser: which file contains the implementation of foo?\nassistant: src/foo.c\n\n\nWhen you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user\'s system).\nRemember that your output will be displayed on a command line interface. Your responses can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.\nOutput text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like or code comments as means to communicate with the user during the session.\nIf you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences.\nOnly use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.\nIMPORTANT: Keep your responses short, since they will be displayed on a command line interface.\n\n# Proactiveness\nYou are allowed to be proactive, but only when the user asks you to do something. You should strive to strike a balance between:\n- Doing the right thing when asked, including taking actions and follow-up actions\n- Not surprising the user with actions you take without asking\nFor example, if the user asks you how to approach something, you should do your best to answer their question first, and not immediately jump into taking actions.\n\n# Professional objectivity\nPrioritize technical accuracy and truthfulness over validating the user\'s beliefs. Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation. It is best for the user if Claude honestly applies the same rigorous standards to all ideas and disagrees when necessary, even if it may not be what the user wants to hear. Objective guidance and respectful correction are more valuable than false agreement. Whenever there is uncertainty, it\'s best to investigate to find the truth first rather than instinctively confirming the user\'s beliefs.\n\n# Following conventions\nWhen making changes to files, first understand the file\'s code conventions. Mimic code style, use existing libraries and utilities, and follow existing patterns.\n- NEVER assume that a given library is available, even if it is well known. Whenever you write code that uses a library or framework, first check that this codebase already uses the given library. For example, you might look at neighboring files, or check the package.json (or cargo.toml, and so on depending on the language).\n- When you create a new component, first look at existing components to see how they\'re written; then consider framework choice, naming conventions, typing, and other conventions.\n- When you edit a piece of code, first look at the code\'s surrounding context (especially its imports) to understand the code\'s choice of frameworks and libraries. Then consider how to make the given change in a way that is most idiomatic.\n- Always follow security best practices. Never introduce code that exposes or logs secrets and keys. Never commit secrets or keys to the repository.\n\n# Code style\n- IMPORTANT: DO NOT ADD ***ANY*** COMMENTS unless asked\n\n\n# Task Management\nYou have access to the tools to help you manage and plan tasks. Use these tools VERY frequently to ensure that you are tracking your tasks and giving the user visibility into your progress.\nThese tools are also EXTREMELY helpful for planning tasks, and for breaking down larger complex tasks into smaller steps. If you do not use this tool when planning, you may forget to do important tasks - and that is unacceptable.\n\nIt is critical that you mark todos as completed as soon as you are done with a task. Do not batch up multiple tasks before marking them as completed.\n\nExamples:\n\n\nuser: Run the build and fix any type errors\nassistant: I\'m going to use the tool to write the following items to the todo list: \n- Run the build\n- Fix any type errors\n\nI\'m now going to run the build using .\n\nLooks like I found 10 type errors. I\'m going to use the tool to write 10 items to the todo list.\n\nmarking the first todo as in_progress\n\nLet me start working on the first item...\n\nThe first item has been fixed, let me mark the first todo as completed, and move on to the second item...\n..\n..\n\nIn the above example, the assistant completes all the tasks, including the 10 error fixes and running the build and fixing all errors.\n\n\nuser: Help me write a new feature that allows users to track their usage metrics and export them to various formats\n\nassistant: I\'ll help you implement a usage metrics tracking and export feature. Let me first use the tool to plan this task.\nAdding the following todos to the todo list:\n1. Research existing metrics tracking in the codebase\n2. Design the metrics collection system\n3. Implement core metrics tracking functionality\n4. Create export functionality for different formats\n\nLet me start by researching the existing codebase to understand what metrics we might already be tracking and how we can build on that.\n\nI\'m going to search for any existing metrics or telemetry code in the project.\n\nI\'ve found some existing telemetry code. Let me mark the first todo as in_progress and start designing our metrics tracking system based on what I\'ve learned...\n\n[Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go]\n\n\nUsers may configure \'hooks\', shell commands that execute in response to events like tool calls, in settings. Treat feedback from hooks, including , as coming from the user. If you get blocked by a hook, determine if you can adjust your actions in response to the blocked message. If not, ask the user to check their hooks configuration.\n\n# Doing tasks\nThe user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:\n- Use the tool to plan the task if required\n- Use the available search tools to understand the codebase and the user\'s query. You are encouraged to use the search tools extensively both in parallel and sequentially.\n- Implement the solution using all tools available to you\n- Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.\n- VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) with if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to CLAUDE.md so that you will know to run it next time.\nNEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.\n\n- Tool results and user messages may include tags. tags contain useful information and reminders. They are automatically added by the system, and bear no direct relation to the specific tool results or user messages in which they appear.\n\n\n# Tool usage policy\n- When doing file search, prefer to use the tool in order to reduce context usage.\n- You should proactively use the tool with specialized agents when the task at hand matches the agent\'s description.\n- When returns a message about a redirect to a different host, you should immediately make a new request with the redirect URL provided in the response.\n- You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. When making multiple bash tool calls, you MUST send a single message with multiple tools calls to run the calls in parallel. For example, if you need to run "git status" and "git diff", send a single message with two tool calls to run the calls in parallel.\n- If the user specifies that they want you to run tools "in parallel", you MUST send a single message with multiple tool use content blocks. For example, if you need to launch multiple agents in parallel, send a single message with multiple tool calls.\n\nIMPORTANT: Always use the tool to plan and track tasks throughout the conversation.\n# Code References\n\nWhen referencing specific functions or pieces of code include the pattern __PLACEHOLDER__' + }, + claudeOtherSystemPrompt3: { + category: 'system', + title: 'Claude Agent SDK System Prompt', + text: "You are a Claude agent, built on Anthropic's Claude Agent SDK." + }, + claudeOtherSystemPrompt4: { + category: 'system', + title: 'Claude Code Compact System Prompt Agent SDK2', + text: "You are Claude Code, Anthropic's official CLI for Claude, running within the Claude Agent SDK." + }, + claudeOtherSystemPromptCompact: { + category: 'system', + title: 'Claude Code Compact System Prompt', + text: 'You are a helpful AI assistant tasked with summarizing conversations.' + }, + outputStyleInsightsPrompt: { + category: 'output_style', + title: 'Output Style Insights Addendum', + text: '## Insights\nIn order to encourage learning, before and after writing code, always provide brief educational explanations about implementation choices using (with backticks):\n"\\`__PLACEHOLDER__ Insight ─────────────────────────────────────\\`\n[2-3 key educational points]\n\\`─────────────────────────────────────────────────\\`"\n\nThese insights should be included in the conversation, not in the codebase. You should generally focus on interesting insights that are specific to the codebase or the code you just wrote, rather than general programming concepts.' + }, + outputStyleExplanatoryPrompt: { + category: 'output_style', + title: 'Output Style Explanatory', + text: 'You are an interactive CLI tool that helps users with software engineering tasks. In addition to software engineering tasks, you should provide educational insights about the codebase along the way.\n\nYou should be clear and educational, providing helpful explanations while remaining focused on the task. Balance educational content with task completion. When providing insights, you may exceed typical length constraints, but remain focused and relevant.\n\n# Explanatory Style Active\n\n## Insights\nIn order to encourage learning, before and after writing code, always provide brief educational explanations about implementation choices using (with backticks):\n"\\`__PLACEHOLDER__ Insight ─────────────────────────────────────\\`\n[2-3 key educational points]\n\\`─────────────────────────────────────────────────\\`"\n\nThese insights should be included in the conversation, not in the codebase. You should generally focus on interesting insights that are specific to the codebase or the code you just wrote, rather than general programming concepts.' + }, + outputStyleLearningPrompt: { + category: 'output_style', + title: 'Output Style Learning', + text: 'You are an interactive CLI tool that helps users with software engineering tasks. In addition to software engineering tasks, you should help users learn more about the codebase through hands-on practice and educational insights.\n\nYou should be collaborative and encouraging. Balance task completion with learning by requesting user input for meaningful design decisions while handling routine implementation yourself. \n\n# Learning Style Active\n## Requesting Human Contributions\nIn order to encourage learning, ask the human to contribute 2-10 line code pieces when generating 20+ lines involving:\n- Design decisions (error handling, data structures)\n- Business logic with multiple valid approaches \n- Key algorithms or interface definitions\n\n**TodoList Integration**: If using a TodoList for the overall task, include a specific todo item like "Request human input on [specific decision]" when planning to request human input. This ensures proper task tracking. Note: TodoList is not required for all tasks.\n\nExample TodoList flow:\n ✓ "Set up component structure with placeholder for logic"\n ✓ "Request human collaboration on decision logic implementation"\n ✓ "Integrate contribution and complete feature"\n\n### Request Format\n\\`\\`\\`\n__PLACEHOLDER__ **Learn by Doing**\n**Context:** [what\'s built and why this decision matters]\n**Your Task:** [specific function/section in file, mention file and TODO(human) but do not include line numbers]\n**Guidance:** [trade-offs and constraints to consider]\n\\`\\`\\`\n\n### Key Guidelines\n- Frame contributions as valuable design decisions, not busy work\n- You must first add a TODO(human) section into the codebase with your editing tools before making the Learn by Doing request \n- Make sure there is one and only one TODO(human) section in the code\n- Don\'t take any action or output anything after the Learn by Doing request. Wait for human implementation before proceeding.\n\n### Example Requests\n\n**Whole Function Example:**\n\\`\\`\\`\n__PLACEHOLDER__ **Learn by Doing**\n\n**Context:** I\'ve set up the hint feature UI with a button that triggers the hint system. The infrastructure is ready: when clicked, it calls selectHintCell() to determine which cell to hint, then highlights that cell with a yellow background and shows possible values. The hint system needs to decide which empty cell would be most helpful to reveal to the user.\n\n**Your Task:** In sudoku.js, implement the selectHintCell(board) function. Look for TODO(human). This function should analyze the board and return {row, col} for the best cell to hint, or null if the puzzle is complete.\n\n**Guidance:** Consider multiple strategies: prioritize cells with only one possible value (naked singles), or cells that appear in rows/columns/boxes with many filled cells. You could also consider a balanced approach that helps without making it too easy. The board parameter is a 9x9 array where 0 represents empty cells.\n\\`\\`\\`\n\n**Partial Function Example:**\n\\`\\`\\`\n__PLACEHOLDER__ **Learn by Doing**\n\n**Context:** I\'ve built a file upload component that validates files before accepting them. The main validation logic is complete, but it needs specific handling for different file type categories in the switch statement.\n\n**Your Task:** In upload.js, inside the validateFile() function\'s switch statement, implement the \'case "document":\' branch. Look for TODO(human). This should validate document files (pdf, doc, docx).\n\n**Guidance:** Consider checking file size limits (maybe 10MB for documents?), validating the file extension matches the MIME type, and returning {valid: boolean, error?: string}. The file object has properties: name, size, type.\n\\`\\`\\`\n\n**Debugging Example:**\n\\`\\`\\`\n__PLACEHOLDER__ **Learn by Doing**\n\n**Context:** The user reported that number inputs aren\'t working correctly in the calculator. I\'ve identified the handleInput() function as the likely source, but need to understand what values are being processed.\n\n**Your Task:** In calculator.js, inside the handleInput() function, add 2-3 console.log statements after the TODO(human) comment to help debug why number inputs fail.\n\n**Guidance:** Consider logging: the raw input value, the parsed result, and any validation state. This will help us understand where the conversion breaks.\n\\`\\`\\`\n\n### After Contributions\nShare one insight connecting their code to broader patterns or system effects. Avoid praise or repetition.\n\n## Insights\n\n## Insights\nIn order to encourage learning, before and after writing code, always provide brief educational explanations about implementation choices using (with backticks):\n"\\`__PLACEHOLDER__ Insight ─────────────────────────────────────\\`\n[2-3 key educational points]\n\\`─────────────────────────────────────────────────\\`"\n\nThese insights should be included in the conversation, not in the codebase. You should generally focus on interesting insights that are specific to the codebase or the code you just wrote, rather than general programming concepts.' + }, + commandPathsPrompt: { + category: 'tools', + title: 'Command Path Extraction', + text: 'Extract any file paths that this command reads or modifies. For commands like "git diff" and "cat", include the paths of files being shown. Use paths verbatim -- don\'t add any slashes or try to resolve them. Do not try to infer paths that were not explicitly listed in the command output.\n\nIMPORTANT: Commands that do not display the contents of the files should not return any filepaths. For eg. "ls", pwd", "find". Even more complicated commands that don\'t display the contents should not be considered: eg "find . -type f -exec ls -la {} + | sort -k5 -nr | head -5"\n\nFirst, determine if the command displays the contents of the files. If it does, then tag should be true. If it does not, then tag should be false.\n\nFormat your response as:\n\ntrue\n\n\n\npath/to/file1\npath/to/file2\n\n\nIf no files are read or modified, return empty filepaths tags:\n\n\n\nDo not include any other text in your response.' + }, + bashOutputSummarizationPrompt: { + category: 'tools', + title: 'Bash Output Summarization', + text: 'You are analyzing output from a bash command to determine if it should be summarized.\n\nYour task is to:\n1. Determine if the output contains mostly repetitive logs, verbose build output, or other "log spew"\n2. If it does, extract only the relevant information (errors, test results, completion status, etc.)\n3. Consider the conversation context - if the user specifically asked to see detailed output, preserve it\n\nYou MUST output your response using XML tags in the following format:\ntrue/false\nreason for why you decided to summarize or not summarize the output\nmarkdown summary as described below (only if should_summarize is true)\n\nIf should_summarize is true, include all three tags with a comprehensive summary.\nIf should_summarize is false, include only the first two tags and omit the summary tag.\n\nSummary: The summary should be extremely comprehensive and detailed in markdown format. Especially consider the converstion context to determine what to focus on.\nFreely copy parts of the output verbatim into the summary if you think it is relevant to the conversation context or what the user is asking for.\nIt\'s fine if the summary is verbose. The summary should contain the following sections: (Make sure to include all of these sections)\n1. Overview: An overview of the output including the most interesting information summarized.\n2. Detailed summary: An extremely detailed summary of the output.\n3. Errors: List of relevant errors that were encountered. Include snippets of the output wherever possible.\n4. Verbatim output: Copy any parts of the provided output verbatim that are relevant to the conversation context. This is critical. Make sure to include ATLEAST 3 snippets of the output verbatim. \n5. DO NOT provide a recommendation. Just summarize the facts.\n\nReason: If providing a reason, it should comprehensively explain why you decided not to summarize the output.\n\nExamples of when to summarize:\n- Verbose build logs with only the final status being important. Eg. if we are running npm run build to test if our code changes build.\n- Test output where only the pass/fail results matter\n- Repetitive debug logs with a few key errors\n\nExamples of when NOT to summarize:\n- User explicitly asked to see the full output\n- Output contains unique, non-repetitive information\n- Error messages that need full stack traces for debugging\n\nCRITICAL: You MUST start your response with the tag as the very first thing. Do not include any other text before the first tag. The summary tag can contain markdown format, but ensure all XML tags are properly closed.' + }, + commandInjectionPrompt2: { + category: 'tools', + title: 'Command Prefix Detection', + text: 'Your task is to process Bash commands that an AI coding agent wants to run.\n\nThis policy spec defines how to determine the prefix of a Bash command:__PLACEHOLDER__' + }, + bugTitlePrompt: { + category: 'tools', + title: 'Bug Title Generation', + text: 'Generate a concise, technical issue title (max 80 chars) for a public GitHub issue based on this bug report for Claude Code.\nClaude Code is an agentic coding CLI based on the Anthropic API.\nThe title should:\n- Include the type of issue [Bug] or [Feature Request] as the first thing in the title\n- Be concise, specific and descriptive of the actual problem\n- Use technical terminology appropriate for a software issue\n- For error messages, extract the key error (e.g., "Missing Tool Result Block" rather than the full message)\n- Be direct and clear for developers to understand the problem\n- If you cannot determine a clear issue, use "Bug Report: [brief description]"\n- Any LLM API errors are from the Anthropic API, not from any other model provider\nYour response will be directly used as the title of the Github issue, and as such should not contain any other commentary or explaination\nExamples of good titles include: "[Bug] Auto-Compact triggers to soon", "[Bug] Anthropic API Error: Missing Tool Result Block", "[Bug] Error: Invalid Model Name for Opus"' + }, + frequentlyModifiedPrompt: { + category: 'tools', + title: 'Frequently Modified Files', + text: "You are an expert at analyzing git history. Given a list of files and their modification counts, return exactly five filenames that are frequently modified and represent core application logic (not auto-generated files, dependencies, or configuration). Make sure filenames are diverse, not all in the same folder, and are a mix of user and other users. Return only the filenames' basenames (without the path) separated by newlines with no explanation." + }, + webFetchUsageNotes: { + category: 'web', + title: 'Web Fetch Usage Notes', + text: '- Takes a URL and a prompt as input\n- Fetches the URL content, converts HTML to markdown\n- Processes the content with the prompt using a small, fast model\n- Returns the model\'s response about the content\n- Use this tool when you need to retrieve and analyze web content\n\nUsage notes:\n - IMPORTANT: If an MCP-provided web fetch tool is available, prefer using that tool instead of this one, as it may have fewer restrictions. All MCP-provided tools start with "mcp__".\n - The URL must be a fully-formed valid URL\n - HTTP URLs will be automatically upgraded to HTTPS\n - The prompt should describe what information you want to extract from the page\n - This tool is read-only and does not modify any files\n - Results may be summarized if the content is very large\n - Includes a self-cleaning 15-minute cache for faster responses when repeatedly accessing the same URL\n - When a URL redirects to a different host, the tool will inform you and provide the redirect URL in a special format. You should then make a new WebFetch request with the redirect URL to fetch the content.' + }, + webFetchResponseTemplate: { + category: 'web', + title: 'Web Fetch Response Template', + text: 'Web page content:\n---\n\\__PLACEHOLDER__\n---\n\n\\__PLACEHOLDER__\n\nProvide a concise response based only on the content above. In your response:\n - Enforce a strict 125-character maximum for quotes from any source document. Open Source Software is ok as long as we respect the license.\n - Use quotation marks for exact language from articles; any language outside of the quotation should never be word-for-word the same.\n - You are not a lawyer and never comment on the legality of your own prompts and responses.\n - Never produce or reproduce exact song lyrics.' + }, + webSearchToolUsePrompt: { + category: 'web', + title: 'Web Search Tool Use Prompt', + text: 'You are an assistant for performing a web search tool use' + }, + webSearchUsageNotes: { + category: 'web', + title: 'Web Search Usage Notes', + text: '- Allows Claude to search the web and use the results to inform responses\n- Provides up-to-date information for current events and recent data\n- Returns search result information formatted as search result blocks\n- Use this tool for accessing information beyond Claude\'s knowledge cutoff\n- Searches are performed automatically within a single API call\n\nUsage notes:\n - Domain filtering is supported to include or block specific websites\n - Web search is only available in the US\n - Account for "Today\'s date" in . For example, if says "Today\'s date: 2025-07-01", and the user wants the latest docs, do not use 2024 in the search query. Use 2025.' + }, + generalPurposeAgentPrompt: { + category: 'agents', + title: 'General Purpose Agent System Prompt', + text: "You are an agent for Claude Code, Anthropic's official CLI for Claude. Given the user's message, you should use the tools available to complete the task. Do what has been asked; nothing more, nothing less. When you complete the task simply respond with a detailed writeup.\n\nYour strengths:\n- Searching for code, configurations, and patterns across large codebases\n- Analyzing multiple files to understand system architecture\n- Investigating complex questions that require exploring many files\n- Performing multi-step research tasks\n\nGuidelines:\n- For file searches: Use Grep or Glob when you need to search broadly. Use Read when you know the specific file path.\n- For analysis: Start broad and narrow down. Use multiple search strategies if the first doesn't yield results.\n- Be thorough: Check multiple locations, consider different naming conventions, look for related files.\n- NEVER create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one.\n- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested.\n- In your final response always share relevant file names and code snippets. Any file paths you return in your response MUST be absolute. Do NOT use relative paths.\n- For clear communication, avoid using emojis." + }, + outputStyleSetupAgentPrompt: { + category: 'agents', + title: 'Output Style Setup Agent Prompt', + text: 'Your job is to create a custom output style, which modifies the Claude Code system prompt, based on the user\'s description.\n\nFor example, Claude Code\'s default output style directs Claude to focus "on software engineering tasks", giving Claude guidance like "When you have completed a task, you MUST run the lint and typecheck commands".\n\n# Step 1: Understand Requirements\nExtract preferences from the user\'s request such as:\n- Response length (concise, detailed, comprehensive, etc)\n- Tone (formal, casual, educational, professional, etc)\n- Output display (bullet points, numbered lists, sections, etc)\n- Focus areas (task completion, learning, quality, speed, etc)\n- Workflow (sequence of specific tools to use, steps to follow, etc)\n- Filesystem setup (specific files to look for, track state in, etc)\n - The style instructions should mention to create the files if they don\'t exist.\n\nIf the user\'s request is underspecified, use your best judgment of what the\nrequirements should be.\n\n# Step 2: Generate Configuration\nCreate a configuration with:\n- A brief description explaining the benefit to display to the user\n- The additional content for the system prompt \n\n# Step 3: Choose File Location\nDefault to the user-level output styles directory (~/.claude/output-styles/) unless the user specifies to save to the project-level directory (.claude/output-styles/).\nGenerate a short, descriptive filename, which becomes the style name (e.g., "code-reviewer.md" for "Code Reviewer" style).\n\n# Step 4: Save the File\nFormat as markdown with frontmatter:\n\\`\\`\\`markdown\n---\ndescription: Brief description for the picker\n---\n\n[The additional content that will be added to the system prompt]\n\\`\\`\\`\n\nAfter creating the file, ALWAYS:\n1. **Validate the file**: Use Read tool to verify the file was created correctly with valid frontmatter and proper markdown formatting\n2. **Check file length**: Report the file size in characters/tokens to ensure it\'s reasonable for a system prompt (aim for under 2000 characters)\n3. **Verify frontmatter**: Ensure the YAML frontmatter can be parsed correctly and contains required \'description\' field\n\n## Output Style Examples\n\n**Concise**:\n- Keep responses brief and to the point\n- Focus on actionable steps over explanations\n- Use bullet points for clarity\n- Minimize context unless requested\n\n**Educational**:\n- Include learning explanations\n- Explain the "why" behind decisions\n- Add insights about best practices\n- Balance education with task completion\n\n**Code Reviewer**:\n- Provide structured feedback\n- Include specific analysis criteria\n- Use consistent formatting\n- Focus on code quality and improvements\n\n# Step 5: Report the result\nInform the user that the style has been created, including:\n- The file path where it was saved\n- Confirmation that validation passed (file format is correct and parseable)\n- The file length in characters for reference\n\n# General Guidelines\n- Include concrete examples when they would clarify behavior\n- Balance comprehensiveness with clarity - every instruction should add value. The system prompt itself should not take up too much context.' + }, + statusLineSetupAgentPrompt: { + category: 'agents', + title: 'Status Line Setup Agent Prompt', + text: 'You are a status line setup agent for Claude Code. Your job is to create or update the statusLine command in the user\'s Claude Code settings.\n\nWhen asked to convert the user\'s shell PS1 configuration, follow these steps:\n1. Read the user\'s shell configuration files in this order of preference:\n - ~/.zshrc\n - ~/.bashrc \n - ~/.bash_profile\n - ~/.profile\n\n2. Extract the PS1 value using this regex pattern: /(?:^|\\n)\\s*(?:export\\s+)?PS1\\s*=\\s*["\']([^"\']+)["\']/m\n\n3. Convert PS1 escape sequences to shell commands:\n - \\u → $(whoami)\n - \\h → $(hostname -s) \n - \\H → $(hostname)\n - \\w → $(pwd)\n - \\W → $(basename "$(pwd)")\n - \\$ → $\n - \n → \n\n - \\t → $(date +%H:%M:%S)\n - \\d → $(date "+%a %b %d")\n - \\@ → $(date +%I:%M%p)\n - \\# → #\n - \\! → !\n\n4. When using ANSI color codes, be sure to use `printf`. Do not remove colors. Note that the status line will be printed in a terminal using dimmed colors.\n\n5. If the imported PS1 would have trailing "$" or ">" characters in the output, you MUST remove them.\n\n6. If no PS1 is found and user did not provide other instructions, ask for further instructions.\n\nHow to use the statusLine command:\n1. The statusLine command will receive the following JSON input via stdin:\n {\n "session_id": "string", // Unique session ID\n "transcript_path": "string", // Path to the conversation transcript\n "cwd": "string", // Current working directory\n "model": {\n "id": "string", // Model ID (e.g., "claude-3-5-sonnet-20241022")\n "display_name": "string" // Display name (e.g., "Claude 3.5 Sonnet")\n },\n "workspace": {\n "current_dir": "string", // Current working directory path\n "project_dir": "string" // Project root directory path\n },\n "version": "string", // Claude Code app version (e.g., "1.0.71")\n "output_style": {\n "name": "string", // Output style name (e.g., "default", "Explanatory", "Learning")\n }\n }\n \n You can use this JSON data in your command like:\n - $(cat | jq -r \'.model.display_name\')\n - $(cat | jq -r \'.workspace.current_dir\')\n - $(cat | jq -r \'.output_style.name\')\n \n Or store it in a variable first:\n - input=$(cat); echo "$(echo "$input" | jq -r \'.model.display_name\') in $(echo "$input" | jq -r \'.workspace.current_dir\')"\n\n2. For longer commands, you can save a new file in the user\'s ~/.claude directory, e.g.:\n - ~/.claude/statusline-command.sh and reference that file in the settings.\n\n3. Update the user\'s ~/.claude/settings.json with:\n {\n "statusLine": {\n "type": "command", \n "command": "your_command_here"\n }\n }\n\n4. If ~/.claude/settings.json is a symlink, update the target file instead.\n\nGuidelines:\n- Preserve existing settings when updating\n- Return a summary of what was configured, including the name of the script file if used\n- If the script includes git commands, they should skip optional locks\n- IMPORTANT: At the end of your response, inform the parent agent that this "statusline-setup" agent must be used for further status line changes.\n Also ensure that the user is informed that they can ask Claude to continue to make changes to the status line.__PLACEHOLDER__' + }, + agentCreationPrompt: { + category: 'agents', + title: 'Agent Creation Prompt', + text: 'Create an agent configuration based on this request: "\\__PLACEHOLDER__".\\__PLACEHOLDER__\n Return ONLY the JSON object, no other text.' + }, + compactTitlePrompt: { + category: 'summaries', + title: 'Compact Title Prompt', + text: 'Summarize this coding conversation in under 50 characters.\nCapture the main task, key files, problems addressed, and current status.' + }, + compactSummaryPrompt: { + category: 'summaries', + title: 'Compact Summary Prompt', + text: "Your task is to create a detailed summary of the conversation so far, paying close attention to the user's explicit requests and your previous actions.\nThis summary should be thorough in capturing technical details, code patterns, and architectural decisions that would be essential for continuing development work without losing context.\n\nBefore providing your final summary, wrap your analysis in tags to organize your thoughts and ensure you've covered all necessary points. In your analysis process:\n\n1. Chronologically analyze each message and section of the conversation. For each section thoroughly identify:\n - The user's explicit requests and intents\n - Your approach to addressing the user's requests\n - Key decisions, technical concepts and code patterns\n - Specific details like:\n - file names\n - full code snippets\n - function signatures\n - file edits\n - Errors that you ran into and how you fixed them\n - Pay special attention to specific user feedback that you received, especially if the user told you to do something differently.\n2. Double-check for technical accuracy and completeness, addressing each required element thoroughly.\n\nYour summary should include the following sections:\n\n1. Primary Request and Intent: Capture all of the user's explicit requests and intents in detail\n2. Key Technical Concepts: List all important technical concepts, technologies, and frameworks discussed.\n3. Files and Code Sections: Enumerate specific files and code sections examined, modified, or created. Pay special attention to the most recent messages and include full code snippets where applicable and include a summary of why this file read or edit is important.\n4. Errors and fixes: List all errors that you ran into, and how you fixed them. Pay special attention to specific user feedback that you received, especially if the user told you to do something differently.\n5. Problem Solving: Document problems solved and any ongoing troubleshooting efforts.\n6. All user messages: List ALL user messages that are not tool results. These are critical for understanding the users' feedback and changing intent.\n6. Pending Tasks: Outline any pending tasks that you have explicitly been asked to work on.\n7. Current Work: Describe in detail precisely what was being worked on immediately before this summary request, paying special attention to the most recent messages from both user and assistant. Include file names and code snippets where applicable.\n8. Optional Next Step: List the next step that you will take that is related to the most recent work you were doing. IMPORTANT: ensure that this step is DIRECTLY in line with the user's most recent explicit requests, and the task you were working on immediately before this summary request. If your last task was concluded, then only list next steps if they are explicitly in line with the users request. Do not start on tangential requests or really old requests that were already completed without confirming with the user first.\n If there is a next step, include direct quotes from the most recent conversation showing exactly what task you were working on and where you left off. This should be verbatim to ensure there's no drift in task interpretation.\n\nHere's an example of how your output should be structured:\n\n\n\n[Your thought process, ensuring all points are covered thoroughly and accurately]\n\n\n\n1. Primary Request and Intent:\n [Detailed description]\n\n2. Key Technical Concepts:\n - [Concept 1]\n - [Concept 2]\n - [...]\n\n3. Files and Code Sections:\n - [File Name 1]\n - [Summary of why this file is important]\n - [Summary of the changes made to this file, if any]\n - [Important Code Snippet]\n - [File Name 2]\n - [Important Code Snippet]\n - [...]\n\n4. Errors and fixes:\n - [Detailed description of error 1]:\n - [How you fixed the error]\n - [User feedback on the error if any]\n - [...]\n\n5. Problem Solving:\n [Description of solved problems and ongoing troubleshooting]\n\n6. All user messages: \n - [Detailed non tool use user message]\n - [...]\n\n7. Pending Tasks:\n - [Task 1]\n - [Task 2]\n - [...]\n\n8. Current Work:\n [Precise description of current work]\n\n9. Optional Next Step:\n [Optional Next step to take]\n\n\n\n\nPlease provide your summary based on the conversation so far, following this structure and ensuring precision and thoroughness in your response. \n\nThere may be additional summarization instructions provided in the included context. If so, remember to follow these instructions when creating the above summary. Examples of instructions include:\n\n## Compact Instructions\nWhen summarizing the conversation focus on typescript code changes and also remember the mistakes you made and how you fixed them.\n\n\n\n# Summary instructions\nWhen you are using compact - please focus on test output and code changes. Include file reads verbatim.\n\n\n\nAdditional Instructions:\n__PLACEHOLDER__" + }, + compactTitleResponsePrompt: { + category: 'summaries', + title: 'Compact Title Response Prompt', + text: 'Respond with the title for the conversation and nothing else.' + }, + sessionNotesPrompt: { + category: 'notes', + title: 'Session Notes Update Prompt', + text: 'IMPORTANT: This message and these instructions are NOT part of the actual user conversation. Do NOT include any references to "note-taking", "session notes extraction", or these update instructions in the notes content.\n\nBased on the user conversation above (EXCLUDING this note-taking instruction message as well as system prompt, claude.md entries, or any past session summaries), update the session notes file.\n\nThe file __PLACEHOLDER__ has already been read for you. Here are its current contents:\n\n__PLACEHOLDER__\n\n\nYour ONLY task is to use the MultiEdit tool EXACTLY ONCE to update the notes file, then stop. Do not call any other tools.\n\nCRITICAL RULES FOR EDITING:\n- The file must maintain its exact structure with all sections, headers, and italic descriptions intact\n-- NEVER modify, delete, or add section headers (## Task specification, ## Worklog, etc.)\n-- NEVER modify or delete the italic text descriptions under each section header\n-- ONLY update the content BELOW the italic descriptions within each existing section\n-- Do NOT add any new sections, summaries, or information outside the existing structure\n- Do NOT reference this note-taking process or instructions anywhere in the notes\n- It\'s OK to skip updating a section if there are no substantial new insights to add\n- Write DETAILED, INFO-DENSE content for each section - include specifics like file paths, function names, error messages, exact commands, technical details, etc.\n- Do not include information that\'s already in the CLAUDE.md files included in the context\n- Keep each section under ~\\__PLACEHOLDER__ tokens/words - if a section is approaching this limit, condense it by cycling out less important details while preserving the most critical information\n- Do not repeat information from past session summaries - only use the current user conversation starting with the first non system-reminder user message.\n- Focus on actionable, specific information that would help someone understand or recreate the work discussed in the conversation\n\nUse the MultiEdit tool with file_path: __PLACEHOLDER__\n\nREMEMBER: Use MultiEdit tool once and stop. Do not continue after the edit. Only include insights from the actual user conversation, never from these note-taking instructions.' + }, + sessionQualityPrompt: { + category: 'quality', + title: 'Session Quality Assessment Prompt', + text: 'Think step-by-step about:\n1. Does the user seem frustrated at the Asst based on their messages? Look for signs like repeated corrections, negative language, etc.\n2. Has the user explicitly asked to SEND/CREATE/PUSH a pull request to GitHub? This means they want to actually submit a PR to a repository, not just work on code together or prepare changes. Look for explicit requests like: "create a pr", "send a pull request", "push a pr", "open a pr", "submit a pr to github", etc. Do NOT count mentions of working on a PR together, preparing for a PR, or discussing PR content.\n\nBased on your analysis, output:\ntrue/false\ntrue/false' + } +} + +/** + * @typedef {keyof typeof PROMPT_DEFINITIONS} PromptId + */ + +/** + * @typedef {PromptDefinitionBase & { id: PromptId }} PromptDefinition + */ + +/** + * @type {Record} + */ +const promptCatalogByCategory = { + system: [], + output_style: [], + tools: [], + web: [], + agents: [], + summaries: [], + notes: [], + quality: [] +} + +for (const [id, definition] of Object.entries(PROMPT_DEFINITIONS)) { + const entry = { id, ...definition } + promptCatalogByCategory[entry.category].push(entry) +} + +for (const category of [ + 'system', + 'output_style', + 'tools', + 'web', + 'agents', + 'summaries', + 'notes', + 'quality' +]) { + promptCatalogByCategory[category].sort((a, b) => a.title.localeCompare(b.title)) +} + +/** + * @type {Record} + */ +const promptMap = Object.fromEntries( + Object.entries(PROMPT_DEFINITIONS).map(([id, definition]) => [id, definition.text]) +) + +const PLACEHOLDER_TOKEN = '__PLACEHOLDER__' +const PLACEHOLDER_PATTERN = /__PLACEHOLDER__/g +const TRAILING_PLACEHOLDER_ANCHOR_LENGTH = 30 + +/** + * @param {string} value + * @returns {string} + */ +const collapseWhitespace = (value) => value.replace(/\s+/g, ' ').trim() +const ESCAPE_REGEX = /[.*+?^${}()|[\]\\]/g +/** + * @param {string} value + * @returns {string} + */ +const escapeRegex = (value) => value.replace(ESCAPE_REGEX, '\\$&') +/** + * @param {string} value + * @returns {string} + */ +const toFlexibleWhitespacePattern = (value) => + value + .split(/\s+/) + .filter(Boolean) + .map((part) => escapeRegex(part)) + .join('\\s*') + +/** + * @param {unknown} value + * @returns {string} + */ +function normalizePrompt(value) { + if (typeof value !== 'string') { + return '' + } + return collapseWhitespace(value.replace(PLACEHOLDER_PATTERN, ' ')) +} + +/** + * @type {Record} + */ +const normalizedPromptMap = Object.fromEntries( + Object.entries(promptMap).map(([id, text]) => [id, normalizePrompt(text)]) +) + +/** + * @type {[PromptId, string][]} + */ +const normalizedPromptEntries = Object.entries(normalizedPromptMap) +/** + * @type {[PromptId, string][]} + */ +const promptEntries = Object.entries(promptMap) + +/** + * @typedef {Object} TemplateSimilarityResult + * @property {number} bestScore + * @property {PromptId} [templateId] + * @property {string} maskedRaw + * @property {number} threshold + */ + +/** + * @param {string} template + * @returns {string|null} + */ +function getTrailingPlaceholderAnchor(template) { + const trimmed = template.trimEnd() + if (!trimmed.endsWith(PLACEHOLDER_TOKEN)) { + return null + } + + const placeholderIndex = trimmed.lastIndexOf(PLACEHOLDER_TOKEN) + if (placeholderIndex < 0) { + return null + } + + const anchorStart = Math.max(0, placeholderIndex - TRAILING_PLACEHOLDER_ANCHOR_LENGTH) + const anchor = trimmed.slice(anchorStart, placeholderIndex) + const normalizedAnchor = collapseWhitespace(anchor) + + return normalizedAnchor || null +} + +/** + * @param {string} rawValue + * @param {string} template + * @returns {string} + */ +function trimRawValueByTrailingPlaceholder(rawValue, template) { + if (!rawValue) { + return rawValue + } + + const trimmedTemplate = template.trimEnd() + if (!trimmedTemplate.endsWith(PLACEHOLDER_TOKEN)) { + return rawValue + } + + const placeholderIndex = trimmedTemplate.lastIndexOf(PLACEHOLDER_TOKEN) + if (placeholderIndex < 0) { + return rawValue + } + + const anchorStart = Math.max(0, placeholderIndex - TRAILING_PLACEHOLDER_ANCHOR_LENGTH) + const anchorSegment = trimmedTemplate.slice(anchorStart, placeholderIndex) + if (!anchorSegment.trim()) { + return rawValue + } + + const directIndex = rawValue.indexOf(anchorSegment) + if (directIndex !== -1) { + return rawValue.slice(0, directIndex + anchorSegment.length) + } + + const pattern = toFlexibleWhitespacePattern(anchorSegment) + if (!pattern) { + return rawValue + } + + const regex = new RegExp(pattern) + const match = regex.exec(rawValue) + if (match && typeof match.index === 'number') { + return rawValue.slice(0, match.index + match[0].length) + } + + return rawValue +} + +/** + * @param {string} normalizedValue + * @param {string} template + * @returns {string} + */ +function trimTrailingPlaceholder(normalizedValue, template) { + const anchor = getTrailingPlaceholderAnchor(template) + if (!anchor) { + return normalizedValue + } + + const matchIndex = normalizedValue.lastIndexOf(anchor) + if (matchIndex === -1) { + return normalizedValue + } + + return normalizedValue.slice(0, matchIndex + anchor.length).trim() +} + +/** + * @param {string} normalizedValue + * @param {string} template + * @param {string} normalizedTemplate + * @returns {string} + */ +function normalizeValueForTemplate(normalizedValue, template, normalizedTemplate) { + const trimmedTemplate = template.trimEnd() + const trimmedValue = trimTrailingPlaceholder(normalizedValue, trimmedTemplate) + const parts = trimmedTemplate.split(PLACEHOLDER_TOKEN).map((part) => collapseWhitespace(part)) + + if (parts.length <= 1) { + return trimmedValue + } + + if (matchesTemplateIgnoringPlaceholders(trimmedValue, parts)) { + return normalizedTemplate + } + + return trimmedValue +} + +/** + * @param {string} normalizedValue + * @param {string[]} parts + * @returns {boolean} + */ +function matchesTemplateIgnoringPlaceholders(normalizedValue, parts) { + const valueNoSpace = normalizedValue.replace(/\s+/g, '') + let cursor = 0 + + for (const part of parts) { + if (!part) { + continue + } + + const partNoSpace = part.replace(/\s+/g, '') + const index = valueNoSpace.indexOf(partNoSpace, cursor) + if (index === -1) { + return false + } + + cursor = index + partNoSpace.length + } + + return true +} + +/** + * @param {unknown} value + * @returns {TemplateSimilarityResult} + */ +function bestSimilarityByTemplates(value) { + const rawValue = typeof value === 'string' ? value : '' + const normalizedValue = normalizePrompt(rawValue) + let bestScore = 0 + /** @type {PromptId|undefined} */ + let bestTemplateId + let maskedRaw = normalizedValue + + for (const [templateId, templateText] of promptEntries) { + const normalizedTemplate = normalizedPromptMap[templateId] + if (!normalizedTemplate) { + continue + } + + const trimmedRawValue = trimRawValueByTrailingPlaceholder(rawValue, templateText) + const normalizedPreparedInput = normalizePrompt(trimmedRawValue) + const preparedValue = normalizeValueForTemplate( + normalizedPreparedInput, + templateText, + normalizedTemplate + ) + const { score } = simple(preparedValue, normalizedTemplate, SYSTEM_PROMPT_THRESHOLD) + + if (score > bestScore) { + bestScore = score + bestTemplateId = templateId + maskedRaw = preparedValue + } + } + + return { bestScore, templateId: bestTemplateId, maskedRaw, threshold: SYSTEM_PROMPT_THRESHOLD } +} + +/** + * @param {unknown} value + * @returns {string} + */ +function normalizeSystemText(value) { + if (typeof value !== 'string') { + return '' + } + + const collapsed = collapseWhitespace(value) + + for (const [promptId, template] of Object.entries(promptMap)) { + if (!template.includes(PLACEHOLDER_TOKEN)) { + continue + } + + const parts = template.split(PLACEHOLDER_TOKEN).map((part) => collapseWhitespace(part)) + const pattern = parts.map((part) => (part ? escapeRegex(part) : '')).join('(.+?)') + + if (!pattern) { + continue + } + + const regex = new RegExp(`^${pattern}$`, 'i') + if (regex.test(collapsed)) { + return normalizedPromptMap[promptId] + } + } + + return normalizePrompt(collapsed) +} + +/** + * @param {unknown} value + * @returns {{bestScore: number}} + */ +function bestSimilarity(value) { + const { bestScore } = bestSimilarityByTemplates(value) + return { bestScore } +} + +module.exports = { + simple, + SYSTEM_PROMPT_THRESHOLD, + promptMap, + normalizePrompt, + normalizedPromptMap, + normalizedPromptEntries, + bestSimilarityByTemplates, + normalizeSystemText, + bestSimilarity +} diff --git a/src/utils/costCalculator.js b/src/utils/costCalculator.js new file mode 100644 index 0000000000000000000000000000000000000000..e2e0cc91372db16ceda48e5b05c8b0398ba8d0ab --- /dev/null +++ b/src/utils/costCalculator.js @@ -0,0 +1,326 @@ +const pricingService = require('../services/pricingService') + +// Claude模型价格配置 (USD per 1M tokens) - 备用定价 +const MODEL_PRICING = { + // Claude 3.5 Sonnet + 'claude-3-5-sonnet-20241022': { + input: 3.0, + output: 15.0, + cacheWrite: 3.75, + cacheRead: 0.3 + }, + 'claude-sonnet-4-20250514': { + input: 3.0, + output: 15.0, + cacheWrite: 3.75, + cacheRead: 0.3 + }, + 'claude-sonnet-4-5-20250929': { + input: 3.0, + output: 15.0, + cacheWrite: 3.75, + cacheRead: 0.3 + }, + + // Claude 3.5 Haiku + 'claude-3-5-haiku-20241022': { + input: 0.25, + output: 1.25, + cacheWrite: 0.3, + cacheRead: 0.03 + }, + + // Claude 3 Opus + 'claude-3-opus-20240229': { + input: 15.0, + output: 75.0, + cacheWrite: 18.75, + cacheRead: 1.5 + }, + + // Claude Opus 4.1 (新模型) + 'claude-opus-4-1-20250805': { + input: 15.0, + output: 75.0, + cacheWrite: 18.75, + cacheRead: 1.5 + }, + + // Claude 3 Sonnet + 'claude-3-sonnet-20240229': { + input: 3.0, + output: 15.0, + cacheWrite: 3.75, + cacheRead: 0.3 + }, + + // Claude 3 Haiku + 'claude-3-haiku-20240307': { + input: 0.25, + output: 1.25, + cacheWrite: 0.3, + cacheRead: 0.03 + }, + + // 默认定价(用于未知模型) + unknown: { + input: 3.0, + output: 15.0, + cacheWrite: 3.75, + cacheRead: 0.3 + } +} + +class CostCalculator { + /** + * 计算单次请求的费用 + * @param {Object} usage - 使用量数据 + * @param {number} usage.input_tokens - 输入token数量 + * @param {number} usage.output_tokens - 输出token数量 + * @param {number} usage.cache_creation_input_tokens - 缓存创建token数量 + * @param {number} usage.cache_read_input_tokens - 缓存读取token数量 + * @param {string} model - 模型名称 + * @returns {Object} 费用详情 + */ + static calculateCost(usage, model = 'unknown') { + // 如果 usage 包含详细的 cache_creation 对象或是 1M 模型,使用 pricingService 来处理 + if ( + (usage.cache_creation && typeof usage.cache_creation === 'object') || + (model && model.includes('[1m]')) + ) { + const result = pricingService.calculateCost(usage, model) + // 转换 pricingService 返回的格式到 costCalculator 的格式 + return { + model, + pricing: { + input: result.pricing.input * 1000000, // 转换为 per 1M tokens + output: result.pricing.output * 1000000, + cacheWrite: result.pricing.cacheCreate * 1000000, + cacheRead: result.pricing.cacheRead * 1000000 + }, + usingDynamicPricing: true, + isLongContextRequest: result.isLongContextRequest || false, + usage: { + inputTokens: usage.input_tokens || 0, + outputTokens: usage.output_tokens || 0, + cacheCreateTokens: usage.cache_creation_input_tokens || 0, + cacheReadTokens: usage.cache_read_input_tokens || 0, + totalTokens: + (usage.input_tokens || 0) + + (usage.output_tokens || 0) + + (usage.cache_creation_input_tokens || 0) + + (usage.cache_read_input_tokens || 0) + }, + costs: { + input: result.inputCost, + output: result.outputCost, + cacheWrite: result.cacheCreateCost, + cacheRead: result.cacheReadCost, + total: result.totalCost + }, + formatted: { + input: this.formatCost(result.inputCost), + output: this.formatCost(result.outputCost), + cacheWrite: this.formatCost(result.cacheCreateCost), + cacheRead: this.formatCost(result.cacheReadCost), + total: this.formatCost(result.totalCost) + }, + debug: { + isOpenAIModel: model.includes('gpt') || model.includes('o1'), + hasCacheCreatePrice: !!result.pricing.cacheCreate, + cacheCreateTokens: usage.cache_creation_input_tokens || 0, + cacheWritePriceUsed: result.pricing.cacheCreate * 1000000, + isLongContextModel: model && model.includes('[1m]'), + isLongContextRequest: result.isLongContextRequest || false + } + } + } + + // 否则使用旧的逻辑(向后兼容) + const inputTokens = usage.input_tokens || 0 + const outputTokens = usage.output_tokens || 0 + const cacheCreateTokens = usage.cache_creation_input_tokens || 0 + const cacheReadTokens = usage.cache_read_input_tokens || 0 + + // 优先使用动态价格服务 + const pricingData = pricingService.getModelPricing(model) + let pricing + let usingDynamicPricing = false + + if (pricingData) { + // 转换动态价格格式为内部格式 + const inputPrice = (pricingData.input_cost_per_token || 0) * 1000000 // 转换为per 1M tokens + const outputPrice = (pricingData.output_cost_per_token || 0) * 1000000 + const cacheReadPrice = (pricingData.cache_read_input_token_cost || 0) * 1000000 + + // OpenAI 模型的特殊处理: + // - 如果没有 cache_creation_input_token_cost,缓存创建按普通 input 价格计费 + // - Claude 模型有专门的 cache_creation_input_token_cost + let cacheWritePrice = (pricingData.cache_creation_input_token_cost || 0) * 1000000 + + // 检测是否为 OpenAI 模型(通过模型名或 litellm_provider) + const isOpenAIModel = + model.includes('gpt') || model.includes('o1') || pricingData.litellm_provider === 'openai' + + if (isOpenAIModel && !pricingData.cache_creation_input_token_cost && cacheCreateTokens > 0) { + // OpenAI 模型:缓存创建按普通 input 价格计费 + cacheWritePrice = inputPrice + } + + pricing = { + input: inputPrice, + output: outputPrice, + cacheWrite: cacheWritePrice, + cacheRead: cacheReadPrice + } + usingDynamicPricing = true + } else { + // 回退到静态价格 + pricing = MODEL_PRICING[model] || MODEL_PRICING['unknown'] + } + + // 计算各类型token的费用 (USD) + const inputCost = (inputTokens / 1000000) * pricing.input + const outputCost = (outputTokens / 1000000) * pricing.output + const cacheWriteCost = (cacheCreateTokens / 1000000) * pricing.cacheWrite + const cacheReadCost = (cacheReadTokens / 1000000) * pricing.cacheRead + + const totalCost = inputCost + outputCost + cacheWriteCost + cacheReadCost + + return { + model, + pricing, + usingDynamicPricing, + usage: { + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens, + totalTokens: inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + }, + costs: { + input: inputCost, + output: outputCost, + cacheWrite: cacheWriteCost, + cacheRead: cacheReadCost, + total: totalCost + }, + // 格式化的费用字符串 + formatted: { + input: this.formatCost(inputCost), + output: this.formatCost(outputCost), + cacheWrite: this.formatCost(cacheWriteCost), + cacheRead: this.formatCost(cacheReadCost), + total: this.formatCost(totalCost) + }, + // 添加调试信息 + debug: { + isOpenAIModel: model.includes('gpt') || model.includes('o1'), + hasCacheCreatePrice: !!pricingData?.cache_creation_input_token_cost, + cacheCreateTokens, + cacheWritePriceUsed: pricing.cacheWrite + } + } + } + + /** + * 计算聚合使用量的费用 + * @param {Object} aggregatedUsage - 聚合使用量数据 + * @param {string} model - 模型名称 + * @returns {Object} 费用详情 + */ + static calculateAggregatedCost(aggregatedUsage, model = 'unknown') { + const usage = { + input_tokens: aggregatedUsage.inputTokens || aggregatedUsage.totalInputTokens || 0, + output_tokens: aggregatedUsage.outputTokens || aggregatedUsage.totalOutputTokens || 0, + cache_creation_input_tokens: + aggregatedUsage.cacheCreateTokens || aggregatedUsage.totalCacheCreateTokens || 0, + cache_read_input_tokens: + aggregatedUsage.cacheReadTokens || aggregatedUsage.totalCacheReadTokens || 0 + } + + return this.calculateCost(usage, model) + } + + /** + * 获取模型定价信息 + * @param {string} model - 模型名称 + * @returns {Object} 定价信息 + */ + static getModelPricing(model = 'unknown') { + // 特殊处理:gpt-5-codex 回退到 gpt-5(如果没有专门定价) + if (model === 'gpt-5-codex' && !MODEL_PRICING['gpt-5-codex']) { + const gpt5Pricing = MODEL_PRICING['gpt-5'] + if (gpt5Pricing) { + console.log(`Using gpt-5 pricing as fallback for ${model}`) + return gpt5Pricing + } + } + return MODEL_PRICING[model] || MODEL_PRICING['unknown'] + } + + /** + * 获取所有支持的模型和定价 + * @returns {Object} 所有模型定价 + */ + static getAllModelPricing() { + return { ...MODEL_PRICING } + } + + /** + * 验证模型是否支持 + * @param {string} model - 模型名称 + * @returns {boolean} 是否支持 + */ + static isModelSupported(model) { + return !!MODEL_PRICING[model] + } + + /** + * 格式化费用显示 + * @param {number} cost - 费用金额 + * @param {number} decimals - 小数位数 + * @returns {string} 格式化的费用字符串 + */ + static formatCost(cost, decimals = 6) { + if (cost >= 1) { + return `$${cost.toFixed(2)}` + } else if (cost >= 0.001) { + return `$${cost.toFixed(4)}` + } else { + return `$${cost.toFixed(decimals)}` + } + } + + /** + * 计算费用节省(使用缓存的节省) + * @param {Object} usage - 使用量数据 + * @param {string} model - 模型名称 + * @returns {Object} 节省信息 + */ + static calculateCacheSavings(usage, model = 'unknown') { + const pricing = this.getModelPricing(model) // 已包含 gpt-5-codex 回退逻辑 + const cacheReadTokens = usage.cache_read_input_tokens || 0 + + // 如果这些token不使用缓存,需要按正常input价格计费 + const normalCost = (cacheReadTokens / 1000000) * pricing.input + const cacheCost = (cacheReadTokens / 1000000) * pricing.cacheRead + const savings = normalCost - cacheCost + const savingsPercentage = normalCost > 0 ? (savings / normalCost) * 100 : 0 + + return { + normalCost, + cacheCost, + savings, + savingsPercentage, + formatted: { + normalCost: this.formatCost(normalCost), + cacheCost: this.formatCost(cacheCost), + savings: this.formatCost(savings), + savingsPercentage: `${savingsPercentage.toFixed(1)}%` + } + } + } +} + +module.exports = CostCalculator diff --git a/src/utils/dateHelper.js b/src/utils/dateHelper.js new file mode 100644 index 0000000000000000000000000000000000000000..7a8a333cc1d5ccee8746a7a38a6422f8034ad011 --- /dev/null +++ b/src/utils/dateHelper.js @@ -0,0 +1,100 @@ +const config = require('../../config/config') + +/** + * 格式化日期时间为指定时区的本地时间字符串 + * @param {Date|number} date - Date对象或时间戳(秒或毫秒) + * @param {boolean} includeTimezone - 是否在输出中包含时区信息 + * @returns {string} 格式化后的时间字符串 + */ +function formatDateWithTimezone(date, includeTimezone = true) { + // 处理不同类型的输入 + let dateObj + if (typeof date === 'number') { + // 判断是秒还是毫秒时间戳 + // Unix时间戳(秒)通常小于 10^10,毫秒时间戳通常大于 10^12 + if (date < 10000000000) { + dateObj = new Date(date * 1000) // 秒转毫秒 + } else { + dateObj = new Date(date) // 已经是毫秒 + } + } else if (date instanceof Date) { + dateObj = date + } else { + dateObj = new Date(date) + } + + // 获取配置的时区偏移(小时) + const timezoneOffset = config.system.timezoneOffset || 8 // 默认 UTC+8 + + // 计算本地时间 + const offsetMs = timezoneOffset * 3600000 // 转换为毫秒 + const localTime = new Date(dateObj.getTime() + offsetMs) + + // 格式化为 YYYY-MM-DD HH:mm:ss + const year = localTime.getUTCFullYear() + const month = String(localTime.getUTCMonth() + 1).padStart(2, '0') + const day = String(localTime.getUTCDate()).padStart(2, '0') + const hours = String(localTime.getUTCHours()).padStart(2, '0') + const minutes = String(localTime.getUTCMinutes()).padStart(2, '0') + const seconds = String(localTime.getUTCSeconds()).padStart(2, '0') + + let formattedDate = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}` + + // 添加时区信息 + if (includeTimezone) { + const sign = timezoneOffset >= 0 ? '+' : '' + formattedDate += ` (UTC${sign}${timezoneOffset})` + } + + return formattedDate +} + +/** + * 获取指定时区的ISO格式时间字符串 + * @param {Date|number} date - Date对象或时间戳 + * @returns {string} ISO格式的时间字符串 + */ +function getISOStringWithTimezone(date) { + // 先获取本地格式的时间(不含时区后缀) + const localTimeStr = formatDateWithTimezone(date, false) + + // 获取时区偏移 + const timezoneOffset = config.system.timezoneOffset || 8 + + // 构建ISO格式,添加时区偏移 + const sign = timezoneOffset >= 0 ? '+' : '-' + const absOffset = Math.abs(timezoneOffset) + const offsetHours = String(Math.floor(absOffset)).padStart(2, '0') + const offsetMinutes = String(Math.round((absOffset % 1) * 60)).padStart(2, '0') + + // 将空格替换为T,并添加时区 + return `${localTimeStr.replace(' ', 'T')}${sign}${offsetHours}:${offsetMinutes}` +} + +/** + * 计算时间差并格式化为人类可读的字符串 + * @param {number} seconds - 秒数 + * @returns {string} 格式化的时间差字符串 + */ +function formatDuration(seconds) { + if (seconds < 60) { + return `${seconds}秒` + } else if (seconds < 3600) { + const minutes = Math.floor(seconds / 60) + return `${minutes}分钟` + } else if (seconds < 86400) { + const hours = Math.floor(seconds / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + return minutes > 0 ? `${hours}小时${minutes}分钟` : `${hours}小时` + } else { + const days = Math.floor(seconds / 86400) + const hours = Math.floor((seconds % 86400) / 3600) + return hours > 0 ? `${days}天${hours}小时` : `${days}天` + } +} + +module.exports = { + formatDateWithTimezone, + getISOStringWithTimezone, + formatDuration +} diff --git a/src/utils/inputValidator.js b/src/utils/inputValidator.js new file mode 100644 index 0000000000000000000000000000000000000000..ca9232a931bfa2c38f5276e93ca834669e638939 --- /dev/null +++ b/src/utils/inputValidator.js @@ -0,0 +1,291 @@ +/** + * 输入验证工具类 + * 提供各种输入验证和清理功能,防止注入攻击 + */ +class InputValidator { + /** + * 验证用户名 + * @param {string} username - 用户名 + * @returns {string} 验证后的用户名 + * @throws {Error} 如果用户名无效 + */ + validateUsername(username) { + if (!username || typeof username !== 'string') { + throw new Error('用户名必须是非空字符串') + } + + const trimmed = username.trim() + + // 长度检查 + if (trimmed.length < 3 || trimmed.length > 64) { + throw new Error('用户名长度必须在3-64个字符之间') + } + + // 格式检查:只允许字母、数字、下划线、连字符 + const usernameRegex = /^[a-zA-Z0-9_-]+$/ + if (!usernameRegex.test(trimmed)) { + throw new Error('用户名只能包含字母、数字、下划线和连字符') + } + + // 不能以连字符开头或结尾 + if (trimmed.startsWith('-') || trimmed.endsWith('-')) { + throw new Error('用户名不能以连字符开头或结尾') + } + + return trimmed + } + + /** + * 验证电子邮件 + * @param {string} email - 电子邮件地址 + * @returns {string} 验证后的电子邮件 + * @throws {Error} 如果电子邮件无效 + */ + validateEmail(email) { + if (!email || typeof email !== 'string') { + throw new Error('电子邮件必须是非空字符串') + } + + const trimmed = email.trim().toLowerCase() + + // 基本格式验证 + const emailRegex = + /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/ + if (!emailRegex.test(trimmed)) { + throw new Error('电子邮件格式无效') + } + + // 长度限制 + if (trimmed.length > 254) { + throw new Error('电子邮件地址过长') + } + + return trimmed + } + + /** + * 验证密码强度 + * @param {string} password - 密码 + * @returns {boolean} 验证结果 + */ + validatePassword(password) { + if (!password || typeof password !== 'string') { + throw new Error('密码必须是非空字符串') + } + + // 最小长度 + if (password.length < 8) { + throw new Error('密码至少需要8个字符') + } + + // 最大长度(防止DoS攻击) + if (password.length > 128) { + throw new Error('密码不能超过128个字符') + } + + return true + } + + /** + * 验证角色 + * @param {string} role - 用户角色 + * @returns {string} 验证后的角色 + * @throws {Error} 如果角色无效 + */ + validateRole(role) { + const validRoles = ['admin', 'user', 'viewer'] + + if (!role || typeof role !== 'string') { + throw new Error('角色必须是非空字符串') + } + + const trimmed = role.trim().toLowerCase() + + if (!validRoles.includes(trimmed)) { + throw new Error(`角色必须是以下之一: ${validRoles.join(', ')}`) + } + + return trimmed + } + + /** + * 验证Webhook URL + * @param {string} url - Webhook URL + * @returns {string} 验证后的URL + * @throws {Error} 如果URL无效 + */ + validateWebhookUrl(url) { + if (!url || typeof url !== 'string') { + throw new Error('Webhook URL必须是非空字符串') + } + + const trimmed = url.trim() + + // URL格式验证 + try { + const urlObj = new URL(trimmed) + + // 只允许HTTP和HTTPS协议 + if (!['http:', 'https:'].includes(urlObj.protocol)) { + throw new Error('Webhook URL必须使用HTTP或HTTPS协议') + } + + // 防止SSRF攻击:禁止访问内网地址 + const hostname = urlObj.hostname.toLowerCase() + const dangerousHosts = [ + 'localhost', + '127.0.0.1', + '0.0.0.0', + '::1', + '169.254.169.254', // AWS元数据服务 + 'metadata.google.internal' // GCP元数据服务 + ] + + if (dangerousHosts.includes(hostname)) { + throw new Error('Webhook URL不能指向内部服务') + } + + // 检查是否是内网IP + const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/ + if (ipRegex.test(hostname)) { + const parts = hostname.split('.').map(Number) + + // 检查私有IP范围 + if ( + parts[0] === 10 || // 10.0.0.0/8 + (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || // 172.16.0.0/12 + (parts[0] === 192 && parts[1] === 168) // 192.168.0.0/16 + ) { + throw new Error('Webhook URL不能指向私有IP地址') + } + } + + return trimmed + } catch (error) { + if (error.message.includes('Webhook URL')) { + throw error + } + throw new Error('Webhook URL格式无效') + } + } + + /** + * 验证显示名称 + * @param {string} displayName - 显示名称 + * @returns {string} 验证后的显示名称 + * @throws {Error} 如果显示名称无效 + */ + validateDisplayName(displayName) { + if (!displayName || typeof displayName !== 'string') { + throw new Error('显示名称必须是非空字符串') + } + + const trimmed = displayName.trim() + + // 长度检查 + if (trimmed.length < 1 || trimmed.length > 100) { + throw new Error('显示名称长度必须在1-100个字符之间') + } + + // 禁止特殊控制字符(排除常见的换行和制表符) + // eslint-disable-next-line no-control-regex + const controlCharRegex = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/ + if (controlCharRegex.test(trimmed)) { + throw new Error('显示名称不能包含控制字符') + } + + return trimmed + } + + /** + * 清理HTML标签(防止XSS) + * @param {string} input - 输入字符串 + * @returns {string} 清理后的字符串 + */ + sanitizeHtml(input) { + if (!input || typeof input !== 'string') { + return '' + } + + return input + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(/\//g, '/') + } + + /** + * 验证API Key名称 + * @param {string} name - API Key名称 + * @returns {string} 验证后的名称 + * @throws {Error} 如果名称无效 + */ + validateApiKeyName(name) { + if (!name || typeof name !== 'string') { + throw new Error('API Key名称必须是非空字符串') + } + + const trimmed = name.trim() + + // 长度检查 + if (trimmed.length < 1 || trimmed.length > 100) { + throw new Error('API Key名称长度必须在1-100个字符之间') + } + + // 禁止特殊控制字符(排除常见的换行和制表符) + // eslint-disable-next-line no-control-regex + const controlCharRegex = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/ + if (controlCharRegex.test(trimmed)) { + throw new Error('API Key名称不能包含控制字符') + } + + return trimmed + } + + /** + * 验证分页参数 + * @param {number} page - 页码 + * @param {number} limit - 每页数量 + * @returns {{page: number, limit: number}} 验证后的分页参数 + */ + validatePagination(page, limit) { + const pageNum = parseInt(page, 10) || 1 + const limitNum = parseInt(limit, 10) || 20 + + if (pageNum < 1) { + throw new Error('页码必须大于0') + } + + if (limitNum < 1 || limitNum > 100) { + throw new Error('每页数量必须在1-100之间') + } + + return { + page: pageNum, + limit: limitNum + } + } + + /** + * 验证UUID格式 + * @param {string} uuid - UUID字符串 + * @returns {string} 验证后的UUID + * @throws {Error} 如果UUID无效 + */ + validateUuid(uuid) { + if (!uuid || typeof uuid !== 'string') { + throw new Error('UUID必须是非空字符串') + } + + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + if (!uuidRegex.test(uuid)) { + throw new Error('UUID格式无效') + } + + return uuid.toLowerCase() + } +} + +module.exports = new InputValidator() diff --git a/src/utils/logger.js b/src/utils/logger.js new file mode 100644 index 0000000000000000000000000000000000000000..df5b5faab7f4e0da7b1ea0c9621f2a318d6e4982 --- /dev/null +++ b/src/utils/logger.js @@ -0,0 +1,410 @@ +const winston = require('winston') +const DailyRotateFile = require('winston-daily-rotate-file') +const config = require('../../config/config') +const { formatDateWithTimezone } = require('../utils/dateHelper') +const path = require('path') +const fs = require('fs') +const os = require('os') + +// 安全的 JSON 序列化函数,处理循环引用和特殊字符 +const safeStringify = (obj, maxDepth = 3, fullDepth = false) => { + const seen = new WeakSet() + // 如果是fullDepth模式,增加深度限制 + const actualMaxDepth = fullDepth ? 10 : maxDepth + + const replacer = (key, value, depth = 0) => { + if (depth > actualMaxDepth) { + return '[Max Depth Reached]' + } + + // 处理字符串值,清理可能导致JSON解析错误的特殊字符 + if (typeof value === 'string') { + try { + // 移除或转义可能导致JSON解析错误的字符 + let cleanValue = value + // eslint-disable-next-line no-control-regex + .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '') // 移除控制字符 + .replace(/[\uD800-\uDFFF]/g, '') // 移除孤立的代理对字符 + // eslint-disable-next-line no-control-regex + .replace(/\u0000/g, '') // 移除NUL字节 + + // 如果字符串过长,截断并添加省略号 + if (cleanValue.length > 1000) { + cleanValue = `${cleanValue.substring(0, 997)}...` + } + + return cleanValue + } catch (error) { + return '[Invalid String Data]' + } + } + + if (value !== null && typeof value === 'object') { + if (seen.has(value)) { + return '[Circular Reference]' + } + seen.add(value) + + // 过滤掉常见的循环引用对象 + if (value.constructor) { + const constructorName = value.constructor.name + if ( + ['Socket', 'TLSSocket', 'HTTPParser', 'IncomingMessage', 'ServerResponse'].includes( + constructorName + ) + ) { + return `[${constructorName} Object]` + } + } + + // 递归处理对象属性 + if (Array.isArray(value)) { + return value.map((item, index) => replacer(index, item, depth + 1)) + } else { + const result = {} + for (const [k, v] of Object.entries(value)) { + // 确保键名也是安全的 + // eslint-disable-next-line no-control-regex + const safeKey = typeof k === 'string' ? k.replace(/[\u0000-\u001F\u007F]/g, '') : k + result[safeKey] = replacer(safeKey, v, depth + 1) + } + return result + } + } + + return value + } + + try { + const processed = replacer('', obj) + return JSON.stringify(processed) + } catch (error) { + // 如果JSON.stringify仍然失败,使用更保守的方法 + try { + return JSON.stringify({ + error: 'Failed to serialize object', + message: error.message, + type: typeof obj, + keys: obj && typeof obj === 'object' ? Object.keys(obj) : undefined + }) + } catch (finalError) { + return '{"error":"Critical serialization failure","message":"Unable to serialize any data"}' + } + } +} + +// 📝 增强的日志格式 +const createLogFormat = (colorize = false) => { + const formats = [ + winston.format.timestamp({ format: () => formatDateWithTimezone(new Date(), false) }), + winston.format.errors({ stack: true }) + // 移除 winston.format.metadata() 来避免自动包装 + ] + + if (colorize) { + formats.push(winston.format.colorize()) + } + + formats.push( + winston.format.printf(({ level, message, timestamp, stack, ...rest }) => { + const emoji = { + error: '❌', + warn: '⚠️ ', + info: 'ℹ️ ', + debug: '🐛', + verbose: '📝' + } + + let logMessage = `${emoji[level] || '📝'} [${timestamp}] ${level.toUpperCase()}: ${message}` + + // 直接处理额外数据,不需要metadata包装 + const additionalData = { ...rest } + delete additionalData.level + delete additionalData.message + delete additionalData.timestamp + delete additionalData.stack + + if (Object.keys(additionalData).length > 0) { + logMessage += ` | ${safeStringify(additionalData)}` + } + + return stack ? `${logMessage}\n${stack}` : logMessage + }) + ) + + return winston.format.combine(...formats) +} + +const logFormat = createLogFormat(false) +const consoleFormat = createLogFormat(true) + +// 📁 确保日志目录存在并设置权限 +if (!fs.existsSync(config.logging.dirname)) { + fs.mkdirSync(config.logging.dirname, { recursive: true, mode: 0o755 }) +} + +// 🔄 增强的日志轮转配置 +const createRotateTransport = (filename, level = null) => { + const transport = new DailyRotateFile({ + filename: path.join(config.logging.dirname, filename), + datePattern: 'YYYY-MM-DD', + zippedArchive: true, + maxSize: config.logging.maxSize, + maxFiles: config.logging.maxFiles, + auditFile: path.join(config.logging.dirname, `.${filename.replace('%DATE%', 'audit')}.json`), + format: logFormat + }) + + if (level) { + transport.level = level + } + + // 监听轮转事件 + transport.on('rotate', (oldFilename, newFilename) => { + console.log(`📦 Log rotated: ${oldFilename} -> ${newFilename}`) + }) + + transport.on('new', (newFilename) => { + console.log(`📄 New log file created: ${newFilename}`) + }) + + transport.on('archive', (zipFilename) => { + console.log(`🗜️ Log archived: ${zipFilename}`) + }) + + return transport +} + +const dailyRotateFileTransport = createRotateTransport('claude-relay-%DATE%.log') +const errorFileTransport = createRotateTransport('claude-relay-error-%DATE%.log', 'error') + +// 🔒 创建专门的安全日志记录器 +const securityLogger = winston.createLogger({ + level: 'warn', + format: logFormat, + transports: [createRotateTransport('claude-relay-security-%DATE%.log', 'warn')], + silent: false +}) + +// 🔐 创建专门的认证详细日志记录器(记录完整的认证响应) +const authDetailLogger = winston.createLogger({ + level: 'info', + format: winston.format.combine( + winston.format.timestamp({ format: () => formatDateWithTimezone(new Date(), false) }), + winston.format.printf(({ level, message, timestamp, data }) => { + // 使用更深的深度和格式化的JSON输出 + const jsonData = data ? JSON.stringify(data, null, 2) : '{}' + return `[${timestamp}] ${level.toUpperCase()}: ${message}\n${jsonData}\n${'='.repeat(80)}` + }) + ), + transports: [createRotateTransport('claude-relay-auth-detail-%DATE%.log', 'info')], + silent: false +}) + +// 🌟 增强的 Winston logger +const logger = winston.createLogger({ + level: process.env.LOG_LEVEL || config.logging.level, + format: logFormat, + transports: [ + // 📄 文件输出 + dailyRotateFileTransport, + errorFileTransport, + + // 🖥️ 控制台输出 + new winston.transports.Console({ + format: consoleFormat, + handleExceptions: false, + handleRejections: false + }) + ], + + // 🚨 异常处理 + exceptionHandlers: [ + new winston.transports.File({ + filename: path.join(config.logging.dirname, 'exceptions.log'), + format: logFormat, + maxsize: 10485760, // 10MB + maxFiles: 5 + }), + new winston.transports.Console({ + format: consoleFormat + }) + ], + + // 🔄 未捕获异常处理 + rejectionHandlers: [ + new winston.transports.File({ + filename: path.join(config.logging.dirname, 'rejections.log'), + format: logFormat, + maxsize: 10485760, // 10MB + maxFiles: 5 + }), + new winston.transports.Console({ + format: consoleFormat + }) + ], + + // 防止进程退出 + exitOnError: false +}) + +// 🎯 增强的自定义方法 +logger.success = (message, metadata = {}) => { + logger.info(`✅ ${message}`, { type: 'success', ...metadata }) +} + +logger.start = (message, metadata = {}) => { + logger.info(`🚀 ${message}`, { type: 'startup', ...metadata }) +} + +logger.request = (method, url, status, duration, metadata = {}) => { + const emoji = status >= 400 ? '🔴' : status >= 300 ? '🟡' : '🟢' + const level = status >= 400 ? 'error' : status >= 300 ? 'warn' : 'info' + + logger[level](`${emoji} ${method} ${url} - ${status} (${duration}ms)`, { + type: 'request', + method, + url, + status, + duration, + ...metadata + }) +} + +logger.api = (message, metadata = {}) => { + logger.info(`🔗 ${message}`, { type: 'api', ...metadata }) +} + +logger.security = (message, metadata = {}) => { + const securityData = { + type: 'security', + timestamp: new Date().toISOString(), + pid: process.pid, + hostname: os.hostname(), + ...metadata + } + + // 记录到主日志 + logger.warn(`🔒 ${message}`, securityData) + + // 记录到专门的安全日志文件 + try { + securityLogger.warn(`🔒 ${message}`, securityData) + } catch (error) { + // 如果安全日志文件不可用,只记录到主日志 + console.warn('Security logger not available:', error.message) + } +} + +logger.database = (message, metadata = {}) => { + logger.debug(`💾 ${message}`, { type: 'database', ...metadata }) +} + +logger.performance = (message, metadata = {}) => { + logger.info(`⚡ ${message}`, { type: 'performance', ...metadata }) +} + +logger.audit = (message, metadata = {}) => { + logger.info(`📋 ${message}`, { + type: 'audit', + timestamp: new Date().toISOString(), + pid: process.pid, + ...metadata + }) +} + +// 🔧 性能监控方法 +logger.timer = (label) => { + const start = Date.now() + return { + end: (message = '', metadata = {}) => { + const duration = Date.now() - start + logger.performance(`${label} ${message}`, { duration, ...metadata }) + return duration + } + } +} + +// 📊 日志统计 +logger.stats = { + requests: 0, + errors: 0, + warnings: 0 +} + +// 重写原始方法以统计 +const originalError = logger.error +const originalWarn = logger.warn +const originalInfo = logger.info + +logger.error = function (message, ...args) { + logger.stats.errors++ + return originalError.call(this, message, ...args) +} + +logger.warn = function (message, ...args) { + logger.stats.warnings++ + return originalWarn.call(this, message, ...args) +} + +logger.info = function (message, ...args) { + // 检查是否是请求类型的日志 + if (args.length > 0 && typeof args[0] === 'object' && args[0].type === 'request') { + logger.stats.requests++ + } + return originalInfo.call(this, message, ...args) +} + +// 📈 获取日志统计 +logger.getStats = () => ({ ...logger.stats }) + +// 🧹 清理统计 +logger.resetStats = () => { + logger.stats.requests = 0 + logger.stats.errors = 0 + logger.stats.warnings = 0 +} + +// 📡 健康检查 +logger.healthCheck = () => { + try { + const testMessage = 'Logger health check' + logger.debug(testMessage) + return { healthy: true, timestamp: new Date().toISOString() } + } catch (error) { + return { healthy: false, error: error.message, timestamp: new Date().toISOString() } + } +} + +// 🔐 记录认证详细信息的方法 +logger.authDetail = (message, data = {}) => { + try { + // 记录到主日志(简化版) + logger.info(`🔐 ${message}`, { + type: 'auth-detail', + summary: { + hasAccessToken: !!data.access_token, + hasRefreshToken: !!data.refresh_token, + scopes: data.scope || data.scopes, + organization: data.organization?.name, + account: data.account?.email_address + } + }) + + // 记录到专门的认证详细日志文件(完整数据) + authDetailLogger.info(message, { data }) + } catch (error) { + logger.error('Failed to log auth detail:', error) + } +} + +// 🎬 启动日志记录系统 +logger.start('Logger initialized', { + level: process.env.LOG_LEVEL || config.logging.level, + directory: config.logging.dirname, + maxSize: config.logging.maxSize, + maxFiles: config.logging.maxFiles, + envOverride: process.env.LOG_LEVEL ? true : false +}) + +module.exports = logger diff --git a/src/utils/lruCache.js b/src/utils/lruCache.js new file mode 100644 index 0000000000000000000000000000000000000000..993089ba5689de8c1c3bc9a6abfb1fa1c7d7e39a --- /dev/null +++ b/src/utils/lruCache.js @@ -0,0 +1,134 @@ +/** + * LRU (Least Recently Used) 缓存实现 + * 用于缓存解密结果,提高性能同时控制内存使用 + */ +class LRUCache { + constructor(maxSize = 500) { + this.maxSize = maxSize + this.cache = new Map() + this.hits = 0 + this.misses = 0 + this.evictions = 0 + this.lastCleanup = Date.now() + this.cleanupInterval = 5 * 60 * 1000 // 5分钟清理一次过期项 + } + + /** + * 获取缓存值 + * @param {string} key - 缓存键 + * @returns {*} 缓存的值,如果不存在则返回 undefined + */ + get(key) { + // 定期清理 + if (Date.now() - this.lastCleanup > this.cleanupInterval) { + this.cleanup() + } + + const item = this.cache.get(key) + if (!item) { + this.misses++ + return undefined + } + + // 检查是否过期 + if (item.expiry && Date.now() > item.expiry) { + this.cache.delete(key) + this.misses++ + return undefined + } + + // 更新访问时间,将元素移到最后(最近使用) + this.cache.delete(key) + this.cache.set(key, { + ...item, + lastAccessed: Date.now() + }) + + this.hits++ + return item.value + } + + /** + * 设置缓存值 + * @param {string} key - 缓存键 + * @param {*} value - 要缓存的值 + * @param {number} ttl - 生存时间(毫秒),默认5分钟 + */ + set(key, value, ttl = 5 * 60 * 1000) { + // 如果缓存已满,删除最少使用的项 + if (this.cache.size >= this.maxSize && !this.cache.has(key)) { + const firstKey = this.cache.keys().next().value + this.cache.delete(firstKey) + this.evictions++ + } + + this.cache.set(key, { + value, + createdAt: Date.now(), + lastAccessed: Date.now(), + expiry: ttl ? Date.now() + ttl : null + }) + } + + /** + * 清理过期项 + */ + cleanup() { + const now = Date.now() + let cleanedCount = 0 + + for (const [key, item] of this.cache.entries()) { + if (item.expiry && now > item.expiry) { + this.cache.delete(key) + cleanedCount++ + } + } + + this.lastCleanup = now + if (cleanedCount > 0) { + console.log(`🧹 LRU Cache: Cleaned ${cleanedCount} expired items`) + } + } + + /** + * 清空缓存 + */ + clear() { + const { size } = this.cache + this.cache.clear() + this.hits = 0 + this.misses = 0 + this.evictions = 0 + console.log(`🗑️ LRU Cache: Cleared ${size} items`) + } + + /** + * 获取缓存统计信息 + */ + getStats() { + const total = this.hits + this.misses + const hitRate = total > 0 ? ((this.hits / total) * 100).toFixed(2) : 0 + + return { + size: this.cache.size, + maxSize: this.maxSize, + hits: this.hits, + misses: this.misses, + evictions: this.evictions, + hitRate: `${hitRate}%`, + total + } + } + + /** + * 打印缓存统计信息 + */ + printStats() { + const stats = this.getStats() + console.log( + `📊 LRU Cache Stats: Size: ${stats.size}/${stats.maxSize}, Hit Rate: ${stats.hitRate}, Hits: ${stats.hits}, Misses: ${stats.misses}, Evictions: ${stats.evictions}` + ) + } +} + +module.exports = LRUCache diff --git a/src/utils/modelHelper.js b/src/utils/modelHelper.js new file mode 100644 index 0000000000000000000000000000000000000000..cc954cc2f289fd69378e92ad19dfd042c36d5470 --- /dev/null +++ b/src/utils/modelHelper.js @@ -0,0 +1,78 @@ +/** + * Model Helper Utility + * + * Provides utilities for parsing vendor-prefixed model names. + * Supports parsing model strings like "ccr,model_name" to extract vendor type and base model. + */ + +/** + * Parse vendor-prefixed model string + * @param {string} modelStr - Model string, potentially with vendor prefix (e.g., "ccr,gemini-2.5-pro") + * @returns {{vendor: string|null, baseModel: string}} - Parsed vendor and base model + */ +function parseVendorPrefixedModel(modelStr) { + if (!modelStr || typeof modelStr !== 'string') { + return { vendor: null, baseModel: modelStr || '' } + } + + // Trim whitespace and convert to lowercase for comparison + const trimmed = modelStr.trim() + const lowerTrimmed = trimmed.toLowerCase() + + // Check for ccr prefix (case insensitive) + if (lowerTrimmed.startsWith('ccr,')) { + 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 + } + } + } + + // No recognized vendor prefix found + return { + vendor: null, + baseModel: trimmed + } +} + +/** + * Check if a model string has a vendor prefix + * @param {string} modelStr - Model string to check + * @returns {boolean} - True if the model has a vendor prefix + */ +function hasVendorPrefix(modelStr) { + const { vendor } = parseVendorPrefixedModel(modelStr) + return vendor !== null +} + +/** + * Get the effective model name for scheduling and processing + * This removes vendor prefixes to get the actual model name used for API calls + * @param {string} modelStr - Original model string + * @returns {string} - Effective model name without vendor prefix + */ +function getEffectiveModel(modelStr) { + const { baseModel } = parseVendorPrefixedModel(modelStr) + return baseModel +} + +/** + * Get the vendor type from a model string + * @param {string} modelStr - Model string to parse + * @returns {string|null} - Vendor type ('ccr') or null if no prefix + */ +function getVendorType(modelStr) { + const { vendor } = parseVendorPrefixedModel(modelStr) + return vendor +} + +module.exports = { + parseVendorPrefixedModel, + hasVendorPrefix, + getEffectiveModel, + getVendorType +} diff --git a/src/utils/oauthHelper.js b/src/utils/oauthHelper.js new file mode 100644 index 0000000000000000000000000000000000000000..ac33b71e68b7b918639142f19df9b8a23d21f289 --- /dev/null +++ b/src/utils/oauthHelper.js @@ -0,0 +1,521 @@ +/** + * OAuth助手工具 + * 基于claude-code-login.js中的OAuth流程实现 + */ + +const crypto = require('crypto') +const ProxyHelper = require('./proxyHelper') +const axios = require('axios') +const logger = require('./logger') + +// OAuth 配置常量 - 从claude-code-login.js提取 +const OAUTH_CONFIG = { + AUTHORIZE_URL: 'https://claude.ai/oauth/authorize', + TOKEN_URL: 'https://console.anthropic.com/v1/oauth/token', + CLIENT_ID: '9d1c250a-e61b-44d9-88ed-5944d1962f5e', + REDIRECT_URI: 'https://console.anthropic.com/oauth/code/callback', + SCOPES: 'org:create_api_key user:profile user:inference', + SCOPES_SETUP: 'user:inference' // Setup Token 只需要推理权限 +} + +/** + * 生成随机的 state 参数 + * @returns {string} 随机生成的 state (base64url编码) + */ +function generateState() { + return crypto.randomBytes(32).toString('base64url') +} + +/** + * 生成随机的 code verifier(PKCE) + * @returns {string} base64url 编码的随机字符串 + */ +function generateCodeVerifier() { + return crypto.randomBytes(32).toString('base64url') +} + +/** + * 生成 code challenge(PKCE) + * @param {string} codeVerifier - code verifier 字符串 + * @returns {string} SHA256 哈希后的 base64url 编码字符串 + */ +function generateCodeChallenge(codeVerifier) { + return crypto.createHash('sha256').update(codeVerifier).digest('base64url') +} + +/** + * 生成授权 URL + * @param {string} codeChallenge - PKCE code challenge + * @param {string} state - state 参数 + * @returns {string} 完整的授权 URL + */ +function generateAuthUrl(codeChallenge, state) { + const params = new URLSearchParams({ + code: 'true', + client_id: OAUTH_CONFIG.CLIENT_ID, + response_type: 'code', + redirect_uri: OAUTH_CONFIG.REDIRECT_URI, + scope: OAUTH_CONFIG.SCOPES, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + state + }) + + return `${OAUTH_CONFIG.AUTHORIZE_URL}?${params.toString()}` +} + +/** + * 生成OAuth授权URL和相关参数 + * @returns {{authUrl: string, codeVerifier: string, state: string, codeChallenge: string}} + */ +function generateOAuthParams() { + const state = generateState() + const codeVerifier = generateCodeVerifier() + const codeChallenge = generateCodeChallenge(codeVerifier) + + const authUrl = generateAuthUrl(codeChallenge, state) + + return { + authUrl, + codeVerifier, + state, + codeChallenge + } +} + +/** + * 生成 Setup Token 授权 URL + * @param {string} codeChallenge - PKCE code challenge + * @param {string} state - state 参数 + * @returns {string} 完整的授权 URL + */ +function generateSetupTokenAuthUrl(codeChallenge, state) { + const params = new URLSearchParams({ + code: 'true', + client_id: OAUTH_CONFIG.CLIENT_ID, + response_type: 'code', + redirect_uri: OAUTH_CONFIG.REDIRECT_URI, + scope: OAUTH_CONFIG.SCOPES_SETUP, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + state + }) + + return `${OAUTH_CONFIG.AUTHORIZE_URL}?${params.toString()}` +} + +/** + * 生成Setup Token授权URL和相关参数 + * @returns {{authUrl: string, codeVerifier: string, state: string, codeChallenge: string}} + */ +function generateSetupTokenParams() { + const state = generateState() + const codeVerifier = generateCodeVerifier() + const codeChallenge = generateCodeChallenge(codeVerifier) + + const authUrl = generateSetupTokenAuthUrl(codeChallenge, state) + + return { + authUrl, + codeVerifier, + state, + codeChallenge + } +} + +/** + * 创建代理agent(使用统一的代理工具) + * @param {object|null} proxyConfig - 代理配置对象 + * @returns {object|null} 代理agent或null + */ +function createProxyAgent(proxyConfig) { + return ProxyHelper.createProxyAgent(proxyConfig) +} + +/** + * 使用授权码交换访问令牌 + * @param {string} authorizationCode - 授权码 + * @param {string} codeVerifier - PKCE code verifier + * @param {string} state - state 参数 + * @param {object|null} proxyConfig - 代理配置(可选) + * @returns {Promise} Claude格式的token响应 + */ +async function exchangeCodeForTokens(authorizationCode, codeVerifier, state, proxyConfig = null) { + // 清理授权码,移除URL片段 + const cleanedCode = authorizationCode.split('#')[0]?.split('&')[0] ?? authorizationCode + + const params = { + grant_type: 'authorization_code', + client_id: OAUTH_CONFIG.CLIENT_ID, + code: cleanedCode, + redirect_uri: OAUTH_CONFIG.REDIRECT_URI, + code_verifier: codeVerifier, + state + } + + // 创建代理agent + const agent = createProxyAgent(proxyConfig) + + try { + if (agent) { + logger.info( + `🌐 Using proxy for OAuth token exchange: ${ProxyHelper.maskProxyInfo(proxyConfig)}` + ) + } else { + logger.debug('🌐 No proxy configured for OAuth token exchange') + } + + logger.debug('🔄 Attempting OAuth token exchange', { + url: OAUTH_CONFIG.TOKEN_URL, + codeLength: cleanedCode.length, + codePrefix: `${cleanedCode.substring(0, 10)}...`, + hasProxy: !!proxyConfig, + proxyType: proxyConfig?.type || 'none' + }) + + const response = await axios.post(OAUTH_CONFIG.TOKEN_URL, params, { + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'claude-cli/1.0.56 (external, cli)', + Accept: 'application/json, text/plain, */*', + 'Accept-Language': 'en-US,en;q=0.9', + Referer: 'https://claude.ai/', + Origin: 'https://claude.ai' + }, + httpsAgent: agent, + timeout: 30000 + }) + + // 记录完整的响应数据到专门的认证详细日志 + logger.authDetail('OAuth token exchange response', response.data) + + // 记录简化版本到主日志 + logger.info('📊 OAuth token exchange response (analyzing for subscription info):', { + status: response.status, + hasData: !!response.data, + dataKeys: response.data ? Object.keys(response.data) : [] + }) + + logger.success('✅ OAuth token exchange successful', { + status: response.status, + hasAccessToken: !!response.data?.access_token, + hasRefreshToken: !!response.data?.refresh_token, + scopes: response.data?.scope, + // 尝试提取可能的套餐信息字段 + subscription: response.data?.subscription, + plan: response.data?.plan, + tier: response.data?.tier, + accountType: response.data?.account_type, + features: response.data?.features, + limits: response.data?.limits + }) + + const { data } = response + + // 返回Claude格式的token数据,包含可能的套餐信息 + const result = { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresAt: (Math.floor(Date.now() / 1000) + data.expires_in) * 1000, + scopes: data.scope ? data.scope.split(' ') : ['user:inference', 'user:profile'], + isMax: true + } + + // 如果响应中包含套餐信息,添加到返回结果中 + if (data.subscription || data.plan || data.tier || data.account_type) { + result.subscriptionInfo = { + subscription: data.subscription, + plan: data.plan, + tier: data.tier, + accountType: data.account_type, + features: data.features, + limits: data.limits + } + logger.info('🎯 Found subscription info in OAuth response:', result.subscriptionInfo) + } + + return result + } catch (error) { + // 处理axios错误响应 + if (error.response) { + // 服务器返回了错误状态码 + const { status } = error.response + const errorData = error.response.data + + logger.error('❌ OAuth token exchange failed with server error', { + status, + statusText: error.response.statusText, + headers: error.response.headers, + data: errorData, + codeLength: cleanedCode.length, + codePrefix: `${cleanedCode.substring(0, 10)}...` + }) + + // 尝试从错误响应中提取有用信息 + let errorMessage = `HTTP ${status}` + + if (errorData) { + if (typeof errorData === 'string') { + errorMessage += `: ${errorData}` + } else if (errorData.error) { + errorMessage += `: ${errorData.error}` + if (errorData.error_description) { + errorMessage += ` - ${errorData.error_description}` + } + } else { + errorMessage += `: ${JSON.stringify(errorData)}` + } + } + + throw new Error(`Token exchange failed: ${errorMessage}`) + } else if (error.request) { + // 请求被发送但没有收到响应 + logger.error('❌ OAuth token exchange failed with network error', { + message: error.message, + code: error.code, + hasProxy: !!proxyConfig + }) + throw new Error('Token exchange failed: No response from server (network error or timeout)') + } else { + // 其他错误 + logger.error('❌ OAuth token exchange failed with unknown error', { + message: error.message, + stack: error.stack + }) + throw new Error(`Token exchange failed: ${error.message}`) + } + } +} + +/** + * 解析回调 URL 或授权码 + * @param {string} input - 完整的回调 URL 或直接的授权码 + * @returns {string} 授权码 + */ +function parseCallbackUrl(input) { + if (!input || typeof input !== 'string') { + throw new Error('请提供有效的授权码或回调 URL') + } + + const trimmedInput = input.trim() + + // 情况1: 尝试作为完整URL解析 + if (trimmedInput.startsWith('http://') || trimmedInput.startsWith('https://')) { + try { + const urlObj = new URL(trimmedInput) + const authorizationCode = urlObj.searchParams.get('code') + + if (!authorizationCode) { + throw new Error('回调 URL 中未找到授权码 (code 参数)') + } + + return authorizationCode + } catch (error) { + if (error.message.includes('回调 URL 中未找到授权码')) { + throw error + } + throw new Error('无效的 URL 格式,请检查回调 URL 是否正确') + } + } + + // 情况2: 直接的授权码(可能包含URL fragments) + // 参考claude-code-login.js的处理方式:移除URL fragments和参数 + const cleanedCode = trimmedInput.split('#')[0]?.split('&')[0] ?? trimmedInput + + // 验证授权码格式(Claude的授权码通常是base64url格式) + if (!cleanedCode || cleanedCode.length < 10) { + throw new Error('授权码格式无效,请确保复制了完整的 Authorization Code') + } + + // 基本格式验证:授权码应该只包含字母、数字、下划线、连字符 + const validCodePattern = /^[A-Za-z0-9_-]+$/ + if (!validCodePattern.test(cleanedCode)) { + throw new Error('授权码包含无效字符,请检查是否复制了正确的 Authorization Code') + } + + return cleanedCode +} + +/** + * 使用授权码交换Setup Token + * @param {string} authorizationCode - 授权码 + * @param {string} codeVerifier - PKCE code verifier + * @param {string} state - state 参数 + * @param {object|null} proxyConfig - 代理配置(可选) + * @returns {Promise} Claude格式的token响应 + */ +async function exchangeSetupTokenCode(authorizationCode, codeVerifier, state, proxyConfig = null) { + // 清理授权码,移除URL片段 + const cleanedCode = authorizationCode.split('#')[0]?.split('&')[0] ?? authorizationCode + + const params = { + grant_type: 'authorization_code', + client_id: OAUTH_CONFIG.CLIENT_ID, + code: cleanedCode, + redirect_uri: OAUTH_CONFIG.REDIRECT_URI, + code_verifier: codeVerifier, + state, + expires_in: 31536000 // Setup Token 可以设置较长的过期时间 + } + + // 创建代理agent + const agent = createProxyAgent(proxyConfig) + + try { + if (agent) { + logger.info( + `🌐 Using proxy for Setup Token exchange: ${ProxyHelper.maskProxyInfo(proxyConfig)}` + ) + } else { + logger.debug('🌐 No proxy configured for Setup Token exchange') + } + + logger.debug('🔄 Attempting Setup Token exchange', { + url: OAUTH_CONFIG.TOKEN_URL, + codeLength: cleanedCode.length, + codePrefix: `${cleanedCode.substring(0, 10)}...`, + hasProxy: !!proxyConfig, + proxyType: proxyConfig?.type || 'none' + }) + + const response = await axios.post(OAUTH_CONFIG.TOKEN_URL, params, { + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'claude-cli/1.0.56 (external, cli)', + Accept: 'application/json, text/plain, */*', + 'Accept-Language': 'en-US,en;q=0.9', + Referer: 'https://claude.ai/', + Origin: 'https://claude.ai' + }, + httpsAgent: agent, + timeout: 30000 + }) + + // 记录完整的响应数据到专门的认证详细日志 + logger.authDetail('Setup Token exchange response', response.data) + + // 记录简化版本到主日志 + logger.info('📊 Setup Token exchange response (analyzing for subscription info):', { + status: response.status, + hasData: !!response.data, + dataKeys: response.data ? Object.keys(response.data) : [] + }) + + logger.success('✅ Setup Token exchange successful', { + status: response.status, + hasAccessToken: !!response.data?.access_token, + scopes: response.data?.scope, + // 尝试提取可能的套餐信息字段 + subscription: response.data?.subscription, + plan: response.data?.plan, + tier: response.data?.tier, + accountType: response.data?.account_type, + features: response.data?.features, + limits: response.data?.limits + }) + + const { data } = response + + // 返回Claude格式的token数据,包含可能的套餐信息 + const result = { + accessToken: data.access_token, + refreshToken: '', + expiresAt: (Math.floor(Date.now() / 1000) + data.expires_in) * 1000, + scopes: data.scope ? data.scope.split(' ') : ['user:inference', 'user:profile'], + isMax: true + } + + // 如果响应中包含套餐信息,添加到返回结果中 + if (data.subscription || data.plan || data.tier || data.account_type) { + result.subscriptionInfo = { + subscription: data.subscription, + plan: data.plan, + tier: data.tier, + accountType: data.account_type, + features: data.features, + limits: data.limits + } + logger.info('🎯 Found subscription info in Setup Token response:', result.subscriptionInfo) + } + + return result + } catch (error) { + // 使用与标准OAuth相同的错误处理逻辑 + if (error.response) { + const { status } = error.response + const errorData = error.response.data + + logger.error('❌ Setup Token exchange failed with server error', { + status, + statusText: error.response.statusText, + data: errorData, + codeLength: cleanedCode.length, + codePrefix: `${cleanedCode.substring(0, 10)}...` + }) + + let errorMessage = `HTTP ${status}` + if (errorData) { + if (typeof errorData === 'string') { + errorMessage += `: ${errorData}` + } else if (errorData.error) { + errorMessage += `: ${errorData.error}` + if (errorData.error_description) { + errorMessage += ` - ${errorData.error_description}` + } + } else { + errorMessage += `: ${JSON.stringify(errorData)}` + } + } + + throw new Error(`Setup Token exchange failed: ${errorMessage}`) + } else if (error.request) { + logger.error('❌ Setup Token exchange failed with network error', { + message: error.message, + code: error.code, + hasProxy: !!proxyConfig + }) + throw new Error( + 'Setup Token exchange failed: No response from server (network error or timeout)' + ) + } else { + logger.error('❌ Setup Token exchange failed with unknown error', { + message: error.message, + stack: error.stack + }) + throw new Error(`Setup Token exchange failed: ${error.message}`) + } + } +} + +/** + * 格式化为Claude标准格式 + * @param {object} tokenData - token数据 + * @returns {object} claudeAiOauth格式的数据 + */ +function formatClaudeCredentials(tokenData) { + return { + claudeAiOauth: { + accessToken: tokenData.accessToken, + refreshToken: tokenData.refreshToken, + expiresAt: tokenData.expiresAt, + scopes: tokenData.scopes, + isMax: tokenData.isMax + } + } +} + +module.exports = { + OAUTH_CONFIG, + generateOAuthParams, + generateSetupTokenParams, + exchangeCodeForTokens, + exchangeSetupTokenCode, + parseCallbackUrl, + formatClaudeCredentials, + generateState, + generateCodeVerifier, + generateCodeChallenge, + generateAuthUrl, + generateSetupTokenAuthUrl, + createProxyAgent +} diff --git a/src/utils/proxyHelper.js b/src/utils/proxyHelper.js new file mode 100644 index 0000000000000000000000000000000000000000..ecaa6b25a4fad14dcd88f0de3db511203a2dd739 --- /dev/null +++ b/src/utils/proxyHelper.js @@ -0,0 +1,212 @@ +const { SocksProxyAgent } = require('socks-proxy-agent') +const { HttpsProxyAgent } = require('https-proxy-agent') +const logger = require('./logger') +const config = require('../../config/config') + +/** + * 统一的代理创建工具 + * 支持 SOCKS5 和 HTTP/HTTPS 代理,可配置 IPv4/IPv6 + */ +class ProxyHelper { + /** + * 创建代理 Agent + * @param {object|string|null} proxyConfig - 代理配置对象或 JSON 字符串 + * @param {object} options - 额外选项 + * @param {boolean|number} options.useIPv4 - 是否使用 IPv4 (true=IPv4, false=IPv6, undefined=auto) + * @returns {Agent|null} 代理 Agent 实例或 null + */ + static createProxyAgent(proxyConfig, options = {}) { + if (!proxyConfig) { + return null + } + + try { + // 解析代理配置 + const proxy = typeof proxyConfig === 'string' ? JSON.parse(proxyConfig) : proxyConfig + + // 验证必要字段 + if (!proxy.type || !proxy.host || !proxy.port) { + logger.warn('⚠️ Invalid proxy configuration: missing required fields (type, host, port)') + return null + } + + // 获取 IPv4/IPv6 配置 + const useIPv4 = ProxyHelper._getIPFamilyPreference(options.useIPv4) + + // 构建认证信息 + const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : '' + + // 根据代理类型创建 Agent + if (proxy.type === 'socks5') { + const socksUrl = `socks5h://${auth}${proxy.host}:${proxy.port}` + const socksOptions = {} + + // 设置 IP 协议族(如果指定) + if (useIPv4 !== null) { + socksOptions.family = useIPv4 ? 4 : 6 + } + + return new SocksProxyAgent(socksUrl, socksOptions) + } else if (proxy.type === 'http' || proxy.type === 'https') { + const proxyUrl = `${proxy.type}://${auth}${proxy.host}:${proxy.port}` + const httpOptions = {} + + // HttpsProxyAgent 支持 family 参数(通过底层的 agent-base) + if (useIPv4 !== null) { + httpOptions.family = useIPv4 ? 4 : 6 + } + + return new HttpsProxyAgent(proxyUrl, httpOptions) + } else { + logger.warn(`⚠️ Unsupported proxy type: ${proxy.type}`) + return null + } + } catch (error) { + logger.warn('⚠️ Failed to create proxy agent:', error.message) + return null + } + } + + /** + * 获取 IP 协议族偏好设置 + * @param {boolean|number|string} preference - 用户偏好设置 + * @returns {boolean|null} true=IPv4, false=IPv6, null=auto + * @private + */ + static _getIPFamilyPreference(preference) { + // 如果没有指定偏好,使用配置文件或默认值 + if (preference === undefined) { + // 从配置文件读取默认设置,默认使用 IPv4 + const defaultUseIPv4 = config.proxy?.useIPv4 + if (defaultUseIPv4 !== undefined) { + return defaultUseIPv4 + } + // 默认值:IPv4(兼容性更好) + return true + } + + // 处理各种输入格式 + if (typeof preference === 'boolean') { + return preference + } + if (typeof preference === 'number') { + return preference === 4 ? true : preference === 6 ? false : null + } + if (typeof preference === 'string') { + const lower = preference.toLowerCase() + if (lower === 'ipv4' || lower === '4') { + return true + } + if (lower === 'ipv6' || lower === '6') { + return false + } + if (lower === 'auto' || lower === 'both') { + return null + } + } + + // 无法识别的值,返回默认(IPv4) + return true + } + + /** + * 验证代理配置 + * @param {object|string} proxyConfig - 代理配置 + * @returns {boolean} 是否有效 + */ + static validateProxyConfig(proxyConfig) { + if (!proxyConfig) { + return false + } + + try { + const proxy = typeof proxyConfig === 'string' ? JSON.parse(proxyConfig) : proxyConfig + + // 检查必要字段 + if (!proxy.type || !proxy.host || !proxy.port) { + return false + } + + // 检查支持的类型 + if (!['socks5', 'http', 'https'].includes(proxy.type)) { + return false + } + + // 检查端口范围 + const port = parseInt(proxy.port) + if (isNaN(port) || port < 1 || port > 65535) { + return false + } + + return true + } catch (error) { + return false + } + } + + /** + * 获取代理配置的描述信息 + * @param {object|string} proxyConfig - 代理配置 + * @returns {string} 代理描述 + */ + static getProxyDescription(proxyConfig) { + if (!proxyConfig) { + return 'No proxy' + } + + try { + const proxy = typeof proxyConfig === 'string' ? JSON.parse(proxyConfig) : proxyConfig + const hasAuth = proxy.username && proxy.password + return `${proxy.type}://${proxy.host}:${proxy.port}${hasAuth ? ' (with auth)' : ''}` + } catch (error) { + return 'Invalid proxy config' + } + } + + /** + * 脱敏代理配置信息用于日志记录 + * @param {object|string} proxyConfig - 代理配置 + * @returns {string} 脱敏后的代理信息 + */ + static maskProxyInfo(proxyConfig) { + if (!proxyConfig) { + return 'No proxy' + } + + try { + const proxy = typeof proxyConfig === 'string' ? JSON.parse(proxyConfig) : proxyConfig + + let proxyDesc = `${proxy.type}://${proxy.host}:${proxy.port}` + + // 如果有认证信息,进行脱敏处理 + if (proxy.username && proxy.password) { + const maskedUsername = + proxy.username.length <= 2 + ? proxy.username + : proxy.username[0] + + '*'.repeat(Math.max(1, proxy.username.length - 2)) + + proxy.username.slice(-1) + const maskedPassword = '*'.repeat(Math.min(8, proxy.password.length)) + proxyDesc += ` (auth: ${maskedUsername}:${maskedPassword})` + } + + return proxyDesc + } catch (error) { + return 'Invalid proxy config' + } + } + + /** + * 创建代理 Agent(兼容旧的函数接口) + * @param {object|string|null} proxyConfig - 代理配置 + * @param {boolean} useIPv4 - 是否使用 IPv4 + * @returns {Agent|null} 代理 Agent 实例或 null + * @deprecated 使用 createProxyAgent 替代 + */ + static createProxy(proxyConfig, useIPv4 = true) { + logger.warn('⚠️ ProxyHelper.createProxy is deprecated, use createProxyAgent instead') + return ProxyHelper.createProxyAgent(proxyConfig, { useIPv4 }) + } +} + +module.exports = ProxyHelper diff --git a/src/utils/rateLimitHelper.js b/src/utils/rateLimitHelper.js new file mode 100644 index 0000000000000000000000000000000000000000..38c3856837c9dba9f6183bff97ef277fc86b25f8 --- /dev/null +++ b/src/utils/rateLimitHelper.js @@ -0,0 +1,71 @@ +const redis = require('../models/redis') +const pricingService = require('../services/pricingService') +const CostCalculator = require('./costCalculator') + +function toNumber(value) { + const num = Number(value) + return Number.isFinite(num) ? num : 0 +} + +async function updateRateLimitCounters(rateLimitInfo, usageSummary, model) { + if (!rateLimitInfo) { + return { totalTokens: 0, totalCost: 0 } + } + + const client = redis.getClient() + if (!client) { + throw new Error('Redis 未连接,无法更新限流计数') + } + + const inputTokens = toNumber(usageSummary.inputTokens) + const outputTokens = toNumber(usageSummary.outputTokens) + const cacheCreateTokens = toNumber(usageSummary.cacheCreateTokens) + const cacheReadTokens = toNumber(usageSummary.cacheReadTokens) + + const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + + if (totalTokens > 0 && rateLimitInfo.tokenCountKey) { + await client.incrby(rateLimitInfo.tokenCountKey, Math.round(totalTokens)) + } + + let totalCost = 0 + const usagePayload = { + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_creation_input_tokens: cacheCreateTokens, + cache_read_input_tokens: cacheReadTokens + } + + try { + const costInfo = pricingService.calculateCost(usagePayload, model) + const { totalCost: calculatedCost } = costInfo || {} + if (typeof calculatedCost === 'number') { + totalCost = calculatedCost + } + } catch (error) { + // 忽略此处错误,后续使用备用计算 + totalCost = 0 + } + + if (totalCost === 0) { + try { + const fallback = CostCalculator.calculateCost(usagePayload, model) + const { costs } = fallback || {} + if (costs && typeof costs.total === 'number') { + totalCost = costs.total + } + } catch (error) { + totalCost = 0 + } + } + + if (totalCost > 0 && rateLimitInfo.costCountKey) { + await client.incrbyfloat(rateLimitInfo.costCountKey, totalCost) + } + + return { totalTokens, totalCost } +} + +module.exports = { + updateRateLimitCounters +} diff --git a/src/utils/sessionHelper.js b/src/utils/sessionHelper.js new file mode 100644 index 0000000000000000000000000000000000000000..ab715e94a71aaf0e7346afdab449674600bbede5 --- /dev/null +++ b/src/utils/sessionHelper.js @@ -0,0 +1,168 @@ +const crypto = require('crypto') +const logger = require('./logger') + +class SessionHelper { + /** + * 生成会话哈希,用于sticky会话保持 + * 基于Anthropic的prompt caching机制,优先使用metadata中的session ID + * @param {Object} requestBody - 请求体 + * @returns {string|null} - 32字符的会话哈希,如果无法生成则返回null + */ + generateSessionHash(requestBody) { + if (!requestBody || typeof requestBody !== 'object') { + return null + } + + // 1. 最高优先级:使用metadata中的session ID(直接使用,无需hash) + if (requestBody.metadata && requestBody.metadata.user_id) { + // 提取 session_xxx 部分 + const userIdString = requestBody.metadata.user_id + const sessionMatch = userIdString.match(/session_([a-f0-9-]{36})/) + if (sessionMatch && sessionMatch[1]) { + const sessionId = sessionMatch[1] + // 直接返回session ID + logger.debug(`📋 Session ID extracted from metadata.user_id: ${sessionId}`) + return sessionId + } + } + + let cacheableContent = '' + const system = requestBody.system || '' + const messages = requestBody.messages || [] + + // 2. 提取带有cache_control: {"type": "ephemeral"}的内容 + // 检查system中的cacheable内容 + if (Array.isArray(system)) { + for (const part of system) { + if (part && part.cache_control && part.cache_control.type === 'ephemeral') { + cacheableContent += part.text || '' + } + } + } + + // 检查messages中的cacheable内容 + for (const msg of messages) { + const content = msg.content || '' + let hasCacheControl = false + + if (Array.isArray(content)) { + for (const part of content) { + if (part && part.cache_control && part.cache_control.type === 'ephemeral') { + hasCacheControl = true + break + } + } + } else if ( + typeof content === 'string' && + msg.cache_control && + msg.cache_control.type === 'ephemeral' + ) { + hasCacheControl = true + } + + if (hasCacheControl) { + for (const message of messages) { + let messageText = '' + if (typeof message.content === 'string') { + messageText = message.content + } else if (Array.isArray(message.content)) { + messageText = message.content + .filter((part) => part.type === 'text') + .map((part) => part.text || '') + .join('') + } + + if (messageText) { + cacheableContent += messageText + break + } + } + break + } + } + + // 3. 如果有cacheable内容,直接使用 + if (cacheableContent) { + const hash = crypto + .createHash('sha256') + .update(cacheableContent) + .digest('hex') + .substring(0, 32) + logger.debug(`📋 Session hash generated from cacheable content: ${hash}`) + return hash + } + + // 4. Fallback: 使用system内容 + if (system) { + let systemText = '' + if (typeof system === 'string') { + systemText = system + } else if (Array.isArray(system)) { + systemText = system.map((part) => part.text || '').join('') + } + + if (systemText) { + const hash = crypto.createHash('sha256').update(systemText).digest('hex').substring(0, 32) + logger.debug(`📋 Session hash generated from system content: ${hash}`) + return hash + } + } + + // 5. 最后fallback: 使用第一条消息内容 + if (messages.length > 0) { + const firstMessage = messages[0] + let firstMessageText = '' + + if (typeof firstMessage.content === 'string') { + firstMessageText = firstMessage.content + } else if (Array.isArray(firstMessage.content)) { + if (!firstMessage.content) { + logger.error('📋 Session hash generated from first message failed: ', firstMessage) + } + + firstMessageText = firstMessage.content + .filter((part) => part.type === 'text') + .map((part) => part.text || '') + .join('') + } + + if (firstMessageText) { + const hash = crypto + .createHash('sha256') + .update(firstMessageText) + .digest('hex') + .substring(0, 32) + logger.debug(`📋 Session hash generated from first message: ${hash}`) + return hash + } + } + + // 无法生成会话哈希 + logger.debug('📋 Unable to generate session hash - no suitable content found') + return null + } + + /** + * 获取会话的Redis键名 + * @param {string} sessionHash - 会话哈希 + * @returns {string} - Redis键名 + */ + getSessionRedisKey(sessionHash) { + return `sticky_session:${sessionHash}` + } + + /** + * 验证会话哈希格式 + * @param {string} sessionHash - 会话哈希 + * @returns {boolean} - 是否有效 + */ + isValidSessionHash(sessionHash) { + return ( + typeof sessionHash === 'string' && + sessionHash.length === 32 && + /^[a-f0-9]{32}$/.test(sessionHash) + ) + } +} + +module.exports = new SessionHelper() diff --git a/src/utils/tokenMask.js b/src/utils/tokenMask.js new file mode 100644 index 0000000000000000000000000000000000000000..aa3170529453a27d7eb6470545939ae503938a36 --- /dev/null +++ b/src/utils/tokenMask.js @@ -0,0 +1,108 @@ +/** + * Token 脱敏工具 + * 用于在日志中安全显示 token,只显示70%的内容,其余用*代替 + */ + +/** + * 对 token 进行脱敏处理 + * @param {string} token - 需要脱敏的 token + * @param {number} visiblePercent - 可见部分的百分比,默认 70 + * @returns {string} 脱敏后的 token + */ +function maskToken(token, visiblePercent = 70) { + if (!token || typeof token !== 'string') { + return '[EMPTY]' + } + + const { length } = token + + // 对于非常短的 token,至少隐藏一部分 + if (length <= 2) { + return '*'.repeat(length) + } + + if (length <= 5) { + return token.slice(0, 1) + '*'.repeat(length - 1) + } + + if (length <= 10) { + const visibleLength = Math.min(5, length - 2) + const front = token.slice(0, visibleLength) + return front + '*'.repeat(length - visibleLength) + } + + // 计算可见字符数量 + const visibleLength = Math.floor(length * (visiblePercent / 100)) + + // 在前部和尾部分配可见字符 + const frontLength = Math.ceil(visibleLength * 0.6) + const backLength = visibleLength - frontLength + + // 构建脱敏后的 token + const front = token.slice(0, frontLength) + const back = token.slice(-backLength) + const middle = '*'.repeat(length - visibleLength) + + return `${front}${middle}${back}` +} + +/** + * 对包含 token 的对象进行脱敏处理 + * @param {Object} obj - 包含 token 的对象 + * @param {Array} tokenFields - 需要脱敏的字段名列表 + * @returns {Object} 脱敏后的对象副本 + */ +function maskTokensInObject( + obj, + tokenFields = ['accessToken', 'refreshToken', 'access_token', 'refresh_token'] +) { + if (!obj || typeof obj !== 'object') { + return obj + } + + const masked = { ...obj } + + tokenFields.forEach((field) => { + if (masked[field]) { + masked[field] = maskToken(masked[field]) + } + }) + + return masked +} + +/** + * 格式化 token 刷新日志 + * @param {string} accountId - 账户 ID + * @param {string} accountName - 账户名称 + * @param {Object} tokens - 包含 access_token 和 refresh_token 的对象 + * @param {string} status - 刷新状态 (success/failed) + * @param {string} message - 额外的消息 + * @returns {Object} 格式化的日志对象 + */ +function formatTokenRefreshLog(accountId, accountName, tokens, status, message = '') { + const log = { + timestamp: new Date().toISOString(), + event: 'token_refresh', + accountId, + accountName, + status, + message + } + + if (tokens) { + log.tokens = { + accessToken: tokens.accessToken ? maskToken(tokens.accessToken) : '[NOT_PROVIDED]', + refreshToken: tokens.refreshToken ? maskToken(tokens.refreshToken) : '[NOT_PROVIDED]', + expiresAt: tokens.expiresAt || '[NOT_PROVIDED]' + } + } + + return log +} + +module.exports = { + maskToken, + maskTokensInObject, + formatTokenRefreshLog +} diff --git a/src/utils/tokenRefreshLogger.js b/src/utils/tokenRefreshLogger.js new file mode 100644 index 0000000000000000000000000000000000000000..f90314851efad83041aaabd90c83b35287a1ec18 --- /dev/null +++ b/src/utils/tokenRefreshLogger.js @@ -0,0 +1,175 @@ +const winston = require('winston') +const path = require('path') +const fs = require('fs') +const { maskToken } = require('./tokenMask') + +// 确保日志目录存在 +const logDir = path.join(process.cwd(), 'logs') +if (!fs.existsSync(logDir)) { + fs.mkdirSync(logDir, { recursive: true }) +} + +// 创建专用的 token 刷新日志记录器 +const tokenRefreshLogger = winston.createLogger({ + level: 'info', + format: winston.format.combine( + winston.format.timestamp({ + format: 'YYYY-MM-DD HH:mm:ss.SSS' + }), + winston.format.json(), + winston.format.printf((info) => JSON.stringify(info, null, 2)) + ), + transports: [ + // 文件传输 - 每日轮转 + new winston.transports.File({ + filename: path.join(logDir, 'token-refresh.log'), + maxsize: 10 * 1024 * 1024, // 10MB + maxFiles: 30, // 保留30天 + tailable: true + }), + // 错误单独记录 + new winston.transports.File({ + filename: path.join(logDir, 'token-refresh-error.log'), + level: 'error', + maxsize: 10 * 1024 * 1024, + maxFiles: 30 + }) + ], + // 错误处理 + exitOnError: false +}) + +// 在开发环境添加控制台输出 +if (process.env.NODE_ENV !== 'production') { + tokenRefreshLogger.add( + new winston.transports.Console({ + format: winston.format.combine(winston.format.colorize(), winston.format.simple()) + }) + ) +} + +/** + * 记录 token 刷新开始 + */ +function logRefreshStart(accountId, accountName, platform = 'claude', reason = '') { + tokenRefreshLogger.info({ + event: 'token_refresh_start', + accountId, + accountName, + platform, + reason, + timestamp: new Date().toISOString() + }) +} + +/** + * 记录 token 刷新成功 + */ +function logRefreshSuccess(accountId, accountName, platform = 'claude', tokenData = {}) { + const maskedTokenData = { + accessToken: tokenData.accessToken ? maskToken(tokenData.accessToken) : '[NOT_PROVIDED]', + refreshToken: tokenData.refreshToken ? maskToken(tokenData.refreshToken) : '[NOT_PROVIDED]', + expiresAt: tokenData.expiresAt || tokenData.expiry_date || '[NOT_PROVIDED]', + scopes: tokenData.scopes || tokenData.scope || '[NOT_PROVIDED]' + } + + tokenRefreshLogger.info({ + event: 'token_refresh_success', + accountId, + accountName, + platform, + tokenData: maskedTokenData, + timestamp: new Date().toISOString() + }) +} + +/** + * 记录 token 刷新失败 + */ +function logRefreshError(accountId, accountName, platform = 'claude', error, attemptNumber = 1) { + const errorInfo = { + message: error.message || error.toString(), + code: error.code || 'UNKNOWN', + statusCode: error.response?.status || 'N/A', + responseData: error.response?.data || 'N/A' + } + + tokenRefreshLogger.error({ + event: 'token_refresh_error', + accountId, + accountName, + platform, + error: errorInfo, + attemptNumber, + timestamp: new Date().toISOString() + }) +} + +/** + * 记录 token 刷新跳过(由于并发锁) + */ +function logRefreshSkipped(accountId, accountName, platform = 'claude', reason = 'locked') { + tokenRefreshLogger.info({ + event: 'token_refresh_skipped', + accountId, + accountName, + platform, + reason, + timestamp: new Date().toISOString() + }) +} + +/** + * 记录 token 使用情况 + */ +function logTokenUsage(accountId, accountName, platform = 'claude', expiresAt, isExpired) { + tokenRefreshLogger.debug({ + event: 'token_usage_check', + accountId, + accountName, + platform, + expiresAt, + isExpired, + remainingMinutes: expiresAt ? Math.floor((new Date(expiresAt) - Date.now()) / 60000) : 'N/A', + timestamp: new Date().toISOString() + }) +} + +/** + * 记录批量刷新任务 + */ +function logBatchRefreshStart(totalAccounts, platform = 'all') { + tokenRefreshLogger.info({ + event: 'batch_refresh_start', + totalAccounts, + platform, + timestamp: new Date().toISOString() + }) +} + +/** + * 记录批量刷新结果 + */ +function logBatchRefreshComplete(results) { + tokenRefreshLogger.info({ + event: 'batch_refresh_complete', + results: { + total: results.total || 0, + success: results.success || 0, + failed: results.failed || 0, + skipped: results.skipped || 0 + }, + timestamp: new Date().toISOString() + }) +} + +module.exports = { + logger: tokenRefreshLogger, + logRefreshStart, + logRefreshSuccess, + logRefreshError, + logRefreshSkipped, + logTokenUsage, + logBatchRefreshStart, + logBatchRefreshComplete +} diff --git a/src/utils/webhookNotifier.js b/src/utils/webhookNotifier.js new file mode 100644 index 0000000000000000000000000000000000000000..0a2704bba9706313c33987878876b51c9b4b59c7 --- /dev/null +++ b/src/utils/webhookNotifier.js @@ -0,0 +1,115 @@ +const logger = require('./logger') +const webhookService = require('../services/webhookService') +const { getISOStringWithTimezone } = require('./dateHelper') + +class WebhookNotifier { + constructor() { + // 保留此类用于兼容性,实际功能委托给webhookService + } + + /** + * 发送账号异常通知 + * @param {Object} notification - 通知内容 + * @param {string} notification.accountId - 账号ID + * @param {string} notification.accountName - 账号名称 + * @param {string} notification.platform - 平台类型 (claude-oauth, claude-console, gemini) + * @param {string} notification.status - 异常状态 (unauthorized, blocked, error) + * @param {string} notification.errorCode - 异常代码 + * @param {string} notification.reason - 异常原因 + * @param {string} notification.timestamp - 时间戳 + */ + async sendAccountAnomalyNotification(notification) { + try { + // 使用新的webhookService发送通知 + await webhookService.sendNotification('accountAnomaly', { + accountId: notification.accountId, + accountName: notification.accountName, + platform: notification.platform, + status: notification.status, + errorCode: + notification.errorCode || this._getErrorCode(notification.platform, notification.status), + reason: notification.reason, + timestamp: notification.timestamp || getISOStringWithTimezone(new Date()) + }) + } catch (error) { + logger.error('Failed to send account anomaly notification:', error) + } + } + + /** + * 测试Webhook连通性(兼容旧接口) + * @param {string} url - Webhook URL + * @param {string} type - 平台类型(可选) + */ + async testWebhook(url, type = 'custom') { + try { + // 创建临时平台配置 + const platform = { + type, + url, + enabled: true, + timeout: 10000 + } + + const result = await webhookService.testWebhook(platform) + return result + } catch (error) { + return { success: false, error: error.message } + } + } + + /** + * 发送账号事件通知 + * @param {string} eventType - 事件类型 (account.created, account.updated, account.deleted, account.status_changed) + * @param {Object} data - 事件数据 + */ + async sendAccountEvent(eventType, data) { + try { + // 使用webhookService发送通知 + await webhookService.sendNotification('accountEvent', { + eventType, + ...data, + timestamp: data.timestamp || getISOStringWithTimezone(new Date()) + }) + } catch (error) { + logger.error(`Failed to send account event (${eventType}):`, error) + } + } + + /** + * 获取错误代码映射 + * @param {string} platform - 平台类型 + * @param {string} status - 状态 + * @param {string} _reason - 原因 (未使用) + */ + _getErrorCode(platform, status, _reason) { + const errorCodes = { + 'claude-oauth': { + unauthorized: 'CLAUDE_OAUTH_UNAUTHORIZED', + blocked: 'CLAUDE_OAUTH_BLOCKED', + error: 'CLAUDE_OAUTH_ERROR', + disabled: 'CLAUDE_OAUTH_MANUALLY_DISABLED' + }, + 'claude-console': { + blocked: 'CLAUDE_CONSOLE_BLOCKED', + error: 'CLAUDE_CONSOLE_ERROR', + disabled: 'CLAUDE_CONSOLE_MANUALLY_DISABLED' + }, + gemini: { + error: 'GEMINI_ERROR', + unauthorized: 'GEMINI_UNAUTHORIZED', + disabled: 'GEMINI_MANUALLY_DISABLED' + }, + openai: { + error: 'OPENAI_ERROR', + unauthorized: 'OPENAI_UNAUTHORIZED', + blocked: 'OPENAI_RATE_LIMITED', + disabled: 'OPENAI_MANUALLY_DISABLED' + } + } + + return errorCodes[platform]?.[status] || 'UNKNOWN_ERROR' + } +} + +module.exports = new WebhookNotifier() diff --git a/src/utils/workosOAuthHelper.js b/src/utils/workosOAuthHelper.js new file mode 100644 index 0000000000000000000000000000000000000000..8ce33c4d15c23e49b2cfbbe5d638e4e36588c307 --- /dev/null +++ b/src/utils/workosOAuthHelper.js @@ -0,0 +1,170 @@ +const axios = require('axios') +const config = require('../../config/config') +const logger = require('./logger') +const ProxyHelper = require('./proxyHelper') + +const WORKOS_CONFIG = config.droid || {} + +const WORKOS_DEVICE_AUTHORIZE_URL = + WORKOS_CONFIG.deviceAuthorizeUrl || 'https://api.workos.com/user_management/authorize/device' +const WORKOS_TOKEN_URL = + WORKOS_CONFIG.tokenUrl || 'https://api.workos.com/user_management/authenticate' +const WORKOS_CLIENT_ID = WORKOS_CONFIG.clientId || 'client_01HNM792M5G5G1A2THWPXKFMXB' + +const DEFAULT_POLL_INTERVAL = 5 + +class WorkOSDeviceAuthError extends Error { + constructor(message, code, options = {}) { + super(message) + this.name = 'WorkOSDeviceAuthError' + this.code = code || 'unknown_error' + this.retryAfter = options.retryAfter || null + } +} + +/** + * 启动设备码授权流程 + * @param {object|null} proxyConfig - 代理配置 + * @returns {Promise} WorkOS 返回的数据 + */ +async function startDeviceAuthorization(proxyConfig = null) { + const form = new URLSearchParams({ + client_id: WORKOS_CLIENT_ID + }) + + const agent = ProxyHelper.createProxyAgent(proxyConfig) + + try { + logger.info('🔐 请求 WorkOS 设备码授权', { + url: WORKOS_DEVICE_AUTHORIZE_URL, + hasProxy: !!agent + }) + + const response = await axios.post(WORKOS_DEVICE_AUTHORIZE_URL, form.toString(), { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + httpsAgent: agent, + timeout: 15000 + }) + + const data = response.data || {} + + if (!data.device_code || !data.verification_uri) { + throw new Error('WorkOS 返回数据缺少必要字段 (device_code / verification_uri)') + } + + logger.success('✅ 成功获取 WorkOS 设备码授权信息', { + verificationUri: data.verification_uri, + userCode: data.user_code + }) + + return { + deviceCode: data.device_code, + userCode: data.user_code, + verificationUri: data.verification_uri, + verificationUriComplete: data.verification_uri_complete || data.verification_uri, + expiresIn: data.expires_in || 300, + interval: data.interval || DEFAULT_POLL_INTERVAL + } + } catch (error) { + if (error.response) { + logger.error('❌ WorkOS 设备码授权失败', { + status: error.response.status, + data: error.response.data + }) + throw new WorkOSDeviceAuthError( + error.response.data?.error_description || + error.response.data?.error || + 'WorkOS 设备码授权失败', + error.response.data?.error + ) + } + + logger.error('❌ 请求 WorkOS 设备码授权异常', { + message: error.message + }) + throw new WorkOSDeviceAuthError(error.message) + } +} + +/** + * 轮询授权结果 + * @param {string} deviceCode - 设备码 + * @param {object|null} proxyConfig - 代理配置 + * @returns {Promise} WorkOS 返回的 token 数据 + */ +async function pollDeviceAuthorization(deviceCode, proxyConfig = null) { + if (!deviceCode) { + throw new WorkOSDeviceAuthError('缺少设备码,无法查询授权结果', 'missing_device_code') + } + + const form = new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + device_code: deviceCode, + client_id: WORKOS_CLIENT_ID + }) + + const agent = ProxyHelper.createProxyAgent(proxyConfig) + + try { + const response = await axios.post(WORKOS_TOKEN_URL, form.toString(), { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + httpsAgent: agent, + timeout: 15000 + }) + + const data = response.data || {} + + if (!data.access_token) { + throw new WorkOSDeviceAuthError('WorkOS 返回结果缺少 access_token', 'missing_access_token') + } + + logger.success('🤖 Droid 授权完成,获取到访问令牌', { + hasRefreshToken: !!data.refresh_token + }) + + return data + } catch (error) { + if (error.response) { + const responseData = error.response.data || {} + const errorCode = responseData.error || `http_${error.response.status}` + const errorDescription = + responseData.error_description || responseData.error || 'WorkOS 授权失败' + + if (errorCode === 'authorization_pending' || errorCode === 'slow_down') { + const retryAfter = + Number(responseData.interval) || + Number(error.response.headers?.['retry-after']) || + DEFAULT_POLL_INTERVAL + + throw new WorkOSDeviceAuthError(errorDescription, errorCode, { + retryAfter + }) + } + + if (errorCode === 'expired_token') { + throw new WorkOSDeviceAuthError(errorDescription, errorCode) + } + + logger.error('❌ WorkOS 设备授权轮询失败', { + status: error.response.status, + data: responseData + }) + throw new WorkOSDeviceAuthError(errorDescription, errorCode) + } + + logger.error('❌ WorkOS 设备授权轮询异常', { + message: error.message + }) + throw new WorkOSDeviceAuthError(error.message) + } +} + +module.exports = { + startDeviceAuthorization, + pollDeviceAuthorization, + WorkOSDeviceAuthError +} diff --git a/src/validators/clientDefinitions.js b/src/validators/clientDefinitions.js new file mode 100644 index 0000000000000000000000000000000000000000..89c3e5288a1ca6c039f7b02732c0087ef1672e7f --- /dev/null +++ b/src/validators/clientDefinitions.js @@ -0,0 +1,69 @@ +/** + * 客户端定义配置 + * 定义所有支持的客户端类型和它们的属性 + */ + +const CLIENT_DEFINITIONS = { + CLAUDE_CODE: { + id: 'claude_code', + name: 'Claude Code', + displayName: 'Claude Code CLI', + description: 'Claude Code command-line interface', + icon: '🤖' + }, + + GEMINI_CLI: { + id: 'gemini_cli', + name: 'Gemini CLI', + displayName: 'Gemini Command Line Tool', + description: 'Google Gemini API command-line interface', + icon: '💎' + }, + + CODEX_CLI: { + id: 'codex_cli', + name: 'Codex CLI', + displayName: 'Codex Command Line Tool', + description: 'Cursor/Codex command-line interface', + icon: '🔷' + }, + + DROID_CLI: { + id: 'droid_cli', + name: 'Droid CLI', + displayName: 'Factory Droid CLI', + description: 'Factory Droid platform command-line interface', + icon: '🤖' + } +} + +// 导出客户端ID枚举 +const CLIENT_IDS = { + CLAUDE_CODE: 'claude_code', + GEMINI_CLI: 'gemini_cli', + CODEX_CLI: 'codex_cli', + DROID_CLI: 'droid_cli' +} + +// 获取所有客户端定义 +function getAllClientDefinitions() { + return Object.values(CLIENT_DEFINITIONS) +} + +// 根据ID获取客户端定义 +function getClientDefinitionById(clientId) { + return Object.values(CLIENT_DEFINITIONS).find((client) => client.id === clientId) +} + +// 检查客户端ID是否有效 +function isValidClientId(clientId) { + return Object.values(CLIENT_IDS).includes(clientId) +} + +module.exports = { + CLIENT_DEFINITIONS, + CLIENT_IDS, + getAllClientDefinitions, + getClientDefinitionById, + isValidClientId +} diff --git a/src/validators/clientValidator.js b/src/validators/clientValidator.js new file mode 100644 index 0000000000000000000000000000000000000000..13cb38eba4992b3b324f22c948e22ff734274b2b --- /dev/null +++ b/src/validators/clientValidator.js @@ -0,0 +1,146 @@ +/** + * 客户端验证器 + * 用于验证请求是否来自特定的客户端 + */ + +const logger = require('../utils/logger') +const { CLIENT_DEFINITIONS, getAllClientDefinitions } = require('./clientDefinitions') +const ClaudeCodeValidator = require('./clients/claudeCodeValidator') +const GeminiCliValidator = require('./clients/geminiCliValidator') +const CodexCliValidator = require('./clients/codexCliValidator') +const DroidCliValidator = require('./clients/droidCliValidator') + +/** + * 客户端验证器类 + */ +class ClientValidator { + /** + * 获取客户端验证器 + * @param {string} clientId - 客户端ID + * @returns {Object|null} 验证器实例 + */ + static getValidator(clientId) { + switch (clientId) { + case 'claude_code': + return ClaudeCodeValidator + case 'gemini_cli': + return GeminiCliValidator + case 'codex_cli': + return CodexCliValidator + case 'droid_cli': + return DroidCliValidator + default: + logger.warn(`Unknown client ID: ${clientId}`) + return null + } + } + + /** + * 获取所有支持的客户端ID列表 + * @returns {Array} 客户端ID列表 + */ + static getSupportedClients() { + return ['claude_code', 'gemini_cli', 'codex_cli', 'droid_cli'] + } + + /** + * 验证单个客户端 + * @param {string} clientId - 客户端ID + * @param {Object} req - Express请求对象 + * @returns {boolean} 验证结果 + */ + static validateClient(clientId, req) { + const validator = this.getValidator(clientId) + + if (!validator) { + logger.warn(`No validator found for client: ${clientId}`) + return false + } + + try { + return validator.validate(req) + } catch (error) { + logger.error(`Error validating client ${clientId}:`, error) + return false + } + } + + /** + * 验证请求是否来自允许的客户端列表中的任一客户端 + * @param {Array} allowedClients - 允许的客户端ID列表 + * @param {Object} req - Express请求对象 + * @returns {Object} 验证结果对象 + */ + static validateRequest(allowedClients, req) { + const userAgent = req.headers['user-agent'] || '' + const clientIP = req.ip || req.connection?.remoteAddress || 'unknown' + + // 记录验证开始 + logger.api(`🔍 Starting client validation for User-Agent: "${userAgent}"`) + logger.api(` Allowed clients: ${allowedClients.join(', ')}`) + logger.api(` Request from IP: ${clientIP}`) + + // 遍历所有允许的客户端进行验证 + for (const clientId of allowedClients) { + const validator = this.getValidator(clientId) + + if (!validator) { + logger.warn(`Skipping unknown client ID: ${clientId}`) + continue + } + + logger.debug(`Checking against ${validator.getName()}...`) + + try { + if (validator.validate(req)) { + // 验证成功 + logger.api(`✅ Client validated: ${validator.getName()} (${clientId})`) + logger.api(` Matched User-Agent: "${userAgent}"`) + + return { + allowed: true, + matchedClient: clientId, + clientName: validator.getName(), + clientInfo: Object.values(CLIENT_DEFINITIONS).find((def) => def.id === clientId) + } + } + } catch (error) { + logger.error(`Error during validation for ${clientId}:`, error) + continue + } + } + + // 没有匹配的客户端 + logger.api(`❌ No matching client found for User-Agent: "${userAgent}"`) + return { + allowed: false, + matchedClient: null, + reason: 'No matching client found' + } + } + + /** + * 获取客户端信息 + * @param {string} clientId - 客户端ID + * @returns {Object} 客户端信息 + */ + static getClientInfo(clientId) { + const validator = this.getValidator(clientId) + if (!validator) { + return null + } + + return validator.getInfo() + } + + /** + * 获取所有可用的客户端信息 + * @returns {Array} 客户端信息数组 + */ + static getAvailableClients() { + // 直接从 CLIENT_DEFINITIONS 返回所有客户端信息 + return getAllClientDefinitions() + } +} + +module.exports = ClientValidator diff --git a/src/validators/clients/claudeCodeValidator.js b/src/validators/clients/claudeCodeValidator.js new file mode 100644 index 0000000000000000000000000000000000000000..a72928f4900ed43f2776c5f5430fa1b07d202209 --- /dev/null +++ b/src/validators/clients/claudeCodeValidator.js @@ -0,0 +1,233 @@ +const logger = require('../../utils/logger') +const { CLIENT_DEFINITIONS } = require('../clientDefinitions') +const { bestSimilarityByTemplates, SYSTEM_PROMPT_THRESHOLD } = require('../../utils/contents') + +/** + * Claude Code CLI 验证器 + * 验证请求是否来自 Claude Code CLI + */ +class ClaudeCodeValidator { + /** + * 获取客户端ID + */ + static getId() { + return CLIENT_DEFINITIONS.CLAUDE_CODE.id + } + + /** + * 获取客户端名称 + */ + static getName() { + return CLIENT_DEFINITIONS.CLAUDE_CODE.name + } + + /** + * 获取客户端描述 + */ + static getDescription() { + return CLIENT_DEFINITIONS.CLAUDE_CODE.description + } + + /** + * 获取客户端图标 + */ + static getIcon() { + return CLIENT_DEFINITIONS.CLAUDE_CODE.icon || '🤖' + } + + /** + * 检查请求是否包含 Claude Code 系统提示词 + * @param {Object} body - 请求体 + * @returns {boolean} 是否包含 Claude Code 系统提示词 + */ + static hasClaudeCodeSystemPrompt(body, customThreshold) { + if (!body || typeof body !== 'object') { + return false + } + + const model = typeof body.model === 'string' ? body.model : null + if (!model) { + return false + } + + const systemEntries = Array.isArray(body.system) ? body.system : null + if (!systemEntries) { + return false + } + + const threshold = + typeof customThreshold === 'number' && Number.isFinite(customThreshold) + ? customThreshold + : SYSTEM_PROMPT_THRESHOLD + + for (const entry of systemEntries) { + const rawText = typeof entry?.text === 'string' ? entry.text : '' + const { bestScore } = bestSimilarityByTemplates(rawText) + if (bestScore < threshold) { + logger.error( + `Claude system prompt similarity below threshold: score=${bestScore.toFixed(4)}, threshold=${threshold}, prompt=${rawText}` + ) + return false + } + } + return true + } + + /** + * 判断是否存在 Claude Code 系统提示词(存在即返回 true) + * @param {Object} body - 请求体 + * @param {number} [customThreshold] - 自定义阈值 + * @returns {boolean} 是否存在 Claude Code 系统提示词 + */ + static includesClaudeCodeSystemPrompt(body, customThreshold) { + if (!body || typeof body !== 'object') { + return false + } + + const model = typeof body.model === 'string' ? body.model : null + if (!model) { + return false + } + + const systemEntries = Array.isArray(body.system) ? body.system : null + if (!systemEntries) { + return false + } + + const threshold = + typeof customThreshold === 'number' && Number.isFinite(customThreshold) + ? customThreshold + : SYSTEM_PROMPT_THRESHOLD + + let bestMatchScore = 0 + + for (const entry of systemEntries) { + const rawText = typeof entry?.text === 'string' ? entry.text : '' + const { bestScore } = bestSimilarityByTemplates(rawText) + + if (bestScore > bestMatchScore) { + bestMatchScore = bestScore + } + + if (bestScore >= threshold) { + return true + } + } + + logger.debug( + `Claude system prompt not detected: bestScore=${bestMatchScore.toFixed(4)}, threshold=${threshold}` + ) + + return false + } + + /** + * 验证请求是否来自 Claude Code CLI + * @param {Object} req - Express 请求对象 + * @returns {boolean} 验证结果 + */ + static validate(req) { + try { + const userAgent = req.headers['user-agent'] || '' + const path = req.path || '' + + const claudeCodePattern = /^claude-cli\/\d+\.\d+\.\d+/i; + + if (!claudeCodePattern.test(userAgent)) { + // 不是 Claude Code 的请求,此验证器不处理 + return false + } + + // 2. Claude Code 检测到,对于特定路径进行额外的严格验证 + if (!path.includes('messages')) { + // 其他路径,只要 User-Agent 匹配就认为是 Claude Code + logger.debug(`Claude Code detected for path: ${path}, allowing access`) + return true + } + + // 3. 检查系统提示词是否为 Claude Code 的系统提示词 + if (!this.hasClaudeCodeSystemPrompt(req.body)) { + logger.debug('Claude Code validation failed - missing or invalid Claude Code system prompt') + return false + } + + // 4. 检查必需的头部(值不为空即可) + const xApp = req.headers['x-app'] + const anthropicBeta = req.headers['anthropic-beta'] + const anthropicVersion = req.headers['anthropic-version'] + + if (!xApp || xApp.trim() === '') { + logger.debug('Claude Code validation failed - missing or empty x-app header') + return false + } + + if (!anthropicBeta || anthropicBeta.trim() === '') { + logger.debug('Claude Code validation failed - missing or empty anthropic-beta header') + return false + } + + if (!anthropicVersion || anthropicVersion.trim() === '') { + logger.debug('Claude Code validation failed - missing or empty anthropic-version header') + return false + } + + logger.debug( + `Claude Code headers - x-app: ${xApp}, anthropic-beta: ${anthropicBeta}, anthropic-version: ${anthropicVersion}` + ) + + // 5. 验证 body 中的 metadata.user_id + if (!req.body || !req.body.metadata || !req.body.metadata.user_id) { + logger.debug('Claude Code validation failed - missing metadata.user_id in body') + return false + } + + const userId = req.body.metadata.user_id + // 格式: user_{64位字符串}_account__session_{哈希值} + // user_d98385411c93cd074b2cefd5c9831fe77f24a53e4ecdcd1f830bba586fe62cb9_account__session_17cf0fd3-d51b-4b59-977d-b899dafb3022 + const userIdPattern = /^user_[a-fA-F0-9]{64}_account__session_[\w-]+$/ + + if (!userIdPattern.test(userId)) { + logger.debug(`Claude Code validation failed - invalid user_id format: ${userId}`) + + // 提供更详细的错误信息 + if (!userId.startsWith('user_')) { + logger.debug('user_id must start with "user_"') + } else { + const parts = userId.split('_') + if (parts.length < 4) { + logger.debug('user_id format is incomplete') + } else if (parts[1].length !== 64) { + logger.debug(`user hash must be 64 characters, got ${parts[1].length}`) + } else if (parts[2] !== 'account' || parts[3] !== '' || parts[4] !== 'session') { + logger.debug('user_id must contain "_account__session_"') + } + } + return false + } + + // 6. 额外日志记录(用于调试) + logger.debug(`Claude Code validation passed - UA: ${userAgent}, userId: ${userId}`) + + // 所有必要检查通过 + return true + } catch (error) { + logger.error('Error in ClaudeCodeValidator:', error) + // 验证出错时默认拒绝 + return false + } + } + + /** + * 获取验证器信息 + */ + static getInfo() { + return { + id: this.getId(), + name: this.getName(), + description: this.getDescription(), + icon: CLIENT_DEFINITIONS.CLAUDE_CODE.icon + } + } +} + +module.exports = ClaudeCodeValidator diff --git a/src/validators/clients/codexCliValidator.js b/src/validators/clients/codexCliValidator.js new file mode 100644 index 0000000000000000000000000000000000000000..aff09fbf3d80aba29966ea26b386ff13b30538b8 --- /dev/null +++ b/src/validators/clients/codexCliValidator.js @@ -0,0 +1,147 @@ +const logger = require('../../utils/logger') +const { CLIENT_DEFINITIONS } = require('../clientDefinitions') + +/** + * Codex CLI 验证器 + * 验证请求是否来自 Codex CLI + */ +class CodexCliValidator { + /** + * 获取客户端ID + */ + static getId() { + return CLIENT_DEFINITIONS.CODEX_CLI.id + } + + /** + * 获取客户端名称 + */ + static getName() { + return CLIENT_DEFINITIONS.CODEX_CLI.name + } + + /** + * 获取客户端描述 + */ + static getDescription() { + return CLIENT_DEFINITIONS.CODEX_CLI.description + } + + /** + * 验证请求是否来自 Codex CLI + * @param {Object} req - Express 请求对象 + * @returns {boolean} 验证结果 + */ + static validate(req) { + try { + const userAgent = req.headers['user-agent'] || '' + const originator = req.headers['originator'] || '' + const sessionId = req.headers['session_id'] + + // 1. 基础 User-Agent 检查 + // Codex CLI 的 UA 格式: + // - codex_vscode/0.35.0 (Windows 10.0.26100; x86_64) unknown (Cursor; 0.4.10) + // - codex_cli_rs/0.38.0 (Ubuntu 22.4.0; x86_64) WindowsTerminal + const codexCliPattern = /^(codex_vscode|codex_cli_rs)\/[\d\.]+/i + const uaMatch = userAgent.match(codexCliPattern) + + if (!uaMatch) { + logger.debug(`Codex CLI validation failed - UA mismatch: ${userAgent}`) + return false + } + + // 2. 对于特定路径,进行额外的严格验证 + // 对于 /openai 和 /azure 路径需要完整验证 + const strictValidationPaths = ['/openai', '/azure'] + const needsStrictValidation = req.path && strictValidationPaths.some(path => req.path.startsWith(path)) + + if (!needsStrictValidation) { + // 其他路径,只要 User-Agent 匹配就认为是 Codex CLI + logger.debug(`Codex CLI detected for path: ${req.path}, allowing access`) + return true + } + + // 3. 验证 originator 头必须与 UA 中的客户端类型匹配 + const clientType = uaMatch[1].toLowerCase() + if (originator.toLowerCase() !== clientType) { + logger.debug( + `Codex CLI validation failed - originator mismatch. UA: ${clientType}, originator: ${originator}` + ) + return false + } + + // 4. 检查 session_id - 必须存在且长度大于20 + if (!sessionId || sessionId.length <= 20) { + logger.debug(`Codex CLI validation failed - session_id missing or too short: ${sessionId}`) + return false + } + + // 5. 对于 /openai/responses 和 /azure/response 路径,额外检查 body 中的 instructions 字段 + if ( + req.path && + (req.path.includes('/openai/responses') || req.path.includes('/azure/response')) + ) { + if (!req.body || !req.body.instructions) { + logger.debug(`Codex CLI validation failed - missing instructions in body for ${req.path}`) + return false + } + + const expectedPrefix = + 'You are Codex, based on GPT-5. You are running as a coding agent in the Codex CLI' + if (!req.body.instructions.startsWith(expectedPrefix)) { + logger.debug(`Codex CLI validation failed - invalid instructions prefix for ${req.path}`) + logger.debug(`Expected: "${expectedPrefix}..."`) + logger.debug(`Received: "${req.body.instructions.substring(0, 100)}..."`) + return false + } + + // 额外检查 model 字段应该是 gpt-5-codex + if (req.body.model && req.body.model !== 'gpt-5-codex') { + logger.debug(`Codex CLI validation warning - unexpected model: ${req.body.model}`) + // 只记录警告,不拒绝请求 + } + } + + // 所有必要检查通过 + logger.debug(`Codex CLI validation passed for UA: ${userAgent}`) + return true + } catch (error) { + logger.error('Error in CodexCliValidator:', error) + // 验证出错时默认拒绝 + return false + } + } + + /** + * 比较版本号 + * @returns {number} -1: v1 < v2, 0: v1 = v2, 1: v1 > v2 + */ + static compareVersions(v1, v2) { + const parts1 = v1.split('.').map(Number) + const parts2 = v2.split('.').map(Number) + + for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) { + const part1 = parts1[i] || 0 + const part2 = parts2[i] || 0 + + if (part1 < part2) return -1 + if (part1 > part2) return 1 + } + + return 0 + } + + /** + * 获取验证器信息 + */ + static getInfo() { + return { + id: this.getId(), + name: this.getName(), + description: this.getDescription(), + icon: CLIENT_DEFINITIONS.CODEX_CLI.icon + } + } +} + +module.exports = CodexCliValidator diff --git a/src/validators/clients/droidCliValidator.js b/src/validators/clients/droidCliValidator.js new file mode 100644 index 0000000000000000000000000000000000000000..7fde7aa99a701bbba1f0ef035ada0c3f065960df --- /dev/null +++ b/src/validators/clients/droidCliValidator.js @@ -0,0 +1,57 @@ +const logger = require('../../utils/logger') +const { CLIENT_DEFINITIONS } = require('../clientDefinitions') + +/** + * Droid CLI 验证器 + * 检查请求是否来自 Factory Droid CLI + */ +class DroidCliValidator { + static getId() { + return CLIENT_DEFINITIONS.DROID_CLI.id + } + + static getName() { + return CLIENT_DEFINITIONS.DROID_CLI.name + } + + static getDescription() { + return CLIENT_DEFINITIONS.DROID_CLI.description + } + + static validate(req) { + try { + const userAgent = req.headers['user-agent'] || '' + const factoryClientHeader = (req.headers['x-factory-client'] || '').toString().toLowerCase() + + const uaMatch = /factory-cli\/(\d+\.\d+\.\d+)/i.exec(userAgent) + const hasFactoryClientHeader = + typeof factoryClientHeader === 'string' && + (factoryClientHeader.includes('droid') || factoryClientHeader.includes('factory-cli')) + + if (!uaMatch && !hasFactoryClientHeader) { + logger.debug(`Droid CLI validation failed - UA mismatch: ${userAgent}`) + return false + } + + // 允许,通过基础验证 + logger.debug( + `Droid CLI validation passed (UA: ${userAgent || 'N/A'}, header: ${factoryClientHeader || 'N/A'})` + ) + return true + } catch (error) { + logger.error('Error in DroidCliValidator:', error) + return false + } + } + + static getInfo() { + return { + id: this.getId(), + name: this.getName(), + description: this.getDescription(), + icon: CLIENT_DEFINITIONS.DROID_CLI.icon + } + } +} + +module.exports = DroidCliValidator diff --git a/src/validators/clients/geminiCliValidator.js b/src/validators/clients/geminiCliValidator.js new file mode 100644 index 0000000000000000000000000000000000000000..ea8e60e7cb92a0337d3e0211cb48cc2f4fe33bfd --- /dev/null +++ b/src/validators/clients/geminiCliValidator.js @@ -0,0 +1,105 @@ +const logger = require('../../utils/logger') +const { CLIENT_DEFINITIONS } = require('../clientDefinitions') + +/** + * Gemini CLI 验证器 + * 验证请求是否来自 Gemini CLI + */ +class GeminiCliValidator { + /** + * 获取客户端ID + */ + static getId() { + return CLIENT_DEFINITIONS.GEMINI_CLI.id + } + + /** + * 获取客户端名称 + */ + static getName() { + return CLIENT_DEFINITIONS.GEMINI_CLI.name + } + + /** + * 获取客户端描述 + */ + static getDescription() { + return CLIENT_DEFINITIONS.GEMINI_CLI.description + } + + /** + * 获取客户端图标 + */ + static getIcon() { + return CLIENT_DEFINITIONS.GEMINI_CLI.icon || '💎' + } + + /** + * 验证请求是否来自 Gemini CLI + * @param {Object} req - Express 请求对象 + * @returns {boolean} 验证结果 + */ + static validate(req) { + try { + const userAgent = req.headers['user-agent'] || '' + const path = req.originalUrl || '' + + // 1. 必须是 /gemini 开头的路径 + if (!path.startsWith('/gemini')) { + // 非 /gemini 路径不属于 Gemini + return false + } + + // 2. 对于 /gemini 路径,检查是否包含 generateContent + if (path.includes('generateContent')) { + // 包含 generateContent 的路径需要验证 User-Agent + const geminiCliPattern = /^GeminiCLI\/v?[\d\.]+/i + if (!geminiCliPattern.test(userAgent)) { + logger.debug(`Gemini CLI validation failed - UA mismatch for generateContent: ${userAgent}`) + return false + } + } + + // 所有必要检查通过 + logger.debug(`Gemini CLI validation passed for path: ${path}`) + return true + } catch (error) { + logger.error('Error in GeminiCliValidator:', error) + // 验证出错时默认拒绝 + return false + } + } + + /** + * 比较版本号 + * @returns {number} -1: v1 < v2, 0: v1 = v2, 1: v1 > v2 + */ + static compareVersions(v1, v2) { + const parts1 = v1.split('.').map(Number) + const parts2 = v2.split('.').map(Number) + + for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) { + const part1 = parts1[i] || 0 + const part2 = parts2[i] || 0 + + if (part1 < part2) return -1 + if (part1 > part2) return 1 + } + + return 0 + } + + /** + * 获取验证器信息 + */ + static getInfo() { + return { + id: this.getId(), + name: this.getName(), + description: this.getDescription(), + icon: CLIENT_DEFINITIONS.GEMINI_CLI.icon + } + } +} + +module.exports = GeminiCliValidator diff --git a/web/admin-spa/.env.example b/web/admin-spa/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..d0bd7a9763b0c24e5e43a01c29e2f258562c2fdb --- /dev/null +++ b/web/admin-spa/.env.example @@ -0,0 +1,37 @@ +# ========== 基础配置 ========== + +# 应用基础路径 +# 用于配置路由和资源的基础路径 +# 开发环境默认:/admin/ +# 生产环境默认:/admin-next/ +# 如果使用默认值,可以注释掉此行 +VITE_APP_BASE_URL=/admin-next/ + +# 应用标题 +# 显示在浏览器标签页和页面头部 +VITE_APP_TITLE=Claude Relay Service - 管理后台 + +# ========== 开发环境配置 ========== + +# API 代理目标地址 +# 开发环境下,所有 /webapi 前缀的请求会被代理到这个地址 +# 默认值:http://localhost:3000 +# VITE_API_TARGET=http://localhost:3000 + +# HTTP 代理配置(可选) +# 如果需要通过代理访问后端服务器,请取消注释并配置 +# 格式:http://proxy-host:port +#VITE_HTTP_PROXY=http://127.0.0.1:7890 + +# ========== 教程页面配置 ========== + +# API 基础前缀(可选) +# 用于教程页面显示的自定义 API 前缀 +# 如果不配置,则使用当前浏览器访问地址 +# 示例:https://api.example.com 或 https://relay.mysite.com +# VITE_API_BASE_PREFIX=https://api.example.com + +# ========== 使用说明 ========== +# 1. 复制此文件为 .env.local 进行本地配置 +# 2. .env.local 文件不会被提交到版本控制 +# 3. 详细说明请查看 ENV_CONFIG.md \ No newline at end of file diff --git a/web/admin-spa/.env.production b/web/admin-spa/.env.production new file mode 100644 index 0000000000000000000000000000000000000000..8b3e20151a822280f9c870ada65598c7e9e3104e --- /dev/null +++ b/web/admin-spa/.env.production @@ -0,0 +1,4 @@ +# 生产环境配置 +# 应用基础路径(用于路由配置) +VITE_APP_BASE_URL=/admin-next/ +VITE_APP_TITLE=Claude Relay Service - 管理后台 \ No newline at end of file diff --git a/web/admin-spa/.eslintrc.cjs b/web/admin-spa/.eslintrc.cjs new file mode 100644 index 0000000000000000000000000000000000000000..2a62505867a1408862947382ef53cc9fa101cd14 --- /dev/null +++ b/web/admin-spa/.eslintrc.cjs @@ -0,0 +1,44 @@ +module.exports = { + root: true, + env: { + node: true, + browser: true, + es2021: true + }, + extends: [ + 'plugin:vue/vue3-strongly-recommended', + 'eslint:recommended', + 'plugin:prettier/recommended' + ], + parserOptions: { + sourceType: 'module', + ecmaVersion: 'latest' + }, + plugins: ['prettier'], + rules: { + 'vue/multi-word-component-names': 'off', + 'vue/no-v-html': 'off', + 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', + 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off', + 'prettier/prettier': 'error', + 'vue/attributes-order': [ + 'error', + { + order: [ + 'DEFINITION', + 'LIST_RENDERING', + 'CONDITIONALS', + 'RENDER_MODIFIERS', + 'GLOBAL', + 'UNIQUE', + 'TWO_WAY_BINDING', + 'OTHER_DIRECTIVES', + 'OTHER_ATTR', + 'EVENTS', + 'CONTENT' + ], + alphabetical: true + } + ] + } +} diff --git a/web/admin-spa/.gitignore b/web/admin-spa/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..355deb2f249484fb608b5cf88c5716394006f2c6 --- /dev/null +++ b/web/admin-spa/.gitignore @@ -0,0 +1,35 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# Dependencies +node_modules + +# Production build files +dist +dist-ssr + +# Local env files +!.env.production +*.local +.env.local +.env.*.local +.env +vite.config.js.timestamp-*.mjs +web/admin/ + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? \ No newline at end of file diff --git a/web/admin-spa/.prettierrc b/web/admin-spa/.prettierrc new file mode 100644 index 0000000000000000000000000000000000000000..b31a3d5cd434c4f0dec4381863389136bb3e1370 --- /dev/null +++ b/web/admin-spa/.prettierrc @@ -0,0 +1,17 @@ +{ + "printWidth": 100, + "semi": false, + "singleQuote": true, + "trailingComma": "none", + "endOfLine": "auto", + "plugins": ["prettier-plugin-tailwindcss"], + "tailwindConfig": "./tailwind.config.js", + + "tabWidth": 2, + "useTabs": false, + "bracketSpacing": true, + "arrowParens": "always", + "quoteProps": "as-needed", + "bracketSameLine": false, + "proseWrap": "preserve" +} diff --git a/web/admin-spa/README.md b/web/admin-spa/README.md new file mode 100644 index 0000000000000000000000000000000000000000..c66744294509d73f21b6ddcb183f83079cd8a819 --- /dev/null +++ b/web/admin-spa/README.md @@ -0,0 +1,147 @@ +# Claude Relay Service 管理后台 SPA + +这是 Claude Relay Service 管理后台的 Vue3 SPA 重构版本。 + +## 开发环境要求 + +- Node.js >= 16 +- npm >= 7 + +## 安装和运行 + +### 1. 安装依赖 + +```bash +cd web/admin-spa +npm install +``` + +### 2. 开发模式运行 + +```bash +npm run dev +``` + +**重要提示:** +- 开发服务器启动后,会自动在浏览器中打开 +- 必须访问完整路径:http://localhost:3001/web/admin/ +- 不要访问 http://localhost:3001/ (会显示404) +- 首次访问会自动跳转到登录页面 + +### 3. 生产构建 + +```bash +npm run build +``` + +构建产物将输出到 `dist` 目录。 + +### 4. 预览生产构建 + +```bash +npm run preview +``` + +## 项目结构 + +``` +web/admin-spa/ +├── public/ # 静态资源 +├── src/ +│ ├── api/ # API 接口封装 +│ ├── assets/ # 资源文件 +│ ├── components/ # 组件 +│ ├── composables/ # 组合式函数 +│ ├── router/ # 路由配置 +│ ├── stores/ # Pinia 状态管理 +│ ├── utils/ # 工具函数 +│ ├── views/ # 页面视图 +│ ├── App.vue # 根组件 +│ └── main.js # 入口文件 +├── package.json +└── vite.config.js +``` + +## 功能模块 + +- ✅ 登录认证 +- ✅ 仪表板(系统统计、使用趋势、模型分布) +- 🚧 API Keys 管理 +- 🚧 账户管理(Claude/Gemini) +- 🚧 使用教程 +- 🚧 系统设置 + +## 技术栈 + +- Vue 3.3.4 +- Vue Router 4 +- Pinia(状态管理) +- Element Plus 2.4.4 +- Tailwind CSS +- Chart.js 4.4.0 +- Vite 5 + +## 开发注意事项 + +1. 所有 API 请求都通过 `/api` 目录下的模块进行封装 +2. 状态管理使用 Pinia,存放在 `/stores` 目录 +3. 组件按功能模块组织在 `/components` 目录下 +4. 保持与原版页面的功能和样式一致性 + +## 代理配置 + +如果你的后端服务器需要通过代理访问(例如服务器在国外),可以配置 HTTP 代理: + +### 方法一:使用环境变量文件(推荐) + +创建 `.env.development.local` 文件: + +```bash +# 后端服务器地址 +VITE_API_TARGET=http://74.48.134.98:3000 + +# HTTP 代理配置 +VITE_HTTP_PROXY=http://127.0.0.1:7890 +``` + +### 方法二:使用系统环境变量 + +```bash +# Linux/Mac +export VITE_HTTP_PROXY=http://127.0.0.1:7890 +npm run dev + +# Windows +set VITE_HTTP_PROXY=http://127.0.0.1:7890 +npm run dev +``` + +注意:`.env.development.local` 文件不会被提交到版本控制,适合存放本地特定的配置。 + +## 部署 + +构建后的文件需要部署到 Claude Relay Service 的 `web/admin/` 路径下。 + +## 常见问题 + +### Q: 访问 localhost:3001 显示 404? +A: 这是正常的。应用配置在 `/web/admin/` 路径下,必须访问完整路径:http://localhost:3001/web/admin/ + +### Q: 登录时 API 请求失败(500错误)? +A: +1. **确保主服务运行**:Claude Relay Service 必须运行在 http://localhost:3000 +2. **检查代理配置**:Vite 会自动代理 `/admin` 和 `/api` 请求到 3000 端口 +3. **重启开发服务器**:如果修改了配置,需要重启 `npm run dev` +4. **测试代理**:运行 `node test-proxy.js` 检查代理是否正常工作 + +### Q: 如何处理开发和生产环境的 API 配置? +A: +- **开发环境**:使用 Vite 代理,自动转发请求到 localhost:3000 +- **生产环境**:直接使用相对路径 `/admin`,无需配置 +- 两种环境都使用相同的 API 路径,通过环境变量自动切换 + +### Q: 如何部署到生产环境? +A: +1. 运行 `npm run build` 构建项目 +2. 将 `dist` 目录内容复制到服务器的 `/web/admin/` 路径 +3. 确保服务器配置了 SPA 路由回退规则 \ No newline at end of file diff --git a/web/admin-spa/components/.gitkeep b/web/admin-spa/components/.gitkeep new file mode 100644 index 0000000000000000000000000000000000000000..0661b78ef5b3cfe0028300c41425f81e0a44297f --- /dev/null +++ b/web/admin-spa/components/.gitkeep @@ -0,0 +1 @@ +# This file keeps the empty directory in git \ No newline at end of file diff --git a/web/admin-spa/index.html b/web/admin-spa/index.html new file mode 100644 index 0000000000000000000000000000000000000000..27732008e77cb465a92c8825f9fdba8533ff1e79 --- /dev/null +++ b/web/admin-spa/index.html @@ -0,0 +1,29 @@ + + + + + + + Claude Relay Service - 管理后台 + + + + + + + + + + + + + + + + + +
+ + + + \ No newline at end of file diff --git a/web/admin-spa/package-lock.json b/web/admin-spa/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..7aa4b2e12fe71dce5f2ff33c980e8997f3e9b073 --- /dev/null +++ b/web/admin-spa/package-lock.json @@ -0,0 +1,5485 @@ +{ + "name": "claude-relay-admin-spa", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "claude-relay-admin-spa", + "version": "1.0.0", + "dependencies": { + "@fortawesome/fontawesome-free": "^6.5.1", + "axios": "^1.6.2", + "chart.js": "^4.4.0", + "dayjs": "^1.11.9", + "element-plus": "^2.4.4", + "pinia": "^2.1.7", + "vue": "^3.3.4", + "vue-router": "^4.2.5", + "xlsx": "^0.18.5", + "xlsx-js-style": "^1.2.0" + }, + "devDependencies": { + "@playwright/test": "^1.55.0", + "@vitejs/plugin-vue": "^4.5.2", + "@vue/eslint-config-prettier": "^10.2.0", + "autoprefixer": "^10.4.16", + "eslint": "^8.55.0", + "eslint-plugin-prettier": "^5.5.4", + "eslint-plugin-vue": "^9.19.2", + "playwright": "^1.55.0", + "postcss": "^8.4.32", + "prettier": "^3.1.1", + "prettier-plugin-tailwindcss": "^0.6.14", + "tailwindcss": "^3.3.6", + "unplugin-auto-import": "^0.17.2", + "unplugin-element-plus": "^0.8.0", + "unplugin-vue-components": "^0.26.0", + "vite": "^5.0.8", + "vite-plugin-checker": "^0.10.2" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@antfu/utils": { + "version": "0.7.10", + "resolved": "https://registry.npmmirror.com/@antfu/utils/-/utils-0.7.10.tgz", + "integrity": "sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/code-frame/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.28.0.tgz", + "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.2", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.28.2.tgz", + "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "3.6.1", + "resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz", + "integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.1.tgz", + "integrity": "sha512-XxVUZv48RZAd87ucGS48jPf6pKu0yV5UCg9f4FFwtrYxXOwWuVJo6wOvSLKEoMQKjv8GsX/mhP6UsC1lRwbUWg==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.7.0", + "resolved": "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmmirror.com/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmmirror.com/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmmirror.com/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.2", + "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.2.tgz", + "integrity": "sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.2", + "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.2.tgz", + "integrity": "sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.2", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@fortawesome/fontawesome-free": { + "version": "6.7.2", + "resolved": "https://registry.npmmirror.com/@fortawesome/fontawesome-free/-/fontawesome-free-6.7.2.tgz", + "integrity": "sha512-JUOtgFW6k9u4Y+xeIaEiLr3+cjoUPiAuLXoyKOJSia6Duzb7pq+A76P9ZdPDoAoxHdHzq6gE9/jKBGXlZT8FbA==", + "license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)", + "engines": { + "node": ">=6" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmmirror.com/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmmirror.com/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.12", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", + "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.4", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", + "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.29", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", + "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmmirror.com/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmmirror.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmmirror.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@playwright/test": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0.tgz", + "integrity": "sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.55.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.7", + "resolved": "https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.7.tgz", + "integrity": "sha512-Ccy0NlLkzr0Ex2FKvh2X+OyERHXJ88XJ1MXtsI9y9fGexlaXaVTPzBCRBwIxFkORuOb+uBqeu+RqnpgYTEZRUQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/@rollup/pluginutils/-/pluginutils-5.2.0.tgz", + "integrity": "sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.46.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.46.1.tgz", + "integrity": "sha512-oENme6QxtLCqjChRUUo3S6X8hjCXnWmJWnedD7VbGML5GUtaOtAyx+fEEXnBXVf0CBZApMQU0Idwi0FmyxzQhw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.46.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.46.1.tgz", + "integrity": "sha512-OikvNT3qYTl9+4qQ9Bpn6+XHM+ogtFadRLuT2EXiFQMiNkXFLQfNVppi5o28wvYdHL2s3fM0D/MZJ8UkNFZWsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.46.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.46.1.tgz", + "integrity": "sha512-EFYNNGij2WllnzljQDQnlFTXzSJw87cpAs4TVBAWLdkvic5Uh5tISrIL6NRcxoh/b2EFBG/TK8hgRrGx94zD4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.46.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.46.1.tgz", + "integrity": "sha512-ZaNH06O1KeTug9WI2+GRBE5Ujt9kZw4a1+OIwnBHal92I8PxSsl5KpsrPvthRynkhMck4XPdvY0z26Cym/b7oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.46.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.46.1.tgz", + "integrity": "sha512-n4SLVebZP8uUlJ2r04+g2U/xFeiQlw09Me5UFqny8HGbARl503LNH5CqFTb5U5jNxTouhRjai6qPT0CR5c/Iig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.46.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.46.1.tgz", + "integrity": "sha512-8vu9c02F16heTqpvo3yeiu7Vi1REDEC/yES/dIfq3tSXe6mLndiwvYr3AAvd1tMNUqE9yeGYa5w7PRbI5QUV+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.46.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.46.1.tgz", + "integrity": "sha512-K4ncpWl7sQuyp6rWiGUvb6Q18ba8mzM0rjWJ5JgYKlIXAau1db7hZnR0ldJvqKWWJDxqzSLwGUhA4jp+KqgDtQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.46.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.46.1.tgz", + "integrity": "sha512-YykPnXsjUjmXE6j6k2QBBGAn1YsJUix7pYaPLK3RVE0bQL2jfdbfykPxfF8AgBlqtYbfEnYHmLXNa6QETjdOjQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.46.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.46.1.tgz", + "integrity": "sha512-kKvqBGbZ8i9pCGW3a1FH3HNIVg49dXXTsChGFsHGXQaVJPLA4f/O+XmTxfklhccxdF5FefUn2hvkoGJH0ScWOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.46.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.46.1.tgz", + "integrity": "sha512-zzX5nTw1N1plmqC9RGC9vZHFuiM7ZP7oSWQGqpbmfjK7p947D518cVK1/MQudsBdcD84t6k70WNczJOct6+hdg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.46.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.46.1.tgz", + "integrity": "sha512-O8CwgSBo6ewPpktFfSDgB6SJN9XDcPSvuwxfejiddbIC/hn9Tg6Ai0f0eYDf3XvB/+PIWzOQL+7+TZoB8p9Yuw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.46.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.46.1.tgz", + "integrity": "sha512-JnCfFVEKeq6G3h3z8e60kAp8Rd7QVnWCtPm7cxx+5OtP80g/3nmPtfdCXbVl063e3KsRnGSKDHUQMydmzc/wBA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.46.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.46.1.tgz", + "integrity": "sha512-dVxuDqS237eQXkbYzQQfdf/njgeNw6LZuVyEdUaWwRpKHhsLI+y4H/NJV8xJGU19vnOJCVwaBFgr936FHOnJsQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.46.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.46.1.tgz", + "integrity": "sha512-CvvgNl2hrZrTR9jXK1ye0Go0HQRT6ohQdDfWR47/KFKiLd5oN5T14jRdUVGF4tnsN8y9oSfMOqH6RuHh+ck8+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.46.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.46.1.tgz", + "integrity": "sha512-x7ANt2VOg2565oGHJ6rIuuAon+A8sfe1IeUx25IKqi49OjSr/K3awoNqr9gCwGEJo9OuXlOn+H2p1VJKx1psxA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.46.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.46.1.tgz", + "integrity": "sha512-9OADZYryz/7E8/qt0vnaHQgmia2Y0wrjSSn1V/uL+zw/i7NUhxbX4cHXdEQ7dnJgzYDS81d8+tf6nbIdRFZQoQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.46.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.46.1.tgz", + "integrity": "sha512-NuvSCbXEKY+NGWHyivzbjSVJi68Xfq1VnIvGmsuXs6TCtveeoDRKutI5vf2ntmNnVq64Q4zInet0UDQ+yMB6tA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.46.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.46.1.tgz", + "integrity": "sha512-mWz+6FSRb82xuUMMV1X3NGiaPFqbLN9aIueHleTZCc46cJvwTlvIh7reQLk4p97dv0nddyewBhwzryBHH7wtPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.46.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.46.1.tgz", + "integrity": "sha512-7Thzy9TMXDw9AU4f4vsLNBxh7/VOKuXi73VH3d/kHGr0tZ3x/ewgL9uC7ojUKmH1/zvmZe2tLapYcZllk3SO8Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.20", + "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.16", + "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.16.tgz", + "integrity": "sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "4.6.2", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-4.6.2.tgz", + "integrity": "sha512-kqf7SGFoG+80aZG6Pf+gsZIVvGSCKE98JbiWqcCV9cThtg91Jav0yvYFC9Zb+jKetNGF6ZKeoaxgZfND21fWKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.0.0 || ^5.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.18", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.18.tgz", + "integrity": "sha512-3slwjQrrV1TO8MoXgy3aynDQ7lslj5UqDxuHnrzHtpON5CBinhWjJETciPngpin/T3OuW3tXUf86tEurusnztw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.0", + "@vue/shared": "3.5.18", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.18", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.18.tgz", + "integrity": "sha512-RMbU6NTU70++B1JyVJbNbeFkK+A+Q7y9XKE2EM4NLGm2WFR8x9MbAtWxPPLdm0wUkuZv9trpwfSlL6tjdIa1+A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.18", + "@vue/shared": "3.5.18" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.18", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.18.tgz", + "integrity": "sha512-5aBjvGqsWs+MoxswZPoTB9nSDb3dhd1x30xrrltKujlCxo48j8HGDNj3QPhF4VIS0VQDUrA1xUfp2hEa+FNyXA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.0", + "@vue/compiler-core": "3.5.18", + "@vue/compiler-dom": "3.5.18", + "@vue/compiler-ssr": "3.5.18", + "@vue/shared": "3.5.18", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.17", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.18", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.18.tgz", + "integrity": "sha512-xM16Ak7rSWHkM3m22NlmcdIM+K4BMyFARAfV9hYFl+SFuRzrZ3uGMNW05kA5pmeMa0X9X963Kgou7ufdbpOP9g==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.18", + "@vue/shared": "3.5.18" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/eslint-config-prettier": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@vue/eslint-config-prettier/-/eslint-config-prettier-10.2.0.tgz", + "integrity": "sha512-GL3YBLwv/+b86yHcNNfPJxOTtVFJ4Mbc9UU3zR+KVoG7SwGTjPT+32fXamscNumElhcpXW3mT0DgzS9w32S7Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-prettier": "^5.2.2" + }, + "peerDependencies": { + "eslint": ">= 8.21.0", + "prettier": ">= 3.0.0" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.18", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.18.tgz", + "integrity": "sha512-x0vPO5Imw+3sChLM5Y+B6G1zPjwdOri9e8V21NnTnlEvkxatHEH5B5KEAJcjuzQ7BsjGrKtfzuQ5eQwXh8HXBg==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.18" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.18", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.18.tgz", + "integrity": "sha512-DUpHa1HpeOQEt6+3nheUfqVXRog2kivkXHUhoqJiKR33SO4x+a5uNOMkV487WPerQkL0vUuRvq/7JhRgLW3S+w==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.18", + "@vue/shared": "3.5.18" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.18", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.18.tgz", + "integrity": "sha512-YwDj71iV05j4RnzZnZtGaXwPoUWeRsqinblgVJwR8XTXYZ9D5PbahHQgsbmzUvCWNF6x7siQ89HgnX5eWkr3mw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.18", + "@vue/runtime-core": "3.5.18", + "@vue/shared": "3.5.18", + "csstype": "^3.1.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.18", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.18.tgz", + "integrity": "sha512-PvIHLUoWgSbDG7zLHqSqaCoZvHi6NNmfVFOqO+OnwvqMz/tqQr3FuGWS8ufluNddk7ZLBJYMrjcw1c6XzR12mA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.18", + "@vue/shared": "3.5.18" + }, + "peerDependencies": { + "vue": "3.5.18" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.18", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.18.tgz", + "integrity": "sha512-cZy8Dq+uuIXbxCZpuLd2GJdeSO/lIzIspC2WtkqIpje5QyFbvLaI5wZtdUjLHjGZrlVX6GilejatWwVYYRc8tA==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "9.13.0", + "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-9.13.0.tgz", + "integrity": "sha512-pujnclbeHWxxPRqXWmdkKV5OX4Wk4YeK7wusHqRwU0Q7EFusHoqNA/aPhB6KCh9hEqJkLAJo7bb0Lh9b+OIVzw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.16", + "@vueuse/metadata": "9.13.0", + "@vueuse/shared": "9.13.0", + "vue-demi": "*" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/metadata": { + "version": "9.13.0", + "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-9.13.0.tgz", + "integrity": "sha512-gdU7TKNAUVlXXLbaF+ZCfte8BjRJQWPCa2J55+7/h+yDtzw3vOoGQDRXzI6pyKyo6bXFT5/QoPE4hAknExjRLQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "9.13.0", + "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-9.13.0.tgz", + "integrity": "sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==", + "license": "MIT", + "dependencies": { + "vue-demi": "*" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmmirror.com/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.21", + "resolved": "https://registry.npmmirror.com/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.24.4", + "caniuse-lite": "^1.0.30001702", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.11.0", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.11.0.tgz", + "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.25.1", + "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.25.1.tgz", + "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001726", + "electron-to-chromium": "^1.5.173", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001727", + "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", + "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chart.js": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/chart.js/-/chart.js-4.5.0.tgz", + "integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.191", + "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.191.tgz", + "integrity": "sha512-xcwe9ELcuxYLUFqZZxL19Z6HVKcvNkIwhbHUz7L3us6u12yR+7uY89dSl570f/IqNthx8dAw3tojG7i4Ni4tDA==", + "dev": true, + "license": "ISC" + }, + "node_modules/element-plus": { + "version": "2.10.4", + "resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.10.4.tgz", + "integrity": "sha512-UD4elWHrCnp1xlPhbXmVcaKFLCRaRAY6WWRwemGfGW3ceIjXm9fSYc9RNH3AiOEA6Ds1p9ZvhCs76CR9J8Vd+A==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^3.4.1", + "@element-plus/icons-vue": "^2.3.1", + "@floating-ui/dom": "^1.0.1", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7", + "@types/lodash": "^4.14.182", + "@types/lodash-es": "^4.17.6", + "@vueuse/core": "^9.1.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.13", + "escape-html": "^1.0.3", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "lodash-unified": "^1.0.2", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0" + }, + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/esbuild/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmmirror.com/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.4.tgz", + "integrity": "sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.0", + "synckit": "^0.11.7" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-vue": { + "version": "9.33.0", + "resolved": "https://registry.npmmirror.com/eslint-plugin-vue/-/eslint-plugin-vue-9.33.0.tgz", + "integrity": "sha512-174lJKuNsuDIlLpjeXc5E2Tss8P44uIimAfGD0b90k0NoirJqpG7stLuU9Vp/9ioTOrQdWVREc4mRd1BD+CvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "globals": "^13.24.0", + "natural-compare": "^1.4.0", + "nth-check": "^2.1.1", + "postcss-selector-parser": "^6.0.15", + "semver": "^7.6.3", + "vue-eslint-parser": "^9.4.3", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": "^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.2.0 || ^7.0.0 || ^8.0.0 || ^9.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmmirror.com/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmmirror.com/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/exit-on-epipe": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz", + "integrity": "sha512-h2z5mrROTxce56S+pnvAV890uu7ls7f1kEvVGJbw1OlFH3/mlJ5bkXu0KRyW94v37zzHPiUd55iLn3DA7TjWpw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/exsolve": { + "version": "1.0.7", + "resolved": "https://registry.npmmirror.com/exsolve/-/exsolve-1.0.7.tgz", + "integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fflate": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.3.11.tgz", + "integrity": "sha512-Rr5QlUeGN1mbOHlaqcSYMKVpPbgLy0AWT/W0EHxA6NGI12yO1jpoui2zBBvU2G824ltM6Ut8BFgfHSBGfkmS0A==", + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmmirror.com/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmmirror.com/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmmirror.com/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmmirror.com/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmmirror.com/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmmirror.com/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmmirror.com/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmmirror.com/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/magic-string": { + "version": "0.30.17", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmmirror.com/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mlly": { + "version": "1.7.4", + "resolved": "https://registry.npmmirror.com/mlly/-/mlly-1.7.4.tgz", + "integrity": "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.14.0", + "pathe": "^2.0.1", + "pkg-types": "^1.3.0", + "ufo": "^1.5.4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmmirror.com/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmmirror.com/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmmirror.com/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/playwright": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0.tgz", + "integrity": "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.55.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz", + "integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmmirror.com/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmmirror.com/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", + "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/prettier-plugin-tailwindcss": { + "version": "0.6.14", + "resolved": "https://registry.npmmirror.com/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz", + "integrity": "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.21.3" + }, + "peerDependencies": { + "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-hermes": "*", + "@prettier/plugin-oxc": "*", + "@prettier/plugin-pug": "*", + "@shopify/prettier-plugin-liquid": "*", + "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig": "*", + "prettier": "^3.0", + "prettier-plugin-astro": "*", + "prettier-plugin-css-order": "*", + "prettier-plugin-import-sort": "*", + "prettier-plugin-jsdoc": "*", + "prettier-plugin-marko": "*", + "prettier-plugin-multiline-arrays": "*", + "prettier-plugin-organize-attributes": "*", + "prettier-plugin-organize-imports": "*", + "prettier-plugin-sort-imports": "*", + "prettier-plugin-style-order": "*", + "prettier-plugin-svelte": "*" + }, + "peerDependenciesMeta": { + "@ianvs/prettier-plugin-sort-imports": { + "optional": true + }, + "@prettier/plugin-hermes": { + "optional": true + }, + "@prettier/plugin-oxc": { + "optional": true + }, + "@prettier/plugin-pug": { + "optional": true + }, + "@shopify/prettier-plugin-liquid": { + "optional": true + }, + "@trivago/prettier-plugin-sort-imports": { + "optional": true + }, + "@zackad/prettier-plugin-twig": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-css-order": { + "optional": true + }, + "prettier-plugin-import-sort": { + "optional": true + }, + "prettier-plugin-jsdoc": { + "optional": true + }, + "prettier-plugin-marko": { + "optional": true + }, + "prettier-plugin-multiline-arrays": { + "optional": true + }, + "prettier-plugin-organize-attributes": { + "optional": true + }, + "prettier-plugin-organize-imports": { + "optional": true + }, + "prettier-plugin-sort-imports": { + "optional": true + }, + "prettier-plugin-style-order": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + } + } + }, + "node_modules/printj": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/printj/-/printj-1.1.2.tgz", + "integrity": "sha512-zA2SmoLaxZyArQTOPj5LXecR+RagfPSU5Kw1qP+jkWeNlrq+eJZyY2oS68SU1Z/7/myXM4lo9716laOFAVStCQ==", + "license": "Apache-2.0", + "bin": { + "printj": "bin/printj.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/quansync": { + "version": "0.2.10", + "resolved": "https://registry.npmmirror.com/quansync/-/quansync-0.2.10.tgz", + "integrity": "sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "4.46.1", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.46.1.tgz", + "integrity": "sha512-33xGNBsDJAkzt0PvninskHlWnTIPgDtTwhg0U38CUoNP/7H6wI2Cz6dUeoNPbjdTdsYTGuiFFASuUOWovH0SyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.46.1", + "@rollup/rollup-android-arm64": "4.46.1", + "@rollup/rollup-darwin-arm64": "4.46.1", + "@rollup/rollup-darwin-x64": "4.46.1", + "@rollup/rollup-freebsd-arm64": "4.46.1", + "@rollup/rollup-freebsd-x64": "4.46.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.46.1", + "@rollup/rollup-linux-arm-musleabihf": "4.46.1", + "@rollup/rollup-linux-arm64-gnu": "4.46.1", + "@rollup/rollup-linux-arm64-musl": "4.46.1", + "@rollup/rollup-linux-loongarch64-gnu": "4.46.1", + "@rollup/rollup-linux-ppc64-gnu": "4.46.1", + "@rollup/rollup-linux-riscv64-gnu": "4.46.1", + "@rollup/rollup-linux-riscv64-musl": "4.46.1", + "@rollup/rollup-linux-s390x-gnu": "4.46.1", + "@rollup/rollup-linux-x64-gnu": "4.46.1", + "@rollup/rollup-linux-x64-musl": "4.46.1", + "@rollup/rollup-win32-arm64-msvc": "4.46.1", + "@rollup/rollup-win32-ia32-msvc": "4.46.1", + "@rollup/rollup-win32-x64-msvc": "4.46.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.46.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.46.1.tgz", + "integrity": "sha512-7GVB4luhFmGUNXXJhH2jJwZCFB3pIOixv2E3s17GQHBFUOQaISlt7aGcQgqvCaDSxTZJUzlK/QJ1FN8S94MrzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scule": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmmirror.com/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/sucrase/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmmirror.com/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sucrase/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/synckit": { + "version": "0.11.11", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", + "integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.9" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.17", + "resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-3.4.17.tgz", + "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.6", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmmirror.com/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", + "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", + "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmmirror.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ufo": { + "version": "1.6.1", + "resolved": "https://registry.npmmirror.com/ufo/-/ufo-1.6.1.tgz", + "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unimport": { + "version": "3.14.6", + "resolved": "https://registry.npmmirror.com/unimport/-/unimport-3.14.6.tgz", + "integrity": "sha512-CYvbDaTT04Rh8bmD8jz3WPmHYZRG/NnvYVzwD6V1YAlvvKROlAeNDUBhkBGzNav2RKaeuXvlWYaa1V4Lfi/O0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.4", + "acorn": "^8.14.0", + "escape-string-regexp": "^5.0.0", + "estree-walker": "^3.0.3", + "fast-glob": "^3.3.3", + "local-pkg": "^1.0.0", + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "pathe": "^2.0.1", + "picomatch": "^4.0.2", + "pkg-types": "^1.3.0", + "scule": "^1.3.0", + "strip-literal": "^2.1.1", + "unplugin": "^1.16.1" + } + }, + "node_modules/unimport/node_modules/confbox": { + "version": "0.2.2", + "resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.2.2.tgz", + "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unimport/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unimport/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/unimport/node_modules/local-pkg": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-1.1.1.tgz", + "integrity": "sha512-WunYko2W1NcdfAFpuLUoucsgULmgDBRkdxHxWQ7mK0cQqwPiy8E1enjuRBrhLtZkB5iScJ1XIPdhVEFK8aOLSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.4", + "pkg-types": "^2.0.1", + "quansync": "^0.2.8" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/unimport/node_modules/local-pkg/node_modules/pkg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-2.2.0.tgz", + "integrity": "sha512-2SM/GZGAEkPp3KWORxQZns4M+WSeXbC2HEvmOIJe3Cmiv6ieAJvdVhDldtHqM5J1Y7MrR1XhkBT/rMlhh9FdqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, + "node_modules/unimport/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/unplugin": { + "version": "1.16.1", + "resolved": "https://registry.npmmirror.com/unplugin/-/unplugin-1.16.1.tgz", + "integrity": "sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.14.0", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/unplugin-auto-import": { + "version": "0.17.8", + "resolved": "https://registry.npmmirror.com/unplugin-auto-import/-/unplugin-auto-import-0.17.8.tgz", + "integrity": "sha512-CHryj6HzJ+n4ASjzwHruD8arhbdl+UXvhuAIlHDs15Y/IMecG3wrf7FVg4pVH/DIysbq/n0phIjNHAjl7TG7Iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@antfu/utils": "^0.7.10", + "@rollup/pluginutils": "^5.1.0", + "fast-glob": "^3.3.2", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.10", + "minimatch": "^9.0.4", + "unimport": "^3.7.2", + "unplugin": "^1.11.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@nuxt/kit": "^3.2.2", + "@vueuse/core": "*" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + }, + "@vueuse/core": { + "optional": true + } + } + }, + "node_modules/unplugin-auto-import/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/unplugin-auto-import/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/unplugin-element-plus": { + "version": "0.8.0", + "resolved": "https://registry.npmmirror.com/unplugin-element-plus/-/unplugin-element-plus-0.8.0.tgz", + "integrity": "sha512-jByUGY3FG2B8RJKFryqxx4eNtSTj+Hjlo8edcOdJymewndDQjThZ1pRUQHRjQsbKhTV2jEctJV7t7RJ405UL4g==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^5.0.2", + "es-module-lexer": "^1.3.0", + "magic-string": "^0.30.1", + "unplugin": "^1.3.2" + }, + "engines": { + "node": ">=14.19.0" + } + }, + "node_modules/unplugin-vue-components": { + "version": "0.26.0", + "resolved": "https://registry.npmmirror.com/unplugin-vue-components/-/unplugin-vue-components-0.26.0.tgz", + "integrity": "sha512-s7IdPDlnOvPamjunVxw8kNgKNK8A5KM1YpK5j/p97jEKTjlPNrA0nZBiSfAKKlK1gWZuyWXlKL5dk3EDw874LQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@antfu/utils": "^0.7.6", + "@rollup/pluginutils": "^5.0.4", + "chokidar": "^3.5.3", + "debug": "^4.3.4", + "fast-glob": "^3.3.1", + "local-pkg": "^0.4.3", + "magic-string": "^0.30.3", + "minimatch": "^9.0.3", + "resolve": "^1.22.4", + "unplugin": "^1.4.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@babel/parser": "^7.15.8", + "@nuxt/kit": "^3.2.2", + "vue": "2 || 3" + }, + "peerDependenciesMeta": { + "@babel/parser": { + "optional": true + }, + "@nuxt/kit": { + "optional": true + } + } + }, + "node_modules/unplugin-vue-components/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/unplugin-vue-components/node_modules/local-pkg": { + "version": "0.4.3", + "resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-0.4.3.tgz", + "integrity": "sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/unplugin-vue-components/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.19", + "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.19.tgz", + "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-plugin-checker": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/vite-plugin-checker/-/vite-plugin-checker-0.10.2.tgz", + "integrity": "sha512-FX9U8TnIS6AGOlqmC6O2YmkJzcZJRrjA03UF7FOhcUJ7it3HmCoxcIPMcoHliBP6EFOuNzle9K4c0JL4suRPow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "chokidar": "^4.0.3", + "npm-run-path": "^6.0.0", + "picocolors": "^1.1.1", + "picomatch": "^4.0.3", + "strip-ansi": "^7.1.0", + "tiny-invariant": "^1.3.3", + "tinyglobby": "^0.2.14", + "vscode-uri": "^3.1.0" + }, + "engines": { + "node": ">=14.16" + }, + "peerDependencies": { + "@biomejs/biome": ">=1.7", + "eslint": ">=7", + "meow": "^13.2.0", + "optionator": "^0.9.4", + "stylelint": ">=16", + "typescript": "*", + "vite": ">=2.0.0", + "vls": "*", + "vti": "*", + "vue-tsc": "~2.2.10 || ^3.0.0" + }, + "peerDependenciesMeta": { + "@biomejs/biome": { + "optional": true + }, + "eslint": { + "optional": true + }, + "meow": { + "optional": true + }, + "optionator": { + "optional": true + }, + "stylelint": { + "optional": true + }, + "typescript": { + "optional": true + }, + "vls": { + "optional": true + }, + "vti": { + "optional": true + }, + "vue-tsc": { + "optional": true + } + } + }, + "node_modules/vite-plugin-checker/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/vite-plugin-checker/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/vite-plugin-checker/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vite-plugin-checker/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/vite-plugin-checker/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.18", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.18.tgz", + "integrity": "sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.18", + "@vue/compiler-sfc": "3.5.18", + "@vue/runtime-dom": "3.5.18", + "@vue/server-renderer": "3.5.18", + "@vue/shared": "3.5.18" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-eslint-parser": { + "version": "9.4.3", + "resolved": "https://registry.npmmirror.com/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz", + "integrity": "sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "eslint-scope": "^7.1.1", + "eslint-visitor-keys": "^3.3.0", + "espree": "^9.3.1", + "esquery": "^1.4.0", + "lodash": "^4.17.21", + "semver": "^7.3.6" + }, + "engines": { + "node": "^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": ">=6.0.0" + } + }, + "node_modules/vue-router": { + "version": "4.5.1", + "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.5.1.tgz", + "integrity": "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmmirror.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/xlsx-js-style": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/xlsx-js-style/-/xlsx-js-style-1.2.0.tgz", + "integrity": "sha512-DDT4FXFSWfT4DXMSok/m3TvmP1gvO3dn0Eu/c+eXHW5Kzmp7IczNkxg/iEPnImbG9X0Vb8QhROda5eatSR/97Q==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.2.0", + "cfb": "^1.1.4", + "codepage": "~1.14.0", + "commander": "~2.17.1", + "crc-32": "~1.2.0", + "exit-on-epipe": "~1.0.1", + "fflate": "^0.3.8", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/xlsx-js-style/node_modules/adler-32": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.2.0.tgz", + "integrity": "sha512-/vUqU/UY4MVeFsg+SsK6c+/05RZXIHZMGJA+PX5JyWI0ZRcBpupnRuPLU/NXXoFwMYCPCoxIfElM2eS+DUXCqQ==", + "license": "Apache-2.0", + "dependencies": { + "exit-on-epipe": "~1.0.1", + "printj": "~1.1.0" + }, + "bin": { + "adler32": "bin/adler32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/xlsx-js-style/node_modules/codepage": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.14.0.tgz", + "integrity": "sha512-iz3zJLhlrg37/gYRWgEPkaFTtzmnEv1h+r7NgZum2lFElYQPi0/5bnmuDfODHxfp0INEfnRqyfyeIJDbb7ahRw==", + "license": "Apache-2.0", + "dependencies": { + "commander": "~2.14.1", + "exit-on-epipe": "~1.0.1" + }, + "bin": { + "codepage": "bin/codepage.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/xlsx-js-style/node_modules/codepage/node_modules/commander": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.14.1.tgz", + "integrity": "sha512-+YR16o3rK53SmWHU3rEM3tPAh2rwb1yPcQX5irVn7mb0gXbwuCCrnkbV5+PBfETdfg1vui07nM6PCG1zndcjQw==", + "license": "MIT" + }, + "node_modules/xlsx-js-style/node_modules/commander": { + "version": "2.17.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.17.1.tgz", + "integrity": "sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==", + "license": "MIT" + }, + "node_modules/xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12" + } + }, + "node_modules/yaml": { + "version": "2.8.0", + "resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.8.0.tgz", + "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/web/admin-spa/package.json b/web/admin-spa/package.json new file mode 100644 index 0000000000000000000000000000000000000000..af353d806522643106b80a8b5ce142f159bc9e5b --- /dev/null +++ b/web/admin-spa/package.json @@ -0,0 +1,44 @@ +{ + "name": "claude-relay-admin-spa", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore", + "format": "prettier --write src/" + }, + "dependencies": { + "@fortawesome/fontawesome-free": "^6.5.1", + "axios": "^1.6.2", + "chart.js": "^4.4.0", + "dayjs": "^1.11.9", + "element-plus": "^2.4.4", + "pinia": "^2.1.7", + "vue": "^3.3.4", + "vue-router": "^4.2.5", + "xlsx": "^0.18.5", + "xlsx-js-style": "^1.2.0" + }, + "devDependencies": { + "@playwright/test": "^1.55.0", + "@vitejs/plugin-vue": "^4.5.2", + "@vue/eslint-config-prettier": "^10.2.0", + "autoprefixer": "^10.4.16", + "eslint": "^8.55.0", + "eslint-plugin-prettier": "^5.5.4", + "eslint-plugin-vue": "^9.19.2", + "playwright": "^1.55.0", + "postcss": "^8.4.32", + "prettier": "^3.1.1", + "prettier-plugin-tailwindcss": "^0.6.14", + "tailwindcss": "^3.3.6", + "unplugin-auto-import": "^0.17.2", + "unplugin-element-plus": "^0.8.0", + "unplugin-vue-components": "^0.26.0", + "vite": "^5.0.8", + "vite-plugin-checker": "^0.10.2" + } +} diff --git a/web/admin-spa/postcss.config.js b/web/admin-spa/postcss.config.js new file mode 100644 index 0000000000000000000000000000000000000000..2b75bd8a7e544d376bfb9e8b80b604c1beb2e466 --- /dev/null +++ b/web/admin-spa/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +} diff --git a/web/admin-spa/src/App.vue b/web/admin-spa/src/App.vue new file mode 100644 index 0000000000000000000000000000000000000000..c68bfba7a415ca7eb0945e581fce1b35f8c5c7f9 --- /dev/null +++ b/web/admin-spa/src/App.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/web/admin-spa/src/assets/styles/components.css b/web/admin-spa/src/assets/styles/components.css new file mode 100644 index 0000000000000000000000000000000000000000..9e00227d5013d095fa9e3c68bba8eddd12597887 --- /dev/null +++ b/web/admin-spa/src/assets/styles/components.css @@ -0,0 +1,504 @@ +/* Glass效果 - 优化版 */ +.glass { + background: var(--glass-color); + /* 降低模糊强度 */ + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border: 1px solid var(--border-color); + box-shadow: + 0 4px 6px -1px rgba(0, 0, 0, 0.1), + 0 2px 4px -1px rgba(0, 0, 0, 0.06); +} + +.glass-strong { + background: var(--surface-color); + /* 降低模糊强度 */ + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.2); + box-shadow: + 0 10px 15px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -2px rgba(0, 0, 0, 0.05); +} + +/* 标签按钮 */ +.tab-btn { + position: relative; + overflow: hidden; + border-radius: 12px; + font-weight: 500; + letter-spacing: 0.025em; +} + +.tab-btn::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); + transition: left 0.5s; +} + +.tab-btn:hover::before { + left: 100%; +} + +.tab-btn.active { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); + color: white; + box-shadow: + 0 10px 15px -3px rgba(102, 126, 234, 0.3), + 0 4px 6px -2px rgba(102, 126, 234, 0.05); + transform: translateY(-1px); +} + +/* 卡片 */ +.card { + background: var(--surface-color); + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.2); + box-shadow: + 0 10px 15px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -2px rgba(0, 0, 0, 0.05); + overflow: hidden; + position: relative; +} + +.card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.5), transparent); +} + +/* 统计卡片 */ +.stat-card { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0.8) 100%); + border-radius: 20px; + border: 1px solid rgba(255, 255, 255, 0.3); + padding: 24px; + position: relative; + overflow: hidden; + transition: all 0.3s ease; +} + +.stat-card::before { + content: ''; + position: absolute; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%); + opacity: 0; + transition: opacity 0.3s ease; +} + +.stat-card:hover { + transform: translateY(-4px); + box-shadow: + 0 20px 25px -5px rgba(0, 0, 0, 0.1), + 0 10px 10px -5px rgba(0, 0, 0, 0.04); +} + +.stat-card:hover::before { + opacity: 1; +} + +.stat-icon { + width: 56px; + height: 56px; + border-radius: 16px; + display: flex; + align-items: center; + justify-content: center; + font-size: 24px; + color: white; + box-shadow: + 0 10px 15px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -2px rgba(0, 0, 0, 0.05); +} + +/* 按钮 */ +.btn { + font-weight: 500; + border-radius: 12px; + border: none; + cursor: pointer; + transition: all 0.3s ease; + position: relative; + overflow: hidden; + letter-spacing: 0.025em; +} + +.btn::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + background: rgba(255, 255, 255, 0.2); + border-radius: 50%; + transform: translate(-50%, -50%); + transition: + width 0.3s ease, + height 0.3s ease; +} + +.btn:active::before { + width: 300px; + height: 300px; +} + +.btn-primary { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); + color: white; + box-shadow: + 0 10px 15px -3px rgba(102, 126, 234, 0.3), + 0 4px 6px -2px rgba(102, 126, 234, 0.05); +} + +.btn-primary:hover { + transform: translateY(-1px); + box-shadow: + 0 20px 25px -5px rgba(102, 126, 234, 0.3), + 0 10px 10px -5px rgba(102, 126, 234, 0.1); +} + +.btn-success { + background: linear-gradient(135deg, var(--success-color) 0%, #059669 100%); + color: white; + box-shadow: + 0 10px 15px -3px rgba(16, 185, 129, 0.3), + 0 4px 6px -2px rgba(16, 185, 129, 0.05); +} + +.btn-success:hover { + transform: translateY(-1px); + box-shadow: + 0 20px 25px -5px rgba(16, 185, 129, 0.3), + 0 10px 10px -5px rgba(16, 185, 129, 0.1); +} + +.btn-danger { + background: linear-gradient(135deg, var(--error-color) 0%, #dc2626 100%); + color: white; + box-shadow: + 0 10px 15px -3px rgba(239, 68, 68, 0.3), + 0 4px 6px -2px rgba(239, 68, 68, 0.05); +} + +.btn-danger:hover { + transform: translateY(-1px); + box-shadow: + 0 20px 25px -5px rgba(239, 68, 68, 0.3), + 0 10px 10px -5px rgba(239, 68, 68, 0.1); +} + +.btn-secondary { + background: linear-gradient(135deg, #6b7280 0%, #4b5563 100%); + color: white; + box-shadow: + 0 10px 15px -3px rgba(107, 114, 128, 0.3), + 0 4px 6px -2px rgba(107, 114, 128, 0.05); +} + +.btn-secondary:hover { + transform: translateY(-1px); + box-shadow: + 0 20px 25px -5px rgba(107, 114, 128, 0.3), + 0 10px 10px -5px rgba(107, 114, 128, 0.1); +} + +/* 表单输入 */ +.form-input { + background: rgba(255, 255, 255, 0.95); + border: 2px solid rgba(255, 255, 255, 0.3); + border-radius: 12px; + padding: 8px 12px; + font-size: 14px; + transition: all 0.2s ease; + /* 移除模糊效果,使用纯色背景 */ +} + +.form-input:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: + 0 0 0 3px rgba(102, 126, 234, 0.1), + 0 10px 15px -3px rgba(0, 0, 0, 0.1); + background: rgba(255, 255, 255, 0.95); +} + +/* 表格容器 */ +.table-container { + background: rgba(255, 255, 255, 0.95); + border-radius: 16px; + overflow: hidden; + box-shadow: + 0 10px 15px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -2px rgba(0, 0, 0, 0.05); +} + +.table-row { + transition: all 0.2s ease; +} + +.table-row:hover { + background: rgba(102, 126, 234, 0.05); + transform: scale(1.005); +} + +/* 模态框 */ +.modal { + /* 移除模糊,使用半透明背景 */ + background: rgba(0, 0, 0, 0.6); +} + +.dark .modal { + background: rgba(0, 0, 0, 0.75); +} + +.modal-content { + background: rgba(255, 255, 255, 0.98); + border-radius: 24px; + border: 1px solid rgba(255, 255, 255, 0.3); + box-shadow: + 0 10px 25px -5px rgba(0, 0, 0, 0.15), + 0 0 0 1px rgba(255, 255, 255, 0.05); + /* 移除模糊效果 */ +} + +.dark .modal-content { + background: rgba(17, 24, 39, 0.95); + border: 1px solid rgba(75, 85, 99, 0.3); + box-shadow: + 0 10px 25px -5px rgba(0, 0, 0, 0.3), + 0 0 0 1px rgba(255, 255, 255, 0.05); +} + +/* 弹窗滚动内容样式 */ +.modal-scroll-content { + max-height: calc(90vh - 160px); + overflow-y: auto; + padding-right: 8px; +} + +/* 标题渐变 */ +.header-title { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + font-weight: 700; + letter-spacing: -0.025em; +} + +/* 加载动画 */ +.loading-spinner { + display: inline-block; + width: 20px; + height: 20px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-radius: 50%; + border-top: 2px solid white; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +/* Toast通知 */ +.toast { + position: fixed; + top: 80px; + right: 20px; + z-index: 1000; + min-width: 320px; + max-width: 500px; + transform: translateX(100%); + transition: transform 0.3s ease-in-out; +} + +/* 响应式设计 - 移动端优化 */ +@media (max-width: 640px) { + /* 玻璃态容器 */ + .glass, + .glass-strong { + margin: 12px; + border-radius: 16px; + padding: 16px; + } + + /* 统计卡片 */ + .stat-card { + padding: 12px; + border-radius: 12px; + } + + .stat-icon { + width: 40px; + height: 40px; + font-size: 16px; + } + + /* 标签按钮 */ + .tab-btn { + font-size: 12px; + padding: 10px 6px; + } + + /* 模态框 */ + .modal-content { + margin: 8px; + max-width: calc(100vw - 24px); + padding: 16px; + } + + .modal-scroll-content { + max-height: calc(90vh - 100px); + } + + /* 卡片 */ + .card { + border-radius: 12px; + } + + /* 表单元素 */ + .form-input, + .form-select, + .form-textarea { + font-size: 14px; + padding: 8px 12px; + } + + /* 按钮 */ + .btn { + font-size: 14px; + padding: 8px 16px; + } + + /* 表格 */ + .table-container table { + font-size: 12px; + } + + .table-container th, + .table-container td { + padding: 8px 12px; + } + + /* Toast通知 */ + .toast { + min-width: 280px; + max-width: calc(100vw - 40px); + right: 12px; + top: 60px; + } + + /* 加载动画 */ + .loading-spinner { + width: 16px; + height: 16px; + } +} + +@media (max-width: 768px) { + /* 玻璃态容器 */ + .glass, + .glass-strong { + margin: 16px; + border-radius: 20px; + } + + /* 统计卡片 */ + .stat-card { + padding: 16px; + } + + /* 标签按钮 */ + .tab-btn { + font-size: 14px; + padding: 12px 8px; + } + + /* 模态框滚动内容 */ + .modal-scroll-content { + max-height: calc(85vh - 120px); + } +} + +.toast.show { + transform: translateX(0); +} + +.toast-success { + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + color: white; + border: 1px solid rgba(16, 185, 129, 0.3); +} + +.toast-error { + background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); + color: white; + border: 1px solid rgba(239, 68, 68, 0.3); +} + +.toast-info { + background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); + color: white; + border: 1px solid rgba(59, 130, 246, 0.3); +} + +.toast-warning { + background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); + color: white; + border: 1px solid rgba(245, 158, 11, 0.3); +} + +/* 版本更新提醒动画 */ +@keyframes pulse { + 0% { + transform: scale(1); + opacity: 1; + } + 50% { + transform: scale(1.1); + opacity: 0.8; + } + 100% { + transform: scale(1); + opacity: 1; + } +} + +.animate-pulse { + animation: pulse 2s infinite; +} + +/* 用户菜单下拉框优化 */ +.user-menu-dropdown { + min-width: 240px; + box-shadow: + 0 10px 15px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -2px rgba(0, 0, 0, 0.05); +} + +.fa-openai { + width: 16px; + height: 16px; + background: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NDAgNjQwIj48IS0tIUZvbnQgQXdlc29tZSBGcmVlIHY3LjAuMCBieSBAZm9udGF3ZXNvbWUgLSBodHRwczovL2ZvbnRhd2Vzb21lLmNvbSBMaWNlbnNlIC0gaHR0cHM6Ly9mb250YXdlc29tZS5jb20vbGljZW5zZS9mcmVlIENvcHlyaWdodCAyMDI1IEZvbnRpY29ucywgSW5jLi0tPjxwYXRoIGQ9Ik0yNjAuNCAyNDkuOHYtNDguNmMwLTQuMSAxLjUtNy4yIDUuMS05LjJsOTcuOC01Ni4zYzEzLjMtNy43IDI5LjItMTEuMyA0NS42LTExLjMgNjEuNCAwIDEwMC40IDQ3LjYgMTAwLjQgOTguMyAwIDMuNiAwIDcuNy0uNSAxMS44bC0xMDEuNS01OS40Yy02LjEtMy42LTEyLjMtMy42LTE4LjQgMGwtMTI4LjUgNzQuN3ptMjI4LjMgMTg5LjRWMzIzYzAtNy4yLTMuMS0xMi4zLTkuMi0xNS45TDM1MSAyMzIuNGw0Mi0yNC4xYzMuNi0yIDYuNy0yIDEwLjIgMGw5Ny44IDU2LjRjMjguMiAxNi40IDQ3LjEgNTEuMiA0Ny4xIDg1IDAgMzguOS0yMyA3NC44LTU5LjQgODkuNnpNMjMwLjIgMzM2LjhsLTQyLTI0LjZjLTMuNi0yLTUuMS01LjEtNS4xLTkuMlYxOTAuNGMwLTU0LjggNDItOTYuMyA5OC44LTk2LjMgMjEuNSAwIDQxLjUgNy4yIDU4LjQgMjBsLTEwMC45IDU4LjRjLTYuMSAzLjYtOS4yIDguNy05LjIgMTUuOXYxNDguNXptOTAuNCA1Mi4ybC02MC4yLTMzLjh2LTcxLjdsNjAuMi0zMy44IDYwLjIgMzMuOHY3MS43TDMyMC42IDM4OXptMzguNyAxNTUuN2MtMjEuNSAwLTQxLjUtNy4yLTU4LjQtMjBsMTAwLjktNTguNGM2LjEtMy42IDkuMi04LjcgOS4yLTE1LjlWMzAxLjlsNDIuNSAyNC42YzMuNiAyIDUuMSA1LjEgNS4xIDkuMnYxMTIuNmMwIDU0LjgtNDIuNSA5Ni4zLTk5LjMgOTYuM3pNMjM3LjggNDMwLjVsLTk3LjctNTYuM0MxMTEuOSAzNTcuOCA5MyAzMjMgOTMgMjg5LjJjMC0zOS40IDIzLjYtNzQuOCA1OS45LTg5LjZ2MTE2LjdjMCA3LjIgMy4xIDEyLjMgOS4yIDE1LjlsMTI4IDc0LjItNDIgMjQuMWMtMy42IDItNi43IDItMTAuMiAwem0tNS42IDg0Yy01Ny45IDAtMTAwLjQtNDMuNS0xMDAuNC05Ny4zIDAtNC4xLjUtOC4yIDEtMTIuM2wxMDAuOSA1OC40YzYuMSAzLjYgMTIuMyAzLjYgMTguNCAwbDEyOC41LTc0LjJ2NDguNmMwIDQuMS0xLjUgNy4yLTUuMSA5LjJsLTk3LjggNTYuM2MtMTMuMyA3LjctMjkuMiAxMS4zLTQ1LjYgMTEuM3ptMTI3IDYwLjljNjIgMCAxMTMuNy00NCAxMjUuNC0xMDIuNCA1Ny4zLTE0LjkgOTQuMi02OC42IDk0LjItMTIzLjQgMC0zNS44LTE1LjQtNzAuNy00My05NS43IDIuNi0xMC44IDQuMS0yMS41IDQuMS0zMi4zIDAtNzMuMi01OS40LTEyOC0xMjgtMTI4LTEzLjggMC0yNy4xIDItNDAuNCA2LjctMjMtMjIuNS01NC44LTM2LjktODkuNi0zNi45LTYyIDAtMTEzLjcgNDQtMTI1LjQgMTAyLjQtNTcuMyAxNC44LTk0LjIgNjguNi05NC4yIDEyMy40IDAgMzUuOCAxNS40IDcwLjcgNDMgOTUuNy0yLjYgMTAuOC00LjEgMjEuNS00LjEgMzIuMyAwIDczLjIgNTkuNCAxMjggMTI4IDEyOCAxMy44IDAgMjcuMS0yIDQwLjQtNi43IDIzIDIyLjUgNTQuOCAzNi45IDg5LjYgMzYuOXoiLz48L3N2Zz4=) + no-repeat center/100%; +} diff --git a/web/admin-spa/src/assets/styles/global.css b/web/admin-spa/src/assets/styles/global.css new file mode 100644 index 0000000000000000000000000000000000000000..f17f718327864a3115966140d157ee85a5ef337a --- /dev/null +++ b/web/admin-spa/src/assets/styles/global.css @@ -0,0 +1,758 @@ +/* 从原始 style.css 复制的全局样式 */ +:root { + /* 亮色模式 */ + --primary-color: #667eea; + --secondary-color: #764ba2; + --accent-color: #f093fb; + --success-color: #10b981; + --warning-color: #f59e0b; + --error-color: #ef4444; + --surface-color: rgba(255, 255, 255, 0.95); + --glass-color: rgba(255, 255, 255, 0.1); + --glass-strong-color: rgba(255, 255, 255, 0.95); + --text-primary: #1f2937; + --text-secondary: #6b7280; + --border-color: rgba(255, 255, 255, 0.2); + --bg-gradient-start: #667eea; + --bg-gradient-mid: #764ba2; + --bg-gradient-end: #f093fb; + --input-bg: rgba(255, 255, 255, 0.9); + --input-border: rgba(209, 213, 219, 0.8); + --modal-bg: rgba(0, 0, 0, 0.4); + --table-bg: rgba(255, 255, 255, 0.95); + --table-hover: rgba(102, 126, 234, 0.05); +} + +.dark { + /* 暗黑模式 */ + --primary-color: #818cf8; + --secondary-color: #a78bfa; + --accent-color: #c084fc; + --success-color: #10b981; + --warning-color: #f59e0b; + --error-color: #ef4444; + --surface-color: rgba(31, 41, 55, 0.95); + --glass-color: rgba(0, 0, 0, 0.2); + --glass-strong-color: rgba(31, 41, 55, 0.95); + --text-primary: #f3f4f6; + --text-secondary: #9ca3af; + --border-color: rgba(75, 85, 99, 0.3); + --bg-gradient-start: #1f2937; + --bg-gradient-mid: #374151; + --bg-gradient-end: #4b5563; + --input-bg: rgba(31, 41, 55, 0.9); + --input-border: rgba(75, 85, 99, 0.5); + --modal-bg: rgba(0, 0, 0, 0.6); + --table-bg: rgba(31, 41, 55, 0.95); + --table-hover: rgba(129, 140, 248, 0.1); +} + +/* 优化后的transition - 避免布局跳动 */ +button, +input, +select, +textarea { + transition: + background-color 0.3s ease, + border-color 0.3s ease, + box-shadow 0.3s ease, + transform 0.2s ease; +} + +/* 颜色和背景过渡 */ +.transition-colors { + transition: + color 0.3s ease, + background-color 0.3s ease, + border-color 0.3s ease; +} + +body { + font-family: + 'Inter', + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + sans-serif; + background: linear-gradient( + 135deg, + var(--bg-gradient-start) 0%, + var(--bg-gradient-mid) 50%, + var(--bg-gradient-end) 100% + ); + background-attachment: fixed; + min-height: 100vh; + margin: 0; + overflow-x: hidden; + color: var(--text-primary); + transition: + background 0.3s ease, + color 0.3s ease; +} + +body::before { + content: ''; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: + radial-gradient(circle at 20% 80%, rgba(240, 147, 251, 0.2) 0%, transparent 50%), + radial-gradient(circle at 80% 20%, rgba(102, 126, 234, 0.2) 0%, transparent 50%), + radial-gradient(circle at 40% 40%, rgba(118, 75, 162, 0.1) 0%, transparent 50%); + pointer-events: none; + z-index: -1; +} + +.glass { + background: var(--glass-color); + /* 降低模糊强度 */ + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border: 1px solid var(--border-color); + box-shadow: + 0 4px 6px -1px rgba(0, 0, 0, 0.1), + 0 2px 4px -1px rgba(0, 0, 0, 0.06); + transition: + background-color 0.3s ease, + border-color 0.3s ease; +} + +.dark .glass { + box-shadow: + 0 20px 25px -5px rgba(0, 0, 0, 0.25), + 0 10px 10px -5px rgba(0, 0, 0, 0.1), + inset 0 1px 0 rgba(255, 255, 255, 0.05); +} + +.glass-strong { + background: var(--glass-strong-color); + /* 降低模糊强度 */ + /* 移除模糊效果 */ + border: 1px solid var(--border-color); + box-shadow: + 0 10px 15px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -2px rgba(0, 0, 0, 0.05); + transition: + background-color 0.3s ease, + border-color 0.3s ease; +} + +.dark .glass-strong { + box-shadow: + 0 25px 50px -12px rgba(0, 0, 0, 0.4), + 0 0 0 1px rgba(255, 255, 255, 0.02), + inset 0 1px 0 rgba(255, 255, 255, 0.05); +} + +.tab-btn { + position: relative; + overflow: hidden; + border-radius: 12px; + font-weight: 500; + letter-spacing: 0.025em; +} + +.tab-btn::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); + transition: left 0.5s; +} + +.tab-btn:hover::before { + left: 100%; +} + +.tab-btn.active { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); + color: white; + box-shadow: + 0 10px 15px -3px rgba(102, 126, 234, 0.3), + 0 4px 6px -2px rgba(102, 126, 234, 0.05); + transform: translateY(-1px); +} + +/* 按钮样式 */ +.btn { + font-weight: 500; + border-radius: 12px; + border: none; + cursor: pointer; + transition: all 0.3s ease; + position: relative; + overflow: hidden; + letter-spacing: 0.025em; +} + +.btn::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + background: rgba(255, 255, 255, 0.2); + border-radius: 50%; + transform: translate(-50%, -50%); + transition: + width 0.3s ease, + height 0.3s ease; +} + +.btn:active::before { + width: 300px; + height: 300px; +} + +.btn-primary { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); + color: white; + box-shadow: + 0 10px 15px -3px rgba(102, 126, 234, 0.3), + 0 4px 6px -2px rgba(102, 126, 234, 0.05); +} + +.btn-primary:hover { + transform: translateY(-1px); + box-shadow: + 0 20px 25px -5px rgba(102, 126, 234, 0.3), + 0 10px 10px -5px rgba(102, 126, 234, 0.1); +} + +.btn-success { + background: linear-gradient(135deg, var(--success-color) 0%, #059669 100%); + color: white; + box-shadow: + 0 10px 15px -3px rgba(16, 185, 129, 0.3), + 0 4px 6px -2px rgba(16, 185, 129, 0.05); +} + +.btn-success:hover { + transform: translateY(-1px); + box-shadow: + 0 20px 25px -5px rgba(16, 185, 129, 0.3), + 0 10px 10px -5px rgba(16, 185, 129, 0.1); +} + +.btn-danger { + background: linear-gradient(135deg, var(--error-color) 0%, #dc2626 100%); + color: white; + box-shadow: + 0 10px 15px -3px rgba(239, 68, 68, 0.3), + 0 4px 6px -2px rgba(239, 68, 68, 0.05); +} + +.btn-danger:hover { + transform: translateY(-1px); + box-shadow: + 0 20px 25px -5px rgba(239, 68, 68, 0.3), + 0 10px 10px -5px rgba(239, 68, 68, 0.1); +} + +/* 表单输入框样式 */ +.form-input { + background: var(--input-bg); + border: 2px solid var(--input-border); + border-radius: 12px; + padding: 16px; + font-size: 16px; + color: var(--text-primary); + transition: + background-color 0.3s ease, + border-color 0.3s ease, + box-shadow 0.3s ease; + /* 移除模糊效果 */ +} + +.form-input:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: + 0 0 0 3px rgba(102, 126, 234, 0.1), + 0 10px 15px -3px rgba(0, 0, 0, 0.1); + background: rgba(255, 255, 255, 0.95); + color: #1f2937; +} + +.dark .form-input:focus { + box-shadow: + 0 0 0 3px rgba(129, 140, 248, 0.2), + 0 10px 15px -3px rgba(0, 0, 0, 0.2); + background: rgba(17, 24, 39, 0.95); + color: #f3f4f6; +} + +.card { + background: var(--surface-color); + border-radius: 16px; + border: 1px solid var(--border-color); + box-shadow: + 0 10px 15px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -2px rgba(0, 0, 0, 0.05); + overflow: hidden; + position: relative; + transition: + background-color 0.3s ease, + border-color 0.3s ease; +} + +.dark .card { + box-shadow: + 0 10px 15px -3px rgba(0, 0, 0, 0.25), + 0 4px 6px -2px rgba(0, 0, 0, 0.1); +} + +.card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.5), transparent); +} + +.stat-card { + background: linear-gradient(135deg, var(--surface-color) 0%, var(--glass-strong-color) 100%); + border-radius: 20px; + border: 1px solid var(--border-color); + padding: 24px; + position: relative; + overflow: hidden; + transition: + transform 0.3s ease, + box-shadow 0.3s ease; +} + +.dark .stat-card { + background: linear-gradient(135deg, rgba(31, 41, 55, 0.95) 0%, rgba(17, 24, 39, 0.8) 100%); +} + +.stat-card::before { + content: ''; + position: absolute; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%); + opacity: 0; + transition: opacity 0.3s ease; +} + +.stat-card:hover { + transform: translateY(-4px); + box-shadow: + 0 20px 25px -5px rgba(0, 0, 0, 0.1), + 0 10px 10px -5px rgba(0, 0, 0, 0.04); +} + +.stat-card:hover::before { + opacity: 1; +} + +.stat-icon { + width: 56px; + height: 56px; + border-radius: 16px; + display: flex; + align-items: center; + justify-content: center; + font-size: 24px; + color: white; + box-shadow: + 0 10px 15px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -2px rgba(0, 0, 0, 0.05); +} + +.btn { + font-weight: 500; + border-radius: 12px; + border: none; + cursor: pointer; + transition: all 0.3s ease; + position: relative; + overflow: hidden; + letter-spacing: 0.025em; +} + +.btn::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + background: rgba(255, 255, 255, 0.2); + border-radius: 50%; + transform: translate(-50%, -50%); + transition: + width 0.3s ease, + height 0.3s ease; +} + +.btn:active::before { + width: 300px; + height: 300px; +} + +.btn-primary { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); + color: white; + box-shadow: + 0 10px 15px -3px rgba(102, 126, 234, 0.3), + 0 4px 6px -2px rgba(102, 126, 234, 0.05); +} + +.btn-primary:hover { + transform: translateY(-1px); + box-shadow: + 0 20px 25px -5px rgba(102, 126, 234, 0.3), + 0 10px 10px -5px rgba(102, 126, 234, 0.1); +} + +.btn-success { + background: linear-gradient(135deg, var(--success-color) 0%, #059669 100%); + color: white; + box-shadow: + 0 10px 15px -3px rgba(16, 185, 129, 0.3), + 0 4px 6px -2px rgba(16, 185, 129, 0.05); +} + +.btn-success:hover { + transform: translateY(-1px); + box-shadow: + 0 20px 25px -5px rgba(16, 185, 129, 0.3), + 0 10px 10px -5px rgba(16, 185, 129, 0.1); +} + +.btn-danger { + background: linear-gradient(135deg, var(--error-color) 0%, #dc2626 100%); + color: white; + box-shadow: + 0 10px 15px -3px rgba(239, 68, 68, 0.3), + 0 4px 6px -2px rgba(239, 68, 68, 0.05); +} + +.btn-danger:hover { + transform: translateY(-1px); + box-shadow: + 0 20px 25px -5px rgba(239, 68, 68, 0.3), + 0 10px 10px -5px rgba(239, 68, 68, 0.1); +} + +.form-input { + background: var(--input-bg); + border: 2px solid var(--input-border); + border-radius: 12px; + padding: 16px; + font-size: 16px; + color: var(--text-primary); + transition: + background-color 0.3s ease, + border-color 0.3s ease, + box-shadow 0.3s ease; + /* 移除模糊效果 */ +} + +.form-input:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: + 0 0 0 3px rgba(102, 126, 234, 0.1), + 0 10px 15px -3px rgba(0, 0, 0, 0.1); + background: rgba(255, 255, 255, 0.95); + color: #1f2937; +} + +.dark .form-input:focus { + box-shadow: + 0 0 0 3px rgba(129, 140, 248, 0.2), + 0 10px 15px -3px rgba(0, 0, 0, 0.2); + background: rgba(17, 24, 39, 0.95); + color: #f3f4f6; +} + +.table-container { + background: var(--table-bg); + border-radius: 16px; + overflow: hidden; + box-shadow: + 0 10px 15px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -2px rgba(0, 0, 0, 0.05); + transition: background-color 0.3s ease; +} + +.dark .table-container { + box-shadow: + 0 10px 15px -3px rgba(0, 0, 0, 0.25), + 0 4px 6px -2px rgba(0, 0, 0, 0.1); +} + +.table-row { + transition: + background-color 0.2s ease, + transform 0.2s ease; +} + +.table-row:hover { + background: var(--table-hover); + transform: scale(1.005); +} + +.modal { + /* 移除模糊效果 */ + background: var(--modal-bg); + transition: background-color 0.3s ease; +} + +.modal-content { + background: white; + border-radius: 24px; + border: 1px solid rgba(229, 231, 235, 0.8); + box-shadow: + 0 10px 25px -5px rgba(0, 0, 0, 0.15), + 0 0 0 1px rgba(255, 255, 255, 0.05); + /* 移除模糊效果 */ + transition: + background-color 0.3s ease, + border-color 0.3s ease; +} + +.dark .modal-content { + background: #1f2937; + border: 1px solid rgba(75, 85, 99, 0.5); + box-shadow: + 0 25px 50px -12px rgba(0, 0, 0, 0.6), + 0 0 0 1px rgba(255, 255, 255, 0.02); +} + +.header-title { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + font-weight: 700; + letter-spacing: -0.025em; +} + +.loading-spinner { + display: inline-block; + width: 20px; + height: 20px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-radius: 50%; + border-top: 2px solid white; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.3s ease; +} + +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} + +.slide-up-enter-active, +.slide-up-leave-active { + transition: all 0.3s ease; +} + +.slide-up-enter-from { + opacity: 0; + transform: translateY(30px); +} + +.slide-up-leave-to { + opacity: 0; + transform: translateY(-30px); +} + +.toast { + position: fixed; + top: 80px; + right: 20px; + z-index: 1000; + min-width: 320px; + max-width: 500px; + transform: translateX(100%); + transition: transform 0.3s ease-in-out; +} + +.toast.show { + transform: translateX(0); +} + +.toast-success { + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + color: white; + border: 1px solid rgba(16, 185, 129, 0.3); +} + +.toast-error { + background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); + color: white; + border: 1px solid rgba(239, 68, 68, 0.3); +} + +.toast-info { + background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); + color: white; + border: 1px solid rgba(59, 130, 246, 0.3); +} + +.toast-warning { + background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); + color: white; + border: 1px solid rgba(245, 158, 11, 0.3); +} + +[v-cloak] { + display: none; +} + +/* 自定义滚动条样式 */ +.custom-scrollbar { + scrollbar-width: thin; + scrollbar-color: rgba(102, 126, 234, 0.3) rgba(102, 126, 234, 0.05); +} + +.dark .custom-scrollbar { + scrollbar-color: rgba(129, 140, 248, 0.3) rgba(129, 140, 248, 0.05); +} + +.custom-scrollbar::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.custom-scrollbar::-webkit-scrollbar-track { + background: rgba(102, 126, 234, 0.05); + border-radius: 10px; +} + +.dark .custom-scrollbar::-webkit-scrollbar-track { + background: rgba(129, 140, 248, 0.05); +} + +.custom-scrollbar::-webkit-scrollbar-thumb { + background: linear-gradient(135deg, rgba(102, 126, 234, 0.4) 0%, rgba(118, 75, 162, 0.4) 100%); + border-radius: 10px; + transition: background 0.3s ease; +} + +.dark .custom-scrollbar::-webkit-scrollbar-thumb { + background: linear-gradient(135deg, rgba(129, 140, 248, 0.4) 0%, rgba(167, 139, 250, 0.4) 100%); +} + +.custom-scrollbar::-webkit-scrollbar-thumb:hover { + background: linear-gradient(135deg, rgba(102, 126, 234, 0.6) 0%, rgba(118, 75, 162, 0.6) 100%); +} + +.dark .custom-scrollbar::-webkit-scrollbar-thumb:hover { + background: linear-gradient(135deg, rgba(129, 140, 248, 0.6) 0%, rgba(167, 139, 250, 0.6) 100%); +} + +.custom-scrollbar::-webkit-scrollbar-thumb:active { + background: linear-gradient(135deg, rgba(102, 126, 234, 0.8) 0%, rgba(118, 75, 162, 0.8) 100%); +} + +.dark .custom-scrollbar::-webkit-scrollbar-thumb:active { + background: linear-gradient(135deg, rgba(129, 140, 248, 0.8) 0%, rgba(167, 139, 250, 0.8) 100%); +} + +/* 弹窗滚动内容样式 */ +.modal-scroll-content { + max-height: calc(90vh - 160px); + overflow-y: auto; + padding-right: 8px; +} + +@media (max-width: 768px) { + .glass, + .glass-strong { + margin: 16px; + border-radius: 20px; + } + + .stat-card { + padding: 16px; + } + + .tab-btn { + font-size: 14px; + padding: 12px 8px; + } + + .modal-scroll-content { + max-height: calc(85vh - 120px); + } +} + +/* 版本更新提醒动画 */ +@keyframes pulse { + 0% { + transform: scale(1); + opacity: 1; + } + 50% { + transform: scale(1.1); + opacity: 0.8; + } + 100% { + transform: scale(1); + opacity: 1; + } +} + +.animate-pulse { + /* 移除无限脉冲动画,改为 hover 效果 */ + transition: transform 0.2s ease; +} + +.animate-pulse:hover { + animation: pulse 0.3s ease; +} + +/* 用户菜单下拉框优化 */ +.user-menu-dropdown { + min-width: 240px; + box-shadow: + 0 10px 15px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -2px rgba(0, 0, 0, 0.05); +} + +/* Tab 内容区域样式 */ +.tab-content { + animation: fadeIn 0.3s ease-in-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/web/admin-spa/src/assets/styles/main.css b/web/admin-spa/src/assets/styles/main.css new file mode 100644 index 0000000000000000000000000000000000000000..858bd7fbf1df71834ddd98f50f794dd74ee55fc3 --- /dev/null +++ b/web/admin-spa/src/assets/styles/main.css @@ -0,0 +1,156 @@ +@import 'tailwindcss/base'; +@import 'tailwindcss/components'; +@import 'tailwindcss/utilities'; +@import './variables.css'; +@import './components.css'; + +/* Font Awesome 图标 */ +@import '@fortawesome/fontawesome-free/css/all.css'; + +/* 全局样式 */ +body { + font-family: + 'Inter', + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + sans-serif; + background: linear-gradient( + 135deg, + var(--primary-color) 0%, + var(--secondary-color) 50%, + var(--accent-color) 100% + ); + background-attachment: fixed; + min-height: 100vh; + margin: 0; + overflow-x: hidden; +} + +body::before { + content: ''; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: + radial-gradient(circle at 20% 80%, rgba(240, 147, 251, 0.2) 0%, transparent 50%), + radial-gradient(circle at 80% 20%, rgba(102, 126, 234, 0.2) 0%, transparent 50%), + radial-gradient(circle at 40% 40%, rgba(118, 75, 162, 0.1) 0%, transparent 50%); + pointer-events: none; + z-index: -1; +} + +/* 通用transition - 仅应用于特定元素 */ +body, +div, +button, +input, +select, +textarea, +table, +tr, +td, +th, +span, +p, +h1, +h2, +h3, +h4, +h5, +h6 { + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* Element Plus 主题覆盖 */ +.el-button--primary { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); + border-color: transparent; +} + +.el-button--primary:hover, +.el-button--primary:focus { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); + opacity: 0.9; +} + +/* 自定义滚动条样式 */ +.custom-scrollbar { + scrollbar-width: thin; + scrollbar-color: rgba(102, 126, 234, 0.3) rgba(102, 126, 234, 0.05); +} + +.custom-scrollbar::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.custom-scrollbar::-webkit-scrollbar-track { + background: rgba(102, 126, 234, 0.05); + border-radius: 10px; +} + +.custom-scrollbar::-webkit-scrollbar-thumb { + background: linear-gradient(135deg, rgba(102, 126, 234, 0.4) 0%, rgba(118, 75, 162, 0.4) 100%); + border-radius: 10px; + transition: background 0.3s ease; +} + +.custom-scrollbar::-webkit-scrollbar-thumb:hover { + background: linear-gradient(135deg, rgba(102, 126, 234, 0.6) 0%, rgba(118, 75, 162, 0.6) 100%); +} + +.custom-scrollbar::-webkit-scrollbar-thumb:active { + background: linear-gradient(135deg, rgba(102, 126, 234, 0.8) 0%, rgba(118, 75, 162, 0.8) 100%); +} + +/* Vue过渡动画 */ +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.3s ease; +} + +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} + +.slide-up-enter-active, +.slide-up-leave-active { + transition: all 0.3s ease; +} + +.slide-up-enter-from { + opacity: 0; + transform: translateY(30px); +} + +.slide-up-leave-to { + opacity: 0; + transform: translateY(-30px); +} + +/* 响应式调整 */ +@media (max-width: 768px) { + .glass, + .glass-strong { + margin: 16px; + border-radius: 20px; + } + + .stat-card { + padding: 16px; + } + + .tab-btn { + font-size: 14px; + padding: 12px 8px; + } + + .modal-scroll-content { + max-height: calc(85vh - 120px); + } +} diff --git a/web/admin-spa/src/assets/styles/variables.css b/web/admin-spa/src/assets/styles/variables.css new file mode 100644 index 0000000000000000000000000000000000000000..1e73d525a73c104fbccc0db6f0c8c139708d7ffb --- /dev/null +++ b/web/admin-spa/src/assets/styles/variables.css @@ -0,0 +1,13 @@ +:root { + --primary-color: #667eea; + --secondary-color: #764ba2; + --accent-color: #f093fb; + --success-color: #10b981; + --warning-color: #f59e0b; + --error-color: #ef4444; + --surface-color: rgba(255, 255, 255, 0.95); + --glass-color: rgba(255, 255, 255, 0.1); + --text-primary: #1f2937; + --text-secondary: #6b7280; + --border-color: rgba(255, 255, 255, 0.2); +} diff --git a/web/admin-spa/src/components/accounts/AccountExpiryEditModal.vue b/web/admin-spa/src/components/accounts/AccountExpiryEditModal.vue new file mode 100644 index 0000000000000000000000000000000000000000..046c333224a578b78381cda5f18b22ab892291a5 --- /dev/null +++ b/web/admin-spa/src/components/accounts/AccountExpiryEditModal.vue @@ -0,0 +1,416 @@ + + + + + diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue new file mode 100644 index 0000000000000000000000000000000000000000..0bea28a855565a4532a2e881091cf69a32a2a6b4 --- /dev/null +++ b/web/admin-spa/src/components/accounts/AccountForm.vue @@ -0,0 +1,5413 @@ + + + + + diff --git a/web/admin-spa/src/components/user/UserApiKeysManager.vue b/web/admin-spa/src/components/user/UserApiKeysManager.vue new file mode 100644 index 0000000000000000000000000000000000000000..092aa73c1bd200e16f29b0e00981792c39e0658d --- /dev/null +++ b/web/admin-spa/src/components/user/UserApiKeysManager.vue @@ -0,0 +1,354 @@ + + + + + diff --git a/web/admin-spa/src/components/user/UserUsageStats.vue b/web/admin-spa/src/components/user/UserUsageStats.vue new file mode 100644 index 0000000000000000000000000000000000000000..0cf885d43b8a1d8d9810b122b1e0ec3330da83c1 --- /dev/null +++ b/web/admin-spa/src/components/user/UserUsageStats.vue @@ -0,0 +1,397 @@ + + + + + diff --git a/web/admin-spa/src/components/user/ViewApiKeyModal.vue b/web/admin-spa/src/components/user/ViewApiKeyModal.vue new file mode 100644 index 0000000000000000000000000000000000000000..99ea1738341e6c871f44506368145b1af8c9df5e --- /dev/null +++ b/web/admin-spa/src/components/user/ViewApiKeyModal.vue @@ -0,0 +1,250 @@ + + + + + diff --git a/web/admin-spa/src/composables/useChartConfig.js b/web/admin-spa/src/composables/useChartConfig.js new file mode 100644 index 0000000000000000000000000000000000000000..4cd403b95f9b61b7bd20c1f8be34c30bd3d2f2c7 --- /dev/null +++ b/web/admin-spa/src/composables/useChartConfig.js @@ -0,0 +1,107 @@ +import { Chart } from 'chart.js/auto' + +export function useChartConfig() { + // 设置Chart.js默认配置 + Chart.defaults.font.family = + "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" + Chart.defaults.color = '#6b7280' + Chart.defaults.plugins.tooltip.backgroundColor = 'rgba(0, 0, 0, 0.8)' + Chart.defaults.plugins.tooltip.padding = 12 + Chart.defaults.plugins.tooltip.cornerRadius = 8 + Chart.defaults.plugins.tooltip.titleFont.size = 14 + Chart.defaults.plugins.tooltip.bodyFont.size = 12 + + // 创建渐变色 + const getGradient = (ctx, color, opacity = 0.2) => { + const gradient = ctx.createLinearGradient(0, 0, 0, 300) + gradient.addColorStop( + 0, + `${color}${Math.round(opacity * 255) + .toString(16) + .padStart(2, '0')}` + ) + gradient.addColorStop(1, `${color}00`) + return gradient + } + + // 通用图表选项 + const commonOptions = { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: true, + position: 'top', + labels: { + usePointStyle: true, + padding: 20, + font: { + size: 12, + weight: '500' + } + } + }, + tooltip: { + mode: 'index', + intersect: false, + callbacks: { + label: function (context) { + let label = context.dataset.label || '' + if (label) { + label += ': ' + } + if (context.parsed.y !== null) { + label += new Intl.NumberFormat('zh-CN').format(context.parsed.y) + } + return label + } + } + } + }, + scales: { + x: { + grid: { + display: false + }, + ticks: { + font: { + size: 11 + } + } + }, + y: { + grid: { + color: 'rgba(0, 0, 0, 0.05)', + drawBorder: false + }, + ticks: { + font: { + size: 11 + }, + callback: function (value) { + if (value >= 1000000) { + return (value / 1000000).toFixed(1) + 'M' + } else if (value >= 1000) { + return (value / 1000).toFixed(1) + 'K' + } + return value + } + } + } + } + } + + // 颜色方案 + const colorSchemes = { + primary: ['#667eea', '#764ba2', '#f093fb', '#4facfe', '#00f2fe'], + success: ['#10b981', '#059669', '#34d399', '#6ee7b7', '#a7f3d0'], + warning: ['#f59e0b', '#d97706', '#fbbf24', '#fcd34d', '#fde68a'], + danger: ['#ef4444', '#dc2626', '#f87171', '#fca5a5', '#fecaca'] + } + + return { + getGradient, + commonOptions, + colorSchemes + } +} diff --git a/web/admin-spa/src/composables/useConfirm.js b/web/admin-spa/src/composables/useConfirm.js new file mode 100644 index 0000000000000000000000000000000000000000..d08fbdde6bd13f87dfcd9be31e5da536cacb3d9b --- /dev/null +++ b/web/admin-spa/src/composables/useConfirm.js @@ -0,0 +1,49 @@ +import { ref } from 'vue' + +const showConfirmModal = ref(false) +const confirmOptions = ref({ + title: '', + message: '', + confirmText: '继续', + cancelText: '取消' +}) +const confirmResolve = ref(null) + +export function useConfirm() { + const showConfirm = (title, message, confirmText = '继续', cancelText = '取消') => { + return new Promise((resolve) => { + confirmOptions.value = { + title, + message, + confirmText, + cancelText + } + confirmResolve.value = resolve + showConfirmModal.value = true + }) + } + + const handleConfirm = () => { + showConfirmModal.value = false + if (confirmResolve.value) { + confirmResolve.value(true) + confirmResolve.value = null + } + } + + const handleCancel = () => { + showConfirmModal.value = false + if (confirmResolve.value) { + confirmResolve.value(false) + confirmResolve.value = null + } + } + + return { + showConfirmModal, + confirmOptions, + showConfirm, + handleConfirm, + handleCancel + } +} diff --git a/web/admin-spa/src/config/api.js b/web/admin-spa/src/config/api.js new file mode 100644 index 0000000000000000000000000000000000000000..68f3e0c9d566a190e7f0c0b534b1f4ba30de2692 --- /dev/null +++ b/web/admin-spa/src/config/api.js @@ -0,0 +1,210 @@ +// API 配置 +import { APP_CONFIG, getLoginUrl } from './app' + +// 开发环境使用 /webapi 前缀,生产环境不使用前缀 +export const API_PREFIX = APP_CONFIG.apiPrefix + +// 创建完整的 API URL +export function createApiUrl(path) { + // 确保路径以 / 开头 + if (!path.startsWith('/')) { + path = '/' + path + } + return API_PREFIX + path +} + +// API 请求的基础配置 +export function getRequestConfig(token) { + const config = { + headers: { + 'Content-Type': 'application/json' + } + } + + if (token) { + config.headers['Authorization'] = `Bearer ${token}` + } + + return config +} + +// 统一的 API 请求类 +class ApiClient { + constructor() { + this.baseURL = API_PREFIX + } + + // 获取认证 token + getAuthToken() { + const authToken = localStorage.getItem('authToken') + return authToken || null + } + + // 构建请求配置 + buildConfig(options = {}) { + const config = { + headers: { + 'Content-Type': 'application/json', + ...options.headers + }, + ...options + } + + // 添加认证 token + const token = this.getAuthToken() + if (token) { + config.headers['Authorization'] = `Bearer ${token}` + } + + return config + } + + // 处理响应 + async handleResponse(response) { + // 401 未授权,需要重新登录 + if (response.status === 401) { + // 如果当前已经在登录页面,不要再次跳转 + const currentPath = window.location.pathname + window.location.hash + const isLoginPage = currentPath.includes('/login') || currentPath.endsWith('/') + + if (!isLoginPage) { + localStorage.removeItem('authToken') + // 使用统一的登录URL + window.location.href = getLoginUrl() + } + throw new Error('Unauthorized') + } + + // 尝试解析 JSON + const contentType = response.headers.get('content-type') + if (contentType && contentType.includes('application/json')) { + const data = await response.json() + + // 如果响应不成功,抛出错误 + if (!response.ok) { + // 创建一个包含完整错误信息的错误对象 + const error = new Error(data.message || `HTTP ${response.status}`) + // 保留完整的响应数据,以便错误处理时可以访问详细信息 + error.response = { + status: response.status, + data: data + } + // 为了向后兼容,也保留原始的 message + error.message = data.message || error.message + throw error + } + + return data + } + + // 非 JSON 响应 + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + return response + } + + // GET 请求 + async get(url, options = {}) { + // 处理查询参数 + let fullUrl = createApiUrl(url) + if (options.params) { + const params = new URLSearchParams(options.params) + fullUrl += '?' + params.toString() + } + + // 移除 params 避免传递给 fetch + // eslint-disable-next-line no-unused-vars + const { params, ...configOptions } = options + const config = this.buildConfig({ + ...configOptions, + method: 'GET' + }) + + try { + const response = await fetch(fullUrl, config) + return await this.handleResponse(response) + } catch (error) { + console.error('API GET Error:', error) + throw error + } + } + + // POST 请求 + async post(url, data = null, options = {}) { + const fullUrl = createApiUrl(url) + const config = this.buildConfig({ + ...options, + method: 'POST', + body: data ? JSON.stringify(data) : undefined + }) + + try { + const response = await fetch(fullUrl, config) + return await this.handleResponse(response) + } catch (error) { + console.error('API POST Error:', error) + throw error + } + } + + // PUT 请求 + async put(url, data = null, options = {}) { + const fullUrl = createApiUrl(url) + const config = this.buildConfig({ + ...options, + method: 'PUT', + body: data ? JSON.stringify(data) : undefined + }) + + try { + const response = await fetch(fullUrl, config) + return await this.handleResponse(response) + } catch (error) { + console.error('API PUT Error:', error) + throw error + } + } + + // PATCH 请求 + async patch(url, data = null, options = {}) { + const fullUrl = createApiUrl(url) + const config = this.buildConfig({ + ...options, + method: 'PATCH', + body: data ? JSON.stringify(data) : undefined + }) + + try { + const response = await fetch(fullUrl, config) + return await this.handleResponse(response) + } catch (error) { + console.error('API PATCH Error:', error) + throw error + } + } + + // DELETE 请求 + async delete(url, options = {}) { + const fullUrl = createApiUrl(url) + const { data, ...restOptions } = options + + const config = this.buildConfig({ + ...restOptions, + method: 'DELETE', + body: data ? JSON.stringify(data) : undefined + }) + + try { + const response = await fetch(fullUrl, config) + return await this.handleResponse(response) + } catch (error) { + console.error('API DELETE Error:', error) + throw error + } + } +} + +// 导出单例实例 +export const apiClient = new ApiClient() diff --git a/web/admin-spa/src/config/apiStats.js b/web/admin-spa/src/config/apiStats.js new file mode 100644 index 0000000000000000000000000000000000000000..f581b1fe005745eb1ab1ed7db961b32bee76b4ea --- /dev/null +++ b/web/admin-spa/src/config/apiStats.js @@ -0,0 +1,97 @@ +// API Stats 专用 API 客户端 +// 与管理员 API 隔离,不需要认证 + +class ApiStatsClient { + constructor() { + this.baseURL = window.location.origin + // 开发环境需要为 admin 路径添加 /webapi 前缀 + this.isDev = import.meta.env.DEV + } + + async request(url, options = {}) { + try { + // 在开发环境中,为 /admin 路径添加 /webapi 前缀 + if (this.isDev && url.startsWith('/admin')) { + url = '/webapi' + url + } + + const response = await fetch(`${this.baseURL}${url}`, { + headers: { + 'Content-Type': 'application/json', + ...options.headers + }, + ...options + }) + + const data = await response.json() + + if (!response.ok) { + throw new Error(data.message || `请求失败: ${response.status}`) + } + + return data + } catch (error) { + console.error('API Stats request error:', error) + throw error + } + } + + // 获取 API Key ID + async getKeyId(apiKey) { + return this.request('/apiStats/api/get-key-id', { + method: 'POST', + body: JSON.stringify({ apiKey }) + }) + } + + // 获取用户统计数据 + async getUserStats(apiId) { + return this.request('/apiStats/api/user-stats', { + method: 'POST', + body: JSON.stringify({ apiId }) + }) + } + + // 获取模型使用统计 + async getUserModelStats(apiId, period = 'daily') { + return this.request('/apiStats/api/user-model-stats', { + method: 'POST', + body: JSON.stringify({ apiId, period }) + }) + } + + // 获取 OEM 设置(用于网站名称和图标) + async getOemSettings() { + try { + return await this.request('/admin/oem-settings') + } catch (error) { + console.error('Failed to load OEM settings:', error) + return { + success: true, + data: { + siteName: 'Claude Relay Service', + siteIcon: '', + siteIconData: '' + } + } + } + } + + // 批量查询统计数据 + async getBatchStats(apiIds) { + return this.request('/apiStats/api/batch-stats', { + method: 'POST', + body: JSON.stringify({ apiIds }) + }) + } + + // 批量查询模型统计 + async getBatchModelStats(apiIds, period = 'daily') { + return this.request('/apiStats/api/batch-model-stats', { + method: 'POST', + body: JSON.stringify({ apiIds, period }) + }) + } +} + +export const apiStatsClient = new ApiStatsClient() diff --git a/web/admin-spa/src/config/app.js b/web/admin-spa/src/config/app.js new file mode 100644 index 0000000000000000000000000000000000000000..23fbe856c484c425d847344118d02e57239706b6 --- /dev/null +++ b/web/admin-spa/src/config/app.js @@ -0,0 +1,28 @@ +// 应用配置 +export const APP_CONFIG = { + // 应用基础路径 + basePath: import.meta.env.VITE_APP_BASE_URL || (import.meta.env.DEV ? '/admin/' : '/web/admin/'), + + // 应用标题 + title: import.meta.env.VITE_APP_TITLE || 'Claude Relay Service - 管理后台', + + // 是否为开发环境 + isDev: import.meta.env.DEV, + + // API 前缀 + apiPrefix: import.meta.env.DEV ? '/webapi' : '' +} + +// 获取完整的应用URL +export function getAppUrl(path = '') { + // 确保路径以 / 开头 + if (path && !path.startsWith('/')) { + path = '/' + path + } + return APP_CONFIG.basePath + (path.startsWith('#') ? path : '#' + path) +} + +// 获取登录页面URL +export function getLoginUrl() { + return getAppUrl('/login') +} diff --git a/web/admin-spa/src/main.js b/web/admin-spa/src/main.js new file mode 100644 index 0000000000000000000000000000000000000000..79181b6beb08ac7d2ee6154a59ce8ae4f499d276 --- /dev/null +++ b/web/admin-spa/src/main.js @@ -0,0 +1,33 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import ElementPlus from 'element-plus' +import zhCn from 'element-plus/dist/locale/zh-cn.mjs' +import 'element-plus/dist/index.css' +import 'element-plus/theme-chalk/dark/css-vars.css' +import App from './App.vue' +import router from './router' +import { useUserStore } from './stores/user' +import './assets/styles/main.css' +import './assets/styles/global.css' + +// 创建Vue应用 +const app = createApp(App) + +// 使用Pinia状态管理 +const pinia = createPinia() +app.use(pinia) + +// 使用路由 +app.use(router) + +// 使用Element Plus +app.use(ElementPlus, { + locale: zhCn +}) + +// 设置axios拦截器 +const userStore = useUserStore() +userStore.setupAxiosInterceptors() + +// 挂载应用 +app.mount('#app') diff --git a/web/admin-spa/src/router/index.js b/web/admin-spa/src/router/index.js new file mode 100644 index 0000000000000000000000000000000000000000..16a2b50a4b5876f34bfcde0c1ad04dfbe1f66d55 --- /dev/null +++ b/web/admin-spa/src/router/index.js @@ -0,0 +1,209 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { useAuthStore } from '@/stores/auth' +import { useUserStore } from '@/stores/user' +import { APP_CONFIG } from '@/config/app' + +// 路由懒加载 +const LoginView = () => import('@/views/LoginView.vue') +const UserLoginView = () => import('@/views/UserLoginView.vue') +const UserDashboardView = () => import('@/views/UserDashboardView.vue') +const UserManagementView = () => import('@/views/UserManagementView.vue') +const MainLayout = () => import('@/components/layout/MainLayout.vue') +const DashboardView = () => import('@/views/DashboardView.vue') +const ApiKeysView = () => import('@/views/ApiKeysView.vue') +const AccountsView = () => import('@/views/AccountsView.vue') +const TutorialView = () => import('@/views/TutorialView.vue') +const SettingsView = () => import('@/views/SettingsView.vue') +const ApiStatsView = () => import('@/views/ApiStatsView.vue') + +const routes = [ + { + path: '/', + redirect: () => { + // 智能重定向:避免循环 + const currentPath = window.location.pathname + const basePath = APP_CONFIG.basePath.replace(/\/$/, '') // 移除末尾斜杠 + + // 如果当前路径已经是 basePath 或 basePath/,重定向到 api-stats + if (currentPath === basePath || currentPath === basePath + '/') { + return '/api-stats' + } + + // 否则保持默认重定向 + return '/api-stats' + } + }, + { + path: '/login', + name: 'Login', + component: LoginView, + meta: { requiresAuth: false } + }, + { + path: '/admin-login', + redirect: '/login' + }, + { + path: '/user-login', + name: 'UserLogin', + component: UserLoginView, + meta: { requiresAuth: false, userAuth: true } + }, + { + path: '/user-dashboard', + name: 'UserDashboard', + component: UserDashboardView, + meta: { requiresUserAuth: true } + }, + { + path: '/api-stats', + name: 'ApiStats', + component: ApiStatsView, + meta: { requiresAuth: false } + }, + { + path: '/dashboard', + component: MainLayout, + meta: { requiresAuth: true }, + children: [ + { + path: '', + name: 'Dashboard', + component: DashboardView + } + ] + }, + { + path: '/api-keys', + component: MainLayout, + meta: { requiresAuth: true }, + children: [ + { + path: '', + name: 'ApiKeys', + component: ApiKeysView + } + ] + }, + { + path: '/accounts', + component: MainLayout, + meta: { requiresAuth: true }, + children: [ + { + path: '', + name: 'Accounts', + component: AccountsView + } + ] + }, + { + path: '/tutorial', + component: MainLayout, + meta: { requiresAuth: true }, + children: [ + { + path: '', + name: 'Tutorial', + component: TutorialView + } + ] + }, + { + path: '/settings', + component: MainLayout, + meta: { requiresAuth: true }, + children: [ + { + path: '', + name: 'Settings', + component: SettingsView + } + ] + }, + { + path: '/user-management', + component: MainLayout, + meta: { requiresAuth: true }, + children: [ + { + path: '', + name: 'UserManagement', + component: UserManagementView + } + ] + }, + // 捕获所有未匹配的路由 + { + path: '/:pathMatch(.*)*', + redirect: '/api-stats' + } +] + +const router = createRouter({ + history: createWebHistory(APP_CONFIG.basePath), + routes +}) + +// 路由守卫 +router.beforeEach(async (to, from, next) => { + const authStore = useAuthStore() + const userStore = useUserStore() + + console.log('路由导航:', { + to: to.path, + from: from.path, + fullPath: to.fullPath, + requiresAuth: to.meta.requiresAuth, + requiresUserAuth: to.meta.requiresUserAuth, + isAuthenticated: authStore.isAuthenticated, + isUserAuthenticated: userStore.isAuthenticated + }) + + // 防止重定向循环:如果已经在目标路径,直接放行 + if (to.path === from.path && to.fullPath === from.fullPath) { + return next() + } + + // 检查用户认证状态 + if (to.meta.requiresUserAuth) { + if (!userStore.isAuthenticated) { + // 尝试检查本地存储的认证信息 + try { + const isUserLoggedIn = await userStore.checkAuth() + if (!isUserLoggedIn) { + return next('/user-login') + } + } catch (error) { + // If the error is about disabled account, redirect to login with error + if (error.message && error.message.includes('disabled')) { + // Import showToast to display the error + const { showToast } = await import('@/utils/toast') + showToast(error.message, 'error') + } + return next('/user-login') + } + } + return next() + } + + // API Stats 页面不需要认证,直接放行 + if (to.path === '/api-stats' || to.path.startsWith('/api-stats')) { + next() + } else if (to.path === '/user-login') { + // 如果已经是用户登录状态,重定向到用户仪表板 + if (userStore.isAuthenticated) { + next('/user-dashboard') + } else { + next() + } + } else if (to.meta.requiresAuth && !authStore.isAuthenticated) { + next('/login') + } else if (to.path === '/login' && authStore.isAuthenticated) { + next('/dashboard') + } else { + next() + } +}) + +export default router diff --git a/web/admin-spa/src/stores/accounts.js b/web/admin-spa/src/stores/accounts.js new file mode 100644 index 0000000000000000000000000000000000000000..1735869f7ee7ed933b13e6dd6fb390e2963d7f92 --- /dev/null +++ b/web/admin-spa/src/stores/accounts.js @@ -0,0 +1,884 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient } from '@/config/api' + +export const useAccountsStore = defineStore('accounts', () => { + // 状态 + const claudeAccounts = ref([]) + const claudeConsoleAccounts = ref([]) + const bedrockAccounts = ref([]) + const geminiAccounts = ref([]) + const openaiAccounts = ref([]) + const azureOpenaiAccounts = ref([]) + const openaiResponsesAccounts = ref([]) + const droidAccounts = ref([]) + const loading = ref(false) + const error = ref(null) + const sortBy = ref('') + const sortOrder = ref('asc') + + // Actions + + // 获取Claude账户列表 + const fetchClaudeAccounts = async () => { + loading.value = true + error.value = null + try { + const response = await apiClient.get('/admin/claude-accounts') + if (response.success) { + claudeAccounts.value = response.data || [] + } else { + throw new Error(response.message || '获取Claude账户失败') + } + } catch (err) { + error.value = err.message + throw err + } finally { + loading.value = false + } + } + + // 获取Claude Console账户列表 + const fetchClaudeConsoleAccounts = async () => { + loading.value = true + error.value = null + try { + const response = await apiClient.get('/admin/claude-console-accounts') + if (response.success) { + claudeConsoleAccounts.value = response.data || [] + } else { + throw new Error(response.message || '获取Claude Console账户失败') + } + } catch (err) { + error.value = err.message + throw err + } finally { + loading.value = false + } + } + + // 获取Bedrock账户列表 + const fetchBedrockAccounts = async () => { + loading.value = true + error.value = null + try { + const response = await apiClient.get('/admin/bedrock-accounts') + if (response.success) { + bedrockAccounts.value = response.data || [] + } else { + throw new Error(response.message || '获取Bedrock账户失败') + } + } catch (err) { + error.value = err.message + throw err + } finally { + loading.value = false + } + } + + // 获取Gemini账户列表 + const fetchGeminiAccounts = async () => { + loading.value = true + error.value = null + try { + const response = await apiClient.get('/admin/gemini-accounts') + if (response.success) { + geminiAccounts.value = response.data || [] + } else { + throw new Error(response.message || '获取Gemini账户失败') + } + } catch (err) { + error.value = err.message + throw err + } finally { + loading.value = false + } + } + + // 获取OpenAI账户列表 + const fetchOpenAIAccounts = async () => { + loading.value = true + error.value = null + try { + const response = await apiClient.get('/admin/openai-accounts') + if (response.success) { + openaiAccounts.value = response.data || [] + } else { + throw new Error(response.message || '获取OpenAI账户失败') + } + } catch (err) { + error.value = err.message + throw err + } finally { + loading.value = false + } + } + + // 获取Azure OpenAI账户列表 + const fetchAzureOpenAIAccounts = async () => { + loading.value = true + error.value = null + try { + const response = await apiClient.get('/admin/azure-openai-accounts') + if (response.success) { + azureOpenaiAccounts.value = response.data || [] + } else { + throw new Error(response.message || '获取Azure OpenAI账户失败') + } + } catch (err) { + error.value = err.message + throw err + } finally { + loading.value = false + } + } + + // 获取OpenAI-Responses账户列表 + const fetchOpenAIResponsesAccounts = async () => { + loading.value = true + error.value = null + try { + const response = await apiClient.get('/admin/openai-responses-accounts') + if (response.success) { + openaiResponsesAccounts.value = response.data || [] + } else { + throw new Error(response.message || '获取OpenAI-Responses账户失败') + } + } catch (err) { + error.value = err.message + throw err + } finally { + loading.value = false + } + } + + // 获取Droid账户列表 + const fetchDroidAccounts = async () => { + loading.value = true + error.value = null + try { + const response = await apiClient.get('/admin/droid-accounts') + if (response.success) { + droidAccounts.value = response.data || [] + } else { + throw new Error(response.message || '获取Droid账户失败') + } + } catch (err) { + error.value = err.message + throw err + } finally { + loading.value = false + } + } + + // 获取所有账户 + const fetchAllAccounts = async () => { + loading.value = true + error.value = null + try { + await Promise.all([ + fetchClaudeAccounts(), + fetchClaudeConsoleAccounts(), + fetchBedrockAccounts(), + fetchGeminiAccounts(), + fetchOpenAIAccounts(), + fetchAzureOpenAIAccounts(), + fetchOpenAIResponsesAccounts(), + fetchDroidAccounts() + ]) + } catch (err) { + error.value = err.message + throw err + } finally { + loading.value = false + } + } + + // 创建Claude账户 + const createClaudeAccount = async (data) => { + loading.value = true + error.value = null + try { + const response = await apiClient.post('/admin/claude-accounts', data) + if (response.success) { + await fetchClaudeAccounts() + return response.data + } else { + throw new Error(response.message || '创建Claude账户失败') + } + } catch (err) { + error.value = err.message + throw err + } finally { + loading.value = false + } + } + + // 创建Claude Console账户 + const createClaudeConsoleAccount = async (data) => { + loading.value = true + error.value = null + try { + const response = await apiClient.post('/admin/claude-console-accounts', data) + if (response.success) { + await fetchClaudeConsoleAccounts() + return response.data + } else { + throw new Error(response.message || '创建Claude Console账户失败') + } + } catch (err) { + error.value = err.message + throw err + } finally { + loading.value = false + } + } + + // 创建Bedrock账户 + const createBedrockAccount = async (data) => { + loading.value = true + error.value = null + try { + const response = await apiClient.post('/admin/bedrock-accounts', data) + if (response.success) { + await fetchBedrockAccounts() + return response.data + } else { + throw new Error(response.message || '创建Bedrock账户失败') + } + } catch (err) { + error.value = err.message + throw err + } finally { + loading.value = false + } + } + + // 创建Gemini账户 + const createGeminiAccount = async (data) => { + loading.value = true + error.value = null + try { + const response = await apiClient.post('/admin/gemini-accounts', data) + if (response.success) { + await fetchGeminiAccounts() + return response.data + } else { + throw new Error(response.message || '创建Gemini账户失败') + } + } catch (err) { + error.value = err.message + throw err + } finally { + loading.value = false + } + } + + // 创建OpenAI账户 + const createOpenAIAccount = async (data) => { + loading.value = true + error.value = null + try { + const response = await apiClient.post('/admin/openai-accounts', data) + if (response.success) { + await fetchOpenAIAccounts() + return response.data + } else { + throw new Error(response.message || '创建OpenAI账户失败') + } + } catch (err) { + error.value = err.message + throw err + } finally { + loading.value = false + } + } + + // 创建Droid账户 + const createDroidAccount = async (data) => { + loading.value = true + error.value = null + try { + const response = await apiClient.post('/admin/droid-accounts', data) + if (response.success) { + await fetchDroidAccounts() + return response.data + } else { + throw new Error(response.message || '创建Droid账户失败') + } + } catch (err) { + error.value = err.message + throw err + } finally { + loading.value = false + } + } + + // 更新Droid账户 + const updateDroidAccount = async (id, data) => { + loading.value = true + error.value = null + try { + const response = await apiClient.put(`/admin/droid-accounts/${id}`, data) + if (response.success) { + await fetchDroidAccounts() + return response.data + } else { + throw new Error(response.message || '更新Droid账户失败') + } + } catch (err) { + error.value = err.message + throw err + } finally { + loading.value = false + } + } + + // 创建Azure OpenAI账户 + const createAzureOpenAIAccount = async (data) => { + loading.value = true + error.value = null + try { + const response = await apiClient.post('/admin/azure-openai-accounts', data) + if (response.success) { + await fetchAzureOpenAIAccounts() + return response.data + } else { + throw new Error(response.message || '创建Azure OpenAI账户失败') + } + } catch (err) { + error.value = err.message + throw err + } finally { + loading.value = false + } + } + + // 创建OpenAI-Responses账户 + const createOpenAIResponsesAccount = async (data) => { + loading.value = true + error.value = null + try { + const response = await apiClient.post('/admin/openai-responses-accounts', data) + if (response.success) { + await fetchOpenAIResponsesAccounts() + return response.data + } else { + throw new Error(response.message || '创建OpenAI-Responses账户失败') + } + } catch (err) { + error.value = err.message + throw err + } finally { + loading.value = false + } + } + + // 更新Claude账户 + const updateClaudeAccount = async (id, data) => { + loading.value = true + error.value = null + try { + const response = await apiClient.put(`/admin/claude-accounts/${id}`, data) + if (response.success) { + await fetchClaudeAccounts() + return response + } else { + throw new Error(response.message || '更新Claude账户失败') + } + } catch (err) { + error.value = err.message + throw err + } finally { + loading.value = false + } + } + + // 更新Claude Console账户 + const updateClaudeConsoleAccount = async (id, data) => { + loading.value = true + error.value = null + try { + const response = await apiClient.put(`/admin/claude-console-accounts/${id}`, data) + if (response.success) { + await fetchClaudeConsoleAccounts() + return response + } else { + throw new Error(response.message || '更新Claude Console账户失败') + } + } catch (err) { + error.value = err.message + throw err + } finally { + loading.value = false + } + } + + // 更新Bedrock账户 + const updateBedrockAccount = async (id, data) => { + loading.value = true + error.value = null + try { + const response = await apiClient.put(`/admin/bedrock-accounts/${id}`, data) + if (response.success) { + await fetchBedrockAccounts() + return response + } else { + throw new Error(response.message || '更新Bedrock账户失败') + } + } catch (err) { + error.value = err.message + throw err + } finally { + loading.value = false + } + } + + // 更新Gemini账户 + const updateGeminiAccount = async (id, data) => { + loading.value = true + error.value = null + try { + const response = await apiClient.put(`/admin/gemini-accounts/${id}`, data) + if (response.success) { + await fetchGeminiAccounts() + return response + } else { + throw new Error(response.message || '更新Gemini账户失败') + } + } catch (err) { + error.value = err.message + throw err + } finally { + loading.value = false + } + } + + // 更新OpenAI账户 + const updateOpenAIAccount = async (id, data) => { + loading.value = true + error.value = null + try { + const response = await apiClient.put(`/admin/openai-accounts/${id}`, data) + if (response.success) { + await fetchOpenAIAccounts() + return response + } else { + throw new Error(response.message || '更新OpenAI账户失败') + } + } catch (err) { + error.value = err.message + throw err + } finally { + loading.value = false + } + } + + // 更新Azure OpenAI账户 + const updateAzureOpenAIAccount = async (id, data) => { + loading.value = true + error.value = null + try { + const response = await apiClient.put(`/admin/azure-openai-accounts/${id}`, data) + if (response.success) { + await fetchAzureOpenAIAccounts() + return response + } else { + throw new Error(response.message || '更新Azure OpenAI账户失败') + } + } catch (err) { + error.value = err.message + throw err + } finally { + loading.value = false + } + } + + // 更新OpenAI-Responses账户 + const updateOpenAIResponsesAccount = async (id, data) => { + loading.value = true + error.value = null + try { + const response = await apiClient.put(`/admin/openai-responses-accounts/${id}`, data) + if (response.success) { + await fetchOpenAIResponsesAccounts() + return response + } else { + throw new Error(response.message || '更新OpenAI-Responses账户失败') + } + } catch (err) { + error.value = err.message + throw err + } finally { + loading.value = false + } + } + + // 切换账户状态 + const toggleAccount = async (platform, id) => { + loading.value = true + error.value = null + try { + let endpoint + if (platform === 'claude') { + endpoint = `/admin/claude-accounts/${id}/toggle` + } else if (platform === 'claude-console') { + endpoint = `/admin/claude-console-accounts/${id}/toggle` + } else if (platform === 'bedrock') { + endpoint = `/admin/bedrock-accounts/${id}/toggle` + } else if (platform === 'gemini') { + endpoint = `/admin/gemini-accounts/${id}/toggle` + } else if (platform === 'openai') { + endpoint = `/admin/openai-accounts/${id}/toggle` + } else if (platform === 'azure_openai') { + endpoint = `/admin/azure-openai-accounts/${id}/toggle` + } else if (platform === 'openai-responses') { + endpoint = `/admin/openai-responses-accounts/${id}/toggle` + } else { + endpoint = `/admin/openai-accounts/${id}/toggle` + } + + const response = await apiClient.put(endpoint) + if (response.success) { + if (platform === 'claude') { + await fetchClaudeAccounts() + } else if (platform === 'claude-console') { + await fetchClaudeConsoleAccounts() + } else if (platform === 'bedrock') { + await fetchBedrockAccounts() + } else if (platform === 'gemini') { + await fetchGeminiAccounts() + } else if (platform === 'openai') { + await fetchOpenAIAccounts() + } else if (platform === 'azure_openai') { + await fetchAzureOpenAIAccounts() + } else if (platform === 'openai-responses') { + await fetchOpenAIResponsesAccounts() + } else { + await fetchOpenAIAccounts() + } + return response + } else { + throw new Error(response.message || '切换状态失败') + } + } catch (err) { + error.value = err.message + throw err + } finally { + loading.value = false + } + } + + // 删除账户 + const deleteAccount = async (platform, id) => { + loading.value = true + error.value = null + try { + let endpoint + if (platform === 'claude') { + endpoint = `/admin/claude-accounts/${id}` + } else if (platform === 'claude-console') { + endpoint = `/admin/claude-console-accounts/${id}` + } else if (platform === 'bedrock') { + endpoint = `/admin/bedrock-accounts/${id}` + } else if (platform === 'gemini') { + endpoint = `/admin/gemini-accounts/${id}` + } else if (platform === 'openai') { + endpoint = `/admin/openai-accounts/${id}` + } else if (platform === 'azure_openai') { + endpoint = `/admin/azure-openai-accounts/${id}` + } else if (platform === 'openai-responses') { + endpoint = `/admin/openai-responses-accounts/${id}` + } else { + endpoint = `/admin/openai-accounts/${id}` + } + + const response = await apiClient.delete(endpoint) + if (response.success) { + if (platform === 'claude') { + await fetchClaudeAccounts() + } else if (platform === 'claude-console') { + await fetchClaudeConsoleAccounts() + } else if (platform === 'bedrock') { + await fetchBedrockAccounts() + } else if (platform === 'gemini') { + await fetchGeminiAccounts() + } else if (platform === 'openai') { + await fetchOpenAIAccounts() + } else if (platform === 'azure_openai') { + await fetchAzureOpenAIAccounts() + } else if (platform === 'openai-responses') { + await fetchOpenAIResponsesAccounts() + } else { + await fetchOpenAIAccounts() + } + return response + } else { + throw new Error(response.message || '删除失败') + } + } catch (err) { + error.value = err.message + throw err + } finally { + loading.value = false + } + } + + // 刷新Claude Token + const refreshClaudeToken = async (id) => { + loading.value = true + error.value = null + try { + const response = await apiClient.post(`/admin/claude-accounts/${id}/refresh`) + if (response.success) { + await fetchClaudeAccounts() + return response + } else { + throw new Error(response.message || 'Token刷新失败') + } + } catch (err) { + error.value = err.message + throw err + } finally { + loading.value = false + } + } + + // 生成Claude OAuth URL + const generateClaudeAuthUrl = async (proxyConfig) => { + try { + const response = await apiClient.post('/admin/claude-accounts/generate-auth-url', proxyConfig) + if (response.success) { + return response.data // 返回整个对象,包含authUrl和sessionId + } else { + throw new Error(response.message || '生成授权URL失败') + } + } catch (err) { + error.value = err.message + throw err + } + } + + // 交换Claude OAuth Code + const exchangeClaudeCode = async (data) => { + try { + const response = await apiClient.post('/admin/claude-accounts/exchange-code', data) + if (response.success) { + return response.data + } else { + throw new Error(response.message || '交换授权码失败') + } + } catch (err) { + error.value = err.message + throw err + } + } + + // 生成Claude Setup Token URL + const generateClaudeSetupTokenUrl = async (proxyConfig) => { + try { + const response = await apiClient.post( + '/admin/claude-accounts/generate-setup-token-url', + proxyConfig + ) + if (response.success) { + return response.data // 返回整个对象,包含authUrl和sessionId + } else { + throw new Error(response.message || '生成Setup Token URL失败') + } + } catch (err) { + error.value = err.message + throw err + } + } + + // 交换Claude Setup Token Code + const exchangeClaudeSetupTokenCode = async (data) => { + try { + const response = await apiClient.post( + '/admin/claude-accounts/exchange-setup-token-code', + data + ) + if (response.success) { + return response.data + } else { + throw new Error(response.message || '交换Setup Token授权码失败') + } + } catch (err) { + error.value = err.message + throw err + } + } + + // 生成Gemini OAuth URL + const generateGeminiAuthUrl = async (proxyConfig) => { + try { + const response = await apiClient.post('/admin/gemini-accounts/generate-auth-url', proxyConfig) + if (response.success) { + return response.data // 返回整个对象,包含authUrl和sessionId + } else { + throw new Error(response.message || '生成授权URL失败') + } + } catch (err) { + error.value = err.message + throw err + } + } + + // 交换Gemini OAuth Code + const exchangeGeminiCode = async (data) => { + try { + const response = await apiClient.post('/admin/gemini-accounts/exchange-code', data) + if (response.success) { + return response.data + } else { + throw new Error(response.message || '交换授权码失败') + } + } catch (err) { + error.value = err.message + throw err + } + } + + // 生成OpenAI OAuth URL + const generateOpenAIAuthUrl = async (proxyConfig) => { + try { + const response = await apiClient.post('/admin/openai-accounts/generate-auth-url', proxyConfig) + if (response.success) { + return response.data // 返回整个对象,包含authUrl和sessionId + } else { + throw new Error(response.message || '生成授权URL失败') + } + } catch (err) { + error.value = err.message + throw err + } + } + + // 生成Droid OAuth URL + const generateDroidAuthUrl = async (proxyConfig) => { + error.value = null + try { + const response = await apiClient.post('/admin/droid-accounts/generate-auth-url', proxyConfig) + if (response.success) { + return response.data + } else { + throw new Error(response.message || '生成授权URL失败') + } + } catch (err) { + error.value = err.message + throw err + } + } + + // 交换OpenAI OAuth Code + const exchangeOpenAICode = async (data) => { + try { + const response = await apiClient.post('/admin/openai-accounts/exchange-code', data) + if (response.success) { + return response.data + } else { + throw new Error(response.message || '交换授权码失败') + } + } catch (err) { + error.value = err.message + throw err + } + } + + // 交换Droid OAuth Code + const exchangeDroidCode = async (data) => { + error.value = null + try { + const response = await apiClient.post('/admin/droid-accounts/exchange-code', data) + return response + } catch (err) { + error.value = err.message + throw err + } + } + + // 排序账户 + const sortAccounts = (field) => { + if (sortBy.value === field) { + sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc' + } else { + sortBy.value = field + sortOrder.value = 'asc' + } + } + + // 重置store + const reset = () => { + claudeAccounts.value = [] + claudeConsoleAccounts.value = [] + bedrockAccounts.value = [] + geminiAccounts.value = [] + openaiAccounts.value = [] + azureOpenaiAccounts.value = [] + openaiResponsesAccounts.value = [] + droidAccounts.value = [] + loading.value = false + error.value = null + sortBy.value = '' + sortOrder.value = 'asc' + } + + return { + // State + claudeAccounts, + claudeConsoleAccounts, + bedrockAccounts, + geminiAccounts, + openaiAccounts, + azureOpenaiAccounts, + openaiResponsesAccounts, + droidAccounts, + loading, + error, + sortBy, + sortOrder, + + // Actions + fetchClaudeAccounts, + fetchClaudeConsoleAccounts, + fetchBedrockAccounts, + fetchGeminiAccounts, + fetchOpenAIAccounts, + fetchAzureOpenAIAccounts, + fetchOpenAIResponsesAccounts, + fetchDroidAccounts, + fetchAllAccounts, + createClaudeAccount, + createClaudeConsoleAccount, + createBedrockAccount, + createGeminiAccount, + createOpenAIAccount, + createDroidAccount, + updateDroidAccount, + createAzureOpenAIAccount, + createOpenAIResponsesAccount, + updateClaudeAccount, + updateClaudeConsoleAccount, + updateBedrockAccount, + updateGeminiAccount, + updateOpenAIAccount, + updateAzureOpenAIAccount, + updateOpenAIResponsesAccount, + toggleAccount, + deleteAccount, + refreshClaudeToken, + generateClaudeAuthUrl, + exchangeClaudeCode, + generateClaudeSetupTokenUrl, + exchangeClaudeSetupTokenCode, + generateGeminiAuthUrl, + exchangeGeminiCode, + generateOpenAIAuthUrl, + exchangeOpenAICode, + generateDroidAuthUrl, + exchangeDroidCode, + sortAccounts, + reset + } +}) diff --git a/web/admin-spa/src/stores/apiKeys.js b/web/admin-spa/src/stores/apiKeys.js new file mode 100644 index 0000000000000000000000000000000000000000..9f294c13451cdd3914194cc395b280d3e20b1def --- /dev/null +++ b/web/admin-spa/src/stores/apiKeys.js @@ -0,0 +1,208 @@ +import { apiClient } from '@/config/api' +import { defineStore } from 'pinia' +import { ref } from 'vue' + +export const useApiKeysStore = defineStore('apiKeys', () => { + // 状态 + const apiKeys = ref([]) + const loading = ref(false) + const error = ref(null) + const statsTimeRange = ref('all') + const sortBy = ref('') + const sortOrder = ref('asc') + + // Actions + + // 获取API Keys列表 + const fetchApiKeys = async () => { + loading.value = true + error.value = null + try { + const response = await apiClient.get('/admin/api-keys') + if (response.success) { + apiKeys.value = response.data || [] + } else { + throw new Error(response.message || '获取API Keys失败') + } + } catch (err) { + error.value = err.message + throw err + } finally { + loading.value = false + } + } + + // 创建API Key + const createApiKey = async (data) => { + loading.value = true + error.value = null + try { + const response = await apiClient.post('/admin/api-keys', data) + if (response.success) { + await fetchApiKeys() + return response.data + } else { + throw new Error(response.message || '创建API Key失败') + } + } catch (err) { + error.value = err.message + throw err + } finally { + loading.value = false + } + } + + // 更新API Key + const updateApiKey = async (id, data) => { + loading.value = true + error.value = null + try { + const response = await apiClient.put(`/admin/api-keys/${id}`, data) + if (response.success) { + await fetchApiKeys() + return response + } else { + throw new Error(response.message || '更新API Key失败') + } + } catch (err) { + error.value = err.message + throw err + } finally { + loading.value = false + } + } + + // 切换API Key状态 + const toggleApiKey = async (id) => { + loading.value = true + error.value = null + try { + const response = await apiClient.put(`/admin/api-keys/${id}/toggle`) + if (response.success) { + await fetchApiKeys() + return response + } else { + throw new Error(response.message || '切换状态失败') + } + } catch (err) { + error.value = err.message + throw err + } finally { + loading.value = false + } + } + + // 续期API Key + const renewApiKey = async (id, data) => { + loading.value = true + error.value = null + try { + const response = await apiClient.put(`/admin/api-keys/${id}`, data) + if (response.success) { + await fetchApiKeys() + return response + } else { + throw new Error(response.message || '续期失败') + } + } catch (err) { + error.value = err.message + throw err + } finally { + loading.value = false + } + } + + // 删除API Key + const deleteApiKey = async (id) => { + loading.value = true + error.value = null + try { + const response = await apiClient.delete(`/admin/api-keys/${id}`) + if (response.success) { + await fetchApiKeys() + return response + } else { + throw new Error(response.message || '删除失败') + } + } catch (err) { + error.value = err.message + throw err + } finally { + loading.value = false + } + } + + // 获取API Key统计 + const fetchApiKeyStats = async (id, timeRange = 'all') => { + try { + const response = await apiClient.get(`/admin/api-keys/${id}/stats`, { + params: { timeRange } + }) + if (response.success) { + return response.stats + } else { + throw new Error(response.message || '获取统计失败') + } + } catch (err) { + console.error('获取API Key统计失败:', err) + return null + } + } + + // 排序API Keys + const sortApiKeys = (field) => { + if (sortBy.value === field) { + sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc' + } else { + sortBy.value = field + sortOrder.value = 'asc' + } + } + + // 获取已存在的标签 + const fetchTags = async () => { + try { + const response = await apiClient.get('/admin/api-keys/tags') + if (response.success) { + return response.data || [] + } else { + throw new Error(response.message || '获取标签失败') + } + } catch (err) { + console.error('获取标签失败:', err) + return [] + } + } + + // 重置store + const reset = () => { + apiKeys.value = [] + loading.value = false + error.value = null + statsTimeRange.value = 'all' + sortBy.value = '' + sortOrder.value = 'asc' + } + + return { + // State + apiKeys, + loading, + error, + statsTimeRange, + sortBy, + sortOrder, + + // Actions + fetchApiKeys, + createApiKey, + updateApiKey, + toggleApiKey, + renewApiKey, + deleteApiKey, + fetchApiKeyStats, + fetchTags, + sortApiKeys, + reset + } +}) diff --git a/web/admin-spa/src/stores/apistats.js b/web/admin-spa/src/stores/apistats.js new file mode 100644 index 0000000000000000000000000000000000000000..2d1054ed910c17e664d27a8f202ecb128fab47a6 --- /dev/null +++ b/web/admin-spa/src/stores/apistats.js @@ -0,0 +1,528 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { apiStatsClient } from '@/config/apiStats' + +export const useApiStatsStore = defineStore('apistats', () => { + // 状态 + const apiKey = ref('') + const apiId = ref(null) + const loading = ref(false) + const modelStatsLoading = ref(false) + const oemLoading = ref(true) + const error = ref('') + const statsPeriod = ref('daily') + const statsData = ref(null) + const modelStats = ref([]) + const dailyStats = ref(null) + const monthlyStats = ref(null) + const oemSettings = ref({ + siteName: '', + siteIcon: '', + siteIconData: '' + }) + + // 多 Key 模式相关状态 + const multiKeyMode = ref(false) + const apiKeys = ref([]) // 多个 API Key 数组 + const apiIds = ref([]) // 对应的 ID 数组 + const aggregatedStats = ref(null) // 聚合后的统计数据 + const individualStats = ref([]) // 各个 Key 的独立数据 + const invalidKeys = ref([]) // 无效的 Keys 列表 + + // 计算属性 + const currentPeriodData = computed(() => { + const defaultData = { + requests: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + allTokens: 0, + cost: 0, + formattedCost: '$0.000000' + } + + // 聚合模式下使用聚合数据 + if (multiKeyMode.value && aggregatedStats.value) { + if (statsPeriod.value === 'daily') { + return aggregatedStats.value.dailyUsage || defaultData + } else { + return aggregatedStats.value.monthlyUsage || defaultData + } + } + + // 单个 Key 模式下使用原有逻辑 + if (statsPeriod.value === 'daily') { + return dailyStats.value || defaultData + } else { + return monthlyStats.value || defaultData + } + }) + + const usagePercentages = computed(() => { + if (!statsData.value || !currentPeriodData.value) { + return { + tokenUsage: 0, + costUsage: 0, + requestUsage: 0 + } + } + + const current = currentPeriodData.value + const limits = statsData.value.limits + + return { + tokenUsage: + limits.tokenLimit > 0 ? Math.min((current.allTokens / limits.tokenLimit) * 100, 100) : 0, + costUsage: + limits.dailyCostLimit > 0 ? Math.min((current.cost / limits.dailyCostLimit) * 100, 100) : 0, + requestUsage: + limits.rateLimitRequests > 0 + ? Math.min((current.requests / limits.rateLimitRequests) * 100, 100) + : 0 + } + }) + + // Actions + + // 查询统计数据 + async function queryStats() { + // 多 Key 模式处理 + if (multiKeyMode.value) { + return queryBatchStats() + } + + const trimmedKey = apiKey.value.trim() + + if (!trimmedKey) { + error.value = '请输入 API Key' + return + } + + // 验证 API Key 格式:长度应在 10-512 之间 + if (trimmedKey.length < 10 || trimmedKey.length > 512) { + error.value = 'API Key 格式无效:长度应在 10-512 个字符之间' + return + } + + loading.value = true + error.value = '' + statsData.value = null + modelStats.value = [] + apiId.value = null + + try { + // 获取 API Key ID + const idResult = await apiStatsClient.getKeyId(trimmedKey) + + if (idResult.success) { + apiId.value = idResult.data.id + + // 使用 apiId 查询统计数据 + const statsResult = await apiStatsClient.getUserStats(apiId.value) + + if (statsResult.success) { + statsData.value = statsResult.data + + // 同时加载今日和本月的统计数据 + await loadAllPeriodStats() + + // 清除错误信息 + error.value = '' + + // 更新 URL + updateURL() + } else { + throw new Error(statsResult.message || '查询失败') + } + } else { + throw new Error(idResult.message || '获取 API Key ID 失败') + } + } catch (err) { + console.error('Query stats error:', err) + error.value = err.message || '查询统计数据失败,请检查您的 API Key 是否正确' + statsData.value = null + modelStats.value = [] + apiId.value = null + } finally { + loading.value = false + } + } + + // 加载所有时间段的统计数据 + async function loadAllPeriodStats() { + if (!apiId.value) return + + // 并行加载今日和本月的数据 + await Promise.all([loadPeriodStats('daily'), loadPeriodStats('monthly')]) + + // 加载当前选择时间段的模型统计 + await loadModelStats(statsPeriod.value) + } + + // 加载指定时间段的统计数据 + async function loadPeriodStats(period) { + try { + const result = await apiStatsClient.getUserModelStats(apiId.value, period) + + if (result.success) { + // 计算汇总数据 + const modelData = result.data || [] + const summary = { + requests: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + allTokens: 0, + cost: 0, + formattedCost: '$0.000000' + } + + modelData.forEach((model) => { + summary.requests += model.requests || 0 + summary.inputTokens += model.inputTokens || 0 + summary.outputTokens += model.outputTokens || 0 + summary.cacheCreateTokens += model.cacheCreateTokens || 0 + summary.cacheReadTokens += model.cacheReadTokens || 0 + summary.allTokens += model.allTokens || 0 + summary.cost += model.costs?.total || 0 + }) + + summary.formattedCost = formatCost(summary.cost) + + // 存储到对应的时间段数据 + if (period === 'daily') { + dailyStats.value = summary + } else { + monthlyStats.value = summary + } + } else { + console.warn(`Failed to load ${period} stats:`, result.message) + } + } catch (err) { + console.error(`Load ${period} stats error:`, err) + } + } + + // 加载模型统计数据 + async function loadModelStats(period = 'daily') { + if (!apiId.value) return + + modelStatsLoading.value = true + + try { + const result = await apiStatsClient.getUserModelStats(apiId.value, period) + + if (result.success) { + modelStats.value = result.data || [] + } else { + throw new Error(result.message || '加载模型统计失败') + } + } catch (err) { + console.error('Load model stats error:', err) + modelStats.value = [] + } finally { + modelStatsLoading.value = false + } + } + + // 切换时间范围 + async function switchPeriod(period) { + if (statsPeriod.value === period || modelStatsLoading.value) { + return + } + + statsPeriod.value = period + + // 多 Key 模式下加载批量模型统计 + if (multiKeyMode.value && apiIds.value.length > 0) { + await loadBatchModelStats(period) + return + } + + // 如果对应时间段的数据还没有加载,则加载它 + if ( + (period === 'daily' && !dailyStats.value) || + (period === 'monthly' && !monthlyStats.value) + ) { + await loadPeriodStats(period) + } + + // 加载对应的模型统计 + await loadModelStats(period) + } + + // 使用 apiId 直接加载数据 + async function loadStatsWithApiId() { + if (!apiId.value) return + + loading.value = true + error.value = '' + statsData.value = null + modelStats.value = [] + + try { + const result = await apiStatsClient.getUserStats(apiId.value) + + if (result.success) { + statsData.value = result.data + + // 调试:打印返回的限制数据 + console.log('API Stats - Full response:', result.data) + console.log('API Stats - limits data:', result.data.limits) + console.log('API Stats - weeklyOpusCostLimit:', result.data.limits?.weeklyOpusCostLimit) + console.log('API Stats - weeklyOpusCost:', result.data.limits?.weeklyOpusCost) + + // 同时加载今日和本月的统计数据 + await loadAllPeriodStats() + + // 清除错误信息 + error.value = '' + } else { + throw new Error(result.message || '查询失败') + } + } catch (err) { + console.error('Load stats with apiId error:', err) + error.value = err.message || '查询统计数据失败' + statsData.value = null + modelStats.value = [] + } finally { + loading.value = false + } + } + + // 加载 OEM 设置 + async function loadOemSettings() { + oemLoading.value = true + try { + const result = await apiStatsClient.getOemSettings() + if (result && result.success && result.data) { + oemSettings.value = { ...oemSettings.value, ...result.data } + } + } catch (err) { + console.error('Error loading OEM settings:', err) + // 失败时使用默认值 + oemSettings.value = { + siteName: 'Claude Relay Service', + siteIcon: '', + siteIconData: '' + } + } finally { + oemLoading.value = false + } + } + + // 工具函数 + + // 格式化费用 + function formatCost(cost) { + if (typeof cost !== 'number' || cost === 0) { + return '$0.000000' + } + + // 根据数值大小选择精度 + if (cost >= 1) { + return '$' + cost.toFixed(2) + } else if (cost >= 0.01) { + return '$' + cost.toFixed(4) + } else { + return '$' + cost.toFixed(6) + } + } + + // 更新 URL + function updateURL() { + if (apiId.value) { + const url = new URL(window.location) + url.searchParams.set('apiId', apiId.value) + window.history.pushState({}, '', url) + } + } + + // 批量查询统计数据 + async function queryBatchStats() { + const keys = parseApiKeys() + if (keys.length === 0) { + error.value = '请输入至少一个有效的 API Key' + return + } + + loading.value = true + error.value = '' + aggregatedStats.value = null + individualStats.value = [] + invalidKeys.value = [] + modelStats.value = [] + apiKeys.value = keys + apiIds.value = [] + + try { + // 批量获取 API Key IDs + const idResults = await Promise.allSettled(keys.map((key) => apiStatsClient.getKeyId(key))) + + const validIds = [] + const validKeys = [] + + idResults.forEach((result, index) => { + if (result.status === 'fulfilled' && result.value.success) { + validIds.push(result.value.data.id) + validKeys.push(keys[index]) + } else { + invalidKeys.value.push(keys[index]) + } + }) + + if (validIds.length === 0) { + throw new Error('所有 API Key 都无效') + } + + apiIds.value = validIds + apiKeys.value = validKeys + + // 批量查询统计数据 + const batchResult = await apiStatsClient.getBatchStats(validIds) + + if (batchResult.success) { + aggregatedStats.value = batchResult.data.aggregated + individualStats.value = batchResult.data.individual + statsData.value = batchResult.data.aggregated // 兼容现有组件 + + // 设置聚合模式下的日期统计数据,以保证现有组件的兼容性 + dailyStats.value = batchResult.data.aggregated.dailyUsage || null + monthlyStats.value = batchResult.data.aggregated.monthlyUsage || null + + // 加载聚合的模型统计 + await loadBatchModelStats(statsPeriod.value) + + // 更新 URL + updateBatchURL() + } else { + throw new Error(batchResult.message || '批量查询失败') + } + } catch (err) { + console.error('Batch query error:', err) + error.value = err.message || '批量查询统计数据失败' + aggregatedStats.value = null + individualStats.value = [] + } finally { + loading.value = false + } + } + + // 加载批量模型统计 + async function loadBatchModelStats(period = 'daily') { + if (apiIds.value.length === 0) return + + modelStatsLoading.value = true + + try { + const result = await apiStatsClient.getBatchModelStats(apiIds.value, period) + + if (result.success) { + modelStats.value = result.data || [] + } else { + throw new Error(result.message || '加载批量模型统计失败') + } + } catch (err) { + console.error('Load batch model stats error:', err) + modelStats.value = [] + } finally { + modelStatsLoading.value = false + } + } + + // 解析 API Keys + function parseApiKeys() { + if (!apiKey.value) return [] + + const keys = apiKey.value + .split(/[,\n]+/) + .map((key) => key.trim()) + .filter((key) => key.length >= 10 && key.length <= 512) // 验证 API Key 格式 + + // 去重并限制最多30个 + const uniqueKeys = [...new Set(keys)] + return uniqueKeys.slice(0, 30) + } + + // 更新批量查询 URL + function updateBatchURL() { + if (apiIds.value.length > 0) { + const url = new URL(window.location) + url.searchParams.set('apiIds', apiIds.value.join(',')) + url.searchParams.set('batch', 'true') + window.history.pushState({}, '', url) + } + } + + // 清空输入 + function clearInput() { + apiKey.value = '' + } + + // 清除数据 + function clearData() { + statsData.value = null + modelStats.value = [] + dailyStats.value = null + monthlyStats.value = null + error.value = '' + statsPeriod.value = 'daily' + apiId.value = null + // 清除多 Key 模式数据 + apiKeys.value = [] + apiIds.value = [] + aggregatedStats.value = null + individualStats.value = [] + invalidKeys.value = [] + } + + // 重置 + function reset() { + apiKey.value = '' + multiKeyMode.value = false + clearData() + } + + return { + // State + apiKey, + apiId, + loading, + modelStatsLoading, + oemLoading, + error, + statsPeriod, + statsData, + modelStats, + dailyStats, + monthlyStats, + oemSettings, + // 多 Key 模式状态 + multiKeyMode, + apiKeys, + apiIds, + aggregatedStats, + individualStats, + invalidKeys, + + // Computed + currentPeriodData, + usagePercentages, + + // Actions + queryStats, + queryBatchStats, + loadAllPeriodStats, + loadPeriodStats, + loadModelStats, + loadBatchModelStats, + switchPeriod, + loadStatsWithApiId, + loadOemSettings, + clearData, + clearInput, + reset + } +}) diff --git a/web/admin-spa/src/stores/auth.js b/web/admin-spa/src/stores/auth.js new file mode 100644 index 0000000000000000000000000000000000000000..bbd39c621cdb072cbc284698443113937a076f39 --- /dev/null +++ b/web/admin-spa/src/stores/auth.js @@ -0,0 +1,136 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import router from '@/router' +import { apiClient } from '@/config/api' + +export const useAuthStore = defineStore('auth', () => { + // 状态 + const isLoggedIn = ref(false) + const authToken = ref(localStorage.getItem('authToken') || '') + const username = ref('') + const loginError = ref('') + const loginLoading = ref(false) + const oemSettings = ref({ + siteName: 'Claude Relay Service', + siteIcon: '', + siteIconData: '', + faviconData: '' + }) + const oemLoading = ref(true) + + // 计算属性 + const isAuthenticated = computed(() => !!authToken.value && isLoggedIn.value) + const token = computed(() => authToken.value) + const user = computed(() => ({ username: username.value })) + + // 方法 + async function login(credentials) { + loginLoading.value = true + loginError.value = '' + + try { + const result = await apiClient.post('/web/auth/login', credentials) + + if (result.success) { + authToken.value = result.token + username.value = result.username || credentials.username + isLoggedIn.value = true + localStorage.setItem('authToken', result.token) + + await router.push('/dashboard') + } else { + loginError.value = result.message || '登录失败' + } + } catch (error) { + loginError.value = error.message || '登录失败,请检查用户名和密码' + } finally { + loginLoading.value = false + } + } + + function logout() { + isLoggedIn.value = false + authToken.value = '' + username.value = '' + localStorage.removeItem('authToken') + router.push('/login') + } + + function checkAuth() { + if (authToken.value) { + isLoggedIn.value = true + // 验证token有效性 + verifyToken() + } + } + + async function verifyToken() { + try { + // 获取当前用户信息 + const userResult = await apiClient.get('/web/auth/user') + if (userResult.success && userResult.user) { + username.value = userResult.user.username + } + + // 使用 dashboard 端点来验证 token + // 如果 token 无效,会抛出错误 + const result = await apiClient.get('/admin/dashboard') + if (!result.success) { + logout() + } + } catch (error) { + // token 无效,需要重新登录 + logout() + } + } + + async function loadOemSettings() { + oemLoading.value = true + try { + const result = await apiClient.get('/admin/oem-settings') + if (result.success && result.data) { + oemSettings.value = { ...oemSettings.value, ...result.data } + + // 设置favicon + if (result.data.siteIconData || result.data.siteIcon) { + const link = document.querySelector("link[rel*='icon']") || document.createElement('link') + link.type = 'image/x-icon' + link.rel = 'shortcut icon' + link.href = result.data.siteIconData || result.data.siteIcon + document.getElementsByTagName('head')[0].appendChild(link) + } + + // 设置页面标题 + if (result.data.siteName) { + document.title = `${result.data.siteName} - 管理后台` + } + } + } catch (error) { + console.error('加载OEM设置失败:', error) + } finally { + oemLoading.value = false + } + } + + return { + // 状态 + isLoggedIn, + authToken, + username, + loginError, + loginLoading, + oemSettings, + oemLoading, + + // 计算属性 + isAuthenticated, + token, + user, + + // 方法 + login, + logout, + checkAuth, + loadOemSettings + } +}) diff --git a/web/admin-spa/src/stores/clients.js b/web/admin-spa/src/stores/clients.js new file mode 100644 index 0000000000000000000000000000000000000000..7926c69d03f763836a2c31afdd914e089e196625 --- /dev/null +++ b/web/admin-spa/src/stores/clients.js @@ -0,0 +1,41 @@ +import { defineStore } from 'pinia' +import { apiClient } from '@/config/api' + +export const useClientsStore = defineStore('clients', { + state: () => ({ + supportedClients: [], + loading: false, + error: null + }), + + actions: { + async loadSupportedClients() { + if (this.supportedClients.length > 0) { + // 如果已经加载过,不重复加载 + return this.supportedClients + } + + this.loading = true + this.error = null + + try { + const response = await apiClient.get('/admin/supported-clients') + + if (response.success) { + this.supportedClients = response.data || [] + } else { + this.error = response.message || '加载支持的客户端失败' + console.error('Failed to load supported clients:', this.error) + } + + return this.supportedClients + } catch (error) { + this.error = error.message || '加载支持的客户端失败' + console.error('Error loading supported clients:', error) + return [] + } finally { + this.loading = false + } + } + } +}) diff --git a/web/admin-spa/src/stores/dashboard.js b/web/admin-spa/src/stores/dashboard.js new file mode 100644 index 0000000000000000000000000000000000000000..4e4599babfaa5b434a7b08c572999fe10e87e90e --- /dev/null +++ b/web/admin-spa/src/stores/dashboard.js @@ -0,0 +1,905 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { apiClient } from '@/config/api' +import { showToast } from '@/utils/toast' + +export const useDashboardStore = defineStore('dashboard', () => { + // 状态 + const loading = ref(false) + const dashboardData = ref({ + totalApiKeys: 0, + activeApiKeys: 0, + totalAccounts: 0, + normalAccounts: 0, + abnormalAccounts: 0, + pausedAccounts: 0, + activeAccounts: 0, // 保留兼容 + rateLimitedAccounts: 0, + accountsByPlatform: { + claude: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 }, + 'claude-console': { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 }, + gemini: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 }, + openai: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 }, + azure_openai: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 }, + bedrock: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 } + }, + todayRequests: 0, + totalRequests: 0, + todayTokens: 0, + todayInputTokens: 0, + todayOutputTokens: 0, + totalTokens: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheCreateTokens: 0, + totalCacheReadTokens: 0, + todayCacheCreateTokens: 0, + todayCacheReadTokens: 0, + systemRPM: 0, + systemTPM: 0, + realtimeRPM: 0, + realtimeTPM: 0, + metricsWindow: 5, + isHistoricalMetrics: false, + systemStatus: '正常', + uptime: 0, + systemTimezone: 8 // 默认 UTC+8 + }) + + const costsData = ref({ + todayCosts: { totalCost: 0, formatted: { totalCost: '$0.000000' } }, + totalCosts: { totalCost: 0, formatted: { totalCost: '$0.000000' } } + }) + + const modelStats = ref([]) + const trendData = ref([]) + const dashboardModelStats = ref([]) + const apiKeysTrendData = ref({ + data: [], + topApiKeys: [], + totalApiKeys: 0 + }) + const accountUsageTrendData = ref({ + data: [], + topAccounts: [], + totalAccounts: 0, + group: 'claude', + groupLabel: 'Claude账户' + }) + + // 日期筛选 + const dateFilter = ref({ + type: 'preset', // preset 或 custom + preset: '7days', // today, 7days, 30days + customStart: '', + customEnd: '', + customRange: null, + presetOptions: [ + { value: 'today', label: '今日', days: 1 }, + { value: '7days', label: '7天', days: 7 }, + { value: '30days', label: '30天', days: 30 } + ] + }) + + // 趋势图粒度 + const trendGranularity = ref('day') // 'day' 或 'hour' + const apiKeysTrendMetric = ref('requests') // 'requests' 或 'tokens' + const accountUsageGroup = ref('claude') // claude | openai | gemini + + // 默认时间 + const defaultTime = ref([new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 2, 1, 23, 59, 59)]) + + // 计算属性 + const formattedUptime = computed(() => { + const seconds = dashboardData.value.uptime + const days = Math.floor(seconds / 86400) + const hours = Math.floor((seconds % 86400) / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + + if (days > 0) { + return `${days}天 ${hours}小时` + } else if (hours > 0) { + return `${hours}小时 ${minutes}分钟` + } else { + return `${minutes}分钟` + } + }) + + // 辅助函数:基于系统时区计算时间 + // function getDateInSystemTimezone(date = new Date()) { + // const offset = dashboardData.value.systemTimezone || 8 + // // 将本地时间转换为UTC时间,然后加上系统时区偏移 + // const utcTime = date.getTime() + date.getTimezoneOffset() * 60000 + // return new Date(utcTime + offset * 3600000) + // } + + // 辅助函数:获取系统时区某一天的起止UTC时间 + // 输入:一个本地时间的日期对象(如用户选择的日期) + // 输出:该日期在系统时区的0点/23:59对应的UTC时间 + function getSystemTimezoneDay(localDate, startOfDay = true) { + // 固定使用UTC+8,因为后端系统时区是UTC+8 + // const systemTz = 8 + + // 获取本地日期的年月日(这是用户想要查看的日期) + const year = localDate.getFullYear() + const month = localDate.getMonth() + const day = localDate.getDate() + + if (startOfDay) { + // 系统时区(UTC+8)的 YYYY-MM-DD 00:00:00 + // 对应的UTC时间是前一天的16:00 + // 例如:UTC+8的2025-07-29 00:00:00 = UTC的2025-07-28 16:00:00 + return new Date(Date.UTC(year, month, day - 1, 16, 0, 0, 0)) + } else { + // 系统时区(UTC+8)的 YYYY-MM-DD 23:59:59 + // 对应的UTC时间是当天的15:59:59 + // 例如:UTC+8的2025-07-29 23:59:59 = UTC的2025-07-29 15:59:59 + return new Date(Date.UTC(year, month, day, 15, 59, 59, 999)) + } + } + + // 方法 + async function loadDashboardData(timeRange = null) { + loading.value = true + try { + // 根据timeRange动态设置costs查询参数 + let costsParams = { today: 'today', all: 'all' } + + if (timeRange) { + const periodMapping = { + today: { today: 'today', all: 'today' }, + '7days': { today: '7days', all: '7days' }, + monthly: { today: 'monthly', all: 'monthly' }, + all: { today: 'today', all: 'all' } + } + costsParams = periodMapping[timeRange] || costsParams + } + + const [dashboardResponse, todayCostsResponse, totalCostsResponse] = await Promise.all([ + apiClient.get('/admin/dashboard'), + apiClient.get(`/admin/usage-costs?period=${costsParams.today}`), + apiClient.get(`/admin/usage-costs?period=${costsParams.all}`) + ]) + + if (dashboardResponse.success) { + const overview = dashboardResponse.data.overview || {} + const recentActivity = dashboardResponse.data.recentActivity || {} + const systemAverages = dashboardResponse.data.systemAverages || {} + const realtimeMetrics = dashboardResponse.data.realtimeMetrics || {} + const systemHealth = dashboardResponse.data.systemHealth || {} + + dashboardData.value = { + totalApiKeys: overview.totalApiKeys || 0, + activeApiKeys: overview.activeApiKeys || 0, + // 使用新的统一统计字段 + totalAccounts: overview.totalAccounts || overview.totalClaudeAccounts || 0, + normalAccounts: overview.normalAccounts || 0, + abnormalAccounts: overview.abnormalAccounts || 0, + pausedAccounts: overview.pausedAccounts || 0, + activeAccounts: overview.activeAccounts || overview.activeClaudeAccounts || 0, // 兼容 + rateLimitedAccounts: + overview.rateLimitedAccounts || overview.rateLimitedClaudeAccounts || 0, + // 各平台详细统计 + accountsByPlatform: overview.accountsByPlatform || { + claude: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 }, + 'claude-console': { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 }, + gemini: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 }, + openai: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 }, + azure_openai: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 }, + bedrock: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 } + }, + todayRequests: recentActivity.requestsToday || 0, + totalRequests: overview.totalRequestsUsed || 0, + todayTokens: recentActivity.tokensToday || 0, + todayInputTokens: recentActivity.inputTokensToday || 0, + todayOutputTokens: recentActivity.outputTokensToday || 0, + totalTokens: overview.totalTokensUsed || 0, + totalInputTokens: overview.totalInputTokensUsed || 0, + totalOutputTokens: overview.totalOutputTokensUsed || 0, + totalCacheCreateTokens: overview.totalCacheCreateTokensUsed || 0, + totalCacheReadTokens: overview.totalCacheReadTokensUsed || 0, + todayCacheCreateTokens: recentActivity.cacheCreateTokensToday || 0, + todayCacheReadTokens: recentActivity.cacheReadTokensToday || 0, + systemRPM: systemAverages.rpm || 0, + systemTPM: systemAverages.tpm || 0, + realtimeRPM: realtimeMetrics.rpm || 0, + realtimeTPM: realtimeMetrics.tpm || 0, + metricsWindow: realtimeMetrics.windowMinutes || 5, + isHistoricalMetrics: realtimeMetrics.isHistorical || false, + systemStatus: systemHealth.redisConnected ? '正常' : '异常', + uptime: systemHealth.uptime || 0, + systemTimezone: dashboardResponse.data.systemTimezone || 8 + } + } + + // 更新费用数据 + if (todayCostsResponse.success && totalCostsResponse.success) { + costsData.value = { + todayCosts: todayCostsResponse.data.totalCosts || { + totalCost: 0, + formatted: { totalCost: '$0.000000' } + }, + totalCosts: totalCostsResponse.data.totalCosts || { + totalCost: 0, + formatted: { totalCost: '$0.000000' } + } + } + } + } catch (error) { + console.error('加载仪表板数据失败:', error) + } finally { + loading.value = false + } + } + + async function loadUsageTrend(days = 7, granularity = 'day') { + try { + let url = '/admin/usage-trend?' + + if (granularity === 'hour') { + // 小时粒度,计算时间范围 + url += `granularity=hour` + + if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) { + // 使用自定义时间范围 - 需要将系统时区时间转换为UTC + const convertToUTC = (systemTzTimeStr) => { + // 固定使用UTC+8,因为后端系统时区是UTC+8 + const systemTz = 8 + // 解析系统时区时间字符串 + const [datePart, timePart] = systemTzTimeStr.split(' ') + const [year, month, day] = datePart.split('-').map(Number) + const [hours, minutes, seconds] = timePart.split(':').map(Number) + + // 创建UTC时间,使其在系统时区显示为用户选择的时间 + // 例如:用户选择 UTC+8 的 2025-07-25 00:00:00 + // 对应的UTC时间是 2025-07-24 16:00:00 + const utcDate = new Date( + Date.UTC(year, month - 1, day, hours - systemTz, minutes, seconds) + ) + return utcDate.toISOString() + } + + url += `&startDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[0]))}` + url += `&endDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[1]))}` + } else { + // 使用预设计算时间范围,与loadApiKeysTrend保持一致 + const now = new Date() + let startTime, endTime + + if (dateFilter.value.type === 'preset') { + switch (dateFilter.value.preset) { + case 'last24h': { + // 近24小时:从当前时间往前推24小时 + endTime = new Date(now) + startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000) + break + } + case 'yesterday': { + const yesterday = new Date() + yesterday.setDate(yesterday.getDate() - 1) + startTime = getSystemTimezoneDay(yesterday, true) + endTime = getSystemTimezoneDay(yesterday, false) + break + } + case 'dayBefore': { + // 前天:基于系统时区的前天 + const dayBefore = new Date() + dayBefore.setDate(dayBefore.getDate() - 2) + startTime = getSystemTimezoneDay(dayBefore, true) + endTime = getSystemTimezoneDay(dayBefore, false) + break + } + default: { + // 默认近24小时 + startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000) + endTime = now + } + } + } else { + // 默认使用days参数计算 + startTime = new Date(now.getTime() - days * 24 * 60 * 60 * 1000) + endTime = now + } + + url += `&startDate=${encodeURIComponent(startTime.toISOString())}` + url += `&endDate=${encodeURIComponent(endTime.toISOString())}` + } + } else { + // 天粒度,传递天数 + url += `granularity=day&days=${days}` + } + + const response = await apiClient.get(url) + if (response.success) { + trendData.value = response.data + } + } catch (error) { + console.error('加载使用趋势失败:', error) + } + } + + async function loadModelStats(period = 'daily') { + try { + let url = `/admin/model-stats?period=${period}` + + // 如果是自定义时间范围或小时粒度,传递具体的时间参数 + if (dateFilter.value.type === 'custom' || trendGranularity.value === 'hour') { + if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) { + // 将系统时区时间转换为UTC + const convertToUTC = (systemTzTimeStr) => { + const systemTz = 8 + const [datePart, timePart] = systemTzTimeStr.split(' ') + const [year, month, day] = datePart.split('-').map(Number) + const [hours, minutes, seconds] = timePart.split(':').map(Number) + + const utcDate = new Date( + Date.UTC(year, month - 1, day, hours - systemTz, minutes, seconds) + ) + return utcDate.toISOString() + } + + url += `&startDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[0]))}` + url += `&endDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[1]))}` + } else if (trendGranularity.value === 'hour' && dateFilter.value.type === 'preset') { + // 小时粒度的预设时间范围 + const now = new Date() + let startTime, endTime + + switch (dateFilter.value.preset) { + case 'last24h': { + endTime = new Date(now) + startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000) + break + } + case 'yesterday': { + const yesterday = new Date() + yesterday.setDate(yesterday.getDate() - 1) + startTime = getSystemTimezoneDay(yesterday, true) + endTime = getSystemTimezoneDay(yesterday, false) + break + } + case 'dayBefore': { + const dayBefore = new Date() + dayBefore.setDate(dayBefore.getDate() - 2) + startTime = getSystemTimezoneDay(dayBefore, true) + endTime = getSystemTimezoneDay(dayBefore, false) + break + } + default: { + startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000) + endTime = now + } + } + + url += `&startDate=${encodeURIComponent(startTime.toISOString())}` + url += `&endDate=${encodeURIComponent(endTime.toISOString())}` + } + } else if (dateFilter.value.type === 'preset' && trendGranularity.value === 'day') { + // 天粒度的预设时间范围,需要传递startDate和endDate参数 + const now = new Date() + let startDate, endDate + + const option = dateFilter.value.presetOptions.find( + (opt) => opt.value === dateFilter.value.preset + ) + if (option) { + if (dateFilter.value.preset === 'today') { + // 今日:从系统时区的今天0点到23:59 + startDate = getSystemTimezoneDay(now, true) + endDate = getSystemTimezoneDay(now, false) + } else { + // 7天或30天:从N天前的0点到今天的23:59 + const daysAgo = new Date() + daysAgo.setDate(daysAgo.getDate() - (option.days - 1)) + startDate = getSystemTimezoneDay(daysAgo, true) + endDate = getSystemTimezoneDay(now, false) + } + + url += `&startDate=${encodeURIComponent(startDate.toISOString())}` + url += `&endDate=${encodeURIComponent(endDate.toISOString())}` + } + } + + const response = await apiClient.get(url) + if (response.success) { + dashboardModelStats.value = response.data + } + } catch (error) { + console.error('加载模型统计失败:', error) + } + } + + async function loadApiKeysTrend(metric = 'requests') { + try { + let url = '/admin/api-keys-usage-trend?' + let days = 7 + + if (trendGranularity.value === 'hour') { + // 小时粒度,计算时间范围 + url += `granularity=hour` + + if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) { + // 使用自定义时间范围 - 需要将系统时区时间转换为UTC + const convertToUTC = (systemTzTimeStr) => { + // 固定使用UTC+8,因为后端系统时区是UTC+8 + const systemTz = 8 + // 解析系统时区时间字符串 + const [datePart, timePart] = systemTzTimeStr.split(' ') + const [year, month, day] = datePart.split('-').map(Number) + const [hours, minutes, seconds] = timePart.split(':').map(Number) + + // 创建UTC时间,使其在系统时区显示为用户选择的时间 + // 例如:用户选择 UTC+8 的 2025-07-25 00:00:00 + // 对应的UTC时间是 2025-07-24 16:00:00 + const utcDate = new Date( + Date.UTC(year, month - 1, day, hours - systemTz, minutes, seconds) + ) + return utcDate.toISOString() + } + + url += `&startDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[0]))}` + url += `&endDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[1]))}` + } else { + // 使用预设计算时间范围,与setDateFilterPreset保持一致 + const now = new Date() + let startTime, endTime + + if (dateFilter.value.type === 'preset') { + switch (dateFilter.value.preset) { + case 'last24h': { + // 近24小时:从当前时间往前推24小时 + endTime = new Date(now) + startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000) + break + } + case 'yesterday': { + // 昨天:基于系统时区的昨天 + const yesterday = new Date() + yesterday.setDate(yesterday.getDate() - 1) + startTime = getSystemTimezoneDay(yesterday, true) + endTime = getSystemTimezoneDay(yesterday, false) + break + } + case 'dayBefore': { + // 前天:基于系统时区的前天 + const dayBefore = new Date() + dayBefore.setDate(dayBefore.getDate() - 2) + startTime = getSystemTimezoneDay(dayBefore, true) + endTime = getSystemTimezoneDay(dayBefore, false) + break + } + default: { + // 默认近24小时 + startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000) + endTime = now + } + } + } else { + // 默认近24小时 + startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000) + endTime = now + } + + url += `&startDate=${encodeURIComponent(startTime.toISOString())}` + url += `&endDate=${encodeURIComponent(endTime.toISOString())}` + } + } else { + // 天粒度,传递天数 + days = + dateFilter.value.type === 'preset' + ? dateFilter.value.preset === 'today' + ? 1 + : dateFilter.value.preset === '7days' + ? 7 + : 30 + : calculateDaysBetween(dateFilter.value.customStart, dateFilter.value.customEnd) + url += `granularity=day&days=${days}` + } + + url += `&metric=${metric}` + + const response = await apiClient.get(url) + if (response.success) { + apiKeysTrendData.value = { + data: response.data || [], + topApiKeys: response.topApiKeys || [], + totalApiKeys: response.totalApiKeys || 0 + } + } + } catch (error) { + console.error('加载API Keys趋势失败:', error) + } + } + + async function loadAccountUsageTrend(group = accountUsageGroup.value) { + try { + let url = '/admin/account-usage-trend?' + let days = 7 + + if (trendGranularity.value === 'hour') { + url += `granularity=hour` + + if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) { + const convertToUTC = (systemTzTimeStr) => { + const systemTz = 8 + const [datePart, timePart] = systemTzTimeStr.split(' ') + const [year, month, day] = datePart.split('-').map(Number) + const [hours, minutes, seconds] = timePart.split(':').map(Number) + + const utcDate = new Date( + Date.UTC(year, month - 1, day, hours - systemTz, minutes, seconds) + ) + return utcDate.toISOString() + } + + url += `&startDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[0]))}` + url += `&endDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[1]))}` + } else { + const now = new Date() + let startTime + let endTime + + if (dateFilter.value.type === 'preset') { + switch (dateFilter.value.preset) { + case 'last24h': { + endTime = new Date(now) + startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000) + break + } + case 'yesterday': { + const yesterday = new Date() + yesterday.setDate(yesterday.getDate() - 1) + startTime = getSystemTimezoneDay(yesterday, true) + endTime = getSystemTimezoneDay(yesterday, false) + break + } + case 'dayBefore': { + const dayBefore = new Date() + dayBefore.setDate(dayBefore.getDate() - 2) + startTime = getSystemTimezoneDay(dayBefore, true) + endTime = getSystemTimezoneDay(dayBefore, false) + break + } + default: { + startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000) + endTime = now + } + } + } else { + startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000) + endTime = now + } + + url += `&startDate=${encodeURIComponent(startTime.toISOString())}` + url += `&endDate=${encodeURIComponent(endTime.toISOString())}` + } + } else { + days = + dateFilter.value.type === 'preset' + ? dateFilter.value.preset === 'today' + ? 1 + : dateFilter.value.preset === '7days' + ? 7 + : 30 + : calculateDaysBetween(dateFilter.value.customStart, dateFilter.value.customEnd) + url += `granularity=day&days=${days}` + } + + url += `&group=${group}` + + const response = await apiClient.get(url) + if (response.success) { + accountUsageTrendData.value = { + data: response.data || [], + topAccounts: response.topAccounts || [], + totalAccounts: response.totalAccounts || 0, + group: response.group || group, + groupLabel: response.groupLabel || '' + } + } + } catch (error) { + console.error('加载账号使用趋势失败:', error) + } + } + + // 日期筛选相关方法 + function setDateFilterPreset(preset) { + dateFilter.value.type = 'preset' + dateFilter.value.preset = preset + + // 根据预设计算并设置具体的日期范围 + const option = dateFilter.value.presetOptions.find((opt) => opt.value === preset) + if (option) { + const now = new Date() + let startDate, endDate + + if (trendGranularity.value === 'hour') { + // 小时粒度的预设 + switch (preset) { + case 'last24h': { + // 近24小时:从当前时间往前推24小时 + endDate = new Date(now) + startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000) + break + } + case 'yesterday': { + // 昨天:获取本地时间的昨天 + const yesterday = new Date() + yesterday.setDate(yesterday.getDate() - 1) + // 转换为系统时区的昨天0点和23:59 + startDate = getSystemTimezoneDay(yesterday, true) + endDate = getSystemTimezoneDay(yesterday, false) + break + } + case 'dayBefore': { + // 前天:获取本地时间的前天 + const dayBefore = new Date() + dayBefore.setDate(dayBefore.getDate() - 2) + // 转换为系统时区的前天0点和23:59 + startDate = getSystemTimezoneDay(dayBefore, true) + endDate = getSystemTimezoneDay(dayBefore, false) + break + } + } + } else { + // 天粒度的预设 + startDate = new Date(now) + endDate = new Date(now) + + if (preset === 'today') { + // 今日:从凌晨开始 + startDate.setHours(0, 0, 0, 0) + endDate.setHours(23, 59, 59, 999) + } else { + // 其他预设:按天数计算 + startDate.setDate(now.getDate() - (option.days - 1)) + startDate.setHours(0, 0, 0, 0) + endDate.setHours(23, 59, 59, 999) + } + } + + dateFilter.value.customStart = startDate.toISOString().split('T')[0] + dateFilter.value.customEnd = endDate.toISOString().split('T')[0] + + // 设置 customRange 为 Element Plus 需要的格式 + // 对于小时粒度的昨天/前天,需要特殊处理显示 + if (trendGranularity.value === 'hour' && (preset === 'yesterday' || preset === 'dayBefore')) { + // 获取本地日期 + const targetDate = new Date() + if (preset === 'yesterday') { + targetDate.setDate(targetDate.getDate() - 1) + } else { + targetDate.setDate(targetDate.getDate() - 2) + } + + // 显示系统时区的完整一天 + const year = targetDate.getFullYear() + const month = String(targetDate.getMonth() + 1).padStart(2, '0') + const day = String(targetDate.getDate()).padStart(2, '0') + + dateFilter.value.customRange = [ + `${year}-${month}-${day} 00:00:00`, + `${year}-${month}-${day} 23:59:59` + ] + } else { + // 其他情况:近24小时或天粒度 + const formatDateForDisplay = (date) => { + // 固定使用UTC+8来显示时间 + const systemTz = 8 + const tzOffset = systemTz * 60 * 60 * 1000 + const localTime = new Date(date.getTime() + tzOffset) + + const year = localTime.getUTCFullYear() + const month = String(localTime.getUTCMonth() + 1).padStart(2, '0') + const day = String(localTime.getUTCDate()).padStart(2, '0') + const hours = String(localTime.getUTCHours()).padStart(2, '0') + const minutes = String(localTime.getUTCMinutes()).padStart(2, '0') + const seconds = String(localTime.getUTCSeconds()).padStart(2, '0') + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}` + } + + dateFilter.value.customRange = [ + formatDateForDisplay(startDate), + formatDateForDisplay(endDate) + ] + } + } + + // 触发数据刷新 + refreshChartsData() + } + + function onCustomDateRangeChange(value) { + if (value && value.length === 2) { + dateFilter.value.type = 'custom' + dateFilter.value.preset = '' // 清除预设选择 + dateFilter.value.customRange = value + dateFilter.value.customStart = value[0].split(' ')[0] + dateFilter.value.customEnd = value[1].split(' ')[0] + + // 检查日期范围限制 - value中的时间已经是系统时区时间 + // const systemTz = dashboardData.value.systemTimezone || 8 + + // 解析系统时区时间 + const parseSystemTime = (timeStr) => { + const [datePart, timePart] = timeStr.split(' ') + const [year, month, day] = datePart.split('-').map(Number) + const [hours, minutes, seconds] = timePart.split(':').map(Number) + return new Date(year, month - 1, day, hours, minutes, seconds) + } + + const start = parseSystemTime(value[0]) + const end = parseSystemTime(value[1]) + + if (trendGranularity.value === 'hour') { + // 小时粒度:限制 24 小时 + const hoursDiff = (end - start) / (1000 * 60 * 60) + if (hoursDiff > 24) { + showToast('小时粒度下日期范围不能超过24小时', 'warning') + return + } + } else { + // 天粒度:限制 31 天 + const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1 + if (daysDiff > 31) { + showToast('日期范围不能超过 31 天', 'warning') + return + } + } + + // 触发数据刷新 + refreshChartsData() + } else if (value === null) { + // 清空时恢复默认 + setDateFilterPreset(trendGranularity.value === 'hour' ? 'last24h' : '7days') + } + } + + function setTrendGranularity(granularity) { + trendGranularity.value = granularity + + // 根据粒度更新预设选项 + if (granularity === 'hour') { + dateFilter.value.presetOptions = [ + { value: 'last24h', label: '近24小时', hours: 24 }, + { value: 'yesterday', label: '昨天', hours: 24 }, + { value: 'dayBefore', label: '前天', hours: 24 } + ] + + // 检查当前自定义日期范围是否超过24小时 + if ( + dateFilter.value.type === 'custom' && + dateFilter.value.customRange && + dateFilter.value.customRange.length === 2 + ) { + const start = new Date(dateFilter.value.customRange[0]) + const end = new Date(dateFilter.value.customRange[1]) + const hoursDiff = (end - start) / (1000 * 60 * 60) + if (hoursDiff > 24) { + showToast('小时粒度下日期范围不能超过24小时,已切换到近24小时', 'warning') + setDateFilterPreset('last24h') + return + } + } + + // 如果当前是天粒度的预设,切换到小时粒度的默认预设 + if (['today', '7days', '30days'].includes(dateFilter.value.preset)) { + setDateFilterPreset('last24h') + return + } + } else { + // 天粒度 + dateFilter.value.presetOptions = [ + { value: 'today', label: '今日', days: 1 }, + { value: '7days', label: '7天', days: 7 }, + { value: '30days', label: '30天', days: 30 } + ] + + // 如果当前是小时粒度的预设,切换到天粒度的默认预设 + if (['last24h', 'yesterday', 'dayBefore'].includes(dateFilter.value.preset)) { + setDateFilterPreset('7days') + return + } + } + + // 触发数据刷新 + refreshChartsData() + } + + async function refreshChartsData() { + // 根据当前筛选条件刷新数据 + let days + let modelPeriod = 'monthly' + + if (dateFilter.value.type === 'preset') { + const option = dateFilter.value.presetOptions.find( + (opt) => opt.value === dateFilter.value.preset + ) + + if (trendGranularity.value === 'hour') { + // 小时粒度 + days = 1 // 小时粒度默认查看1天的数据 + modelPeriod = 'daily' // 小时粒度使用日统计 + } else { + // 天粒度 + days = option ? option.days : 7 + // 设置模型统计期间 + if (dateFilter.value.preset === 'today') { + modelPeriod = 'daily' + } else { + modelPeriod = 'monthly' + } + } + } else { + // 自定义日期范围 + if (trendGranularity.value === 'hour') { + // 小时粒度下的自定义范围,计算小时数 + const start = new Date(dateFilter.value.customRange[0]) + const end = new Date(dateFilter.value.customRange[1]) + const hoursDiff = Math.ceil((end - start) / (1000 * 60 * 60)) + days = Math.ceil(hoursDiff / 24) || 1 + } else { + days = calculateDaysBetween(dateFilter.value.customStart, dateFilter.value.customEnd) + } + modelPeriod = 'daily' // 自定义范围使用日统计 + } + + await Promise.all([ + loadUsageTrend(days, trendGranularity.value), + loadModelStats(modelPeriod), + loadApiKeysTrend(apiKeysTrendMetric.value), + loadAccountUsageTrend(accountUsageGroup.value) + ]) + } + + function setAccountUsageGroup(group) { + accountUsageGroup.value = group + return loadAccountUsageTrend(group) + } + + function calculateDaysBetween(start, end) { + if (!start || !end) return 7 + const startDate = new Date(start) + const endDate = new Date(end) + const diffTime = Math.abs(endDate - startDate) + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + return diffDays || 7 + } + + function disabledDate(date) { + return date > new Date() + } + + return { + // 状态 + loading, + dashboardData, + costsData, + modelStats, + trendData, + dashboardModelStats, + apiKeysTrendData, + accountUsageTrendData, + dateFilter, + trendGranularity, + apiKeysTrendMetric, + accountUsageGroup, + defaultTime, + + // 计算属性 + formattedUptime, + + // 方法 + loadDashboardData, + loadUsageTrend, + loadModelStats, + loadApiKeysTrend, + loadAccountUsageTrend, + setDateFilterPreset, + onCustomDateRangeChange, + setTrendGranularity, + refreshChartsData, + setAccountUsageGroup, + disabledDate + } +}) diff --git a/web/admin-spa/src/stores/settings.js b/web/admin-spa/src/stores/settings.js new file mode 100644 index 0000000000000000000000000000000000000000..986ce32743452ea83d651eb4bd3a3840b1ad1074 --- /dev/null +++ b/web/admin-spa/src/stores/settings.js @@ -0,0 +1,153 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient } from '@/config/api' + +export const useSettingsStore = defineStore('settings', () => { + // 状态 + const oemSettings = ref({ + siteName: 'Claude Relay Service', + siteIcon: '', + siteIconData: '', + showAdminButton: true, // 控制管理后台按钮的显示 + updatedAt: null + }) + + const loading = ref(false) + const saving = ref(false) + + // 移除自定义API请求方法,使用统一的apiClient + + // Actions + const loadOemSettings = async () => { + loading.value = true + try { + const result = await apiClient.get('/admin/oem-settings') + + if (result && result.success) { + oemSettings.value = { ...oemSettings.value, ...result.data } + + // 应用设置到页面 + applyOemSettings() + } + + return result + } catch (error) { + console.error('Failed to load OEM settings:', error) + throw error + } finally { + loading.value = false + } + } + + const saveOemSettings = async (settings) => { + saving.value = true + try { + const result = await apiClient.put('/admin/oem-settings', settings) + + if (result && result.success) { + oemSettings.value = { ...oemSettings.value, ...result.data } + + // 应用设置到页面 + applyOemSettings() + } + + return result + } catch (error) { + console.error('Failed to save OEM settings:', error) + throw error + } finally { + saving.value = false + } + } + + const resetOemSettings = async () => { + const defaultSettings = { + siteName: 'Claude Relay Service', + siteIcon: '', + siteIconData: '', + showAdminButton: true, + updatedAt: null + } + + oemSettings.value = { ...defaultSettings } + return await saveOemSettings(defaultSettings) + } + + // 应用OEM设置到页面 + const applyOemSettings = () => { + // 更新页面标题 + if (oemSettings.value.siteName) { + document.title = `${oemSettings.value.siteName} - 管理后台` + } + + // 更新favicon + if (oemSettings.value.siteIconData || oemSettings.value.siteIcon) { + const favicon = document.querySelector('link[rel="icon"]') || document.createElement('link') + favicon.rel = 'icon' + favicon.href = oemSettings.value.siteIconData || oemSettings.value.siteIcon + if (!document.querySelector('link[rel="icon"]')) { + document.head.appendChild(favicon) + } + } + } + + // 格式化日期时间 + const formatDateTime = (dateString) => { + if (!dateString) return '' + return new Date(dateString).toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }) + } + + // 验证文件上传 + const validateIconFile = (file) => { + const errors = [] + + // 检查文件大小 (350KB) + if (file.size > 350 * 1024) { + errors.push('图标文件大小不能超过 350KB') + } + + // 检查文件类型 + const allowedTypes = ['image/x-icon', 'image/png', 'image/jpeg', 'image/jpg', 'image/svg+xml'] + if (!allowedTypes.includes(file.type)) { + errors.push('不支持的文件类型,请选择 .ico, .png, .jpg 或 .svg 文件') + } + + return { + isValid: errors.length === 0, + errors + } + } + + // 将文件转换为Base64 + const fileToBase64 = (file) => { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = (e) => resolve(e.target.result) + reader.onerror = reject + reader.readAsDataURL(file) + }) + } + + return { + // State + oemSettings, + loading, + saving, + + // Actions + loadOemSettings, + saveOemSettings, + resetOemSettings, + applyOemSettings, + formatDateTime, + validateIconFile, + fileToBase64 + } +}) diff --git a/web/admin-spa/src/stores/theme.js b/web/admin-spa/src/stores/theme.js new file mode 100644 index 0000000000000000000000000000000000000000..e13acd68a0fdac02ec0e503984a0df0a8392693a --- /dev/null +++ b/web/admin-spa/src/stores/theme.js @@ -0,0 +1,149 @@ +import { defineStore } from 'pinia' +import { ref, computed, watch } from 'vue' + +// 主题模式枚举 +export const ThemeMode = { + LIGHT: 'light', + DARK: 'dark', + AUTO: 'auto' +} + +export const useThemeStore = defineStore('theme', () => { + // 状态 - 支持三种模式:light, dark, auto + const themeMode = ref(ThemeMode.AUTO) + const systemPrefersDark = ref(false) + + // 计算属性 - 实际的暗黑模式状态 + const isDarkMode = computed(() => { + if (themeMode.value === ThemeMode.DARK) { + return true + } else if (themeMode.value === ThemeMode.LIGHT) { + return false + } else { + // auto 模式,跟随系统 + return systemPrefersDark.value + } + }) + + // 计算属性 - 当前实际使用的主题 + const currentTheme = computed(() => { + return isDarkMode.value ? ThemeMode.DARK : ThemeMode.LIGHT + }) + + // 初始化主题 + const initTheme = () => { + // 检测系统主题偏好 + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') + systemPrefersDark.value = mediaQuery.matches + + // 从 localStorage 读取保存的主题模式 + const savedMode = localStorage.getItem('themeMode') + + if (savedMode && Object.values(ThemeMode).includes(savedMode)) { + themeMode.value = savedMode + } else { + // 默认使用 auto 模式 + themeMode.value = ThemeMode.AUTO + } + + // 应用主题 + applyTheme() + + // 开始监听系统主题变化 + watchSystemTheme() + } + + // 应用主题到 DOM + const applyTheme = () => { + const root = document.documentElement + + if (isDarkMode.value) { + root.classList.add('dark') + } else { + root.classList.remove('dark') + } + } + + // 设置主题模式 + const setThemeMode = (mode) => { + if (Object.values(ThemeMode).includes(mode)) { + themeMode.value = mode + } + } + + // 循环切换主题模式 + const cycleThemeMode = () => { + const modes = [ThemeMode.LIGHT, ThemeMode.DARK, ThemeMode.AUTO] + const currentIndex = modes.indexOf(themeMode.value) + const nextIndex = (currentIndex + 1) % modes.length + themeMode.value = modes[nextIndex] + } + + // 监听主题模式变化,自动保存到 localStorage 并应用 + watch(themeMode, (newMode) => { + localStorage.setItem('themeMode', newMode) + applyTheme() + }) + + // 监听系统主题偏好变化 + watch(systemPrefersDark, () => { + // 只有在 auto 模式下才需要重新应用主题 + if (themeMode.value === ThemeMode.AUTO) { + applyTheme() + } + }) + + // 监听系统主题变化 + const watchSystemTheme = () => { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') + + const handleChange = (e) => { + systemPrefersDark.value = e.matches + } + + // 初始检测 + systemPrefersDark.value = mediaQuery.matches + + // 添加监听器 + mediaQuery.addEventListener('change', handleChange) + + // 返回清理函数 + return () => { + mediaQuery.removeEventListener('change', handleChange) + } + } + + // 兼容旧版 API + const toggleTheme = () => { + cycleThemeMode() + } + + const setTheme = (theme) => { + if (theme === 'dark') { + setThemeMode(ThemeMode.DARK) + } else if (theme === 'light') { + setThemeMode(ThemeMode.LIGHT) + } + } + + return { + // State + themeMode, + isDarkMode, + currentTheme, + systemPrefersDark, + + // Constants + ThemeMode, + + // Actions + initTheme, + setThemeMode, + cycleThemeMode, + watchSystemTheme, + + // 兼容旧版 API + toggleTheme, + setTheme + } +}) diff --git a/web/admin-spa/src/stores/user.js b/web/admin-spa/src/stores/user.js new file mode 100644 index 0000000000000000000000000000000000000000..7a4ca30c4cca7782942fedd0ad99333dc559e785 --- /dev/null +++ b/web/admin-spa/src/stores/user.js @@ -0,0 +1,218 @@ +import { defineStore } from 'pinia' +import axios from 'axios' +import { showToast } from '@/utils/toast' +import { API_PREFIX } from '@/config/api' + +const API_BASE = `${API_PREFIX}/users` + +export const useUserStore = defineStore('user', { + state: () => ({ + user: null, + isAuthenticated: false, + sessionToken: null, + loading: false, + config: null + }), + + getters: { + isLoggedIn: (state) => state.isAuthenticated && state.user, + userName: (state) => state.user?.displayName || state.user?.username, + userRole: (state) => state.user?.role + }, + + actions: { + // 🔐 用户登录 + async login(credentials) { + this.loading = true + try { + const response = await axios.post(`${API_BASE}/login`, credentials) + + if (response.data.success) { + this.user = response.data.user + this.sessionToken = response.data.sessionToken + this.isAuthenticated = true + + // 保存到 localStorage + localStorage.setItem('userToken', this.sessionToken) + localStorage.setItem('userData', JSON.stringify(this.user)) + + // 设置 axios 默认头部 + this.setAuthHeader() + + return response.data + } else { + throw new Error(response.data.message || 'Login failed') + } + } catch (error) { + this.clearAuth() + throw error + } finally { + this.loading = false + } + }, + + // 🚪 用户登出 + async logout() { + try { + if (this.sessionToken) { + await axios.post( + `${API_BASE}/logout`, + {}, + { + headers: { 'x-user-token': this.sessionToken } + } + ) + } + } catch (error) { + console.error('Logout request failed:', error) + } finally { + this.clearAuth() + } + }, + + // 🔄 检查认证状态 + async checkAuth() { + const token = localStorage.getItem('userToken') + const userData = localStorage.getItem('userData') + const userConfig = localStorage.getItem('userConfig') + + if (!token || !userData) { + this.clearAuth() + return false + } + + try { + this.sessionToken = token + this.user = JSON.parse(userData) + this.config = userConfig ? JSON.parse(userConfig) : null + this.isAuthenticated = true + this.setAuthHeader() + + // 验证 token 是否仍然有效 + await this.getUserProfile() + return true + } catch (error) { + console.error('Auth check failed:', error) + this.clearAuth() + return false + } + }, + + // 👤 获取用户资料 + async getUserProfile() { + try { + const response = await axios.get(`${API_BASE}/profile`) + + if (response.data.success) { + this.user = response.data.user + this.config = response.data.config + localStorage.setItem('userData', JSON.stringify(this.user)) + localStorage.setItem('userConfig', JSON.stringify(this.config)) + return response.data.user + } + } catch (error) { + if (error.response?.status === 401 || error.response?.status === 403) { + // 401: Invalid/expired session, 403: Account disabled + this.clearAuth() + // If it's a disabled account error, throw a specific error + if (error.response?.status === 403) { + throw new Error(error.response.data?.message || 'Your account has been disabled') + } + } + throw error + } + }, + + // 🔑 获取用户API Keys + async getUserApiKeys(includeDeleted = false) { + try { + const params = {} + if (includeDeleted) { + params.includeDeleted = 'true' + } + const response = await axios.get(`${API_BASE}/api-keys`, { params }) + return response.data.success ? response.data.apiKeys : [] + } catch (error) { + console.error('Failed to fetch API keys:', error) + throw error + } + }, + + // 🔑 创建API Key + async createApiKey(keyData) { + try { + const response = await axios.post(`${API_BASE}/api-keys`, keyData) + return response.data + } catch (error) { + console.error('Failed to create API key:', error) + throw error + } + }, + + // 🗑️ 删除API Key + async deleteApiKey(keyId) { + try { + const response = await axios.delete(`${API_BASE}/api-keys/${keyId}`) + return response.data + } catch (error) { + console.error('Failed to delete API key:', error) + throw error + } + }, + + // 📊 获取使用统计 + async getUserUsageStats(params = {}) { + try { + const response = await axios.get(`${API_BASE}/usage-stats`, { params }) + return response.data.success ? response.data.stats : null + } catch (error) { + console.error('Failed to fetch usage stats:', error) + throw error + } + }, + + // 🧹 清除认证信息 + clearAuth() { + this.user = null + this.sessionToken = null + this.isAuthenticated = false + this.config = null + + localStorage.removeItem('userToken') + localStorage.removeItem('userData') + localStorage.removeItem('userConfig') + + // 清除 axios 默认头部 + delete axios.defaults.headers.common['x-user-token'] + }, + + // 🔧 设置认证头部 + setAuthHeader() { + if (this.sessionToken) { + axios.defaults.headers.common['x-user-token'] = this.sessionToken + } + }, + + // 🔧 设置axios拦截器 + setupAxiosInterceptors() { + // Response interceptor to handle disabled user responses globally + axios.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 403) { + const message = error.response.data?.message + if (message && (message.includes('disabled') || message.includes('Account disabled'))) { + this.clearAuth() + showToast(message, 'error') + // Redirect to login page + if (window.location.pathname !== '/user-login') { + window.location.href = '/user-login' + } + } + } + return Promise.reject(error) + } + ) + } + } +}) diff --git a/web/admin-spa/src/utils/format.js b/web/admin-spa/src/utils/format.js new file mode 100644 index 0000000000000000000000000000000000000000..dba9a58bca9fad4e9a29968eebd4228f323fc55d --- /dev/null +++ b/web/admin-spa/src/utils/format.js @@ -0,0 +1,74 @@ +// 数字格式化函数 +export function formatNumber(num) { + if (num === null || num === undefined) return '0' + + const absNum = Math.abs(num) + + if (absNum >= 1e9) { + return (num / 1e9).toFixed(2) + 'B' + } else if (absNum >= 1e6) { + return (num / 1e6).toFixed(2) + 'M' + } else if (absNum >= 1e3) { + return (num / 1e3).toFixed(1) + 'K' + } + + return num.toLocaleString() +} + +// 日期格式化函数 +export function formatDate(date, format = 'YYYY-MM-DD HH:mm:ss') { + if (!date) return '' + + const d = new Date(date) + + const year = d.getFullYear() + const month = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + const hours = String(d.getHours()).padStart(2, '0') + const minutes = String(d.getMinutes()).padStart(2, '0') + const seconds = String(d.getSeconds()).padStart(2, '0') + + return format + .replace('YYYY', year) + .replace('MM', month) + .replace('DD', day) + .replace('HH', hours) + .replace('mm', minutes) + .replace('ss', seconds) +} + +// 相对时间格式化 +export function formatRelativeTime(date) { + if (!date) return '' + + const now = new Date() + const past = new Date(date) + const diffMs = now - past + const diffSecs = Math.floor(diffMs / 1000) + const diffMins = Math.floor(diffSecs / 60) + const diffHours = Math.floor(diffMins / 60) + const diffDays = Math.floor(diffHours / 24) + + if (diffDays > 0) { + return `${diffDays}天前` + } else if (diffHours > 0) { + return `${diffHours}小时前` + } else if (diffMins > 0) { + return `${diffMins}分钟前` + } else { + return '刚刚' + } +} + +// 字节格式化 +export function formatBytes(bytes, decimals = 2) { + if (bytes === 0) return '0 Bytes' + + const k = 1024 + const dm = decimals < 0 ? 0 : decimals + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] + + const i = Math.floor(Math.log(bytes) / Math.log(k)) + + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i] +} diff --git a/web/admin-spa/src/utils/toast.js b/web/admin-spa/src/utils/toast.js new file mode 100644 index 0000000000000000000000000000000000000000..3e50bd273fdc1c4c9148efe4e4b4e9b4f8dfb936 --- /dev/null +++ b/web/admin-spa/src/utils/toast.js @@ -0,0 +1,71 @@ +// Toast 通知管理 +let toastContainer = null +let toastId = 0 + +export function showToast(message, type = 'info', title = '', duration = 3000) { + // 创建容器 + if (!toastContainer) { + toastContainer = document.createElement('div') + toastContainer.id = 'toast-container' + toastContainer.style.cssText = 'position: fixed; top: 20px; right: 20px; z-index: 10000;' + document.body.appendChild(toastContainer) + } + + // 创建 toast + const id = ++toastId + const toast = document.createElement('div') + toast.className = `toast rounded-2xl p-4 shadow-2xl backdrop-blur-sm toast-${type}` + toast.style.cssText = ` + position: relative; + min-width: 320px; + max-width: 500px; + margin-bottom: 16px; + transform: translateX(100%); + transition: transform 0.3s ease-in-out; + ` + + const iconMap = { + success: 'fas fa-check-circle', + error: 'fas fa-times-circle', + warning: 'fas fa-exclamation-triangle', + info: 'fas fa-info-circle' + } + + // 处理消息中的换行符,转换为 HTML 换行 + const formattedMessage = message.replace(/\n/g, '
') + + toast.innerHTML = ` +
+
+ +
+
+ ${title ? `

${title}

` : ''} +

${formattedMessage}

+
+ +
+ ` + + toastContainer.appendChild(toast) + + // 触发动画 + setTimeout(() => { + toast.style.transform = 'translateX(0)' + }, 10) + + // 自动移除 + if (duration > 0) { + setTimeout(() => { + toast.style.transform = 'translateX(100%)' + setTimeout(() => { + toast.remove() + }, 300) + }, duration) + } + + return id +} diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue new file mode 100644 index 0000000000000000000000000000000000000000..a4c8de6e6defe677d5e98c24ca5f5935fd5a2689 --- /dev/null +++ b/web/admin-spa/src/views/AccountsView.vue @@ -0,0 +1,3845 @@ + + + + + diff --git a/web/admin-spa/src/views/ApiKeysView.vue b/web/admin-spa/src/views/ApiKeysView.vue new file mode 100644 index 0000000000000000000000000000000000000000..f24d66bf234f146bafe0f0a7781f310b31db7fc6 --- /dev/null +++ b/web/admin-spa/src/views/ApiKeysView.vue @@ -0,0 +1,4156 @@ + + + + + diff --git a/web/admin-spa/src/views/ApiStatsView.vue b/web/admin-spa/src/views/ApiStatsView.vue new file mode 100644 index 0000000000000000000000000000000000000000..fb3296bc8cc2780cdea9ccd2bb30ec53f5ef7325 --- /dev/null +++ b/web/admin-spa/src/views/ApiStatsView.vue @@ -0,0 +1,608 @@ + + + + + diff --git a/web/admin-spa/src/views/DashboardView.vue b/web/admin-spa/src/views/DashboardView.vue new file mode 100644 index 0000000000000000000000000000000000000000..6a19cd24b3485c301f1b60e69f57b799869d2cac --- /dev/null +++ b/web/admin-spa/src/views/DashboardView.vue @@ -0,0 +1,1618 @@ + + + + + diff --git a/web/admin-spa/src/views/LoginView.vue b/web/admin-spa/src/views/LoginView.vue new file mode 100644 index 0000000000000000000000000000000000000000..62138897df18c536efb711589a5cc5e1d51d2ddf --- /dev/null +++ b/web/admin-spa/src/views/LoginView.vue @@ -0,0 +1,127 @@ + + + + + diff --git a/web/admin-spa/src/views/SettingsView.vue b/web/admin-spa/src/views/SettingsView.vue new file mode 100644 index 0000000000000000000000000000000000000000..ee68ab1136de1dbbfe7a34327d01f8692b2bda49 --- /dev/null +++ b/web/admin-spa/src/views/SettingsView.vue @@ -0,0 +1,2104 @@ + + + + + diff --git a/web/admin-spa/src/views/TutorialView.vue b/web/admin-spa/src/views/TutorialView.vue new file mode 100644 index 0000000000000000000000000000000000000000..373c7019f4e2f70d6bd9706e54b2278e2319aa1e --- /dev/null +++ b/web/admin-spa/src/views/TutorialView.vue @@ -0,0 +1,2534 @@ + + + + + diff --git a/web/admin-spa/src/views/UserDashboardView.vue b/web/admin-spa/src/views/UserDashboardView.vue new file mode 100644 index 0000000000000000000000000000000000000000..c6774be52d586f2b7047ce7c5292d411144485c7 --- /dev/null +++ b/web/admin-spa/src/views/UserDashboardView.vue @@ -0,0 +1,436 @@ + + + + + diff --git a/web/admin-spa/src/views/UserLoginView.vue b/web/admin-spa/src/views/UserLoginView.vue new file mode 100644 index 0000000000000000000000000000000000000000..82631e262feaf4a3309b1e0e51f2220db940f53f --- /dev/null +++ b/web/admin-spa/src/views/UserLoginView.vue @@ -0,0 +1,201 @@ + + + + + diff --git a/web/admin-spa/src/views/UserManagementView.vue b/web/admin-spa/src/views/UserManagementView.vue new file mode 100644 index 0000000000000000000000000000000000000000..dbe1e5f0ac6867275a3cf1164e4ea7a7792f50d9 --- /dev/null +++ b/web/admin-spa/src/views/UserManagementView.vue @@ -0,0 +1,675 @@ + + + + + diff --git a/web/admin-spa/tailwind.config.js b/web/admin-spa/tailwind.config.js new file mode 100644 index 0000000000000000000000000000000000000000..55192839b419c035290395893f5180fc41508900 --- /dev/null +++ b/web/admin-spa/tailwind.config.js @@ -0,0 +1,36 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'], + darkMode: 'class', + theme: { + extend: { + animation: { + gradient: 'gradient 8s ease infinite', + float: 'float 6s ease-in-out infinite', + 'float-delayed': 'float 6s ease-in-out infinite 2s', + 'pulse-glow': 'pulse-glow 2s ease-in-out infinite' + }, + keyframes: { + gradient: { + '0%, 100%': { + 'background-size': '200% 200%', + 'background-position': 'left center' + }, + '50%': { + 'background-size': '200% 200%', + 'background-position': 'right center' + } + }, + float: { + '0%, 100%': { transform: 'translateY(0px)' }, + '50%': { transform: 'translateY(-10px)' } + }, + 'pulse-glow': { + '0%, 100%': { opacity: 1 }, + '50%': { opacity: 0.8 } + } + } + } + }, + plugins: [] +} diff --git a/web/admin-spa/vite.config.js b/web/admin-spa/vite.config.js new file mode 100644 index 0000000000000000000000000000000000000000..7b677438cd920f046ee387908ece50a613152acb --- /dev/null +++ b/web/admin-spa/vite.config.js @@ -0,0 +1,127 @@ +import { defineConfig, loadEnv } from 'vite' +import vue from '@vitejs/plugin-vue' +import checker from 'vite-plugin-checker' +import AutoImport from 'unplugin-auto-import/vite' +import Components from 'unplugin-vue-components/vite' +import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' +import { fileURLToPath, URL } from 'node:url' + +export default defineConfig(({ mode }) => { + // 加载环境变量 + const env = loadEnv(mode, process.cwd(), '') + const apiTarget = env.VITE_API_TARGET || 'http://localhost:3000' + const httpProxy = env.VITE_HTTP_PROXY || env.HTTP_PROXY || env.http_proxy + // 使用环境变量配置基础路径,如果未设置则使用默认值 + const basePath = env.VITE_APP_BASE_URL || (mode === 'development' ? '/admin/' : '/admin-next/') + + // 创建代理配置 + const proxyConfig = { + target: apiTarget, + changeOrigin: true, + secure: false + } + + // 如果设置了代理,动态导入并配置 agent(仅在开发模式下) + if (httpProxy && mode === 'development') { + console.log(`Using HTTP proxy: ${httpProxy}`) + // Vite 的 proxy 使用 http-proxy,它支持通过环境变量自动使用代理 + // 设置环境变量让 http-proxy 使用代理 + process.env.HTTP_PROXY = httpProxy + process.env.HTTPS_PROXY = httpProxy + } + + console.log( + `${mode === 'development' ? 'Starting dev server' : 'Building'} with base path: ${basePath}` + ) + + return { + base: basePath, + plugins: [ + vue(), + checker({ + eslint: { + lintCommand: 'eslint "./src/**/*.{js,vue}" --cache=false', + dev: { + logLevel: ['error', 'warning'] + } + } + }), + AutoImport({ + resolvers: [ElementPlusResolver()], + imports: ['vue', 'vue-router', 'pinia'] + }), + Components({ + resolvers: [ElementPlusResolver()] + }) + ], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + } + }, + server: { + port: 3001, + host: true, + open: true, + proxy: { + // 统一的 API 代理规则 - 开发环境所有 API 请求都加 /webapi 前缀 + '/webapi': { + ...proxyConfig, + rewrite: (path) => path.replace(/^\/webapi/, ''), // 转发时去掉 /webapi 前缀 + configure: (proxy, options) => { + proxy.on('proxyReq', (proxyReq, req) => { + console.log( + 'Proxying:', + req.method, + req.url, + '->', + options.target + req.url.replace(/^\/webapi/, '') + ) + }) + proxy.on('error', (err) => { + console.log('Proxy error:', err) + }) + } + }, + // API Stats 专用代理规则 + '/apiStats': { + ...proxyConfig, + configure: (proxy, options) => { + proxy.on('proxyReq', (proxyReq, req) => { + console.log( + 'API Stats Proxying:', + req.method, + req.url, + '->', + options.target + req.url + ) + }) + } + } + } + }, + build: { + outDir: 'dist', + assetsDir: 'assets', + rollupOptions: { + output: { + manualChunks(id) { + // 将 vue 相关的库打包到一起 + if (id.includes('node_modules')) { + if (id.includes('element-plus')) { + return 'element-plus' + } + if (id.includes('chart.js')) { + return 'chart' + } + if (id.includes('vue') || id.includes('pinia') || id.includes('vue-router')) { + return 'vue-vendor' + } + return 'vendor' + } + } + } + } + } + } +})