mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-03-29 21:56:18 +00:00
1
This commit is contained in:
@@ -155,6 +155,12 @@ DEBUG_HTTP_TRAFFIC=false # 启用HTTP请求/响应调试日志(仅开发环
|
||||
ENABLE_CORS=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
|
||||
|
||||
|
||||
724
CLAUDE.md
724
CLAUDE.md
@@ -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.
|
||||
|
||||
这个文件为 Claude Code (claude.ai/code) 提供在此代码库中工作的指导。
|
||||
This file provides guidance to Claude Code when working with this repository.
|
||||
|
||||
## 项目概述
|
||||
|
||||
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
|
||||
- **Token管理**: 自动监控OAuth token过期并刷新,支持10秒提前刷新策略
|
||||
- **代理支持**: 每个账户支持独立代理配置,OAuth token交换也通过代理进行
|
||||
- **数据加密**: 敏感数据(refreshToken, accessToken, credentials)使用AES加密存储在Redis
|
||||
- **粘性会话**: 支持会话级别的账户绑定,同一会话使用同一账户,确保上下文连续性
|
||||
- **权限控制**: API Key支持权限配置(all/claude/gemini/openai等),控制可访问的服务类型
|
||||
- **客户端限制**: 基于User-Agent的客户端识别和限制,支持ClaudeCode、Gemini-CLI等预定义客户端
|
||||
- **模型黑名单**: 支持API Key级别的模型访问限制
|
||||
- **并发请求排队**: 当API Key并发数超限时,请求进入队列等待而非立即返回429,支持配置最大排队数、超时时间,适用于Claude Code Agent并行工具调用场景
|
||||
| 层级 | 目录 | 职责 |
|
||||
|------|------|------|
|
||||
| **框架层** | `src/routes/`, `src/middleware/` | HTTP 路由、请求验证、响应格式化 |
|
||||
| **接口适配层** | `src/handlers/`, `src/services/openaiToClaude.js` | 请求/响应格式转换 |
|
||||
| **用例层** | `src/services/*Scheduler.js`, `*RelayService.js` | 调度逻辑、转发编排 |
|
||||
| **实体层** | `src/services/*AccountService.js`, `src/models/` | 账户管理、数据模型 |
|
||||
| **基础设施层** | `src/utils/`, `config/` | 日志、缓存、加密、代理 |
|
||||
|
||||
### 主要服务组件
|
||||
### 开发原则
|
||||
|
||||
#### 核心转发服务
|
||||
- **依赖方向**: 外层 → 内层,内层不知道外层存在
|
||||
- **新增路由**: 只做参数提取和响应格式化,业务逻辑放 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**: 账户组管理,支持账户分组和优先级
|
||||
## 项目结构
|
||||
|
||||
#### 统一调度器
|
||||
|
||||
- **unifiedClaudeScheduler.js**: Claude多账户类型统一调度(claude-official/console/bedrock/ccr)
|
||||
- **unifiedGeminiScheduler.js**: Gemini账户统一调度
|
||||
- **unifiedOpenAIScheduler.js**: OpenAI兼容服务统一调度
|
||||
- **droidScheduler.js**: Droid账户调度
|
||||
|
||||
#### 核心功能服务
|
||||
|
||||
- **apiKeyService.js**: API Key管理,验证、限流、使用统计、成本计算
|
||||
- **userService.js**: 用户管理系统,支持用户注册、登录、API Key管理
|
||||
- **userMessageQueueService.js**: 用户消息串行队列,防止同账户并发用户消息触发限流
|
||||
- **pricingService.js**: 定价服务,模型价格管理和成本计算
|
||||
- **costInitService.js**: 成本数据初始化服务
|
||||
- **webhookService.js**: Webhook通知服务
|
||||
- **webhookConfigService.js**: Webhook配置管理
|
||||
- **ldapService.js**: LDAP认证服务
|
||||
- **tokenRefreshService.js**: Token自动刷新服务
|
||||
- **rateLimitCleanupService.js**: 速率限制状态清理服务
|
||||
- **claudeCodeHeadersService.js**: Claude Code客户端请求头处理
|
||||
|
||||
#### 工具服务
|
||||
|
||||
- **oauthHelper.js**: OAuth工具,PKCE流程实现和代理支持
|
||||
- **workosOAuthHelper.js**: WorkOS OAuth集成
|
||||
- **openaiToClaude.js**: OpenAI格式到Claude格式的转换
|
||||
|
||||
### 认证和代理流程
|
||||
|
||||
1. 客户端使用自建API Key(cr\_前缀格式)发送请求到对应路由(/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. 通过账户配置的代理发送到目标API(Anthropic、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 # 自动生成密钥并创建管理员账户
|
||||
```
|
||||
src/
|
||||
├── routes/ # HTTP 路由
|
||||
│ ├── api.js # Claude API 主路由
|
||||
│ ├── admin/ # 管理后台路由(24个子文件)
|
||||
│ ├── geminiRoutes.js, standardGeminiRoutes.js
|
||||
│ ├── openaiRoutes.js, openaiClaudeRoutes.js, openaiGeminiRoutes.js
|
||||
│ ├── azureOpenaiRoutes.js, droidRoutes.js
|
||||
│ ├── userRoutes.js, webhook.js, unified.js, apiStats.js, web.js
|
||||
├── middleware/ # auth.js(认证/权限/限流), browserFallback.js
|
||||
├── handlers/ # geminiHandlers.js
|
||||
├── services/ # 业务服务
|
||||
│ ├── relay/ # 各平台转发服务(9个)
|
||||
│ ├── account/ # 各平台账户管理(11个)
|
||||
│ ├── scheduler/ # 统一调度器(4个)
|
||||
│ ├── apiKeyService.js # API Key 管理
|
||||
│ ├── pricingService.js # 定价和成本
|
||||
│ └── ... # 其余 ~30 个业务服务
|
||||
├── models/redis.js # Redis 数据模型
|
||||
├── utils/ # 35+ 工具文件(logger, proxy, oauth, cache, stream...)
|
||||
config/config.js # 主配置
|
||||
scripts/ # 运维脚本
|
||||
cli/ # CLI 工具
|
||||
web/admin-spa/ # Vue SPA 管理界面
|
||||
data/init.json # 管理员凭据
|
||||
```
|
||||
|
||||
## Web界面功能
|
||||
## 核心请求流程
|
||||
|
||||
### OAuth账户添加流程
|
||||
```
|
||||
客户端(cr_前缀Key) → 路由 → auth中间件(验证/权限/限流/模型黑名单)
|
||||
→ 统一调度器(选账户/粘性会话) → Token检查/刷新
|
||||
→ 转发服务(通过代理发送) → 上游API
|
||||
→ 流式/非流式响应 → Usage捕获 → 成本计算 → 返回客户端
|
||||
```
|
||||
|
||||
1. **基本信息和代理设置**: 配置账户名称、描述和代理参数
|
||||
2. **OAuth授权**:
|
||||
- 生成授权URL → 用户打开链接并登录Claude Code账号
|
||||
- 授权后会显示Authorization Code → 复制并粘贴到输入框
|
||||
- 系统自动交换token并创建账户
|
||||
关键机制:
|
||||
- **粘性会话**: 基于请求内容 hash 绑定账户,同一会话用同一账户
|
||||
- **并发控制**: Redis Sorted Set 实现,支持排队等待(非直接 429)
|
||||
- **529 处理**: 自动标记过载账户,配置时长内排除
|
||||
- **加密存储**: 敏感数据(OAuth token、credentials)AES 加密存于 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)
|
||||
- **缓存监控**: 解密缓存统计和性能监控
|
||||
### 代码格式化
|
||||
|
||||
## 重要端点
|
||||
|
||||
### API转发端点(多路由支持)
|
||||
|
||||
#### 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` 确认服务正常运行
|
||||
- 注意:当前项目缺少实际测试文件,建议补充单元测试和集成测试
|
||||
- **必须使用 Prettier**: `npx prettier --write <file>`
|
||||
- 前端额外安装了 `prettier-plugin-tailwindcss`
|
||||
- 提交前检查:`npx prettier --check <file>`
|
||||
|
||||
### 开发工作流
|
||||
|
||||
- **功能开发**: 始终从理解现有代码开始,重用已有的服务和模式
|
||||
- **调试流程**: 使用 Winston 日志 + Web 界面实时日志查看 + CLI 状态工具
|
||||
- **代码审查**: 关注安全性(加密存储)、性能(异步处理)、错误处理
|
||||
- **部署前检查**: 运行 lint → 测试 CLI 功能 → 检查日志 → Docker 构建
|
||||
1. **理解现有代码** → 读相关文件,了解现有模式
|
||||
2. **编写代码** → 重用已有服务和工具函数
|
||||
3. **格式化** → `npx prettier --write <修改的文件>`
|
||||
4. **检查** → `npm run lint`
|
||||
5. **测试** → `npm test`
|
||||
6. **验证** → `npm run cli status` 确认服务正常
|
||||
|
||||
### 常见文件位置
|
||||
### 前端要求
|
||||
|
||||
- 核心服务逻辑:`src/services/` 目录(30+服务文件)
|
||||
- 路由处理:`src/routes/` 目录(api.js、admin.js、geminiRoutes.js、openaiRoutes.js等13个路由文件)
|
||||
- 中间件:`src/middleware/` 目录(auth.js、browserFallback.js、debugInterceptor.js等)
|
||||
- 配置管理:`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/`(各类日志文件)
|
||||
- 响应式设计:Tailwind CSS 响应式前缀(sm:、md:、lg:、xl:)
|
||||
- 暗黑模式:所有组件必须兼容,使用 `dark:` 前缀
|
||||
- 主题切换:`web/admin-spa/src/stores/theme.js` 的 `useThemeStore()`
|
||||
- 保持现有玻璃态设计风格
|
||||
|
||||
### 重要架构决策
|
||||
暗黑模式配色对照:
|
||||
|
||||
- **统一调度系统**: 使用统一调度器(unifiedClaudeScheduler等)实现跨账户类型的智能调度,支持粘性会话、负载均衡、故障转移
|
||||
- **多账户类型支持**: 支持8种账户类型(claude-official、claude-console、bedrock、ccr、droid、gemini、openai-responses、azure-openai)
|
||||
- **加密存储**: 所有敏感数据(OAuth token、refreshToken、credentials)都使用 AES 加密存储在 Redis
|
||||
- **独立代理**: 每个账户支持独立的代理配置(SOCKS5/HTTP),包括OAuth授权流程
|
||||
- **API Key哈希**: 使用SHA-256哈希存储,支持自定义前缀(默认 `cr_`)
|
||||
- **权限系统**: API Key支持细粒度权限控制(all/claude/gemini/openai等)
|
||||
- **请求流程**: 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错误自动标记账户过载状态,配置时长内自动排除该账户
|
||||
| 元素 | 明亮模式 | 暗黑模式 |
|
||||
|------|----------|----------|
|
||||
| 文本 | `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` | 保持一致 |
|
||||
|
||||
### 核心数据流和性能优化
|
||||
### 代码修改原则
|
||||
|
||||
- **哈希映射优化**: API Key 验证从 O(n) 优化到 O(1) 查找
|
||||
- **智能 Usage 捕获**: 从 SSE 流中解析真实的 token 使用数据
|
||||
- **多维度统计**: 支持按时间、模型、用户的实时使用统计
|
||||
- **异步处理**: 非阻塞的统计记录和日志写入
|
||||
- **原子操作**: Redis 管道操作确保数据一致性
|
||||
- 先检查现有模式和风格,重用已有服务和工具函数
|
||||
- 敏感数据必须加密存储(参考 claudeAccountService.js)
|
||||
- 遵循现有的错误处理和日志记录模式
|
||||
|
||||
### 安全和容错机制
|
||||
|
||||
- **多层加密**: 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
|
||||
# API Key管理
|
||||
npm run cli keys create -- --name "MyApp" --limit 1000
|
||||
npm run cli keys list
|
||||
npm run cli keys delete -- --id <keyId>
|
||||
npm run cli keys update -- --id <keyId> --limit 2000
|
||||
|
||||
# 系统状态查看
|
||||
npm run cli status # 查看系统概况
|
||||
npm run status # 统一状态脚本
|
||||
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 # 增强监控脚本
|
||||
npm install && npm run setup # 初始化
|
||||
npm run dev # 开发模式(热重载)
|
||||
npm start # 生产模式
|
||||
npm run lint # ESLint 检查
|
||||
npm test # Jest + SuperTest
|
||||
docker-compose up -d # Docker 部署
|
||||
npm run cli status # 系统状态
|
||||
npm run data:export # 导出 Redis 数据
|
||||
npm run data:debug # 调试 Redis 键
|
||||
```
|
||||
|
||||
## 环境变量(必须)
|
||||
|
||||
- `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
|
||||
|
||||
Do what has been asked; nothing more, nothing less.
|
||||
NEVER create files unless they're absolutely necessary for achieving your goal.
|
||||
ALWAYS prefer editing an existing file to creating a new one.
|
||||
NEVER proactively create documentation files (\*.md) or README files. Only create documentation files if explicitly requested by the User.
|
||||
````
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"keep": {
|
||||
"days": false,
|
||||
"amount": 5
|
||||
},
|
||||
"auditLog": "D:\\code\\github\\claude-relay-service\\logs\\.claude-relay-audit.log.json",
|
||||
"files": [
|
||||
{
|
||||
"date": 1769443203308,
|
||||
"name": "D:\\code\\github\\claude-relay-service\\logs\\claude-relay-2026-01-27.log",
|
||||
"hash": "2d09cc308d32c20207a0bf4eb0ae7aa7f25485b5cf2fee4dfce4be9ff2db8055"
|
||||
},
|
||||
{
|
||||
"date": 1770353006077,
|
||||
"name": "D:\\code\\github\\claude-relay-service\\logs\\claude-relay-2026-02-06.log",
|
||||
"hash": "b370804c9b7f59563bcbbdb8bb2a920bd5711af9f25af77d50cb78e11c482e34"
|
||||
},
|
||||
{
|
||||
"date": 1770535480737,
|
||||
"name": "D:\\code\\github\\claude-relay-service\\logs\\claude-relay-2026-02-08.log",
|
||||
"hash": "641ed3aaf8fe8003a5ed1ebb2331bf18f6957f26bd084e74d33b54aa69a5ff69"
|
||||
},
|
||||
{
|
||||
"date": 1770566683359,
|
||||
"name": "D:\\code\\github\\claude-relay-service\\logs\\claude-relay-2026-02-09.log",
|
||||
"hash": "66a23ce0a7addb428d1f03594856b909a5660cf19574ab4b7cedf42aa48574f5"
|
||||
},
|
||||
{
|
||||
"date": 1770623291843,
|
||||
"name": "/mnt/d/code/github/claude-relay-service/logs/claude-relay-2026-02-09.log",
|
||||
"hash": "261a1c288f8f0a2b394c970ece8d005240366bccac0cfe9f7f90c44deb8ea8da"
|
||||
}
|
||||
],
|
||||
"hashType": "sha256"
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"keep": {
|
||||
"days": false,
|
||||
"amount": 5
|
||||
},
|
||||
"auditLog": "D:\\code\\github\\claude-relay-service\\logs\\.claude-relay-auth-detail-audit.log.json",
|
||||
"files": [
|
||||
{
|
||||
"date": 1769440584327,
|
||||
"name": "D:\\code\\github\\claude-relay-service\\logs\\claude-relay-auth-detail-2026-01-26.log",
|
||||
"hash": "08a424abf9d6edc0047328d0c2ca6c1f377b61c71ce13d4bf5226719ea0ac4a6"
|
||||
},
|
||||
{
|
||||
"date": 1770353006082,
|
||||
"name": "D:\\code\\github\\claude-relay-service\\logs\\claude-relay-auth-detail-2026-02-06.log",
|
||||
"hash": "94960d294f44af312596a8c363a42f21d4ac14d6641bf0d41ce6a7892a72777d"
|
||||
},
|
||||
{
|
||||
"date": 1770535480742,
|
||||
"name": "D:\\code\\github\\claude-relay-service\\logs\\claude-relay-auth-detail-2026-02-08.log",
|
||||
"hash": "2c8ab04264ba558d4f288851d4ecc293f3ca6f1f44a6b1a7fa8572372cc222bc"
|
||||
},
|
||||
{
|
||||
"date": 1770568187072,
|
||||
"name": "D:\\code\\github\\claude-relay-service\\logs\\claude-relay-auth-detail-2026-02-09.log",
|
||||
"hash": "2e53b7737b1c159bb0767a53ec6840e5fb40632bc09e75d68b436e709ae77e48"
|
||||
},
|
||||
{
|
||||
"date": 1770623291858,
|
||||
"name": "/mnt/d/code/github/claude-relay-service/logs/claude-relay-auth-detail-2026-02-09.log",
|
||||
"hash": "59fdda7c9d8d3fead2b5caef37285278e5b2f7d761aabeaf7809b0e312fde2bc"
|
||||
}
|
||||
],
|
||||
"hashType": "sha256"
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"keep": {
|
||||
"days": false,
|
||||
"amount": 5
|
||||
},
|
||||
"auditLog": "D:\\code\\github\\claude-relay-service\\logs\\.claude-relay-error-audit.log.json",
|
||||
"files": [
|
||||
{
|
||||
"date": 1769440584323,
|
||||
"name": "D:\\code\\github\\claude-relay-service\\logs\\claude-relay-error-2026-01-26.log",
|
||||
"hash": "8370bcc61ba4622611d3962dabf9e174600f5d3ac541e6ec3e3fab9f25557d39"
|
||||
},
|
||||
{
|
||||
"date": 1770353006079,
|
||||
"name": "D:\\code\\github\\claude-relay-service\\logs\\claude-relay-error-2026-02-06.log",
|
||||
"hash": "7ef9cbd6923d8439948c9933ccdcc1969bf574b5bf3ce363df613278e9fe0ff7"
|
||||
},
|
||||
{
|
||||
"date": 1770535480739,
|
||||
"name": "D:\\code\\github\\claude-relay-service\\logs\\claude-relay-error-2026-02-08.log",
|
||||
"hash": "003cd6b8b6d1b690ecc5db32d12c6bf928ac063292d0411191d38b5e353e03f7"
|
||||
},
|
||||
{
|
||||
"date": 1770568187069,
|
||||
"name": "D:\\code\\github\\claude-relay-service\\logs\\claude-relay-error-2026-02-09.log",
|
||||
"hash": "3e1b5ef731712188fb42b1f8dfdfcfb3938ce4c735007d064586286bad026978"
|
||||
},
|
||||
{
|
||||
"date": 1770623291848,
|
||||
"name": "/mnt/d/code/github/claude-relay-service/logs/claude-relay-error-2026-02-09.log",
|
||||
"hash": "0ab72bd08dff65ef26beb0df51cabfd29a6d71bd4b65c609496a06c00e939718"
|
||||
}
|
||||
],
|
||||
"hashType": "sha256"
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"keep": {
|
||||
"days": false,
|
||||
"amount": 5
|
||||
},
|
||||
"auditLog": "D:\\code\\github\\claude-relay-service\\logs\\.claude-relay-security-audit.log.json",
|
||||
"files": [
|
||||
{
|
||||
"date": 1769443932546,
|
||||
"name": "D:\\code\\github\\claude-relay-service\\logs\\claude-relay-security-2026-01-27.log",
|
||||
"hash": "8fd27ee13e9c6f8c3f20ce2dd84292d0ba2b132ae3c5a67e86c3ebb212efe36d"
|
||||
},
|
||||
{
|
||||
"date": 1770353006080,
|
||||
"name": "D:\\code\\github\\claude-relay-service\\logs\\claude-relay-security-2026-02-06.log",
|
||||
"hash": "4efd558cc9ecacc592809085db8e0014db2285fa05dd06a4476fdefc66fcef06"
|
||||
},
|
||||
{
|
||||
"date": 1770535480741,
|
||||
"name": "D:\\code\\github\\claude-relay-service\\logs\\claude-relay-security-2026-02-08.log",
|
||||
"hash": "59e5ca0add7eccb588c701630ef109e319fa617a9eed87986a9c8ea117fecb4b"
|
||||
},
|
||||
{
|
||||
"date": 1770567313766,
|
||||
"name": "D:\\code\\github\\claude-relay-service\\logs\\claude-relay-security-2026-02-09.log",
|
||||
"hash": "5f58e9eb8dda2a1bde5e2573152c310e76716e8856072d9d32746e0561cdeb23"
|
||||
},
|
||||
{
|
||||
"date": 1770623291852,
|
||||
"name": "/mnt/d/code/github/claude-relay-service/logs/claude-relay-security-2026-02-09.log",
|
||||
"hash": "4f0265e9f8b3322ae3929aec511e4baf4473f784f5e75cbfa110a9e1f6e3e6f4"
|
||||
}
|
||||
],
|
||||
"hashType": "sha256"
|
||||
}
|
||||
@@ -11,8 +11,8 @@ const path = require('path')
|
||||
|
||||
const redis = require('../src/models/redis')
|
||||
const apiKeyService = require('../src/services/apiKeyService')
|
||||
const claudeAccountService = require('../src/services/claudeAccountService')
|
||||
const bedrockAccountService = require('../src/services/bedrockAccountService')
|
||||
const claudeAccountService = require('../src/services/account/claudeAccountService')
|
||||
const bedrockAccountService = require('../src/services/account/bedrockAccountService')
|
||||
|
||||
const program = new Command()
|
||||
|
||||
|
||||
@@ -228,6 +228,14 @@ const config = {
|
||||
enabled: process.env.QUOTA_CARD_LIMITS_ENABLED !== 'false', // 默认启用
|
||||
maxExpiryDays: parseInt(process.env.QUOTA_CARD_MAX_EXPIRY_DAYS) || 90, // 最大有效期距今天数
|
||||
maxTotalCostLimit: parseFloat(process.env.QUOTA_CARD_MAX_TOTAL_COST_LIMIT) || 1000 // 最大总额度(美元)
|
||||
},
|
||||
|
||||
// ⏱️ 上游错误自动暂停配置
|
||||
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超时暂停秒数
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,13 @@ const OPENAI_MODELS = [
|
||||
{ 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 = [
|
||||
{ value: 'deepseek-chat', label: 'DeepSeek Chat' },
|
||||
@@ -43,11 +50,25 @@ const OTHER_MODELS = [
|
||||
{ value: 'GLM', label: 'GLM' }
|
||||
]
|
||||
|
||||
// 各平台测试可用模型
|
||||
const PLATFORM_TEST_MODELS = {
|
||||
claude: CLAUDE_MODELS,
|
||||
'claude-console': CLAUDE_MODELS,
|
||||
bedrock: BEDROCK_MODELS,
|
||||
gemini: GEMINI_MODELS,
|
||||
'openai-responses': OPENAI_MODELS,
|
||||
'azure-openai': [],
|
||||
droid: CLAUDE_MODELS,
|
||||
ccr: CLAUDE_MODELS
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CLAUDE_MODELS,
|
||||
GEMINI_MODELS,
|
||||
OPENAI_MODELS,
|
||||
BEDROCK_MODELS,
|
||||
OTHER_MODELS,
|
||||
PLATFORM_TEST_MODELS,
|
||||
// 按服务分组
|
||||
getModelsByService: (service) => {
|
||||
switch (service) {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
const redis = require('../src/models/redis')
|
||||
const claudeAccountService = require('../src/services/claudeAccountService')
|
||||
const claudeAccountService = require('../src/services/account/claudeAccountService')
|
||||
const readline = require('readline')
|
||||
|
||||
// 创建readline接口
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
*/
|
||||
|
||||
const redis = require('../src/models/redis')
|
||||
const claudeAccountService = require('../src/services/claudeAccountService')
|
||||
const claudeConsoleAccountService = require('../src/services/claudeConsoleAccountService')
|
||||
const claudeAccountService = require('../src/services/account/claudeAccountService')
|
||||
const claudeConsoleAccountService = require('../src/services/account/claudeConsoleAccountService')
|
||||
const accountGroupService = require('../src/services/accountGroupService')
|
||||
|
||||
async function testApiResponse() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const bedrockRelayService = require('../src/services/bedrockRelayService')
|
||||
const bedrockRelayService = require('../src/services/relay/bedrockRelayService')
|
||||
|
||||
async function testBedrockModels() {
|
||||
try {
|
||||
|
||||
@@ -11,7 +11,7 @@ const dotenv = require('dotenv')
|
||||
dotenv.config({ path: path.join(__dirname, '..', '.env') })
|
||||
|
||||
const redis = require('../src/models/redis')
|
||||
const geminiAccountService = require('../src/services/geminiAccountService')
|
||||
const geminiAccountService = require('../src/services/account/geminiAccountService')
|
||||
const crypto = require('crypto')
|
||||
const config = require('../config/config')
|
||||
|
||||
|
||||
@@ -7,10 +7,10 @@ require('dotenv').config()
|
||||
const { v4: uuidv4 } = require('uuid')
|
||||
const redis = require('../src/models/redis')
|
||||
const accountGroupService = require('../src/services/accountGroupService')
|
||||
const claudeAccountService = require('../src/services/claudeAccountService')
|
||||
const claudeConsoleAccountService = require('../src/services/claudeConsoleAccountService')
|
||||
const claudeAccountService = require('../src/services/account/claudeAccountService')
|
||||
const claudeConsoleAccountService = require('../src/services/account/claudeConsoleAccountService')
|
||||
const apiKeyService = require('../src/services/apiKeyService')
|
||||
const unifiedClaudeScheduler = require('../src/services/unifiedClaudeScheduler')
|
||||
const unifiedClaudeScheduler = require('../src/services/scheduler/unifiedClaudeScheduler')
|
||||
|
||||
// 测试配置
|
||||
const TEST_PREFIX = 'test_group_'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const bedrockRelayService = require('../src/services/bedrockRelayService')
|
||||
const bedrockRelayService = require('../src/services/relay/bedrockRelayService')
|
||||
|
||||
function testModelMapping() {
|
||||
console.log('🧪 测试模型映射功能...')
|
||||
|
||||
15
src/app.js
15
src/app.js
@@ -86,7 +86,7 @@ class Application {
|
||||
|
||||
// 💳 初始化账户余额查询服务(Provider 注册)
|
||||
try {
|
||||
const accountBalanceService = require('./services/accountBalanceService')
|
||||
const accountBalanceService = require('./services/account/accountBalanceService')
|
||||
const { registerAllProviders } = require('./services/balanceProviders')
|
||||
registerAllProviders(accountBalanceService)
|
||||
logger.info('✅ 账户余额查询服务已初始化')
|
||||
@@ -137,7 +137,7 @@ class Application {
|
||||
|
||||
// 🕐 初始化Claude账户会话窗口
|
||||
logger.info('🕐 Initializing Claude account session windows...')
|
||||
const claudeAccountService = require('./services/claudeAccountService')
|
||||
const claudeAccountService = require('./services/account/claudeAccountService')
|
||||
await claudeAccountService.initializeSessionWindows()
|
||||
|
||||
// 📊 初始化费用排序索引服务
|
||||
@@ -639,9 +639,12 @@ class Application {
|
||||
|
||||
// 注册各个服务的缓存实例
|
||||
const services = [
|
||||
{ name: 'claudeAccount', service: require('./services/claudeAccountService') },
|
||||
{ name: 'claudeConsole', service: require('./services/claudeConsoleAccountService') },
|
||||
{ name: 'bedrockAccount', service: require('./services/bedrockAccountService') }
|
||||
{ name: 'claudeAccount', service: require('./services/account/claudeAccountService') },
|
||||
{
|
||||
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...')
|
||||
|
||||
const apiKeyService = require('./services/apiKeyService')
|
||||
const claudeAccountService = require('./services/claudeAccountService')
|
||||
const claudeAccountService = require('./services/account/claudeAccountService')
|
||||
|
||||
const [expiredKeys, errorAccounts] = await Promise.all([
|
||||
apiKeyService.cleanupExpiredKeys(),
|
||||
|
||||
@@ -6,13 +6,13 @@
|
||||
*/
|
||||
|
||||
const logger = require('../utils/logger')
|
||||
const geminiAccountService = require('../services/geminiAccountService')
|
||||
const geminiApiAccountService = require('../services/geminiApiAccountService')
|
||||
const { sendGeminiRequest, getAvailableModels } = require('../services/geminiRelayService')
|
||||
const { sendAntigravityRequest } = require('../services/antigravityRelayService')
|
||||
const geminiAccountService = require('../services/account/geminiAccountService')
|
||||
const geminiApiAccountService = require('../services/account/geminiApiAccountService')
|
||||
const { sendGeminiRequest, getAvailableModels } = require('../services/relay/geminiRelayService')
|
||||
const { sendAntigravityRequest } = require('../services/relay/antigravityRelayService')
|
||||
const crypto = require('crypto')
|
||||
const sessionHelper = require('../utils/sessionHelper')
|
||||
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler')
|
||||
const unifiedGeminiScheduler = require('../services/scheduler/unifiedGeminiScheduler')
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
const redis = require('../models/redis')
|
||||
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
||||
@@ -20,6 +20,52 @@ const { parseSSELine } = require('../utils/sseParser')
|
||||
const axios = require('axios')
|
||||
const { getSafeMessage } = require('../utils/errorSanitizer')
|
||||
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,65 @@ function buildGeminiApiUrl(baseUrl, model, action, apiKey, options = {}) {
|
||||
// 移除末尾的斜杠(如果有)
|
||||
const normalizedBaseUrl = baseUrl.replace(/\/+$/, '')
|
||||
|
||||
// 检查是否为新格式(以 /models 结尾)
|
||||
const isNewFormat = normalizedBaseUrl.endsWith('/models')
|
||||
// 模式 3: URL 模板(包含 {model} 占位符)
|
||||
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
|
||||
if (listModels) {
|
||||
// 获取模型列表
|
||||
if (isNewFormat) {
|
||||
// 新格式: baseUrl 已包含 /v1beta/models,直接添加查询参数
|
||||
if (isTemplate) {
|
||||
// 模板模式: 分离 path 和 query,分别剔除含 {model}/{action} 的部分
|
||||
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')) {
|
||||
const logger = require('../utils/logger')
|
||||
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}`
|
||||
} else {
|
||||
// 旧格式: 需要拼接 /v1beta/models
|
||||
url = `${normalizedBaseUrl}/v1beta/models?key=${apiKey}`
|
||||
}
|
||||
} else {
|
||||
// 模型操作 (generateContent, streamGenerateContent, countTokens)
|
||||
const streamParam = stream ? '&alt=sse' : ''
|
||||
|
||||
if (isNewFormat) {
|
||||
// 新格式: baseUrl 已包含 /v1beta/models,直接拼接 /{model}:action
|
||||
if (isTemplate) {
|
||||
// 模板模式: 直接替换占位符({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}`
|
||||
} else {
|
||||
// 旧格式: 需要拼接 /v1beta/models/{model}:action
|
||||
url = `${normalizedBaseUrl}/v1beta/models/${model}:${action}?key=${apiKey}${streamParam}`
|
||||
}
|
||||
}
|
||||
@@ -664,6 +747,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 errorResponse = {
|
||||
@@ -1429,6 +1522,10 @@ async function handleCountTokens(req, res) {
|
||||
* 处理 generateContent 请求(v1internal 格式)
|
||||
*/
|
||||
async function handleGenerateContent(req, res) {
|
||||
let accountId = null
|
||||
let accountType = null
|
||||
let sessionHash = null
|
||||
|
||||
try {
|
||||
if (!ensureGeminiPermission(req, res)) {
|
||||
return undefined
|
||||
@@ -1437,7 +1534,7 @@ async function handleGenerateContent(req, res) {
|
||||
const { project, user_prompt_id, request: requestData } = req.body
|
||||
// 从路径参数或请求体中获取模型名
|
||||
const model = req.body.model || req.params.modelName || 'gemini-2.5-flash'
|
||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||
sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||
|
||||
// 处理不同格式的请求
|
||||
let actualRequestData = requestData
|
||||
@@ -1478,7 +1575,7 @@ async function handleGenerateContent(req, res) {
|
||||
sessionHash,
|
||||
model
|
||||
)
|
||||
const { accountId, accountType } = schedulerResult
|
||||
;({ accountId, accountType } = schedulerResult)
|
||||
|
||||
// v1internal 路由只支持 OAuth 账户,不支持 API Key 账户
|
||||
if (accountType === 'gemini-api') {
|
||||
@@ -1638,6 +1735,14 @@ async function handleGenerateContent(req, res) {
|
||||
requestMethod: error.config?.method,
|
||||
stack: error.stack
|
||||
})
|
||||
await handleGeminiUpstreamError(
|
||||
error.response?.status,
|
||||
accountId,
|
||||
accountType,
|
||||
sessionHash,
|
||||
error.response?.headers,
|
||||
account?.disableAutoProtection
|
||||
)
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: getSafeMessage(error) || 'Internal server error',
|
||||
@@ -1653,6 +1758,9 @@ async function handleGenerateContent(req, res) {
|
||||
*/
|
||||
async function handleStreamGenerateContent(req, res) {
|
||||
let abortController = null
|
||||
let accountId = null
|
||||
let accountType = null
|
||||
let sessionHash = null
|
||||
|
||||
try {
|
||||
if (!ensureGeminiPermission(req, res)) {
|
||||
@@ -1662,7 +1770,7 @@ async function handleStreamGenerateContent(req, res) {
|
||||
const { project, user_prompt_id, request: requestData } = req.body
|
||||
// 从路径参数或请求体中获取模型名
|
||||
const model = req.body.model || req.params.modelName || 'gemini-2.5-flash'
|
||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||
sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||
|
||||
// 处理不同格式的请求
|
||||
let actualRequestData = requestData
|
||||
@@ -1703,7 +1811,7 @@ async function handleStreamGenerateContent(req, res) {
|
||||
sessionHash,
|
||||
model
|
||||
)
|
||||
const { accountId, accountType } = schedulerResult
|
||||
;({ accountId, accountType } = schedulerResult)
|
||||
|
||||
// v1internal 路由只支持 OAuth 账户,不支持 API Key 账户
|
||||
if (accountType === 'gemini-api') {
|
||||
@@ -1997,6 +2105,14 @@ async function handleStreamGenerateContent(req, res) {
|
||||
requestMethod: error.config?.method,
|
||||
stack: error.stack
|
||||
})
|
||||
await handleGeminiUpstreamError(
|
||||
error.response?.status,
|
||||
accountId,
|
||||
accountType,
|
||||
sessionHash,
|
||||
error.response?.headers,
|
||||
account?.disableAutoProtection
|
||||
)
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
@@ -2025,6 +2141,7 @@ async function handleStandardGenerateContent(req, res) {
|
||||
let account = null
|
||||
let sessionHash = null
|
||||
let accountId = null
|
||||
let accountType = null
|
||||
let isApiAccount = false
|
||||
|
||||
try {
|
||||
@@ -2102,8 +2219,7 @@ async function handleStandardGenerateContent(req, res) {
|
||||
model,
|
||||
{ allowApiAccounts: true }
|
||||
)
|
||||
;({ accountId } = schedulerResult)
|
||||
const { accountType } = schedulerResult
|
||||
;({ accountId, accountType } = schedulerResult)
|
||||
|
||||
isApiAccount = accountType === 'gemini-api'
|
||||
const actualAccountId = accountId
|
||||
@@ -2148,6 +2264,12 @@ async function handleStandardGenerateContent(req, res) {
|
||||
// Gemini API 账户:直接使用 API Key 请求
|
||||
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 = {
|
||||
method: 'POST',
|
||||
url: apiUrl,
|
||||
@@ -2282,6 +2404,14 @@ async function handleStandardGenerateContent(req, res) {
|
||||
responseData: error.response?.data,
|
||||
stack: error.stack
|
||||
})
|
||||
await handleGeminiUpstreamError(
|
||||
error.response?.status,
|
||||
accountId,
|
||||
accountType,
|
||||
sessionHash,
|
||||
error.response?.headers,
|
||||
account?.disableAutoProtection
|
||||
)
|
||||
|
||||
res.status(500).json({
|
||||
error: {
|
||||
@@ -2300,6 +2430,7 @@ async function handleStandardStreamGenerateContent(req, res) {
|
||||
let account = null
|
||||
let sessionHash = null
|
||||
let accountId = null
|
||||
let accountType = null
|
||||
let isApiAccount = false
|
||||
|
||||
try {
|
||||
@@ -2375,8 +2506,7 @@ async function handleStandardStreamGenerateContent(req, res) {
|
||||
model,
|
||||
{ allowApiAccounts: true }
|
||||
)
|
||||
;({ accountId } = schedulerResult)
|
||||
const { accountType } = schedulerResult
|
||||
;({ accountId, accountType } = schedulerResult)
|
||||
|
||||
isApiAccount = accountType === 'gemini-api'
|
||||
const actualAccountId = accountId
|
||||
@@ -2446,6 +2576,12 @@ async function handleStandardStreamGenerateContent(req, res) {
|
||||
}
|
||||
)
|
||||
|
||||
logger.info('📤 Gemini upstream request', {
|
||||
targetUrl: apiUrl.replace(/key=[^&]+/, 'key=***'),
|
||||
model,
|
||||
accountId: actualAccountId
|
||||
})
|
||||
|
||||
const axiosConfig = {
|
||||
method: 'POST',
|
||||
url: apiUrl,
|
||||
@@ -2755,9 +2891,17 @@ async function handleStandardStreamGenerateContent(req, res) {
|
||||
responseData: normalizedError.parsedBody || normalizedError.rawBody,
|
||||
stack: error.stack
|
||||
})
|
||||
await handleGeminiUpstreamError(
|
||||
normalizedError.status || error.response?.status,
|
||||
accountId,
|
||||
accountType,
|
||||
sessionHash,
|
||||
error.response?.headers,
|
||||
account?.disableAutoProtection
|
||||
)
|
||||
|
||||
if (!res.headersSent) {
|
||||
const statusCode = normalizedError.status || 500
|
||||
const statusCode = error.statusCode || normalizedError.status || 500
|
||||
const responseBody = {
|
||||
error: {
|
||||
message: normalizedError.message,
|
||||
|
||||
@@ -1451,6 +1451,7 @@ const authenticateAdmin = async (req, res, next) => {
|
||||
}
|
||||
|
||||
const authDuration = Date.now() - startTime
|
||||
req._authInfo = `${adminSession.username} ${authDuration}ms`
|
||||
logger.security(`Admin authenticated: ${adminSession.username} in ${authDuration}ms`)
|
||||
|
||||
return next()
|
||||
@@ -1593,6 +1594,7 @@ const authenticateUserOrAdmin = async (req, res, next) => {
|
||||
req.userType = 'admin'
|
||||
|
||||
const authDuration = Date.now() - startTime
|
||||
req._authInfo = `${adminSession.username} ${authDuration}ms`
|
||||
logger.security(`Admin authenticated: ${adminSession.username} in ${authDuration}ms`)
|
||||
return next()
|
||||
}
|
||||
@@ -1773,67 +1775,80 @@ const requestLogger = (req, res, next) => {
|
||||
const userAgent = req.get('User-Agent') || 'unknown'
|
||||
const referer = req.get('Referer') || 'none'
|
||||
|
||||
// 记录请求开始
|
||||
// 请求开始 → debug 级别(减少正常请求的日志量)
|
||||
const isDebugRoute = req.originalUrl.includes('event_logging')
|
||||
if (req.originalUrl !== '/health') {
|
||||
if (isDebugRoute) {
|
||||
logger.debug(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`)
|
||||
} else {
|
||||
logger.info(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`)
|
||||
}
|
||||
logger.debug(`▶ [${requestId}] ${req.method} ${req.originalUrl}`, {
|
||||
ip: clientIP,
|
||||
body: req.body && Object.keys(req.body).length > 0 ? req.body : undefined
|
||||
})
|
||||
}
|
||||
|
||||
// 拦截 res.json() 捕获响应体
|
||||
const originalJson = res.json.bind(res)
|
||||
res.json = (body) => {
|
||||
res._responseBody = body
|
||||
return originalJson(body)
|
||||
}
|
||||
|
||||
res.on('finish', () => {
|
||||
if (req.originalUrl === '/health') {
|
||||
return
|
||||
}
|
||||
const duration = Date.now() - start
|
||||
const contentLength = res.get('Content-Length') || '0'
|
||||
const status = res.statusCode
|
||||
|
||||
// 构建日志元数据
|
||||
const logMetadata = {
|
||||
requestId,
|
||||
method: req.method,
|
||||
url: req.originalUrl,
|
||||
status: res.statusCode,
|
||||
duration,
|
||||
contentLength,
|
||||
ip: clientIP,
|
||||
userAgent,
|
||||
referer
|
||||
// 状态 emoji
|
||||
const emoji = status >= 500 ? '❌' : status >= 400 ? '⚠️ ' : '🟢'
|
||||
const level = status >= 500 ? 'error' : status >= 400 ? 'warn' : 'info'
|
||||
|
||||
// 主消息行
|
||||
const msg = `${emoji} ${status} ${req.method} ${req.originalUrl} ${duration}ms ${contentLength}B`
|
||||
|
||||
// 构建树形 metadata
|
||||
const meta = { requestId }
|
||||
|
||||
// 请求体(非 GET 且有内容时显示)
|
||||
if (req.method !== 'GET' && req.body && Object.keys(req.body).length > 0) {
|
||||
meta.req = req.body
|
||||
}
|
||||
|
||||
// 根据状态码选择日志级别
|
||||
if (res.statusCode >= 500) {
|
||||
logger.error(
|
||||
`◀️ [${requestId}] ${req.method} ${req.originalUrl} | ${res.statusCode} | ${duration}ms | ${contentLength}B`,
|
||||
logMetadata
|
||||
)
|
||||
} else if (res.statusCode >= 400) {
|
||||
logger.warn(
|
||||
`◀️ [${requestId}] ${req.method} ${req.originalUrl} | ${res.statusCode} | ${duration}ms | ${contentLength}B`,
|
||||
logMetadata
|
||||
)
|
||||
} else if (req.originalUrl !== '/health') {
|
||||
if (isDebugRoute) {
|
||||
logger.debug(
|
||||
`🟢 ${req.method} ${req.originalUrl} - ${res.statusCode} (${duration}ms)`,
|
||||
logMetadata
|
||||
)
|
||||
} else {
|
||||
logger.request(req.method, req.originalUrl, res.statusCode, duration, logMetadata)
|
||||
}
|
||||
// 查询参数(GET 请求且有查询参数时单独显示)
|
||||
const queryIdx = req.originalUrl.indexOf('?')
|
||||
if (queryIdx > -1) {
|
||||
meta.query = req.originalUrl.substring(queryIdx + 1)
|
||||
}
|
||||
|
||||
// API Key相关日志
|
||||
// 响应体
|
||||
if (res._responseBody) {
|
||||
meta.res = res._responseBody
|
||||
}
|
||||
|
||||
// API Key 信息(合并到同一条日志)
|
||||
if (req.apiKey) {
|
||||
logger.api(
|
||||
`📱 [${requestId}] Request from ${req.apiKey.name} (${req.apiKey.id}) | ${duration}ms`
|
||||
)
|
||||
meta.key = `${req.apiKey.name} (${req.apiKey.id})`
|
||||
}
|
||||
|
||||
// 认证信息
|
||||
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) {
|
||||
logger.warn(
|
||||
`🐌 [${requestId}] Slow request detected: ${duration}ms for ${req.method} ${req.originalUrl}`
|
||||
)
|
||||
logger.warn(`🐌 Slow request: ${duration}ms ${req.method} ${req.originalUrl}`)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const express = require('express')
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
const logger = require('../../utils/logger')
|
||||
const accountBalanceService = require('../../services/accountBalanceService')
|
||||
const accountBalanceService = require('../../services/account/accountBalanceService')
|
||||
const balanceScriptService = require('../../services/balanceScriptService')
|
||||
const { isBalanceScriptEnabled } = require('../../utils/featureFlags')
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
const express = require('express')
|
||||
const accountGroupService = require('../../services/accountGroupService')
|
||||
const claudeAccountService = require('../../services/claudeAccountService')
|
||||
const claudeConsoleAccountService = require('../../services/claudeConsoleAccountService')
|
||||
const geminiAccountService = require('../../services/geminiAccountService')
|
||||
const openaiAccountService = require('../../services/openaiAccountService')
|
||||
const droidAccountService = require('../../services/droidAccountService')
|
||||
const claudeAccountService = require('../../services/account/claudeAccountService')
|
||||
const claudeConsoleAccountService = require('../../services/account/claudeConsoleAccountService')
|
||||
const geminiAccountService = require('../../services/account/geminiAccountService')
|
||||
const openaiAccountService = require('../../services/account/openaiAccountService')
|
||||
const droidAccountService = require('../../services/account/droidAccountService')
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
const logger = require('../../utils/logger')
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const express = require('express')
|
||||
const azureOpenaiAccountService = require('../../services/azureOpenaiAccountService')
|
||||
const azureOpenaiAccountService = require('../../services/account/azureOpenaiAccountService')
|
||||
const accountGroupService = require('../../services/accountGroupService')
|
||||
const apiKeyService = require('../../services/apiKeyService')
|
||||
const redis = require('../../models/redis')
|
||||
@@ -433,13 +433,16 @@ router.post('/azure-openai-accounts/:accountId/test', authenticateAdmin, async (
|
||||
}
|
||||
|
||||
// 构造测试请求
|
||||
const { createOpenAITestPayload } = require('../../utils/testPayloadHelper')
|
||||
const {
|
||||
createChatCompletionsTestPayload,
|
||||
extractErrorMessage
|
||||
} = require('../../utils/testPayloadHelper')
|
||||
const { getProxyAgent } = require('../../utils/proxyHelper')
|
||||
|
||||
const deploymentName = account.deploymentName || 'gpt-4o-mini'
|
||||
const apiVersion = account.apiVersion || '2024-02-15-preview'
|
||||
const apiUrl = `${account.endpoint}/openai/deployments/${deploymentName}/chat/completions?api-version=${apiVersion}`
|
||||
const payload = createOpenAITestPayload(deploymentName)
|
||||
const payload = createChatCompletionsTestPayload(deploymentName)
|
||||
|
||||
const requestConfig = {
|
||||
headers: {
|
||||
@@ -488,10 +491,23 @@ router.post('/azure-openai-accounts/:accountId/test', authenticateAdmin, async (
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Test failed',
|
||||
message: error.response?.data?.error?.message || error.message,
|
||||
message: extractErrorMessage(error.response?.data, error.message),
|
||||
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
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const bedrockAccountService = require('../../services/bedrockAccountService')
|
||||
const bedrockAccountService = require('../../services/account/bedrockAccountService')
|
||||
const apiKeyService = require('../../services/apiKeyService')
|
||||
const accountGroupService = require('../../services/accountGroupService')
|
||||
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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const express = require('express')
|
||||
const ccrAccountService = require('../../services/ccrAccountService')
|
||||
const ccrAccountService = require('../../services/account/ccrAccountService')
|
||||
const accountGroupService = require('../../services/accountGroupService')
|
||||
const apiKeyService = require('../../services/apiKeyService')
|
||||
const redis = require('../../models/redis')
|
||||
@@ -7,6 +7,7 @@ const { authenticateAdmin } = require('../../middleware/auth')
|
||||
const logger = require('../../utils/logger')
|
||||
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||
const { formatAccountExpiry, mapExpiryField } = require('./utils')
|
||||
const { extractErrorMessage } = require('../../utils/testPayloadHelper')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
@@ -492,7 +493,7 @@ router.post('/:accountId/test', authenticateAdmin, async (req, res) => {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Test failed',
|
||||
message: error.response?.data?.error?.message || error.message,
|
||||
message: extractErrorMessage(error.response?.data, error.message),
|
||||
latency
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
|
||||
const claudeAccountService = require('../../services/claudeAccountService')
|
||||
const claudeRelayService = require('../../services/claudeRelayService')
|
||||
const claudeAccountService = require('../../services/account/claudeAccountService')
|
||||
const claudeRelayService = require('../../services/relay/claudeRelayService')
|
||||
const accountGroupService = require('../../services/accountGroupService')
|
||||
const accountTestSchedulerService = require('../../services/accountTestSchedulerService')
|
||||
const apiKeyService = require('../../services/apiKeyService')
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
|
||||
const claudeConsoleAccountService = require('../../services/claudeConsoleAccountService')
|
||||
const claudeConsoleRelayService = require('../../services/claudeConsoleRelayService')
|
||||
const claudeConsoleAccountService = require('../../services/account/claudeConsoleAccountService')
|
||||
const claudeConsoleRelayService = require('../../services/relay/claudeConsoleRelayService')
|
||||
const accountGroupService = require('../../services/accountGroupService')
|
||||
const apiKeyService = require('../../services/apiKeyService')
|
||||
const redis = require('../../models/redis')
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
const express = require('express')
|
||||
const apiKeyService = require('../../services/apiKeyService')
|
||||
const claudeAccountService = require('../../services/claudeAccountService')
|
||||
const claudeConsoleAccountService = require('../../services/claudeConsoleAccountService')
|
||||
const bedrockAccountService = require('../../services/bedrockAccountService')
|
||||
const ccrAccountService = require('../../services/ccrAccountService')
|
||||
const geminiAccountService = require('../../services/geminiAccountService')
|
||||
const droidAccountService = require('../../services/droidAccountService')
|
||||
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService')
|
||||
const claudeAccountService = require('../../services/account/claudeAccountService')
|
||||
const claudeConsoleAccountService = require('../../services/account/claudeConsoleAccountService')
|
||||
const bedrockAccountService = require('../../services/account/bedrockAccountService')
|
||||
const ccrAccountService = require('../../services/account/ccrAccountService')
|
||||
const geminiAccountService = require('../../services/account/geminiAccountService')
|
||||
const droidAccountService = require('../../services/account/droidAccountService')
|
||||
const openaiResponsesAccountService = require('../../services/account/openaiResponsesAccountService')
|
||||
const redis = require('../../models/redis')
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
const logger = require('../../utils/logger')
|
||||
const CostCalculator = require('../../utils/costCalculator')
|
||||
const upstreamErrorHelper = require('../../utils/upstreamErrorHelper')
|
||||
const config = require('../../../config/config')
|
||||
|
||||
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) => {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const express = require('express')
|
||||
const crypto = require('crypto')
|
||||
const droidAccountService = require('../../services/droidAccountService')
|
||||
const droidAccountService = require('../../services/account/droidAccountService')
|
||||
const accountGroupService = require('../../services/accountGroupService')
|
||||
const apiKeyService = require('../../services/apiKeyService')
|
||||
const redis = require('../../models/redis')
|
||||
@@ -13,6 +13,7 @@ const {
|
||||
} = require('../../utils/workosOAuthHelper')
|
||||
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||
const { formatAccountExpiry, mapExpiryField } = require('./utils')
|
||||
const { extractErrorMessage } = require('../../utils/testPayloadHelper')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
@@ -683,10 +684,23 @@ router.post('/droid-accounts/:accountId/test', authenticateAdmin, async (req, re
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Test failed',
|
||||
message: error.response?.data?.error?.message || error.message,
|
||||
message: extractErrorMessage(error.response?.data, error.message),
|
||||
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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const express = require('express')
|
||||
const geminiAccountService = require('../../services/geminiAccountService')
|
||||
const geminiAccountService = require('../../services/account/geminiAccountService')
|
||||
const accountGroupService = require('../../services/accountGroupService')
|
||||
const apiKeyService = require('../../services/apiKeyService')
|
||||
const redis = require('../../models/redis')
|
||||
@@ -532,7 +532,10 @@ router.post('/:accountId/test', authenticateAdmin, async (req, res) => {
|
||||
|
||||
// 构造测试请求
|
||||
const axios = require('axios')
|
||||
const { createGeminiTestPayload } = require('../../utils/testPayloadHelper')
|
||||
const {
|
||||
createGeminiTestPayload,
|
||||
extractErrorMessage
|
||||
} = require('../../utils/testPayloadHelper')
|
||||
const { getProxyAgent } = require('../../utils/proxyHelper')
|
||||
|
||||
const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`
|
||||
@@ -585,7 +588,7 @@ router.post('/:accountId/test', authenticateAdmin, async (req, res) => {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Test failed',
|
||||
message: error.response?.data?.error?.message || error.message,
|
||||
message: extractErrorMessage(error.response?.data, error.message),
|
||||
latency
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const express = require('express')
|
||||
const geminiApiAccountService = require('../../services/geminiApiAccountService')
|
||||
const geminiApiAccountService = require('../../services/account/geminiApiAccountService')
|
||||
const apiKeyService = require('../../services/apiKeyService')
|
||||
const accountGroupService = require('../../services/accountGroupService')
|
||||
const redis = require('../../models/redis')
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
const express = require('express')
|
||||
const crypto = require('crypto')
|
||||
const axios = require('axios')
|
||||
const openaiAccountService = require('../../services/openaiAccountService')
|
||||
const openaiAccountService = require('../../services/account/openaiAccountService')
|
||||
const accountGroupService = require('../../services/accountGroupService')
|
||||
const apiKeyService = require('../../services/apiKeyService')
|
||||
const redis = require('../../models/redis')
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
*/
|
||||
|
||||
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 accountGroupService = require('../../services/accountGroupService')
|
||||
const redis = require('../../models/redis')
|
||||
@@ -12,6 +13,8 @@ const { authenticateAdmin } = require('../../middleware/auth')
|
||||
const logger = require('../../utils/logger')
|
||||
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||
const { formatAccountExpiry, mapExpiryField } = require('./utils')
|
||||
const { createOpenAITestPayload, extractErrorMessage } = require('../../utils/testPayloadHelper')
|
||||
const { getProxyAgent } = require('../../utils/proxyHelper')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
@@ -459,31 +462,25 @@ router.post('/openai-responses-accounts/:accountId/test', authenticateAdmin, asy
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
// 获取账户信息
|
||||
// 获取账户信息(apiKey 已自动解密)
|
||||
const account = await openaiResponsesAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
return res.status(404).json({ error: 'Account not found' })
|
||||
}
|
||||
|
||||
// 获取解密后的 API Key
|
||||
const apiKey = await openaiResponsesAccountService.getDecryptedApiKey(accountId)
|
||||
if (!apiKey) {
|
||||
if (!account.apiKey) {
|
||||
return res.status(401).json({ error: 'API Key not found or decryption failed' })
|
||||
}
|
||||
|
||||
// 构造测试请求
|
||||
const axios = require('axios')
|
||||
const { createOpenAITestPayload } = require('../../utils/testPayloadHelper')
|
||||
const { getProxyAgent } = require('../../utils/proxyHelper')
|
||||
|
||||
const baseUrl = account.baseUrl || 'https://api.openai.com'
|
||||
const apiUrl = `${baseUrl}/v1/chat/completions`
|
||||
const payload = createOpenAITestPayload(model)
|
||||
const baseUrl = account.baseApi || 'https://api.openai.com'
|
||||
const apiUrl = `${baseUrl}/responses`
|
||||
const payload = createOpenAITestPayload(model, { stream: false })
|
||||
|
||||
const requestConfig = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`
|
||||
Authorization: `Bearer ${account.apiKey}`
|
||||
},
|
||||
timeout: 30000
|
||||
}
|
||||
@@ -500,10 +497,19 @@ router.post('/openai-responses-accounts/:accountId/test', authenticateAdmin, asy
|
||||
const response = await axios.post(apiUrl, payload, requestConfig)
|
||||
const latency = Date.now() - startTime
|
||||
|
||||
// 提取响应文本
|
||||
// 提取响应文本(Responses API 格式)
|
||||
let responseText = ''
|
||||
if (response.data?.choices?.[0]?.message?.content) {
|
||||
responseText = response.data.choices[0].message.content
|
||||
const output = response.data?.output
|
||||
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(
|
||||
@@ -527,7 +533,7 @@ router.post('/openai-responses-accounts/:accountId/test', authenticateAdmin, asy
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Test failed',
|
||||
message: error.response?.data?.error?.message || error.message,
|
||||
message: extractErrorMessage(error.response?.data, error.message),
|
||||
latency
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,10 +8,10 @@ const router = express.Router()
|
||||
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
const redis = require('../../models/redis')
|
||||
const claudeAccountService = require('../../services/claudeAccountService')
|
||||
const claudeConsoleAccountService = require('../../services/claudeConsoleAccountService')
|
||||
const openaiAccountService = require('../../services/openaiAccountService')
|
||||
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService')
|
||||
const claudeAccountService = require('../../services/account/claudeAccountService')
|
||||
const claudeConsoleAccountService = require('../../services/account/claudeConsoleAccountService')
|
||||
const openaiAccountService = require('../../services/account/openaiAccountService')
|
||||
const openaiResponsesAccountService = require('../../services/account/openaiResponsesAccountService')
|
||||
const logger = require('../../utils/logger')
|
||||
|
||||
function toBool(value, defaultValue = false) {
|
||||
|
||||
@@ -3,7 +3,7 @@ const fs = require('fs')
|
||||
const path = require('path')
|
||||
const axios = require('axios')
|
||||
const claudeCodeHeadersService = require('../../services/claudeCodeHeadersService')
|
||||
const claudeAccountService = require('../../services/claudeAccountService')
|
||||
const claudeAccountService = require('../../services/account/claudeAccountService')
|
||||
const redis = require('../../models/redis')
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
const logger = require('../../utils/logger')
|
||||
@@ -408,4 +408,44 @@ router.post('/claude-code-version/clear', authenticateAdmin, async (req, res) =>
|
||||
}
|
||||
})
|
||||
|
||||
// ==================== 模型价格管理 ====================
|
||||
|
||||
const pricingService = require('../../services/pricingService')
|
||||
|
||||
// 获取所有模型价格数据
|
||||
router.get('/models/pricing', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
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
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
const express = require('express')
|
||||
const apiKeyService = require('../../services/apiKeyService')
|
||||
const ccrAccountService = require('../../services/ccrAccountService')
|
||||
const claudeAccountService = require('../../services/claudeAccountService')
|
||||
const claudeConsoleAccountService = require('../../services/claudeConsoleAccountService')
|
||||
const geminiAccountService = require('../../services/geminiAccountService')
|
||||
const geminiApiAccountService = require('../../services/geminiApiAccountService')
|
||||
const openaiAccountService = require('../../services/openaiAccountService')
|
||||
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService')
|
||||
const droidAccountService = require('../../services/droidAccountService')
|
||||
const bedrockAccountService = require('../../services/bedrockAccountService')
|
||||
const ccrAccountService = require('../../services/account/ccrAccountService')
|
||||
const claudeAccountService = require('../../services/account/claudeAccountService')
|
||||
const claudeConsoleAccountService = require('../../services/account/claudeConsoleAccountService')
|
||||
const geminiAccountService = require('../../services/account/geminiAccountService')
|
||||
const geminiApiAccountService = require('../../services/account/geminiApiAccountService')
|
||||
const openaiAccountService = require('../../services/account/openaiAccountService')
|
||||
const openaiResponsesAccountService = require('../../services/account/openaiResponsesAccountService')
|
||||
const droidAccountService = require('../../services/account/droidAccountService')
|
||||
const bedrockAccountService = require('../../services/account/bedrockAccountService')
|
||||
const redis = require('../../models/redis')
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
const logger = require('../../utils/logger')
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
const express = require('express')
|
||||
const claudeRelayService = require('../services/claudeRelayService')
|
||||
const claudeConsoleRelayService = require('../services/claudeConsoleRelayService')
|
||||
const bedrockRelayService = require('../services/bedrockRelayService')
|
||||
const ccrRelayService = require('../services/ccrRelayService')
|
||||
const bedrockAccountService = require('../services/bedrockAccountService')
|
||||
const unifiedClaudeScheduler = require('../services/unifiedClaudeScheduler')
|
||||
const claudeRelayService = require('../services/relay/claudeRelayService')
|
||||
const claudeConsoleRelayService = require('../services/relay/claudeConsoleRelayService')
|
||||
const bedrockRelayService = require('../services/relay/bedrockRelayService')
|
||||
const ccrRelayService = require('../services/relay/ccrRelayService')
|
||||
const bedrockAccountService = require('../services/account/bedrockAccountService')
|
||||
const unifiedClaudeScheduler = require('../services/scheduler/unifiedClaudeScheduler')
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
const { authenticateApiKey } = require('../middleware/auth')
|
||||
const logger = require('../utils/logger')
|
||||
@@ -12,8 +12,8 @@ const { getEffectiveModel, parseVendorPrefixedModel } = require('../utils/modelH
|
||||
const sessionHelper = require('../utils/sessionHelper')
|
||||
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
||||
const claudeRelayConfigService = require('../services/claudeRelayConfigService')
|
||||
const claudeAccountService = require('../services/claudeAccountService')
|
||||
const claudeConsoleAccountService = require('../services/claudeConsoleAccountService')
|
||||
const claudeAccountService = require('../services/account/claudeAccountService')
|
||||
const claudeConsoleAccountService = require('../services/account/claudeConsoleAccountService')
|
||||
const {
|
||||
isWarmupRequest,
|
||||
buildMockWarmupResponse,
|
||||
@@ -1289,8 +1289,8 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
||||
})
|
||||
}
|
||||
|
||||
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler')
|
||||
const geminiAccountService = require('../services/geminiAccountService')
|
||||
const unifiedGeminiScheduler = require('../services/scheduler/unifiedGeminiScheduler')
|
||||
const geminiAccountService = require('../services/account/geminiAccountService')
|
||||
|
||||
let accountSelection
|
||||
try {
|
||||
|
||||
@@ -3,10 +3,14 @@ const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
const CostCalculator = require('../utils/costCalculator')
|
||||
const claudeAccountService = require('../services/claudeAccountService')
|
||||
const openaiAccountService = require('../services/openaiAccountService')
|
||||
const claudeAccountService = require('../services/account/claudeAccountService')
|
||||
const openaiAccountService = require('../services/account/openaiAccountService')
|
||||
const serviceRatesService = require('../services/serviceRatesService')
|
||||
const { createClaudeTestPayload } = require('../utils/testPayloadHelper')
|
||||
const {
|
||||
createClaudeTestPayload,
|
||||
extractErrorMessage,
|
||||
sanitizeErrorMsg
|
||||
} = require('../utils/testPayloadHelper')
|
||||
const modelsConfig = require('../../config/models')
|
||||
const { getSafeMessage } = require('../utils/errorSanitizer')
|
||||
|
||||
@@ -25,7 +29,7 @@ router.get('/models', (req, res) => {
|
||||
})
|
||||
}
|
||||
|
||||
// 返回所有模型(按服务分组)
|
||||
// 返回所有模型(按服务分组 + 平台维度)
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
@@ -33,7 +37,8 @@ router.get('/models', (req, res) => {
|
||||
gemini: modelsConfig.GEMINI_MODELS,
|
||||
openai: modelsConfig.OPENAI_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,
|
||||
payload: createClaudeTestPayload(model, { stream: true, prompt, maxTokens }),
|
||||
timeout: 60000,
|
||||
extraHeaders: { 'x-api-key': apiKey }
|
||||
extraHeaders: { 'x-api-key': apiKey },
|
||||
sanitize: true
|
||||
})
|
||||
} catch (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}`
|
||||
try {
|
||||
const json = JSON.parse(errorData)
|
||||
errorMsg = json.message || json.error?.message || json.error || errorMsg
|
||||
errorMsg = extractErrorMessage(json, errorMsg)
|
||||
} catch {
|
||||
if (errorData.length < 200) {
|
||||
errorMsg = errorData || errorMsg
|
||||
}
|
||||
}
|
||||
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()
|
||||
})
|
||||
@@ -1168,14 +1174,14 @@ router.post('/api-key/test-openai', async (req, res) => {
|
||||
let errorMsg = `API Error: ${response.status}`
|
||||
try {
|
||||
const json = JSON.parse(errorData)
|
||||
errorMsg = json.message || json.error?.message || json.error || errorMsg
|
||||
errorMsg = extractErrorMessage(json, errorMsg)
|
||||
} catch {
|
||||
if (errorData.length < 200) {
|
||||
errorMsg = errorData || errorMsg
|
||||
}
|
||||
}
|
||||
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()
|
||||
})
|
||||
|
||||
@@ -2,10 +2,11 @@ const express = require('express')
|
||||
const router = express.Router()
|
||||
const logger = require('../utils/logger')
|
||||
const { authenticateApiKey } = require('../middleware/auth')
|
||||
const azureOpenaiAccountService = require('../services/azureOpenaiAccountService')
|
||||
const azureOpenaiRelayService = require('../services/azureOpenaiRelayService')
|
||||
const azureOpenaiAccountService = require('../services/account/azureOpenaiAccountService')
|
||||
const azureOpenaiRelayService = require('../services/relay/azureOpenaiRelayService')
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
const crypto = require('crypto')
|
||||
const upstreamErrorHelper = require('../utils/upstreamErrorHelper')
|
||||
|
||||
// 支持的模型列表 - 基于真实的 Azure OpenAI 模型
|
||||
const ALLOWED_MODELS = {
|
||||
@@ -163,6 +164,16 @@ router.post('/chat/completions', authenticateApiKey, async (req, res) => {
|
||||
let account = null
|
||||
if (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) {
|
||||
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'
|
||||
})
|
||||
|
||||
// 检查上游响应状态码(仅对认证/限流/服务端错误暂停,不对 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) {
|
||||
await azureOpenaiRelayService.handleStreamResponse(response, res, {
|
||||
@@ -256,6 +285,16 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
|
||||
let account = null
|
||||
if (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) {
|
||||
logger.warn(`Bound Azure OpenAI account not found: ${req.apiKey.azureOpenaiAccountId}`)
|
||||
}
|
||||
@@ -275,6 +314,24 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
|
||||
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) {
|
||||
await azureOpenaiRelayService.handleStreamResponse(response, res, {
|
||||
@@ -348,6 +405,16 @@ router.post('/embeddings', authenticateApiKey, async (req, res) => {
|
||||
let account = null
|
||||
if (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) {
|
||||
logger.warn(`Bound Azure OpenAI account not found: ${req.apiKey.azureOpenaiAccountId}`)
|
||||
}
|
||||
@@ -367,6 +434,24 @@ router.post('/embeddings', authenticateApiKey, async (req, res) => {
|
||||
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(
|
||||
response,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const crypto = require('crypto')
|
||||
const express = require('express')
|
||||
const { authenticateApiKey } = require('../middleware/auth')
|
||||
const droidRelayService = require('../services/droidRelayService')
|
||||
const droidRelayService = require('../services/relay/droidRelayService')
|
||||
const sessionHelper = require('../utils/sessionHelper')
|
||||
const logger = require('../utils/logger')
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
|
||||
@@ -7,11 +7,11 @@ const express = require('express')
|
||||
const router = express.Router()
|
||||
const logger = require('../utils/logger')
|
||||
const { authenticateApiKey } = require('../middleware/auth')
|
||||
const claudeRelayService = require('../services/claudeRelayService')
|
||||
const claudeConsoleRelayService = require('../services/claudeConsoleRelayService')
|
||||
const claudeRelayService = require('../services/relay/claudeRelayService')
|
||||
const claudeConsoleRelayService = require('../services/relay/claudeConsoleRelayService')
|
||||
const openaiToClaude = require('../services/openaiToClaude')
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
const unifiedClaudeScheduler = require('../services/unifiedClaudeScheduler')
|
||||
const unifiedClaudeScheduler = require('../services/scheduler/unifiedClaudeScheduler')
|
||||
const claudeCodeHeadersService = require('../services/claudeCodeHeadersService')
|
||||
const { getSafeMessage } = require('../utils/errorSanitizer')
|
||||
const sessionHelper = require('../utils/sessionHelper')
|
||||
|
||||
@@ -2,9 +2,9 @@ const express = require('express')
|
||||
const router = express.Router()
|
||||
const logger = require('../utils/logger')
|
||||
const { authenticateApiKey } = require('../middleware/auth')
|
||||
const geminiAccountService = require('../services/geminiAccountService')
|
||||
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler')
|
||||
const { getAvailableModels } = require('../services/geminiRelayService')
|
||||
const geminiAccountService = require('../services/account/geminiAccountService')
|
||||
const unifiedGeminiScheduler = require('../services/scheduler/unifiedGeminiScheduler')
|
||||
const { getAvailableModels } = require('../services/relay/geminiRelayService')
|
||||
const crypto = require('crypto')
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
|
||||
|
||||
@@ -4,10 +4,10 @@ const router = express.Router()
|
||||
const logger = require('../utils/logger')
|
||||
const config = require('../../config/config')
|
||||
const { authenticateApiKey } = require('../middleware/auth')
|
||||
const unifiedOpenAIScheduler = require('../services/unifiedOpenAIScheduler')
|
||||
const openaiAccountService = require('../services/openaiAccountService')
|
||||
const openaiResponsesAccountService = require('../services/openaiResponsesAccountService')
|
||||
const openaiResponsesRelayService = require('../services/openaiResponsesRelayService')
|
||||
const unifiedOpenAIScheduler = require('../services/scheduler/unifiedOpenAIScheduler')
|
||||
const openaiAccountService = require('../services/account/openaiAccountService')
|
||||
const openaiResponsesAccountService = require('../services/account/openaiResponsesAccountService')
|
||||
const openaiResponsesRelayService = require('../services/relay/openaiResponsesRelayService')
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
const redis = require('../models/redis')
|
||||
const crypto = require('crypto')
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
const redis = require('../models/redis')
|
||||
const balanceScriptService = require('./balanceScriptService')
|
||||
const logger = require('../utils/logger')
|
||||
const CostCalculator = require('../utils/costCalculator')
|
||||
const { isBalanceScriptEnabled } = require('../utils/featureFlags')
|
||||
const redis = require('../../models/redis')
|
||||
const balanceScriptService = require('../balanceScriptService')
|
||||
const logger = require('../../utils/logger')
|
||||
const CostCalculator = require('../../utils/costCalculator')
|
||||
const { isBalanceScriptEnabled } = require('../../utils/featureFlags')
|
||||
|
||||
class AccountBalanceService {
|
||||
constructor(options = {}) {
|
||||
@@ -1,8 +1,9 @@
|
||||
const redisClient = require('../models/redis')
|
||||
const redisClient = require('../../models/redis')
|
||||
const { v4: uuidv4 } = require('uuid')
|
||||
const crypto = require('crypto')
|
||||
const config = require('../../config/config')
|
||||
const logger = require('../utils/logger')
|
||||
const config = require('../../../config/config')
|
||||
const logger = require('../../utils/logger')
|
||||
const upstreamErrorHelper = require('../../utils/upstreamErrorHelper')
|
||||
|
||||
// 加密相关常量
|
||||
const ALGORITHM = 'aes-256-cbc'
|
||||
@@ -138,6 +139,10 @@ async function createAccount(accountData) {
|
||||
isActive: accountData.isActive !== false ? 'true' : 'false',
|
||||
status: 'active',
|
||||
schedulable: accountData.schedulable !== false ? 'true' : 'false',
|
||||
disableAutoProtection:
|
||||
accountData.disableAutoProtection === true || accountData.disableAutoProtection === 'true'
|
||||
? 'true'
|
||||
: 'false', // 关闭自动防护
|
||||
createdAt: 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()
|
||||
if (updates.accountType && updates.accountType !== existingAccount.accountType) {
|
||||
@@ -262,7 +275,7 @@ async function updateAccount(accountId, updates) {
|
||||
// 删除账户
|
||||
async function deleteAccount(accountId) {
|
||||
// 首先从所有分组中移除此账户
|
||||
const accountGroupService = require('./accountGroupService')
|
||||
const accountGroupService = require('../accountGroupService')
|
||||
await accountGroupService.removeAccountFromAllGroups(accountId)
|
||||
|
||||
const client = redisClient.getClientSafe()
|
||||
@@ -380,8 +393,14 @@ async function selectAvailableAccount(sessionId = null) {
|
||||
if (accountId) {
|
||||
const account = await getAccount(accountId)
|
||||
if (account && account.isActive === 'true' && account.schedulable === 'true') {
|
||||
logger.debug(`Reusing Azure OpenAI account ${accountId} for session ${sessionId}`)
|
||||
return account
|
||||
const isTempUnavail = await upstreamErrorHelper.isTempUnavailable(accountId, 'azure-openai')
|
||||
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 availableAccounts = sharedAccounts.filter((acc) => {
|
||||
// ✅ 检查账户订阅是否过期
|
||||
// 过滤出可用的账户(异步过滤,包含临时不可用检查)
|
||||
const availableAccounts = []
|
||||
for (const acc of sharedAccounts) {
|
||||
// 检查账户订阅是否过期
|
||||
if (isSubscriptionExpired(acc)) {
|
||||
logger.debug(
|
||||
`⏰ 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) {
|
||||
throw new Error('No available Azure OpenAI accounts')
|
||||
@@ -515,6 +546,69 @@ async function migrateApiKeysForAzureSupport() {
|
||||
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 = {
|
||||
createAccount,
|
||||
getAccount,
|
||||
@@ -528,6 +622,7 @@ module.exports = {
|
||||
performHealthChecks,
|
||||
toggleSchedulable,
|
||||
migrateApiKeysForAzureSupport,
|
||||
resetAccountStatus,
|
||||
encrypt,
|
||||
decrypt
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
const { v4: uuidv4 } = require('uuid')
|
||||
const crypto = require('crypto')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const config = require('../../config/config')
|
||||
const bedrockRelayService = require('./bedrockRelayService')
|
||||
const LRUCache = require('../utils/lruCache')
|
||||
const redis = require('../../models/redis')
|
||||
const logger = require('../../utils/logger')
|
||||
const config = require('../../../config/config')
|
||||
const bedrockRelayService = require('../relay/bedrockRelayService')
|
||||
const LRUCache = require('../../utils/lruCache')
|
||||
const upstreamErrorHelper = require('../../utils/upstreamErrorHelper')
|
||||
|
||||
class BedrockAccountService {
|
||||
constructor() {
|
||||
@@ -41,7 +42,8 @@ class BedrockAccountService {
|
||||
accountType = 'shared', // 'dedicated' or 'shared'
|
||||
priority = 50, // 调度优先级 (1-100,数字越小优先级越高)
|
||||
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
|
||||
|
||||
const accountId = uuidv4()
|
||||
@@ -64,7 +66,8 @@ class BedrockAccountService {
|
||||
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
type: 'bedrock' // 标识这是Bedrock账户
|
||||
type: 'bedrock', // 标识这是Bedrock账户
|
||||
disableAutoProtection // 关闭自动防护
|
||||
}
|
||||
|
||||
// 加密存储AWS凭证
|
||||
@@ -343,6 +346,11 @@ class BedrockAccountService {
|
||||
account.subscriptionExpiresAt = updates.subscriptionExpiresAt
|
||||
}
|
||||
|
||||
// 自动防护开关
|
||||
if (updates.disableAutoProtection !== undefined) {
|
||||
account.disableAutoProtection = updates.disableAutoProtection
|
||||
}
|
||||
|
||||
account.updatedAt = new Date().toISOString()
|
||||
|
||||
await client.set(`bedrock_account:${accountId}`, JSON.stringify(account))
|
||||
@@ -776,6 +784,66 @@ class BedrockAccountService {
|
||||
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()
|
||||
@@ -1,8 +1,9 @@
|
||||
const { v4: uuidv4 } = require('uuid')
|
||||
const ProxyHelper = require('../utils/proxyHelper')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const { createEncryptor } = require('../utils/commonHelper')
|
||||
const ProxyHelper = require('../../utils/proxyHelper')
|
||||
const redis = require('../../models/redis')
|
||||
const logger = require('../../utils/logger')
|
||||
const { createEncryptor } = require('../../utils/commonHelper')
|
||||
const upstreamErrorHelper = require('../../utils/upstreamErrorHelper')
|
||||
|
||||
class CcrAccountService {
|
||||
constructor() {
|
||||
@@ -39,7 +40,8 @@ class CcrAccountService {
|
||||
accountType = 'shared', // 'dedicated' or 'shared'
|
||||
schedulable = true, // 是否可被调度
|
||||
dailyQuota = 0, // 每日额度限制(美元),0表示不限制
|
||||
quotaResetTime = '00:00' // 额度重置时间(HH:mm格式)
|
||||
quotaResetTime = '00:00', // 额度重置时间(HH:mm格式)
|
||||
disableAutoProtection = false // 是否关闭自动防护(429/401/400/529 不自动禁用)
|
||||
} = options
|
||||
|
||||
// 验证必填字段
|
||||
@@ -86,7 +88,8 @@ class CcrAccountService {
|
||||
// 使用与统计一致的时区日期,避免边界问题
|
||||
lastResetDate: redis.getDateStringInTimezone(), // 最后重置日期(按配置时区)
|
||||
quotaResetTime, // 额度重置时间
|
||||
quotaStoppedAt: '' // 因额度停用的时间
|
||||
quotaStoppedAt: '', // 因额度停用的时间
|
||||
disableAutoProtection: disableAutoProtection.toString() // 关闭自动防护
|
||||
}
|
||||
|
||||
const client = redis.getClientSafe()
|
||||
@@ -175,7 +178,8 @@ class CcrAccountService {
|
||||
dailyUsage: parseFloat(accountData.dailyUsage || '0'),
|
||||
lastResetDate: accountData.lastResetDate || '',
|
||||
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.schedulable = accountData.schedulable !== 'false' // 默认为true
|
||||
accountData.disableAutoProtection = accountData.disableAutoProtection === 'true'
|
||||
|
||||
if (accountData.proxy) {
|
||||
accountData.proxy = JSON.parse(accountData.proxy)
|
||||
@@ -299,6 +304,11 @@ class CcrAccountService {
|
||||
updatedData.subscriptionExpiresAt = updates.subscriptionExpiresAt
|
||||
}
|
||||
|
||||
// 自动防护开关
|
||||
if (updates.disableAutoProtection !== undefined) {
|
||||
updatedData.disableAutoProtection = updates.disableAutoProtection.toString()
|
||||
}
|
||||
|
||||
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updatedData)
|
||||
|
||||
// 处理共享账户集合变更
|
||||
@@ -691,7 +701,7 @@ class CcrAccountService {
|
||||
|
||||
// 发送 Webhook 通知
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: account.name || accountId,
|
||||
@@ -858,9 +868,12 @@ class CcrAccountService {
|
||||
|
||||
logger.success(`Reset all error status for CCR account ${accountId}`)
|
||||
|
||||
// 清除临时不可用状态
|
||||
await upstreamErrorHelper.clearTempUnavailable(accountId, 'ccr').catch(() => {})
|
||||
|
||||
// 异步发送 Webhook 通知(忽略错误)
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: accountData.name || accountId,
|
||||
@@ -1,22 +1,23 @@
|
||||
const { v4: uuidv4 } = require('uuid')
|
||||
const crypto = require('crypto')
|
||||
const ProxyHelper = require('../utils/proxyHelper')
|
||||
const ProxyHelper = require('../../utils/proxyHelper')
|
||||
const axios = require('axios')
|
||||
const redis = require('../models/redis')
|
||||
const config = require('../../config/config')
|
||||
const logger = require('../utils/logger')
|
||||
const { maskToken } = require('../utils/tokenMask')
|
||||
const redis = require('../../models/redis')
|
||||
const config = require('../../../config/config')
|
||||
const logger = require('../../utils/logger')
|
||||
const { maskToken } = require('../../utils/tokenMask')
|
||||
const upstreamErrorHelper = require('../../utils/upstreamErrorHelper')
|
||||
const {
|
||||
logRefreshStart,
|
||||
logRefreshSuccess,
|
||||
logRefreshError,
|
||||
logTokenUsage,
|
||||
logRefreshSkipped
|
||||
} = require('../utils/tokenRefreshLogger')
|
||||
const tokenRefreshService = require('./tokenRefreshService')
|
||||
const LRUCache = require('../utils/lruCache')
|
||||
const { formatDateWithTimezone, getISOStringWithTimezone } = require('../utils/dateHelper')
|
||||
const { isOpus45OrNewer } = require('../utils/modelHelper')
|
||||
} = require('../../utils/tokenRefreshLogger')
|
||||
const tokenRefreshService = require('../tokenRefreshService')
|
||||
const LRUCache = require('../../utils/lruCache')
|
||||
const { formatDateWithTimezone, getISOStringWithTimezone } = require('../../utils/dateHelper')
|
||||
const { isOpus45OrNewer } = require('../../utils/modelHelper')
|
||||
|
||||
/**
|
||||
* Check if account is Pro (not Max)
|
||||
@@ -401,7 +402,7 @@ class ClaudeAccountService {
|
||||
|
||||
// 发送Webhook通知
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: accountData.name,
|
||||
@@ -787,7 +788,7 @@ class ClaudeAccountService {
|
||||
// 检查是否手动禁用了账号,如果是则发送webhook通知
|
||||
if (updates.isActive === 'false' && accountData.isActive === 'true') {
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: updatedData.name || 'Unknown Account',
|
||||
@@ -831,7 +832,7 @@ class ClaudeAccountService {
|
||||
async deleteAccount(accountId) {
|
||||
try {
|
||||
// 首先从所有分组中移除此账户
|
||||
const accountGroupService = require('./accountGroupService')
|
||||
const accountGroupService = require('../accountGroupService')
|
||||
await accountGroupService.removeAccountFromAllGroups(accountId)
|
||||
|
||||
const result = await redis.deleteClaudeAccount(accountId)
|
||||
@@ -1387,7 +1388,7 @@ class ClaudeAccountService {
|
||||
|
||||
// 发送Webhook通知
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: accountData.name || 'Claude Account',
|
||||
@@ -1742,7 +1743,7 @@ class ClaudeAccountService {
|
||||
|
||||
// 发送Webhook通知
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: accountData.name || 'Claude Account',
|
||||
@@ -2386,7 +2387,7 @@ class ClaudeAccountService {
|
||||
|
||||
// 发送Webhook通知
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: accountData.name,
|
||||
@@ -2500,6 +2501,13 @@ class ClaudeAccountService {
|
||||
const serverErrorKey = `claude_account:${accountId}:5xx_errors`
|
||||
await redis.client.del(serverErrorKey)
|
||||
|
||||
// 清除过载状态
|
||||
const overloadKey = `account:overload:${accountId}`
|
||||
await redis.client.del(overloadKey)
|
||||
|
||||
// 清除临时不可用状态
|
||||
await upstreamErrorHelper.clearTempUnavailable(accountId, 'claude-official').catch(() => {})
|
||||
|
||||
logger.info(
|
||||
`✅ Successfully reset all error states for account ${accountData.name} (${accountId})`
|
||||
)
|
||||
@@ -2704,7 +2712,7 @@ class ClaudeAccountService {
|
||||
|
||||
// 发送Webhook通知
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: accountData.name,
|
||||
@@ -2795,7 +2803,7 @@ class ClaudeAccountService {
|
||||
if (canSendWarning) {
|
||||
// 发送Webhook通知
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: accountData.name || 'Claude Account',
|
||||
@@ -1,10 +1,11 @@
|
||||
const { v4: uuidv4 } = require('uuid')
|
||||
const crypto = require('crypto')
|
||||
const ProxyHelper = require('../utils/proxyHelper')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const config = require('../../config/config')
|
||||
const LRUCache = require('../utils/lruCache')
|
||||
const ProxyHelper = require('../../utils/proxyHelper')
|
||||
const redis = require('../../models/redis')
|
||||
const logger = require('../../utils/logger')
|
||||
const config = require('../../../config/config')
|
||||
const LRUCache = require('../../utils/lruCache')
|
||||
const upstreamErrorHelper = require('../../utils/upstreamErrorHelper')
|
||||
|
||||
class ClaudeConsoleAccountService {
|
||||
constructor() {
|
||||
@@ -414,7 +415,7 @@ class ClaudeConsoleAccountService {
|
||||
// 检查是否手动禁用了账号,如果是则发送webhook通知
|
||||
if (updates.isActive === false && existingAccount.isActive === true) {
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: updatedData.name || existingAccount.name || 'Unknown Account',
|
||||
@@ -512,8 +513,8 @@ class ClaudeConsoleAccountService {
|
||||
|
||||
// 发送Webhook通知
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
const { getISOStringWithTimezone } = require('../utils/dateHelper')
|
||||
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||
const { getISOStringWithTimezone } = require('../../utils/dateHelper')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: account.name || 'Claude Console Account',
|
||||
@@ -726,7 +727,7 @@ class ClaudeConsoleAccountService {
|
||||
|
||||
// 发送Webhook通知
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: account.name || 'Claude Console Account',
|
||||
@@ -793,7 +794,7 @@ class ClaudeConsoleAccountService {
|
||||
|
||||
// 发送Webhook通知,包含完整错误详情
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: account.name || 'Claude Console Account',
|
||||
@@ -947,7 +948,7 @@ class ClaudeConsoleAccountService {
|
||||
|
||||
// 发送Webhook通知
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: account.name || 'Claude Console Account',
|
||||
@@ -1040,7 +1041,7 @@ class ClaudeConsoleAccountService {
|
||||
// 发送Webhook通知
|
||||
if (accountData && Object.keys(accountData).length > 0) {
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: accountData.name || 'Unknown Account',
|
||||
@@ -1329,7 +1330,7 @@ class ClaudeConsoleAccountService {
|
||||
|
||||
// 发送webhook通知
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: accountData.name || 'Unknown Account',
|
||||
@@ -1479,9 +1480,12 @@ class ClaudeConsoleAccountService {
|
||||
|
||||
logger.success(`Reset all error status for Claude Console account ${accountId}`)
|
||||
|
||||
// 清除临时不可用状态
|
||||
await upstreamErrorHelper.clearTempUnavailable(accountId, 'claude-console').catch(() => {})
|
||||
|
||||
// 发送 Webhook 通知
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: accountData.name || accountId,
|
||||
@@ -1,11 +1,12 @@
|
||||
const { v4: uuidv4 } = require('uuid')
|
||||
const crypto = require('crypto')
|
||||
const axios = require('axios')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const { maskToken } = require('../utils/tokenMask')
|
||||
const ProxyHelper = require('../utils/proxyHelper')
|
||||
const { createEncryptor, isTruthy } = require('../utils/commonHelper')
|
||||
const redis = require('../../models/redis')
|
||||
const logger = require('../../utils/logger')
|
||||
const { maskToken } = require('../../utils/tokenMask')
|
||||
const ProxyHelper = require('../../utils/proxyHelper')
|
||||
const { createEncryptor, isTruthy } = require('../../utils/commonHelper')
|
||||
const upstreamErrorHelper = require('../../utils/upstreamErrorHelper')
|
||||
|
||||
/**
|
||||
* Droid 账户管理服务
|
||||
@@ -476,7 +477,8 @@ class DroidAccountService {
|
||||
authenticationMethod = '',
|
||||
expiresIn = null,
|
||||
apiKeys = [],
|
||||
userAgent = '' // 自定义 User-Agent
|
||||
userAgent = '', // 自定义 User-Agent
|
||||
disableAutoProtection = false // 是否关闭自动防护(429/401/400/529 不自动禁用)
|
||||
} = options
|
||||
|
||||
const accountId = uuidv4()
|
||||
@@ -753,7 +755,8 @@ class DroidAccountService {
|
||||
apiKeys: hasApiKeys ? JSON.stringify(apiKeyEntries) : '',
|
||||
apiKeyCount: hasApiKeys ? String(apiKeyEntries.length) : '0',
|
||||
apiKeyStrategy: hasApiKeys ? 'random_sticky' : '',
|
||||
userAgent: userAgent || '' // 自定义 User-Agent
|
||||
userAgent: userAgent || '', // 自定义 User-Agent
|
||||
disableAutoProtection: disableAutoProtection.toString() // 关闭自动防护
|
||||
}
|
||||
|
||||
await redis.setDroidAccount(accountId, accountData)
|
||||
@@ -1494,6 +1497,66 @@ class DroidAccountService {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
@@ -1,21 +1,22 @@
|
||||
const redisClient = require('../models/redis')
|
||||
const redisClient = require('../../models/redis')
|
||||
const { v4: uuidv4 } = require('uuid')
|
||||
const crypto = require('crypto')
|
||||
const https = require('https')
|
||||
const logger = require('../utils/logger')
|
||||
const logger = require('../../utils/logger')
|
||||
const { OAuth2Client } = require('google-auth-library')
|
||||
const { maskToken } = require('../utils/tokenMask')
|
||||
const ProxyHelper = require('../utils/proxyHelper')
|
||||
const { maskToken } = require('../../utils/tokenMask')
|
||||
const ProxyHelper = require('../../utils/proxyHelper')
|
||||
const upstreamErrorHelper = require('../../utils/upstreamErrorHelper')
|
||||
const {
|
||||
logRefreshStart,
|
||||
logRefreshSuccess,
|
||||
logRefreshError,
|
||||
logTokenUsage,
|
||||
logRefreshSkipped
|
||||
} = require('../utils/tokenRefreshLogger')
|
||||
const tokenRefreshService = require('./tokenRefreshService')
|
||||
const { createEncryptor } = require('../utils/commonHelper')
|
||||
const antigravityClient = require('./antigravityClient')
|
||||
} = require('../../utils/tokenRefreshLogger')
|
||||
const tokenRefreshService = require('../tokenRefreshService')
|
||||
const { createEncryptor } = require('../../utils/commonHelper')
|
||||
const antigravityClient = require('../antigravityClient')
|
||||
|
||||
// Gemini 账户键前缀
|
||||
const GEMINI_ACCOUNT_KEY_PREFIX = 'gemini_account:'
|
||||
@@ -134,7 +135,7 @@ async function fetchAvailableModelsAntigravity(
|
||||
getAntigravityModelAlias,
|
||||
getAntigravityModelMetadata,
|
||||
normalizeAntigravityModelInput
|
||||
} = require('../utils/antigravityModel')
|
||||
} = require('../../utils/antigravityModel')
|
||||
|
||||
const pushModel = (modelId) => {
|
||||
if (!modelId || seen.has(modelId)) {
|
||||
@@ -523,6 +524,12 @@ async function createAccount(accountData) {
|
||||
// 支持的模型列表(可选)
|
||||
supportedModels: accountData.supportedModels || [], // 空数组表示支持所有模型
|
||||
|
||||
// 自动防护开关
|
||||
disableAutoProtection:
|
||||
accountData.disableAutoProtection === true || accountData.disableAutoProtection === 'true'
|
||||
? 'true'
|
||||
: 'false',
|
||||
|
||||
// 时间戳
|
||||
createdAt: 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
|
||||
if (updates.geminiOauth && !oldRefreshToken) {
|
||||
const oauthData =
|
||||
@@ -692,7 +707,7 @@ async function updateAccount(accountId, updates) {
|
||||
// 检查是否手动禁用了账号,如果是则发送webhook通知
|
||||
if (updates.isActive === 'false' && existingAccount.isActive !== 'false') {
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: updates.name || existingAccount.name || 'Unknown Account',
|
||||
@@ -1076,7 +1091,7 @@ async function refreshAccountToken(accountId) {
|
||||
|
||||
// 发送Webhook通知
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: account.name,
|
||||
@@ -1843,9 +1858,12 @@ async function resetAccountStatus(accountId) {
|
||||
await updateAccount(accountId, updates)
|
||||
logger.info(`✅ Reset all error status for Gemini account ${accountId}`)
|
||||
|
||||
// 清除临时不可用状态
|
||||
await upstreamErrorHelper.clearTempUnavailable(accountId, 'gemini').catch(() => {})
|
||||
|
||||
// 发送 Webhook 通知
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: account.name || accountId,
|
||||
@@ -1,9 +1,10 @@
|
||||
const { v4: uuidv4 } = require('uuid')
|
||||
const crypto = require('crypto')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const config = require('../../config/config')
|
||||
const LRUCache = require('../utils/lruCache')
|
||||
const redis = require('../../models/redis')
|
||||
const logger = require('../../utils/logger')
|
||||
const config = require('../../../config/config')
|
||||
const LRUCache = require('../../utils/lruCache')
|
||||
const upstreamErrorHelper = require('../../utils/upstreamErrorHelper')
|
||||
|
||||
class GeminiApiAccountService {
|
||||
constructor() {
|
||||
@@ -44,7 +45,8 @@ class GeminiApiAccountService {
|
||||
accountType = 'shared', // 'dedicated' or 'shared'
|
||||
schedulable = true, // 是否可被调度
|
||||
supportedModels = [], // 支持的模型列表
|
||||
rateLimitDuration = 60 // 限流时间(分钟)
|
||||
rateLimitDuration = 60, // 限流时间(分钟)
|
||||
disableAutoProtection = false
|
||||
} = options
|
||||
|
||||
// 验证必填字段
|
||||
@@ -79,7 +81,11 @@ class GeminiApiAccountService {
|
||||
// 限流相关
|
||||
rateLimitedAt: '',
|
||||
rateLimitStatus: '',
|
||||
rateLimitDuration: rateLimitDuration.toString()
|
||||
rateLimitDuration: rateLimitDuration.toString(),
|
||||
|
||||
// 自动防护开关
|
||||
disableAutoProtection:
|
||||
disableAutoProtection === true || disableAutoProtection === 'true' ? 'true' : 'false'
|
||||
}
|
||||
|
||||
// 保存到 Redis
|
||||
@@ -154,6 +160,14 @@ class GeminiApiAccountService {
|
||||
: updates.baseUrl
|
||||
}
|
||||
|
||||
// 处理 disableAutoProtection 布尔值转字符串
|
||||
if (updates.disableAutoProtection !== undefined) {
|
||||
updates.disableAutoProtection =
|
||||
updates.disableAutoProtection === true || updates.disableAutoProtection === 'true'
|
||||
? 'true'
|
||||
: 'false'
|
||||
}
|
||||
|
||||
// 更新 Redis
|
||||
const client = redis.getClientSafe()
|
||||
const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
@@ -363,7 +377,7 @@ class GeminiApiAccountService {
|
||||
)
|
||||
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: account.name || accountId,
|
||||
@@ -456,9 +470,12 @@ class GeminiApiAccountService {
|
||||
await this.updateAccount(accountId, updates)
|
||||
logger.info(`✅ Reset all error status for Gemini-API account ${accountId}`)
|
||||
|
||||
// 清除临时不可用状态
|
||||
await upstreamErrorHelper.clearTempUnavailable(accountId, 'gemini-api').catch(() => {})
|
||||
|
||||
// 发送 Webhook 通知
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: account.name || accountId,
|
||||
@@ -1,19 +1,20 @@
|
||||
const redisClient = require('../models/redis')
|
||||
const redisClient = require('../../models/redis')
|
||||
const { v4: uuidv4 } = require('uuid')
|
||||
const axios = require('axios')
|
||||
const ProxyHelper = require('../utils/proxyHelper')
|
||||
const config = require('../../config/config')
|
||||
const logger = require('../utils/logger')
|
||||
// const { maskToken } = require('../utils/tokenMask')
|
||||
const ProxyHelper = require('../../utils/proxyHelper')
|
||||
const config = require('../../../config/config')
|
||||
const logger = require('../../utils/logger')
|
||||
const upstreamErrorHelper = require('../../utils/upstreamErrorHelper')
|
||||
// const { maskToken } = require('../../utils/tokenMask')
|
||||
const {
|
||||
logRefreshStart,
|
||||
logRefreshSuccess,
|
||||
logRefreshError,
|
||||
logTokenUsage,
|
||||
logRefreshSkipped
|
||||
} = require('../utils/tokenRefreshLogger')
|
||||
const tokenRefreshService = require('./tokenRefreshService')
|
||||
const { createEncryptor } = require('../utils/commonHelper')
|
||||
} = require('../../utils/tokenRefreshLogger')
|
||||
const tokenRefreshService = require('../tokenRefreshService')
|
||||
const { createEncryptor } = require('../../utils/commonHelper')
|
||||
|
||||
// 使用 commonHelper 的加密器
|
||||
const encryptor = createEncryptor('openai-account-salt')
|
||||
@@ -405,7 +406,7 @@ async function refreshAccountToken(accountId) {
|
||||
|
||||
// 发送 Webhook 通知(如果启用)
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: account?.name || accountName,
|
||||
@@ -496,6 +497,11 @@ async function createAccount(accountData) {
|
||||
isActive: accountData.isActive !== false ? 'true' : 'false',
|
||||
status: 'active',
|
||||
schedulable: accountData.schedulable !== false ? 'true' : 'false',
|
||||
// 自动防护开关
|
||||
disableAutoProtection:
|
||||
accountData.disableAutoProtection === true || accountData.disableAutoProtection === 'true'
|
||||
? 'true'
|
||||
: 'false',
|
||||
lastRefresh: now,
|
||||
createdAt: 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()
|
||||
if (updates.accountType && updates.accountType !== existingAccount.accountType) {
|
||||
@@ -961,7 +975,7 @@ async function setAccountRateLimited(accountId, isLimited, resetsInSeconds = nul
|
||||
if (isLimited) {
|
||||
try {
|
||||
const account = await getAccount(accountId)
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: account.name || accountId,
|
||||
@@ -1005,7 +1019,7 @@ async function markAccountUnauthorized(accountId, reason = 'OpenAI账号认证
|
||||
)
|
||||
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: account.name || accountId,
|
||||
@@ -1045,9 +1059,12 @@ async function resetAccountStatus(accountId) {
|
||||
await updateAccount(accountId, updates)
|
||||
logger.info(`✅ Reset all error status for OpenAI account ${accountId}`)
|
||||
|
||||
// 清除临时不可用状态
|
||||
await upstreamErrorHelper.clearTempUnavailable(accountId, 'openai').catch(() => {})
|
||||
|
||||
// 发送 Webhook 通知
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: account.name || accountId,
|
||||
@@ -1,9 +1,10 @@
|
||||
const { v4: uuidv4 } = require('uuid')
|
||||
const crypto = require('crypto')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const config = require('../../config/config')
|
||||
const LRUCache = require('../utils/lruCache')
|
||||
const redis = require('../../models/redis')
|
||||
const logger = require('../../utils/logger')
|
||||
const config = require('../../../config/config')
|
||||
const LRUCache = require('../../utils/lruCache')
|
||||
const upstreamErrorHelper = require('../../utils/upstreamErrorHelper')
|
||||
|
||||
class OpenAIResponsesAccountService {
|
||||
constructor() {
|
||||
@@ -49,7 +50,8 @@ class OpenAIResponsesAccountService {
|
||||
schedulable = true, // 是否可被调度
|
||||
dailyQuota = 0, // 每日额度限制(美元),0表示不限制
|
||||
quotaResetTime = '00:00', // 额度重置时间(HH:mm格式)
|
||||
rateLimitDuration = 60 // 限流时间(分钟)
|
||||
rateLimitDuration = 60, // 限流时间(分钟)
|
||||
disableAutoProtection = false // 是否关闭自动防护(429/401/400/529 不自动禁用)
|
||||
} = options
|
||||
|
||||
// 验证必填字段
|
||||
@@ -93,7 +95,8 @@ class OpenAIResponsesAccountService {
|
||||
dailyUsage: '0',
|
||||
lastResetDate: redis.getDateStringInTimezone(),
|
||||
quotaResetTime,
|
||||
quotaStoppedAt: ''
|
||||
quotaStoppedAt: '',
|
||||
disableAutoProtection: disableAutoProtection.toString() // 关闭自动防护
|
||||
}
|
||||
|
||||
// 保存到 Redis
|
||||
@@ -162,6 +165,11 @@ class OpenAIResponsesAccountService {
|
||||
// 直接保存,不做任何调整
|
||||
}
|
||||
|
||||
// 自动防护开关
|
||||
if (updates.disableAutoProtection !== undefined) {
|
||||
updates.disableAutoProtection = updates.disableAutoProtection.toString()
|
||||
}
|
||||
|
||||
// 更新 Redis
|
||||
const client = redis.getClientSafe()
|
||||
const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
@@ -310,7 +318,7 @@ class OpenAIResponsesAccountService {
|
||||
)
|
||||
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: account.name || accountId,
|
||||
@@ -475,9 +483,12 @@ class OpenAIResponsesAccountService {
|
||||
await this.updateAccount(accountId, updates)
|
||||
logger.info(`✅ Reset all error status for OpenAI-Responses account ${accountId}`)
|
||||
|
||||
// 清除临时不可用状态
|
||||
await upstreamErrorHelper.clearTempUnavailable(accountId, 'openai-responses').catch(() => {})
|
||||
|
||||
// 发送 Webhook 通知
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
const webhookNotifier = require('../../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: account.name || accountId,
|
||||
@@ -49,26 +49,26 @@ class AccountNameCacheService {
|
||||
const newGroupCache = new Map()
|
||||
|
||||
// 延迟加载服务,避免循环依赖
|
||||
const claudeAccountService = require('./claudeAccountService')
|
||||
const claudeConsoleAccountService = require('./claudeConsoleAccountService')
|
||||
const geminiAccountService = require('./geminiAccountService')
|
||||
const openaiAccountService = require('./openaiAccountService')
|
||||
const azureOpenaiAccountService = require('./azureOpenaiAccountService')
|
||||
const bedrockAccountService = require('./bedrockAccountService')
|
||||
const droidAccountService = require('./droidAccountService')
|
||||
const ccrAccountService = require('./ccrAccountService')
|
||||
const claudeAccountService = require('./account/claudeAccountService')
|
||||
const claudeConsoleAccountService = require('./account/claudeConsoleAccountService')
|
||||
const geminiAccountService = require('./account/geminiAccountService')
|
||||
const openaiAccountService = require('./account/openaiAccountService')
|
||||
const azureOpenaiAccountService = require('./account/azureOpenaiAccountService')
|
||||
const bedrockAccountService = require('./account/bedrockAccountService')
|
||||
const droidAccountService = require('./account/droidAccountService')
|
||||
const ccrAccountService = require('./account/ccrAccountService')
|
||||
const accountGroupService = require('./accountGroupService')
|
||||
|
||||
// 可选服务(可能不存在)
|
||||
let geminiApiAccountService = null
|
||||
let openaiResponsesAccountService = null
|
||||
try {
|
||||
geminiApiAccountService = require('./geminiApiAccountService')
|
||||
geminiApiAccountService = require('./account/geminiApiAccountService')
|
||||
} catch (e) {
|
||||
// 服务不存在,忽略
|
||||
}
|
||||
try {
|
||||
openaiResponsesAccountService = require('./openaiResponsesAccountService')
|
||||
openaiResponsesAccountService = require('./account/openaiResponsesAccountService')
|
||||
} catch (e) {
|
||||
// 服务不存在,忽略
|
||||
}
|
||||
|
||||
@@ -269,7 +269,7 @@ class AccountTestSchedulerService {
|
||||
* @private
|
||||
*/
|
||||
async _testClaudeAccount(accountId, model) {
|
||||
const claudeRelayService = require('./claudeRelayService')
|
||||
const claudeRelayService = require('./relay/claudeRelayService')
|
||||
return await claudeRelayService.testAccountConnectionSync(accountId, model)
|
||||
}
|
||||
|
||||
|
||||
@@ -34,8 +34,8 @@ const fs = require('fs')
|
||||
const path = require('path')
|
||||
const logger = require('../utils/logger')
|
||||
const { getProjectRoot } = require('../utils/projectPaths')
|
||||
const geminiAccountService = require('./geminiAccountService')
|
||||
const unifiedGeminiScheduler = require('./unifiedGeminiScheduler')
|
||||
const geminiAccountService = require('./account/geminiAccountService')
|
||||
const unifiedGeminiScheduler = require('./scheduler/unifiedGeminiScheduler')
|
||||
const sessionHelper = require('../utils/sessionHelper')
|
||||
const signatureCache = require('../utils/signatureCache')
|
||||
const apiKeyService = require('./apiKeyService')
|
||||
|
||||
@@ -280,16 +280,16 @@ class ClaudeRelayConfigService {
|
||||
let accountService
|
||||
switch (accountType) {
|
||||
case 'claude-official':
|
||||
accountService = require('./claudeAccountService')
|
||||
accountService = require('./account/claudeAccountService')
|
||||
break
|
||||
case 'claude-console':
|
||||
accountService = require('./claudeConsoleAccountService')
|
||||
accountService = require('./account/claudeConsoleAccountService')
|
||||
break
|
||||
case 'bedrock':
|
||||
accountService = require('./bedrockAccountService')
|
||||
accountService = require('./account/bedrockAccountService')
|
||||
break
|
||||
case 'ccr':
|
||||
accountService = require('./ccrAccountService')
|
||||
accountService = require('./account/ccrAccountService')
|
||||
break
|
||||
default:
|
||||
logger.warn(`Unknown account type for validation: ${accountType}`)
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
*/
|
||||
|
||||
const logger = require('../utils/logger')
|
||||
const openaiAccountService = require('./openaiAccountService')
|
||||
const claudeAccountService = require('./claudeAccountService')
|
||||
const claudeConsoleAccountService = require('./claudeConsoleAccountService')
|
||||
const unifiedOpenAIScheduler = require('./unifiedOpenAIScheduler')
|
||||
const openaiAccountService = require('./account/openaiAccountService')
|
||||
const claudeAccountService = require('./account/claudeAccountService')
|
||||
const claudeConsoleAccountService = require('./account/claudeConsoleAccountService')
|
||||
const unifiedOpenAIScheduler = require('./scheduler/unifiedOpenAIScheduler')
|
||||
const webhookService = require('./webhookService')
|
||||
|
||||
class RateLimitCleanupService {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const apiKeyService = require('./apiKeyService')
|
||||
const apiKeyService = require('../apiKeyService')
|
||||
const { convertMessagesToGemini, convertGeminiResponse } = require('./geminiRelayService')
|
||||
const { normalizeAntigravityModelInput } = require('../utils/antigravityModel')
|
||||
const antigravityClient = require('./antigravityClient')
|
||||
const { normalizeAntigravityModelInput } = require('../../utils/antigravityModel')
|
||||
const antigravityClient = require('../antigravityClient')
|
||||
|
||||
function buildRequestData({ messages, model, temperature, maxTokens, sessionId }) {
|
||||
const requestedModel = normalizeAntigravityModelInput(model)
|
||||
@@ -1,7 +1,8 @@
|
||||
const axios = require('axios')
|
||||
const ProxyHelper = require('../utils/proxyHelper')
|
||||
const logger = require('../utils/logger')
|
||||
const config = require('../../config/config')
|
||||
const ProxyHelper = require('../../utils/proxyHelper')
|
||||
const logger = require('../../utils/logger')
|
||||
const config = require('../../../config/config')
|
||||
const upstreamErrorHelper = require('../../utils/upstreamErrorHelper')
|
||||
|
||||
// 转换模型名称(去掉 azure/ 前缀)
|
||||
function normalizeModelName(model) {
|
||||
@@ -212,6 +213,16 @@ async function handleAzureOpenAIRequest({
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,10 @@ const {
|
||||
InvokeModelWithResponseStreamCommand
|
||||
} = require('@aws-sdk/client-bedrock-runtime')
|
||||
const { fromEnv } = require('@aws-sdk/credential-providers')
|
||||
const logger = require('../utils/logger')
|
||||
const config = require('../../config/config')
|
||||
const userMessageQueueService = require('./userMessageQueueService')
|
||||
const logger = require('../../utils/logger')
|
||||
const config = require('../../../config/config')
|
||||
const userMessageQueueService = require('../userMessageQueueService')
|
||||
const upstreamErrorHelper = require('../../utils/upstreamErrorHelper')
|
||||
|
||||
class BedrockRelayService {
|
||||
constructor() {
|
||||
@@ -188,7 +189,7 @@ class BedrockRelayService {
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Bedrock非流式请求失败:', error)
|
||||
throw this._handleBedrockError(error)
|
||||
throw this._handleBedrockError(error, accountId, bedrockAccount)
|
||||
} finally {
|
||||
// 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放)
|
||||
if (queueLockAcquired && queueRequestId && accountId) {
|
||||
@@ -376,10 +377,12 @@ class BedrockRelayService {
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
throw this._handleBedrockError(error)
|
||||
throw this._handleBedrockError(error, accountId, bedrockAccount)
|
||||
} finally {
|
||||
// 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放)
|
||||
if (queueLockAcquired && queueRequestId && accountId) {
|
||||
@@ -647,7 +650,25 @@ class BedrockRelayService {
|
||||
}
|
||||
|
||||
// 处理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'
|
||||
|
||||
if (error.name === 'ValidationException') {
|
||||
@@ -1,10 +1,11 @@
|
||||
const axios = require('axios')
|
||||
const ccrAccountService = require('./ccrAccountService')
|
||||
const logger = require('../utils/logger')
|
||||
const config = require('../../config/config')
|
||||
const { parseVendorPrefixedModel } = require('../utils/modelHelper')
|
||||
const userMessageQueueService = require('./userMessageQueueService')
|
||||
const { isStreamWritable } = require('../utils/streamHelper')
|
||||
const ccrAccountService = require('../account/ccrAccountService')
|
||||
const logger = require('../../utils/logger')
|
||||
const config = require('../../../config/config')
|
||||
const { parseVendorPrefixedModel } = require('../../utils/modelHelper')
|
||||
const userMessageQueueService = require('../userMessageQueueService')
|
||||
const { isStreamWritable } = require('../../utils/streamHelper')
|
||||
const upstreamErrorHelper = require('../../utils/upstreamErrorHelper')
|
||||
|
||||
class CcrRelayService {
|
||||
constructor() {
|
||||
@@ -261,7 +262,11 @@ class CcrRelayService {
|
||||
// 检查错误状态并相应处理
|
||||
if (response.status === 401) {
|
||||
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) {
|
||||
logger.warn(`🚫 Rate limit detected for CCR account ${accountId}`)
|
||||
// 收到429先检查是否因为超过了手动配置的每日额度
|
||||
@@ -270,9 +275,35 @@ class CcrRelayService {
|
||||
})
|
||||
|
||||
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) {
|
||||
logger.warn(`🚫 Overload error detected for CCR account ${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) {
|
||||
// 如果请求成功,检查并移除错误状态
|
||||
const isRateLimited = await ccrAccountService.isAccountRateLimited(accountId)
|
||||
@@ -310,6 +341,15 @@ class CcrRelayService {
|
||||
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
|
||||
} finally {
|
||||
// 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放)
|
||||
@@ -489,6 +529,14 @@ class CcrRelayService {
|
||||
)
|
||||
} else {
|
||||
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
|
||||
} finally {
|
||||
@@ -596,16 +644,40 @@ class CcrRelayService {
|
||||
`❌ CCR API returned error status: ${response.status} | Account: ${account?.name || accountId}`
|
||||
)
|
||||
|
||||
const autoProtectionDisabled =
|
||||
account?.disableAutoProtection === true || account?.disableAutoProtection === 'true'
|
||||
|
||||
if (response.status === 401) {
|
||||
ccrAccountService.markAccountUnauthorized(accountId)
|
||||
if (!autoProtectionDisabled) {
|
||||
upstreamErrorHelper.markTempUnavailable(accountId, 'ccr', 401).catch(() => {})
|
||||
}
|
||||
} else if (response.status === 429) {
|
||||
ccrAccountService.markAccountRateLimited(accountId)
|
||||
if (!autoProtectionDisabled) {
|
||||
upstreamErrorHelper
|
||||
.markTempUnavailable(
|
||||
accountId,
|
||||
'ccr',
|
||||
429,
|
||||
upstreamErrorHelper.parseRetryAfter(response.headers)
|
||||
)
|
||||
.catch(() => {})
|
||||
}
|
||||
// 检查是否因为超过每日额度
|
||||
ccrAccountService.checkQuotaUsage(accountId).catch((err) => {
|
||||
logger.error('❌ Failed to check quota after 429 error:', err)
|
||||
})
|
||||
} else if (response.status === 529) {
|
||||
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) {
|
||||
try {
|
||||
const redis = require('../models/redis')
|
||||
const redis = require('../../models/redis')
|
||||
const client = redis.getClientSafe()
|
||||
await client.hset(`ccr_account:${accountId}`, 'lastUsedAt', new Date().toISOString())
|
||||
} catch (error) {
|
||||
@@ -1,17 +1,18 @@
|
||||
const axios = require('axios')
|
||||
const { v4: uuidv4 } = require('uuid')
|
||||
const claudeConsoleAccountService = require('./claudeConsoleAccountService')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const config = require('../../config/config')
|
||||
const claudeConsoleAccountService = require('../account/claudeConsoleAccountService')
|
||||
const redis = require('../../models/redis')
|
||||
const logger = require('../../utils/logger')
|
||||
const config = require('../../../config/config')
|
||||
const {
|
||||
sanitizeUpstreamError,
|
||||
sanitizeErrorMessage,
|
||||
isAccountDisabledError
|
||||
} = require('../utils/errorSanitizer')
|
||||
const userMessageQueueService = require('./userMessageQueueService')
|
||||
const { isStreamWritable } = require('../utils/streamHelper')
|
||||
const { filterForClaude } = require('../utils/headerFilter')
|
||||
} = require('../../utils/errorSanitizer')
|
||||
const upstreamErrorHelper = require('../../utils/upstreamErrorHelper')
|
||||
const userMessageQueueService = require('../userMessageQueueService')
|
||||
const { isStreamWritable } = require('../../utils/streamHelper')
|
||||
const { filterForClaude } = require('../../utils/headerFilter')
|
||||
|
||||
class ClaudeConsoleRelayService {
|
||||
constructor() {
|
||||
@@ -334,7 +335,9 @@ class ClaudeConsoleRelayService {
|
||||
`🚫 Unauthorized error detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
|
||||
)
|
||||
if (!autoProtectionDisabled) {
|
||||
await claudeConsoleAccountService.markAccountUnauthorized(accountId)
|
||||
await upstreamErrorHelper
|
||||
.markTempUnavailable(accountId, 'claude-console', 401)
|
||||
.catch(() => {})
|
||||
}
|
||||
} else if (accountDisabledError) {
|
||||
logger.error(
|
||||
@@ -357,6 +360,14 @@ class ClaudeConsoleRelayService {
|
||||
|
||||
if (!autoProtectionDisabled) {
|
||||
await claudeConsoleAccountService.markAccountRateLimited(accountId)
|
||||
await upstreamErrorHelper
|
||||
.markTempUnavailable(
|
||||
accountId,
|
||||
'claude-console',
|
||||
429,
|
||||
upstreamErrorHelper.parseRetryAfter(response.headers)
|
||||
)
|
||||
.catch(() => {})
|
||||
}
|
||||
} else if (response.status === 529) {
|
||||
logger.warn(
|
||||
@@ -364,6 +375,18 @@ class ClaudeConsoleRelayService {
|
||||
)
|
||||
if (!autoProtectionDisabled) {
|
||||
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) {
|
||||
// 如果请求成功,检查并移除错误状态
|
||||
@@ -831,7 +854,9 @@ class ClaudeConsoleRelayService {
|
||||
`🚫 [Stream] Unauthorized error detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
|
||||
)
|
||||
if (!autoProtectionDisabled) {
|
||||
await claudeConsoleAccountService.markAccountUnauthorized(accountId)
|
||||
await upstreamErrorHelper
|
||||
.markTempUnavailable(accountId, 'claude-console', 401)
|
||||
.catch(() => {})
|
||||
}
|
||||
} else if (accountDisabledError) {
|
||||
logger.error(
|
||||
@@ -854,6 +879,14 @@ class ClaudeConsoleRelayService {
|
||||
})
|
||||
if (!autoProtectionDisabled) {
|
||||
await claudeConsoleAccountService.markAccountRateLimited(accountId)
|
||||
await upstreamErrorHelper
|
||||
.markTempUnavailable(
|
||||
accountId,
|
||||
'claude-console',
|
||||
429,
|
||||
upstreamErrorHelper.parseRetryAfter(response.headers)
|
||||
)
|
||||
.catch(() => {})
|
||||
}
|
||||
} else if (response.status === 529) {
|
||||
logger.warn(
|
||||
@@ -861,6 +894,18 @@ class ClaudeConsoleRelayService {
|
||||
)
|
||||
if (!autoProtectionDisabled) {
|
||||
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) {
|
||||
const catchAutoProtectionDisabled =
|
||||
account?.disableAutoProtection === true || account?.disableAutoProtection === 'true'
|
||||
if (error.response.status === 401) {
|
||||
claudeConsoleAccountService.markAccountUnauthorized(accountId)
|
||||
if (!catchAutoProtectionDisabled) {
|
||||
upstreamErrorHelper
|
||||
.markTempUnavailable(accountId, 'claude-console', 401)
|
||||
.catch(() => {})
|
||||
}
|
||||
} else if (error.response.status === 429) {
|
||||
claudeConsoleAccountService.markAccountRateLimited(accountId)
|
||||
// 检查是否因为超过每日额度
|
||||
claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => {
|
||||
logger.error('❌ Failed to check quota after 429 error:', err)
|
||||
})
|
||||
if (!catchAutoProtectionDisabled) {
|
||||
claudeConsoleAccountService.markAccountRateLimited(accountId)
|
||||
// 检查是否因为超过每日额度
|
||||
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) {
|
||||
claudeConsoleAccountService.markAccountOverloaded(accountId)
|
||||
if (!catchAutoProtectionDisabled) {
|
||||
claudeConsoleAccountService.markAccountOverloaded(accountId)
|
||||
upstreamErrorHelper
|
||||
.markTempUnavailable(accountId, 'claude-console', 529)
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1311,7 +1377,7 @@ class ClaudeConsoleRelayService {
|
||||
// 🕐 更新最后使用时间
|
||||
async _updateLastUsedTime(accountId) {
|
||||
try {
|
||||
const client = require('../models/redis').getClientSafe()
|
||||
const client = require('../../models/redis').getClientSafe()
|
||||
const accountKey = `claude_console_account:${accountId}`
|
||||
const exists = await client.exists(accountKey)
|
||||
|
||||
@@ -1390,7 +1456,7 @@ class ClaudeConsoleRelayService {
|
||||
|
||||
// 🧪 测试账号连接(供Admin API使用)
|
||||
async testAccountConnection(accountId, responseStream) {
|
||||
const { sendStreamTestRequest } = require('../utils/testPayloadHelper')
|
||||
const { sendStreamTestRequest } = require('../../utils/testPayloadHelper')
|
||||
|
||||
try {
|
||||
const account = await claudeConsoleAccountService.getAccount(accountId)
|
||||
@@ -1,26 +1,27 @@
|
||||
const https = require('https')
|
||||
const zlib = require('zlib')
|
||||
const path = require('path')
|
||||
const ProxyHelper = require('../utils/proxyHelper')
|
||||
const { filterForClaude } = require('../utils/headerFilter')
|
||||
const claudeAccountService = require('./claudeAccountService')
|
||||
const unifiedClaudeScheduler = require('./unifiedClaudeScheduler')
|
||||
const sessionHelper = require('../utils/sessionHelper')
|
||||
const logger = require('../utils/logger')
|
||||
const config = require('../../config/config')
|
||||
const claudeCodeHeadersService = require('./claudeCodeHeadersService')
|
||||
const redis = require('../models/redis')
|
||||
const ClaudeCodeValidator = require('../validators/clients/claudeCodeValidator')
|
||||
const { formatDateWithTimezone } = require('../utils/dateHelper')
|
||||
const requestIdentityService = require('./requestIdentityService')
|
||||
const { createClaudeTestPayload } = require('../utils/testPayloadHelper')
|
||||
const userMessageQueueService = require('./userMessageQueueService')
|
||||
const { isStreamWritable } = require('../utils/streamHelper')
|
||||
const ProxyHelper = require('../../utils/proxyHelper')
|
||||
const { filterForClaude } = require('../../utils/headerFilter')
|
||||
const claudeAccountService = require('../account/claudeAccountService')
|
||||
const unifiedClaudeScheduler = require('../scheduler/unifiedClaudeScheduler')
|
||||
const sessionHelper = require('../../utils/sessionHelper')
|
||||
const logger = require('../../utils/logger')
|
||||
const config = require('../../../config/config')
|
||||
const claudeCodeHeadersService = require('../claudeCodeHeadersService')
|
||||
const redis = require('../../models/redis')
|
||||
const ClaudeCodeValidator = require('../../validators/clients/claudeCodeValidator')
|
||||
const { formatDateWithTimezone } = require('../../utils/dateHelper')
|
||||
const requestIdentityService = require('../requestIdentityService')
|
||||
const { createClaudeTestPayload } = require('../../utils/testPayloadHelper')
|
||||
const userMessageQueueService = require('../userMessageQueueService')
|
||||
const { isStreamWritable } = require('../../utils/streamHelper')
|
||||
const upstreamErrorHelper = require('../../utils/upstreamErrorHelper')
|
||||
const {
|
||||
getHttpsAgentForStream,
|
||||
getHttpsAgentForNonStream,
|
||||
getPricingData
|
||||
} = require('../utils/performanceOptimizer')
|
||||
} = require('../../utils/performanceOptimizer')
|
||||
|
||||
// structuredClone polyfill for Node < 17
|
||||
const safeClone =
|
||||
@@ -693,22 +694,26 @@ class ClaudeRelayService {
|
||||
|
||||
if (errorCount >= 1) {
|
||||
logger.error(
|
||||
`❌ Account ${accountId} encountered 401 error (${errorCount} errors), marking as unauthorized`
|
||||
)
|
||||
await unifiedClaudeScheduler.markAccountUnauthorized(
|
||||
accountId,
|
||||
accountType,
|
||||
sessionHash
|
||||
`❌ Account ${accountId} encountered 401 error (${errorCount} errors), temporarily pausing`
|
||||
)
|
||||
}
|
||||
await upstreamErrorHelper.markTempUnavailable(accountId, accountType, 401).catch(() => {})
|
||||
// 清除粘性会话,让后续请求路由到其他账户
|
||||
if (sessionHash) {
|
||||
await unifiedClaudeScheduler.clearSessionMapping(sessionHash).catch(() => {})
|
||||
}
|
||||
}
|
||||
// 检查是否为403状态码(禁止访问)
|
||||
// 注意:如果进行了重试,retryCount > 0;这里的 403 是重试后最终的结果
|
||||
else if (response.statusCode === 403) {
|
||||
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状态码)
|
||||
else if (organizationDisabledError) {
|
||||
@@ -734,6 +739,7 @@ class ClaudeRelayService {
|
||||
} else {
|
||||
logger.info(`🚫 529 error handling is disabled, skipping account overload marking`)
|
||||
}
|
||||
await upstreamErrorHelper.markTempUnavailable(accountId, accountType, 529).catch(() => {})
|
||||
}
|
||||
// 检查是否为5xx状态码
|
||||
else if (response.statusCode >= 500 && response.statusCode < 600) {
|
||||
@@ -819,6 +825,14 @@ class ClaudeRelayService {
|
||||
sessionHash,
|
||||
rateLimitResetTimestamp
|
||||
)
|
||||
await upstreamErrorHelper
|
||||
.markTempUnavailable(
|
||||
accountId,
|
||||
accountType,
|
||||
429,
|
||||
upstreamErrorHelper.parseRetryAfter(response.headers)
|
||||
)
|
||||
.catch(() => {})
|
||||
|
||||
if (dedicatedRateLimitMessage) {
|
||||
return {
|
||||
@@ -1935,6 +1949,14 @@ class ClaudeRelayService {
|
||||
sessionHash,
|
||||
rateLimitResetTimestamp
|
||||
)
|
||||
await upstreamErrorHelper
|
||||
.markTempUnavailable(
|
||||
accountId,
|
||||
accountType,
|
||||
429,
|
||||
upstreamErrorHelper.parseRetryAfter(res.headers)
|
||||
)
|
||||
.catch(() => {})
|
||||
logger.warn(`🚫 [Stream] Rate limit detected for account ${accountId}, status 429`)
|
||||
|
||||
if (isDedicatedOfficialAccount) {
|
||||
@@ -2032,21 +2054,29 @@ class ClaudeRelayService {
|
||||
|
||||
if (errorCount >= 1) {
|
||||
logger.error(
|
||||
`❌ [Stream] Account ${accountId} encountered 401 error (${errorCount} errors), marking as unauthorized`
|
||||
)
|
||||
await unifiedClaudeScheduler.markAccountUnauthorized(
|
||||
accountId,
|
||||
accountType,
|
||||
sessionHash
|
||||
`❌ [Stream] Account ${accountId} encountered 401 error (${errorCount} errors), temporarily pausing`
|
||||
)
|
||||
}
|
||||
await upstreamErrorHelper
|
||||
.markTempUnavailable(accountId, accountType, 401)
|
||||
.catch(() => {})
|
||||
// 清除粘性会话,让后续请求路由到其他账户
|
||||
if (sessionHash) {
|
||||
await unifiedClaudeScheduler.clearSessionMapping(sessionHash).catch(() => {})
|
||||
}
|
||||
} else if (res.statusCode === 403) {
|
||||
// 403 处理:走到这里说明重试已用尽或不适用重试,直接标记 blocked
|
||||
// 注意:重试逻辑已在 handleErrorResponse 外部提前处理
|
||||
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) {
|
||||
logger.warn(`🚫 [Stream] Overload error (529) detected for account ${accountId}`)
|
||||
|
||||
@@ -2068,6 +2098,9 @@ class ClaudeRelayService {
|
||||
`🚫 [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) {
|
||||
logger.warn(
|
||||
`🔥 [Stream] Server error (${res.statusCode}) detected for account ${accountId}`
|
||||
@@ -2506,6 +2539,14 @@ class ClaudeRelayService {
|
||||
sessionHash,
|
||||
rateLimitResetTimestamp
|
||||
)
|
||||
await upstreamErrorHelper
|
||||
.markTempUnavailable(
|
||||
accountId,
|
||||
accountType,
|
||||
429,
|
||||
upstreamErrorHelper.parseRetryAfter(res.headers)
|
||||
)
|
||||
.catch(() => {})
|
||||
}
|
||||
} else if (res.statusCode === 200) {
|
||||
// 请求成功,清除401和500错误计数
|
||||
@@ -1,13 +1,14 @@
|
||||
const https = require('https')
|
||||
const axios = require('axios')
|
||||
const ProxyHelper = require('../utils/proxyHelper')
|
||||
const droidScheduler = require('./droidScheduler')
|
||||
const droidAccountService = require('./droidAccountService')
|
||||
const apiKeyService = require('./apiKeyService')
|
||||
const redis = require('../models/redis')
|
||||
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
||||
const logger = require('../utils/logger')
|
||||
const runtimeAddon = require('../utils/runtimeAddon')
|
||||
const ProxyHelper = require('../../utils/proxyHelper')
|
||||
const droidScheduler = require('../scheduler/droidScheduler')
|
||||
const droidAccountService = require('../account/droidAccountService')
|
||||
const apiKeyService = require('../apiKeyService')
|
||||
const redis = require('../../models/redis')
|
||||
const { updateRateLimitCounters } = require('../../utils/rateLimitHelper')
|
||||
const logger = require('../../utils/logger')
|
||||
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 RUNTIME_EVENT_FMT_PAYLOAD = 'fmtPayload'
|
||||
@@ -346,6 +347,21 @@ class DroidRelayService {
|
||||
}
|
||||
|
||||
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) {
|
||||
try {
|
||||
await this._handleUpstreamClientError(status, {
|
||||
@@ -518,6 +534,15 @@ class DroidRelayService {
|
||||
logger.info('✅ res.end() reached')
|
||||
const body = Buffer.concat(chunks).toString()
|
||||
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) {
|
||||
this._handleUpstreamClientError(res.statusCode, {
|
||||
account,
|
||||
@@ -1380,7 +1405,11 @@ class DroidRelayService {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
const axios = require('axios')
|
||||
const ProxyHelper = require('../utils/proxyHelper')
|
||||
const logger = require('../utils/logger')
|
||||
const config = require('../../config/config')
|
||||
const apiKeyService = require('./apiKeyService')
|
||||
const ProxyHelper = require('../../utils/proxyHelper')
|
||||
const logger = require('../../utils/logger')
|
||||
const config = require('../../../config/config')
|
||||
const apiKeyService = require('../apiKeyService')
|
||||
|
||||
// Gemini API 配置
|
||||
const GEMINI_API_BASE = 'https://cloudcode.googleapis.com/v1'
|
||||
@@ -1,13 +1,14 @@
|
||||
const axios = require('axios')
|
||||
const ProxyHelper = require('../utils/proxyHelper')
|
||||
const logger = require('../utils/logger')
|
||||
const { filterForOpenAI } = require('../utils/headerFilter')
|
||||
const openaiResponsesAccountService = require('./openaiResponsesAccountService')
|
||||
const apiKeyService = require('./apiKeyService')
|
||||
const unifiedOpenAIScheduler = require('./unifiedOpenAIScheduler')
|
||||
const config = require('../../config/config')
|
||||
const ProxyHelper = require('../../utils/proxyHelper')
|
||||
const logger = require('../../utils/logger')
|
||||
const { filterForOpenAI } = require('../../utils/headerFilter')
|
||||
const openaiResponsesAccountService = require('../account/openaiResponsesAccountService')
|
||||
const apiKeyService = require('../apiKeyService')
|
||||
const unifiedOpenAIScheduler = require('../scheduler/unifiedOpenAIScheduler')
|
||||
const config = require('../../../config/config')
|
||||
const crypto = require('crypto')
|
||||
const LRUCache = require('../utils/lruCache')
|
||||
const LRUCache = require('../../utils/lruCache')
|
||||
const upstreamErrorHelper = require('../../utils/upstreamErrorHelper')
|
||||
|
||||
// lastUsedAt 更新节流(每账户 60 秒内最多更新一次,使用 LRU 防止内存泄漏)
|
||||
const lastUsedAtThrottle = new LRUCache(1000) // 最多缓存 1000 个账户
|
||||
@@ -160,6 +161,19 @@ class OpenAIResponsesRelayService {
|
||||
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 || {
|
||||
error: {
|
||||
@@ -218,31 +232,23 @@ class OpenAIResponsesRelayService {
|
||||
})
|
||||
|
||||
if (response.status === 401) {
|
||||
let reason = 'OpenAI Responses账号认证失败(401错误)'
|
||||
if (errorData) {
|
||||
if (typeof errorData === 'string' && errorData.trim()) {
|
||||
reason = `OpenAI Responses账号认证失败(401错误):${errorData.trim()}`
|
||||
} else if (
|
||||
errorData.error &&
|
||||
typeof errorData.error.message === 'string' &&
|
||||
errorData.error.message.trim()
|
||||
) {
|
||||
reason = `OpenAI Responses账号认证失败(401错误):${errorData.error.message.trim()}`
|
||||
} else if (typeof errorData.message === 'string' && errorData.message.trim()) {
|
||||
reason = `OpenAI Responses账号认证失败(401错误):${errorData.message.trim()}`
|
||||
}
|
||||
}
|
||||
logger.warn(`🚫 OpenAI Responses账号认证失败(401错误)for account ${account?.id}`)
|
||||
|
||||
try {
|
||||
await unifiedOpenAIScheduler.markAccountUnauthorized(
|
||||
account.id,
|
||||
'openai-responses',
|
||||
sessionHash,
|
||||
reason
|
||||
)
|
||||
// 仅临时暂停,不永久禁用
|
||||
const oaiAutoProtectionDisabled =
|
||||
account?.disableAutoProtection === true || account?.disableAutoProtection === 'true'
|
||||
if (!oaiAutoProtectionDisabled) {
|
||||
await upstreamErrorHelper
|
||||
.markTempUnavailable(account.id, 'openai-responses', 401)
|
||||
.catch(() => {})
|
||||
}
|
||||
if (sessionHash) {
|
||||
await unifiedOpenAIScheduler._deleteSessionMapping(sessionHash).catch(() => {})
|
||||
}
|
||||
} catch (markError) {
|
||||
logger.error(
|
||||
'❌ Failed to mark OpenAI-Responses account unauthorized after 401:',
|
||||
'❌ Failed to mark OpenAI-Responses account temporarily unavailable after 401:',
|
||||
markError
|
||||
)
|
||||
}
|
||||
@@ -272,11 +278,36 @@ class OpenAIResponsesRelayService {
|
||||
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)
|
||||
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') {
|
||||
await openaiResponsesAccountService.updateAccount(account.id, {
|
||||
status: 'error',
|
||||
errorMessage: `Connection error: ${error.code}`
|
||||
})
|
||||
if (account?.id) {
|
||||
const oaiAutoProtectionDisabled =
|
||||
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) {
|
||||
let reason = 'OpenAI Responses账号认证失败(401错误)'
|
||||
if (errorData) {
|
||||
if (typeof errorData === 'string' && errorData.trim()) {
|
||||
reason = `OpenAI Responses账号认证失败(401错误):${errorData.trim()}`
|
||||
} else if (
|
||||
errorData.error &&
|
||||
typeof errorData.error.message === 'string' &&
|
||||
errorData.error.message.trim()
|
||||
) {
|
||||
reason = `OpenAI Responses账号认证失败(401错误):${errorData.error.message.trim()}`
|
||||
} else if (typeof errorData.message === 'string' && errorData.message.trim()) {
|
||||
reason = `OpenAI Responses账号认证失败(401错误):${errorData.message.trim()}`
|
||||
}
|
||||
}
|
||||
logger.warn(
|
||||
`🚫 OpenAI Responses账号认证失败(401错误)for account ${account?.id} (catch handler)`
|
||||
)
|
||||
|
||||
try {
|
||||
await unifiedOpenAIScheduler.markAccountUnauthorized(
|
||||
account.id,
|
||||
'openai-responses',
|
||||
sessionHash,
|
||||
reason
|
||||
)
|
||||
// 仅临时暂停,不永久禁用
|
||||
const oaiAutoProtectionDisabled =
|
||||
account?.disableAutoProtection === true || account?.disableAutoProtection === 'true'
|
||||
if (!oaiAutoProtectionDisabled) {
|
||||
await upstreamErrorHelper
|
||||
.markTempUnavailable(account.id, 'openai-responses', 401)
|
||||
.catch(() => {})
|
||||
}
|
||||
if (sessionHash) {
|
||||
await unifiedOpenAIScheduler._deleteSessionMapping(sessionHash).catch(() => {})
|
||||
}
|
||||
} catch (markError) {
|
||||
logger.error(
|
||||
'❌ Failed to mark OpenAI-Responses account unauthorized in catch handler:',
|
||||
'❌ Failed to mark OpenAI-Responses account temporarily unavailable in catch handler:',
|
||||
markError
|
||||
)
|
||||
}
|
||||
@@ -402,7 +432,7 @@ class OpenAIResponsesRelayService {
|
||||
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) {
|
||||
// 使用CostCalculator正确计算费用(考虑缓存token的不同价格)
|
||||
const CostCalculator = require('../utils/costCalculator')
|
||||
const CostCalculator = require('../../utils/costCalculator')
|
||||
const costInfo = CostCalculator.calculateCost(
|
||||
{
|
||||
input_tokens: actualInputTokens, // 实际输入(不含缓存)
|
||||
@@ -700,7 +730,7 @@ class OpenAIResponsesRelayService {
|
||||
// 更新账户使用额度(如果设置了额度限制)
|
||||
if (parseFloat(account.dailyQuota) > 0) {
|
||||
// 使用CostCalculator正确计算费用(考虑缓存token的不同价格)
|
||||
const CostCalculator = require('../utils/costCalculator')
|
||||
const CostCalculator = require('../../utils/costCalculator')
|
||||
const costInfo = CostCalculator.calculateCost(
|
||||
{
|
||||
input_tokens: actualInputTokens, // 实际输入(不含缓存)
|
||||
@@ -1,13 +1,14 @@
|
||||
const droidAccountService = require('./droidAccountService')
|
||||
const accountGroupService = require('./accountGroupService')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const droidAccountService = require('../account/droidAccountService')
|
||||
const accountGroupService = require('../accountGroupService')
|
||||
const redis = require('../../models/redis')
|
||||
const logger = require('../../utils/logger')
|
||||
const upstreamErrorHelper = require('../../utils/upstreamErrorHelper')
|
||||
const {
|
||||
isTruthy,
|
||||
isAccountHealthy,
|
||||
sortAccountsByPriority,
|
||||
normalizeEndpointType
|
||||
} = require('../utils/commonHelper')
|
||||
} = require('../../utils/commonHelper')
|
||||
|
||||
class DroidScheduler {
|
||||
constructor() {
|
||||
@@ -57,9 +58,21 @@ class DroidScheduler {
|
||||
})
|
||||
)
|
||||
|
||||
return accounts.filter(
|
||||
(account) => account && isAccountHealthy(account) && this._isAccountSchedulable(account)
|
||||
)
|
||||
const result = []
|
||||
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) {
|
||||
@@ -99,8 +112,15 @@ class DroidScheduler {
|
||||
} else {
|
||||
const account = await droidAccountService.getAccount(binding)
|
||||
if (account) {
|
||||
candidates = [account]
|
||||
isDedicatedBinding = true
|
||||
const isTempUnavailable = await upstreamErrorHelper.isTempUnavailable(account.id, 'droid')
|
||||
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)
|
||||
}
|
||||
|
||||
const filtered = candidates.filter(
|
||||
const syncFiltered = candidates.filter(
|
||||
(account) =>
|
||||
account &&
|
||||
isAccountHealthy(account) &&
|
||||
this._isAccountSchedulable(account) &&
|
||||
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) {
|
||||
throw new Error(
|
||||
@@ -1,12 +1,13 @@
|
||||
const claudeAccountService = require('./claudeAccountService')
|
||||
const claudeConsoleAccountService = require('./claudeConsoleAccountService')
|
||||
const bedrockAccountService = require('./bedrockAccountService')
|
||||
const ccrAccountService = require('./ccrAccountService')
|
||||
const accountGroupService = require('./accountGroupService')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const { parseVendorPrefixedModel, isOpus45OrNewer } = require('../utils/modelHelper')
|
||||
const { isSchedulable, sortAccountsByPriority } = require('../utils/commonHelper')
|
||||
const claudeAccountService = require('../account/claudeAccountService')
|
||||
const claudeConsoleAccountService = require('../account/claudeConsoleAccountService')
|
||||
const bedrockAccountService = require('../account/bedrockAccountService')
|
||||
const ccrAccountService = require('../account/ccrAccountService')
|
||||
const accountGroupService = require('../accountGroupService')
|
||||
const redis = require('../../models/redis')
|
||||
const logger = require('../../utils/logger')
|
||||
const { parseVendorPrefixedModel, isOpus45OrNewer } = require('../../utils/modelHelper')
|
||||
const { isSchedulable, sortAccountsByPriority } = require('../../utils/commonHelper')
|
||||
const upstreamErrorHelper = require('../../utils/upstreamErrorHelper')
|
||||
|
||||
/**
|
||||
* Check if account is Pro (not Max)
|
||||
@@ -1175,7 +1176,7 @@ class UnifiedClaudeScheduler {
|
||||
const client = redis.getClientSafe()
|
||||
const mappingData = JSON.stringify({ accountId, accountType })
|
||||
// 依据配置设置TTL(小时)
|
||||
const appConfig = require('../../config/config')
|
||||
const appConfig = require('../../../config/config')
|
||||
const ttlHours = appConfig.session?.stickyTtlHours || 1
|
||||
const ttlSeconds = Math.max(1, Math.floor(ttlHours * 60 * 60))
|
||||
await client.setex(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`, ttlSeconds, mappingData)
|
||||
@@ -1224,7 +1225,7 @@ class UnifiedClaudeScheduler {
|
||||
return true
|
||||
}
|
||||
|
||||
const appConfig = require('../../config/config')
|
||||
const appConfig = require('../../../config/config')
|
||||
const ttlHours = appConfig.session?.stickyTtlHours || 1
|
||||
const renewalThresholdMinutes = appConfig.session?.renewalThresholdMinutes || 0
|
||||
|
||||
@@ -1261,15 +1262,10 @@ class UnifiedClaudeScheduler {
|
||||
ttlSeconds = 300
|
||||
) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const key = `temp_unavailable:${accountType}:${accountId}`
|
||||
await client.setex(key, ttlSeconds, '1')
|
||||
await upstreamErrorHelper.markTempUnavailable(accountId, accountType, 500, ttlSeconds)
|
||||
if (sessionHash) {
|
||||
await this._deleteSessionMapping(sessionHash)
|
||||
}
|
||||
logger.warn(
|
||||
`⏱️ Account ${accountId} (${accountType}) marked temporarily unavailable for ${ttlSeconds}s`
|
||||
)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to mark account temporarily unavailable: ${accountId}`, error)
|
||||
@@ -1279,14 +1275,7 @@ class UnifiedClaudeScheduler {
|
||||
|
||||
// 🔍 检查账户是否临时不可用
|
||||
async isAccountTemporarilyUnavailable(accountId, accountType) {
|
||||
try {
|
||||
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
|
||||
}
|
||||
return upstreamErrorHelper.isTempUnavailable(accountId, accountType)
|
||||
}
|
||||
|
||||
// 🚫 标记账户为限流状态
|
||||
@@ -1,9 +1,10 @@
|
||||
const geminiAccountService = require('./geminiAccountService')
|
||||
const geminiApiAccountService = require('./geminiApiAccountService')
|
||||
const accountGroupService = require('./accountGroupService')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const { isSchedulable, isActive, sortAccountsByPriority } = require('../utils/commonHelper')
|
||||
const geminiAccountService = require('../account/geminiAccountService')
|
||||
const geminiApiAccountService = require('../account/geminiApiAccountService')
|
||||
const accountGroupService = require('../accountGroupService')
|
||||
const redis = require('../../models/redis')
|
||||
const logger = require('../../utils/logger')
|
||||
const { isSchedulable, isActive, sortAccountsByPriority } = require('../../utils/commonHelper')
|
||||
const upstreamErrorHelper = require('../../utils/upstreamErrorHelper')
|
||||
|
||||
const OAUTH_PROVIDER_GEMINI_CLI = 'gemini-cli'
|
||||
const OAUTH_PROVIDER_ANTIGRAVITY = 'antigravity'
|
||||
@@ -241,8 +242,17 @@ class UnifiedGeminiScheduler {
|
||||
const accountId = apiKeyData.geminiAccountId.replace('api:', '')
|
||||
const boundAccount = await geminiApiAccountService.getAccount(accountId)
|
||||
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)
|
||||
if (!isRateLimited) {
|
||||
if (!isRateLimited && !isTempUnavailable) {
|
||||
// 检查模型支持
|
||||
if (
|
||||
requestedModel &&
|
||||
@@ -298,8 +308,17 @@ class UnifiedGeminiScheduler {
|
||||
) {
|
||||
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)
|
||||
if (!isRateLimited) {
|
||||
if (!isRateLimited && !isTempUnavailable) {
|
||||
// 检查模型支持
|
||||
if (
|
||||
requestedModel &&
|
||||
@@ -364,6 +383,13 @@ class UnifiedGeminiScheduler {
|
||||
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) {
|
||||
// 处理可能带有 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)
|
||||
if (!isRateLimited) {
|
||||
@@ -451,6 +487,14 @@ class UnifiedGeminiScheduler {
|
||||
logger.info(`🚫 Gemini account ${accountId} is not schedulable`)
|
||||
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))
|
||||
} else if (accountType === 'gemini-api') {
|
||||
const account = await geminiApiAccountService.getAccount(accountId)
|
||||
@@ -462,6 +506,14 @@ class UnifiedGeminiScheduler {
|
||||
logger.info(`🚫 Gemini-API account ${accountId} is not schedulable`)
|
||||
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 false
|
||||
@@ -494,7 +546,7 @@ class UnifiedGeminiScheduler {
|
||||
const client = redis.getClientSafe()
|
||||
const mappingData = JSON.stringify({ accountId, accountType })
|
||||
// 依据配置设置TTL(小时)
|
||||
const appConfig = require('../../config/config')
|
||||
const appConfig = require('../../../config/config')
|
||||
const ttlHours = appConfig.session?.stickyTtlHours || 1
|
||||
const ttlSeconds = Math.max(1, Math.floor(ttlHours * 60 * 60))
|
||||
const key = this._getSessionMappingKey(sessionHash, oauthProvider)
|
||||
@@ -535,7 +587,7 @@ class UnifiedGeminiScheduler {
|
||||
return true
|
||||
}
|
||||
|
||||
const appConfig = require('../../config/config')
|
||||
const appConfig = require('../../../config/config')
|
||||
const ttlHours = appConfig.session?.stickyTtlHours || 1
|
||||
const renewalThresholdMinutes = appConfig.session?.renewalThresholdMinutes || 0
|
||||
if (!renewalThresholdMinutes) {
|
||||
@@ -749,6 +801,14 @@ class UnifiedGeminiScheduler {
|
||||
// 检查是否被限流
|
||||
const isRateLimited = await this.isAccountRateLimited(account.id, accountType)
|
||||
if (!isRateLimited) {
|
||||
const isTempUnavailable = await upstreamErrorHelper.isTempUnavailable(
|
||||
account.id,
|
||||
accountType
|
||||
)
|
||||
if (isTempUnavailable) {
|
||||
logger.debug(`⏭️ Skipping group member ${account.name} - temporarily unavailable`)
|
||||
continue
|
||||
}
|
||||
availableAccounts.push({
|
||||
...account,
|
||||
accountId: account.id,
|
||||
@@ -1,9 +1,10 @@
|
||||
const openaiAccountService = require('./openaiAccountService')
|
||||
const openaiResponsesAccountService = require('./openaiResponsesAccountService')
|
||||
const accountGroupService = require('./accountGroupService')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const { isSchedulable, sortAccountsByPriority } = require('../utils/commonHelper')
|
||||
const openaiAccountService = require('../account/openaiAccountService')
|
||||
const openaiResponsesAccountService = require('../account/openaiResponsesAccountService')
|
||||
const accountGroupService = require('../accountGroupService')
|
||||
const redis = require('../../models/redis')
|
||||
const logger = require('../../utils/logger')
|
||||
const { isSchedulable, sortAccountsByPriority } = require('../../utils/commonHelper')
|
||||
const upstreamErrorHelper = require('../../utils/upstreamErrorHelper')
|
||||
|
||||
class UnifiedOpenAIScheduler {
|
||||
constructor() {
|
||||
@@ -153,91 +154,102 @@ class UnifiedOpenAIScheduler {
|
||||
boundAccount.status !== 'unauthorized'
|
||||
|
||||
if (isActiveBoundAccount) {
|
||||
if (accountType === 'openai') {
|
||||
const readiness = await this._ensureAccountReadyForScheduling(
|
||||
boundAccount,
|
||||
boundAccount.id,
|
||||
{ sanitized: false }
|
||||
)
|
||||
|
||||
if (!readiness.canUse) {
|
||||
const isRateLimited = readiness.reason === 'rate_limited'
|
||||
const errorMsg = isRateLimited
|
||||
? `Dedicated account ${boundAccount.name} is currently rate limited`
|
||||
: `Dedicated account ${boundAccount.name} is not schedulable`
|
||||
logger.warn(`⚠️ ${errorMsg}`)
|
||||
const error = new Error(errorMsg)
|
||||
error.statusCode = isRateLimited ? 429 : 403
|
||||
throw error
|
||||
}
|
||||
} else {
|
||||
const hasRateLimitFlag = this._isRateLimited(boundAccount.rateLimitStatus)
|
||||
if (hasRateLimitFlag) {
|
||||
const isRateLimitCleared = await openaiResponsesAccountService.checkAndClearRateLimit(
|
||||
boundAccount.id
|
||||
)
|
||||
if (!isRateLimitCleared) {
|
||||
const errorMsg = `Dedicated account ${boundAccount.name} is currently rate limited`
|
||||
logger.warn(`⚠️ ${errorMsg}`)
|
||||
const error = new Error(errorMsg)
|
||||
error.statusCode = 429 // Too Many Requests - 限流
|
||||
throw error
|
||||
}
|
||||
// 限流已解除,刷新账户最新状态,确保后续调度信息准确
|
||||
boundAccount = await openaiResponsesAccountService.getAccount(boundAccount.id)
|
||||
if (!boundAccount) {
|
||||
const errorMsg = `Dedicated account ${apiKeyData.openaiAccountId} not found after rate limit reset`
|
||||
logger.warn(`⚠️ ${errorMsg}`)
|
||||
const error = new Error(errorMsg)
|
||||
error.statusCode = 404
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
if (!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,
|
||||
// 检查是否临时不可用
|
||||
const isTempUnavailable = await upstreamErrorHelper.isTempUnavailable(
|
||||
boundAccount.id,
|
||||
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 {
|
||||
// 专属账户不可用时直接报错,不降级到共享池
|
||||
@@ -370,6 +382,12 @@ class UnifiedOpenAIScheduler {
|
||||
continue
|
||||
}
|
||||
|
||||
const isTempUnavailable = await upstreamErrorHelper.isTempUnavailable(accountId, 'openai')
|
||||
if (isTempUnavailable) {
|
||||
logger.debug(`⏭️ Skipping openai account ${account.name} - temporarily unavailable`)
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查token是否过期并自动刷新
|
||||
const isExpired = openaiAccountService.isTokenExpired(account)
|
||||
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)) {
|
||||
logger.debug(
|
||||
@@ -517,6 +546,15 @@ class UnifiedOpenAIScheduler {
|
||||
return false
|
||||
}
|
||||
|
||||
const isTempUnavailable = await upstreamErrorHelper.isTempUnavailable(
|
||||
accountId,
|
||||
accountType
|
||||
)
|
||||
if (isTempUnavailable) {
|
||||
logger.info(`⏱️ OpenAI account ${accountId} (${accountType}) is temporarily unavailable`)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
} else if (accountType === 'openai-responses') {
|
||||
const account = await openaiResponsesAccountService.getAccount(accountId)
|
||||
@@ -541,7 +579,20 @@ class UnifiedOpenAIScheduler {
|
||||
// 检查并清除过期的限流状态
|
||||
const isRateLimitCleared =
|
||||
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
|
||||
} catch (error) {
|
||||
@@ -572,7 +623,7 @@ class UnifiedOpenAIScheduler {
|
||||
const client = redis.getClientSafe()
|
||||
const mappingData = JSON.stringify({ accountId, accountType })
|
||||
// 依据配置设置TTL(小时)
|
||||
const appConfig = require('../../config/config')
|
||||
const appConfig = require('../../../config/config')
|
||||
const ttlHours = appConfig.session?.stickyTtlHours || 1
|
||||
const ttlSeconds = Math.max(1, Math.floor(ttlHours * 60 * 60))
|
||||
await client.setex(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`, ttlSeconds, mappingData)
|
||||
@@ -598,7 +649,7 @@ class UnifiedOpenAIScheduler {
|
||||
return true
|
||||
}
|
||||
|
||||
const appConfig = require('../../config/config')
|
||||
const appConfig = require('../../../config/config')
|
||||
const ttlHours = appConfig.session?.stickyTtlHours || 1
|
||||
const renewalThresholdMinutes = appConfig.session?.renewalThresholdMinutes || 0
|
||||
if (!renewalThresholdMinutes) {
|
||||
@@ -849,6 +900,17 @@ class UnifiedOpenAIScheduler {
|
||||
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 账户检查)
|
||||
if (accountType === 'openai') {
|
||||
const isExpired = openaiAccountService.isTokenExpired(account)
|
||||
@@ -7,13 +7,11 @@ const fs = require('fs')
|
||||
const os = require('os')
|
||||
|
||||
// 安全的 JSON 序列化函数,处理循环引用和特殊字符
|
||||
const safeStringify = (obj, maxDepth = 3, fullDepth = false) => {
|
||||
const safeStringify = (obj, maxDepth = Infinity) => {
|
||||
const seen = new WeakSet()
|
||||
// 如果是fullDepth模式,增加深度限制
|
||||
const actualMaxDepth = fullDepth ? 10 : maxDepth
|
||||
|
||||
const replacer = (key, value, depth = 0) => {
|
||||
if (depth > actualMaxDepth) {
|
||||
if (depth > maxDepth) {
|
||||
return '[Max Depth Reached]'
|
||||
}
|
||||
|
||||
@@ -21,18 +19,13 @@ const safeStringify = (obj, maxDepth = 3, fullDepth = false) => {
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
// 移除或转义可能导致JSON解析错误的字符
|
||||
let cleanValue = value
|
||||
const cleanValue = value
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '') // 移除控制字符
|
||||
.replace(/[\uD800-\uDFFF]/g, '') // 移除孤立的代理对字符
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/\u0000/g, '') // 移除NUL字节
|
||||
|
||||
// 如果字符串过长,截断并添加省略号
|
||||
if (cleanValue.length > 1000) {
|
||||
cleanValue = `${cleanValue.substring(0, 997)}...`
|
||||
}
|
||||
|
||||
return cleanValue
|
||||
} catch (error) {
|
||||
return '[Invalid String Data]'
|
||||
@@ -77,7 +70,37 @@ const safeStringify = (obj, maxDepth = 3, fullDepth = false) => {
|
||||
|
||||
try {
|
||||
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) {
|
||||
// 如果JSON.stringify仍然失败,使用更保守的方法
|
||||
try {
|
||||
@@ -93,50 +116,64 @@ const safeStringify = (obj, maxDepth = 3, fullDepth = false) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 📝 增强的日志格式
|
||||
const createLogFormat = (colorize = false) => {
|
||||
const formats = [
|
||||
// 控制台不显示的 metadata 字段(已在 message 中或低价值)
|
||||
const CONSOLE_SKIP_KEYS = new Set(['type', 'level', 'message', 'timestamp', 'stack'])
|
||||
|
||||
// 控制台格式: 树形展示 metadata
|
||||
const createConsoleFormat = () =>
|
||||
winston.format.combine(
|
||||
winston.format.timestamp({ format: () => formatDateWithTimezone(new Date(), false) }),
|
||||
winston.format.errors({ stack: true })
|
||||
// 移除 winston.format.metadata() 来避免自动包装
|
||||
]
|
||||
|
||||
if (colorize) {
|
||||
formats.push(winston.format.colorize())
|
||||
}
|
||||
|
||||
formats.push(
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.colorize(),
|
||||
winston.format.printf(({ level, message, timestamp, stack, ...rest }) => {
|
||||
const emoji = {
|
||||
error: '❌',
|
||||
warn: '⚠️ ',
|
||||
info: 'ℹ️ ',
|
||||
debug: '🐛',
|
||||
verbose: '📝'
|
||||
// 时间戳只取时分秒
|
||||
const shortTime = timestamp ? timestamp.split(' ').pop() : ''
|
||||
|
||||
let logMessage = `${shortTime} ${message}`
|
||||
|
||||
// 收集要显示的 metadata
|
||||
const entries = Object.entries(rest).filter(([k]) => !CONSOLE_SKIP_KEYS.has(k))
|
||||
|
||||
if (entries.length > 0) {
|
||||
const indent = ' '.repeat(shortTime.length + 1)
|
||||
entries.forEach(([key, value], i) => {
|
||||
const isLast = i === entries.length - 1
|
||||
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}`
|
||||
|
||||
// 直接处理额外数据,不需要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)}`
|
||||
if (stack) {
|
||||
logMessage += `\n${stack}`
|
||||
}
|
||||
|
||||
return stack ? `${logMessage}\n${stack}` : logMessage
|
||||
return 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 consoleFormat = createLogFormat(true)
|
||||
const fileFormat = createFileFormat()
|
||||
const consoleFormat = createConsoleFormat()
|
||||
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,
|
||||
maxFiles: config.logging.maxFiles,
|
||||
auditFile: path.join(config.logging.dirname, `.${filename.replace('%DATE%', 'audit')}.json`),
|
||||
format: logFormat
|
||||
format: fileFormat
|
||||
})
|
||||
|
||||
if (level) {
|
||||
@@ -184,7 +221,7 @@ const errorFileTransport = createRotateTransport('claude-relay-error-%DATE%.log'
|
||||
// 🔒 创建专门的安全日志记录器
|
||||
const securityLogger = winston.createLogger({
|
||||
level: 'warn',
|
||||
format: logFormat,
|
||||
format: fileFormat,
|
||||
transports: [createRotateTransport('claude-relay-security-%DATE%.log', 'warn')],
|
||||
silent: false
|
||||
})
|
||||
@@ -207,7 +244,7 @@ const authDetailLogger = winston.createLogger({
|
||||
// 🌟 增强的 Winston logger
|
||||
const logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || config.logging.level,
|
||||
format: logFormat,
|
||||
format: fileFormat,
|
||||
transports: [
|
||||
// 📄 文件输出
|
||||
dailyRotateFileTransport,
|
||||
@@ -225,7 +262,7 @@ const logger = winston.createLogger({
|
||||
exceptionHandlers: [
|
||||
new winston.transports.File({
|
||||
filename: path.join(config.logging.dirname, 'exceptions.log'),
|
||||
format: logFormat,
|
||||
format: fileFormat,
|
||||
maxsize: 10485760, // 10MB
|
||||
maxFiles: 5
|
||||
}),
|
||||
@@ -238,7 +275,7 @@ const logger = winston.createLogger({
|
||||
rejectionHandlers: [
|
||||
new winston.transports.File({
|
||||
filename: path.join(config.logging.dirname, 'rejections.log'),
|
||||
format: logFormat,
|
||||
format: fileFormat,
|
||||
maxsize: 10485760, // 10MB
|
||||
maxFiles: 5
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
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 }),
|
||||
proxyAgent = null,
|
||||
timeout = 30000,
|
||||
extraHeaders = {}
|
||||
extraHeaders = {},
|
||||
sanitize = false
|
||||
} = options
|
||||
|
||||
const sendSSE = (type, data = {}) => {
|
||||
@@ -166,17 +174,17 @@ async function sendStreamTestRequest(options) {
|
||||
let errorMsg = `API Error: ${response.status}`
|
||||
try {
|
||||
const json = JSON.parse(errorData)
|
||||
errorMsg = json.message || json.error?.message || json.error || errorMsg
|
||||
errorMsg = extractErrorMessage(json, errorMsg)
|
||||
} catch {
|
||||
if (errorData.length < 200) {
|
||||
errorMsg = errorData || errorMsg
|
||||
}
|
||||
}
|
||||
endTest(false, errorMsg)
|
||||
endTest(false, sanitize ? sanitizeErrorMsg(errorMsg) : errorMsg)
|
||||
resolve()
|
||||
})
|
||||
response.data.on('error', (err) => {
|
||||
endTest(false, err.message)
|
||||
endTest(false, sanitize ? sanitizeErrorMsg(err.message) : err.message)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
@@ -270,7 +278,7 @@ function createGeminiTestPayload(_model = 'gemini-2.5-pro', options = {}) {
|
||||
* @returns {object} 测试请求体
|
||||
*/
|
||||
function createOpenAITestPayload(model = 'gpt-5', options = {}) {
|
||||
const { prompt = 'hi', maxTokens = 100 } = options
|
||||
const { prompt = 'hi', maxTokens = 100, stream = true } = options
|
||||
return {
|
||||
model,
|
||||
input: [
|
||||
@@ -280,15 +288,77 @@ function createOpenAITestPayload(model = 'gpt-5', options = {}) {
|
||||
}
|
||||
],
|
||||
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 = {
|
||||
randomHex,
|
||||
generateSessionString,
|
||||
createClaudeTestPayload,
|
||||
createGeminiTestPayload,
|
||||
createOpenAITestPayload,
|
||||
createChatCompletionsTestPayload,
|
||||
extractErrorMessage,
|
||||
sanitizeErrorMsg,
|
||||
sendStreamTestRequest
|
||||
}
|
||||
|
||||
255
src/utils/upstreamErrorHelper.js
Normal file
255
src/utils/upstreamErrorHelper.js
Normal 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
|
||||
}
|
||||
@@ -6,7 +6,7 @@ jest.mock('../src/utils/logger', () => ({
|
||||
error: jest.fn()
|
||||
}))
|
||||
|
||||
const accountBalanceServiceModule = require('../src/services/accountBalanceService')
|
||||
const accountBalanceServiceModule = require('../src/services/account/accountBalanceService')
|
||||
|
||||
const { AccountBalanceService } = accountBalanceServiceModule
|
||||
|
||||
|
||||
@@ -1614,7 +1614,7 @@
|
||||
</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
|
||||
>
|
||||
@@ -1714,24 +1714,26 @@
|
||||
{{ errors.baseUrl }}
|
||||
</p>
|
||||
<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"
|
||||
>/{model}:generateContent</code
|
||||
>https://proxy.com/v1beta/models</code
|
||||
>
|
||||
</p>
|
||||
<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"
|
||||
>https://generativelanguage.googleapis.com/v1beta/models</code
|
||||
>https://proxy.com/api/{model}:{action}</code
|
||||
>
|
||||
</p>
|
||||
<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"
|
||||
>https://your-crs.com/gemini/v1beta/models</code
|
||||
>https://generativelanguage.googleapis.com</code
|
||||
>
|
||||
(自动拼接 /v1beta/models)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -3371,26 +3373,24 @@
|
||||
<p class="mt-1 text-xs text-gray-500">账号被限流后暂停调度的时间(分钟)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 上游错误处理(编辑模式)-->
|
||||
<div v-if="form.platform === 'claude-console'">
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
上游错误处理
|
||||
</label>
|
||||
<label class="inline-flex cursor-pointer items-center">
|
||||
<input
|
||||
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"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||
上游错误不自动暂停调度
|
||||
</span>
|
||||
</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
勾选后遇到 401/400/429/529 等上游错误仅记录日志并透传,不自动禁用或限流
|
||||
</p>
|
||||
</div>
|
||||
<!-- 上游错误处理(编辑模式)-->
|
||||
<div v-if="autoProtectionPlatforms.includes(form.platform)">
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
上游错误处理
|
||||
</label>
|
||||
<label class="inline-flex cursor-pointer items-center">
|
||||
<input
|
||||
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"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300"> 上游错误不自动暂停调度 </span>
|
||||
</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
勾选后遇到 401/400/429/529 等上游错误仅记录日志并透传,不自动禁用或限流
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI-Responses 特定字段(编辑模式)-->
|
||||
@@ -3505,24 +3505,26 @@
|
||||
{{ errors.baseUrl }}
|
||||
</p>
|
||||
<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"
|
||||
>/{model}:generateContent</code
|
||||
>https://proxy.com/v1beta/models</code
|
||||
>
|
||||
</p>
|
||||
<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"
|
||||
>https://generativelanguage.googleapis.com/v1beta/models</code
|
||||
>https://proxy.com/api/{model}:{action}</code
|
||||
>
|
||||
</p>
|
||||
<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"
|
||||
>https://your-crs.com/gemini/v1beta/models</code
|
||||
>https://generativelanguage.googleapis.com</code
|
||||
>
|
||||
(自动拼接 /v1beta/models)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -4039,6 +4041,20 @@ const handleCancel = () => {
|
||||
const isEdit = computed(() => !!props.account)
|
||||
const show = ref(true)
|
||||
|
||||
// 支持 disableAutoProtection 的平台白名单
|
||||
const autoProtectionPlatforms = [
|
||||
'claude-console',
|
||||
'ccr',
|
||||
'droid',
|
||||
'bedrock',
|
||||
'azure-openai',
|
||||
'azure_openai',
|
||||
'gemini',
|
||||
'gemini-api',
|
||||
'openai',
|
||||
'openai-responses'
|
||||
]
|
||||
|
||||
// OAuthFlow 组件引用
|
||||
const oauthFlowRef = ref(null)
|
||||
|
||||
@@ -4274,7 +4290,9 @@ const form = ref({
|
||||
})(),
|
||||
userAgent: props.account?.userAgent || '',
|
||||
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,
|
||||
dailyUsage: props.account?.dailyUsage || 0,
|
||||
@@ -5242,13 +5260,9 @@ const createAccount = async () => {
|
||||
errors.value.apiKey = '请填写 API Key'
|
||||
hasError = true
|
||||
}
|
||||
// 验证 baseUrl 必须以 /models 结尾
|
||||
if (!form.value.baseUrl || form.value.baseUrl.trim() === '') {
|
||||
errors.value.baseUrl = '请填写 API 基础地址'
|
||||
hasError = true
|
||||
} else if (!form.value.baseUrl.trim().endsWith('/models')) {
|
||||
errors.value.baseUrl = 'API 基础地址必须以 /models 结尾'
|
||||
hasError = true
|
||||
}
|
||||
} else {
|
||||
// 其他平台(如 Droid)使用多 API Key 输入
|
||||
@@ -5407,9 +5421,7 @@ const createAccount = async () => {
|
||||
data.userAgent = form.value.userAgent || null
|
||||
// 如果不启用限流,传递 0 表示不限流
|
||||
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
|
||||
// 上游错误处理(仅 Claude Console)
|
||||
if (form.value.platform === 'claude-console') {
|
||||
data.disableAutoProtection = !!form.value.disableAutoProtection
|
||||
data.interceptWarmup = !!form.value.interceptWarmup
|
||||
}
|
||||
// 额度管理字段
|
||||
@@ -5474,6 +5486,11 @@ const createAccount = async () => {
|
||||
data.schedulable = form.value.schedulable !== false
|
||||
}
|
||||
|
||||
// 支持 disableAutoProtection 的平台才写入
|
||||
if (autoProtectionPlatforms.includes(form.value.platform)) {
|
||||
data.disableAutoProtection = !!form.value.disableAutoProtection
|
||||
}
|
||||
|
||||
let result
|
||||
if (form.value.platform === 'claude') {
|
||||
result = await accountsStore.createClaudeAccount(data)
|
||||
@@ -5540,17 +5557,13 @@ const updateAccount = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// Gemini API 的 baseUrl 验证(必须以 /models 结尾)
|
||||
// Gemini API 的 baseUrl 验证
|
||||
if (form.value.platform === 'gemini-api') {
|
||||
const baseUrl = form.value.baseUrl?.trim() || ''
|
||||
if (!baseUrl) {
|
||||
errors.value.baseUrl = '请填写 API 基础地址'
|
||||
return
|
||||
}
|
||||
if (!baseUrl.endsWith('/models')) {
|
||||
errors.value.baseUrl = 'API 基础地址必须以 /models 结尾'
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 分组类型验证 - 更新账户流程修复
|
||||
@@ -5755,8 +5768,6 @@ const updateAccount = async () => {
|
||||
data.userAgent = form.value.userAgent || null
|
||||
// 如果不启用限流,传递 0 表示不限流
|
||||
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
|
||||
// 上游错误处理
|
||||
data.disableAutoProtection = !!form.value.disableAutoProtection
|
||||
// 拦截预热请求
|
||||
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') {
|
||||
await accountsStore.updateClaudeAccount(props.account.id, data)
|
||||
} else if (props.account.platform === 'claude-console') {
|
||||
@@ -6399,7 +6415,8 @@ watch(
|
||||
// 并发控制字段
|
||||
maxConcurrentTasks: newAccount.maxConcurrentTasks || 0,
|
||||
// 上游错误处理
|
||||
disableAutoProtection: newAccount.disableAutoProtection === true
|
||||
disableAutoProtection:
|
||||
newAccount.disableAutoProtection === true || newAccount.disableAutoProtection === 'true'
|
||||
}
|
||||
|
||||
// 如果是Claude Console账户,加载实时使用情况
|
||||
|
||||
@@ -111,30 +111,12 @@
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
测试模型
|
||||
</label>
|
||||
<input
|
||||
<ModelSelector
|
||||
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"
|
||||
placeholder="claude-sonnet-4-5-20250929"
|
||||
type="text"
|
||||
:models="modelOptions"
|
||||
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>
|
||||
|
||||
<!-- 测试历史 -->
|
||||
@@ -219,9 +201,11 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { APP_CONFIG } from '@/utils/tools'
|
||||
import { showToast } from '@/utils/tools'
|
||||
import { getModelsApi } from '@/utils/http_apis'
|
||||
import ModelSelector from '@/components/common/ModelSelector.vue'
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
@@ -256,12 +240,18 @@ const cronPresets = [
|
||||
{ label: '工作日 9:00', value: '0 9 * * 1-5' }
|
||||
]
|
||||
|
||||
// 模型选项
|
||||
const modelOptions = [
|
||||
{ label: 'Claude Sonnet 4.5', value: 'claude-sonnet-4-5-20250929' },
|
||||
{ label: 'Claude Haiku 4.5', value: 'claude-haiku-4-5-20251001' },
|
||||
{ label: 'Claude Opus 4.5', value: 'claude-opus-4-5-20251101' }
|
||||
]
|
||||
// 模型选项(从 API 动态获取)
|
||||
const modelOptions = ref([])
|
||||
|
||||
const loadModels = async () => {
|
||||
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) {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
69
web/admin-spa/src/components/common/ModelSelector.vue
Normal file
69
web/admin-spa/src/components/common/ModelSelector.vue
Normal 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>
|
||||
589
web/admin-spa/src/components/common/UnifiedTestModal.vue
Normal file
589
web/admin-spa/src/components/common/UnifiedTestModal.vue
Normal file
@@ -0,0 +1,589 @@
|
||||
<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',
|
||||
'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'
|
||||
},
|
||||
'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`,
|
||||
'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'].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>
|
||||
345
web/admin-spa/src/components/settings/ModelPricingSection.vue
Normal file
345
web/admin-spa/src/components/settings/ModelPricingSection.vue
Normal file
@@ -0,0 +1,345 @@
|
||||
<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
|
||||
if (statusResult.success) pricingStatus.value = statusResult.data
|
||||
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>
|
||||
@@ -3,6 +3,13 @@ import request from '@/utils/request'
|
||||
// 模型
|
||||
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
|
||||
export const getKeyIdApi = (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 getTempUnavailableApi = () =>
|
||||
request({ url: '/admin/temp-unavailable', method: 'GET' })
|
||||
export const getUsageCostsApi = (period) =>
|
||||
request({ url: `/admin/usage-costs?period=${period}`, method: 'GET' })
|
||||
export const getUsageStatsApi = (url) => request({ url, method: 'GET' })
|
||||
|
||||
208
web/admin-spa/src/utils/useTestState.js
Normal file
208
web/admin-spa/src/utils/useTestState.js
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -750,6 +750,23 @@
|
||||
>({{ formatRateLimitTime(account.rateLimitStatus.minutesRemaining) }})</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
|
||||
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"
|
||||
@@ -1991,8 +2008,9 @@
|
||||
/>
|
||||
|
||||
<!-- 账户测试弹窗 -->
|
||||
<AccountTestModal
|
||||
<UnifiedTestModal
|
||||
:account="testingAccount"
|
||||
mode="account"
|
||||
:show="showAccountTestModal"
|
||||
@close="closeAccountTestModal"
|
||||
/>
|
||||
@@ -2170,7 +2188,7 @@ import AccountForm from '@/components/accounts/AccountForm.vue'
|
||||
import CcrAccountForm from '@/components/accounts/CcrAccountForm.vue'
|
||||
import AccountUsageDetailModal from '@/components/accounts/AccountUsageDetailModal.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 ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||
import CustomDropdown from '@/components/common/CustomDropdown.vue'
|
||||
@@ -2489,7 +2507,10 @@ const showResetButton = (account) => {
|
||||
'openai-responses',
|
||||
'gemini',
|
||||
'gemini-api',
|
||||
'ccr'
|
||||
'ccr',
|
||||
'droid',
|
||||
'bedrock',
|
||||
'azure-openai'
|
||||
]
|
||||
return (
|
||||
supportedPlatforms.includes(account.platform) &&
|
||||
@@ -2497,6 +2518,7 @@ const showResetButton = (account) => {
|
||||
account.status !== 'active' ||
|
||||
account.rateLimitStatus?.isRateLimited ||
|
||||
account.rateLimitStatus === 'limited' ||
|
||||
account.tempUnavailable ||
|
||||
!account.isActive)
|
||||
)
|
||||
}
|
||||
@@ -3305,6 +3327,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
|
||||
cleanupSelectedAccounts()
|
||||
|
||||
@@ -3588,6 +3643,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) => {
|
||||
if (!account) return false
|
||||
@@ -3868,6 +3933,10 @@ const resetAccountStatus = async (account) => {
|
||||
endpoint = `/admin/gemini-api-accounts/${account.id}/reset-status`
|
||||
} else if (account.platform === 'gemini') {
|
||||
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 {
|
||||
showToast('不支持的账户类型', 'error')
|
||||
account.isResetting = false
|
||||
|
||||
@@ -468,9 +468,10 @@
|
||||
</div>
|
||||
|
||||
<!-- API Key 测试弹窗 -->
|
||||
<ApiKeyTestModal
|
||||
<UnifiedTestModal
|
||||
:api-key-name="statsData?.name || ''"
|
||||
:api-key-value="apiKey"
|
||||
mode="apikey"
|
||||
:service-type="testServiceType"
|
||||
:show="showTestModal"
|
||||
@close="closeTestModal"
|
||||
@@ -542,7 +543,7 @@ import AggregatedStatsCard from '@/components/apistats/AggregatedStatsCard.vue'
|
||||
import ModelUsageStats from '@/components/apistats/ModelUsageStats.vue'
|
||||
import ServiceCostCards from '@/components/apistats/ServiceCostCards.vue'
|
||||
import TutorialView from './TutorialView.vue'
|
||||
import ApiKeyTestModal from '@/components/apikeys/ApiKeyTestModal.vue'
|
||||
import UnifiedTestModal from '@/components/common/UnifiedTestModal.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const apiStatsStore = useApiStatsStore()
|
||||
|
||||
@@ -60,6 +60,18 @@
|
||||
<i class="fas fa-balance-scale mr-2"></i>
|
||||
服务倍率
|
||||
</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>
|
||||
</div>
|
||||
|
||||
@@ -1785,6 +1797,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模型价格部分 -->
|
||||
<div v-show="activeSection === 'modelPricing'">
|
||||
<ModelPricingSection />
|
||||
</div>
|
||||
|
||||
<!-- ConfirmModal -->
|
||||
<ConfirmModal
|
||||
:cancel-text="confirmModalConfig.cancelText"
|
||||
@@ -1807,6 +1824,7 @@ import { useSettingsStore } from '@/stores/settings'
|
||||
|
||||
import * as httpApis from '@/utils/http_apis'
|
||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||
import ModelPricingSection from '@/components/settings/ModelPricingSection.vue'
|
||||
|
||||
// 定义组件名称,用于keep-alive排除
|
||||
defineOptions({
|
||||
|
||||
Reference in New Issue
Block a user