ZyphrZero
commited on
Commit
·
4803675
1
Parent(s):
b875088
🌟 feat(core): 功能:实现token池管理,以改进身份验证处理
Browse files- 添加了 TokenPool 类,用于管理身份验证token,并实现负载均衡和容错。
- 引入了从文件加载token、将token标记为成功或失败以及对token执行健康检查的方法。
- 更新了 Settings 类,使其包含token管理配置,例如token文件路径和健康检查间隔。
- 增强了 chat_completions 函数,使其更有效地处理令牌重试和失败。
- 添加了新的 API 端点,用于检查token池的状态并手动触发健康检查。
- 更新了 Docker Compose 配置,删除了已弃用的环境变量。
- 创建了示例令牌配置文件,以指导用户设置令牌。
- 更新了requirements.txt,使其包含用于异步 HTTP 请求的 httpx
- .env.example +18 -6
- README.md +98 -46
- app/core/config.py +98 -5
- app/core/openai.py +133 -6
- app/core/zai_transformer.py +42 -8
- app/utils/token_pool.py +454 -0
- deploy/docker-compose.yml +0 -2
- main.py +21 -3
- pyproject.toml +1 -0
- requirements.txt +1 -0
- tokens.txt.example +21 -0
.env.example
CHANGED
|
@@ -11,20 +11,32 @@ AUTH_TOKEN=sk-your-api-key
|
|
| 11 |
# 跳过客户端认证(仅开发环境使用)
|
| 12 |
SKIP_AUTH_TOKEN=false
|
| 13 |
|
| 14 |
-
#
|
| 15 |
-
#
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
# ========== 服务器配置 ==========
|
| 19 |
# 服务监听端口
|
| 20 |
LISTEN_PORT=8080
|
| 21 |
|
| 22 |
-
#
|
| 23 |
DEBUG_LOGGING=true
|
| 24 |
|
| 25 |
-
#
|
|
|
|
| 26 |
# true: 自动从 Z.ai 获取临时访问令牌,避免对话历史共享
|
| 27 |
-
# false: 使用固定令牌 BACKUP_TOKEN
|
| 28 |
ANONYMOUS_MODE=true
|
| 29 |
|
| 30 |
# Function Call 功能开关
|
|
|
|
| 11 |
# 跳过客户端认证(仅开发环境使用)
|
| 12 |
SKIP_AUTH_TOKEN=false
|
| 13 |
|
| 14 |
+
# ========== Token池配置 ==========
|
| 15 |
+
# Token失败阈值(失败多少次后标记为不可用)
|
| 16 |
+
TOKEN_FAILURE_THRESHOLD=3
|
| 17 |
+
|
| 18 |
+
# Token恢复超时时间(秒,失败token在此时间后重新尝试)
|
| 19 |
+
TOKEN_RECOVERY_TIMEOUT=1800
|
| 20 |
+
|
| 21 |
+
# Token健康检查间隔(秒,定期检查token状态)
|
| 22 |
+
TOKEN_HEALTH_CHECK_INTERVAL=300
|
| 23 |
+
|
| 24 |
+
# Z.ai 认证token配置(当匿名模式失败时使用)
|
| 25 |
+
#
|
| 26 |
+
# 使用独立的token文件配置
|
| 27 |
+
# 在项目根目录创建 tokens.txt 文件,每行一个token或逗号分隔
|
| 28 |
+
AUTH_TOKENS_FILE=tokens.txt
|
| 29 |
|
| 30 |
# ========== 服务器配置 ==========
|
| 31 |
# 服务监听端口
|
| 32 |
LISTEN_PORT=8080
|
| 33 |
|
| 34 |
+
# 调试日志
|
| 35 |
DEBUG_LOGGING=true
|
| 36 |
|
| 37 |
+
# 匿名用户模式
|
| 38 |
+
# false: 使用认证用户令牌
|
| 39 |
# true: 自动从 Z.ai 获取临时访问令牌,避免对话历史共享
|
|
|
|
| 40 |
ANONYMOUS_MODE=true
|
| 41 |
|
| 42 |
# Function Call 功能开关
|
README.md
CHANGED
|
@@ -1,30 +1,34 @@
|
|
| 1 |
# Z.AI OpenAI API 代理服务
|
| 2 |
|
| 3 |

|
| 4 |
-

