hequ commited on
Commit
75031b4
·
verified ·
1 Parent(s): 9abffe0

Upload 224 files

Browse files
.github/workflows/codex-pr-review.yml ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Codex PR Review
2
+
3
+ on:
4
+ pull_request:
5
+ types: [opened, reopened, synchronize]
6
+
7
+ jobs:
8
+ codex:
9
+ runs-on: ubuntu-latest
10
+ permissions:
11
+ contents: read
12
+ outputs:
13
+ final_message: ${{ steps.run_codex.outputs['final-message'] }}
14
+ environment: CODEX
15
+ name: Codex PR Review
16
+ steps:
17
+ - name: Checkout
18
+ uses: actions/checkout@v5
19
+ with:
20
+ ref: refs/pull/${{ github.event.pull_request.number }}/merge
21
+
22
+ - name: Pre-fetch base and head refs for the PR
23
+ run: |
24
+ git fetch --no-tags origin \
25
+ ${{ github.event.pull_request.base.ref }} \
26
+ +refs/pull/${{ github.event.pull_request.number }}/head
27
+
28
+ - name: Run Codex
29
+ id: run_codex
30
+ uses: hewenyu/codex-action@crs
31
+ with:
32
+ crs-api-key: ${{ secrets.CRS_API_KEY }}
33
+ crs-base-url: ${{ secrets.CRS_API_BASE_URL }}
34
+ crs-model: "gpt-5-codex"
35
+ crs-reasoning-effort: "high"
36
+ prompt: |
37
+ This is PR #${{ github.event.pull_request.number }} for ${{ github.repository }}.
38
+ Base SHA: ${{ github.event.pull_request.base.sha }}
39
+ Head SHA: ${{ github.event.pull_request.head.sha }}
40
+
41
+ Review ONLY the changes introduced by the PR.
42
+ Suggest any improvements, potential bugs, or issues.
43
+ Be concise and specific in your feedback.
44
+
45
+ Pull request title and body:
46
+ ----
47
+ ${{ github.event.pull_request.title }}
48
+ ${{ github.event.pull_request.body }}
49
+
50
+ post_feedback:
51
+ runs-on: ubuntu-latest
52
+ needs: codex
53
+ if: needs.codex.outputs.final_message != ''
54
+ permissions:
55
+ issues: write
56
+ pull-requests: write
57
+ steps:
58
+ - name: Report Codex feedback
59
+ uses: actions/github-script@v7
60
+ env:
61
+ CODEX_FINAL_MESSAGE: ${{ needs.codex.outputs.final_message }}
62
+ with:
63
+ github-token: ${{ github.token }}
64
+ script: |
65
+ await github.rest.issues.createComment({
66
+ owner: context.repo.owner,
67
+ repo: context.repo.repo,
68
+ issue_number: context.payload.pull_request.number,
69
+ body: process.env.CODEX_FINAL_MESSAGE,
70
+ });
.github/workflows/sync-model-pricing.yml ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: 同步模型价格数据
2
+
3
+ on:
4
+ schedule:
5
+ - cron: '*/10 * * * *'
6
+ workflow_dispatch: {}
7
+
8
+ jobs:
9
+ sync-pricing:
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ contents: write
13
+ steps:
14
+ - name: 检出 price-mirror 分支
15
+ uses: actions/checkout@v4
16
+ with:
17
+ ref: price-mirror
18
+ fetch-depth: 0
19
+
20
+ - name: 下载上游价格文件
21
+ id: fetch
22
+ run: |
23
+ set -euo pipefail
24
+ curl -fsSL https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json \
25
+ -o model_prices_and_context_window.json.new
26
+
27
+ NEW_HASH=$(sha256sum model_prices_and_context_window.json.new | awk '{print $1}')
28
+
29
+ if [ -f model_prices_and_context_window.sha256 ]; then
30
+ OLD_HASH=$(cat model_prices_and_context_window.sha256 | tr -d ' \n\r')
31
+ else
32
+ OLD_HASH=""
33
+ fi
34
+
35
+ if [ "$NEW_HASH" = "$OLD_HASH" ]; then
36
+ echo "价格文件无变化,跳过提交"
37
+ echo "changed=false" >> "$GITHUB_OUTPUT"
38
+ rm -f model_prices_and_context_window.json.new
39
+ exit 0
40
+ fi
41
+
42
+ mv model_prices_and_context_window.json.new model_prices_and_context_window.json
43
+ echo "$NEW_HASH" > model_prices_and_context_window.sha256
44
+
45
+ echo "changed=true" >> "$GITHUB_OUTPUT"
46
+ echo "hash=$NEW_HASH" >> "$GITHUB_OUTPUT"
47
+
48
+ - name: 提交并推送变更
49
+ if: steps.fetch.outputs.changed == 'true'
50
+ env:
51
+ NEW_HASH: ${{ steps.fetch.outputs.hash }}
52
+ run: |
53
+ set -euo pipefail
54
+ git config user.name "github-actions[bot]"
55
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
56
+ git add model_prices_and_context_window.json model_prices_and_context_window.sha256
57
+ COMMIT_MSG="chore: 同步模型价格数据"
58
+ if [ -n "${NEW_HASH}" ]; then
59
+ COMMIT_MSG="$COMMIT_MSG (${NEW_HASH})"
60
+ fi
61
+ git commit -m "$COMMIT_MSG"
62
+ git push origin price-mirror
CLAUDE.md CHANGED
@@ -6,34 +6,87 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
6
 
7
  ## 项目概述
8
 
9
- Claude Relay Service 是一个功能完整的 AI API 中转服务,支持 Claude Gemini 双平台。提供多账户管理、API Key 认证、代理配置和现代化 Web 管理界面。该服务作为客户端(如 SillyTavern、Claude Code、Gemini CLI)与 AI API 之间的中间件,提供认证、限流、监控等功能。
10
 
11
  ## 核心架构
12
 
13
  ### 关键架构概念
14
 
15
- - **代理认证流**: 客户端用自建API Key → 验证 → 获取Claude账户OAuth token → 转发到Anthropic
 
 
16
  - **Token管理**: 自动监控OAuth token过期并刷新,支持10秒提前刷新策略
17
- - **代理支持**: 每个Claude账户支持独立代理配置,OAuth token交换也通过代理进行
18
- - **数据加密**: 敏感数据(refreshToken, accessToken)使用AES加密存储在Redis
 
 
 
 
19
 
20
  ### 主要服务组件
21
 
22
- - **claudeRelayService.js**: 核心代理服务,处理请求转发和流式响应
23
- - **claudeAccountService.js**: Claude账户管理,OAuth token刷新和账户选择
24
- - **geminiAccountService.js**: Gemini账户管理,Google OAuth token刷新和账户选择
25
- - **apiKeyService.js**: API Key管理,验证、限流和使用统计
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  - **oauthHelper.js**: OAuth工具,PKCE流程实现和代理支持
 
 
27
 
28
  ### 认证和代理流程
29
 
30
- 1. 客户端使用自建API Key(cr\_前缀格式)发送请求
31
- 2. authenticateApiKey中间件验证API Key有效性和速率限制
32
- 3. claudeAccountService自动选择可用Claude账户
33
- 4. 检查OAuth access token有效性,过期则自动刷新(使用代理)
34
- 5. 移除客户端API Key,使用OAuth Bearer token转发请求
35
- 6. 通过账户配置的代理发送到Anthropic API
36
- 7. 流式或非流式返回响应,记录使用统计
 
 
 
37
 
38
  ### OAuth集成
39
 
@@ -42,6 +95,51 @@ Claude Relay Service 是一个功能完整的 AI API 中转服务,支持 Claud
42
  - **代理支持**: OAuth授权和token交换全程支持代理配置
43
  - **安全存储**: claudeAiOauth数据加密存储,包含accessToken、refreshToken、scopes
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  ## 常用命令
46
 
47
  ### 基本开发命令
@@ -69,19 +167,47 @@ npm run service:logs # 查看日志
69
  npm run service:stop # 停止服务
70
 
71
  ### 开发环境配置
72
- 必须配置的环境变量:
 
73
  - `JWT_SECRET`: JWT密钥(32字符以上随机字符串)
74
  - `ENCRYPTION_KEY`: 数据加密密钥(32字符固定长度)
75
  - `REDIS_HOST`: Redis主机地址(默认localhost)
76
  - `REDIS_PORT`: Redis端口(默认6379)
77
  - `REDIS_PASSWORD`: Redis密码(可选)
78
 
79
- 初始化命令:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  ```bash
81
  cp config/config.example.js config/config.js
82
  cp .env.example .env
83
  npm run setup # 自动生成密钥并创建管理员账户
84
- ````
85
 
86
  ## Web界面功能
87
 
@@ -95,31 +221,82 @@ npm run setup # 自动生成密钥并创建管理员账户
95
 
96
  ### 核心管理功能
97
 
98
- - **实时仪表板**: 系统统计、账户状态、使用量监控
99
- - **API Key管理**: 创建、配额设置、使用统计查看
100
- - **Claude账户管理**: OAuth账户添加、代理配置、状态监控
101
- - **系统日志**: 实时日志查看,多级别过滤
 
 
 
 
 
 
 
 
 
102
  - **主题系统**: 支持明亮/暗黑模式切换,自动保存用户偏好设置
 
 
103
 
104
  ## 重要端点
105
 
106
- ### API转发端点
107
 
108
- - `POST /api/v1/messages` - 主要消息处理端点(支持流式)
109
- - `GET /api/v1/models` - 模型列表(兼容性)
 
 
 
110
  - `GET /api/v1/usage` - 使用统计查询
111
  - `GET /api/v1/key-info` - API Key信息
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
 
113
- ### OAuth管理端点
 
 
114
 
 
 
 
 
 
 
115
  - `POST /admin/claude-accounts/generate-auth-url` - 生成OAuth授权URL(含代理)
116
  - `POST /admin/claude-accounts/exchange-code` - 交换authorization code
117
- - `POST /admin/claude-accounts` - 创建OAuth账户
 
 
 
 
 
 
 
 
 
 
 
 
 
118
 
119
  ### 系统端点
120
 
121
- - `GET /health` - 健康检查
122
- - `GET /web` - Web管理界面
 
 
123
  - `GET /admin/dashboard` - 系统概览数据
124
 
125
  ## 故障排除
@@ -138,17 +315,43 @@ npm run setup # 自动生成密钥并创建管理员账户
138
 
139
  ### 常见开发问题
140
 
141
- 1. **Redis连接失败**: 确认Redis服务运行,检查连接配置
142
- 2. **管理员登录失败**: 检查init.json同步到Redis,运行npm run setup
143
- 3. **API Key格式错误**: 确保使用cr\_前缀格式
144
- 4. **代理连接问题**: 验证SOCKS5/HTTP代理配置和认证信息
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
 
146
  ### 调试工具
147
 
148
- - **日志系统**: Winston结构化日志,支持不同级别
149
- - **CLI工具**: 命令行状态查看和管理
150
- - **Web界面**: 实时日志查看和系统监控
151
- - **健康检查**: /health端点提供系统状态
 
 
 
 
 
 
 
 
152
 
153
  ## 开发最佳实践
154
 
@@ -197,23 +400,57 @@ npm run setup # 自动生成密钥并创建管理员账户
197
 
198
  ### 常见文件位置
199
 
200
- - 核心服务逻辑:`src/services/` 目录
201
- - 路由处理:`src/routes/` 目录
202
- - 中间件:`src/middleware/` 目录
203
- - 配置管理:`config/config.js`
204
  - Redis 模型:`src/models/redis.js`
205
  - 工具函数:`src/utils/` 目录
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  - 前端主题管理:`web/admin-spa/src/stores/theme.js`
207
  - 前端组件:`web/admin-spa/src/components/` 目录
208
  - 前端页面:`web/admin-spa/src/views/` 目录
 
 
209
 
210
  ### 重要架构决策
211
 
212
- - 所有敏感数据(OAuth token、refreshToken)都使用 AES 加密存储在 Redis
213
- - 每个 Claude 账户支持独立的代理配置,包括 SOCKS5 和 HTTP 代理
214
- - API Key 使用哈希存储,支持 `cr_` 前缀格式
215
- - 请求流程:API Key 验证 → 账户选择 → Token 刷新(如需)→ 请求转发
216
- - 支持流式和非流式响应,客户端断开时自动清理资源
 
 
 
 
 
 
 
 
 
 
217
 
218
  ### 核心数据流和性能优化
219
 
@@ -235,36 +472,107 @@ npm run setup # 自动生成密钥并创建管理员账户
235
 
236
  ### Redis 数据结构
237
 
238
- - **API Keys**: `api_key:{id}` (详细信息) + `api_key_hash:{hash}` (快速查找)
239
- - **Claude 账户**: `claude_account:{id}` (加密的 OAuth 数据)
240
- - **管理员**: `admin:{id}` + `admin_username:{username}` (用户名映射)
241
- - **会话**: `session:{token}` (JWT 会话管理)
242
- - **使用统计**: `usage:daily:{date}:{key}:{model}` (多维度统计)
243
- - **系统信息**: `system_info` (系统状态缓存)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
 
245
  ### 流式响应处理
246
 
247
- - 支持 SSE (Server-Sent Events) 流式传输
248
- - 自动从流中解析 usage 数据并记录
249
- - 客户端断开时通过 AbortController 清理资源
250
- - 错误时发送适当的 SSE ���误事件
 
 
 
251
 
252
  ### CLI 工具使用示例
253
 
254
  ```bash
255
- # 创建新的 API Key
256
  npm run cli keys create -- --name "MyApp" --limit 1000
 
 
 
257
 
258
- # 查看系统状态
259
- npm run cli status
 
 
260
 
261
- # 管理 Claude 账户
262
  npm run cli accounts list
263
  npm run cli accounts refresh <accountId>
 
 
 
 
 
264
 
265
  # 管理员操作
266
  npm run cli admin create -- --username admin2
