Upload 224 files
Browse files- .github/workflows/codex-pr-review.yml +70 -0
- .github/workflows/sync-model-pricing.yml +62 -0
- CLAUDE.md +369 -60
- VERSION +1 -1
- config/pricingSource.js +17 -0
- resources/model-pricing/README.md +6 -6
- scripts/test-billing-events.js +340 -0
- scripts/update-model-pricing.js +12 -6
- src/routes/openaiClaudeRoutes.js +2 -14
- src/services/apiKeyService.js +42 -0
- src/services/billingEventPublisher.js +224 -0
- src/services/ccrAccountService.js +30 -4
- src/services/claudeConsoleAccountService.js +35 -4
- src/services/claudeConsoleRelayService.js +9 -5
- src/services/pricingService.js +150 -9
- web/admin-spa/src/components/accounts/AccountForm.vue +97 -1
- web/admin-spa/src/components/accounts/ApiKeyManagementModal.vue +328 -28
- web/admin-spa/src/components/apikeys/BatchEditApiKeyModal.vue +43 -5
- web/admin-spa/src/views/AccountsView.vue +0 -7
.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
|
| 10 |
|
| 11 |
## 核心架构
|
| 12 |
|
| 13 |
### 关键架构概念
|
| 14 |
|
| 15 |
-
-
|
|
|
|
|
|
|
| 16 |
- **Token管理**: 自动监控OAuth token过期并刷新,支持10秒提前刷新策略
|
| 17 |
-
- **代理支持**:
|
| 18 |
-
- **数据加密**: 敏感数据(refreshToken, accessToken)使用AES加密存储在Redis
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
### 主要服务组件
|
| 21 |
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
- **
|
| 25 |
-
- **
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
- **oauthHelper.js**: OAuth工具,PKCE流程实现和代理支持
|
|
|
|
|
|
|
| 27 |
|
| 28 |
### 认证和代理流程
|
| 29 |
|
| 30 |
-
1. 客户端使用自建API Key(cr\_
|
| 31 |
-
2. authenticateApiKey
|
| 32 |
-
3.
|
| 33 |
-
4.
|
| 34 |
-
5.
|
| 35 |
-
6.
|
| 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 |
-
-
|
| 101 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
- **主题系统**: 支持明亮/暗黑模式切换,自动保存用户偏好设置
|
|
|
|
|
|
|
| 103 |
|
| 104 |
## 重要端点
|
| 105 |
|
| 106 |
-
### API
|
| 107 |
|
| 108 |
-
|
| 109 |
-
- `
|
|
|
|
|
|
|
|
|
|
| 110 |
- `GET /api/v1/usage` - 使用统计查询
|
| 111 |
- `GET /api/v1/key-info` - API Key信息
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
-
|
|
|
|
|
|
|
| 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 /
|
|
|
|
|
|
|
| 123 |
- `GET /admin/dashboard` - 系统概览数据
|
| 124 |
|
| 125 |
## 故障排除
|
|
@@ -138,17 +315,43 @@ npm run setup # 自动生成密钥并创建管理员账户
|
|
| 138 |
|
| 139 |
### 常见开发问题
|
| 140 |
|
| 141 |
-
1. **Redis连接失败**: 确认Redis
|
| 142 |
-
2. **管理员登录失败**: 检查init.json
|
| 143 |
-
3. **API Key格式错误**: 确保使用cr\_
|
| 144 |
-
4. **代理连接问题**: 验证SOCKS5/HTTP
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
|
| 146 |
### 调试工具
|
| 147 |
|
| 148 |
-
- **日志系统**: Winston
|
| 149 |
-
-
|
| 150 |
-
-
|
| 151 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
-
|
| 213 |
-
-
|
| 214 |
-
-
|
| 215 |
-
-
|
| 216 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
|
| 218 |
### 核心数据流和性能优化
|
| 219 |
|
|
@@ -235,36 +472,107 @@ npm run setup # 自动生成密钥并创建管理员账户
|
|
| 235 |
|
| 236 |
### Redis 数据结构
|
| 237 |
|
| 238 |
-
- **API Keys**:
|
| 239 |
-
-
|
| 240 |
-
-
|
| 241 |
-
-
|
| 242 |
-
-
|
| 243 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
|
| 245 |
### 流式响应处理
|
| 246 |
|
| 247 |
-
- 支持 SSE (Server-Sent Events)
|
| 248 |
-
-
|
| 249 |
-
- 客户端断开时通过 AbortController
|
| 250 |
-
- 错误时发送适当的 SSE
|
|
|
|
|
|
|
|
|
|
| 251 |
|
| 252 |
### CLI 工具使用示例
|
| 253 |
|
| 254 |
```bash
|
| 255 |
-
#
|
| 256 |
npm run cli keys create -- --name "MyApp" --limit 1000
|
|
|
|
|
|
|
|
|
|
| 257 |
|
| 258 |
-
#
|
| 259 |
-
npm run cli status
|
|
|
|
|
|
|
| 260 |
|
| 261 |
-
#
|
| 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.
|
|
|
|
| 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
|
| 4 |
|
| 5 |
## Source
|
| 6 |
-
The original file is maintained by the LiteLLM project:
|
| 7 |
-
-
|
| 8 |
-
-
|
| 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 |
-
*
|
| 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 |
-
|
| 36 |
-
|
| 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('
|
| 89 |
-
log.info(
|
| 90 |
|
| 91 |
const request = https.get(config.pricingUrl, (response) => {
|
| 92 |
if (response.statusCode !== 200) {
|
|
@@ -115,7 +117,11 @@ function downloadPricingData() {
|
|
| 115 |
}
|
| 116 |
|
| 117 |
// 保存到文件
|
| 118 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 568 |
}
|
| 569 |
|
| 570 |
// 🔄 获取映射后的模型名称
|
|
@@ -574,8 +587,21 @@ class CcrAccountService {
|
|
| 574 |
return requestedModel
|
| 575 |
}
|
| 576 |
|
| 577 |
-
//
|
| 578 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 995 |
}
|
| 996 |
|
| 997 |
// 🔄 获取映射后的模型名称
|
|
@@ -1001,8 +1019,21 @@ class ClaudeConsoleAccountService {
|
|
| 1001 |
return requestedModel
|
| 1002 |
}
|
| 1003 |
|
| 1004 |
-
//
|
| 1005 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 778 |
-
|
| 779 |
-
|
| 780 |
-
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 158 |
response.on('data', (chunk) => {
|
| 159 |
-
|
|
|
|
| 160 |
})
|
| 161 |
|
| 162 |
response.on('end', () => {
|
| 163 |
try {
|
| 164 |
-
const
|
|
|
|
|
|
|
| 165 |
|
| 166 |
-
//
|
| 167 |
-
fs.writeFileSync(this.pricingFile,
|
|
|
|
| 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,
|
|
|
|
| 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-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 ===
|
| 116 |
title="重置状态"
|
| 117 |
-
@click="resetApiKeyStatus(apiKey
|
| 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 ===
|
| 128 |
-
@click="deleteApiKey(apiKey
|
| 129 |
>
|
| 130 |
-
<div v-if="deleting ===
|
| 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(
|
| 258 |
const copyingAll = ref(false)
|
| 259 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
// 计算属性
|
| 261 |
-
const totalItems = computed(() =>
|
| 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
|
| 267 |
})
|
| 268 |
|
| 269 |
-
//
|
| 270 |
-
const
|
| 271 |
-
return
|
| 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
|
| 353 |
if (!confirm(`确定要删除 API Key "${maskApiKey(apiKey.key)}" 吗?`)) {
|
| 354 |
return
|
| 355 |
}
|
| 356 |
|
| 357 |
-
deleting.value =
|
| 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
|
| 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 =
|
| 389 |
try {
|
| 390 |
// 准备更新数据:重置指定 key 的状态
|
| 391 |
const updateData = {
|
|
@@ -412,12 +611,113 @@ const resetApiKeyStatus = async (apiKey, index) => {
|
|
| 412 |
}
|
| 413 |
}
|
| 414 |
|
| 415 |
-
//
|
| 416 |
-
const
|
| 417 |
-
|
| 418 |
-
|
|
|
|
|
|
|
| 419 |
}
|
| 420 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 631 |
-
|
| 632 |
-
|
| 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:
|
| 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 |
}
|