Upload 15 files
Browse files- .github/workflows/docker-image.yml +44 -0
- Dockerfile +33 -1
- LICENSE +21 -0
- README.md +273 -10
- app.py +33 -0
- auth.py +112 -0
- client.py +468 -0
- config.py +400 -0
- requirements.txt +4 -0
- retry.py +408 -0
- routes.py +1043 -0
- static/css/styles.css +698 -0
- static/js/scripts.js +457 -0
- templates/stats.html +240 -0
- utils.py +158 -0
.github/workflows/docker-image.yml
ADDED
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
name: Docker Image CI
|
2 |
+
|
3 |
+
on:
|
4 |
+
push:
|
5 |
+
branches:
|
6 |
+
- main
|
7 |
+
workflow_dispatch:
|
8 |
+
|
9 |
+
jobs:
|
10 |
+
build-and-push:
|
11 |
+
runs-on: ubuntu-latest
|
12 |
+
permissions:
|
13 |
+
packages: write
|
14 |
+
contents: read
|
15 |
+
steps:
|
16 |
+
- uses: actions/checkout@v4
|
17 |
+
|
18 |
+
- name: Set up QEMU
|
19 |
+
uses: docker/setup-qemu-action@v3
|
20 |
+
|
21 |
+
- name: Set up Docker Buildx
|
22 |
+
uses: docker/setup-buildx-action@v3
|
23 |
+
|
24 |
+
- name: Login to GitHub Container Registry
|
25 |
+
uses: docker/login-action@v3
|
26 |
+
with:
|
27 |
+
registry: ghcr.io
|
28 |
+
username: ${{ github.actor }}
|
29 |
+
password: ${{ secrets.GITHUB_TOKEN }}
|
30 |
+
|
31 |
+
- name: Set repository owner to lowercase
|
32 |
+
id: repo_owner
|
33 |
+
run: echo "owner_lc=${GITHUB_REPOSITORY_OWNER,,}" >> $GITHUB_ENV
|
34 |
+
|
35 |
+
- name: Build and push Docker image to GHCR
|
36 |
+
uses: docker/build-push-action@v5
|
37 |
+
with:
|
38 |
+
context: .
|
39 |
+
file: ./Dockerfile
|
40 |
+
push: true
|
41 |
+
tags: ghcr.io/${{ env.owner_lc }}/od2api_plus:latest
|
42 |
+
|
43 |
+
- name: Logout from GitHub Container Registry
|
44 |
+
run: docker logout ghcr.io
|
Dockerfile
CHANGED
@@ -1 +1,33 @@
|
|
1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Use official Python base image
|
2 |
+
FROM python:3.9-slim
|
3 |
+
|
4 |
+
# Set working directory inside the container
|
5 |
+
WORKDIR /app
|
6 |
+
|
7 |
+
# Copy requirements file
|
8 |
+
COPY requirements.txt .
|
9 |
+
|
10 |
+
# Install dependencies
|
11 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
12 |
+
|
13 |
+
# 复制核心应用文件
|
14 |
+
COPY app.py .
|
15 |
+
COPY auth.py .
|
16 |
+
COPY client.py .
|
17 |
+
COPY routes.py .
|
18 |
+
COPY utils.py .
|
19 |
+
COPY config.py .
|
20 |
+
COPY retry.py .
|
21 |
+
COPY static/ static/
|
22 |
+
COPY templates/ templates/
|
23 |
+
|
24 |
+
RUN chmod -R 0755 /app
|
25 |
+
|
26 |
+
# Expose the port (Flask 默认端口)
|
27 |
+
EXPOSE 5000
|
28 |
+
|
29 |
+
# 设置 UTF-8 避免中文乱码
|
30 |
+
ENV LANG=C.UTF-8
|
31 |
+
|
32 |
+
# 启动主程序
|
33 |
+
CMD ["python", "app.py"]
|
LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
MIT License
|
2 |
+
|
3 |
+
Copyright (c) 2025 Drunkweng
|
4 |
+
|
5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6 |
+
of this software and associated documentation files (the "Software"), to deal
|
7 |
+
in the Software without restriction, including without limitation the rights
|
8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9 |
+
copies of the Software, and to permit persons to whom the Software is
|
10 |
+
furnished to do so, subject to the following conditions:
|
11 |
+
|
12 |
+
The above copyright notice and this permission notice shall be included in all
|
13 |
+
copies or substantial portions of the Software.
|
14 |
+
|
15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21 |
+
SOFTWARE.
|
README.md
CHANGED
@@ -1,10 +1,273 @@
|
|
1 |
-
|
2 |
-
|
3 |
-
|
4 |
-
|
5 |
-
|
6 |
-
|
7 |
-
|
8 |
-
|
9 |
-
|
10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# OnDemand-API-Proxy 代理服务
|
2 |
+
|
3 |
+
## 本项目仅供学习交流使用,请勿用于其他用途
|
4 |
+
|
5 |
+
一款基于 Flask 的 API 代理服务,提供兼容 OpenAI API 的接口,支持多种大型语言模型,实现多账户轮询和会话管理。
|
6 |
+
|
7 |
+
## 功能特点
|
8 |
+
|
9 |
+
- **兼容 OpenAI API**:提供标准的 `/v1/models` 和 `/v1/chat/completions` 接口
|
10 |
+
- **多模型支持**:支持 GPT-4o、Claude 3.7 Sonnet、Gemini 2.0 Flash 等多种模型
|
11 |
+
- **多轮对话**:通过会话管理保持对话上下文
|
12 |
+
- **账户轮换**:自动轮询使用多个 on-demand.io 账户,平衡负载
|
13 |
+
- **会话管理**:自动处理会话超时和重新连接
|
14 |
+
- **统计面板**:提供实时使用统计和图表展示
|
15 |
+
- **可配置的认证**:支持通过环境变量或配置文件设置 API 访问令牌
|
16 |
+
- **Docker 支持**:易于部署到 Hugging Face Spaces 或其他容器环境
|
17 |
+
|
18 |
+
## 支持的模型
|
19 |
+
|
20 |
+
服务支持以下模型(部分列表):
|
21 |
+
|
22 |
+
| API 模型名称 | 实际使用模型 |
|
23 |
+
|------------|------------|
|
24 |
+
| `gpt-4o` | predefined-openai-gpt4o |
|
25 |
+
| `gpt-4o-mini` | predefined-openai-gpt4o-mini |
|
26 |
+
| `gpt-3.5-turbo` / `gpto3-mini` | predefined-openai-gpto3-mini |
|
27 |
+
| `gpt-4-turbo` / `gpt-4.1` | predefined-openai-gpt4.1 |
|
28 |
+
| `gpt-4.1-mini` | predefined-openai-gpt4.1-mini |
|
29 |
+
| `gpt-4.1-nano` | predefined-openai-gpt4.1-nano |
|
30 |
+
| `claude-3.5-sonnet` / `claude-3.7-sonnet` | predefined-claude-3.7-sonnet |
|
31 |
+
| `claude-3-opus` | predefined-claude-3-opus |
|
32 |
+
| `claude-3-haiku` | predefined-claude-3-haiku |
|
33 |
+
| `gemini-1.5-pro` / `gemini-2.0-flash` | predefined-gemini-2.0-flash |
|
34 |
+
| `deepseek-v3` | predefined-deepseek-v3 |
|
35 |
+
| `deepseek-r1` | predefined-deepseek-r1 |
|
36 |
+
|
37 |
+
## 配置说明
|
38 |
+
|
39 |
+
### 配置文件 (config.json)
|
40 |
+
|
41 |
+
配置文件支持以下参数:
|
42 |
+
|
43 |
+
```json
|
44 |
+
{
|
45 |
+
"api_access_token": "你的自定义访问令牌",
|
46 |
+
"accounts": [
|
47 |
+
{"email": "账户[email protected]", "password": "密码1"},
|
48 |
+
{"email": "账户[email protected]", "password": "密码2"}
|
49 |
+
],
|
50 |
+
"session_timeout_minutes": 30,
|
51 |
+
"max_retries": 3,
|
52 |
+
"retry_delay": 1,
|
53 |
+
"request_timeout": 30,
|
54 |
+
"stream_timeout": 120,
|
55 |
+
"rate_limit": 60,
|
56 |
+
"debug_mode": false
|
57 |
+
}
|
58 |
+
```
|
59 |
+
|
60 |
+
### 环境变量
|
61 |
+
|
62 |
+
所有配置也可以通过环境变量设置:
|
63 |
+
|
64 |
+
- `API_ACCESS_TOKEN`: API 访问令牌
|
65 |
+
- `ONDEMAND_ACCOUNTS`: JSON 格式的账户信息
|
66 |
+
- `SESSION_TIMEOUT_MINUTES`: 会话超时时间(分钟)
|
67 |
+
- `MAX_RETRIES`: 最大重试次数
|
68 |
+
- `RETRY_DELAY`: 重试延迟(秒)
|
69 |
+
- `REQUEST_TIMEOUT`: 请求超时(秒)
|
70 |
+
- `STREAM_TIMEOUT`: 流式请求超时(秒)
|
71 |
+
- `RATE_LIMIT`: 速率限制(每分钟请求数)
|
72 |
+
- `DEBUG_MODE`: 调试模式(true/false)
|
73 |
+
|
74 |
+
## API 接口说明
|
75 |
+
|
76 |
+
### 获取模型列表
|
77 |
+
|
78 |
+
```
|
79 |
+
GET /v1/models
|
80 |
+
```
|
81 |
+
|
82 |
+
返回支持的模型列表,格式与 OpenAI API 兼容。
|
83 |
+
|
84 |
+
### 聊天补全
|
85 |
+
|
86 |
+
```
|
87 |
+
POST /v1/chat/completions
|
88 |
+
```
|
89 |
+
|
90 |
+
**请求头:**
|
91 |
+
```
|
92 |
+
Authorization: Bearer 你的API访问令牌
|
93 |
+
Content-Type: application/json
|
94 |
+
```
|
95 |
+
|
96 |
+
**请求体:**
|
97 |
+
```json
|
98 |
+
{
|
99 |
+
"model": "gpt-4o",
|
100 |
+
"messages": [
|
101 |
+
{"role": "system", "content": "你是一个有用的助手。"},
|
102 |
+
{"role": "user", "content": "你好,请介绍一下自己。"}
|
103 |
+
],
|
104 |
+
"temperature": 0.7,
|
105 |
+
"max_tokens": 2000,
|
106 |
+
"stream": false
|
107 |
+
}
|
108 |
+
```
|
109 |
+
|
110 |
+
**参数说明:**
|
111 |
+
- `model`: 使用的模型名称
|
112 |
+
- `messages`: 对话消息数组
|
113 |
+
- `temperature`: 温度参数(0-1)
|
114 |
+
- `max_tokens`: 最大生成令牌数
|
115 |
+
- `stream`: 是否使用流式响应
|
116 |
+
- `top_p`: 核采样参数(0-1)
|
117 |
+
- `frequency_penalty`: 频率惩罚(0-2)
|
118 |
+
- `presence_penalty`: 存在惩罚(0-2)
|
119 |
+
|
120 |
+
## 统计面板
|
121 |
+
|
122 |
+
访问根路径 `/` 可以查看使用统计面板,包括:
|
123 |
+
|
124 |
+
- 总请求数和成功率
|
125 |
+
- Token 使用统计
|
126 |
+
- 每日和每小时使用量图表
|
127 |
+
- 模型使用情况
|
128 |
+
- 最近请求历史
|
129 |
+
|
130 |
+
## 部署指南
|
131 |
+
|
132 |
+
### Hugging Face Spaces 部署(推荐)
|
133 |
+
|
134 |
+
1. **创建 Hugging Face 账户**:
|
135 |
+
- 访问 [https://huggingface.co/](https://huggingface.co/) 注册账户
|
136 |
+
|
137 |
+
2. **创建 Space**:
|
138 |
+
- 点击 [创建新的 Space](https://huggingface.co/new-space)
|
139 |
+
- 填写 Space 名称
|
140 |
+
- **重要**:选择 `Docker` 作为 Space 类型
|
141 |
+
- 设置权限(公开或私有)
|
142 |
+
|
143 |
+
3. **上传代码**:
|
144 |
+
- 将以下文件上传到你的 Space 代码仓库:
|
145 |
+
- `app.py`(主程序)
|
146 |
+
- `routes.py`(路由定义)
|
147 |
+
- `config.py`(配置管理)
|
148 |
+
- `auth.py`(认证模块)
|
149 |
+
- `client.py`(客户端实现)
|
150 |
+
- `utils.py`(工具函数)
|
151 |
+
- `requirements.txt`(依赖列表)
|
152 |
+
- `Dockerfile`(Docker 配置)
|
153 |
+
- `templates/`(模板目录)
|
154 |
+
- `static/`(静态资源目录)
|
155 |
+
|
156 |
+
4. **配置账户信息和 API 访问令牌**:
|
157 |
+
- 进入 Space 的 "Settings" → "Repository secrets"
|
158 |
+
- 添加 `ONDEMAND_ACCOUNTS` Secret:
|
159 |
+
```json
|
160 |
+
{
|
161 |
+
"accounts": [
|
162 |
+
{"email": "你的邮箱[email protected]", "password": "你的密码1"},
|
163 |
+
{"email": "你的邮箱[email protected]", "password": "你的密码2"}
|
164 |
+
]
|
165 |
+
}
|
166 |
+
```
|
167 |
+
- 添加 `API_ACCESS_TOKEN` Secret 设置自定义访问令牌
|
168 |
+
- 如果不设置,将使用默认值 "sk-2api-ondemand-access-token-2025"
|
169 |
+
|
170 |
+
5. **可选配置**:
|
171 |
+
- 添加其他环境变量如 `SESSION_TIMEOUT_MINUTES`、`RATE_LIMIT` 等
|
172 |
+
|
173 |
+
6. **完成部署**:
|
174 |
+
- Hugging Face 会自动构建 Docker 镜像并部署你的 API
|
175 |
+
- 访问你的 Space URL(如 `https://你的用户名-你的space名称.hf.space`)
|
176 |
+
|
177 |
+
### 本地部署
|
178 |
+
|
179 |
+
1. **克隆代码**:
|
180 |
+
```bash
|
181 |
+
git clone https://github.com/你的用户名/ondemand-api-proxy.git
|
182 |
+
cd ondemand-api-proxy
|
183 |
+
```
|
184 |
+
|
185 |
+
2. **安装依赖**:
|
186 |
+
```bash
|
187 |
+
pip install -r requirements.txt
|
188 |
+
```
|
189 |
+
|
190 |
+
3. **配置**:
|
191 |
+
- 创建 `config.json` 文件:
|
192 |
+
```json
|
193 |
+
{
|
194 |
+
"api_access_token": "你的自定义访问令牌",
|
195 |
+
"accounts": [
|
196 |
+
{"email": "账户[email protected]", "password": "密码1"},
|
197 |
+
{"email": "账户[email protected]", "password": "密码2"}
|
198 |
+
]
|
199 |
+
}
|
200 |
+
```
|
201 |
+
- 或设置环境变量
|
202 |
+
|
203 |
+
4. **启动服务**:
|
204 |
+
```bash
|
205 |
+
python app.py
|
206 |
+
```
|
207 |
+
|
208 |
+
5. **访问服务**:
|
209 |
+
- API 接口:`http://localhost:5000/v1/chat/completions`
|
210 |
+
- 统计面板:`http://localhost:5000/`
|
211 |
+
|
212 |
+
### Docker 部署
|
213 |
+
|
214 |
+
```bash
|
215 |
+
# 构建镜像
|
216 |
+
docker build -t ondemand-api-proxy .
|
217 |
+
|
218 |
+
# 运行容器
|
219 |
+
docker run -p 7860:7860 \
|
220 |
+
-e API_ACCESS_TOKEN="你的访问令牌" \
|
221 |
+
-e ONDEMAND_ACCOUNTS='{"accounts":[{"email":"账户[email protected]","password":"密码1"}]}' \
|
222 |
+
ondemand-api-proxy
|
223 |
+
```
|
224 |
+
|
225 |
+
## 客户端连接
|
226 |
+
|
227 |
+
### Cherry Studio 连接
|
228 |
+
|
229 |
+
1. 打开 Cherry Studio
|
230 |
+
2. 进入设置 → API 设置
|
231 |
+
3. 选择 "OpenAI API"
|
232 |
+
4. API 密钥填入你配置的 API 访问令牌
|
233 |
+
5. API 地址填入你的服务地址(如 `https://你的用户名-你的space名称.hf.space/v1`)
|
234 |
+
|
235 |
+
### 其他 OpenAI 兼容客户端
|
236 |
+
|
237 |
+
任何支持 OpenAI API 的客户端都可以连接到此服务,只需将 API 地址修改为你的服务地址即可。
|
238 |
+
|
239 |
+
## 故障排除
|
240 |
+
|
241 |
+
### 常见问题
|
242 |
+
|
243 |
+
1. **认证失败**:
|
244 |
+
- 检查 API 访问令牌是否正确配置
|
245 |
+
- 确认请求头中包含 `Authorization: Bearer 你的令牌`
|
246 |
+
|
247 |
+
2. **账户连接问题**:
|
248 |
+
- 确认 on-demand.io 账户信息正确
|
249 |
+
- 检查账户是否被限制或封禁
|
250 |
+
|
251 |
+
3. **模型不可用**:
|
252 |
+
- 确认请求的模型名称在支持列表中
|
253 |
+
- 检查 on-demand.io 是否支持该模型
|
254 |
+
|
255 |
+
4. **统计图表显示错误**:
|
256 |
+
- 清除浏览器缓存后重试
|
257 |
+
- 检查浏览器控制台是否有错误信息
|
258 |
+
|
259 |
+
## 安全建议
|
260 |
+
|
261 |
+
1. **永远不要**在代码中硬编码账户信息和访问令牌
|
262 |
+
2. 使用环境变量或安全的配置管理系统存储敏感信息
|
263 |
+
3. 定期更换 API 访问令牌
|
264 |
+
4. 限制 API 的访问范围,只允许受信任的客户端连接
|
265 |
+
5. 启用速率限制防止滥用
|
266 |
+
|
267 |
+
## 贡献与反馈
|
268 |
+
|
269 |
+
欢迎提交 Issue 和 Pull Request 来改进此项目。如有任何问题或建议,请随时联系。
|
270 |
+
|
271 |
+
## 许可证
|
272 |
+
|
273 |
+
本项目采用 MIT 许可证。
|
app.py
ADDED
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
from flask import Flask
|
3 |
+
from utils import logger
|
4 |
+
import config
|
5 |
+
from auth import start_cleanup_thread
|
6 |
+
from routes import register_routes
|
7 |
+
|
8 |
+
def create_app():
|
9 |
+
"""创建并配置Flask应用"""
|
10 |
+
config.init_config() # 调整到 create_app 开头
|
11 |
+
app = Flask(__name__)
|
12 |
+
|
13 |
+
# 启动会话清理线程
|
14 |
+
start_cleanup_thread()
|
15 |
+
|
16 |
+
# 注册路由
|
17 |
+
register_routes(app)
|
18 |
+
|
19 |
+
return app
|
20 |
+
|
21 |
+
if __name__ == "__main__":
|
22 |
+
# 初始化配置 # 已移至 create_app
|
23 |
+
|
24 |
+
# 创建应用
|
25 |
+
app = create_app()
|
26 |
+
|
27 |
+
# 获取端口
|
28 |
+
port = int(os.getenv("PORT", 7860))
|
29 |
+
print(f"[系统] Flask 应用将在 0.0.0.0:{port} 启动 (Flask 开发服)")
|
30 |
+
|
31 |
+
# 启动应用
|
32 |
+
flask_debug_mode = config.get_config_value("FLASK_DEBUG", default=False) # 从配置获取调试模式
|
33 |
+
app.run(host='0.0.0.0', port=port, debug=flask_debug_mode)
|
auth.py
ADDED
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import threading
|
2 |
+
import time
|
3 |
+
from datetime import datetime, timedelta
|
4 |
+
from functools import wraps
|
5 |
+
# from flask import request, jsonify # 移除冗余导入
|
6 |
+
from utils import logger
|
7 |
+
import config
|
8 |
+
|
9 |
+
class RateLimiter:
|
10 |
+
"""请求速率限制器 (基于token/IP)"""
|
11 |
+
def __init__(self, limit_per_minute=None): # 允许传入参数,但优先配置
|
12 |
+
# 优先从配置读取,如果未配置或传入了明确值,则使用该值
|
13 |
+
# 配置项: "rate_limit"
|
14 |
+
configured_limit = config.get_config_value("rate_limit", default=60) # 默认60次/分钟
|
15 |
+
self.limit = limit_per_minute if limit_per_minute is not None else configured_limit
|
16 |
+
self.window_size = 60 # 窗口大小(秒)
|
17 |
+
self.requests = {} # {identifier: [timestamp1, timestamp2, ...]}
|
18 |
+
self.lock = threading.Lock()
|
19 |
+
|
20 |
+
def is_allowed(self, identifier: str) -> bool:
|
21 |
+
"""
|
22 |
+
检查标识符请求是否允许
|
23 |
+
|
24 |
+
参数:
|
25 |
+
identifier: 唯一标识 (token/IP)
|
26 |
+
|
27 |
+
返回:
|
28 |
+
bool: 允许则True,否则False
|
29 |
+
"""
|
30 |
+
with self.lock:
|
31 |
+
now = time.time()
|
32 |
+
if identifier not in self.requests:
|
33 |
+
self.requests[identifier] = []
|
34 |
+
|
35 |
+
# 清理过期请求
|
36 |
+
self.requests[identifier] = [t for t in self.requests[identifier] if now - t < self.window_size]
|
37 |
+
|
38 |
+
# 检查请求数是否超限
|
39 |
+
if len(self.requests[identifier]) >= self.limit:
|
40 |
+
return False
|
41 |
+
|
42 |
+
# 记录当前请求
|
43 |
+
self.requests[identifier].append(now)
|
44 |
+
return True
|
45 |
+
|
46 |
+
def session_cleanup():
|
47 |
+
"""定期清理过期会话"""
|
48 |
+
# 获取配置
|
49 |
+
config_instance = config.config_instance
|
50 |
+
|
51 |
+
with config_instance.client_sessions_lock:
|
52 |
+
current_time = datetime.now()
|
53 |
+
total_expired = 0
|
54 |
+
|
55 |
+
# 遍历用户
|
56 |
+
for user_id in list(config_instance.client_sessions.keys()):
|
57 |
+
user_sessions = config_instance.client_sessions[user_id]
|
58 |
+
expired_accounts = []
|
59 |
+
|
60 |
+
# 遍历账户会话
|
61 |
+
for account_email, session_data in user_sessions.items():
|
62 |
+
last_time = session_data["last_time"]
|
63 |
+
if current_time - last_time > timedelta(minutes=config_instance.get('session_timeout_minutes')):
|
64 |
+
expired_accounts.append(account_email)
|
65 |
+
# 记录过期会话信息 (上下文/IP)
|
66 |
+
context_info = session_data.get("context", "无上下文")
|
67 |
+
ip_info = session_data.get("ip", "无IP")
|
68 |
+
# 上下文预览(前30字符),防日志过长
|
69 |
+
context_preview = context_info[:30] + "..." if len(context_info) > 30 else context_info
|
70 |
+
logger.debug(f"过期会话: 用户={user_id[:8]}..., 账户={account_email}, 上下文={context_preview}, IP={ip_info}")
|
71 |
+
|
72 |
+
# 删除过期账户会话
|
73 |
+
for account_email in expired_accounts:
|
74 |
+
del user_sessions[account_email]
|
75 |
+
total_expired += 1
|
76 |
+
|
77 |
+
# 若用户无会话,则删除
|
78 |
+
if not user_sessions:
|
79 |
+
del config_instance.client_sessions[user_id]
|
80 |
+
|
81 |
+
if total_expired:
|
82 |
+
logger.info(f"已清理 {total_expired} 个过期会话")
|
83 |
+
|
84 |
+
_cleanup_thread_started = False
|
85 |
+
_cleanup_thread_lock = threading.Lock()
|
86 |
+
|
87 |
+
def start_cleanup_thread():
|
88 |
+
"""启动会话定期清理线程 (幂等)"""
|
89 |
+
global _cleanup_thread_started
|
90 |
+
with _cleanup_thread_lock:
|
91 |
+
if _cleanup_thread_started:
|
92 |
+
logger.debug("会话清理线程已运行,跳过此次启动。")
|
93 |
+
return
|
94 |
+
|
95 |
+
def cleanup_worker():
|
96 |
+
while True:
|
97 |
+
# 循环内获取最新配置,防动态更新
|
98 |
+
try:
|
99 |
+
timeout_minutes = config.get_config_value('session_timeout_minutes', default=30) # 默认值
|
100 |
+
sleep_interval = timeout_minutes * 60 / 2
|
101 |
+
if sleep_interval <= 0: # 防无效休眠间隔
|
102 |
+
logger.warning(f"无效会话清理休眠间隔: {sleep_interval}s, 用默认15分钟。")
|
103 |
+
sleep_interval = 15 * 60
|
104 |
+
time.sleep(sleep_interval)
|
105 |
+
session_cleanup()
|
106 |
+
except Exception as e:
|
107 |
+
logger.error(f"会话清理线程异常: {e}", exc_info=True) # 添加 exc_info=True 获取更详细的堆栈
|
108 |
+
|
109 |
+
cleanup_thread = threading.Thread(target=cleanup_worker, daemon=True, name="SessionCleanupThread")
|
110 |
+
cleanup_thread.start()
|
111 |
+
_cleanup_thread_started = True
|
112 |
+
logger.info("会话清理线程启动成功。")
|
client.py
ADDED
@@ -0,0 +1,468 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import requests
|
2 |
+
import json
|
3 |
+
import base64
|
4 |
+
import threading
|
5 |
+
import time
|
6 |
+
import uuid
|
7 |
+
from datetime import datetime
|
8 |
+
from typing import Dict, Optional, Any
|
9 |
+
|
10 |
+
from utils import logger, mask_email
|
11 |
+
import config
|
12 |
+
from retry import with_retry
|
13 |
+
|
14 |
+
class OnDemandAPIClient:
|
15 |
+
"""OnDemand API 客户端,处理认证、会话管理和查询"""
|
16 |
+
|
17 |
+
def __init__(self, email: str, password: str, client_id: str = "default_client"):
|
18 |
+
"""初始化客户端
|
19 |
+
|
20 |
+
Args:
|
21 |
+
email: OnDemand账户邮箱
|
22 |
+
password: OnDemand账户密码
|
23 |
+
client_id: 客户端标识符,用于日志记录
|
24 |
+
"""
|
25 |
+
self.email = email
|
26 |
+
self.password = password
|
27 |
+
self.client_id = client_id
|
28 |
+
self.token = ""
|
29 |
+
self.refresh_token = ""
|
30 |
+
self.user_id = ""
|
31 |
+
self.company_id = ""
|
32 |
+
self.session_id = ""
|
33 |
+
self.base_url = "https://gateway.on-demand.io/v1"
|
34 |
+
self.chat_base_url = "https://api.on-demand.io/chat/v1/client" # 恢复为原始路径
|
35 |
+
self.last_error: Optional[str] = None
|
36 |
+
self.last_activity = datetime.now()
|
37 |
+
self.lock = threading.RLock() # 可重入锁,用于线程安全操作
|
38 |
+
|
39 |
+
# 新增属性
|
40 |
+
self._associated_user_identifier: Optional[str] = None
|
41 |
+
self._associated_request_ip: Optional[str] = None
|
42 |
+
self._current_request_context_hash: Optional[str] = None # 新增:用于暂存当前请求的上下文哈希
|
43 |
+
|
44 |
+
# 隐藏密码的日志
|
45 |
+
masked_email = mask_email(email)
|
46 |
+
logger.info(f"已为 {masked_email} 初始化 OnDemandAPIClient (ID: {client_id})")
|
47 |
+
|
48 |
+
def _log(self, message: str, level: str = "INFO"):
|
49 |
+
"""内部日志方法,使用结构化日志记录
|
50 |
+
|
51 |
+
Args:
|
52 |
+
message: 日志消息
|
53 |
+
level: 日志级别
|
54 |
+
"""
|
55 |
+
masked_email = mask_email(self.email)
|
56 |
+
log_method = getattr(logger, level.lower(), logger.info)
|
57 |
+
log_method(f"[{self.client_id} / {masked_email}] {message}")
|
58 |
+
self.last_activity = datetime.now() # 更新最后活动时间
|
59 |
+
|
60 |
+
def get_authorization(self) -> str:
|
61 |
+
"""生成登录用 Basic Authorization 头"""
|
62 |
+
text = f"{self.email}:{self.password}"
|
63 |
+
encoded = base64.b64encode(text.encode("utf-8")).decode("utf-8")
|
64 |
+
return encoded
|
65 |
+
|
66 |
+
def _do_request(self, method: str, url: str, headers: Dict[str, str],
|
67 |
+
data: Optional[Dict] = None, stream: bool = False,
|
68 |
+
timeout: int = None) -> requests.Response:
|
69 |
+
"""执行HTTP请求的实际逻辑,不包含重试
|
70 |
+
|
71 |
+
Args:
|
72 |
+
method: HTTP方法 (GET, POST等)
|
73 |
+
url: 请求URL
|
74 |
+
headers: HTTP头
|
75 |
+
data: 请求数据
|
76 |
+
stream: 是否使用流式传输
|
77 |
+
timeout: 请求超时时间
|
78 |
+
|
79 |
+
Returns:
|
80 |
+
requests.Response对象
|
81 |
+
|
82 |
+
Raises:
|
83 |
+
requests.exceptions.RequestException: 请求失败
|
84 |
+
"""
|
85 |
+
if method.upper() == 'GET':
|
86 |
+
response = requests.get(url, headers=headers, stream=stream, timeout=timeout)
|
87 |
+
elif method.upper() == 'POST':
|
88 |
+
json_data = json.dumps(data) if data else None
|
89 |
+
response = requests.post(url, data=json_data, headers=headers, stream=stream, timeout=timeout)
|
90 |
+
else:
|
91 |
+
raise ValueError(f"不支持的HTTP方法: {method}")
|
92 |
+
|
93 |
+
response.raise_for_status()
|
94 |
+
return response
|
95 |
+
|
96 |
+
@with_retry()
|
97 |
+
def sign_in(self, context: Optional[str] = None) -> bool:
|
98 |
+
"""登录以获取 token, refreshToken, userId, 和 companyId"""
|
99 |
+
with self.lock: # 线程安全
|
100 |
+
self.last_error = None
|
101 |
+
url = f"{self.base_url}/auth/user/signin"
|
102 |
+
payload = {"accountType": "default"}
|
103 |
+
headers = {
|
104 |
+
'User-Agent': "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36 Edg/135.0.0.0",
|
105 |
+
'Accept': "application/json, text/plain, */*",
|
106 |
+
'Content-Type': "application/json",
|
107 |
+
'Authorization': f"Basic {self.get_authorization()}", # 登录时使用 Basic 认证
|
108 |
+
'Referer': "https://app.on-demand.io/"
|
109 |
+
}
|
110 |
+
if context:
|
111 |
+
self._current_request_context_hash = context
|
112 |
+
|
113 |
+
try:
|
114 |
+
masked_email = mask_email(self.email)
|
115 |
+
self._log(f"尝试登录 {masked_email}...")
|
116 |
+
|
117 |
+
# 使用不带重试的_do_request,因为重试逻辑由装饰器处理
|
118 |
+
response = self._do_request('POST', url, headers, payload, timeout=config.get_config_value('request_timeout'))
|
119 |
+
data = response.json()
|
120 |
+
|
121 |
+
if config.get_config_value('debug_mode'):
|
122 |
+
# 在调试模式下记录响应,但隐藏敏感信息
|
123 |
+
debug_data = data.copy()
|
124 |
+
if 'data' in debug_data and 'tokenData' in debug_data['data']:
|
125 |
+
debug_data['data']['tokenData']['token'] = '***REDACTED***'
|
126 |
+
debug_data['data']['tokenData']['refreshToken'] = '***REDACTED***'
|
127 |
+
self._log(f"登录原始响应: {json.dumps(debug_data, indent=2, ensure_ascii=False)}", "DEBUG")
|
128 |
+
|
129 |
+
self.token = data.get('data', {}).get('tokenData', {}).get('token', '')
|
130 |
+
self.refresh_token = data.get('data', {}).get('tokenData', {}).get('refreshToken', '')
|
131 |
+
self.user_id = data.get('data', {}).get('user', {}).get('userId', '')
|
132 |
+
self.company_id = data.get('data', {}).get('user', {}).get('default_company_id', '')
|
133 |
+
|
134 |
+
if self.token and self.user_id and self.company_id:
|
135 |
+
self._log(f"登录成功。已获取必要的凭证。")
|
136 |
+
return True
|
137 |
+
else:
|
138 |
+
self.last_error = "登录成功,但未能从响应中提取必要的字段。"
|
139 |
+
self._log(f"登录失败: {self.last_error}", level="ERROR")
|
140 |
+
return False
|
141 |
+
|
142 |
+
except requests.exceptions.RequestException as e:
|
143 |
+
self.last_error = f"登录请求失败: {e}"
|
144 |
+
self._log(f"登录失败: {e}", level="ERROR")
|
145 |
+
raise # 重新抛出异常,让装饰器处理重试
|
146 |
+
|
147 |
+
except json.JSONDecodeError as e:
|
148 |
+
self.last_error = f"登录 JSON 解码失败: {e}. 响应文本: {response.text if 'response' in locals() else 'N/A'}"
|
149 |
+
self._log(self.last_error, level="ERROR")
|
150 |
+
return False
|
151 |
+
|
152 |
+
except Exception as e:
|
153 |
+
self.last_error = f"登录过程中发生意外错误: {e}"
|
154 |
+
self._log(self.last_error, level="ERROR")
|
155 |
+
return False
|
156 |
+
|
157 |
+
@with_retry()
|
158 |
+
def refresh_token_if_needed(self) -> bool:
|
159 |
+
"""如果令牌过期或无效,则刷新令牌
|
160 |
+
|
161 |
+
Returns:
|
162 |
+
bool: 刷新成功返回True,否则返回False
|
163 |
+
"""
|
164 |
+
with self.lock: # 线程安全
|
165 |
+
self.last_error = None
|
166 |
+
if not self.refresh_token:
|
167 |
+
self.last_error = "没有可用的 refresh token 来刷新令牌。"
|
168 |
+
self._log(self.last_error, level="WARNING")
|
169 |
+
return False
|
170 |
+
|
171 |
+
url = f"{self.base_url}/auth/user/refresh_token"
|
172 |
+
payload = {"data": {"token": self.token, "refreshToken": self.refresh_token}}
|
173 |
+
headers = {'Content-Type': "application/json"}
|
174 |
+
|
175 |
+
try:
|
176 |
+
self._log("尝试刷新令牌...")
|
177 |
+
|
178 |
+
# 使用不带重试的_do_request,因为重试逻辑由装饰器处理
|
179 |
+
response = self._do_request('POST', url, headers, payload, timeout=config.get_config_value('request_timeout'))
|
180 |
+
data = response.json()
|
181 |
+
|
182 |
+
if config.get_config_value('debug_mode'):
|
183 |
+
# 在调试模式下记录响应,但隐藏敏感信息
|
184 |
+
debug_data = data.copy()
|
185 |
+
if 'data' in debug_data:
|
186 |
+
if 'token' in debug_data['data']:
|
187 |
+
debug_data['data']['token'] = '***REDACTED***'
|
188 |
+
if 'refreshToken' in debug_data['data']:
|
189 |
+
debug_data['data']['refreshToken'] = '***REDACTED***'
|
190 |
+
self._log(f"刷新令牌原始响应: {json.dumps(debug_data, indent=2, ensure_ascii=False)}", "DEBUG")
|
191 |
+
|
192 |
+
new_token = data.get('data', {}).get('token', '')
|
193 |
+
new_refresh_token = data.get('data', {}).get('refreshToken', '') # OnDemand 可能不总返回新的 refresh token
|
194 |
+
|
195 |
+
if new_token:
|
196 |
+
self.token = new_token
|
197 |
+
if new_refresh_token: # 仅当返回了新的 refresh token 时才更新
|
198 |
+
self.refresh_token = new_refresh_token
|
199 |
+
self._log("令牌刷新成功。")
|
200 |
+
return True
|
201 |
+
else:
|
202 |
+
self.last_error = "令牌刷新成功,但响应中没有新的 token。"
|
203 |
+
self._log(f"令牌刷新失败: {self.last_error}", level="ERROR")
|
204 |
+
return False
|
205 |
+
|
206 |
+
except requests.exceptions.RequestException as e:
|
207 |
+
self.last_error = f"令牌刷新请求失败: {e}"
|
208 |
+
self._log(f"令牌刷新失败: {e}", level="ERROR")
|
209 |
+
|
210 |
+
# 如果是认证错误,可能需要完全重新登录
|
211 |
+
if hasattr(e, 'response') and e.response is not None and e.response.status_code == 401:
|
212 |
+
self._log("令牌刷新返回401错误,可能需要完全重新登录", level="WARNING")
|
213 |
+
|
214 |
+
raise # 重新抛出异常,让装饰器处理重试
|
215 |
+
|
216 |
+
except json.JSONDecodeError as e:
|
217 |
+
self.last_error = f"令牌刷新 JSON 解码失败: {e}. 响应文本: {response.text if 'response' in locals() else 'N/A'}"
|
218 |
+
self._log(self.last_error, level="ERROR")
|
219 |
+
return False
|
220 |
+
|
221 |
+
except Exception as e:
|
222 |
+
self.last_error = f"令牌刷新过程中发生意外错误: {e}"
|
223 |
+
self._log(self.last_error, level="ERROR")
|
224 |
+
return False
|
225 |
+
|
226 |
+
@with_retry()
|
227 |
+
def create_session(self, external_user_id: str = "openai-adapter-user", external_context: Optional[str] = None) -> bool:
|
228 |
+
"""为聊天创建一个新会话
|
229 |
+
|
230 |
+
Args:
|
231 |
+
external_user_id: 外部用户ID前缀,会附加UUID确保唯一性
|
232 |
+
external_context: 外部上下文哈希 (可选)
|
233 |
+
|
234 |
+
Returns:
|
235 |
+
bool: 创建成功返回True,否则返回False
|
236 |
+
"""
|
237 |
+
with self.lock: # 线程安全
|
238 |
+
self.last_error = None
|
239 |
+
if external_context:
|
240 |
+
self._current_request_context_hash = external_context
|
241 |
+
if not self.token or not self.user_id or not self.company_id:
|
242 |
+
self.last_error = "创建会话缺少 token, user_id, 或 company_id。正在尝试登录。"
|
243 |
+
self._log(self.last_error, level="WARNING")
|
244 |
+
if not self.sign_in(): # 如果未登录,尝试登录
|
245 |
+
self.last_error = f"无法创建会话:登录失败。最近的客户端错误: {self.last_error}"
|
246 |
+
return False # 如果登录失败,则无法继续
|
247 |
+
|
248 |
+
url = f"{self.chat_base_url}/sessions"
|
249 |
+
# 确保 externalUserId 对于每个会话是唯一的,以避免冲突
|
250 |
+
unique_id = f"{external_user_id}-{uuid.uuid4().hex}"
|
251 |
+
payload = {"externalUserId": unique_id, "pluginIds": []}
|
252 |
+
headers = {
|
253 |
+
'Content-Type': "application/json",
|
254 |
+
'Authorization': f"Bearer {self.token}", # 恢复为原始认证方式
|
255 |
+
'x-company-id': self.company_id,
|
256 |
+
'x-user-id': self.user_id
|
257 |
+
}
|
258 |
+
|
259 |
+
self._log(f"尝试创建会话,company_id: {self.company_id}, user_id: {self.user_id}, external_id: {unique_id}")
|
260 |
+
|
261 |
+
try:
|
262 |
+
try:
|
263 |
+
# 首先尝试创建会话,使用不带重试的_do_request
|
264 |
+
response = self._do_request('POST', url, headers, payload, timeout=config.get_config_value('request_timeout'))
|
265 |
+
except requests.exceptions.HTTPError as e:
|
266 |
+
# 如果是401错误,尝试刷新令牌
|
267 |
+
if e.response.status_code == 401:
|
268 |
+
self._log("创建会话时令牌过期,尝试刷新...", level="INFO")
|
269 |
+
if self.refresh_token_if_needed():
|
270 |
+
headers['Authorization'] = f"Bearer {self.token}" # 使用新令牌更新头
|
271 |
+
response = self._do_request('POST', url, headers, payload, timeout=config.get_config_value('request_timeout'))
|
272 |
+
else: # 刷新失败,尝试完全重新登录
|
273 |
+
self._log("令牌刷新失败。尝试完全重新登录以创建会话。", level="WARNING")
|
274 |
+
if self.sign_in():
|
275 |
+
headers['Authorization'] = f"Bearer {self.token}"
|
276 |
+
response = self._do_request('POST', url, headers, payload, timeout=config.get_config_value('request_timeout'))
|
277 |
+
else:
|
278 |
+
self.last_error = f"会话创建失败:令牌刷新和重新登录均失败。最近的客户端错误: {self.last_error}"
|
279 |
+
self._log(self.last_error, level="ERROR")
|
280 |
+
return False
|
281 |
+
else:
|
282 |
+
# 其他HTTP错误,直接抛出
|
283 |
+
raise
|
284 |
+
|
285 |
+
data = response.json()
|
286 |
+
|
287 |
+
if config.get_config_value('debug_mode'):
|
288 |
+
self._log(f"创建会话原始响应: {json.dumps(data, indent=2, ensure_ascii=False)}", "DEBUG")
|
289 |
+
|
290 |
+
session_id_val = data.get('data', {}).get('id', '')
|
291 |
+
if session_id_val:
|
292 |
+
self.session_id = session_id_val
|
293 |
+
self._log(f"会话创建成功。会话 ID: {self.session_id}")
|
294 |
+
return True
|
295 |
+
else:
|
296 |
+
self.last_error = f"会话创建成功,但响应中没有会话 ID。"
|
297 |
+
self._log(f"会话创建失败: {self.last_error}", level="ERROR")
|
298 |
+
return False
|
299 |
+
|
300 |
+
except requests.exceptions.RequestException as e:
|
301 |
+
self.last_error = f"会话创建请求失败: {e}"
|
302 |
+
self._log(f"会话创建失败: {e}", level="ERROR")
|
303 |
+
raise # 重新抛出异常,让装饰器处理重试
|
304 |
+
|
305 |
+
except json.JSONDecodeError as e:
|
306 |
+
self.last_error = f"会话创建 JSON 解码失败: {e}. 响应文本: {response.text if 'response' in locals() else 'N/A'}"
|
307 |
+
self._log(self.last_error, level="ERROR")
|
308 |
+
return False
|
309 |
+
|
310 |
+
except Exception as e:
|
311 |
+
self.last_error = f"会话创建过程中发生意外错误: {e}"
|
312 |
+
self._log(self.last_error, level="ERROR")
|
313 |
+
return False
|
314 |
+
|
315 |
+
@with_retry()
|
316 |
+
def send_query(self, query: str, endpoint_id: str = "predefined-claude-3.7-sonnet",
|
317 |
+
stream: bool = False, model_configs_input: Optional[Dict] = None,
|
318 |
+
full_query_override: Optional[str] = None) -> Dict:
|
319 |
+
"""向聊天会话发送查询,并处理流式或非流式响应
|
320 |
+
|
321 |
+
Args:
|
322 |
+
query: 查询文本 (如果提供了 full_query_override,则此参数被忽略)
|
323 |
+
endpoint_id: OnDemand端点ID
|
324 |
+
stream: 是否使用流式响应
|
325 |
+
model_configs_input: 模型配置参数,如temperature、maxTokens等
|
326 |
+
|
327 |
+
Returns:
|
328 |
+
Dict: 包含响应内容或流对象的字典
|
329 |
+
"""
|
330 |
+
with self.lock: # 线程安全
|
331 |
+
self.last_error = None
|
332 |
+
|
333 |
+
# 会话检查和创建
|
334 |
+
if not self.session_id:
|
335 |
+
self.last_error = "没有可用的会话 ID。正在尝试创建新会话。"
|
336 |
+
self._log(self.last_error, level="WARNING")
|
337 |
+
if not self.create_session():
|
338 |
+
self.last_error = f"查询失败:会话创建失败。最近的客户端错误: {self.last_error}"
|
339 |
+
self._log(self.last_error, level="ERROR")
|
340 |
+
return {"error": self.last_error}
|
341 |
+
|
342 |
+
if not self.token:
|
343 |
+
self.last_error = "发送查询没有可用的 token。"
|
344 |
+
self._log(self.last_error, level="ERROR")
|
345 |
+
return {"error": self.last_error}
|
346 |
+
|
347 |
+
url = f"{self.chat_base_url}/sessions/{self.session_id}/query"
|
348 |
+
|
349 |
+
# 处理 query 输入
|
350 |
+
current_query = ""
|
351 |
+
if query is None:
|
352 |
+
self._log("警告:查询内容为None,已替换为空字符串", level="WARNING")
|
353 |
+
elif not isinstance(query, str):
|
354 |
+
current_query = str(query)
|
355 |
+
self._log(f"警告:查询内容不是字符串类型,已转换为字符串: {type(query)} -> {type(current_query)}", level="WARNING")
|
356 |
+
else:
|
357 |
+
current_query = query
|
358 |
+
|
359 |
+
# 优先使用 full_query_override
|
360 |
+
query_to_send = full_query_override if full_query_override is not None else current_query
|
361 |
+
if full_query_override is not None:
|
362 |
+
self._log(f"使用 full_query_override (长度: {len(full_query_override)}) 代替原始 query。", "DEBUG")
|
363 |
+
|
364 |
+
payload = {
|
365 |
+
"endpointId": endpoint_id,
|
366 |
+
"query": query_to_send, # 使用处理后的 query 或 override
|
367 |
+
"pluginIds": [],
|
368 |
+
"responseMode": "stream" if stream else "sync",
|
369 |
+
"debugMode": "on" if config.get_config_value('debug_mode') else "off",
|
370 |
+
"fulfillmentOnly": False
|
371 |
+
}
|
372 |
+
|
373 |
+
# 处理 model_configs_input
|
374 |
+
if model_configs_input:
|
375 |
+
# 直接使用传入的 model_configs_input,只包含非 None 值
|
376 |
+
# API 应该能处理额外的、非预期的配置项,或者忽略它们
|
377 |
+
# 如果API严格要求特定字段,那么这里的逻辑需要更精确地过滤
|
378 |
+
processed_model_configs = {k: v for k, v in model_configs_input.items() if v is not None}
|
379 |
+
if processed_model_configs: # 只有当有有效配置时才添加modelConfigs
|
380 |
+
payload["modelConfigs"] = processed_model_configs
|
381 |
+
|
382 |
+
self._log(f"最终的payload: {json.dumps(payload, ensure_ascii=False)}", level="DEBUG")
|
383 |
+
|
384 |
+
headers = {
|
385 |
+
'Content-Type': "application/json",
|
386 |
+
'Authorization': f"Bearer {self.token}",
|
387 |
+
'x-company-id': self.company_id
|
388 |
+
}
|
389 |
+
|
390 |
+
truncated_query_log = current_query[:100] + "..." if len(current_query) > 100 else current_query
|
391 |
+
self._log(f"向端点 {endpoint_id} 发送查询 (stream={stream})。查询内容: {truncated_query_log}")
|
392 |
+
|
393 |
+
try:
|
394 |
+
response = self._do_request('POST', url, headers, payload, stream=True, timeout=config.get_config_value('stream_timeout'))
|
395 |
+
|
396 |
+
if stream:
|
397 |
+
self._log("返回流式响应对象供外部处理")
|
398 |
+
return {"stream": True, "response_obj": response}
|
399 |
+
else: # stream (方法参数) 为 False
|
400 |
+
full_answer = ""
|
401 |
+
try:
|
402 |
+
# 既然 _do_request 总是 stream=True,我们仍然需要消耗这个流。
|
403 |
+
# OnDemand API 在 responseMode="sync" 时,理论上应该直接返回完整内容。
|
404 |
+
|
405 |
+
response_body = response.text # 读取整个响应体
|
406 |
+
response.close() # 确保连接关闭
|
407 |
+
|
408 |
+
self._log(f"非流式响应原始文本 (前500字符): {response_body[:500]}", "DEBUG")
|
409 |
+
|
410 |
+
try:
|
411 |
+
# 优先尝试将整个响应体按单个JSON对象解析
|
412 |
+
data = json.loads(response_body)
|
413 |
+
if isinstance(data, dict):
|
414 |
+
if "answer" in data and isinstance(data["answer"], str):
|
415 |
+
full_answer = data["answer"]
|
416 |
+
elif "content" in data and isinstance(data["content"], str): # 备选字段
|
417 |
+
full_answer = data["content"]
|
418 |
+
elif data.get("eventType") == "fulfillment" and "answer" in data:
|
419 |
+
full_answer = data.get("answer", "")
|
420 |
+
else:
|
421 |
+
if not full_answer: # 避免覆盖已找到的答案
|
422 |
+
self._log(f"非流式响应解析为JSON后,未在顶层或常见字段找到答案: {response_body[:200]}", "WARNING")
|
423 |
+
else:
|
424 |
+
self._log(f"非流式响应解析为JSON后,不是字典类型: {type(data)}", "WARNING")
|
425 |
+
|
426 |
+
except json.JSONDecodeError:
|
427 |
+
# 如果直接解析JSON失败,再尝试按行解析SSE(作为后备)
|
428 |
+
self._log(f"非流式响应直接解析JSON失败,尝试按SSE行解析: {response_body[:200]}", "WARNING")
|
429 |
+
for line in response_body.splitlines():
|
430 |
+
if line:
|
431 |
+
decoded_line = line #已经是str
|
432 |
+
if decoded_line.startswith("data:"):
|
433 |
+
json_str = decoded_line[len("data:"):].strip()
|
434 |
+
if json_str == "[DONE]":
|
435 |
+
break
|
436 |
+
try:
|
437 |
+
event_data = json.loads(json_str)
|
438 |
+
if event_data.get("eventType", "") == "fulfillment":
|
439 |
+
full_answer += event_data.get("answer", "")
|
440 |
+
except json.JSONDecodeError:
|
441 |
+
self._log(f"非流式后备SSE解析时 JSONDecodeError: {json_str}", level="WARNING")
|
442 |
+
continue
|
443 |
+
|
444 |
+
self._log(f"非流式响应接收完毕。聚合内容长度: {len(full_answer)}")
|
445 |
+
return {"stream": False, "content": full_answer}
|
446 |
+
|
447 |
+
except requests.exceptions.RequestException as e: # 这应该在 _do_request 中捕获并重试
|
448 |
+
self.last_error = f"非流式请求时发生错误: {e}"
|
449 |
+
self._log(self.last_error, level="ERROR")
|
450 |
+
# 如果 _do_request 抛异常到这里,说明重试也失败了
|
451 |
+
# raise e # 或者返回错误结构体,让上层处理
|
452 |
+
return {"error": self.last_error, "stream": False, "content": ""}
|
453 |
+
except Exception as e:
|
454 |
+
self.last_error = f"非流式处理中发生意外错误: {e}"
|
455 |
+
self._log(self.last_error, level="ERROR")
|
456 |
+
return {"error": self.last_error, "stream": False, "content": ""}
|
457 |
+
|
458 |
+
except requests.exceptions.RequestException as e:
|
459 |
+
self.last_error = f"请求失败: {e}"
|
460 |
+
self._log(f"查询失败: {e}", level="ERROR")
|
461 |
+
raise
|
462 |
+
|
463 |
+
except Exception as e:
|
464 |
+
error_message = f"send_query 过程中发生意外错误: {e}"
|
465 |
+
error_type = type(e).__name__
|
466 |
+
self.last_error = error_message
|
467 |
+
self._log(f"{error_message} (错误类型: {error_type})", level="CRITICAL")
|
468 |
+
return {"error": str(e)}
|
config.py
ADDED
@@ -0,0 +1,400 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import json
|
3 |
+
import time
|
4 |
+
from collections import defaultdict
|
5 |
+
import threading
|
6 |
+
from typing import Dict, List, Any, Optional, Union, get_type_hints
|
7 |
+
from datetime import datetime, timedelta
|
8 |
+
from utils import logger, load_config
|
9 |
+
|
10 |
+
|
11 |
+
class Config:
|
12 |
+
"""配置管理类,用于存储和管理所有配置"""
|
13 |
+
|
14 |
+
# 默认配置值
|
15 |
+
_defaults = {
|
16 |
+
"ondemand_session_timeout_minutes": 30, # OnDemand 会话的活跃超时时间(分钟)
|
17 |
+
"session_timeout_minutes": 3600, # 会话不活动超时时间(分钟)- 增加以减少创建新会话的频率
|
18 |
+
"max_retries": 5, # 默认重试次数 - 增加以处理更多错误
|
19 |
+
"retry_delay": 3, # 默认重试延迟(秒)- 增加以减少请求频率
|
20 |
+
"request_timeout": 45, # 默认请求超时(秒)- 增加以允许更长的处理时间
|
21 |
+
"stream_timeout": 180, # 流式请求的默认超时(秒)- 增加以允许更长的处理时间
|
22 |
+
"rate_limit": 30, # 默认速率限制(每分钟请求数)- 减少以避免触发API速率限制
|
23 |
+
"account_cooldown_seconds": 300, # 账户冷却期(秒)- 在遇到429错误后暂时不使用该账户
|
24 |
+
"debug_mode": False, # 调试模式
|
25 |
+
"api_access_token": "sk-2api-ondemand-access-token-2025", # API访问认证Token
|
26 |
+
"stats_file_path": "stats_data.json", # 统计数据文件路径
|
27 |
+
"stats_backup_path": "stats_data_backup.json", # 统计数据备份文件路径
|
28 |
+
"stats_save_interval": 300, # 每5分钟保存一次统计数据
|
29 |
+
"max_history_items": 1000, # 最多保存的历史记录数量
|
30 |
+
"default_endpoint_id": "predefined-claude-3.7-sonnet" # 备用/默认端点 ID
|
31 |
+
}
|
32 |
+
|
33 |
+
# 模型名称映射:OpenAI 模型名 -> on-demand.io endpointId
|
34 |
+
_model_mapping = {
|
35 |
+
"gpt-3.5-turbo": "predefined-openai-gpto3-mini",
|
36 |
+
"gpto3-mini": "predefined-openai-gpto3-mini",
|
37 |
+
"gpt-4o": "predefined-openai-gpt4o",
|
38 |
+
"gpt-4o-mini": "predefined-openai-gpt4o-mini",
|
39 |
+
"gpt-4-turbo": "predefined-openai-gpt4.1", # gpt-4.1 的别名
|
40 |
+
"gpt-4.1": "predefined-openai-gpt4.1",
|
41 |
+
"gpt-4.1-mini": "predefined-openai-gpt4.1-mini",
|
42 |
+
"gpt-4.1-nano": "predefined-openai-gpt4.1-nano",
|
43 |
+
"deepseek-v3": "predefined-deepseek-v3",
|
44 |
+
"deepseek-r1": "predefined-deepseek-r1",
|
45 |
+
"claude-3.5-sonnet": "predefined-claude-3.5-sonnet",
|
46 |
+
"claude-3.7-sonnet": "predefined-claude-3.7-sonnet",
|
47 |
+
"claude-3-opus": "predefined-claude-3-opus",
|
48 |
+
"claude-3-haiku": "predefined-claude-3-haiku",
|
49 |
+
"gemini-1.5-pro": "predefined-gemini-2.0-flash",
|
50 |
+
"gemini-2.0-flash": "predefined-gemini-2.0-flash",
|
51 |
+
# 根据需要添加更多映射
|
52 |
+
}
|
53 |
+
|
54 |
+
def __init__(self):
|
55 |
+
"""初始化配置对象"""
|
56 |
+
# 从默认值初始化配置
|
57 |
+
self._config = self._defaults.copy()
|
58 |
+
|
59 |
+
# 用量统计
|
60 |
+
self.usage_stats = {
|
61 |
+
"total_requests": 0,
|
62 |
+
"successful_requests": 0,
|
63 |
+
"failed_requests": 0,
|
64 |
+
"model_usage": defaultdict(int), # 模型使用次数
|
65 |
+
"account_usage": defaultdict(int), # 账户使用次数
|
66 |
+
"daily_usage": defaultdict(int), # 每日使用次数
|
67 |
+
"hourly_usage": defaultdict(int), # 每小时使用次数
|
68 |
+
"request_history": [], # 请求历史记录
|
69 |
+
"total_prompt_tokens": 0, # 总提示tokens
|
70 |
+
"total_completion_tokens": 0, # 总完成tokens
|
71 |
+
"total_tokens": 0, # 总tokens
|
72 |
+
"model_tokens": defaultdict(int), # 每个模型的tokens使用量
|
73 |
+
"daily_tokens": defaultdict(int), # 每日tokens使用量
|
74 |
+
"hourly_tokens": defaultdict(int), # 每小时tokens使用量
|
75 |
+
"last_saved": datetime.now().isoformat() # 最后保存时间
|
76 |
+
}
|
77 |
+
|
78 |
+
# 线程锁
|
79 |
+
self.usage_stats_lock = threading.Lock() # 用于线程安全的统计数据访问
|
80 |
+
self.account_index_lock = threading.Lock() # 用于线程安全的账户选择
|
81 |
+
self.client_sessions_lock = threading.Lock() # 用于线程安全的会话管理
|
82 |
+
|
83 |
+
# 当前账户索引(用于创建新客户端会话时的轮询选择)
|
84 |
+
self.current_account_index = 0
|
85 |
+
|
86 |
+
# 内存中存储每个客户端的会话和最后交互时间
|
87 |
+
# 格式: {用户标识符: {账户邮箱: {"client": OnDemandAPIClient实例, "last_time": datetime对象}}}
|
88 |
+
# 这样确保不同用户的会话是隔离的,每个用户只能访问自己的会话
|
89 |
+
self.client_sessions = {}
|
90 |
+
|
91 |
+
# 账户信息
|
92 |
+
self.accounts = []
|
93 |
+
|
94 |
+
# 账户冷却期记录 - 存储因速率限制而暂时不使用的账户
|
95 |
+
# 格式: {账户邮箱: 冷却期结束时间(datetime对象)}
|
96 |
+
self.account_cooldowns = {}
|
97 |
+
|
98 |
+
def get(self, key: str, default: Any = None) -> Any:
|
99 |
+
"""获取配置值"""
|
100 |
+
return self._config.get(key, default)
|
101 |
+
|
102 |
+
def set(self, key: str, value: Any) -> None:
|
103 |
+
"""设置配置值"""
|
104 |
+
self._config[key] = value
|
105 |
+
|
106 |
+
def update(self, config_dict: Dict[str, Any]) -> None:
|
107 |
+
"""批量更新配置值"""
|
108 |
+
self._config.update(config_dict)
|
109 |
+
|
110 |
+
def get_model_endpoint(self, model_name: str) -> str:
|
111 |
+
"""获取模型对应的端点ID"""
|
112 |
+
return self._model_mapping.get(model_name, self.get("default_endpoint_id"))
|
113 |
+
|
114 |
+
def load_from_file(self) -> bool:
|
115 |
+
"""从配置文件加载配置"""
|
116 |
+
try:
|
117 |
+
# utils.load_config() 当前不接受 file_path 参数,因此移除
|
118 |
+
config_data = load_config()
|
119 |
+
if config_data:
|
120 |
+
# 更新配置
|
121 |
+
for key, value in config_data.items():
|
122 |
+
if key != "accounts": # 账户信息单独处理
|
123 |
+
self.set(key, value)
|
124 |
+
|
125 |
+
# 处理账户信息
|
126 |
+
if "accounts" in config_data:
|
127 |
+
self.accounts = config_data["accounts"]
|
128 |
+
|
129 |
+
logger.info("已从配置文件加载配置")
|
130 |
+
return True
|
131 |
+
return False
|
132 |
+
except Exception as e:
|
133 |
+
logger.error(f"加载配置文件时出错: {e}")
|
134 |
+
return False
|
135 |
+
|
136 |
+
def load_from_env(self) -> None:
|
137 |
+
"""从环境变量加载配置"""
|
138 |
+
# 从环境变量加载账户信息
|
139 |
+
if not self.accounts:
|
140 |
+
accounts_env = os.getenv("ONDEMAND_ACCOUNTS", "")
|
141 |
+
if accounts_env:
|
142 |
+
try:
|
143 |
+
self.accounts = json.loads(accounts_env).get('accounts', [])
|
144 |
+
logger.info("已从环境变量加载账户信息")
|
145 |
+
except json.JSONDecodeError:
|
146 |
+
logger.error("解码 ONDEMAND_ACCOUNTS 环境变量失败")
|
147 |
+
|
148 |
+
# 从环境变量加载其他设置
|
149 |
+
env_mappings = {
|
150 |
+
"ondemand_session_timeout_minutes": "ONDEMAND_SESSION_TIMEOUT_MINUTES",
|
151 |
+
"session_timeout_minutes": "SESSION_TIMEOUT_MINUTES",
|
152 |
+
"max_retries": "MAX_RETRIES",
|
153 |
+
"retry_delay": "RETRY_DELAY",
|
154 |
+
"request_timeout": "REQUEST_TIMEOUT",
|
155 |
+
"stream_timeout": "STREAM_TIMEOUT",
|
156 |
+
"rate_limit": "RATE_LIMIT",
|
157 |
+
"debug_mode": "DEBUG_MODE",
|
158 |
+
"api_access_token": "API_ACCESS_TOKEN"
|
159 |
+
}
|
160 |
+
|
161 |
+
for config_key, env_key in env_mappings.items():
|
162 |
+
env_value = os.getenv(env_key)
|
163 |
+
if env_value is not None:
|
164 |
+
# 根据默认值的类型进行转换
|
165 |
+
default_value = self.get(config_key)
|
166 |
+
if isinstance(default_value, bool):
|
167 |
+
self.set(config_key, env_value.lower() == 'true')
|
168 |
+
elif isinstance(default_value, int):
|
169 |
+
self.set(config_key, int(env_value))
|
170 |
+
elif isinstance(default_value, float):
|
171 |
+
self.set(config_key, float(env_value))
|
172 |
+
else:
|
173 |
+
self.set(config_key, env_value)
|
174 |
+
|
175 |
+
def save_stats_to_file(self):
|
176 |
+
"""将统计数据保存到文件中"""
|
177 |
+
try:
|
178 |
+
with self.usage_stats_lock:
|
179 |
+
# 创建统计数据的副本
|
180 |
+
stats_copy = {
|
181 |
+
"total_requests": self.usage_stats["total_requests"],
|
182 |
+
"successful_requests": self.usage_stats["successful_requests"],
|
183 |
+
"failed_requests": self.usage_stats["failed_requests"],
|
184 |
+
"model_usage": dict(self.usage_stats["model_usage"]),
|
185 |
+
"account_usage": dict(self.usage_stats["account_usage"]),
|
186 |
+
"daily_usage": dict(self.usage_stats["daily_usage"]),
|
187 |
+
"hourly_usage": dict(self.usage_stats["hourly_usage"]),
|
188 |
+
"request_history": list(self.usage_stats["request_history"]),
|
189 |
+
"total_prompt_tokens": self.usage_stats["total_prompt_tokens"],
|
190 |
+
"total_completion_tokens": self.usage_stats["total_completion_tokens"],
|
191 |
+
"total_tokens": self.usage_stats["total_tokens"],
|
192 |
+
"model_tokens": dict(self.usage_stats["model_tokens"]),
|
193 |
+
"daily_tokens": dict(self.usage_stats["daily_tokens"]),
|
194 |
+
"hourly_tokens": dict(self.usage_stats["hourly_tokens"]),
|
195 |
+
"last_saved": datetime.now().isoformat()
|
196 |
+
}
|
197 |
+
|
198 |
+
stats_file_path = self.get("stats_file_path")
|
199 |
+
stats_backup_path = self.get("stats_backup_path")
|
200 |
+
|
201 |
+
# 先保存到备份文件,然后重命名,避免写入过程中的文件损坏
|
202 |
+
with open(stats_backup_path, 'w', encoding='utf-8') as f:
|
203 |
+
json.dump(stats_copy, f, ensure_ascii=False, indent=2)
|
204 |
+
|
205 |
+
# 如果主文件存在,先删除它
|
206 |
+
if os.path.exists(stats_file_path):
|
207 |
+
os.remove(stats_file_path)
|
208 |
+
|
209 |
+
# 将备份文件重命名为主文件
|
210 |
+
os.rename(stats_backup_path, stats_file_path)
|
211 |
+
|
212 |
+
logger.info(f"统计数据已保存到 {stats_file_path}")
|
213 |
+
self.usage_stats["last_saved"] = datetime.now().isoformat()
|
214 |
+
except Exception as e:
|
215 |
+
logger.error(f"保存统计数据时出错: {e}")
|
216 |
+
|
217 |
+
def load_stats_from_file(self):
|
218 |
+
"""从文件中加载统计数据"""
|
219 |
+
try:
|
220 |
+
stats_file_path = self.get("stats_file_path")
|
221 |
+
if os.path.exists(stats_file_path):
|
222 |
+
with open(stats_file_path, 'r', encoding='utf-8') as f:
|
223 |
+
saved_stats = json.load(f)
|
224 |
+
|
225 |
+
with self.usage_stats_lock:
|
226 |
+
# 更新基本计数器
|
227 |
+
self.usage_stats["total_requests"] = saved_stats.get("total_requests", 0)
|
228 |
+
self.usage_stats["successful_requests"] = saved_stats.get("successful_requests", 0)
|
229 |
+
self.usage_stats["failed_requests"] = saved_stats.get("failed_requests", 0)
|
230 |
+
self.usage_stats["total_prompt_tokens"] = saved_stats.get("total_prompt_tokens", 0)
|
231 |
+
self.usage_stats["total_completion_tokens"] = saved_stats.get("total_completion_tokens", 0)
|
232 |
+
self.usage_stats["total_tokens"] = saved_stats.get("total_tokens", 0)
|
233 |
+
|
234 |
+
# 更新字典类型的统计数据
|
235 |
+
for model, count in saved_stats.get("model_usage", {}).items():
|
236 |
+
self.usage_stats["model_usage"][model] = count
|
237 |
+
|
238 |
+
for account, count in saved_stats.get("account_usage", {}).items():
|
239 |
+
self.usage_stats["account_usage"][account] = count
|
240 |
+
|
241 |
+
for day, count in saved_stats.get("daily_usage", {}).items():
|
242 |
+
self.usage_stats["daily_usage"][day] = count
|
243 |
+
|
244 |
+
for hour, count in saved_stats.get("hourly_usage", {}).items():
|
245 |
+
self.usage_stats["hourly_usage"][hour] = count
|
246 |
+
|
247 |
+
for model, tokens in saved_stats.get("model_tokens", {}).items():
|
248 |
+
self.usage_stats["model_tokens"][model] = tokens
|
249 |
+
|
250 |
+
for day, tokens in saved_stats.get("daily_tokens", {}).items():
|
251 |
+
self.usage_stats["daily_tokens"][day] = tokens
|
252 |
+
|
253 |
+
for hour, tokens in saved_stats.get("hourly_tokens", {}).items():
|
254 |
+
self.usage_stats["hourly_tokens"][hour] = tokens
|
255 |
+
|
256 |
+
# 更新请求历史
|
257 |
+
self.usage_stats["request_history"] = saved_stats.get("request_history", [])
|
258 |
+
|
259 |
+
# 限制历史记录数量
|
260 |
+
max_history_items = self.get("max_history_items")
|
261 |
+
if len(self.usage_stats["request_history"]) > max_history_items:
|
262 |
+
self.usage_stats["request_history"] = self.usage_stats["request_history"][-max_history_items:]
|
263 |
+
|
264 |
+
logger.info(f"已从 {stats_file_path} 加载统计数据")
|
265 |
+
return True
|
266 |
+
else:
|
267 |
+
logger.info(f"未找到统计数据文件 {stats_file_path},将使用默认值")
|
268 |
+
return False
|
269 |
+
except Exception as e:
|
270 |
+
logger.error(f"加载统计数据时出错: {e}")
|
271 |
+
return False
|
272 |
+
|
273 |
+
def start_stats_save_thread(self):
|
274 |
+
"""启动定期保存统计数据的线程"""
|
275 |
+
def save_stats_periodically():
|
276 |
+
while True:
|
277 |
+
time.sleep(self.get("stats_save_interval"))
|
278 |
+
self.save_stats_to_file()
|
279 |
+
|
280 |
+
save_thread = threading.Thread(target=save_stats_periodically, daemon=True)
|
281 |
+
save_thread.start()
|
282 |
+
logger.info(f"统计数据保存线程已启动,每 {self.get('stats_save_interval')} 秒保存一次")
|
283 |
+
|
284 |
+
def init(self):
|
285 |
+
"""初始化配置,从配置文件或环境变量加载设置"""
|
286 |
+
# 从配置文件加载配置
|
287 |
+
self.load_from_file()
|
288 |
+
|
289 |
+
# 从环境变量加载配置
|
290 |
+
self.load_from_env()
|
291 |
+
|
292 |
+
# 验证账户信息
|
293 |
+
if not self.accounts:
|
294 |
+
error_msg = "在 config.json 或环境变量 ONDEMAND_ACCOUNTS 中未找到账户信息"
|
295 |
+
logger.critical(error_msg)
|
296 |
+
# 不抛出异常,而是继续运行
|
297 |
+
logger.warning("将继续运行,但没有账户信息,可能会导致功能受限")
|
298 |
+
|
299 |
+
logger.info("已加载API访问Token")
|
300 |
+
|
301 |
+
# 加载之前保存的统计数据
|
302 |
+
self.load_stats_from_file()
|
303 |
+
|
304 |
+
# 启动定期保存统计数据的线程
|
305 |
+
self.start_stats_save_thread()
|
306 |
+
|
307 |
+
def get_next_ondemand_account_details(self):
|
308 |
+
"""获取下一个 OnDemand 账户的邮箱和密码,用于轮询。
|
309 |
+
会跳过处于冷却期的账户。"""
|
310 |
+
with self.account_index_lock:
|
311 |
+
current_time = datetime.now()
|
312 |
+
|
313 |
+
# 清理过期的冷却记录
|
314 |
+
expired_cooldowns = [email for email, end_time in self.account_cooldowns.items()
|
315 |
+
if end_time < current_time]
|
316 |
+
for email in expired_cooldowns:
|
317 |
+
del self.account_cooldowns[email]
|
318 |
+
logger.info(f"账户 {email} 的冷却期已结束,现在可用")
|
319 |
+
|
320 |
+
# 尝试最多len(self.accounts)次,以找到一个不在冷却期的账户
|
321 |
+
for _ in range(len(self.accounts)):
|
322 |
+
account_details = self.accounts[self.current_account_index]
|
323 |
+
email = account_details.get('email')
|
324 |
+
|
325 |
+
# 更新索引到下一个账户,为下次调用做准备
|
326 |
+
self.current_account_index = (self.current_account_index + 1) % len(self.accounts)
|
327 |
+
|
328 |
+
# 检查账户是否在冷却期
|
329 |
+
if email in self.account_cooldowns:
|
330 |
+
cooldown_end = self.account_cooldowns[email]
|
331 |
+
remaining_seconds = (cooldown_end - current_time).total_seconds()
|
332 |
+
logger.warning(f"账户 {email} 仍在冷却期中,还剩 {remaining_seconds:.1f} 秒")
|
333 |
+
continue # 尝试下一个账户
|
334 |
+
|
335 |
+
# 找到一个可用账户
|
336 |
+
logger.info(f"[系统] 新会话将使用账户: {email}")
|
337 |
+
return email, account_details.get('password')
|
338 |
+
|
339 |
+
# 如果所有账户都在冷却期,使用第一个账户(即使它在冷却期)
|
340 |
+
logger.warning("所有账户都在冷却期!使用第一个账户,尽管它可能会触发速率限制")
|
341 |
+
account_details = self.accounts[0]
|
342 |
+
return account_details.get('email'), account_details.get('password')
|
343 |
+
|
344 |
+
|
345 |
+
# 创建全局配置实例
|
346 |
+
config_instance = Config()
|
347 |
+
|
348 |
+
def init_config():
|
349 |
+
"""初始化配置的兼容函数,用于向后兼容"""
|
350 |
+
config_instance.init()
|
351 |
+
|
352 |
+
|
353 |
+
def get_config_value(name: str, default: Any = None) -> Any:
|
354 |
+
"""
|
355 |
+
获取当前配置变量的最新值。
|
356 |
+
推荐外部通过 config.get_config_value('变量名') 获取配置。
|
357 |
+
对于 accounts, model_mapping, usage_stats, client_sessions,请使用新增的专用getter函数。
|
358 |
+
"""
|
359 |
+
return config_instance.get(name, default)
|
360 |
+
|
361 |
+
# 新增的类型安全的getter函数
|
362 |
+
def get_accounts() -> List[Dict[str, str]]:
|
363 |
+
"""获取账户信息列表"""
|
364 |
+
return config_instance.accounts
|
365 |
+
|
366 |
+
def get_model_mapping() -> Dict[str, str]:
|
367 |
+
"""获取模型名称到端点ID的映射"""
|
368 |
+
return config_instance._model_mapping
|
369 |
+
|
370 |
+
def get_usage_stats() -> Dict[str, Any]:
|
371 |
+
"""获取用量统计数据"""
|
372 |
+
return config_instance.usage_stats
|
373 |
+
|
374 |
+
def get_client_sessions() -> Dict[str, Any]:
|
375 |
+
"""获取客户端会话信息"""
|
376 |
+
return config_instance.client_sessions
|
377 |
+
|
378 |
+
def get_next_ondemand_account_details():
|
379 |
+
"""获取下一个账户的兼容函数"""
|
380 |
+
return config_instance.get_next_ondemand_account_details()
|
381 |
+
|
382 |
+
def set_account_cooldown(email, cooldown_seconds=None):
|
383 |
+
"""设置账户冷却期
|
384 |
+
|
385 |
+
Args:
|
386 |
+
email: 账户邮箱
|
387 |
+
cooldown_seconds: 冷却时间(秒),如果为None则使用默认配置
|
388 |
+
"""
|
389 |
+
if cooldown_seconds is None:
|
390 |
+
cooldown_seconds = config_instance.get('account_cooldown_seconds')
|
391 |
+
|
392 |
+
cooldown_end = datetime.now() + timedelta(seconds=cooldown_seconds)
|
393 |
+
with config_instance.account_index_lock: # 使用相同的锁保护冷却期字典
|
394 |
+
config_instance.account_cooldowns[email] = cooldown_end
|
395 |
+
logger.warning(f"账户 {email} 已设置冷却期 {cooldown_seconds} 秒,将于 {cooldown_end.strftime('%Y-%m-%d %H:%M:%S')} 结束")
|
396 |
+
|
397 |
+
|
398 |
+
# ⚠️ 警告:为保证配置动态更新,请勿使用 from config import XXX,只使用 import config 并通过 config.get_config_value('变量名') 获取配置。
|
399 |
+
# 这样可确保配置值始终是最新的。
|
400 |
+
# (。•ᴗ-)ノ゙ 你的聪明小助手温馨提示~
|
requirements.txt
ADDED
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
1 |
+
flask
|
2 |
+
requests
|
3 |
+
tiktoken
|
4 |
+
regex
|
retry.py
ADDED
@@ -0,0 +1,408 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import time
|
2 |
+
import logging
|
3 |
+
import functools
|
4 |
+
import requests
|
5 |
+
from abc import ABC, abstractmethod
|
6 |
+
from typing import Callable, Any, Dict, Optional, Type, Union, TypeVar, cast
|
7 |
+
|
8 |
+
# 导入配置模块
|
9 |
+
import config
|
10 |
+
|
11 |
+
# 类型变量定义
|
12 |
+
T = TypeVar('T')
|
13 |
+
|
14 |
+
class RetryStrategy(ABC):
|
15 |
+
"""重试策略的抽象基类"""
|
16 |
+
|
17 |
+
@abstractmethod
|
18 |
+
def should_retry(self, exception: Exception, retry_count: int, max_retries: int) -> bool:
|
19 |
+
"""
|
20 |
+
判断是否应该重试
|
21 |
+
|
22 |
+
Args:
|
23 |
+
exception: 捕获的异常
|
24 |
+
retry_count: 当前重试次数
|
25 |
+
max_retries: 最大重试次数
|
26 |
+
|
27 |
+
Returns:
|
28 |
+
bool: 是否应该重试
|
29 |
+
"""
|
30 |
+
pass
|
31 |
+
|
32 |
+
@abstractmethod
|
33 |
+
def get_retry_delay(self, retry_count: int, base_delay: int) -> float:
|
34 |
+
"""
|
35 |
+
计算重试延迟时间
|
36 |
+
|
37 |
+
Args:
|
38 |
+
retry_count: 当前重试次数
|
39 |
+
base_delay: 基础延迟时间(秒)
|
40 |
+
|
41 |
+
Returns:
|
42 |
+
float: 重试延迟时间(秒)
|
43 |
+
"""
|
44 |
+
pass
|
45 |
+
|
46 |
+
@abstractmethod
|
47 |
+
def log_retry_attempt(self, logger: logging.Logger, exception: Exception,
|
48 |
+
retry_count: int, max_retries: int, delay: float) -> None:
|
49 |
+
"""
|
50 |
+
记录重试尝试
|
51 |
+
|
52 |
+
Args:
|
53 |
+
logger: 日志记录器
|
54 |
+
exception: 捕获的异常
|
55 |
+
retry_count: 当前重试次数
|
56 |
+
max_retries: 最大重试次数
|
57 |
+
delay: 重试延迟时间
|
58 |
+
"""
|
59 |
+
pass
|
60 |
+
|
61 |
+
@abstractmethod
|
62 |
+
def on_retry(self, exception: Exception, retry_count: int) -> None:
|
63 |
+
"""
|
64 |
+
重试前的回调函数,可以执行额外操作
|
65 |
+
|
66 |
+
Args:
|
67 |
+
exception: 捕获的异常
|
68 |
+
retry_count: 当前重试次数
|
69 |
+
"""
|
70 |
+
pass
|
71 |
+
|
72 |
+
|
73 |
+
class ExponentialBackoffStrategy(RetryStrategy):
|
74 |
+
"""指数退避重试策略,适用于连接错误"""
|
75 |
+
|
76 |
+
def should_retry(self, exception: Exception, retry_count: int, max_retries: int) -> bool:
|
77 |
+
return (isinstance(exception, requests.exceptions.ConnectionError) and
|
78 |
+
retry_count < max_retries)
|
79 |
+
|
80 |
+
def get_retry_delay(self, retry_count: int, base_delay: int) -> float:
|
81 |
+
# 指数退避: base_delay * 2^(retry_count)
|
82 |
+
return base_delay * (2 ** retry_count)
|
83 |
+
|
84 |
+
def log_retry_attempt(self, logger: logging.Logger, exception: Exception,
|
85 |
+
retry_count: int, max_retries: int, delay: float) -> None:
|
86 |
+
# 检查logger是否为函数对象(如client._log)
|
87 |
+
if callable(logger) and not isinstance(logger, logging.Logger):
|
88 |
+
# 如果是函数,直接调用它
|
89 |
+
logger(f"连接错误,{delay:.1f}秒后重试 ({retry_count}/{max_retries}): {exception}", "WARNING")
|
90 |
+
else:
|
91 |
+
# 如果是Logger对象,调用warning方法
|
92 |
+
logger.warning(f"连接错误,{delay:.1f}秒后重试 ({retry_count}/{max_retries}): {exception}")
|
93 |
+
|
94 |
+
def on_retry(self, exception: Exception, retry_count: int) -> None:
|
95 |
+
# 连接错误不需要额外操作
|
96 |
+
pass
|
97 |
+
|
98 |
+
|
99 |
+
class LinearBackoffStrategy(RetryStrategy):
|
100 |
+
"""线性退避重试策略,适用于超时错误"""
|
101 |
+
|
102 |
+
def should_retry(self, exception: Exception, retry_count: int, max_retries: int) -> bool:
|
103 |
+
return (isinstance(exception, requests.exceptions.Timeout) and
|
104 |
+
retry_count < max_retries)
|
105 |
+
|
106 |
+
def get_retry_delay(self, retry_count: int, base_delay: int) -> float:
|
107 |
+
# 线性退避: base_delay * retry_count
|
108 |
+
return base_delay * retry_count
|
109 |
+
|
110 |
+
def log_retry_attempt(self, logger: logging.Logger, exception: Exception,
|
111 |
+
retry_count: int, max_retries: int, delay: float) -> None:
|
112 |
+
# 检查logger是否为函数对象(如client._log)
|
113 |
+
if callable(logger) and not isinstance(logger, logging.Logger):
|
114 |
+
# 如果是函数,直接调用它
|
115 |
+
logger(f"请求超时,{delay:.1f}秒后重试 ({retry_count}/{max_retries}): {exception}", "WARNING")
|
116 |
+
else:
|
117 |
+
# 如果是Logger对象,调用warning方法
|
118 |
+
logger.warning(f"请求超时,{delay:.1f}秒后重试 ({retry_count}/{max_retries}): {exception}")
|
119 |
+
|
120 |
+
def on_retry(self, exception: Exception, retry_count: int) -> None:
|
121 |
+
# 超时错误不需要额外操作
|
122 |
+
pass
|
123 |
+
|
124 |
+
|
125 |
+
class ServerErrorStrategy(RetryStrategy):
|
126 |
+
"""服务器错误重试策略,适用于5xx错误"""
|
127 |
+
|
128 |
+
def should_retry(self, exception: Exception, retry_count: int, max_retries: int) -> bool:
|
129 |
+
if not isinstance(exception, requests.exceptions.HTTPError):
|
130 |
+
return False
|
131 |
+
|
132 |
+
response = getattr(exception, 'response', None)
|
133 |
+
if response is None:
|
134 |
+
return False
|
135 |
+
|
136 |
+
return (500 <= response.status_code < 600 and retry_count < max_retries)
|
137 |
+
|
138 |
+
def get_retry_delay(self, retry_count: int, base_delay: int) -> float:
|
139 |
+
# 线性退避: base_delay * retry_count
|
140 |
+
return base_delay * retry_count
|
141 |
+
|
142 |
+
def log_retry_attempt(self, logger: logging.Logger, exception: Exception,
|
143 |
+
retry_count: int, max_retries: int, delay: float) -> None:
|
144 |
+
response = getattr(exception, 'response', None)
|
145 |
+
status_code = response.status_code if response else 'unknown'
|
146 |
+
# 检查logger是否为函数对象(如client._log)
|
147 |
+
if callable(logger) and not isinstance(logger, logging.Logger):
|
148 |
+
# 如果是函数,直接调用它
|
149 |
+
logger(f"服务器错误 {status_code},{delay:.1f}秒后重试 ({retry_count}/{max_retries})", "WARNING")
|
150 |
+
else:
|
151 |
+
# 如果是Logger对象,调用warning方法
|
152 |
+
logger.warning(f"服务器错误 {status_code},{delay:.1f}秒后重试 ({retry_count}/{max_retries})")
|
153 |
+
|
154 |
+
def on_retry(self, exception: Exception, retry_count: int) -> None:
|
155 |
+
# 服务器错误不需要额外操作
|
156 |
+
pass
|
157 |
+
|
158 |
+
|
159 |
+
class RateLimitStrategy(RetryStrategy):
|
160 |
+
"""速率限制重试策略,适用于429错误,包括账号切换逻辑和延迟重试"""
|
161 |
+
|
162 |
+
def __init__(self, client=None):
|
163 |
+
"""
|
164 |
+
初始化速率限制重试策略
|
165 |
+
|
166 |
+
Args:
|
167 |
+
client: API客户端实例,用于切换账号
|
168 |
+
"""
|
169 |
+
self.client = client
|
170 |
+
self.consecutive_429_count = 0 # 连续429错误计数器
|
171 |
+
|
172 |
+
def should_retry(self, exception: Exception, retry_count: int, max_retries: int) -> bool:
|
173 |
+
if not isinstance(exception, requests.exceptions.HTTPError):
|
174 |
+
return False
|
175 |
+
|
176 |
+
response = getattr(exception, 'response', None)
|
177 |
+
if response is None:
|
178 |
+
return False
|
179 |
+
|
180 |
+
is_rate_limit = response.status_code == 429
|
181 |
+
if is_rate_limit:
|
182 |
+
self.consecutive_429_count += 1
|
183 |
+
else:
|
184 |
+
self.consecutive_429_count = 0 # 重置计数器
|
185 |
+
|
186 |
+
return is_rate_limit
|
187 |
+
|
188 |
+
def get_retry_delay(self, retry_count: int, base_delay: int) -> float:
|
189 |
+
# 根据用户反馈,429错误时不需要延迟,立即重试
|
190 |
+
return 0
|
191 |
+
|
192 |
+
def log_retry_attempt(self, logger: logging.Logger, exception: Exception,
|
193 |
+
retry_count: int, max_retries: int, delay: float) -> None:
|
194 |
+
# 检查logger是否为函数对象(如client._log)
|
195 |
+
message = ""
|
196 |
+
if self.consecutive_429_count > 1:
|
197 |
+
message = f"连续第{self.consecutive_429_count}次速率限制错误,尝试立即重试"
|
198 |
+
else:
|
199 |
+
message = "速率限制错误,尝试切换账号"
|
200 |
+
|
201 |
+
if callable(logger) and not isinstance(logger, logging.Logger):
|
202 |
+
# 如果是函数,直接调用它
|
203 |
+
logger(message, "WARNING")
|
204 |
+
else:
|
205 |
+
# 如果是Logger对象,调用warning方法
|
206 |
+
logger.warning(message)
|
207 |
+
|
208 |
+
def on_retry(self, exception: Exception, retry_count: int) -> None:
|
209 |
+
# 新增: 获取关联信息
|
210 |
+
user_identifier = getattr(self.client, '_associated_user_identifier', None)
|
211 |
+
request_ip = getattr(self.client, '_associated_request_ip', None) # request_ip 可能在某些情况下需要
|
212 |
+
|
213 |
+
# 只有在首次429错误或账号池中有多个账号时才切换账号
|
214 |
+
if self.consecutive_429_count == 1 or (self.consecutive_429_count > 0 and self.consecutive_429_count % 3 == 0):
|
215 |
+
if self.client and hasattr(self.client, 'email'):
|
216 |
+
# 记录当前账号进入冷却期
|
217 |
+
current_email = self.client.email # 这是切换前的 email
|
218 |
+
config.set_account_cooldown(current_email)
|
219 |
+
|
220 |
+
# 获取新账号
|
221 |
+
new_email, new_password = config.get_next_ondemand_account_details()
|
222 |
+
if new_email:
|
223 |
+
# 更新客户端信息
|
224 |
+
self.client.email = new_email # 这是切换后的 email
|
225 |
+
self.client.password = new_password
|
226 |
+
self.client.token = ""
|
227 |
+
self.client.refresh_token = ""
|
228 |
+
self.client.session_id = "" # 重置会话ID,确保创建新会话
|
229 |
+
|
230 |
+
# 尝试使用新账号登录并创建会话
|
231 |
+
try:
|
232 |
+
# 获取当前请求的上下文哈希,以便在切换账号后重新登录和创建会话时使用
|
233 |
+
current_context_hash = getattr(self.client, '_current_request_context_hash', None)
|
234 |
+
|
235 |
+
self.client.sign_in(context=current_context_hash)
|
236 |
+
if self.client.create_session(external_context=current_context_hash):
|
237 |
+
# 如果成功登录并创建会话,记录日志并设置标志位
|
238 |
+
if hasattr(self.client, '_log'):
|
239 |
+
self.client._log(f"成功切换到账号 {new_email} 并使用上下文哈希 '{current_context_hash}' 重新登录和创建新会话。", "INFO")
|
240 |
+
# 设置标志位,通知调用方下次需要发送完整历史
|
241 |
+
setattr(self.client, '_new_session_requires_full_history', True)
|
242 |
+
if hasattr(self.client, '_log'):
|
243 |
+
self.client._log(f"已设置 _new_session_requires_full_history = True,下次查询应发送完整历史。", "INFO")
|
244 |
+
else:
|
245 |
+
# 会话创建失败,记录错误
|
246 |
+
if hasattr(self.client, '_log'):
|
247 |
+
self.client._log(f"切换到账号 {new_email} 后,创建新会话失败。", "WARNING")
|
248 |
+
# 确保在这种情况下不设置需要完整历史的标志,因为会话本身就没成功
|
249 |
+
setattr(self.client, '_new_session_requires_full_history', False)
|
250 |
+
|
251 |
+
|
252 |
+
# --- 新增: 更新 client_sessions ---
|
253 |
+
if not user_identifier:
|
254 |
+
if hasattr(self.client, '_log'):
|
255 |
+
self.client._log("RateLimitStrategy: _associated_user_identifier not found on client. Cannot update client_sessions.", "ERROR")
|
256 |
+
# 即使没有 user_identifier,账号切换和会话创建也已发生,只是无法更新全局会话池
|
257 |
+
else:
|
258 |
+
old_email_in_strategy = current_email # 切换前的 email
|
259 |
+
new_email_in_strategy = self.client.email # 切换后的 email (即 new_email)
|
260 |
+
|
261 |
+
with config.config_instance.client_sessions_lock:
|
262 |
+
if user_identifier in config.config_instance.client_sessions:
|
263 |
+
user_specific_sessions = config.config_instance.client_sessions[user_identifier]
|
264 |
+
|
265 |
+
# 1. 移除旧 email 的条目 (如果存在)
|
266 |
+
# 我们只移除那些 client 实例确实是当前 self.client 的条目,
|
267 |
+
# 或者更简单地,如果旧 email 存在,就移除它,因为 user_identifier
|
268 |
+
# 现在应该通过 new_email 使用这个(已被修改的)client 实例。
|
269 |
+
if old_email_in_strategy in user_specific_sessions:
|
270 |
+
# 检查 client 实例是否匹配可能不可靠,因为 client 内部状态已变。
|
271 |
+
# 直接删除旧 email 的条目,因为这个 user_identifier + client 组合现在用新 email。
|
272 |
+
del user_specific_sessions[old_email_in_strategy]
|
273 |
+
if hasattr(self.client, '_log'):
|
274 |
+
self.client._log(f"RateLimitStrategy: Removed session for old email '{old_email_in_strategy}' for user '{user_identifier}'.", "INFO")
|
275 |
+
|
276 |
+
# 2. 添加/更新新 email 的条目
|
277 |
+
# 确保它指向当前这个已被修改的 self.client 实例
|
278 |
+
# 并重置 active_context_hash。
|
279 |
+
# IP 地址应来自 self.client._associated_request_ip 或 routes.py 中设置的值。
|
280 |
+
# 由于 routes.py 在创建/分配会话时已将 IP 存入 client_sessions,
|
281 |
+
# 这里我们主要关注 client 实例和 active_context_hash。
|
282 |
+
# 如果 request_ip 在 self.client 中可用,则使用它,否则尝试保留已有的。
|
283 |
+
ip_to_use = request_ip if request_ip else user_specific_sessions.get(new_email_in_strategy, {}).get("ip", "unknown_ip_in_retry_update")
|
284 |
+
|
285 |
+
# 需要导入 datetime
|
286 |
+
from datetime import datetime
|
287 |
+
|
288 |
+
# 从 client 实例获取原始请求的上下文哈希
|
289 |
+
# 这个哈希应该由 routes.py 在调用 send_query 之前设置到 client 实例上
|
290 |
+
active_hash_for_new_session = getattr(self.client, '_current_request_context_hash', None)
|
291 |
+
|
292 |
+
user_specific_sessions[new_email_in_strategy] = {
|
293 |
+
"client": self.client, # 关键: 指向当前更新了 email/session_id 的 client 实例
|
294 |
+
"active_context_hash": active_hash_for_new_session, # 使用来自 client 实例的哈希
|
295 |
+
"last_time": datetime.now(), # 更新时间
|
296 |
+
"ip": ip_to_use
|
297 |
+
}
|
298 |
+
log_message_hash_part = f"set to '{active_hash_for_new_session}' (from client instance's _current_request_context_hash)" if active_hash_for_new_session is not None else "set to None (_current_request_context_hash not found on client instance)"
|
299 |
+
if hasattr(self.client, '_log'):
|
300 |
+
self.client._log(f"RateLimitStrategy: Updated/added session for new email '{new_email_in_strategy}' for user '{user_identifier}'. active_context_hash {log_message_hash_part}.", "INFO")
|
301 |
+
else:
|
302 |
+
if hasattr(self.client, '_log'):
|
303 |
+
self.client._log(f"RateLimitStrategy: User '{user_identifier}' not found in client_sessions during update attempt.", "WARNING")
|
304 |
+
# --- 更新 client_sessions 结束 ---
|
305 |
+
|
306 |
+
except Exception as e:
|
307 |
+
# 登录或创建会话失败,记录错误但不抛出异常
|
308 |
+
# 让后续的重试机制处理
|
309 |
+
if hasattr(self.client, '_log'):
|
310 |
+
self.client._log(f"切换到账号 {new_email} 后登录或创建会话失败: {e}", "WARNING")
|
311 |
+
# 此处不应更新 client_sessions,因为新账号的会话未成功建立
|
312 |
+
|
313 |
+
|
314 |
+
class RetryHandler:
|
315 |
+
"""重试处理器,管理多个重试策略"""
|
316 |
+
|
317 |
+
def __init__(self, client=None, logger=None):
|
318 |
+
"""
|
319 |
+
初始化重试处理器
|
320 |
+
|
321 |
+
Args:
|
322 |
+
client: API客户端实例,用于切换账号
|
323 |
+
logger: 日志记录器或日志函数
|
324 |
+
"""
|
325 |
+
self.client = client
|
326 |
+
# 如果logger是None,使用默认logger
|
327 |
+
# 如果logger是函数或Logger对象,直接使用
|
328 |
+
self.logger = logger or logging.getLogger(__name__)
|
329 |
+
self.strategies = [
|
330 |
+
ExponentialBackoffStrategy(),
|
331 |
+
LinearBackoffStrategy(),
|
332 |
+
ServerErrorStrategy(),
|
333 |
+
RateLimitStrategy(client)
|
334 |
+
]
|
335 |
+
|
336 |
+
def retry_operation(self, operation: Callable[..., T], *args, **kwargs) -> T:
|
337 |
+
"""
|
338 |
+
使用重试策略执行操作
|
339 |
+
|
340 |
+
Args:
|
341 |
+
operation: 要执行的操作
|
342 |
+
*args: 操作的位置参数
|
343 |
+
**kwargs: 操作的关键字参数
|
344 |
+
|
345 |
+
Returns:
|
346 |
+
操作的结果
|
347 |
+
|
348 |
+
Raises:
|
349 |
+
Exception: 如果所有重试都失败,则抛出最后一个异常
|
350 |
+
"""
|
351 |
+
max_retries = config.get_config_value('max_retries')
|
352 |
+
base_delay = config.get_config_value('retry_delay')
|
353 |
+
retry_count = 0
|
354 |
+
last_exception = None
|
355 |
+
|
356 |
+
while True:
|
357 |
+
try:
|
358 |
+
return operation(*args, **kwargs)
|
359 |
+
except Exception as e:
|
360 |
+
last_exception = e
|
361 |
+
|
362 |
+
# 查找适用的重试策略
|
363 |
+
strategy = next((s for s in self.strategies if s.should_retry(e, retry_count, max_retries)), None)
|
364 |
+
|
365 |
+
if strategy:
|
366 |
+
retry_count += 1
|
367 |
+
delay = strategy.get_retry_delay(retry_count, base_delay)
|
368 |
+
strategy.log_retry_attempt(self.logger, e, retry_count, max_retries, delay)
|
369 |
+
strategy.on_retry(e, retry_count)
|
370 |
+
|
371 |
+
if delay > 0:
|
372 |
+
time.sleep(delay)
|
373 |
+
else:
|
374 |
+
# 没有适用的重试策略,或者已达到最大重试次数
|
375 |
+
raise
|
376 |
+
|
377 |
+
|
378 |
+
def with_retry(max_retries: Optional[int] = None, retry_delay: Optional[int] = None):
|
379 |
+
"""
|
380 |
+
重试装饰器,用于装饰需要重试的方法
|
381 |
+
|
382 |
+
Args:
|
383 |
+
max_retries: 最大重试次数,如果为None则使用配置值
|
384 |
+
retry_delay: 基础重试延迟,如果为None则使用配置值
|
385 |
+
|
386 |
+
Returns:
|
387 |
+
装饰后的函数
|
388 |
+
"""
|
389 |
+
def decorator(func):
|
390 |
+
@functools.wraps(func)
|
391 |
+
def wrapper(self, *args, **kwargs):
|
392 |
+
# 获取配置值
|
393 |
+
_max_retries = max_retries or config.get_config_value('max_retries')
|
394 |
+
_retry_delay = retry_delay or config.get_config_value('retry_delay')
|
395 |
+
|
396 |
+
# 创建重试处理器
|
397 |
+
handler = RetryHandler(client=self, logger=getattr(self, '_log', None))
|
398 |
+
|
399 |
+
# 定义要重试的操作
|
400 |
+
def operation():
|
401 |
+
return func(self, *args, **kwargs)
|
402 |
+
|
403 |
+
# 执行操作并处理重试
|
404 |
+
return handler.retry_operation(operation)
|
405 |
+
|
406 |
+
return wrapper
|
407 |
+
|
408 |
+
return decorator
|
routes.py
ADDED
@@ -0,0 +1,1043 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import json
|
2 |
+
import time
|
3 |
+
import uuid
|
4 |
+
import html
|
5 |
+
import hashlib # Added import
|
6 |
+
from datetime import datetime
|
7 |
+
from typing import Dict, List, Any, Optional
|
8 |
+
from flask import request, Response, stream_with_context, jsonify, render_template, redirect, url_for, flash
|
9 |
+
from datetime import datetime
|
10 |
+
|
11 |
+
from utils import logger, generate_request_id, count_tokens, count_message_tokens
|
12 |
+
import config
|
13 |
+
from auth import RateLimiter
|
14 |
+
from client import OnDemandAPIClient
|
15 |
+
from datetime import timedelta
|
16 |
+
|
17 |
+
# 初始化速率限制器
|
18 |
+
# rate_limiter 将在 config_instance 定义后初始化
|
19 |
+
|
20 |
+
# 获取配置实例
|
21 |
+
config_instance = config.config_instance
|
22 |
+
rate_limiter = RateLimiter(config_instance.get('rate_limit_per_minute', 60)) # 从配置读取,默认为60
|
23 |
+
|
24 |
+
# 模型价格配置将从 config_instance 获取
|
25 |
+
# 默认价格也将从 config_instance 获取
|
26 |
+
|
27 |
+
def format_datetime(timestamp):
|
28 |
+
"""将ISO格式时间戳格式化为更易读的格式"""
|
29 |
+
if not timestamp or timestamp == "从未保存":
|
30 |
+
return timestamp
|
31 |
+
|
32 |
+
try:
|
33 |
+
# 处理ISO格式时间戳
|
34 |
+
if 'T' in timestamp:
|
35 |
+
dt = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
|
36 |
+
return dt.strftime('%Y-%m-%d %H:%M:%S')
|
37 |
+
# 处理已经是格式化字符串的情况
|
38 |
+
return timestamp
|
39 |
+
except Exception:
|
40 |
+
return timestamp
|
41 |
+
|
42 |
+
def format_number(value):
|
43 |
+
"""根据数值大小自动转换单位"""
|
44 |
+
if value is None or value == '-':
|
45 |
+
return '-'
|
46 |
+
|
47 |
+
try:
|
48 |
+
value = float(value)
|
49 |
+
if value >= 1000000000000: # 万亿 (T)
|
50 |
+
return f"{value/1000000000000:.2f}T"
|
51 |
+
elif value >= 1000000000: # 十亿 (G)
|
52 |
+
return f"{value/1000000000:.2f}G"
|
53 |
+
elif value >= 1000000: # 百万 (M)
|
54 |
+
return f"{value/1000000:.2f}M"
|
55 |
+
elif value >= 1000: # 千 (K)
|
56 |
+
return f"{value/1000:.2f}K"
|
57 |
+
elif value == 0: # 零
|
58 |
+
return "0"
|
59 |
+
elif abs(value) < 0.01: # 非常小的数值,使用科学计数法
|
60 |
+
return f"{value:.2e}"
|
61 |
+
else:
|
62 |
+
return f"{value:.0f}" if value == int(value) else f"{value:.2f}"
|
63 |
+
except (ValueError, TypeError):
|
64 |
+
return str(value)
|
65 |
+
|
66 |
+
def format_duration(ms):
|
67 |
+
"""将毫秒格式化为更易读的格式"""
|
68 |
+
if ms is None or ms == '-':
|
69 |
+
return '-'
|
70 |
+
|
71 |
+
try:
|
72 |
+
ms = float(ms) # 使用float而不是int,以支持小数
|
73 |
+
if ms >= 86400000: # 超过1天 (24*60*60*1000)
|
74 |
+
return f"{ms/86400000:.2f}天"
|
75 |
+
elif ms >= 3600000: # 超过1小时 (60*60*1000)
|
76 |
+
return f"{ms/3600000:.2f}小时"
|
77 |
+
elif ms >= 60000: # 超过1分钟 (60*1000)
|
78 |
+
return f"{ms/60000:.2f}分钟"
|
79 |
+
elif ms >= 1000: # 超过1秒
|
80 |
+
return f"{ms/1000:.2f}秒"
|
81 |
+
else:
|
82 |
+
return f"{ms:.0f}" if ms == int(ms) else f"{ms:.2f}毫秒"
|
83 |
+
except (ValueError, TypeError):
|
84 |
+
return str(ms)
|
85 |
+
|
86 |
+
def _update_usage_statistics(
|
87 |
+
config_inst,
|
88 |
+
request_id: str,
|
89 |
+
requested_model_name: str,
|
90 |
+
account_email: Optional[str],
|
91 |
+
is_success: bool,
|
92 |
+
duration_ms: int,
|
93 |
+
is_stream: bool,
|
94 |
+
prompt_tokens_val: int,
|
95 |
+
completion_tokens_val: int,
|
96 |
+
total_tokens_val: int,
|
97 |
+
prompt_length: Optional[int] = None,
|
98 |
+
completion_length: Optional[int] = None,
|
99 |
+
error_message: Optional[str] = None,
|
100 |
+
used_actual_tokens_for_history: bool = False
|
101 |
+
):
|
102 |
+
"""更新使用统计与请求历史的辅助函数。"""
|
103 |
+
with config_inst.usage_stats_lock:
|
104 |
+
config_inst.usage_stats["total_requests"] += 1
|
105 |
+
|
106 |
+
current_email_for_stats = account_email if account_email else "unknown_account"
|
107 |
+
|
108 |
+
if is_success:
|
109 |
+
config_inst.usage_stats["successful_requests"] += 1
|
110 |
+
config_inst.usage_stats["model_usage"].setdefault(requested_model_name, 0)
|
111 |
+
config_inst.usage_stats["model_usage"][requested_model_name] += 1
|
112 |
+
|
113 |
+
config_inst.usage_stats["account_usage"].setdefault(current_email_for_stats, 0)
|
114 |
+
config_inst.usage_stats["account_usage"][current_email_for_stats] += 1
|
115 |
+
|
116 |
+
config_inst.usage_stats["total_prompt_tokens"] += prompt_tokens_val
|
117 |
+
config_inst.usage_stats["total_completion_tokens"] += completion_tokens_val
|
118 |
+
config_inst.usage_stats["total_tokens"] += total_tokens_val
|
119 |
+
config_inst.usage_stats["model_tokens"].setdefault(requested_model_name, 0)
|
120 |
+
config_inst.usage_stats["model_tokens"][requested_model_name] += total_tokens_val
|
121 |
+
|
122 |
+
today = datetime.now().strftime("%Y-%m-%d")
|
123 |
+
hour = datetime.now().strftime("%Y-%m-%d %H:00")
|
124 |
+
|
125 |
+
config_inst.usage_stats["daily_usage"].setdefault(today, 0)
|
126 |
+
config_inst.usage_stats["daily_usage"][today] += 1
|
127 |
+
|
128 |
+
config_inst.usage_stats["hourly_usage"].setdefault(hour, 0)
|
129 |
+
config_inst.usage_stats["hourly_usage"][hour] += 1
|
130 |
+
|
131 |
+
config_inst.usage_stats["daily_tokens"].setdefault(today, 0)
|
132 |
+
config_inst.usage_stats["daily_tokens"][today] += total_tokens_val
|
133 |
+
|
134 |
+
config_inst.usage_stats["hourly_tokens"].setdefault(hour, 0)
|
135 |
+
config_inst.usage_stats["hourly_tokens"][hour] += total_tokens_val
|
136 |
+
else:
|
137 |
+
config_inst.usage_stats["failed_requests"] += 1
|
138 |
+
|
139 |
+
history_entry = {
|
140 |
+
"id": request_id,
|
141 |
+
"timestamp": datetime.now().isoformat(),
|
142 |
+
"model": requested_model_name,
|
143 |
+
"account": current_email_for_stats,
|
144 |
+
"success": is_success,
|
145 |
+
"duration_ms": duration_ms,
|
146 |
+
"stream": is_stream,
|
147 |
+
}
|
148 |
+
|
149 |
+
if is_success:
|
150 |
+
if prompt_length is not None:
|
151 |
+
history_entry["prompt_length"] = prompt_length
|
152 |
+
if completion_length is not None:
|
153 |
+
history_entry["completion_length"] = completion_length
|
154 |
+
|
155 |
+
if is_stream:
|
156 |
+
if used_actual_tokens_for_history:
|
157 |
+
history_entry["prompt_tokens"] = prompt_tokens_val
|
158 |
+
history_entry["completion_tokens"] = completion_tokens_val
|
159 |
+
history_entry["total_tokens"] = total_tokens_val
|
160 |
+
else:
|
161 |
+
history_entry["prompt_tokens"] = prompt_tokens_val
|
162 |
+
history_entry["estimated_completion_tokens"] = completion_tokens_val
|
163 |
+
history_entry["estimated_total_tokens"] = total_tokens_val
|
164 |
+
else:
|
165 |
+
history_entry["prompt_tokens"] = prompt_tokens_val
|
166 |
+
history_entry["completion_tokens"] = completion_tokens_val
|
167 |
+
history_entry["total_tokens"] = total_tokens_val
|
168 |
+
else:
|
169 |
+
if error_message:
|
170 |
+
history_entry["error"] = error_message
|
171 |
+
if prompt_tokens_val > 0:
|
172 |
+
history_entry["prompt_tokens_attempted"] = prompt_tokens_val
|
173 |
+
|
174 |
+
config_inst.usage_stats["request_history"].append(history_entry)
|
175 |
+
max_history_items = config_inst.get('max_history_items', 1000)
|
176 |
+
if len(config_inst.usage_stats["request_history"]) > max_history_items:
|
177 |
+
config_inst.usage_stats["request_history"] = \
|
178 |
+
config_inst.usage_stats["request_history"][-max_history_items:]
|
179 |
+
|
180 |
+
def _generate_hash_for_full_history(full_messages_list: List[Dict[str, str]], req_id: str) -> Optional[str]:
|
181 |
+
"""
|
182 |
+
Generates a SHA256 hash from a list of messages, considering all messages.
|
183 |
+
"""
|
184 |
+
if not full_messages_list:
|
185 |
+
logger.debug(f"[{req_id}] (_generate_hash_for_full_history) No messages to hash.")
|
186 |
+
return None
|
187 |
+
try:
|
188 |
+
# Ensure consistent serialization for hashing
|
189 |
+
# Context meaning is only in role and content
|
190 |
+
simplified_history = [{"role": msg.get("role"), "content": msg.get("content")} for msg in full_messages_list]
|
191 |
+
serialized_history = json.dumps(simplified_history, sort_keys=True)
|
192 |
+
return hashlib.sha256(serialized_history.encode('utf-8')).hexdigest()
|
193 |
+
except (TypeError, ValueError) as e:
|
194 |
+
logger.error(f"[{req_id}] (_generate_hash_for_full_history) Failed to serialize full history messages for hashing: {e}")
|
195 |
+
return None
|
196 |
+
|
197 |
+
def _update_client_context_hash_after_reply(
|
198 |
+
original_request_messages: List[Dict[str, str]],
|
199 |
+
assistant_reply_content: str,
|
200 |
+
request_id: str,
|
201 |
+
user_identifier: str, # Corresponds to 'token' in chat_completions
|
202 |
+
email_for_stats: Optional[str],
|
203 |
+
current_ondemand_client_instance: Optional[OnDemandAPIClient],
|
204 |
+
config_inst: config.Config,
|
205 |
+
logger_instance # Pass logger directly
|
206 |
+
):
|
207 |
+
"""
|
208 |
+
Helper to update the client's active_context_hash after a successful reply
|
209 |
+
using the full conversation history up to the assistant's reply.
|
210 |
+
"""
|
211 |
+
if not assistant_reply_content or not email_for_stats or not current_ondemand_client_instance:
|
212 |
+
logger_instance.debug(f"[{request_id}] 更新客户端上下文哈希的条件不足(回复内容 '{bool(assistant_reply_content)}', 邮箱 '{email_for_stats}', 客户端实例 '{bool(current_ondemand_client_instance)}'),跳过。")
|
213 |
+
return
|
214 |
+
|
215 |
+
assistant_message = {"role": "assistant", "content": assistant_reply_content}
|
216 |
+
# original_request_messages should be the messages list as it was when the request came in.
|
217 |
+
full_history_up_to_assistant_reply = original_request_messages + [assistant_message]
|
218 |
+
|
219 |
+
next_active_context_hash = _generate_hash_for_full_history(full_history_up_to_assistant_reply, request_id)
|
220 |
+
|
221 |
+
if next_active_context_hash:
|
222 |
+
with config_inst.client_sessions_lock:
|
223 |
+
if user_identifier in config_inst.client_sessions and \
|
224 |
+
email_for_stats in config_inst.client_sessions[user_identifier]:
|
225 |
+
|
226 |
+
session_data_to_update = config_inst.client_sessions[user_identifier][email_for_stats]
|
227 |
+
client_in_session = session_data_to_update.get("client")
|
228 |
+
|
229 |
+
# DEBUGGING LOGS START
|
230 |
+
logger_instance.debug(f"[{request_id}] HASH_UPDATE_DEBUG: client_in_session id={id(client_in_session)}, email={getattr(client_in_session, 'email', 'N/A')}, session_id={getattr(client_in_session, 'session_id', 'N/A')}")
|
231 |
+
logger_instance.debug(f"[{request_id}] HASH_UPDATE_DEBUG: current_ondemand_client_instance id={id(current_ondemand_client_instance)}, email={getattr(current_ondemand_client_instance, 'email', 'N/A')}, session_id={getattr(current_ondemand_client_instance, 'session_id', 'N/A')}")
|
232 |
+
logger_instance.debug(f"[{request_id}] HASH_UPDATE_DEBUG: Comparison result (client_in_session == current_ondemand_client_instance): {client_in_session == current_ondemand_client_instance}")
|
233 |
+
logger_instance.debug(f"[{request_id}] HASH_UPDATE_DEBUG: Comparison result (client_in_session is current_ondemand_client_instance): {client_in_session is current_ondemand_client_instance}")
|
234 |
+
# DEBUGGING LOGS END
|
235 |
+
|
236 |
+
if client_in_session == current_ondemand_client_instance:
|
237 |
+
old_hash = session_data_to_update.get("active_context_hash")
|
238 |
+
session_data_to_update["active_context_hash"] = next_active_context_hash
|
239 |
+
session_data_to_update["last_time"] = datetime.now()
|
240 |
+
logger_instance.info(f"[{request_id}] 客户端 (账户: {email_for_stats}) 的 active_context_hash 已从 '{old_hash}' 更新为 '{next_active_context_hash}' 以反映对话进展。")
|
241 |
+
else:
|
242 |
+
logger_instance.warning(f"[{request_id}] 尝试更新哈希时,发现 email_for_stats '{email_for_stats}' 对应的存储客户端与当前使用的 ondemand_client 不一致。跳过更新。")
|
243 |
+
else:
|
244 |
+
logger_instance.warning(f"[{request_id}] 尝试更新哈希时,在 client_sessions 中未找到用户 '{user_identifier}' 或账户 '{email_for_stats}'。跳过更新。")
|
245 |
+
else:
|
246 |
+
logger_instance.warning(f"[{request_id}] 未能为下一次交互生成新的 active_context_hash (基于回复 '{bool(assistant_reply_content)}'). 客户端的哈希未更新。")
|
247 |
+
|
248 |
+
def _get_context_key_from_messages(messages: List[Dict[str, str]], req_id: str) -> Optional[str]:
|
249 |
+
"""
|
250 |
+
从末次用户消息前的消息列表生成上下文哈希密钥。
|
251 |
+
"""
|
252 |
+
if not messages:
|
253 |
+
logger.debug(f"[{req_id}] 无消息可供生成上下文密钥。")
|
254 |
+
return None
|
255 |
+
|
256 |
+
last_user_msg_idx = -1
|
257 |
+
for i in range(len(messages) - 1, -1, -1):
|
258 |
+
if messages[i].get('role') == 'user':
|
259 |
+
last_user_msg_idx = i
|
260 |
+
break
|
261 |
+
|
262 |
+
# 若无用户消息或用户消息为首条,则无先前历史可生成上下文密钥。
|
263 |
+
if last_user_msg_idx <= 0:
|
264 |
+
logger.debug(f"[{req_id}] 无先前历史可生成上下文密钥 (last_user_msg_idx: {last_user_msg_idx})。")
|
265 |
+
return None
|
266 |
+
|
267 |
+
historical_messages = messages[:last_user_msg_idx]
|
268 |
+
if not historical_messages: # 应由 last_user_msg_idx <= 0 捕获,此处为额外保障
|
269 |
+
logger.debug(f"[{req_id}] 上下文密钥的历史消息列表为空。")
|
270 |
+
return None
|
271 |
+
|
272 |
+
try:
|
273 |
+
# 确保哈希序列化的一致性
|
274 |
+
# 上下文意义仅关注角色和内容
|
275 |
+
simplified_history = [{"role": msg.get("role"), "content": msg.get("content")} for msg in historical_messages]
|
276 |
+
serialized_history = json.dumps(simplified_history, sort_keys=True)
|
277 |
+
return hashlib.sha256(serialized_history.encode('utf-8')).hexdigest()
|
278 |
+
except (TypeError, ValueError) as e:
|
279 |
+
logger.error(f"[{req_id}] 序列化历史消息以生成上下文密钥失败: {e}")
|
280 |
+
return None
|
281 |
+
|
282 |
+
def register_routes(app):
|
283 |
+
"""注册所有路由到Flask应用"""
|
284 |
+
|
285 |
+
# 注册自定义过滤器
|
286 |
+
app.jinja_env.filters['format_datetime'] = format_datetime
|
287 |
+
app.jinja_env.filters['format_number'] = format_number
|
288 |
+
app.jinja_env.filters['format_duration'] = format_duration
|
289 |
+
|
290 |
+
@app.route('/health', methods=['GET'])
|
291 |
+
def health_check():
|
292 |
+
"""健康检查端点,返回服务状态"""
|
293 |
+
return {"status": "ok", "message": "2API服务运行正常"}, 200
|
294 |
+
|
295 |
+
@app.route('/v1/models', methods=['GET'])
|
296 |
+
def list_models():
|
297 |
+
"""以 OpenAI 格式返回可用模型列表。"""
|
298 |
+
data = []
|
299 |
+
# 获取当前时间戳,用于 'created' 字段
|
300 |
+
created_time = int(time.time())
|
301 |
+
model_mapping = config_instance._model_mapping
|
302 |
+
for openai_name in model_mapping.keys(): # 仅列出已映射的模型
|
303 |
+
data.append({
|
304 |
+
"id": openai_name,
|
305 |
+
"object": "model",
|
306 |
+
"created": created_time,
|
307 |
+
"owned_by": "on-demand.io" # 或根据模型来源填写 "openai", "anthropic" 等
|
308 |
+
})
|
309 |
+
return {"object": "list", "data": data}
|
310 |
+
|
311 |
+
@app.route('/v1/chat/completions', methods=['POST'])
|
312 |
+
def chat_completions():
|
313 |
+
"""处理聊天补全请求,兼容 OpenAI 格式。"""
|
314 |
+
request_id = generate_request_id() # 生成唯一的请求 ID
|
315 |
+
logger.info(f"[{request_id}] CHAT_COMPLETIONS_ENTRY_POINT") # 最早的日志点
|
316 |
+
client_ip = request.remote_addr # 获取客户端 IP 地址,仅用于日志记录
|
317 |
+
logger.info(f"[{request_id}] 收到来自 IP: {client_ip} 的 /v1/chat/completions 请求")
|
318 |
+
|
319 |
+
# 尝试在更早的位置打印一些调试信息
|
320 |
+
logger.info(f"[{request_id}] DEBUG_ENTRY: 进入 chat_completions。")
|
321 |
+
|
322 |
+
# 验证访问令牌
|
323 |
+
auth_header = request.headers.get('Authorization')
|
324 |
+
if not auth_header or not auth_header.startswith('Bearer '):
|
325 |
+
logger.warning(f"[{request_id}] 未提供认证令牌或格式错误")
|
326 |
+
return {"error": {"message": "缺少有效的认证令牌", "type": "auth_error", "code": "missing_token"}}, 401
|
327 |
+
|
328 |
+
# 获取API访问令牌
|
329 |
+
api_access_token = config_instance.get('api_access_token')
|
330 |
+
token = auth_header[7:] # 去掉 'Bearer ' 前缀
|
331 |
+
if token != api_access_token:
|
332 |
+
logger.warning(f"[{request_id}] 提供了无效的认证令牌")
|
333 |
+
return {"error": {"message": "无效的认证令牌", "type": "auth_error", "code": "invalid_token"}}, 401
|
334 |
+
|
335 |
+
# 检查速率限制 - 使用token而不是IP进行限制
|
336 |
+
if not rate_limiter.is_allowed(token):
|
337 |
+
logger.warning(f"[{request_id}] 用户 {token[:8]}... 超过速率限制")
|
338 |
+
return {"error": {"message": "请求频率过高,请稍后再试", "type": "rate_limit_error", "code": "rate_limit_exceeded"}}, 429
|
339 |
+
|
340 |
+
openai_data = request.get_json()
|
341 |
+
if not openai_data:
|
342 |
+
logger.error(f"[{request_id}] 请求体不是有效的JSON")
|
343 |
+
return {"error": {"message": "请求体必须是 JSON。", "type": "invalid_request_error", "code": None}}, 400
|
344 |
+
|
345 |
+
if app.config.get('DEBUG_MODE', False):
|
346 |
+
logger.debug(f"[{request_id}] OpenAI 请求数据: {json.dumps(openai_data, indent=2, ensure_ascii=False)}")
|
347 |
+
|
348 |
+
# 从 OpenAI 请求中提取参数
|
349 |
+
# Capture the initial messages from the request for later use in rolling hash update
|
350 |
+
initial_messages_from_request: List[Dict[str, str]] = openai_data.get('messages', [])
|
351 |
+
messages: List[Dict[str, str]] = initial_messages_from_request # Keep 'messages' for existing logic
|
352 |
+
stream_requested: bool = openai_data.get('stream', False)
|
353 |
+
# 如果请求中没有指定模型,则使用映射表中的一个默认模型,或者最终的 DEFAULT_ENDPOINT_ID
|
354 |
+
model_mapping = config_instance._model_mapping
|
355 |
+
default_endpoint_id = config_instance.get('default_endpoint_id')
|
356 |
+
requested_model_name: str = openai_data.get('model', list(model_mapping.keys())[0] if model_mapping else default_endpoint_id)
|
357 |
+
|
358 |
+
# 从请求中获取参数,如果未提供则为 None
|
359 |
+
temperature: Optional[float] = openai_data.get('temperature')
|
360 |
+
max_tokens: Optional[int] = openai_data.get('max_tokens')
|
361 |
+
top_p: Optional[float] = openai_data.get('top_p')
|
362 |
+
frequency_penalty: Optional[float] = openai_data.get('frequency_penalty')
|
363 |
+
presence_penalty: Optional[float] = openai_data.get('presence_penalty')
|
364 |
+
|
365 |
+
if not messages:
|
366 |
+
logger.error(f"[{request_id}] 缺少 'messages' 字段")
|
367 |
+
return {"error": {"message": "缺少 'messages' 字段。", "type": "invalid_request_error", "code": "missing_messages"}}, 400
|
368 |
+
|
369 |
+
# 为 on-demand.io 构建查询
|
370 |
+
# on-demand.io 通常接受单个查询字符串,上下文由其会话管理。
|
371 |
+
# 我们将发送最新的用户查询,可选地以系统提示为前缀。
|
372 |
+
# --- 上下文感知会话管理与查询构建 (v2) ---
|
373 |
+
|
374 |
+
# 1. 提取消息组件与上下文密钥
|
375 |
+
logger.info(f"[{request_id}] DEBUG_PRE_HASH_COMPUTATION: 即将计算 request_context_hash。")
|
376 |
+
request_context_hash = _get_context_key_from_messages(messages, request_id)
|
377 |
+
logger.info(f"[{request_id}] 请求上下文哈希值: {repr(request_context_hash)}") # 使用 repr()
|
378 |
+
|
379 |
+
logger.info(f"[{request_id}] DEBUG_POINT_A: 即将初始化 historical_messages。")
|
380 |
+
historical_messages = []
|
381 |
+
logger.info(f"[{request_id}] DEBUG_POINT_B: historical_messages 初始化为空列表。即将检查 request_context_hash ({repr(request_context_hash)}).")
|
382 |
+
|
383 |
+
if request_context_hash: # 注意:空字符串的布尔值为 False
|
384 |
+
logger.info(f"[{request_id}] DEBUG_POINT_C: request_context_hash ({repr(request_context_hash)}) 为真,进入历史提取块。")
|
385 |
+
last_user_idx = -1
|
386 |
+
try:
|
387 |
+
for i in range(len(messages) - 1, -1, -1):
|
388 |
+
if messages[i].get('role') == 'user': last_user_idx = i; break
|
389 |
+
except Exception as e_loop:
|
390 |
+
logger.error(f"[{request_id}] DEBUG_LOOP_ERROR: 在查找 last_user_idx 的循环中发生错误: {e_loop}")
|
391 |
+
last_user_idx = -1 # 确保安全
|
392 |
+
|
393 |
+
logger.info(f"[{request_id}] DEBUG_POINT_D: last_user_idx = {last_user_idx}")
|
394 |
+
if last_user_idx > 0:
|
395 |
+
try:
|
396 |
+
historical_messages = messages[:last_user_idx]
|
397 |
+
logger.info(f"[{request_id}] DEBUG_POINT_E: historical_messages 赋值自 messages[:{last_user_idx}]")
|
398 |
+
except Exception as e_slice:
|
399 |
+
logger.error(f"[{request_id}] DEBUG_SLICE_ERROR: 在切片 messages[:{last_user_idx}] 时发生错误: {e_slice}")
|
400 |
+
historical_messages = [] # 确保安全
|
401 |
+
|
402 |
+
if historical_messages:
|
403 |
+
logger.info(f"[{request_id}] DEBUG_HISTORICAL_CONTENT: 'historical_messages' 提取后内容: {json.dumps(historical_messages, ensure_ascii=False, indent=2)}")
|
404 |
+
else:
|
405 |
+
logger.info(f"[{request_id}] DEBUG_HISTORICAL_EMPTY: 'historical_messages' 提取后为空列表。last_user_idx={last_user_idx}, request_context_hash='{request_context_hash}'")
|
406 |
+
|
407 |
+
elif not request_context_hash: # request_context_hash is None or empty string
|
408 |
+
logger.info(f"[{request_id}] DEBUG_HISTORICAL_NOHASH: 'request_context_hash' ({repr(request_context_hash)}) 为假, 'historical_messages' 保持为空列表。")
|
409 |
+
|
410 |
+
logger.info(f"[{request_id}] DEBUG_POST_HISTORICAL_EXTRACTION: 即将提取 system 和 user query。")
|
411 |
+
current_system_prompts_contents = [msg['content'] for msg in messages if msg.get('role') == 'system' and msg.get('content')]
|
412 |
+
system_prompt_combined = "\n".join(current_system_prompts_contents)
|
413 |
+
|
414 |
+
current_user_messages_contents = [msg['content'] for msg in messages if msg.get('role') == 'user' and msg.get('content')]
|
415 |
+
current_user_query = current_user_messages_contents[-1] if current_user_messages_contents else ""
|
416 |
+
|
417 |
+
if not current_user_query: # 此检查至关重要
|
418 |
+
logger.error(f"[{request_id}] 'messages' 中未找到有效的 'user' 角色的消息内容。")
|
419 |
+
# 记录调试消息
|
420 |
+
logger.debug(f"[{request_id}] 接收到的消息: {json.dumps(messages, ensure_ascii=False)}")
|
421 |
+
return {"error": {"message": "'messages' 中未找到有效的 'user' 角色的消息内容。", "type": "invalid_request_error", "code": "no_user_message"}}, 400
|
422 |
+
|
423 |
+
user_identifier = token
|
424 |
+
# 记录请求开始时间,确保在所有路径中 duration_ms 可用
|
425 |
+
request_start_time = time.time()
|
426 |
+
ondemand_client = None
|
427 |
+
email_for_stats = None # 此为 OnDemandAPIClient 所用账户的邮箱
|
428 |
+
# 初始化 is_newly_assigned_context,默认为 True,如果后续阶段匹配成功会被修改
|
429 |
+
is_newly_assigned_context = True
|
430 |
+
|
431 |
+
# 获取会话超时配置
|
432 |
+
ondemand_session_timeout_minutes = config_instance.get('ondemand_session_timeout_minutes', 30)
|
433 |
+
logger.info(f"[{request_id}] OnDemand 会话超时设置为: {ondemand_session_timeout_minutes} 分钟。")
|
434 |
+
# 将分钟转换为 timedelta 对象,便于比较
|
435 |
+
session_timeout_delta = timedelta(minutes=ondemand_session_timeout_minutes)
|
436 |
+
|
437 |
+
with config_instance.client_sessions_lock:
|
438 |
+
current_time_dt = datetime.now() # 使用 datetime 对象进行比较
|
439 |
+
if user_identifier not in config_instance.client_sessions:
|
440 |
+
config_instance.client_sessions[user_identifier] = {}
|
441 |
+
user_sessions_for_id = config_instance.client_sessions[user_identifier]
|
442 |
+
|
443 |
+
# 阶段 0: 优先复用“活跃”会话
|
444 |
+
# 遍历时按 last_time 降序排列,优先选择最近使用的活跃会话
|
445 |
+
sorted_sessions = sorted(
|
446 |
+
user_sessions_for_id.items(),
|
447 |
+
key=lambda item: item[1].get("last_time", datetime.min),
|
448 |
+
reverse=True
|
449 |
+
)
|
450 |
+
|
451 |
+
for acc_email_p0, session_data_p0 in sorted_sessions:
|
452 |
+
client_p0 = session_data_p0.get("client")
|
453 |
+
last_time_p0 = session_data_p0.get("last_time")
|
454 |
+
|
455 |
+
if client_p0 and client_p0.token and client_p0.session_id and last_time_p0:
|
456 |
+
if (current_time_dt - last_time_p0) < session_timeout_delta: # 使用 session_timeout_delta
|
457 |
+
stored_active_hash = session_data_p0.get("active_context_hash")
|
458 |
+
hash_match_status = "匹配" if stored_active_hash == request_context_hash else "不匹配"
|
459 |
+
logger.info(f"[{request_id}] 阶段0: 找到账户 {acc_email_p0} 的活跃会话。请求上下文哈希 ({request_context_hash or 'None'}) 与存储哈希 ({stored_active_hash or 'None'}) {hash_match_status}。")
|
460 |
+
|
461 |
+
# 新增:检查上下文哈希是否匹配
|
462 |
+
if stored_active_hash == request_context_hash:
|
463 |
+
# 如果哈希匹配,则复用此客户端
|
464 |
+
ondemand_client = client_p0
|
465 |
+
email_for_stats = acc_email_p0
|
466 |
+
ondemand_client._associated_user_identifier = user_identifier
|
467 |
+
ondemand_client._associated_request_ip = client_ip
|
468 |
+
session_data_p0["last_time"] = current_time_dt # 使用 current_time_dt
|
469 |
+
session_data_p0["ip"] = client_ip
|
470 |
+
is_newly_assigned_context = False # 复用现有活跃会话
|
471 |
+
logger.info(f"[{request_id}] 阶段0: 上下文哈希匹配,复用账户 {email_for_stats} 的活跃会话。")
|
472 |
+
break # 已找到并复用活跃客户端
|
473 |
+
else:
|
474 |
+
logger.info(f"[{request_id}] 阶段0: 上下文哈希不匹配,跳过复用此活跃会话。")
|
475 |
+
# Continue the loop to check other sessions or proceed to Stage 1
|
476 |
+
|
477 |
+
# 阶段 1: 若阶段0失败,则查找已服务此 context_hash 的客户端 (精确哈希匹配)
|
478 |
+
if not ondemand_client and request_context_hash: # 只有在 request_context_hash 存在时才进行阶段1匹配
|
479 |
+
for acc_email_p1, session_data_p1 in user_sessions_for_id.items(): # 无需再次排序,因为阶段0已处理最优选择
|
480 |
+
client_p1 = session_data_p1.get("client")
|
481 |
+
if client_p1 and client_p1.token and client_p1.session_id and \
|
482 |
+
session_data_p1.get("active_context_hash") == request_context_hash:
|
483 |
+
|
484 |
+
# 检查此精确匹配的会话是否也“活跃”,如果不活跃,可能不如创建一个新的
|
485 |
+
last_time_p1 = session_data_p1.get("last_time")
|
486 |
+
if last_time_p1 and (current_time_dt - last_time_p1) >= session_timeout_delta: # 使用 session_timeout_delta
|
487 |
+
logger.info(f"[{request_id}] 阶段1: 找到精确哈希匹配的账户 {acc_email_p1},但其会话已超时。将跳过并尝试创建新会话。")
|
488 |
+
continue # 跳过这个超时的精确匹配
|
489 |
+
|
490 |
+
ondemand_client = client_p1
|
491 |
+
email_for_stats = acc_email_p1
|
492 |
+
ondemand_client._associated_user_identifier = user_identifier
|
493 |
+
ondemand_client._associated_request_ip = client_ip
|
494 |
+
session_data_p1["last_time"] = current_time_dt # 使用 current_time_dt
|
495 |
+
session_data_p1["ip"] = client_ip
|
496 |
+
is_newly_assigned_context = False # 精确上下文匹配
|
497 |
+
logger.info(f"[{request_id}] 阶段1: 上下文精确匹配。复用账户 {email_for_stats} 的客户端 (上下文哈希: {request_context_hash})。")
|
498 |
+
break # 已找到客户端
|
499 |
+
|
500 |
+
# 阶段 2: 若阶段0和阶段1均失败,则必须创建新客户端会话
|
501 |
+
if not ondemand_client:
|
502 |
+
logger.info(f"[{request_id}] 阶段0及阶段1均未找到可复用会话 (请求上下文哈希: {request_context_hash or 'None'})。尝试获取/创建新客户端会话。")
|
503 |
+
MAX_ACCOUNT_ATTEMPTS = config_instance.get('max_account_attempts', 3) # 从配置获取或默认3
|
504 |
+
for attempt in range(MAX_ACCOUNT_ATTEMPTS):
|
505 |
+
new_ondemand_email, new_ondemand_password = config.get_next_ondemand_account_details()
|
506 |
+
if not new_ondemand_email:
|
507 |
+
logger.error(f"[{request_id}] 尝试 {attempt+1} 次后,配置中无可用 OnDemand 账户。")
|
508 |
+
break
|
509 |
+
|
510 |
+
email_for_stats = new_ondemand_email # 本次尝试暂设值
|
511 |
+
|
512 |
+
# 检查 user_identifier 是否已对 new_ondemand_email 存在会话数据,但可能 client 实例需要重建
|
513 |
+
# 或者这是一个全新的账户分配给此 user_identifier
|
514 |
+
|
515 |
+
# 总是尝试创建新的 OnDemandAPIClient 实例和新的 OnDemand session_id
|
516 |
+
# 因为到这一步意味着我们没有找到合适的现有活跃会话来复用其 session_id
|
517 |
+
logger.info(f"[{request_id}] 阶段2: 为账户 {new_ondemand_email} 创建新客户端实例和会话 (尝试 {attempt+1})。")
|
518 |
+
client_id_for_log = f"{user_identifier[:8]}-{new_ondemand_email.split('@')[0]}-{request_id[:4]}" # 更具区分度的 client_id
|
519 |
+
temp_ondemand_client = OnDemandAPIClient(new_ondemand_email, new_ondemand_password, client_id=client_id_for_log)
|
520 |
+
|
521 |
+
if not temp_ondemand_client.sign_in() or not temp_ondemand_client.create_session():
|
522 |
+
logger.error(f"[{request_id}] 为 {new_ondemand_email} 初始化新客户端会话失败: {temp_ondemand_client.last_error}")
|
523 |
+
# 此处不将 ondemand_client 设为 None,因为 email_for_stats 需要在失败统计时使用
|
524 |
+
# email_for_stats = None # 移除,以确保失败统计时有邮箱
|
525 |
+
continue # 尝试下一账户
|
526 |
+
|
527 |
+
ondemand_client = temp_ondemand_client # 成功创建,赋值
|
528 |
+
ondemand_client._associated_user_identifier = user_identifier
|
529 |
+
ondemand_client._associated_request_ip = client_ip
|
530 |
+
|
531 |
+
user_sessions_for_id[new_ondemand_email] = {
|
532 |
+
"client": ondemand_client,
|
533 |
+
"last_time": current_time_dt, # 使用 current_time_dt
|
534 |
+
"ip": client_ip,
|
535 |
+
"active_context_hash": request_context_hash # 新会话关联到当前请求的上下文哈希
|
536 |
+
}
|
537 |
+
is_newly_assigned_context = True # 这是一个新的 OnDemand 会话,或者为现有账户分配了新的上下文
|
538 |
+
logger.info(f"[{request_id}] 阶段2: 已为账户 {email_for_stats} 成功创建/分配新客户端会话 (is_newly_assigned_context=True, 关联上下文哈希: {request_context_hash or 'None'})。")
|
539 |
+
break # 跳出账户尝试循环,客户端就绪
|
540 |
+
|
541 |
+
if not ondemand_client: # 获取/创建客户端尝试均失败
|
542 |
+
# is_newly_assigned_context 此时应保持为 True (其默认值)
|
543 |
+
last_attempt_error = temp_ondemand_client.last_error if 'temp_ondemand_client' in locals() and temp_ondemand_client else '未知错误'
|
544 |
+
logger.error(f"[{request_id}] 尝试 {MAX_ACCOUNT_ATTEMPTS} 次后获取/创建客户端失败 (is_newly_assigned_context 保持为 {is_newly_assigned_context})。最后一次尝试失败原因: {last_attempt_error}")
|
545 |
+
|
546 |
+
prompt_tok_val_err, _, _ = count_message_tokens(messages, requested_model_name)
|
547 |
+
_update_usage_statistics(
|
548 |
+
config_inst=config_instance, request_id=request_id, requested_model_name=requested_model_name,
|
549 |
+
account_email=email_for_stats, # 可能为最后尝试的邮箱或None
|
550 |
+
is_success=False, duration_ms=int((time.time() - request_start_time) * 1000), # request_start_time 可能未定义
|
551 |
+
is_stream=stream_requested, prompt_tokens_val=prompt_tok_val_err or 0,
|
552 |
+
completion_tokens_val=0, total_tokens_val=prompt_tok_val_err or 0,
|
553 |
+
error_message=f"多次尝试后获取/创建客户端会话失败。最后一次尝试失败原因: {last_attempt_error}"
|
554 |
+
)
|
555 |
+
return {"error": {"message": f"当前无法与 OnDemand 服务建立会话。最后一次尝试失败原因: {last_attempt_error}", "type": "api_error", "code": "ondemand_session_unavailable"}}, 503
|
556 |
+
|
557 |
+
# --- 会话管理结束 ---
|
558 |
+
|
559 |
+
# 4. 基于 is_newly_assigned_context 构建 final_query_to_ondemand
|
560 |
+
final_query_to_ondemand = ""
|
561 |
+
query_parts = []
|
562 |
+
|
563 |
+
# 在构建查询之前,记录关键变量的状态
|
564 |
+
logger.debug(f"[{request_id}] 查询构建前状态:is_newly_assigned_context={is_newly_assigned_context}, request_context_hash='{request_context_hash}', historical_messages_empty={not bool(historical_messages)}")
|
565 |
+
if historical_messages: # 只在列表非空时尝试序列化
|
566 |
+
logger.debug(f"[{request_id}] 查询构建前状态:historical_messages 内容: {json.dumps(historical_messages, ensure_ascii=False, indent=2)}")
|
567 |
+
else:
|
568 |
+
logger.debug(f"[{request_id}] 查询构建前状态:historical_messages 为空列表。")
|
569 |
+
|
570 |
+
if is_newly_assigned_context:
|
571 |
+
# 阶段2:新建/重分配会话
|
572 |
+
logger.info(f"[{request_id}] 查询构建:会话为新建/重分配 (is_newly_assigned_context=True, 账户: {email_for_stats})。")
|
573 |
+
|
574 |
+
# 在新建会话时,如果存在系统提示,则添加到 query_parts
|
575 |
+
if system_prompt_combined:
|
576 |
+
query_parts.append(f"System: {system_prompt_combined}")
|
577 |
+
logger.debug(f"[{request_id}] 查询构建:新建会话,添加了合并的系统提示。")
|
578 |
+
|
579 |
+
if request_context_hash and historical_messages: # 有历史上下文 (historical_messages 已在前面提取)
|
580 |
+
logger.info(f"[{request_id}] 查询构建:存在历史上下文 ({request_context_hash}),将发送历史消息。")
|
581 |
+
formatted_historical_parts = []
|
582 |
+
for msg in historical_messages: # historical_messages 是 messages[:last_user_idx]
|
583 |
+
role = msg.get('role', 'unknown').capitalize()
|
584 |
+
content = msg.get('content', '')
|
585 |
+
if content: formatted_historical_parts.append(f"{role}: {content}")
|
586 |
+
if formatted_historical_parts: query_parts.append("\n".join(formatted_historical_parts))
|
587 |
+
else: # 无历史上下文 (例如对话首条消息,或 request_context_hash 为 None)
|
588 |
+
logger.info(f"[{request_id}] 查询构建:无历史上下文。仅发送当前用户查询。") # 系统提示已在前面处理
|
589 |
+
|
590 |
+
else:
|
591 |
+
# 阶段0或阶段1:复用现有会话
|
592 |
+
# 不发送 historical_messages 和 system prompt,信任 OnDemand API 通过 session_id 维护上下文
|
593 |
+
stored_active_hash = "N/A"
|
594 |
+
if ondemand_client: # ondemand_client 应该总是存在的,除非前面逻辑有误
|
595 |
+
# 尝试从 client_sessions 获取最新的哈希,因为 client 实例可能刚被更新
|
596 |
+
client_session_data = config_instance.client_sessions.get(user_identifier, {}).get(email_for_stats, {})
|
597 |
+
stored_active_hash = client_session_data.get('active_context_hash', 'N/A')
|
598 |
+
|
599 |
+
hash_match_status = "匹配" if stored_active_hash == request_context_hash else "不匹配"
|
600 |
+
logger.info(f"[{request_id}] 查询构建:复用现有会话 (is_newly_assigned_context=False, 账户: {email_for_stats})。不发送历史消息或系统提示。请求上下文哈希 ({request_context_hash or 'None'}) 与存储哈希 ({stored_active_hash or 'None'}) {hash_match_status}。")
|
601 |
+
|
602 |
+
# 始终添加当前用户查询
|
603 |
+
if current_user_query: # current_user_query 是 messages 中最后一个用户消息的内容
|
604 |
+
query_parts.append(f"User: {current_user_query}")
|
605 |
+
logger.debug(f"[{request_id}] 查询构建:添加了当前用户查询。")
|
606 |
+
else: # 此情况应在早期被捕获 (messages 中无 user role)
|
607 |
+
logger.error(f"[{request_id}] 严重错误: 最终查询构建时 current_user_query 为空!")
|
608 |
+
if not query_parts: query_parts.append(" ") # 确保查询非空
|
609 |
+
|
610 |
+
final_query_to_ondemand = "\n\n".join(filter(None, query_parts))
|
611 |
+
if not final_query_to_ondemand.strip(): # 确保查询字符串实际有内容
|
612 |
+
logger.warning(f"[{request_id}] 构建的查询为空或全为空格。发送占位符查询。")
|
613 |
+
final_query_to_ondemand = " "
|
614 |
+
|
615 |
+
logger.info(f"[{request_id}] 构建的 OnDemand 查询 (前1000字符): {final_query_to_ondemand[:1000]}...")
|
616 |
+
|
617 |
+
# 根据请求的模型名称获取 on-demand.io 的 endpoint_id
|
618 |
+
endpoint_id = model_mapping.get(requested_model_name, default_endpoint_id)
|
619 |
+
if requested_model_name not in model_mapping:
|
620 |
+
logger.warning(f"[{request_id}] 模型 '{requested_model_name}' 不在映射表中, 将使用默认端点 '{default_endpoint_id}'.")
|
621 |
+
|
622 |
+
# 构建模型配置,只包含用户明确提供的参数
|
623 |
+
model_configs = {}
|
624 |
+
|
625 |
+
# 构建模型配置,只包含用户明确提供的参数 (值为None的参数不会被包含)
|
626 |
+
if temperature is not None:
|
627 |
+
model_configs["temperature"] = temperature
|
628 |
+
if max_tokens is not None:
|
629 |
+
model_configs["maxTokens"] = max_tokens
|
630 |
+
if top_p is not None:
|
631 |
+
model_configs["topP"] = top_p
|
632 |
+
if frequency_penalty is not None:
|
633 |
+
model_configs["frequency_penalty"] = frequency_penalty
|
634 |
+
if presence_penalty is not None:
|
635 |
+
model_configs["presence_penalty"] = presence_penalty
|
636 |
+
|
637 |
+
logger.info(f"[{request_id}] 构建的模型配置: {json.dumps(model_configs, ensure_ascii=False)}")
|
638 |
+
|
639 |
+
# request_start_time 已移至会话管理之前
|
640 |
+
|
641 |
+
# 在调用 send_query 之前,将 request_context_hash 存储到 ondemand_client 实例上
|
642 |
+
# 以便在 RateLimitStrategy 中进行账户切换时可以访问到它
|
643 |
+
if ondemand_client: #确保 ondemand_client 不是 None
|
644 |
+
ondemand_client._current_request_context_hash = request_context_hash
|
645 |
+
logger.debug(f"[{request_id}] Stored request_context_hash ('{request_context_hash}') onto ondemand_client instance before send_query.")
|
646 |
+
else:
|
647 |
+
logger.error(f"[{request_id}] CRITICAL: ondemand_client is None before send_query. This should not happen.")
|
648 |
+
# 可以在这里决定是否提前返回错误,或者让后续的 send_query 调用失败
|
649 |
+
# 为安全起见,如果 ondemand_client 为 None,后续调用会 AttributeError
|
650 |
+
|
651 |
+
# 使用特定于此 IP 的客户端实例向 OnDemand API 发送查询
|
652 |
+
ondemand_result = ondemand_client.send_query(final_query_to_ondemand, endpoint_id=endpoint_id,
|
653 |
+
stream=stream_requested, model_configs_input=model_configs)
|
654 |
+
|
655 |
+
# 处理响应
|
656 |
+
if stream_requested:
|
657 |
+
# 流式响应
|
658 |
+
def generate_openai_stream(captured_initial_request_messages: List[Dict[str, str]]):
|
659 |
+
full_assistant_reply_parts = [] # For aggregating streamed reply
|
660 |
+
stream_response_obj = ondemand_result.get("response_obj")
|
661 |
+
if not stream_response_obj: # 确保 response_obj 存在
|
662 |
+
# 计算token数量(仅提示部分,因为流式响应无法准确计算完成tokens)
|
663 |
+
prompt_tokens, _, _ = count_message_tokens(messages, requested_model_name)
|
664 |
+
# 确保prompt_tokens不为None
|
665 |
+
if prompt_tokens is None:
|
666 |
+
prompt_tokens = 0
|
667 |
+
# 错误情况下,完成tokens为0
|
668 |
+
estimated_completion_tokens = 0
|
669 |
+
# 错误情况下,总tokens等于提示tokens
|
670 |
+
estimated_total_tokens = prompt_tokens
|
671 |
+
|
672 |
+
error_json = {
|
673 |
+
"id": request_id,
|
674 |
+
"object": "chat.completion.chunk",
|
675 |
+
"created": int(time.time()),
|
676 |
+
"model": requested_model_name,
|
677 |
+
"choices": [{"delta": {"content": "[流错误:未获取到响应对象]"}, "index": 0, "finish_reason": "error"}],
|
678 |
+
"usage": { # 添加token统计信息
|
679 |
+
"prompt_tokens": prompt_tokens,
|
680 |
+
"completion_tokens": estimated_completion_tokens,
|
681 |
+
"total_tokens": estimated_total_tokens
|
682 |
+
}
|
683 |
+
}
|
684 |
+
yield f"data: {json.dumps(error_json, ensure_ascii=False)}\n\n"
|
685 |
+
yield "data: [DONE]\n\n"
|
686 |
+
return
|
687 |
+
|
688 |
+
logger.info(f"[{request_id}] 开始流式传输 OpenAI 格式的响应。")
|
689 |
+
# 初始化token计数变量
|
690 |
+
actual_input_tokens = None
|
691 |
+
actual_output_tokens = None
|
692 |
+
actual_total_tokens = None
|
693 |
+
|
694 |
+
try:
|
695 |
+
for line in stream_response_obj.iter_lines():
|
696 |
+
if line:
|
697 |
+
decoded_line = line.decode('utf-8')
|
698 |
+
if decoded_line.startswith("data:"):
|
699 |
+
json_str = decoded_line[len("data:"):].strip()
|
700 |
+
if json_str == "[DONE]": # 这是 on-demand.io 的结束标记
|
701 |
+
break # 我们将在循环外发送 OpenAI 的 [DONE]
|
702 |
+
try:
|
703 |
+
event_data = json.loads(json_str)
|
704 |
+
event_type = event_data.get("eventType", "")
|
705 |
+
|
706 |
+
# 处理内容块
|
707 |
+
if event_type == "fulfillment":
|
708 |
+
content_chunk = event_data.get("answer", "")
|
709 |
+
if content_chunk is not None: # 确保 content_chunk 不是 None
|
710 |
+
full_assistant_reply_parts.append(content_chunk) # Aggregate
|
711 |
+
openai_chunk = {
|
712 |
+
"id": request_id,
|
713 |
+
"object": "chat.completion.chunk",
|
714 |
+
"created": int(time.time()),
|
715 |
+
"model": requested_model_name,
|
716 |
+
"choices": [
|
717 |
+
{
|
718 |
+
"delta": {"content": content_chunk},
|
719 |
+
"index": 0,
|
720 |
+
"finish_reason": None # 流式传输过程中 finish_reason 为 None
|
721 |
+
}
|
722 |
+
]
|
723 |
+
}
|
724 |
+
yield f"data: {json.dumps(openai_chunk, ensure_ascii=False)}\n\n"
|
725 |
+
|
726 |
+
# 从metrics事件中提取准确的token计数
|
727 |
+
elif event_type == "metricsLog":
|
728 |
+
public_metrics = event_data.get("publicMetrics", {})
|
729 |
+
if public_metrics:
|
730 |
+
# 确保获取到的token计数是整数,避免None值
|
731 |
+
actual_input_tokens = public_metrics.get("inputTokens", 0)
|
732 |
+
if actual_input_tokens is None:
|
733 |
+
actual_input_tokens = 0
|
734 |
+
|
735 |
+
actual_output_tokens = public_metrics.get("outputTokens", 0)
|
736 |
+
if actual_output_tokens is None:
|
737 |
+
actual_output_tokens = 0
|
738 |
+
|
739 |
+
actual_total_tokens = public_metrics.get("totalTokens", 0)
|
740 |
+
if actual_total_tokens is None:
|
741 |
+
actual_total_tokens = 0
|
742 |
+
|
743 |
+
logger.info(f"[{request_id}] 从metricsLog获取到准确的token计数: 输入={actual_input_tokens}, 输出={actual_output_tokens}, 总计={actual_total_tokens}")
|
744 |
+
|
745 |
+
except json.JSONDecodeError:
|
746 |
+
logger.warning(f"[{request_id}] 流式传输中 JSONDecodeError: {json_str}")
|
747 |
+
continue # 跳过无法解析的行
|
748 |
+
|
749 |
+
# 如果没有从metrics中获取到准确的token计数,则使用估算方法
|
750 |
+
if actual_input_tokens == 0 or actual_output_tokens == 0 or actual_total_tokens == 0:
|
751 |
+
logger.warning(f"[{request_id}] 未从metricsLog获取到有效的token计数,使用估算方法")
|
752 |
+
prompt_tokens, _, _ = count_message_tokens(messages, requested_model_name)
|
753 |
+
# 确保prompt_tokens不为None
|
754 |
+
if prompt_tokens is None:
|
755 |
+
prompt_tokens = 0
|
756 |
+
estimated_completion_tokens = max(1, prompt_tokens // 2) # 确保至少为1
|
757 |
+
estimated_total_tokens = prompt_tokens + estimated_completion_tokens
|
758 |
+
else:
|
759 |
+
# 使用从metrics中获取的准确token计数
|
760 |
+
prompt_tokens = actual_input_tokens
|
761 |
+
estimated_completion_tokens = actual_output_tokens
|
762 |
+
estimated_total_tokens = actual_total_tokens
|
763 |
+
|
764 |
+
# 循环结束后,发送 OpenAI 流的终止块
|
765 |
+
final_chunk = {
|
766 |
+
"id": request_id,
|
767 |
+
"object": "chat.completion.chunk",
|
768 |
+
"created": int(time.time()),
|
769 |
+
"model": requested_model_name,
|
770 |
+
"choices": [{"delta": {}, "index": 0, "finish_reason": "stop"}], # 标准的结束方式
|
771 |
+
"usage": { # 添加token统计信息
|
772 |
+
"prompt_tokens": prompt_tokens,
|
773 |
+
"completion_tokens": estimated_completion_tokens,
|
774 |
+
"total_tokens": estimated_total_tokens
|
775 |
+
}
|
776 |
+
}
|
777 |
+
yield f"data: {json.dumps(final_chunk, ensure_ascii=False)}\n\n"
|
778 |
+
yield "data: [DONE]\n\n" # OpenAI 流的最终结束标记
|
779 |
+
logger.info(f"[{request_id}] 完成 OpenAI 格式响应的流式传输。")
|
780 |
+
|
781 |
+
full_streamed_reply = "".join(full_assistant_reply_parts)
|
782 |
+
|
783 |
+
# 更新使用统计
|
784 |
+
request_duration_val = int((time.time() - request_start_time) * 1000)
|
785 |
+
final_prompt_tokens_for_stats = actual_input_tokens if actual_input_tokens is not None and actual_input_tokens > 0 else prompt_tokens
|
786 |
+
final_completion_tokens_for_stats = actual_output_tokens if actual_output_tokens is not None and actual_output_tokens > 0 else estimated_completion_tokens
|
787 |
+
final_total_tokens_for_stats = actual_total_tokens if actual_total_tokens is not None and actual_total_tokens > 0 else estimated_total_tokens
|
788 |
+
used_actual_for_history = actual_input_tokens is not None and actual_input_tokens > 0
|
789 |
+
|
790 |
+
_update_usage_statistics(
|
791 |
+
config_inst=config_instance,
|
792 |
+
request_id=request_id,
|
793 |
+
requested_model_name=requested_model_name,
|
794 |
+
account_email=ondemand_client.email,
|
795 |
+
is_success=True,
|
796 |
+
duration_ms=request_duration_val,
|
797 |
+
is_stream=True,
|
798 |
+
prompt_tokens_val=final_prompt_tokens_for_stats,
|
799 |
+
completion_tokens_val=final_completion_tokens_for_stats,
|
800 |
+
total_tokens_val=final_total_tokens_for_stats,
|
801 |
+
prompt_length=len(final_query_to_ondemand),
|
802 |
+
used_actual_tokens_for_history=used_actual_for_history
|
803 |
+
)
|
804 |
+
|
805 |
+
# 更新客户端的 active_context_hash 以反映对话进展
|
806 |
+
_update_client_context_hash_after_reply(
|
807 |
+
original_request_messages=captured_initial_request_messages,
|
808 |
+
assistant_reply_content=full_streamed_reply,
|
809 |
+
request_id=request_id,
|
810 |
+
user_identifier=token, # user_identifier is token
|
811 |
+
email_for_stats=ondemand_client.email, # <--- 使用 ondemand_client 当前的 email
|
812 |
+
current_ondemand_client_instance=ondemand_client,
|
813 |
+
config_inst=config_instance,
|
814 |
+
logger_instance=logger
|
815 |
+
)
|
816 |
+
except Exception as e: # 捕获流处理过程中的任何异常
|
817 |
+
logger.error(f"[{request_id}] 流式传输过程中发生错误: {e}")
|
818 |
+
# 在流错误的情况下,不更新 active_context_hash,因为它可能基于不完整的对话
|
819 |
+
# 计算token数量(仅提示部分,因为流式响应无法准确计算完成tokens)
|
820 |
+
prompt_tokens, _, _ = count_message_tokens(messages, requested_model_name)
|
821 |
+
# 确保prompt_tokens不为None
|
822 |
+
if prompt_tokens is None:
|
823 |
+
prompt_tokens = 0
|
824 |
+
# 错误情况下,完成tokens为0
|
825 |
+
estimated_completion_tokens = 0
|
826 |
+
# 错误情况下,总tokens等于提示tokens
|
827 |
+
estimated_total_tokens = prompt_tokens
|
828 |
+
|
829 |
+
error_json = { # 发送一个错误块
|
830 |
+
"id": request_id,
|
831 |
+
"object": "chat.completion.chunk",
|
832 |
+
"created": int(time.time()),
|
833 |
+
"model": requested_model_name,
|
834 |
+
"choices": [{"delta": {"content": f"[流处理异常: {str(e)}]"}, "index": 0, "finish_reason": "error"}],
|
835 |
+
"usage": { # 添加token统计信息
|
836 |
+
"prompt_tokens": prompt_tokens,
|
837 |
+
"completion_tokens": estimated_completion_tokens,
|
838 |
+
"total_tokens": estimated_total_tokens
|
839 |
+
}
|
840 |
+
}
|
841 |
+
yield f"data: {json.dumps(error_json, ensure_ascii=False)}\n\n"
|
842 |
+
yield "data: [DONE]\n\n"
|
843 |
+
|
844 |
+
# 更新使用统计 - 失败的流式请求
|
845 |
+
request_duration_val = int((time.time() - request_start_time) * 1000)
|
846 |
+
_update_usage_statistics(
|
847 |
+
config_inst=config_instance,
|
848 |
+
request_id=request_id,
|
849 |
+
requested_model_name=requested_model_name,
|
850 |
+
account_email=ondemand_client.email if ondemand_client else email_for_stats,
|
851 |
+
is_success=False,
|
852 |
+
duration_ms=request_duration_val,
|
853 |
+
is_stream=True,
|
854 |
+
prompt_tokens_val=prompt_tokens if prompt_tokens is not None else 0,
|
855 |
+
completion_tokens_val=0,
|
856 |
+
total_tokens_val=prompt_tokens if prompt_tokens is not None else 0,
|
857 |
+
error_message=str(e)
|
858 |
+
)
|
859 |
+
finally:
|
860 |
+
if stream_response_obj: # 确保关闭响应对象
|
861 |
+
stream_response_obj.close()
|
862 |
+
|
863 |
+
return Response(stream_with_context(generate_openai_stream(initial_messages_from_request)), content_type='text/event-stream; charset=utf-8')
|
864 |
+
else:
|
865 |
+
# 非流式响应
|
866 |
+
final_content = ondemand_result.get("content", "")
|
867 |
+
|
868 |
+
# 计算token数量
|
869 |
+
prompt_tokens, completion_tokens, total_tokens = count_message_tokens(messages, requested_model_name)
|
870 |
+
completion_tokens_actual = count_tokens(final_content, requested_model_name)
|
871 |
+
total_tokens_actual = prompt_tokens + completion_tokens_actual
|
872 |
+
|
873 |
+
openai_response = {
|
874 |
+
"id": request_id,
|
875 |
+
"object": "chat.completion",
|
876 |
+
"created": int(time.time()),
|
877 |
+
"model": requested_model_name,
|
878 |
+
"choices": [
|
879 |
+
{
|
880 |
+
"message": {
|
881 |
+
"role": "assistant",
|
882 |
+
"content": final_content
|
883 |
+
},
|
884 |
+
"finish_reason": "stop", # 假设成功完成则为 "stop"
|
885 |
+
"index": 0
|
886 |
+
}
|
887 |
+
],
|
888 |
+
"usage": { # 计算token数量
|
889 |
+
"prompt_tokens": prompt_tokens,
|
890 |
+
"completion_tokens": completion_tokens_actual,
|
891 |
+
"total_tokens": total_tokens_actual
|
892 |
+
}
|
893 |
+
}
|
894 |
+
logger.info(f"[{request_id}] 发送非流式 OpenAI 格式的响应。")
|
895 |
+
|
896 |
+
# 更新使用统计 - 非流式成功请求
|
897 |
+
request_duration_val = int((time.time() - request_start_time) * 1000)
|
898 |
+
_update_usage_statistics(
|
899 |
+
config_inst=config_instance,
|
900 |
+
request_id=request_id,
|
901 |
+
requested_model_name=requested_model_name,
|
902 |
+
account_email=ondemand_client.email,
|
903 |
+
is_success=True,
|
904 |
+
duration_ms=request_duration_val,
|
905 |
+
is_stream=False,
|
906 |
+
prompt_tokens_val=prompt_tokens,
|
907 |
+
completion_tokens_val=completion_tokens_actual,
|
908 |
+
total_tokens_val=total_tokens_actual,
|
909 |
+
prompt_length=len(final_query_to_ondemand),
|
910 |
+
completion_length=len(final_content) if final_content else 0,
|
911 |
+
used_actual_tokens_for_history=True
|
912 |
+
)
|
913 |
+
|
914 |
+
# 更新客户端的 active_context_hash 以反映对话进展
|
915 |
+
_update_client_context_hash_after_reply(
|
916 |
+
original_request_messages=initial_messages_from_request,
|
917 |
+
assistant_reply_content=final_content,
|
918 |
+
request_id=request_id,
|
919 |
+
user_identifier=token, # user_identifier is token
|
920 |
+
email_for_stats=ondemand_client.email, # <--- 使用 ondemand_client 当前的 email
|
921 |
+
current_ondemand_client_instance=ondemand_client,
|
922 |
+
config_inst=config_instance,
|
923 |
+
logger_instance=logger
|
924 |
+
)
|
925 |
+
|
926 |
+
return openai_response
|
927 |
+
|
928 |
+
@app.route('/', methods=['GET'])
|
929 |
+
def show_stats():
|
930 |
+
"""显示用量统计信息的HTML页面"""
|
931 |
+
current_time = datetime.now()
|
932 |
+
current_time_str = current_time.strftime('%Y-%m-%d %H:%M:%S')
|
933 |
+
current_date = current_time.strftime('%Y-%m-%d')
|
934 |
+
|
935 |
+
with config_instance.usage_stats_lock:
|
936 |
+
# 复制基础统计数据
|
937 |
+
total_requests = config_instance.usage_stats["total_requests"]
|
938 |
+
successful_requests = config_instance.usage_stats["successful_requests"]
|
939 |
+
failed_requests = config_instance.usage_stats["failed_requests"]
|
940 |
+
total_prompt_tokens = config_instance.usage_stats["total_prompt_tokens"]
|
941 |
+
total_completion_tokens = config_instance.usage_stats["total_completion_tokens"]
|
942 |
+
total_tokens = config_instance.usage_stats["total_tokens"]
|
943 |
+
|
944 |
+
# 计算成功率(整数百分比)
|
945 |
+
success_rate = int((successful_requests / total_requests * 100) if total_requests > 0 else 0)
|
946 |
+
|
947 |
+
# 计算平均响应时间
|
948 |
+
successful_history = [req for req in config_instance.usage_stats["request_history"] if req.get('success', False)]
|
949 |
+
total_duration = sum(req.get('duration_ms', 0) for req in successful_history)
|
950 |
+
avg_duration = (total_duration / successful_requests) if successful_requests > 0 else 0
|
951 |
+
|
952 |
+
# 计算最快响应时间
|
953 |
+
min_duration = min([req.get('duration_ms', float('inf')) for req in successful_history]) if successful_history else 0
|
954 |
+
|
955 |
+
# 计算今日请求数和增长率
|
956 |
+
today_requests = config_instance.usage_stats["daily_usage"].get(current_date, 0)
|
957 |
+
# 确保不会出现除以零或None值的情况
|
958 |
+
if total_requests is None or today_requests is None:
|
959 |
+
growth_rate = 0
|
960 |
+
elif total_requests == today_requests or (total_requests - today_requests) <= 0:
|
961 |
+
growth_rate = 100 # 如果所有请求都是今天的,增长率为100%
|
962 |
+
else:
|
963 |
+
growth_rate = (today_requests / (total_requests - today_requests) * 100)
|
964 |
+
|
965 |
+
# 计算估算成本 - 使用模型价格配置
|
966 |
+
total_cost = 0.0
|
967 |
+
model_costs = {} # 存储每个模型的成本
|
968 |
+
|
969 |
+
# 获取请求历史中的token使用情况
|
970 |
+
for req in successful_history:
|
971 |
+
model_name = req.get('model', '')
|
972 |
+
# 从配置获取模型价格
|
973 |
+
all_model_prices = config_instance.get('model_prices', {})
|
974 |
+
default_model_price = config_instance.get('default_model_price', {'input': 0.50 / 1000000, 'output': 2.00 / 1000000}) # 提供备用默认值
|
975 |
+
model_price = all_model_prices.get(model_name, default_model_price)
|
976 |
+
|
977 |
+
# 获取输入和输出token数量
|
978 |
+
input_tokens = req.get('prompt_tokens', 0)
|
979 |
+
|
980 |
+
# 根据是否有准确的completion_tokens字段决定使用哪个字段
|
981 |
+
if 'completion_tokens' in req:
|
982 |
+
output_tokens = req.get('completion_tokens', 0)
|
983 |
+
else:
|
984 |
+
output_tokens = req.get('estimated_completion_tokens', 0)
|
985 |
+
|
986 |
+
# 计算此次请求的成本
|
987 |
+
request_cost = (input_tokens * model_price['input']) + (output_tokens * model_price['output'])
|
988 |
+
total_cost += request_cost
|
989 |
+
|
990 |
+
# 累加到模型成本中
|
991 |
+
if model_name not in model_costs:
|
992 |
+
model_costs[model_name] = 0
|
993 |
+
model_costs[model_name] += request_cost
|
994 |
+
|
995 |
+
# 计算平均成本
|
996 |
+
avg_cost = (total_cost / successful_requests) if successful_requests > 0 else 0
|
997 |
+
|
998 |
+
# ���取最常用模型
|
999 |
+
model_usage = dict(config_instance.usage_stats["model_usage"])
|
1000 |
+
top_models = sorted(model_usage.items(), key=lambda x: x[1], reverse=True)
|
1001 |
+
top_model = top_models[0] if top_models else None
|
1002 |
+
|
1003 |
+
# 构建完整的统计数据字典
|
1004 |
+
stats = {
|
1005 |
+
"total_requests": total_requests,
|
1006 |
+
"successful_requests": successful_requests,
|
1007 |
+
"failed_requests": failed_requests,
|
1008 |
+
"success_rate": success_rate,
|
1009 |
+
"avg_duration": avg_duration,
|
1010 |
+
"min_duration": min_duration,
|
1011 |
+
"today_requests": today_requests,
|
1012 |
+
"growth_rate": growth_rate,
|
1013 |
+
"total_prompt_tokens": total_prompt_tokens,
|
1014 |
+
"total_completion_tokens": total_completion_tokens,
|
1015 |
+
"total_tokens": total_tokens,
|
1016 |
+
"total_cost": total_cost,
|
1017 |
+
"avg_cost": avg_cost,
|
1018 |
+
"model_usage": model_usage,
|
1019 |
+
"model_costs": model_costs, # 添加每个模型的成本
|
1020 |
+
"top_model": top_model,
|
1021 |
+
"model_tokens": dict(config_instance.usage_stats["model_tokens"]),
|
1022 |
+
"account_usage": dict(config_instance.usage_stats["account_usage"]),
|
1023 |
+
"daily_usage": dict(sorted(config_instance.usage_stats["daily_usage"].items(), reverse=True)[:30]), # 最近30天
|
1024 |
+
"hourly_usage": dict(sorted(config_instance.usage_stats["hourly_usage"].items(), reverse=True)[:48]), # 最近48小时
|
1025 |
+
"request_history": list(config_instance.usage_stats["request_history"][:50]),
|
1026 |
+
"daily_tokens": dict(sorted(config_instance.usage_stats["daily_tokens"].items(), reverse=True)[:30]), # 最近30天
|
1027 |
+
"hourly_tokens": dict(sorted(config_instance.usage_stats["hourly_tokens"].items(), reverse=True)[:48]), # 最近48小时
|
1028 |
+
"last_saved": config_instance.usage_stats.get("last_saved", "从未保存")
|
1029 |
+
}
|
1030 |
+
|
1031 |
+
# 使用render_template渲染模板
|
1032 |
+
return render_template('stats.html', stats=stats, current_time=current_time_str)
|
1033 |
+
|
1034 |
+
@app.route('/save_stats', methods=['POST'])
|
1035 |
+
def save_stats():
|
1036 |
+
"""手动保存统计数据"""
|
1037 |
+
try:
|
1038 |
+
config_instance.save_stats_to_file()
|
1039 |
+
logger.info("统计数据已手动保存")
|
1040 |
+
return redirect(url_for('show_stats'))
|
1041 |
+
except Exception as e:
|
1042 |
+
logger.error(f"手动保存统计数据时出错: {e}")
|
1043 |
+
return jsonify({"status": "error", "message": str(e)}), 500
|
static/css/styles.css
ADDED
@@ -0,0 +1,698 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
:root {
|
2 |
+
--primary-color: #3498db;
|
3 |
+
--secondary-color: #2c3e50;
|
4 |
+
--success-color: #27ae60;
|
5 |
+
--info-color: #3498db;
|
6 |
+
--warning-color: #f39c12;
|
7 |
+
--danger-color: #e74c3c;
|
8 |
+
--light-bg: #f5f5f5;
|
9 |
+
--card-bg: #f8f9fa;
|
10 |
+
--border-color: #ddd;
|
11 |
+
--shadow-color: rgba(0,0,0,0.1);
|
12 |
+
--text-color: #333;
|
13 |
+
--heading-color: #2c3e50;
|
14 |
+
--button-hover: #2980b9;
|
15 |
+
--save-button: #e67e22;
|
16 |
+
--save-button-hover: #d35400;
|
17 |
+
--refresh-button: #2ecc71;
|
18 |
+
--refresh-button-hover: #27ae60;
|
19 |
+
--chart-bg: #fff;
|
20 |
+
--table-header-bg: #3498db;
|
21 |
+
--table-row-hover: #f5f5f5;
|
22 |
+
--table-border: #ddd;
|
23 |
+
--success-text: #27ae60;
|
24 |
+
--fail-text: #e74c3c;
|
25 |
+
--header-height: 60px;
|
26 |
+
--footer-height: 60px;
|
27 |
+
}
|
28 |
+
|
29 |
+
/* 暗黑模式变量 */
|
30 |
+
body.dark-mode {
|
31 |
+
--primary-color: #2980b9;
|
32 |
+
--secondary-color: #34495e;
|
33 |
+
--light-bg: #1a1a1a;
|
34 |
+
--card-bg: #2c2c2c;
|
35 |
+
--border-color: #444;
|
36 |
+
--shadow-color: rgba(0,0,0,0.3);
|
37 |
+
--text-color: #f5f5f5;
|
38 |
+
--heading-color: #f5f5f5;
|
39 |
+
--button-hover: #3498db;
|
40 |
+
--chart-bg: #2c2c2c;
|
41 |
+
--table-header-bg: #2980b9;
|
42 |
+
--table-row-hover: #3a3a3a;
|
43 |
+
--table-border: #444;
|
44 |
+
--save-button: #d35400;
|
45 |
+
--save-button-hover: #e67e22;
|
46 |
+
--refresh-button: #27ae60;
|
47 |
+
--refresh-button-hover: #2ecc71;
|
48 |
+
}
|
49 |
+
|
50 |
+
* {
|
51 |
+
box-sizing: border-box;
|
52 |
+
margin: 0;
|
53 |
+
padding: 0;
|
54 |
+
}
|
55 |
+
|
56 |
+
body {
|
57 |
+
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
58 |
+
margin: 0;
|
59 |
+
padding: 0;
|
60 |
+
background-color: var(--light-bg);
|
61 |
+
color: var(--text-color);
|
62 |
+
line-height: 1.6;
|
63 |
+
transition: background-color 0.3s ease, color 0.3s ease;
|
64 |
+
}
|
65 |
+
|
66 |
+
body.dark-mode {
|
67 |
+
background-color: var(--light-bg);
|
68 |
+
color: var(--text-color);
|
69 |
+
}
|
70 |
+
|
71 |
+
/* 主布局结构 */
|
72 |
+
.dashboard-wrapper {
|
73 |
+
display: flex;
|
74 |
+
min-height: 100vh;
|
75 |
+
position: relative;
|
76 |
+
flex-direction: column;
|
77 |
+
}
|
78 |
+
|
79 |
+
/* 主内容区域 */
|
80 |
+
.main-content {
|
81 |
+
flex: 1;
|
82 |
+
min-height: 100vh;
|
83 |
+
display: flex;
|
84 |
+
flex-direction: column;
|
85 |
+
}
|
86 |
+
|
87 |
+
/* 主内容头部 */
|
88 |
+
.main-header {
|
89 |
+
background-color: var(--card-bg);
|
90 |
+
padding: 1rem 1.5rem;
|
91 |
+
box-shadow: 0 2px 5px var(--shadow-color);
|
92 |
+
display: flex;
|
93 |
+
justify-content: space-between;
|
94 |
+
align-items: center;
|
95 |
+
position: sticky;
|
96 |
+
top: 0;
|
97 |
+
z-index: 90;
|
98 |
+
height: var(--header-height);
|
99 |
+
}
|
100 |
+
|
101 |
+
.header-left {
|
102 |
+
display: flex;
|
103 |
+
align-items: center;
|
104 |
+
gap: 1rem;
|
105 |
+
}
|
106 |
+
|
107 |
+
.header-left h1 {
|
108 |
+
font-size: 1.8rem;
|
109 |
+
margin: 0;
|
110 |
+
color: var(--primary-color);
|
111 |
+
display: flex;
|
112 |
+
align-items: center;
|
113 |
+
gap: 0.5rem;
|
114 |
+
}
|
115 |
+
|
116 |
+
.header-right {
|
117 |
+
display: flex;
|
118 |
+
align-items: center;
|
119 |
+
gap: 1.5rem;
|
120 |
+
}
|
121 |
+
|
122 |
+
/* 自动刷新进度条 */
|
123 |
+
.auto-refresh-bar {
|
124 |
+
background-color: var(--card-bg);
|
125 |
+
padding: 0.5rem 1rem;
|
126 |
+
margin-bottom: 1rem;
|
127 |
+
border-radius: 4px;
|
128 |
+
box-shadow: 0 1px 3px var(--shadow-color);
|
129 |
+
}
|
130 |
+
|
131 |
+
.refresh-progress {
|
132 |
+
height: 4px;
|
133 |
+
background-color: rgba(0,0,0,0.1);
|
134 |
+
border-radius: 2px;
|
135 |
+
margin-bottom: 0.5rem;
|
136 |
+
overflow: hidden;
|
137 |
+
}
|
138 |
+
|
139 |
+
.progress-bar {
|
140 |
+
height: 100%;
|
141 |
+
background-color: var(--primary-color);
|
142 |
+
width: 0;
|
143 |
+
transition: width 1s linear;
|
144 |
+
border-radius: 2px;
|
145 |
+
}
|
146 |
+
|
147 |
+
.refresh-info {
|
148 |
+
display: flex;
|
149 |
+
justify-content: space-between;
|
150 |
+
align-items: center;
|
151 |
+
font-size: 0.85rem;
|
152 |
+
}
|
153 |
+
|
154 |
+
h1, h2, h3 {
|
155 |
+
color: var(--heading-color);
|
156 |
+
margin-bottom: 1rem;
|
157 |
+
}
|
158 |
+
|
159 |
+
/* 仪表盘部分 */
|
160 |
+
.dashboard-section {
|
161 |
+
padding: 1rem 1.5rem;
|
162 |
+
display: none;
|
163 |
+
}
|
164 |
+
|
165 |
+
.dashboard-section.active-section {
|
166 |
+
display: block;
|
167 |
+
}
|
168 |
+
|
169 |
+
.section-header {
|
170 |
+
display: flex;
|
171 |
+
justify-content: space-between;
|
172 |
+
align-items: center;
|
173 |
+
margin-bottom: 1.5rem;
|
174 |
+
}
|
175 |
+
|
176 |
+
.section-header h2 {
|
177 |
+
font-size: 1.5rem;
|
178 |
+
margin: 0;
|
179 |
+
display: flex;
|
180 |
+
align-items: center;
|
181 |
+
gap: 0.5rem;
|
182 |
+
}
|
183 |
+
|
184 |
+
.section-header h2 i {
|
185 |
+
color: var(--primary-color);
|
186 |
+
}
|
187 |
+
|
188 |
+
.time-info {
|
189 |
+
font-size: 0.9rem;
|
190 |
+
color: var(--text-color);
|
191 |
+
opacity: 0.8;
|
192 |
+
}
|
193 |
+
|
194 |
+
.time-info span {
|
195 |
+
margin-right: 1rem;
|
196 |
+
}
|
197 |
+
|
198 |
+
.time-info i {
|
199 |
+
margin-right: 0.5rem;
|
200 |
+
color: var(--primary-color);
|
201 |
+
}
|
202 |
+
|
203 |
+
.actions {
|
204 |
+
display: flex;
|
205 |
+
gap: 0.5rem;
|
206 |
+
}
|
207 |
+
|
208 |
+
.save-button, .refresh-button {
|
209 |
+
background-color: var(--save-button);
|
210 |
+
color: white;
|
211 |
+
border: none;
|
212 |
+
padding: 0.5rem 1rem;
|
213 |
+
border-radius: 4px;
|
214 |
+
cursor: pointer;
|
215 |
+
font-weight: 600;
|
216 |
+
transition: all 0.3s ease;
|
217 |
+
display: flex;
|
218 |
+
align-items: center;
|
219 |
+
gap: 0.5rem;
|
220 |
+
}
|
221 |
+
|
222 |
+
.save-button:hover {
|
223 |
+
background-color: var(--save-button-hover);
|
224 |
+
transform: translateY(-2px);
|
225 |
+
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
226 |
+
}
|
227 |
+
|
228 |
+
.refresh-button {
|
229 |
+
background-color: var(--refresh-button);
|
230 |
+
}
|
231 |
+
|
232 |
+
.refresh-button:hover {
|
233 |
+
background-color: var(--refresh-button-hover);
|
234 |
+
transform: translateY(-2px);
|
235 |
+
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
236 |
+
}
|
237 |
+
|
238 |
+
/* 统计卡片网格 */
|
239 |
+
.stats-overview {
|
240 |
+
display: grid;
|
241 |
+
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
242 |
+
gap: 1.5rem;
|
243 |
+
margin-bottom: 2rem;
|
244 |
+
}
|
245 |
+
|
246 |
+
/* 统计卡片样式 */
|
247 |
+
.stats-card {
|
248 |
+
background-color: var(--card-bg);
|
249 |
+
border-radius: 10px;
|
250 |
+
padding: 1.5rem;
|
251 |
+
box-shadow: 0 2px 5px var(--shadow-color);
|
252 |
+
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
253 |
+
border-top: 4px solid var(--primary-color);
|
254 |
+
position: relative;
|
255 |
+
overflow: hidden;
|
256 |
+
display: flex;
|
257 |
+
align-items: center;
|
258 |
+
gap: 1rem;
|
259 |
+
}
|
260 |
+
|
261 |
+
.stats-card.primary {
|
262 |
+
border-top-color: var(--primary-color);
|
263 |
+
}
|
264 |
+
|
265 |
+
.stats-card.success {
|
266 |
+
border-top-color: var(--success-color);
|
267 |
+
}
|
268 |
+
|
269 |
+
.stats-card.info {
|
270 |
+
border-top-color: var(--info-color);
|
271 |
+
}
|
272 |
+
|
273 |
+
.stats-card.warning {
|
274 |
+
border-top-color: var(--warning-color);
|
275 |
+
}
|
276 |
+
|
277 |
+
.stats-card.danger {
|
278 |
+
border-top-color: var(--danger-color);
|
279 |
+
}
|
280 |
+
|
281 |
+
.stats-card.secondary {
|
282 |
+
border-top-color: var(--secondary-color);
|
283 |
+
}
|
284 |
+
|
285 |
+
.stats-icon {
|
286 |
+
width: 50px;
|
287 |
+
height: 50px;
|
288 |
+
border-radius: 50%;
|
289 |
+
background-color: rgba(52, 152, 219, 0.1);
|
290 |
+
display: flex;
|
291 |
+
align-items: center;
|
292 |
+
justify-content: center;
|
293 |
+
font-size: 1.5rem;
|
294 |
+
color: var(--primary-color);
|
295 |
+
}
|
296 |
+
|
297 |
+
.stats-card.primary .stats-icon {
|
298 |
+
background-color: rgba(52, 152, 219, 0.1);
|
299 |
+
color: var(--primary-color);
|
300 |
+
}
|
301 |
+
|
302 |
+
.stats-card.success .stats-icon {
|
303 |
+
background-color: rgba(39, 174, 96, 0.1);
|
304 |
+
color: var(--success-color);
|
305 |
+
}
|
306 |
+
|
307 |
+
.stats-card.info .stats-icon {
|
308 |
+
background-color: rgba(52, 152, 219, 0.1);
|
309 |
+
color: var(--info-color);
|
310 |
+
}
|
311 |
+
|
312 |
+
.stats-card.warning .stats-icon {
|
313 |
+
background-color: rgba(243, 156, 18, 0.1);
|
314 |
+
color: var(--warning-color);
|
315 |
+
}
|
316 |
+
|
317 |
+
.stats-card.danger .stats-icon {
|
318 |
+
background-color: rgba(231, 76, 60, 0.1);
|
319 |
+
color: var(--danger-color);
|
320 |
+
}
|
321 |
+
|
322 |
+
.stats-card.secondary .stats-icon {
|
323 |
+
background-color: rgba(44, 62, 80, 0.1);
|
324 |
+
color: var(--secondary-color);
|
325 |
+
}
|
326 |
+
|
327 |
+
.stats-content {
|
328 |
+
flex: 1;
|
329 |
+
}
|
330 |
+
|
331 |
+
.stats-card::after {
|
332 |
+
content: '';
|
333 |
+
position: absolute;
|
334 |
+
bottom: 0;
|
335 |
+
right: 0;
|
336 |
+
width: 30%;
|
337 |
+
height: 4px;
|
338 |
+
background-color: var(--primary-color);
|
339 |
+
opacity: 0.3;
|
340 |
+
}
|
341 |
+
|
342 |
+
.stats-card:hover {
|
343 |
+
transform: translateY(-5px);
|
344 |
+
box-shadow: 0 5px 15px var(--shadow-color);
|
345 |
+
}
|
346 |
+
|
347 |
+
.stats-card h3 {
|
348 |
+
font-size: 1rem;
|
349 |
+
color: var(--text-color);
|
350 |
+
opacity: 0.8;
|
351 |
+
margin-bottom: 0.5rem;
|
352 |
+
}
|
353 |
+
|
354 |
+
.stats-number {
|
355 |
+
font-size: 2rem;
|
356 |
+
font-weight: bold;
|
357 |
+
color: var(--primary-color);
|
358 |
+
margin: 0.5rem 0;
|
359 |
+
display: flex;
|
360 |
+
align-items: center;
|
361 |
+
}
|
362 |
+
|
363 |
+
/* 图表布局 */
|
364 |
+
.dashboard-charts {
|
365 |
+
margin-top: 2rem;
|
366 |
+
}
|
367 |
+
|
368 |
+
.chart-row {
|
369 |
+
display: grid;
|
370 |
+
grid-template-columns: 1fr 1fr;
|
371 |
+
gap: 1.5rem;
|
372 |
+
margin-bottom: 1.5rem;
|
373 |
+
}
|
374 |
+
|
375 |
+
.chart-card {
|
376 |
+
background-color: var(--card-bg);
|
377 |
+
border-radius: 10px;
|
378 |
+
box-shadow: 0 2px 5px var(--shadow-color);
|
379 |
+
overflow: hidden;
|
380 |
+
}
|
381 |
+
|
382 |
+
.chart-header {
|
383 |
+
display: flex;
|
384 |
+
justify-content: space-between;
|
385 |
+
align-items: center;
|
386 |
+
padding: 1rem 1.5rem;
|
387 |
+
border-bottom: 1px solid var(--border-color);
|
388 |
+
}
|
389 |
+
|
390 |
+
.chart-header h3 {
|
391 |
+
margin: 0;
|
392 |
+
font-size: 1.1rem;
|
393 |
+
display: flex;
|
394 |
+
align-items: center;
|
395 |
+
gap: 0.5rem;
|
396 |
+
}
|
397 |
+
|
398 |
+
.chart-header h3 i {
|
399 |
+
color: var(--primary-color);
|
400 |
+
}
|
401 |
+
|
402 |
+
.chart-body {
|
403 |
+
padding: 1rem;
|
404 |
+
height: 300px;
|
405 |
+
}
|
406 |
+
|
407 |
+
/* 表格样式 */
|
408 |
+
.table-container {
|
409 |
+
max-height: 500px;
|
410 |
+
overflow-y: auto;
|
411 |
+
border-radius: 10px;
|
412 |
+
box-shadow: 0 2px 5px var(--shadow-color);
|
413 |
+
margin-bottom: 1rem;
|
414 |
+
}
|
415 |
+
|
416 |
+
table {
|
417 |
+
width: 100%;
|
418 |
+
border-collapse: collapse;
|
419 |
+
margin-top: 1rem;
|
420 |
+
background-color: var(--card-bg);
|
421 |
+
border-radius: 10px;
|
422 |
+
overflow: hidden;
|
423 |
+
box-shadow: 0 2px 5px var(--shadow-color);
|
424 |
+
}
|
425 |
+
|
426 |
+
th, td {
|
427 |
+
padding: 1rem;
|
428 |
+
text-align: left;
|
429 |
+
border-bottom: 1px solid var(--table-border);
|
430 |
+
}
|
431 |
+
|
432 |
+
th {
|
433 |
+
background-color: var(--table-header-bg);
|
434 |
+
color: white;
|
435 |
+
font-weight: 600;
|
436 |
+
position: sticky;
|
437 |
+
top: 0;
|
438 |
+
z-index: 10;
|
439 |
+
}
|
440 |
+
|
441 |
+
th[data-sort] {
|
442 |
+
cursor: pointer;
|
443 |
+
}
|
444 |
+
|
445 |
+
th[data-sort] i {
|
446 |
+
margin-left: 0.5rem;
|
447 |
+
font-size: 0.8rem;
|
448 |
+
}
|
449 |
+
|
450 |
+
th.asc i, th.desc i {
|
451 |
+
color: #fff;
|
452 |
+
}
|
453 |
+
|
454 |
+
tr:last-child td {
|
455 |
+
border-bottom: none;
|
456 |
+
}
|
457 |
+
|
458 |
+
tr:hover {
|
459 |
+
background-color: var(--table-row-hover);
|
460 |
+
}
|
461 |
+
|
462 |
+
td.success {
|
463 |
+
color: var(--success-text);
|
464 |
+
font-weight: 600;
|
465 |
+
}
|
466 |
+
|
467 |
+
td.fail {
|
468 |
+
color: var(--fail-text);
|
469 |
+
font-weight: 600;
|
470 |
+
}
|
471 |
+
|
472 |
+
.history-section {
|
473 |
+
margin-top: 2rem;
|
474 |
+
}
|
475 |
+
|
476 |
+
.history-actions {
|
477 |
+
display: flex;
|
478 |
+
justify-content: space-between;
|
479 |
+
align-items: center;
|
480 |
+
margin-bottom: 1rem;
|
481 |
+
flex-wrap: wrap;
|
482 |
+
gap: 1rem;
|
483 |
+
}
|
484 |
+
|
485 |
+
.search-box {
|
486 |
+
position: relative;
|
487 |
+
flex: 1;
|
488 |
+
min-width: 200px;
|
489 |
+
}
|
490 |
+
|
491 |
+
.search-box input {
|
492 |
+
width: 100%;
|
493 |
+
padding: 0.5rem 1rem 0.5rem 2.5rem;
|
494 |
+
border: 1px solid var(--border-color);
|
495 |
+
border-radius: 4px;
|
496 |
+
font-size: 1rem;
|
497 |
+
background-color: var(--card-bg);
|
498 |
+
color: var(--text-color);
|
499 |
+
}
|
500 |
+
|
501 |
+
.search-box i {
|
502 |
+
position: absolute;
|
503 |
+
left: 0.8rem;
|
504 |
+
top: 50%;
|
505 |
+
transform: translateY(-50%);
|
506 |
+
color: var(--primary-color);
|
507 |
+
}
|
508 |
+
|
509 |
+
.pagination {
|
510 |
+
display: flex;
|
511 |
+
justify-content: space-between;
|
512 |
+
align-items: center;
|
513 |
+
margin-top: 1rem;
|
514 |
+
}
|
515 |
+
|
516 |
+
.pagination button {
|
517 |
+
background-color: var(--primary-color);
|
518 |
+
color: white;
|
519 |
+
border: none;
|
520 |
+
padding: 0.5rem 1rem;
|
521 |
+
border-radius: 4px;
|
522 |
+
cursor: pointer;
|
523 |
+
transition: background-color 0.3s ease;
|
524 |
+
display: flex;
|
525 |
+
align-items: center;
|
526 |
+
gap: 0.5rem;
|
527 |
+
}
|
528 |
+
|
529 |
+
.pagination button:disabled {
|
530 |
+
background-color: #ccc;
|
531 |
+
cursor: not-allowed;
|
532 |
+
}
|
533 |
+
|
534 |
+
.pagination button:not(:disabled):hover {
|
535 |
+
background-color: var(--button-hover);
|
536 |
+
}
|
537 |
+
|
538 |
+
#page-info {
|
539 |
+
font-size: 0.9rem;
|
540 |
+
color: var(--text-color);
|
541 |
+
}
|
542 |
+
|
543 |
+
/* 页脚样式 */
|
544 |
+
.main-footer {
|
545 |
+
margin-top: auto;
|
546 |
+
padding: 1rem 1.5rem;
|
547 |
+
border-top: 1px solid var(--border-color);
|
548 |
+
background-color: var(--card-bg);
|
549 |
+
box-shadow: 0 -2px 5px var(--shadow-color);
|
550 |
+
}
|
551 |
+
|
552 |
+
.footer-content {
|
553 |
+
display: flex;
|
554 |
+
justify-content: space-between;
|
555 |
+
align-items: center;
|
556 |
+
}
|
557 |
+
|
558 |
+
.footer-logo h3 {
|
559 |
+
margin: 0;
|
560 |
+
font-size: 1.2rem;
|
561 |
+
color: var(--primary-color);
|
562 |
+
}
|
563 |
+
|
564 |
+
.footer-logo h3 span {
|
565 |
+
font-weight: normal;
|
566 |
+
opacity: 0.8;
|
567 |
+
}
|
568 |
+
|
569 |
+
.footer-info {
|
570 |
+
font-size: 0.85rem;
|
571 |
+
opacity: 0.8;
|
572 |
+
}
|
573 |
+
|
574 |
+
#countdown {
|
575 |
+
font-weight: bold;
|
576 |
+
color: var(--primary-color);
|
577 |
+
}
|
578 |
+
|
579 |
+
/* 状态徽章样式 */
|
580 |
+
.status-badge {
|
581 |
+
display: inline-flex;
|
582 |
+
align-items: center;
|
583 |
+
gap: 0.3rem;
|
584 |
+
padding: 0.3rem 0.6rem;
|
585 |
+
border-radius: 20px;
|
586 |
+
font-size: 0.85rem;
|
587 |
+
font-weight: 600;
|
588 |
+
}
|
589 |
+
|
590 |
+
.status-badge.success {
|
591 |
+
background-color: rgba(39, 174, 96, 0.1);
|
592 |
+
color: var(--success-color);
|
593 |
+
}
|
594 |
+
|
595 |
+
.status-badge.fail {
|
596 |
+
background-color: rgba(231, 76, 60, 0.1);
|
597 |
+
color: var(--fail-text);
|
598 |
+
}
|
599 |
+
|
600 |
+
/* 模型徽章样式 */
|
601 |
+
.model-badge {
|
602 |
+
display: inline-block;
|
603 |
+
padding: 0.3rem 0.6rem;
|
604 |
+
border-radius: 20px;
|
605 |
+
font-size: 0.85rem;
|
606 |
+
background-color: rgba(52, 152, 219, 0.1);
|
607 |
+
color: var(--primary-color);
|
608 |
+
}
|
609 |
+
|
610 |
+
.model-badge.small {
|
611 |
+
font-size: 0.75rem;
|
612 |
+
padding: 0.2rem 0.4rem;
|
613 |
+
}
|
614 |
+
|
615 |
+
/* 账户头像样式 */
|
616 |
+
.account-avatar {
|
617 |
+
display: inline-flex;
|
618 |
+
align-items: center;
|
619 |
+
justify-content: center;
|
620 |
+
width: 30px;
|
621 |
+
height: 30px;
|
622 |
+
border-radius: 50%;
|
623 |
+
background-color: var(--primary-color);
|
624 |
+
color: white;
|
625 |
+
font-weight: bold;
|
626 |
+
}
|
627 |
+
|
628 |
+
.account-avatar.small {
|
629 |
+
width: 24px;
|
630 |
+
height: 24px;
|
631 |
+
font-size: 0.8rem;
|
632 |
+
}
|
633 |
+
|
634 |
+
.account-cell {
|
635 |
+
display: flex;
|
636 |
+
align-items: center;
|
637 |
+
gap: 0.5rem;
|
638 |
+
}
|
639 |
+
|
640 |
+
/* 趋势指示器 */
|
641 |
+
.stats-trend {
|
642 |
+
display: flex;
|
643 |
+
align-items: center;
|
644 |
+
gap: 0.3rem;
|
645 |
+
font-size: 0.85rem;
|
646 |
+
margin-top: 0.5rem;
|
647 |
+
}
|
648 |
+
|
649 |
+
.stats-trend.positive {
|
650 |
+
color: var(--success-color);
|
651 |
+
}
|
652 |
+
|
653 |
+
.stats-trend.negative {
|
654 |
+
color: var(--danger-color);
|
655 |
+
}
|
656 |
+
|
657 |
+
.stats-detail {
|
658 |
+
font-size: 0.85rem;
|
659 |
+
opacity: 0.8;
|
660 |
+
margin-top: 0.5rem;
|
661 |
+
}
|
662 |
+
|
663 |
+
/* 响应式设计优化 */
|
664 |
+
@media (max-width: 992px) {
|
665 |
+
.chart-row {
|
666 |
+
grid-template-columns: 1fr;
|
667 |
+
}
|
668 |
+
|
669 |
+
.stats-overview {
|
670 |
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
671 |
+
}
|
672 |
+
}
|
673 |
+
|
674 |
+
@media (max-width: 768px) {
|
675 |
+
.stats-overview {
|
676 |
+
grid-template-columns: 1fr;
|
677 |
+
}
|
678 |
+
|
679 |
+
.chart-body {
|
680 |
+
height: 250px;
|
681 |
+
}
|
682 |
+
|
683 |
+
table {
|
684 |
+
display: block;
|
685 |
+
overflow-x: auto;
|
686 |
+
}
|
687 |
+
|
688 |
+
.history-actions {
|
689 |
+
flex-direction: column;
|
690 |
+
align-items: stretch;
|
691 |
+
}
|
692 |
+
|
693 |
+
.footer-content {
|
694 |
+
flex-direction: column;
|
695 |
+
gap: 1rem;
|
696 |
+
text-align: center;
|
697 |
+
}
|
698 |
+
}
|
static/js/scripts.js
ADDED
@@ -0,0 +1,457 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
// 全局变量
|
2 |
+
let refreshInterval = 60; // 默认刷新间隔(秒)
|
3 |
+
let autoRefreshEnabled = true; // 默认启用自动刷新
|
4 |
+
let chartInstances = {}; // 存储图表实例的对象
|
5 |
+
let darkModeEnabled = localStorage.getItem('theme') === 'dark'; // 深色模式状态
|
6 |
+
|
7 |
+
// 格式化大数值的函数
|
8 |
+
function formatChartNumber(value) {
|
9 |
+
if (value >= 1000000000) {
|
10 |
+
return (value / 1000000000).toFixed(1) + 'G';
|
11 |
+
} else if (value >= 1000000) {
|
12 |
+
return (value / 1000000).toFixed(1) + 'M';
|
13 |
+
} else if (value >= 1000) {
|
14 |
+
return (value / 1000).toFixed(1) + 'K';
|
15 |
+
}
|
16 |
+
return value;
|
17 |
+
}
|
18 |
+
|
19 |
+
// 页面加载完成后执行
|
20 |
+
document.addEventListener('DOMContentLoaded', function() {
|
21 |
+
// 初始化图表
|
22 |
+
initializeCharts();
|
23 |
+
|
24 |
+
// 设置自动刷新
|
25 |
+
setupAutoRefresh();
|
26 |
+
|
27 |
+
// 主题切换
|
28 |
+
setupThemeToggle();
|
29 |
+
|
30 |
+
// 加载保存的主题
|
31 |
+
loadSavedTheme();
|
32 |
+
|
33 |
+
// 添加表格交互功能
|
34 |
+
enhanceTableInteraction();
|
35 |
+
|
36 |
+
// 添加保存统计数据按钮事件
|
37 |
+
setupSaveStatsButton();
|
38 |
+
|
39 |
+
// 更新页脚信息
|
40 |
+
updateFooterInfo();
|
41 |
+
|
42 |
+
// 表格排序和筛选
|
43 |
+
const table = document.getElementById('history-table');
|
44 |
+
if (table) {
|
45 |
+
const headers = table.querySelectorAll('th[data-sort]');
|
46 |
+
const rows = Array.from(table.querySelectorAll('tbody tr'));
|
47 |
+
const rowsPerPage = 10;
|
48 |
+
let currentPage = 1;
|
49 |
+
let filteredRows = [...rows];
|
50 |
+
|
51 |
+
// 初始化分页
|
52 |
+
function initPagination() {
|
53 |
+
const totalPages = Math.ceil(filteredRows.length / rowsPerPage);
|
54 |
+
document.getElementById('total-pages').textContent = totalPages;
|
55 |
+
document.getElementById('current-page').textContent = currentPage;
|
56 |
+
document.getElementById('prev-page').disabled = currentPage === 1;
|
57 |
+
document.getElementById('next-page').disabled = currentPage === totalPages || totalPages === 0;
|
58 |
+
|
59 |
+
// 显示当前页的行
|
60 |
+
const startIndex = (currentPage - 1) * rowsPerPage;
|
61 |
+
const endIndex = startIndex + rowsPerPage;
|
62 |
+
|
63 |
+
rows.forEach(row => row.style.display = 'none');
|
64 |
+
filteredRows.slice(startIndex, endIndex).forEach(row => row.style.display = '');
|
65 |
+
}
|
66 |
+
|
67 |
+
// 排序功能
|
68 |
+
headers.forEach(header => {
|
69 |
+
header.addEventListener('click', () => {
|
70 |
+
const sortBy = header.getAttribute('data-sort');
|
71 |
+
const isAscending = header.classList.contains('asc');
|
72 |
+
|
73 |
+
// 移除所有排序指示器
|
74 |
+
headers.forEach(h => {
|
75 |
+
h.classList.remove('asc', 'desc');
|
76 |
+
h.querySelector('i').className = 'fas fa-sort';
|
77 |
+
});
|
78 |
+
|
79 |
+
// 设置当前排序方向
|
80 |
+
if (isAscending) {
|
81 |
+
header.classList.add('desc');
|
82 |
+
header.querySelector('i').className = 'fas fa-sort-down';
|
83 |
+
} else {
|
84 |
+
header.classList.add('asc');
|
85 |
+
header.querySelector('i').className = 'fas fa-sort-up';
|
86 |
+
}
|
87 |
+
|
88 |
+
// 排序行
|
89 |
+
filteredRows.sort((a, b) => {
|
90 |
+
let aValue, bValue;
|
91 |
+
|
92 |
+
if (sortBy === 'id') {
|
93 |
+
aValue = a.cells[0].getAttribute('title');
|
94 |
+
bValue = b.cells[0].getAttribute('title');
|
95 |
+
} else if (sortBy === 'timestamp') {
|
96 |
+
aValue = a.cells[1].textContent;
|
97 |
+
bValue = b.cells[1].textContent;
|
98 |
+
} else if (sortBy === 'duration' || sortBy === 'total') {
|
99 |
+
const aText = a.cells[sortBy === 'duration' ? 5 : 6].textContent;
|
100 |
+
const bText = b.cells[sortBy === 'duration' ? 5 : 6].textContent;
|
101 |
+
aValue = aText === '-' ? 0 : parseInt(aText.replace(/,/g, '').replace(/[KMG]/g, ''));
|
102 |
+
bValue = bText === '-' ? 0 : parseInt(bText.replace(/,/g, '').replace(/[KMG]/g, ''));
|
103 |
+
} else {
|
104 |
+
aValue = a.cells[sortBy === 'model' ? 2 : (sortBy === 'account' ? 3 : 4)].textContent;
|
105 |
+
bValue = b.cells[sortBy === 'model' ? 2 : (sortBy === 'account' ? 3 : 4)].textContent;
|
106 |
+
}
|
107 |
+
|
108 |
+
if (aValue < bValue) return isAscending ? -1 : 1;
|
109 |
+
if (aValue > bValue) return isAscending ? 1 : -1;
|
110 |
+
return 0;
|
111 |
+
});
|
112 |
+
|
113 |
+
// 更新显示
|
114 |
+
currentPage = 1;
|
115 |
+
initPagination();
|
116 |
+
});
|
117 |
+
});
|
118 |
+
|
119 |
+
// 搜索功能
|
120 |
+
const searchInput = document.getElementById('history-search');
|
121 |
+
if (searchInput) {
|
122 |
+
searchInput.addEventListener('input', function() {
|
123 |
+
const searchTerm = this.value.toLowerCase();
|
124 |
+
|
125 |
+
filteredRows = rows.filter(row => {
|
126 |
+
const rowText = Array.from(row.cells).map(cell => cell.textContent.toLowerCase()).join(' ');
|
127 |
+
return rowText.includes(searchTerm);
|
128 |
+
});
|
129 |
+
|
130 |
+
currentPage = 1;
|
131 |
+
initPagination();
|
132 |
+
});
|
133 |
+
}
|
134 |
+
|
135 |
+
// 分页控制
|
136 |
+
const prevPageBtn = document.getElementById('prev-page');
|
137 |
+
const nextPageBtn = document.getElementById('next-page');
|
138 |
+
|
139 |
+
if (prevPageBtn) {
|
140 |
+
prevPageBtn.addEventListener('click', () => {
|
141 |
+
if (currentPage > 1) {
|
142 |
+
currentPage--;
|
143 |
+
initPagination();
|
144 |
+
}
|
145 |
+
});
|
146 |
+
}
|
147 |
+
|
148 |
+
if (nextPageBtn) {
|
149 |
+
nextPageBtn.addEventListener('click', () => {
|
150 |
+
const totalPages = Math.ceil(filteredRows.length / rowsPerPage);
|
151 |
+
if (currentPage < totalPages) {
|
152 |
+
currentPage++;
|
153 |
+
initPagination();
|
154 |
+
}
|
155 |
+
});
|
156 |
+
}
|
157 |
+
|
158 |
+
// 初始化表格
|
159 |
+
initPagination();
|
160 |
+
}
|
161 |
+
|
162 |
+
// 刷新按钮
|
163 |
+
const refreshBtn = document.getElementById('refresh-btn');
|
164 |
+
if (refreshBtn) {
|
165 |
+
refreshBtn.addEventListener('click', () => {
|
166 |
+
location.reload();
|
167 |
+
});
|
168 |
+
}
|
169 |
+
});
|
170 |
+
|
171 |
+
// 初始化图表
|
172 |
+
function initializeCharts() {
|
173 |
+
try {
|
174 |
+
// 注册Chart.js插件
|
175 |
+
Chart.register(ChartDataLabels);
|
176 |
+
|
177 |
+
// 设置全局默认值
|
178 |
+
Chart.defaults.font.family = 'Nunito, sans-serif';
|
179 |
+
Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--text-color');
|
180 |
+
|
181 |
+
// 每日请求趋势图表
|
182 |
+
const dailyChartElement = document.getElementById('dailyChart');
|
183 |
+
if (dailyChartElement) {
|
184 |
+
const labels = JSON.parse(dailyChartElement.dataset.labels || '[]');
|
185 |
+
const values = JSON.parse(dailyChartElement.dataset.values || '[]');
|
186 |
+
|
187 |
+
const dailyChart = new Chart(dailyChartElement, {
|
188 |
+
type: 'line',
|
189 |
+
data: {
|
190 |
+
labels: labels,
|
191 |
+
datasets: [{
|
192 |
+
label: '请求数',
|
193 |
+
data: values,
|
194 |
+
backgroundColor: 'rgba(52, 152, 219, 0.2)',
|
195 |
+
borderColor: 'rgba(52, 152, 219, 1)',
|
196 |
+
borderWidth: 2,
|
197 |
+
pointBackgroundColor: 'rgba(52, 152, 219, 1)',
|
198 |
+
pointRadius: 4,
|
199 |
+
tension: 0.3,
|
200 |
+
fill: true
|
201 |
+
}]
|
202 |
+
},
|
203 |
+
options: {
|
204 |
+
responsive: true,
|
205 |
+
maintainAspectRatio: false,
|
206 |
+
plugins: {
|
207 |
+
legend: {
|
208 |
+
display: false
|
209 |
+
},
|
210 |
+
tooltip: {
|
211 |
+
mode: 'index',
|
212 |
+
intersect: false,
|
213 |
+
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
214 |
+
titleFont: {
|
215 |
+
size: 14
|
216 |
+
},
|
217 |
+
bodyFont: {
|
218 |
+
size: 13
|
219 |
+
},
|
220 |
+
padding: 10,
|
221 |
+
displayColors: false
|
222 |
+
},
|
223 |
+
datalabels: {
|
224 |
+
display: false
|
225 |
+
}
|
226 |
+
},
|
227 |
+
scales: {
|
228 |
+
x: {
|
229 |
+
grid: {
|
230 |
+
display: false
|
231 |
+
},
|
232 |
+
ticks: {
|
233 |
+
maxRotation: 45,
|
234 |
+
minRotation: 45
|
235 |
+
}
|
236 |
+
},
|
237 |
+
y: {
|
238 |
+
beginAtZero: true,
|
239 |
+
grid: {
|
240 |
+
color: 'rgba(200, 200, 200, 0.1)'
|
241 |
+
},
|
242 |
+
ticks: {
|
243 |
+
precision: 0
|
244 |
+
}
|
245 |
+
}
|
246 |
+
}
|
247 |
+
}
|
248 |
+
});
|
249 |
+
|
250 |
+
chartInstances['dailyChart'] = dailyChart;
|
251 |
+
}
|
252 |
+
|
253 |
+
// 模型使用分布图表
|
254 |
+
const modelChartElement = document.getElementById('modelChart');
|
255 |
+
if (modelChartElement) {
|
256 |
+
const labels = JSON.parse(modelChartElement.dataset.labels || '[]');
|
257 |
+
const values = JSON.parse(modelChartElement.dataset.values || '[]');
|
258 |
+
|
259 |
+
const modelChart = new Chart(modelChartElement, {
|
260 |
+
type: 'pie',
|
261 |
+
data: {
|
262 |
+
labels: labels,
|
263 |
+
datasets: [{
|
264 |
+
label: '模型使用次数',
|
265 |
+
data: values,
|
266 |
+
backgroundColor: [
|
267 |
+
'rgba(255, 99, 132, 0.5)',
|
268 |
+
'rgba(54, 162, 235, 0.5)',
|
269 |
+
'rgba(255, 206, 86, 0.5)',
|
270 |
+
'rgba(75, 192, 192, 0.5)',
|
271 |
+
'rgba(153, 102, 255, 0.5)',
|
272 |
+
'rgba(255, 159, 64, 0.5)',
|
273 |
+
'rgba(199, 199, 199, 0.5)',
|
274 |
+
'rgba(83, 102, 255, 0.5)',
|
275 |
+
'rgba(40, 159, 64, 0.5)',
|
276 |
+
'rgba(210, 199, 199, 0.5)'
|
277 |
+
],
|
278 |
+
borderColor: [
|
279 |
+
'rgba(255, 99, 132, 1)',
|
280 |
+
'rgba(54, 162, 235, 1)',
|
281 |
+
'rgba(255, 206, 86, 1)',
|
282 |
+
'rgba(75, 192, 192, 1)',
|
283 |
+
'rgba(153, 102, 255, 1)',
|
284 |
+
'rgba(255, 159, 64, 1)',
|
285 |
+
'rgba(199, 199, 199, 1)',
|
286 |
+
'rgba(83, 102, 255, 1)',
|
287 |
+
'rgba(40, 159, 64, 1)',
|
288 |
+
'rgba(210, 199, 199, 1)'
|
289 |
+
],
|
290 |
+
borderWidth: 1
|
291 |
+
}]
|
292 |
+
},
|
293 |
+
options: {
|
294 |
+
responsive: true,
|
295 |
+
maintainAspectRatio: false,
|
296 |
+
plugins: {
|
297 |
+
tooltip: {
|
298 |
+
callbacks: {
|
299 |
+
label: function(context) {
|
300 |
+
let label = context.label || '';
|
301 |
+
if (label) {
|
302 |
+
label += ': ';
|
303 |
+
}
|
304 |
+
label += formatChartNumber(context.parsed);
|
305 |
+
return label;
|
306 |
+
}
|
307 |
+
}
|
308 |
+
}
|
309 |
+
}
|
310 |
+
}
|
311 |
+
});
|
312 |
+
|
313 |
+
chartInstances['modelChart'] = modelChart;
|
314 |
+
}
|
315 |
+
} catch (error) {
|
316 |
+
console.error('初始化图表失败:', error);
|
317 |
+
}
|
318 |
+
}
|
319 |
+
|
320 |
+
// 设置自动刷新功能
|
321 |
+
function setupAutoRefresh() {
|
322 |
+
// 获取已有的刷新进度条元素
|
323 |
+
const progressBar = document.getElementById('refresh-progress-bar');
|
324 |
+
const countdownElement = document.getElementById('countdown');
|
325 |
+
let countdownTimer;
|
326 |
+
|
327 |
+
// 倒计时功能
|
328 |
+
let countdown = refreshInterval;
|
329 |
+
|
330 |
+
function startCountdown() {
|
331 |
+
if (countdownTimer) clearInterval(countdownTimer);
|
332 |
+
|
333 |
+
countdown = refreshInterval;
|
334 |
+
countdownElement.textContent = countdown;
|
335 |
+
|
336 |
+
// 重置进度条
|
337 |
+
progressBar.style.width = '100%';
|
338 |
+
|
339 |
+
if (autoRefreshEnabled) {
|
340 |
+
// 设置进度条动画
|
341 |
+
progressBar.style.transition = `width ${refreshInterval}s linear`;
|
342 |
+
progressBar.style.width = '0%';
|
343 |
+
|
344 |
+
countdownTimer = setInterval(function() {
|
345 |
+
countdown--;
|
346 |
+
if (countdown <= 0) {
|
347 |
+
countdown = refreshInterval;
|
348 |
+
location.reload();
|
349 |
+
}
|
350 |
+
countdownElement.textContent = countdown;
|
351 |
+
}, 1000);
|
352 |
+
} else {
|
353 |
+
// 暂停进度条动画
|
354 |
+
progressBar.style.transition = 'none';
|
355 |
+
progressBar.style.width = '0%';
|
356 |
+
}
|
357 |
+
}
|
358 |
+
|
359 |
+
// 立即启动倒计时
|
360 |
+
startCountdown();
|
361 |
+
}
|
362 |
+
|
363 |
+
// 设置主题切换
|
364 |
+
function setupThemeToggle() {
|
365 |
+
// 在简化版中,我们移除了主题切换按钮,但保留功能以备将来使用
|
366 |
+
const themeToggleBtn = document.getElementById('theme-toggle-btn');
|
367 |
+
if (themeToggleBtn) {
|
368 |
+
themeToggleBtn.addEventListener('click', function() {
|
369 |
+
document.body.classList.toggle('dark-mode');
|
370 |
+
darkModeEnabled = document.body.classList.contains('dark-mode');
|
371 |
+
|
372 |
+
localStorage.setItem('theme', darkModeEnabled ? 'dark' : 'light');
|
373 |
+
|
374 |
+
// 更新所有图表的颜色
|
375 |
+
updateChartsTheme();
|
376 |
+
});
|
377 |
+
}
|
378 |
+
}
|
379 |
+
|
380 |
+
// 加载保存的主题
|
381 |
+
function loadSavedTheme() {
|
382 |
+
if (darkModeEnabled) {
|
383 |
+
document.body.classList.add('dark-mode');
|
384 |
+
const themeToggleBtn = document.querySelector('#theme-toggle-btn i');
|
385 |
+
if (themeToggleBtn) {
|
386 |
+
themeToggleBtn.classList.remove('fa-moon');
|
387 |
+
themeToggleBtn.classList.add('fa-sun');
|
388 |
+
}
|
389 |
+
}
|
390 |
+
}
|
391 |
+
|
392 |
+
// 更新图表主题
|
393 |
+
function updateChartsTheme() {
|
394 |
+
// 更新所有图表的颜色主题
|
395 |
+
Object.values(chartInstances).forEach(chart => {
|
396 |
+
// 更新网格线颜色
|
397 |
+
if (chart.options.scales && chart.options.scales.y) {
|
398 |
+
chart.options.scales.y.grid.color = darkModeEnabled ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
|
399 |
+
chart.options.scales.x.grid.color = darkModeEnabled ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
|
400 |
+
|
401 |
+
// 更新刻度颜色
|
402 |
+
chart.options.scales.y.ticks.color = darkModeEnabled ? '#ddd' : '#666';
|
403 |
+
chart.options.scales.x.ticks.color = darkModeEnabled ? '#ddd' : '#666';
|
404 |
+
}
|
405 |
+
|
406 |
+
// 更新图例颜色
|
407 |
+
if (chart.options.plugins && chart.options.plugins.legend) {
|
408 |
+
chart.options.plugins.legend.labels.color = darkModeEnabled ? '#ddd' : '#666';
|
409 |
+
}
|
410 |
+
|
411 |
+
chart.update();
|
412 |
+
});
|
413 |
+
}
|
414 |
+
|
415 |
+
// 设置保存统计数据按钮事件
|
416 |
+
function setupSaveStatsButton() {
|
417 |
+
const saveButton = document.querySelector('.save-button');
|
418 |
+
if (saveButton) {
|
419 |
+
// 添加点击动画效果
|
420 |
+
saveButton.addEventListener('click', function() {
|
421 |
+
this.classList.add('saving');
|
422 |
+
setTimeout(() => {
|
423 |
+
this.classList.remove('saving');
|
424 |
+
}, 1000);
|
425 |
+
});
|
426 |
+
}
|
427 |
+
}
|
428 |
+
|
429 |
+
// 添加表格交互功能
|
430 |
+
function enhanceTableInteraction() {
|
431 |
+
// 为请求历史表格添加高亮效果
|
432 |
+
const historyRows = document.querySelectorAll('#history-table tbody tr');
|
433 |
+
historyRows.forEach(row => {
|
434 |
+
row.addEventListener('mouseenter', function() {
|
435 |
+
this.classList.add('highlight');
|
436 |
+
});
|
437 |
+
|
438 |
+
row.addEventListener('mouseleave', function() {
|
439 |
+
this.classList.remove('highlight');
|
440 |
+
});
|
441 |
+
});
|
442 |
+
}
|
443 |
+
|
444 |
+
// 更新页脚信息
|
445 |
+
function updateFooterInfo() {
|
446 |
+
const footer = document.querySelector('.main-footer');
|
447 |
+
if (!footer) return;
|
448 |
+
|
449 |
+
// 获取当前年份
|
450 |
+
const currentYear = new Date().getFullYear();
|
451 |
+
|
452 |
+
// 更新版权年份
|
453 |
+
const copyrightText = footer.querySelector('p:first-child');
|
454 |
+
if (copyrightText) {
|
455 |
+
copyrightText.textContent = `© ${currentYear} 2API 统计面板 | 版本 1.0.1`;
|
456 |
+
}
|
457 |
+
}
|
templates/stats.html
ADDED
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<!DOCTYPE html>
|
2 |
+
<html lang="zh-CN">
|
3 |
+
<head>
|
4 |
+
<meta charset="UTF-8">
|
5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
6 |
+
<meta http-equiv="refresh" content="60">
|
7 |
+
<title>2API 用量统计</title>
|
8 |
+
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
|
9 |
+
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
10 |
+
<script src="https://cdn.jsdelivr.net/npm/[email protected]"></script>
|
11 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
12 |
+
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@300;400;600;700&display=swap" rel="stylesheet">
|
13 |
+
</head>
|
14 |
+
<body>
|
15 |
+
<div class="dashboard-wrapper">
|
16 |
+
<header class="main-header">
|
17 |
+
<div class="header-left">
|
18 |
+
<h1><i class="fas fa-chart-line"></i> 2API 监控面板</h1>
|
19 |
+
</div>
|
20 |
+
<div class="header-right">
|
21 |
+
<div class="time-info">
|
22 |
+
<span><i class="fas fa-clock"></i> 最后更新: {{ current_time }}</span>
|
23 |
+
<span><i class="fas fa-save"></i> 最后保存: {{ stats.last_saved|format_datetime if stats.last_saved != "从未保存" else "从未保存" }}</span>
|
24 |
+
</div>
|
25 |
+
<div class="actions">
|
26 |
+
<form action="/save_stats" method="post">
|
27 |
+
<button type="submit" class="save-button" title="保存统计数据"><i class="fas fa-save"></i></button>
|
28 |
+
</form>
|
29 |
+
<button id="refresh-btn" class="refresh-button" title="刷新数据"><i class="fas fa-sync-alt"></i></button>
|
30 |
+
</div>
|
31 |
+
</div>
|
32 |
+
</header>
|
33 |
+
|
34 |
+
<div class="main-content">
|
35 |
+
<div class="auto-refresh-bar">
|
36 |
+
<div class="refresh-progress">
|
37 |
+
<div class="progress-bar" id="refresh-progress-bar" style="width: 100%;"></div>
|
38 |
+
</div>
|
39 |
+
<div class="refresh-info">
|
40 |
+
<span>数据将在 <span id="countdown">60</span> 秒后自动刷新</span>
|
41 |
+
</div>
|
42 |
+
</div>
|
43 |
+
|
44 |
+
<!-- 统计概览部分 -->
|
45 |
+
<section id="dashboard" class="dashboard-section active-section">
|
46 |
+
<div class="section-header">
|
47 |
+
<h2><i class="fas fa-tachometer-alt"></i> 统计概览</h2>
|
48 |
+
</div>
|
49 |
+
|
50 |
+
<div class="stats-overview">
|
51 |
+
<div class="stats-card primary">
|
52 |
+
<div class="stats-icon">
|
53 |
+
<i class="fas fa-server"></i>
|
54 |
+
</div>
|
55 |
+
<div class="stats-content">
|
56 |
+
<h3>总请求数</h3>
|
57 |
+
<div class="stats-number">{{ stats.total_requests|format_number }}</div>
|
58 |
+
<div class="stats-trend positive">
|
59 |
+
<i class="fas fa-arrow-up"></i>
|
60 |
+
{{ stats.growth_rate|round(2) }}% 今日
|
61 |
+
</div>
|
62 |
+
</div>
|
63 |
+
</div>
|
64 |
+
|
65 |
+
<div class="stats-card success">
|
66 |
+
<div class="stats-icon">
|
67 |
+
<i class="fas fa-check-circle"></i>
|
68 |
+
</div>
|
69 |
+
<div class="stats-content">
|
70 |
+
<h3>成功率</h3>
|
71 |
+
<div class="stats-number">{{ stats.success_rate }}%</div>
|
72 |
+
<div class="stats-detail">
|
73 |
+
成功: {{ stats.successful_requests|format_number }} / 失败: {{ stats.failed_requests|format_number }}
|
74 |
+
</div>
|
75 |
+
</div>
|
76 |
+
</div>
|
77 |
+
|
78 |
+
<div class="stats-card info">
|
79 |
+
<div class="stats-icon">
|
80 |
+
<i class="fas fa-bolt"></i>
|
81 |
+
</div>
|
82 |
+
<div class="stats-content">
|
83 |
+
<h3>平均响应时间</h3>
|
84 |
+
<div class="stats-number">
|
85 |
+
{{ stats.avg_duration|format_duration }}
|
86 |
+
</div>
|
87 |
+
<div class="stats-detail">
|
88 |
+
最快: {{ stats.min_duration|format_duration }}
|
89 |
+
</div>
|
90 |
+
</div>
|
91 |
+
</div>
|
92 |
+
|
93 |
+
<div class="stats-card warning">
|
94 |
+
<div class="stats-icon">
|
95 |
+
<i class="fas fa-coins"></i>
|
96 |
+
</div>
|
97 |
+
<div class="stats-content">
|
98 |
+
<h3>总 Tokens</h3>
|
99 |
+
<div class="stats-number">{{ stats.total_tokens|format_number }}</div>
|
100 |
+
<div class="stats-detail">
|
101 |
+
提示: {{ stats.total_prompt_tokens|format_number }} / 完成: {{ stats.total_completion_tokens|format_number }}
|
102 |
+
</div>
|
103 |
+
</div>
|
104 |
+
</div>
|
105 |
+
|
106 |
+
<div class="stats-card danger">
|
107 |
+
<div class="stats-icon">
|
108 |
+
<i class="fas fa-dollar-sign"></i>
|
109 |
+
</div>
|
110 |
+
<div class="stats-content">
|
111 |
+
<h3>估算成本</h3>
|
112 |
+
<div class="stats-number">
|
113 |
+
${{ stats.total_cost | round(2) }}
|
114 |
+
</div>
|
115 |
+
<div class="stats-detail">
|
116 |
+
平均: ${{ stats.avg_cost | round(2) }}/请求
|
117 |
+
</div>
|
118 |
+
</div>
|
119 |
+
</div>
|
120 |
+
|
121 |
+
<div class="stats-card secondary">
|
122 |
+
<div class="stats-icon">
|
123 |
+
<i class="fas fa-robot"></i>
|
124 |
+
</div>
|
125 |
+
<div class="stats-content">
|
126 |
+
<h3>模型使用</h3>
|
127 |
+
<div class="stats-number">{{ stats.model_usage.keys()|list|length }}</div>
|
128 |
+
<div class="stats-detail">
|
129 |
+
{% if stats.top_model %}
|
130 |
+
最常用: {{ stats.top_model[0] }} ({{ stats.top_model[1] }}次)
|
131 |
+
{% else %}
|
132 |
+
暂无模型使用数据
|
133 |
+
{% endif %}
|
134 |
+
</div>
|
135 |
+
</div>
|
136 |
+
</div>
|
137 |
+
</div>
|
138 |
+
|
139 |
+
<!-- 简化的图表部分 -->
|
140 |
+
<div class="dashboard-charts">
|
141 |
+
<div class="chart-row">
|
142 |
+
<div class="chart-card">
|
143 |
+
<div class="chart-header">
|
144 |
+
<h3><i class="fas fa-calendar-day"></i> 每日请求趋势</h3>
|
145 |
+
</div>
|
146 |
+
<div class="chart-body">
|
147 |
+
<canvas id="dailyChart"
|
148 |
+
data-labels='{{ stats.daily_usage.keys()|list|tojson }}'
|
149 |
+
data-values='{{ stats.daily_usage.values()|list|tojson }}'></canvas>
|
150 |
+
</div>
|
151 |
+
</div>
|
152 |
+
|
153 |
+
<div class="chart-card">
|
154 |
+
<div class="chart-header">
|
155 |
+
<h3><i class="fas fa-robot"></i> 模型使用分布</h3>
|
156 |
+
</div>
|
157 |
+
<div class="chart-body">
|
158 |
+
<canvas id="modelChart"
|
159 |
+
data-labels='{{ stats.model_usage.keys()|list|tojson }}'
|
160 |
+
data-values='{{ stats.model_usage.values()|list|tojson }}'></canvas>
|
161 |
+
</div>
|
162 |
+
</div>
|
163 |
+
</div>
|
164 |
+
</div>
|
165 |
+
</section>
|
166 |
+
|
167 |
+
<!-- 简化的请求历史部分 -->
|
168 |
+
<section id="history" class="dashboard-section">
|
169 |
+
<div class="section-header">
|
170 |
+
<h2><i class="fas fa-history"></i> 请求历史</h2>
|
171 |
+
<div class="history-actions">
|
172 |
+
<div class="search-box">
|
173 |
+
<input type="text" id="history-search" placeholder="搜索请求...">
|
174 |
+
<i class="fas fa-search"></i>
|
175 |
+
</div>
|
176 |
+
</div>
|
177 |
+
</div>
|
178 |
+
|
179 |
+
<div class="table-container">
|
180 |
+
<table id="history-table" class="data-table">
|
181 |
+
<thead>
|
182 |
+
<tr>
|
183 |
+
<th data-sort="id">请求ID <i class="fas fa-sort"></i></th>
|
184 |
+
<th data-sort="timestamp">时间 <i class="fas fa-sort"></i></th>
|
185 |
+
<th data-sort="model">模型 <i class="fas fa-sort"></i></th>
|
186 |
+
<th data-sort="account">账户 <i class="fas fa-sort"></i></th>
|
187 |
+
<th data-sort="status">状态 <i class="fas fa-sort"></i></th>
|
188 |
+
<th data-sort="duration">耗时(ms) <i class="fas fa-sort"></i></th>
|
189 |
+
<th data-sort="total">总Tokens <i class="fas fa-sort"></i></th>
|
190 |
+
</tr>
|
191 |
+
</thead>
|
192 |
+
<tbody>
|
193 |
+
{% for req in stats.request_history|reverse %}
|
194 |
+
<tr data-model="{{ req.model }}" data-status="{{ 'success' if req.success else 'fail' }}" data-id="{{ req.id }}">
|
195 |
+
<td title="{{ req.id }}">{{ req.id[:8] }}...</td>
|
196 |
+
<td>{{ req.timestamp|format_datetime }}</td>
|
197 |
+
<td><span class="model-badge small">{{ req.model }}</span></td>
|
198 |
+
<td title="{{ req.account }}">
|
199 |
+
<div class="account-cell">
|
200 |
+
<span class="account-avatar small">{{ req.account[0]|upper }}</span>
|
201 |
+
<span>{{ req.account.split('@')[0] }}</span>
|
202 |
+
</div>
|
203 |
+
</td>
|
204 |
+
<td class="{{ 'success' if req.success else 'fail' }}">
|
205 |
+
<span class="status-badge {{ 'success' if req.success else 'fail' }}">
|
206 |
+
<i class="fas {{ 'fa-check-circle' if req.success else 'fa-times-circle' }}"></i>
|
207 |
+
{{ '成功' if req.success else '失败' }}
|
208 |
+
</span>
|
209 |
+
</td>
|
210 |
+
<td>{{ req.duration_ms|format_duration }}</td>
|
211 |
+
<td>{{ (req.total_tokens if req.total_tokens is defined else req.estimated_total_tokens if req.estimated_total_tokens is defined else '-')|format_number if (req.total_tokens is defined or req.estimated_total_tokens is defined) else '-' }}</td>
|
212 |
+
</tr>
|
213 |
+
{% endfor %}
|
214 |
+
</tbody>
|
215 |
+
</table>
|
216 |
+
</div>
|
217 |
+
<div class="pagination">
|
218 |
+
<button id="prev-page" disabled><i class="fas fa-chevron-left"></i> 上一页</button>
|
219 |
+
<span id="page-info">第 <span id="current-page">1</span> 页,共 <span id="total-pages">1</span> 页</span>
|
220 |
+
<button id="next-page"><i class="fas fa-chevron-right"></i> 下一页</button>
|
221 |
+
</div>
|
222 |
+
</section>
|
223 |
+
|
224 |
+
<footer class="main-footer">
|
225 |
+
<div class="footer-content">
|
226 |
+
<div class="footer-logo">
|
227 |
+
<h3>2API <span>统计面板</span></h3>
|
228 |
+
</div>
|
229 |
+
<div class="footer-info">
|
230 |
+
<p>© 2025 2API 统计面板 | 版本 1.0.1</p>
|
231 |
+
<p>数据每60秒自动刷新</p>
|
232 |
+
</div>
|
233 |
+
</div>
|
234 |
+
</footer>
|
235 |
+
</div>
|
236 |
+
</div>
|
237 |
+
|
238 |
+
<script src="{{ url_for('static', filename='js/scripts.js') }}"></script>
|
239 |
+
</body>
|
240 |
+
</html>
|
utils.py
ADDED
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import logging
|
2 |
+
import json
|
3 |
+
import os
|
4 |
+
import time
|
5 |
+
import tiktoken
|
6 |
+
from datetime import datetime
|
7 |
+
from typing import Dict, Any, Optional, Tuple
|
8 |
+
|
9 |
+
# 配置日志
|
10 |
+
def setup_logging():
|
11 |
+
"""配置日志系统"""
|
12 |
+
log_path = os.environ.get("LOG_PATH", "/tmp/2api.log")
|
13 |
+
log_level_str = os.environ.get("LOG_LEVEL", "INFO").upper()
|
14 |
+
log_level = getattr(logging, log_level_str, logging.INFO)
|
15 |
+
log_format = os.environ.get("LOG_FORMAT", "%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
16 |
+
|
17 |
+
file_handler = logging.FileHandler(log_path, encoding='utf-8')
|
18 |
+
stream_handler = logging.StreamHandler()
|
19 |
+
logging.basicConfig(
|
20 |
+
level=log_level,
|
21 |
+
format=log_format,
|
22 |
+
handlers=[stream_handler, file_handler]
|
23 |
+
)
|
24 |
+
return logging.getLogger('2api')
|
25 |
+
|
26 |
+
logger = setup_logging()
|
27 |
+
|
28 |
+
def load_config():
|
29 |
+
"""从 config.json 加载配置(如果存在),否则使用环境变量"""
|
30 |
+
default_config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'config.json')
|
31 |
+
CONFIG_FILE = os.environ.get("CONFIG_FILE_PATH", default_config_path)
|
32 |
+
config = {}
|
33 |
+
|
34 |
+
if os.path.exists(CONFIG_FILE):
|
35 |
+
try:
|
36 |
+
with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
|
37 |
+
config = json.load(f)
|
38 |
+
logger.info(f"已从 {CONFIG_FILE} 加载配置")
|
39 |
+
except (json.JSONDecodeError, IOError) as e:
|
40 |
+
logger.error(f"加载配置文件失败: {e}")
|
41 |
+
config = {}
|
42 |
+
|
43 |
+
return config
|
44 |
+
|
45 |
+
def mask_email(email: str) -> str:
|
46 |
+
"""隐藏邮箱中间部分,保护隐私"""
|
47 |
+
if not email or '@' not in email:
|
48 |
+
return "无效邮箱"
|
49 |
+
|
50 |
+
parts = email.split('@')
|
51 |
+
username = parts[0]
|
52 |
+
domain = parts[1]
|
53 |
+
|
54 |
+
if len(username) <= 3:
|
55 |
+
masked_username = username[0] + '*' * (len(username) - 1)
|
56 |
+
else:
|
57 |
+
masked_username = username[0] + '*' * (len(username) - 2) + username[-1]
|
58 |
+
|
59 |
+
return f"{masked_username}@{domain}"
|
60 |
+
|
61 |
+
def generate_request_id() -> str:
|
62 |
+
"""生成唯一的请求ID"""
|
63 |
+
return f"chatcmpl-{os.urandom(16).hex()}"
|
64 |
+
|
65 |
+
def count_tokens(text: str, model: str = "gpt-3.5-turbo") -> int:
|
66 |
+
"""
|
67 |
+
计算文本的token数量
|
68 |
+
|
69 |
+
Args:
|
70 |
+
text: 要计算token数量的文本
|
71 |
+
model: 模型名称,默认为gpt-3.5-turbo
|
72 |
+
|
73 |
+
Returns:
|
74 |
+
int: token数量
|
75 |
+
"""
|
76 |
+
# 类型保护,防止text为None或非字符串类型
|
77 |
+
if text is None:
|
78 |
+
text = ""
|
79 |
+
elif not isinstance(text, str):
|
80 |
+
text = str(text)
|
81 |
+
try:
|
82 |
+
# 根据模型名称获取编码器
|
83 |
+
if "gpt-4" in model:
|
84 |
+
encoding = tiktoken.encoding_for_model("gpt-4")
|
85 |
+
elif "gpt-3.5" in model:
|
86 |
+
encoding = tiktoken.encoding_for_model("gpt-3.5-turbo")
|
87 |
+
elif "claude" in model:
|
88 |
+
# Claude模型使用cl100k_base编码器
|
89 |
+
encoding = tiktoken.get_encoding("cl100k_base")
|
90 |
+
else:
|
91 |
+
# 默认使用cl100k_base编码器
|
92 |
+
encoding = tiktoken.get_encoding("cl100k_base")
|
93 |
+
|
94 |
+
# 计算token数量
|
95 |
+
tokens = encoding.encode(text)
|
96 |
+
return len(tokens)
|
97 |
+
except Exception as e:
|
98 |
+
logger.error(f"计算token数量时出错: {e}")
|
99 |
+
# 如果出错,使用简单的估算方法(每4个字符约为1个token)
|
100 |
+
return len(text) // 4
|
101 |
+
|
102 |
+
def count_message_tokens(messages: list, model: str = "gpt-3.5-turbo") -> Tuple[int, int, int]:
|
103 |
+
"""
|
104 |
+
计算OpenAI格式消息列表的token数量
|
105 |
+
|
106 |
+
Args:
|
107 |
+
messages: OpenAI格式的消息列表
|
108 |
+
model: 模型名称,默认为gpt-3.5-turbo
|
109 |
+
|
110 |
+
Returns:
|
111 |
+
Tuple[int, int, int]: (提示tokens数, 完成tokens数, 总tokens数)
|
112 |
+
"""
|
113 |
+
# 类型保护,防止messages为None或非列表类型
|
114 |
+
if messages is None:
|
115 |
+
messages = []
|
116 |
+
elif not isinstance(messages, list):
|
117 |
+
logger.warning(f"count_message_tokens 收到非列表类型的消息: {type(messages)}")
|
118 |
+
messages = []
|
119 |
+
|
120 |
+
prompt_tokens = 0
|
121 |
+
completion_tokens = 0
|
122 |
+
|
123 |
+
try:
|
124 |
+
# 计算提示tokens
|
125 |
+
for message in messages:
|
126 |
+
# 确保message是字典类型
|
127 |
+
if not isinstance(message, dict):
|
128 |
+
logger.warning(f"跳过非字典类型的消息: {type(message)}")
|
129 |
+
continue
|
130 |
+
|
131 |
+
role = message.get('role', '')
|
132 |
+
content = message.get('content', '')
|
133 |
+
|
134 |
+
if role and content:
|
135 |
+
# 每条消息的基本token开销
|
136 |
+
prompt_tokens += 4 # 每条消息的基本开销
|
137 |
+
|
138 |
+
# 角色名称的token
|
139 |
+
prompt_tokens += 1 # 角色名称的开销
|
140 |
+
|
141 |
+
# 内容的token
|
142 |
+
prompt_tokens += count_tokens(content, model)
|
143 |
+
|
144 |
+
# 如果是assistant角色,计算完成tokens
|
145 |
+
if role == 'assistant':
|
146 |
+
completion_tokens += count_tokens(content, model)
|
147 |
+
|
148 |
+
# 消息结束的token
|
149 |
+
prompt_tokens += 2 # 消息结束的开销
|
150 |
+
|
151 |
+
# 计算总tokens
|
152 |
+
total_tokens = prompt_tokens + completion_tokens
|
153 |
+
|
154 |
+
return prompt_tokens, completion_tokens, total_tokens
|
155 |
+
except Exception as e:
|
156 |
+
logger.error(f"计算消息token数量时出错: {e}")
|
157 |
+
# 返回安全的默认值
|
158 |
+
return 0, 0, 0
|