267
  npm run cli admin reset-password -- --username admin
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
  ```
269
 
270
  # important-instruction-reminders
@@ -273,3 +581,4 @@ Do what has been asked; nothing more, nothing less.
273
  NEVER create files unless they're absolutely necessary for achieving your goal.
274
  ALWAYS prefer editing an existing file to creating a new one.
275
  NEVER proactively create documentation files (\*.md) or README files. Only create documentation files if explicitly requested by the User.
 
 
6
 
7
  ## 项目概述
8
 
9
+ 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 之间的中间件,提供认证、限流、监控、定价计算、成本统计等功能。
10
 
11
  ## 核心架构
12
 
13
  ### 关键架构概念
14
 
15
+ - **统一调度系统**: 使用 unifiedClaudeScheduler、unifiedGeminiScheduler、unifiedOpenAIScheduler、droidScheduler 实现跨账户类型的智能调度
16
+ - **多账户类型支持**: 支持 claude-official、claude-console、bedrock、ccr、droid、gemini、openai-responses、azure-openai 等账户类型
17
+ - **代理认证流**: 客户端用自建API Key → 验证 → 统一调度器选择账户 → 获取账户token → 转发到对应API
18
  - **Token管理**: 自动监控OAuth token过期并刷新,支持10秒提前刷新策略
19
+ - **代理支持**: 每个账户支持独立代理配置,OAuth token交换也通过代理进行
20
+ - **数据加密**: 敏感数据(refreshToken, accessToken, credentials)使用AES加密存储在Redis
21
+ - **粘性会话**: 支持会话级别的账户绑定,同一会话使用同一账户,确保上下文连续性
22
+ - **权限控制**: API Key支持权限配置(all/claude/gemini/openai等),控制可访问的服务类型
23
+ - **客户端限制**: 基于User-Agent的客户端识别和限制,支持ClaudeCode、Gemini-CLI等预定义客户端
24
+ - **模型黑名单**: 支持API Key级别的模型访问限制
25
 
26
  ### 主要服务组件
27
 
28
+ #### 核心转发服务
29
+
30
+ - **claudeRelayService.js**: Claude官方API转发,处理OAuth认证和流式响应
31
+ - **claudeConsoleRelayService.js**: Claude Console账户转发服务
32
+ - **geminiRelayService.js**: Gemini API转发服务
33
+ - **bedrockRelayService.js**: AWS Bedrock API转发服务
34
+ - **azureOpenaiRelayService.js**: Azure OpenAI API转发服务
35
+ - **droidRelayService.js**: Droid (Factory.ai) API转发服务
36
+ - **ccrRelayService.js**: CCR账户转发服务
37
+ - **openaiResponsesRelayService.js**: OpenAI Responses (Codex) 转发服务
38
+
39
+ #### 账户管理服务
40
+
41
+ - **claudeAccountService.js**: Claude官方账户管理,OAuth token刷新和账户选择
42
+ - **claudeConsoleAccountService.js**: Claude Console账户管理
43
+ - **geminiAccountService.js**: Gemini账户管理,Google OAuth token刷新
44
+ - **bedrockAccountService.js**: AWS Bedrock账户管理
45
+ - **azureOpenaiAccountService.js**: Azure OpenAI账户管理
46
+ - **droidAccountService.js**: Droid账户管理
47
+ - **ccrAccountService.js**: CCR账户管理
48
+ - **openaiResponsesAccountService.js**: OpenAI Responses账户管理
49
+ - **openaiAccountService.js**: OpenAI兼容账户管理
50
+ - **accountGroupService.js**: 账户组管理,支持账户分组和优先级
51
+
52
+ #### 统一调度器
53
+
54
+ - **unifiedClaudeScheduler.js**: Claude多账户类型统一调度(claude-official/console/bedrock/ccr)
55
+ - **unifiedGeminiScheduler.js**: Gemini账户统一调度
56
+ - **unifiedOpenAIScheduler.js**: OpenAI兼容服务统一调度
57
+ - **droidScheduler.js**: Droid账户调度
58
+
59
+ #### 核心功能服务
60
+
61
+ - **apiKeyService.js**: API Key管理,验证、限流、使用统计、成本计算
62
+ - **userService.js**: 用户管理系统,支持用户注册、登录、API Key管理
63
+ - **pricingService.js**: 定价服务,模型价格管理和成本计算
64
+ - **costInitService.js**: 成本数据初始化服务
65
+ - **webhookService.js**: Webhook通知服务
66
+ - **webhookConfigService.js**: Webhook配置管理
67
+ - **ldapService.js**: LDAP认证服务
68
+ - **tokenRefreshService.js**: Token自动刷新服务
69
+ - **rateLimitCleanupService.js**: 速率限制状态清理服务
70
+ - **claudeCodeHeadersService.js**: Claude Code客户端请求头处理
71
+
72
+ #### 工��服务
73
+
74
  - **oauthHelper.js**: OAuth工具,PKCE流程实现和代理支持
75
+ - **workosOAuthHelper.js**: WorkOS OAuth集成
76
+ - **openaiToClaude.js**: OpenAI格式到Claude格式的转换
77
 
78
  ### 认证和代理流程
79
 
80
+ 1. 客户端使用自建API Key(cr\_前缀格式)发送请求到对应路由(/api、/claude、/gemini、/openai、/droid等)
81
+ 2. **authenticateApiKey中间件**验证API Key有效性、速率限制、权限、客户端限制、模型黑名单
82
+ 3. **统一调度器**(如 unifiedClaudeScheduler)根据请求模型、会话hash、API Key权限选择最优账户
83
+ 4. 检查选中账户的token有效性,过期则自动刷新(使用代理)
84
+ 5. 根据账户类型调用对应的转发服务(claudeRelayService、geminiRelayService等)
85
+ 6. 移除客户端API Key,使用账户凭据(OAuth Bearer token、API Key等)转发请求
86
+ 7. 通过账户配置的代理发送到目标API(Anthropic、Google、AWS等)
87
+ 8. 流式或非流式返回响应,捕获真实usage数据
88
+ 9. 记录使用统计(input/output/cache_create/cache_read tokens)和成本计算
89
+ 10. 更新速率限制计数器和并发控制
90
 
91
  ### OAuth集成
92
 
 
95
  - **代理支持**: OAuth授权和token交换全程支持代理配置
96
  - **安全存储**: claudeAiOauth数据加密存储,包含accessToken、refreshToken、scopes
97
 
98
+ ## 新增功能概览(相比旧版本)
99
+
100
+ ### 多平台支持
101
+
102
+ - ✅ **Claude Console账户**: 支持Claude Console类型账户
103
+ - ✅ **AWS Bedrock**: 完整的AWS Bedrock API支持
104
+ - ✅ **Azure OpenAI**: Azure OpenAI服务支持
105
+ - ✅ **Droid (Factory.ai)**: Factory.ai API支持
106
+ - ✅ **CCR账户**: CCR凭据支持
107
+ - ✅ **OpenAI兼容**: OpenAI格式转换和Responses格式支持
108
+
109
+ ### 用户和权限系统
110
+
111
+ - ✅ **用户管理**: 完整的用户注册、登录、API Key管理系统
112
+ - ✅ **LDAP认证**: 企业级LDAP/Active Directory集成
113
+ - ✅ **权限控制**: API Key级别的服务权限(all/claude/gemini/openai)
114
+ - ✅ **客户端限制**: 基于User-Agent的客户端识别和限制
115
+ - ✅ **模型黑名单**: API Key级别的模型访问控制
116
+
117
+ ### 统一调度和会话管理
118
+
119
+ - ✅ **统一调度器**: 跨账户类型的智能调度系统
120
+ - ✅ **粘性会话**: 会话级账户绑定,支持自动续期
121
+ - ✅ **并发控制**: Redis Sorted Set实现的并发限制
122
+ - ✅ **负载均衡**: 自动账户选择和故障转移
123
+
124
+ ### 成本和监控
125
+
126
+ - ✅ **定价服务**: 模型价格管理和自动成本计算
127
+ - ✅ **成本统计**: 详细的token使用和费用统计
128
+ - ✅ **缓存监控**: 全局缓存统计和命中率分析
129
+ - ✅ **实时指标**: 可配置窗口的实时统计(METRICS_WINDOW)
130
+
131
+ ### Webhook和通知
132
+
133
+ - ✅ **Webhook系统**: 事件通知和Webhook配置管理
134
+ - ✅ **多URL支持**: 支持多个Webhook URL(逗号分隔)
135
+
136
+ ### 高级功能
137
+
138
+ - ✅ **529错误处理**: 自动识别Claude过载状态并暂时排除账户
139
+ - ✅ **HTTP调试**: DEBUG_HTTP_TRAFFIC模式详细记录HTTP请求/响应
140
+ - ✅ **数据迁移**: 完整的数据导入导出工具(含加密/脱敏)
141
+ - ✅ **自动清理**: 并发计数、速率限制、临时错误状态自动清理
142
+
143
  ## 常用命令
144
 
145
  ### 基本开发命令
 
167
  npm run service:stop # 停止服务
168
 
169
  ### 开发环境配置
170
+
171
+ #### 必须配置的环境变量
172
  - `JWT_SECRET`: JWT密钥(32字符以上随机字符串)
173
  - `ENCRYPTION_KEY`: 数据加密密钥(32字符固定长度)
174
  - `REDIS_HOST`: Redis主机地址(默认localhost)
175
  - `REDIS_PORT`: Redis端口(默认6379)
176
  - `REDIS_PASSWORD`: Redis密码(可选)
177
 
178
+ #### 新增重要环境变量(可选)
179
+ - `USER_MANAGEMENT_ENABLED`: 启用用户管理系统(默认false)
180
+ - `LDAP_ENABLED`: 启用LDAP认证(默认false)
181
+ - `LDAP_URL`: LDAP服务器地址(如 ldaps://ldap.example.com:636)
182
+ - `LDAP_TLS_REJECT_UNAUTHORIZED`: LDAP证书验证(默认true)
183
+ - `WEBHOOK_ENABLED`: 启用Webhook通知(默认true)
184
+ - `WEBHOOK_URLS`: Webhook通知URL列表(逗号分隔)
185
+ - `CLAUDE_OVERLOAD_HANDLING_MINUTES`: Claude 529错误处理持续时间(分钟,0表示禁用)
186
+ - `STICKY_SESSION_TTL_HOURS`: 粘性会话TTL(小时,默认1)
187
+ - `STICKY_SESSION_RENEWAL_THRESHOLD_MINUTES`: 粘性会话续期阈值(分钟,默认0)
188
+ - `METRICS_WINDOW`: 实时指标统计窗口(分钟,1-60,默认5)
189
+ - `MAX_API_KEYS_PER_USER`: 每用户最大API Key���量(默认1)
190
+ - `ALLOW_USER_DELETE_API_KEYS`: 允许用户删除自己的API Keys(默认false)
191
+ - `DEBUG_HTTP_TRAFFIC`: 启用HTTP请求/响应调试日志(默认false,仅开发环境)
192
+ - `PROXY_USE_IPV4`: 代理使用IPv4(默认true)
193
+ - `REQUEST_TIMEOUT`: 请求超时时间(毫秒,默认600000即10分钟)
194
+
195
+ #### AWS Bedrock配置(可选)
196
+ - `CLAUDE_CODE_USE_BEDROCK`: 启用Bedrock(设置为1启用)
197
+ - `AWS_REGION`: AWS默认区域(默认us-east-1)
198
+ - `ANTHROPIC_MODEL`: Bedrock默认模型
199
+ - `ANTHROPIC_SMALL_FAST_MODEL`: Bedrock小型快速模型
200
+ - `ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION`: 小型模型区域
201
+ - `CLAUDE_CODE_MAX_OUTPUT_TOKENS`: 最大输出tokens(默认4096)
202
+ - `MAX_THINKING_TOKENS`: 最大思考tokens(默认1024)
203
+ - `DISABLE_PROMPT_CACHING`: 禁用提示缓存(设置为1禁用)
204
+
205
+ #### 初始化命令
206
  ```bash
207
  cp config/config.example.js config/config.js
208
  cp .env.example .env
209
  npm run setup # 自动生成密钥并创建管理员账户