|
| 6 |
-
![Version: 1.
|
| 7 |
|
| 8 |
-
|
|
|
|
|
|
|
| 9 |
|
| 10 |
## ✨ 核心特性
|
| 11 |
|
| 12 |
- 🔌 **完全兼容 OpenAI API** - 无缝集成现有应用
|
| 13 |
- 🤖 **Claude Code 支持** - 通过 Claude Code Router 接入 Claude Code (**CCR 工具请升级到 v1.0.47 以上**)
|
| 14 |
- 🚀 **高性能流式响应** - Server-Sent Events (SSE) 支持
|
| 15 |
-
- 🛠️ **增强工具调用** - 改进的 Function Call
|
| 16 |
- 🧠 **思考模式支持** - 智能处理模型推理过程
|
| 17 |
- 🔍 **搜索模型集成** - GLM-4.5-Search 网络搜索能力
|
| 18 |
- 🐳 **Docker 部署** - 一键容器化部署
|
| 19 |
- 🛡️ **会话隔离** - 匿名模式保护隐私
|
| 20 |
- 🔧 **灵活配置** - 环境变量灵活配置
|
| 21 |
- 📊 **多模型映射** - 智能上游模型路由
|
|
|
|
|
|
|
| 22 |
|
| 23 |
## 🚀 快速开始
|
| 24 |
|
| 25 |
### 环境要求
|
| 26 |
|
| 27 |
-
- Python 3.
|
| 28 |
- pip 或 uv (推荐)
|
| 29 |
|
| 30 |
### 安装运行
|
|
@@ -44,7 +48,9 @@ pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
|
|
| 44 |
python main.py
|
| 45 |
```
|
| 46 |
|
| 47 |
-
|
|
|
|
|
|
|
| 48 |
|
| 49 |
### 基础使用
|
| 50 |
|
|
@@ -145,18 +151,65 @@ for chunk in response:
|
|
| 145 |
| `API_ENDPOINT` | `https://chat.z.ai/api/chat/completions` | 上游 API 地址 |
|
| 146 |
| `LISTEN_PORT` | `8080` | 服务监听端口 |
|
| 147 |
| `DEBUG_LOGGING` | `true` | 调试日志开关 |
|
| 148 |
-
| `
|
| 149 |
-
| `ANONYMOUS_MODE` | `true` | 匿名模式开关 |
|
| 150 |
| `TOOL_SUPPORT` | `true` | Function Call 功能开关 |
|
| 151 |
| `SKIP_AUTH_TOKEN` | `false` | 跳过认证令牌验证 |
|
| 152 |
| `SCAN_LIMIT` | `200000` | 扫描限制 |
|
| 153 |
-
| `
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
|
| 155 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
|
| 161 |
## 🎯 使用场景
|
| 162 |
|
|
@@ -203,6 +256,12 @@ if response.choices[0].message.tool_calls:
|
|
| 203 |
**Q: 如何获取 AUTH_TOKEN?**
|
| 204 |
A: `AUTH_TOKEN` 为自己自定义的 api key,在环境变量中配置,需要保证客户端与服务端一致。
|
| 205 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
**Q: 如何通过 Claude Code 使用本服务?**
|
| 207 |
|
| 208 |
A: 创建 [zai.js](https://gist.githubusercontent.com/musistudio/b35402d6f9c95c64269c7666b8405348/raw/f108d66fa050f308387938f149a2b14a295d29e9/gistfile1.txt) 这个 ccr 插件放在`./.claude-code-router/plugins`目录下,配置 `./.claude-code-router/config.json` 指向本服务地址,使用 `AUTH_TOKEN` 进行认证。
|
|
@@ -287,32 +346,25 @@ A: 通过环境变量配置,推荐使用 `.env` 文件。
|
|
| 287 |
|
| 288 |
要使用完整的多模态功能,需要获取正式的 Z.ai API Token:
|
| 289 |
|
| 290 |
-
### 方式 1: 通过 Z.ai 网站
|
| 291 |
-
|
| 292 |
-
1. 访问 [Z.ai 官网](https://chat.z.ai)
|
| 293 |
-
2. 注册账户并登录,进入 [Z.ai API Keys](https://z.ai/manage-apikey/apikey-list) 设置页面,在该页面设置 _**个人 API Token**_
|
| 294 |
-
3. 将 Token 放置在 `BACKUP_TOKEN` 环境变量中
|
| 295 |
-
|
| 296 |
-
### 方式 2: 浏览器开发者工具(临时方案)
|
| 297 |
-
|
| 298 |
1. 打开 [Z.ai 聊天界面](https://chat.z.ai)
|
| 299 |
2. 按 F12 打开开发者工具
|
| 300 |
3. 切换到 "Application" 或 "存储" 标签
|
| 301 |
4. 查看 Local Storage 中的认证 token
|
| 302 |
5. 复制 token 值设置为环境变量
|
| 303 |
|
| 304 |
-
>
|
| 305 |
-
> ❗ **重要提示**: 多模态模型需要**官方 Z.ai API 非匿名 Token**,匿名 token 不支持多媒体处理。
|
| 306 |
|
| 307 |
## 🛠️ 技术栈
|
| 308 |
|
| 309 |
| 组件 | 技术 | 版本 | 说明 |
|
| 310 |
| --------------- | --------------------------------------------------------------------------------- | ------- | ------------------------------------------ |
|
| 311 |
-
| **Web 框架** | [FastAPI](https://fastapi.tiangolo.com/) | 0.
|
| 312 |
| **ASGI 服务器** | [Granian](https://github.com/emmett-framework/granian) | 2.5.2 | 基于 Rust 的高性能 ASGI 服务器,支持热重载 |
|
| 313 |
-
| **HTTP 客户端** | [Requests](https://requests.readthedocs.io/)
|
| 314 |
| **数据验证** | [Pydantic](https://pydantic.dev/) | 2.11.7 | 类型安全的数据验证与序列化 |
|
| 315 |
| **配置管理** | [Pydantic Settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/) | 2.10.1 | 基于 Pydantic 的配置管理 |
|
|
|
|
|
|
|
| 316 |
|
| 317 |
## 🏗️ 技术架构
|
| 318 |
|
|
@@ -338,27 +390,27 @@ A: 通过环境变量配置,推荐使用 `.env` 文件。
|
|
| 338 |
|
| 339 |
```
|
| 340 |
z.ai2api_python/
|
| 341 |
-
├── app/
|
| 342 |
-
│ ├── core/
|
| 343 |
-
│ │ ├──
|
| 344 |
-
│ │ ├──
|
| 345 |
-
│ │
|
| 346 |
-
│
|
| 347 |
-
│
|
| 348 |
-
│
|
| 349 |
-
│
|
| 350 |
-
│
|
| 351 |
-
│
|
| 352 |
-
│
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
│
|
| 356 |
-
|
| 357 |
-
├──
|
| 358 |
-
├──
|
| 359 |
-
├──
|
| 360 |
-
├── .
|
| 361 |
-
└──
|
| 362 |
```
|
| 363 |
|
| 364 |
## 🤝 贡献指南
|
|
|
|
| 1 |
# Z.AI OpenAI API 代理服务
|
| 2 |
|
| 3 |

|
| 4 |
+

|
| 5 |

|
| 6 |
+

|
| 7 |
|
| 8 |
+
> 🎯 **项目愿景**:提供完全兼容 OpenAI API 的 Z.AI 代理服务,让用户无需修改现有代码即可接入 GLM-4.5 系列模型。
|
| 9 |
+
|
| 10 |
+
轻量级、高性能的 OpenAI API 兼容代理服务,通过 Claude Code Router 接入 Z.AI,支持 GLM-4.5 系列模型的完整功能。
|
| 11 |
|
| 12 |
## ✨ 核心特性
|
| 13 |
|
| 14 |
- 🔌 **完全兼容 OpenAI API** - 无缝集成现有应用
|
| 15 |
- 🤖 **Claude Code 支持** - 通过 Claude Code Router 接入 Claude Code (**CCR 工具请升级到 v1.0.47 以上**)
|
| 16 |
- 🚀 **高性能流式响应** - Server-Sent Events (SSE) 支持
|
| 17 |
+
- 🛠️ **增强工具调用** - 改进的 Function Call 实现,支持复杂工具链
|
| 18 |
- 🧠 **思考模式支持** - 智能处理模型推理过程
|
| 19 |
- 🔍 **搜索模型集成** - GLM-4.5-Search 网络搜索能力
|
| 20 |
- 🐳 **Docker 部署** - 一键容器化部署
|
| 21 |
- 🛡️ **会话隔离** - 匿名模式保护隐私
|
| 22 |
- 🔧 **灵活配置** - 环境变量灵活配置
|
| 23 |
- 📊 **多模型映射** - 智能上游模型路由
|
| 24 |
+
- 🔄 **Token 池管理** - 自动轮询、容错恢复、动态更新
|
| 25 |
+
- 🛡️ **错误处理** - 完善的异常捕获和重试机制
|
| 26 |
|
| 27 |
## 🚀 快速开始
|
| 28 |
|
| 29 |
### 环境要求
|
| 30 |
|
| 31 |
+
- Python 3.9-3.12
|
| 32 |
- pip 或 uv (推荐)
|
| 33 |
|
| 34 |
### 安装运行
|
|
|
|
| 48 |
python main.py
|
| 49 |
```
|
| 50 |
|
| 51 |
+
> 服务启动后访问接口文档:http://localhost:8080/docs
|
| 52 |
+
> 💡 **提示**:默认端口为 8080,可通过环境变量 `LISTEN_PORT` 修改
|
| 53 |
+
> ⚠️ **注意**:请勿将 `AUTH_TOKEN` 泄露给其他人,请使用 `AUTH_TOKENS` 配置多个认证令牌
|
| 54 |
|
| 55 |
### 基础使用
|
| 56 |
|
|
|
|
| 151 |
| `API_ENDPOINT` | `https://chat.z.ai/api/chat/completions` | 上游 API 地址 |
|
| 152 |
| `LISTEN_PORT` | `8080` | 服务监听端口 |
|
| 153 |
| `DEBUG_LOGGING` | `true` | 调试日志开关 |
|
| 154 |
+
| `ANONYMOUS_MODE` | `true` | 匿名用户模式开关 |
|
|
|
|
| 155 |
| `TOOL_SUPPORT` | `true` | Function Call 功能开关 |
|
| 156 |
| `SKIP_AUTH_TOKEN` | `false` | 跳过认证令牌验证 |
|
| 157 |
| `SCAN_LIMIT` | `200000` | 扫描限制 |
|
| 158 |
+
| `AUTH_TOKENS_FILE` | `tokens.txt` | 认证token文件路径 |
|
| 159 |
+
|
| 160 |
+
> 💡 详细配置请查看 `.env.example` 文件
|
| 161 |
+
|
| 162 |
+
## 🔄 Token池机制
|
| 163 |
+
|
| 164 |
+
### 功能特性
|
| 165 |
+
|
| 166 |
+
- **负载均衡**:轮询使用多个auth token,分散请求负载
|
| 167 |
+
- **自动容错**:token失败时自动切换到下一个可用token
|
| 168 |
+
- **健康监控**:基于Z.AI API的role字段精确验证token类型
|
| 169 |
+
- **自动恢复**:失败token在超时后自动重新尝试
|
| 170 |
+
- **动态管理**:支持运行时更新token池
|
| 171 |
+
- **智能去重**:自动检测和去除重复token
|
| 172 |
+
- **类型验证**:只接受认证用户token (role: "user"),拒绝匿名token (role: "guest")
|
| 173 |
+
|
| 174 |
+
### Token配置方式
|
| 175 |
|
| 176 |
+
创建 `tokens.txt` 文件,支持两种格式:
|
| 177 |
+
|
| 178 |
+
**格式1:每行一个token**
|
| 179 |
+
```txt
|
| 180 |
+
# 认证token配置文件
|
| 181 |
+
# 支持注释行(以#开头)和空行
|
| 182 |
+
# 只添加认证用户token (role: "user")
|
| 183 |
+
|
| 184 |
+
eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InVzZXItMTIzIn0.signature1
|
| 185 |
+
eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InVzZXItNDU2In0.signature2
|
| 186 |
+
eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InVzZXItNzg5In0.signature3
|
| 187 |
+
```
|
| 188 |
+
|
| 189 |
+
**格式2:逗号分隔**
|
| 190 |
+
```txt
|
| 191 |
+
# 认证token配置文件
|
| 192 |
+
eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InVzZXItMTIzIn0.signature1,
|
| 193 |
+
eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InVzZXItNDU2In0.signature2,
|
| 194 |
+
eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InVzZXItNzg5In0.signature3
|
| 195 |
+
```
|
| 196 |
|
| 197 |
+
## 监控API
|
| 198 |
+
|
| 199 |
+
```bash
|
| 200 |
+
# 查看token池状态
|
| 201 |
+
curl http://localhost:8080/v1/token-pool/status
|
| 202 |
+
|
| 203 |
+
# 手动健康检查
|
| 204 |
+
curl -X POST http://localhost:8080/v1/token-pool/health-check
|
| 205 |
+
|
| 206 |
+
# 动态更新token池
|
| 207 |
+
curl -X POST http://localhost:8080/v1/token-pool/update \
|
| 208 |
+
-H "Content-Type: application/json" \
|
| 209 |
+
-d '["new_token1", "new_token2"]'
|
| 210 |
+
```
|
| 211 |
+
|
| 212 |
+
详细文档请参考:[Token池功能说明](TOKEN_POOL_README.md)
|
| 213 |
|
| 214 |
## 🎯 使用场景
|
| 215 |
|
|
|
|
| 256 |
**Q: 如何获取 AUTH_TOKEN?**
|
| 257 |
A: `AUTH_TOKEN` 为自己自定义的 api key,在环境变量中配置,需要保证客户端与服务端一致。
|
| 258 |
|
| 259 |
+
**Q: 遇到 "Illegal header value b'Bearer '" 错误怎么办?**
|
| 260 |
+
A: 这通常是因为 Token 获取失败导致的。请检查:
|
| 261 |
+
- 匿名模式是否正确配置(`ANONYMOUS_MODE=true`)
|
| 262 |
+
- Token 文件是否存在且格式正确(`tokens.txt`)
|
| 263 |
+
- 网络连接是否正常,能否访问 Z.AI API
|
| 264 |
+
|
| 265 |
**Q: 如何通过 Claude Code 使用本服务?**
|
| 266 |
|
| 267 |
A: 创建 [zai.js](https://gist.githubusercontent.com/musistudio/b35402d6f9c95c64269c7666b8405348/raw/f108d66fa050f308387938f149a2b14a295d29e9/gistfile1.txt) 这个 ccr 插件放在`./.claude-code-router/plugins`目录下,配置 `./.claude-code-router/config.json` 指向本服务地址,使用 `AUTH_TOKEN` 进行认证。
|
|
|
|
| 346 |
|
| 347 |
要使用完整的多模态功能,需要获取正式的 Z.ai API Token:
|
| 348 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 349 |
1. 打开 [Z.ai 聊天界面](https://chat.z.ai)
|
| 350 |
2. 按 F12 打开开发者工具
|
| 351 |
3. 切换到 "Application" 或 "存储" 标签
|
| 352 |
4. 查看 Local Storage 中的认证 token
|
| 353 |
5. 复制 token 值设置为环境变量
|
| 354 |
|
| 355 |
+
> ❗ **重要提示**: 获取的 token 可能有时效性,多模态模型需要**官方 Z.ai API 非匿名 Token**,匿名 token 不支持多媒体处理
|
|
|
|
| 356 |
|
| 357 |
## 🛠️ 技术栈
|
| 358 |
|
| 359 |
| 组件 | 技术 | 版本 | 说明 |
|
| 360 |
| --------------- | --------------------------------------------------------------------------------- | ------- | ------------------------------------------ |
|
| 361 |
+
| **Web 框架** | [FastAPI](https://fastapi.tiangolo.com/) | 0.116.1 | 高性能异步 Web 框架,支持自动 API 文档生成 |
|
| 362 |
| **ASGI 服务器** | [Granian](https://github.com/emmett-framework/granian) | 2.5.2 | 基于 Rust 的高性能 ASGI 服务器,支持热重载 |
|
| 363 |
+
| **HTTP 客户端** | [HTTPX](https://www.python-httpx.org/) / [Requests](https://requests.readthedocs.io/) | 0.27.0 / 2.32.5 | 异步/同步 HTTP 库,用于上游 API 调用 |
|
| 364 |
| **数据验证** | [Pydantic](https://pydantic.dev/) | 2.11.7 | 类型安全的数据验证与序列化 |
|
| 365 |
| **配置管理** | [Pydantic Settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/) | 2.10.1 | 基于 Pydantic 的配置管理 |
|
| 366 |
+
| **日志系统** | [Loguru](https://loguru.readthedocs.io/) | 0.7.3 | 高性能结构化日志库 |
|
| 367 |
+
| **用户代理** | [Fake UserAgent](https://pypi.org/project/fake-useragent/) | 2.2.0 | 动态用户代理生成 |
|
| 368 |
|
| 369 |
## 🏗️ 技术架构
|
| 370 |
|
|
|
|
| 390 |
|
| 391 |
```
|
| 392 |
z.ai2api_python/
|
| 393 |
+
├── app/ # 主应用模块
|
| 394 |
+
│ ├── core/ # 核心模块
|
| 395 |
+
│ │ ├── config.py # 配置管理(Pydantic Settings)
|
| 396 |
+
│ │ ├── openai.py # OpenAI API 兼容层
|
| 397 |
+
│ │ └── zai_transformer.py # Z.AI 请求/响应转换器
|
| 398 |
+
│ ├── models/ # 数据模型
|
| 399 |
+
│ │ └── schemas.py # Pydantic 数据模型
|
| 400 |
+
│ └── utils/ # 工具模块
|
| 401 |
+
│ ├── logger.py # Loguru 日志系统
|
| 402 |
+
│ ├── reload_config.py # 热重载配置
|
| 403 |
+
│ ├── sse_tool_handler.py # SSE 工具调用处理器
|
| 404 |
+
│ └── token_pool.py # Token 池管理
|
| 405 |
+
├── tests/ # 测试文件
|
| 406 |
+
├── deploy/ # 部署配置
|
| 407 |
+
│ ├── Dockerfile # Docker 镜像构建
|
| 408 |
+
│ └── docker-compose.yml # 容器编排
|
| 409 |
+
├── main.py # FastAPI 应用入口
|
| 410 |
+
├── requirements.txt # 依赖清单
|
| 411 |
+
├── pyproject.toml # 项目配置
|
| 412 |
+
├── tokens.txt.example # Token 配置文件
|
| 413 |
+
└── .env.example # 环境变量示例
|
| 414 |
```
|
| 415 |
|
| 416 |
## 🤝 贡献指南
|
app/core/config.py
CHANGED
|
@@ -2,8 +2,9 @@
|
|
| 2 |
# -*- coding: utf-8 -*-
|
| 3 |
|
| 4 |
import os
|
| 5 |
-
from typing import Dict, Optional
|
| 6 |
from pydantic_settings import BaseSettings
|
|
|
|
| 7 |
|
| 8 |
|
| 9 |
class Settings(BaseSettings):
|
|
@@ -12,10 +13,102 @@ class Settings(BaseSettings):
|
|
| 12 |
# API Configuration
|
| 13 |
API_ENDPOINT: str = os.getenv("API_ENDPOINT", "https://chat.z.ai/api/chat/completions")
|
| 14 |
AUTH_TOKEN: str = os.getenv("AUTH_TOKEN", "sk-your-api-key")
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
# Model Configuration
|
| 21 |
PRIMARY_MODEL: str = os.getenv("PRIMARY_MODEL", "GLM-4.5")
|
|
|
|
| 2 |
# -*- coding: utf-8 -*-
|
| 3 |
|
| 4 |
import os
|
| 5 |
+
from typing import Dict, List, Optional
|
| 6 |
from pydantic_settings import BaseSettings
|
| 7 |
+
from app.utils.logger import logger
|
| 8 |
|
| 9 |
|
| 10 |
class Settings(BaseSettings):
|
|
|
|
| 13 |
# API Configuration
|
| 14 |
API_ENDPOINT: str = os.getenv("API_ENDPOINT", "https://chat.z.ai/api/chat/completions")
|
| 15 |
AUTH_TOKEN: str = os.getenv("AUTH_TOKEN", "sk-your-api-key")
|
| 16 |
+
|
| 17 |
+
# 认证token文件路径
|
| 18 |
+
AUTH_TOKENS_FILE: str = os.getenv("AUTH_TOKENS_FILE", "tokens.txt")
|
| 19 |
+
|
| 20 |
+
# Token池配置
|
| 21 |
+
TOKEN_HEALTH_CHECK_INTERVAL: int = int(os.getenv("TOKEN_HEALTH_CHECK_INTERVAL", "300")) # 5分钟
|
| 22 |
+
TOKEN_FAILURE_THRESHOLD: int = int(os.getenv("TOKEN_FAILURE_THRESHOLD", "3")) # 失败3次后标记为不可用
|
| 23 |
+
TOKEN_RECOVERY_TIMEOUT: int = int(os.getenv("TOKEN_RECOVERY_TIMEOUT", "1800")) # 30分钟后重试失败的token
|
| 24 |
+
|
| 25 |
+
def _load_tokens_from_file(self, file_path: str) -> List[str]:
|
| 26 |
+
"""
|
| 27 |
+
从文件加载token列表
|
| 28 |
+
|
| 29 |
+
支持两种格式:
|
| 30 |
+
1. 每行一个token(原格式)
|
| 31 |
+
2. 逗号分隔的token(新格式)
|
| 32 |
+
|
| 33 |
+
处理规则:
|
| 34 |
+
- 跳过空行和注释行(以#开头)
|
| 35 |
+
- 自动检测并处理逗号分隔格式
|
| 36 |
+
- 去除空格和换行符
|
| 37 |
+
"""
|
| 38 |
+
tokens = []
|
| 39 |
+
try:
|
| 40 |
+
if os.path.exists(file_path):
|
| 41 |
+
with open(file_path, 'r', encoding='utf-8') as f:
|
| 42 |
+
content = f.read().strip()
|
| 43 |
+
|
| 44 |
+
if not content:
|
| 45 |
+
logger.debug(f"📄 Token文件为空: {file_path}")
|
| 46 |
+
return tokens
|
| 47 |
+
|
| 48 |
+
# 检查是否包含逗号分隔格式
|
| 49 |
+
if ',' in content:
|
| 50 |
+
# 逗号分隔格式:将整个文件内容按逗号分割
|
| 51 |
+
logger.debug(f"📄 检测到逗号分隔格式: {file_path}")
|
| 52 |
+
|
| 53 |
+
# 移除注释行后再分割
|
| 54 |
+
lines = content.split('\n')
|
| 55 |
+
clean_content = []
|
| 56 |
+
for line in lines:
|
| 57 |
+
line = line.strip()
|
| 58 |
+
if line and not line.startswith('#'):
|
| 59 |
+
clean_content.append(line)
|
| 60 |
+
|
| 61 |
+
# 合并所有非注释内容,然后按逗号分割
|
| 62 |
+
merged_content = ' '.join(clean_content)
|
| 63 |
+
raw_tokens = merged_content.split(',')
|
| 64 |
+
|
| 65 |
+
for token in raw_tokens:
|
| 66 |
+
token = token.strip()
|
| 67 |
+
if token: # 跳过空token
|
| 68 |
+
tokens.append(token)
|
| 69 |
+
else:
|
| 70 |
+
# 每行一个token格式(原格式)
|
| 71 |
+
logger.debug(f"📄 使用每行一个token格式: {file_path}")
|
| 72 |
+
for line in content.split('\n'):
|
| 73 |
+
line = line.strip()
|
| 74 |
+
# 跳过空行和注释行
|
| 75 |
+
if line and not line.startswith('#'):
|
| 76 |
+
tokens.append(line)
|
| 77 |
+
|
| 78 |
+
logger.info(f"📄 从文件加载了 {len(tokens)} 个token: {file_path}")
|
| 79 |
+
else:
|
| 80 |
+
logger.debug(f"📄 Token文件不存在: {file_path}")
|
| 81 |
+
except Exception as e:
|
| 82 |
+
logger.error(f"❌ 读取token文件失败 {file_path}: {e}")
|
| 83 |
+
return tokens
|
| 84 |
+
|
| 85 |
+
@property
|
| 86 |
+
def auth_token_list(self) -> List[str]:
|
| 87 |
+
"""
|
| 88 |
+
解析认证token列表
|
| 89 |
+
|
| 90 |
+
仅从AUTH_TOKENS_FILE指定的文件加载token
|
| 91 |
+
"""
|
| 92 |
+
# 从文件加载token
|
| 93 |
+
tokens = self._load_tokens_from_file(self.AUTH_TOKENS_FILE)
|
| 94 |
+
|
| 95 |
+
# 去重,保持顺序
|
| 96 |
+
if tokens:
|
| 97 |
+
seen = set()
|
| 98 |
+
unique_tokens = []
|
| 99 |
+
for token in tokens:
|
| 100 |
+
if token not in seen:
|
| 101 |
+
unique_tokens.append(token)
|
| 102 |
+
seen.add(token)
|
| 103 |
+
|
| 104 |
+
# 记录去重信息
|
| 105 |
+
duplicate_count = len(tokens) - len(unique_tokens)
|
| 106 |
+
if duplicate_count > 0:
|
| 107 |
+
logger.warning(f"⚠️ 检测到 {duplicate_count} 个重复token,已自动去重")
|
| 108 |
+
|
| 109 |
+
return unique_tokens
|
| 110 |
+
|
| 111 |
+
return []
|
| 112 |
|
| 113 |
# Model Configuration
|
| 114 |
PRIMARY_MODEL: str = os.getenv("PRIMARY_MODEL", "GLM-4.5")
|
app/core/openai.py
CHANGED
|
@@ -15,6 +15,7 @@ from app.models.schemas import OpenAIRequest, Message, ModelsResponse, Model
|
|
| 15 |
from app.utils.logger import get_logger
|
| 16 |
from app.core.zai_transformer import ZAITransformer, generate_uuid
|
| 17 |
from app.utils.sse_tool_handler import SSEToolHandler
|
|
|
|
| 18 |
|
| 19 |
logger = get_logger()
|
| 20 |
|
|
@@ -90,6 +91,7 @@ async def chat_completions(request: OpenAIRequest, authorization: str = Header(.
|
|
| 90 |
"""流式响应生成器(包含重试机制)"""
|
| 91 |
retry_count = 0
|
| 92 |
last_error = None
|
|
|
|
| 93 |
|
| 94 |
while retry_count <= settings.MAX_RETRIES:
|
| 95 |
try:
|
|
@@ -102,12 +104,19 @@ async def chat_completions(request: OpenAIRequest, authorization: str = Header(.
|
|
| 102 |
)
|
| 103 |
await asyncio.sleep(delay)
|
| 104 |
|
| 105 |
-
#
|
| 106 |
-
if settings.ANONYMOUS_MODE:
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
|
| 112 |
async with httpx.AsyncClient(timeout=60.0) as client:
|
| 113 |
# 发送请求到上游
|
|
@@ -173,6 +182,10 @@ async def chat_completions(request: OpenAIRequest, authorization: str = Header(.
|
|
| 173 |
if retry_count > 0:
|
| 174 |
logger.info(f"✨ 第 {retry_count} 次重试成功")
|
| 175 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
# 初始化工具处理器(如果需要)
|
| 177 |
has_tools = transformed["body"].get("tools") is not None
|
| 178 |
tool_handler = None
|
|
@@ -443,6 +456,10 @@ async def chat_completions(request: OpenAIRequest, authorization: str = Header(.
|
|
| 443 |
import traceback
|
| 444 |
logger.error(traceback.format_exc())
|
| 445 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 446 |
# 检查是否还可以重试
|
| 447 |
retry_count += 1
|
| 448 |
last_error = str(e)
|
|
@@ -494,3 +511,113 @@ async def chat_completions(request: OpenAIRequest, authorization: str = Header(.
|
|
| 494 |
|
| 495 |
logger.error(f"错误堆栈: {traceback.format_exc()}")
|
| 496 |
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
from app.utils.logger import get_logger
|
| 16 |
from app.core.zai_transformer import ZAITransformer, generate_uuid
|
| 17 |
from app.utils.sse_tool_handler import SSEToolHandler
|
| 18 |
+
from app.utils.token_pool import get_token_pool
|
| 19 |
|
| 20 |
logger = get_logger()
|
| 21 |
|
|
|
|
| 91 |
"""流式响应生成器(包含重试机制)"""
|
| 92 |
retry_count = 0
|
| 93 |
last_error = None
|
| 94 |
+
current_token = transformed.get("token", "") # 获取当前使用的token
|
| 95 |
|
| 96 |
while retry_count <= settings.MAX_RETRIES:
|
| 97 |
try:
|
|
|
|
| 104 |
)
|
| 105 |
await asyncio.sleep(delay)
|
| 106 |
|
| 107 |
+
# 标记前一个token失败(如果不是匿名模式)
|
| 108 |
+
if current_token and not settings.ANONYMOUS_MODE:
|
| 109 |
+
transformer.mark_token_failure(current_token, Exception(f"Retry {retry_count}: {last_error}"))
|
| 110 |
+
|
| 111 |
+
# 重新获取令牌
|
| 112 |
+
logger.info("🔑 重新获取令牌用于重试...")
|
| 113 |
+
new_token = await transformer.get_token()
|
| 114 |
+
if not new_token:
|
| 115 |
+
logger.error("❌ 重试时无法获取有效的认证令牌")
|
| 116 |
+
raise Exception("重试时无法获取有效的认证令牌")
|
| 117 |
+
transformed["config"]["headers"]["Authorization"] = f"Bearer {new_token}"
|
| 118 |
+
current_token = new_token
|
| 119 |
+
logger.debug(f" 新令牌: {new_token[:20] if new_token else 'None'}...")
|
| 120 |
|
| 121 |
async with httpx.AsyncClient(timeout=60.0) as client:
|
| 122 |
# 发送请求到上游
|
|
|
|
| 182 |
if retry_count > 0:
|
| 183 |
logger.info(f"✨ 第 {retry_count} 次重试成功")
|
| 184 |
|
| 185 |
+
# 标记token使用成功(如果不是匿名模式)
|
| 186 |
+
if current_token and not settings.ANONYMOUS_MODE:
|
| 187 |
+
transformer.mark_token_success(current_token)
|
| 188 |
+
|
| 189 |
# 初始化工具处理器(如果需要)
|
| 190 |
has_tools = transformed["body"].get("tools") is not None
|
| 191 |
tool_handler = None
|
|
|
|
| 456 |
import traceback
|
| 457 |
logger.error(traceback.format_exc())
|
| 458 |
|
| 459 |
+
# 标记token失败(如果不是匿名模式)
|
| 460 |
+
if current_token and not settings.ANONYMOUS_MODE:
|
| 461 |
+
transformer.mark_token_failure(current_token, e)
|
| 462 |
+
|
| 463 |
# 检查是否还可以重试
|
| 464 |
retry_count += 1
|
| 465 |
last_error = str(e)
|
|
|
|
| 511 |
|
| 512 |
logger.error(f"错误堆栈: {traceback.format_exc()}")
|
| 513 |
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
| 514 |
+
|
| 515 |
+
|
| 516 |
+
@router.get("/v1/token-pool/status")
|
| 517 |
+
async def get_token_pool_status():
|
| 518 |
+
"""获取token池状态信息"""
|
| 519 |
+
try:
|
| 520 |
+
token_pool = get_token_pool()
|
| 521 |
+
if not token_pool:
|
| 522 |
+
return {
|
| 523 |
+
"status": "disabled",
|
| 524 |
+
"message": "Token池未初始化,当前仅使用匿名模式",
|
| 525 |
+
"anonymous_mode": settings.ANONYMOUS_MODE,
|
| 526 |
+
"auth_tokens_file": settings.AUTH_TOKENS_FILE,
|
| 527 |
+
"auth_tokens_configured": len(settings.auth_token_list) > 0
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
pool_status = token_pool.get_pool_status()
|
| 531 |
+
return {
|
| 532 |
+
"status": "active",
|
| 533 |
+
"pool_info": pool_status,
|
| 534 |
+
"config": {
|
| 535 |
+
"anonymous_mode": settings.ANONYMOUS_MODE,
|
| 536 |
+
"failure_threshold": settings.TOKEN_FAILURE_THRESHOLD,
|
| 537 |
+
"recovery_timeout": settings.TOKEN_RECOVERY_TIMEOUT,
|
| 538 |
+
"health_check_interval": settings.TOKEN_HEALTH_CHECK_INTERVAL
|
| 539 |
+
}
|
| 540 |
+
}
|
| 541 |
+
except Exception as e:
|
| 542 |
+
logger.error(f"获取token池状态失败: {e}")
|
| 543 |
+
raise HTTPException(status_code=500, detail=f"Failed to get token pool status: {str(e)}")
|
| 544 |
+
|
| 545 |
+
|
| 546 |
+
@router.post("/v1/token-pool/health-check")
|
| 547 |
+
async def trigger_health_check():
|
| 548 |
+
"""手动触发token池健康检查"""
|
| 549 |
+
try:
|
| 550 |
+
token_pool = get_token_pool()
|
| 551 |
+
if not token_pool:
|
| 552 |
+
raise HTTPException(status_code=404, detail="Token池未初始化")
|
| 553 |
+
|
| 554 |
+
# 记录开始时间
|
| 555 |
+
import time
|
| 556 |
+
start_time = time.time()
|
| 557 |
+
|
| 558 |
+
logger.info("🔍 API触发Token池健康检查...")
|
| 559 |
+
await token_pool.health_check_all()
|
| 560 |
+
|
| 561 |
+
# 计算耗时
|
| 562 |
+
duration = time.time() - start_time
|
| 563 |
+
|
| 564 |
+
pool_status = token_pool.get_pool_status()
|
| 565 |
+
|
| 566 |
+
# 统计健康检查结果 - 基于实际的健康状态
|
| 567 |
+
total_tokens = pool_status['total_tokens']
|
| 568 |
+
healthy_tokens = sum(1 for token_info in pool_status['tokens'] if token_info['is_healthy'])
|
| 569 |
+
unhealthy_tokens = total_tokens - healthy_tokens
|
| 570 |
+
|
| 571 |
+
# 构建响应
|
| 572 |
+
response = {
|
| 573 |
+
"status": "completed",
|
| 574 |
+
"message": f"健康检查已完成,耗时 {duration:.2f} 秒",
|
| 575 |
+
"summary": {
|
| 576 |
+
"total_tokens": total_tokens,
|
| 577 |
+
"healthy_tokens": healthy_tokens,
|
| 578 |
+
"unhealthy_tokens": unhealthy_tokens,
|
| 579 |
+
"health_rate": f"{(healthy_tokens/total_tokens*100):.1f}%" if total_tokens > 0 else "0%",
|
| 580 |
+
"duration_seconds": round(duration, 2)
|
| 581 |
+
},
|
| 582 |
+
"pool_info": pool_status
|
| 583 |
+
}
|
| 584 |
+
|
| 585 |
+
# 添加建议
|
| 586 |
+
if unhealthy_tokens > 0:
|
| 587 |
+
response["recommendations"] = []
|
| 588 |
+
if unhealthy_tokens == total_tokens:
|
| 589 |
+
response["recommendations"].append("所有token都不健康,请检查token配置和网络连接")
|
| 590 |
+
else:
|
| 591 |
+
response["recommendations"].append(f"有 {unhealthy_tokens} 个token不健康,建议检查这些token的有效性")
|
| 592 |
+
|
| 593 |
+
logger.info(f"✅ API健康检查完成: {healthy_tokens}/{total_tokens} 个token健康")
|
| 594 |
+
return response
|
| 595 |
+
except Exception as e:
|
| 596 |
+
logger.error(f"健康检查失败: {e}")
|
| 597 |
+
raise HTTPException(status_code=500, detail=f"Health check failed: {str(e)}")
|
| 598 |
+
|
| 599 |
+
|
| 600 |
+
@router.post("/v1/token-pool/update")
|
| 601 |
+
async def update_token_pool(tokens: List[str]):
|
| 602 |
+
"""动态更新token池"""
|
| 603 |
+
try:
|
| 604 |
+
from app.utils.token_pool import update_token_pool
|
| 605 |
+
|
| 606 |
+
# 过滤空token
|
| 607 |
+
valid_tokens = [token.strip() for token in tokens if token.strip()]
|
| 608 |
+
if not valid_tokens:
|
| 609 |
+
raise HTTPException(status_code=400, detail="至少需要提供一个有效的token")
|
| 610 |
+
|
| 611 |
+
update_token_pool(valid_tokens)
|
| 612 |
+
|
| 613 |
+
token_pool = get_token_pool()
|
| 614 |
+
pool_status = token_pool.get_pool_status() if token_pool else None
|
| 615 |
+
|
| 616 |
+
return {
|
| 617 |
+
"status": "updated",
|
| 618 |
+
"message": f"Token池已更新,共 {len(valid_tokens)} 个token",
|
| 619 |
+
"pool_info": pool_status
|
| 620 |
+
}
|
| 621 |
+
except Exception as e:
|
| 622 |
+
logger.error(f"更新token池失败: {e}")
|
| 623 |
+
raise HTTPException(status_code=500, detail=f"Failed to update token pool: {str(e)}")
|
app/core/zai_transformer.py
CHANGED
|
@@ -14,6 +14,7 @@ from fake_useragent import UserAgent
|
|
| 14 |
|
| 15 |
from app.core.config import settings
|
| 16 |
from app.utils.logger import get_logger
|
|
|
|
| 17 |
|
| 18 |
logger = get_logger()
|
| 19 |
|
|
@@ -116,9 +117,17 @@ def get_auth_token_sync() -> str:
|
|
| 116 |
except Exception as e:
|
| 117 |
logger.warning(f"获取访客令牌失败: {e}")
|
| 118 |
|
| 119 |
-
#
|
| 120 |
-
|
| 121 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
|
| 123 |
|
| 124 |
class ZAITransformer:
|
|
@@ -156,9 +165,29 @@ class ZAITransformer:
|
|
| 156 |
except Exception as e:
|
| 157 |
logger.warning(f"异步获取访客令牌失败: {e}")
|
| 158 |
|
| 159 |
-
#
|
| 160 |
-
|
| 161 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
|
| 163 |
async def transform_request_in(self, request: Dict[str, Any]) -> Dict[str, Any]:
|
| 164 |
"""
|
|
@@ -171,6 +200,11 @@ class ZAITransformer:
|
|
| 171 |
token = await self.get_token()
|
| 172 |
logger.debug(f" 使用令牌: {token[:20] if token else 'None'}...")
|
| 173 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
# 确定请求的模型特性
|
| 175 |
requested_model = request.get("model", settings.PRIMARY_MODEL)
|
| 176 |
is_thinking = requested_model == settings.THINKING_MODEL or request.get("reasoning", False)
|
|
@@ -308,8 +342,8 @@ class ZAITransformer:
|
|
| 308 |
logger.debug(f" 目标URL: {config['url']}")
|
| 309 |
logger.debug(f" 请求头数量: {len(config['headers'])}")
|
| 310 |
logger.debug(f" 消息数: {len(body['messages'])}, 工具数: {len(body.get('tools', [])) if body.get('tools') else 0}")
|
| 311 |
-
|
| 312 |
-
return {"body": body, "config": config}
|
| 313 |
|
| 314 |
async def transform_response_out(
|
| 315 |
self, response_stream: Generator, context: Dict[str, Any]
|
|
|
|
| 14 |
|
| 15 |
from app.core.config import settings
|
| 16 |
from app.utils.logger import get_logger
|
| 17 |
+
from app.utils.token_pool import get_token_pool, initialize_token_pool
|
| 18 |
|
| 19 |
logger = get_logger()
|
| 20 |
|
|
|
|
| 117 |
except Exception as e:
|
| 118 |
logger.warning(f"获取访客令牌失败: {e}")
|
| 119 |
|
| 120 |
+
# 使用token池获取备份令牌
|
| 121 |
+
token_pool = get_token_pool()
|
| 122 |
+
if token_pool:
|
| 123 |
+
token = token_pool.get_next_token()
|
| 124 |
+
if token:
|
| 125 |
+
logger.debug(f"从token池获取令牌: {token[:20]}...")
|
| 126 |
+
return token
|
| 127 |
+
|
| 128 |
+
# 没有可用的token
|
| 129 |
+
logger.warning("⚠️ 没有可用的备份token")
|
| 130 |
+
return ""
|
| 131 |
|
| 132 |
|
| 133 |
class ZAITransformer:
|
|
|
|
| 165 |
except Exception as e:
|
| 166 |
logger.warning(f"异步获取访客令牌失败: {e}")
|
| 167 |
|
| 168 |
+
# 使用token池获取备份令牌
|
| 169 |
+
token_pool = get_token_pool()
|
| 170 |
+
if token_pool:
|
| 171 |
+
token = token_pool.get_next_token()
|
| 172 |
+
if token:
|
| 173 |
+
logger.debug(f"从token池获取令牌: {token[:20]}...")
|
| 174 |
+
return token
|
| 175 |
+
|
| 176 |
+
# 没有可用的token
|
| 177 |
+
logger.warning("⚠️ 没有可用的备份token")
|
| 178 |
+
return ""
|
| 179 |
+
|
| 180 |
+
def mark_token_success(self, token: str):
|
| 181 |
+
"""标记token使用成功"""
|
| 182 |
+
token_pool = get_token_pool()
|
| 183 |
+
if token_pool:
|
| 184 |
+
token_pool.mark_token_success(token)
|
| 185 |
+
|
| 186 |
+
def mark_token_failure(self, token: str, error: Exception = None):
|
| 187 |
+
"""标记token使用失败"""
|
| 188 |
+
token_pool = get_token_pool()
|
| 189 |
+
if token_pool:
|
| 190 |
+
token_pool.mark_token_failure(token, error)
|
| 191 |
|
| 192 |
async def transform_request_in(self, request: Dict[str, Any]) -> Dict[str, Any]:
|
| 193 |
"""
|
|
|
|
| 200 |
token = await self.get_token()
|
| 201 |
logger.debug(f" 使用令牌: {token[:20] if token else 'None'}...")
|
| 202 |
|
| 203 |
+
# 检查token是否有效
|
| 204 |
+
if not token:
|
| 205 |
+
logger.error("❌ 无法获取有效的认证令牌")
|
| 206 |
+
raise Exception("无法获取有效的认证令牌,请检查匿名模式配置或token池配置")
|
| 207 |
+
|
| 208 |
# 确定请求的模型特性
|
| 209 |
requested_model = request.get("model", settings.PRIMARY_MODEL)
|
| 210 |
is_thinking = requested_model == settings.THINKING_MODEL or request.get("reasoning", False)
|
|
|
|
| 342 |
logger.debug(f" 目标URL: {config['url']}")
|
| 343 |
logger.debug(f" 请求头数量: {len(config['headers'])}")
|
| 344 |
logger.debug(f" 消息数: {len(body['messages'])}, 工具数: {len(body.get('tools', [])) if body.get('tools') else 0}")
|
| 345 |
+
|
| 346 |
+
return {"body": body, "config": config, "token": token}
|
| 347 |
|
| 348 |
async def transform_response_out(
|
| 349 |
self, response_stream: Generator, context: Dict[str, Any]
|
app/utils/token_pool.py
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
|
| 4 |
+
"""
|
| 5 |
+
Token池管理器
|
| 6 |
+
实现AUTH_TOKEN的轮询机制,提供负载均衡和容错功能
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import asyncio
|
| 10 |
+
import time
|
| 11 |
+
from typing import Dict, List, Optional, Tuple
|
| 12 |
+
from dataclasses import dataclass, field
|
| 13 |
+
from threading import Lock
|
| 14 |
+
import httpx
|
| 15 |
+
import requests
|
| 16 |
+
|
| 17 |
+
from app.utils.logger import logger
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@dataclass
|
| 21 |
+
class TokenStatus:
|
| 22 |
+
"""Token状态信息"""
|
| 23 |
+
token: str
|
| 24 |
+
is_available: bool = True
|
| 25 |
+
failure_count: int = 0
|
| 26 |
+
last_failure_time: float = 0.0
|
| 27 |
+
last_success_time: float = 0.0
|
| 28 |
+
total_requests: int = 0
|
| 29 |
+
successful_requests: int = 0
|
| 30 |
+
token_type: str = "unknown" # "user", "guest", "unknown"
|
| 31 |
+
|
| 32 |
+
@property
|
| 33 |
+
def success_rate(self) -> float:
|
| 34 |
+
"""成功率"""
|
| 35 |
+
if self.total_requests == 0:
|
| 36 |
+
return 1.0
|
| 37 |
+
return self.successful_requests / self.total_requests
|
| 38 |
+
|
| 39 |
+
@property
|
| 40 |
+
def is_healthy(self) -> bool:
|
| 41 |
+
"""
|
| 42 |
+
是否健康
|
| 43 |
+
|
| 44 |
+
健康的定义:
|
| 45 |
+
1. 必须是认证用户token (token_type = "user")
|
| 46 |
+
2. 当前可用 (is_available = True)
|
| 47 |
+
3. 成功率 >= 50% 或者总请求数 <= 3(新token容错)
|
| 48 |
+
|
| 49 |
+
注意:guest token不应该在AUTH_TOKENS中
|
| 50 |
+
"""
|
| 51 |
+
# guest token永远不健康
|
| 52 |
+
if self.token_type == "guest":
|
| 53 |
+
return False
|
| 54 |
+
|
| 55 |
+
# 未知类型token不健康
|
| 56 |
+
if self.token_type != "user":
|
| 57 |
+
return False
|
| 58 |
+
|
| 59 |
+
# 不可用的token不健康
|
| 60 |
+
if not self.is_available:
|
| 61 |
+
return False
|
| 62 |
+
|
| 63 |
+
# 对于认证用户token,基于成功率判断
|
| 64 |
+
# 新token或请求数很少时,给予容错
|
| 65 |
+
if self.total_requests <= 3:
|
| 66 |
+
return self.failure_count == 0
|
| 67 |
+
|
| 68 |
+
# 基于成功率判断健康状态
|
| 69 |
+
return self.success_rate >= 0.5
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class TokenPool:
|
| 73 |
+
"""Token池管理器"""
|
| 74 |
+
|
| 75 |
+
def __init__(self, tokens: List[str], failure_threshold: int = 3, recovery_timeout: int = 1800):
|
| 76 |
+
"""
|
| 77 |
+
初始化Token池
|
| 78 |
+
|
| 79 |
+
Args:
|
| 80 |
+
tokens: token列表
|
| 81 |
+
failure_threshold: 失败阈值,超过此次数将标记为不可用
|
| 82 |
+
recovery_timeout: 恢复超时时间(秒),失败token在此时间后重新尝试
|
| 83 |
+
"""
|
| 84 |
+
self.failure_threshold = failure_threshold
|
| 85 |
+
self.recovery_timeout = recovery_timeout
|
| 86 |
+
self._lock = Lock()
|
| 87 |
+
self._current_index = 0
|
| 88 |
+
|
| 89 |
+
# 初始化token状态
|
| 90 |
+
self.token_statuses: Dict[str, TokenStatus] = {}
|
| 91 |
+
original_count = len(tokens)
|
| 92 |
+
unique_tokens = []
|
| 93 |
+
|
| 94 |
+
# 去重处理
|
| 95 |
+
for token in tokens:
|
| 96 |
+
if token and token not in self.token_statuses: # 过滤空token和重复token
|
| 97 |
+
self.token_statuses[token] = TokenStatus(token=token)
|
| 98 |
+
unique_tokens.append(token)
|
| 99 |
+
|
| 100 |
+
duplicate_count = original_count - len(unique_tokens)
|
| 101 |
+
if duplicate_count > 0:
|
| 102 |
+
logger.warning(f"⚠️ 检测到 {duplicate_count} 个重复token,已自动去重")
|
| 103 |
+
|
| 104 |
+
if not self.token_statuses:
|
| 105 |
+
logger.warning("⚠️ Token池为空,将依赖匿名模式")
|
| 106 |
+
else:
|
| 107 |
+
logger.info(f"🔧 初始化Token池,共 {len(self.token_statuses)} 个token")
|
| 108 |
+
|
| 109 |
+
def get_next_token(self) -> Optional[str]:
|
| 110 |
+
"""
|
| 111 |
+
获取下一个可用的token(轮询算法)
|
| 112 |
+
|
| 113 |
+
Returns:
|
| 114 |
+
可用的token,如果没有可用token则返回None
|
| 115 |
+
"""
|
| 116 |
+
with self._lock:
|
| 117 |
+
if not self.token_statuses:
|
| 118 |
+
return None
|
| 119 |
+
|
| 120 |
+
available_tokens = self._get_available_tokens()
|
| 121 |
+
if not available_tokens:
|
| 122 |
+
# 尝试恢复过期的失败token
|
| 123 |
+
self._try_recover_failed_tokens()
|
| 124 |
+
available_tokens = self._get_available_tokens()
|
| 125 |
+
|
| 126 |
+
if not available_tokens:
|
| 127 |
+
logger.warning("⚠️ 没有可用的token")
|
| 128 |
+
return None
|
| 129 |
+
|
| 130 |
+
# 轮询选择token
|
| 131 |
+
token = available_tokens[self._current_index % len(available_tokens)]
|
| 132 |
+
self._current_index = (self._current_index + 1) % len(available_tokens)
|
| 133 |
+
|
| 134 |
+
return token
|
| 135 |
+
|
| 136 |
+
def _get_available_tokens(self) -> List[str]:
|
| 137 |
+
"""
|
| 138 |
+
获取当前可用的认证用户token列表
|
| 139 |
+
|
| 140 |
+
只返回满足以下条件的token:
|
| 141 |
+
1. is_available = True (可用状态)
|
| 142 |
+
2. token_type = "user" (认证用户token)
|
| 143 |
+
|
| 144 |
+
这确保轮询机制只会选择有效的认证用户token,跳过匿名用户token
|
| 145 |
+
"""
|
| 146 |
+
available_user_tokens = [
|
| 147 |
+
status.token for status in self.token_statuses.values()
|
| 148 |
+
if status.is_available and status.token_type == "user"
|
| 149 |
+
]
|
| 150 |
+
|
| 151 |
+
# 如果没有可用的认证用户token
|
| 152 |
+
if not available_user_tokens and self.token_statuses:
|
| 153 |
+
guest_tokens = [
|
| 154 |
+
status.token for status in self.token_statuses.values()
|
| 155 |
+
if status.token_type == "guest"
|
| 156 |
+
]
|
| 157 |
+
if guest_tokens:
|
| 158 |
+
logger.warning(f"⚠️ 检测到 {len(guest_tokens)} 个匿名用户token,轮询机制将跳过这些token")
|
| 159 |
+
|
| 160 |
+
return available_user_tokens
|
| 161 |
+
|
| 162 |
+
def _try_recover_failed_tokens(self):
|
| 163 |
+
"""尝试恢复失败的token"""
|
| 164 |
+
current_time = time.time()
|
| 165 |
+
recovered_count = 0
|
| 166 |
+
|
| 167 |
+
for status in self.token_statuses.values():
|
| 168 |
+
if (not status.is_available and
|
| 169 |
+
current_time - status.last_failure_time > self.recovery_timeout):
|
| 170 |
+
status.is_available = True
|
| 171 |
+
status.failure_count = 0
|
| 172 |
+
recovered_count += 1
|
| 173 |
+
logger.info(f"🔄 恢复失败token: {status.token[:20]}...")
|
| 174 |
+
|
| 175 |
+
if recovered_count > 0:
|
| 176 |
+
logger.info(f"✅ 恢复了 {recovered_count} 个失败的token")
|
| 177 |
+
|
| 178 |
+
def mark_token_success(self, token: str):
|
| 179 |
+
"""标记token使用成功"""
|
| 180 |
+
with self._lock:
|
| 181 |
+
if token in self.token_statuses:
|
| 182 |
+
status = self.token_statuses[token]
|
| 183 |
+
status.total_requests += 1
|
| 184 |
+
status.successful_requests += 1
|
| 185 |
+
status.last_success_time = time.time()
|
| 186 |
+
status.failure_count = 0 # 重置失败计数
|
| 187 |
+
|
| 188 |
+
if not status.is_available:
|
| 189 |
+
status.is_available = True
|
| 190 |
+
logger.info(f"✅ Token恢复可用: {token[:20]}...")
|
| 191 |
+
|
| 192 |
+
def mark_token_failure(self, token: str, error: Exception = None):
|
| 193 |
+
"""标记token使用失败"""
|
| 194 |
+
with self._lock:
|
| 195 |
+
if token in self.token_statuses:
|
| 196 |
+
status = self.token_statuses[token]
|
| 197 |
+
status.total_requests += 1
|
| 198 |
+
status.failure_count += 1
|
| 199 |
+
status.last_failure_time = time.time()
|
| 200 |
+
|
| 201 |
+
if status.failure_count >= self.failure_threshold:
|
| 202 |
+
status.is_available = False
|
| 203 |
+
logger.warning(f"🚫 Token已禁用: {token[:20]}... (失败 {status.failure_count} 次)")
|
| 204 |
+
|
| 205 |
+
def get_pool_status(self) -> Dict:
|
| 206 |
+
"""获取token池状态信息"""
|
| 207 |
+
with self._lock:
|
| 208 |
+
available_count = len(self._get_available_tokens())
|
| 209 |
+
total_count = len(self.token_statuses)
|
| 210 |
+
|
| 211 |
+
# 统计健康token数量
|
| 212 |
+
healthy_count = sum(1 for status in self.token_statuses.values() if status.is_healthy)
|
| 213 |
+
|
| 214 |
+
status_info = {
|
| 215 |
+
"total_tokens": total_count,
|
| 216 |
+
"available_tokens": available_count,
|
| 217 |
+
"unavailable_tokens": total_count - available_count,
|
| 218 |
+
"healthy_tokens": healthy_count,
|
| 219 |
+
"unhealthy_tokens": total_count - healthy_count,
|
| 220 |
+
"current_index": self._current_index,
|
| 221 |
+
"tokens": []
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
for token, status in self.token_statuses.items():
|
| 225 |
+
status_info["tokens"].append({
|
| 226 |
+
"token": f"{token[:10]}...{token[-10:]}",
|
| 227 |
+
"token_type": status.token_type,
|
| 228 |
+
"is_available": status.is_available,
|
| 229 |
+
"failure_count": status.failure_count,
|
| 230 |
+
"success_count": status.successful_requests,
|
| 231 |
+
"success_rate": f"{status.success_rate:.2%}",
|
| 232 |
+
"total_requests": status.total_requests,
|
| 233 |
+
"is_healthy": status.is_healthy,
|
| 234 |
+
"last_failure_time": status.last_failure_time,
|
| 235 |
+
"last_success_time": status.last_success_time
|
| 236 |
+
})
|
| 237 |
+
|
| 238 |
+
return status_info
|
| 239 |
+
|
| 240 |
+
def update_tokens(self, new_tokens: List[str]):
|
| 241 |
+
"""动态更新token列表"""
|
| 242 |
+
with self._lock:
|
| 243 |
+
# 保留现有token的状态信息
|
| 244 |
+
old_statuses = self.token_statuses.copy()
|
| 245 |
+
self.token_statuses.clear()
|
| 246 |
+
|
| 247 |
+
original_count = len(new_tokens)
|
| 248 |
+
unique_tokens = []
|
| 249 |
+
|
| 250 |
+
# 去重并添加新token,保留已存在token的状态
|
| 251 |
+
for token in new_tokens:
|
| 252 |
+
if token and token not in self.token_statuses: # 过滤空token和重复token
|
| 253 |
+
if token in old_statuses:
|
| 254 |
+
self.token_statuses[token] = old_statuses[token]
|
| 255 |
+
else:
|
| 256 |
+
self.token_statuses[token] = TokenStatus(token=token)
|
| 257 |
+
unique_tokens.append(token)
|
| 258 |
+
|
| 259 |
+
# 记录去重信息
|
| 260 |
+
duplicate_count = original_count - len(unique_tokens)
|
| 261 |
+
if duplicate_count > 0:
|
| 262 |
+
logger.warning(f"⚠️ 更新时检测到 {duplicate_count} 个重复token,已自动去重")
|
| 263 |
+
|
| 264 |
+
# 重置索引
|
| 265 |
+
self._current_index = 0
|
| 266 |
+
|
| 267 |
+
logger.info(f"🔄 更新Token池,共 {len(self.token_statuses)} 个token")
|
| 268 |
+
|
| 269 |
+
async def health_check_token(self, token: str, auth_url: str = "https://chat.z.ai/api/v1/auths/") -> bool:
|
| 270 |
+
"""
|
| 271 |
+
异步健康检查单个token
|
| 272 |
+
|
| 273 |
+
使用Z.AI认证API验证token的有效性,通过检查响应内容判断token是否有效
|
| 274 |
+
|
| 275 |
+
Args:
|
| 276 |
+
token: 要检查的token
|
| 277 |
+
auth_url: 认证URL
|
| 278 |
+
|
| 279 |
+
Returns:
|
| 280 |
+
token是否健康
|
| 281 |
+
"""
|
| 282 |
+
try:
|
| 283 |
+
# 构建完整的请求头,模拟真实浏览器请求
|
| 284 |
+
headers = {
|
| 285 |
+
"Accept": "*/*",
|
| 286 |
+
"Accept-Language": "zh-CN,zh;q=0.9",
|
| 287 |
+
"Authorization": f"Bearer {token}",
|
| 288 |
+
"Connection": "keep-alive",
|
| 289 |
+
"Content-Type": "application/json",
|
| 290 |
+
"DNT": "1",
|
| 291 |
+
"Referer": "https://chat.z.ai/",
|
| 292 |
+
"Sec-Fetch-Dest": "empty",
|
| 293 |
+
"Sec-Fetch-Mode": "cors",
|
| 294 |
+
"Sec-Fetch-Site": "same-origin",
|
| 295 |
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36",
|
| 296 |
+
"sec-ch-ua": '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"',
|
| 297 |
+
"sec-ch-ua-mobile": "?0",
|
| 298 |
+
"sec-ch-ua-platform": "Windows"
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
async with httpx.AsyncClient(timeout=15.0) as client:
|
| 302 |
+
response = await client.get(auth_url, headers=headers)
|
| 303 |
+
|
| 304 |
+
# 验证token有效性并获取类型
|
| 305 |
+
token_type, is_healthy = self._validate_token_response(response)
|
| 306 |
+
|
| 307 |
+
# 更新token类型
|
| 308 |
+
if token in self.token_statuses:
|
| 309 |
+
self.token_statuses[token].token_type = token_type
|
| 310 |
+
|
| 311 |
+
if is_healthy:
|
| 312 |
+
self.mark_token_success(token)
|
| 313 |
+
else:
|
| 314 |
+
# 简化错误信息,只记录关键错误类型
|
| 315 |
+
if token_type == "guest":
|
| 316 |
+
error_msg = "匿名用户token"
|
| 317 |
+
elif response.status_code != 200:
|
| 318 |
+
error_msg = f"HTTP {response.status_code}"
|
| 319 |
+
else:
|
| 320 |
+
error_msg = "认证失败"
|
| 321 |
+
|
| 322 |
+
self.mark_token_failure(token, Exception(error_msg))
|
| 323 |
+
|
| 324 |
+
return is_healthy
|
| 325 |
+
|
| 326 |
+
except (httpx.TimeoutException, httpx.ConnectError, Exception) as e:
|
| 327 |
+
self.mark_token_failure(token, e)
|
| 328 |
+
return False
|
| 329 |
+
|
| 330 |
+
def _validate_token_response(self, response: httpx.Response) -> bool:
|
| 331 |
+
"""
|
| 332 |
+
基于Z.AI API响应中的role字段验证token类型
|
| 333 |
+
|
| 334 |
+
验证规则:
|
| 335 |
+
- role: "user" = 认证用户token(有效,可用于AUTH_TOKENS)
|
| 336 |
+
- role: "guest" = 匿名用户token(无效,不应在AUTH_TOKENS中)
|
| 337 |
+
- 无role字段或其他值 = 无效token
|
| 338 |
+
|
| 339 |
+
Args:
|
| 340 |
+
response: HTTP响应对象
|
| 341 |
+
|
| 342 |
+
Returns:
|
| 343 |
+
token是否为有效的认证用户token
|
| 344 |
+
"""
|
| 345 |
+
# 首先检查HTTP状态码
|
| 346 |
+
if response.status_code != 200:
|
| 347 |
+
return ("unknown", False)
|
| 348 |
+
|
| 349 |
+
try:
|
| 350 |
+
# 尝试解析JSON响应
|
| 351 |
+
response_data = response.json()
|
| 352 |
+
|
| 353 |
+
if not isinstance(response_data, dict):
|
| 354 |
+
return ("unknown", False)
|
| 355 |
+
|
| 356 |
+
# 检查是否包含错误信息
|
| 357 |
+
if "error" in response_data:
|
| 358 |
+
return ("unknown", False)
|
| 359 |
+
|
| 360 |
+
if "message" in response_data and "error" in response_data.get("message", "").lower():
|
| 361 |
+
return ("unknown", False)
|
| 362 |
+
|
| 363 |
+
# 核心验证:检查role字段
|
| 364 |
+
role = response_data.get("role")
|
| 365 |
+
|
| 366 |
+
if role == "user":
|
| 367 |
+
return ("user", True)
|
| 368 |
+
elif role == "guest":
|
| 369 |
+
|
| 370 |
+
if not hasattr(self, '_guest_token_warned'):
|
| 371 |
+
logger.warning("⚠️ 检测到匿名用户token,建议仅在AUTH_TOKENS中配置认证用户token")
|
| 372 |
+
self._guest_token_warned = True
|
| 373 |
+
return ("guest", False)
|
| 374 |
+
else:
|
| 375 |
+
return ("unknown", False)
|
| 376 |
+
|
| 377 |
+
except (ValueError, Exception):
|
| 378 |
+
return ("unknown", False)
|
| 379 |
+
|
| 380 |
+
async def health_check_all(self, auth_url: str = "https://chat.z.ai/api/v1/auths/"):
|
| 381 |
+
"""异步健康检查所有token"""
|
| 382 |
+
if not self.token_statuses:
|
| 383 |
+
logger.warning("⚠️ Token池为空,跳过健康检查")
|
| 384 |
+
return
|
| 385 |
+
|
| 386 |
+
total_tokens = len(self.token_statuses)
|
| 387 |
+
logger.info(f"🔍 开始Token池健康检查... (共 {total_tokens} 个token)")
|
| 388 |
+
|
| 389 |
+
# 并发执行所有token的健康检查
|
| 390 |
+
tasks = []
|
| 391 |
+
token_list = list(self.token_statuses.keys())
|
| 392 |
+
|
| 393 |
+
for token in token_list:
|
| 394 |
+
task = self.health_check_token(token, auth_url)
|
| 395 |
+
tasks.append(task)
|
| 396 |
+
|
| 397 |
+
# 执行并收集结果
|
| 398 |
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
| 399 |
+
|
| 400 |
+
# 统计结果
|
| 401 |
+
healthy_count = 0
|
| 402 |
+
failed_count = 0
|
| 403 |
+
exception_count = 0
|
| 404 |
+
|
| 405 |
+
for i, result in enumerate(results):
|
| 406 |
+
if result is True:
|
| 407 |
+
healthy_count += 1
|
| 408 |
+
elif result is False:
|
| 409 |
+
failed_count += 1
|
| 410 |
+
else:
|
| 411 |
+
# 异常情况
|
| 412 |
+
exception_count += 1
|
| 413 |
+
token = token_list[i]
|
| 414 |
+
logger.error(f"💥 Token {token[:20]}... 健康检查异常: {result}")
|
| 415 |
+
|
| 416 |
+
health_rate = (healthy_count / total_tokens) * 100 if total_tokens > 0 else 0
|
| 417 |
+
|
| 418 |
+
if healthy_count == 0 and total_tokens > 0:
|
| 419 |
+
logger.warning(f"⚠️ 健康检查完成: 0/{total_tokens} 个token健康 - 请检查token配置")
|
| 420 |
+
elif failed_count > 0:
|
| 421 |
+
logger.warning(f"⚠️ 健康检查完成: {healthy_count}/{total_tokens} 个token健康 ({health_rate:.1f}%)")
|
| 422 |
+
else:
|
| 423 |
+
logger.info(f"✅ 健康检查完成: {healthy_count}/{total_tokens} 个token健康")
|
| 424 |
+
|
| 425 |
+
if exception_count > 0:
|
| 426 |
+
logger.error(f"💥 {exception_count} 个token检查异常")
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
# 全局token池实例
|
| 430 |
+
_token_pool: Optional[TokenPool] = None
|
| 431 |
+
_pool_lock = Lock()
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
def get_token_pool() -> Optional[TokenPool]:
|
| 435 |
+
"""获取全局token池实例"""
|
| 436 |
+
return _token_pool
|
| 437 |
+
|
| 438 |
+
|
| 439 |
+
def initialize_token_pool(tokens: List[str], failure_threshold: int = 3, recovery_timeout: int = 1800) -> TokenPool:
|
| 440 |
+
"""初始化全局token池"""
|
| 441 |
+
global _token_pool
|
| 442 |
+
with _pool_lock:
|
| 443 |
+
_token_pool = TokenPool(tokens, failure_threshold, recovery_timeout)
|
| 444 |
+
return _token_pool
|
| 445 |
+
|
| 446 |
+
|
| 447 |
+
def update_token_pool(tokens: List[str]):
|
| 448 |
+
"""更新全局token池"""
|
| 449 |
+
global _token_pool
|
| 450 |
+
with _pool_lock:
|
| 451 |
+
if _token_pool:
|
| 452 |
+
_token_pool.update_tokens(tokens)
|
| 453 |
+
else:
|
| 454 |
+
_token_pool = TokenPool(tokens)
|
deploy/docker-compose.yml
CHANGED
|
@@ -15,8 +15,6 @@ services:
|
|
| 15 |
- SKIP_AUTH_TOKEN=false
|
| 16 |
# Server Configurations
|
| 17 |
- DEBUG_LOGGING=true
|
| 18 |
-
# Feature Configuration
|
| 19 |
-
- THINKING_PROCESSING=think
|
| 20 |
- ANONYMOUS_MODE=true
|
| 21 |
- TOOL_SUPPORT=true
|
| 22 |
- SCAN_LIMIT=200000
|
|
|
|
| 15 |
- SKIP_AUTH_TOKEN=false
|
| 16 |
# Server Configurations
|
| 17 |
- DEBUG_LOGGING=true
|
|
|
|
|
|
|
| 18 |
- ANONYMOUS_MODE=true
|
| 19 |
- TOOL_SUPPORT=true
|
| 20 |
- SCAN_LIMIT=200000
|
main.py
CHANGED
|
@@ -1,13 +1,15 @@
|
|
| 1 |
#!/usr/bin/env python
|
| 2 |
# -*- coding: utf-8 -*-
|
| 3 |
|
| 4 |
-
from
|
|
|
|
| 5 |
from fastapi.middleware.cors import CORSMiddleware
|
| 6 |
|
| 7 |
from app.core.config import settings
|
| 8 |
from app.core import openai
|
| 9 |
from app.utils.reload_config import RELOAD_CONFIG
|
| 10 |
from app.utils.logger import setup_logger
|
|
|
|
| 11 |
|
| 12 |
from granian import Granian
|
| 13 |
|
|
@@ -15,8 +17,24 @@ from granian import Granian
|
|
| 15 |
# Setup logger
|
| 16 |
logger = setup_logger(log_dir="logs", debug_mode=settings.DEBUG_LOGGING)
|
| 17 |
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
# Add CORS middleware
|
| 22 |
app.add_middleware(
|
|
|
|
| 1 |
#!/usr/bin/env python
|
| 2 |
# -*- coding: utf-8 -*-
|
| 3 |
|
| 4 |
+
from contextlib import asynccontextmanager
|
| 5 |
+
from fastapi import FastAPI, Response
|
| 6 |
from fastapi.middleware.cors import CORSMiddleware
|
| 7 |
|
| 8 |
from app.core.config import settings
|
| 9 |
from app.core import openai
|
| 10 |
from app.utils.reload_config import RELOAD_CONFIG
|
| 11 |
from app.utils.logger import setup_logger
|
| 12 |
+
from app.utils.token_pool import initialize_token_pool
|
| 13 |
|
| 14 |
from granian import Granian
|
| 15 |
|
|
|
|
| 17 |
# Setup logger
|
| 18 |
logger = setup_logger(log_dir="logs", debug_mode=settings.DEBUG_LOGGING)
|
| 19 |
|
| 20 |
+
|
| 21 |
+
@asynccontextmanager
|
| 22 |
+
async def lifespan(app: FastAPI):
|
| 23 |
+
token_list = settings.auth_token_list
|
| 24 |
+
if token_list:
|
| 25 |
+
token_pool = initialize_token_pool(
|
| 26 |
+
tokens=token_list,
|
| 27 |
+
failure_threshold=settings.TOKEN_FAILURE_THRESHOLD,
|
| 28 |
+
recovery_timeout=settings.TOKEN_RECOVERY_TIMEOUT
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
yield
|
| 32 |
+
|
| 33 |
+
logger.info("🔄 应用正在关闭...")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# Create FastAPI app with lifespan
|
| 37 |
+
app = FastAPI(lifespan=lifespan)
|
| 38 |
|
| 39 |
# Add CORS middleware
|
| 40 |
app.add_middleware(
|
pyproject.toml
CHANGED
|
@@ -33,6 +33,7 @@ dependencies = [
|
|
| 33 |
"typing-inspection==0.4.1",
|
| 34 |
"fake-useragent==2.2.0",
|
| 35 |
"loguru==0.7.3",
|
|
|
|
| 36 |
]
|
| 37 |
|
| 38 |
[project.scripts]
|
|
|
|
| 33 |
"typing-inspection==0.4.1",
|
| 34 |
"fake-useragent==2.2.0",
|
| 35 |
"loguru==0.7.3",
|
| 36 |
+
"httpx==0.27.0"
|
| 37 |
]
|
| 38 |
|
| 39 |
[project.scripts]
|
requirements.txt
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
fastapi==0.116.1
|
| 2 |
granian[reload]==2.5.2
|
| 3 |
requests==2.32.5
|
|
|
|
| 4 |
pydantic==2.11.7
|
| 5 |
pydantic-settings==2.10.1
|
| 6 |
pydantic-core==2.33.2
|
|
|
|
| 1 |
fastapi==0.116.1
|
| 2 |
granian[reload]==2.5.2
|
| 3 |
requests==2.32.5
|
| 4 |
+
httpx==0.27.0
|
| 5 |
pydantic==2.11.7
|
| 6 |
pydantic-settings==2.10.1
|
| 7 |
pydantic-core==2.33.2
|
tokens.txt.example
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 认证Token配置文件
|
| 2 |
+
#
|
| 3 |
+
# 说明:
|
| 4 |
+
# 1. 支持两种格式:每行一个token 或 逗号分隔的token
|
| 5 |
+
# 2. 只包含认证用户token (role: "user"),不要添加匿名用户token (role: "guest")
|
| 6 |
+
# 3. 系统会自动去重和验证token有效性
|
| 7 |
+
# 4. 修改此文件后无需重启服务,系统会自动重新加载
|
| 8 |
+
# 5. 自动跳过空格、换行符和空token
|
| 9 |
+
#
|
| 10 |
+
# 格式1:每行一个token
|
| 11 |
+
# eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InVzZXItMTIzIn0.signature1
|
| 12 |
+
# eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InVzZXItNDU2In0.signature2
|
| 13 |
+
# eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InVzZXItNzg5In0.signature3
|
| 14 |
+
#
|
| 15 |
+
# 格式2:逗号分隔(推荐,更紧凑)
|
| 16 |
+
# eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InVzZXItMTIzIn0.signature1,
|
| 17 |
+
# eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InVzZXItNDU2In0.signature2,
|
| 18 |
+
# eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InVzZXItNzg5In0.signature3
|
| 19 |
+
|
| 20 |
+
# 请在下方添加您的认证用户token(使用任一格式):
|
| 21 |
+
|