├── .github
└── workflows
│ └── docker-image.yml
├── .gitignore
├── Dockerfile
├── LICENSE
├── README.md
├── app.py
├── auth.py
├── client.py
├── config.py
├── config_loader.py
├── logging_config.py
├── requirements.txt
├── retry.py
├── routes.py
├── static
├── css
│ └── styles.css
└── js
│ └── scripts.js
├── templates
└── stats.html
└── utils.py
/.github/workflows/docker-image.yml:
--------------------------------------------------------------------------------
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
45 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Python 缓存
2 | __pycache__/
3 | *.pyc
4 | *.pyo
5 |
6 | # 虚拟环境
7 | .venv/
8 | venv/
9 |
10 | # 编辑器配置
11 | .vscode/
12 | .idea/
13 |
14 | # 日志和数据文件
15 | *.log
16 | *.sqlite3
17 | *.db
18 | *.pid
19 | *.csv
20 |
21 | # 系统文件
22 | Thumbs.db
23 | .DS_Store
24 |
25 | # 运行时生成的数据
26 | memory-bank/
27 | stats_data.json
28 | 2pai.log
29 | config.json
30 |
31 | # 本地环境变量
32 | .env
33 |
34 | # 前端构建产物(如有)
35 | dist/
36 | build/
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
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 /* .
15 |
16 | RUN chmod -R 0755 /app
17 |
18 | # Expose the port (Flask 默认端口)
19 | EXPOSE 5000
20 |
21 | # 设置 UTF-8 避免中文乱码
22 | ENV LANG=C.UTF-8
23 |
24 | # 启动主程序
25 | CMD ["python", "app.py"]
26 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
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.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
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": "账户1@example.com", "password": "密码1"},
48 | {"email": "账户2@example.com", "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": "你的邮箱1@example.com", "password": "你的密码1"},
163 | {"email": "你的邮箱2@example.com", "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": "账户1@example.com", "password": "密码1"},
197 | {"email": "账户2@example.com", "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":"账户1@example.com","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 许可证。
274 |
--------------------------------------------------------------------------------
/app.py:
--------------------------------------------------------------------------------
1 | import os
2 | from flask import Flask
3 | import config
4 | from auth import start_cleanup_thread
5 | from routes import register_routes
6 |
7 | def create_app():
8 | """创建并配置Flask应用"""
9 | config.init_config() # 调整到 create_app 开头
10 | app = Flask(__name__)
11 |
12 | # 启动会话清理线程
13 | start_cleanup_thread()
14 |
15 | # 注册路由
16 | register_routes(app)
17 |
18 | return app
19 |
20 | if __name__ == "__main__":
21 | # 初始化配置 # 已移至 create_app
22 |
23 | # 创建应用
24 | app = create_app()
25 |
26 | # 获取端口
27 | port = int(os.getenv("PORT", 7860))
28 | print(f"[系统] Flask 应用将在 0.0.0.0:{port} 启动 (Flask 开发服)")
29 |
30 | # 启动应用
31 | flask_debug_mode = config.get_config_value("FLASK_DEBUG", default=False) # 从配置获取调试模式
32 | app.run(host='0.0.0.0', port=port, debug=flask_debug_mode)
--------------------------------------------------------------------------------
/auth.py:
--------------------------------------------------------------------------------
1 | import threading
2 | import time
3 | from datetime import datetime, timedelta
4 | from functools import wraps
5 | from utils import logger
6 | import config
7 |
8 | class RateLimiter:
9 | """请求速率限制器 (基于token/IP)"""
10 | def __init__(self, limit_per_minute=None): # 允许传入参数,但优先配置
11 | # 优先从配置读取,如果未配置或传入了明确值,则使用该值
12 | # 配置项: "rate_limit"
13 | configured_limit = config.get_config_value("rate_limit", default=30) # 从配置读取,默认30次/分钟
14 | self.limit = limit_per_minute if limit_per_minute is not None else configured_limit
15 | self.window_size = config.get_config_value("rate_limit_window_seconds", default=60) # 从配置读取,默认60秒
16 | self.requests = {} # {identifier: [timestamp1, timestamp2, ...]}
17 | self.lock = threading.Lock()
18 |
19 | def is_allowed(self, identifier: str) -> bool:
20 | """
21 | 检查标识符请求是否允许
22 |
23 | 参数:
24 | identifier: 唯一标识 (token/IP)
25 |
26 | 返回:
27 | bool: 允许则True,否则False
28 | """
29 | with self.lock:
30 | now = time.time()
31 | if identifier not in self.requests:
32 | self.requests[identifier] = []
33 |
34 | # 清理过期请求
35 | self.requests[identifier] = [t for t in self.requests[identifier] if now - t < self.window_size]
36 |
37 | # 检查请求数是否超限
38 | if len(self.requests[identifier]) >= self.limit:
39 | return False
40 |
41 | # 记录当前请求
42 | self.requests[identifier].append(now)
43 | return True
44 |
45 | def session_cleanup():
46 | """定期清理过期会话"""
47 | # 获取配置
48 | config_instance = config.config_instance
49 |
50 | with config_instance.client_sessions_lock:
51 | current_time = datetime.now()
52 | total_expired = 0
53 |
54 | # 遍历用户
55 | for user_id in list(config_instance.client_sessions.keys()):
56 | user_sessions = config_instance.client_sessions[user_id]
57 | expired_accounts = []
58 |
59 | # 遍历账户会话
60 | for account_email, session_data in user_sessions.items():
61 | last_time = session_data["last_time"]
62 | if current_time - last_time > timedelta(minutes=config_instance.get('session_timeout_minutes')):
63 | expired_accounts.append(account_email)
64 | # 记录过期会话信息 (上下文/IP)
65 | context_info = session_data.get("context", "无上下文")
66 | ip_info = session_data.get("ip", "无IP")
67 | # 上下文预览(前30字符),防日志过长
68 | context_preview = context_info[:30] + "..." if len(context_info) > 30 else context_info
69 | logger.debug(f"过期会话: 用户={user_id[:8]}..., 账户={account_email}, 上下文={context_preview}, IP={ip_info}")
70 |
71 | # 删除过期账户会话
72 | for account_email in expired_accounts:
73 | del user_sessions[account_email]
74 | total_expired += 1
75 |
76 | # 若用户无会话,则删除
77 | if not user_sessions:
78 | del config_instance.client_sessions[user_id]
79 |
80 | if total_expired:
81 | logger.info(f"已清理 {total_expired} 个过期会话")
82 |
83 | _cleanup_thread_started = False
84 | _cleanup_thread_lock = threading.Lock()
85 |
86 | def start_cleanup_thread():
87 | """启动会话定期清理线程 (幂等)"""
88 | global _cleanup_thread_started
89 | with _cleanup_thread_lock:
90 | if _cleanup_thread_started:
91 | logger.debug("会话清理线程已运行,跳过此次启动。")
92 | return
93 |
94 | def cleanup_worker():
95 | while True:
96 | # 循环内获取最新配置,防动态更新
97 | try:
98 | timeout_minutes = config.get_config_value('session_timeout_minutes', default=30) # 默认值
99 | sleep_interval = timeout_minutes * 60 / 2
100 | if sleep_interval <= 0: # 防无效休眠间隔
101 | logger.warning(f"无效会话清理休眠间隔: {sleep_interval}s, 用默认15分钟。")
102 | sleep_interval = 15 * 60
103 | time.sleep(sleep_interval)
104 | session_cleanup()
105 | except Exception as e:
106 | logger.error(f"会话清理线程异常: {e}", exc_info=True) # 添加 exc_info=True 获取更详细的堆栈
107 |
108 | cleanup_thread = threading.Thread(target=cleanup_worker, daemon=True, name="SessionCleanupThread")
109 | cleanup_thread.start()
110 | _cleanup_thread_started = True
111 | logger.info("会话清理线程启动成功。")
--------------------------------------------------------------------------------
/client.py:
--------------------------------------------------------------------------------
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:
--------------------------------------------------------------------------------
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 logging_config import logger
9 | from config_loader import load_config
10 |
11 |
12 | class Config:
13 | """配置管理类,用于存储和管理所有配置"""
14 |
15 | # 默认配置值
16 | _defaults = {
17 | "ondemand_session_timeout_minutes": 30, # OnDemand 会话的活跃超时时间(分钟)
18 | "session_timeout_minutes": 3600, # 会话不活动超时时间(分钟)- 增加以减少创建新会话的频率
19 | "max_retries": 5, # 默认重试次数 - 增加以处理更多错误
20 | "retry_delay": 3, # 默认重试延迟(秒)- 增加以减少请求频率
21 | "request_timeout": 45, # 默认请求超时(秒)- 增加以允许更长的处理时间
22 | "stream_timeout": 180, # 流式请求的默认超时(秒)- 增加以允许更长的处理时间
23 | "rate_limit_per_minute": 60, # 每分钟请求数限制 (用于 RateLimiter)
24 | # "rate_limit": 30, # 旧的速率限制键,考虑移除或保留作参考
25 | # "rate_limit_window_seconds": 60, # 旧的速率限制窗口,考虑移除
26 | "account_cooldown_seconds": 300, # 账户冷却期(秒)- 在遇到429错误后暂时不使用该账户
27 | "debug_mode": False, # 调试模式
28 | "api_access_token": "sk-2api-ondemand-access-token-2025", # API访问认证Token
29 | # 模型价格配置 (单位:美元/百万Tokens)
30 | "model_prices": {
31 | # OpenAI 模型
32 | "gpt-3.5-turbo": {"input": 0.25, "output": 0.75},
33 | "o3-mini": {"input": 0.55, "output": 2.20}, # 对应价格表中的 o3-mini
34 | "o3": {"input": 5.00, "output": 20.00}, # 对应价格表中的 o3
35 | "gpt-4o": {"input": 1.25, "output": 5.00},
36 | "gpt-4o-mini": {"input": 0.075, "output": 0.30},
37 | "o4-mini": {"input": 0.55, "output": 2.20}, # 对应价格表中的 o4-mini
38 | "gpt-4-turbo": {"input": 5.00, "output": 15.00}, # gpt-4.1 的别名
39 | "gpt-4.1": {"input": 1.00, "output": 4.00},
40 | "gpt-4.1-mini": {"input": 0.20, "output": 0.80},
41 | "gpt-4.1-nano": {"input": 0.05, "output": 0.20},
42 |
43 | # Deepseek 模型
44 | "deepseek-v3": {"input": 0.15, "output": 0.44}, # 价格表 deepseek/deepseek-chat-v3
45 | "deepseek-r1": {"input": 0.25, "output": 1.09}, # 价格表 deepseek/deepseek-r1
46 | "deepseek-r1-distill-llama-70b": {"input": 0.05, "output": 0.20}, # 价格表 deepseek/deepseek-r1-distill-llama-70b
47 |
48 | # Claude 模型
49 | "claude-3.5-sonnet": {"input": 1.50, "output": 7.50},
50 | "claude-3.7-sonnet": {"input": 1.50, "output": 7.50},
51 | "claude-3-opus": {"input": 7.50, "output": 37.50},
52 | "claude-3-haiku": {"input": 0.125, "output": 0.625},
53 | "claude-4-opus": {"input": 7.50, "output": 37.50}, # 价格表 anthropic/claude-opus-4
54 | "claude-4-sonnet": {"input": 1.50, "output": 7.50}, # 价格表 anthropic/claude-sonnet-4
55 |
56 | # Gemini 模型
57 | "gemini-1.5-pro": {"input": 0.625, "output": 2.50}, # 价格表 gemini-1.5-pro
58 | "gemini-2.0-flash": {"input": 0.05, "output": 0.20}, # 价格表 google/gemini-2.0-flash-001
59 | "gemini-2.5-pro": {"input": 0.625, "output": 5.00}, # 价格表 gemini-2.5-pro-preview
60 | "gemini-2.5-flash": {"input": 0.075, "output": 0.30} # 价格表 gemini-2.5-flash-preview
61 |
62 | # 根据需要添加更多模型的价格
63 | },
64 | "default_model_price": {"input": 1.00, "output": 3.00}, # 默认模型价格(美元/百万Tokens)
65 | "stats_file_path": "stats_data.json", # 统计数据文件路径
66 | "stats_backup_path": "stats_data_backup.json", # 统计数据备份文件路径
67 | "stats_save_interval": 300, # 每5分钟保存一次统计数据
68 | "max_history_items": 1000, # 最多保存的历史记录数量
69 | "default_endpoint_id": "predefined-claude-4-sonnet" # 备用/默认端点 ID
70 | }
71 |
72 | # 模型名称映射:OpenAI 模型名 -> on-demand.io endpointId
73 | _model_mapping = {
74 | # OpenAI 模型
75 | "gpt-3.5-turbo": "predefined-openai-gpto3-mini",
76 | "o3-mini": "predefined-openai-gpto3-mini",
77 | "o3": "predefined-openai-gpto3", # 当前状态:inactive
78 | "gpt-4o": "predefined-openai-gpt4o",
79 | "gpt-4o-mini": "predefined-openai-gpt4o-mini",
80 | "o4-mini": "predefined-openai-gpto4-mini", # 当前状态:inactive
81 | "gpt-4-turbo": "predefined-openai-gpt4.1", # gpt-4.1 的别名
82 | "gpt-4.1": "predefined-openai-gpt4.1",
83 | "gpt-4.1-mini": "predefined-openai-gpt4.1-mini",
84 | "gpt-4.1-nano": "predefined-openai-gpt4.1-nano",
85 |
86 | # Deepseek 模型
87 | "deepseek-v3": "predefined-deepseek-v3",
88 | "deepseek-r1": "predefined-deepseek-r1",
89 | "deepseek-r1-distill-llama-70b": "predefined-deepseek-r1-distill-llama-70b", # 当前状态:inactive
90 |
91 | # Claude 模型
92 | "claude-4-opus": "predefined-claude-4-opus",
93 | "claude-4-sonnet": "predefined-claude-4-sonnet",
94 |
95 | # Gemini 模型
96 | "gemini-2.0-flash": "predefined-gemini-2.0-flash",
97 | "gemini-2.5-pro": "predefined-gemini-2.5-pro-preview", # 当前状态:inactive
98 | "gemini-2.5-flash": "predefined-gemini-2.5-flash", # 当前状态:inactive
99 |
100 | # 根据需要添加更多映射
101 | }
102 |
103 | def __init__(self):
104 | """初始化配置对象"""
105 | # 从默认值初始化配置
106 | self._config = self._defaults.copy()
107 |
108 | # 用量统计
109 | self.usage_stats = {
110 | "total_requests": 0,
111 | "successful_requests": 0,
112 | "failed_requests": 0,
113 | "model_usage": defaultdict(int), # 模型使用次数
114 | "account_usage": defaultdict(int), # 账户使用次数
115 | "daily_usage": defaultdict(int), # 每日使用次数
116 | "hourly_usage": defaultdict(int), # 每小时使用次数
117 | "total_prompt_tokens": 0, # 总提示tokens
118 | "total_completion_tokens": 0, # 总完成tokens
119 | "total_tokens": 0, # 总tokens
120 | "model_tokens": defaultdict(int), # 每个模型的tokens使用量
121 | "daily_tokens": defaultdict(int), # 每日tokens使用量
122 | "hourly_tokens": defaultdict(int), # 每小时tokens使用量
123 | "last_saved": datetime.now().isoformat() # 最后保存时间
124 | }
125 |
126 | # 线程锁
127 | self.usage_stats_lock = threading.Lock() # 用于线程安全的统计数据访问
128 | self.account_index_lock = threading.Lock() # 用于线程安全的账户选择
129 | self.client_sessions_lock = threading.Lock() # 用于线程安全的会话管理
130 |
131 | # 当前账户索引(用于创建新客户端会话时的轮询选择)
132 | self.current_account_index = 0
133 |
134 | # 内存中存储每个客户端的会话和最后交互时间
135 | # 格式: {用户标识符: {账户邮箱: {"client": OnDemandAPIClient实例, "last_time": datetime对象}}}
136 | # 这样确保不同用户的会话是隔离的,每个用户只能访问自己的会话
137 | self.client_sessions = {}
138 |
139 | # 账户信息
140 | self.accounts = []
141 |
142 | # 账户冷却期记录 - 存储因速率限制而暂时不使用的账户
143 | # 格式: {账户邮箱: 冷却期结束时间(datetime对象)}
144 | self.account_cooldowns = {}
145 |
146 | def get(self, key: str, default: Any = None) -> Any:
147 | """获取配置值"""
148 | return self._config.get(key, default)
149 |
150 | def set(self, key: str, value: Any) -> None:
151 | """设置配置值"""
152 | self._config[key] = value
153 |
154 | def update(self, config_dict: Dict[str, Any]) -> None:
155 | """批量更新配置值"""
156 | self._config.update(config_dict)
157 |
158 | def get_model_mapping(self) -> Dict[str, str]:
159 | """获取模型名称到端点ID的映射"""
160 | # 返回副本以防止外部修改
161 | return self._model_mapping.copy()
162 |
163 | def get_model_endpoint(self, model_name: str) -> str:
164 | """获取模型对应的端点ID"""
165 | mapping = self.get_model_mapping()
166 | default_id = self.get("default_endpoint_id")
167 | # 确保总是返回一个字符串值
168 | if model_name in mapping:
169 | return mapping[model_name]
170 | elif default_id is not None:
171 | return default_id
172 | else:
173 | return "predefined-claude-4-sonnet" # 硬编码的后备值
174 |
175 | def get_accounts(self) -> List[Dict[str, str]]:
176 | """获取账户信息列表"""
177 | # 返回副本以防止外部修改
178 | return list(self.accounts) # 创建列表副本
179 |
180 | def get_usage_stats(self) -> Dict[str, Any]:
181 | """获取用量统计数据"""
182 | # 返回副本以防止外部修改
183 | with self.usage_stats_lock:
184 | # 创建深层一些的副本可能更安全,但这里为了性能暂时只复制顶层
185 | return self.usage_stats.copy()
186 |
187 | def get_client_sessions(self) -> Dict[str, Any]:
188 | """获取客户端会话信息"""
189 | # 返回副本以防止外部修改
190 | with self.client_sessions_lock:
191 | # 创建深层一些的副本可能更安全,但这里为了性能暂时只复制顶层
192 | return self.client_sessions.copy()
193 |
194 | def load_from_file(self) -> bool:
195 | """从配置文件加载配置"""
196 | try:
197 | # utils.load_config() 当前不接受 file_path 参数,因此移除
198 | config_data = load_config()
199 | if config_data:
200 | # 更新配置
201 | for key, value in config_data.items():
202 | if key != "accounts": # 账户信息单独处理
203 | self.set(key, value)
204 |
205 | # 处理账户信息
206 | if "accounts" in config_data:
207 | self.accounts = config_data["accounts"]
208 |
209 | logger.info("已从配置文件加载配置")
210 | return True
211 | return False
212 | except Exception as e:
213 | logger.error(f"加载配置文件时出错: {e}")
214 | return False
215 |
216 | def load_from_env(self) -> None:
217 | """从环境变量加载配置"""
218 | # 从环境变量加载账户信息
219 | if not self.accounts:
220 | accounts_env = os.getenv("ONDEMAND_ACCOUNTS", "")
221 | if accounts_env:
222 | try:
223 | self.accounts = json.loads(accounts_env).get('accounts', [])
224 | logger.info("已从环境变量加载账户信息")
225 | except json.JSONDecodeError:
226 | logger.error("解码 ONDEMAND_ACCOUNTS 环境变量失败")
227 |
228 | # 从环境变量加载其他设置
229 | env_mappings = {
230 | "ondemand_session_timeout_minutes": "ONDEMAND_SESSION_TIMEOUT_MINUTES",
231 | "session_timeout_minutes": "SESSION_TIMEOUT_MINUTES",
232 | "max_retries": "MAX_RETRIES",
233 | "retry_delay": "RETRY_DELAY",
234 | "request_timeout": "REQUEST_TIMEOUT",
235 | "stream_timeout": "STREAM_TIMEOUT",
236 | "rate_limit": "RATE_LIMIT",
237 | "debug_mode": "DEBUG_MODE",
238 | "api_access_token": "API_ACCESS_TOKEN"
239 | }
240 |
241 | for config_key, env_key in env_mappings.items():
242 | env_value = os.getenv(env_key)
243 | if env_value is not None:
244 | # 根据默认值的类型进行转换
245 | default_value = self.get(config_key)
246 | if isinstance(default_value, bool):
247 | self.set(config_key, env_value.lower() == 'true')
248 | elif isinstance(default_value, int):
249 | self.set(config_key, int(env_value))
250 | elif isinstance(default_value, float):
251 | self.set(config_key, float(env_value))
252 | else:
253 | self.set(config_key, env_value)
254 |
255 | def save_stats_to_file(self):
256 | """将统计数据保存到文件中"""
257 | try:
258 | with self.usage_stats_lock:
259 | # 创建统计数据的副本,但不包含 request_history
260 | stats_copy = {
261 | "total_requests": self.usage_stats["total_requests"],
262 | "successful_requests": self.usage_stats["successful_requests"],
263 | "failed_requests": self.usage_stats["failed_requests"],
264 | "model_usage": dict(self.usage_stats["model_usage"]),
265 | "account_usage": dict(self.usage_stats["account_usage"]),
266 | "daily_usage": dict(self.usage_stats["daily_usage"]),
267 | "hourly_usage": dict(self.usage_stats["hourly_usage"]),
268 | # 不复制 request_history 到文件,避免文件过大
269 | "total_prompt_tokens": self.usage_stats["total_prompt_tokens"],
270 | "total_completion_tokens": self.usage_stats["total_completion_tokens"],
271 | "total_tokens": self.usage_stats["total_tokens"],
272 | "model_tokens": dict(self.usage_stats["model_tokens"]),
273 | "daily_tokens": dict(self.usage_stats["daily_tokens"]),
274 | "hourly_tokens": dict(self.usage_stats["hourly_tokens"]),
275 | "last_saved": datetime.now().isoformat()
276 | }
277 |
278 | stats_file_path = self.get("stats_file_path")
279 | stats_backup_path = self.get("stats_backup_path")
280 |
281 | # 先保存到备份文件,然后重命名,避免写入过程中的文件损坏
282 | with open(stats_backup_path, 'w', encoding='utf-8') as f:
283 | json.dump(stats_copy, f, ensure_ascii=False, indent=2)
284 |
285 | # 如果主文件存在,先删除它
286 | if os.path.exists(stats_file_path):
287 | os.remove(stats_file_path)
288 |
289 | # 将备份文件重命名为主文件
290 | os.rename(stats_backup_path, stats_file_path)
291 |
292 | logger.info(f"统计数据已保存到 {stats_file_path}")
293 | self.usage_stats["last_saved"] = datetime.now().isoformat()
294 | except Exception as e:
295 | logger.error(f"保存统计数据时出错: {e}")
296 |
297 | def load_stats_from_file(self):
298 | """从文件中加载统计数据"""
299 | try:
300 | stats_file_path = self.get("stats_file_path")
301 | if os.path.exists(stats_file_path):
302 | with open(stats_file_path, 'r', encoding='utf-8') as f:
303 | saved_stats = json.load(f)
304 |
305 | with self.usage_stats_lock:
306 | # 更新基本计数器
307 | self.usage_stats["total_requests"] = saved_stats.get("total_requests", 0)
308 | self.usage_stats["successful_requests"] = saved_stats.get("successful_requests", 0)
309 | self.usage_stats["failed_requests"] = saved_stats.get("failed_requests", 0)
310 | self.usage_stats["total_prompt_tokens"] = saved_stats.get("total_prompt_tokens", 0)
311 | self.usage_stats["total_completion_tokens"] = saved_stats.get("total_completion_tokens", 0)
312 | self.usage_stats["total_tokens"] = saved_stats.get("total_tokens", 0)
313 |
314 | # 更新字典类型的统计数据
315 | for model, count in saved_stats.get("model_usage", {}).items():
316 | self.usage_stats["model_usage"][model] = count
317 |
318 | for account, count in saved_stats.get("account_usage", {}).items():
319 | self.usage_stats["account_usage"][account] = count
320 |
321 | for day, count in saved_stats.get("daily_usage", {}).items():
322 | self.usage_stats["daily_usage"][day] = count
323 |
324 | for hour, count in saved_stats.get("hourly_usage", {}).items():
325 | self.usage_stats["hourly_usage"][hour] = count
326 |
327 | for model, tokens in saved_stats.get("model_tokens", {}).items():
328 | self.usage_stats["model_tokens"][model] = tokens
329 |
330 | for day, tokens in saved_stats.get("daily_tokens", {}).items():
331 | self.usage_stats["daily_tokens"][day] = tokens
332 |
333 | for hour, tokens in saved_stats.get("hourly_tokens", {}).items():
334 | self.usage_stats["hourly_tokens"][hour] = tokens
335 |
336 | # 不再加载请求历史
337 |
338 | logger.info(f"已从 {stats_file_path} 加载统计数据")
339 | return True
340 | else:
341 | logger.info(f"未找到统计数据文件 {stats_file_path},将使用默认值")
342 | return False
343 | except Exception as e:
344 | logger.error(f"加载统计数据时出错: {e}")
345 | return False
346 |
347 | def start_stats_save_thread(self):
348 | """启动定期保存统计数据的线程"""
349 | def save_stats_periodically():
350 | while True:
351 | time.sleep(self.get("stats_save_interval"))
352 | self.save_stats_to_file()
353 |
354 | save_thread = threading.Thread(target=save_stats_periodically, daemon=True)
355 | save_thread.start()
356 | logger.info(f"统计数据保存线程已启动,每 {self.get('stats_save_interval')} 秒保存一次")
357 |
358 | def init(self):
359 | """初始化配置,从配置文件或环境变量加载设置"""
360 | # 从配置文件加载配置
361 | self.load_from_file()
362 |
363 | # 从环境变量加载配置
364 | self.load_from_env()
365 |
366 | # 验证账户信息
367 | if not self.accounts:
368 | error_msg = "在 config.json 或环境变量 ONDEMAND_ACCOUNTS 中未找到账户信息"
369 | logger.critical(error_msg)
370 | # 抛出异常,因为没有账户信息服务无法正常运行
371 | raise ValueError(error_msg)
372 |
373 | logger.info("已加载API访问Token")
374 |
375 | # 加载之前保存的统计数据
376 | self.load_stats_from_file()
377 |
378 | # 启动定期保存统计数据的线程
379 | self.start_stats_save_thread()
380 |
381 | def get_next_ondemand_account_details(self):
382 | """获取下一个 OnDemand 账户的邮箱和密码,用于轮询。
383 | 会跳过处于冷却期的账户。"""
384 | with self.account_index_lock:
385 | current_time = datetime.now()
386 |
387 | # 清理过期的冷却记录
388 | expired_cooldowns = [email for email, end_time in self.account_cooldowns.items()
389 | if end_time < current_time]
390 | for email in expired_cooldowns:
391 | del self.account_cooldowns[email]
392 | logger.info(f"账户 {email} 的冷却期已结束,现在可用")
393 |
394 | accounts = self.get_accounts() # 获取账户列表
395 | num_accounts = len(accounts)
396 | if num_accounts == 0:
397 | # 理论上不应该到这里,因为init会检查并抛出异常
398 | logger.critical("尝试获取下一个账户,但账户列表为空!")
399 | # 即使init检查过,这里也返回明确的错误信号
400 | return None, None
401 |
402 | # 尝试最多len(self.accounts)次,以找到一个不在冷却期的账户
403 | for _ in range(num_accounts):
404 | account_details = accounts[self.current_account_index]
405 | email = account_details.get('email')
406 |
407 | # 更新索引到下一个账户,为下次调用做准备
408 | self.current_account_index = (self.current_account_index + 1) % num_accounts
409 |
410 | # 检查账户是否在冷却期
411 | if email in self.account_cooldowns:
412 | cooldown_end = self.account_cooldowns[email]
413 | remaining_seconds = (cooldown_end - current_time).total_seconds()
414 | logger.warning(f"账户 {email} 仍在冷却期中,还剩 {remaining_seconds:.1f} 秒")
415 | continue # 尝试下一个账户
416 |
417 | # 找到一个可用账户
418 | logger.info(f"[系统] 新会话将使用账户: {email}")
419 | return email, account_details.get('password')
420 |
421 | # 如果所有账户都在冷却期,记录警告并返回第一个账户(即使它可能在冷却期)
422 | logger.warning("所有账户都在冷却期!将尝试使用索引为0的账户,但它可能仍在冷却期")
423 | # 确保即使所有账户都在冷却期,也返回第一个账户的信息
424 | # 注意:这里需要使用之前获取的 accounts 列表
425 | if num_accounts > 0: # 再次检查以防万一
426 | account_details = accounts[0]
427 | return account_details.get('email'), account_details.get('password')
428 | else:
429 | # 这种情况理论上不应该发生,因为前面已经检查过 num_accounts == 0
430 | logger.error("在处理所有账户冷却的情况时发现账户列表为空!")
431 | return None, None
432 |
433 |
434 | # 创建全局配置实例
435 | config_instance = Config()
436 |
437 | def init_config():
438 | """初始化配置的兼容函数,用于向后兼容"""
439 | config_instance.init()
440 |
441 |
442 | def get_config_value(name: str, default: Any = None) -> Any:
443 | """
444 | 获取通用配置变量的值。
445 | 对于结构化的配置数据(如 accounts, model_mapping, usage_stats, client_sessions),
446 | 推荐使用 `config_instance` 对象的专用 getter 方法(例如 `config_instance.get_accounts()`)以获得类型安全。
447 | """
448 | return config_instance.get(name, default)
449 |
450 | # 全局兼容函数(保持向后兼容性,但推荐直接使用 config_instance 的方法)
451 | def get_accounts() -> List[Dict[str, str]]:
452 | """获取账户信息列表 (兼容函数)"""
453 | logger.warning("调用全局 get_accounts() 函数,推荐使用 config_instance.get_accounts()")
454 | return config_instance.get_accounts()
455 |
456 | def get_model_mapping() -> Dict[str, str]:
457 | """获取模型映射 (兼容函数)"""
458 | logger.warning("调用全局 get_model_mapping() 函数,推荐使用 config_instance.get_model_mapping()")
459 | return config_instance.get_model_mapping()
460 |
461 | def get_usage_stats() -> Dict[str, Any]:
462 | """获取用量统计 (兼容函数)"""
463 | logger.warning("调用全局 get_usage_stats() 函数,推荐使用 config_instance.get_usage_stats()")
464 | return config_instance.get_usage_stats()
465 |
466 | def get_client_sessions() -> Dict[str, Any]:
467 | """获取客户端会话 (兼容函数)"""
468 | logger.warning("调用全局 get_client_sessions() 函数,推荐使用 config_instance.get_client_sessions()")
469 | return config_instance.get_client_sessions()
470 |
471 | def get_next_ondemand_account_details():
472 | """获取下一个账户的邮箱和密码 (兼容函数)"""
473 | return config_instance.get_next_ondemand_account_details()
474 |
475 | def set_account_cooldown(email, cooldown_seconds=None):
476 | """设置账户冷却期 (兼容函数)
477 |
478 | Args:
479 | email: 账户邮箱
480 | cooldown_seconds: 冷却时间(秒),如果为None则使用默认配置
481 | """
482 | if cooldown_seconds is None:
483 | cooldown_seconds = config_instance.get('account_cooldown_seconds')
484 |
485 | cooldown_end = datetime.now() + timedelta(seconds=cooldown_seconds)
486 | with config_instance.account_index_lock: # 使用相同的锁保护冷却期字典
487 | config_instance.account_cooldowns[email] = cooldown_end
488 | logger.warning(f"账户 {email} 已设置冷却期 {cooldown_seconds} 秒,将于 {cooldown_end.strftime('%Y-%m-%d %H:%M:%S')} 结束")
489 |
490 |
491 | # ⚠️ 警告:为保证配置动态更新,请勿使用 from config import XXX,只使用 import config 并通过 config.get_config_value('变量名') 获取配置。
492 | # 这样可确保配置值始终是最新的。
493 | # (。•ᴗ-)ノ゙ 你的聪明小助手温馨提示~
--------------------------------------------------------------------------------
/config_loader.py:
--------------------------------------------------------------------------------
1 | import json
2 | import os
3 | from logging_config import logger
4 | from typing import Dict, Any, Optional
5 |
6 | def load_config(file_path: Optional[str] = None) -> Dict[str, Any]:
7 | """
8 | 加载配置信息。
9 |
10 | 加载顺序:
11 | 1. 如果提供了 file_path 参数,则尝试从该路径加载。
12 | 2. 如果未提供 file_path,检查 APP_CONFIG_PATH 环境变量,如果设置了,则尝试从该路径加载。
13 | 3. 如果以上都没有提供,则尝试从相对于此文件目录的 config.json 加载。
14 |
15 | Args:
16 | file_path (Optional[str]): 要加载的配置文件的可选路径。
17 |
18 | Returns:
19 | Dict[str, Any]: 加载的配置字典,如果加载失败或未找到文件则为空字典。
20 | """
21 | config_file_to_load = None
22 | if file_path:
23 | config_file_to_load = file_path
24 | logger.info(f"尝试从参数指定的路径加载配置: {config_file_to_load}")
25 | else:
26 | env_path = os.environ.get("APP_CONFIG_PATH")
27 | if env_path:
28 | config_file_to_load = env_path
29 | logger.info(f"尝试从环境变量 APP_CONFIG_PATH 加载配置: {config_file_to_load}")
30 | else:
31 | # 使用默认路径 'config.json' 相对于当前目录
32 | default_config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'config.json')
33 | config_file_to_load = default_config_path
34 | logger.info(f"尝试从默认路径加载配置: {config_file_to_load}")
35 |
36 | config = {}
37 | if config_file_to_load and os.path.exists(config_file_to_load):
38 | try:
39 | with open(config_file_to_load, 'r', encoding='utf-8') as f:
40 | config = json.load(f)
41 | logger.info(f"已从 {config_file_to_load} 加载配置")
42 | except (json.JSONDecodeError, IOError) as e:
43 | logger.error(f"从 {config_file_to_load} 加载配置文件失败: {e}")
44 | config = {} # 加载失败视为空配置
45 | else:
46 | logger.warning(f"配置文件未找到或路径无效: {config_file_to_load}")
47 | config = {} # 未找到文件视为空配置
48 |
49 | return config
--------------------------------------------------------------------------------
/logging_config.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import os
3 |
4 | def setup_logging():
5 | """独立日志配置模块"""
6 | log_path = os.environ.get("LOG_PATH", "/tmp/2api.log")
7 | log_level = os.environ.get("LOG_LEVEL", "INFO").upper()
8 | log_format = os.environ.get("LOG_FORMAT",
9 | "%(asctime)s - %(name)s - %(levelname)s - %(message)s")
10 |
11 | file_handler = logging.FileHandler(log_path, encoding='utf-8')
12 | stream_handler = logging.StreamHandler()
13 |
14 | logging.basicConfig(
15 | level=getattr(logging, log_level, logging.INFO),
16 | format=log_format,
17 | handlers=[stream_handler, file_handler]
18 | )
19 | return logging.getLogger('2api')
20 |
21 | logger = setup_logging()
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | flask
2 | requests
3 | tiktoken
4 | regex
--------------------------------------------------------------------------------
/retry.py:
--------------------------------------------------------------------------------
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 | from datetime import datetime # <--- 移动到这里
8 |
9 | # 导入配置模块
10 | import config
11 |
12 | # 类型变量定义
13 | T = TypeVar('T')
14 |
15 | class RetryStrategy(ABC):
16 | """重试策略的抽象基类"""
17 |
18 | @abstractmethod
19 | def should_retry(self, exception: Exception, retry_count: int, max_retries: int) -> bool:
20 | """
21 | 判断是否应该重试
22 |
23 | Args:
24 | exception: 捕获的异常
25 | retry_count: 当前重试次数
26 | max_retries: 最大重试次数
27 |
28 | Returns:
29 | bool: 是否应该重试
30 | """
31 | pass
32 |
33 | @abstractmethod
34 | def get_retry_delay(self, retry_count: int, base_delay: int) -> float:
35 | """
36 | 计算重试延迟时间
37 |
38 | Args:
39 | retry_count: 当前重试次数
40 | base_delay: 基础延迟时间(秒)
41 |
42 | Returns:
43 | float: 重试延迟时间(秒)
44 | """
45 | pass
46 |
47 | @abstractmethod
48 | def log_retry_attempt(self, logger: logging.Logger, exception: Exception,
49 | retry_count: int, max_retries: int, delay: float) -> None:
50 | """
51 | 记录重试尝试
52 |
53 | Args:
54 | logger: 日志记录器
55 | exception: 捕获的异常
56 | retry_count: 当前重试次数
57 | max_retries: 最大重试次数
58 | delay: 重试延迟时间
59 | """
60 | pass
61 |
62 | @abstractmethod
63 | def on_retry(self, exception: Exception, retry_count: int) -> None:
64 | """
65 | 重试前的回调函数,可以执行额外操作
66 |
67 | Args:
68 | exception: 捕获的异常
69 | retry_count: 当前重试次数
70 | """
71 | pass
72 |
73 |
74 | class ExponentialBackoffStrategy(RetryStrategy):
75 | """指数退避重试策略,适用于连接错误"""
76 |
77 | def should_retry(self, exception: Exception, retry_count: int, max_retries: int) -> bool:
78 | return (isinstance(exception, requests.exceptions.ConnectionError) and
79 | retry_count < max_retries)
80 |
81 | def get_retry_delay(self, retry_count: int, base_delay: int) -> float:
82 | # 指数退避: base_delay * 2^(retry_count)
83 | return base_delay * (2 ** retry_count)
84 |
85 | def log_retry_attempt(self, logger: logging.Logger, exception: Exception,
86 | retry_count: int, max_retries: int, delay: float) -> None:
87 | # 检查logger是否为函数对象(如client._log)
88 | if callable(logger) and not isinstance(logger, logging.Logger):
89 | # 如果是函数,直接调用它
90 | logger(f"连接错误,{delay:.1f}秒后重试 ({retry_count}/{max_retries}): {exception}", "WARNING")
91 | else:
92 | # 如果是Logger对象,调用warning方法
93 | logger.warning(f"连接错误,{delay:.1f}秒后重试 ({retry_count}/{max_retries}): {exception}")
94 |
95 | def on_retry(self, exception: Exception, retry_count: int) -> None:
96 | # 连接错误不需要额外操作
97 | pass
98 |
99 |
100 | class LinearBackoffStrategy(RetryStrategy):
101 | """线性退避重试策略,适用于超时错误"""
102 |
103 | def should_retry(self, exception: Exception, retry_count: int, max_retries: int) -> bool:
104 | return (isinstance(exception, requests.exceptions.Timeout) and
105 | retry_count < max_retries)
106 |
107 | def get_retry_delay(self, retry_count: int, base_delay: int) -> float:
108 | # 线性退避: base_delay * retry_count
109 | return base_delay * retry_count
110 |
111 | def log_retry_attempt(self, logger: logging.Logger, exception: Exception,
112 | retry_count: int, max_retries: int, delay: float) -> None:
113 | # 检查logger是否为函数对象(如client._log)
114 | if callable(logger) and not isinstance(logger, logging.Logger):
115 | # 如果是函数,直接调用它
116 | logger(f"请求超时,{delay:.1f}秒后重试 ({retry_count}/{max_retries}): {exception}", "WARNING")
117 | else:
118 | # 如果是Logger对象,调用warning方法
119 | logger.warning(f"请求超时,{delay:.1f}秒后重试 ({retry_count}/{max_retries}): {exception}")
120 |
121 | def on_retry(self, exception: Exception, retry_count: int) -> None:
122 | # 超时错误不需要额外操作
123 | pass
124 |
125 |
126 | class ServerErrorStrategy(RetryStrategy):
127 | """服务器错误重试策略,适用于5xx错误"""
128 |
129 | def should_retry(self, exception: Exception, retry_count: int, max_retries: int) -> bool:
130 | if not isinstance(exception, requests.exceptions.HTTPError):
131 | return False
132 |
133 | response = getattr(exception, 'response', None)
134 | if response is None:
135 | return False
136 |
137 | return (500 <= response.status_code < 600 and retry_count < max_retries)
138 |
139 | def get_retry_delay(self, retry_count: int, base_delay: int) -> float:
140 | # 线性退避: base_delay * retry_count
141 | return base_delay * retry_count
142 |
143 | def log_retry_attempt(self, logger: logging.Logger, exception: Exception,
144 | retry_count: int, max_retries: int, delay: float) -> None:
145 | response = getattr(exception, 'response', None)
146 | status_code = response.status_code if response else 'unknown'
147 | # 检查logger是否为函数对象(如client._log)
148 | if callable(logger) and not isinstance(logger, logging.Logger):
149 | # 如果是函数,直接调用它
150 | logger(f"服务器错误 {status_code},{delay:.1f}秒后重试 ({retry_count}/{max_retries})", "WARNING")
151 | else:
152 | # 如果是Logger对象,调用warning方法
153 | logger.warning(f"服务器错误 {status_code},{delay:.1f}秒后重试 ({retry_count}/{max_retries})")
154 |
155 | def on_retry(self, exception: Exception, retry_count: int) -> None:
156 | # 服务器错误不需要额外操作
157 | pass
158 |
159 |
160 | class RateLimitStrategy(RetryStrategy):
161 | """速率限制重试策略,适用于429错误,包括账号切换逻辑和延迟重试"""
162 |
163 | def __init__(self, client=None):
164 | """
165 | 初始化速率限制重试策略
166 |
167 | Args:
168 | client: API客户端实例,用于切换账号
169 | """
170 | self.client = client
171 | self.consecutive_429_count = 0 # 连续429错误计数器
172 |
173 | def should_retry(self, exception: Exception, retry_count: int, max_retries: int) -> bool:
174 | if not isinstance(exception, requests.exceptions.HTTPError):
175 | return False
176 |
177 | response = getattr(exception, 'response', None)
178 | if response is None:
179 | return False
180 |
181 | is_rate_limit = response.status_code == 429
182 | if is_rate_limit:
183 | self.consecutive_429_count += 1
184 | else:
185 | self.consecutive_429_count = 0 # 重置计数器
186 |
187 | return is_rate_limit
188 |
189 | def get_retry_delay(self, retry_count: int, base_delay: int) -> float:
190 | # 根据用户反馈,429错误时不需要延迟,立即重试
191 | return 0
192 |
193 | def log_retry_attempt(self, logger: logging.Logger, exception: Exception,
194 | retry_count: int, max_retries: int, delay: float) -> None:
195 | # 检查logger是否为函数对象(如client._log)
196 | message = ""
197 | if self.consecutive_429_count > 1:
198 | message = f"连续第{self.consecutive_429_count}次速率限制错误,尝试立即重试"
199 | else:
200 | message = "速率限制错误,尝试切换账号"
201 |
202 | if callable(logger) and not isinstance(logger, logging.Logger):
203 | # 如果是函数,直接调用它
204 | logger(message, "WARNING")
205 | else:
206 | # 如果是Logger对象,调用warning方法
207 | logger.warning(message)
208 |
209 | def on_retry(self, exception: Exception, retry_count: int) -> None:
210 | # 新增: 获取关联信息
211 | user_identifier = getattr(self.client, '_associated_user_identifier', None)
212 | request_ip = getattr(self.client, '_associated_request_ip', None) # request_ip 可能在某些情况下需要
213 |
214 | # 只有在首次429错误或账号池中有多个账号时才切换账号
215 | if self.consecutive_429_count == 1 or (self.consecutive_429_count > 0 and self.consecutive_429_count % 3 == 0):
216 | if self.client and hasattr(self.client, 'email'):
217 | # 记录当前账号进入冷却期
218 | current_email = self.client.email # 这是切换前的 email
219 | config.set_account_cooldown(current_email)
220 |
221 | # 获取新账号
222 | new_email, new_password = config.get_next_ondemand_account_details()
223 | if new_email:
224 | # 更新客户端信息
225 | self.client.email = new_email # 这是切换后的 email
226 | self.client.password = new_password
227 | self.client.token = ""
228 | self.client.refresh_token = ""
229 | self.client.session_id = "" # 重置会话ID,确保创建新会话
230 |
231 | # 尝试使用新账号登录并创建会话
232 | try:
233 | # 获取当前请求的上下文哈希,以便在切换账号后重新登录和创建会话时使用
234 | current_context_hash = getattr(self.client, '_current_request_context_hash', None)
235 |
236 | self.client.sign_in(context=current_context_hash)
237 | if self.client.create_session(external_context=current_context_hash):
238 | # 如果成功登录并创建会话,记录日志并设置标志位
239 | if hasattr(self.client, '_log'):
240 | self.client._log(f"成功切换到账号 {new_email} 并使用上下文哈希 '{current_context_hash}' 重新登录和创建新会话。", "INFO")
241 | # 设置标志位,通知调用方下次需要发送完整历史
242 | setattr(self.client, '_new_session_requires_full_history', True)
243 | if hasattr(self.client, '_log'):
244 | self.client._log(f"已设置 _new_session_requires_full_history = True,下次查询应发送完整历史。", "INFO")
245 | else:
246 | # 会话创建失败,记录错误
247 | if hasattr(self.client, '_log'):
248 | self.client._log(f"切换到账号 {new_email} 后,创建新会话失败。", "WARNING")
249 | # 确保在这种情况下不设置需要完整历史的标志,因为会话本身就没成功
250 | setattr(self.client, '_new_session_requires_full_history', False)
251 |
252 |
253 | # --- 新增: 更新 client_sessions ---
254 | if not user_identifier:
255 | if hasattr(self.client, '_log'):
256 | self.client._log("RateLimitStrategy: _associated_user_identifier not found on client. Cannot update client_sessions.", "ERROR")
257 | # 即使没有 user_identifier,账号切换和会话创建也已发生,只是无法更新全局会话池
258 | else:
259 | old_email_in_strategy = current_email # 切换前的 email
260 | new_email_in_strategy = self.client.email # 切换后的 email (即 new_email)
261 |
262 | with config.config_instance.client_sessions_lock:
263 | if user_identifier in config.config_instance.client_sessions:
264 | user_specific_sessions = config.config_instance.client_sessions[user_identifier]
265 |
266 | # 1. 移除旧 email 的条目 (如果存在)
267 | # 我们只移除那些 client 实例确实是当前 self.client 的条目,
268 | # 或者更简单地,如果旧 email 存在,就移除它,因为 user_identifier
269 | # 现在应该通过 new_email 使用这个(已被修改的)client 实例。
270 | if old_email_in_strategy in user_specific_sessions:
271 | # 检查 client 实例是否匹配可能不可靠,因为 client 内部状态已变。
272 | # 直接删除旧 email 的条目,因为这个 user_identifier + client 组合现在用新 email。
273 | del user_specific_sessions[old_email_in_strategy]
274 | if hasattr(self.client, '_log'):
275 | self.client._log(f"RateLimitStrategy: Removed session for old email '{old_email_in_strategy}' for user '{user_identifier}'.", "INFO")
276 |
277 | # 2. 添加/更新新 email 的条目
278 | # 确保它指向当前这个已被修改的 self.client 实例
279 | # 并重置 active_context_hash。
280 | # IP 地址应来自 self.client._associated_request_ip 或 routes.py 中设置的值。
281 | # 由于 routes.py 在创建/分配会话时已将 IP 存入 client_sessions,
282 | # 这里我们主要关注 client 实例和 active_context_hash。
283 | # 如果 request_ip 在 self.client 中可用,则使用它,否则尝试保留已有的。
284 | ip_to_use = request_ip if request_ip else user_specific_sessions.get(new_email_in_strategy, {}).get("ip", "unknown_ip_in_retry_update")
285 |
286 | # 从 client 实例获取原始请求的上下文哈希
287 | # 这个哈希应该由 routes.py 在调用 send_query 之前设置到 client 实例上
288 | active_hash_for_new_session = getattr(self.client, '_current_request_context_hash', None)
289 |
290 | user_specific_sessions[new_email_in_strategy] = {
291 | "client": self.client, # 关键: 指向当前更新了 email/session_id 的 client 实例
292 | "active_context_hash": active_hash_for_new_session, # 使用来自 client 实例的哈希
293 | "last_time": datetime.now(), # 更新时间
294 | "ip": ip_to_use
295 | }
296 | 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)"
297 | if hasattr(self.client, '_log'):
298 | 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")
299 | else:
300 | if hasattr(self.client, '_log'):
301 | self.client._log(f"RateLimitStrategy: User '{user_identifier}' not found in client_sessions during update attempt.", "WARNING")
302 | # --- 更新 client_sessions 结束 ---
303 |
304 | except Exception as e:
305 | # 登录或创建会话失败,记录错误但不抛出异常
306 | # 让后续的重试机制处理
307 | if hasattr(self.client, '_log'):
308 | self.client._log(f"切换到账号 {new_email} 后登录或创建会话失败: {e}", "WARNING")
309 | # 此处不应更新 client_sessions,因为新账号的会话未成功建立
310 |
311 |
312 | class RetryHandler:
313 | """重试处理器,管理多个重试策略"""
314 |
315 | def __init__(self, client=None, logger=None):
316 | """
317 | 初始化重试处理器
318 |
319 | Args:
320 | client: API客户端实例,用于切换账号
321 | logger: 日志记录器或日志函数
322 | """
323 | self.client = client
324 | # 如果logger是None,使用默认logger
325 | # 如果logger是函数或Logger对象,直接使用
326 | self.logger = logger or logging.getLogger(__name__)
327 | self.strategies = [
328 | ExponentialBackoffStrategy(),
329 | LinearBackoffStrategy(),
330 | ServerErrorStrategy(),
331 | RateLimitStrategy(client)
332 | ]
333 |
334 | def retry_operation(self, operation: Callable[..., T], *args, **kwargs) -> T:
335 | """
336 | 使用重试策略执行操作
337 |
338 | Args:
339 | operation: 要执行的操作
340 | *args: 操作的位置参数
341 | **kwargs: 操作的关键字参数
342 |
343 | Returns:
344 | 操作的结果
345 |
346 | Raises:
347 | Exception: 如果所有重试都失败,则抛出最后一个异常
348 | """
349 | max_retries = config.get_config_value('max_retries')
350 | base_delay = config.get_config_value('retry_delay')
351 | retry_count = 0
352 | last_exception = None
353 |
354 | while True:
355 | try:
356 | return operation(*args, **kwargs)
357 | except Exception as e:
358 | last_exception = e
359 |
360 | # 查找适用的重试策略
361 | strategy = next((s for s in self.strategies if s.should_retry(e, retry_count, max_retries)), None)
362 |
363 | if strategy:
364 | retry_count += 1
365 | delay = strategy.get_retry_delay(retry_count, base_delay)
366 | strategy.log_retry_attempt(self.logger, e, retry_count, max_retries, delay)
367 | strategy.on_retry(e, retry_count)
368 |
369 | if delay > 0:
370 | time.sleep(delay)
371 | else:
372 | # 没有适用的重试策略,或者已达到最大重试次数
373 | raise
374 |
375 |
376 | def with_retry(max_retries: Optional[int] = None, retry_delay: Optional[int] = None):
377 | """
378 | 重试装饰器,用于装饰需要重试的方法
379 |
380 | Args:
381 | max_retries: 最大重试次数,如果为None则使用配置值
382 | retry_delay: 基础重试延迟,如果为None则使用配置值
383 |
384 | Returns:
385 | 装饰后的函数
386 | """
387 | def decorator(func):
388 | @functools.wraps(func)
389 | def wrapper(self, *args, **kwargs):
390 | # 获取配置值
391 | _max_retries = max_retries or config.get_config_value('max_retries')
392 | _retry_delay = retry_delay or config.get_config_value('retry_delay')
393 |
394 | # 创建重试处理器
395 | handler = RetryHandler(client=self, logger=getattr(self, '_log', None))
396 |
397 | # 定义要重试的操作
398 | def operation():
399 | return func(self, *args, **kwargs)
400 |
401 | # 执行操作并处理重试
402 | return handler.retry_operation(operation)
403 |
404 | return wrapper
405 |
406 | return decorator
--------------------------------------------------------------------------------
/routes.py:
--------------------------------------------------------------------------------
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 | # 移除了request_history相关代码
140 |
141 | def register_routes(app):
142 | """注册所有路由到Flask应用"""
143 |
144 | # 注册自定义过滤器
145 | app.jinja_env.filters['format_datetime'] = format_datetime
146 | app.jinja_env.filters['format_number'] = format_number
147 | app.jinja_env.filters['format_duration'] = format_duration
148 |
149 | @app.route('/health', methods=['GET'])
150 | def health_check():
151 | """健康检查端点,返回服务状态"""
152 | return {"status": "ok", "message": "2API服务运行正常"}, 200
153 |
154 | @app.route('/v1/models', methods=['GET'])
155 | def list_models():
156 | """以 OpenAI 格式返回可用模型列表。"""
157 | data = []
158 | # 获取当前时间戳,用于 'created' 字段
159 | created_time = int(time.time())
160 | model_mapping = config_instance._model_mapping
161 | for openai_name in model_mapping.keys(): # 仅列出已映射的模型
162 | data.append({
163 | "id": openai_name,
164 | "object": "model",
165 | "created": created_time,
166 | "owned_by": "on-demand.io" # 或根据模型来源填写 "openai", "anthropic" 等
167 | })
168 | return {"object": "list", "data": data}
169 |
170 | @app.route('/v1/chat/completions', methods=['POST'])
171 | def chat_completions():
172 | """处理聊天补全请求,兼容 OpenAI 格式。"""
173 | request_id = generate_request_id()
174 | request_start_time = time.time()
175 |
176 | # 验证访问令牌
177 | auth_header = request.headers.get('Authorization')
178 | if not auth_header or not auth_header.startswith('Bearer '):
179 | return {"error": {"message": "缺少有效的认证令牌", "type": "auth_error", "code": "missing_token"}}, 401
180 |
181 | token = auth_header[7:] # 去掉 'Bearer ' 前缀
182 | if token != config_instance.get('api_access_token'):
183 | return {"error": {"message": "无效的认证令牌", "type": "auth_error", "code": "invalid_token"}}, 401
184 |
185 | # 检查速率限制
186 | if not rate_limiter.is_allowed(token):
187 | return {"error": {"message": "请求频率过高,请稍后再试", "type": "rate_limit_error", "code": "rate_limit_exceeded"}}, 429
188 |
189 | # 解析请求数据
190 | openai_data = request.get_json()
191 | if not openai_data:
192 | return {"error": {"message": "请求体必须是 JSON。", "type": "invalid_request_error", "code": None}}, 400
193 |
194 | # 提取基本参数
195 | messages = openai_data.get('messages', [])
196 | if not messages:
197 | return {"error": {"message": "缺少 'messages' 字段。", "type": "invalid_request_error", "code": "missing_messages"}}, 400
198 |
199 | stream_requested = openai_data.get('stream', False)
200 | model_mapping = config_instance._model_mapping
201 | default_endpoint_id = config_instance.get('default_endpoint_id')
202 | requested_model_name = openai_data.get('model', list(model_mapping.keys())[0] if model_mapping else default_endpoint_id)
203 |
204 | # 检查是否有用户消息
205 | user_messages = [msg for msg in messages if msg.get('role') == 'user' and msg.get('content')]
206 | if not user_messages:
207 | return {"error": {"message": "'messages' 中未找到有效的 'user' 角色的消息内容。", "type": "invalid_request_error", "code": "no_user_message"}}, 400
208 |
209 | # 创建新的客户端会话
210 | ondemand_client = None
211 | email_for_stats = None
212 |
213 | with config_instance.client_sessions_lock:
214 | MAX_ACCOUNT_ATTEMPTS = config_instance.get('max_account_attempts', 3)
215 | for attempt in range(MAX_ACCOUNT_ATTEMPTS):
216 | email, password = config.get_next_ondemand_account_details()
217 | if not email or not password:
218 | continue
219 |
220 | email_for_stats = email
221 | client_id = f"{token[:8]}-{email.split('@')[0]}-{request_id[:4]}"
222 | temp_client = OnDemandAPIClient(email, password, client_id=client_id)
223 |
224 | if temp_client.sign_in() and temp_client.create_session():
225 | ondemand_client = temp_client
226 | ondemand_client._associated_user_identifier = token
227 | ondemand_client._associated_request_ip = request.remote_addr
228 | break
229 |
230 | if not ondemand_client:
231 | error_msg = "无法创建有效的客户端会话"
232 | prompt_tokens, _, _ = count_message_tokens(messages, requested_model_name)
233 | _update_usage_statistics(
234 | config_inst=config_instance, request_id=request_id,
235 | requested_model_name=requested_model_name,
236 | account_email=email_for_stats,
237 | is_success=False, duration_ms=int((time.time() - request_start_time) * 1000),
238 | is_stream=stream_requested, prompt_tokens_val=prompt_tokens or 0,
239 | completion_tokens_val=0, total_tokens_val=prompt_tokens or 0,
240 | error_message=error_msg
241 | )
242 | return {"error": {"message": error_msg, "type": "api_error", "code": "client_unavailable"}}, 503
243 |
244 | # 构建查询 - 使用符合OnDemand API期望的格式
245 | # 根据API期望将消息构建为JSON对象
246 | messages_to_send = []
247 |
248 | # 处理系统消息 - 将系统消息放在最前面
249 | system_messages = [msg for msg in messages if msg.get('role') == 'system']
250 | # 处理用户和助手消息 - 保持原始顺序
251 | other_messages = [msg for msg in messages if msg.get('role') != 'system']
252 |
253 | # 按顺序添加所有消息
254 | processed_messages = system_messages + other_messages
255 |
256 | # 构建最终查询 - 使用JSON格式
257 | final_query_to_ondemand = json.dumps({
258 | "messages": processed_messages
259 | })
260 |
261 | # 确保消息不为空
262 | if not processed_messages:
263 | final_query_to_ondemand = json.dumps({
264 | "messages": [{"role": "user", "content": " "}]
265 | })
266 |
267 | # 构建模型配置
268 | model_configs = {}
269 | for param_name, api_name in [
270 | ('temperature', 'temperature'),
271 | ('max_tokens', 'maxTokens'),
272 | ('top_p', 'topP'),
273 | ('frequency_penalty', 'frequency_penalty'),
274 | ('presence_penalty', 'presence_penalty')
275 | ]:
276 | if openai_data.get(param_name) is not None:
277 | model_configs[api_name] = openai_data.get(param_name)
278 |
279 | # 发送查询
280 | endpoint_id = model_mapping.get(requested_model_name, default_endpoint_id)
281 | ondemand_result = ondemand_client.send_query(
282 | final_query_to_ondemand,
283 | endpoint_id=endpoint_id or default_endpoint_id,
284 | stream=stream_requested,
285 | model_configs_input=model_configs
286 | )
287 |
288 | # 处理响应
289 | if stream_requested:
290 | # 流式响应
291 | def generate_openai_stream():
292 | stream_response_obj = ondemand_result.get("response_obj")
293 | if not stream_response_obj:
294 | prompt_tokens, _, _ = count_message_tokens(messages, requested_model_name)
295 | error_json = {
296 | "id": request_id,
297 | "object": "chat.completion.chunk",
298 | "created": int(time.time()),
299 | "model": requested_model_name,
300 | "choices": [{"delta": {"content": "[流错误:未获取到响应对象]"}, "index": 0, "finish_reason": "error"}],
301 | "usage": {"prompt_tokens": prompt_tokens or 0, "completion_tokens": 0, "total_tokens": prompt_tokens or 0}
302 | }
303 | yield f"data: {json.dumps(error_json, ensure_ascii=False)}\n\n"
304 | yield "data: [DONE]\n\n"
305 | return
306 |
307 | # 用于聚合完整回复和跟踪token统计
308 | full_reply = []
309 | actual_tokens = {"input": 0, "output": 0, "total": 0}
310 |
311 | try:
312 | for line in stream_response_obj.iter_lines():
313 | if not line:
314 | continue
315 |
316 | decoded_line = line.decode('utf-8')
317 | if not decoded_line.startswith("data:"):
318 | continue
319 |
320 | json_str = decoded_line[len("data:"):].strip()
321 | if json_str == "[DONE]":
322 | break
323 |
324 | try:
325 | event_data = json.loads(json_str)
326 | event_type = event_data.get("eventType", "")
327 |
328 | # 处理内容块
329 | if event_type == "fulfillment":
330 | content = event_data.get("answer", "")
331 | if content is not None:
332 | full_reply.append(content)
333 | chunk_data = {
334 | 'id': request_id,
335 | 'object': 'chat.completion.chunk',
336 | 'created': int(time.time()),
337 | 'model': requested_model_name,
338 | 'choices': [{'delta': {'content': content}, 'index': 0, 'finish_reason': None}]
339 | }
340 | yield f"data: {json.dumps(chunk_data, ensure_ascii=False)}\n\n"
341 |
342 | # 从metrics事件中提取准确的token计数
343 | elif event_type == "metricsLog":
344 | metrics = event_data.get("publicMetrics", {})
345 | if metrics:
346 | actual_tokens["input"] = metrics.get("inputTokens", 0) or 0
347 | actual_tokens["output"] = metrics.get("outputTokens", 0) or 0
348 | actual_tokens["total"] = metrics.get("totalTokens", 0) or 0
349 | except json.JSONDecodeError:
350 | continue
351 |
352 | # 使用实际token或回退到估算
353 | if not any(actual_tokens.values()):
354 | prompt_tokens, _, _ = count_message_tokens(messages, requested_model_name)
355 | completion_tokens = max(1, len("".join(full_reply)) // 4) # 粗略估算
356 | total_tokens = (prompt_tokens or 0) + completion_tokens
357 | else:
358 | prompt_tokens = actual_tokens["input"]
359 | completion_tokens = actual_tokens["output"]
360 | total_tokens = actual_tokens["total"]
361 |
362 | # 发送终止块
363 | end_chunk_data = {
364 | 'id': request_id,
365 | 'object': 'chat.completion.chunk',
366 | 'created': int(time.time()),
367 | 'model': requested_model_name,
368 | 'choices': [{'delta': {}, 'index': 0, 'finish_reason': 'stop'}],
369 | 'usage': {
370 | 'prompt_tokens': prompt_tokens,
371 | 'completion_tokens': completion_tokens,
372 | 'total_tokens': total_tokens
373 | }
374 | }
375 | yield f"data: {json.dumps(end_chunk_data, ensure_ascii=False)}\n\n"
376 | yield "data: [DONE]\n\n"
377 |
378 | # 更新使用统计
379 | _update_usage_statistics(
380 | config_inst=config_instance,
381 | request_id=request_id,
382 | requested_model_name=requested_model_name,
383 | account_email=ondemand_client.email,
384 | is_success=True,
385 | duration_ms=int((time.time() - request_start_time) * 1000),
386 | is_stream=True,
387 | prompt_tokens_val=prompt_tokens,
388 | completion_tokens_val=completion_tokens,
389 | total_tokens_val=total_tokens,
390 | prompt_length=len(final_query_to_ondemand)
391 | )
392 | except Exception as e:
393 | # 处理异常
394 | prompt_tokens, _, _ = count_message_tokens(messages, requested_model_name)
395 | error_chunk_data = {
396 | 'id': request_id,
397 | 'object': 'chat.completion.chunk',
398 | 'created': int(time.time()),
399 | 'model': requested_model_name,
400 | 'choices': [{'delta': {'content': f'[流处理异常: {str(e)}]'}, 'index': 0, 'finish_reason': 'error'}],
401 | 'usage': {'prompt_tokens': prompt_tokens or 0, 'completion_tokens': 0, 'total_tokens': prompt_tokens or 0}
402 | }
403 | yield f"data: {json.dumps(error_chunk_data, ensure_ascii=False)}\n\n"
404 | yield "data: [DONE]\n\n"
405 |
406 | # 更新失败统计
407 | _update_usage_statistics(
408 | config_inst=config_instance,
409 | request_id=request_id,
410 | requested_model_name=requested_model_name,
411 | account_email=ondemand_client.email,
412 | is_success=False,
413 | duration_ms=int((time.time() - request_start_time) * 1000),
414 | is_stream=True,
415 | prompt_tokens_val=prompt_tokens or 0,
416 | completion_tokens_val=0,
417 | total_tokens_val=prompt_tokens or 0,
418 | error_message=str(e)
419 | )
420 | finally:
421 | if stream_response_obj:
422 | stream_response_obj.close()
423 |
424 | return Response(stream_with_context(generate_openai_stream()),
425 | content_type='text/event-stream; charset=utf-8')
426 | else:
427 | # 非流式响应
428 | final_content = ondemand_result.get("content", "")
429 |
430 | # 计算token数量
431 | prompt_tokens, _, _ = count_message_tokens(messages, requested_model_name)
432 | completion_tokens = count_tokens(final_content, requested_model_name)
433 | total_tokens = (prompt_tokens or 0) + (completion_tokens or 0)
434 |
435 | # 构建OpenAI格式响应
436 | response = {
437 | "id": request_id,
438 | "object": "chat.completion",
439 | "created": int(time.time()),
440 | "model": requested_model_name,
441 | "choices": [{
442 | "message": {"role": "assistant", "content": final_content},
443 | "finish_reason": "stop",
444 | "index": 0
445 | }],
446 | "usage": {
447 | "prompt_tokens": prompt_tokens,
448 | "completion_tokens": completion_tokens,
449 | "total_tokens": total_tokens
450 | }
451 | }
452 |
453 | # 更新使用统计
454 | _update_usage_statistics(
455 | config_inst=config_instance,
456 | request_id=request_id,
457 | requested_model_name=requested_model_name,
458 | account_email=ondemand_client.email,
459 | is_success=True,
460 | duration_ms=int((time.time() - request_start_time) * 1000),
461 | is_stream=False,
462 | prompt_tokens_val=prompt_tokens,
463 | completion_tokens_val=completion_tokens,
464 | total_tokens_val=total_tokens,
465 | prompt_length=len(final_query_to_ondemand),
466 | completion_length=len(final_content) if final_content else 0
467 | )
468 |
469 | return response
470 |
471 | @app.route('/', methods=['GET'])
472 | def show_stats():
473 | """显示用量统计信息的HTML页面"""
474 | current_time = datetime.now()
475 | current_time_str = current_time.strftime('%Y-%m-%d %H:%M:%S')
476 | current_date = current_time.strftime('%Y-%m-%d')
477 |
478 | with config_instance.usage_stats_lock:
479 | # 复制基础统计数据
480 | total_requests = config_instance.usage_stats["total_requests"]
481 | successful_requests = config_instance.usage_stats["successful_requests"]
482 | failed_requests = config_instance.usage_stats["failed_requests"]
483 | total_prompt_tokens = config_instance.usage_stats["total_prompt_tokens"]
484 | total_completion_tokens = config_instance.usage_stats["total_completion_tokens"]
485 | total_tokens = config_instance.usage_stats["total_tokens"]
486 |
487 | # 计算成功率(整数百分比)
488 | success_rate = int((successful_requests / total_requests * 100) if total_requests > 0 else 0)
489 |
490 | # 由于移除了request_history,无法计算具体的平均响应时间和最快响应时间
491 | # 设置为默认值
492 | avg_duration = 0
493 | min_duration = 0
494 |
495 | # 计算今日请求数和增长率
496 | today_requests = config_instance.usage_stats["daily_usage"].get(current_date, 0)
497 | # 确保不会出现除以零或None值的情况
498 | if total_requests is None or today_requests is None:
499 | growth_rate = 0
500 | elif total_requests == today_requests or (total_requests - today_requests) <= 0:
501 | growth_rate = 100 # 如果所有请求都是今天的,增长率为100%
502 | else:
503 | growth_rate = (today_requests / (total_requests - today_requests) * 100)
504 |
505 | # 计算估算成本 - 使用模型价格配置
506 | total_cost = 0.0
507 | model_costs = {} # 存储每个模型的成本
508 |
509 | # 由于移除了request_history,我们无法直接计算成本
510 | # 这里使用总token数和默认价格来估算总成本
511 | all_model_prices = config_instance.get('model_prices', {})
512 | default_model_price = config_instance.get('default_model_price', {'input': 0.50 / 1000000, 'output': 2.00 / 1000000})
513 |
514 | # 假设输入输出token比例为1:3来估算成本
515 | avg_input_price = sum([price['input'] for price in all_model_prices.values()]) / len(all_model_prices) if all_model_prices else default_model_price['input']
516 | avg_output_price = sum([price['output'] for price in all_model_prices.values()]) / len(all_model_prices) if all_model_prices else default_model_price['output']
517 |
518 | estimated_input_tokens = total_prompt_tokens
519 | estimated_output_tokens = total_completion_tokens
520 |
521 | total_cost = (estimated_input_tokens * avg_input_price / 1000000) + (estimated_output_tokens * avg_output_price / 1000000)
522 |
523 | # 从model_tokens统计中估算各模型成本
524 | for model_name, tokens in config_instance.usage_stats["model_tokens"].items():
525 | model_price = all_model_prices.get(model_name, default_model_price)
526 | # 假设输入输出token比例为1:3
527 | input_ratio = 0.25
528 | output_ratio = 0.75
529 | estimated_model_cost = (tokens * input_ratio * model_price['input'] / 1000000) + (tokens * output_ratio * model_price['output'] / 1000000)
530 | model_costs[model_name] = estimated_model_cost
531 |
532 | # 计算平均成本
533 | avg_cost = (total_cost / successful_requests) if successful_requests > 0 else 0
534 |
535 | # 获取最常用模型
536 | model_usage = dict(config_instance.usage_stats["model_usage"])
537 | top_models = sorted(model_usage.items(), key=lambda x: x[1], reverse=True)
538 | top_model = top_models[0] if top_models else None
539 |
540 | # 构建完整的统计数据字典
541 | stats = {
542 | "total_requests": total_requests,
543 | "successful_requests": successful_requests,
544 | "failed_requests": failed_requests,
545 | "success_rate": success_rate,
546 | "avg_duration": avg_duration,
547 | "min_duration": min_duration,
548 | "today_requests": today_requests,
549 | "growth_rate": growth_rate,
550 | "total_prompt_tokens": total_prompt_tokens,
551 | "total_completion_tokens": total_completion_tokens,
552 | "total_tokens": total_tokens,
553 | "total_cost": total_cost,
554 | "avg_cost": avg_cost,
555 | "model_usage": model_usage,
556 | "model_costs": model_costs, # 添加每个模型的成本
557 | "top_model": top_model,
558 | "model_tokens": dict(config_instance.usage_stats["model_tokens"]),
559 | "account_usage": dict(config_instance.usage_stats["account_usage"]),
560 | "daily_usage": dict(sorted(config_instance.usage_stats["daily_usage"].items(), reverse=True)[:30]), # 最近30天
561 | "hourly_usage": dict(sorted(config_instance.usage_stats["hourly_usage"].items(), reverse=True)[:48]), # 最近48小时
562 | # 移除request_history
563 | "daily_tokens": dict(sorted(config_instance.usage_stats["daily_tokens"].items(), reverse=True)[:30]), # 最近30天
564 | "hourly_tokens": dict(sorted(config_instance.usage_stats["hourly_tokens"].items(), reverse=True)[:48]), # 最近48小时
565 | "last_saved": config_instance.usage_stats.get("last_saved", "从未保存")
566 | }
567 |
568 | # 使用render_template渲染模板
569 | return render_template('stats.html', stats=stats, current_time=current_time_str)
570 |
571 | @app.route('/save_stats', methods=['POST'])
572 | def save_stats():
573 | """手动保存统计数据"""
574 | try:
575 | config_instance.save_stats_to_file()
576 | logger.info("统计数据已手动保存")
577 | return redirect(url_for('show_stats'))
578 | except Exception as e:
579 | logger.error(f"手动保存统计数据时出错: {e}")
580 | return jsonify({"status": "error", "message": str(e)}), 500
--------------------------------------------------------------------------------
/static/css/styles.css:
--------------------------------------------------------------------------------
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:
--------------------------------------------------------------------------------
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:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | 2API 用量统计
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
33 |
34 |
35 |
36 |
39 |
40 | 数据将在 60 秒后自动刷新
41 |
42 |
43 |
44 |
45 |
46 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
总请求数
57 |
{{ stats.total_requests|format_number }}
58 |
59 |
60 | {{ stats.growth_rate|round(2) }}% 今日
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
成功率
71 |
{{ stats.success_rate }}%
72 |
73 | 成功: {{ stats.successful_requests|format_number }} / 失败: {{ stats.failed_requests|format_number }}
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
平均响应时间
84 |
85 | {{ stats.avg_duration|format_duration }}
86 |
87 |
88 | 最快: {{ stats.min_duration|format_duration }}
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
总 Tokens
99 |
{{ stats.total_tokens|format_number }}
100 |
101 | 提示: {{ stats.total_prompt_tokens|format_number }} / 完成: {{ stats.total_completion_tokens|format_number }}
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
估算成本
112 |
113 | ${{ stats.total_cost | round(2) }}
114 |
115 |
116 | 平均: ${{ stats.avg_cost | round(2) }}/请求
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
模型使用
127 |
{{ stats.model_usage.keys()|list|length }}
128 |
129 | {% if stats.top_model %}
130 | 最常用: {{ stats.top_model[0] }} ({{ stats.top_model[1] }}次)
131 | {% else %}
132 | 暂无模型使用数据
133 | {% endif %}
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
146 |
147 |
150 |
151 |
152 |
153 |
154 |
157 |
158 |
161 |
162 |
163 |
164 |
165 |
166 |
167 |
168 |
169 |
178 |
179 |
180 |
181 |
182 |
183 | 请求ID
184 | 时间
185 | 模型
186 | 账户
187 | 状态
188 | 耗时(ms)
189 | 总Tokens
190 |
191 |
192 |
193 | {% for req in stats.request_history|reverse %}
194 |
195 | {{ req.id[:8] }}...
196 | {{ req.timestamp|format_datetime }}
197 | {{ req.model }}
198 |
199 |
200 | {{ req.account[0]|upper }}
201 | {{ req.account.split('@')[0] }}
202 |
203 |
204 |
205 |
206 |
207 | {{ '成功' if req.success else '失败' }}
208 |
209 |
210 | {{ req.duration_ms|format_duration }}
211 | {{ (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 '-' }}
212 |
213 | {% endfor %}
214 |
215 |
216 |
217 |
222 |
223 |
224 |
235 |
236 |
237 |
238 |
239 |
240 |
--------------------------------------------------------------------------------
/utils.py:
--------------------------------------------------------------------------------
1 | import json
2 | import os
3 | import time
4 | import tiktoken
5 | from datetime import datetime
6 | from typing import Dict, Any, Optional, Tuple
7 | from logging_config import logger
8 | from config import get_config_value # 导入配置获取函数
9 | # from config import get_config_value # 导入配置获取函数 - 已移除循环导入
10 |
11 |
12 |
13 | def mask_email(email: str) -> str:
14 | """隐藏邮箱中间部分,保护隐私"""
15 | if not email or '@' not in email:
16 | return "无效邮箱"
17 |
18 | parts = email.split('@')
19 | username = parts[0]
20 | domain = parts[1]
21 |
22 | if len(username) <= 3:
23 | masked_username = username[0] + '*' * (len(username) - 1)
24 | else:
25 | masked_username = username[0] + '*' * (len(username) - 2) + username[-1]
26 |
27 | return f"{masked_username}@{domain}"
28 |
29 | def generate_request_id() -> str:
30 | """生成唯一的请求ID"""
31 | return f"chatcmpl-{os.urandom(16).hex()}"
32 |
33 | def count_tokens(text: str, model: str = "gpt-3.5-turbo") -> int:
34 | """
35 | 计算文本的token数量
36 |
37 | Args:
38 | text: 要计算token数量的文本
39 | model: 模型名称,默认为gpt-3.5-turbo
40 |
41 | Returns:
42 | int: token数量
43 | """
44 | # 类型保护,防止text为None或非字符串类型
45 | if text is None:
46 | text = ""
47 | elif not isinstance(text, str):
48 | text = str(text)
49 | try:
50 | # 根据模型名称获取编码器
51 | if "gpt-4" in model:
52 | encoding = tiktoken.encoding_for_model("gpt-4")
53 | elif "gpt-3.5" in model:
54 | encoding = tiktoken.encoding_for_model("gpt-3.5-turbo")
55 | elif "claude" in model:
56 | # Claude模型使用cl100k_base编码器
57 | encoding = tiktoken.get_encoding("cl100k_base")
58 | else:
59 | # 默认使用cl100k_base编码器
60 | encoding = tiktoken.get_encoding("cl100k_base")
61 |
62 | # 计算token数量
63 | tokens = encoding.encode(text)
64 | return len(tokens)
65 | except Exception as e:
66 | logger.error(f"计算token数量时出错: {e}")
67 | # 如果出错,使用简单的估算方法(每4个字符约为1个token)
68 | return len(text) // 4
69 |
70 | def count_message_tokens(messages: list, model: str = "gpt-3.5-turbo") -> Tuple[int, int, int]:
71 | """
72 | 计算OpenAI格式消息列表的token数量
73 |
74 | Args:
75 | messages: OpenAI格式的消息列表
76 | model: 模型名称,默认为gpt-3.5-turbo
77 |
78 | Returns:
79 | Tuple[int, int, int]: (提示tokens数, 完成tokens数, 总tokens数)
80 | """
81 | # 类型保护,防止messages为None或非列表类型
82 | if messages is None:
83 | messages = []
84 | elif not isinstance(messages, list):
85 | logger.warning(f"count_message_tokens 收到非列表类型的消息: {type(messages)}")
86 | messages = []
87 |
88 | prompt_tokens = 0
89 | completion_tokens = 0
90 |
91 | try:
92 | # 计算提示tokens
93 | for message in messages:
94 | # 确保message是字典类型
95 | if not isinstance(message, dict):
96 | logger.warning(f"跳过非字典类型的消息: {type(message)}")
97 | continue
98 |
99 | role = message.get('role', '')
100 | content = message.get('content', '')
101 |
102 | if role and content:
103 | # 每条消息的基本token开销
104 | prompt_tokens += 4 # 每条消息的基本开销
105 |
106 | # 角色名称的token
107 | prompt_tokens += 1 # 角色名称的开销
108 |
109 | # 内容的token
110 | prompt_tokens += count_tokens(content, model)
111 |
112 | # 如果是assistant角色,计算完成tokens
113 | if role == 'assistant':
114 | completion_tokens += count_tokens(content, model)
115 |
116 | # 消息结束的token
117 | prompt_tokens += 2 # 消息结束的开销
118 |
119 | # 计算总tokens
120 | total_tokens = prompt_tokens + completion_tokens
121 |
122 | return prompt_tokens, completion_tokens, total_tokens
123 | except Exception as e:
124 | logger.error(f"计算消息token数量时出错: {e}")
125 | # 返回安全的默认值
126 | return 0, 0, 0
--------------------------------------------------------------------------------