210
+ ```
211
 
212
  ## Web界面功能
213
 
 
221
 
222
  ### 核心管理功能
223
 
224
+ - **实时仪表板**: 系统统计、账户状态、使用量监控、实时指标(METRICS_WINDOW配置窗口)
225
+ - **API Key管理**: 创建、配额设置、使用统计查看、权限配置、客户端限制、模型黑名单
226
+ - **多平台账户管理**:
227
+ - Claude账户(官方/Console): OAuth账户添加、代理配置、状态监控
228
+ - Gemini账户: Google OAuth授权、代理配置
229
+ - OpenAI Responses (Codex)账户: API Key配置
230
+ - AWS Bedrock账户: AWS凭据配置
231
+ - Azure OpenAI账户: Azure凭据和端点配置
232
+ - Droid账户: Factory.ai API Key配置
233
+ - CCR账户: CCR凭据配置
234
+ - **用户管理**: 用户注册、登录、API Key分配(USER_MANAGEMENT_ENABLED启用时)
235
+ - **系统日志**: 实时日志查看,多级别过滤,HTTP调试日志(DEBUG_HTTP_TRAFFIC启用时)
236
+ - **Webhook配置**: Webhook URL管理、事件配置
237
  - **主题系统**: 支持明亮/暗黑模式切换,自动保存用户偏好设置
238
+ - **成本分析**: 详细的token使用和成本统计(基于pricingService)
239
+ - **缓存监控**: 解密缓存统计和性能监控
240
 
241
  ## 重要端点
242
 
243
+ ### API转发端点(多路由支持)
244
 
245
+ #### Claude服务路由
246
+ - `POST /api/v1/messages` - Claude消息处理(支持流式)
247
+ - `POST /claude/v1/messages` - Claude消息处理(别名路由)
248
+ - `POST /v1/messages/count_tokens` - Token计数Beta API
249
+ - `GET /api/v1/models` - 模型列表
250
  - `GET /api/v1/usage` - 使用统计查询
251
  - `GET /api/v1/key-info` - API Key信息
252
+ - `GET /v1/me` - 用户信息(Claude Code客户端需要)
253
+ - `GET /v1/organizations/:org_id/usage` - 组织使用统计
254
+
255
+ #### Gemini服务路由
256
+ - `POST /gemini/v1/models/:model:generateContent` - 标准Gemini API格式
257
+ - `POST /gemini/v1/models/:model:streamGenerateContent` - Gemini流式
258
+ - `GET /gemini/v1/models` - Gemini模型列表
259
+ - 其他Gemini兼容路由(保持向后兼容)
260
+
261
+ #### OpenAI兼容路由
262
+ - `POST /openai/v1/chat/completions` - OpenAI格式转发(支持responses格式)
263
+ - `POST /openai/claude/v1/chat/completions` - OpenAI格式转Claude
264
+ - `POST /openai/gemini/v1/chat/completions` - OpenAI格式转Gemini
265
+ - `GET /openai/v1/models` - OpenAI格式模型列表
266
 
267
+ #### Droid (Factory.ai) 路由
268
+ - `POST /droid/claude/v1/messages` - Droid Claude转发
269
+ - `POST /droid/openai/v1/chat/completions` - Droid OpenAI转发
270
 
271
+ #### Azure OpenAI 路由
272
+ - `POST /azure/...` - Azure OpenAI API转发
273
+
274
+ ### 管理端点
275
+
276
+ #### OAuth和账户管理
277
  - `POST /admin/claude-accounts/generate-auth-url` - 生成OAuth授权URL(含代理)
278
  - `POST /admin/claude-accounts/exchange-code` - 交换authorization code
279
+ - `POST /admin/claude-accounts` - 创建Claude OAuth账户
280
+ - 各平台账户CRUD端点(gemini、openai、bedrock、azure、droid、ccr)
281
+
282
+ #### 用户管理(USER_MANAGEMENT_ENABLED启用时)
283
+ - `POST /users/register` - 用户注册
284
+ - `POST /users/login` - 用户登录
285
+ - `GET /users/profile` - 用户资料
286
+ - `POST /users/api-keys` - 创建用户API Key
287
+
288
+ #### Webhook管理
289
+ - `GET /admin/webhook/configs` - 获取Webhook配置
290
+ - `POST /admin/webhook/configs` - 创建Webhook配置
291
+ - `PUT /admin/webhook/configs/:id` - 更新Webhook配置
292
+ - `DELETE /admin/webhook/configs/:id` - 删除Webhook配置
293
 
294
  ### 系统端点
295
 
296
+ - `GET /health` - 健康检查(包含组件状态、版本、内存等)
297
+ - `GET /metrics` - 系统指标(使用统计、uptime、内存)
298
+ - `GET /web` - 传统Web管理界面
299
+ - `GET /admin-next/` - 新版SPA管理界面(主界面)
300
  - `GET /admin/dashboard` - 系统概览数据
301
 
302
  ## 故障排除
 
315
 
316
  ### 常见开发问题
317
 
318
+ 1. **Redis连接失败**: 确认Redis服务运行,检查REDIS_HOST、REDIS_PORT、REDIS_PASSWORD配置
319
+ 2. **管理员登录失败**: 检查data/init.json存在,运行npm run setup重新初始化
320
+ 3. **API Key格式错误**: 确保使用cr\_前缀格式(可通过API_KEY_PREFIX配置修改)
321
+ 4. **代理连接问题**: 验证SOCKS5/HTTP代理配置和认证信息,检查PROXY_USE_IPV4设置
322
+ 5. **粘性会话失效**: 检查Redis中session数据,确认STICKY_SESSION_TTL_HOURS配置,通过Nginx代理时需添加 `underscores_in_headers on;`
323
+ 6. **LDAP认证失败**:
324
+ - 检查LDAP_URL、LDAP_BIND_DN、LDAP_BIND_PASSWORD配置
325
+ - 自签名证书问题:设置 LDAP_TLS_REJECT_UNAUTHORIZED=false
326
+ - 查看日志中的LDAP连接错误详情
327
+ 7. **用户管理功能不可用**: 确认USER_MANAGEMENT_ENABLED=true,检查userService初始化
328
+ 8. **Webhook通知失败**:
329
+ - 确认WEBHOOK_ENABLED=true
330
+ - 检查WEBHOOK_URLS格式(逗号分隔)
331
+ - 查看logs/webhook-*.log日志
332
+ 9. **统一调度器选择账户失败**:
333
+ - 检查账户状态(status: 'active')
334
+ - 确认账户类型与请求路由匹配
335
+ - 查看粘性会话绑定情况
336
+ 10. **并发计数泄漏**: 系统每分钟自动清理过期并发计数(concurrency cleanup task),重启时也会自动清理
337
+ 11. **速率限制未清理**: rateLimitCleanupService每5分钟自动清理过期限流状态
338
+ 12. **成本统计不准确**: 运行 `npm run init:costs` 初始化成本数据,检查pricingService是否正确加载模型价格
339
+ 13. **缓存命中率低**: 查看缓存监控统计,调整LRU缓存大小配置
340
 
341
  ### 调试工具
342
 
343
+ - **日志系统**: Winston结构化日志,支持不同级别,logs/目录下分类存储
344
+ - `logs/claude-relay-*.log` - 应用主日志
345
+ - `logs/token-refresh-error.log` - Token刷新错误
346
+ - `logs/webhook-*.log` - Webhook通知日志
347
+ - `logs/http-debug-*.log` - HTTP调试日志(DEBUG_HTTP_TRAFFIC=true时)
348
+ - **CLI工具**: 命令行状态查看和管理(npm run cli)
349
+ - **Web界面**: 实时日志查看和系统监控(/admin-next/)
350
+ - **健康检查**: /health端点提供系统状态(redis、logger、内存、版本等)
351
+ - **系统指标**: /metrics端点提供详细的使用统计和性能指标
352
+ - **缓存监控**: cacheMonitor提供全局缓存统计和命中率分析
353
+ - **数据导出工具**: npm run data:export 导出Redis数据进行调试
354
+ - **Redis Key调试**: npm run data:debug 查看所有Redis键
355
 
356
  ## 开发最佳实践
357
 
 
400
 
401
  ### 常见文件位置
402
 
403
+ - 核心服务逻辑:`src/services/` 目录(30+服务文件)
404
+ - 路由处理:`src/routes/` 目录(api.js、admin.js、geminiRoutes.js、openaiRoutes.js等13个路由文件)
405
+ - 中间件:`src/middleware/` 目录(auth.js、browserFallback.js、debugInterceptor.js等)
406
+ - 配置管理:`config/config.js`(完整的多平台配置)
407
  - Redis 模型:`src/models/redis.js`
408
  - 工具函数:`src/utils/` 目录
409
+ - `logger.js` - 日志系统
410
+ - `oauthHelper.js` - OAuth工具
411
+ - `proxyHelper.js` - 代理工具
412
+ - `sessionHelper.js` - 会话管理
413
+ - `cacheMonitor.js` - 缓存监控
414
+ - `costCalculator.js` - 成本计算
415
+ - `rateLimitHelper.js` - 速率限制
416
+ - `webhookNotifier.js` - Webhook通知
417
+ - `tokenMask.js` - Token脱敏
418
+ - `workosOAuthHelper.js` - WorkOS OAuth
419
+ - `modelHelper.js` - 模型工具
420
+ - `inputValidator.js` - 输入验证
421
+ - CLI工具:`cli/index.js` 和 `src/cli/` 目录
422
+ - 脚本目录:`scripts/` 目录
423
+ - `setup.js` - 初始化脚本
424
+ - `manage.js` - 服务管理
425
+ - `migrate-apikey-expiry.js` - API Key过期迁移
426
+ - `fix-usage-stats.js` - 使用统计修复
427
+ - `data-transfer.js` / `data-transfer-enhanced.js` - 数据导入导出
428
+ - `update-model-pricing.js` - 模型价格更新
429
+ - `test-pricing-fallback.js` - 价格回退测试
430
+ - `debug-redis-keys.js` - Redis调试
431
  - 前端主题管理:`web/admin-spa/src/stores/theme.js`
432
  - 前端组件:`web/admin-spa/src/components/` 目录
433
  - 前端页面:`web/admin-spa/src/views/` 目录
434
+ - 初始化数据:`data/init.json`(管理员凭据存储)
435
+ - 日志目录:`logs/`(各类日志文件)
436
 
437
  ### 重要架构决策
438
 
439
+ - **统一调度系统**: 使用统一调度器(unifiedClaudeScheduler等)实现跨账户类型的智能调度,支持粘性会话、负载均衡、故障转移
440
+ - **多账户类型支持**: 支持8种账户类型(claude-official、claude-console、bedrock、ccr、droid、gemini、openai-responses、azure-openai)
441
+ - **加密存储**: 所有敏感数据(OAuth token、refreshToken、credentials)都使用 AES 加密存储在 Redis
442
+ - **独立代理**: 每个账户支持独立的代理配置(SOCKS5/HTTP),包括OAuth授权流程
443
+ - **API Key哈希**: 使用SHA-256哈希存储,支持自定义前缀(默认 `cr_`)
444
+ - **权限系统**: API Key支持细粒度权限控制(all/claude/gemini/openai等)
445
+ - **请求流程**: API Key验证(含权限、客户端、模型黑名单) → 统一调度器选择账户 → Token刷新(如需)→ 请求转发 → Usage捕获 → 成本计算
446
+ - **流式响应**: 支持SSE流式响应,实时捕获真实usage数据,客户端断开时自动清理资源(AbortController)
447
+ - **粘性会话**: 基于请求内容hash的会话绑定,同一会话始终使用同一账户,支持自动续期
448
+ - **自动清理**: 定时清理任务(过期Key、错误账户、临时错误、并发计数、速率限制状态)
449
+ - **缓存优化**: 多层LRU缓存(解密缓存、账户缓存),全局缓存监控和统计
450
+ - **成本追踪**: 实时token使用统计(input/output/cache_create/cache_read)和成本计算(基于pricingService)
451
+ - **并发控制**: Redis Sorted Set实现的并发计数,支持自动过期清理
452
+ - **客户端识别**: 基于User-Agent的客户端限制,支持预定义客户端(ClaudeCode、Gemini-CLI等)
453
+ - **错误处理**: 529错误自动标记账户过载状态,配置时长内自动排除该账户
454
 
455
  ### 核心数据流和性能优化
456
 
 
472
 
473
  ### Redis 数据结构
474
 
475
+ - **API Keys**:
476
+ - `api_key:{id}` - API Key详细信息(含权限、客户端限制、模型黑名单等)
477
+ - `api_key_hash:{hash}` - 哈希到ID的快速映射
478
+ - `api_key_usage:{keyId}` - 使用统计数据
479
+ - `api_key_cost:{keyId}` - 成本统计数据
480
+ - **账户数据**(多类型):
481
+ - `claude_account:{id}` - Claude官方账户(加密的OAuth数据)
482
+ - `claude_console_account:{id}` - Claude Console账户
483
+ - `gemini_account:{id}` - Gemini账户
484
+ - `openai_responses_account:{id}` - OpenAI Responses账户
485
+ - `bedrock_account:{id}` - AWS Bedrock账户
486
+ - `azure_openai_account:{id}` - Azure OpenAI账户
487
+ - `droid_account:{id}` - Droid账户
488
+ - `ccr_account:{id}` - CCR账户
489
+ - **用户管理**:
490
+ - `user:{id}` - 用户信息
491
+ - `user_email:{email}` - 邮箱到用户ID映射
492
+ - `user_session:{token}` - 用户会话
493
+ - **管理员**:
494
+ - `admin:{id}` - 管理员信息
495
+ - `admin_username:{username}` - 用户名映射
496
+ - `admin_credentials` - 管理员凭据(从data/init.json同步)
497
+ - **会话管理**:
498
+ - `session:{token}` - JWT会话管理
499
+ - `sticky_session:{sessionHash}` - 粘性会话账户绑定
500
+ - `session_window:{accountId}` - 账户会话窗口
501
+ - **使用统计**:
502
+ - `usage:daily:{date}:{key}:{model}` - 按日期、Key、模型的使用统计
503
+ - `usage:account:{accountId}:{date}` - 按账户的使用统计
504
+ - `usage:global:{date}` - 全局使用统计
505
+ - **速率限制**:
506
+ - `rate_limit:{keyId}:{window}` - 速率限制计数器
507
+ - `rate_limit_state:{accountId}` - 账户限流状态
508
+ - `overload:{accountId}` - 账户过载状态(529错误)
509
+ - **并发控制**:
510
+ - `concurrency:{accountId}` - Redis Sorted Set实现的并发计数
511
+ - **Webhook配置**:
512
+ - `webhook_config:{id}` - Webhook配置
513
+ - **系统信息**:
514
+ - `system_info` - 系统状态缓存
515
+ - `model_pricing` - 模型价格数据(pricingService)
516
 
517
  ### 流式响应处理
518
 
519
+ - 支持 SSE (Server-Sent Events) 流式传输,实时推送响应数据
520
+ - 自动从SSE流中解析真实usage数据(input/output/cache_create/cache_read tokens)
521
+ - 客户端断开时通过 AbortController 清理资源和并发计数
522
+ - 错误时发送适当的 SSE 错误事件(带时间戳和错误类型)
523
+ - 支持大文件流式传输(REQUEST_TIMEOUT配置超时时间)
524
+ - 禁用Nagle算法确保数据立即发送(socket.setNoDelay)
525
+ - 设置 `X-Accel-Buffering: no` 禁用Nginx缓冲
526
 
527
  ### CLI 工具使用示例
528
 
529
  ```bash
530
+ # API Key管理
531
  npm run cli keys create -- --name "MyApp" --limit 1000
532
+ npm run cli keys list
533
+ npm run cli keys delete -- --id <keyId>
534
+ npm run cli keys update -- --id <keyId> --limit 2000
535
 
536
+ # 系统状态查看
537
+ npm run cli status # 查看系统概况
538
+ npm run status # 统一状态脚本
539
+ npm run status:detail # 详细状态
540
 
541
+ # Claude账户管理
542
  npm run cli accounts list
543
  npm run cli accounts refresh <accountId>
544
+ npm run cli accounts add -- --name "Account1"
545
+
546
+ # Gemini账户管理
547
+ npm run cli gemini list
548
+ npm run cli gemini add -- --name "Gemini1"
549
 
550
  # 管理员操作
551
  npm run cli admin create -- --username admin2
552
  npm run cli admin reset-password -- --username admin
553
+ npm run cli admin list
554
+
555
+ # 数据管理
556
+ npm run data:export # 导出Redis数据
557
+ npm run data:export:sanitized # 导出脱敏数据
558
+ npm run data:export:enhanced # 增强导出(含解密)
559
+ npm run data:export:encrypted # 导出加密数据
560
+ npm run data:import # 导入数据
561
+ npm run data:import:enhanced # 增强导入
562
+ npm run data:debug # 调试Redis键
563
+
564
+ # 数据迁移和修复
565
+ npm run migrate:apikey-expiry # API Key过期时间迁移
566
+ npm run migrate:apikey-expiry:dry # 干跑模式
567
+ npm run migrate:fix-usage-stats # 修复使用统计
568
+
569
+ # 成本和定价
570
+ npm run init:costs # 初始化成本数据
571
+ npm run update:pricing # 更新模型价格
572
+ npm run test:pricing-fallback # 测试价格回退
573
+
574
+ # 监控
575
+ npm run monitor # 增强监控脚本
576
  ```
577
 
578
  # important-instruction-reminders
 
581
  NEVER create files unless they're absolutely necessary for achieving your goal.
582
  ALWAYS prefer editing an existing file to creating a new one.
583
  NEVER proactively create documentation files (\*.md) or README files. Only create documentation files if explicitly requested by the User.
584
+ ````
VERSION CHANGED
@@ -1 +1 @@
1
- 1.1.176
 
1
+ 1.1.179
config/pricingSource.js ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const repository =
2
+ process.env.PRICE_MIRROR_REPO || process.env.GITHUB_REPOSITORY || 'Wei-Shaw/claude-relay-service'
3
+ const branch = process.env.PRICE_MIRROR_BRANCH || 'price-mirror'
4
+ const pricingFileName = process.env.PRICE_MIRROR_FILENAME || 'model_prices_and_context_window.json'
5
+ const hashFileName = process.env.PRICE_MIRROR_HASH_FILENAME || 'model_prices_and_context_window.sha256'
6
+
7
+ const baseUrl = process.env.PRICE_MIRROR_BASE_URL
8
+ ? process.env.PRICE_MIRROR_BASE_URL.replace(/\/$/, '')
9
+ : `https://raw.githubusercontent.com/${repository}/${branch}`
10
+
11
+ module.exports = {
12
+ pricingFileName,
13
+ hashFileName,
14
+ pricingUrl:
15
+ process.env.PRICE_MIRROR_JSON_URL || `${baseUrl}/${pricingFileName}`,
16
+ hashUrl: process.env.PRICE_MIRROR_HASH_URL || `${baseUrl}/${hashFileName}`
17
+ }
resources/model-pricing/README.md CHANGED
@@ -1,11 +1,11 @@
1
  # Model Pricing Data
2
 
3
- This directory contains a local copy of the LiteLLM model pricing data as a fallback mechanism.
4
 
5
  ## Source
6
- The original file is maintained by the LiteLLM project:
7
- - Repository: https://github.com/BerriAI/litellm
8
- - File: https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json
9
 
10
  ## Purpose
11
  This local copy serves as a fallback when the remote file cannot be downloaded due to:
@@ -22,7 +22,7 @@ The pricingService will:
22
  3. Log a warning when using the fallback file
23
 
24
  ## Manual Update
25
- To manually update this file with the latest pricing data:
26
  ```bash
27
  curl -s https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json -o model_prices_and_context_window.json
28
  ```
@@ -34,4 +34,4 @@ The file contains JSON data with model pricing information including:
34
  - Context window sizes
35
  - Model capabilities
36
 
37
- Last updated: 2025-08-10
 
1
  # Model Pricing Data
2
 
3
+ This directory contains a local copy of the mirrored model pricing data as a fallback mechanism.
4
 
5
  ## Source
6
+ The original file is maintained by the LiteLLM project and mirrored into the `price-mirror` branch of this repository via GitHub Actions:
7
+ - Mirror branch (configurable via `PRICE_MIRROR_REPO`): https://raw.githubusercontent.com/<your-repo>/price-mirror/model_prices_and_context_window.json
8
+ - Upstream source: https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json
9
 
10
  ## Purpose
11
  This local copy serves as a fallback when the remote file cannot be downloaded due to:
 
22
  3. Log a warning when using the fallback file
23
 
24
  ## Manual Update
25
+ To manually update this file with the latest pricing data (if automation is unavailable):
26
  ```bash
27
  curl -s https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json -o model_prices_and_context_window.json
28
  ```
 
34
  - Context window sizes
35
  - Model capabilities
36
 
