Merge remote-tracking branch 'upstream/main'

This commit is contained in:
bensonz
2026-02-10 22:14:17 +08:00
86 changed files with 3870 additions and 2626 deletions

View File

@@ -155,6 +155,12 @@ DEBUG_HTTP_TRAFFIC=false # 启用HTTP请求/响应调试日志(仅开发环
ENABLE_CORS=true ENABLE_CORS=true
TRUST_PROXY=true TRUST_PROXY=true
# ⏱️ 上游错误自动暂停配置(秒)
# UPSTREAM_ERROR_5XX_TTL_SECONDS=300 # 5xx错误暂停时间默认5分钟
# UPSTREAM_ERROR_OVERLOAD_TTL_SECONDS=600 # 529过载暂停时间默认10分钟
# UPSTREAM_ERROR_AUTH_TTL_SECONDS=1800 # 401/403认证错误暂停时间默认30分钟
# UPSTREAM_ERROR_TIMEOUT_TTL_SECONDS=300 # 504超时暂停时间默认5分钟
# 🔒 客户端限制(可选) # 🔒 客户端限制(可选)
# ALLOW_CUSTOM_CLIENTS=false # ALLOW_CUSTOM_CLIENTS=false

724
CLAUDE.md
View File

@@ -1,637 +1,167 @@
# CLAUDE.md # CLAUDE.md <260209.0>
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. This file provides guidance to Claude Code when working with this repository.
这个文件为 Claude Code (claude.ai/code) 提供在此代码库中工作的指导。
## 项目概述 ## 项目概述
Claude Relay Service 是一个多平台 AI API 中转服务,支持 **Claude (官方/Console)、Gemini、OpenAI Responses (Codex)、AWS Bedrock、Azure OpenAI、Droid (Factory.ai)、CCR** 等多种账户类型。提供完整的多账户管理、API Key 认证、代理配置、用户管理、LDAP认证、Webhook通知和现代化 Web 管理界面。该服务作为客户端(如 Claude Code、Gemini CLI、Codex、Droid CLI、Cherry Studio 等)与 AI API 之间的中间件,提供认证、限流、监控、定价计算、成本统计等功能 Claude Relay Service 多平台 AI API 中转服务,作为客户端与上游 AI API 之间的中间件
支持 Claude (官方/Console)、Gemini、OpenAI Responses、AWS Bedrock、Azure OpenAI、Droid、CCR 等账户类型。
核心能力多账户管理、API Key 认证、统一调度、代理配置、限流、成本统计。
## 核心架构 ## 架构原则
### 关键架构概念 ### Clean Architecture 分层映射
- **统一调度系统**: 使用 unifiedClaudeScheduler、unifiedGeminiScheduler、unifiedOpenAIScheduler、droidScheduler 实现跨账户类型的智能调度 | 层级 | 目录 | 职责 |
- **多账户类型支持**: 支持 claude-official、claude-console、bedrock、ccr、droid、gemini、openai-responses、azure-openai 等账户类型 |------|------|------|
- **代理认证流**: 客户端用自建API Key → 验证 → 统一调度器选择账户 → 获取账户token → 转发到对应API | **框架层** | `src/routes/`, `src/middleware/` | HTTP 路由、请求验证、响应格式化 |
- **Token管理**: 自动监控OAuth token过期并刷新支持10秒提前刷新策略 | **接口适配层** | `src/handlers/`, `src/services/openaiToClaude.js` | 请求/响应格式转换 |
- **代理支持**: 每个账户支持独立代理配置OAuth token交换也通过代理进行 | **用例层** | `src/services/*Scheduler.js`, `*RelayService.js` | 调度逻辑、转发编排 |
- **数据加密**: 敏感数据refreshToken, accessToken, credentials使用AES加密存储在Redis | **实体层** | `src/services/*AccountService.js`, `src/models/` | 账户管理、数据模型 |
- **粘性会话**: 支持会话级别的账户绑定,同一会话使用同一账户,确保上下文连续性 | **基础设施层** | `src/utils/`, `config/` | 日志、缓存、加密、代理 |
- **权限控制**: API Key支持权限配置all/claude/gemini/openai等控制可访问的服务类型
- **客户端限制**: 基于User-Agent的客户端识别和限制支持ClaudeCode、Gemini-CLI等预定义客户端
- **模型黑名单**: 支持API Key级别的模型访问限制
- **并发请求排队**: 当API Key并发数超限时请求进入队列等待而非立即返回429支持配置最大排队数、超时时间适用于Claude Code Agent并行工具调用场景
### 主要服务组件 ### 开发原则
#### 核心转发服务 - **依赖方向**: 外层 → 内层,内层不知道外层存在
- **新增路由**: 只做参数提取和响应格式化,业务逻辑放 service
- **新增服务**: 先确定属于哪一层,遵循该层职责边界
- **格式转换**: 不同 API 格式的转换放 handlers 或专用转换服务
- **数据访问**: 通过 `src/models/redis.js` 统一访问
- **claudeRelayService.js**: Claude官方API转发处理OAuth认证和流式响应 ### 安全约束
- **claudeConsoleRelayService.js**: Claude Console账户转发服务
- **geminiRelayService.js**: Gemini API转发服务
- **bedrockRelayService.js**: AWS Bedrock API转发服务
- **azureOpenaiRelayService.js**: Azure OpenAI API转发服务
- **droidRelayService.js**: Droid (Factory.ai) API转发服务
- **ccrRelayService.js**: CCR账户转发服务
- **openaiResponsesRelayService.js**: OpenAI Responses (Codex) 转发服务
#### 账户管理服务 - 敏感数据OAuth token、refreshToken、credentials必须 AES 加密存储(参考 `claudeAccountService.js`
- API Key 使用 SHA-256 哈希存储,禁止明文
- 每个请求必须经过完整认证链API Key → 权限 → 客户端限制 → 模型黑名单)
- 客户端断开时必须通过 AbortController 清理资源和并发计数
- 日志中禁止输出完整 token使用 `tokenMask.js` 脱敏
- **claudeAccountService.js**: Claude官方账户管理OAuth token刷新和账户选择 ## 项目结构
- **claudeConsoleAccountService.js**: Claude Console账户管理
- **geminiAccountService.js**: Gemini账户管理Google OAuth token刷新
- **bedrockAccountService.js**: AWS Bedrock账户管理
- **azureOpenaiAccountService.js**: Azure OpenAI账户管理
- **droidAccountService.js**: Droid账户管理
- **ccrAccountService.js**: CCR账户管理
- **openaiResponsesAccountService.js**: OpenAI Responses账户管理
- **openaiAccountService.js**: OpenAI兼容账户管理
- **accountGroupService.js**: 账户组管理,支持账户分组和优先级
#### 统一调度器 ```
src/
- **unifiedClaudeScheduler.js**: Claude多账户类型统一调度claude-official/console/bedrock/ccr ├── routes/ # HTTP 路由
- **unifiedGeminiScheduler.js**: Gemini账户统一调度 │ ├── api.js # Claude API 主路由
- **unifiedOpenAIScheduler.js**: OpenAI兼容服务统一调度 │ ├── admin/ # 管理后台路由24个子文件
- **droidScheduler.js**: Droid账户调度 │ ├── geminiRoutes.js, standardGeminiRoutes.js
│ ├── openaiRoutes.js, openaiClaudeRoutes.js, openaiGeminiRoutes.js
#### 核心功能服务 │ ├── azureOpenaiRoutes.js, droidRoutes.js
│ ├── userRoutes.js, webhook.js, unified.js, apiStats.js, web.js
- **apiKeyService.js**: API Key管理验证、限流、使用统计、成本计算 ├── middleware/ # auth.js(认证/权限/限流), browserFallback.js
- **userService.js**: 用户管理系统支持用户注册、登录、API Key管理 ├── handlers/ # geminiHandlers.js
- **userMessageQueueService.js**: 用户消息串行队列,防止同账户并发用户消息触发限流 ├── services/ # 业务服务
- **pricingService.js**: 定价服务,模型价格管理和成本计算 │ ├── relay/ # 各平台转发服务9个
- **costInitService.js**: 成本数据初始化服务 │ ├── account/ # 各平台账户管理11个
- **webhookService.js**: Webhook通知服务 │ ├── scheduler/ # 统一调度器4个
- **webhookConfigService.js**: Webhook配置管理 │ ├── apiKeyService.js # API Key 管理
- **ldapService.js**: LDAP认证服务 │ ├── pricingService.js # 定价和成本
- **tokenRefreshService.js**: Token自动刷新服务 │ └── ... # 其余 ~30 个业务服务
- **rateLimitCleanupService.js**: 速率限制状态清理服务 ├── models/redis.js # Redis 数据模型
- **claudeCodeHeadersService.js**: Claude Code客户端请求头处理 ├── utils/ # 35+ 工具文件logger, proxy, oauth, cache, stream...
config/config.js # 主配置
#### 工具服务 scripts/ # 运维脚本
cli/ # CLI 工具
- **oauthHelper.js**: OAuth工具PKCE流程实现和代理支持 web/admin-spa/ # Vue SPA 管理界面
- **workosOAuthHelper.js**: WorkOS OAuth集成 data/init.json # 管理员凭据
- **openaiToClaude.js**: OpenAI格式到Claude格式的转换
### 认证和代理流程
1. 客户端使用自建API Keycr\_前缀格式发送请求到对应路由/api、/claude、/gemini、/openai、/droid等
2. **authenticateApiKey中间件**验证API Key有效性、速率限制、权限、客户端限制、模型黑名单
3. **统一调度器**(如 unifiedClaudeScheduler根据请求模型、会话hash、API Key权限选择最优账户
4. 检查选中账户的token有效性过期则自动刷新使用代理
5. 根据账户类型调用对应的转发服务claudeRelayService、geminiRelayService等
6. 移除客户端API Key使用账户凭据OAuth Bearer token、API Key等转发请求
7. 通过账户配置的代理发送到目标APIAnthropic、Google、AWS等
8. 流式或非流式返回响应捕获真实usage数据
9. 记录使用统计input/output/cache_create/cache_read tokens和成本计算
10. 更新速率限制计数器和并发控制
### OAuth集成
- **PKCE流程**: 完整的OAuth 2.0 PKCE实现支持代理
- **自动刷新**: 智能token过期检测和自动刷新机制
- **代理支持**: OAuth授权和token交换全程支持代理配置
- **安全存储**: claudeAiOauth数据加密存储包含accessToken、refreshToken、scopes
## 新增功能概览(相比旧版本)
### 多平台支持
-**Claude Console账户**: 支持Claude Console类型账户
-**AWS Bedrock**: 完整的AWS Bedrock API支持
-**Azure OpenAI**: Azure OpenAI服务支持
-**Droid (Factory.ai)**: Factory.ai API支持
-**CCR账户**: CCR凭据支持
-**OpenAI兼容**: OpenAI格式转换和Responses格式支持
### 用户和权限系统
-**用户管理**: 完整的用户注册、登录、API Key管理系统
-**LDAP认证**: 企业级LDAP/Active Directory集成
-**权限控制**: API Key级别的服务权限all/claude/gemini/openai
-**客户端限制**: 基于User-Agent的客户端识别和限制
-**模型黑名单**: API Key级别的模型访问控制
### 统一调度和会话管理
-**统一调度器**: 跨账户类型的智能调度系统
-**粘性会话**: 会话级账户绑定,支持自动续期
-**并发控制**: Redis Sorted Set实现的并发限制
-**负载均衡**: 自动账户选择和故障转移
### 成本和监控
-**定价服务**: 模型价格管理和自动成本计算
-**成本统计**: 详细的token使用和费用统计
-**缓存监控**: 全局缓存统计和命中率分析
-**实时指标**: 可配置窗口的实时统计METRICS_WINDOW
### Webhook和通知
-**Webhook系统**: 事件通知和Webhook配置管理
-**多URL支持**: 支持多个Webhook URL逗号分隔
### 高级功能
-**529错误处理**: 自动识别Claude过载状态并暂时排除账户
-**HTTP调试**: DEBUG_HTTP_TRAFFIC模式详细记录HTTP请求/响应
-**数据迁移**: 完整的数据导入导出工具(含加密/脱敏)
-**自动清理**: 并发计数、速率限制、临时错误状态自动清理
## 常用命令
### 基本开发命令
````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密码可选
#### 新增重要环境变量(可选)
- `USER_MANAGEMENT_ENABLED`: 启用用户管理系统默认false
- `LDAP_ENABLED`: 启用LDAP认证默认false
- `LDAP_URL`: LDAP服务器地址如 ldaps://ldap.example.com:636
- `LDAP_TLS_REJECT_UNAUTHORIZED`: LDAP证书验证默认true
- `WEBHOOK_ENABLED`: 启用Webhook通知默认true
- `WEBHOOK_URLS`: Webhook通知URL列表逗号分隔
- `CLAUDE_OVERLOAD_HANDLING_MINUTES`: Claude 529错误处理持续时间分钟0表示禁用
- `STICKY_SESSION_TTL_HOURS`: 粘性会话TTL小时默认1
- `STICKY_SESSION_RENEWAL_THRESHOLD_MINUTES`: 粘性会话续期阈值分钟默认0
- `USER_MESSAGE_QUEUE_ENABLED`: 启用用户消息串行队列默认false
- `USER_MESSAGE_QUEUE_DELAY_MS`: 用户消息请求间隔毫秒默认200
- `USER_MESSAGE_QUEUE_TIMEOUT_MS`: 队列等待超时毫秒默认5000锁持有时间短无需长等待
- `USER_MESSAGE_QUEUE_LOCK_TTL_MS`: 锁TTL毫秒默认5000请求发送后立即释放无需长TTL
- `METRICS_WINDOW`: 实时指标统计窗口分钟1-60默认5
- `MAX_API_KEYS_PER_USER`: 每用户最大API Key数量默认1
- `ALLOW_USER_DELETE_API_KEYS`: 允许用户删除自己的API Keys默认false
- `DEBUG_HTTP_TRAFFIC`: 启用HTTP请求/响应调试日志默认false仅开发环境
- `PROXY_USE_IPV4`: 代理使用IPv4默认true
- `REQUEST_TIMEOUT`: 请求超时时间毫秒默认600000即10分钟
- `CLEAR_CONCURRENCY_QUEUES_ON_STARTUP`: 启动时清理残留的并发排队计数器默认true多实例部署时建议设为false
#### AWS Bedrock配置可选
- `CLAUDE_CODE_USE_BEDROCK`: 启用Bedrock设置为1启用
- `AWS_REGION`: AWS默认区域默认us-east-1
- `ANTHROPIC_MODEL`: Bedrock默认模型
- `ANTHROPIC_SMALL_FAST_MODEL`: Bedrock小型快速模型
- `ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION`: 小型模型区域
- `CLAUDE_CODE_MAX_OUTPUT_TOKENS`: 最大输出tokens默认4096
- `MAX_THINKING_TOKENS`: 最大思考tokens默认1024
- `DISABLE_PROMPT_CACHING`: 禁用提示缓存设置为1禁用
#### 初始化命令
```bash
cp config/config.example.js config/config.js
cp .env.example .env
npm run setup # 自动生成密钥并创建管理员账户
``` ```
## Web界面功能 ## 核心请求流程
### OAuth账户添加流程 ```
客户端(cr_前缀Key) → 路由 → auth中间件(验证/权限/限流/模型黑名单)
→ 统一调度器(选账户/粘性会话) → Token检查/刷新
→ 转发服务(通过代理发送) → 上游API
→ 流式/非流式响应 → Usage捕获 → 成本计算 → 返回客户端
```
1. **基本信息和代理设置**: 配置账户名称、描述和代理参数 关键机制:
2. **OAuth授权**: - **粘性会话**: 基于请求内容 hash 绑定账户,同一会话用同一账户
- 生成授权URL → 用户打开链接并登录Claude Code账号 - **并发控制**: Redis Sorted Set 实现,支持排队等待(非直接 429
- 授权后会显示Authorization Code → 复制并粘贴到输入框 - **529 处理**: 自动标记过载账户,配置时长内排除
- 系统自动交换token并创建账户 - **加密存储**: 敏感数据OAuth token、credentialsAES 加密存于 Redis
- **流式响应**: SSE 传输,实时捕获 usage客户端断开时 AbortController 清理资源
### 核心管理功能 ## 开发规范
- **实时仪表板**: 系统统计、账户状态、使用量监控、实时指标METRICS_WINDOW配置窗口 ### 代码格式化
- **API Key管理**: 创建、配额设置、使用统计查看、权限配置、客户端限制、模型黑名单
- **多平台账户管理**:
- Claude账户官方/Console: OAuth账户添加、代理配置、状态监控
- Gemini账户: Google OAuth授权、代理配置
- OpenAI Responses (Codex)账户: API Key配置
- AWS Bedrock账户: AWS凭据配置
- Azure OpenAI账户: Azure凭据和端点配置
- Droid账户: Factory.ai API Key配置
- CCR账户: CCR凭据配置
- **用户管理**: 用户注册、登录、API Key分配USER_MANAGEMENT_ENABLED启用时
- **系统日志**: 实时日志查看多级别过滤HTTP调试日志DEBUG_HTTP_TRAFFIC启用时
- **Webhook配置**: Webhook URL管理、事件配置
- **主题系统**: 支持明亮/暗黑模式切换,自动保存用户偏好设置
- **成本分析**: 详细的token使用和成本统计基于pricingService
- **缓存监控**: 解密缓存统计和性能监控
## 重要端点 - **必须使用 Prettier**: `npx prettier --write <file>`
- 前端额外安装了 `prettier-plugin-tailwindcss`
### API转发端点多路由支持 - 提交前检查:`npx prettier --check <file>`
#### Claude服务路由
- `POST /api/v1/messages` - Claude消息处理支持流式
- `POST /claude/v1/messages` - Claude消息处理别名路由
- `POST /v1/messages/count_tokens` - Token计数Beta API
- `GET /api/v1/models` - 模型列表
- `GET /api/v1/usage` - 使用统计查询
- `GET /api/v1/key-info` - API Key信息
- `GET /v1/me` - 用户信息Claude Code客户端需要
- `GET /v1/organizations/:org_id/usage` - 组织使用统计
#### Gemini服务路由
- `POST /gemini/v1/models/:model:generateContent` - 标准Gemini API格式
- `POST /gemini/v1/models/:model:streamGenerateContent` - Gemini流式
- `GET /gemini/v1/models` - Gemini模型列表
- 其他Gemini兼容路由保持向后兼容
#### OpenAI兼容路由
- `POST /openai/v1/chat/completions` - OpenAI格式转发支持responses格式
- `POST /openai/claude/v1/chat/completions` - OpenAI格式转Claude
- `POST /openai/gemini/v1/chat/completions` - OpenAI格式转Gemini
- `GET /openai/v1/models` - OpenAI格式模型列表
#### Droid (Factory.ai) 路由
- `POST /droid/claude/v1/messages` - Droid Claude转发
- `POST /droid/openai/v1/chat/completions` - Droid OpenAI转发
#### Azure OpenAI 路由
- `POST /azure/...` - Azure OpenAI API转发
### 管理端点
#### OAuth和账户管理
- `POST /admin/claude-accounts/generate-auth-url` - 生成OAuth授权URL含代理
- `POST /admin/claude-accounts/exchange-code` - 交换authorization code
- `POST /admin/claude-accounts` - 创建Claude OAuth账户
- 各平台账户CRUD端点gemini、openai、bedrock、azure、droid、ccr
#### 用户管理USER_MANAGEMENT_ENABLED启用时
- `POST /users/register` - 用户注册
- `POST /users/login` - 用户登录
- `GET /users/profile` - 用户资料
- `POST /users/api-keys` - 创建用户API Key
#### Webhook管理
- `GET /admin/webhook/configs` - 获取Webhook配置
- `POST /admin/webhook/configs` - 创建Webhook配置
- `PUT /admin/webhook/configs/:id` - 更新Webhook配置
- `DELETE /admin/webhook/configs/:id` - 删除Webhook配置
### 系统端点
- `GET /health` - 健康检查(包含组件状态、版本、内存等)
- `GET /metrics` - 系统指标使用统计、uptime、内存
- `GET /web` - 传统Web管理界面
- `GET /admin-next/` - 新版SPA管理界面主界面
- `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服务运行检查REDIS_HOST、REDIS_PORT、REDIS_PASSWORD配置
2. **管理员登录失败**: 检查data/init.json存在运行npm run setup重新初始化
3. **API Key格式错误**: 确保使用cr\_前缀格式可通过API_KEY_PREFIX配置修改
4. **代理连接问题**: 验证SOCKS5/HTTP代理配置和认证信息检查PROXY_USE_IPV4设置
5. **粘性会话失效**: 检查Redis中session数据确认STICKY_SESSION_TTL_HOURS配置通过Nginx代理时需添加 `underscores_in_headers on;`
6. **LDAP认证失败**:
- 检查LDAP_URL、LDAP_BIND_DN、LDAP_BIND_PASSWORD配置
- 自签名证书问题:设置 LDAP_TLS_REJECT_UNAUTHORIZED=false
- 查看日志中的LDAP连接错误详情
7. **用户管理功能不可用**: 确认USER_MANAGEMENT_ENABLED=true检查userService初始化
8. **Webhook通知失败**:
- 确认WEBHOOK_ENABLED=true
- 检查WEBHOOK_URLS格式逗号分隔
- 查看logs/webhook-*.log日志
9. **统一调度器选择账户失败**:
- 检查账户状态status: 'active'
- 确认账户类型与请求路由匹配
- 查看粘性会话绑定情况
10. **并发计数泄漏**: 系统每分钟自动清理过期并发计数concurrency cleanup task重启时也会自动清理
11. **速率限制未清理**: rateLimitCleanupService每5分钟自动清理过期限流状态
12. **成本统计不准确**: 运行 `npm run init:costs` 初始化成本数据检查pricingService是否正确加载模型价格
13. **缓存命中率低**: 查看缓存监控统计调整LRU缓存大小配置
14. **用户消息队列超时**: 优化后锁持有时间已从分钟级降到毫秒级(请求发送后立即释放),默认 `USER_MESSAGE_QUEUE_TIMEOUT_MS=5000` 已足够。如仍有超时,检查网络延迟或禁用此功能(`USER_MESSAGE_QUEUE_ENABLED=false`
15. **并发请求排队问题**:
- 排队超时:检查 `concurrentRequestQueueTimeoutMs` 配置是否合理默认10秒
- 排队数过多:调整 `concurrentRequestQueueMaxSize` 和 `concurrentRequestQueueMaxSizeMultiplier`
- 查看排队统计:访问 `/admin/concurrency-queue/stats` 接口查看 entered/success/timeout/cancelled/socket_changed/rejected_overload 统计
- 排队计数泄漏:系统重启时自动清理,或访问 `/admin/concurrency-queue` DELETE 接口手动清理
- Socket 身份验证失败:查看 `socket_changed` 统计,如果频繁发生,检查代理配置或客户端连接稳定性
- 健康检查拒绝:查看 `rejected_overload` 统计,表示队列过载时的快速失败次数
### 代理配置要求(并发请求排队)
使用并发请求排队功能时,需要正确配置代理(如 Nginx的超时参数
- **推荐配置**: `proxy_read_timeout >= max(2 × concurrentRequestQueueTimeoutMs, 60s)`
- 当前默认排队超时 10 秒Nginx 默认 `proxy_read_timeout = 60s` 已满足要求
- 如果调整排队超时到 60 秒,推荐代理超时 ≥ 120 秒
- **Nginx 配置示例**:
```nginx
location /api/ {
proxy_read_timeout 120s; # 排队超时 60s 时推荐 120s
proxy_connect_timeout 10s;
# ...其他配置
}
```
- **企业防火墙环境**:
- 某些企业防火墙可能静默关闭长时间无数据的连接20-40 秒)
- 如遇此问题,联系网络管理员调整空闲连接超时策略
- 或降低 `concurrentRequestQueueTimeoutMs` 配置
- **后续升级说明**: 如有需要,后续版本可能提供可选的轻量级心跳机制
### 调试工具
- **日志系统**: Winston结构化日志支持不同级别logs/目录下分类存储
- `logs/claude-relay-*.log` - 应用主日志
- `logs/token-refresh-error.log` - Token刷新错误
- `logs/webhook-*.log` - Webhook通知日志
- `logs/http-debug-*.log` - HTTP调试日志DEBUG_HTTP_TRAFFIC=true时
- **CLI工具**: 命令行状态查看和管理npm run cli
- **Web界面**: 实时日志查看和系统监控(/admin-next/
- **健康检查**: /health端点提供系统状态redis、logger、内存、版本等
- **系统指标**: /metrics端点提供详细的使用统计和性能指标
- **缓存监控**: cacheMonitor提供全局缓存统计和命中率分析
- **数据导出工具**: npm run data:export 导出Redis数据进行调试
- **Redis Key调试**: npm run data:debug 查看所有Redis键
## 开发最佳实践
### 代码格式化要求
- **必须使用 Prettier 格式化所有代码**
- 后端代码src/):运行 `npx prettier --write <file>` 格式化
- 前端代码web/admin-spa/):已安装 `prettier-plugin-tailwindcss`,运行 `npx prettier --write <file>` 格式化
- 提交前检查格式:`npx prettier --check <file>`
- 格式化所有文件:`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` 确认服务正常运行
- 注意:当前项目缺少实际测试文件,建议补充单元测试和集成测试
### 开发工作流 ### 开发工作流
- **功能开发**: 始终从理解现有代码开始,重用已有的服务和模式 1. **理解现有代码** → 读相关文件,了解现有模式
- **调试流程**: 使用 Winston 日志 + Web 界面实时日志查看 + CLI 状态工具 2. **编写代码** → 重用已有服务和工具函数
- **代码审查**: 关注安全性(加密存储)、性能(异步处理)、错误处理 3. **格式化**`npx prettier --write <修改的文件>`
- **部署前检查**: 运行 lint → 测试 CLI 功能 → 检查日志 → Docker 构建 4. **检查**`npm run lint`
5. **测试**`npm test`
6. **验证**`npm run cli status` 确认服务正常
### 常见文件位置 ### 前端要求
- 核心服务逻辑:`src/services/` 目录30+服务文件 - 响应式设计Tailwind CSS 响应式前缀sm:、md:、lg:、xl:
- 路由处理:`src/routes/` 目录api.js、admin.js、geminiRoutes.js、openaiRoutes.js等13个路由文件 - 暗黑模式:所有组件必须兼容,使用 `dark:` 前缀
- 中间件:`src/middleware/` 目录auth.js、browserFallback.js、debugInterceptor.js等 - 主题切换:`web/admin-spa/src/stores/theme.js``useThemeStore()`
- 配置管理:`config/config.js`(完整的多平台配置) - 保持现有玻璃态设计风格
- Redis 模型:`src/models/redis.js`
- 工具函数:`src/utils/` 目录
- `logger.js` - 日志系统
- `oauthHelper.js` - OAuth工具
- `proxyHelper.js` - 代理工具
- `sessionHelper.js` - 会话管理
- `cacheMonitor.js` - 缓存监控
- `costCalculator.js` - 成本计算
- `rateLimitHelper.js` - 速率限制
- `webhookNotifier.js` - Webhook通知
- `tokenMask.js` - Token脱敏
- `workosOAuthHelper.js` - WorkOS OAuth
- `modelHelper.js` - 模型工具
- `inputValidator.js` - 输入验证
- CLI工具`cli/index.js` 和 `src/cli/` 目录
- 脚本目录:`scripts/` 目录
- `setup.js` - 初始化脚本
- `manage.js` - 服务管理
- `migrate-apikey-expiry.js` - API Key过期迁移
- `fix-usage-stats.js` - 使用统计修复
- `data-transfer.js` / `data-transfer-enhanced.js` - 数据导入导出
- `update-model-pricing.js` - 模型价格更新
- `test-pricing-fallback.js` - 价格回退测试
- `debug-redis-keys.js` - Redis调试
- 前端主题管理:`web/admin-spa/src/stores/theme.js`
- 前端组件:`web/admin-spa/src/components/` 目录
- 前端页面:`web/admin-spa/src/views/` 目录
- 初始化数据:`data/init.json`(管理员凭据存储)
- 日志目录:`logs/`(各类日志文件)
### 重要架构决策 暗黑模式配色对照:
- **统一调度系统**: 使用统一调度器unifiedClaudeScheduler等实现跨账户类型的智能调度支持粘性会话、负载均衡、故障转移 | 元素 | 明亮模式 | 暗黑模式 |
- **多账户类型支持**: 支持8种账户类型claude-official、claude-console、bedrock、ccr、droid、gemini、openai-responses、azure-openai |------|----------|----------|
- **加密存储**: 所有敏感数据OAuth token、refreshToken、credentials都使用 AES 加密存储在 Redis | 文本 | `text-gray-700` | `dark:text-gray-200` |
- **独立代理**: 每个账户支持独立的代理配置SOCKS5/HTTP包括OAuth授权流程 | 背景 | `bg-white` | `dark:bg-gray-800` |
- **API Key哈希**: 使用SHA-256哈希存储支持自定义前缀默认 `cr_` | 边框 | `border-gray-200` | `dark:border-gray-700` |
- **权限系统**: API Key支持细粒度权限控制all/claude/gemini/openai等 | 状态色 | `text-blue-500` / `text-green-600` / `text-red-500` | 保持一致 |
- **请求流程**: API Key验证含权限、客户端、模型黑名单 → 统一调度器选择账户 → Token刷新如需→ 请求转发 → Usage捕获 → 成本计算
- **流式响应**: 支持SSE流式响应实时捕获真实usage数据客户端断开时自动清理资源AbortController
- **粘性会话**: 基于请求内容hash的会话绑定同一会话始终使用同一账户支持自动续期
- **自动清理**: 定时清理任务过期Key、错误账户、临时错误、并发计数、速率限制状态
- **缓存优化**: 多层LRU缓存解密缓存、账户缓存全局缓存监控和统计
- **成本追踪**: 实时token使用统计input/output/cache_create/cache_read和成本计算基于pricingService
- **并发控制**: Redis Sorted Set实现的并发计数支持自动过期清理
- **并发请求排队**: 当API Key并发超限时请求进入队列等待而非直接返回429
- **工作原理**: 采用「先占后检查」模式,每次轮询尝试占位,超限则释放继续等待
- **指数退避**: 初始200ms指数增长至最大2秒带±20%抖动防惊群效应
- **智能清理**: 排队计数有TTL保护超时+30秒进程崩溃也能自动清理
- **Socket身份验证**: 使用UUID token + socket对象引用双重验证避免HTTP Keep-Alive连接复用导致的身份混淆
- **健康检查**: P90等待时间超过阈值时快速失败返回429避免新请求在过载时继续排队
- **配置参数**: `concurrentRequestQueueEnabled`默认false、`concurrentRequestQueueMaxSize`默认3、`concurrentRequestQueueMaxSizeMultiplier`默认0、`concurrentRequestQueueTimeoutMs`默认10秒、`concurrentRequestQueueMaxRedisFailCount`默认5、`concurrentRequestQueueHealthCheckEnabled`默认true、`concurrentRequestQueueHealthThreshold`默认0.8
- **最大排队数**: max(固定值, 并发限制×倍数),例如并发限制=10、倍数=2时最大排队数=20
- **适用场景**: Claude Code Agent并行工具调用、批量请求处理
- **客户端识别**: 基于User-Agent的客户端限制支持预定义客户端ClaudeCode、Gemini-CLI等
- **错误处理**: 529错误自动标记账户过载状态配置时长内自动排除该账户
### 核心数据流和性能优化 ### 代码修改原则
- **哈希映射优化**: API Key 验证从 O(n) 优化到 O(1) 查找 - 先检查现有模式和风格,重用已有服务和工具函数
- **智能 Usage 捕获**: 从 SSE 流中解析真实的 token 使用数据 - 敏感数据必须加密存储(参考 claudeAccountService.js
- **多维度统计**: 支持按时间、模型、用户的实时使用统计 - 遵循现有的错误处理和日志记录模式
- **异步处理**: 非阻塞的统计记录和日志写入
- **原子操作**: Redis 管道操作确保数据一致性
### 安全和容错机制 ## 常用命令
- **多层加密**: API Key 哈希 + OAuth Token AES 加密
- **零信任验证**: 每个请求都需要完整的认证链
- **优雅降级**: Redis 连接失败时的回退机制
- **自动重试**: 指数退避重试策略和错误隔离
- **资源清理**: 客户端断开时的自动清理机制
## 项目特定注意事项
### Redis 数据结构
- **API Keys**:
- `api_key:{id}` - API Key详细信息含权限、客户端限制、模型黑名单等
- `api_key_hash:{hash}` - 哈希到ID的快速映射
- `api_key_usage:{keyId}` - 使用统计数据
- `api_key_cost:{keyId}` - 成本统计数据
- **账户数据**(多类型):
- `claude_account:{id}` - Claude官方账户加密的OAuth数据
- `claude_console_account:{id}` - Claude Console账户
- `gemini_account:{id}` - Gemini账户
- `openai_responses_account:{id}` - OpenAI Responses账户
- `bedrock_account:{id}` - AWS Bedrock账户
- `azure_openai_account:{id}` - Azure OpenAI账户
- `droid_account:{id}` - Droid账户
- `ccr_account:{id}` - CCR账户
- **用户管理**:
- `user:{id}` - 用户信息
- `user_email:{email}` - 邮箱到用户ID映射
- `user_session:{token}` - 用户会话
- **管理员**:
- `admin:{id}` - 管理员信息
- `admin_username:{username}` - 用户名映射
- `admin_credentials` - 管理员凭据从data/init.json同步
- **会话管理**:
- `session:{token}` - JWT会话管理
- `sticky_session:{sessionHash}` - 粘性会话账户绑定
- `session_window:{accountId}` - 账户会话窗口
- **使用统计**:
- `usage:daily:{date}:{key}:{model}` - 按日期、Key、模型的使用统计
- `usage:account:{accountId}:{date}` - 按账户的使用统计
- `usage:global:{date}` - 全局使用统计
- **速率限制**:
- `rate_limit:{keyId}:{window}` - 速率限制计数器
- `rate_limit_state:{accountId}` - 账户限流状态
- `overload:{accountId}` - 账户过载状态529错误
- **并发控制**:
- `concurrency:{accountId}` - Redis Sorted Set实现的并发计数
- **并发请求排队**:
- `concurrency:queue:{apiKeyId}` - API Key级别的排队计数器TTL由 `concurrentRequestQueueTimeoutMs` + 30秒缓冲决定
- `concurrency:queue:stats:{apiKeyId}` - 排队统计entered/success/timeout/cancelled
- `concurrency:queue:wait_times:{apiKeyId}` - 按API Key的等待时间记录用于P50/P90/P99计算
- `concurrency:queue:wait_times:global` - 全局等待时间记录
- **Webhook配置**:
- `webhook_config:{id}` - Webhook配置
- **用户消息队列**:
- `user_msg_queue_lock:{accountId}` - 用户消息队列锁当前持有者requestId
- `user_msg_queue_last:{accountId}` - 上次请求完成时间戳(用于延迟计算)
- **系统信息**:
- `system_info` - 系统状态缓存
- `model_pricing` - 模型价格数据pricingService
### 流式响应处理
- 支持 SSE (Server-Sent Events) 流式传输,实时推送响应数据
- 自动从SSE流中解析真实usage数据input/output/cache_create/cache_read tokens
- 客户端断开时通过 AbortController 清理资源和并发计数
- 错误时发送适当的 SSE 错误事件(带时间戳和错误类型)
- 支持大文件流式传输REQUEST_TIMEOUT配置超时时间
- 禁用Nagle算法确保数据立即发送socket.setNoDelay
- 设置 `X-Accel-Buffering: no` 禁用Nginx缓冲
### CLI 工具使用示例
```bash ```bash
# API Key管理 npm install && npm run setup # 初始化
npm run cli keys create -- --name "MyApp" --limit 1000 npm run dev # 开发模式(热重载)
npm run cli keys list npm start # 生产模式
npm run cli keys delete -- --id <keyId> npm run lint # ESLint 检查
npm run cli keys update -- --id <keyId> --limit 2000 npm test # Jest + SuperTest
docker-compose up -d # Docker 部署
# 系统状态查看 npm run cli status # 系统状态
npm run cli status # 查看系统概况 npm run data:export # 导出 Redis 数据
npm run status # 统一状态脚本 npm run data:debug # 调试 Redis 键
npm run status:detail # 详细状态
# Claude账户管理
npm run cli accounts list
npm run cli accounts refresh <accountId>
npm run cli accounts add -- --name "Account1"
# Gemini账户管理
npm run cli gemini list
npm run cli gemini add -- --name "Gemini1"
# 管理员操作
npm run cli admin create -- --username admin2
npm run cli admin reset-password -- --username admin
npm run cli admin list
# 数据管理
npm run data:export # 导出Redis数据
npm run data:export:sanitized # 导出脱敏数据
npm run data:export:enhanced # 增强导出(含解密)
npm run data:export:encrypted # 导出加密数据
npm run data:import # 导入数据
npm run data:import:enhanced # 增强导入
npm run data:debug # 调试Redis键
# 数据迁移和修复
npm run migrate:apikey-expiry # API Key过期时间迁移
npm run migrate:apikey-expiry:dry # 干跑模式
npm run migrate:fix-usage-stats # 修复使用统计
# 成本和定价
npm run init:costs # 初始化成本数据
npm run update:pricing # 更新模型价格
npm run test:pricing-fallback # 测试价格回退
# 监控
npm run monitor # 增强监控脚本
``` ```
## 环境变量(必须)
- `JWT_SECRET` — JWT 密钥32字符+
- `ENCRYPTION_KEY` — AES 加密密钥32字符固定
- `REDIS_HOST` / `REDIS_PORT` / `REDIS_PASSWORD` — Redis 连接
其他可选环境变量见 `.env.example`
## 故障排除
| 问题 | 排查方向 |
|------|----------|
| Redis 连接失败 | 检查 REDIS_HOST/PORT/PASSWORD |
| 管理员登录失败 | 检查 data/init.json运行 `npm run setup` |
| API Key 格式错误 | 确保使用 `cr_` 前缀格式(可通过 API_KEY_PREFIX 配置) |
| Token 刷新失败 | 检查 refreshToken 有效性和代理配置,查看 `logs/token-refresh-error.log` |
| 调度器选账户失败 | 检查账户 status:'active',确认类型与路由匹配,查看粘性会话绑定 |
| 并发计数泄漏 | 系统每分钟自动清理,重启也会清理 |
| 粘性会话失效 | 检查 Redis 中 session 数据Nginx 代理需添加 `underscores_in_headers on` |
| LDAP 认证失败 | 检查 LDAP_URL/BIND_DN/BIND_PASSWORD自签名证书设 `LDAP_TLS_REJECT_UNAUTHORIZED=false` |
| Webhook 通知失败 | 确认 WEBHOOK_ENABLED=true检查 WEBHOOK_URLS 格式,查看 `logs/webhook-*.log` |
| 成本统计不准确 | 运行 `npm run init:costs`,检查 pricingService 模型价格 |
日志:`logs/` 目录。Web 界面 `/admin-next/` 可实时查看。
# important-instruction-reminders # important-instruction-reminders
Do what has been asked; nothing more, nothing less. Do what has been asked; nothing more, nothing less.
NEVER create files unless they're absolutely necessary for achieving your goal. NEVER create files unless they're absolutely necessary for achieving your goal.
ALWAYS prefer editing an existing file to creating a new one. 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. NEVER proactively create documentation files (\*.md) or README files. Only create documentation files if explicitly requested by the User.
````

View File

@@ -1 +1 @@
1.1.272 1.1.274

View File

@@ -11,8 +11,8 @@ const path = require('path')
const redis = require('../src/models/redis') const redis = require('../src/models/redis')
const apiKeyService = require('../src/services/apiKeyService') const apiKeyService = require('../src/services/apiKeyService')
const claudeAccountService = require('../src/services/claudeAccountService') const claudeAccountService = require('../src/services/account/claudeAccountService')
const bedrockAccountService = require('../src/services/bedrockAccountService') const bedrockAccountService = require('../src/services/account/bedrockAccountService')
const program = new Command() const program = new Command()

View File

@@ -228,6 +228,14 @@ const config = {
enabled: process.env.QUOTA_CARD_LIMITS_ENABLED !== 'false', // 默认启用 enabled: process.env.QUOTA_CARD_LIMITS_ENABLED !== 'false', // 默认启用
maxExpiryDays: parseInt(process.env.QUOTA_CARD_MAX_EXPIRY_DAYS) || 90, // 最大有效期距今天数 maxExpiryDays: parseInt(process.env.QUOTA_CARD_MAX_EXPIRY_DAYS) || 90, // 最大有效期距今天数
maxTotalCostLimit: parseFloat(process.env.QUOTA_CARD_MAX_TOTAL_COST_LIMIT) || 1000 // 最大总额度(美元) maxTotalCostLimit: parseFloat(process.env.QUOTA_CARD_MAX_TOTAL_COST_LIMIT) || 1000 // 最大总额度(美元)
},
// ⏱️ 上游错误自动暂停配置
upstreamError: {
serverErrorTtlSeconds: parseInt(process.env.UPSTREAM_ERROR_5XX_TTL_SECONDS) || 300, // 5xx错误暂停秒数
overloadTtlSeconds: parseInt(process.env.UPSTREAM_ERROR_OVERLOAD_TTL_SECONDS) || 600, // 529过载暂停秒数
authErrorTtlSeconds: parseInt(process.env.UPSTREAM_ERROR_AUTH_TTL_SECONDS) || 1800, // 401/403认证错误暂停秒数
timeoutTtlSeconds: parseInt(process.env.UPSTREAM_ERROR_TIMEOUT_TTL_SECONDS) || 300 // 504超时暂停秒数
} }
} }

View File

@@ -35,6 +35,13 @@ const OPENAI_MODELS = [
{ value: 'codex-mini', label: 'Codex Mini' } { value: 'codex-mini', label: 'Codex Mini' }
] ]
const BEDROCK_MODELS = [
{ value: 'us.anthropic.claude-opus-4-6-20250610-v1:0', label: 'Claude Opus 4.6' },
{ value: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', label: 'Claude Sonnet 4.5' },
{ value: 'us.anthropic.claude-sonnet-4-20250514-v1:0', label: 'Claude Sonnet 4' },
{ value: 'us.anthropic.claude-3-5-haiku-20241022-v1:0', label: 'Claude 3.5 Haiku' }
]
// 其他模型(用于账户编辑的模型映射) // 其他模型(用于账户编辑的模型映射)
const OTHER_MODELS = [ const OTHER_MODELS = [
{ value: 'deepseek-chat', label: 'DeepSeek Chat' }, { value: 'deepseek-chat', label: 'DeepSeek Chat' },
@@ -43,11 +50,26 @@ const OTHER_MODELS = [
{ value: 'GLM', label: 'GLM' } { value: 'GLM', label: 'GLM' }
] ]
// 各平台测试可用模型
const PLATFORM_TEST_MODELS = {
claude: CLAUDE_MODELS,
'claude-console': CLAUDE_MODELS,
bedrock: BEDROCK_MODELS,
gemini: GEMINI_MODELS,
'gemini-api': GEMINI_MODELS,
'openai-responses': OPENAI_MODELS,
'azure-openai': [],
droid: CLAUDE_MODELS,
ccr: CLAUDE_MODELS
}
module.exports = { module.exports = {
CLAUDE_MODELS, CLAUDE_MODELS,
GEMINI_MODELS, GEMINI_MODELS,
OPENAI_MODELS, OPENAI_MODELS,
BEDROCK_MODELS,
OTHER_MODELS, OTHER_MODELS,
PLATFORM_TEST_MODELS,
// 按服务分组 // 按服务分组
getModelsByService: (service) => { getModelsByService: (service) => {
switch (service) { switch (service) {

View File

@@ -6,7 +6,7 @@
*/ */
const redis = require('../src/models/redis') const redis = require('../src/models/redis')
const claudeAccountService = require('../src/services/claudeAccountService') const claudeAccountService = require('../src/services/account/claudeAccountService')
const readline = require('readline') const readline = require('readline')
// 创建readline接口 // 创建readline接口

View File

@@ -3,8 +3,8 @@
*/ */
const redis = require('../src/models/redis') const redis = require('../src/models/redis')
const claudeAccountService = require('../src/services/claudeAccountService') const claudeAccountService = require('../src/services/account/claudeAccountService')
const claudeConsoleAccountService = require('../src/services/claudeConsoleAccountService') const claudeConsoleAccountService = require('../src/services/account/claudeConsoleAccountService')
const accountGroupService = require('../src/services/accountGroupService') const accountGroupService = require('../src/services/accountGroupService')
async function testApiResponse() { async function testApiResponse() {

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env node #!/usr/bin/env node
const bedrockRelayService = require('../src/services/bedrockRelayService') const bedrockRelayService = require('../src/services/relay/bedrockRelayService')
async function testBedrockModels() { async function testBedrockModels() {
try { try {

View File

@@ -11,7 +11,7 @@ const dotenv = require('dotenv')
dotenv.config({ path: path.join(__dirname, '..', '.env') }) dotenv.config({ path: path.join(__dirname, '..', '.env') })
const redis = require('../src/models/redis') const redis = require('../src/models/redis')
const geminiAccountService = require('../src/services/geminiAccountService') const geminiAccountService = require('../src/services/account/geminiAccountService')
const crypto = require('crypto') const crypto = require('crypto')
const config = require('../config/config') const config = require('../config/config')

View File

@@ -7,10 +7,10 @@ require('dotenv').config()
const { v4: uuidv4 } = require('uuid') const { v4: uuidv4 } = require('uuid')
const redis = require('../src/models/redis') const redis = require('../src/models/redis')
const accountGroupService = require('../src/services/accountGroupService') const accountGroupService = require('../src/services/accountGroupService')
const claudeAccountService = require('../src/services/claudeAccountService') const claudeAccountService = require('../src/services/account/claudeAccountService')
const claudeConsoleAccountService = require('../src/services/claudeConsoleAccountService') const claudeConsoleAccountService = require('../src/services/account/claudeConsoleAccountService')
const apiKeyService = require('../src/services/apiKeyService') const apiKeyService = require('../src/services/apiKeyService')
const unifiedClaudeScheduler = require('../src/services/unifiedClaudeScheduler') const unifiedClaudeScheduler = require('../src/services/scheduler/unifiedClaudeScheduler')
// 测试配置 // 测试配置
const TEST_PREFIX = 'test_group_' const TEST_PREFIX = 'test_group_'

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env node #!/usr/bin/env node
const bedrockRelayService = require('../src/services/bedrockRelayService') const bedrockRelayService = require('../src/services/relay/bedrockRelayService')
function testModelMapping() { function testModelMapping() {
console.log('🧪 测试模型映射功能...') console.log('🧪 测试模型映射功能...')

View File

@@ -86,7 +86,7 @@ class Application {
// 💳 初始化账户余额查询服务Provider 注册) // 💳 初始化账户余额查询服务Provider 注册)
try { try {
const accountBalanceService = require('./services/accountBalanceService') const accountBalanceService = require('./services/account/accountBalanceService')
const { registerAllProviders } = require('./services/balanceProviders') const { registerAllProviders } = require('./services/balanceProviders')
registerAllProviders(accountBalanceService) registerAllProviders(accountBalanceService)
logger.info('✅ 账户余额查询服务已初始化') logger.info('✅ 账户余额查询服务已初始化')
@@ -137,7 +137,7 @@ class Application {
// 🕐 初始化Claude账户会话窗口 // 🕐 初始化Claude账户会话窗口
logger.info('🕐 Initializing Claude account session windows...') logger.info('🕐 Initializing Claude account session windows...')
const claudeAccountService = require('./services/claudeAccountService') const claudeAccountService = require('./services/account/claudeAccountService')
await claudeAccountService.initializeSessionWindows() await claudeAccountService.initializeSessionWindows()
// 📊 初始化费用排序索引服务 // 📊 初始化费用排序索引服务
@@ -639,9 +639,12 @@ class Application {
// 注册各个服务的缓存实例 // 注册各个服务的缓存实例
const services = [ const services = [
{ name: 'claudeAccount', service: require('./services/claudeAccountService') }, { name: 'claudeAccount', service: require('./services/account/claudeAccountService') },
{ name: 'claudeConsole', service: require('./services/claudeConsoleAccountService') }, {
{ name: 'bedrockAccount', service: require('./services/bedrockAccountService') } name: 'claudeConsole',
service: require('./services/account/claudeConsoleAccountService')
},
{ name: 'bedrockAccount', service: require('./services/account/bedrockAccountService') }
] ]
// 注册已加载的服务缓存 // 注册已加载的服务缓存
@@ -673,7 +676,7 @@ class Application {
logger.info('🧹 Starting scheduled cleanup...') logger.info('🧹 Starting scheduled cleanup...')
const apiKeyService = require('./services/apiKeyService') const apiKeyService = require('./services/apiKeyService')
const claudeAccountService = require('./services/claudeAccountService') const claudeAccountService = require('./services/account/claudeAccountService')
const [expiredKeys, errorAccounts] = await Promise.all([ const [expiredKeys, errorAccounts] = await Promise.all([
apiKeyService.cleanupExpiredKeys(), apiKeyService.cleanupExpiredKeys(),

View File

@@ -6,13 +6,13 @@
*/ */
const logger = require('../utils/logger') const logger = require('../utils/logger')
const geminiAccountService = require('../services/geminiAccountService') const geminiAccountService = require('../services/account/geminiAccountService')
const geminiApiAccountService = require('../services/geminiApiAccountService') const geminiApiAccountService = require('../services/account/geminiApiAccountService')
const { sendGeminiRequest, getAvailableModels } = require('../services/geminiRelayService') const { sendGeminiRequest, getAvailableModels } = require('../services/relay/geminiRelayService')
const { sendAntigravityRequest } = require('../services/antigravityRelayService') const { sendAntigravityRequest } = require('../services/relay/antigravityRelayService')
const crypto = require('crypto') const crypto = require('crypto')
const sessionHelper = require('../utils/sessionHelper') const sessionHelper = require('../utils/sessionHelper')
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler') const unifiedGeminiScheduler = require('../services/scheduler/unifiedGeminiScheduler')
const apiKeyService = require('../services/apiKeyService') const apiKeyService = require('../services/apiKeyService')
const redis = require('../models/redis') const redis = require('../models/redis')
const { updateRateLimitCounters } = require('../utils/rateLimitHelper') const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
@@ -20,6 +20,52 @@ const { parseSSELine } = require('../utils/sseParser')
const axios = require('axios') const axios = require('axios')
const { getSafeMessage } = require('../utils/errorSanitizer') const { getSafeMessage } = require('../utils/errorSanitizer')
const ProxyHelper = require('../utils/proxyHelper') const ProxyHelper = require('../utils/proxyHelper')
const upstreamErrorHelper = require('../utils/upstreamErrorHelper')
// 处理 Gemini 上游错误,标记账户为临时不可用
const handleGeminiUpstreamError = async (
errorStatus,
accountId,
accountType,
sessionHash,
headers,
disableAutoProtection = false
) => {
if (!accountId || !errorStatus) {
return
}
const autoProtectionDisabled = disableAutoProtection === true || disableAutoProtection === 'true'
try {
if (errorStatus === 429) {
if (!autoProtectionDisabled) {
const ttl = upstreamErrorHelper.parseRetryAfter(headers)
await upstreamErrorHelper.markTempUnavailable(accountId, accountType || 'gemini', 429, ttl)
// 同时设置 rate-limit 状态,保持与 /messages handler 一致
await unifiedGeminiScheduler
.markAccountRateLimited(accountId, accountType || 'gemini', sessionHash)
.catch((e) => logger.warn('Failed to mark account as rate limited:', e))
}
if (sessionHash) {
await unifiedGeminiScheduler._deleteSessionMapping(sessionHash)
}
return
}
if (errorStatus >= 500 || errorStatus === 401 || errorStatus === 403) {
if (!autoProtectionDisabled) {
await upstreamErrorHelper.markTempUnavailable(
accountId,
accountType || 'gemini',
errorStatus
)
}
}
if (sessionHash) {
await unifiedGeminiScheduler._deleteSessionMapping(sessionHash)
}
} catch (e) {
logger.warn('[UpstreamError] Failed to handle Gemini upstream error:', e)
}
}
// ============================================================================ // ============================================================================
// 工具函数 // 工具函数
@@ -44,28 +90,64 @@ function buildGeminiApiUrl(baseUrl, model, action, apiKey, options = {}) {
// 移除末尾的斜杠(如果有) // 移除末尾的斜杠(如果有)
const normalizedBaseUrl = baseUrl.replace(/\/+$/, '') const normalizedBaseUrl = baseUrl.replace(/\/+$/, '')
// 检查是否为新格式(以 /models 结尾 // 模式 3: URL 模板(包含 {model} 占位符
const isNewFormat = normalizedBaseUrl.endsWith('/models') const isTemplate = normalizedBaseUrl.includes('{model}')
// 模式 2: 以 /models 结尾
const isModelsFormat = normalizedBaseUrl.endsWith('/models')
// 模板校验: 有 {model} 但没有 {action} 且 {model} 后面没有 : 开头的固定 action
if (isTemplate && !listModels && !normalizedBaseUrl.includes('{action}')) {
const afterModel = normalizedBaseUrl.split('{model}')[1] || ''
if (!afterModel.startsWith(':')) {
const err = new Error(
`Gemini baseUrl 模板配置错误: 包含 {model} 但缺少 :{action} 或固定 action。` +
`当前: ${baseUrl},示例: https://proxy.com/v1beta/models/{model}:{action}`
)
err.statusCode = 400
throw err
}
}
let url let url
if (listModels) { if (listModels) {
// 获取模型列表 if (isTemplate) {
if (isNewFormat) { // 模板模式: 分离 path 和 query分别剔除含 {model}/{action} 的部分
// 新格式: baseUrl 已包含 /v1beta/models直接添加查询参数 const [pathPart, queryPart] = normalizedBaseUrl.split('?')
let cleanPath = pathPart.split('{model}')[0].replace(/\/+$/, '')
let cleanQuery = ''
if (queryPart) {
cleanQuery = queryPart
.split('&')
.filter((p) => !p.includes('{model}') && !p.includes('{action}'))
.join('&')
}
// 如果 {model} 在 query 里path 未变path 可能缺少 /models
if (cleanPath === pathPart.replace(/\/+$/, '') && !cleanPath.endsWith('/models')) {
logger.warn(
'Gemini 模板 {model} 在 query 中listModels 路径可能不正确,自动追加 /v1beta/models',
{ baseUrl }
)
cleanPath += '/v1beta/models'
}
const base = cleanQuery ? `${cleanPath}?${cleanQuery}` : cleanPath
const separator = base.includes('?') ? '&' : '?'
url = `${base}${separator}key=${apiKey}`
} else if (isModelsFormat) {
url = `${normalizedBaseUrl}?key=${apiKey}` url = `${normalizedBaseUrl}?key=${apiKey}`
} else { } else {
// 旧格式: 需要拼接 /v1beta/models
url = `${normalizedBaseUrl}/v1beta/models?key=${apiKey}` url = `${normalizedBaseUrl}/v1beta/models?key=${apiKey}`
} }
} else { } else {
// 模型操作 (generateContent, streamGenerateContent, countTokens)
const streamParam = stream ? '&alt=sse' : '' const streamParam = stream ? '&alt=sse' : ''
if (isNewFormat) { if (isTemplate) {
// 新格式: baseUrl 已包含 /v1beta/models直接拼接 /{model}:action // 模板模式: 直接替换占位符({action} 可选,用户可硬编码 action
url = normalizedBaseUrl.replace('{model}', model).replace('{action}', action)
const separator = url.includes('?') ? '&' : '?'
url += `${separator}key=${apiKey}${streamParam}`
} else if (isModelsFormat) {
url = `${normalizedBaseUrl}/${model}:${action}?key=${apiKey}${streamParam}` url = `${normalizedBaseUrl}/${model}:${action}?key=${apiKey}${streamParam}`
} else { } else {
// 旧格式: 需要拼接 /v1beta/models/{model}:action
url = `${normalizedBaseUrl}/v1beta/models/${model}:${action}?key=${apiKey}${streamParam}` url = `${normalizedBaseUrl}/v1beta/models/${model}:${action}?key=${apiKey}${streamParam}`
} }
} }
@@ -308,6 +390,7 @@ async function handleMessages(req, res) {
let accountId let accountId
let accountType let accountType
let sessionHash let sessionHash
let account
try { try {
const apiKeyData = req.apiKey const apiKeyData = req.apiKey
@@ -367,7 +450,6 @@ async function handleMessages(req, res) {
const isApiAccount = accountType === 'gemini-api' const isApiAccount = accountType === 'gemini-api'
// 获取账户详情 // 获取账户详情
let account
if (isApiAccount) { if (isApiAccount) {
account = await geminiApiAccountService.getAccount(accountId) account = await geminiApiAccountService.getAccount(accountId)
if (!account) { if (!account) {
@@ -664,6 +746,16 @@ async function handleMessages(req, res) {
} }
} }
// 处理其他上游错误5xx/401/403
await handleGeminiUpstreamError(
errorStatus,
accountId,
accountType,
sessionHash,
error.response?.headers,
account?.disableAutoProtection
)
// 返回错误响应 // 返回错误响应
const status = errorStatus || 500 const status = errorStatus || 500
const errorResponse = { const errorResponse = {
@@ -1429,6 +1521,11 @@ async function handleCountTokens(req, res) {
* 处理 generateContent 请求v1internal 格式) * 处理 generateContent 请求v1internal 格式)
*/ */
async function handleGenerateContent(req, res) { async function handleGenerateContent(req, res) {
let accountId = null
let accountType = null
let sessionHash = null
let account = null
try { try {
if (!ensureGeminiPermission(req, res)) { if (!ensureGeminiPermission(req, res)) {
return undefined return undefined
@@ -1437,7 +1534,7 @@ async function handleGenerateContent(req, res) {
const { project, user_prompt_id, request: requestData } = req.body const { project, user_prompt_id, request: requestData } = req.body
// 从路径参数或请求体中获取模型名 // 从路径参数或请求体中获取模型名
const model = req.body.model || req.params.modelName || 'gemini-2.5-flash' const model = req.body.model || req.params.modelName || 'gemini-2.5-flash'
const sessionHash = sessionHelper.generateSessionHash(req.body) sessionHash = sessionHelper.generateSessionHash(req.body)
// 处理不同格式的请求 // 处理不同格式的请求
let actualRequestData = requestData let actualRequestData = requestData
@@ -1478,7 +1575,7 @@ async function handleGenerateContent(req, res) {
sessionHash, sessionHash,
model model
) )
const { accountId, accountType } = schedulerResult ;({ accountId, accountType } = schedulerResult)
// v1internal 路由只支持 OAuth 账户,不支持 API Key 账户 // v1internal 路由只支持 OAuth 账户,不支持 API Key 账户
if (accountType === 'gemini-api') { if (accountType === 'gemini-api') {
@@ -1492,7 +1589,7 @@ async function handleGenerateContent(req, res) {
}) })
} }
const account = await geminiAccountService.getAccount(accountId) account = await geminiAccountService.getAccount(accountId)
if (!account) { if (!account) {
logger.error(`❌ Gemini account not found: ${accountId}`) logger.error(`❌ Gemini account not found: ${accountId}`)
return res.status(404).json({ return res.status(404).json({
@@ -1638,6 +1735,14 @@ async function handleGenerateContent(req, res) {
requestMethod: error.config?.method, requestMethod: error.config?.method,
stack: error.stack stack: error.stack
}) })
await handleGeminiUpstreamError(
error.response?.status,
accountId,
accountType,
sessionHash,
error.response?.headers,
account?.disableAutoProtection
)
res.status(500).json({ res.status(500).json({
error: { error: {
message: getSafeMessage(error) || 'Internal server error', message: getSafeMessage(error) || 'Internal server error',
@@ -1653,6 +1758,10 @@ async function handleGenerateContent(req, res) {
*/ */
async function handleStreamGenerateContent(req, res) { async function handleStreamGenerateContent(req, res) {
let abortController = null let abortController = null
let accountId = null
let accountType = null
let sessionHash = null
let account = null
try { try {
if (!ensureGeminiPermission(req, res)) { if (!ensureGeminiPermission(req, res)) {
@@ -1662,7 +1771,7 @@ async function handleStreamGenerateContent(req, res) {
const { project, user_prompt_id, request: requestData } = req.body const { project, user_prompt_id, request: requestData } = req.body
// 从路径参数或请求体中获取模型名 // 从路径参数或请求体中获取模型名
const model = req.body.model || req.params.modelName || 'gemini-2.5-flash' const model = req.body.model || req.params.modelName || 'gemini-2.5-flash'
const sessionHash = sessionHelper.generateSessionHash(req.body) sessionHash = sessionHelper.generateSessionHash(req.body)
// 处理不同格式的请求 // 处理不同格式的请求
let actualRequestData = requestData let actualRequestData = requestData
@@ -1703,7 +1812,7 @@ async function handleStreamGenerateContent(req, res) {
sessionHash, sessionHash,
model model
) )
const { accountId, accountType } = schedulerResult ;({ accountId, accountType } = schedulerResult)
// v1internal 路由只支持 OAuth 账户,不支持 API Key 账户 // v1internal 路由只支持 OAuth 账户,不支持 API Key 账户
if (accountType === 'gemini-api') { if (accountType === 'gemini-api') {
@@ -1717,7 +1826,7 @@ async function handleStreamGenerateContent(req, res) {
}) })
} }
const account = await geminiAccountService.getAccount(accountId) account = await geminiAccountService.getAccount(accountId)
if (!account) { if (!account) {
logger.error(`❌ Gemini account not found: ${accountId}`) logger.error(`❌ Gemini account not found: ${accountId}`)
return res.status(404).json({ return res.status(404).json({
@@ -1997,6 +2106,14 @@ async function handleStreamGenerateContent(req, res) {
requestMethod: error.config?.method, requestMethod: error.config?.method,
stack: error.stack stack: error.stack
}) })
await handleGeminiUpstreamError(
error.response?.status,
accountId,
accountType,
sessionHash,
error.response?.headers,
account?.disableAutoProtection
)
if (!res.headersSent) { if (!res.headersSent) {
res.status(500).json({ res.status(500).json({
@@ -2025,6 +2142,7 @@ async function handleStandardGenerateContent(req, res) {
let account = null let account = null
let sessionHash = null let sessionHash = null
let accountId = null let accountId = null
let accountType = null
let isApiAccount = false let isApiAccount = false
try { try {
@@ -2102,8 +2220,7 @@ async function handleStandardGenerateContent(req, res) {
model, model,
{ allowApiAccounts: true } { allowApiAccounts: true }
) )
;({ accountId } = schedulerResult) ;({ accountId, accountType } = schedulerResult)
const { accountType } = schedulerResult
isApiAccount = accountType === 'gemini-api' isApiAccount = accountType === 'gemini-api'
const actualAccountId = accountId const actualAccountId = accountId
@@ -2148,6 +2265,12 @@ async function handleStandardGenerateContent(req, res) {
// Gemini API 账户:直接使用 API Key 请求 // Gemini API 账户:直接使用 API Key 请求
const apiUrl = buildGeminiApiUrl(account.baseUrl, model, 'generateContent', account.apiKey) const apiUrl = buildGeminiApiUrl(account.baseUrl, model, 'generateContent', account.apiKey)
logger.info('📤 Gemini upstream request', {
targetUrl: apiUrl.replace(/key=[^&]+/, 'key=***'),
model,
accountId: account.id
})
const axiosConfig = { const axiosConfig = {
method: 'POST', method: 'POST',
url: apiUrl, url: apiUrl,
@@ -2282,6 +2405,14 @@ async function handleStandardGenerateContent(req, res) {
responseData: error.response?.data, responseData: error.response?.data,
stack: error.stack stack: error.stack
}) })
await handleGeminiUpstreamError(
error.response?.status,
accountId,
accountType,
sessionHash,
error.response?.headers,
account?.disableAutoProtection
)
res.status(500).json({ res.status(500).json({
error: { error: {
@@ -2300,6 +2431,7 @@ async function handleStandardStreamGenerateContent(req, res) {
let account = null let account = null
let sessionHash = null let sessionHash = null
let accountId = null let accountId = null
let accountType = null
let isApiAccount = false let isApiAccount = false
try { try {
@@ -2375,8 +2507,7 @@ async function handleStandardStreamGenerateContent(req, res) {
model, model,
{ allowApiAccounts: true } { allowApiAccounts: true }
) )
;({ accountId } = schedulerResult) ;({ accountId, accountType } = schedulerResult)
const { accountType } = schedulerResult
isApiAccount = accountType === 'gemini-api' isApiAccount = accountType === 'gemini-api'
const actualAccountId = accountId const actualAccountId = accountId
@@ -2446,6 +2577,12 @@ async function handleStandardStreamGenerateContent(req, res) {
} }
) )
logger.info('📤 Gemini upstream request', {
targetUrl: apiUrl.replace(/key=[^&]+/, 'key=***'),
model,
accountId: actualAccountId
})
const axiosConfig = { const axiosConfig = {
method: 'POST', method: 'POST',
url: apiUrl, url: apiUrl,
@@ -2755,9 +2892,17 @@ async function handleStandardStreamGenerateContent(req, res) {
responseData: normalizedError.parsedBody || normalizedError.rawBody, responseData: normalizedError.parsedBody || normalizedError.rawBody,
stack: error.stack stack: error.stack
}) })
await handleGeminiUpstreamError(
normalizedError.status || error.response?.status,
accountId,
accountType,
sessionHash,
error.response?.headers,
account?.disableAutoProtection
)
if (!res.headersSent) { if (!res.headersSent) {
const statusCode = normalizedError.status || 500 const statusCode = error.statusCode || normalizedError.status || 500
const responseBody = { const responseBody = {
error: { error: {
message: normalizedError.message, message: normalizedError.message,
@@ -2792,6 +2937,7 @@ async function handleStandardStreamGenerateContent(req, res) {
module.exports = { module.exports = {
// 工具函数 // 工具函数
buildGeminiApiUrl,
generateSessionHash, generateSessionHash,
checkPermissions, checkPermissions,
ensureGeminiPermission, ensureGeminiPermission,

View File

@@ -9,7 +9,7 @@ const ClientValidator = require('../validators/clientValidator')
const ClaudeCodeValidator = require('../validators/clients/claudeCodeValidator') const ClaudeCodeValidator = require('../validators/clients/claudeCodeValidator')
const claudeRelayConfigService = require('../services/claudeRelayConfigService') const claudeRelayConfigService = require('../services/claudeRelayConfigService')
const { calculateWaitTimeStats } = require('../utils/statsHelper') const { calculateWaitTimeStats } = require('../utils/statsHelper')
const { isClaudeFamilyModel } = require('../utils/modelHelper') const { isOpusModel } = require('../utils/modelHelper')
// 工具函数 // 工具函数
function sleep(ms) { function sleep(ms) {
@@ -1256,7 +1256,7 @@ const authenticateApiKey = async (req, res, next) => {
const model = requestBody.model || '' const model = requestBody.model || ''
// 判断是否为 Claude 模型 // 判断是否为 Claude 模型
if (isClaudeFamilyModel(model)) { if (isOpusModel(model)) {
const weeklyOpusCost = validation.keyData.weeklyOpusCost || 0 const weeklyOpusCost = validation.keyData.weeklyOpusCost || 0
if (weeklyOpusCost >= weeklyOpusCostLimit) { if (weeklyOpusCost >= weeklyOpusCostLimit) {
@@ -1451,6 +1451,7 @@ const authenticateAdmin = async (req, res, next) => {
} }
const authDuration = Date.now() - startTime const authDuration = Date.now() - startTime
req._authInfo = `${adminSession.username} ${authDuration}ms`
logger.security(`Admin authenticated: ${adminSession.username} in ${authDuration}ms`) logger.security(`Admin authenticated: ${adminSession.username} in ${authDuration}ms`)
return next() return next()
@@ -1593,6 +1594,7 @@ const authenticateUserOrAdmin = async (req, res, next) => {
req.userType = 'admin' req.userType = 'admin'
const authDuration = Date.now() - startTime const authDuration = Date.now() - startTime
req._authInfo = `${adminSession.username} ${authDuration}ms`
logger.security(`Admin authenticated: ${adminSession.username} in ${authDuration}ms`) logger.security(`Admin authenticated: ${adminSession.username} in ${authDuration}ms`)
return next() return next()
} }
@@ -1773,67 +1775,80 @@ const requestLogger = (req, res, next) => {
const userAgent = req.get('User-Agent') || 'unknown' const userAgent = req.get('User-Agent') || 'unknown'
const referer = req.get('Referer') || 'none' const referer = req.get('Referer') || 'none'
// 记录请求开始 // 请求开始 → debug 级别(减少正常请求的日志量)
const isDebugRoute = req.originalUrl.includes('event_logging') const isDebugRoute = req.originalUrl.includes('event_logging')
if (req.originalUrl !== '/health') { if (req.originalUrl !== '/health') {
if (isDebugRoute) { logger.debug(`▶ [${requestId}] ${req.method} ${req.originalUrl}`, {
logger.debug(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`) ip: clientIP,
} else { body: req.body && Object.keys(req.body).length > 0 ? req.body : undefined
logger.info(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`) })
} }
// 拦截 res.json() 捕获响应体
const originalJson = res.json.bind(res)
res.json = (body) => {
res._responseBody = body
return originalJson(body)
} }
res.on('finish', () => { res.on('finish', () => {
if (req.originalUrl === '/health') {
return
}
const duration = Date.now() - start const duration = Date.now() - start
const contentLength = res.get('Content-Length') || '0' const contentLength = res.get('Content-Length') || '0'
const status = res.statusCode
// 构建日志元数据 // 状态 emoji
const logMetadata = { const emoji = status >= 500 ? '❌' : status >= 400 ? '⚠️ ' : '🟢'
requestId, const level = status >= 500 ? 'error' : status >= 400 ? 'warn' : 'info'
method: req.method,
url: req.originalUrl, // 主消息行
status: res.statusCode, const msg = `${emoji} ${status} ${req.method} ${req.originalUrl} ${duration}ms ${contentLength}B`
duration,
contentLength, // 构建树形 metadata
ip: clientIP, const meta = { requestId }
userAgent,
referer // 请求体(非 GET 且有内容时显示)
if (req.method !== 'GET' && req.body && Object.keys(req.body).length > 0) {
meta.req = req.body
} }
// 根据状态码选择日志级别 // 查询参数GET 请求且有查询参数时单独显示)
if (res.statusCode >= 500) { const queryIdx = req.originalUrl.indexOf('?')
logger.error( if (queryIdx > -1) {
`◀️ [${requestId}] ${req.method} ${req.originalUrl} | ${res.statusCode} | ${duration}ms | ${contentLength}B`, meta.query = req.originalUrl.substring(queryIdx + 1)
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') {
if (isDebugRoute) {
logger.debug(
`🟢 ${req.method} ${req.originalUrl} - ${res.statusCode} (${duration}ms)`,
logMetadata
)
} else {
logger.request(req.method, req.originalUrl, res.statusCode, duration, logMetadata)
}
} }
// API Key相关日志 // 响应体
if (res._responseBody) {
meta.res = res._responseBody
}
// API Key 信息(合并到同一条日志)
if (req.apiKey) { if (req.apiKey) {
logger.api( meta.key = `${req.apiKey.name} (${req.apiKey.id})`
`📱 [${requestId}] Request from ${req.apiKey.name} (${req.apiKey.id}) | ${duration}ms` }
)
// 认证信息
if (req._authInfo) {
meta.auth = req._authInfo
}
// 完整信息写入文件
meta.ip = clientIP
meta.ua = userAgent
meta.referer = referer
if (isDebugRoute) {
logger.debug(msg, meta)
} else {
logger[level](msg, meta)
} }
// 慢请求警告 // 慢请求警告
if (duration > 5000) { if (duration > 5000) {
logger.warn( logger.warn(`🐌 Slow request: ${duration}ms ${req.method} ${req.originalUrl}`)
`🐌 [${requestId}] Slow request detected: ${duration}ms for ${req.method} ${req.originalUrl}`
)
} }
}) })

View File

@@ -1,7 +1,7 @@
const express = require('express') const express = require('express')
const { authenticateAdmin } = require('../../middleware/auth') const { authenticateAdmin } = require('../../middleware/auth')
const logger = require('../../utils/logger') const logger = require('../../utils/logger')
const accountBalanceService = require('../../services/accountBalanceService') const accountBalanceService = require('../../services/account/accountBalanceService')
const balanceScriptService = require('../../services/balanceScriptService') const balanceScriptService = require('../../services/balanceScriptService')
const { isBalanceScriptEnabled } = require('../../utils/featureFlags') const { isBalanceScriptEnabled } = require('../../utils/featureFlags')

View File

@@ -1,10 +1,10 @@
const express = require('express') const express = require('express')
const accountGroupService = require('../../services/accountGroupService') const accountGroupService = require('../../services/accountGroupService')
const claudeAccountService = require('../../services/claudeAccountService') const claudeAccountService = require('../../services/account/claudeAccountService')
const claudeConsoleAccountService = require('../../services/claudeConsoleAccountService') const claudeConsoleAccountService = require('../../services/account/claudeConsoleAccountService')
const geminiAccountService = require('../../services/geminiAccountService') const geminiAccountService = require('../../services/account/geminiAccountService')
const openaiAccountService = require('../../services/openaiAccountService') const openaiAccountService = require('../../services/account/openaiAccountService')
const droidAccountService = require('../../services/droidAccountService') const droidAccountService = require('../../services/account/droidAccountService')
const { authenticateAdmin } = require('../../middleware/auth') const { authenticateAdmin } = require('../../middleware/auth')
const logger = require('../../utils/logger') const logger = require('../../utils/logger')

View File

@@ -1,5 +1,5 @@
const express = require('express') const express = require('express')
const azureOpenaiAccountService = require('../../services/azureOpenaiAccountService') const azureOpenaiAccountService = require('../../services/account/azureOpenaiAccountService')
const accountGroupService = require('../../services/accountGroupService') const accountGroupService = require('../../services/accountGroupService')
const apiKeyService = require('../../services/apiKeyService') const apiKeyService = require('../../services/apiKeyService')
const redis = require('../../models/redis') const redis = require('../../models/redis')
@@ -418,6 +418,10 @@ router.post('/migrate-api-keys-azure', authenticateAdmin, async (req, res) => {
router.post('/azure-openai-accounts/:accountId/test', authenticateAdmin, async (req, res) => { router.post('/azure-openai-accounts/:accountId/test', authenticateAdmin, async (req, res) => {
const { accountId } = req.params const { accountId } = req.params
const startTime = Date.now() const startTime = Date.now()
const {
createChatCompletionsTestPayload,
extractErrorMessage
} = require('../../utils/testPayloadHelper')
try { try {
// 获取账户信息 // 获取账户信息
@@ -433,13 +437,12 @@ router.post('/azure-openai-accounts/:accountId/test', authenticateAdmin, async (
} }
// 构造测试请求 // 构造测试请求
const { createOpenAITestPayload } = require('../../utils/testPayloadHelper')
const { getProxyAgent } = require('../../utils/proxyHelper') const { getProxyAgent } = require('../../utils/proxyHelper')
const deploymentName = account.deploymentName || 'gpt-4o-mini' const deploymentName = account.deploymentName || 'gpt-4o-mini'
const apiVersion = account.apiVersion || '2024-02-15-preview' const apiVersion = account.apiVersion || '2024-02-15-preview'
const apiUrl = `${account.endpoint}/openai/deployments/${deploymentName}/chat/completions?api-version=${apiVersion}` const apiUrl = `${account.endpoint}/openai/deployments/${deploymentName}/chat/completions?api-version=${apiVersion}`
const payload = createOpenAITestPayload(deploymentName) const payload = createChatCompletionsTestPayload(deploymentName)
const requestConfig = { const requestConfig = {
headers: { headers: {
@@ -488,10 +491,23 @@ router.post('/azure-openai-accounts/:accountId/test', authenticateAdmin, async (
return res.status(500).json({ return res.status(500).json({
success: false, success: false,
error: 'Test failed', error: 'Test failed',
message: error.response?.data?.error?.message || error.message, message: extractErrorMessage(error.response?.data, error.message),
latency latency
}) })
} }
}) })
// 重置 Azure OpenAI 账户状态
router.post('/:accountId/reset-status', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const result = await azureOpenaiAccountService.resetAccountStatus(accountId)
logger.success(`Admin reset status for Azure OpenAI account: ${accountId}`)
return res.json({ success: true, data: result })
} catch (error) {
logger.error('❌ Failed to reset Azure OpenAI account status:', error)
return res.status(500).json({ error: 'Failed to reset status', message: error.message })
}
})
module.exports = router module.exports = router

View File

@@ -5,7 +5,7 @@
const express = require('express') const express = require('express')
const router = express.Router() const router = express.Router()
const bedrockAccountService = require('../../services/bedrockAccountService') const bedrockAccountService = require('../../services/account/bedrockAccountService')
const apiKeyService = require('../../services/apiKeyService') const apiKeyService = require('../../services/apiKeyService')
const accountGroupService = require('../../services/accountGroupService') const accountGroupService = require('../../services/accountGroupService')
const redis = require('../../models/redis') const redis = require('../../models/redis')
@@ -363,4 +363,17 @@ router.post('/:accountId/test', authenticateAdmin, async (req, res) => {
} }
}) })
// 重置 Bedrock 账户状态
router.post('/:accountId/reset-status', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const result = await bedrockAccountService.resetAccountStatus(accountId)
logger.success(`Admin reset status for Bedrock account: ${accountId}`)
return res.json({ success: true, data: result })
} catch (error) {
logger.error('❌ Failed to reset Bedrock account status:', error)
return res.status(500).json({ error: 'Failed to reset status', message: error.message })
}
})
module.exports = router module.exports = router

View File

@@ -1,5 +1,5 @@
const express = require('express') const express = require('express')
const ccrAccountService = require('../../services/ccrAccountService') const ccrAccountService = require('../../services/account/ccrAccountService')
const accountGroupService = require('../../services/accountGroupService') const accountGroupService = require('../../services/accountGroupService')
const apiKeyService = require('../../services/apiKeyService') const apiKeyService = require('../../services/apiKeyService')
const redis = require('../../models/redis') const redis = require('../../models/redis')
@@ -7,6 +7,7 @@ const { authenticateAdmin } = require('../../middleware/auth')
const logger = require('../../utils/logger') const logger = require('../../utils/logger')
const webhookNotifier = require('../../utils/webhookNotifier') const webhookNotifier = require('../../utils/webhookNotifier')
const { formatAccountExpiry, mapExpiryField } = require('./utils') const { formatAccountExpiry, mapExpiryField } = require('./utils')
const { extractErrorMessage } = require('../../utils/testPayloadHelper')
const router = express.Router() const router = express.Router()
@@ -492,7 +493,7 @@ router.post('/:accountId/test', authenticateAdmin, async (req, res) => {
return res.status(500).json({ return res.status(500).json({
success: false, success: false,
error: 'Test failed', error: 'Test failed',
message: error.response?.data?.error?.message || error.message, message: extractErrorMessage(error.response?.data, error.message),
latency latency
}) })
} }

View File

@@ -6,8 +6,8 @@
const express = require('express') const express = require('express')
const router = express.Router() const router = express.Router()
const claudeAccountService = require('../../services/claudeAccountService') const claudeAccountService = require('../../services/account/claudeAccountService')
const claudeRelayService = require('../../services/claudeRelayService') const claudeRelayService = require('../../services/relay/claudeRelayService')
const accountGroupService = require('../../services/accountGroupService') const accountGroupService = require('../../services/accountGroupService')
const accountTestSchedulerService = require('../../services/accountTestSchedulerService') const accountTestSchedulerService = require('../../services/accountTestSchedulerService')
const apiKeyService = require('../../services/apiKeyService') const apiKeyService = require('../../services/apiKeyService')

View File

@@ -6,8 +6,8 @@
const express = require('express') const express = require('express')
const router = express.Router() const router = express.Router()
const claudeConsoleAccountService = require('../../services/claudeConsoleAccountService') const claudeConsoleAccountService = require('../../services/account/claudeConsoleAccountService')
const claudeConsoleRelayService = require('../../services/claudeConsoleRelayService') const claudeConsoleRelayService = require('../../services/relay/claudeConsoleRelayService')
const accountGroupService = require('../../services/accountGroupService') const accountGroupService = require('../../services/accountGroupService')
const apiKeyService = require('../../services/apiKeyService') const apiKeyService = require('../../services/apiKeyService')
const redis = require('../../models/redis') const redis = require('../../models/redis')

View File

@@ -1,16 +1,17 @@
const express = require('express') const express = require('express')
const apiKeyService = require('../../services/apiKeyService') const apiKeyService = require('../../services/apiKeyService')
const claudeAccountService = require('../../services/claudeAccountService') const claudeAccountService = require('../../services/account/claudeAccountService')
const claudeConsoleAccountService = require('../../services/claudeConsoleAccountService') const claudeConsoleAccountService = require('../../services/account/claudeConsoleAccountService')
const bedrockAccountService = require('../../services/bedrockAccountService') const bedrockAccountService = require('../../services/account/bedrockAccountService')
const ccrAccountService = require('../../services/ccrAccountService') const ccrAccountService = require('../../services/account/ccrAccountService')
const geminiAccountService = require('../../services/geminiAccountService') const geminiAccountService = require('../../services/account/geminiAccountService')
const droidAccountService = require('../../services/droidAccountService') const droidAccountService = require('../../services/account/droidAccountService')
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService') const openaiResponsesAccountService = require('../../services/account/openaiResponsesAccountService')
const redis = require('../../models/redis') const redis = require('../../models/redis')
const { authenticateAdmin } = require('../../middleware/auth') const { authenticateAdmin } = require('../../middleware/auth')
const logger = require('../../utils/logger') const logger = require('../../utils/logger')
const CostCalculator = require('../../utils/costCalculator') const CostCalculator = require('../../utils/costCalculator')
const upstreamErrorHelper = require('../../utils/upstreamErrorHelper')
const config = require('../../../config/config') const config = require('../../../config/config')
const router = express.Router() const router = express.Router()
@@ -352,6 +353,17 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
} }
}) })
// 获取所有临时不可用账户状态
router.get('/temp-unavailable', authenticateAdmin, async (req, res) => {
try {
const statuses = await upstreamErrorHelper.getAllTempUnavailable()
return res.json({ success: true, data: statuses })
} catch (error) {
logger.error('❌ Failed to get temp unavailable statuses:', error)
return res.status(500).json({ error: 'Failed to get temp unavailable statuses' })
}
})
// 获取使用统计 // 获取使用统计
router.get('/usage-stats', authenticateAdmin, async (req, res) => { router.get('/usage-stats', authenticateAdmin, async (req, res) => {
try { try {

View File

@@ -1,6 +1,6 @@
const express = require('express') const express = require('express')
const crypto = require('crypto') const crypto = require('crypto')
const droidAccountService = require('../../services/droidAccountService') const droidAccountService = require('../../services/account/droidAccountService')
const accountGroupService = require('../../services/accountGroupService') const accountGroupService = require('../../services/accountGroupService')
const apiKeyService = require('../../services/apiKeyService') const apiKeyService = require('../../services/apiKeyService')
const redis = require('../../models/redis') const redis = require('../../models/redis')
@@ -13,6 +13,7 @@ const {
} = require('../../utils/workosOAuthHelper') } = require('../../utils/workosOAuthHelper')
const webhookNotifier = require('../../utils/webhookNotifier') const webhookNotifier = require('../../utils/webhookNotifier')
const { formatAccountExpiry, mapExpiryField } = require('./utils') const { formatAccountExpiry, mapExpiryField } = require('./utils')
const { extractErrorMessage } = require('../../utils/testPayloadHelper')
const router = express.Router() const router = express.Router()
@@ -683,10 +684,23 @@ router.post('/droid-accounts/:accountId/test', authenticateAdmin, async (req, re
return res.status(500).json({ return res.status(500).json({
success: false, success: false,
error: 'Test failed', error: 'Test failed',
message: error.response?.data?.error?.message || error.message, message: extractErrorMessage(error.response?.data, error.message),
latency latency
}) })
} }
}) })
// 重置 Droid 账户状态
router.post('/:accountId/reset-status', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const result = await droidAccountService.resetAccountStatus(accountId)
logger.success(`Admin reset status for Droid account: ${accountId}`)
return res.json({ success: true, data: result })
} catch (error) {
logger.error('❌ Failed to reset Droid account status:', error)
return res.status(500).json({ error: 'Failed to reset status', message: error.message })
}
})
module.exports = router module.exports = router

View File

@@ -1,5 +1,5 @@
const express = require('express') const express = require('express')
const geminiAccountService = require('../../services/geminiAccountService') const geminiAccountService = require('../../services/account/geminiAccountService')
const accountGroupService = require('../../services/accountGroupService') const accountGroupService = require('../../services/accountGroupService')
const apiKeyService = require('../../services/apiKeyService') const apiKeyService = require('../../services/apiKeyService')
const redis = require('../../models/redis') const redis = require('../../models/redis')
@@ -511,6 +511,7 @@ router.post('/:accountId/test', authenticateAdmin, async (req, res) => {
const { accountId } = req.params const { accountId } = req.params
const { model = 'gemini-2.5-flash' } = req.body const { model = 'gemini-2.5-flash' } = req.body
const startTime = Date.now() const startTime = Date.now()
const { extractErrorMessage } = require('../../utils/testPayloadHelper')
try { try {
// 获取账户信息 // 获取账户信息
@@ -585,7 +586,7 @@ router.post('/:accountId/test', authenticateAdmin, async (req, res) => {
return res.status(500).json({ return res.status(500).json({
success: false, success: false,
error: 'Test failed', error: 'Test failed',
message: error.response?.data?.error?.message || error.message, message: extractErrorMessage(error.response?.data, error.message),
latency latency
}) })
} }

View File

@@ -1,5 +1,5 @@
const express = require('express') const express = require('express')
const geminiApiAccountService = require('../../services/geminiApiAccountService') const geminiApiAccountService = require('../../services/account/geminiApiAccountService')
const apiKeyService = require('../../services/apiKeyService') const apiKeyService = require('../../services/apiKeyService')
const accountGroupService = require('../../services/accountGroupService') const accountGroupService = require('../../services/accountGroupService')
const redis = require('../../models/redis') const redis = require('../../models/redis')
@@ -452,4 +452,164 @@ router.post('/gemini-api-accounts/:id/reset-status', authenticateAdmin, async (r
} }
}) })
// 测试 Gemini-API 账户连通性SSE 流式)
const ALLOWED_MAX_TOKENS = [100, 500, 1000, 2000, 4096]
const sanitizeMaxTokens = (value) =>
ALLOWED_MAX_TOKENS.includes(Number(value)) ? Number(value) : 500
router.post('/gemini-api-accounts/:accountId/test', authenticateAdmin, async (req, res) => {
const { accountId } = req.params
const { model = 'gemini-2.5-flash', prompt = 'hi' } = req.body
const maxTokens = sanitizeMaxTokens(req.body.maxTokens)
const { createGeminiTestPayload, extractErrorMessage } = require('../../utils/testPayloadHelper')
const { buildGeminiApiUrl } = require('../../handlers/geminiHandlers')
const ProxyHelper = require('../../utils/proxyHelper')
const axios = require('axios')
const abortController = new AbortController()
res.on('close', () => abortController.abort())
const safeWrite = (data) => {
if (!res.writableEnded && !res.destroyed) {
res.write(data)
}
}
const safeEnd = () => {
if (!res.writableEnded && !res.destroyed) {
res.end()
}
}
try {
const account = await geminiApiAccountService.getAccount(accountId)
if (!account) {
return res.status(404).json({ error: 'Account not found' })
}
if (!account.apiKey) {
return res.status(401).json({ error: 'API Key not found or decryption failed' })
}
const baseUrl = account.baseUrl || 'https://generativelanguage.googleapis.com'
const apiUrl = buildGeminiApiUrl(baseUrl, model, 'streamGenerateContent', account.apiKey, {
stream: true
})
// 设置 SSE 响应头
if (res.writableEnded || res.destroyed) {
return
}
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no'
})
safeWrite(`data: ${JSON.stringify({ type: 'test_start', message: 'Test started' })}\n\n`)
const payload = createGeminiTestPayload(model, { prompt, maxTokens })
const requestConfig = {
headers: { 'Content-Type': 'application/json' },
timeout: 60000,
responseType: 'stream',
validateStatus: () => true,
signal: abortController.signal
}
// 配置代理
if (account.proxy) {
const agent = ProxyHelper.createProxyAgent(account.proxy)
if (agent) {
requestConfig.httpsAgent = agent
requestConfig.httpAgent = agent
}
}
try {
const response = await axios.post(apiUrl, payload, requestConfig)
if (response.status !== 200) {
const chunks = []
response.data.on('data', (chunk) => chunks.push(chunk))
response.data.on('end', () => {
const errorData = Buffer.concat(chunks).toString()
let errorMsg = `API Error: ${response.status}`
try {
const json = JSON.parse(errorData)
errorMsg = extractErrorMessage(json, errorMsg)
} catch {
if (errorData.length < 500) {
errorMsg = errorData || errorMsg
}
}
safeWrite(
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: errorMsg })}\n\n`
)
safeEnd()
})
response.data.on('error', () => {
safeWrite(
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: `API Error: ${response.status}` })}\n\n`
)
safeEnd()
})
return
}
let buffer = ''
response.data.on('data', (chunk) => {
buffer += chunk.toString()
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (!line.startsWith('data:')) {
continue
}
const jsonStr = line.substring(5).trim()
if (!jsonStr || jsonStr === '[DONE]') {
continue
}
try {
const data = JSON.parse(jsonStr)
const text = data.candidates?.[0]?.content?.parts?.[0]?.text
if (text) {
safeWrite(`data: ${JSON.stringify({ type: 'content', text })}\n\n`)
}
} catch {
// ignore parse errors
}
}
})
response.data.on('end', () => {
safeWrite(`data: ${JSON.stringify({ type: 'test_complete', success: true })}\n\n`)
safeEnd()
})
response.data.on('error', (err) => {
safeWrite(
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: err.message })}\n\n`
)
safeEnd()
})
} catch (axiosError) {
if (axiosError.name === 'CanceledError') {
return
}
safeWrite(
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: axiosError.message })}\n\n`
)
safeEnd()
}
} catch (error) {
logger.error('Gemini-API account test failed:', error)
if (!res.headersSent) {
return res.status(500).json({ error: 'Test failed', message: error.message })
}
safeWrite(`data: ${JSON.stringify({ type: 'error', error: error.message })}\n\n`)
safeEnd()
}
})
module.exports = router module.exports = router

View File

@@ -6,7 +6,7 @@
const express = require('express') const express = require('express')
const crypto = require('crypto') const crypto = require('crypto')
const axios = require('axios') const axios = require('axios')
const openaiAccountService = require('../../services/openaiAccountService') const openaiAccountService = require('../../services/account/openaiAccountService')
const accountGroupService = require('../../services/accountGroupService') const accountGroupService = require('../../services/accountGroupService')
const apiKeyService = require('../../services/apiKeyService') const apiKeyService = require('../../services/apiKeyService')
const redis = require('../../models/redis') const redis = require('../../models/redis')

View File

@@ -4,7 +4,8 @@
*/ */
const express = require('express') const express = require('express')
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService') const axios = require('axios')
const openaiResponsesAccountService = require('../../services/account/openaiResponsesAccountService')
const apiKeyService = require('../../services/apiKeyService') const apiKeyService = require('../../services/apiKeyService')
const accountGroupService = require('../../services/accountGroupService') const accountGroupService = require('../../services/accountGroupService')
const redis = require('../../models/redis') const redis = require('../../models/redis')
@@ -12,6 +13,8 @@ const { authenticateAdmin } = require('../../middleware/auth')
const logger = require('../../utils/logger') const logger = require('../../utils/logger')
const webhookNotifier = require('../../utils/webhookNotifier') const webhookNotifier = require('../../utils/webhookNotifier')
const { formatAccountExpiry, mapExpiryField } = require('./utils') const { formatAccountExpiry, mapExpiryField } = require('./utils')
const { createOpenAITestPayload, extractErrorMessage } = require('../../utils/testPayloadHelper')
const { getProxyAgent } = require('../../utils/proxyHelper')
const router = express.Router() const router = express.Router()
@@ -459,31 +462,25 @@ router.post('/openai-responses-accounts/:accountId/test', authenticateAdmin, asy
const startTime = Date.now() const startTime = Date.now()
try { try {
// 获取账户信息 // 获取账户信息apiKey 已自动解密)
const account = await openaiResponsesAccountService.getAccount(accountId) const account = await openaiResponsesAccountService.getAccount(accountId)
if (!account) { if (!account) {
return res.status(404).json({ error: 'Account not found' }) return res.status(404).json({ error: 'Account not found' })
} }
// 获取解密后的 API Key if (!account.apiKey) {
const apiKey = await openaiResponsesAccountService.getDecryptedApiKey(accountId)
if (!apiKey) {
return res.status(401).json({ error: 'API Key not found or decryption failed' }) return res.status(401).json({ error: 'API Key not found or decryption failed' })
} }
// 构造测试请求 // 构造测试请求
const axios = require('axios') const baseUrl = account.baseApi || 'https://api.openai.com'
const { createOpenAITestPayload } = require('../../utils/testPayloadHelper') const apiUrl = `${baseUrl}/responses`
const { getProxyAgent } = require('../../utils/proxyHelper') const payload = createOpenAITestPayload(model, { stream: false })
const baseUrl = account.baseUrl || 'https://api.openai.com'
const apiUrl = `${baseUrl}/v1/chat/completions`
const payload = createOpenAITestPayload(model)
const requestConfig = { const requestConfig = {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}` Authorization: `Bearer ${account.apiKey}`
}, },
timeout: 30000 timeout: 30000
} }
@@ -500,10 +497,19 @@ router.post('/openai-responses-accounts/:accountId/test', authenticateAdmin, asy
const response = await axios.post(apiUrl, payload, requestConfig) const response = await axios.post(apiUrl, payload, requestConfig)
const latency = Date.now() - startTime const latency = Date.now() - startTime
// 提取响应文本 // 提取响应文本Responses API 格式)
let responseText = '' let responseText = ''
if (response.data?.choices?.[0]?.message?.content) { const output = response.data?.output
responseText = response.data.choices[0].message.content if (Array.isArray(output)) {
for (const item of output) {
if (item.type === 'message' && Array.isArray(item.content)) {
for (const block of item.content) {
if (block.type === 'output_text' && block.text) {
responseText += block.text
}
}
}
}
} }
logger.success( logger.success(
@@ -527,7 +533,7 @@ router.post('/openai-responses-accounts/:accountId/test', authenticateAdmin, asy
return res.status(500).json({ return res.status(500).json({
success: false, success: false,
error: 'Test failed', error: 'Test failed',
message: error.response?.data?.error?.message || error.message, message: extractErrorMessage(error.response?.data, error.message),
latency latency
}) })
} }

View File

@@ -8,10 +8,10 @@ const router = express.Router()
const { authenticateAdmin } = require('../../middleware/auth') const { authenticateAdmin } = require('../../middleware/auth')
const redis = require('../../models/redis') const redis = require('../../models/redis')
const claudeAccountService = require('../../services/claudeAccountService') const claudeAccountService = require('../../services/account/claudeAccountService')
const claudeConsoleAccountService = require('../../services/claudeConsoleAccountService') const claudeConsoleAccountService = require('../../services/account/claudeConsoleAccountService')
const openaiAccountService = require('../../services/openaiAccountService') const openaiAccountService = require('../../services/account/openaiAccountService')
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService') const openaiResponsesAccountService = require('../../services/account/openaiResponsesAccountService')
const logger = require('../../utils/logger') const logger = require('../../utils/logger')
function toBool(value, defaultValue = false) { function toBool(value, defaultValue = false) {

View File

@@ -3,7 +3,7 @@ const fs = require('fs')
const path = require('path') const path = require('path')
const axios = require('axios') const axios = require('axios')
const claudeCodeHeadersService = require('../../services/claudeCodeHeadersService') const claudeCodeHeadersService = require('../../services/claudeCodeHeadersService')
const claudeAccountService = require('../../services/claudeAccountService') const claudeAccountService = require('../../services/account/claudeAccountService')
const redis = require('../../models/redis') const redis = require('../../models/redis')
const { authenticateAdmin } = require('../../middleware/auth') const { authenticateAdmin } = require('../../middleware/auth')
const logger = require('../../utils/logger') const logger = require('../../utils/logger')
@@ -408,4 +408,47 @@ router.post('/claude-code-version/clear', authenticateAdmin, async (req, res) =>
} }
}) })
// ==================== 模型价格管理 ====================
const pricingService = require('../../services/pricingService')
// 获取所有模型价格数据
router.get('/models/pricing', authenticateAdmin, async (req, res) => {
try {
if (!pricingService.pricingData || Object.keys(pricingService.pricingData).length === 0) {
await pricingService.loadPricingData()
}
const data = pricingService.pricingData
res.json({
success: true,
data: data || {}
})
} catch (error) {
logger.error('Failed to get model pricing:', error)
res.status(500).json({ error: 'Failed to get model pricing', message: error.message })
}
})
// 获取价格服务状态
router.get('/models/pricing/status', authenticateAdmin, async (req, res) => {
try {
const status = pricingService.getStatus()
res.json({ success: true, data: status })
} catch (error) {
logger.error('Failed to get pricing status:', error)
res.status(500).json({ error: 'Failed to get pricing status', message: error.message })
}
})
// 强制刷新价格数据
router.post('/models/pricing/refresh', authenticateAdmin, async (req, res) => {
try {
const result = await pricingService.forceUpdate()
res.json({ success: result.success, message: result.message })
} catch (error) {
logger.error('Failed to refresh pricing:', error)
res.status(500).json({ error: 'Failed to refresh pricing', message: error.message })
}
})
module.exports = router module.exports = router

View File

@@ -1,14 +1,14 @@
const express = require('express') const express = require('express')
const apiKeyService = require('../../services/apiKeyService') const apiKeyService = require('../../services/apiKeyService')
const ccrAccountService = require('../../services/ccrAccountService') const ccrAccountService = require('../../services/account/ccrAccountService')
const claudeAccountService = require('../../services/claudeAccountService') const claudeAccountService = require('../../services/account/claudeAccountService')
const claudeConsoleAccountService = require('../../services/claudeConsoleAccountService') const claudeConsoleAccountService = require('../../services/account/claudeConsoleAccountService')
const geminiAccountService = require('../../services/geminiAccountService') const geminiAccountService = require('../../services/account/geminiAccountService')
const geminiApiAccountService = require('../../services/geminiApiAccountService') const geminiApiAccountService = require('../../services/account/geminiApiAccountService')
const openaiAccountService = require('../../services/openaiAccountService') const openaiAccountService = require('../../services/account/openaiAccountService')
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService') const openaiResponsesAccountService = require('../../services/account/openaiResponsesAccountService')
const droidAccountService = require('../../services/droidAccountService') const droidAccountService = require('../../services/account/droidAccountService')
const bedrockAccountService = require('../../services/bedrockAccountService') const bedrockAccountService = require('../../services/account/bedrockAccountService')
const redis = require('../../models/redis') const redis = require('../../models/redis')
const { authenticateAdmin } = require('../../middleware/auth') const { authenticateAdmin } = require('../../middleware/auth')
const logger = require('../../utils/logger') const logger = require('../../utils/logger')

View File

@@ -1,10 +1,10 @@
const express = require('express') const express = require('express')
const claudeRelayService = require('../services/claudeRelayService') const claudeRelayService = require('../services/relay/claudeRelayService')
const claudeConsoleRelayService = require('../services/claudeConsoleRelayService') const claudeConsoleRelayService = require('../services/relay/claudeConsoleRelayService')
const bedrockRelayService = require('../services/bedrockRelayService') const bedrockRelayService = require('../services/relay/bedrockRelayService')
const ccrRelayService = require('../services/ccrRelayService') const ccrRelayService = require('../services/relay/ccrRelayService')
const bedrockAccountService = require('../services/bedrockAccountService') const bedrockAccountService = require('../services/account/bedrockAccountService')
const unifiedClaudeScheduler = require('../services/unifiedClaudeScheduler') const unifiedClaudeScheduler = require('../services/scheduler/unifiedClaudeScheduler')
const apiKeyService = require('../services/apiKeyService') const apiKeyService = require('../services/apiKeyService')
const { authenticateApiKey } = require('../middleware/auth') const { authenticateApiKey } = require('../middleware/auth')
const logger = require('../utils/logger') const logger = require('../utils/logger')
@@ -12,8 +12,8 @@ const { getEffectiveModel, parseVendorPrefixedModel } = require('../utils/modelH
const sessionHelper = require('../utils/sessionHelper') const sessionHelper = require('../utils/sessionHelper')
const { updateRateLimitCounters } = require('../utils/rateLimitHelper') const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
const claudeRelayConfigService = require('../services/claudeRelayConfigService') const claudeRelayConfigService = require('../services/claudeRelayConfigService')
const claudeAccountService = require('../services/claudeAccountService') const claudeAccountService = require('../services/account/claudeAccountService')
const claudeConsoleAccountService = require('../services/claudeConsoleAccountService') const claudeConsoleAccountService = require('../services/account/claudeConsoleAccountService')
const { const {
isWarmupRequest, isWarmupRequest,
buildMockWarmupResponse, buildMockWarmupResponse,
@@ -1289,8 +1289,8 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
}) })
} }
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler') const unifiedGeminiScheduler = require('../services/scheduler/unifiedGeminiScheduler')
const geminiAccountService = require('../services/geminiAccountService') const geminiAccountService = require('../services/account/geminiAccountService')
let accountSelection let accountSelection
try { try {

View File

@@ -3,10 +3,14 @@ const redis = require('../models/redis')
const logger = require('../utils/logger') const logger = require('../utils/logger')
const apiKeyService = require('../services/apiKeyService') const apiKeyService = require('../services/apiKeyService')
const CostCalculator = require('../utils/costCalculator') const CostCalculator = require('../utils/costCalculator')
const claudeAccountService = require('../services/claudeAccountService') const claudeAccountService = require('../services/account/claudeAccountService')
const openaiAccountService = require('../services/openaiAccountService') const openaiAccountService = require('../services/account/openaiAccountService')
const serviceRatesService = require('../services/serviceRatesService') const serviceRatesService = require('../services/serviceRatesService')
const { createClaudeTestPayload } = require('../utils/testPayloadHelper') const {
createClaudeTestPayload,
extractErrorMessage,
sanitizeErrorMsg
} = require('../utils/testPayloadHelper')
const modelsConfig = require('../../config/models') const modelsConfig = require('../../config/models')
const { getSafeMessage } = require('../utils/errorSanitizer') const { getSafeMessage } = require('../utils/errorSanitizer')
@@ -25,7 +29,7 @@ router.get('/models', (req, res) => {
}) })
} }
// 返回所有模型(按服务分组) // 返回所有模型(按服务分组 + 平台维度
res.json({ res.json({
success: true, success: true,
data: { data: {
@@ -33,7 +37,8 @@ router.get('/models', (req, res) => {
gemini: modelsConfig.GEMINI_MODELS, gemini: modelsConfig.GEMINI_MODELS,
openai: modelsConfig.OPENAI_MODELS, openai: modelsConfig.OPENAI_MODELS,
other: modelsConfig.OTHER_MODELS, other: modelsConfig.OTHER_MODELS,
all: modelsConfig.getAllModels() all: modelsConfig.getAllModels(),
platforms: modelsConfig.PLATFORM_TEST_MODELS
} }
}) })
}) })
@@ -920,7 +925,8 @@ router.post('/api-key/test', async (req, res) => {
responseStream: res, responseStream: res,
payload: createClaudeTestPayload(model, { stream: true, prompt, maxTokens }), payload: createClaudeTestPayload(model, { stream: true, prompt, maxTokens }),
timeout: 60000, timeout: 60000,
extraHeaders: { 'x-api-key': apiKey } extraHeaders: { 'x-api-key': apiKey },
sanitize: true
}) })
} catch (error) { } catch (error) {
logger.error('❌ API Key test failed:', error) logger.error('❌ API Key test failed:', error)
@@ -1015,14 +1021,14 @@ router.post('/api-key/test-gemini', async (req, res) => {
let errorMsg = `API Error: ${response.status}` let errorMsg = `API Error: ${response.status}`
try { try {
const json = JSON.parse(errorData) const json = JSON.parse(errorData)
errorMsg = json.message || json.error?.message || json.error || errorMsg errorMsg = extractErrorMessage(json, errorMsg)
} catch { } catch {
if (errorData.length < 200) { if (errorData.length < 200) {
errorMsg = errorData || errorMsg errorMsg = errorData || errorMsg
} }
} }
res.write( res.write(
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: errorMsg })}\n\n` `data: ${JSON.stringify({ type: 'test_complete', success: false, error: sanitizeErrorMsg(errorMsg) })}\n\n`
) )
res.end() res.end()
}) })
@@ -1168,14 +1174,14 @@ router.post('/api-key/test-openai', async (req, res) => {
let errorMsg = `API Error: ${response.status}` let errorMsg = `API Error: ${response.status}`
try { try {
const json = JSON.parse(errorData) const json = JSON.parse(errorData)
errorMsg = json.message || json.error?.message || json.error || errorMsg errorMsg = extractErrorMessage(json, errorMsg)
} catch { } catch {
if (errorData.length < 200) { if (errorData.length < 200) {
errorMsg = errorData || errorMsg errorMsg = errorData || errorMsg
} }
} }
res.write( res.write(
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: errorMsg })}\n\n` `data: ${JSON.stringify({ type: 'test_complete', success: false, error: sanitizeErrorMsg(errorMsg) })}\n\n`
) )
res.end() res.end()
}) })

View File

@@ -2,10 +2,11 @@ const express = require('express')
const router = express.Router() const router = express.Router()
const logger = require('../utils/logger') const logger = require('../utils/logger')
const { authenticateApiKey } = require('../middleware/auth') const { authenticateApiKey } = require('../middleware/auth')
const azureOpenaiAccountService = require('../services/azureOpenaiAccountService') const azureOpenaiAccountService = require('../services/account/azureOpenaiAccountService')
const azureOpenaiRelayService = require('../services/azureOpenaiRelayService') const azureOpenaiRelayService = require('../services/relay/azureOpenaiRelayService')
const apiKeyService = require('../services/apiKeyService') const apiKeyService = require('../services/apiKeyService')
const crypto = require('crypto') const crypto = require('crypto')
const upstreamErrorHelper = require('../utils/upstreamErrorHelper')
// 支持的模型列表 - 基于真实的 Azure OpenAI 模型 // 支持的模型列表 - 基于真实的 Azure OpenAI 模型
const ALLOWED_MODELS = { const ALLOWED_MODELS = {
@@ -163,6 +164,16 @@ router.post('/chat/completions', authenticateApiKey, async (req, res) => {
let account = null let account = null
if (req.apiKey?.azureOpenaiAccountId) { if (req.apiKey?.azureOpenaiAccountId) {
account = await azureOpenaiAccountService.getAccount(req.apiKey.azureOpenaiAccountId) account = await azureOpenaiAccountService.getAccount(req.apiKey.azureOpenaiAccountId)
if (account) {
const isTempUnavailable = await upstreamErrorHelper.isTempUnavailable(
account.id,
'azure-openai'
)
if (isTempUnavailable) {
logger.warn(`⏱️ Bound Azure OpenAI account temporarily unavailable, falling back to pool`)
account = null
}
}
if (!account) { if (!account) {
logger.warn(`Bound Azure OpenAI account not found: ${req.apiKey.azureOpenaiAccountId}`) logger.warn(`Bound Azure OpenAI account not found: ${req.apiKey.azureOpenaiAccountId}`)
} }
@@ -182,6 +193,24 @@ router.post('/chat/completions', authenticateApiKey, async (req, res) => {
endpoint: 'chat/completions' endpoint: 'chat/completions'
}) })
// 检查上游响应状态码(仅对认证/限流/服务端错误暂停,不对 400/404 等客户端错误暂停)
const azureAutoProtectionDisabled =
account?.disableAutoProtection === true || account?.disableAutoProtection === 'true'
const shouldPause =
account?.id &&
!azureAutoProtectionDisabled &&
(response.status === 401 ||
response.status === 403 ||
response.status === 429 ||
response.status >= 500)
if (shouldPause) {
const customTtl =
response.status === 429 ? upstreamErrorHelper.parseRetryAfter(response.headers) : null
await upstreamErrorHelper
.markTempUnavailable(account.id, 'azure-openai', response.status, customTtl)
.catch(() => {})
}
// 处理流式响应 // 处理流式响应
if (req.body.stream) { if (req.body.stream) {
await azureOpenaiRelayService.handleStreamResponse(response, res, { await azureOpenaiRelayService.handleStreamResponse(response, res, {
@@ -256,6 +285,16 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
let account = null let account = null
if (req.apiKey?.azureOpenaiAccountId) { if (req.apiKey?.azureOpenaiAccountId) {
account = await azureOpenaiAccountService.getAccount(req.apiKey.azureOpenaiAccountId) account = await azureOpenaiAccountService.getAccount(req.apiKey.azureOpenaiAccountId)
if (account) {
const isTempUnavailable = await upstreamErrorHelper.isTempUnavailable(
account.id,
'azure-openai'
)
if (isTempUnavailable) {
logger.warn(`⏱️ Bound Azure OpenAI account temporarily unavailable, falling back to pool`)
account = null
}
}
if (!account) { if (!account) {
logger.warn(`Bound Azure OpenAI account not found: ${req.apiKey.azureOpenaiAccountId}`) logger.warn(`Bound Azure OpenAI account not found: ${req.apiKey.azureOpenaiAccountId}`)
} }
@@ -275,6 +314,24 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
endpoint: 'responses' endpoint: 'responses'
}) })
// 检查上游响应状态码(仅对认证/限流/服务端错误暂停,不对 400/404 等客户端错误暂停)
const azureAutoProtectionDisabled =
account?.disableAutoProtection === true || account?.disableAutoProtection === 'true'
const shouldPause =
account?.id &&
!azureAutoProtectionDisabled &&
(response.status === 401 ||
response.status === 403 ||
response.status === 429 ||
response.status >= 500)
if (shouldPause) {
const customTtl =
response.status === 429 ? upstreamErrorHelper.parseRetryAfter(response.headers) : null
await upstreamErrorHelper
.markTempUnavailable(account.id, 'azure-openai', response.status, customTtl)
.catch(() => {})
}
// 处理流式响应 // 处理流式响应
if (req.body.stream) { if (req.body.stream) {
await azureOpenaiRelayService.handleStreamResponse(response, res, { await azureOpenaiRelayService.handleStreamResponse(response, res, {
@@ -348,6 +405,16 @@ router.post('/embeddings', authenticateApiKey, async (req, res) => {
let account = null let account = null
if (req.apiKey?.azureOpenaiAccountId) { if (req.apiKey?.azureOpenaiAccountId) {
account = await azureOpenaiAccountService.getAccount(req.apiKey.azureOpenaiAccountId) account = await azureOpenaiAccountService.getAccount(req.apiKey.azureOpenaiAccountId)
if (account) {
const isTempUnavailable = await upstreamErrorHelper.isTempUnavailable(
account.id,
'azure-openai'
)
if (isTempUnavailable) {
logger.warn(`⏱️ Bound Azure OpenAI account temporarily unavailable, falling back to pool`)
account = null
}
}
if (!account) { if (!account) {
logger.warn(`Bound Azure OpenAI account not found: ${req.apiKey.azureOpenaiAccountId}`) logger.warn(`Bound Azure OpenAI account not found: ${req.apiKey.azureOpenaiAccountId}`)
} }
@@ -367,6 +434,24 @@ router.post('/embeddings', authenticateApiKey, async (req, res) => {
endpoint: 'embeddings' endpoint: 'embeddings'
}) })
// 检查上游响应状态码(仅对认证/限流/服务端错误暂停,不对 400/404 等客户端错误暂停)
const azureAutoProtectionDisabled =
account?.disableAutoProtection === true || account?.disableAutoProtection === 'true'
const shouldPause =
account?.id &&
!azureAutoProtectionDisabled &&
(response.status === 401 ||
response.status === 403 ||
response.status === 429 ||
response.status >= 500)
if (shouldPause) {
const customTtl =
response.status === 429 ? upstreamErrorHelper.parseRetryAfter(response.headers) : null
await upstreamErrorHelper
.markTempUnavailable(account.id, 'azure-openai', response.status, customTtl)
.catch(() => {})
}
// 处理响应 // 处理响应
const { usageData, actualModel } = azureOpenaiRelayService.handleNonStreamResponse( const { usageData, actualModel } = azureOpenaiRelayService.handleNonStreamResponse(
response, response,

View File

@@ -1,7 +1,7 @@
const crypto = require('crypto') const crypto = require('crypto')
const express = require('express') const express = require('express')
const { authenticateApiKey } = require('../middleware/auth') const { authenticateApiKey } = require('../middleware/auth')
const droidRelayService = require('../services/droidRelayService') const droidRelayService = require('../services/relay/droidRelayService')
const sessionHelper = require('../utils/sessionHelper') const sessionHelper = require('../utils/sessionHelper')
const logger = require('../utils/logger') const logger = require('../utils/logger')
const apiKeyService = require('../services/apiKeyService') const apiKeyService = require('../services/apiKeyService')

View File

@@ -7,11 +7,11 @@ const express = require('express')
const router = express.Router() const router = express.Router()
const logger = require('../utils/logger') const logger = require('../utils/logger')
const { authenticateApiKey } = require('../middleware/auth') const { authenticateApiKey } = require('../middleware/auth')
const claudeRelayService = require('../services/claudeRelayService') const claudeRelayService = require('../services/relay/claudeRelayService')
const claudeConsoleRelayService = require('../services/claudeConsoleRelayService') const claudeConsoleRelayService = require('../services/relay/claudeConsoleRelayService')
const openaiToClaude = require('../services/openaiToClaude') const openaiToClaude = require('../services/openaiToClaude')
const apiKeyService = require('../services/apiKeyService') const apiKeyService = require('../services/apiKeyService')
const unifiedClaudeScheduler = require('../services/unifiedClaudeScheduler') const unifiedClaudeScheduler = require('../services/scheduler/unifiedClaudeScheduler')
const claudeCodeHeadersService = require('../services/claudeCodeHeadersService') const claudeCodeHeadersService = require('../services/claudeCodeHeadersService')
const { getSafeMessage } = require('../utils/errorSanitizer') const { getSafeMessage } = require('../utils/errorSanitizer')
const sessionHelper = require('../utils/sessionHelper') const sessionHelper = require('../utils/sessionHelper')

View File

@@ -2,9 +2,9 @@ const express = require('express')
const router = express.Router() const router = express.Router()
const logger = require('../utils/logger') const logger = require('../utils/logger')
const { authenticateApiKey } = require('../middleware/auth') const { authenticateApiKey } = require('../middleware/auth')
const geminiAccountService = require('../services/geminiAccountService') const geminiAccountService = require('../services/account/geminiAccountService')
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler') const unifiedGeminiScheduler = require('../services/scheduler/unifiedGeminiScheduler')
const { getAvailableModels } = require('../services/geminiRelayService') const { getAvailableModels } = require('../services/relay/geminiRelayService')
const crypto = require('crypto') const crypto = require('crypto')
const apiKeyService = require('../services/apiKeyService') const apiKeyService = require('../services/apiKeyService')

View File

@@ -4,10 +4,10 @@ const router = express.Router()
const logger = require('../utils/logger') const logger = require('../utils/logger')
const config = require('../../config/config') const config = require('../../config/config')
const { authenticateApiKey } = require('../middleware/auth') const { authenticateApiKey } = require('../middleware/auth')
const unifiedOpenAIScheduler = require('../services/unifiedOpenAIScheduler') const unifiedOpenAIScheduler = require('../services/scheduler/unifiedOpenAIScheduler')
const openaiAccountService = require('../services/openaiAccountService') const openaiAccountService = require('../services/account/openaiAccountService')
const openaiResponsesAccountService = require('../services/openaiResponsesAccountService') const openaiResponsesAccountService = require('../services/account/openaiResponsesAccountService')
const openaiResponsesRelayService = require('../services/openaiResponsesRelayService') const openaiResponsesRelayService = require('../services/relay/openaiResponsesRelayService')
const apiKeyService = require('../services/apiKeyService') const apiKeyService = require('../services/apiKeyService')
const redis = require('../models/redis') const redis = require('../models/redis')
const crypto = require('crypto') const crypto = require('crypto')

View File

@@ -1,8 +1,8 @@
const redis = require('../models/redis') const redis = require('../../models/redis')
const balanceScriptService = require('./balanceScriptService') const balanceScriptService = require('../balanceScriptService')
const logger = require('../utils/logger') const logger = require('../../utils/logger')
const CostCalculator = require('../utils/costCalculator') const CostCalculator = require('../../utils/costCalculator')
const { isBalanceScriptEnabled } = require('../utils/featureFlags') const { isBalanceScriptEnabled } = require('../../utils/featureFlags')
class AccountBalanceService { class AccountBalanceService {
constructor(options = {}) { constructor(options = {}) {

View File

@@ -1,8 +1,9 @@
const redisClient = require('../models/redis') const redisClient = require('../../models/redis')
const { v4: uuidv4 } = require('uuid') const { v4: uuidv4 } = require('uuid')
const crypto = require('crypto') const crypto = require('crypto')
const config = require('../../config/config') const config = require('../../../config/config')
const logger = require('../utils/logger') const logger = require('../../utils/logger')
const upstreamErrorHelper = require('../../utils/upstreamErrorHelper')
// 加密相关常量 // 加密相关常量
const ALGORITHM = 'aes-256-cbc' const ALGORITHM = 'aes-256-cbc'
@@ -138,6 +139,10 @@ async function createAccount(accountData) {
isActive: accountData.isActive !== false ? 'true' : 'false', isActive: accountData.isActive !== false ? 'true' : 'false',
status: 'active', status: 'active',
schedulable: accountData.schedulable !== false ? 'true' : 'false', schedulable: accountData.schedulable !== false ? 'true' : 'false',
disableAutoProtection:
accountData.disableAutoProtection === true || accountData.disableAutoProtection === 'true'
? 'true'
: 'false', // 关闭自动防护
createdAt: now, createdAt: now,
updatedAt: now updatedAt: now
} }
@@ -230,6 +235,14 @@ async function updateAccount(accountId, updates) {
// 直接保存,不做任何调整 // 直接保存,不做任何调整
} }
// 自动防护开关
if (updates.disableAutoProtection !== undefined) {
updates.disableAutoProtection =
updates.disableAutoProtection === true || updates.disableAutoProtection === 'true'
? 'true'
: 'false'
}
// 更新账户类型时处理共享账户集合 // 更新账户类型时处理共享账户集合
const client = redisClient.getClientSafe() const client = redisClient.getClientSafe()
if (updates.accountType && updates.accountType !== existingAccount.accountType) { if (updates.accountType && updates.accountType !== existingAccount.accountType) {
@@ -262,7 +275,7 @@ async function updateAccount(accountId, updates) {
// 删除账户 // 删除账户
async function deleteAccount(accountId) { async function deleteAccount(accountId) {
// 首先从所有分组中移除此账户 // 首先从所有分组中移除此账户
const accountGroupService = require('./accountGroupService') const accountGroupService = require('../accountGroupService')
await accountGroupService.removeAccountFromAllGroups(accountId) await accountGroupService.removeAccountFromAllGroups(accountId)
const client = redisClient.getClientSafe() const client = redisClient.getClientSafe()
@@ -380,8 +393,14 @@ async function selectAvailableAccount(sessionId = null) {
if (accountId) { if (accountId) {
const account = await getAccount(accountId) const account = await getAccount(accountId)
if (account && account.isActive === 'true' && account.schedulable === 'true') { if (account && account.isActive === 'true' && account.schedulable === 'true') {
logger.debug(`Reusing Azure OpenAI account ${accountId} for session ${sessionId}`) const isTempUnavail = await upstreamErrorHelper.isTempUnavailable(accountId, 'azure-openai')
return account if (!isTempUnavail) {
logger.debug(`Reusing Azure OpenAI account ${accountId} for session ${sessionId}`)
return account
}
logger.warn(
`⏱️ Session-bound Azure OpenAI account ${accountId} temporarily unavailable, falling back to pool`
)
} }
} }
} }
@@ -389,18 +408,30 @@ async function selectAvailableAccount(sessionId = null) {
// 获取所有共享账户 // 获取所有共享账户
const sharedAccounts = await getSharedAccounts() const sharedAccounts = await getSharedAccounts()
// 过滤出可用的账户 // 过滤出可用的账户(异步过滤,包含临时不可用检查)
const availableAccounts = sharedAccounts.filter((acc) => { const availableAccounts = []
// ✅ 检查账户订阅是否过期 for (const acc of sharedAccounts) {
// 检查账户订阅是否过期
if (isSubscriptionExpired(acc)) { if (isSubscriptionExpired(acc)) {
logger.debug( logger.debug(
`⏰ Skipping expired Azure OpenAI account: ${acc.name}, expired at ${acc.subscriptionExpiresAt}` `⏰ Skipping expired Azure OpenAI account: ${acc.name}, expired at ${acc.subscriptionExpiresAt}`
) )
return false continue
} }
return acc.isActive === 'true' && acc.schedulable === 'true' if (acc.isActive !== 'true' || acc.schedulable !== 'true') {
}) continue
}
// 检查临时不可用状态
const isTempUnavail = await upstreamErrorHelper.isTempUnavailable(acc.id, 'azure-openai')
if (isTempUnavail) {
logger.debug(`⏱️ Skipping temporarily unavailable Azure OpenAI account: ${acc.name}`)
continue
}
availableAccounts.push(acc)
}
if (availableAccounts.length === 0) { if (availableAccounts.length === 0) {
throw new Error('No available Azure OpenAI accounts') throw new Error('No available Azure OpenAI accounts')
@@ -515,6 +546,69 @@ async function migrateApiKeysForAzureSupport() {
return migratedCount return migratedCount
} }
// 🔄 重置Azure OpenAI账户所有异常状态
async function resetAccountStatus(accountId) {
try {
const accountData = await getAccount(accountId)
if (!accountData) {
throw new Error('Account not found')
}
const client = redisClient.getClientSafe()
const accountKey = `azure_openai:account:${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 Azure OpenAI account ${accountId}`)
// 清除临时不可用状态
await upstreamErrorHelper.clearTempUnavailable(accountId, 'azure-openai').catch(() => {})
// 异步发送 Webhook 通知(忽略错误)
try {
const webhookNotifier = require('../../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName: accountData.name || accountId,
platform: 'azure-openai',
status: 'recovered',
errorCode: 'STATUS_RESET',
reason: 'Account status manually reset',
timestamp: new Date().toISOString()
})
} catch (webhookError) {
logger.warn(
'Failed to send webhook notification for Azure OpenAI status reset:',
webhookError
)
}
return { success: true, accountId }
} catch (error) {
logger.error(`❌ Failed to reset Azure OpenAI account status: ${accountId}`, error)
throw error
}
}
module.exports = { module.exports = {
createAccount, createAccount,
getAccount, getAccount,
@@ -528,6 +622,7 @@ module.exports = {
performHealthChecks, performHealthChecks,
toggleSchedulable, toggleSchedulable,
migrateApiKeysForAzureSupport, migrateApiKeysForAzureSupport,
resetAccountStatus,
encrypt, encrypt,
decrypt decrypt
} }

View File

@@ -1,10 +1,11 @@
const { v4: uuidv4 } = require('uuid') const { v4: uuidv4 } = require('uuid')
const crypto = require('crypto') const crypto = require('crypto')
const redis = require('../models/redis') const redis = require('../../models/redis')
const logger = require('../utils/logger') const logger = require('../../utils/logger')
const config = require('../../config/config') const config = require('../../../config/config')
const bedrockRelayService = require('./bedrockRelayService') const bedrockRelayService = require('../relay/bedrockRelayService')
const LRUCache = require('../utils/lruCache') const LRUCache = require('../../utils/lruCache')
const upstreamErrorHelper = require('../../utils/upstreamErrorHelper')
class BedrockAccountService { class BedrockAccountService {
constructor() { constructor() {
@@ -41,7 +42,8 @@ class BedrockAccountService {
accountType = 'shared', // 'dedicated' or 'shared' accountType = 'shared', // 'dedicated' or 'shared'
priority = 50, // 调度优先级 (1-100数字越小优先级越高) priority = 50, // 调度优先级 (1-100数字越小优先级越高)
schedulable = true, // 是否可被调度 schedulable = true, // 是否可被调度
credentialType = 'access_key' // 'access_key', 'bearer_token'(默认为 access_key credentialType = 'access_key', // 'access_key', 'bearer_token'(默认为 access_key
disableAutoProtection = false // 是否关闭自动防护429/401/400/529 不自动禁用)
} = options } = options
const accountId = uuidv4() const accountId = uuidv4()
@@ -64,7 +66,8 @@ class BedrockAccountService {
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
type: 'bedrock' // 标识这是Bedrock账户 type: 'bedrock', // 标识这是Bedrock账户
disableAutoProtection // 关闭自动防护
} }
// 加密存储AWS凭证 // 加密存储AWS凭证
@@ -343,6 +346,11 @@ class BedrockAccountService {
account.subscriptionExpiresAt = updates.subscriptionExpiresAt account.subscriptionExpiresAt = updates.subscriptionExpiresAt
} }
// 自动防护开关
if (updates.disableAutoProtection !== undefined) {
account.disableAutoProtection = updates.disableAutoProtection
}
account.updatedAt = new Date().toISOString() account.updatedAt = new Date().toISOString()
await client.set(`bedrock_account:${accountId}`, JSON.stringify(account)) await client.set(`bedrock_account:${accountId}`, JSON.stringify(account))
@@ -776,6 +784,66 @@ class BedrockAccountService {
return { success: false, error: error.message } return { success: false, error: error.message }
} }
} }
// 🔄 重置Bedrock账户所有异常状态
async resetAccountStatus(accountId) {
try {
const accountData = await this.getAccount(accountId)
if (!accountData) {
throw new Error('Account not found')
}
const client = redis.getClientSafe()
const accountKey = `bedrock:account:${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 Bedrock account ${accountId}`)
// 清除临时不可用状态
await upstreamErrorHelper.clearTempUnavailable(accountId, 'bedrock').catch(() => {})
// 异步发送 Webhook 通知(忽略错误)
try {
const webhookNotifier = require('../../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName: accountData.name || accountId,
platform: 'bedrock',
status: 'recovered',
errorCode: 'STATUS_RESET',
reason: 'Account status manually reset',
timestamp: new Date().toISOString()
})
} catch (webhookError) {
logger.warn('Failed to send webhook notification for Bedrock status reset:', webhookError)
}
return { success: true, accountId }
} catch (error) {
logger.error(`❌ Failed to reset Bedrock account status: ${accountId}`, error)
throw error
}
}
} }
module.exports = new BedrockAccountService() module.exports = new BedrockAccountService()

View File

@@ -1,8 +1,9 @@
const { v4: uuidv4 } = require('uuid') const { v4: uuidv4 } = require('uuid')
const ProxyHelper = require('../utils/proxyHelper') const ProxyHelper = require('../../utils/proxyHelper')
const redis = require('../models/redis') const redis = require('../../models/redis')
const logger = require('../utils/logger') const logger = require('../../utils/logger')
const { createEncryptor } = require('../utils/commonHelper') const { createEncryptor } = require('../../utils/commonHelper')
const upstreamErrorHelper = require('../../utils/upstreamErrorHelper')
class CcrAccountService { class CcrAccountService {
constructor() { constructor() {
@@ -39,7 +40,8 @@ class CcrAccountService {
accountType = 'shared', // 'dedicated' or 'shared' accountType = 'shared', // 'dedicated' or 'shared'
schedulable = true, // 是否可被调度 schedulable = true, // 是否可被调度
dailyQuota = 0, // 每日额度限制美元0表示不限制 dailyQuota = 0, // 每日额度限制美元0表示不限制
quotaResetTime = '00:00' // 额度重置时间HH:mm格式 quotaResetTime = '00:00', // 额度重置时间HH:mm格式
disableAutoProtection = false // 是否关闭自动防护429/401/400/529 不自动禁用)
} = options } = options
// 验证必填字段 // 验证必填字段
@@ -86,7 +88,8 @@ class CcrAccountService {
// 使用与统计一致的时区日期,避免边界问题 // 使用与统计一致的时区日期,避免边界问题
lastResetDate: redis.getDateStringInTimezone(), // 最后重置日期(按配置时区) lastResetDate: redis.getDateStringInTimezone(), // 最后重置日期(按配置时区)
quotaResetTime, // 额度重置时间 quotaResetTime, // 额度重置时间
quotaStoppedAt: '' // 因额度停用的时间 quotaStoppedAt: '', // 因额度停用的时间
disableAutoProtection: disableAutoProtection.toString() // 关闭自动防护
} }
const client = redis.getClientSafe() const client = redis.getClientSafe()
@@ -175,7 +178,8 @@ class CcrAccountService {
dailyUsage: parseFloat(accountData.dailyUsage || '0'), dailyUsage: parseFloat(accountData.dailyUsage || '0'),
lastResetDate: accountData.lastResetDate || '', lastResetDate: accountData.lastResetDate || '',
quotaResetTime: accountData.quotaResetTime || '00:00', quotaResetTime: accountData.quotaResetTime || '00:00',
quotaStoppedAt: accountData.quotaStoppedAt || null quotaStoppedAt: accountData.quotaStoppedAt || null,
disableAutoProtection: accountData.disableAutoProtection === 'true'
}) })
} }
} }
@@ -221,6 +225,7 @@ class CcrAccountService {
} }
accountData.isActive = accountData.isActive === 'true' accountData.isActive = accountData.isActive === 'true'
accountData.schedulable = accountData.schedulable !== 'false' // 默认为true accountData.schedulable = accountData.schedulable !== 'false' // 默认为true
accountData.disableAutoProtection = accountData.disableAutoProtection === 'true'
if (accountData.proxy) { if (accountData.proxy) {
accountData.proxy = JSON.parse(accountData.proxy) accountData.proxy = JSON.parse(accountData.proxy)
@@ -299,6 +304,11 @@ class CcrAccountService {
updatedData.subscriptionExpiresAt = updates.subscriptionExpiresAt updatedData.subscriptionExpiresAt = updates.subscriptionExpiresAt
} }
// 自动防护开关
if (updates.disableAutoProtection !== undefined) {
updatedData.disableAutoProtection = updates.disableAutoProtection.toString()
}
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updatedData) await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updatedData)
// 处理共享账户集合变更 // 处理共享账户集合变更
@@ -691,7 +701,7 @@ class CcrAccountService {
// 发送 Webhook 通知 // 发送 Webhook 通知
try { try {
const webhookNotifier = require('../utils/webhookNotifier') const webhookNotifier = require('../../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({ await webhookNotifier.sendAccountAnomalyNotification({
accountId, accountId,
accountName: account.name || accountId, accountName: account.name || accountId,
@@ -858,9 +868,12 @@ class CcrAccountService {
logger.success(`Reset all error status for CCR account ${accountId}`) logger.success(`Reset all error status for CCR account ${accountId}`)
// 清除临时不可用状态
await upstreamErrorHelper.clearTempUnavailable(accountId, 'ccr').catch(() => {})
// 异步发送 Webhook 通知(忽略错误) // 异步发送 Webhook 通知(忽略错误)
try { try {
const webhookNotifier = require('../utils/webhookNotifier') const webhookNotifier = require('../../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({ await webhookNotifier.sendAccountAnomalyNotification({
accountId, accountId,
accountName: accountData.name || accountId, accountName: accountData.name || accountId,

View File

@@ -1,22 +1,23 @@
const { v4: uuidv4 } = require('uuid') const { v4: uuidv4 } = require('uuid')
const crypto = require('crypto') const crypto = require('crypto')
const ProxyHelper = require('../utils/proxyHelper') const ProxyHelper = require('../../utils/proxyHelper')
const axios = require('axios') const axios = require('axios')
const redis = require('../models/redis') const redis = require('../../models/redis')
const config = require('../../config/config') const config = require('../../../config/config')
const logger = require('../utils/logger') const logger = require('../../utils/logger')
const { maskToken } = require('../utils/tokenMask') const { maskToken } = require('../../utils/tokenMask')
const upstreamErrorHelper = require('../../utils/upstreamErrorHelper')
const { const {
logRefreshStart, logRefreshStart,
logRefreshSuccess, logRefreshSuccess,
logRefreshError, logRefreshError,
logTokenUsage, logTokenUsage,
logRefreshSkipped logRefreshSkipped
} = require('../utils/tokenRefreshLogger') } = require('../../utils/tokenRefreshLogger')
const tokenRefreshService = require('./tokenRefreshService') const tokenRefreshService = require('../tokenRefreshService')
const LRUCache = require('../utils/lruCache') const LRUCache = require('../../utils/lruCache')
const { formatDateWithTimezone, getISOStringWithTimezone } = require('../utils/dateHelper') const { formatDateWithTimezone, getISOStringWithTimezone } = require('../../utils/dateHelper')
const { isOpus45OrNewer } = require('../utils/modelHelper') const { isOpus45OrNewer } = require('../../utils/modelHelper')
/** /**
* Check if account is Pro (not Max) * Check if account is Pro (not Max)
@@ -401,7 +402,7 @@ class ClaudeAccountService {
// 发送Webhook通知 // 发送Webhook通知
try { try {
const webhookNotifier = require('../utils/webhookNotifier') const webhookNotifier = require('../../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({ await webhookNotifier.sendAccountAnomalyNotification({
accountId, accountId,
accountName: accountData.name, accountName: accountData.name,
@@ -787,7 +788,7 @@ class ClaudeAccountService {
// 检查是否手动禁用了账号如果是则发送webhook通知 // 检查是否手动禁用了账号如果是则发送webhook通知
if (updates.isActive === 'false' && accountData.isActive === 'true') { if (updates.isActive === 'false' && accountData.isActive === 'true') {
try { try {
const webhookNotifier = require('../utils/webhookNotifier') const webhookNotifier = require('../../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({ await webhookNotifier.sendAccountAnomalyNotification({
accountId, accountId,
accountName: updatedData.name || 'Unknown Account', accountName: updatedData.name || 'Unknown Account',
@@ -831,7 +832,7 @@ class ClaudeAccountService {
async deleteAccount(accountId) { async deleteAccount(accountId) {
try { try {
// 首先从所有分组中移除此账户 // 首先从所有分组中移除此账户
const accountGroupService = require('./accountGroupService') const accountGroupService = require('../accountGroupService')
await accountGroupService.removeAccountFromAllGroups(accountId) await accountGroupService.removeAccountFromAllGroups(accountId)
const result = await redis.deleteClaudeAccount(accountId) const result = await redis.deleteClaudeAccount(accountId)
@@ -1387,7 +1388,7 @@ class ClaudeAccountService {
// 发送Webhook通知 // 发送Webhook通知
try { try {
const webhookNotifier = require('../utils/webhookNotifier') const webhookNotifier = require('../../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({ await webhookNotifier.sendAccountAnomalyNotification({
accountId, accountId,
accountName: accountData.name || 'Claude Account', accountName: accountData.name || 'Claude Account',
@@ -1742,7 +1743,7 @@ class ClaudeAccountService {
// 发送Webhook通知 // 发送Webhook通知
try { try {
const webhookNotifier = require('../utils/webhookNotifier') const webhookNotifier = require('../../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({ await webhookNotifier.sendAccountAnomalyNotification({
accountId, accountId,
accountName: accountData.name || 'Claude Account', accountName: accountData.name || 'Claude Account',
@@ -2386,7 +2387,7 @@ class ClaudeAccountService {
// 发送Webhook通知 // 发送Webhook通知
try { try {
const webhookNotifier = require('../utils/webhookNotifier') const webhookNotifier = require('../../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({ await webhookNotifier.sendAccountAnomalyNotification({
accountId, accountId,
accountName: accountData.name, accountName: accountData.name,
@@ -2500,6 +2501,13 @@ class ClaudeAccountService {
const serverErrorKey = `claude_account:${accountId}:5xx_errors` const serverErrorKey = `claude_account:${accountId}:5xx_errors`
await redis.client.del(serverErrorKey) await redis.client.del(serverErrorKey)
// 清除过载状态
const overloadKey = `account:overload:${accountId}`
await redis.client.del(overloadKey)
// 清除临时不可用状态
await upstreamErrorHelper.clearTempUnavailable(accountId, 'claude-official').catch(() => {})
logger.info( logger.info(
`✅ Successfully reset all error states for account ${accountData.name} (${accountId})` `✅ Successfully reset all error states for account ${accountData.name} (${accountId})`
) )
@@ -2704,7 +2712,7 @@ class ClaudeAccountService {
// 发送Webhook通知 // 发送Webhook通知
try { try {
const webhookNotifier = require('../utils/webhookNotifier') const webhookNotifier = require('../../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({ await webhookNotifier.sendAccountAnomalyNotification({
accountId, accountId,
accountName: accountData.name, accountName: accountData.name,
@@ -2795,7 +2803,7 @@ class ClaudeAccountService {
if (canSendWarning) { if (canSendWarning) {
// 发送Webhook通知 // 发送Webhook通知
try { try {
const webhookNotifier = require('../utils/webhookNotifier') const webhookNotifier = require('../../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({ await webhookNotifier.sendAccountAnomalyNotification({
accountId, accountId,
accountName: accountData.name || 'Claude Account', accountName: accountData.name || 'Claude Account',

View File

@@ -1,10 +1,11 @@
const { v4: uuidv4 } = require('uuid') const { v4: uuidv4 } = require('uuid')
const crypto = require('crypto') const crypto = require('crypto')
const ProxyHelper = require('../utils/proxyHelper') const ProxyHelper = require('../../utils/proxyHelper')
const redis = require('../models/redis') const redis = require('../../models/redis')
const logger = require('../utils/logger') const logger = require('../../utils/logger')
const config = require('../../config/config') const config = require('../../../config/config')
const LRUCache = require('../utils/lruCache') const LRUCache = require('../../utils/lruCache')
const upstreamErrorHelper = require('../../utils/upstreamErrorHelper')
class ClaudeConsoleAccountService { class ClaudeConsoleAccountService {
constructor() { constructor() {
@@ -414,7 +415,7 @@ class ClaudeConsoleAccountService {
// 检查是否手动禁用了账号如果是则发送webhook通知 // 检查是否手动禁用了账号如果是则发送webhook通知
if (updates.isActive === false && existingAccount.isActive === true) { if (updates.isActive === false && existingAccount.isActive === true) {
try { try {
const webhookNotifier = require('../utils/webhookNotifier') const webhookNotifier = require('../../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({ await webhookNotifier.sendAccountAnomalyNotification({
accountId, accountId,
accountName: updatedData.name || existingAccount.name || 'Unknown Account', accountName: updatedData.name || existingAccount.name || 'Unknown Account',
@@ -512,8 +513,8 @@ class ClaudeConsoleAccountService {
// 发送Webhook通知 // 发送Webhook通知
try { try {
const webhookNotifier = require('../utils/webhookNotifier') const webhookNotifier = require('../../utils/webhookNotifier')
const { getISOStringWithTimezone } = require('../utils/dateHelper') const { getISOStringWithTimezone } = require('../../utils/dateHelper')
await webhookNotifier.sendAccountAnomalyNotification({ await webhookNotifier.sendAccountAnomalyNotification({
accountId, accountId,
accountName: account.name || 'Claude Console Account', accountName: account.name || 'Claude Console Account',
@@ -726,7 +727,7 @@ class ClaudeConsoleAccountService {
// 发送Webhook通知 // 发送Webhook通知
try { try {
const webhookNotifier = require('../utils/webhookNotifier') const webhookNotifier = require('../../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({ await webhookNotifier.sendAccountAnomalyNotification({
accountId, accountId,
accountName: account.name || 'Claude Console Account', accountName: account.name || 'Claude Console Account',
@@ -793,7 +794,7 @@ class ClaudeConsoleAccountService {
// 发送Webhook通知包含完整错误详情 // 发送Webhook通知包含完整错误详情
try { try {
const webhookNotifier = require('../utils/webhookNotifier') const webhookNotifier = require('../../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({ await webhookNotifier.sendAccountAnomalyNotification({
accountId, accountId,
accountName: account.name || 'Claude Console Account', accountName: account.name || 'Claude Console Account',
@@ -947,7 +948,7 @@ class ClaudeConsoleAccountService {
// 发送Webhook通知 // 发送Webhook通知
try { try {
const webhookNotifier = require('../utils/webhookNotifier') const webhookNotifier = require('../../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({ await webhookNotifier.sendAccountAnomalyNotification({
accountId, accountId,
accountName: account.name || 'Claude Console Account', accountName: account.name || 'Claude Console Account',
@@ -1040,7 +1041,7 @@ class ClaudeConsoleAccountService {
// 发送Webhook通知 // 发送Webhook通知
if (accountData && Object.keys(accountData).length > 0) { if (accountData && Object.keys(accountData).length > 0) {
try { try {
const webhookNotifier = require('../utils/webhookNotifier') const webhookNotifier = require('../../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({ await webhookNotifier.sendAccountAnomalyNotification({
accountId, accountId,
accountName: accountData.name || 'Unknown Account', accountName: accountData.name || 'Unknown Account',
@@ -1329,7 +1330,7 @@ class ClaudeConsoleAccountService {
// 发送webhook通知 // 发送webhook通知
try { try {
const webhookNotifier = require('../utils/webhookNotifier') const webhookNotifier = require('../../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({ await webhookNotifier.sendAccountAnomalyNotification({
accountId, accountId,
accountName: accountData.name || 'Unknown Account', accountName: accountData.name || 'Unknown Account',
@@ -1479,9 +1480,12 @@ class ClaudeConsoleAccountService {
logger.success(`Reset all error status for Claude Console account ${accountId}`) logger.success(`Reset all error status for Claude Console account ${accountId}`)
// 清除临时不可用状态
await upstreamErrorHelper.clearTempUnavailable(accountId, 'claude-console').catch(() => {})
// 发送 Webhook 通知 // 发送 Webhook 通知
try { try {
const webhookNotifier = require('../utils/webhookNotifier') const webhookNotifier = require('../../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({ await webhookNotifier.sendAccountAnomalyNotification({
accountId, accountId,
accountName: accountData.name || accountId, accountName: accountData.name || accountId,

View File

@@ -1,11 +1,12 @@
const { v4: uuidv4 } = require('uuid') const { v4: uuidv4 } = require('uuid')
const crypto = require('crypto') const crypto = require('crypto')
const axios = require('axios') const axios = require('axios')
const redis = require('../models/redis') const redis = require('../../models/redis')
const logger = require('../utils/logger') const logger = require('../../utils/logger')
const { maskToken } = require('../utils/tokenMask') const { maskToken } = require('../../utils/tokenMask')
const ProxyHelper = require('../utils/proxyHelper') const ProxyHelper = require('../../utils/proxyHelper')
const { createEncryptor, isTruthy } = require('../utils/commonHelper') const { createEncryptor, isTruthy } = require('../../utils/commonHelper')
const upstreamErrorHelper = require('../../utils/upstreamErrorHelper')
/** /**
* Droid 账户管理服务 * Droid 账户管理服务
@@ -476,7 +477,8 @@ class DroidAccountService {
authenticationMethod = '', authenticationMethod = '',
expiresIn = null, expiresIn = null,
apiKeys = [], apiKeys = [],
userAgent = '' // 自定义 User-Agent userAgent = '', // 自定义 User-Agent
disableAutoProtection = false // 是否关闭自动防护429/401/400/529 不自动禁用)
} = options } = options
const accountId = uuidv4() const accountId = uuidv4()
@@ -753,7 +755,8 @@ class DroidAccountService {
apiKeys: hasApiKeys ? JSON.stringify(apiKeyEntries) : '', apiKeys: hasApiKeys ? JSON.stringify(apiKeyEntries) : '',
apiKeyCount: hasApiKeys ? String(apiKeyEntries.length) : '0', apiKeyCount: hasApiKeys ? String(apiKeyEntries.length) : '0',
apiKeyStrategy: hasApiKeys ? 'random_sticky' : '', apiKeyStrategy: hasApiKeys ? 'random_sticky' : '',
userAgent: userAgent || '' // 自定义 User-Agent userAgent: userAgent || '', // 自定义 User-Agent
disableAutoProtection: disableAutoProtection.toString() // 关闭自动防护
} }
await redis.setDroidAccount(accountId, accountData) await redis.setDroidAccount(accountId, accountData)
@@ -1494,6 +1497,66 @@ class DroidAccountService {
logger.warn(`⚠️ Failed to update lastUsedAt for Droid account ${accountId}:`, error) logger.warn(`⚠️ Failed to update lastUsedAt for Droid account ${accountId}:`, error)
} }
} }
// 🔄 重置Droid账户所有异常状态
async resetAccountStatus(accountId) {
try {
const accountData = await this.getAccount(accountId)
if (!accountData) {
throw new Error('Account not found')
}
const client = redis.getClientSafe()
const accountKey = `droid:account:${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 Droid account ${accountId}`)
// 清除临时不可用状态
await upstreamErrorHelper.clearTempUnavailable(accountId, 'droid').catch(() => {})
// 异步发送 Webhook 通知(忽略错误)
try {
const webhookNotifier = require('../../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName: accountData.name || accountId,
platform: 'droid',
status: 'recovered',
errorCode: 'STATUS_RESET',
reason: 'Account status manually reset',
timestamp: new Date().toISOString()
})
} catch (webhookError) {
logger.warn('Failed to send webhook notification for Droid status reset:', webhookError)
}
return { success: true, accountId }
} catch (error) {
logger.error(`❌ Failed to reset Droid account status: ${accountId}`, error)
throw error
}
}
} }
// 导出单例 // 导出单例

View File

@@ -1,21 +1,22 @@
const redisClient = require('../models/redis') const redisClient = require('../../models/redis')
const { v4: uuidv4 } = require('uuid') const { v4: uuidv4 } = require('uuid')
const crypto = require('crypto') const crypto = require('crypto')
const https = require('https') const https = require('https')
const logger = require('../utils/logger') const logger = require('../../utils/logger')
const { OAuth2Client } = require('google-auth-library') const { OAuth2Client } = require('google-auth-library')
const { maskToken } = require('../utils/tokenMask') const { maskToken } = require('../../utils/tokenMask')
const ProxyHelper = require('../utils/proxyHelper') const ProxyHelper = require('../../utils/proxyHelper')
const upstreamErrorHelper = require('../../utils/upstreamErrorHelper')
const { const {
logRefreshStart, logRefreshStart,
logRefreshSuccess, logRefreshSuccess,
logRefreshError, logRefreshError,
logTokenUsage, logTokenUsage,
logRefreshSkipped logRefreshSkipped
} = require('../utils/tokenRefreshLogger') } = require('../../utils/tokenRefreshLogger')
const tokenRefreshService = require('./tokenRefreshService') const tokenRefreshService = require('../tokenRefreshService')
const { createEncryptor } = require('../utils/commonHelper') const { createEncryptor } = require('../../utils/commonHelper')
const antigravityClient = require('./antigravityClient') const antigravityClient = require('../antigravityClient')
// Gemini 账户键前缀 // Gemini 账户键前缀
const GEMINI_ACCOUNT_KEY_PREFIX = 'gemini_account:' const GEMINI_ACCOUNT_KEY_PREFIX = 'gemini_account:'
@@ -134,7 +135,7 @@ async function fetchAvailableModelsAntigravity(
getAntigravityModelAlias, getAntigravityModelAlias,
getAntigravityModelMetadata, getAntigravityModelMetadata,
normalizeAntigravityModelInput normalizeAntigravityModelInput
} = require('../utils/antigravityModel') } = require('../../utils/antigravityModel')
const pushModel = (modelId) => { const pushModel = (modelId) => {
if (!modelId || seen.has(modelId)) { if (!modelId || seen.has(modelId)) {
@@ -523,6 +524,12 @@ async function createAccount(accountData) {
// 支持的模型列表(可选) // 支持的模型列表(可选)
supportedModels: accountData.supportedModels || [], // 空数组表示支持所有模型 supportedModels: accountData.supportedModels || [], // 空数组表示支持所有模型
// 自动防护开关
disableAutoProtection:
accountData.disableAutoProtection === true || accountData.disableAutoProtection === 'true'
? 'true'
: 'false',
// 时间戳 // 时间戳
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@@ -666,6 +673,14 @@ async function updateAccount(accountId, updates) {
// 直接保存,不做任何调整 // 直接保存,不做任何调整
} }
// 自动防护开关
if (updates.disableAutoProtection !== undefined) {
updates.disableAutoProtection =
updates.disableAutoProtection === true || updates.disableAutoProtection === 'true'
? 'true'
: 'false'
}
// 如果通过 geminiOauth 更新,也要检查是否新增了 refresh token // 如果通过 geminiOauth 更新,也要检查是否新增了 refresh token
if (updates.geminiOauth && !oldRefreshToken) { if (updates.geminiOauth && !oldRefreshToken) {
const oauthData = const oauthData =
@@ -692,7 +707,7 @@ async function updateAccount(accountId, updates) {
// 检查是否手动禁用了账号如果是则发送webhook通知 // 检查是否手动禁用了账号如果是则发送webhook通知
if (updates.isActive === 'false' && existingAccount.isActive !== 'false') { if (updates.isActive === 'false' && existingAccount.isActive !== 'false') {
try { try {
const webhookNotifier = require('../utils/webhookNotifier') const webhookNotifier = require('../../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({ await webhookNotifier.sendAccountAnomalyNotification({
accountId, accountId,
accountName: updates.name || existingAccount.name || 'Unknown Account', accountName: updates.name || existingAccount.name || 'Unknown Account',
@@ -1076,7 +1091,7 @@ async function refreshAccountToken(accountId) {
// 发送Webhook通知 // 发送Webhook通知
try { try {
const webhookNotifier = require('../utils/webhookNotifier') const webhookNotifier = require('../../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({ await webhookNotifier.sendAccountAnomalyNotification({
accountId, accountId,
accountName: account.name, accountName: account.name,
@@ -1843,9 +1858,12 @@ async function resetAccountStatus(accountId) {
await updateAccount(accountId, updates) await updateAccount(accountId, updates)
logger.info(`✅ Reset all error status for Gemini account ${accountId}`) logger.info(`✅ Reset all error status for Gemini account ${accountId}`)
// 清除临时不可用状态
await upstreamErrorHelper.clearTempUnavailable(accountId, 'gemini').catch(() => {})
// 发送 Webhook 通知 // 发送 Webhook 通知
try { try {
const webhookNotifier = require('../utils/webhookNotifier') const webhookNotifier = require('../../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({ await webhookNotifier.sendAccountAnomalyNotification({
accountId, accountId,
accountName: account.name || accountId, accountName: account.name || accountId,

View File

@@ -1,9 +1,10 @@
const { v4: uuidv4 } = require('uuid') const { v4: uuidv4 } = require('uuid')
const crypto = require('crypto') const crypto = require('crypto')
const redis = require('../models/redis') const redis = require('../../models/redis')
const logger = require('../utils/logger') const logger = require('../../utils/logger')
const config = require('../../config/config') const config = require('../../../config/config')
const LRUCache = require('../utils/lruCache') const LRUCache = require('../../utils/lruCache')
const upstreamErrorHelper = require('../../utils/upstreamErrorHelper')
class GeminiApiAccountService { class GeminiApiAccountService {
constructor() { constructor() {
@@ -44,7 +45,8 @@ class GeminiApiAccountService {
accountType = 'shared', // 'dedicated' or 'shared' accountType = 'shared', // 'dedicated' or 'shared'
schedulable = true, // 是否可被调度 schedulable = true, // 是否可被调度
supportedModels = [], // 支持的模型列表 supportedModels = [], // 支持的模型列表
rateLimitDuration = 60 // 限流时间(分钟) rateLimitDuration = 60, // 限流时间(分钟)
disableAutoProtection = false
} = options } = options
// 验证必填字段 // 验证必填字段
@@ -79,7 +81,11 @@ class GeminiApiAccountService {
// 限流相关 // 限流相关
rateLimitedAt: '', rateLimitedAt: '',
rateLimitStatus: '', rateLimitStatus: '',
rateLimitDuration: rateLimitDuration.toString() rateLimitDuration: rateLimitDuration.toString(),
// 自动防护开关
disableAutoProtection:
disableAutoProtection === true || disableAutoProtection === 'true' ? 'true' : 'false'
} }
// 保存到 Redis // 保存到 Redis
@@ -154,6 +160,14 @@ class GeminiApiAccountService {
: updates.baseUrl : updates.baseUrl
} }
// 处理 disableAutoProtection 布尔值转字符串
if (updates.disableAutoProtection !== undefined) {
updates.disableAutoProtection =
updates.disableAutoProtection === true || updates.disableAutoProtection === 'true'
? 'true'
: 'false'
}
// 更新 Redis // 更新 Redis
const client = redis.getClientSafe() const client = redis.getClientSafe()
const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}` const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
@@ -363,7 +377,7 @@ class GeminiApiAccountService {
) )
try { try {
const webhookNotifier = require('../utils/webhookNotifier') const webhookNotifier = require('../../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({ await webhookNotifier.sendAccountAnomalyNotification({
accountId, accountId,
accountName: account.name || accountId, accountName: account.name || accountId,
@@ -456,9 +470,12 @@ class GeminiApiAccountService {
await this.updateAccount(accountId, updates) await this.updateAccount(accountId, updates)
logger.info(`✅ Reset all error status for Gemini-API account ${accountId}`) logger.info(`✅ Reset all error status for Gemini-API account ${accountId}`)
// 清除临时不可用状态
await upstreamErrorHelper.clearTempUnavailable(accountId, 'gemini-api').catch(() => {})
// 发送 Webhook 通知 // 发送 Webhook 通知
try { try {
const webhookNotifier = require('../utils/webhookNotifier') const webhookNotifier = require('../../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({ await webhookNotifier.sendAccountAnomalyNotification({
accountId, accountId,
accountName: account.name || accountId, accountName: account.name || accountId,

View File

@@ -1,19 +1,20 @@
const redisClient = require('../models/redis') const redisClient = require('../../models/redis')
const { v4: uuidv4 } = require('uuid') const { v4: uuidv4 } = require('uuid')
const axios = require('axios') const axios = require('axios')
const ProxyHelper = require('../utils/proxyHelper') const ProxyHelper = require('../../utils/proxyHelper')
const config = require('../../config/config') const config = require('../../../config/config')
const logger = require('../utils/logger') const logger = require('../../utils/logger')
// const { maskToken } = require('../utils/tokenMask') const upstreamErrorHelper = require('../../utils/upstreamErrorHelper')
// const { maskToken } = require('../../utils/tokenMask')
const { const {
logRefreshStart, logRefreshStart,
logRefreshSuccess, logRefreshSuccess,
logRefreshError, logRefreshError,
logTokenUsage, logTokenUsage,
logRefreshSkipped logRefreshSkipped
} = require('../utils/tokenRefreshLogger') } = require('../../utils/tokenRefreshLogger')
const tokenRefreshService = require('./tokenRefreshService') const tokenRefreshService = require('../tokenRefreshService')
const { createEncryptor } = require('../utils/commonHelper') const { createEncryptor } = require('../../utils/commonHelper')
// 使用 commonHelper 的加密器 // 使用 commonHelper 的加密器
const encryptor = createEncryptor('openai-account-salt') const encryptor = createEncryptor('openai-account-salt')
@@ -405,7 +406,7 @@ async function refreshAccountToken(accountId) {
// 发送 Webhook 通知(如果启用) // 发送 Webhook 通知(如果启用)
try { try {
const webhookNotifier = require('../utils/webhookNotifier') const webhookNotifier = require('../../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({ await webhookNotifier.sendAccountAnomalyNotification({
accountId, accountId,
accountName: account?.name || accountName, accountName: account?.name || accountName,
@@ -496,6 +497,11 @@ async function createAccount(accountData) {
isActive: accountData.isActive !== false ? 'true' : 'false', isActive: accountData.isActive !== false ? 'true' : 'false',
status: 'active', status: 'active',
schedulable: accountData.schedulable !== false ? 'true' : 'false', schedulable: accountData.schedulable !== false ? 'true' : 'false',
// 自动防护开关
disableAutoProtection:
accountData.disableAutoProtection === true || accountData.disableAutoProtection === 'true'
? 'true'
: 'false',
lastRefresh: now, lastRefresh: now,
createdAt: now, createdAt: now,
updatedAt: now updatedAt: now
@@ -605,6 +611,14 @@ async function updateAccount(accountId, updates) {
// 直接保存,不做任何调整 // 直接保存,不做任何调整
} }
// 处理 disableAutoProtection 布尔值转字符串
if (updates.disableAutoProtection !== undefined) {
updates.disableAutoProtection =
updates.disableAutoProtection === true || updates.disableAutoProtection === 'true'
? 'true'
: 'false'
}
// 更新账户类型时处理共享账户集合 // 更新账户类型时处理共享账户集合
const client = redisClient.getClientSafe() const client = redisClient.getClientSafe()
if (updates.accountType && updates.accountType !== existingAccount.accountType) { if (updates.accountType && updates.accountType !== existingAccount.accountType) {
@@ -961,7 +975,7 @@ async function setAccountRateLimited(accountId, isLimited, resetsInSeconds = nul
if (isLimited) { if (isLimited) {
try { try {
const account = await getAccount(accountId) const account = await getAccount(accountId)
const webhookNotifier = require('../utils/webhookNotifier') const webhookNotifier = require('../../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({ await webhookNotifier.sendAccountAnomalyNotification({
accountId, accountId,
accountName: account.name || accountId, accountName: account.name || accountId,
@@ -1005,7 +1019,7 @@ async function markAccountUnauthorized(accountId, reason = 'OpenAI账号认证
) )
try { try {
const webhookNotifier = require('../utils/webhookNotifier') const webhookNotifier = require('../../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({ await webhookNotifier.sendAccountAnomalyNotification({
accountId, accountId,
accountName: account.name || accountId, accountName: account.name || accountId,
@@ -1045,9 +1059,12 @@ async function resetAccountStatus(accountId) {
await updateAccount(accountId, updates) await updateAccount(accountId, updates)
logger.info(`✅ Reset all error status for OpenAI account ${accountId}`) logger.info(`✅ Reset all error status for OpenAI account ${accountId}`)
// 清除临时不可用状态
await upstreamErrorHelper.clearTempUnavailable(accountId, 'openai').catch(() => {})
// 发送 Webhook 通知 // 发送 Webhook 通知
try { try {
const webhookNotifier = require('../utils/webhookNotifier') const webhookNotifier = require('../../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({ await webhookNotifier.sendAccountAnomalyNotification({
accountId, accountId,
accountName: account.name || accountId, accountName: account.name || accountId,

View File

@@ -1,9 +1,10 @@
const { v4: uuidv4 } = require('uuid') const { v4: uuidv4 } = require('uuid')
const crypto = require('crypto') const crypto = require('crypto')
const redis = require('../models/redis') const redis = require('../../models/redis')
const logger = require('../utils/logger') const logger = require('../../utils/logger')
const config = require('../../config/config') const config = require('../../../config/config')
const LRUCache = require('../utils/lruCache') const LRUCache = require('../../utils/lruCache')
const upstreamErrorHelper = require('../../utils/upstreamErrorHelper')
class OpenAIResponsesAccountService { class OpenAIResponsesAccountService {
constructor() { constructor() {
@@ -49,7 +50,8 @@ class OpenAIResponsesAccountService {
schedulable = true, // 是否可被调度 schedulable = true, // 是否可被调度
dailyQuota = 0, // 每日额度限制美元0表示不限制 dailyQuota = 0, // 每日额度限制美元0表示不限制
quotaResetTime = '00:00', // 额度重置时间HH:mm格式 quotaResetTime = '00:00', // 额度重置时间HH:mm格式
rateLimitDuration = 60 // 限流时间(分钟) rateLimitDuration = 60, // 限流时间(分钟)
disableAutoProtection = false // 是否关闭自动防护429/401/400/529 不自动禁用)
} = options } = options
// 验证必填字段 // 验证必填字段
@@ -93,7 +95,8 @@ class OpenAIResponsesAccountService {
dailyUsage: '0', dailyUsage: '0',
lastResetDate: redis.getDateStringInTimezone(), lastResetDate: redis.getDateStringInTimezone(),
quotaResetTime, quotaResetTime,
quotaStoppedAt: '' quotaStoppedAt: '',
disableAutoProtection: disableAutoProtection.toString() // 关闭自动防护
} }
// 保存到 Redis // 保存到 Redis
@@ -162,6 +165,11 @@ class OpenAIResponsesAccountService {
// 直接保存,不做任何调整 // 直接保存,不做任何调整
} }
// 自动防护开关
if (updates.disableAutoProtection !== undefined) {
updates.disableAutoProtection = updates.disableAutoProtection.toString()
}
// 更新 Redis // 更新 Redis
const client = redis.getClientSafe() const client = redis.getClientSafe()
const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}` const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
@@ -310,7 +318,7 @@ class OpenAIResponsesAccountService {
) )
try { try {
const webhookNotifier = require('../utils/webhookNotifier') const webhookNotifier = require('../../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({ await webhookNotifier.sendAccountAnomalyNotification({
accountId, accountId,
accountName: account.name || accountId, accountName: account.name || accountId,
@@ -475,9 +483,12 @@ class OpenAIResponsesAccountService {
await this.updateAccount(accountId, updates) await this.updateAccount(accountId, updates)
logger.info(`✅ Reset all error status for OpenAI-Responses account ${accountId}`) logger.info(`✅ Reset all error status for OpenAI-Responses account ${accountId}`)
// 清除临时不可用状态
await upstreamErrorHelper.clearTempUnavailable(accountId, 'openai-responses').catch(() => {})
// 发送 Webhook 通知 // 发送 Webhook 通知
try { try {
const webhookNotifier = require('../utils/webhookNotifier') const webhookNotifier = require('../../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({ await webhookNotifier.sendAccountAnomalyNotification({
accountId, accountId,
accountName: account.name || accountId, accountName: account.name || accountId,

View File

@@ -49,26 +49,26 @@ class AccountNameCacheService {
const newGroupCache = new Map() const newGroupCache = new Map()
// 延迟加载服务,避免循环依赖 // 延迟加载服务,避免循环依赖
const claudeAccountService = require('./claudeAccountService') const claudeAccountService = require('./account/claudeAccountService')
const claudeConsoleAccountService = require('./claudeConsoleAccountService') const claudeConsoleAccountService = require('./account/claudeConsoleAccountService')
const geminiAccountService = require('./geminiAccountService') const geminiAccountService = require('./account/geminiAccountService')
const openaiAccountService = require('./openaiAccountService') const openaiAccountService = require('./account/openaiAccountService')
const azureOpenaiAccountService = require('./azureOpenaiAccountService') const azureOpenaiAccountService = require('./account/azureOpenaiAccountService')
const bedrockAccountService = require('./bedrockAccountService') const bedrockAccountService = require('./account/bedrockAccountService')
const droidAccountService = require('./droidAccountService') const droidAccountService = require('./account/droidAccountService')
const ccrAccountService = require('./ccrAccountService') const ccrAccountService = require('./account/ccrAccountService')
const accountGroupService = require('./accountGroupService') const accountGroupService = require('./accountGroupService')
// 可选服务(可能不存在) // 可选服务(可能不存在)
let geminiApiAccountService = null let geminiApiAccountService = null
let openaiResponsesAccountService = null let openaiResponsesAccountService = null
try { try {
geminiApiAccountService = require('./geminiApiAccountService') geminiApiAccountService = require('./account/geminiApiAccountService')
} catch (e) { } catch (e) {
// 服务不存在,忽略 // 服务不存在,忽略
} }
try { try {
openaiResponsesAccountService = require('./openaiResponsesAccountService') openaiResponsesAccountService = require('./account/openaiResponsesAccountService')
} catch (e) { } catch (e) {
// 服务不存在,忽略 // 服务不存在,忽略
} }

View File

@@ -269,7 +269,7 @@ class AccountTestSchedulerService {
* @private * @private
*/ */
async _testClaudeAccount(accountId, model) { async _testClaudeAccount(accountId, model) {
const claudeRelayService = require('./claudeRelayService') const claudeRelayService = require('./relay/claudeRelayService')
return await claudeRelayService.testAccountConnectionSync(accountId, model) return await claudeRelayService.testAccountConnectionSync(accountId, model)
} }

View File

@@ -34,8 +34,8 @@ const fs = require('fs')
const path = require('path') const path = require('path')
const logger = require('../utils/logger') const logger = require('../utils/logger')
const { getProjectRoot } = require('../utils/projectPaths') const { getProjectRoot } = require('../utils/projectPaths')
const geminiAccountService = require('./geminiAccountService') const geminiAccountService = require('./account/geminiAccountService')
const unifiedGeminiScheduler = require('./unifiedGeminiScheduler') const unifiedGeminiScheduler = require('./scheduler/unifiedGeminiScheduler')
const sessionHelper = require('../utils/sessionHelper') const sessionHelper = require('../utils/sessionHelper')
const signatureCache = require('../utils/signatureCache') const signatureCache = require('../utils/signatureCache')
const apiKeyService = require('./apiKeyService') const apiKeyService = require('./apiKeyService')

View File

@@ -4,7 +4,7 @@ const config = require('../../config/config')
const redis = require('../models/redis') const redis = require('../models/redis')
const logger = require('../utils/logger') const logger = require('../utils/logger')
const serviceRatesService = require('./serviceRatesService') const serviceRatesService = require('./serviceRatesService')
const { isClaudeFamilyModel } = require('../utils/modelHelper') const { isOpusModel } = require('../utils/modelHelper')
const ACCOUNT_TYPE_CONFIG = { const ACCOUNT_TYPE_CONFIG = {
claude: { prefix: 'claude:account:' }, claude: { prefix: 'claude:account:' },
@@ -1649,7 +1649,7 @@ class ApiKeyService {
async recordOpusCost(keyId, ratedCost, realCost, model, accountType) { async recordOpusCost(keyId, ratedCost, realCost, model, accountType) {
try { try {
// 判断是否为 Claude 系列模型(包含 Bedrock 格式等) // 判断是否为 Claude 系列模型(包含 Bedrock 格式等)
if (!isClaudeFamilyModel(model)) { if (!isOpusModel(model)) {
return return
} }

View File

@@ -280,16 +280,16 @@ class ClaudeRelayConfigService {
let accountService let accountService
switch (accountType) { switch (accountType) {
case 'claude-official': case 'claude-official':
accountService = require('./claudeAccountService') accountService = require('./account/claudeAccountService')
break break
case 'claude-console': case 'claude-console':
accountService = require('./claudeConsoleAccountService') accountService = require('./account/claudeConsoleAccountService')
break break
case 'bedrock': case 'bedrock':
accountService = require('./bedrockAccountService') accountService = require('./account/bedrockAccountService')
break break
case 'ccr': case 'ccr':
accountService = require('./ccrAccountService') accountService = require('./account/ccrAccountService')
break break
default: default:
logger.warn(`Unknown account type for validation: ${accountType}`) logger.warn(`Unknown account type for validation: ${accountType}`)

View File

@@ -4,10 +4,10 @@
*/ */
const logger = require('../utils/logger') const logger = require('../utils/logger')
const openaiAccountService = require('./openaiAccountService') const openaiAccountService = require('./account/openaiAccountService')
const claudeAccountService = require('./claudeAccountService') const claudeAccountService = require('./account/claudeAccountService')
const claudeConsoleAccountService = require('./claudeConsoleAccountService') const claudeConsoleAccountService = require('./account/claudeConsoleAccountService')
const unifiedOpenAIScheduler = require('./unifiedOpenAIScheduler') const unifiedOpenAIScheduler = require('./scheduler/unifiedOpenAIScheduler')
const webhookService = require('./webhookService') const webhookService = require('./webhookService')
class RateLimitCleanupService { class RateLimitCleanupService {

View File

@@ -1,7 +1,7 @@
const apiKeyService = require('./apiKeyService') const apiKeyService = require('../apiKeyService')
const { convertMessagesToGemini, convertGeminiResponse } = require('./geminiRelayService') const { convertMessagesToGemini, convertGeminiResponse } = require('./geminiRelayService')
const { normalizeAntigravityModelInput } = require('../utils/antigravityModel') const { normalizeAntigravityModelInput } = require('../../utils/antigravityModel')
const antigravityClient = require('./antigravityClient') const antigravityClient = require('../antigravityClient')
function buildRequestData({ messages, model, temperature, maxTokens, sessionId }) { function buildRequestData({ messages, model, temperature, maxTokens, sessionId }) {
const requestedModel = normalizeAntigravityModelInput(model) const requestedModel = normalizeAntigravityModelInput(model)

View File

@@ -1,7 +1,8 @@
const axios = require('axios') const axios = require('axios')
const ProxyHelper = require('../utils/proxyHelper') const ProxyHelper = require('../../utils/proxyHelper')
const logger = require('../utils/logger') const logger = require('../../utils/logger')
const config = require('../../config/config') const config = require('../../../config/config')
const upstreamErrorHelper = require('../../utils/upstreamErrorHelper')
// 转换模型名称(去掉 azure/ 前缀) // 转换模型名称(去掉 azure/ 前缀)
function normalizeModelName(model) { function normalizeModelName(model) {
@@ -212,6 +213,16 @@ async function handleAzureOpenAIRequest({
logger.error('Azure OpenAI Request Failed', errorDetails) logger.error('Azure OpenAI Request Failed', errorDetails)
} }
// 网络错误标记临时不可用
const azureAutoProtectionDisabled =
account?.disableAutoProtection === true || account?.disableAutoProtection === 'true'
if (account?.id && !azureAutoProtectionDisabled) {
const statusCode = error.response?.status || 503
await upstreamErrorHelper
.markTempUnavailable(account.id, 'azure-openai', statusCode)
.catch(() => {})
}
throw error throw error
} }
} }

View File

@@ -4,9 +4,10 @@ const {
InvokeModelWithResponseStreamCommand InvokeModelWithResponseStreamCommand
} = require('@aws-sdk/client-bedrock-runtime') } = require('@aws-sdk/client-bedrock-runtime')
const { fromEnv } = require('@aws-sdk/credential-providers') const { fromEnv } = require('@aws-sdk/credential-providers')
const logger = require('../utils/logger') const logger = require('../../utils/logger')
const config = require('../../config/config') const config = require('../../../config/config')
const userMessageQueueService = require('./userMessageQueueService') const userMessageQueueService = require('../userMessageQueueService')
const upstreamErrorHelper = require('../../utils/upstreamErrorHelper')
class BedrockRelayService { class BedrockRelayService {
constructor() { constructor() {
@@ -188,7 +189,7 @@ class BedrockRelayService {
} }
} catch (error) { } catch (error) {
logger.error('❌ Bedrock非流式请求失败:', error) logger.error('❌ Bedrock非流式请求失败:', error)
throw this._handleBedrockError(error) throw this._handleBedrockError(error, accountId, bedrockAccount)
} finally { } finally {
// 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放) // 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放)
if (queueLockAcquired && queueRequestId && accountId) { if (queueLockAcquired && queueRequestId && accountId) {
@@ -376,10 +377,12 @@ class BedrockRelayService {
} }
res.write('event: error\n') res.write('event: error\n')
res.write(`data: ${JSON.stringify({ error: this._handleBedrockError(error).message })}\n\n`) res.write(
`data: ${JSON.stringify({ error: this._handleBedrockError(error, accountId, bedrockAccount).message })}\n\n`
)
res.end() res.end()
throw this._handleBedrockError(error) throw this._handleBedrockError(error, accountId, bedrockAccount)
} finally { } finally {
// 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放) // 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放)
if (queueLockAcquired && queueRequestId && accountId) { if (queueLockAcquired && queueRequestId && accountId) {
@@ -684,7 +687,25 @@ class BedrockRelayService {
} }
// 处理Bedrock错误 // 处理Bedrock错误
_handleBedrockError(error) { _handleBedrockError(error, accountId = null, bedrockAccount = null) {
const autoProtectionDisabled =
bedrockAccount?.disableAutoProtection === true ||
bedrockAccount?.disableAutoProtection === 'true'
if (accountId && !autoProtectionDisabled) {
if (error.name === 'ThrottlingException') {
upstreamErrorHelper.markTempUnavailable(accountId, 'bedrock', 429).catch(() => {})
} else if (error.name === 'AccessDeniedException') {
upstreamErrorHelper.markTempUnavailable(accountId, 'bedrock', 403).catch(() => {})
} else if (
error.name === 'ServiceUnavailableException' ||
error.name === 'InternalServerException'
) {
upstreamErrorHelper.markTempUnavailable(accountId, 'bedrock', 500).catch(() => {})
} else if (error.name === 'ModelNotReadyException') {
upstreamErrorHelper.markTempUnavailable(accountId, 'bedrock', 503).catch(() => {})
}
}
const errorMessage = error.message || 'Unknown Bedrock error' const errorMessage = error.message || 'Unknown Bedrock error'
if (error.name === 'ValidationException') { if (error.name === 'ValidationException') {

View File

@@ -1,10 +1,11 @@
const axios = require('axios') const axios = require('axios')
const ccrAccountService = require('./ccrAccountService') const ccrAccountService = require('../account/ccrAccountService')
const logger = require('../utils/logger') const logger = require('../../utils/logger')
const config = require('../../config/config') const config = require('../../../config/config')
const { parseVendorPrefixedModel } = require('../utils/modelHelper') const { parseVendorPrefixedModel } = require('../../utils/modelHelper')
const userMessageQueueService = require('./userMessageQueueService') const userMessageQueueService = require('../userMessageQueueService')
const { isStreamWritable } = require('../utils/streamHelper') const { isStreamWritable } = require('../../utils/streamHelper')
const upstreamErrorHelper = require('../../utils/upstreamErrorHelper')
class CcrRelayService { class CcrRelayService {
constructor() { constructor() {
@@ -261,7 +262,11 @@ class CcrRelayService {
// 检查错误状态并相应处理 // 检查错误状态并相应处理
if (response.status === 401) { if (response.status === 401) {
logger.warn(`🚫 Unauthorized error detected for CCR account ${accountId}`) logger.warn(`🚫 Unauthorized error detected for CCR account ${accountId}`)
await ccrAccountService.markAccountUnauthorized(accountId) const autoProtectionDisabled =
account?.disableAutoProtection === true || account?.disableAutoProtection === 'true'
if (!autoProtectionDisabled) {
await upstreamErrorHelper.markTempUnavailable(accountId, 'ccr', 401).catch(() => {})
}
} else if (response.status === 429) { } else if (response.status === 429) {
logger.warn(`🚫 Rate limit detected for CCR account ${accountId}`) logger.warn(`🚫 Rate limit detected for CCR account ${accountId}`)
// 收到429先检查是否因为超过了手动配置的每日额度 // 收到429先检查是否因为超过了手动配置的每日额度
@@ -270,9 +275,35 @@ class CcrRelayService {
}) })
await ccrAccountService.markAccountRateLimited(accountId) await ccrAccountService.markAccountRateLimited(accountId)
const autoProtectionDisabled =
account?.disableAutoProtection === true || account?.disableAutoProtection === 'true'
if (!autoProtectionDisabled) {
await upstreamErrorHelper
.markTempUnavailable(
accountId,
'ccr',
429,
upstreamErrorHelper.parseRetryAfter(response.headers)
)
.catch(() => {})
}
} else if (response.status === 529) { } else if (response.status === 529) {
logger.warn(`🚫 Overload error detected for CCR account ${accountId}`) logger.warn(`🚫 Overload error detected for CCR account ${accountId}`)
await ccrAccountService.markAccountOverloaded(accountId) await ccrAccountService.markAccountOverloaded(accountId)
const autoProtectionDisabled =
account?.disableAutoProtection === true || account?.disableAutoProtection === 'true'
if (!autoProtectionDisabled) {
await upstreamErrorHelper.markTempUnavailable(accountId, 'ccr', 529).catch(() => {})
}
} else if (response.status >= 500) {
logger.warn(`🔥 Server error (${response.status}) detected for CCR account ${accountId}`)
const autoProtectionDisabled =
account?.disableAutoProtection === true || account?.disableAutoProtection === 'true'
if (!autoProtectionDisabled) {
await upstreamErrorHelper
.markTempUnavailable(accountId, 'ccr', response.status)
.catch(() => {})
}
} else if (response.status === 200 || response.status === 201) { } else if (response.status === 200 || response.status === 201) {
// 如果请求成功,检查并移除错误状态 // 如果请求成功,检查并移除错误状态
const isRateLimited = await ccrAccountService.isAccountRateLimited(accountId) const isRateLimited = await ccrAccountService.isAccountRateLimited(accountId)
@@ -310,6 +341,15 @@ class CcrRelayService {
error.message error.message
) )
// 网络错误标记临时不可用
if (accountId && !error.response) {
const autoProtectionDisabled =
account?.disableAutoProtection === true || account?.disableAutoProtection === 'true'
if (!autoProtectionDisabled) {
await upstreamErrorHelper.markTempUnavailable(accountId, 'ccr', 503).catch(() => {})
}
}
throw error throw error
} finally { } finally {
// 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放) // 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放)
@@ -489,6 +529,14 @@ class CcrRelayService {
) )
} else { } else {
logger.error(`❌ CCR stream relay failed (Account: ${account?.name || accountId}):`, error) logger.error(`❌ CCR stream relay failed (Account: ${account?.name || accountId}):`, error)
// 网络错误标记临时不可用
if (accountId && !error.response) {
const autoProtectionDisabled =
account?.disableAutoProtection === true || account?.disableAutoProtection === 'true'
if (!autoProtectionDisabled) {
await upstreamErrorHelper.markTempUnavailable(accountId, 'ccr', 503).catch(() => {})
}
}
} }
throw error throw error
} finally { } finally {
@@ -596,16 +644,40 @@ class CcrRelayService {
`❌ CCR API returned error status: ${response.status} | Account: ${account?.name || accountId}` `❌ CCR API returned error status: ${response.status} | Account: ${account?.name || accountId}`
) )
const autoProtectionDisabled =
account?.disableAutoProtection === true || account?.disableAutoProtection === 'true'
if (response.status === 401) { if (response.status === 401) {
ccrAccountService.markAccountUnauthorized(accountId) if (!autoProtectionDisabled) {
upstreamErrorHelper.markTempUnavailable(accountId, 'ccr', 401).catch(() => {})
}
} else if (response.status === 429) { } else if (response.status === 429) {
ccrAccountService.markAccountRateLimited(accountId) ccrAccountService.markAccountRateLimited(accountId)
if (!autoProtectionDisabled) {
upstreamErrorHelper
.markTempUnavailable(
accountId,
'ccr',
429,
upstreamErrorHelper.parseRetryAfter(response.headers)
)
.catch(() => {})
}
// 检查是否因为超过每日额度 // 检查是否因为超过每日额度
ccrAccountService.checkQuotaUsage(accountId).catch((err) => { ccrAccountService.checkQuotaUsage(accountId).catch((err) => {
logger.error('❌ Failed to check quota after 429 error:', err) logger.error('❌ Failed to check quota after 429 error:', err)
}) })
} else if (response.status === 529) { } else if (response.status === 529) {
ccrAccountService.markAccountOverloaded(accountId) ccrAccountService.markAccountOverloaded(accountId)
if (!autoProtectionDisabled) {
upstreamErrorHelper.markTempUnavailable(accountId, 'ccr', 529).catch(() => {})
}
} else if (response.status >= 500) {
if (!autoProtectionDisabled) {
upstreamErrorHelper
.markTempUnavailable(accountId, 'ccr', response.status)
.catch(() => {})
}
} }
// 设置错误响应的状态码和响应头 // 设置错误响应的状态码和响应头
@@ -885,7 +957,7 @@ class CcrRelayService {
// ⏰ 更新账户最后使用时间 // ⏰ 更新账户最后使用时间
async _updateLastUsedTime(accountId) { async _updateLastUsedTime(accountId) {
try { try {
const redis = require('../models/redis') const redis = require('../../models/redis')
const client = redis.getClientSafe() const client = redis.getClientSafe()
await client.hset(`ccr_account:${accountId}`, 'lastUsedAt', new Date().toISOString()) await client.hset(`ccr_account:${accountId}`, 'lastUsedAt', new Date().toISOString())
} catch (error) { } catch (error) {

View File

@@ -1,17 +1,18 @@
const axios = require('axios') const axios = require('axios')
const { v4: uuidv4 } = require('uuid') const { v4: uuidv4 } = require('uuid')
const claudeConsoleAccountService = require('./claudeConsoleAccountService') const claudeConsoleAccountService = require('../account/claudeConsoleAccountService')
const redis = require('../models/redis') const redis = require('../../models/redis')
const logger = require('../utils/logger') const logger = require('../../utils/logger')
const config = require('../../config/config') const config = require('../../../config/config')
const { const {
sanitizeUpstreamError, sanitizeUpstreamError,
sanitizeErrorMessage, sanitizeErrorMessage,
isAccountDisabledError isAccountDisabledError
} = require('../utils/errorSanitizer') } = require('../../utils/errorSanitizer')
const userMessageQueueService = require('./userMessageQueueService') const upstreamErrorHelper = require('../../utils/upstreamErrorHelper')
const { isStreamWritable } = require('../utils/streamHelper') const userMessageQueueService = require('../userMessageQueueService')
const { filterForClaude } = require('../utils/headerFilter') const { isStreamWritable } = require('../../utils/streamHelper')
const { filterForClaude } = require('../../utils/headerFilter')
class ClaudeConsoleRelayService { class ClaudeConsoleRelayService {
constructor() { constructor() {
@@ -334,7 +335,9 @@ class ClaudeConsoleRelayService {
`🚫 Unauthorized error detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}` `🚫 Unauthorized error detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
) )
if (!autoProtectionDisabled) { if (!autoProtectionDisabled) {
await claudeConsoleAccountService.markAccountUnauthorized(accountId) await upstreamErrorHelper
.markTempUnavailable(accountId, 'claude-console', 401)
.catch(() => {})
} }
} else if (accountDisabledError) { } else if (accountDisabledError) {
logger.error( logger.error(
@@ -357,6 +360,14 @@ class ClaudeConsoleRelayService {
if (!autoProtectionDisabled) { if (!autoProtectionDisabled) {
await claudeConsoleAccountService.markAccountRateLimited(accountId) await claudeConsoleAccountService.markAccountRateLimited(accountId)
await upstreamErrorHelper
.markTempUnavailable(
accountId,
'claude-console',
429,
upstreamErrorHelper.parseRetryAfter(response.headers)
)
.catch(() => {})
} }
} else if (response.status === 529) { } else if (response.status === 529) {
logger.warn( logger.warn(
@@ -364,6 +375,18 @@ class ClaudeConsoleRelayService {
) )
if (!autoProtectionDisabled) { if (!autoProtectionDisabled) {
await claudeConsoleAccountService.markAccountOverloaded(accountId) await claudeConsoleAccountService.markAccountOverloaded(accountId)
await upstreamErrorHelper
.markTempUnavailable(accountId, 'claude-console', 529)
.catch(() => {})
}
} else if (response.status >= 500) {
logger.warn(
`🔥 Server error (${response.status}) detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
)
if (!autoProtectionDisabled) {
await upstreamErrorHelper
.markTempUnavailable(accountId, 'claude-console', response.status)
.catch(() => {})
} }
} else if (response.status === 200 || response.status === 201) { } else if (response.status === 200 || response.status === 201) {
// 如果请求成功,检查并移除错误状态 // 如果请求成功,检查并移除错误状态
@@ -831,7 +854,9 @@ class ClaudeConsoleRelayService {
`🚫 [Stream] Unauthorized error detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}` `🚫 [Stream] Unauthorized error detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
) )
if (!autoProtectionDisabled) { if (!autoProtectionDisabled) {
await claudeConsoleAccountService.markAccountUnauthorized(accountId) await upstreamErrorHelper
.markTempUnavailable(accountId, 'claude-console', 401)
.catch(() => {})
} }
} else if (accountDisabledError) { } else if (accountDisabledError) {
logger.error( logger.error(
@@ -854,6 +879,14 @@ class ClaudeConsoleRelayService {
}) })
if (!autoProtectionDisabled) { if (!autoProtectionDisabled) {
await claudeConsoleAccountService.markAccountRateLimited(accountId) await claudeConsoleAccountService.markAccountRateLimited(accountId)
await upstreamErrorHelper
.markTempUnavailable(
accountId,
'claude-console',
429,
upstreamErrorHelper.parseRetryAfter(response.headers)
)
.catch(() => {})
} }
} else if (response.status === 529) { } else if (response.status === 529) {
logger.warn( logger.warn(
@@ -861,6 +894,18 @@ class ClaudeConsoleRelayService {
) )
if (!autoProtectionDisabled) { if (!autoProtectionDisabled) {
await claudeConsoleAccountService.markAccountOverloaded(accountId) await claudeConsoleAccountService.markAccountOverloaded(accountId)
await upstreamErrorHelper
.markTempUnavailable(accountId, 'claude-console', 529)
.catch(() => {})
}
} else if (response.status >= 500) {
logger.warn(
`🔥 [Stream] Server error (${response.status}) detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
)
if (!autoProtectionDisabled) {
await upstreamErrorHelper
.markTempUnavailable(accountId, 'claude-console', response.status)
.catch(() => {})
} }
} }
@@ -1246,16 +1291,37 @@ class ClaudeConsoleRelayService {
// 检查错误状态 // 检查错误状态
if (error.response) { if (error.response) {
const catchAutoProtectionDisabled =
account?.disableAutoProtection === true || account?.disableAutoProtection === 'true'
if (error.response.status === 401) { if (error.response.status === 401) {
claudeConsoleAccountService.markAccountUnauthorized(accountId) if (!catchAutoProtectionDisabled) {
upstreamErrorHelper
.markTempUnavailable(accountId, 'claude-console', 401)
.catch(() => {})
}
} else if (error.response.status === 429) { } else if (error.response.status === 429) {
claudeConsoleAccountService.markAccountRateLimited(accountId) if (!catchAutoProtectionDisabled) {
// 检查是否因为超过每日额度 claudeConsoleAccountService.markAccountRateLimited(accountId)
claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => { // 检查是否因为超过每日额度
logger.error('❌ Failed to check quota after 429 error:', err) claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => {
}) logger.error('❌ Failed to check quota after 429 error:', err)
})
upstreamErrorHelper
.markTempUnavailable(
accountId,
'claude-console',
429,
upstreamErrorHelper.parseRetryAfter(error.response.headers)
)
.catch(() => {})
}
} else if (error.response.status === 529) { } else if (error.response.status === 529) {
claudeConsoleAccountService.markAccountOverloaded(accountId) if (!catchAutoProtectionDisabled) {
claudeConsoleAccountService.markAccountOverloaded(accountId)
upstreamErrorHelper
.markTempUnavailable(accountId, 'claude-console', 529)
.catch(() => {})
}
} }
} }
@@ -1311,7 +1377,7 @@ class ClaudeConsoleRelayService {
// 🕐 更新最后使用时间 // 🕐 更新最后使用时间
async _updateLastUsedTime(accountId) { async _updateLastUsedTime(accountId) {
try { try {
const client = require('../models/redis').getClientSafe() const client = require('../../models/redis').getClientSafe()
const accountKey = `claude_console_account:${accountId}` const accountKey = `claude_console_account:${accountId}`
const exists = await client.exists(accountKey) const exists = await client.exists(accountKey)
@@ -1390,7 +1456,7 @@ class ClaudeConsoleRelayService {
// 🧪 测试账号连接供Admin API使用 // 🧪 测试账号连接供Admin API使用
async testAccountConnection(accountId, responseStream) { async testAccountConnection(accountId, responseStream) {
const { sendStreamTestRequest } = require('../utils/testPayloadHelper') const { sendStreamTestRequest } = require('../../utils/testPayloadHelper')
try { try {
const account = await claudeConsoleAccountService.getAccount(accountId) const account = await claudeConsoleAccountService.getAccount(accountId)

View File

@@ -1,26 +1,27 @@
const https = require('https') const https = require('https')
const zlib = require('zlib') const zlib = require('zlib')
const path = require('path') const path = require('path')
const ProxyHelper = require('../utils/proxyHelper') const ProxyHelper = require('../../utils/proxyHelper')
const { filterForClaude } = require('../utils/headerFilter') const { filterForClaude } = require('../../utils/headerFilter')
const claudeAccountService = require('./claudeAccountService') const claudeAccountService = require('../account/claudeAccountService')
const unifiedClaudeScheduler = require('./unifiedClaudeScheduler') const unifiedClaudeScheduler = require('../scheduler/unifiedClaudeScheduler')
const sessionHelper = require('../utils/sessionHelper') const sessionHelper = require('../../utils/sessionHelper')
const logger = require('../utils/logger') const logger = require('../../utils/logger')
const config = require('../../config/config') const config = require('../../../config/config')
const claudeCodeHeadersService = require('./claudeCodeHeadersService') const claudeCodeHeadersService = require('../claudeCodeHeadersService')
const redis = require('../models/redis') const redis = require('../../models/redis')
const ClaudeCodeValidator = require('../validators/clients/claudeCodeValidator') const ClaudeCodeValidator = require('../../validators/clients/claudeCodeValidator')
const { formatDateWithTimezone } = require('../utils/dateHelper') const { formatDateWithTimezone } = require('../../utils/dateHelper')
const requestIdentityService = require('./requestIdentityService') const requestIdentityService = require('../requestIdentityService')
const { createClaudeTestPayload } = require('../utils/testPayloadHelper') const { createClaudeTestPayload } = require('../../utils/testPayloadHelper')
const userMessageQueueService = require('./userMessageQueueService') const userMessageQueueService = require('../userMessageQueueService')
const { isStreamWritable } = require('../utils/streamHelper') const { isStreamWritable } = require('../../utils/streamHelper')
const upstreamErrorHelper = require('../../utils/upstreamErrorHelper')
const { const {
getHttpsAgentForStream, getHttpsAgentForStream,
getHttpsAgentForNonStream, getHttpsAgentForNonStream,
getPricingData getPricingData
} = require('../utils/performanceOptimizer') } = require('../../utils/performanceOptimizer')
// structuredClone polyfill for Node < 17 // structuredClone polyfill for Node < 17
const safeClone = const safeClone =
@@ -693,22 +694,26 @@ class ClaudeRelayService {
if (errorCount >= 1) { if (errorCount >= 1) {
logger.error( logger.error(
`❌ Account ${accountId} encountered 401 error (${errorCount} errors), marking as unauthorized` `❌ Account ${accountId} encountered 401 error (${errorCount} errors), temporarily pausing`
)
await unifiedClaudeScheduler.markAccountUnauthorized(
accountId,
accountType,
sessionHash
) )
} }
await upstreamErrorHelper.markTempUnavailable(accountId, accountType, 401).catch(() => {})
// 清除粘性会话,让后续请求路由到其他账户
if (sessionHash) {
await unifiedClaudeScheduler.clearSessionMapping(sessionHash).catch(() => {})
}
} }
// 检查是否为403状态码禁止访问 // 检查是否为403状态码禁止访问
// 注意如果进行了重试retryCount > 0这里的 403 是重试后最终的结果 // 注意如果进行了重试retryCount > 0这里的 403 是重试后最终的结果
else if (response.statusCode === 403) { else if (response.statusCode === 403) {
logger.error( logger.error(
`🚫 Forbidden error (403) detected for account ${accountId}${retryCount > 0 ? ` after ${retryCount} retries` : ''}, marking as blocked` `🚫 Forbidden error (403) detected for account ${accountId}${retryCount > 0 ? ` after ${retryCount} retries` : ''}, temporarily pausing`
) )
await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash) await upstreamErrorHelper.markTempUnavailable(accountId, accountType, 403).catch(() => {})
// 清除粘性会话,让后续请求路由到其他账户
if (sessionHash) {
await unifiedClaudeScheduler.clearSessionMapping(sessionHash).catch(() => {})
}
} }
// 检查是否返回组织被禁用错误400状态码 // 检查是否返回组织被禁用错误400状态码
else if (organizationDisabledError) { else if (organizationDisabledError) {
@@ -734,6 +739,7 @@ class ClaudeRelayService {
} else { } else {
logger.info(`🚫 529 error handling is disabled, skipping account overload marking`) logger.info(`🚫 529 error handling is disabled, skipping account overload marking`)
} }
await upstreamErrorHelper.markTempUnavailable(accountId, accountType, 529).catch(() => {})
} }
// 检查是否为5xx状态码 // 检查是否为5xx状态码
else if (response.statusCode >= 500 && response.statusCode < 600) { else if (response.statusCode >= 500 && response.statusCode < 600) {
@@ -819,6 +825,14 @@ class ClaudeRelayService {
sessionHash, sessionHash,
rateLimitResetTimestamp rateLimitResetTimestamp
) )
await upstreamErrorHelper
.markTempUnavailable(
accountId,
accountType,
429,
upstreamErrorHelper.parseRetryAfter(response.headers)
)
.catch(() => {})
if (dedicatedRateLimitMessage) { if (dedicatedRateLimitMessage) {
return { return {
@@ -1971,6 +1985,14 @@ class ClaudeRelayService {
sessionHash, sessionHash,
rateLimitResetTimestamp rateLimitResetTimestamp
) )
await upstreamErrorHelper
.markTempUnavailable(
accountId,
accountType,
429,
upstreamErrorHelper.parseRetryAfter(res.headers)
)
.catch(() => {})
logger.warn(`🚫 [Stream] Rate limit detected for account ${accountId}, status 429`) logger.warn(`🚫 [Stream] Rate limit detected for account ${accountId}, status 429`)
if (isDedicatedOfficialAccount) { if (isDedicatedOfficialAccount) {
@@ -2068,21 +2090,29 @@ class ClaudeRelayService {
if (errorCount >= 1) { if (errorCount >= 1) {
logger.error( logger.error(
`❌ [Stream] Account ${accountId} encountered 401 error (${errorCount} errors), marking as unauthorized` `❌ [Stream] Account ${accountId} encountered 401 error (${errorCount} errors), temporarily pausing`
)
await unifiedClaudeScheduler.markAccountUnauthorized(
accountId,
accountType,
sessionHash
) )
} }
await upstreamErrorHelper
.markTempUnavailable(accountId, accountType, 401)
.catch(() => {})
// 清除粘性会话,让后续请求路由到其他账户
if (sessionHash) {
await unifiedClaudeScheduler.clearSessionMapping(sessionHash).catch(() => {})
}
} else if (res.statusCode === 403) { } else if (res.statusCode === 403) {
// 403 处理:走到这里说明重试已用尽或不适用重试,直接标记 blocked // 403 处理:走到这里说明重试已用尽或不适用重试,直接标记 blocked
// 注意:重试逻辑已在 handleErrorResponse 外部提前处理 // 注意:重试逻辑已在 handleErrorResponse 外部提前处理
logger.error( logger.error(
`🚫 [Stream] Forbidden error (403) detected for account ${accountId}${retryCount > 0 ? ` after ${retryCount} retries` : ''}, marking as blocked` `🚫 [Stream] Forbidden error (403) detected for account ${accountId}${retryCount > 0 ? ` after ${retryCount} retries` : ''}, temporarily pausing`
) )
await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash) await upstreamErrorHelper
.markTempUnavailable(accountId, accountType, 403)
.catch(() => {})
// 清除粘性会话,让后续请求路由到其他账户
if (sessionHash) {
await unifiedClaudeScheduler.clearSessionMapping(sessionHash).catch(() => {})
}
} else if (res.statusCode === 529) { } else if (res.statusCode === 529) {
logger.warn(`🚫 [Stream] Overload error (529) detected for account ${accountId}`) logger.warn(`🚫 [Stream] Overload error (529) detected for account ${accountId}`)
@@ -2104,6 +2134,9 @@ class ClaudeRelayService {
`🚫 [Stream] 529 error handling is disabled, skipping account overload marking` `🚫 [Stream] 529 error handling is disabled, skipping account overload marking`
) )
} }
await upstreamErrorHelper
.markTempUnavailable(accountId, accountType, 529)
.catch(() => {})
} else if (res.statusCode >= 500 && res.statusCode < 600) { } else if (res.statusCode >= 500 && res.statusCode < 600) {
logger.warn( logger.warn(
`🔥 [Stream] Server error (${res.statusCode}) detected for account ${accountId}` `🔥 [Stream] Server error (${res.statusCode}) detected for account ${accountId}`
@@ -2542,6 +2575,14 @@ class ClaudeRelayService {
sessionHash, sessionHash,
rateLimitResetTimestamp rateLimitResetTimestamp
) )
await upstreamErrorHelper
.markTempUnavailable(
accountId,
accountType,
429,
upstreamErrorHelper.parseRetryAfter(res.headers)
)
.catch(() => {})
} }
} else if (res.statusCode === 200) { } else if (res.statusCode === 200) {
// 请求成功清除401和500错误计数 // 请求成功清除401和500错误计数

View File

@@ -1,13 +1,14 @@
const https = require('https') const https = require('https')
const axios = require('axios') const axios = require('axios')
const ProxyHelper = require('../utils/proxyHelper') const ProxyHelper = require('../../utils/proxyHelper')
const droidScheduler = require('./droidScheduler') const droidScheduler = require('../scheduler/droidScheduler')
const droidAccountService = require('./droidAccountService') const droidAccountService = require('../account/droidAccountService')
const apiKeyService = require('./apiKeyService') const apiKeyService = require('../apiKeyService')
const redis = require('../models/redis') const redis = require('../../models/redis')
const { updateRateLimitCounters } = require('../utils/rateLimitHelper') const { updateRateLimitCounters } = require('../../utils/rateLimitHelper')
const logger = require('../utils/logger') const logger = require('../../utils/logger')
const runtimeAddon = require('../utils/runtimeAddon') const runtimeAddon = require('../../utils/runtimeAddon')
const upstreamErrorHelper = require('../../utils/upstreamErrorHelper')
const SYSTEM_PROMPT = 'You are Droid, an AI software engineering agent built by Factory.' const SYSTEM_PROMPT = 'You are Droid, an AI software engineering agent built by Factory.'
const RUNTIME_EVENT_FMT_PAYLOAD = 'fmtPayload' const RUNTIME_EVENT_FMT_PAYLOAD = 'fmtPayload'
@@ -346,6 +347,21 @@ class DroidRelayService {
} }
const status = error?.response?.status const status = error?.response?.status
const droidAutoProtectionDisabled =
account?.disableAutoProtection === true || account?.disableAutoProtection === 'true'
// 5xx 错误
if (status >= 500 && account?.id && !droidAutoProtectionDisabled) {
await upstreamErrorHelper.markTempUnavailable(account.id, 'droid', status).catch(() => {})
} else if (
!status &&
account?.id &&
error.message !== 'Client disconnected' &&
!droidAutoProtectionDisabled
) {
// 网络错误(非客户端断开),临时不可用
await upstreamErrorHelper.markTempUnavailable(account.id, 'droid', 503).catch(() => {})
}
if (status >= 400 && status < 500) { if (status >= 400 && status < 500) {
try { try {
await this._handleUpstreamClientError(status, { await this._handleUpstreamClientError(status, {
@@ -518,6 +534,15 @@ class DroidRelayService {
logger.info('✅ res.end() reached') logger.info('✅ res.end() reached')
const body = Buffer.concat(chunks).toString() const body = Buffer.concat(chunks).toString()
logger.error(`❌ Factory.ai error response body: ${body || '(empty)'}`) logger.error(`❌ Factory.ai error response body: ${body || '(empty)'}`)
if (res.statusCode >= 500) {
const streamAutoProtectionDisabled =
account?.disableAutoProtection === true || account?.disableAutoProtection === 'true'
if (!streamAutoProtectionDisabled) {
upstreamErrorHelper
.markTempUnavailable(account.id, 'droid', res.statusCode)
.catch(() => {})
}
}
if (res.statusCode >= 400 && res.statusCode < 500) { if (res.statusCode >= 400 && res.statusCode < 500) {
this._handleUpstreamClientError(res.statusCode, { this._handleUpstreamClientError(res.statusCode, {
account, account,
@@ -1380,7 +1405,11 @@ class DroidRelayService {
return return
} }
await this._stopDroidAccountScheduling(accountId, statusCode, '凭证不可用') const clientErrorAutoProtectionDisabled =
account?.disableAutoProtection === true || account?.disableAutoProtection === 'true'
if (!clientErrorAutoProtectionDisabled) {
await upstreamErrorHelper.markTempUnavailable(accountId, 'droid', statusCode)
}
await this._clearAccountStickyMapping(normalizedEndpoint, sessionHash, clientApiKeyId) await this._clearAccountStickyMapping(normalizedEndpoint, sessionHash, clientApiKeyId)
} }

View File

@@ -1,8 +1,8 @@
const axios = require('axios') const axios = require('axios')
const ProxyHelper = require('../utils/proxyHelper') const ProxyHelper = require('../../utils/proxyHelper')
const logger = require('../utils/logger') const logger = require('../../utils/logger')
const config = require('../../config/config') const config = require('../../../config/config')
const apiKeyService = require('./apiKeyService') const apiKeyService = require('../apiKeyService')
// Gemini API 配置 // Gemini API 配置
const GEMINI_API_BASE = 'https://cloudcode.googleapis.com/v1' const GEMINI_API_BASE = 'https://cloudcode.googleapis.com/v1'

View File

@@ -1,13 +1,14 @@
const axios = require('axios') const axios = require('axios')
const ProxyHelper = require('../utils/proxyHelper') const ProxyHelper = require('../../utils/proxyHelper')
const logger = require('../utils/logger') const logger = require('../../utils/logger')
const { filterForOpenAI } = require('../utils/headerFilter') const { filterForOpenAI } = require('../../utils/headerFilter')
const openaiResponsesAccountService = require('./openaiResponsesAccountService') const openaiResponsesAccountService = require('../account/openaiResponsesAccountService')
const apiKeyService = require('./apiKeyService') const apiKeyService = require('../apiKeyService')
const unifiedOpenAIScheduler = require('./unifiedOpenAIScheduler') const unifiedOpenAIScheduler = require('../scheduler/unifiedOpenAIScheduler')
const config = require('../../config/config') const config = require('../../../config/config')
const crypto = require('crypto') const crypto = require('crypto')
const LRUCache = require('../utils/lruCache') const LRUCache = require('../../utils/lruCache')
const upstreamErrorHelper = require('../../utils/upstreamErrorHelper')
// lastUsedAt 更新节流(每账户 60 秒内最多更新一次,使用 LRU 防止内存泄漏) // lastUsedAt 更新节流(每账户 60 秒内最多更新一次,使用 LRU 防止内存泄漏)
const lastUsedAtThrottle = new LRUCache(1000) // 最多缓存 1000 个账户 const lastUsedAtThrottle = new LRUCache(1000) // 最多缓存 1000 个账户
@@ -160,6 +161,19 @@ class OpenAIResponsesRelayService {
sessionHash sessionHash
) )
const oaiAutoProtectionDisabled =
account?.disableAutoProtection === true || account?.disableAutoProtection === 'true'
if (!oaiAutoProtectionDisabled) {
await upstreamErrorHelper
.markTempUnavailable(
account.id,
'openai-responses',
429,
resetsInSeconds || upstreamErrorHelper.parseRetryAfter(response.headers)
)
.catch(() => {})
}
// 返回错误响应(使用处理后的数据,避免循环引用) // 返回错误响应(使用处理后的数据,避免循环引用)
const errorResponse = errorData || { const errorResponse = errorData || {
error: { error: {
@@ -218,31 +232,23 @@ class OpenAIResponsesRelayService {
}) })
if (response.status === 401) { if (response.status === 401) {
let reason = 'OpenAI Responses账号认证失败401错误' logger.warn(`🚫 OpenAI Responses账号认证失败401错误for account ${account?.id}`)
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 { try {
await unifiedOpenAIScheduler.markAccountUnauthorized( // 仅临时暂停,不永久禁用
account.id, const oaiAutoProtectionDisabled =
'openai-responses', account?.disableAutoProtection === true || account?.disableAutoProtection === 'true'
sessionHash, if (!oaiAutoProtectionDisabled) {
reason await upstreamErrorHelper
) .markTempUnavailable(account.id, 'openai-responses', 401)
.catch(() => {})
}
if (sessionHash) {
await unifiedOpenAIScheduler._deleteSessionMapping(sessionHash).catch(() => {})
}
} catch (markError) { } catch (markError) {
logger.error( logger.error(
'❌ Failed to mark OpenAI-Responses account unauthorized after 401:', '❌ Failed to mark OpenAI-Responses account temporarily unavailable after 401:',
markError markError
) )
} }
@@ -272,11 +278,36 @@ class OpenAIResponsesRelayService {
return res.status(401).json(unauthorizedResponse) return res.status(401).json(unauthorizedResponse)
} }
// 处理 5xx 上游错误
if (response.status >= 500 && account?.id) {
try {
const oaiAutoProtectionDisabled =
account?.disableAutoProtection === true || account?.disableAutoProtection === 'true'
if (!oaiAutoProtectionDisabled) {
await upstreamErrorHelper.markTempUnavailable(
account.id,
'openai-responses',
response.status
)
}
if (sessionHash) {
await unifiedOpenAIScheduler._deleteSessionMapping(sessionHash).catch(() => {})
}
} catch (markError) {
logger.warn(
'Failed to mark OpenAI-Responses account temporarily unavailable:',
markError
)
}
}
// 清理监听器 // 清理监听器
req.removeListener('close', handleClientDisconnect) req.removeListener('close', handleClientDisconnect)
res.removeListener('close', handleClientDisconnect) res.removeListener('close', handleClientDisconnect)
return res.status(response.status).json(errorData) return res
.status(response.status)
.json(upstreamErrorHelper.sanitizeErrorForClient(errorData))
} }
// 更新最后使用时间(节流) // 更新最后使用时间(节流)
@@ -314,10 +345,15 @@ class OpenAIResponsesRelayService {
// 检查是否是网络错误 // 检查是否是网络错误
if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT') { if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT') {
await openaiResponsesAccountService.updateAccount(account.id, { if (account?.id) {
status: 'error', const oaiAutoProtectionDisabled =
errorMessage: `Connection error: ${error.code}` account?.disableAutoProtection === true || account?.disableAutoProtection === 'true'
}) if (!oaiAutoProtectionDisabled) {
await upstreamErrorHelper
.markTempUnavailable(account.id, 'openai-responses', 503)
.catch(() => {})
}
}
} }
// 如果已经发送了响应头,直接结束 // 如果已经发送了响应头,直接结束
@@ -352,31 +388,25 @@ class OpenAIResponsesRelayService {
} }
if (status === 401) { if (status === 401) {
let reason = 'OpenAI Responses账号认证失败401错误' logger.warn(
if (errorData) { `🚫 OpenAI Responses账号认证失败401错误for account ${account?.id} (catch handler)`
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 { try {
await unifiedOpenAIScheduler.markAccountUnauthorized( // 仅临时暂停,不永久禁用
account.id, const oaiAutoProtectionDisabled =
'openai-responses', account?.disableAutoProtection === true || account?.disableAutoProtection === 'true'
sessionHash, if (!oaiAutoProtectionDisabled) {
reason await upstreamErrorHelper
) .markTempUnavailable(account.id, 'openai-responses', 401)
.catch(() => {})
}
if (sessionHash) {
await unifiedOpenAIScheduler._deleteSessionMapping(sessionHash).catch(() => {})
}
} catch (markError) { } catch (markError) {
logger.error( logger.error(
'❌ Failed to mark OpenAI-Responses account unauthorized in catch handler:', '❌ Failed to mark OpenAI-Responses account temporarily unavailable in catch handler:',
markError markError
) )
} }
@@ -402,7 +432,7 @@ class OpenAIResponsesRelayService {
return res.status(401).json(unauthorizedResponse) return res.status(401).json(unauthorizedResponse)
} }
return res.status(status).json(errorData) return res.status(status).json(upstreamErrorHelper.sanitizeErrorForClient(errorData))
} }
// 其他错误 // 其他错误
@@ -571,7 +601,7 @@ class OpenAIResponsesRelayService {
// 更新账户使用额度(如果设置了额度限制) // 更新账户使用额度(如果设置了额度限制)
if (parseFloat(account.dailyQuota) > 0) { if (parseFloat(account.dailyQuota) > 0) {
// 使用CostCalculator正确计算费用考虑缓存token的不同价格 // 使用CostCalculator正确计算费用考虑缓存token的不同价格
const CostCalculator = require('../utils/costCalculator') const CostCalculator = require('../../utils/costCalculator')
const costInfo = CostCalculator.calculateCost( const costInfo = CostCalculator.calculateCost(
{ {
input_tokens: actualInputTokens, // 实际输入(不含缓存) input_tokens: actualInputTokens, // 实际输入(不含缓存)
@@ -700,7 +730,7 @@ class OpenAIResponsesRelayService {
// 更新账户使用额度(如果设置了额度限制) // 更新账户使用额度(如果设置了额度限制)
if (parseFloat(account.dailyQuota) > 0) { if (parseFloat(account.dailyQuota) > 0) {
// 使用CostCalculator正确计算费用考虑缓存token的不同价格 // 使用CostCalculator正确计算费用考虑缓存token的不同价格
const CostCalculator = require('../utils/costCalculator') const CostCalculator = require('../../utils/costCalculator')
const costInfo = CostCalculator.calculateCost( const costInfo = CostCalculator.calculateCost(
{ {
input_tokens: actualInputTokens, // 实际输入(不含缓存) input_tokens: actualInputTokens, // 实际输入(不含缓存)

View File

@@ -1,13 +1,14 @@
const droidAccountService = require('./droidAccountService') const droidAccountService = require('../account/droidAccountService')
const accountGroupService = require('./accountGroupService') const accountGroupService = require('../accountGroupService')
const redis = require('../models/redis') const redis = require('../../models/redis')
const logger = require('../utils/logger') const logger = require('../../utils/logger')
const upstreamErrorHelper = require('../../utils/upstreamErrorHelper')
const { const {
isTruthy, isTruthy,
isAccountHealthy, isAccountHealthy,
sortAccountsByPriority, sortAccountsByPriority,
normalizeEndpointType normalizeEndpointType
} = require('../utils/commonHelper') } = require('../../utils/commonHelper')
class DroidScheduler { class DroidScheduler {
constructor() { constructor() {
@@ -57,9 +58,21 @@ class DroidScheduler {
}) })
) )
return accounts.filter( const result = []
(account) => account && isAccountHealthy(account) && this._isAccountSchedulable(account) for (const account of accounts) {
) if (!account || !isAccountHealthy(account) || !this._isAccountSchedulable(account)) {
continue
}
const isTempUnavailable = await upstreamErrorHelper.isTempUnavailable(account.id, 'droid')
if (isTempUnavailable) {
logger.debug(
`⏭️ Skipping Droid group member ${account.name || account.id} - temporarily unavailable`
)
continue
}
result.push(account)
}
return result
} }
async _ensureLastUsedUpdated(accountId) { async _ensureLastUsedUpdated(accountId) {
@@ -99,8 +112,15 @@ class DroidScheduler {
} else { } else {
const account = await droidAccountService.getAccount(binding) const account = await droidAccountService.getAccount(binding)
if (account) { if (account) {
candidates = [account] const isTempUnavailable = await upstreamErrorHelper.isTempUnavailable(account.id, 'droid')
isDedicatedBinding = true if (isTempUnavailable) {
logger.warn(
`⏱️ Bound Droid account ${account.name || account.id} temporarily unavailable, falling back to pool`
)
} else {
candidates = [account]
isDedicatedBinding = true
}
} }
} }
} }
@@ -109,13 +129,26 @@ class DroidScheduler {
candidates = await droidAccountService.getSchedulableAccounts(normalizedEndpoint) candidates = await droidAccountService.getSchedulableAccounts(normalizedEndpoint)
} }
const filtered = candidates.filter( const syncFiltered = candidates.filter(
(account) => (account) =>
account && account &&
isAccountHealthy(account) && isAccountHealthy(account) &&
this._isAccountSchedulable(account) && this._isAccountSchedulable(account) &&
this._matchesEndpoint(account, normalizedEndpoint) this._matchesEndpoint(account, normalizedEndpoint)
) )
const filteredResults = await Promise.all(
syncFiltered.map(async (account) => {
const isTempUnavailable = await upstreamErrorHelper.isTempUnavailable(account.id, 'droid')
if (isTempUnavailable) {
logger.debug(
`⏭️ Skipping Droid account ${account.name || account.id} - temporarily unavailable`
)
return null
}
return account
})
)
const filtered = filteredResults.filter(Boolean)
if (filtered.length === 0) { if (filtered.length === 0) {
throw new Error( throw new Error(

View File

@@ -1,12 +1,13 @@
const claudeAccountService = require('./claudeAccountService') const claudeAccountService = require('../account/claudeAccountService')
const claudeConsoleAccountService = require('./claudeConsoleAccountService') const claudeConsoleAccountService = require('../account/claudeConsoleAccountService')
const bedrockAccountService = require('./bedrockAccountService') const bedrockAccountService = require('../account/bedrockAccountService')
const ccrAccountService = require('./ccrAccountService') const ccrAccountService = require('../account/ccrAccountService')
const accountGroupService = require('./accountGroupService') const accountGroupService = require('../accountGroupService')
const redis = require('../models/redis') const redis = require('../../models/redis')
const logger = require('../utils/logger') const logger = require('../../utils/logger')
const { parseVendorPrefixedModel, isOpus45OrNewer } = require('../utils/modelHelper') const { parseVendorPrefixedModel, isOpus45OrNewer } = require('../../utils/modelHelper')
const { isSchedulable, sortAccountsByPriority } = require('../utils/commonHelper') const { isSchedulable, sortAccountsByPriority } = require('../../utils/commonHelper')
const upstreamErrorHelper = require('../../utils/upstreamErrorHelper')
/** /**
* Check if account is Pro (not Max) * Check if account is Pro (not Max)
@@ -1175,7 +1176,7 @@ class UnifiedClaudeScheduler {
const client = redis.getClientSafe() const client = redis.getClientSafe()
const mappingData = JSON.stringify({ accountId, accountType }) const mappingData = JSON.stringify({ accountId, accountType })
// 依据配置设置TTL小时 // 依据配置设置TTL小时
const appConfig = require('../../config/config') const appConfig = require('../../../config/config')
const ttlHours = appConfig.session?.stickyTtlHours || 1 const ttlHours = appConfig.session?.stickyTtlHours || 1
const ttlSeconds = Math.max(1, Math.floor(ttlHours * 60 * 60)) const ttlSeconds = Math.max(1, Math.floor(ttlHours * 60 * 60))
await client.setex(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`, ttlSeconds, mappingData) await client.setex(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`, ttlSeconds, mappingData)
@@ -1224,7 +1225,7 @@ class UnifiedClaudeScheduler {
return true return true
} }
const appConfig = require('../../config/config') const appConfig = require('../../../config/config')
const ttlHours = appConfig.session?.stickyTtlHours || 1 const ttlHours = appConfig.session?.stickyTtlHours || 1
const renewalThresholdMinutes = appConfig.session?.renewalThresholdMinutes || 0 const renewalThresholdMinutes = appConfig.session?.renewalThresholdMinutes || 0
@@ -1261,15 +1262,10 @@ class UnifiedClaudeScheduler {
ttlSeconds = 300 ttlSeconds = 300
) { ) {
try { try {
const client = redis.getClientSafe() await upstreamErrorHelper.markTempUnavailable(accountId, accountType, 500, ttlSeconds)
const key = `temp_unavailable:${accountType}:${accountId}`
await client.setex(key, ttlSeconds, '1')
if (sessionHash) { if (sessionHash) {
await this._deleteSessionMapping(sessionHash) await this._deleteSessionMapping(sessionHash)
} }
logger.warn(
`⏱️ Account ${accountId} (${accountType}) marked temporarily unavailable for ${ttlSeconds}s`
)
return { success: true } return { success: true }
} catch (error) { } catch (error) {
logger.error(`❌ Failed to mark account temporarily unavailable: ${accountId}`, error) logger.error(`❌ Failed to mark account temporarily unavailable: ${accountId}`, error)
@@ -1279,14 +1275,7 @@ class UnifiedClaudeScheduler {
// 🔍 检查账户是否临时不可用 // 🔍 检查账户是否临时不可用
async isAccountTemporarilyUnavailable(accountId, accountType) { async isAccountTemporarilyUnavailable(accountId, accountType) {
try { return upstreamErrorHelper.isTempUnavailable(accountId, accountType)
const client = redis.getClientSafe()
const key = `temp_unavailable:${accountType}:${accountId}`
return (await client.exists(key)) === 1
} catch (error) {
logger.error(`❌ Failed to check temp unavailable status: ${accountId}`, error)
return false
}
} }
// 🚫 标记账户为限流状态 // 🚫 标记账户为限流状态

View File

@@ -1,9 +1,10 @@
const geminiAccountService = require('./geminiAccountService') const geminiAccountService = require('../account/geminiAccountService')
const geminiApiAccountService = require('./geminiApiAccountService') const geminiApiAccountService = require('../account/geminiApiAccountService')
const accountGroupService = require('./accountGroupService') const accountGroupService = require('../accountGroupService')
const redis = require('../models/redis') const redis = require('../../models/redis')
const logger = require('../utils/logger') const logger = require('../../utils/logger')
const { isSchedulable, isActive, sortAccountsByPriority } = require('../utils/commonHelper') const { isSchedulable, isActive, sortAccountsByPriority } = require('../../utils/commonHelper')
const upstreamErrorHelper = require('../../utils/upstreamErrorHelper')
const OAUTH_PROVIDER_GEMINI_CLI = 'gemini-cli' const OAUTH_PROVIDER_GEMINI_CLI = 'gemini-cli'
const OAUTH_PROVIDER_ANTIGRAVITY = 'antigravity' const OAUTH_PROVIDER_ANTIGRAVITY = 'antigravity'
@@ -241,8 +242,17 @@ class UnifiedGeminiScheduler {
const accountId = apiKeyData.geminiAccountId.replace('api:', '') const accountId = apiKeyData.geminiAccountId.replace('api:', '')
const boundAccount = await geminiApiAccountService.getAccount(accountId) const boundAccount = await geminiApiAccountService.getAccount(accountId)
if (boundAccount && isActive(boundAccount.isActive) && boundAccount.status !== 'error') { if (boundAccount && isActive(boundAccount.isActive) && boundAccount.status !== 'error') {
const isTempUnavailable = await upstreamErrorHelper.isTempUnavailable(
accountId,
'gemini-api'
)
if (isTempUnavailable) {
logger.warn(
`⏱️ Bound Gemini-API account ${boundAccount.name} (${accountId}) temporarily unavailable, falling back to pool`
)
}
const isRateLimited = await this.isAccountRateLimited(accountId) const isRateLimited = await this.isAccountRateLimited(accountId)
if (!isRateLimited) { if (!isRateLimited && !isTempUnavailable) {
// 检查模型支持 // 检查模型支持
if ( if (
requestedModel && requestedModel &&
@@ -298,8 +308,17 @@ class UnifiedGeminiScheduler {
) { ) {
return availableAccounts return availableAccounts
} }
const isTempUnavailable = await upstreamErrorHelper.isTempUnavailable(
boundAccount.id,
'gemini'
)
if (isTempUnavailable) {
logger.warn(
`⏱️ Bound Gemini account ${boundAccount.name} (${boundAccount.id}) temporarily unavailable, falling back to pool`
)
}
const isRateLimited = await this.isAccountRateLimited(boundAccount.id) const isRateLimited = await this.isAccountRateLimited(boundAccount.id)
if (!isRateLimited) { if (!isRateLimited && !isTempUnavailable) {
// 检查模型支持 // 检查模型支持
if ( if (
requestedModel && requestedModel &&
@@ -364,6 +383,13 @@ class UnifiedGeminiScheduler {
continue continue
} }
// 检查临时不可用
const isTempUnavailable = await upstreamErrorHelper.isTempUnavailable(account.id, 'gemini')
if (isTempUnavailable) {
logger.debug(`⏭️ Skipping Gemini account ${account.name} - temporarily unavailable`)
continue
}
// 检查模型支持 // 检查模型支持
if (requestedModel && account.supportedModels && account.supportedModels.length > 0) { if (requestedModel && account.supportedModels && account.supportedModels.length > 0) {
// 处理可能带有 models/ 前缀的模型名 // 处理可能带有 models/ 前缀的模型名
@@ -417,6 +443,16 @@ class UnifiedGeminiScheduler {
} }
} }
// 检查临时不可用
const isTempUnavailable = await upstreamErrorHelper.isTempUnavailable(
account.id,
'gemini-api'
)
if (isTempUnavailable) {
logger.debug(`⏭️ Skipping Gemini-API account ${account.name} - temporarily unavailable`)
continue
}
// 检查是否被限流 // 检查是否被限流
const isRateLimited = await this.isAccountRateLimited(account.id) const isRateLimited = await this.isAccountRateLimited(account.id)
if (!isRateLimited) { if (!isRateLimited) {
@@ -451,6 +487,14 @@ class UnifiedGeminiScheduler {
logger.info(`🚫 Gemini account ${accountId} is not schedulable`) logger.info(`🚫 Gemini account ${accountId} is not schedulable`)
return false return false
} }
const isTempUnavailable = await upstreamErrorHelper.isTempUnavailable(
accountId,
accountType
)
if (isTempUnavailable) {
logger.info(`⏱️ Gemini account ${accountId} is temporarily unavailable`)
return false
}
return !(await this.isAccountRateLimited(accountId)) return !(await this.isAccountRateLimited(accountId))
} else if (accountType === 'gemini-api') { } else if (accountType === 'gemini-api') {
const account = await geminiApiAccountService.getAccount(accountId) const account = await geminiApiAccountService.getAccount(accountId)
@@ -462,6 +506,14 @@ class UnifiedGeminiScheduler {
logger.info(`🚫 Gemini-API account ${accountId} is not schedulable`) logger.info(`🚫 Gemini-API account ${accountId} is not schedulable`)
return false return false
} }
const isTempUnavailable = await upstreamErrorHelper.isTempUnavailable(
accountId,
accountType
)
if (isTempUnavailable) {
logger.info(`⏱️ Gemini account ${accountId} is temporarily unavailable`)
return false
}
return !(await this.isAccountRateLimited(accountId)) return !(await this.isAccountRateLimited(accountId))
} }
return false return false
@@ -494,7 +546,7 @@ class UnifiedGeminiScheduler {
const client = redis.getClientSafe() const client = redis.getClientSafe()
const mappingData = JSON.stringify({ accountId, accountType }) const mappingData = JSON.stringify({ accountId, accountType })
// 依据配置设置TTL小时 // 依据配置设置TTL小时
const appConfig = require('../../config/config') const appConfig = require('../../../config/config')
const ttlHours = appConfig.session?.stickyTtlHours || 1 const ttlHours = appConfig.session?.stickyTtlHours || 1
const ttlSeconds = Math.max(1, Math.floor(ttlHours * 60 * 60)) const ttlSeconds = Math.max(1, Math.floor(ttlHours * 60 * 60))
const key = this._getSessionMappingKey(sessionHash, oauthProvider) const key = this._getSessionMappingKey(sessionHash, oauthProvider)
@@ -535,7 +587,7 @@ class UnifiedGeminiScheduler {
return true return true
} }
const appConfig = require('../../config/config') const appConfig = require('../../../config/config')
const ttlHours = appConfig.session?.stickyTtlHours || 1 const ttlHours = appConfig.session?.stickyTtlHours || 1
const renewalThresholdMinutes = appConfig.session?.renewalThresholdMinutes || 0 const renewalThresholdMinutes = appConfig.session?.renewalThresholdMinutes || 0
if (!renewalThresholdMinutes) { if (!renewalThresholdMinutes) {
@@ -749,6 +801,14 @@ class UnifiedGeminiScheduler {
// 检查是否被限流 // 检查是否被限流
const isRateLimited = await this.isAccountRateLimited(account.id, accountType) const isRateLimited = await this.isAccountRateLimited(account.id, accountType)
if (!isRateLimited) { if (!isRateLimited) {
const isTempUnavailable = await upstreamErrorHelper.isTempUnavailable(
account.id,
accountType
)
if (isTempUnavailable) {
logger.debug(`⏭️ Skipping group member ${account.name} - temporarily unavailable`)
continue
}
availableAccounts.push({ availableAccounts.push({
...account, ...account,
accountId: account.id, accountId: account.id,

View File

@@ -1,9 +1,10 @@
const openaiAccountService = require('./openaiAccountService') const openaiAccountService = require('../account/openaiAccountService')
const openaiResponsesAccountService = require('./openaiResponsesAccountService') const openaiResponsesAccountService = require('../account/openaiResponsesAccountService')
const accountGroupService = require('./accountGroupService') const accountGroupService = require('../accountGroupService')
const redis = require('../models/redis') const redis = require('../../models/redis')
const logger = require('../utils/logger') const logger = require('../../utils/logger')
const { isSchedulable, sortAccountsByPriority } = require('../utils/commonHelper') const { isSchedulable, sortAccountsByPriority } = require('../../utils/commonHelper')
const upstreamErrorHelper = require('../../utils/upstreamErrorHelper')
class UnifiedOpenAIScheduler { class UnifiedOpenAIScheduler {
constructor() { constructor() {
@@ -153,91 +154,102 @@ class UnifiedOpenAIScheduler {
boundAccount.status !== 'unauthorized' boundAccount.status !== 'unauthorized'
if (isActiveBoundAccount) { if (isActiveBoundAccount) {
if (accountType === 'openai') { // 检查是否临时不可用
const readiness = await this._ensureAccountReadyForScheduling( const isTempUnavailable = await upstreamErrorHelper.isTempUnavailable(
boundAccount, boundAccount.id,
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 (!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
}
// ⏰ 检查 OpenAI-Responses 专属账户订阅是否过期
if (openaiResponsesAccountService.isSubscriptionExpired(boundAccount)) {
const errorMsg = `Dedicated account ${boundAccount.name} subscription has expired`
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}`
)
// 更新账户的最后使用时间
await this.updateAccountLastUsed(boundAccount.id, accountType)
return {
accountId: boundAccount.id,
accountType accountType
)
if (isTempUnavailable) {
logger.warn(
`⏱️ Bound ${accountType} account ${boundAccount.name} temporarily unavailable, falling back to pool`
)
// 不 throw让代码继续走到共享池选择
} else {
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 (!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
}
// ⏰ 检查 OpenAI-Responses 专属账户订阅是否过期
if (openaiResponsesAccountService.isSubscriptionExpired(boundAccount)) {
const errorMsg = `Dedicated account ${boundAccount.name} subscription has expired`
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}`
)
// 更新账户的最后使用时间
await this.updateAccountLastUsed(boundAccount.id, accountType)
return {
accountId: boundAccount.id,
accountType
}
} }
} else { } else {
// 专属账户不可用时直接报错,不降级到共享池 // 专属账户不可用时直接报错,不降级到共享池
@@ -370,6 +382,12 @@ class UnifiedOpenAIScheduler {
continue continue
} }
const isTempUnavailable = await upstreamErrorHelper.isTempUnavailable(accountId, 'openai')
if (isTempUnavailable) {
logger.debug(`⏭️ Skipping openai account ${account.name} - temporarily unavailable`)
continue
}
// 检查token是否过期并自动刷新 // 检查token是否过期并自动刷新
const isExpired = openaiAccountService.isTokenExpired(account) const isExpired = openaiAccountService.isTokenExpired(account)
if (isExpired) { if (isExpired) {
@@ -465,6 +483,17 @@ class UnifiedOpenAIScheduler {
} }
} }
const isTempUnavailable = await upstreamErrorHelper.isTempUnavailable(
account.id,
'openai-responses'
)
if (isTempUnavailable) {
logger.debug(
`⏭️ Skipping openai-responses account ${account.name} - temporarily unavailable`
)
continue
}
// ⏰ 检查订阅是否过期 // ⏰ 检查订阅是否过期
if (openaiResponsesAccountService.isSubscriptionExpired(account)) { if (openaiResponsesAccountService.isSubscriptionExpired(account)) {
logger.debug( logger.debug(
@@ -517,6 +546,15 @@ class UnifiedOpenAIScheduler {
return false return false
} }
const isTempUnavailable = await upstreamErrorHelper.isTempUnavailable(
accountId,
accountType
)
if (isTempUnavailable) {
logger.info(`⏱️ OpenAI account ${accountId} (${accountType}) is temporarily unavailable`)
return false
}
return true return true
} else if (accountType === 'openai-responses') { } else if (accountType === 'openai-responses') {
const account = await openaiResponsesAccountService.getAccount(accountId) const account = await openaiResponsesAccountService.getAccount(accountId)
@@ -541,7 +579,20 @@ class UnifiedOpenAIScheduler {
// 检查并清除过期的限流状态 // 检查并清除过期的限流状态
const isRateLimitCleared = const isRateLimitCleared =
await openaiResponsesAccountService.checkAndClearRateLimit(accountId) await openaiResponsesAccountService.checkAndClearRateLimit(accountId)
return !this._isRateLimited(account.rateLimitStatus) || isRateLimitCleared if (this._isRateLimited(account.rateLimitStatus) && !isRateLimitCleared) {
return false
}
const isTempUnavailable = await upstreamErrorHelper.isTempUnavailable(
accountId,
accountType
)
if (isTempUnavailable) {
logger.info(`⏱️ OpenAI account ${accountId} (${accountType}) is temporarily unavailable`)
return false
}
return true
} }
return false return false
} catch (error) { } catch (error) {
@@ -572,7 +623,7 @@ class UnifiedOpenAIScheduler {
const client = redis.getClientSafe() const client = redis.getClientSafe()
const mappingData = JSON.stringify({ accountId, accountType }) const mappingData = JSON.stringify({ accountId, accountType })
// 依据配置设置TTL小时 // 依据配置设置TTL小时
const appConfig = require('../../config/config') const appConfig = require('../../../config/config')
const ttlHours = appConfig.session?.stickyTtlHours || 1 const ttlHours = appConfig.session?.stickyTtlHours || 1
const ttlSeconds = Math.max(1, Math.floor(ttlHours * 60 * 60)) const ttlSeconds = Math.max(1, Math.floor(ttlHours * 60 * 60))
await client.setex(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`, ttlSeconds, mappingData) await client.setex(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`, ttlSeconds, mappingData)
@@ -598,7 +649,7 @@ class UnifiedOpenAIScheduler {
return true return true
} }
const appConfig = require('../../config/config') const appConfig = require('../../../config/config')
const ttlHours = appConfig.session?.stickyTtlHours || 1 const ttlHours = appConfig.session?.stickyTtlHours || 1
const renewalThresholdMinutes = appConfig.session?.renewalThresholdMinutes || 0 const renewalThresholdMinutes = appConfig.session?.renewalThresholdMinutes || 0
if (!renewalThresholdMinutes) { if (!renewalThresholdMinutes) {
@@ -849,6 +900,17 @@ class UnifiedOpenAIScheduler {
continue continue
} }
const isTempUnavailable = await upstreamErrorHelper.isTempUnavailable(
account.id,
accountType
)
if (isTempUnavailable) {
logger.debug(
`⏭️ Skipping group member ${accountType} account ${account.name} - temporarily unavailable`
)
continue
}
// 检查token是否过期仅对 OpenAI OAuth 账户检查) // 检查token是否过期仅对 OpenAI OAuth 账户检查)
if (accountType === 'openai') { if (accountType === 'openai') {
const isExpired = openaiAccountService.isTokenExpired(account) const isExpired = openaiAccountService.isTokenExpired(account)

View File

@@ -2,7 +2,7 @@ const redis = require('../models/redis')
const logger = require('../utils/logger') const logger = require('../utils/logger')
const pricingService = require('./pricingService') const pricingService = require('./pricingService')
const serviceRatesService = require('./serviceRatesService') const serviceRatesService = require('./serviceRatesService')
const { isClaudeFamilyModel } = require('../utils/modelHelper') const { isOpusModel } = require('../utils/modelHelper')
function pad2(n) { function pad2(n) {
return String(n).padStart(2, '0') return String(n).padStart(2, '0')
@@ -151,7 +151,7 @@ class WeeklyClaudeCostInitService {
} }
const keyId = match[1] const keyId = match[1]
const model = match[2] const model = match[2]
if (!isClaudeFamilyModel(model)) { if (!isOpusModel(model)) {
continue continue
} }
matchedClaudeKeys++ matchedClaudeKeys++

View File

@@ -7,13 +7,11 @@ const fs = require('fs')
const os = require('os') const os = require('os')
// 安全的 JSON 序列化函数,处理循环引用和特殊字符 // 安全的 JSON 序列化函数,处理循环引用和特殊字符
const safeStringify = (obj, maxDepth = 3, fullDepth = false) => { const safeStringify = (obj, maxDepth = Infinity) => {
const seen = new WeakSet() const seen = new WeakSet()
// 如果是fullDepth模式增加深度限制
const actualMaxDepth = fullDepth ? 10 : maxDepth
const replacer = (key, value, depth = 0) => { const replacer = (key, value, depth = 0) => {
if (depth > actualMaxDepth) { if (depth > maxDepth) {
return '[Max Depth Reached]' return '[Max Depth Reached]'
} }
@@ -21,18 +19,13 @@ const safeStringify = (obj, maxDepth = 3, fullDepth = false) => {
if (typeof value === 'string') { if (typeof value === 'string') {
try { try {
// 移除或转义可能导致JSON解析错误的字符 // 移除或转义可能导致JSON解析错误的字符
let cleanValue = value const cleanValue = value
// eslint-disable-next-line no-control-regex // eslint-disable-next-line no-control-regex
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '') // 移除控制字符 .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '') // 移除控制字符
.replace(/[\uD800-\uDFFF]/g, '') // 移除孤立的代理对字符 .replace(/[\uD800-\uDFFF]/g, '') // 移除孤立的代理对字符
// eslint-disable-next-line no-control-regex // eslint-disable-next-line no-control-regex
.replace(/\u0000/g, '') // 移除NUL字节 .replace(/\u0000/g, '') // 移除NUL字节
// 如果字符串过长,截断并添加省略号
if (cleanValue.length > 1000) {
cleanValue = `${cleanValue.substring(0, 997)}...`
}
return cleanValue return cleanValue
} catch (error) { } catch (error) {
return '[Invalid String Data]' return '[Invalid String Data]'
@@ -77,7 +70,37 @@ const safeStringify = (obj, maxDepth = 3, fullDepth = false) => {
try { try {
const processed = replacer('', obj) const processed = replacer('', obj)
return JSON.stringify(processed) const result = JSON.stringify(processed)
// 体积保护: 超过 50KB 时对大字段做截断,保留顶层结构
if (result.length > 50000 && processed && typeof processed === 'object') {
const truncated = { ...processed, _truncated: true, _totalChars: result.length }
// 第一轮: 截断单个大字段
for (const [k, v] of Object.entries(truncated)) {
if (k.startsWith('_')) {
continue
}
const fieldStr = typeof v === 'string' ? v : JSON.stringify(v)
if (fieldStr && fieldStr.length > 10000) {
truncated[k] = `${fieldStr.substring(0, 10000)}...[truncated]`
}
}
// 第二轮: 如果总长度仍超 50KB逐字段缩减到 2KB
let secondResult = JSON.stringify(truncated)
if (secondResult.length > 50000) {
for (const [k, v] of Object.entries(truncated)) {
if (k.startsWith('_')) {
continue
}
const fieldStr = typeof v === 'string' ? v : JSON.stringify(v)
if (fieldStr && fieldStr.length > 2000) {
truncated[k] = `${fieldStr.substring(0, 2000)}...[truncated]`
}
}
secondResult = JSON.stringify(truncated)
}
return secondResult
}
return result
} catch (error) { } catch (error) {
// 如果JSON.stringify仍然失败使用更保守的方法 // 如果JSON.stringify仍然失败使用更保守的方法
try { try {
@@ -93,50 +116,64 @@ const safeStringify = (obj, maxDepth = 3, fullDepth = false) => {
} }
} }
// 📝 增强的日志格式 // 控制台不显示的 metadata 字段(已在 message 中或低价值)
const createLogFormat = (colorize = false) => { const CONSOLE_SKIP_KEYS = new Set(['type', 'level', 'message', 'timestamp', 'stack'])
const formats = [
// 控制台格式: 树形展示 metadata
const createConsoleFormat = () =>
winston.format.combine(
winston.format.timestamp({ format: () => formatDateWithTimezone(new Date(), false) }), winston.format.timestamp({ format: () => formatDateWithTimezone(new Date(), false) }),
winston.format.errors({ stack: true }) winston.format.errors({ stack: true }),
// 移除 winston.format.metadata() 来避免自动包装 winston.format.colorize(),
] winston.format.printf(({ level: _level, message, timestamp, stack, ...rest }) => {
// 时间戳只取时分秒
const shortTime = timestamp ? timestamp.split(' ').pop() : ''
if (colorize) { let logMessage = `${shortTime} ${message}`
formats.push(winston.format.colorize())
}
formats.push( // 收集要显示的 metadata
winston.format.printf(({ level, message, timestamp, stack, ...rest }) => { const entries = Object.entries(rest).filter(([k]) => !CONSOLE_SKIP_KEYS.has(k))
const emoji = {
error: '❌', if (entries.length > 0) {
warn: '⚠️ ', const indent = ' '.repeat(shortTime.length + 1)
info: ' ', entries.forEach(([key, value], i) => {
debug: '🐛', const isLast = i === entries.length - 1
verbose: '📝' const branch = isLast ? '└─' : '├─'
const displayValue =
value !== null && typeof value === 'object' ? safeStringify(value) : String(value)
logMessage += `\n${indent}${branch} ${key}: ${displayValue}`
})
} }
let logMessage = `${emoji[level] || '📝'} [${timestamp}] ${level.toUpperCase()}: ${message}` if (stack) {
logMessage += `\n${stack}`
// 直接处理额外数据不需要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 logMessage
return stack ? `${logMessage}\n${stack}` : logMessage
}) })
) )
return winston.format.combine(...formats) // 文件格式: NDJSON完整结构化数据
} const createFileFormat = () =>
winston.format.combine(
winston.format.timestamp({ format: () => formatDateWithTimezone(new Date(), false) }),
winston.format.errors({ stack: true }),
winston.format.printf(({ level, message, timestamp, stack, ...rest }) => {
const entry = { ts: timestamp, lvl: level, msg: message }
// 合并所有 metadata
for (const [k, v] of Object.entries(rest)) {
if (k !== 'level' && k !== 'message' && k !== 'timestamp' && k !== 'stack') {
entry[k] = v
}
}
if (stack) {
entry.stack = stack
}
return safeStringify(entry)
})
)
const logFormat = createLogFormat(false) const fileFormat = createFileFormat()
const consoleFormat = createLogFormat(true) const consoleFormat = createConsoleFormat()
const isTestEnv = process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID const isTestEnv = process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID
// 📁 确保日志目录存在并设置权限 // 📁 确保日志目录存在并设置权限
@@ -153,7 +190,7 @@ const createRotateTransport = (filename, level = null) => {
maxSize: config.logging.maxSize, maxSize: config.logging.maxSize,
maxFiles: config.logging.maxFiles, maxFiles: config.logging.maxFiles,
auditFile: path.join(config.logging.dirname, `.${filename.replace('%DATE%', 'audit')}.json`), auditFile: path.join(config.logging.dirname, `.${filename.replace('%DATE%', 'audit')}.json`),
format: logFormat format: fileFormat
}) })
if (level) { if (level) {
@@ -184,7 +221,7 @@ const errorFileTransport = createRotateTransport('claude-relay-error-%DATE%.log'
// 🔒 创建专门的安全日志记录器 // 🔒 创建专门的安全日志记录器
const securityLogger = winston.createLogger({ const securityLogger = winston.createLogger({
level: 'warn', level: 'warn',
format: logFormat, format: fileFormat,
transports: [createRotateTransport('claude-relay-security-%DATE%.log', 'warn')], transports: [createRotateTransport('claude-relay-security-%DATE%.log', 'warn')],
silent: false silent: false
}) })
@@ -207,7 +244,7 @@ const authDetailLogger = winston.createLogger({
// 🌟 增强的 Winston logger // 🌟 增强的 Winston logger
const logger = winston.createLogger({ const logger = winston.createLogger({
level: process.env.LOG_LEVEL || config.logging.level, level: process.env.LOG_LEVEL || config.logging.level,
format: logFormat, format: fileFormat,
transports: [ transports: [
// 📄 文件输出 // 📄 文件输出
dailyRotateFileTransport, dailyRotateFileTransport,
@@ -225,7 +262,7 @@ const logger = winston.createLogger({
exceptionHandlers: [ exceptionHandlers: [
new winston.transports.File({ new winston.transports.File({
filename: path.join(config.logging.dirname, 'exceptions.log'), filename: path.join(config.logging.dirname, 'exceptions.log'),
format: logFormat, format: fileFormat,
maxsize: 10485760, // 10MB maxsize: 10485760, // 10MB
maxFiles: 5 maxFiles: 5
}), }),
@@ -238,7 +275,7 @@ const logger = winston.createLogger({
rejectionHandlers: [ rejectionHandlers: [
new winston.transports.File({ new winston.transports.File({
filename: path.join(config.logging.dirname, 'rejections.log'), filename: path.join(config.logging.dirname, 'rejections.log'),
format: logFormat, format: fileFormat,
maxsize: 10485760, // 10MB maxsize: 10485760, // 10MB
maxFiles: 5 maxFiles: 5
}), }),

View File

@@ -188,6 +188,22 @@ function isOpus45OrNewer(modelName) {
return false return false
} }
/**
* 判断是否为 Opus 模型(任意版本)
* 匹配所有包含 "opus" 关键词的 Claude 模型
*/
function isOpusModel(modelName) {
if (!modelName || typeof modelName !== 'string') {
return false
}
const { baseModel } = parseVendorPrefixedModel(modelName)
const m = (baseModel || '').trim().toLowerCase()
if (!m) {
return false
}
return m.includes('opus')
}
/** /**
* 判断某个 model 名称是否属于 Anthropic Claude 系列模型。 * 判断某个 model 名称是否属于 Anthropic Claude 系列模型。
* *
@@ -237,5 +253,6 @@ module.exports = {
getEffectiveModel, getEffectiveModel,
getVendorType, getVendorType,
isOpus45OrNewer, isOpus45OrNewer,
isOpusModel,
isClaudeFamilyModel isClaudeFamilyModel
} }

View File

@@ -1,4 +1,11 @@
const crypto = require('crypto') const crypto = require('crypto')
const { mapToErrorCode } = require('./errorSanitizer')
// 将原始错误信息映射为安全的标准错误码消息
const sanitizeErrorMsg = (msg) => {
const mapped = mapToErrorCode({ message: msg }, { logOriginal: false })
return `[${mapped.code}] ${mapped.message}`
}
/** /**
* 生成随机十六进制字符串 * 生成随机十六进制字符串
@@ -92,7 +99,8 @@ async function sendStreamTestRequest(options) {
payload = createClaudeTestPayload('claude-sonnet-4-5-20250929', { stream: true }), payload = createClaudeTestPayload('claude-sonnet-4-5-20250929', { stream: true }),
proxyAgent = null, proxyAgent = null,
timeout = 30000, timeout = 30000,
extraHeaders = {} extraHeaders = {},
sanitize = false
} = options } = options
const sendSSE = (type, data = {}) => { const sendSSE = (type, data = {}) => {
@@ -166,17 +174,17 @@ async function sendStreamTestRequest(options) {
let errorMsg = `API Error: ${response.status}` let errorMsg = `API Error: ${response.status}`
try { try {
const json = JSON.parse(errorData) const json = JSON.parse(errorData)
errorMsg = json.message || json.error?.message || json.error || errorMsg errorMsg = extractErrorMessage(json, errorMsg)
} catch { } catch {
if (errorData.length < 200) { if (errorData.length < 200) {
errorMsg = errorData || errorMsg errorMsg = errorData || errorMsg
} }
} }
endTest(false, errorMsg) endTest(false, sanitize ? sanitizeErrorMsg(errorMsg) : errorMsg)
resolve() resolve()
}) })
response.data.on('error', (err) => { response.data.on('error', (err) => {
endTest(false, err.message) endTest(false, sanitize ? sanitizeErrorMsg(err.message) : err.message)
resolve() resolve()
}) })
}) })
@@ -270,7 +278,7 @@ function createGeminiTestPayload(_model = 'gemini-2.5-pro', options = {}) {
* @returns {object} 测试请求体 * @returns {object} 测试请求体
*/ */
function createOpenAITestPayload(model = 'gpt-5', options = {}) { function createOpenAITestPayload(model = 'gpt-5', options = {}) {
const { prompt = 'hi', maxTokens = 100 } = options const { prompt = 'hi', maxTokens = 100, stream = true } = options
return { return {
model, model,
input: [ input: [
@@ -280,15 +288,77 @@ function createOpenAITestPayload(model = 'gpt-5', options = {}) {
} }
], ],
max_output_tokens: maxTokens, max_output_tokens: maxTokens,
stream: true stream
} }
} }
/**
* 生成 Chat Completions 测试请求体(用于 Azure OpenAI 等 Chat Completions 端点)
* @param {string} model - 模型名称
* @param {object} options - 可选配置
* @param {string} options.prompt - 自定义提示词(默认 'hi'
* @param {number} options.maxTokens - 最大输出 token默认 100
* @returns {object} 测试请求体
*/
function createChatCompletionsTestPayload(model = 'gpt-4o-mini', options = {}) {
const { prompt = 'hi', maxTokens = 100 } = options
return {
model,
messages: [
{
role: 'user',
content: prompt
}
],
max_tokens: maxTokens
}
}
/**
* 从各种格式的错误响应中提取可读错误信息
* 支持格式: {message}, {error:{message}}, {msg:{error:{message}}}, {error:"string"} 等
* @param {object} json - 解析后的 JSON 错误响应
* @param {string} fallback - 提取失败时的回退信息
* @returns {string} 错误信息
*/
function extractErrorMessage(json, fallback) {
if (!json || typeof json !== 'object') {
return fallback
}
// 直接 message
if (json.message && typeof json.message === 'string') {
return json.message
}
// {error: {message: "..."}}
if (json.error?.message) {
return json.error.message
}
// {msg: {error: {message: "..."}}} (relay 包装格式)
if (json.msg?.error?.message) {
return json.msg.error.message
}
if (json.msg?.message) {
return json.msg.message
}
// {error: "string"}
if (typeof json.error === 'string') {
return json.error
}
// {msg: "string"}
if (typeof json.msg === 'string') {
return json.msg
}
return fallback
}
module.exports = { module.exports = {
randomHex, randomHex,
generateSessionString, generateSessionString,
createClaudeTestPayload, createClaudeTestPayload,
createGeminiTestPayload, createGeminiTestPayload,
createOpenAITestPayload, createOpenAITestPayload,
createChatCompletionsTestPayload,
extractErrorMessage,
sanitizeErrorMsg,
sendStreamTestRequest sendStreamTestRequest
} }

View File

@@ -0,0 +1,255 @@
const logger = require('./logger')
const TEMP_UNAVAILABLE_PREFIX = 'temp_unavailable'
// 默认 TTL
const DEFAULT_TTL = {
server_error: 300, // 5xx: 5分钟
overload: 600, // 529: 10分钟
auth_error: 1800, // 401/403: 30分钟
timeout: 300, // 504/网络超时: 5分钟
rate_limit: 300 // 429: 5分钟优先使用响应头解析值
}
// 延迟加载配置,避免循环依赖
let _configCache = null
const getConfig = () => {
if (!_configCache) {
try {
_configCache = require('../../config/config')
} catch {
_configCache = {}
}
}
return _configCache
}
const getTtlConfig = () => {
const config = getConfig()
return {
server_error: config.upstreamError?.serverErrorTtlSeconds ?? DEFAULT_TTL.server_error,
overload: config.upstreamError?.overloadTtlSeconds ?? DEFAULT_TTL.overload,
auth_error: config.upstreamError?.authErrorTtlSeconds ?? DEFAULT_TTL.auth_error,
timeout: config.upstreamError?.timeoutTtlSeconds ?? DEFAULT_TTL.timeout,
rate_limit: DEFAULT_TTL.rate_limit
}
}
// 延迟加载 redis避免循环依赖
let _redis = null
const getRedis = () => {
if (!_redis) {
_redis = require('../models/redis')
}
return _redis
}
// 根据 HTTP 状态码分类错误类型
const classifyError = (statusCode) => {
if (statusCode === 529) {
return 'overload'
}
if (statusCode === 504) {
return 'timeout'
}
if (statusCode === 401 || statusCode === 403) {
return 'auth_error'
}
if (statusCode === 429) {
return 'rate_limit'
}
if (statusCode >= 500) {
return 'server_error'
}
return null
}
// 解析 429 响应头中的重置时间(返回秒数)
const parseRetryAfter = (headers) => {
if (!headers) {
return null
}
// 标准 Retry-After 头(秒数或 HTTP 日期)
const retryAfter = headers['retry-after']
if (retryAfter) {
const seconds = parseInt(retryAfter, 10)
if (!isNaN(seconds) && seconds > 0) {
return seconds
}
const date = new Date(retryAfter)
if (!isNaN(date.getTime())) {
const diff = Math.ceil((date.getTime() - Date.now()) / 1000)
if (diff > 0) {
return diff
}
}
}
// Anthropic 限流重置头ISO 时间)
const anthropicReset = headers['anthropic-ratelimit-unified-reset']
if (anthropicReset) {
const date = new Date(anthropicReset)
if (!isNaN(date.getTime())) {
const diff = Math.ceil((date.getTime() - Date.now()) / 1000)
if (diff > 0) {
return diff
}
}
}
// OpenAI/Codex 限流重置头
const xReset = headers['x-ratelimit-reset-requests'] || headers['x-codex-ratelimit-reset']
if (xReset) {
const seconds = parseInt(xReset, 10)
if (!isNaN(seconds) && seconds > 0) {
return seconds
}
}
return null
}
// 标记账户为临时不可用
const markTempUnavailable = async (accountId, accountType, statusCode, customTtl = null) => {
try {
const errorType = classifyError(statusCode)
if (!errorType) {
return { success: false, reason: 'not_a_pausable_error' }
}
const ttlConfig = getTtlConfig()
const ttlSeconds = customTtl ?? ttlConfig[errorType]
const redis = getRedis()
const client = redis.getClientSafe()
const key = `${TEMP_UNAVAILABLE_PREFIX}:${accountType}:${accountId}`
await client.setex(
key,
ttlSeconds,
JSON.stringify({
statusCode,
errorType,
markedAt: new Date().toISOString()
})
)
logger.warn(
`⏱️ [UpstreamError] Account ${accountId} (${accountType}) marked temporarily unavailable for ${ttlSeconds}s (${statusCode} ${errorType})`
)
return { success: true, ttlSeconds, errorType }
} catch (error) {
logger.error(
`❌ [UpstreamError] Failed to mark account ${accountId} temporarily unavailable:`,
error
)
return { success: false }
}
}
// 检查账户是否临时不可用
const isTempUnavailable = async (accountId, accountType) => {
try {
const redis = getRedis()
const client = redis.getClientSafe()
const key = `${TEMP_UNAVAILABLE_PREFIX}:${accountType}:${accountId}`
return (await client.exists(key)) === 1
} catch (error) {
logger.error(
`❌ [UpstreamError] Failed to check temp unavailable status for ${accountId}:`,
error
)
return false
}
}
// 清除临时不可用状态
const clearTempUnavailable = async (accountId, accountType) => {
try {
const redis = getRedis()
const client = redis.getClientSafe()
const key = `${TEMP_UNAVAILABLE_PREFIX}:${accountType}:${accountId}`
await client.del(key)
} catch (error) {
logger.error(`❌ [UpstreamError] Failed to clear temp unavailable for ${accountId}:`, error)
}
}
// 批量查询所有临时不可用状态(用于前端展示)
const getAllTempUnavailable = async () => {
try {
const redis = getRedis()
const client = redis.getClientSafe()
const pattern = `${TEMP_UNAVAILABLE_PREFIX}:*`
const keys = await client.keys(pattern)
if (!keys.length) {
return {}
}
const pipeline = client.pipeline()
for (const key of keys) {
pipeline.get(key)
pipeline.ttl(key)
}
const results = await pipeline.exec()
const statuses = {}
for (let i = 0; i < keys.length; i++) {
const key = keys[i]
// key format: temp_unavailable:{accountType}:{accountId}
const parts = key.split(':')
const accountType = parts[1]
const accountId = parts.slice(2).join(':')
const [getErr, value] = results[i * 2]
const [ttlErr, ttl] = results[i * 2 + 1]
if (getErr || ttlErr || !value) {
continue
}
try {
const data = JSON.parse(value)
const compositeKey = `${accountType}:${accountId}`
statuses[compositeKey] = {
accountId,
accountType,
statusCode: data.statusCode,
errorType: data.errorType,
markedAt: data.markedAt,
ttl: ttl > 0 ? ttl : 0
}
} catch {
// ignore parse errors
}
}
return statuses
} catch (error) {
logger.error('❌ [UpstreamError] Failed to get all temp unavailable statuses:', error)
return {}
}
}
// 清洗上游错误数据,去除内部路由标识(如 [codex/codex]
const sanitizeErrorForClient = (errorData) => {
if (!errorData || typeof errorData !== 'object') {
return errorData
}
try {
const str = JSON.stringify(errorData)
const cleaned = str.replace(/ \[[^\]/]+\/[^\]]+\]/g, '')
return JSON.parse(cleaned)
} catch {
return errorData
}
}
module.exports = {
markTempUnavailable,
isTempUnavailable,
clearTempUnavailable,
getAllTempUnavailable,
classifyError,
parseRetryAfter,
sanitizeErrorForClient,
TEMP_UNAVAILABLE_PREFIX
}

View File

@@ -6,7 +6,7 @@ jest.mock('../src/utils/logger', () => ({
error: jest.fn() error: jest.fn()
})) }))
const accountBalanceServiceModule = require('../src/services/accountBalanceService') const accountBalanceServiceModule = require('../src/services/account/accountBalanceService')
const { AccountBalanceService } = accountBalanceServiceModule const { AccountBalanceService } = accountBalanceServiceModule

View File

@@ -1614,7 +1614,7 @@
</div> </div>
<!-- 上游错误处理 --> <!-- 上游错误处理 -->
<div v-if="form.platform === 'claude-console'"> <div v-if="autoProtectionPlatforms.includes(form.platform)">
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" <label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>上游错误处理</label >上游错误处理</label
> >
@@ -1714,24 +1714,26 @@
{{ errors.baseUrl }} {{ errors.baseUrl }}
</p> </p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
填写 API 基础地址,必须以 支持三种格式,系统自动识别:
<code class="rounded bg-gray-100 px-1 dark:bg-gray-600">/models</code> </p>
结尾。系统会自动拼接 <p class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
以 /models 结尾:
<code class="rounded bg-gray-100 px-1 dark:bg-gray-600" <code class="rounded bg-gray-100 px-1 dark:bg-gray-600"
>/{model}:generateContent</code >https://proxy.com/v1beta/models</code
> >
</p> </p>
<p class="mt-0.5 text-xs text-gray-400 dark:text-gray-500"> <p class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
官方: 模板模式:
<code class="rounded bg-gray-100 px-1 dark:bg-gray-600" <code class="rounded bg-gray-100 px-1 dark:bg-gray-600"
>https://generativelanguage.googleapis.com/v1beta/models</code >https://proxy.com/api/{model}:{action}</code
> >
</p> </p>
<p class="mt-0.5 text-xs text-gray-400 dark:text-gray-500"> <p class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
上游为 CRS: 域名:
<code class="rounded bg-gray-100 px-1 dark:bg-gray-600" <code class="rounded bg-gray-100 px-1 dark:bg-gray-600"
>https://your-crs.com/gemini/v1beta/models</code >https://generativelanguage.googleapis.com</code
> >
(自动拼接 /v1beta/models)
</p> </p>
</div> </div>
@@ -3371,26 +3373,24 @@
<p class="mt-1 text-xs text-gray-500">账号被限流后暂停调度的时间(分钟)</p> <p class="mt-1 text-xs text-gray-500">账号被限流后暂停调度的时间(分钟)</p>
</div> </div>
</div> </div>
</div>
<!-- 上游错误处理(编辑模式)--> <!-- 上游错误处理(编辑模式)-->
<div v-if="form.platform === 'claude-console'"> <div v-if="autoProtectionPlatforms.includes(form.platform)">
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"> <label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">
上游错误处理 上游错误处理
</label> </label>
<label class="inline-flex cursor-pointer items-center"> <label class="inline-flex cursor-pointer items-center">
<input <input
v-model="form.disableAutoProtection" v-model="form.disableAutoProtection"
class="mr-2 rounded border-gray-300 text-blue-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-gray-600 dark:bg-gray-700" class="mr-2 rounded border-gray-300 text-blue-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-gray-600 dark:bg-gray-700"
type="checkbox" type="checkbox"
/> />
<span class="text-sm text-gray-700 dark:text-gray-300"> <span class="text-sm text-gray-700 dark:text-gray-300"> 上游错误不自动暂停调度 </span>
上游错误不自动暂停调度 </label>
</span> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
</label> 勾选后遇到 401/400/429/529 等上游错误仅记录日志并透传,不自动禁用或限流
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> </p>
勾选后遇到 401/400/429/529 等上游错误仅记录日志并透传,不自动禁用或限流
</p>
</div>
</div> </div>
<!-- OpenAI-Responses 特定字段(编辑模式)--> <!-- OpenAI-Responses 特定字段(编辑模式)-->
@@ -3505,24 +3505,26 @@
{{ errors.baseUrl }} {{ errors.baseUrl }}
</p> </p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
填写 API 基础地址,必须以 支持三种格式,系统自动识别:
<code class="rounded bg-gray-100 px-1 dark:bg-gray-600">/models</code> </p>
结尾。系统会自动拼接 <p class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
以 /models 结尾:
<code class="rounded bg-gray-100 px-1 dark:bg-gray-600" <code class="rounded bg-gray-100 px-1 dark:bg-gray-600"
>/{model}:generateContent</code >https://proxy.com/v1beta/models</code
> >
</p> </p>
<p class="mt-0.5 text-xs text-gray-400 dark:text-gray-500"> <p class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
官方: 模板模式:
<code class="rounded bg-gray-100 px-1 dark:bg-gray-600" <code class="rounded bg-gray-100 px-1 dark:bg-gray-600"
>https://generativelanguage.googleapis.com/v1beta/models</code >https://proxy.com/api/{model}:{action}</code
> >
</p> </p>
<p class="mt-0.5 text-xs text-gray-400 dark:text-gray-500"> <p class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
上游为 CRS: 域名:
<code class="rounded bg-gray-100 px-1 dark:bg-gray-600" <code class="rounded bg-gray-100 px-1 dark:bg-gray-600"
>https://your-crs.com/gemini/v1beta/models</code >https://generativelanguage.googleapis.com</code
> >
(自动拼接 /v1beta/models)
</p> </p>
</div> </div>
@@ -4039,6 +4041,20 @@ const handleCancel = () => {
const isEdit = computed(() => !!props.account) const isEdit = computed(() => !!props.account)
const show = ref(true) const show = ref(true)
// 支持 disableAutoProtection 的平台白名单
const autoProtectionPlatforms = [
'claude-console',
'ccr',
'droid',
'bedrock',
'azure-openai',
'azure_openai',
'gemini',
'gemini-api',
'openai',
'openai-responses'
]
// OAuthFlow 组件引用 // OAuthFlow 组件引用
const oauthFlowRef = ref(null) const oauthFlowRef = ref(null)
@@ -4274,7 +4290,9 @@ const form = ref({
})(), })(),
userAgent: props.account?.userAgent || '', userAgent: props.account?.userAgent || '',
enableRateLimit: props.account ? props.account.rateLimitDuration > 0 : true, enableRateLimit: props.account ? props.account.rateLimitDuration > 0 : true,
disableAutoProtection: props.account?.disableAutoProtection === true, disableAutoProtection:
props.account?.disableAutoProtection === true ||
props.account?.disableAutoProtection === 'true',
// 额度管理字段 // 额度管理字段
dailyQuota: props.account?.dailyQuota || 0, dailyQuota: props.account?.dailyQuota || 0,
dailyUsage: props.account?.dailyUsage || 0, dailyUsage: props.account?.dailyUsage || 0,
@@ -5242,13 +5260,9 @@ const createAccount = async () => {
errors.value.apiKey = '请填写 API Key' errors.value.apiKey = '请填写 API Key'
hasError = true hasError = true
} }
// 验证 baseUrl 必须以 /models 结尾
if (!form.value.baseUrl || form.value.baseUrl.trim() === '') { if (!form.value.baseUrl || form.value.baseUrl.trim() === '') {
errors.value.baseUrl = '请填写 API 基础地址' errors.value.baseUrl = '请填写 API 基础地址'
hasError = true hasError = true
} else if (!form.value.baseUrl.trim().endsWith('/models')) {
errors.value.baseUrl = 'API 基础地址必须以 /models 结尾'
hasError = true
} }
} else { } else {
// 其他平台(如 Droid使用多 API Key 输入 // 其他平台(如 Droid使用多 API Key 输入
@@ -5407,9 +5421,7 @@ const createAccount = async () => {
data.userAgent = form.value.userAgent || null data.userAgent = form.value.userAgent || null
// 如果不启用限流,传递 0 表示不限流 // 如果不启用限流,传递 0 表示不限流
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0 data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
// 上游错误处理(仅 Claude Console
if (form.value.platform === 'claude-console') { if (form.value.platform === 'claude-console') {
data.disableAutoProtection = !!form.value.disableAutoProtection
data.interceptWarmup = !!form.value.interceptWarmup data.interceptWarmup = !!form.value.interceptWarmup
} }
// 额度管理字段 // 额度管理字段
@@ -5474,6 +5486,11 @@ const createAccount = async () => {
data.schedulable = form.value.schedulable !== false data.schedulable = form.value.schedulable !== false
} }
// 支持 disableAutoProtection 的平台才写入
if (autoProtectionPlatforms.includes(form.value.platform)) {
data.disableAutoProtection = !!form.value.disableAutoProtection
}
let result let result
if (form.value.platform === 'claude') { if (form.value.platform === 'claude') {
result = await accountsStore.createClaudeAccount(data) result = await accountsStore.createClaudeAccount(data)
@@ -5540,17 +5557,13 @@ const updateAccount = async () => {
return return
} }
// Gemini API 的 baseUrl 验证(必须以 /models 结尾) // Gemini API 的 baseUrl 验证
if (form.value.platform === 'gemini-api') { if (form.value.platform === 'gemini-api') {
const baseUrl = form.value.baseUrl?.trim() || '' const baseUrl = form.value.baseUrl?.trim() || ''
if (!baseUrl) { if (!baseUrl) {
errors.value.baseUrl = '请填写 API 基础地址' errors.value.baseUrl = '请填写 API 基础地址'
return return
} }
if (!baseUrl.endsWith('/models')) {
errors.value.baseUrl = 'API 基础地址必须以 /models 结尾'
return
}
} }
// 分组类型验证 - 更新账户流程修复 // 分组类型验证 - 更新账户流程修复
@@ -5755,8 +5768,6 @@ const updateAccount = async () => {
data.userAgent = form.value.userAgent || null data.userAgent = form.value.userAgent || null
// 如果不启用限流,传递 0 表示不限流 // 如果不启用限流,传递 0 表示不限流
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0 data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
// 上游错误处理
data.disableAutoProtection = !!form.value.disableAutoProtection
// 拦截预热请求 // 拦截预热请求
data.interceptWarmup = !!form.value.interceptWarmup data.interceptWarmup = !!form.value.interceptWarmup
// 额度管理字段 // 额度管理字段
@@ -5847,6 +5858,11 @@ const updateAccount = async () => {
: [] : []
} }
// 支持 disableAutoProtection 的平台才写入
if (autoProtectionPlatforms.includes(props.account.platform)) {
data.disableAutoProtection = !!form.value.disableAutoProtection
}
if (props.account.platform === 'claude') { if (props.account.platform === 'claude') {
await accountsStore.updateClaudeAccount(props.account.id, data) await accountsStore.updateClaudeAccount(props.account.id, data)
} else if (props.account.platform === 'claude-console') { } else if (props.account.platform === 'claude-console') {
@@ -6399,7 +6415,8 @@ watch(
// 并发控制字段 // 并发控制字段
maxConcurrentTasks: newAccount.maxConcurrentTasks || 0, maxConcurrentTasks: newAccount.maxConcurrentTasks || 0,
// 上游错误处理 // 上游错误处理
disableAutoProtection: newAccount.disableAutoProtection === true disableAutoProtection:
newAccount.disableAutoProtection === true || newAccount.disableAutoProtection === 'true'
} }
// 如果是Claude Console账户加载实时使用情况 // 如果是Claude Console账户加载实时使用情况

View File

@@ -111,30 +111,12 @@
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
测试模型 测试模型
</label> </label>
<input <ModelSelector
v-model="config.model" v-model="config.model"
class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 placeholder-gray-400 transition focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:placeholder-gray-500"
:disabled="!config.enabled" :disabled="!config.enabled"
placeholder="claude-sonnet-4-5-20250929" :models="modelOptions"
type="text" placeholder="输入模型 ID..."
/> />
<div class="mt-2 flex flex-wrap gap-2">
<button
v-for="modelOption in modelOptions"
:key="modelOption.value"
:class="[
'rounded-lg border px-3 py-1.5 text-xs font-medium transition',
config.model === modelOption.value
? 'border-blue-500 bg-blue-50 text-blue-700 dark:border-blue-400 dark:bg-blue-900/30 dark:text-blue-300'
: 'border-gray-200 bg-gray-50 text-gray-600 hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700',
!config.enabled && 'cursor-not-allowed opacity-50'
]"
:disabled="!config.enabled"
@click="config.model = modelOption.value"
>
{{ modelOption.label }}
</button>
</div>
</div> </div>
<!-- 测试历史 --> <!-- 测试历史 -->
@@ -219,9 +201,11 @@
</template> </template>
<script setup> <script setup>
import { ref, watch } from 'vue' import { ref, watch, onMounted } from 'vue'
import { APP_CONFIG } from '@/utils/tools' import { APP_CONFIG } from '@/utils/tools'
import { showToast } from '@/utils/tools' import { showToast } from '@/utils/tools'
import { getModelsApi } from '@/utils/http_apis'
import ModelSelector from '@/components/common/ModelSelector.vue'
const props = defineProps({ const props = defineProps({
show: { show: {
@@ -256,12 +240,18 @@ const cronPresets = [
{ label: '工作日 9:00', value: '0 9 * * 1-5' } { label: '工作日 9:00', value: '0 9 * * 1-5' }
] ]
// 模型选项 // 模型选项(从 API 动态获取)
const modelOptions = [ const modelOptions = ref([])
{ label: 'Claude Sonnet 4.5', value: 'claude-sonnet-4-5-20250929' },
{ label: 'Claude Haiku 4.5', value: 'claude-haiku-4-5-20251001' }, const loadModels = async () => {
{ label: 'Claude Opus 4.5', value: 'claude-opus-4-5-20251101' } const result = await getModelsApi()
] if (result.success && result.data) {
const platform = props.account?.platform
modelOptions.value = result.data.platforms?.[platform] || result.data.claude || []
}
}
onMounted(loadModels)
// 格式化时间戳 // 格式化时间戳
function formatTimestamp(timestamp) { function formatTimestamp(timestamp) {

View File

@@ -1,654 +0,0 @@
<template>
<Teleport to="body">
<div
v-if="show"
class="fixed inset-0 z-[1050] flex items-center justify-center bg-gray-900/40 backdrop-blur-sm"
>
<div class="absolute inset-0" @click="handleClose" />
<div
class="relative z-10 mx-3 flex w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-gray-200/70 bg-white/95 shadow-2xl ring-1 ring-black/5 transition-all dark:border-gray-700/60 dark:bg-gray-900/95 dark:ring-white/10 sm:mx-4"
>
<!-- 顶部栏 -->
<div
class="flex items-center justify-between border-b border-gray-100 bg-white/80 px-5 py-4 backdrop-blur dark:border-gray-800 dark:bg-gray-900/80"
>
<div class="flex items-center gap-3">
<div
:class="[
'flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl text-white shadow-lg',
testStatus === 'success'
? 'bg-gradient-to-br from-green-500 to-emerald-500'
: testStatus === 'error'
? 'bg-gradient-to-br from-red-500 to-pink-500'
: 'bg-gradient-to-br from-blue-500 to-indigo-500'
]"
>
<i
:class="[
'fas',
testStatus === 'idle'
? 'fa-vial'
: testStatus === 'testing'
? 'fa-spinner fa-spin'
: testStatus === 'success'
? 'fa-check'
: 'fa-times'
]"
/>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">账户连通性测试</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ account?.name || '未知账户' }}
</p>
</div>
</div>
<button
class="flex h-9 w-9 items-center justify-center rounded-full bg-gray-100 text-gray-500 transition hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200"
:disabled="testStatus === 'testing'"
@click="handleClose"
>
<i class="fas fa-times text-sm" />
</button>
</div>
<!-- 内容区域 -->
<div class="px-5 py-4">
<!-- 测试信息 -->
<div class="mb-4 space-y-2">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-500 dark:text-gray-400">平台类型</span>
<span
:class="[
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium',
platformBadgeClass
]"
>
<i :class="platformIcon" />
{{ platformLabel }}
</span>
</div>
<!-- Bedrock 账号类型 -->
<div
v-if="props.account?.platform === 'bedrock'"
class="flex items-center justify-between text-sm"
>
<span class="text-gray-500 dark:text-gray-400">账号类型</span>
<span
:class="[
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium',
credentialTypeBadgeClass
]"
>
<i :class="credentialTypeIcon" />
{{ credentialTypeLabel }}
</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-gray-500 dark:text-gray-400">测试模型</span>
<select
v-model="selectedModel"
class="rounded-lg border border-gray-200 bg-white px-2 py-1 text-xs font-medium text-gray-700 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"
:disabled="testStatus === 'testing'"
>
<option v-for="m in availableModels" :key="m" :value="m">{{ m }}</option>
</select>
</div>
</div>
<!-- 状态指示 -->
<div :class="['mb-4 rounded-xl border p-4 transition-all duration-300', statusCardClass]">
<div class="flex items-center gap-3">
<div
:class="['flex h-8 w-8 items-center justify-center rounded-lg', statusIconBgClass]"
>
<i :class="['fas text-sm', statusIcon, statusIconClass]" />
</div>
<div>
<p :class="['font-medium', statusTextClass]">{{ statusTitle }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ statusDescription }}</p>
</div>
</div>
</div>
<!-- 响应内容区域 -->
<div
v-if="testStatus !== 'idle'"
class="mb-4 overflow-hidden rounded-xl border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50"
>
<div
class="flex items-center justify-between border-b border-gray-200 bg-gray-100 px-3 py-2 dark:border-gray-700 dark:bg-gray-800"
>
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">AI 响应</span>
<span v-if="responseText" class="text-xs text-gray-500 dark:text-gray-500">
{{ responseText.length }} 字符
</span>
</div>
<div class="max-h-40 overflow-y-auto p-3">
<p
v-if="responseText"
class="whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-300"
>
{{ responseText }}
<span
v-if="testStatus === 'testing'"
class="inline-block h-4 w-1 animate-pulse bg-blue-500"
/>
</p>
<p
v-else-if="testStatus === 'testing'"
class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400"
>
<i class="fas fa-circle-notch fa-spin" />
等待响应中...
</p>
<p
v-else-if="testStatus === 'error' && errorMessage"
class="text-sm text-red-600 dark:text-red-400"
>
{{ errorMessage }}
</p>
</div>
</div>
<!-- 测试时间 -->
<div
v-if="testDuration > 0"
class="mb-4 flex items-center justify-center gap-2 text-xs text-gray-500 dark:text-gray-400"
>
<i class="fas fa-clock" />
<span>耗时 {{ (testDuration / 1000).toFixed(2) }} </span>
</div>
</div>
<!-- 底部操作栏 -->
<div
class="flex items-center justify-end gap-3 border-t border-gray-100 bg-gray-50/80 px-5 py-3 dark:border-gray-800 dark:bg-gray-900/50"
>
<button
class="rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 hover:shadow dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
:disabled="testStatus === 'testing'"
@click="handleClose"
>
关闭
</button>
<button
:class="[
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium shadow-sm transition',
testStatus === 'testing'
? 'cursor-not-allowed bg-gray-200 text-gray-400 dark:bg-gray-700 dark:text-gray-500'
: 'bg-gradient-to-r from-blue-500 to-indigo-500 text-white hover:from-blue-600 hover:to-indigo-600 hover:shadow-md'
]"
:disabled="testStatus === 'testing'"
@click="startTest"
>
<i :class="['fas', testStatus === 'testing' ? 'fa-spinner fa-spin' : 'fa-play']" />
{{
testStatus === 'testing'
? '测试中...'
: testStatus === 'idle'
? '开始测试'
: '重新测试'
}}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { ref, computed, watch, onUnmounted } from 'vue'
import { APP_CONFIG } from '@/utils/tools'
const props = defineProps({
show: {
type: Boolean,
default: false
},
account: {
type: Object,
default: null
}
})
const emit = defineEmits(['close'])
// 状态
const testStatus = ref('idle') // idle, testing, success, error
const responseText = ref('')
const errorMessage = ref('')
const testDuration = ref(0)
const testStartTime = ref(null)
const eventSource = ref(null)
const selectedModel = ref('')
// 可用模型列表 - 根据账户类型
const availableModels = computed(() => {
if (!props.account) return []
const platform = props.account.platform
const modelLists = {
claude: ['claude-sonnet-4-5-20250929', 'claude-sonnet-4-20250514', 'claude-3-5-haiku-20241022'],
'claude-console': [
'claude-sonnet-4-5-20250929',
'claude-sonnet-4-20250514',
'claude-3-5-haiku-20241022'
],
bedrock: [
'claude-sonnet-4-5-20250929',
'claude-sonnet-4-20250514',
'claude-3-5-haiku-20241022'
],
gemini: ['gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-2.0-flash'],
'openai-responses': ['gpt-4o-mini', 'gpt-4o', 'o3-mini'],
'azure-openai': [props.account.deploymentName || 'gpt-4o-mini'],
droid: ['claude-sonnet-4-20250514', 'claude-3-5-haiku-20241022'],
ccr: ['claude-sonnet-4-20250514', 'claude-3-5-haiku-20241022']
}
return modelLists[platform] || []
})
// 默认测试模型
const defaultModel = computed(() => {
if (!props.account) return ''
const platform = props.account.platform
const models = {
claude: 'claude-sonnet-4-5-20250929',
'claude-console': 'claude-sonnet-4-5-20250929',
bedrock: 'claude-sonnet-4-5-20250929',
gemini: 'gemini-2.5-flash',
'openai-responses': 'gpt-4o-mini',
'azure-openai': props.account.deploymentName || 'gpt-4o-mini',
droid: 'claude-sonnet-4-20250514',
ccr: 'claude-sonnet-4-20250514'
}
return models[platform] || ''
})
// 监听账户变化,重置选中的模型
watch(
() => props.account,
() => {
selectedModel.value = defaultModel.value
},
{ immediate: true }
)
// 是否使用 SSE 流式响应
const useSSE = computed(() => {
if (!props.account) return false
return ['claude', 'claude-console'].includes(props.account.platform)
})
// 计算属性
const platformLabel = computed(() => {
if (!props.account) return '未知'
const platform = props.account.platform
const labels = {
claude: 'Claude OAuth',
'claude-console': 'Claude Console',
bedrock: 'AWS Bedrock',
gemini: 'Gemini',
'openai-responses': 'OpenAI Responses',
'azure-openai': 'Azure OpenAI',
droid: 'Droid',
ccr: 'CCR'
}
return labels[platform] || platform
})
const platformIcon = computed(() => {
if (!props.account) return 'fas fa-question'
const platform = props.account.platform
const icons = {
claude: 'fas fa-brain',
'claude-console': 'fas fa-brain',
bedrock: 'fab fa-aws',
gemini: 'fas fa-gem',
'openai-responses': 'fas fa-code',
'azure-openai': 'fab fa-microsoft',
droid: 'fas fa-robot',
ccr: 'fas fa-key'
}
return icons[platform] || 'fas fa-robot'
})
const platformBadgeClass = computed(() => {
if (!props.account) return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
const platform = props.account.platform
const classes = {
claude: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300',
'claude-console': 'bg-purple-100 text-purple-700 dark:bg-purple-500/20 dark:text-purple-300',
bedrock: 'bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-300',
gemini: 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300',
'openai-responses': 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-300',
'azure-openai': 'bg-cyan-100 text-cyan-700 dark:bg-cyan-500/20 dark:text-cyan-300',
droid: 'bg-pink-100 text-pink-700 dark:bg-pink-500/20 dark:text-pink-300',
ccr: 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300'
}
return classes[platform] || 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
})
// Bedrock 账号类型相关
const credentialTypeLabel = computed(() => {
if (!props.account || props.account.platform !== 'bedrock') return ''
const credentialType = props.account.credentialType
if (credentialType === 'access_key') return 'Access Key'
if (credentialType === 'bearer_token') return 'Bearer Token'
return 'Unknown'
})
const credentialTypeIcon = computed(() => {
if (!props.account || props.account.platform !== 'bedrock') return ''
const credentialType = props.account.credentialType
if (credentialType === 'access_key') return 'fas fa-key'
if (credentialType === 'bearer_token') return 'fas fa-ticket'
return 'fas fa-question'
})
const credentialTypeBadgeClass = computed(() => {
if (!props.account || props.account.platform !== 'bedrock')
return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
const credentialType = props.account.credentialType
if (credentialType === 'access_key') {
return 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300'
}
if (credentialType === 'bearer_token') {
return 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-300'
}
return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
})
const statusTitle = computed(() => {
switch (testStatus.value) {
case 'idle':
return '准备就绪'
case 'testing':
return '正在测试...'
case 'success':
return '测试成功'
case 'error':
return '测试失败'
default:
return '未知状态'
}
})
const statusDescription = computed(() => {
const apiName = platformLabel.value || 'API'
switch (testStatus.value) {
case 'idle':
return '点击下方按钮开始测试账户连通性'
case 'testing':
return '正在发送测试请求并等待响应'
case 'success':
return `账户可以正常访问 ${apiName}`
case 'error':
return errorMessage.value || `无法连接到 ${apiName}`
default:
return ''
}
})
const statusCardClass = computed(() => {
switch (testStatus.value) {
case 'idle':
return 'border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50'
case 'testing':
return 'border-blue-200 bg-blue-50 dark:border-blue-500/30 dark:bg-blue-900/20'
case 'success':
return 'border-green-200 bg-green-50 dark:border-green-500/30 dark:bg-green-900/20'
case 'error':
return 'border-red-200 bg-red-50 dark:border-red-500/30 dark:bg-red-900/20'
default:
return 'border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50'
}
})
const statusIconBgClass = computed(() => {
switch (testStatus.value) {
case 'idle':
return 'bg-gray-200 dark:bg-gray-700'
case 'testing':
return 'bg-blue-100 dark:bg-blue-500/30'
case 'success':
return 'bg-green-100 dark:bg-green-500/30'
case 'error':
return 'bg-red-100 dark:bg-red-500/30'
default:
return 'bg-gray-200 dark:bg-gray-700'
}
})
const statusIcon = computed(() => {
switch (testStatus.value) {
case 'idle':
return 'fa-hourglass-start'
case 'testing':
return 'fa-spinner fa-spin'
case 'success':
return 'fa-check-circle'
case 'error':
return 'fa-exclamation-circle'
default:
return 'fa-question-circle'
}
})
const statusIconClass = computed(() => {
switch (testStatus.value) {
case 'idle':
return 'text-gray-500 dark:text-gray-400'
case 'testing':
return 'text-blue-500 dark:text-blue-400'
case 'success':
return 'text-green-500 dark:text-green-400'
case 'error':
return 'text-red-500 dark:text-red-400'
default:
return 'text-gray-500 dark:text-gray-400'
}
})
const statusTextClass = computed(() => {
switch (testStatus.value) {
case 'idle':
return 'text-gray-700 dark:text-gray-300'
case 'testing':
return 'text-blue-700 dark:text-blue-300'
case 'success':
return 'text-green-700 dark:text-green-300'
case 'error':
return 'text-red-700 dark:text-red-300'
default:
return 'text-gray-700 dark:text-gray-300'
}
})
// 方法
function getTestEndpoint() {
if (!props.account) return ''
const platform = props.account.platform
const endpoints = {
claude: `${APP_CONFIG.apiPrefix}/admin/claude-accounts/${props.account.id}/test`,
'claude-console': `${APP_CONFIG.apiPrefix}/admin/claude-console-accounts/${props.account.id}/test`,
bedrock: `${APP_CONFIG.apiPrefix}/admin/bedrock-accounts/${props.account.id}/test`,
gemini: `${APP_CONFIG.apiPrefix}/admin/gemini-accounts/${props.account.id}/test`,
'openai-responses': `${APP_CONFIG.apiPrefix}/admin/openai-responses-accounts/${props.account.id}/test`,
'azure-openai': `${APP_CONFIG.apiPrefix}/admin/azure-openai-accounts/${props.account.id}/test`,
droid: `${APP_CONFIG.apiPrefix}/admin/droid-accounts/${props.account.id}/test`,
ccr: `${APP_CONFIG.apiPrefix}/admin/ccr-accounts/${props.account.id}/test`
}
return endpoints[platform] || ''
}
async function startTest() {
if (!props.account) return
// 重置状态
testStatus.value = 'testing'
responseText.value = ''
errorMessage.value = ''
testDuration.value = 0
testStartTime.value = Date.now()
// 关闭之前的连接
if (eventSource.value) {
eventSource.value.close()
}
const endpoint = getTestEndpoint()
if (!endpoint) {
testStatus.value = 'error'
errorMessage.value = '不支持的账户类型'
return
}
try {
// 获取认证token
const authToken = localStorage.getItem('authToken')
// 使用fetch发送POST请求
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: authToken ? `Bearer ${authToken}` : ''
},
body: JSON.stringify({ model: selectedModel.value })
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.message || `HTTP ${response.status}`)
}
// 根据账户类型处理响应
if (useSSE.value) {
// SSE 流式响应 (Claude/Console)
const reader = response.body.getReader()
const decoder = new TextDecoder()
let streamDone = false
while (!streamDone) {
const { done, value } = await reader.read()
if (done) {
streamDone = true
continue
}
const chunk = decoder.decode(value)
const lines = chunk.split('\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.substring(6))
handleSSEEvent(data)
} catch {
// 忽略解析错误
}
}
}
}
} else {
// JSON 响应 (其他平台)
const data = await response.json()
testDuration.value = Date.now() - testStartTime.value
if (data.success) {
testStatus.value = 'success'
responseText.value = data.data?.responseText || 'Test passed'
} else {
testStatus.value = 'error'
errorMessage.value = data.message || 'Test failed'
}
}
} catch (err) {
testStatus.value = 'error'
errorMessage.value = err.message || '连接失败'
testDuration.value = Date.now() - testStartTime.value
}
}
function handleSSEEvent(data) {
switch (data.type) {
case 'test_start':
// 测试开始
break
case 'content':
responseText.value += data.text
break
case 'message_stop':
// 消息结束
break
case 'test_complete':
testDuration.value = Date.now() - testStartTime.value
if (data.success) {
testStatus.value = 'success'
} else {
testStatus.value = 'error'
errorMessage.value = data.error || '测试失败'
}
break
case 'error':
testStatus.value = 'error'
errorMessage.value = data.error || '未知错误'
testDuration.value = Date.now() - testStartTime.value
break
}
}
function handleClose() {
if (testStatus.value === 'testing') return
// 关闭SSE连接
if (eventSource.value) {
eventSource.value.close()
eventSource.value = null
}
// 重置状态
testStatus.value = 'idle'
responseText.value = ''
errorMessage.value = ''
testDuration.value = 0
emit('close')
}
// 监听show变化重置状态并设置测试模型
watch(
() => props.show,
(newVal) => {
if (newVal) {
testStatus.value = 'idle'
responseText.value = ''
errorMessage.value = ''
testDuration.value = 0
// 根据平台和账号类型设置测试模型
if (props.account?.platform === 'bedrock') {
const credentialType = props.account.credentialType
if (credentialType === 'bearer_token') {
// Bearer Token 模式使用 Sonnet 4.5
selectedModel.value = 'us.anthropic.claude-sonnet-4-5-20250929-v1:0'
} else {
// Access Key 模式使用 Haiku更快更便宜
selectedModel.value = 'us.anthropic.claude-3-5-haiku-20241022-v1:0'
}
} else {
// 其他平台使用默认模型
selectedModel.value = 'claude-sonnet-4-5-20250929'
}
}
}
)
// 组件卸载时清理
onUnmounted(() => {
if (eventSource.value) {
eventSource.value.close()
}
})
</script>

View File

@@ -1,629 +0,0 @@
<template>
<Teleport to="body">
<div
v-if="show"
class="fixed inset-0 z-[1050] flex items-center justify-center bg-gray-900/40 backdrop-blur-sm"
>
<div class="absolute inset-0" @click="handleClose" />
<div
class="relative z-10 mx-3 flex w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-gray-200/70 bg-white/95 shadow-2xl ring-1 ring-black/5 transition-all dark:border-gray-700/60 dark:bg-gray-900/95 dark:ring-white/10 sm:mx-4"
>
<!-- 顶部栏 -->
<div
class="flex items-center justify-between border-b border-gray-100 bg-white/80 px-5 py-4 backdrop-blur dark:border-gray-800 dark:bg-gray-900/80"
>
<div class="flex items-center gap-3">
<div
:class="[
'flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl text-white shadow-lg',
testStatus === 'success'
? 'bg-gradient-to-br from-green-500 to-emerald-500'
: testStatus === 'error'
? 'bg-gradient-to-br from-red-500 to-pink-500'
: 'bg-gradient-to-br from-blue-500 to-indigo-500'
]"
>
<i
:class="[
'fas',
testStatus === 'idle'
? 'fa-vial'
: testStatus === 'testing'
? 'fa-spinner fa-spin'
: testStatus === 'success'
? 'fa-check'
: 'fa-times'
]"
/>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
API Key 端点测试
</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ displayName }}
</p>
</div>
</div>
<button
class="flex h-9 w-9 items-center justify-center rounded-full bg-gray-100 text-gray-500 transition hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200"
:disabled="testStatus === 'testing'"
@click="handleClose"
>
<i class="fas fa-times text-sm" />
</button>
</div>
<!-- 内容区域 -->
<div class="max-h-[70vh] overflow-y-auto px-5 py-4">
<!-- API Key 显示区域只读 -->
<div class="mb-4">
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
API Key
</label>
<div class="relative">
<input
class="w-full rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 pr-10 text-sm text-gray-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
readonly
type="text"
:value="maskedApiKey"
/>
<div class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400">
<i class="fas fa-lock text-xs" />
</div>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
测试将使用此 API Key 调用当前服务的 /api 端点
</p>
</div>
<!-- 测试信息 -->
<div class="mb-4 space-y-2">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-500 dark:text-gray-400">测试端点</span>
<span
class="inline-flex items-center gap-1.5 rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-500/20 dark:text-blue-300"
>
<i class="fas fa-link" />
{{ serviceConfig.displayEndpoint }}
</span>
</div>
<div class="text-sm">
<div class="mb-1 flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">测试模型</span>
<select
v-model="testModel"
class="rounded-lg border border-gray-200 bg-white px-2 py-1 text-sm text-gray-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300"
>
<option v-for="model in availableModels" :key="model.value" :value="model.value">
{{ model.label }}
</option>
</select>
</div>
<div class="text-right text-xs text-gray-400 dark:text-gray-500">
{{ testModel }}
</div>
</div>
<div class="text-sm">
<div class="mb-1 flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">最大输出 Token</span>
<select
v-model="maxTokens"
class="rounded-lg border border-gray-200 bg-white px-2 py-1 text-sm text-gray-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300"
>
<option v-for="opt in maxTokensOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</div>
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-gray-500 dark:text-gray-400">测试服务</span>
<span class="font-medium text-gray-700 dark:text-gray-300">{{
serviceConfig.name
}}</span>
</div>
</div>
<!-- 提示词输入 -->
<div class="mb-4">
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
提示词
</label>
<textarea
v-model="testPrompt"
class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
placeholder="输入测试提示词..."
rows="2"
/>
</div>
<!-- 状态指示 -->
<div :class="['mb-4 rounded-xl border p-4 transition-all duration-300', statusCardClass]">
<div class="flex items-center gap-3">
<div
:class="['flex h-8 w-8 items-center justify-center rounded-lg', statusIconBgClass]"
>
<i :class="['fas text-sm', statusIcon, statusIconClass]" />
</div>
<div>
<p :class="['font-medium', statusTextClass]">{{ statusTitle }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ statusDescription }}</p>
</div>
</div>
</div>
<!-- 响应内容区域 -->
<div
v-if="testStatus !== 'idle'"
class="mb-4 overflow-hidden rounded-xl border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50"
>
<div
class="flex items-center justify-between border-b border-gray-200 bg-gray-100 px-3 py-2 dark:border-gray-700 dark:bg-gray-800"
>
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">AI 响应</span>
<span v-if="responseText" class="text-xs text-gray-500 dark:text-gray-500">
{{ responseText.length }} 字符
</span>
</div>
<div class="max-h-40 overflow-y-auto p-3">
<p
v-if="responseText"
class="whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-300"
>
{{ responseText }}
<span
v-if="testStatus === 'testing'"
class="inline-block h-4 w-1 animate-pulse bg-blue-500"
/>
</p>
<p
v-else-if="testStatus === 'testing'"
class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400"
>
<i class="fas fa-circle-notch fa-spin" />
等待响应中...
</p>
<p
v-else-if="testStatus === 'error' && errorMessage"
class="text-sm text-red-600 dark:text-red-400"
>
{{ errorMessage }}
</p>
</div>
</div>
<!-- 测试时间 -->
<div
v-if="testDuration > 0"
class="mb-4 flex items-center justify-center gap-2 text-xs text-gray-500 dark:text-gray-400"
>
<i class="fas fa-clock" />
<span>耗时 {{ (testDuration / 1000).toFixed(2) }} </span>
</div>
</div>
<!-- 底部操作栏 -->
<div
class="flex items-center justify-end gap-3 border-t border-gray-100 bg-gray-50/80 px-5 py-3 dark:border-gray-800 dark:bg-gray-900/50"
>
<button
class="rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 hover:shadow dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
:disabled="testStatus === 'testing'"
@click="handleClose"
>
关闭
</button>
<button
:class="[
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium shadow-sm transition',
testStatus === 'testing' || !apiKeyValue
? 'cursor-not-allowed bg-gray-200 text-gray-400 dark:bg-gray-700 dark:text-gray-500'
: 'bg-gradient-to-r from-blue-500 to-indigo-500 text-white hover:from-blue-600 hover:to-indigo-600 hover:shadow-md'
]"
:disabled="testStatus === 'testing' || !apiKeyValue"
@click="startTest"
>
<i :class="['fas', testStatus === 'testing' ? 'fa-spinner fa-spin' : 'fa-play']" />
{{
testStatus === 'testing'
? '测试中...'
: testStatus === 'idle'
? '开始测试'
: '重新测试'
}}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { ref, computed, watch, onUnmounted, onMounted } from 'vue'
import { APP_CONFIG } from '@/utils/tools'
import { getModelsApi } from '@/utils/http_apis'
const props = defineProps({
show: {
type: Boolean,
default: false
},
// API Key 完整值(用于测试)
apiKeyValue: {
type: String,
default: ''
},
// API Key 名称(用于显示)
apiKeyName: {
type: String,
default: ''
},
// 服务类型: claude, gemini, openai
serviceType: {
type: String,
default: 'claude',
validator: (value) => ['claude', 'gemini', 'openai'].includes(value)
}
})
const emit = defineEmits(['close'])
// 状态
const testStatus = ref('idle') // idle, testing, success, error
const responseText = ref('')
const errorMessage = ref('')
const testDuration = ref(0)
const testStartTime = ref(null)
const abortController = ref(null)
// 测试模型
const testModel = ref('claude-sonnet-4-5-20250929')
// 测试提示词
const testPrompt = ref('hi')
// 最大输出 token
const maxTokens = ref(1000)
const maxTokensOptions = [
{ value: 100, label: '100' },
{ value: 500, label: '500' },
{ value: 1000, label: '1000' },
{ value: 2000, label: '2000' },
{ value: 4096, label: '4096' }
]
// 从 API 获取的模型列表
const modelsFromApi = ref({
claude: [],
gemini: [],
openai: []
})
// 加载模型列表
const loadModels = async () => {
try {
const result = await getModelsApi()
if (result.success && result.data) {
modelsFromApi.value = {
claude: result.data.claude || [],
gemini: result.data.gemini || [],
openai: result.data.openai || []
}
}
} catch (error) {
console.error('Failed to load models:', error)
}
}
// 服务配置
const serviceConfig = computed(() => {
const configs = {
claude: {
name: 'Claude',
endpoint: '/api-key/test',
defaultModel: 'claude-sonnet-4-5-20250929',
displayEndpoint: '/api/v1/messages'
},
gemini: {
name: 'Gemini',
endpoint: '/api-key/test-gemini',
defaultModel: 'gemini-2.5-pro',
displayEndpoint: '/gemini/v1/models/:model:streamGenerateContent'
},
openai: {
name: 'OpenAI (Codex)',
endpoint: '/api-key/test-openai',
defaultModel: 'gpt-5',
displayEndpoint: '/openai/responses'
}
}
return configs[props.serviceType] || configs.claude
})
// 可用模型列表(从 API 获取)
const availableModels = computed(() => {
return modelsFromApi.value[props.serviceType] || []
})
// 组件挂载时加载模型
onMounted(() => {
loadModels()
})
// 计算属性
const displayName = computed(() => {
return props.apiKeyName || '当前 API Key'
})
const maskedApiKey = computed(() => {
const key = props.apiKeyValue
if (!key) return ''
if (key.length <= 10) return '****'
return key.substring(0, 6) + '****' + key.substring(key.length - 4)
})
// 计算属性
const statusTitle = computed(() => {
switch (testStatus.value) {
case 'idle':
return '准备就绪'
case 'testing':
return '正在测试...'
case 'success':
return '测试成功'
case 'error':
return '测试失败'
default:
return '未知状态'
}
})
const statusDescription = computed(() => {
switch (testStatus.value) {
case 'idle':
return '点击下方按钮开始测试 API Key 连通性'
case 'testing':
return '正在通过 /api 端点发送测试请求'
case 'success':
return 'API Key 可以正常访问服务'
case 'error':
return errorMessage.value || '无法通过 API Key 访问服务'
default:
return ''
}
})
const statusCardClass = computed(() => {
switch (testStatus.value) {
case 'idle':
return 'border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50'
case 'testing':
return 'border-blue-200 bg-blue-50 dark:border-blue-500/30 dark:bg-blue-900/20'
case 'success':
return 'border-green-200 bg-green-50 dark:border-green-500/30 dark:bg-green-900/20'
case 'error':
return 'border-red-200 bg-red-50 dark:border-red-500/30 dark:bg-red-900/20'
default:
return 'border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50'
}
})
const statusIconBgClass = computed(() => {
switch (testStatus.value) {
case 'idle':
return 'bg-gray-200 dark:bg-gray-700'
case 'testing':
return 'bg-blue-100 dark:bg-blue-500/30'
case 'success':
return 'bg-green-100 dark:bg-green-500/30'
case 'error':
return 'bg-red-100 dark:bg-red-500/30'
default:
return 'bg-gray-200 dark:bg-gray-700'
}
})
const statusIcon = computed(() => {
switch (testStatus.value) {
case 'idle':
return 'fa-hourglass-start'
case 'testing':
return 'fa-spinner fa-spin'
case 'success':
return 'fa-check-circle'
case 'error':
return 'fa-exclamation-circle'
default:
return 'fa-question-circle'
}
})
const statusIconClass = computed(() => {
switch (testStatus.value) {
case 'idle':
return 'text-gray-500 dark:text-gray-400'
case 'testing':
return 'text-blue-500 dark:text-blue-400'
case 'success':
return 'text-green-500 dark:text-green-400'
case 'error':
return 'text-red-500 dark:text-red-400'
default:
return 'text-gray-500 dark:text-gray-400'
}
})
const statusTextClass = computed(() => {
switch (testStatus.value) {
case 'idle':
return 'text-gray-700 dark:text-gray-300'
case 'testing':
return 'text-blue-700 dark:text-blue-300'
case 'success':
return 'text-green-700 dark:text-green-300'
case 'error':
return 'text-red-700 dark:text-red-300'
default:
return 'text-gray-700 dark:text-gray-300'
}
})
// 方法
async function startTest() {
if (!props.apiKeyValue) return
// 重置状态
testStatus.value = 'testing'
responseText.value = ''
errorMessage.value = ''
testDuration.value = 0
testStartTime.value = Date.now()
// 取消之前的请求
if (abortController.value) {
abortController.value.abort()
}
abortController.value = new AbortController()
// 使用公开的测试端点,不需要管理员认证
// apiStats 路由挂载在 /apiStats 下
const endpoint = `${APP_CONFIG.apiPrefix}/apiStats${serviceConfig.value.endpoint}`
try {
// 使用fetch发送POST请求并处理SSE
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
apiKey: props.apiKeyValue,
model: testModel.value,
prompt: testPrompt.value,
maxTokens: maxTokens.value
}),
signal: abortController.value.signal
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.message || errorData.error || `HTTP ${response.status}`)
}
// 处理SSE流
const reader = response.body.getReader()
const decoder = new TextDecoder()
let streamDone = false
while (!streamDone) {
const { done, value } = await reader.read()
if (done) {
streamDone = true
continue
}
const chunk = decoder.decode(value)
const lines = chunk.split('\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.substring(6))
handleSSEEvent(data)
} catch {
// 忽略解析错误
}
}
}
}
} catch (err) {
if (err.name === 'AbortError') {
// 请求被取消
return
}
testStatus.value = 'error'
errorMessage.value = err.message || '连接失败'
testDuration.value = Date.now() - testStartTime.value
}
}
function handleSSEEvent(data) {
switch (data.type) {
case 'test_start':
// 测试开始
break
case 'content':
responseText.value += data.text
break
case 'message_stop':
// 消息结束
break
case 'test_complete':
testDuration.value = Date.now() - testStartTime.value
if (data.success) {
testStatus.value = 'success'
} else {
testStatus.value = 'error'
errorMessage.value = data.error || '测试失败'
}
break
case 'error':
testStatus.value = 'error'
errorMessage.value = data.error || '未知错误'
testDuration.value = Date.now() - testStartTime.value
break
}
}
function handleClose() {
if (testStatus.value === 'testing') return
// 取消请求
if (abortController.value) {
abortController.value.abort()
abortController.value = null
}
// 重置状态
testStatus.value = 'idle'
responseText.value = ''
errorMessage.value = ''
testDuration.value = 0
emit('close')
}
// 监听show变化重置状态
watch(
() => props.show,
(newVal) => {
if (newVal) {
testStatus.value = 'idle'
responseText.value = ''
errorMessage.value = ''
testDuration.value = 0
// 重置为当前服务的默认模型
testModel.value = serviceConfig.value.defaultModel
// 重置提示词和 maxTokens
testPrompt.value = 'hi'
maxTokens.value = 1000
}
}
)
// 监听服务类型变化,重置模型
watch(
() => props.serviceType,
() => {
testModel.value = serviceConfig.value.defaultModel
}
)
// 组件卸载时清理
onUnmounted(() => {
if (abortController.value) {
abortController.value.abort()
}
})
</script>

View File

@@ -0,0 +1,69 @@
<template>
<div class="flex items-center gap-2">
<!-- 下拉选择模式 -->
<select
v-if="!customMode"
class="flex-1 rounded-lg border border-gray-200 bg-white px-2 py-1 text-xs font-medium text-gray-700 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"
:disabled="disabled"
:value="modelValue"
@change="handleSelectChange"
>
<option v-for="m in models" :key="m.value" :value="m.value">
{{ m.label }}
</option>
<option value="__custom__">自定义模型...</option>
</select>
<!-- 自定义输入模式 -->
<template v-else>
<input
class="flex-1 rounded-lg border border-gray-200 bg-white px-2 py-1 text-xs text-gray-700 placeholder-gray-400 transition focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:placeholder-gray-500"
:disabled="disabled"
:placeholder="placeholder"
type="text"
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
/>
<button
class="flex-shrink-0 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-gray-500 transition hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600"
:disabled="disabled"
title="返回列表"
@click="exitCustomMode"
>
<i class="fas fa-list text-[10px]" />
</button>
</template>
</div>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps({
modelValue: { type: String, default: '' },
models: { type: Array, default: () => [] },
disabled: { type: Boolean, default: false },
placeholder: { type: String, default: '输入模型 ID...' }
})
const emit = defineEmits(['update:modelValue'])
const customMode = ref(false)
const handleSelectChange = (e) => {
if (e.target.value === '__custom__') {
customMode.value = true
emit('update:modelValue', '')
} else {
emit('update:modelValue', e.target.value)
}
}
const exitCustomMode = () => {
customMode.value = false
// 切回列表时选中第一个预设模型
if (props.models.length > 0) {
emit('update:modelValue', props.models[0].value)
}
}
</script>

View File

@@ -0,0 +1,598 @@
<template>
<Teleport to="body">
<div
v-if="show"
class="fixed inset-0 z-[1050] flex items-center justify-center bg-gray-900/40 backdrop-blur-sm"
>
<div class="absolute inset-0" @click="handleClose" />
<div
class="relative z-10 mx-3 flex w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-gray-200/70 bg-white/95 shadow-2xl ring-1 ring-black/5 transition-all dark:border-gray-700/60 dark:bg-gray-900/95 dark:ring-white/10 sm:mx-4"
>
<!-- 顶部栏 -->
<div
class="flex items-center justify-between border-b border-gray-100 bg-white/80 px-5 py-4 backdrop-blur dark:border-gray-800 dark:bg-gray-900/80"
>
<div class="flex items-center gap-3">
<div
:class="[
'flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl text-white shadow-lg',
headerIconBgClass
]"
>
<i
:class="[
'fas',
state.testStatus.value === 'idle'
? 'fa-vial'
: state.testStatus.value === 'testing'
? 'fa-spinner fa-spin'
: state.testStatus.value === 'success'
? 'fa-check'
: 'fa-times'
]"
/>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
{{ modalTitle }}
</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ modalSubtitle }}
</p>
</div>
</div>
<button
class="flex h-9 w-9 items-center justify-center rounded-full bg-gray-100 text-gray-500 transition hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200"
:disabled="state.testStatus.value === 'testing'"
@click="handleClose"
>
<i class="fas fa-times text-sm" />
</button>
</div>
<!-- 内容区域 -->
<div class="max-h-[70vh] overflow-y-auto px-5 py-4">
<!-- [apikey] API Key 显示 -->
<div v-if="mode === 'apikey'" class="mb-4">
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
API Key
</label>
<div class="relative">
<input
class="w-full rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 pr-10 text-sm text-gray-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
readonly
type="text"
:value="maskedApiKey"
/>
<div class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400">
<i class="fas fa-lock text-xs" />
</div>
</div>
</div>
<!-- 测试信息 -->
<div class="mb-4 space-y-2">
<!-- [account] 平台类型 -->
<div v-if="mode === 'account'" class="flex items-center justify-between text-sm">
<span class="text-gray-500 dark:text-gray-400">平台类型</span>
<span
:class="[
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium',
platformBadgeClass
]"
>
<i :class="platformIcon" />
{{ platformLabel }}
</span>
</div>
<!-- [account+bedrock] 凭证类型 -->
<div
v-if="mode === 'account' && account?.platform === 'bedrock'"
class="flex items-center justify-between text-sm"
>
<span class="text-gray-500 dark:text-gray-400">账号类型</span>
<span
:class="[
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium',
credentialTypeBadgeClass
]"
>
<i :class="credentialTypeIcon" />
{{ credentialTypeLabel }}
</span>
</div>
<!-- [apikey] 测试端点 -->
<div v-if="mode === 'apikey'" class="flex items-center justify-between text-sm">
<span class="text-gray-500 dark:text-gray-400">测试端点</span>
<span
class="inline-flex items-center gap-1.5 rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-500/20 dark:text-blue-300"
>
<i class="fas fa-link" />
{{ apikeyServiceConfig.displayEndpoint }}
</span>
</div>
<!-- 测试模型两种模式都有 -->
<div class="text-sm">
<div class="mb-1 flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">测试模型</span>
<ModelSelector
v-model="selectedModel"
:disabled="state.testStatus.value === 'testing'"
:models="availableModels"
/>
</div>
<div class="text-right text-xs text-gray-400 dark:text-gray-500">
{{ selectedModel }}
</div>
</div>
<!-- [apikey] 最大输出 Token -->
<div v-if="mode === 'apikey'" class="text-sm">
<div class="mb-1 flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">最大输出 Token</span>
<select
v-model="maxTokens"
class="rounded-lg border border-gray-200 bg-white px-2 py-1 text-sm text-gray-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300"
>
<option v-for="opt in maxTokensOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</div>
</div>
<!-- [apikey] 测试服务 -->
<div v-if="mode === 'apikey'" class="flex items-center justify-between text-sm">
<span class="text-gray-500 dark:text-gray-400">测试服务</span>
<span class="font-medium text-gray-700 dark:text-gray-300">
{{ apikeyServiceConfig.name }}
</span>
</div>
</div>
<!-- [apikey] 提示词输入 -->
<div v-if="mode === 'apikey'" class="mb-4">
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
提示词
</label>
<textarea
v-model="testPrompt"
class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
placeholder="输入测试提示词..."
rows="2"
/>
</div>
<!-- 状态指示 -->
<div
:class="[
'mb-4 rounded-xl border p-4 transition-all duration-300',
state.statusCardClass.value
]"
>
<div class="flex items-center gap-3">
<div
:class="[
'flex h-8 w-8 items-center justify-center rounded-lg',
state.statusIconBgClass.value
]"
>
<i :class="['fas text-sm', state.statusIcon.value, state.statusIconClass.value]" />
</div>
<div>
<p :class="['font-medium', state.statusTextClass.value]">
{{ state.statusTitle.value }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ statusDescription }}</p>
</div>
</div>
</div>
<!-- 响应内容区域 -->
<div
v-if="state.testStatus.value !== 'idle'"
class="mb-4 overflow-hidden rounded-xl border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50"
>
<div
class="flex items-center justify-between border-b border-gray-200 bg-gray-100 px-3 py-2 dark:border-gray-700 dark:bg-gray-800"
>
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">AI 响应</span>
<span
v-if="state.responseText.value"
class="text-xs text-gray-500 dark:text-gray-500"
>
{{ state.responseText.value.length }} 字符
</span>
</div>
<div class="max-h-40 overflow-y-auto p-3">
<p
v-if="state.responseText.value"
class="whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-300"
>
{{ state.responseText.value }}
<span
v-if="state.testStatus.value === 'testing'"
class="inline-block h-4 w-1 animate-pulse bg-blue-500"
/>
</p>
<p
v-else-if="state.testStatus.value === 'testing'"
class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400"
>
<i class="fas fa-circle-notch fa-spin" />
等待响应中...
</p>
<p
v-else-if="state.testStatus.value === 'error' && state.errorMessage.value"
class="text-sm text-red-600 dark:text-red-400"
>
{{ state.errorMessage.value }}
</p>
</div>
</div>
<!-- 测试时间 -->
<div
v-if="state.testDuration.value > 0"
class="mb-4 flex items-center justify-center gap-2 text-xs text-gray-500 dark:text-gray-400"
>
<i class="fas fa-clock" />
<span>耗时 {{ (state.testDuration.value / 1000).toFixed(2) }} </span>
</div>
</div>
<!-- 底部操作栏 -->
<div
class="flex items-center justify-end gap-3 border-t border-gray-100 bg-gray-50/80 px-5 py-3 dark:border-gray-800 dark:bg-gray-900/50"
>
<button
class="rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 hover:shadow dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
:disabled="state.testStatus.value === 'testing'"
@click="handleClose"
>
关闭
</button>
<button
:class="[
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium shadow-sm transition',
state.testStatus.value === 'testing' || disableTest
? 'cursor-not-allowed bg-gray-200 text-gray-400 dark:bg-gray-700 dark:text-gray-500'
: 'bg-gradient-to-r from-blue-500 to-indigo-500 text-white hover:from-blue-600 hover:to-indigo-600 hover:shadow-md'
]"
:disabled="state.testStatus.value === 'testing' || disableTest"
@click="startTest"
>
<i
:class="[
'fas',
state.testStatus.value === 'testing' ? 'fa-spinner fa-spin' : 'fa-play'
]"
/>
{{
state.testStatus.value === 'testing'
? '测试中...'
: state.testStatus.value === 'idle'
? '开始测试'
: '重新测试'
}}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { APP_CONFIG } from '@/utils/tools'
import { getModelsApi } from '@/utils/http_apis'
import { useTestState } from '@/utils/useTestState'
import ModelSelector from '@/components/common/ModelSelector.vue'
const props = defineProps({
show: { type: Boolean, default: false },
mode: { type: String, default: 'account' }, // 'account' | 'apikey'
// account 模式
account: { type: Object, default: null },
// apikey 模式
apiKeyValue: { type: String, default: '' },
apiKeyName: { type: String, default: '' },
serviceType: { type: String, default: 'claude' }
})
const emit = defineEmits(['close'])
const state = useTestState()
// ========== 模型相关 ==========
const selectedModel = ref('')
const modelsFromApi = ref({ claude: [], gemini: [], openai: [], platforms: {} })
const loadModels = async () => {
const result = await getModelsApi()
if (result.success && result.data) {
modelsFromApi.value = result.data
}
}
onMounted(loadModels)
const availableModels = computed(() => {
if (props.mode === 'account') {
const platform = props.account?.platform
if (!platform) return []
// azure-openai 使用 deploymentName
if (platform === 'azure-openai') {
return [{ value: props.account.deploymentName, label: props.account.deploymentName }]
}
return modelsFromApi.value.platforms?.[platform] || []
}
// apikey 模式
return modelsFromApi.value[props.serviceType] || []
})
// 各平台回退默认模型(模型列表未加载时使用)
const platformFallbackModels = {
claude: 'claude-sonnet-4-5-20250929',
'claude-console': 'claude-sonnet-4-5-20250929',
gemini: 'gemini-2.5-pro',
'gemini-api': 'gemini-2.5-flash',
'openai-responses': 'gpt-5',
droid: 'claude-sonnet-4-5-20250929',
ccr: 'claude-sonnet-4-5-20250929'
}
const defaultModel = computed(() => {
if (props.mode === 'account') {
const platform = props.account?.platform
if (platform === 'azure-openai') return props.account?.deploymentName
// bedrock 优先用列表,列表为空时按凭证类型回退
if (platform === 'bedrock') {
const models = availableModels.value
if (models.length > 0) return models[0].value
if (props.account?.credentialType === 'bearer_token')
return 'us.anthropic.claude-sonnet-4-5-20250929-v1:0'
return 'us.anthropic.claude-3-5-haiku-20241022-v1:0'
}
const models = availableModels.value
if (models.length > 0) return models[0].value
return platformFallbackModels[platform] || platformFallbackModels.claude
}
// apikey 模式: 优先用列表,回退用 serviceConfig 的 defaultModel
const models = availableModels.value
if (models.length > 0) return models[0].value
return apikeyServiceConfig.value.defaultModel
})
// ========== apikey 模式专用 ==========
const testPrompt = ref('hi')
const maxTokens = ref(1000)
const maxTokensOptions = [
{ value: 100, label: '100' },
{ value: 500, label: '500' },
{ value: 1000, label: '1000' },
{ value: 2000, label: '2000' },
{ value: 4096, label: '4096' }
]
const apikeyServiceConfigs = {
claude: {
name: 'Claude',
endpoint: '/api-key/test',
defaultModel: 'claude-sonnet-4-5-20250929',
displayEndpoint: '/api/v1/messages'
},
gemini: {
name: 'Gemini',
endpoint: '/api-key/test-gemini',
defaultModel: 'gemini-2.5-pro',
displayEndpoint: '/gemini/v1/models/:model:streamGenerateContent'
},
openai: {
name: 'OpenAI (Codex)',
endpoint: '/api-key/test-openai',
defaultModel: 'gpt-5',
displayEndpoint: '/openai/responses'
}
}
const apikeyServiceConfig = computed(
() => apikeyServiceConfigs[props.serviceType] || apikeyServiceConfigs.claude
)
const maskedApiKey = computed(() => {
const key = props.apiKeyValue
if (!key) return ''
if (key.length <= 10) return '****'
return key.substring(0, 6) + '****' + key.substring(key.length - 4)
})
const disableTest = computed(() => props.mode === 'apikey' && !props.apiKeyValue)
// ========== account 模式 - 平台信息 ==========
const platformConfigs = {
claude: {
label: 'Claude OAuth',
icon: 'fas fa-brain',
badge: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300'
},
'claude-console': {
label: 'Claude Console',
icon: 'fas fa-brain',
badge: 'bg-purple-100 text-purple-700 dark:bg-purple-500/20 dark:text-purple-300'
},
bedrock: {
label: 'AWS Bedrock',
icon: 'fab fa-aws',
badge: 'bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-300'
},
gemini: {
label: 'Gemini',
icon: 'fas fa-gem',
badge: 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300'
},
'gemini-api': {
label: 'Gemini API',
icon: 'fas fa-gem',
badge: 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300'
},
'openai-responses': {
label: 'OpenAI Responses',
icon: 'fas fa-code',
badge: 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-300'
},
'azure-openai': {
label: 'Azure OpenAI',
icon: 'fab fa-microsoft',
badge: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-500/20 dark:text-cyan-300'
},
droid: {
label: 'Droid',
icon: 'fas fa-robot',
badge: 'bg-pink-100 text-pink-700 dark:bg-pink-500/20 dark:text-pink-300'
},
ccr: {
label: 'CCR',
icon: 'fas fa-key',
badge: 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300'
}
}
const platformConfig = computed(
() =>
platformConfigs[props.account?.platform] || {
label: '未知',
icon: 'fas fa-question',
badge: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
}
)
const platformLabel = computed(() => platformConfig.value.label)
const platformIcon = computed(() => platformConfig.value.icon)
const platformBadgeClass = computed(() => platformConfig.value.badge)
const credentialTypeLabel = computed(() => {
const ct = props.account?.credentialType
if (ct === 'access_key') return 'Access Key'
if (ct === 'bearer_token') return 'Bearer Token'
return 'Unknown'
})
const credentialTypeIcon = computed(() => {
const ct = props.account?.credentialType
if (ct === 'access_key') return 'fas fa-key'
if (ct === 'bearer_token') return 'fas fa-ticket'
return 'fas fa-question'
})
const credentialTypeBadgeClass = computed(() => {
const ct = props.account?.credentialType
if (ct === 'access_key') return 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300'
if (ct === 'bearer_token')
return 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-300'
return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
})
// ========== 通用计算属性 ==========
const modalTitle = computed(() =>
props.mode === 'account' ? '账户连通性测试' : 'API Key 端点测试'
)
const modalSubtitle = computed(() => {
if (props.mode === 'account') return props.account?.name || '未知账户'
return props.apiKeyName || '当前 API Key'
})
const headerIconBgClass = computed(() => {
const s = state.testStatus.value
if (s === 'success') return 'bg-gradient-to-br from-green-500 to-emerald-500'
if (s === 'error') return 'bg-gradient-to-br from-red-500 to-pink-500'
return 'bg-gradient-to-br from-blue-500 to-indigo-500'
})
const statusDescription = computed(() => {
const s = state.testStatus.value
const apiName = props.mode === 'account' ? platformLabel.value : apikeyServiceConfig.value.name
if (s === 'idle')
return props.mode === 'account'
? '点击下方按钮开始测试账户连通性'
: '点击下方按钮开始测试 API Key 连通性'
if (s === 'testing') return '正在发送测试请求并等待响应'
if (s === 'success')
return props.mode === 'account' ? `账户可以正常访问 ${apiName}` : 'API Key 可以正常访问服务'
if (s === 'error') return state.errorMessage.value || `无法连接到 ${apiName}`
return ''
})
// ========== 测试逻辑 ==========
const getAccountEndpoint = () => {
if (!props.account) return ''
const platform = props.account.platform
const endpoints = {
claude: `${APP_CONFIG.apiPrefix}/admin/claude-accounts/${props.account.id}/test`,
'claude-console': `${APP_CONFIG.apiPrefix}/admin/claude-console-accounts/${props.account.id}/test`,
bedrock: `${APP_CONFIG.apiPrefix}/admin/bedrock-accounts/${props.account.id}/test`,
gemini: `${APP_CONFIG.apiPrefix}/admin/gemini-accounts/${props.account.id}/test`,
'gemini-api': `${APP_CONFIG.apiPrefix}/admin/gemini-api-accounts/${props.account.id}/test`,
'openai-responses': `${APP_CONFIG.apiPrefix}/admin/openai-responses-accounts/${props.account.id}/test`,
'azure-openai': `${APP_CONFIG.apiPrefix}/admin/azure-openai-accounts/${props.account.id}/test`,
droid: `${APP_CONFIG.apiPrefix}/admin/droid-accounts/${props.account.id}/test`,
ccr: `${APP_CONFIG.apiPrefix}/admin/ccr-accounts/${props.account.id}/test`
}
return endpoints[platform] || ''
}
const startTest = () => {
if (props.mode === 'account') {
const endpoint = getAccountEndpoint()
if (!endpoint) return
const authToken = localStorage.getItem('authToken')
const useSSE = ['claude', 'claude-console', 'bedrock', 'gemini-api'].includes(
props.account.platform
)
state.sendTestRequest(
endpoint,
{ model: selectedModel.value },
{
useSSE,
headers: authToken ? { Authorization: `Bearer ${authToken}` } : {}
}
)
} else {
const endpoint = `${APP_CONFIG.apiPrefix}/apiStats${apikeyServiceConfig.value.endpoint}`
state.sendTestRequest(
endpoint,
{
apiKey: props.apiKeyValue,
model: selectedModel.value,
prompt: testPrompt.value,
maxTokens: maxTokens.value
},
{ useSSE: true }
)
}
}
const handleClose = () => {
if (state.testStatus.value === 'testing') return
state.cleanup()
state.resetState()
emit('close')
}
// ========== 监听 ==========
watch(
() => props.show,
(newVal) => {
if (newVal) {
state.resetState()
selectedModel.value = defaultModel.value
if (props.mode === 'apikey') {
testPrompt.value = 'hi'
maxTokens.value = 1000
}
}
}
)
watch(
() => [props.account, props.serviceType],
() => {
selectedModel.value = defaultModel.value
},
{ deep: true }
)
</script>

View File

@@ -0,0 +1,353 @@
<template>
<div>
<!-- 状态卡片 -->
<div
class="mb-6 rounded-xl border border-gray-200 bg-gradient-to-r from-blue-50 to-indigo-50 p-4 dark:border-gray-700 dark:from-blue-900/20 dark:to-indigo-900/20"
>
<div class="flex flex-wrap items-center justify-between gap-4">
<div class="flex items-center gap-4">
<div
class="flex h-12 w-12 items-center justify-center rounded-xl bg-blue-500/10 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400"
>
<i class="fas fa-coins text-xl" />
</div>
<div>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">
模型总数:
<span class="font-bold text-blue-600 dark:text-blue-400">{{ modelCount }}</span>
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">上次更新: {{ lastUpdated }}</p>
</div>
</div>
<button
:class="[
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium shadow-sm transition',
refreshing
? 'cursor-not-allowed bg-gray-200 text-gray-400 dark:bg-gray-700 dark:text-gray-500'
: 'bg-blue-500 text-white hover:bg-blue-600 hover:shadow-md'
]"
:disabled="refreshing"
@click="handleRefresh"
>
<i :class="['fas', refreshing ? 'fa-spinner fa-spin' : 'fa-sync-alt']" />
{{ refreshing ? '刷新中...' : '立即刷新' }}
</button>
</div>
</div>
<!-- 搜索 + 平台筛选 -->
<div class="mb-4 flex flex-wrap items-center gap-3">
<div class="relative flex-1">
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
v-model="searchQuery"
class="w-full rounded-lg border border-gray-200 bg-white py-2 pl-9 pr-3 text-sm text-gray-700 placeholder-gray-400 transition focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300"
placeholder="搜索模型名称..."
type="text"
/>
</div>
<div class="flex gap-1">
<button
v-for="tab in platformTabs"
:key="tab.key"
:class="[
'rounded-lg px-3 py-2 text-xs font-medium transition',
activePlatform === tab.key
? 'bg-blue-500 text-white shadow-sm'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'
]"
@click="activePlatform = tab.key"
>
{{ tab.label }}
</button>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="py-12 text-center">
<i class="fas fa-spinner fa-spin mb-4 text-2xl text-blue-500" />
<p class="text-gray-500 dark:text-gray-400">加载价格数据中...</p>
</div>
<!-- 表格 -->
<div v-else class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
<table class="min-w-full text-sm">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr>
<th
class="cursor-pointer px-3 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
@click="toggleSort('name')"
>
模型名称
<i
v-if="sortField === 'name'"
:class="['fas ml-1', sortAsc ? 'fa-sort-up' : 'fa-sort-down']"
/>
</th>
<th
class="cursor-pointer px-3 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
@click="toggleSort('input')"
>
输入 $/MTok
<i
v-if="sortField === 'input'"
:class="['fas ml-1', sortAsc ? 'fa-sort-up' : 'fa-sort-down']"
/>
</th>
<th
class="cursor-pointer px-3 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
@click="toggleSort('output')"
>
输出 $/MTok
<i
v-if="sortField === 'output'"
:class="['fas ml-1', sortAsc ? 'fa-sort-up' : 'fa-sort-down']"
/>
</th>
<th
class="hidden px-3 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400 md:table-cell"
>
缓存创建
</th>
<th
class="hidden px-3 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400 md:table-cell"
>
缓存读取
</th>
<th
class="hidden px-3 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400 lg:table-cell"
>
上下文窗口
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900">
<tr
v-for="model in sortedModels"
:key="model.name"
class="transition hover:bg-gray-50 dark:hover:bg-gray-800/50"
>
<td class="whitespace-nowrap px-3 py-2.5">
<div class="font-medium text-gray-900 dark:text-gray-100">{{ model.name }}</div>
<div v-if="model.provider" class="text-xs text-gray-400">{{ model.provider }}</div>
</td>
<td
class="whitespace-nowrap px-3 py-2.5 text-right font-mono text-gray-700 dark:text-gray-300"
>
{{ formatPrice(model.inputCost) }}
</td>
<td
class="whitespace-nowrap px-3 py-2.5 text-right font-mono text-gray-700 dark:text-gray-300"
>
{{ formatPrice(model.outputCost) }}
</td>
<td
class="hidden whitespace-nowrap px-3 py-2.5 text-right font-mono text-gray-500 dark:text-gray-400 md:table-cell"
>
{{ formatPrice(model.cacheCreateCost) }}
</td>
<td
class="hidden whitespace-nowrap px-3 py-2.5 text-right font-mono text-gray-500 dark:text-gray-400 md:table-cell"
>
{{ formatPrice(model.cacheReadCost) }}
</td>
<td
class="hidden whitespace-nowrap px-3 py-2.5 text-right text-gray-500 dark:text-gray-400 lg:table-cell"
>
{{ formatContext(model.maxTokens) }}
</td>
</tr>
<tr v-if="sortedModels.length === 0">
<td class="px-3 py-8 text-center text-gray-500 dark:text-gray-400" colspan="6">
<i class="fas fa-search mb-2 text-2xl text-gray-300 dark:text-gray-600" />
<p>没有匹配的模型</p>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 模型数量统计 -->
<div v-if="!loading" class="mt-3 text-right text-xs text-gray-400 dark:text-gray-500">
显示 {{ sortedModels.length }} / {{ allModels.length }} 个模型
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import {
getModelPricingApi,
getModelPricingStatusApi,
refreshModelPricingApi
} from '@/utils/http_apis'
import { showToast } from '@/utils/tools'
// ========== 状态 ==========
const loading = ref(false)
const refreshing = ref(false)
const pricingData = ref({})
const pricingStatus = ref({})
const searchQuery = ref('')
const activePlatform = ref('all')
const sortField = ref('name')
const sortAsc = ref(true)
const platformTabs = [
{ key: 'all', label: '全部' },
{ key: 'claude', label: 'Claude' },
{ key: 'gemini', label: 'Gemini' },
{ key: 'openai', label: 'OpenAI' },
{ key: 'other', label: '其他' }
]
// ========== 计算属性 ==========
const modelCount = computed(() => Object.keys(pricingData.value).length)
const lastUpdated = computed(() => {
if (!pricingStatus.value.lastUpdated) return '未知'
return new Date(pricingStatus.value.lastUpdated).toLocaleString('zh-CN')
})
const allModels = computed(() =>
Object.entries(pricingData.value).map(([name, data]) => ({
name,
provider: detectProvider(name),
inputCost: (data.input_cost_per_token || 0) * 1e6,
outputCost: (data.output_cost_per_token || 0) * 1e6,
cacheCreateCost: (data.cache_creation_input_token_cost || 0) * 1e6,
cacheReadCost: (data.cache_read_input_token_cost || 0) * 1e6,
maxTokens: data.max_tokens || data.max_output_tokens || 0
}))
)
const filteredModels = computed(() => {
let models = allModels.value
// 平台筛选
if (activePlatform.value !== 'all') {
const platformFilters = {
claude: (n) => n.includes('claude'),
gemini: (n) => n.includes('gemini'),
openai: (n) =>
n.includes('gpt') ||
n.includes('o1') ||
n.includes('o3') ||
n.includes('o4') ||
n.includes('codex'),
other: (n) =>
!n.includes('claude') &&
!n.includes('gemini') &&
!n.includes('gpt') &&
!n.includes('o1') &&
!n.includes('o3') &&
!n.includes('o4') &&
!n.includes('codex')
}
const filter = platformFilters[activePlatform.value]
if (filter) models = models.filter((m) => filter(m.name.toLowerCase()))
}
// 搜索筛选
if (searchQuery.value) {
const q = searchQuery.value.toLowerCase()
models = models.filter((m) => m.name.toLowerCase().includes(q))
}
return models
})
const sortedModels = computed(() => {
const models = [...filteredModels.value]
const fieldMap = {
name: (m) => m.name,
input: (m) => m.inputCost,
output: (m) => m.outputCost
}
const getter = fieldMap[sortField.value]
if (!getter) return models
models.sort((a, b) => {
const va = getter(a)
const vb = getter(b)
if (typeof va === 'string') return sortAsc.value ? va.localeCompare(vb) : vb.localeCompare(va)
return sortAsc.value ? va - vb : vb - va
})
return models
})
// ========== 方法 ==========
const detectProvider = (name) => {
const n = name.toLowerCase()
if (n.includes('claude')) return 'Anthropic'
if (n.includes('gemini')) return 'Google'
if (
n.includes('gpt') ||
n.includes('o1') ||
n.includes('o3') ||
n.includes('o4') ||
n.includes('codex')
)
return 'OpenAI'
if (n.includes('deepseek')) return 'DeepSeek'
if (n.includes('llama') || n.includes('meta')) return 'Meta'
if (n.includes('mistral')) return 'Mistral'
return ''
}
const formatPrice = (price) => {
if (!price || price === 0) return '-'
if (price < 0.01) return `$${price.toFixed(4)}`
if (price < 1) return `$${price.toFixed(3)}`
return `$${price.toFixed(2)}`
}
const formatContext = (tokens) => {
if (!tokens) return '-'
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`
if (tokens >= 1000) return `${(tokens / 1000).toFixed(0)}K`
return String(tokens)
}
const toggleSort = (field) => {
if (sortField.value === field) {
sortAsc.value = !sortAsc.value
} else {
sortField.value = field
sortAsc.value = true
}
}
const loadData = async () => {
loading.value = true
const [pricingResult, statusResult] = await Promise.all([
getModelPricingApi(),
getModelPricingStatusApi()
])
if (pricingResult.success) {
pricingData.value = pricingResult.data
} else {
showToast(pricingResult.message || '加载模型价格失败', 'error')
}
if (statusResult.success) {
pricingStatus.value = statusResult.data
} else {
showToast(statusResult.message || '获取价格状态失败', 'error')
}
loading.value = false
}
const handleRefresh = async () => {
refreshing.value = true
const result = await refreshModelPricingApi()
if (result.success) {
showToast('价格数据已刷新', 'success')
await loadData()
} else {
showToast(result.message || '刷新失败', 'error')
}
refreshing.value = false
}
onMounted(loadData)
</script>

View File

@@ -3,6 +3,13 @@ import request from '@/utils/request'
// 模型 // 模型
export const getModelsApi = () => request({ url: '/apiStats/models', method: 'GET' }) export const getModelsApi = () => request({ url: '/apiStats/models', method: 'GET' })
// 模型价格管理
export const getModelPricingApi = () => request({ url: '/admin/models/pricing', method: 'GET' })
export const getModelPricingStatusApi = () =>
request({ url: '/admin/models/pricing/status', method: 'GET' })
export const refreshModelPricingApi = () =>
request({ url: '/admin/models/pricing/refresh', method: 'POST' })
// API Stats // API Stats
export const getKeyIdApi = (apiKey) => export const getKeyIdApi = (apiKey) =>
request({ url: '/apiStats/api/get-key-id', method: 'POST', data: { apiKey } }) request({ url: '/apiStats/api/get-key-id', method: 'POST', data: { apiKey } })
@@ -37,6 +44,8 @@ export const getRedemptionHistoryByApiIdApi = (apiId, params = {}) =>
// 仪表板 // 仪表板
export const getDashboardApi = () => request({ url: '/admin/dashboard', method: 'GET' }) export const getDashboardApi = () => request({ url: '/admin/dashboard', method: 'GET' })
export const getTempUnavailableApi = () =>
request({ url: '/admin/temp-unavailable', method: 'GET' })
export const getUsageCostsApi = (period) => export const getUsageCostsApi = (period) =>
request({ url: `/admin/usage-costs?period=${period}`, method: 'GET' }) request({ url: `/admin/usage-costs?period=${period}`, method: 'GET' })
export const getUsageStatsApi = (url) => request({ url, method: 'GET' }) export const getUsageStatsApi = (url) => request({ url, method: 'GET' })

View File

@@ -0,0 +1,208 @@
import { ref, computed, onUnmounted } from 'vue'
export const useTestState = () => {
// ========== 状态 ==========
const testStatus = ref('idle') // idle, testing, success, error
const responseText = ref('')
const errorMessage = ref('')
const testDuration = ref(0)
const testStartTime = ref(null)
const abortController = ref(null)
// ========== 状态样式计算属性 ==========
const statusStyleMap = {
idle: {
title: '准备就绪',
card: 'border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50',
iconBg: 'bg-gray-200 dark:bg-gray-700',
icon: 'fa-hourglass-start',
iconColor: 'text-gray-500 dark:text-gray-400',
text: 'text-gray-700 dark:text-gray-300'
},
testing: {
title: '正在测试...',
card: 'border-blue-200 bg-blue-50 dark:border-blue-500/30 dark:bg-blue-900/20',
iconBg: 'bg-blue-100 dark:bg-blue-500/30',
icon: 'fa-spinner fa-spin',
iconColor: 'text-blue-500 dark:text-blue-400',
text: 'text-blue-700 dark:text-blue-300'
},
success: {
title: '测试成功',
card: 'border-green-200 bg-green-50 dark:border-green-500/30 dark:bg-green-900/20',
iconBg: 'bg-green-100 dark:bg-green-500/30',
icon: 'fa-check-circle',
iconColor: 'text-green-500 dark:text-green-400',
text: 'text-green-700 dark:text-green-300'
},
error: {
title: '测试失败',
card: 'border-red-200 bg-red-50 dark:border-red-500/30 dark:bg-red-900/20',
iconBg: 'bg-red-100 dark:bg-red-500/30',
icon: 'fa-exclamation-circle',
iconColor: 'text-red-500 dark:text-red-400',
text: 'text-red-700 dark:text-red-300'
}
}
const currentStyle = computed(() => statusStyleMap[testStatus.value] || statusStyleMap.idle)
const statusTitle = computed(() => currentStyle.value.title)
const statusCardClass = computed(() => currentStyle.value.card)
const statusIconBgClass = computed(() => currentStyle.value.iconBg)
const statusIcon = computed(() => currentStyle.value.icon)
const statusIconClass = computed(() => currentStyle.value.iconColor)
const statusTextClass = computed(() => currentStyle.value.text)
// ========== SSE 事件处理 ==========
const handleSSEEvent = (data) => {
switch (data.type) {
case 'test_start':
break
case 'content':
responseText.value += data.text
break
case 'message_stop':
break
case 'test_complete':
testDuration.value = Date.now() - testStartTime.value
if (data.success) {
testStatus.value = 'success'
} else {
testStatus.value = 'error'
errorMessage.value = data.error || '测试失败'
}
break
case 'error':
testStatus.value = 'error'
errorMessage.value = data.error || '未知错误'
testDuration.value = Date.now() - testStartTime.value
break
}
}
// ========== SSE 流读取 ==========
const readSSEStream = async (response) => {
const reader = response.body.getReader()
const decoder = new TextDecoder()
let streamDone = false
let buffer = ''
while (!streamDone) {
const { done, value } = await reader.read()
if (done) {
streamDone = true
// 处理缓冲区中剩余的数据
if (buffer.trim()) {
processSSELine(buffer)
}
continue
}
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
// 最后一行可能不完整,保留在缓冲区
buffer = lines.pop() || ''
for (const line of lines) {
processSSELine(line)
}
}
}
const processSSELine = (line) => {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.substring(6))
handleSSEEvent(data)
} catch {
// 忽略解析错误
}
}
}
// ========== 通用测试请求 ==========
const sendTestRequest = async (endpoint, payload, options = {}) => {
const { useSSE = true, headers = {} } = options
// 重置状态
testStatus.value = 'testing'
responseText.value = ''
errorMessage.value = ''
testDuration.value = 0
testStartTime.value = Date.now()
// 取消之前的请求
if (abortController.value) {
abortController.value.abort()
}
abortController.value = new AbortController()
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify(payload),
signal: abortController.value.signal
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.message || errorData.error || `HTTP ${response.status}`)
}
if (useSSE) {
await readSSEStream(response)
} else {
// JSON 响应
const data = await response.json()
testDuration.value = Date.now() - testStartTime.value
if (data.success) {
testStatus.value = 'success'
responseText.value = data.data?.responseText || 'Test passed'
} else {
testStatus.value = 'error'
errorMessage.value = data.message || 'Test failed'
}
}
} catch (err) {
if (err.name === 'AbortError') return
testStatus.value = 'error'
errorMessage.value = err.message || '连接失败'
testDuration.value = Date.now() - testStartTime.value
}
}
// ========== 重置 + 清理 ==========
const resetState = () => {
testStatus.value = 'idle'
responseText.value = ''
errorMessage.value = ''
testDuration.value = 0
testStartTime.value = null
}
const cleanup = () => {
if (abortController.value) {
abortController.value.abort()
abortController.value = null
}
}
onUnmounted(cleanup)
return {
testStatus,
responseText,
errorMessage,
testDuration,
statusTitle,
statusCardClass,
statusIconBgClass,
statusIcon,
statusIconClass,
statusTextClass,
sendTestRequest,
resetState,
cleanup
}
}

View File

@@ -750,6 +750,23 @@
>({{ formatRateLimitTime(account.rateLimitStatus.minutesRemaining) }})</span >({{ formatRateLimitTime(account.rateLimitStatus.minutesRemaining) }})</span
> >
</span> </span>
<span
v-if="account.tempUnavailable"
class="inline-flex items-center rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-800 dark:bg-amber-900/30 dark:text-amber-300"
>
<i class="fas fa-clock mr-1" />
临时暂停
<span v-if="account.tempUnavailable.ttl > 0"
>({{ formatTempUnavailableTime(account.tempUnavailable.ttl) }})</span
>
<el-tooltip
:content="`${account.tempUnavailable.errorType} (HTTP ${account.tempUnavailable.statusCode})`"
effect="dark"
placement="top"
>
<i class="fas fa-info-circle ml-1 cursor-help" />
</el-tooltip>
</span>
<span <span
v-if="account.schedulable === false" v-if="account.schedulable === false"
class="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-semibold text-gray-700" class="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-semibold text-gray-700"
@@ -1991,8 +2008,9 @@
/> />
<!-- 账户测试弹窗 --> <!-- 账户测试弹窗 -->
<AccountTestModal <UnifiedTestModal
:account="testingAccount" :account="testingAccount"
mode="account"
:show="showAccountTestModal" :show="showAccountTestModal"
@close="closeAccountTestModal" @close="closeAccountTestModal"
/> />
@@ -2170,7 +2188,7 @@ import AccountForm from '@/components/accounts/AccountForm.vue'
import CcrAccountForm from '@/components/accounts/CcrAccountForm.vue' import CcrAccountForm from '@/components/accounts/CcrAccountForm.vue'
import AccountUsageDetailModal from '@/components/accounts/AccountUsageDetailModal.vue' import AccountUsageDetailModal from '@/components/accounts/AccountUsageDetailModal.vue'
import AccountExpiryEditModal from '@/components/accounts/AccountExpiryEditModal.vue' import AccountExpiryEditModal from '@/components/accounts/AccountExpiryEditModal.vue'
import AccountTestModal from '@/components/accounts/AccountTestModal.vue' import UnifiedTestModal from '@/components/common/UnifiedTestModal.vue'
import AccountScheduledTestModal from '@/components/accounts/AccountScheduledTestModal.vue' import AccountScheduledTestModal from '@/components/accounts/AccountScheduledTestModal.vue'
import ConfirmModal from '@/components/common/ConfirmModal.vue' import ConfirmModal from '@/components/common/ConfirmModal.vue'
import CustomDropdown from '@/components/common/CustomDropdown.vue' import CustomDropdown from '@/components/common/CustomDropdown.vue'
@@ -2489,7 +2507,10 @@ const showResetButton = (account) => {
'openai-responses', 'openai-responses',
'gemini', 'gemini',
'gemini-api', 'gemini-api',
'ccr' 'ccr',
'droid',
'bedrock',
'azure-openai'
] ]
return ( return (
supportedPlatforms.includes(account.platform) && supportedPlatforms.includes(account.platform) &&
@@ -2497,6 +2518,7 @@ const showResetButton = (account) => {
account.status !== 'active' || account.status !== 'active' ||
account.rateLimitStatus?.isRateLimited || account.rateLimitStatus?.isRateLimited ||
account.rateLimitStatus === 'limited' || account.rateLimitStatus === 'limited' ||
account.tempUnavailable ||
!account.isActive) !account.isActive)
) )
} }
@@ -2596,6 +2618,7 @@ const supportedTestPlatforms = [
'claude-console', 'claude-console',
'bedrock', 'bedrock',
'gemini', 'gemini',
'gemini-api',
'openai-responses', 'openai-responses',
'azure-openai', 'azure-openai',
'droid', 'droid',
@@ -3305,6 +3328,39 @@ const loadAccounts = async (forceReload = false) => {
} }
}) })
// 获取临时不可用状态并附加到账户数据
try {
const tempRes = await httpApis.getTempUnavailableApi()
if (tempRes?.success && tempRes.data) {
const tempStatuses = tempRes.data
filteredAccounts = filteredAccounts.map((account) => {
// 尝试匹配 accountType:accountId
const platformTypeMap = {
claude: 'claude-official',
'claude-console': 'claude-console',
bedrock: 'bedrock',
gemini: 'gemini',
'gemini-api': 'gemini-api',
openai: 'openai',
'openai-responses': 'openai-responses',
ccr: 'ccr',
droid: 'droid',
azure_openai: 'azure-openai',
'azure-openai': 'azure-openai'
}
const accountType = platformTypeMap[account.platform] || account.platform
const key = `${accountType}:${account.id}`
const tempStatus = tempStatuses[key]
if (tempStatus) {
return { ...account, tempUnavailable: tempStatus }
}
return account
})
}
} catch {
// 忽略错误,不影响账户列表显示
}
accounts.value = filteredAccounts accounts.value = filteredAccounts
cleanupSelectedAccounts() cleanupSelectedAccounts()
@@ -3588,6 +3644,16 @@ const formatRateLimitTime = (minutes) => {
} }
} }
// 格式化临时暂停剩余时间(秒 → 可读格式)
const formatTempUnavailableTime = (seconds) => {
if (!seconds || seconds <= 0) return ''
seconds = Math.floor(seconds)
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
if (mins > 0) return `${mins}m${secs > 0 ? secs + 's' : ''}`
return `${secs}s`
}
// 检查账户是否被限流 // 检查账户是否被限流
const isAccountRateLimited = (account) => { const isAccountRateLimited = (account) => {
if (!account) return false if (!account) return false
@@ -3868,6 +3934,10 @@ const resetAccountStatus = async (account) => {
endpoint = `/admin/gemini-api-accounts/${account.id}/reset-status` endpoint = `/admin/gemini-api-accounts/${account.id}/reset-status`
} else if (account.platform === 'gemini') { } else if (account.platform === 'gemini') {
endpoint = `/admin/gemini-accounts/${account.id}/reset-status` endpoint = `/admin/gemini-accounts/${account.id}/reset-status`
} else if (account.platform === 'bedrock') {
endpoint = `/admin/bedrock-accounts/${account.id}/reset-status`
} else if (account.platform === 'azure-openai') {
endpoint = `/admin/azure-openai-accounts/${account.id}/reset-status`
} else { } else {
showToast('不支持的账户类型', 'error') showToast('不支持的账户类型', 'error')
account.isResetting = false account.isResetting = false

View File

@@ -468,9 +468,10 @@
</div> </div>
<!-- API Key 测试弹窗 --> <!-- API Key 测试弹窗 -->
<ApiKeyTestModal <UnifiedTestModal
:api-key-name="statsData?.name || ''" :api-key-name="statsData?.name || ''"
:api-key-value="apiKey" :api-key-value="apiKey"
mode="apikey"
:service-type="testServiceType" :service-type="testServiceType"
:show="showTestModal" :show="showTestModal"
@close="closeTestModal" @close="closeTestModal"
@@ -542,7 +543,7 @@ import AggregatedStatsCard from '@/components/apistats/AggregatedStatsCard.vue'
import ModelUsageStats from '@/components/apistats/ModelUsageStats.vue' import ModelUsageStats from '@/components/apistats/ModelUsageStats.vue'
import ServiceCostCards from '@/components/apistats/ServiceCostCards.vue' import ServiceCostCards from '@/components/apistats/ServiceCostCards.vue'
import TutorialView from './TutorialView.vue' import TutorialView from './TutorialView.vue'
import ApiKeyTestModal from '@/components/apikeys/ApiKeyTestModal.vue' import UnifiedTestModal from '@/components/common/UnifiedTestModal.vue'
const route = useRoute() const route = useRoute()
const apiStatsStore = useApiStatsStore() const apiStatsStore = useApiStatsStore()

View File

@@ -60,6 +60,18 @@
<i class="fas fa-balance-scale mr-2"></i> <i class="fas fa-balance-scale mr-2"></i>
服务倍率 服务倍率
</button> </button>
<button
:class="[
'border-b-2 pb-2 text-sm font-medium transition-colors',
activeSection === 'modelPricing'
? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
]"
@click="activeSection = 'modelPricing'"
>
<i class="fas fa-coins mr-2"></i>
模型价格
</button>
</nav> </nav>
</div> </div>
@@ -1206,6 +1218,11 @@
</div> </div>
</div> </div>
</div> </div>
<!-- 模型价格部分 -->
<div v-show="activeSection === 'modelPricing'">
<ModelPricingSection />
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -1807,6 +1824,7 @@ import { useSettingsStore } from '@/stores/settings'
import * as httpApis from '@/utils/http_apis' import * as httpApis from '@/utils/http_apis'
import ConfirmModal from '@/components/common/ConfirmModal.vue' import ConfirmModal from '@/components/common/ConfirmModal.vue'
import ModelPricingSection from '@/components/settings/ModelPricingSection.vue'
// 定义组件名称用于keep-alive排除 // 定义组件名称用于keep-alive排除
defineOptions({ defineOptions({