37
+ Last updated: 2025-08-10
scripts/test-billing-events.js ADDED
@@ -0,0 +1,340 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * 计费事件测试脚本
5
+ *
6
+ * 用于测试计费事件的发布和消费功能
7
+ *
8
+ * 使用方法:
9
+ * node scripts/test-billing-events.js [command]
10
+ *
11
+ * 命令:
12
+ * publish - 发布测试事件
13
+ * consume - 消费事件(测试模式)
14
+ * info - 查看队列状态
15
+ * clear - 清空队列(危险操作)
16
+ */
17
+
18
+ const path = require('path')
19
+ const Redis = require('ioredis')
20
+
21
+ // 加载配置
22
+ require('dotenv').config({ path: path.join(__dirname, '../.env') })
23
+
24
+ const config = {
25
+ host: process.env.REDIS_HOST || 'localhost',
26
+ port: parseInt(process.env.REDIS_PORT) || 6379,
27
+ password: process.env.REDIS_PASSWORD || '',
28
+ db: parseInt(process.env.REDIS_DB) || 0
29
+ }
30
+
31
+ const redis = new Redis(config)
32
+ const STREAM_KEY = 'billing:events'
33
+
34
+ // ========================================
35
+ // 命令实现
36
+ // ========================================
37
+
38
+ /**
39
+ * 发布测试事件
40
+ */
41
+ async function publishTestEvent() {
42
+ console.log('📤 Publishing test billing event...')
43
+
44
+ const testEvent = {
45
+ eventId: `test-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
46
+ eventType: 'usage.recorded',
47
+ timestamp: new Date().toISOString(),
48
+ version: '1.0',
49
+ apiKey: {
50
+ id: 'test-key-123',
51
+ name: 'Test API Key',
52
+ userId: 'test-user-456'
53
+ },
54
+ usage: {
55
+ model: 'claude-sonnet-4-20250514',
56
+ inputTokens: 1500,
57
+ outputTokens: 800,
58
+ cacheCreateTokens: 200,
59
+ cacheReadTokens: 100,
60
+ ephemeral5mTokens: 150,
61
+ ephemeral1hTokens: 50,
62
+ totalTokens: 2600
63
+ },
64
+ cost: {
65
+ total: 0.0156,
66
+ currency: 'USD',
67
+ breakdown: {
68
+ input: 0.0045,
69
+ output: 0.012,
70
+ cacheCreate: 0.00075,
71
+ cacheRead: 0.00003,
72
+ ephemeral5m: 0.0005625,
73
+ ephemeral1h: 0.0001875
74
+ }
75
+ },
76
+ account: {
77
+ id: 'test-account-789',
78
+ type: 'claude-official'
79
+ },
80
+ context: {
81
+ isLongContext: false,
82
+ requestTimestamp: new Date().toISOString()
83
+ }
84
+ }
85
+
86
+ try {
87
+ const messageId = await redis.xadd(
88
+ STREAM_KEY,
89
+ 'MAXLEN',
90
+ '~',
91
+ 100000,
92
+ '*',
93
+ 'data',
94
+ JSON.stringify(testEvent)
95
+ )
96
+
97
+ console.log('✅ Event published successfully!')
98
+ console.log(` Message ID: ${messageId}`)
99
+ console.log(` Event ID: ${testEvent.eventId}`)
100
+ console.log(` Cost: $${testEvent.cost.total}`)
101
+ } catch (error) {
102
+ console.error('❌ Failed to publish event:', error.message)
103
+ process.exit(1)
104
+ }
105
+ }
106
+
107
+ /**
108
+ * 消费事件(测试模式,不创建消费者组)
109
+ */
110
+ async function consumeTestEvents() {
111
+ console.log('📬 Consuming test events...')
112
+ console.log(' Press Ctrl+C to stop\n')
113
+
114
+ let isRunning = true
115
+
116
+ process.on('SIGINT', () => {
117
+ console.log('\n⏹️ Stopping consumer...')
118
+ isRunning = false
119
+ })
120
+
121
+ let lastId = '0' // 从头开始
122
+
123
+ while (isRunning) {
124
+ try {
125
+ // 使用 XREAD 而不是 XREADGROUP(测试模式)
126
+ const messages = await redis.xread('BLOCK', 5000, 'COUNT', 10, 'STREAMS', STREAM_KEY, lastId)
127
+
128
+ if (!messages || messages.length === 0) {
129
+ continue
130
+ }
131
+
132
+ const [streamKey, entries] = messages[0]
133
+ console.log(`📬 Received ${entries.length} messages from ${streamKey}\n`)
134
+
135
+ for (const [messageId, fields] of entries) {
136
+ try {
137
+ const data = {}
138
+ for (let i = 0; i < fields.length; i += 2) {
139
+ data[fields[i]] = fields[i + 1]
140
+ }
141
+
142
+ const event = JSON.parse(data.data)
143
+
144
+ console.log(`📊 Event: ${event.eventId}`)
145
+ console.log(` API Key: ${event.apiKey.name} (${event.apiKey.id})`)
146
+ console.log(` Model: ${event.usage.model}`)
147
+ console.log(` Tokens: ${event.usage.totalTokens}`)
148
+ console.log(` Cost: $${event.cost.total.toFixed(6)}`)
149
+ console.log(` Timestamp: ${event.timestamp}`)
150
+ console.log('')
151
+
152
+ lastId = messageId // 更新位置
153
+ } catch (parseError) {
154
+ console.error(`❌ Failed to parse message ${messageId}:`, parseError.message)
155
+ }
156
+ }
157
+ } catch (error) {
158
+ if (isRunning) {
159
+ console.error('❌ Error consuming messages:', error.message)
160
+ await new Promise((resolve) => setTimeout(resolve, 5000))
161
+ }
162
+ }
163
+ }
164
+
165
+ console.log('👋 Consumer stopped')
166
+ }
167
+
168
+ /**
169
+ * 查看队列状态
170
+ */
171
+ async function showQueueInfo() {
172
+ console.log('📊 Queue Information\n')
173
+
174
+ try {
175
+ // Stream 长度
176
+ const length = await redis.xlen(STREAM_KEY)
177
+ console.log(`Stream: ${STREAM_KEY}`)
178
+ console.log(`Length: ${length} messages\n`)
179
+
180
+ if (length === 0) {
181
+ console.log('ℹ️ Queue is empty')
182
+ return
183
+ }
184
+
185
+ // Stream 详细信息
186
+ const info = await redis.xinfo('STREAM', STREAM_KEY)
187
+ const infoObj = {}
188
+ for (let i = 0; i < info.length; i += 2) {
189
+ infoObj[info[i]] = info[i + 1]
190
+ }
191
+
192
+ console.log('Stream Details:')
193
+ console.log(` First Entry ID: ${infoObj['first-entry'] ? infoObj['first-entry'][0] : 'N/A'}`)
194
+ console.log(` Last Entry ID: ${infoObj['last-entry'] ? infoObj['last-entry'][0] : 'N/A'}`)
195
+ console.log(` Consumer Groups: ${infoObj.groups || 0}\n`)
196
+
197
+ // 消费者组信息
198
+ if (infoObj.groups > 0) {
199
+ console.log('Consumer Groups:')
200
+ const groups = await redis.xinfo('GROUPS', STREAM_KEY)
201
+
202
+ for (let i = 0; i < groups.length; i++) {
203
+ const group = groups[i]
204
+ const groupObj = {}
205
+ for (let j = 0; j < group.length; j += 2) {
206
+ groupObj[group[j]] = group[j + 1]
207
+ }
208
+
209
+ console.log(`\n Group: ${groupObj.name}`)
210
+ console.log(` Consumers: ${groupObj.consumers}`)
211
+ console.log(` Pending: ${groupObj.pending}`)
212
+ console.log(` Last Delivered ID: ${groupObj['last-delivered-id']}`)
213
+
214
+ // 消费者详情
215
+ if (groupObj.consumers > 0) {
216
+ const consumers = await redis.xinfo('CONSUMERS', STREAM_KEY, groupObj.name)
217
+ console.log(' Consumer Details:')
218
+
219
+ for (let k = 0; k < consumers.length; k++) {
220
+ const consumer = consumers[k]
221
+ const consumerObj = {}
222
+ for (let l = 0; l < consumer.length; l += 2) {
223
+ consumerObj[consumer[l]] = consumer[l + 1]
224
+ }
225
+
226
+ console.log(` - ${consumerObj.name}`)
227
+ console.log(` Pending: ${consumerObj.pending}`)
228
+ console.log(` Idle: ${Math.round(consumerObj.idle / 1000)}s`)
229
+ }
230
+ }
231
+ }
232
+ }
233
+
234
+ // 最新 5 条消息
235
+ console.log('\n📬 Latest 5 Messages:')
236
+ const latest = await redis.xrevrange(STREAM_KEY, '+', '-', 'COUNT', 5)
237
+
238
+ if (latest.length === 0) {
239
+ console.log(' No messages')
240
+ } else {
241
+ for (const [messageId, fields] of latest) {
242
+ const data = {}
243
+ for (let i = 0; i < fields.length; i += 2) {
244
+ data[fields[i]] = fields[i + 1]
245
+ }
246
+
247
+ try {
248
+ const event = JSON.parse(data.data)
249
+ console.log(`\n ${messageId}`)
250
+ console.log(` Event ID: ${event.eventId}`)
251
+ console.log(` Model: ${event.usage.model}`)
252
+ console.log(` Cost: $${event.cost.total.toFixed(6)}`)
253
+ console.log(` Time: ${event.timestamp}`)
254
+ } catch (e) {
255
+ console.log(`\n ${messageId} (Parse Error)`)
256
+ }
257
+ }
258
+ }
259
+ } catch (error) {
260
+ console.error('❌ Failed to get queue info:', error.message)
261
+ process.exit(1)
262
+ }
263
+ }
264
+
265
+ /**
266
+ * 清空队列(危险操作)
267
+ */
268
+ async function clearQueue() {
269
+ console.log('⚠️ WARNING: This will delete all messages in the queue!')
270
+ console.log(` Stream: ${STREAM_KEY}`)
271
+
272
+ // 简单的确认机制
273
+ const readline = require('readline')
274
+ const rl = readline.createInterface({
275
+ input: process.stdin,
276
+ output: process.stdout
277
+ })
278
+
279
+ rl.question('Type "yes" to confirm: ', async (answer) => {
280
+ if (answer.toLowerCase() === 'yes') {
281
+ try {
282
+ await redis.del(STREAM_KEY)
283
+ console.log('✅ Queue cleared successfully')
284
+ } catch (error) {
285
+ console.error('❌ Failed to clear queue:', error.message)
286
+ }
287
+ } else {
288
+ console.log('❌ Operation cancelled')
289
+ }
290
+ rl.close()
291
+ redis.quit()
292
+ })
293
+ }
294
+
295
+ // ========================================
296
+ // CLI 处理
297
+ // ========================================
298
+
299
+ async function main() {
300
+ const command = process.argv[2] || 'info'
301
+
302
+ console.log('🔧 Billing Events Test Tool\n')
303
+
304
+ try {
305
+ switch (command) {
306
+ case 'publish':
307
+ await publishTestEvent()
308
+ break
309
+
310
+ case 'consume':
311
+ await consumeTestEvents()
312
+ break
313
+
314
+ case 'info':
315
+ await showQueueInfo()
316
+ break
317
+
318
+ case 'clear':
319
+ await clearQueue()
320
+ return // clearQueue 会自己关闭连接
321
+
322
+ default:
323
+ console.error(`❌ Unknown command: ${command}`)
324
+ console.log('\nAvailable commands:')
325
+ console.log(' publish - Publish a test event')
326
+ console.log(' consume - Consume events (test mode)')
327
+ console.log(' info - Show queue status')
328
+ console.log(' clear - Clear the queue (dangerous)')
329
+ process.exit(1)
330
+ }
331
+
332
+ await redis.quit()
333
+ } catch (error) {
334
+ console.error('💥 Fatal error:', error)
335
+ await redis.quit()
336
+ process.exit(1)
337
+ }
338
+ }
339
+
340
+ main()
scripts/update-model-pricing.js CHANGED
@@ -2,12 +2,14 @@
2
 
3
  /**
4
  * 手动更新模型价格数据脚本
5
- * 从 LiteLLM 仓库下载最新的模型价格和上下文窗口信息
6
  */
7
 
8
  const fs = require('fs')
9
  const path = require('path')
10
  const https = require('https')
 
 
11
 
12
  // 颜色输出
13
  const colors = {
@@ -32,8 +34,8 @@ const log = {
32
  const config = {
33
  dataDir: path.join(process.cwd(), 'data'),
34
  pricingFile: path.join(process.cwd(), 'data', 'model_pricing.json'),
35
- pricingUrl:
36
- 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json',
37
  fallbackFile: path.join(
38
  process.cwd(),
39
  'resources',
@@ -85,8 +87,8 @@ function restoreBackup() {
85
  // 下载价格数据
86
  function downloadPricingData() {
87
  return new Promise((resolve, reject) => {
88
- log.info('Downloading model pricing data from LiteLLM...')
89
- log.info(`URL: ${config.pricingUrl}`)
90
 
91
  const request = https.get(config.pricingUrl, (response) => {
92
  if (response.statusCode !== 200) {
@@ -115,7 +117,11 @@ function downloadPricingData() {
115
  }
116
 
117
  // 保存到文件
118
- fs.writeFileSync(config.pricingFile, JSON.stringify(jsonData, null, 2))
 
 
 
 
119
 
120
  const modelCount = Object.keys(jsonData).length
121
  const fileSize = Math.round(fs.statSync(config.pricingFile).size / 1024)
 
2
 
3
  /**
4
  * 手动更新模型价格数据脚本
5
+ * 从价格镜像分支下载最新的模型价格和上下文窗口信息
6
  */
7
 
8
  const fs = require('fs')
9
  const path = require('path')
10
  const https = require('https')
11
+ const crypto = require('crypto')
12
+ const pricingSource = require('../config/pricingSource')
13
 
14
  // 颜色输出
15
  const colors = {
 
34
  const config = {
35
  dataDir: path.join(process.cwd(), 'data'),
36
  pricingFile: path.join(process.cwd(), 'data', 'model_pricing.json'),
37
+ hashFile: path.join(process.cwd(), 'data', 'model_pricing.sha256'),
38
+ pricingUrl: pricingSource.pricingUrl,
39
  fallbackFile: path.join(
40
  process.cwd(),
41
  'resources',
 
87
  // 下载价格数据
88
  function downloadPricingData() {
89
  return new Promise((resolve, reject) => {
90
+ log.info('正在从价格镜像分支拉取最新的模型价格数据...')
91
+ log.info(`拉取地址: ${config.pricingUrl}`)
92
 
93
  const request = https.get(config.pricingUrl, (response) => {
94
  if (response.statusCode !== 200) {
 
117
  }
118
 
119
  // 保存到文件
120
+ const formattedJson = JSON.stringify(jsonData, null, 2)
121
+ fs.writeFileSync(config.pricingFile, formattedJson)
122
+
123
+ const hash = crypto.createHash('sha256').update(formattedJson).digest('hex')
124
+ fs.writeFileSync(config.hashFile, `${hash}\n`)
125
 
126
  const modelCount = Object.keys(jsonData).length
127
  const fileSize = Math.round(fs.statSync(config.pricingFile).size / 1024)
src/routes/openaiClaudeRoutes.js CHANGED
@@ -5,8 +5,6 @@
5
 
6
  const express = require('express')
7
  const router = express.Router()
8
- const fs = require('fs')
9
- const path = require('path')
10
  const logger = require('../utils/logger')
11
  const { authenticateApiKey } = require('../middleware/auth')
12
  const claudeRelayService = require('../services/claudeRelayService')
@@ -16,17 +14,7 @@ const unifiedClaudeScheduler = require('../services/unifiedClaudeScheduler')
16
  const claudeCodeHeadersService = require('../services/claudeCodeHeadersService')
17
  const sessionHelper = require('../utils/sessionHelper')
18
  const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
19
-
20
- // 加载模型定价数据
21
- let modelPricingData = {}
22
- try {
23
- const pricingPath = path.join(__dirname, '../../data/model_pricing.json')
24
- const pricingContent = fs.readFileSync(pricingPath, 'utf8')
25
- modelPricingData = JSON.parse(pricingContent)
26
- logger.info('✅ Model pricing data loaded successfully')
27
- } catch (error) {
28
- logger.error('❌ Failed to load model pricing data:', error)
29
- }
30
 
31
  // 🔧 辅助函数:检查 API Key 权限
32
  function checkPermissions(apiKeyData, requiredPermission = 'claude') {
@@ -140,7 +128,7 @@ router.get('/v1/models/:model', authenticateApiKey, async (req, res) => {
140
  }
141
 
142
  // 从 model_pricing.json 获取模型信息
143
- const modelData = modelPricingData[modelId]
144
 
145
  // 构建标准 OpenAI 格式的模型响应
146
  let modelInfo
 
5
 
6
  const express = require('express')
7
  const router = express.Router()
 
 
8
  const logger = require('../utils/logger')
9
  const { authenticateApiKey } = require('../middleware/auth')
10
  const claudeRelayService = require('../services/claudeRelayService')
 
14
  const claudeCodeHeadersService = require('../services/claudeCodeHeadersService')
15
  const sessionHelper = require('../utils/sessionHelper')
16
  const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
17
+ const pricingService = require('../services/pricingService')
 
 
 
 
 
 
 
 
 
 
18
 
19
  // 🔧 辅助函数:检查 API Key 权限
20
  function checkPermissions(apiKeyData, requiredPermission = 'claude') {
 
128
  }
129
 
130
  // 从 model_pricing.json 获取模型信息
131
+ const modelData = pricingService.getModelPricing(modelId)
132
 
133
  // 构建标准 OpenAI 格式的模型响应
134
  let modelInfo
src/services/apiKeyService.js CHANGED
@@ -1125,11 +1125,53 @@ class ApiKeyService {
1125
  logParts.push(`Total: ${totalTokens} tokens`)
1126
 
1127
  logger.database(`📊 Recorded usage: ${keyId} - ${logParts.join(', ')}`)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1128
  } catch (error) {
1129
  logger.error('❌ Failed to record usage:', error)
1130
  }
1131
  }
1132
 
 
 
 
 
 
 
 
 
 
 
 
1133
  // 🔐 生成密钥
1134
  _generateSecretKey() {
1135
  return crypto.randomBytes(32).toString('hex')
 
1125
  logParts.push(`Total: ${totalTokens} tokens`)
1126
 
1127
  logger.database(`📊 Recorded usage: ${keyId} - ${logParts.join(', ')}`)
1128
+
1129
+ // 🔔 发布计费事件到消息队列(异步非阻塞)
1130
+ this._publishBillingEvent({
1131
+ keyId,
1132
+ keyName: keyData?.name,
1133
+ userId: keyData?.userId,
1134
+ model,
1135
+ inputTokens,
1136
+ outputTokens,
1137
+ cacheCreateTokens,
1138
+ cacheReadTokens,
1139
+ ephemeral5mTokens,
1140
+ ephemeral1hTokens,
1141
+ totalTokens,
1142
+ cost: costInfo.totalCost || 0,
1143
+ costBreakdown: {
1144
+ input: costInfo.inputCost || 0,
1145
+ output: costInfo.outputCost || 0,
1146
+ cacheCreate: costInfo.cacheCreateCost || 0,
1147
+ cacheRead: costInfo.cacheReadCost || 0,
1148
+ ephemeral5m: costInfo.ephemeral5mCost || 0,
1149
+ ephemeral1h: costInfo.ephemeral1hCost || 0
1150
+ },
1151
+ accountId,
1152
+ accountType,
1153
+ isLongContext: costInfo.isLongContextRequest || false,
1154
+ requestTimestamp: usageRecord.timestamp
1155
+ }).catch((err) => {
1156
+ // 发布失败不影响主流程,只记录错误
1157
+ logger.warn('⚠️ Failed to publish billing event:', err.message)
1158
+ })
1159
  } catch (error) {
1160
  logger.error('❌ Failed to record usage:', error)
1161
  }
1162
  }
1163
 
1164
+ // 🔔 发布计费事件(内部方法)
1165
+ async _publishBillingEvent(eventData) {
1166
+ try {
1167
+ const billingEventPublisher = require('./billingEventPublisher')
1168
+ await billingEventPublisher.publishBillingEvent(eventData)
1169
+ } catch (error) {
1170
+ // 静默失败,不影响主流程
1171
+ logger.debug('Failed to publish billing event:', error.message)
1172
+ }
1173
+ }
1174
+
1175
  // 🔐 生成密钥
1176
  _generateSecretKey() {
1177
  return crypto.randomBytes(32).toString('hex')
src/services/billingEventPublisher.js ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const redis = require('../models/redis')
2
+ const logger = require('../utils/logger')
3
+
4
+ /**
5
+ * 计费事件发布器 - 使用 Redis Stream 解耦计费系统
6
+ *
7
+ * 设计原则:
8
+ * 1. 异步非阻塞: 发布失败不影响主流程
9
+ * 2. 结构化数据: 使用标准化的事件格式
10
+ * 3. 可追溯性: 每个事件包含完整上下文
11
+ */
12
+ class BillingEventPublisher {
13
+ constructor() {
14
+ this.streamKey = 'billing:events'
15
+ this.maxLength = 100000 // 保留最近 10 万条事件
16
+ this.enabled = process.env.BILLING_EVENTS_ENABLED !== 'false' // 默认开启
17
+ }
18
+
19
+ /**
20
+ * 发布计费事件
21
+ * @param {Object} eventData - 事件数据
22
+ * @returns {Promise<string|null>} - 事件ID 或 null
23
+ */
24
+ async publishBillingEvent(eventData) {
25
+ if (!this.enabled) {
26
+ logger.debug('📭 Billing events disabled, skipping publish')
27
+ return null
28
+ }
29
+
30
+ try {
31
+ const client = redis.getClientSafe()
32
+
33
+ // 构建标准化事件
34
+ const event = {
35
+ // 事件元数据
36
+ eventId: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
37
+ eventType: 'usage.recorded',
38
+ timestamp: new Date().toISOString(),
39
+ version: '1.0',
40
+
41
+ // 核心计费数据
42
+ apiKey: {
43
+ id: eventData.keyId,
44
+ name: eventData.keyName || null,
45
+ userId: eventData.userId || null
46
+ },
47
+
48
+ // 使用量详情
49
+ usage: {
50
+ model: eventData.model,
51
+ inputTokens: eventData.inputTokens || 0,
52
+ outputTokens: eventData.outputTokens || 0,
53
+ cacheCreateTokens: eventData.cacheCreateTokens || 0,
54
+ cacheReadTokens: eventData.cacheReadTokens || 0,
55
+ ephemeral5mTokens: eventData.ephemeral5mTokens || 0,
56
+ ephemeral1hTokens: eventData.ephemeral1hTokens || 0,
57
+ totalTokens: eventData.totalTokens || 0
58
+ },
59
+
60
+ // 费用详情
61
+ cost: {
62
+ total: eventData.cost || 0,
63
+ currency: 'USD',
64
+ breakdown: {
65
+ input: eventData.costBreakdown?.input || 0,
66
+ output: eventData.costBreakdown?.output || 0,
67
+ cacheCreate: eventData.costBreakdown?.cacheCreate || 0,
68
+ cacheRead: eventData.costBreakdown?.cacheRead || 0,
69
+ ephemeral5m: eventData.costBreakdown?.ephemeral5m || 0,
70
+ ephemeral1h: eventData.costBreakdown?.ephemeral1h || 0
71
+ }
72
+ },
73
+
74
+ // 账户信息
75
+ account: {
76
+ id: eventData.accountId || null,
77
+ type: eventData.accountType || null
78
+ },
79
+
80
+ // 请求上下文
81
+ context: {
82
+ isLongContext: eventData.isLongContext || false,
83
+ requestTimestamp: eventData.requestTimestamp || new Date().toISOString()
84
+ }
85
+ }
86
+
87
+ // 使用 XADD 发布事件到 Stream
88
+ // MAXLEN ~ 10000: 近似截断,保持性能
89
+ const messageId = await client.xadd(
90
+ this.streamKey,
91
+ 'MAXLEN',
92
+ '~',
93
+ this.maxLength,
94
+ '*', // 自动生成消息ID
95
+ 'data',
96
+ JSON.stringify(event)
97
+ )
98
+
99
+ logger.debug(
100
+ `📤 Published billing event: ${messageId} | Key: ${eventData.keyId} | Cost: $${event.cost.total.toFixed(6)}`
101
+ )
102
+
103
+ return messageId
104
+ } catch (error) {
105
+ // ⚠️ 发布失败不影响主流程,只记录错误
106
+ logger.error('❌ Failed to publish billing event:', error)
107
+ return null
108
+ }
109
+ }
110
+
111
+ /**
112
+ * 批量发布计费事件(优化性能)
113
+ * @param {Array<Object>} events - 事件数组
114
+ * @returns {Promise<number>} - 成功发布的事件数
115
+ */
116
+ async publishBatchBillingEvents(events) {
117
+ if (!this.enabled || !events || events.length === 0) {
118
+ return 0
119
+ }
120
+
121
+ try {
122
+ const client = redis.getClientSafe()
123
+ const pipeline = client.pipeline()
124
+
125
+ events.forEach((eventData) => {
126
+ const event = {
127
+ eventId: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
128
+ eventType: 'usage.recorded',
129
+ timestamp: new Date().toISOString(),
130
+ version: '1.0',
131
+ apiKey: {
132
+ id: eventData.keyId,
133
+ name: eventData.keyName || null
134
+ },
135
+ usage: {
136
+ model: eventData.model,
137
+ inputTokens: eventData.inputTokens || 0,
138
+ outputTokens: eventData.outputTokens || 0,
139
+ totalTokens: eventData.totalTokens || 0
140
+ },
141
+ cost: {
142
+ total: eventData.cost || 0,
143
+ currency: 'USD'
144
+ }
145
+ }
146
+
147
+ pipeline.xadd(
148
+ this.streamKey,
149
+ 'MAXLEN',
150
+ '~',
151
+ this.maxLength,
152
+ '*',
153
+ 'data',
154
+ JSON.stringify(event)
155
+ )
156
+ })
157
+
158
+ const results = await pipeline.exec()
159
+ const successCount = results.filter((r) => r[0] === null).length
160
+
161
+ logger.info(`📤 Batch published ${successCount}/${events.length} billing events`)
162
+ return successCount
163
+ } catch (error) {
164
+ logger.error('❌ Failed to batch publish billing events:', error)
165
+ return 0
166
+ }
167
+ }
168
+
169
+ /**
170
+ * 获取 Stream 信息(用于监控)
171
+ * @returns {Promise<Object>}
172
+ */
173
+ async getStreamInfo() {
174
+ try {
175
+ const client = redis.getClientSafe()
176
+ const info = await client.xinfo('STREAM', this.streamKey)
177
+
178
+ // 解析 Redis XINFO 返回的数组格式
179
+ const result = {}
180
+ for (let i = 0; i < info.length; i += 2) {
181
+ result[info[i]] = info[i + 1]
182
+ }
183
+
184
+ return {
185
+ length: result.length || 0,
186
+ firstEntry: result['first-entry'] || null,
187
+ lastEntry: result['last-entry'] || null,
188
+ groups: result.groups || 0
189
+ }
190
+ } catch (error) {
191
+ if (error.message.includes('no such key')) {
192
+ return { length: 0, groups: 0 }
193
+ }
194
+ logger.error('❌ Failed to get stream info:', error)
195
+ return null
196
+ }
197
+ }
198
+
199
+ /**
200
+ * 创建消费者组(供外部计费系统使用)
201
+ * @param {string} groupName - 消费者组名称
202
+ * @returns {Promise<boolean>}
203
+ */
204
+ async createConsumerGroup(groupName = 'billing-system') {
205
+ try {
206
+ const client = redis.getClientSafe()
207
+
208
+ // MKSTREAM: 如果 stream 不存在则创建
209
+ await client.xgroup('CREATE', this.streamKey, groupName, '0', 'MKSTREAM')
210
+
211
+ logger.success(`✅ Created consumer group: ${groupName}`)
212
+ return true
213
+ } catch (error) {
214
+ if (error.message.includes('BUSYGROUP')) {
215
+ logger.debug(`Consumer group ${groupName} already exists`)
216
+ return true
217
+ }
218
+ logger.error(`❌ Failed to create consumer group ${groupName}:`, error)
219
+ return false
220
+ }
221
+ }
222
+ }
223
+
224
+ module.exports = new BillingEventPublisher()
src/services/ccrAccountService.js CHANGED
@@ -563,8 +563,21 @@ class CcrAccountService {
563
  if (!modelMapping || Object.keys(modelMapping).length === 0) {
564
  return true
565
  }
566
- // 检查请求的模型是否在映射表的键中
567
- return Object.prototype.hasOwnProperty.call(modelMapping, requestedModel)
 
 
 
 
 
 
 
 
 
 
 
 
 
568
  }
569
 
570
  // 🔄 获取映射后的模型名称
@@ -574,8 +587,21 @@ class CcrAccountService {
574
  return requestedModel
575
  }
576
 
577
- // 返回映射后的模型名,如果不存在映射则返回原模型名
578
- return modelMapping[requestedModel] || requestedModel
 
 
 
 
 
 
 
 
 
 
 
 
 
579
  }
580
 
581
  // 🔐 加密敏感数据
 
563
  if (!modelMapping || Object.keys(modelMapping).length === 0) {
564
  return true
565
  }
566
+
567
+ // 检查请求的模型是否在映射表的键中(精确匹配)
568
+ if (Object.prototype.hasOwnProperty.call(modelMapping, requestedModel)) {
569
+ return true
570
+ }
571
+
572
+ // 尝试大小写不敏感匹配
573
+ const requestedModelLower = requestedModel.toLowerCase()
574
+ for (const key of Object.keys(modelMapping)) {
575
+ if (key.toLowerCase() === requestedModelLower) {
576
+ return true
577
+ }
578
+ }
579
+
580
+ return false
581
  }
582
 
583
  // 🔄 获取映射后的模型名称
 
587
  return requestedModel
588
  }
589
 
590
+ // 精确匹配
591
+ if (modelMapping[requestedModel]) {
592
+ return modelMapping[requestedModel]
593
+ }
594
+
595
+ // 大小写不敏感匹配
596
+ const requestedModelLower = requestedModel.toLowerCase()
597
+ for (const [key, value] of Object.entries(modelMapping)) {
598
+ if (key.toLowerCase() === requestedModelLower) {
599
+ return value
600
+ }
601
+ }
602
+
603
+ // 如果不存在映射则返回原模型名
604
+ return requestedModel
605
  }
606
 
607
  // 🔐 加密敏感数据
src/services/claudeConsoleAccountService.js CHANGED
@@ -149,6 +149,12 @@ class ClaudeConsoleAccountService {
149
  for (const key of keys) {
150
  const accountData = await client.hgetall(key)
151
  if (accountData && Object.keys(accountData).length > 0) {
 
 
 
 
 
 
152
  // 获取限流状态信息
153
  const rateLimitInfo = this._getRateLimitInfo(accountData)
154
 
@@ -990,8 +996,20 @@ class ClaudeConsoleAccountService {
990
  return true
991
  }
992
 
993
- // 检查请求的模型是否在映射表的键中
994
- return Object.prototype.hasOwnProperty.call(modelMapping, requestedModel)
 
 
 
 
 
 
 
 
 
 
 
 
995
  }
996
 
997
  // 🔄 获取映射后的模型名称
@@ -1001,8 +1019,21 @@ class ClaudeConsoleAccountService {
1001
  return requestedModel
1002
  }
1003
 
1004
- // 返回映射后的模型,如果不存在则返回原模型
1005
- return modelMapping[requestedModel] || requestedModel
 
 
 
 
 
 
 
 
 
 
 
 
 
1006
  }
1007
 
1008
  // 💰 检查账户使用额度(基于实时统计数据)
 
149
  for (const key of keys) {
150
  const accountData = await client.hgetall(key)
151
  if (accountData && Object.keys(accountData).length > 0) {
152
+ if (!accountData.id) {
153
+ logger.warn(`⚠️ 检测到缺少ID的Claude Console账户数据,执行清理: ${key}`)
154
+ await client.del(key)
155
+ continue
156
+ }
157
+
158
  // 获取限流状态信息
159
  const rateLimitInfo = this._getRateLimitInfo(accountData)
160
 
 
996
  return true
997
  }
998
 
999
+ // 检查请求的模型是否在映射表的键中(精确匹配)
1000
+ if (Object.prototype.hasOwnProperty.call(modelMapping, requestedModel)) {
1001
+ return true
1002
+ }
1003
+
1004
+ // 尝试大小写不敏感匹配
1005
+ const requestedModelLower = requestedModel.toLowerCase()
1006
+ for (const key of Object.keys(modelMapping)) {
1007
+ if (key.toLowerCase() === requestedModelLower) {
1008
+ return true
1009
+ }
1010
+ }
1011
+
1012
+ return false
1013
  }
1014
 
1015
  // 🔄 获取映射后的模型名称
 
1019
  return requestedModel
1020
  }
1021
 
1022
+ // 精确匹配
1023
+ if (modelMapping[requestedModel]) {
1024
+ return modelMapping[requestedModel]
1025
+ }
1026
+
1027
+ // 大小写不敏感匹配
1028
+ const requestedModelLower = requestedModel.toLowerCase()
1029
+ for (const [key, value] of Object.entries(modelMapping)) {
1030
+ if (key.toLowerCase() === requestedModelLower) {
1031
+ return value
1032
+ }
1033
+ }
1034
+
1035
+ // 如果不存在则返回原模型
1036
+ return requestedModel
1037
  }
1038
 
1039
  // 💰 检查账户使用额度(基于实时统计数据)
src/services/claudeConsoleRelayService.js CHANGED
@@ -774,11 +774,15 @@ class ClaudeConsoleRelayService {
774
  async _updateLastUsedTime(accountId) {
775
  try {
776
  const client = require('../models/redis').getClientSafe()
777
- await client.hset(
778
- `claude_console_account:${accountId}`,
779
- 'lastUsedAt',
780
- new Date().toISOString()
781
- )
 
 
 
 
782
  } catch (error) {
783
  logger.warn(
784
  `⚠️ Failed to update last used time for Claude Console account ${accountId}:`,
 
774
  async _updateLastUsedTime(accountId) {
775
  try {
776
  const client = require('../models/redis').getClientSafe()
777
+ const accountKey = `claude_console_account:${accountId}`
778
+ const exists = await client.exists(accountKey)
779
+
780
+ if (!exists) {
781
+ logger.debug(`🔎 跳过更新已删除的Claude Console账号最近使用时间: ${accountId}`)
782
+ return
783
+ }
784
+
785
+ await client.hset(accountKey, 'lastUsedAt', new Date().toISOString())
786
  } catch (error) {
787
  logger.warn(
788
  `⚠️ Failed to update last used time for Claude Console account ${accountId}:`,
src/services/pricingService.js CHANGED
@@ -1,25 +1,32 @@
1
  const fs = require('fs')
2
  const path = require('path')
3
  const https = require('https')
 
 
4
  const logger = require('../utils/logger')
5
 
6
  class PricingService {
7
  constructor() {
8
  this.dataDir = path.join(process.cwd(), 'data')
9
  this.pricingFile = path.join(this.dataDir, 'model_pricing.json')
10
- this.pricingUrl =
11
- 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json'
12
  this.fallbackFile = path.join(
13
  process.cwd(),
14
  'resources',
15
  'model-pricing',
16
  'model_prices_and_context_window.json'
17
  )
 
18
  this.pricingData = null
19
  this.lastUpdated = null
20
  this.updateInterval = 24 * 60 * 60 * 1000 // 24小时
 
21
  this.fileWatcher = null // 文件监听器
22
  this.reloadDebounceTimer = null // 防抖定时器
 
 
 
23
 
24
  // 硬编码的 1 小时缓存价格(美元/百万 token)
25
  // ephemeral_5m 的价格使用 model_pricing.json 中的 cache_creation_input_token_cost
@@ -81,11 +88,20 @@ class PricingService {
81
  // 检查是否需要下载或更新价格数据
82
  await this.checkAndUpdatePricing()
83
 
 
 
 
84
  // 设置定时更新
85
- setInterval(() => {
 
 
 
86
  this.checkAndUpdatePricing()
87
  }, this.updateInterval)
88
 
 
 
 
89
  // 设置文件监听器
90
  this.setupFileWatcher()
91
 
@@ -145,6 +161,114 @@ class PricingService {
145
  }
146
  }
147
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  // 实际的下载逻辑
149
  _downloadFromRemote() {
150
  return new Promise((resolve, reject) => {
@@ -154,17 +278,21 @@ class PricingService {
154
  return
155
  }
156
 
157
- let data = ''
158
  response.on('data', (chunk) => {
159
- data += chunk
 
160
  })
161
 
162
  response.on('end', () => {
163
  try {
164
- const jsonData = JSON.parse(data)
 
 
165
 
166
- // 保存到文件
167
- fs.writeFileSync(this.pricingFile, JSON.stringify(jsonData, null, 2))
 
168
 
169
  // 更新内存中的数据
170
  this.pricingData = jsonData
@@ -226,8 +354,11 @@ class PricingService {
226
  const fallbackData = fs.readFileSync(this.fallbackFile, 'utf8')
227
  const jsonData = JSON.parse(fallbackData)
228
 
 
 
229
  // 保存到data目录
230
- fs.writeFileSync(this.pricingFile, JSON.stringify(jsonData, null, 2))
 
231
 
232
  // 更新内存中的数据
233
  this.pricingData = jsonData
@@ -649,6 +780,11 @@ class PricingService {
649
 
650
  // 清理资源
651
  cleanup() {
 
 
 
 
 
652
  if (this.fileWatcher) {
653
  this.fileWatcher.close()
654
  this.fileWatcher = null
@@ -658,6 +794,11 @@ class PricingService {
658
  clearTimeout(this.reloadDebounceTimer)
659
  this.reloadDebounceTimer = null
660
  }
 
 
 
 
 
661
  }
662
  }
663
 
 
1
  const fs = require('fs')
2
  const path = require('path')
3
  const https = require('https')
4
+ const crypto = require('crypto')
5
+ const pricingSource = require('../../config/pricingSource')
6
  const logger = require('../utils/logger')
7
 
8
  class PricingService {
9
  constructor() {
10
  this.dataDir = path.join(process.cwd(), 'data')
11
  this.pricingFile = path.join(this.dataDir, 'model_pricing.json')
12
+ this.pricingUrl = pricingSource.pricingUrl
13
+ this.hashUrl = pricingSource.hashUrl
14
  this.fallbackFile = path.join(
15
  process.cwd(),
16
  'resources',
17
  'model-pricing',
18
  'model_prices_and_context_window.json'
19
  )
20
+ this.localHashFile = path.join(this.dataDir, 'model_pricing.sha256')
21
  this.pricingData = null
22
  this.lastUpdated = null
23
  this.updateInterval = 24 * 60 * 60 * 1000 // 24小时
24
+ this.hashCheckInterval = 10 * 60 * 1000 // 10分钟哈希校验
25
  this.fileWatcher = null // 文件监听器
26
  this.reloadDebounceTimer = null // 防抖定时器
27
+ this.hashCheckTimer = null // 哈希轮询定时器
28
+ this.updateTimer = null // 定时更新任务句柄
29
+ this.hashSyncInProgress = false // 哈希同步状态
30
 
31
  // 硬编码的 1 小时缓存价格(美元/百万 token)
32
  // ephemeral_5m 的价格使用 model_pricing.json 中的 cache_creation_input_token_cost
 
88
  // 检查是否需要下载或更新价格数据
89
  await this.checkAndUpdatePricing()
90
 
91
+ // 初次启动时执行一次哈希校验,确保与远端保持一致
92
+ await this.syncWithRemoteHash()
93
+
94
  // 设置定时更新
95
+ if (this.updateTimer) {
96
+ clearInterval(this.updateTimer)
97
+ }
98
+ this.updateTimer = setInterval(() => {
99
  this.checkAndUpdatePricing()
100
  }, this.updateInterval)
101
 
102
+ // 设置哈希轮询
103
+ this.setupHashCheck()
104
+
105
  // 设置文件监听器
106
  this.setupFileWatcher()
107
 
 
161
  }
162
  }
163
 
164
+ // 哈希轮询设置
165
+ setupHashCheck() {
166
+ if (this.hashCheckTimer) {
167
+ clearInterval(this.hashCheckTimer)
168
+ }
169
+
170
+ this.hashCheckTimer = setInterval(() => {
171
+ this.syncWithRemoteHash()
172
+ }, this.hashCheckInterval)
173
+
174
+ logger.info('🕒 已启用价格文件哈希轮询(每10分钟校验一次)')
175
+ }
176
+
177
+ // 与远端哈希对比
178
+ async syncWithRemoteHash() {
179
+ if (this.hashSyncInProgress) {
180
+ return
181
+ }
182
+
183
+ this.hashSyncInProgress = true
184
+ try {
185
+ const remoteHash = await this.fetchRemoteHash()
186
+
187
+ if (!remoteHash) {
188
+ return
189
+ }
190
+
191
+ const localHash = this.computeLocalHash()
192
+
193
+ if (!localHash) {
194
+ logger.info('📄 本地价格文件缺失,尝试下载最新版本')
195
+ await this.downloadPricingData()
196
+ return
197
+ }
198
+
199
+ if (remoteHash !== localHash) {
200
+ logger.info('🔁 检测到远端价格文件更新,开始下载最新数据')
201
+ await this.downloadPricingData()
202
+ }
203
+ } catch (error) {
204
+ logger.warn(`⚠️ 哈希校验失败:${error.message}`)
205
+ } finally {
206
+ this.hashSyncInProgress = false
207
+ }
208
+ }
209
+
210
+ // 获取远端哈希值
211
+ fetchRemoteHash() {
212
+ return new Promise((resolve, reject) => {
213
+ const request = https.get(this.hashUrl, (response) => {
214
+ if (response.statusCode !== 200) {
215
+ reject(new Error(`哈希文件获取失败:HTTP ${response.statusCode}`))
216
+ return
217
+ }
218
+
219
+ let data = ''
220
+ response.on('data', (chunk) => {
221
+ data += chunk
222
+ })
223
+
224
+ response.on('end', () => {
225
+ const hash = data.trim().split(/\s+/)[0]
226
+
227
+ if (!hash) {
228
+ reject(new Error('哈希文件内容为空'))
229
+ return
230
+ }
231
+
232
+ resolve(hash)
233
+ })
234
+ })
235
+
236
+ request.on('error', (error) => {
237
+ reject(new Error(`网络错误:${error.message}`))
238
+ })
239
+
240
+ request.setTimeout(30000, () => {
241
+ request.destroy()
242
+ reject(new Error('获取哈希超时(30秒)'))
243
+ })
244
+ })
245
+ }
246
+
247
+ // 计算本地文件哈希
248
+ computeLocalHash() {
249
+ if (!fs.existsSync(this.pricingFile)) {
250
+ return null
251
+ }
252
+
253
+ if (fs.existsSync(this.localHashFile)) {
254
+ const cached = fs.readFileSync(this.localHashFile, 'utf8').trim()
255
+ if (cached) {
256
+ return cached
257
+ }
258
+ }
259
+
260
+ const fileBuffer = fs.readFileSync(this.pricingFile)
261
+ return this.persistLocalHash(fileBuffer)
262
+ }
263
+
264
+ // 写入本地哈希文件
265
+ persistLocalHash(content) {
266
+ const buffer = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8')
267
+ const hash = crypto.createHash('sha256').update(buffer).digest('hex')
268
+ fs.writeFileSync(this.localHashFile, `${hash}\n`)
269
+ return hash
270
+ }
271
+
272
  // 实际的下载逻辑
273
  _downloadFromRemote() {
274
  return new Promise((resolve, reject) => {
 
278
  return
279
  }
280
 
281
+ const chunks = []
282
  response.on('data', (chunk) => {
283
+ const bufferChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
284
+ chunks.push(bufferChunk)
285
  })
286
 
287
  response.on('end', () => {
288
  try {
289
+ const buffer = Buffer.concat(chunks)
290
+ const rawContent = buffer.toString('utf8')
291
+ const jsonData = JSON.parse(rawContent)
292
 
293
+ // 保存到文件并更新哈希
294
+ fs.writeFileSync(this.pricingFile, rawContent)
295
+ this.persistLocalHash(buffer)
296
 
297
  // 更新内存中的数据
298
  this.pricingData = jsonData
 
354
  const fallbackData = fs.readFileSync(this.fallbackFile, 'utf8')
355
  const jsonData = JSON.parse(fallbackData)
356
 
357
+ const formattedJson = JSON.stringify(jsonData, null, 2)
358
+
359
  // 保存到data目录
360
+ fs.writeFileSync(this.pricingFile, formattedJson)
361
+ this.persistLocalHash(formattedJson)
362
 
363
  // 更新内存中的数据
364
  this.pricingData = jsonData
 
780
 
781
  // 清理资源
782
  cleanup() {
783
+ if (this.updateTimer) {
784
+ clearInterval(this.updateTimer)
785
+ this.updateTimer = null
786
+ logger.debug('💰 Pricing update timer cleared')
787
+ }
788
  if (this.fileWatcher) {
789
  this.fileWatcher.close()
790
  this.fileWatcher = null
 
794
  clearTimeout(this.reloadDebounceTimer)
795
  this.reloadDebounceTimer = null
796
  }
797
+ if (this.hashCheckTimer) {
798
+ clearInterval(this.hashCheckTimer)
799
+ this.hashCheckTimer = null
800
+ logger.debug('💰 Hash check timer cleared')
801
+ }
802
  }
803
  }
804
 
web/admin-spa/src/components/accounts/AccountForm.vue CHANGED
@@ -1276,6 +1276,15 @@
1276
  >
1277
  + Sonnet 4
1278
  </button>
 
 
 
 
 
 
 
 
 
1279
  <button
1280
  class="rounded-lg bg-purple-100 px-3 py-1 text-xs text-purple-700 transition-colors hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400 dark:hover:bg-purple-900/50"
1281
  type="button"
@@ -1294,9 +1303,46 @@
1294
  >
1295
  + Haiku 3.5
1296
  </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1297
  <button
1298
  class="rounded-lg bg-orange-100 px-3 py-1 text-xs text-orange-700 transition-colors hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400 dark:hover:bg-orange-900/50"
1299
  type="button"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1300
  @click="
1301
  addPresetMapping('claude-opus-4-1-20250805', 'claude-sonnet-4-20250514')
1302
  "
@@ -2628,6 +2674,15 @@
2628
  >
2629
  + Sonnet 4
2630
  </button>
 
 
 
 
 
 
 
 
 
2631
  <button
2632
  class="rounded-lg bg-purple-100 px-3 py-1 text-xs text-purple-700 transition-colors hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400 dark:hover:bg-purple-900/50"
2633
  type="button"
@@ -2646,9 +2701,46 @@
2646
  >
2647
  + Haiku 3.5
2648
  </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2649
  <button
2650
  class="rounded-lg bg-orange-100 px-3 py-1 text-xs text-orange-700 transition-colors hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400 dark:hover:bg-orange-900/50"
2651
  type="button"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2652
  @click="
2653
  addPresetMapping('claude-opus-4-1-20250805', 'claude-sonnet-4-20250514')
2654
  "
@@ -3494,9 +3586,13 @@ const commonModels = [
3494
  { value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4', color: 'blue' },
3495
  { value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5', color: 'indigo' },
3496
  { value: 'claude-3-5-haiku-20241022', label: 'Claude 3.5 Haiku', color: 'green' },
 
3497
  { value: 'claude-opus-4-20250514', label: 'Claude Opus 4', color: 'purple' },
3498
  { value: 'claude-opus-4-1-20250805', label: 'Claude Opus 4.1', color: 'purple' },
3499
- { value: 'deepseek-chat', label: 'DeepSeek Chat', color: 'cyan' }
 
 
 
3500
  ]
3501
 
3502
  // 模型映射表数据
 
1276
  >
1277
  + Sonnet 4
1278
  </button>
1279
+ <button
1280
+ class="rounded-lg bg-indigo-100 px-3 py-1 text-xs text-indigo-700 transition-colors hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400 dark:hover:bg-indigo-900/50"
1281
+ type="button"
1282
+ @click="
1283
+ addPresetMapping('claude-sonnet-4-5-20250929', 'claude-sonnet-4-5-20250929')
1284
+ "
1285
+ >
1286
+ + Sonnet 4.5
1287
+ </button>
1288
  <button
1289
  class="rounded-lg bg-purple-100 px-3 py-1 text-xs text-purple-700 transition-colors hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400 dark:hover:bg-purple-900/50"
1290
  type="button"
 
1303
  >
1304
  + Haiku 3.5
1305
  </button>
1306
+ <button
1307
+ class="rounded-lg bg-emerald-100 px-3 py-1 text-xs text-emerald-700 transition-colors hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400 dark:hover:bg-emerald-900/50"
1308
+ type="button"
1309
+ @click="
1310
+ addPresetMapping('claude-haiku-4-5-20251001', 'claude-haiku-4-5-20251001')
1311
+ "
1312
+ >
1313
+ + Haiku 4.5
1314
+ </button>
1315
+ <button
1316
+ class="rounded-lg bg-cyan-100 px-3 py-1 text-xs text-cyan-700 transition-colors hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400 dark:hover:bg-cyan-900/50"
1317
+ type="button"
1318
+ @click="addPresetMapping('deepseek-chat', 'deepseek-chat')"
1319
+ >
1320
+ + DeepSeek
1321
+ </button>
1322
  <button
1323
  class="rounded-lg bg-orange-100 px-3 py-1 text-xs text-orange-700 transition-colors hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400 dark:hover:bg-orange-900/50"
1324
  type="button"
1325
+ @click="addPresetMapping('Qwen', 'Qwen')"
1326
+ >
1327
+ + Qwen
1328
+ </button>
1329
+ <button
1330
+ class="rounded-lg bg-pink-100 px-3 py-1 text-xs text-pink-700 transition-colors hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400 dark:hover:bg-pink-900/50"
1331
+ type="button"
1332
+ @click="addPresetMapping('Kimi', 'Kimi')"
1333
+ >
1334
+ + Kimi
1335
+ </button>
1336
+ <button
1337
+ class="rounded-lg bg-teal-100 px-3 py-1 text-xs text-teal-700 transition-colors hover:bg-teal-200 dark:bg-teal-900/30 dark:text-teal-400 dark:hover:bg-teal-900/50"
1338
+ type="button"
1339
+ @click="addPresetMapping('GLM', 'GLM')"
1340
+ >
1341
+ + GLM
1342
+ </button>
1343
+ <button
1344
+ class="rounded-lg bg-amber-100 px-3 py-1 text-xs text-amber-700 transition-colors hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400 dark:hover:bg-amber-900/50"
1345
+ type="button"
1346
  @click="
1347
  addPresetMapping('claude-opus-4-1-20250805', 'claude-sonnet-4-20250514')
1348
  "
 
2674
  >
2675
  + Sonnet 4
2676
  </button>
2677
+ <button
2678
+ class="rounded-lg bg-indigo-100 px-3 py-1 text-xs text-indigo-700 transition-colors hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400 dark:hover:bg-indigo-900/50"
2679
+ type="button"
2680
+ @click="
2681
+ addPresetMapping('claude-sonnet-4-5-20250929', 'claude-sonnet-4-5-20250929')
2682
+ "
2683
+ >
2684
+ + Sonnet 4.5
2685
+ </button>
2686
  <button
2687
  class="rounded-lg bg-purple-100 px-3 py-1 text-xs text-purple-700 transition-colors hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400 dark:hover:bg-purple-900/50"
2688
  type="button"
 
2701
  >
2702
  + Haiku 3.5
2703
  </button>
2704
+ <button
2705
+ class="rounded-lg bg-emerald-100 px-3 py-1 text-xs text-emerald-700 transition-colors hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400 dark:hover:bg-emerald-900/50"
2706
+ type="button"
2707
+ @click="
2708
+ addPresetMapping('claude-haiku-4-5-20251001', 'claude-haiku-4-5-20251001')
2709
+ "
2710
+ >
2711
+ + Haiku 4.5
2712
+ </button>
2713
+ <button
2714
+ class="rounded-lg bg-cyan-100 px-3 py-1 text-xs text-cyan-700 transition-colors hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400 dark:hover:bg-cyan-900/50"
2715
+ type="button"
2716
+ @click="addPresetMapping('deepseek-chat', 'deepseek-chat')"
2717
+ >
2718
+ + DeepSeek
2719
+ </button>
2720
  <button
2721
  class="rounded-lg bg-orange-100 px-3 py-1 text-xs text-orange-700 transition-colors hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400 dark:hover:bg-orange-900/50"
2722
  type="button"
2723
+ @click="addPresetMapping('Qwen', 'Qwen')"
2724
+ >
2725
+ + Qwen
2726
+ </button>
2727
+ <button
2728
+ class="rounded-lg bg-pink-100 px-3 py-1 text-xs text-pink-700 transition-colors hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400 dark:hover:bg-pink-900/50"
2729
+ type="button"
2730
+ @click="addPresetMapping('Kimi', 'Kimi')"
2731
+ >
2732
+ + Kimi
2733
+ </button>
2734
+ <button
2735
+ class="rounded-lg bg-teal-100 px-3 py-1 text-xs text-teal-700 transition-colors hover:bg-teal-200 dark:bg-teal-900/30 dark:text-teal-400 dark:hover:bg-teal-900/50"
2736
+ type="button"
2737
+ @click="addPresetMapping('GLM', 'GLM')"
2738
+ >
2739
+ + GLM
2740
+ </button>
2741
+ <button
2742
+ class="rounded-lg bg-amber-100 px-3 py-1 text-xs text-amber-700 transition-colors hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400 dark:hover:bg-amber-900/50"
2743
+ type="button"
2744
  @click="
2745
  addPresetMapping('claude-opus-4-1-20250805', 'claude-sonnet-4-20250514')
2746
  "
 
3586
  { value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4', color: 'blue' },
3587
  { value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5', color: 'indigo' },
3588
  { value: 'claude-3-5-haiku-20241022', label: 'Claude 3.5 Haiku', color: 'green' },
3589
+ { value: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5', color: 'emerald' },
3590
  { value: 'claude-opus-4-20250514', label: 'Claude Opus 4', color: 'purple' },
3591
  { value: 'claude-opus-4-1-20250805', label: 'Claude Opus 4.1', color: 'purple' },
3592
+ { value: 'deepseek-chat', label: 'DeepSeek Chat', color: 'cyan' },
3593
+ { value: 'Qwen', label: 'Qwen', color: 'orange' },
3594
+ { value: 'Kimi', label: 'Kimi', color: 'pink' },
3595
+ { value: 'GLM', label: 'GLM', color: 'teal' }
3596
  ]
3597
 
3598
  // 模型映射表数据
web/admin-spa/src/components/accounts/ApiKeyManagementModal.vue CHANGED
@@ -50,16 +50,173 @@
50
  <p class="text-gray-500 dark:text-gray-400">加载中...</p>
51
  </div>
52
 
53
- <!-- API Key 列表 -->
54
  <div
55
- v-else-if="apiKeys.length === 0"
56
  class="rounded-lg bg-gray-50 py-8 text-center dark:bg-gray-800"
57
  >
58
  <i class="fas fa-key mb-4 text-4xl text-gray-300 dark:text-gray-600" />
59
  <p class="text-gray-500 dark:text-gray-400">暂无 API Key</p>
60
  </div>
61
 
62
- <div v-else>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  <!-- API Key 网格布局 -->
64
  <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
65
  <div
@@ -112,22 +269,19 @@
112
  ? 'text-orange-500 hover:text-orange-700 dark:text-orange-400 dark:hover:text-orange-300'
113
  : 'text-yellow-500 hover:text-yellow-700 dark:text-yellow-400 dark:hover:text-yellow-300'
114
  ]"
115
- :disabled="resetting === getOriginalIndex(index)"
116
  title="重置状态"
117
- @click="resetApiKeyStatus(apiKey, getOriginalIndex(index))"
118
  >
119
- <div
120
- v-if="resetting === getOriginalIndex(index)"
121
- class="loading-spinner-sm"
122
- />
123
  <i v-else class="fas fa-redo"></i>
124
  </button>
125
  <button
126
  class="text-xs text-red-500 transition-colors hover:text-red-700 disabled:cursor-not-allowed disabled:opacity-50 dark:text-red-400 dark:hover:text-red-600"
127
- :disabled="deleting === getOriginalIndex(index)"
128
- @click="deleteApiKey(apiKey, getOriginalIndex(index))"
129
  >
130
- <div v-if="deleting === getOriginalIndex(index)" class="loading-spinner-sm" />
131
  <i v-else class="fas fa-trash" />
132
  </button>
133
  </div>
@@ -254,22 +408,67 @@ const deleting = ref(null)
254
  const resetting = ref(null)
255
  const apiKeys = ref([])
256
  const currentPage = ref(1)
257
- const pageSize = ref(18)
258
  const copyingAll = ref(false)
259
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
  // 计算属性
261
- const totalItems = computed(() => apiKeys.value.length)
262
  const totalPages = computed(() => Math.ceil(totalItems.value / pageSize.value))
263
  const paginatedApiKeys = computed(() => {
264
  const start = (currentPage.value - 1) * pageSize.value
265
  const end = start + pageSize.value
266
- return apiKeys.value.slice(start, end)
267
  })
268
 
269
- // 获取原始索引的方法
270
- const getOriginalIndex = (paginatedIndex) => {
271
- return (currentPage.value - 1) * pageSize.value + paginatedIndex
272
- }
 
 
 
 
273
 
274
  // 加载 API Keys
275
  const loadApiKeys = async () => {
@@ -349,12 +548,12 @@ const loadApiKeys = async () => {
349
  }
350
 
351
  // 删除 API Key
352
- const deleteApiKey = async (apiKey, index) => {
353
  if (!confirm(`确定要删除 API Key "${maskApiKey(apiKey.key)}" 吗?`)) {
354
  return
355
  }
356
 
357
- deleting.value = index
358
  try {
359
  // 准备更新数据:删除指定的 key
360
  const updateData = {
@@ -376,7 +575,7 @@ const deleteApiKey = async (apiKey, index) => {
376
  }
377
 
378
  // 重置 API Key 状态
379
- const resetApiKeyStatus = async (apiKey, index) => {
380
  if (
381
  !confirm(
382
  `确定要重置 API Key "${maskApiKey(apiKey.key)}" 的状态吗?这将清除错误信息并恢复为正常状态。`
@@ -385,7 +584,7 @@ const resetApiKeyStatus = async (apiKey, index) => {
385
  return
386
  }
387
 
388
- resetting.value = index
389
  try {
390
  // 准备更新数据:重置指定 key 的状态
391
  const updateData = {
@@ -412,12 +611,113 @@ const resetApiKeyStatus = async (apiKey, index) => {
412
  }
413
  }
414
 
415
- // 掩码显示 API Key
416
- const maskApiKey = (key) => {
417
- if (!key || key.length < 12) {
418
- return key
 
 
419
  }
420
- return `${key.substring(0, 8)}...${key.substring(key.length - 4)}`
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
421
  }
422
 
423
  // 写入剪贴板(带回退逻辑)
 
50
  <p class="text-gray-500 dark:text-gray-400">加载中...</p>
51
  </div>
52
 
53
+ <!-- 空状态:没有加载且没有 API Key -->
54
  <div
55
+ v-if="!loading && apiKeys.length === 0"
56
  class="rounded-lg bg-gray-50 py-8 text-center dark:bg-gray-800"
57
  >
58
  <i class="fas fa-key mb-4 text-4xl text-gray-300 dark:text-gray-600" />
59
  <p class="text-gray-500 dark:text-gray-400">暂无 API Key</p>
60
  </div>
61
 
62
+ <!-- 有 API Key 时显示菜单和列表 -->
63
+ <div v-if="!loading && apiKeys.length > 0">
64
+ <!-- 菜单栏 -->
65
+ <div class="mb-4 space-y-3">
66
+ <!-- 工具栏:筛选、搜索和操作 -->
67
+ <div
68
+ class="rounded-lg border border-gray-200 bg-white p-3 shadow-sm dark:border-gray-700 dark:bg-gray-800"
69
+ >
70
+ <!-- 第一行:筛选和搜索 -->
71
+ <div class="mb-3 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
72
+ <!-- 左侧:状态筛选 -->
73
+ <div class="flex items-center gap-2">
74
+ <i class="fas fa-filter text-gray-400 dark:text-gray-500" />
75
+ <span class="text-sm font-medium text-gray-700 dark:text-gray-300">筛选:</span>
76
+ <div class="flex gap-1">
77
+ <button
78
+ :class="[
79
+ 'rounded-md px-3 py-1.5 text-xs font-medium transition-colors',
80
+ statusFilter === 'all'
81
+ ? 'bg-purple-500 text-white shadow-sm'
82
+ : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600'
83
+ ]"
84
+ @click="statusFilter = 'all'"
85
+ >
86
+ 全部 ({{ apiKeys.length }})
87
+ </button>
88
+ <button
89
+ :class="[
90
+ 'rounded-md px-3 py-1.5 text-xs font-medium transition-colors',
91
+ statusFilter === 'active'
92
+ ? 'bg-green-500 text-white shadow-sm'
93
+ : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600'
94
+ ]"
95
+ @click="statusFilter = 'active'"
96
+ >
97
+ <i class="fas fa-check-circle mr-1" />
98
+ 正常 ({{ activeKeysCount }})
99
+ </button>
100
+ <button
101
+ :class="[
102
+ 'rounded-md px-3 py-1.5 text-xs font-medium transition-colors',
103
+ statusFilter === 'error'
104
+ ? 'bg-red-500 text-white shadow-sm'
105
+ : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600'
106
+ ]"
107
+ @click="statusFilter = 'error'"
108
+ >
109
+ <i class="fas fa-exclamation-triangle mr-1" />
110
+ 异常 ({{ errorKeysCount }})
111
+ </button>
112
+ </div>
113
+ </div>
114
+
115
+ <!-- 右侧:搜索框 -->
116
+ <div class="flex flex-1 items-center gap-2 lg:max-w-md">
117
+ <div class="relative flex-1">
118
+ <input
119
+ v-model="searchQuery"
120
+ class="w-full rounded-md border border-gray-300 bg-gray-50 py-2 pl-10 pr-3 text-sm text-gray-700 transition-colors placeholder:text-gray-400 focus:border-purple-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-purple-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:placeholder:text-gray-500 dark:focus:border-purple-400 dark:focus:bg-gray-800"
121
+ placeholder="搜索 API Key..."
122
+ type="text"
123
+ />
124
+ <i
125
+ class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500"
126
+ />
127
+ </div>
128
+ <div class="flex gap-1">
129
+ <button
130
+ :class="[
131
+ 'rounded-md px-2.5 py-2 text-xs font-medium transition-colors',
132
+ searchMode === 'fuzzy'
133
+ ? 'bg-purple-500 text-white shadow-sm'
134
+ : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600'
135
+ ]"
136
+ title="模糊搜索:包含查询字符串即可"
137
+ @click="searchMode = 'fuzzy'"
138
+ >
139
+ <i class="fas fa-search mr-1" />
140
+ 模糊
141
+ </button>
142
+ <button
143
+ :class="[
144
+ 'rounded-md px-2.5 py-2 text-xs font-medium transition-colors',
145
+ searchMode === 'exact'
146
+ ? 'bg-purple-500 text-white shadow-sm'
147
+ : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600'
148
+ ]"
149
+ title="精确搜索:完全匹配完整 Key"
150
+ @click="searchMode = 'exact'"
151
+ >
152
+ <i class="fas fa-equals mr-1" />
153
+ 精确
154
+ </button>
155
+ </div>
156
+ </div>
157
+ </div>
158
+
159
+ <!-- 分隔线 -->
160
+ <div class="my-3 border-t border-gray-200 dark:border-gray-700"></div>
161
+
162
+ <!-- 第二行:批量操作 -->
163
+ <div class="flex flex-wrap items-center justify-between gap-2">
164
+ <!-- 左侧:操作按钮 -->
165
+ <div class="flex flex-wrap items-center gap-2">
166
+ <span class="text-xs font-medium text-gray-500 dark:text-gray-400"
167
+ >批量操作:</span
168
+ >
169
+ <button
170
+ class="group rounded-md bg-gradient-to-r from-red-500 to-red-600 px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-all hover:from-red-600 hover:to-red-700 hover:shadow disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:shadow-sm"
171
+ :disabled="errorKeysCount === 0 || batchDeleting"
172
+ title="删除所有异常状态的 API Key"
173
+ @click="deleteAllErrorKeys"
174
+ >
175
+ <i class="fas fa-trash-alt mr-1" />
176
+ 删除异常
177
+ </button>
178
+ <button
179
+ class="group rounded-md bg-gradient-to-r from-red-600 to-red-700 px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-all hover:from-red-700 hover:to-red-800 hover:shadow disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:shadow-sm"
180
+ :disabled="apiKeys.length === 0 || batchDeleting"
181
+ title="删除所有 API Key"
182
+ @click="deleteAllKeys"
183
+ >
184
+ <i class="fas fa-trash mr-1" />
185
+ 删除全部
186
+ </button>
187
+ <div class="mx-1 h-5 w-px bg-gray-300 dark:bg-gray-600"></div>
188
+ <button
189
+ class="rounded-md bg-gradient-to-r from-blue-500 to-blue-600 px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-all hover:from-blue-600 hover:to-blue-700 hover:shadow disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:shadow-sm"
190
+ :disabled="errorKeysCount === 0"
191
+ title="导出所有异常状态的 API Key"
192
+ @click="exportKeys('error')"
193
+ >
194
+ <i class="fas fa-download mr-1" />
195
+ 导出异常
196
+ </button>
197
+ <button
198
+ class="rounded-md bg-gradient-to-r from-blue-600 to-blue-700 px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-all hover:from-blue-700 hover:to-blue-800 hover:shadow disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:shadow-sm"
199
+ :disabled="apiKeys.length === 0"
200
+ title="导出所有 API Key"
201
+ @click="exportKeys('all')"
202
+ >
203
+ <i class="fas fa-file-export mr-1" />
204
+ 导出全部
205
+ </button>
206
+ </div>
207
+
208
+ <!-- 右侧:统计信息 -->
209
+ <div
210
+ class="flex items-center gap-2 rounded-md bg-purple-50 px-3 py-1.5 dark:bg-purple-900/20"
211
+ >
212
+ <i class="fas fa-info-circle text-purple-500 dark:text-purple-400" />
213
+ <span class="text-xs font-medium text-purple-700 dark:text-purple-300">
214
+ 显示 <strong>{{ filteredApiKeys.length }}</strong> 个
215
+ </span>
216
+ </div>
217
+ </div>
218
+ </div>
219
+ </div>
220
  <!-- API Key 网格布局 -->
221
  <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
222
  <div
 
269
  ? 'text-orange-500 hover:text-orange-700 dark:text-orange-400 dark:hover:text-orange-300'
270
  : 'text-yellow-500 hover:text-yellow-700 dark:text-yellow-400 dark:hover:text-yellow-300'
271
  ]"
272
+ :disabled="resetting === apiKey.key"
273
  title="重置状态"
274
+ @click="resetApiKeyStatus(apiKey)"
275
  >
276
+ <div v-if="resetting === apiKey.key" class="loading-spinner-sm" />
 
 
 
277
  <i v-else class="fas fa-redo"></i>
278
  </button>
279
  <button
280
  class="text-xs text-red-500 transition-colors hover:text-red-700 disabled:cursor-not-allowed disabled:opacity-50 dark:text-red-400 dark:hover:text-red-600"
281
+ :disabled="deleting === apiKey.key"
282
+ @click="deleteApiKey(apiKey)"
283
  >
284
+ <div v-if="deleting === apiKey.key" class="loading-spinner-sm" />
285
  <i v-else class="fas fa-trash" />
286
  </button>
287
  </div>
 
408
  const resetting = ref(null)
409
  const apiKeys = ref([])
410
  const currentPage = ref(1)
411
+ const pageSize = ref(15)
412
  const copyingAll = ref(false)
413
 
414
+ // 新增:筛选和搜索相关状态
415
+ const statusFilter = ref('all') // 'all' | 'active' | 'error'
416
+ const searchQuery = ref('')
417
+ const searchMode = ref('fuzzy') // 'fuzzy' | 'exact'
418
+ const batchDeleting = ref(false)
419
+
420
+ // 掩码显示 API Key(提前声明供 computed 使用)
421
+ const maskApiKey = (key) => {
422
+ if (!key || key.length < 12) {
423
+ return key
424
+ }
425
+ return `${key.substring(0, 8)}...${key.substring(key.length - 4)}`
426
+ }
427
+
428
+ // 计算属性:筛选后的 API Keys
429
+ const filteredApiKeys = computed(() => {
430
+ let filtered = apiKeys.value
431
+
432
+ // 状态筛选
433
+ if (statusFilter.value !== 'all') {
434
+ filtered = filtered.filter((key) => key.status === statusFilter.value)
435
+ }
436
+
437
+ // 搜索筛选(使用完整的 key 进行搜索)
438
+ if (searchQuery.value.trim()) {
439
+ const query = searchQuery.value.trim()
440
+ filtered = filtered.filter((key) => {
441
+ const fullKey = key.key // 使用完整的 key
442
+ if (searchMode.value === 'exact') {
443
+ // 精确搜索:完全匹配完整的 key
444
+ return fullKey === query
445
+ } else {
446
+ // 模糊搜索:完整 key 包含查询字符串(不区分大小写)
447
+ return fullKey.toLowerCase().includes(query.toLowerCase())
448
+ }
449
+ })
450
+ }
451
+
452
+ return filtered
453
+ })
454
+
455
  // 计算属性
456
+ const totalItems = computed(() => filteredApiKeys.value.length)
457
  const totalPages = computed(() => Math.ceil(totalItems.value / pageSize.value))
458
  const paginatedApiKeys = computed(() => {
459
  const start = (currentPage.value - 1) * pageSize.value
460
  const end = start + pageSize.value
461
+ return filteredApiKeys.value.slice(start, end)
462
  })
463
 
464
+ // 统计数量
465
+ const activeKeysCount = computed(() => {
466
+ return apiKeys.value.filter((key) => key.status === 'active').length
467
+ })
468
+
469
+ const errorKeysCount = computed(() => {
470
+ return apiKeys.value.filter((key) => key.status === 'error').length
471
+ })
472
 
473
  // 加载 API Keys
474
  const loadApiKeys = async () => {
 
548
  }
549
 
550
  // 删除 API Key
551
+ const deleteApiKey = async (apiKey) => {
552
  if (!confirm(`确定要删除 API Key "${maskApiKey(apiKey.key)}" 吗?`)) {
553
  return
554
  }
555
 
556
+ deleting.value = apiKey.key
557
  try {
558
  // 准备更新数据:删除指定的 key
559
  const updateData = {
 
575
  }
576
 
577
  // 重置 API Key 状态
578
+ const resetApiKeyStatus = async (apiKey) => {
579
  if (
580
  !confirm(
581
  `确定要重置 API Key "${maskApiKey(apiKey.key)}" 的状态吗?这将清除错误信息并恢复为正常状态。`
 
584
  return
585
  }
586
 
587
+ resetting.value = apiKey.key
588
  try {
589
  // 准备更新数据:重置指定 key 的状态
590
  const updateData = {
 
611
  }
612
  }
613
 
614
+ // 批量删除所有异常状态的 Key
615
+ const deleteAllErrorKeys = async () => {
616
+ const errorKeys = apiKeys.value.filter((key) => key.status === 'error')
617
+ if (errorKeys.length === 0) {
618
+ showToast('没有异常状态的 API Key', 'warning')
619
+ return
620
  }
621
+
622
+ if (!confirm(`确定要删除所有 ${errorKeys.length} 个异常状态的 API Key 吗?此操作不可恢复!`)) {
623
+ return
624
+ }
625
+
626
+ batchDeleting.value = true
627
+ try {
628
+ const keysToDelete = errorKeys.map((key) => key.key)
629
+ const updateData = {
630
+ removeApiKeys: keysToDelete,
631
+ apiKeyUpdateMode: 'delete'
632
+ }
633
+
634
+ await apiClient.put(`/admin/droid-accounts/${props.accountId}`, updateData)
635
+
636
+ showToast(`成功删除 ${errorKeys.length} 个异常 API Key`, 'success')
637
+ await loadApiKeys()
638
+ emit('refresh')
639
+ } catch (error) {
640
+ console.error('Failed to delete error API keys:', error)
641
+ showToast(error.response?.data?.error || '批量删除失败', 'error')
642
+ } finally {
643
+ batchDeleting.value = false
644
+ }
645
+ }
646
+
647
+ // 批量删除所有 Key
648
+ const deleteAllKeys = async () => {
649
+ if (apiKeys.value.length === 0) {
650
+ showToast('没有可删除的 API Key', 'warning')
651
+ return
652
+ }
653
+
654
+ if (
655
+ !confirm(
656
+ `确定要删除所有 ${apiKeys.value.length} 个 API Key 吗?此操作不可恢复!\n\n请再次确认:这将删除该账户下的所有 API Key。`
657
+ )
658
+ ) {
659
+ return
660
+ }
661
+
662
+ // 二次确认
663
+ if (!confirm('最后确认:真的要删除所有 API Key 吗?')) {
664
+ return
665
+ }
666
+
667
+ batchDeleting.value = true
668
+ try {
669
+ const keysToDelete = apiKeys.value.map((key) => key.key)
670
+ const updateData = {
671
+ removeApiKeys: keysToDelete,
672
+ apiKeyUpdateMode: 'delete'
673
+ }
674
+
675
+ await apiClient.put(`/admin/droid-accounts/${props.accountId}`, updateData)
676
+
677
+ showToast(`成功删除所有 ${keysToDelete.length} 个 API Key`, 'success')
678
+ await loadApiKeys()
679
+ emit('refresh')
680
+ } catch (error) {
681
+ console.error('Failed to delete all API keys:', error)
682
+ showToast(error.response?.data?.error || '批量删除失败', 'error')
683
+ } finally {
684
+ batchDeleting.value = false
685
+ }
686
+ }
687
+
688
+ // 导出 Key
689
+ const exportKeys = (type) => {
690
+ let keysToExport = []
691
+ let filename = ''
692
+
693
+ if (type === 'error') {
694
+ keysToExport = apiKeys.value.filter((key) => key.status === 'error')
695
+ filename = `error_api_keys_${props.accountName}_${new Date().toISOString().split('T')[0]}.txt`
696
+ } else {
697
+ keysToExport = apiKeys.value
698
+ filename = `all_api_keys_${props.accountName}_${new Date().toISOString().split('T')[0]}.txt`
699
+ }
700
+
701
+ if (keysToExport.length === 0) {
702
+ showToast('没有可导出的 API Key', 'warning')
703
+ return
704
+ }
705
+
706
+ // 生成 TXT 内容(每行一个完整的 key)
707
+ const content = keysToExport.map((key) => key.key).join('\n')
708
+
709
+ // 创建下载
710
+ const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
711
+ const url = URL.createObjectURL(blob)
712
+ const link = document.createElement('a')
713
+ link.href = url
714
+ link.download = filename
715
+ document.body.appendChild(link)
716
+ link.click()
717
+ document.body.removeChild(link)
718
+ URL.revokeObjectURL(url)
719
+
720
+ showToast(`成功导出 ${keysToExport.length} 个 API Key`, 'success')
721
  }
722
 
723
  // 写入剪贴板(带回退逻辑)
web/admin-spa/src/components/apikeys/BatchEditApiKeyModal.vue CHANGED
@@ -462,6 +462,7 @@ const props = defineProps({
462
  claude: [],
463
  gemini: [],
464
  openai: [],
 
465
  bedrock: [],
466
  droid: [],
467
  claudeGroups: [],
@@ -581,6 +582,7 @@ const refreshAccounts = async () => {
581
  claudeConsoleData,
582
  geminiData,
583
  openaiData,
 
584
  bedrockData,
585
  droidData,
586
  groupsData
@@ -589,6 +591,7 @@ const refreshAccounts = async () => {
589
  apiClient.get('/admin/claude-console-accounts'),
590
  apiClient.get('/admin/gemini-accounts'),
591
  apiClient.get('/admin/openai-accounts'),
 
592
  apiClient.get('/admin/bedrock-accounts'),
593
  apiClient.get('/admin/droid-accounts'),
594
  apiClient.get('/admin/account-groups')
@@ -626,13 +629,30 @@ const refreshAccounts = async () => {
626
  }))
627
  }
628
 
 
 
629
  if (openaiData.success) {
630
- localAccounts.value.openai = (openaiData.data || []).map((account) => ({
631
- ...account,
632
- isDedicated: account.accountType === 'dedicated'
633
- }))
 
 
 
 
 
 
 
 
 
 
 
 
 
634
  }
635
 
 
 
636
  if (bedrockData.success) {
637
  localAccounts.value.bedrock = (bedrockData.data || []).map((account) => ({
638
  ...account,
@@ -799,10 +819,28 @@ onMounted(async () => {
799
 
800
  // 初始化账号数据
801
  if (props.accounts) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
802
  localAccounts.value = {
803
  claude: props.accounts.claude || [],
804
  gemini: props.accounts.gemini || [],
805
- openai: props.accounts.openai || [],
806
  bedrock: props.accounts.bedrock || [],
807
  droid: props.accounts.droid || [],
808
  claudeGroups: props.accounts.claudeGroups || [],
 
462
  claude: [],
463
  gemini: [],
464
  openai: [],
465
+ openaiResponses: [],
466
  bedrock: [],
467
  droid: [],
468
  claudeGroups: [],
 
582
  claudeConsoleData,
583
  geminiData,
584
  openaiData,
585
+ openaiResponsesData,
586
  bedrockData,
587
  droidData,
588
  groupsData
 
591
  apiClient.get('/admin/claude-console-accounts'),
592
  apiClient.get('/admin/gemini-accounts'),
593
  apiClient.get('/admin/openai-accounts'),
594
+ apiClient.get('/admin/openai-responses-accounts'),
595
  apiClient.get('/admin/bedrock-accounts'),
596
  apiClient.get('/admin/droid-accounts'),
597
  apiClient.get('/admin/account-groups')
 
629
  }))
630
  }
631
 
632
+ const openaiAccounts = []
633
+
634
  if (openaiData.success) {
635
+ ;(openaiData.data || []).forEach((account) => {
636
+ openaiAccounts.push({
637
+ ...account,
638
+ platform: 'openai',
639
+ isDedicated: account.accountType === 'dedicated'
640
+ })
641
+ })
642
+ }
643
+
644
+ if (openaiResponsesData.success) {
645
+ ;(openaiResponsesData.data || []).forEach((account) => {
646
+ openaiAccounts.push({
647
+ ...account,
648
+ platform: 'openai-responses',
649
+ isDedicated: account.accountType === 'dedicated'
650
+ })
651
+ })
652
  }
653
 
654
+ localAccounts.value.openai = openaiAccounts
655
+
656
  if (bedrockData.success) {
657
  localAccounts.value.bedrock = (bedrockData.data || []).map((account) => ({
658
  ...account,
 
819
 
820
  // 初始化账号数据
821
  if (props.accounts) {
822
+ const openaiAccounts = []
823
+ if (props.accounts.openai) {
824
+ props.accounts.openai.forEach((account) => {
825
+ openaiAccounts.push({
826
+ ...account,
827
+ platform: 'openai'
828
+ })
829
+ })
830
+ }
831
+ if (props.accounts.openaiResponses) {
832
+ props.accounts.openaiResponses.forEach((account) => {
833
+ openaiAccounts.push({
834
+ ...account,
835
+ platform: 'openai-responses'
836
+ })
837
+ })
838
+ }
839
+
840
  localAccounts.value = {
841
  claude: props.accounts.claude || [],
842
  gemini: props.accounts.gemini || [],
843
+ openai: openaiAccounts,
844
  bedrock: props.accounts.bedrock || [],
845
  droid: props.accounts.droid || [],
846
  claudeGroups: props.accounts.claudeGroups || [],
web/admin-spa/src/views/AccountsView.vue CHANGED
@@ -3810,7 +3810,6 @@ onMounted(() => {
3810
 
3811
  <style scoped>
3812
  .table-container {
3813
- overflow-x: auto;
3814
  border-radius: 12px;
3815
  border: 1px solid rgba(0, 0, 0, 0.05);
3816
  }
@@ -3844,12 +3843,6 @@ onMounted(() => {
3844
  min-height: calc(100vh - 300px);
3845
  }
3846
 
3847
- .table-container {
3848
- overflow-x: auto;
3849
- border-radius: 12px;
3850
- border: 1px solid rgba(0, 0, 0, 0.05);
3851
- }
3852
-
3853
  .table-row {
3854
  transition: all 0.2s ease;
3855
  }
 
3810
 
3811
  <style scoped>
3812
  .table-container {
 
3813
  border-radius: 12px;
3814
  border: 1px solid rgba(0, 0, 0, 0.05);
3815
  }
 
3843
  min-height: calc(100vh - 300px);
3844
  }
3845
 
 
 
 
 
 
 
3846
  .table-row {
3847
  transition: all 0.2s ease;
3848
  }