update sth at 2025-10-16 14:55:36
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .env.example +53 -0
- .gitattributes +2 -35
- .github/workflows/docker.yml +64 -0
- .gitignore +180 -0
- Dockerfile +16 -16
- LICENSE +21 -0
- app/__init__.py +6 -0
- app/admin/__init__.py +3 -0
- app/admin/api.py +728 -0
- app/admin/auth.py +129 -0
- app/admin/routes.py +116 -0
- app/core/__init__.py +6 -0
- app/core/config.py +95 -0
- app/core/openai.py +189 -0
- app/models/__init__.py +6 -0
- app/models/request_log.py +31 -0
- app/models/schemas.py +151 -0
- app/models/token_db.py +48 -0
- app/providers/__init__.py +26 -0
- app/providers/base.py +268 -0
- app/providers/k2think_provider.py +509 -0
- app/providers/longcat_provider.py +466 -0
- app/providers/provider_factory.py +208 -0
- app/providers/zai_provider.py +1152 -0
- app/services/request_log_dao.py +267 -0
- app/services/token_dao.py +480 -0
- app/templates/base.html +201 -0
- app/templates/components/provider_status.html +78 -0
- app/templates/components/recent_logs.html +50 -0
- app/templates/components/token_list.html +80 -0
- app/templates/components/token_pool.html +40 -0
- app/templates/components/token_row.html +153 -0
- app/templates/components/token_stats.html +125 -0
- app/templates/config.html +222 -0
- app/templates/index.html +174 -0
- app/templates/login.html +143 -0
- app/templates/monitor.html +83 -0
- app/templates/tokens.html +391 -0
- app/utils/__init__.py +6 -0
- app/utils/logger.py +106 -0
- app/utils/reload_config.py +89 -0
- app/utils/sse_tool_handler.py +612 -0
- app/utils/token_pool.py +598 -0
- app/utils/tool_call_handler.py +347 -0
- app/utils/user_agent.py +133 -0
- deploy/.dockerignore +54 -0
- deploy/.env.example +35 -0
- deploy/Dockerfile +24 -0
- deploy/NGINX_SETUP.md +278 -0
- deploy/README_DOCKER.md +357 -0
.env.example
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 代理服务配置文件示例
|
| 2 |
+
# 复制此文件为 .env 并根据需要修改配置值
|
| 3 |
+
|
| 4 |
+
# ========== API 基础配置 ==========
|
| 5 |
+
# 客户端认证密钥(您自定义的 API 密钥,用于客户端访问本服务)
|
| 6 |
+
AUTH_TOKEN=sk-your-api-key
|
| 7 |
+
|
| 8 |
+
# 跳过客户端认证(仅开发环境使用)
|
| 9 |
+
SKIP_AUTH_TOKEN=false
|
| 10 |
+
|
| 11 |
+
# ========== Z.ai Token池配置 ==========
|
| 12 |
+
# Token失败阈值(失败多少次后标记为不可用)
|
| 13 |
+
TOKEN_FAILURE_THRESHOLD=3
|
| 14 |
+
|
| 15 |
+
# Token恢复超时时间(秒,失败token在此时间后重新尝试)
|
| 16 |
+
TOKEN_RECOVERY_TIMEOUT=1800
|
| 17 |
+
|
| 18 |
+
# Z.AI 匿名用户模式
|
| 19 |
+
# false: 使用认证 Token 令牌,失败时自动降级为匿名请求
|
| 20 |
+
# true: 自动从 Z.ai 获取临时访问令牌,避免对话历史共享
|
| 21 |
+
ANONYMOUS_MODE=true
|
| 22 |
+
|
| 23 |
+
# ========== LongCat 配置 ==========
|
| 24 |
+
# LongCat token(单个token)
|
| 25 |
+
# LONGCAT_TOKEN=your_passport_token_here
|
| 26 |
+
|
| 27 |
+
# ========== 服务器配置 ==========
|
| 28 |
+
# 服务监听端口
|
| 29 |
+
LISTEN_PORT=7860
|
| 30 |
+
|
| 31 |
+
# 服务名称
|
| 32 |
+
SERVICE_NAME=z-ai2api-server
|
| 33 |
+
|
| 34 |
+
# 调试日志
|
| 35 |
+
DEBUG_LOGGING=false
|
| 36 |
+
|
| 37 |
+
# Nginx 反向代理路径前缀(可选,用于在子路径下部署)
|
| 38 |
+
# 例如:ROOT_PATH=/ai2api 则服务部署在 http://domain.com/ai2api
|
| 39 |
+
# 留空表示部署在根路径
|
| 40 |
+
ROOT_PATH=
|
| 41 |
+
|
| 42 |
+
# Function Call 功能开关
|
| 43 |
+
TOOL_SUPPORT=true
|
| 44 |
+
|
| 45 |
+
# 工具调用扫描限制(字符数)
|
| 46 |
+
SCAN_LIMIT=200000
|
| 47 |
+
|
| 48 |
+
# ========== 管理后台认证 ==========
|
| 49 |
+
# 管理后台登录密码(建议修改为复杂密码)
|
| 50 |
+
ADMIN_PASSWORD=admin123
|
| 51 |
+
|
| 52 |
+
# Session 密钥(用于加密会话,建议生成随机字符串)
|
| 53 |
+
SESSION_SECRET_KEY=your-secret-key-change-in-production
|
.gitattributes
CHANGED
|
@@ -1,35 +1,2 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
-
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
-
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
-
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
-
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
-
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
-
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
-
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
-
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
-
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
-
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
-
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
-
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
-
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
-
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
-
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
-
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
-
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
-
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
-
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
-
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
-
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
-
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
-
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
-
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
-
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
-
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
-
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
-
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
-
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
-
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 1 |
+
# Auto detect text files and perform LF normalization
|
| 2 |
+
* text=auto
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.github/workflows/docker.yml
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Build and Push Docker Image
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches:
|
| 6 |
+
- main
|
| 7 |
+
tags:
|
| 8 |
+
- 'v*'
|
| 9 |
+
|
| 10 |
+
env:
|
| 11 |
+
IMAGE_NAME: z-ai2api-python
|
| 12 |
+
|
| 13 |
+
jobs:
|
| 14 |
+
docker:
|
| 15 |
+
runs-on: ubuntu-latest
|
| 16 |
+
permissions:
|
| 17 |
+
contents: read
|
| 18 |
+
packages: write
|
| 19 |
+
|
| 20 |
+
steps:
|
| 21 |
+
- name: Checkout
|
| 22 |
+
uses: actions/checkout@v4
|
| 23 |
+
|
| 24 |
+
- name: Set up Docker Buildx
|
| 25 |
+
uses: docker/setup-buildx-action@v3
|
| 26 |
+
|
| 27 |
+
- name: Login to GitHub Container Registry
|
| 28 |
+
uses: docker/login-action@v3
|
| 29 |
+
with:
|
| 30 |
+
registry: ghcr.io
|
| 31 |
+
username: ${{ github.actor }}
|
| 32 |
+
password: ${{ secrets.GITHUB_TOKEN }}
|
| 33 |
+
|
| 34 |
+
- name: Login to Docker Hub
|
| 35 |
+
if: github.event_name != 'pull_request'
|
| 36 |
+
uses: docker/login-action@v3
|
| 37 |
+
with:
|
| 38 |
+
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
| 39 |
+
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
| 40 |
+
|
| 41 |
+
- name: Extract metadata
|
| 42 |
+
id: meta
|
| 43 |
+
uses: docker/metadata-action@v5
|
| 44 |
+
with:
|
| 45 |
+
images: |
|
| 46 |
+
ghcr.io/${{ github.repository }}
|
| 47 |
+
${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}
|
| 48 |
+
tags: |
|
| 49 |
+
type=ref,event=branch
|
| 50 |
+
type=semver,pattern={{version}}
|
| 51 |
+
type=semver,pattern={{major}}.{{minor}}
|
| 52 |
+
type=raw,value=latest,enable={{is_default_branch}}
|
| 53 |
+
|
| 54 |
+
- name: Build and push
|
| 55 |
+
uses: docker/build-push-action@v5
|
| 56 |
+
with:
|
| 57 |
+
context: .
|
| 58 |
+
file: ./deploy/Dockerfile
|
| 59 |
+
platforms: linux/amd64,linux/arm64
|
| 60 |
+
push: true
|
| 61 |
+
tags: ${{ steps.meta.outputs.tags }}
|
| 62 |
+
labels: ${{ steps.meta.outputs.labels }}
|
| 63 |
+
cache-from: type=gha
|
| 64 |
+
cache-to: type=gha,mode=max
|
.gitignore
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Custom
|
| 2 |
+
.vs/
|
| 3 |
+
.vscode/
|
| 4 |
+
.idea/
|
| 5 |
+
.conda/
|
| 6 |
+
*.zip
|
| 7 |
+
*.txt
|
| 8 |
+
*.pid
|
| 9 |
+
docs/
|
| 10 |
+
output/
|
| 11 |
+
main.build/
|
| 12 |
+
main.dist/
|
| 13 |
+
main.onefile-build/
|
| 14 |
+
*report.xml
|
| 15 |
+
*.yaml
|
| 16 |
+
logs/
|
| 17 |
+
backup/
|
| 18 |
+
uv.lock
|
| 19 |
+
AGENTS.md
|
| 20 |
+
*.db
|
| 21 |
+
|
| 22 |
+
# AI Toolset
|
| 23 |
+
.augment/
|
| 24 |
+
.cursor/
|
| 25 |
+
.claude/
|
| 26 |
+
CLAUDE.md
|
| 27 |
+
|
| 28 |
+
# Byte-compiled / optimized / DLL files
|
| 29 |
+
__pycache__/
|
| 30 |
+
*.py[cod]
|
| 31 |
+
*$py.class
|
| 32 |
+
|
| 33 |
+
# C extensions
|
| 34 |
+
*.so
|
| 35 |
+
|
| 36 |
+
# Distribution / packaging
|
| 37 |
+
.Python
|
| 38 |
+
build/
|
| 39 |
+
develop-eggs/
|
| 40 |
+
dist/
|
| 41 |
+
downloads/
|
| 42 |
+
eggs/
|
| 43 |
+
.eggs/
|
| 44 |
+
lib/
|
| 45 |
+
lib64/
|
| 46 |
+
parts/
|
| 47 |
+
sdist/
|
| 48 |
+
var/
|
| 49 |
+
wheels/
|
| 50 |
+
share/python-wheels/
|
| 51 |
+
*.egg-info/
|
| 52 |
+
.installed.cfg
|
| 53 |
+
*.egg
|
| 54 |
+
MANIFEST
|
| 55 |
+
|
| 56 |
+
# PyInstaller
|
| 57 |
+
# Usually these files are written by a python script from a template
|
| 58 |
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
| 59 |
+
*.manifest
|
| 60 |
+
*.spec
|
| 61 |
+
|
| 62 |
+
# Installer logs
|
| 63 |
+
pip-log.txt
|
| 64 |
+
pip-delete-this-directory.txt
|
| 65 |
+
|
| 66 |
+
# Unit test / coverage reports
|
| 67 |
+
htmlcov/
|
| 68 |
+
.tox/
|
| 69 |
+
.nox/
|
| 70 |
+
.coverage
|
| 71 |
+
.coverage.*
|
| 72 |
+
.cache
|
| 73 |
+
nosetests.xml
|
| 74 |
+
coverage.xml
|
| 75 |
+
*.cover
|
| 76 |
+
*.py,cover
|
| 77 |
+
.hypothesis/
|
| 78 |
+
.pytest_cache/
|
| 79 |
+
cover/
|
| 80 |
+
|
| 81 |
+
# Translations
|
| 82 |
+
*.mo
|
| 83 |
+
*.pot
|
| 84 |
+
|
| 85 |
+
# Django stuff:
|
| 86 |
+
*.log
|
| 87 |
+
local_settings.py
|
| 88 |
+
db.sqlite3
|
| 89 |
+
db.sqlite3-journal
|
| 90 |
+
|
| 91 |
+
# Flask stuff:
|
| 92 |
+
instance/
|
| 93 |
+
.webassets-cache
|
| 94 |
+
|
| 95 |
+
# Scrapy stuff:
|
| 96 |
+
.scrapy
|
| 97 |
+
|
| 98 |
+
# Sphinx documentation
|
| 99 |
+
docs/_build/
|
| 100 |
+
|
| 101 |
+
# PyBuilder
|
| 102 |
+
.pybuilder/
|
| 103 |
+
target/
|
| 104 |
+
|
| 105 |
+
# Jupyter Notebook
|
| 106 |
+
.ipynb_checkpoints
|
| 107 |
+
|
| 108 |
+
# IPython
|
| 109 |
+
profile_default/
|
| 110 |
+
ipython_config.py
|
| 111 |
+
|
| 112 |
+
# pyenv
|
| 113 |
+
# For a library or package, you might want to ignore these files since the code is
|
| 114 |
+
# intended to run in multiple environments; otherwise, check them in:
|
| 115 |
+
# .python-version
|
| 116 |
+
|
| 117 |
+
# pipenv
|
| 118 |
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
| 119 |
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
| 120 |
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
| 121 |
+
# install all needed dependencies.
|
| 122 |
+
#Pipfile.lock
|
| 123 |
+
|
| 124 |
+
# poetry
|
| 125 |
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
| 126 |
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
| 127 |
+
# commonly ignored for libraries.
|
| 128 |
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
| 129 |
+
#poetry.lock
|
| 130 |
+
|
| 131 |
+
# pdm
|
| 132 |
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
| 133 |
+
#pdm.lock
|
| 134 |
+
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
| 135 |
+
# in version control.
|
| 136 |
+
# https://pdm.fming.dev/#use-with-ide
|
| 137 |
+
.pdm.toml
|
| 138 |
+
|
| 139 |
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
| 140 |
+
__pypackages__/
|
| 141 |
+
|
| 142 |
+
# Celery stuff
|
| 143 |
+
celerybeat-schedule
|
| 144 |
+
celerybeat.pid
|
| 145 |
+
|
| 146 |
+
# SageMath parsed files
|
| 147 |
+
*.sage.py
|
| 148 |
+
|
| 149 |
+
# Environments
|
| 150 |
+
.env
|
| 151 |
+
.venv
|
| 152 |
+
env/
|
| 153 |
+
venv/
|
| 154 |
+
ENV/
|
| 155 |
+
env.bak/
|
| 156 |
+
venv.bak/
|
| 157 |
+
|
| 158 |
+
# Spyder project settings
|
| 159 |
+
.spyderproject
|
| 160 |
+
.spyproject
|
| 161 |
+
|
| 162 |
+
# Rope project settings
|
| 163 |
+
.ropeproject
|
| 164 |
+
|
| 165 |
+
# mkdocs documentation
|
| 166 |
+
/site
|
| 167 |
+
|
| 168 |
+
# mypy
|
| 169 |
+
.mypy_cache/
|
| 170 |
+
.dmypy.json
|
| 171 |
+
dmypy.json
|
| 172 |
+
|
| 173 |
+
# Pyre type checker
|
| 174 |
+
.pyre/
|
| 175 |
+
|
| 176 |
+
# pytype static type analyzer
|
| 177 |
+
.pytype/
|
| 178 |
+
|
| 179 |
+
# Cython debug symbols
|
| 180 |
+
cython_debug/
|
Dockerfile
CHANGED
|
@@ -1,24 +1,24 @@
|
|
| 1 |
-
|
| 2 |
-
FROM golang:1.24-alpine AS builder
|
| 3 |
-
WORKDIR /app
|
| 4 |
-
COPY go.mod go.sum ./
|
| 5 |
-
RUN go mod download
|
| 6 |
-
COPY . .
|
| 7 |
-
RUN CGO_ENABLED=0 go build -o main .
|
| 8 |
|
| 9 |
-
#
|
| 10 |
-
FROM alpine:latest
|
| 11 |
-
RUN apk --no-cache add ca-certificates
|
| 12 |
WORKDIR /app
|
| 13 |
-
COPY --from=builder /app/main .
|
| 14 |
|
| 15 |
-
#
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
# Expose port
|
| 21 |
EXPOSE 7860
|
| 22 |
|
| 23 |
# Run the application
|
| 24 |
-
CMD ["
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
+
# Set working directory
|
|
|
|
|
|
|
| 4 |
WORKDIR /app
|
|
|
|
| 5 |
|
| 6 |
+
# Create data and logs directories with proper permissions
|
| 7 |
+
RUN mkdir -p /app/data /app/logs && \
|
| 8 |
+
chmod 755 /app/data /app/logs
|
| 9 |
+
|
| 10 |
+
# Install dependencies
|
| 11 |
+
COPY requirements.txt .
|
| 12 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 13 |
+
|
| 14 |
+
# Copy application code
|
| 15 |
+
COPY . .
|
| 16 |
+
|
| 17 |
+
# Set environment variable for database path
|
| 18 |
+
ENV DB_PATH=/app/data/tokens.db
|
| 19 |
|
| 20 |
# Expose port
|
| 21 |
EXPOSE 7860
|
| 22 |
|
| 23 |
# Run the application
|
| 24 |
+
CMD ["python", "main.py"]
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2025 ZyphrZero
|
| 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.
|
app/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
|
| 4 |
+
from app import core, models, utils
|
| 5 |
+
|
| 6 |
+
__all__ = ["core", "models", "utils"]
|
app/admin/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
管理后台模块初始化
|
| 3 |
+
"""
|
app/admin/api.py
ADDED
|
@@ -0,0 +1,728 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
管理后台 API 接口
|
| 3 |
+
用于 htmx 调用的 HTML 片段返回
|
| 4 |
+
"""
|
| 5 |
+
from fastapi import APIRouter, Request
|
| 6 |
+
from fastapi.templating import Jinja2Templates
|
| 7 |
+
from fastapi.responses import HTMLResponse, JSONResponse, Response
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from app.utils.logger import logger
|
| 10 |
+
import os
|
| 11 |
+
|
| 12 |
+
router = APIRouter(prefix="/admin/api", tags=["admin-api"])
|
| 13 |
+
templates = Jinja2Templates(directory="app/templates")
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
# ==================== 认证 API ====================
|
| 17 |
+
|
| 18 |
+
@router.post("/login")
|
| 19 |
+
async def login(request: Request):
|
| 20 |
+
"""管理后台登录"""
|
| 21 |
+
from app.admin.auth import create_session
|
| 22 |
+
|
| 23 |
+
try:
|
| 24 |
+
data = await request.json()
|
| 25 |
+
password = data.get("password", "")
|
| 26 |
+
|
| 27 |
+
# 创建 session
|
| 28 |
+
session_token = create_session(password)
|
| 29 |
+
|
| 30 |
+
if session_token:
|
| 31 |
+
# 登录成功,设置 cookie
|
| 32 |
+
response = JSONResponse({
|
| 33 |
+
"success": True,
|
| 34 |
+
"message": "登录成功"
|
| 35 |
+
})
|
| 36 |
+
response.set_cookie(
|
| 37 |
+
key="admin_session",
|
| 38 |
+
value=session_token,
|
| 39 |
+
httponly=True,
|
| 40 |
+
max_age=86400, # 24小时
|
| 41 |
+
samesite="lax"
|
| 42 |
+
)
|
| 43 |
+
logger.info("✅ 管理后台登录成功")
|
| 44 |
+
return response
|
| 45 |
+
else:
|
| 46 |
+
# 密码错误
|
| 47 |
+
logger.warning("❌ 管理后台登录失败:密码错误")
|
| 48 |
+
return JSONResponse({
|
| 49 |
+
"success": False,
|
| 50 |
+
"message": "密码错误"
|
| 51 |
+
}, status_code=401)
|
| 52 |
+
|
| 53 |
+
except Exception as e:
|
| 54 |
+
logger.error(f"❌ 登录异常: {e}")
|
| 55 |
+
return JSONResponse({
|
| 56 |
+
"success": False,
|
| 57 |
+
"message": "登录失败"
|
| 58 |
+
}, status_code=500)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@router.post("/logout")
|
| 62 |
+
async def logout(request: Request):
|
| 63 |
+
"""管理后台登出"""
|
| 64 |
+
from app.admin.auth import delete_session, get_session_token_from_request
|
| 65 |
+
|
| 66 |
+
session_token = get_session_token_from_request(request)
|
| 67 |
+
delete_session(session_token)
|
| 68 |
+
|
| 69 |
+
# 清除 cookie
|
| 70 |
+
response = JSONResponse({
|
| 71 |
+
"success": True,
|
| 72 |
+
"message": "已登出"
|
| 73 |
+
})
|
| 74 |
+
response.delete_cookie("admin_session")
|
| 75 |
+
logger.info("✅ 管理后台已登出")
|
| 76 |
+
return response
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
async def reload_settings():
|
| 80 |
+
"""热重载配置(重新加载环境变量并更新 settings 对象)"""
|
| 81 |
+
from app.core.config import settings
|
| 82 |
+
from app.utils.logger import setup_logger
|
| 83 |
+
from dotenv import load_dotenv
|
| 84 |
+
|
| 85 |
+
# 重新加载 .env 文件
|
| 86 |
+
load_dotenv(override=True)
|
| 87 |
+
|
| 88 |
+
# 重新创建 Settings 对象并更新全局配置
|
| 89 |
+
new_settings = type(settings)()
|
| 90 |
+
|
| 91 |
+
# 更新全局 settings 的所有属性
|
| 92 |
+
for field_name in new_settings.model_fields.keys():
|
| 93 |
+
setattr(settings, field_name, getattr(new_settings, field_name))
|
| 94 |
+
|
| 95 |
+
# 重新初始化 logger(使用新的 DEBUG_LOGGING 配置)
|
| 96 |
+
setup_logger(log_dir="logs", debug_mode=settings.DEBUG_LOGGING)
|
| 97 |
+
|
| 98 |
+
logger.info(f"🔄 配置已热重载 (DEBUG_LOGGING={settings.DEBUG_LOGGING})")
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
@router.get("/token-pool", response_class=HTMLResponse)
|
| 102 |
+
async def get_token_pool_status(request: Request):
|
| 103 |
+
"""获取 Token 池状态(HTML 片段)"""
|
| 104 |
+
from app.utils.token_pool import get_token_pool
|
| 105 |
+
|
| 106 |
+
token_pool = get_token_pool()
|
| 107 |
+
|
| 108 |
+
if not token_pool:
|
| 109 |
+
# Token 池未初始化
|
| 110 |
+
context = {
|
| 111 |
+
"request": request,
|
| 112 |
+
"tokens": [],
|
| 113 |
+
}
|
| 114 |
+
return templates.TemplateResponse("components/token_pool.html", context)
|
| 115 |
+
|
| 116 |
+
# 获取 token 状态统计
|
| 117 |
+
pool_status = token_pool.get_pool_status()
|
| 118 |
+
tokens_info = []
|
| 119 |
+
|
| 120 |
+
for idx, token_info in enumerate(pool_status.get("tokens", []), 1):
|
| 121 |
+
is_available = token_info.get("is_available", False)
|
| 122 |
+
is_healthy = token_info.get("is_healthy", False)
|
| 123 |
+
|
| 124 |
+
# 确定状态和颜色
|
| 125 |
+
if is_healthy:
|
| 126 |
+
status = "健康"
|
| 127 |
+
status_color = "bg-green-100 text-green-800"
|
| 128 |
+
elif is_available:
|
| 129 |
+
status = "可用"
|
| 130 |
+
status_color = "bg-yellow-100 text-yellow-800"
|
| 131 |
+
else:
|
| 132 |
+
status = "失败"
|
| 133 |
+
status_color = "bg-red-100 text-red-800"
|
| 134 |
+
|
| 135 |
+
# 格式化最后使用时间
|
| 136 |
+
last_success = token_info.get("last_success_time", 0)
|
| 137 |
+
if last_success > 0:
|
| 138 |
+
from datetime import datetime
|
| 139 |
+
last_used = datetime.fromtimestamp(last_success).strftime("%Y-%m-%d %H:%M:%S")
|
| 140 |
+
else:
|
| 141 |
+
last_used = "从未使用"
|
| 142 |
+
|
| 143 |
+
tokens_info.append({
|
| 144 |
+
"index": idx,
|
| 145 |
+
"key": token_info.get("token", "")[:20] + "...",
|
| 146 |
+
"status": status,
|
| 147 |
+
"status_color": status_color,
|
| 148 |
+
"last_used": last_used,
|
| 149 |
+
"failure_count": token_info.get("failure_count", 0),
|
| 150 |
+
"success_rate": token_info.get("success_rate", "0%"),
|
| 151 |
+
"token_type": token_info.get("token_type", "unknown"),
|
| 152 |
+
})
|
| 153 |
+
|
| 154 |
+
context = {
|
| 155 |
+
"request": request,
|
| 156 |
+
"tokens": tokens_info,
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
return templates.TemplateResponse("components/token_pool.html", context)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
@router.get("/recent-logs", response_class=HTMLResponse)
|
| 163 |
+
async def get_recent_logs(request: Request):
|
| 164 |
+
"""获取最近的请求日志(HTML 片段)"""
|
| 165 |
+
# TODO: 从数据库或日志文件读取
|
| 166 |
+
logs = [
|
| 167 |
+
{
|
| 168 |
+
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
| 169 |
+
"endpoint": "/v1/chat/completions",
|
| 170 |
+
"model": "gpt-4o",
|
| 171 |
+
"status": 200,
|
| 172 |
+
"duration": "1.23s",
|
| 173 |
+
"provider": "zai",
|
| 174 |
+
}
|
| 175 |
+
]
|
| 176 |
+
|
| 177 |
+
context = {
|
| 178 |
+
"request": request,
|
| 179 |
+
"logs": logs,
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
return templates.TemplateResponse("components/recent_logs.html", context)
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
@router.post("/config/save")
|
| 186 |
+
async def save_config(request: Request):
|
| 187 |
+
"""保存配置到 .env 文件并热重载"""
|
| 188 |
+
try:
|
| 189 |
+
form_data = await request.form()
|
| 190 |
+
|
| 191 |
+
# 构建 .env 内容
|
| 192 |
+
env_lines = [
|
| 193 |
+
"# Z.AI2API 配置文件",
|
| 194 |
+
"",
|
| 195 |
+
"# ========== 服务器配置 ==========",
|
| 196 |
+
f"SERVICE_NAME={form_data.get('service_name', 'Z.AI2API')}",
|
| 197 |
+
f"LISTEN_PORT={form_data.get('listen_port', '7860')}",
|
| 198 |
+
f"DEBUG_LOGGING={'true' if 'debug_logging' in form_data else 'false'}",
|
| 199 |
+
"",
|
| 200 |
+
"# ========== 认证配置 ==========",
|
| 201 |
+
f"AUTH_TOKEN={form_data.get('auth_token', 'sk-your-api-key')}",
|
| 202 |
+
f"SKIP_AUTH_TOKEN={'true' if 'skip_auth_token' in form_data else 'false'}",
|
| 203 |
+
f"ANONYMOUS_MODE={'true' if 'anonymous_mode' in form_data else 'false'}",
|
| 204 |
+
"",
|
| 205 |
+
"# ========== 功能配置 ==========",
|
| 206 |
+
f"TOOL_SUPPORT={'true' if 'tool_support' in form_data else 'false'}",
|
| 207 |
+
f"SCAN_LIMIT={form_data.get('scan_limit', '200000')}",
|
| 208 |
+
"",
|
| 209 |
+
"# ========== Token 池配置 ==========",
|
| 210 |
+
f"TOKEN_FAILURE_THRESHOLD={form_data.get('token_failure_threshold', '3')}",
|
| 211 |
+
f"TOKEN_RECOVERY_TIMEOUT={form_data.get('token_recovery_timeout', '1800')}",
|
| 212 |
+
"",
|
| 213 |
+
"# ========== 提供商配置 ==========",
|
| 214 |
+
f"DEFAULT_PROVIDER={form_data.get('default_provider', 'zai')}",
|
| 215 |
+
]
|
| 216 |
+
|
| 217 |
+
# LongCat Token(可选)
|
| 218 |
+
longcat_token = form_data.get('longcat_token', '').strip()
|
| 219 |
+
if longcat_token:
|
| 220 |
+
env_lines.append(f"LONGCAT_TOKEN={longcat_token}")
|
| 221 |
+
|
| 222 |
+
# 写入 .env 文件
|
| 223 |
+
with open(".env", "w", encoding="utf-8") as f:
|
| 224 |
+
f.write("\n".join(env_lines))
|
| 225 |
+
|
| 226 |
+
logger.info("✅ 配置文件已保存")
|
| 227 |
+
|
| 228 |
+
# 热重载配置
|
| 229 |
+
await reload_settings()
|
| 230 |
+
|
| 231 |
+
return HTMLResponse("""
|
| 232 |
+
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded relative" role="alert">
|
| 233 |
+
<strong class="font-bold">成功!</strong>
|
| 234 |
+
<span class="block sm:inline">配置已保存并重载成功</span>
|
| 235 |
+
</div>
|
| 236 |
+
""")
|
| 237 |
+
|
| 238 |
+
except Exception as e:
|
| 239 |
+
logger.error(f"❌ 配置保存失败: {str(e)}")
|
| 240 |
+
return HTMLResponse(f"""
|
| 241 |
+
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
|
| 242 |
+
<strong class="font-bold">错误!</strong>
|
| 243 |
+
<span class="block sm:inline">保存失败: {str(e)}</span>
|
| 244 |
+
</div>
|
| 245 |
+
""")
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
@router.get("/env-preview")
|
| 249 |
+
async def get_env_preview():
|
| 250 |
+
"""获取 .env 文件预览"""
|
| 251 |
+
try:
|
| 252 |
+
with open(".env", "r", encoding="utf-8") as f:
|
| 253 |
+
content = f.read()
|
| 254 |
+
return HTMLResponse(f"<pre>{content}</pre>")
|
| 255 |
+
except FileNotFoundError:
|
| 256 |
+
return HTMLResponse("<pre># .env 文件不存在</pre>")
|
| 257 |
+
except Exception as e:
|
| 258 |
+
return HTMLResponse(f"<pre># 读取失败: {str(e)}</pre>")
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
@router.get("/provider-status", response_class=HTMLResponse)
|
| 262 |
+
async def get_provider_status(request: Request):
|
| 263 |
+
"""获取提供商状态详情(HTML 片段)"""
|
| 264 |
+
from app.services.token_dao import get_token_dao
|
| 265 |
+
|
| 266 |
+
dao = get_token_dao()
|
| 267 |
+
|
| 268 |
+
# 获取所有提供商的统计信息
|
| 269 |
+
providers = ["zai", "k2think", "longcat"]
|
| 270 |
+
provider_stats_list = []
|
| 271 |
+
|
| 272 |
+
for provider in providers:
|
| 273 |
+
stats = await dao.get_provider_stats(provider)
|
| 274 |
+
tokens = await dao.get_tokens_by_provider(provider, enabled_only=False)
|
| 275 |
+
|
| 276 |
+
# 计算成功率
|
| 277 |
+
total_requests = stats.get("total_requests", 0) or 0
|
| 278 |
+
successful_requests = stats.get("successful_requests", 0) or 0
|
| 279 |
+
failed_requests = stats.get("failed_requests", 0) or 0
|
| 280 |
+
|
| 281 |
+
if total_requests > 0:
|
| 282 |
+
success_rate = f"{(successful_requests / total_requests * 100):.1f}%"
|
| 283 |
+
else:
|
| 284 |
+
success_rate = "N/A"
|
| 285 |
+
|
| 286 |
+
# Token 类型统计
|
| 287 |
+
user_tokens = sum(1 for t in tokens if t.get("token_type") == "user")
|
| 288 |
+
guest_tokens = sum(1 for t in tokens if t.get("token_type") == "guest")
|
| 289 |
+
unknown_tokens = sum(1 for t in tokens if t.get("token_type") == "unknown")
|
| 290 |
+
|
| 291 |
+
provider_stats_list.append({
|
| 292 |
+
"name": provider, # 小写名称(用于 URL 参数)
|
| 293 |
+
"name_upper": provider.upper(), # 大写名称(用于显示)
|
| 294 |
+
"display_name": {
|
| 295 |
+
"zai": "Z.AI",
|
| 296 |
+
"k2think": "K2Think",
|
| 297 |
+
"longcat": "LongCat"
|
| 298 |
+
}.get(provider, provider.upper()),
|
| 299 |
+
"total_tokens": stats.get("total_tokens", 0) or 0,
|
| 300 |
+
"enabled_tokens": stats.get("enabled_tokens", 0) or 0,
|
| 301 |
+
"user_tokens": user_tokens,
|
| 302 |
+
"guest_tokens": guest_tokens,
|
| 303 |
+
"unknown_tokens": unknown_tokens,
|
| 304 |
+
"total_requests": total_requests,
|
| 305 |
+
"successful_requests": successful_requests,
|
| 306 |
+
"failed_requests": failed_requests,
|
| 307 |
+
"success_rate": success_rate,
|
| 308 |
+
})
|
| 309 |
+
|
| 310 |
+
context = {
|
| 311 |
+
"request": request,
|
| 312 |
+
"providers": provider_stats_list,
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
return templates.TemplateResponse("components/provider_status.html", context)
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
@router.get("/live-logs", response_class=HTMLResponse)
|
| 319 |
+
async def get_live_logs():
|
| 320 |
+
"""获取实时日志(最新 50 行)"""
|
| 321 |
+
import os
|
| 322 |
+
from datetime import datetime
|
| 323 |
+
|
| 324 |
+
logs = []
|
| 325 |
+
|
| 326 |
+
# 尝试读取日志文件
|
| 327 |
+
log_dir = "logs"
|
| 328 |
+
if os.path.exists(log_dir):
|
| 329 |
+
log_files = sorted([f for f in os.listdir(log_dir) if f.endswith('.log')], reverse=True)
|
| 330 |
+
if log_files:
|
| 331 |
+
log_file = os.path.join(log_dir, log_files[0])
|
| 332 |
+
try:
|
| 333 |
+
with open(log_file, 'r', encoding='utf-8') as f:
|
| 334 |
+
# 读取最后 50 行
|
| 335 |
+
lines = f.readlines()[-50:]
|
| 336 |
+
logs = lines
|
| 337 |
+
except Exception as e:
|
| 338 |
+
logs = [f"# [{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] 读取日志失败: {str(e)}"]
|
| 339 |
+
|
| 340 |
+
if not logs:
|
| 341 |
+
logs = [f"# [{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] 暂无日志数据"]
|
| 342 |
+
|
| 343 |
+
html = ""
|
| 344 |
+
for log in logs:
|
| 345 |
+
log_line = log.strip()
|
| 346 |
+
if not log_line:
|
| 347 |
+
continue
|
| 348 |
+
|
| 349 |
+
# 根据日志级别设置颜色和样式
|
| 350 |
+
if "ERROR" in log_line or "CRITICAL" in log_line:
|
| 351 |
+
color_class = "text-red-400 font-semibold"
|
| 352 |
+
icon = "❌"
|
| 353 |
+
elif "WARNING" in log_line or "WARN" in log_line:
|
| 354 |
+
color_class = "text-yellow-400"
|
| 355 |
+
icon = "⚠️"
|
| 356 |
+
elif "SUCCESS" in log_line or "✅" in log_line:
|
| 357 |
+
color_class = "text-green-400"
|
| 358 |
+
icon = "✅"
|
| 359 |
+
elif "INFO" in log_line:
|
| 360 |
+
color_class = "text-blue-400"
|
| 361 |
+
icon = "ℹ️"
|
| 362 |
+
elif "DEBUG" in log_line:
|
| 363 |
+
color_class = "text-gray-400 text-xs"
|
| 364 |
+
icon = "🔍"
|
| 365 |
+
else:
|
| 366 |
+
color_class = "text-gray-300"
|
| 367 |
+
icon = "•"
|
| 368 |
+
|
| 369 |
+
# 转义 HTML 特殊字符
|
| 370 |
+
log_escaped = log_line.replace('<', '<').replace('>', '>')
|
| 371 |
+
|
| 372 |
+
html += f'<div class="{color_class} py-0.5 hover:bg-gray-800 px-2 rounded transition-colors">{icon} {log_escaped}</div>'
|
| 373 |
+
|
| 374 |
+
return HTMLResponse(html)
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
# ==================== Token 管理 API ====================
|
| 378 |
+
|
| 379 |
+
@router.get("/tokens/list", response_class=HTMLResponse)
|
| 380 |
+
async def get_tokens_list(request: Request, provider: str = "zai"):
|
| 381 |
+
"""获取 Token 列表(HTML 片段)"""
|
| 382 |
+
from app.services.token_dao import get_token_dao
|
| 383 |
+
|
| 384 |
+
dao = get_token_dao()
|
| 385 |
+
tokens = await dao.get_tokens_by_provider(provider, enabled_only=False)
|
| 386 |
+
|
| 387 |
+
context = {
|
| 388 |
+
"request": request,
|
| 389 |
+
"tokens": tokens,
|
| 390 |
+
"provider": provider
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
return templates.TemplateResponse("components/token_list.html", context)
|
| 394 |
+
|
| 395 |
+
|
| 396 |
+
@router.post("/tokens/add")
|
| 397 |
+
async def add_tokens(request: Request):
|
| 398 |
+
"""添加 Token"""
|
| 399 |
+
from app.services.token_dao import get_token_dao
|
| 400 |
+
from app.utils.token_pool import get_token_pool
|
| 401 |
+
|
| 402 |
+
form_data = await request.form()
|
| 403 |
+
provider = form_data.get("provider", "zai")
|
| 404 |
+
single_token = form_data.get("single_token", "").strip()
|
| 405 |
+
bulk_tokens = form_data.get("bulk_tokens", "").strip()
|
| 406 |
+
|
| 407 |
+
dao = get_token_dao()
|
| 408 |
+
added_count = 0
|
| 409 |
+
failed_count = 0
|
| 410 |
+
|
| 411 |
+
# 添加单个 Token(带验证)
|
| 412 |
+
if single_token:
|
| 413 |
+
token_id = await dao.add_token(provider, single_token, validate=True)
|
| 414 |
+
if token_id:
|
| 415 |
+
added_count += 1
|
| 416 |
+
else:
|
| 417 |
+
failed_count += 1
|
| 418 |
+
|
| 419 |
+
# 批量添加 Token(带验证)
|
| 420 |
+
if bulk_tokens:
|
| 421 |
+
# 支持换行和逗号分隔
|
| 422 |
+
tokens = []
|
| 423 |
+
for line in bulk_tokens.split('\n'):
|
| 424 |
+
line = line.strip()
|
| 425 |
+
if ',' in line:
|
| 426 |
+
tokens.extend([t.strip() for t in line.split(',') if t.strip()])
|
| 427 |
+
elif line:
|
| 428 |
+
tokens.append(line)
|
| 429 |
+
|
| 430 |
+
success, failed = await dao.bulk_add_tokens(provider, tokens, validate=True)
|
| 431 |
+
added_count += success
|
| 432 |
+
failed_count += failed
|
| 433 |
+
|
| 434 |
+
# 同步 Token 池状态(如果有新增成功的 Token)
|
| 435 |
+
if added_count > 0:
|
| 436 |
+
pool = get_token_pool()
|
| 437 |
+
if pool:
|
| 438 |
+
await pool.sync_from_database(provider)
|
| 439 |
+
logger.info(f"✅ Token 池已同步,新增 {added_count} 个 Token ({provider})")
|
| 440 |
+
|
| 441 |
+
# 生成响应
|
| 442 |
+
if added_count > 0 and failed_count == 0:
|
| 443 |
+
return HTMLResponse(f"""
|
| 444 |
+
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded relative" role="alert">
|
| 445 |
+
<strong class="font-bold">成功!</strong>
|
| 446 |
+
<span class="block sm:inline">已添加 {added_count} 个有效 Token</span>
|
| 447 |
+
</div>
|
| 448 |
+
""")
|
| 449 |
+
elif added_count > 0 and failed_count > 0:
|
| 450 |
+
return HTMLResponse(f"""
|
| 451 |
+
<div class="bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded relative" role="alert">
|
| 452 |
+
<strong class="font-bold">部分成功!</strong>
|
| 453 |
+
<span class="block sm:inline">已添加 {added_count} 个 Token,{failed_count} 个失败(可能是重复、无效或匿名 Token)</span>
|
| 454 |
+
</div>
|
| 455 |
+
""")
|
| 456 |
+
else:
|
| 457 |
+
return HTMLResponse("""
|
| 458 |
+
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
|
| 459 |
+
<strong class="font-bold">失败!</strong>
|
| 460 |
+
<span class="block sm:inline">所有 Token 添加失败(可能是重复、无效或匿名 Token)</span>
|
| 461 |
+
</div>
|
| 462 |
+
""")
|
| 463 |
+
|
| 464 |
+
|
| 465 |
+
@router.post("/tokens/toggle/{token_id}")
|
| 466 |
+
async def toggle_token(token_id: int, enabled: bool):
|
| 467 |
+
"""切换 Token 启用状态"""
|
| 468 |
+
from app.services.token_dao import get_token_dao
|
| 469 |
+
from app.utils.token_pool import get_token_pool
|
| 470 |
+
|
| 471 |
+
dao = get_token_dao()
|
| 472 |
+
await dao.update_token_status(token_id, enabled)
|
| 473 |
+
|
| 474 |
+
# 同步 Token 池状态
|
| 475 |
+
pool = get_token_pool()
|
| 476 |
+
if pool:
|
| 477 |
+
# 获取 Token 的提供商信息
|
| 478 |
+
async with dao.get_connection() as conn:
|
| 479 |
+
cursor = await conn.execute("SELECT provider FROM tokens WHERE id = ?", (token_id,))
|
| 480 |
+
row = await cursor.fetchone()
|
| 481 |
+
if row:
|
| 482 |
+
provider = row[0]
|
| 483 |
+
await pool.sync_from_database(provider)
|
| 484 |
+
logger.info(f"✅ Token 池已同步 ({provider})")
|
| 485 |
+
|
| 486 |
+
# 根据状态返回不同样式的按钮
|
| 487 |
+
if enabled:
|
| 488 |
+
button_class = "bg-green-100 text-green-800 hover:bg-green-200"
|
| 489 |
+
indicator_class = "bg-green-500"
|
| 490 |
+
label = "已启用"
|
| 491 |
+
next_state = "false"
|
| 492 |
+
else:
|
| 493 |
+
button_class = "bg-red-100 text-red-800 hover:bg-red-200"
|
| 494 |
+
indicator_class = "bg-red-500"
|
| 495 |
+
label = "已禁用"
|
| 496 |
+
next_state = "true"
|
| 497 |
+
|
| 498 |
+
return HTMLResponse(f"""
|
| 499 |
+
<button hx-post="/admin/api/tokens/toggle/{token_id}?enabled={next_state}"
|
| 500 |
+
hx-swap="outerHTML"
|
| 501 |
+
class="inline-flex items-center px-2.5 py-0.5 text-xs font-semibold rounded-full transition-colors {button_class}">
|
| 502 |
+
<span class="h-2 w-2 rounded-full mr-1.5 {indicator_class}"></span>
|
| 503 |
+
{label}
|
| 504 |
+
</button>
|
| 505 |
+
""")
|
| 506 |
+
|
| 507 |
+
|
| 508 |
+
@router.delete("/tokens/delete/{token_id}")
|
| 509 |
+
async def delete_token(token_id: int):
|
| 510 |
+
"""删除 Token"""
|
| 511 |
+
from app.services.token_dao import get_token_dao
|
| 512 |
+
from app.utils.token_pool import get_token_pool
|
| 513 |
+
|
| 514 |
+
dao = get_token_dao()
|
| 515 |
+
|
| 516 |
+
# 获取 Token 信息以确定提供商
|
| 517 |
+
async with dao.get_connection() as conn:
|
| 518 |
+
cursor = await conn.execute("SELECT provider FROM tokens WHERE id = ?", (token_id,))
|
| 519 |
+
row = await cursor.fetchone()
|
| 520 |
+
provider = row[0] if row else "zai"
|
| 521 |
+
|
| 522 |
+
await dao.delete_token(token_id)
|
| 523 |
+
|
| 524 |
+
# 同步 Token 池状态
|
| 525 |
+
pool = get_token_pool()
|
| 526 |
+
if pool:
|
| 527 |
+
await pool.sync_from_database(provider)
|
| 528 |
+
logger.info(f"✅ Token 池已同步 ({provider})")
|
| 529 |
+
|
| 530 |
+
return HTMLResponse("") # 返回空内容,让 htmx 移除元素
|
| 531 |
+
|
| 532 |
+
|
| 533 |
+
@router.get("/tokens/stats", response_class=HTMLResponse)
|
| 534 |
+
async def get_tokens_stats(request: Request, provider: str = "zai"):
|
| 535 |
+
"""获取 Token 统计信息(HTML 片段)"""
|
| 536 |
+
from app.services.token_dao import get_token_dao
|
| 537 |
+
|
| 538 |
+
dao = get_token_dao()
|
| 539 |
+
|
| 540 |
+
# 获取提供商统计
|
| 541 |
+
stats = await dao.get_provider_stats(provider)
|
| 542 |
+
|
| 543 |
+
# 获取所有 Token 进行类型统计
|
| 544 |
+
tokens = await dao.get_tokens_by_provider(provider, enabled_only=False)
|
| 545 |
+
|
| 546 |
+
user_tokens = sum(1 for t in tokens if t.get("token_type") == "user")
|
| 547 |
+
guest_tokens = sum(1 for t in tokens if t.get("token_type") == "guest")
|
| 548 |
+
unknown_tokens = sum(1 for t in tokens if t.get("token_type") == "unknown")
|
| 549 |
+
|
| 550 |
+
stats_data = {
|
| 551 |
+
"total_tokens": stats.get("total_tokens", 0) or 0,
|
| 552 |
+
"enabled_tokens": stats.get("enabled_tokens", 0) or 0,
|
| 553 |
+
"user_tokens": user_tokens,
|
| 554 |
+
"guest_tokens": guest_tokens,
|
| 555 |
+
"unknown_tokens": unknown_tokens,
|
| 556 |
+
"total_requests": stats.get("total_requests", 0) or 0,
|
| 557 |
+
"successful_requests": stats.get("successful_requests", 0) or 0,
|
| 558 |
+
"failed_requests": stats.get("failed_requests", 0) or 0,
|
| 559 |
+
}
|
| 560 |
+
|
| 561 |
+
context = {
|
| 562 |
+
"request": request,
|
| 563 |
+
"stats": stats_data,
|
| 564 |
+
"provider": provider
|
| 565 |
+
}
|
| 566 |
+
|
| 567 |
+
return templates.TemplateResponse("components/token_stats.html", context)
|
| 568 |
+
|
| 569 |
+
|
| 570 |
+
@router.post("/tokens/validate")
|
| 571 |
+
async def validate_tokens(request: Request):
|
| 572 |
+
"""批量验证 Token"""
|
| 573 |
+
from app.services.token_dao import get_token_dao
|
| 574 |
+
|
| 575 |
+
form_data = await request.form()
|
| 576 |
+
provider = form_data.get("provider", "zai")
|
| 577 |
+
|
| 578 |
+
dao = get_token_dao()
|
| 579 |
+
|
| 580 |
+
# 执行批量验证
|
| 581 |
+
stats = await dao.validate_all_tokens(provider)
|
| 582 |
+
|
| 583 |
+
valid_count = stats.get("valid", 0)
|
| 584 |
+
guest_count = stats.get("guest", 0)
|
| 585 |
+
invalid_count = stats.get("invalid", 0)
|
| 586 |
+
|
| 587 |
+
# 生成通知消息
|
| 588 |
+
if guest_count > 0:
|
| 589 |
+
message_class = "bg-yellow-100 border-yellow-400 text-yellow-700"
|
| 590 |
+
message = f"验证完成:有效 {valid_count} 个,匿名 {guest_count} 个,无效 {invalid_count} 个。匿名 Token 已标记。"
|
| 591 |
+
elif invalid_count > 0:
|
| 592 |
+
message_class = "bg-blue-100 border-blue-400 text-blue-700"
|
| 593 |
+
message = f"验证完成:有效 {valid_count} 个,无效 {invalid_count} 个。"
|
| 594 |
+
else:
|
| 595 |
+
message_class = "bg-green-100 border-green-400 text-green-700"
|
| 596 |
+
message = f"验证完成:所有 {valid_count} 个 Token 均有效!"
|
| 597 |
+
|
| 598 |
+
return HTMLResponse(f"""
|
| 599 |
+
<div class="{message_class} border px-4 py-3 rounded relative" role="alert">
|
| 600 |
+
<strong class="font-bold">批量验证完成!</strong>
|
| 601 |
+
<span class="block sm:inline">{message}</span>
|
| 602 |
+
</div>
|
| 603 |
+
""")
|
| 604 |
+
|
| 605 |
+
|
| 606 |
+
@router.post("/tokens/validate-single/{token_id}")
|
| 607 |
+
async def validate_single_token(request: Request, token_id: int):
|
| 608 |
+
"""验证单个 Token 并返回更新后的行"""
|
| 609 |
+
from app.services.token_dao import get_token_dao
|
| 610 |
+
|
| 611 |
+
dao = get_token_dao()
|
| 612 |
+
|
| 613 |
+
# 验证 Token
|
| 614 |
+
is_valid = await dao.validate_and_update_token(token_id)
|
| 615 |
+
|
| 616 |
+
# 获取更新后的 Token 信息
|
| 617 |
+
async with dao.get_connection() as conn:
|
| 618 |
+
cursor = await conn.execute("""
|
| 619 |
+
SELECT t.*, ts.total_requests, ts.successful_requests, ts.failed_requests,
|
| 620 |
+
ts.last_success_time, ts.last_failure_time
|
| 621 |
+
FROM tokens t
|
| 622 |
+
LEFT JOIN token_stats ts ON t.id = ts.token_id
|
| 623 |
+
WHERE t.id = ?
|
| 624 |
+
""", (token_id,))
|
| 625 |
+
row = await cursor.fetchone()
|
| 626 |
+
|
| 627 |
+
if row:
|
| 628 |
+
# 返回更新后的单行 HTML
|
| 629 |
+
token = dict(row)
|
| 630 |
+
context = {
|
| 631 |
+
"request": request,
|
| 632 |
+
"token": token,
|
| 633 |
+
}
|
| 634 |
+
# 使用单行模板渲染
|
| 635 |
+
return templates.TemplateResponse("components/token_row.html", context)
|
| 636 |
+
else:
|
| 637 |
+
return HTMLResponse("")
|
| 638 |
+
|
| 639 |
+
|
| 640 |
+
@router.post("/tokens/health-check")
|
| 641 |
+
async def health_check_tokens(request: Request):
|
| 642 |
+
"""执行 Token 池健康检查"""
|
| 643 |
+
from app.utils.token_pool import get_token_pool
|
| 644 |
+
|
| 645 |
+
form_data = await request.form()
|
| 646 |
+
provider = form_data.get("provider", "zai")
|
| 647 |
+
|
| 648 |
+
pool = get_token_pool()
|
| 649 |
+
|
| 650 |
+
if not pool:
|
| 651 |
+
return HTMLResponse("""
|
| 652 |
+
<div class="bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded relative" role="alert">
|
| 653 |
+
<strong class="font-bold">提示!</strong>
|
| 654 |
+
<span class="block sm:inline">Token 池未初始化,请重启服务。</span>
|
| 655 |
+
</div>
|
| 656 |
+
""")
|
| 657 |
+
|
| 658 |
+
# 执行健康检查
|
| 659 |
+
await pool.health_check_all()
|
| 660 |
+
|
| 661 |
+
# 获取健康状态
|
| 662 |
+
status = pool.get_pool_status()
|
| 663 |
+
healthy_count = status.get("healthy_tokens", 0)
|
| 664 |
+
total_count = status.get("total_tokens", 0)
|
| 665 |
+
|
| 666 |
+
if healthy_count == total_count:
|
| 667 |
+
message_class = "bg-green-100 border-green-400 text-green-700"
|
| 668 |
+
message = f"所有 {total_count} 个 Token 均健康!"
|
| 669 |
+
elif healthy_count > 0:
|
| 670 |
+
message_class = "bg-blue-100 border-blue-400 text-blue-700"
|
| 671 |
+
message = f"健康检查完成:{healthy_count}/{total_count} 个 Token 健康。"
|
| 672 |
+
else:
|
| 673 |
+
message_class = "bg-red-100 border-red-400 text-red-700"
|
| 674 |
+
message = f"警告:0/{total_count} 个 Token 健康,请检查配置。"
|
| 675 |
+
|
| 676 |
+
return HTMLResponse(f"""
|
| 677 |
+
<div class="{message_class} border px-4 py-3 rounded relative" role="alert">
|
| 678 |
+
<strong class="font-bold">健康检查完成!</strong>
|
| 679 |
+
<span class="block sm:inline">{message}</span>
|
| 680 |
+
</div>
|
| 681 |
+
""")
|
| 682 |
+
|
| 683 |
+
|
| 684 |
+
@router.post("/tokens/sync-pool")
|
| 685 |
+
async def sync_token_pool(request: Request):
|
| 686 |
+
"""手动同步 Token 池(从数据库重新加载)"""
|
| 687 |
+
from app.utils.token_pool import get_token_pool
|
| 688 |
+
|
| 689 |
+
form_data = await request.form()
|
| 690 |
+
provider = form_data.get("provider", "zai")
|
| 691 |
+
|
| 692 |
+
pool = get_token_pool()
|
| 693 |
+
|
| 694 |
+
if not pool:
|
| 695 |
+
return HTMLResponse("""
|
| 696 |
+
<div class="bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded relative" role="alert">
|
| 697 |
+
<strong class="font-bold">提示!</strong>
|
| 698 |
+
<span class="block sm:inline">Token 池未初始化,请重启服务。</span>
|
| 699 |
+
</div>
|
| 700 |
+
""")
|
| 701 |
+
|
| 702 |
+
# 从数据库同步
|
| 703 |
+
await pool.sync_from_database(provider)
|
| 704 |
+
|
| 705 |
+
# 获取同步后的状态
|
| 706 |
+
status = pool.get_pool_status()
|
| 707 |
+
total_count = status.get("total_tokens", 0)
|
| 708 |
+
available_count = status.get("available_tokens", 0)
|
| 709 |
+
user_count = status.get("user_tokens", 0)
|
| 710 |
+
|
| 711 |
+
logger.info(f"✅ Token 池手动同步完成: {provider}, 总计 {total_count} 个 Token, 可用 {available_count} 个, 认证用户 {user_count} 个")
|
| 712 |
+
|
| 713 |
+
if total_count == 0:
|
| 714 |
+
message_class = "bg-yellow-100 border-yellow-400 text-yellow-700"
|
| 715 |
+
message = f"同步完成:当前没有可用的 {provider.upper()} Token,请在数据库中启用 Token。"
|
| 716 |
+
elif available_count == 0:
|
| 717 |
+
message_class = "bg-orange-100 border-orange-400 text-orange-700"
|
| 718 |
+
message = f"同步完成:共 {total_count} 个 Token,但无可用 Token(可能都已禁用)。"
|
| 719 |
+
else:
|
| 720 |
+
message_class = "bg-green-100 border-green-400 text-green-700"
|
| 721 |
+
message = f"同步完成:共 {total_count} 个 Token,{available_count} 个可用,{user_count} 个认证用户。"
|
| 722 |
+
|
| 723 |
+
return HTMLResponse(f"""
|
| 724 |
+
<div class="{message_class} border px-4 py-3 rounded relative" role="alert">
|
| 725 |
+
<strong class="font-bold">Token 池同步完成!</strong>
|
| 726 |
+
<span class="block sm:inline">{message}</span>
|
| 727 |
+
</div>
|
| 728 |
+
""")
|
app/admin/auth.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
管理后台认证中间件
|
| 3 |
+
"""
|
| 4 |
+
from fastapi import Request, HTTPException, status
|
| 5 |
+
from fastapi.responses import RedirectResponse
|
| 6 |
+
from typing import Optional
|
| 7 |
+
import hashlib
|
| 8 |
+
import secrets
|
| 9 |
+
from datetime import datetime, timedelta
|
| 10 |
+
|
| 11 |
+
from app.core.config import settings
|
| 12 |
+
|
| 13 |
+
# 简单的内存 Session 存储(生产环境建议使用 Redis)
|
| 14 |
+
_sessions = {}
|
| 15 |
+
|
| 16 |
+
# Session 有效期(小时)
|
| 17 |
+
SESSION_EXPIRE_HOURS = 24
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def generate_session_token() -> str:
|
| 21 |
+
"""生成随机 session token"""
|
| 22 |
+
return secrets.token_urlsafe(32)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def create_session(password: str) -> Optional[str]:
|
| 26 |
+
"""
|
| 27 |
+
创建 session
|
| 28 |
+
|
| 29 |
+
Args:
|
| 30 |
+
password: 用户输入的密码
|
| 31 |
+
|
| 32 |
+
Returns:
|
| 33 |
+
session_token 或 None(密码错误)
|
| 34 |
+
"""
|
| 35 |
+
# 验证密码
|
| 36 |
+
if password != settings.ADMIN_PASSWORD:
|
| 37 |
+
return None
|
| 38 |
+
|
| 39 |
+
# 生成 session token
|
| 40 |
+
session_token = generate_session_token()
|
| 41 |
+
|
| 42 |
+
# 存储 session(包含过期时间)
|
| 43 |
+
_sessions[session_token] = {
|
| 44 |
+
"created_at": datetime.now(),
|
| 45 |
+
"expires_at": datetime.now() + timedelta(hours=SESSION_EXPIRE_HOURS),
|
| 46 |
+
"authenticated": True
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
return session_token
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def verify_session(session_token: Optional[str]) -> bool:
|
| 53 |
+
"""
|
| 54 |
+
验证 session 是否有效
|
| 55 |
+
|
| 56 |
+
Args:
|
| 57 |
+
session_token: Session token
|
| 58 |
+
|
| 59 |
+
Returns:
|
| 60 |
+
是否已认证
|
| 61 |
+
"""
|
| 62 |
+
if not session_token:
|
| 63 |
+
return False
|
| 64 |
+
|
| 65 |
+
session = _sessions.get(session_token)
|
| 66 |
+
if not session:
|
| 67 |
+
return False
|
| 68 |
+
|
| 69 |
+
# 检查是否过期
|
| 70 |
+
if datetime.now() > session["expires_at"]:
|
| 71 |
+
# 删除过期 session
|
| 72 |
+
del _sessions[session_token]
|
| 73 |
+
return False
|
| 74 |
+
|
| 75 |
+
return session.get("authenticated", False)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def delete_session(session_token: Optional[str]):
|
| 79 |
+
"""删除 session(登出)"""
|
| 80 |
+
if session_token and session_token in _sessions:
|
| 81 |
+
del _sessions[session_token]
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def get_session_token_from_request(request: Request) -> Optional[str]:
|
| 85 |
+
"""从请求中获取 session token"""
|
| 86 |
+
return request.cookies.get("admin_session")
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
async def require_auth(request: Request):
|
| 90 |
+
"""
|
| 91 |
+
认证依赖项:要求用户已登录
|
| 92 |
+
|
| 93 |
+
在路由中使用:
|
| 94 |
+
@router.get("/admin", dependencies=[Depends(require_auth)])
|
| 95 |
+
"""
|
| 96 |
+
session_token = get_session_token_from_request(request)
|
| 97 |
+
|
| 98 |
+
if not verify_session(session_token):
|
| 99 |
+
# 未认证,重定向到登录页
|
| 100 |
+
raise HTTPException(
|
| 101 |
+
status_code=status.HTTP_303_SEE_OTHER,
|
| 102 |
+
detail="未登录",
|
| 103 |
+
headers={"Location": "/admin/login"}
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def get_authenticated_user(request: Request) -> bool:
|
| 108 |
+
"""
|
| 109 |
+
获取当前认证状态(用于模板)
|
| 110 |
+
|
| 111 |
+
Returns:
|
| 112 |
+
是否已认证
|
| 113 |
+
"""
|
| 114 |
+
session_token = get_session_token_from_request(request)
|
| 115 |
+
return verify_session(session_token)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def cleanup_expired_sessions():
|
| 119 |
+
"""清理过期的 session(定时任务调用)"""
|
| 120 |
+
now = datetime.now()
|
| 121 |
+
expired_tokens = [
|
| 122 |
+
token for token, session in _sessions.items()
|
| 123 |
+
if now > session["expires_at"]
|
| 124 |
+
]
|
| 125 |
+
|
| 126 |
+
for token in expired_tokens:
|
| 127 |
+
del _sessions[token]
|
| 128 |
+
|
| 129 |
+
return len(expired_tokens)
|
app/admin/routes.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
管理后台路由模块
|
| 3 |
+
"""
|
| 4 |
+
from fastapi import APIRouter, Request, Form, Depends
|
| 5 |
+
from fastapi.templating import Jinja2Templates
|
| 6 |
+
from fastapi.responses import HTMLResponse
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
import os
|
| 9 |
+
|
| 10 |
+
from app.admin.auth import require_auth
|
| 11 |
+
|
| 12 |
+
router = APIRouter(prefix="/admin", tags=["admin"])
|
| 13 |
+
templates = Jinja2Templates(directory="app/templates")
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@router.get("/login", response_class=HTMLResponse)
|
| 17 |
+
async def login_page(request: Request):
|
| 18 |
+
"""登录页面"""
|
| 19 |
+
return templates.TemplateResponse("login.html", {"request": request})
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@router.get("/", response_class=HTMLResponse, dependencies=[Depends(require_auth)])
|
| 23 |
+
async def dashboard(request: Request):
|
| 24 |
+
"""仪表盘首页"""
|
| 25 |
+
from app.utils.token_pool import get_token_pool
|
| 26 |
+
from app.services.token_dao import get_token_dao
|
| 27 |
+
|
| 28 |
+
token_pool = get_token_pool()
|
| 29 |
+
dao = get_token_dao()
|
| 30 |
+
|
| 31 |
+
# 统计 Token 池状态(内存中)
|
| 32 |
+
if token_pool:
|
| 33 |
+
pool_status = token_pool.get_pool_status()
|
| 34 |
+
available_tokens = pool_status.get("available_tokens", 0)
|
| 35 |
+
total_tokens = pool_status.get("total_tokens", 0)
|
| 36 |
+
healthy_tokens = pool_status.get("healthy_tokens", 0)
|
| 37 |
+
user_tokens = pool_status.get("user_tokens", 0)
|
| 38 |
+
guest_tokens = pool_status.get("guest_tokens", 0)
|
| 39 |
+
else:
|
| 40 |
+
available_tokens = 0
|
| 41 |
+
total_tokens = 0
|
| 42 |
+
healthy_tokens = 0
|
| 43 |
+
user_tokens = 0
|
| 44 |
+
guest_tokens = 0
|
| 45 |
+
|
| 46 |
+
# 基础统计信息
|
| 47 |
+
stats = {
|
| 48 |
+
"uptime": "N/A",
|
| 49 |
+
"total_requests": 0,
|
| 50 |
+
"success_rate": 0,
|
| 51 |
+
"available_tokens": available_tokens,
|
| 52 |
+
"total_tokens": total_tokens,
|
| 53 |
+
"healthy_tokens": healthy_tokens,
|
| 54 |
+
"user_tokens": user_tokens,
|
| 55 |
+
"guest_tokens": guest_tokens,
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
context = {
|
| 59 |
+
"request": request,
|
| 60 |
+
"stats": stats,
|
| 61 |
+
"current_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
return templates.TemplateResponse("index.html", context)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
@router.get("/config", response_class=HTMLResponse, dependencies=[Depends(require_auth)])
|
| 68 |
+
async def config_page(request: Request):
|
| 69 |
+
"""配置管理页面"""
|
| 70 |
+
from app.core.config import settings
|
| 71 |
+
|
| 72 |
+
# 读取 .env 文件内容
|
| 73 |
+
env_content = ""
|
| 74 |
+
try:
|
| 75 |
+
with open(".env", "r", encoding="utf-8") as f:
|
| 76 |
+
env_content = f.read()
|
| 77 |
+
except FileNotFoundError:
|
| 78 |
+
env_content = "# .env 文件不存在"
|
| 79 |
+
|
| 80 |
+
context = {
|
| 81 |
+
"request": request,
|
| 82 |
+
"config": {
|
| 83 |
+
"SERVICE_NAME": settings.SERVICE_NAME,
|
| 84 |
+
"LISTEN_PORT": settings.LISTEN_PORT,
|
| 85 |
+
"DEBUG_LOGGING": settings.DEBUG_LOGGING,
|
| 86 |
+
"ANONYMOUS_MODE": settings.ANONYMOUS_MODE,
|
| 87 |
+
"AUTH_TOKEN": settings.AUTH_TOKEN,
|
| 88 |
+
"SKIP_AUTH_TOKEN": settings.SKIP_AUTH_TOKEN,
|
| 89 |
+
"TOOL_SUPPORT": settings.TOOL_SUPPORT,
|
| 90 |
+
"TOKEN_FAILURE_THRESHOLD": settings.TOKEN_FAILURE_THRESHOLD,
|
| 91 |
+
"TOKEN_RECOVERY_TIMEOUT": settings.TOKEN_RECOVERY_TIMEOUT,
|
| 92 |
+
"SCAN_LIMIT": settings.SCAN_LIMIT,
|
| 93 |
+
"LONGCAT_TOKEN": settings.LONGCAT_TOKEN or "",
|
| 94 |
+
"DEFAULT_PROVIDER": settings.DEFAULT_PROVIDER,
|
| 95 |
+
},
|
| 96 |
+
"env_content": env_content,
|
| 97 |
+
}
|
| 98 |
+
return templates.TemplateResponse("config.html", context)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
@router.get("/monitor", response_class=HTMLResponse, dependencies=[Depends(require_auth)])
|
| 102 |
+
async def monitor_page(request: Request):
|
| 103 |
+
"""服务监控页面"""
|
| 104 |
+
context = {
|
| 105 |
+
"request": request,
|
| 106 |
+
}
|
| 107 |
+
return templates.TemplateResponse("monitor.html", context)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
@router.get("/tokens", response_class=HTMLResponse, dependencies=[Depends(require_auth)])
|
| 111 |
+
async def tokens_page(request: Request):
|
| 112 |
+
"""Token 管理页面"""
|
| 113 |
+
context = {
|
| 114 |
+
"request": request,
|
| 115 |
+
}
|
| 116 |
+
return templates.TemplateResponse("tokens.html", context)
|
app/core/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
|
| 4 |
+
from app.core import config, openai
|
| 5 |
+
|
| 6 |
+
__all__ = ["config", "openai"]
|
app/core/config.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
|
| 4 |
+
import os
|
| 5 |
+
from typing import Dict, List, Optional
|
| 6 |
+
from pydantic_settings import BaseSettings
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class Settings(BaseSettings):
|
| 10 |
+
"""Application settings"""
|
| 11 |
+
|
| 12 |
+
# API Configuration
|
| 13 |
+
API_ENDPOINT: str = "https://chat.z.ai/api/chat/completions"
|
| 14 |
+
|
| 15 |
+
# Authentication
|
| 16 |
+
AUTH_TOKEN: Optional[str] = os.getenv("AUTH_TOKEN")
|
| 17 |
+
|
| 18 |
+
# Token池配置
|
| 19 |
+
TOKEN_FAILURE_THRESHOLD: int = int(os.getenv("TOKEN_FAILURE_THRESHOLD", "3")) # 失败3次后标记为不可用
|
| 20 |
+
TOKEN_RECOVERY_TIMEOUT: int = int(os.getenv("TOKEN_RECOVERY_TIMEOUT", "1800")) # 30分钟后重试失败的token
|
| 21 |
+
|
| 22 |
+
# Model Configuration
|
| 23 |
+
GLM45_MODEL: str = os.getenv("GLM45_MODEL", "GLM-4.5")
|
| 24 |
+
GLM45_THINKING_MODEL: str = os.getenv("GLM45_THINKING_MODEL", "GLM-4.5-Thinking")
|
| 25 |
+
GLM45_SEARCH_MODEL: str = os.getenv("GLM45_SEARCH_MODEL", "GLM-4.5-Search")
|
| 26 |
+
GLM45_AIR_MODEL: str = os.getenv("GLM45_AIR_MODEL", "GLM-4.5-Air")
|
| 27 |
+
GLM45V_MODEL: str = os.getenv("GLM45V_MODEL", "GLM-4.5V")
|
| 28 |
+
GLM46_MODEL: str = os.getenv("GLM46_MODEL", "GLM-4.6")
|
| 29 |
+
GLM46_THINKING_MODEL: str = os.getenv("GLM46_THINKING_MODEL", "GLM-4.6-Thinking")
|
| 30 |
+
GLM46_SEARCH_MODEL: str = os.getenv("GLM46_SEARCH_MODEL", "GLM-4.6-Search")
|
| 31 |
+
GLM46_ADVANCED_SEARCH_MODEL: str = os.getenv("GLM46_ADVANCED_SEARCH_MODEL", "GLM-4.6-advanced-search")
|
| 32 |
+
|
| 33 |
+
# Provider Model Mapping
|
| 34 |
+
@property
|
| 35 |
+
def provider_model_mapping(self) -> Dict[str, str]:
|
| 36 |
+
"""模型到提供商的映射"""
|
| 37 |
+
return {
|
| 38 |
+
# Z.AI models
|
| 39 |
+
"GLM-4.5": "zai",
|
| 40 |
+
"GLM-4.5-Thinking": "zai",
|
| 41 |
+
"GLM-4.5-Search": "zai",
|
| 42 |
+
"GLM-4.5-Air": "zai",
|
| 43 |
+
"GLM-4.5V": "zai",
|
| 44 |
+
"GLM-4.6": "zai",
|
| 45 |
+
"GLM-4.6-Thinking": "zai",
|
| 46 |
+
"GLM-4.6-Search": "zai",
|
| 47 |
+
"GLM-4.6-advanced-search": "zai",
|
| 48 |
+
# K2Think models
|
| 49 |
+
"MBZUAI-IFM/K2-Think": "k2think",
|
| 50 |
+
# LongCat models
|
| 51 |
+
"LongCat-Flash": "longcat",
|
| 52 |
+
"LongCat": "longcat",
|
| 53 |
+
"LongCat-Search": "longcat",
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
# Server Configuration
|
| 57 |
+
LISTEN_PORT: int = int(os.getenv("LISTEN_PORT", "7860"))
|
| 58 |
+
DEBUG_LOGGING: bool = os.getenv("DEBUG_LOGGING", "true").lower() == "true"
|
| 59 |
+
SERVICE_NAME: str = os.getenv("SERVICE_NAME", "z-ai2api-server")
|
| 60 |
+
ROOT_PATH: str = os.getenv("ROOT_PATH", "") # For Nginx reverse proxy path prefix, e.g., "/api" or "/path-prefix"
|
| 61 |
+
|
| 62 |
+
ANONYMOUS_MODE: bool = os.getenv("ANONYMOUS_MODE", "true").lower() == "true"
|
| 63 |
+
TOOL_SUPPORT: bool = os.getenv("TOOL_SUPPORT", "true").lower() == "true"
|
| 64 |
+
SCAN_LIMIT: int = int(os.getenv("SCAN_LIMIT", "200000"))
|
| 65 |
+
SKIP_AUTH_TOKEN: bool = os.getenv("SKIP_AUTH_TOKEN", "false").lower() == "true"
|
| 66 |
+
|
| 67 |
+
# LongCat Configuration
|
| 68 |
+
LONGCAT_TOKEN: Optional[str] = os.getenv("LONGCAT_TOKEN")
|
| 69 |
+
|
| 70 |
+
# Provider Configuration
|
| 71 |
+
DEFAULT_PROVIDER: str = os.getenv("DEFAULT_PROVIDER", "zai") # 默认提供商:zai/k2think/longcat
|
| 72 |
+
|
| 73 |
+
# Admin Panel Authentication
|
| 74 |
+
ADMIN_PASSWORD: str = os.getenv("ADMIN_PASSWORD", "admin123") # 管理后台密码
|
| 75 |
+
SESSION_SECRET_KEY: str = os.getenv("SESSION_SECRET_KEY", "your-secret-key-change-in-production") # Session 密钥
|
| 76 |
+
|
| 77 |
+
# Browser Headers
|
| 78 |
+
CLIENT_HEADERS: Dict[str, str] = {
|
| 79 |
+
"Content-Type": "application/json",
|
| 80 |
+
"Accept": "application/json, text/event-stream",
|
| 81 |
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0",
|
| 82 |
+
"Accept-Language": "zh-CN",
|
| 83 |
+
"sec-ch-ua": '"Not;A=Brand";v="99", "Microsoft Edge";v="139", "Chromium";v="139"',
|
| 84 |
+
"sec-ch-ua-mobile": "?0",
|
| 85 |
+
"sec-ch-ua-platform": '"Windows"',
|
| 86 |
+
"X-FE-Version": "prod-fe-1.0.98",
|
| 87 |
+
"Origin": "https://chat.z.ai",
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
class Config:
|
| 91 |
+
env_file = ".env"
|
| 92 |
+
extra = "ignore" # 忽略额外字段,防止环境变量中的未知字段导致验证错误
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
settings = Settings()
|
app/core/openai.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
|
| 4 |
+
import time
|
| 5 |
+
import json
|
| 6 |
+
from typing import List, Dict, Any
|
| 7 |
+
from fastapi import APIRouter, Header, HTTPException
|
| 8 |
+
from fastapi.responses import StreamingResponse, JSONResponse
|
| 9 |
+
|
| 10 |
+
from app.core.config import settings
|
| 11 |
+
from app.models.schemas import OpenAIRequest, Message, ModelsResponse, Model, OpenAIResponse, Choice, Usage
|
| 12 |
+
from app.utils.logger import get_logger
|
| 13 |
+
from app.providers import get_provider_router
|
| 14 |
+
from app.utils.token_pool import get_token_pool
|
| 15 |
+
|
| 16 |
+
logger = get_logger()
|
| 17 |
+
router = APIRouter()
|
| 18 |
+
|
| 19 |
+
# 全局提供商路由器实例
|
| 20 |
+
provider_router = None
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def get_provider_router_instance():
|
| 24 |
+
"""获取提供商路由器实例"""
|
| 25 |
+
global provider_router
|
| 26 |
+
if provider_router is None:
|
| 27 |
+
provider_router = get_provider_router()
|
| 28 |
+
return provider_router
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def create_chunk(chat_id: str, model: str, delta: Dict[str, Any], finish_reason: str = None) -> Dict[str, Any]:
|
| 32 |
+
"""创建标准的 OpenAI chunk 结构"""
|
| 33 |
+
return {
|
| 34 |
+
"choices": [{
|
| 35 |
+
"delta": delta,
|
| 36 |
+
"finish_reason": finish_reason,
|
| 37 |
+
"index": 0,
|
| 38 |
+
"logprobs": None,
|
| 39 |
+
}],
|
| 40 |
+
"created": int(time.time()),
|
| 41 |
+
"id": chat_id,
|
| 42 |
+
"model": model,
|
| 43 |
+
"object": "chat.completion.chunk",
|
| 44 |
+
"system_fingerprint": "fp_zai_001",
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
async def handle_non_stream_response(stream_response, request: OpenAIRequest) -> JSONResponse:
|
| 49 |
+
"""处理非流式响应"""
|
| 50 |
+
logger.info("📄 开始处理非流式响应")
|
| 51 |
+
|
| 52 |
+
# 收集所有流式数据
|
| 53 |
+
full_content = []
|
| 54 |
+
async for chunk_data in stream_response():
|
| 55 |
+
if chunk_data.startswith("data: "):
|
| 56 |
+
chunk_str = chunk_data[6:].strip()
|
| 57 |
+
if chunk_str and chunk_str != "[DONE]":
|
| 58 |
+
try:
|
| 59 |
+
chunk = json.loads(chunk_str)
|
| 60 |
+
if "choices" in chunk and chunk["choices"]:
|
| 61 |
+
choice = chunk["choices"][0]
|
| 62 |
+
if "delta" in choice and "content" in choice["delta"]:
|
| 63 |
+
content = choice["delta"]["content"]
|
| 64 |
+
if content:
|
| 65 |
+
full_content.append(content)
|
| 66 |
+
except json.JSONDecodeError:
|
| 67 |
+
continue
|
| 68 |
+
|
| 69 |
+
# 构建响应
|
| 70 |
+
response_data = OpenAIResponse(
|
| 71 |
+
id=f"chatcmpl-{int(time.time())}",
|
| 72 |
+
object="chat.completion",
|
| 73 |
+
created=int(time.time()),
|
| 74 |
+
model=request.model,
|
| 75 |
+
choices=[Choice(
|
| 76 |
+
index=0,
|
| 77 |
+
message=Message(
|
| 78 |
+
role="assistant",
|
| 79 |
+
content="".join(full_content),
|
| 80 |
+
tool_calls=None
|
| 81 |
+
),
|
| 82 |
+
finish_reason="stop"
|
| 83 |
+
)],
|
| 84 |
+
usage=Usage(
|
| 85 |
+
prompt_tokens=0,
|
| 86 |
+
completion_tokens=0,
|
| 87 |
+
total_tokens=0
|
| 88 |
+
)
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
logger.info("✅ 非流式响应处理完成")
|
| 92 |
+
return JSONResponse(content=response_data.model_dump(exclude_none=True))
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
@router.get("/v1/models")
|
| 96 |
+
@router.get("/api/v1/models")
|
| 97 |
+
@router.get("/hf/v1/models")
|
| 98 |
+
async def list_models():
|
| 99 |
+
"""List available models from all providers"""
|
| 100 |
+
try:
|
| 101 |
+
router_instance = get_provider_router_instance()
|
| 102 |
+
models_data = router_instance.get_models_list()
|
| 103 |
+
return JSONResponse(content=models_data)
|
| 104 |
+
except Exception as e:
|
| 105 |
+
logger.error(f"❌ 获取模型列表失败: {e}")
|
| 106 |
+
# 返回默认模型列表作为后备
|
| 107 |
+
current_time = int(time.time())
|
| 108 |
+
fallback_response = ModelsResponse(
|
| 109 |
+
data=[
|
| 110 |
+
Model(id=settings.GLM46_MODEL, created=current_time, owned_by="z.ai"),
|
| 111 |
+
Model(id=settings.GLM46_THINKING_MODEL, created=current_time, owned_by="z.ai"),
|
| 112 |
+
Model(id=settings.GLM46_SEARCH_MODEL, created=current_time, owned_by="z.ai"),
|
| 113 |
+
Model(id=settings.GLM45_AIR_MODEL, created=current_time, owned_by="z.ai"),
|
| 114 |
+
]
|
| 115 |
+
)
|
| 116 |
+
return fallback_response
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
@router.post("/v1/chat/completions")
|
| 120 |
+
@router.post("/api/v1/chat/completions")
|
| 121 |
+
@router.post("/hf/v1/chat/completions")
|
| 122 |
+
async def chat_completions(request: OpenAIRequest, authorization: str = Header(...)):
|
| 123 |
+
"""Handle chat completion requests with multi-provider architecture"""
|
| 124 |
+
role = request.messages[0].role if request.messages else "unknown"
|
| 125 |
+
logger.info(f"😶🌫️ 收到客户端请求 - 模型: {request.model}, 流式: {request.stream}, 消息数: {len(request.messages)}, 角色: {role}, 工具数: {len(request.tools) if request.tools else 0}")
|
| 126 |
+
|
| 127 |
+
# 获取提供商信息(用于统计)
|
| 128 |
+
provider = "unknown"
|
| 129 |
+
|
| 130 |
+
try:
|
| 131 |
+
# Validate API key (skip if SKIP_AUTH_TOKEN is enabled)
|
| 132 |
+
if not settings.SKIP_AUTH_TOKEN:
|
| 133 |
+
if not authorization.startswith("Bearer "):
|
| 134 |
+
raise HTTPException(status_code=401, detail="Missing or invalid Authorization header")
|
| 135 |
+
|
| 136 |
+
api_key = authorization[7:]
|
| 137 |
+
if api_key != settings.AUTH_TOKEN:
|
| 138 |
+
raise HTTPException(status_code=401, detail="Invalid API key")
|
| 139 |
+
|
| 140 |
+
# 使用多提供商路由器处理请求
|
| 141 |
+
router_instance = get_provider_router_instance()
|
| 142 |
+
|
| 143 |
+
# 从路由器获取提供商信息
|
| 144 |
+
provider_info = router_instance.get_provider_for_model(request.model)
|
| 145 |
+
if provider_info:
|
| 146 |
+
provider = provider_info.get("provider", "unknown")
|
| 147 |
+
|
| 148 |
+
result = await router_instance.route_request(request)
|
| 149 |
+
|
| 150 |
+
# 检查是否有错误
|
| 151 |
+
if isinstance(result, dict) and "error" in result:
|
| 152 |
+
error_info = result["error"]
|
| 153 |
+
|
| 154 |
+
if error_info.get("code") == "model_not_found":
|
| 155 |
+
raise HTTPException(status_code=404, detail=error_info["message"])
|
| 156 |
+
else:
|
| 157 |
+
raise HTTPException(status_code=500, detail=error_info["message"])
|
| 158 |
+
|
| 159 |
+
# 处理响应
|
| 160 |
+
if request.stream:
|
| 161 |
+
# 流式响应
|
| 162 |
+
if hasattr(result, '__aiter__'):
|
| 163 |
+
# 结果是异步生成器
|
| 164 |
+
return StreamingResponse(
|
| 165 |
+
result,
|
| 166 |
+
media_type="text/event-stream",
|
| 167 |
+
headers={
|
| 168 |
+
"Cache-Control": "no-cache",
|
| 169 |
+
"Connection": "keep-alive",
|
| 170 |
+
"Access-Control-Allow-Origin": "*",
|
| 171 |
+
}
|
| 172 |
+
)
|
| 173 |
+
else:
|
| 174 |
+
# 结果是字典,可能包含错误
|
| 175 |
+
raise HTTPException(status_code=500, detail="Expected streaming response but got non-streaming result")
|
| 176 |
+
else:
|
| 177 |
+
# 非流式响应
|
| 178 |
+
if isinstance(result, dict):
|
| 179 |
+
return JSONResponse(content=result)
|
| 180 |
+
else:
|
| 181 |
+
# 如果是异步生成器,需要收集所有内容
|
| 182 |
+
return await handle_non_stream_response(result, request)
|
| 183 |
+
|
| 184 |
+
except HTTPException as http_exc:
|
| 185 |
+
# 重新抛出 HTTP 异常
|
| 186 |
+
raise
|
| 187 |
+
except Exception as e:
|
| 188 |
+
logger.error(f"❌ 请求处理失败: {e}")
|
| 189 |
+
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
app/models/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
|
| 4 |
+
from app.models import schemas
|
| 5 |
+
|
| 6 |
+
__all__ = ["schemas"]
|
app/models/request_log.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
请求日志数据库模型
|
| 3 |
+
用于存储API请求的详细记录
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
|
| 8 |
+
# 数据库路径 - 支持环境变量配置
|
| 9 |
+
DB_PATH = os.getenv("DB_PATH", "tokens.db") # 复用 tokens 数据库
|
| 10 |
+
|
| 11 |
+
# 创建请求日志表的SQL
|
| 12 |
+
SQL_CREATE_REQUEST_LOGS_TABLE = """
|
| 13 |
+
CREATE TABLE IF NOT EXISTS request_logs (
|
| 14 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 15 |
+
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
| 16 |
+
provider TEXT NOT NULL,
|
| 17 |
+
model TEXT NOT NULL,
|
| 18 |
+
success BOOLEAN NOT NULL,
|
| 19 |
+
duration REAL,
|
| 20 |
+
first_token_time REAL,
|
| 21 |
+
input_tokens INTEGER DEFAULT 0,
|
| 22 |
+
output_tokens INTEGER DEFAULT 0,
|
| 23 |
+
total_tokens INTEGER DEFAULT 0,
|
| 24 |
+
error_message TEXT,
|
| 25 |
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
| 26 |
+
);
|
| 27 |
+
|
| 28 |
+
CREATE INDEX IF NOT EXISTS idx_request_logs_timestamp ON request_logs(timestamp);
|
| 29 |
+
CREATE INDEX IF NOT EXISTS idx_request_logs_model ON request_logs(model);
|
| 30 |
+
CREATE INDEX IF NOT EXISTS idx_request_logs_provider ON request_logs(provider);
|
| 31 |
+
"""
|
app/models/schemas.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
|
| 4 |
+
from typing import Dict, List, Optional, Any, Union, Literal
|
| 5 |
+
from pydantic import BaseModel
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class ImageUrl(BaseModel):
|
| 9 |
+
"""Image URL model for vision content"""
|
| 10 |
+
url: str
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class ContentPart(BaseModel):
|
| 14 |
+
"""Content part model for OpenAI's new content format"""
|
| 15 |
+
|
| 16 |
+
type: str
|
| 17 |
+
text: Optional[str] = None
|
| 18 |
+
image_url: Optional[ImageUrl] = None # 添加 image_url 字段
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class Message(BaseModel):
|
| 22 |
+
"""Chat message model"""
|
| 23 |
+
|
| 24 |
+
role: str
|
| 25 |
+
content: Optional[Union[str, List[ContentPart]]] = None
|
| 26 |
+
reasoning_content: Optional[str] = None
|
| 27 |
+
tool_calls: Optional[List[Dict[str, Any]]] = None
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class OpenAIRequest(BaseModel):
|
| 31 |
+
"""OpenAI-compatible request model"""
|
| 32 |
+
|
| 33 |
+
model: str
|
| 34 |
+
messages: List[Message]
|
| 35 |
+
stream: Optional[bool] = False
|
| 36 |
+
temperature: Optional[float] = None
|
| 37 |
+
max_tokens: Optional[int] = None
|
| 38 |
+
tools: Optional[List[Dict[str, Any]]] = None
|
| 39 |
+
tool_choice: Optional[Any] = None
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class ModelItem(BaseModel):
|
| 43 |
+
"""Model information item"""
|
| 44 |
+
|
| 45 |
+
id: str
|
| 46 |
+
name: str
|
| 47 |
+
owned_by: str
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class UpstreamRequest(BaseModel):
|
| 51 |
+
"""Upstream service request model"""
|
| 52 |
+
|
| 53 |
+
stream: bool
|
| 54 |
+
model: str
|
| 55 |
+
messages: List[Message]
|
| 56 |
+
params: Dict[str, Any] = {}
|
| 57 |
+
features: Dict[str, Any] = {}
|
| 58 |
+
background_tasks: Optional[Dict[str, bool]] = None
|
| 59 |
+
chat_id: Optional[str] = None
|
| 60 |
+
id: Optional[str] = None
|
| 61 |
+
mcp_servers: Optional[List[str]] = None
|
| 62 |
+
model_item: Optional[Dict[str, Any]] = {} # Model item dictionary
|
| 63 |
+
tools: Optional[List[Dict[str, Any]]] = None # Add tools field for OpenAI compatibility
|
| 64 |
+
variables: Optional[Dict[str, str]] = None
|
| 65 |
+
model_config = {"protected_namespaces": ()}
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
class Delta(BaseModel):
|
| 69 |
+
"""Stream delta model"""
|
| 70 |
+
|
| 71 |
+
role: Optional[str] = None
|
| 72 |
+
content: Optional[str] = "" or None
|
| 73 |
+
reasoning_content: Optional[str] = None
|
| 74 |
+
tool_calls: Optional[List[Dict[str, Any]]] = None
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
class Choice(BaseModel):
|
| 78 |
+
"""Response choice model"""
|
| 79 |
+
|
| 80 |
+
index: int
|
| 81 |
+
message: Optional[Message] = None
|
| 82 |
+
delta: Optional[Delta] = None
|
| 83 |
+
finish_reason: Optional[str] = None
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
class Usage(BaseModel):
|
| 87 |
+
"""Token usage statistics"""
|
| 88 |
+
|
| 89 |
+
prompt_tokens: int = 0
|
| 90 |
+
completion_tokens: int = 0
|
| 91 |
+
total_tokens: int = 0
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
class OpenAIResponse(BaseModel):
|
| 95 |
+
"""OpenAI-compatible response model"""
|
| 96 |
+
|
| 97 |
+
id: str
|
| 98 |
+
object: str
|
| 99 |
+
created: int
|
| 100 |
+
model: str
|
| 101 |
+
choices: List[Choice]
|
| 102 |
+
usage: Optional[Usage] = None
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
class UpstreamError(BaseModel):
|
| 106 |
+
"""Upstream error model"""
|
| 107 |
+
|
| 108 |
+
detail: str
|
| 109 |
+
code: int
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
class UpstreamDataInner(BaseModel):
|
| 113 |
+
"""Inner upstream data model"""
|
| 114 |
+
|
| 115 |
+
error: Optional[UpstreamError] = None
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
class UpstreamDataData(BaseModel):
|
| 119 |
+
"""Upstream data content model"""
|
| 120 |
+
|
| 121 |
+
delta_content: str = ""
|
| 122 |
+
edit_content: str = ""
|
| 123 |
+
phase: str = ""
|
| 124 |
+
done: bool = False
|
| 125 |
+
usage: Optional[Usage] = None
|
| 126 |
+
error: Optional[UpstreamError] = None
|
| 127 |
+
inner: Optional[UpstreamDataInner] = None
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
class UpstreamData(BaseModel):
|
| 131 |
+
"""Upstream data model"""
|
| 132 |
+
|
| 133 |
+
type: str
|
| 134 |
+
data: UpstreamDataData
|
| 135 |
+
error: Optional[UpstreamError] = None
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
class Model(BaseModel):
|
| 139 |
+
"""Model information for listing"""
|
| 140 |
+
|
| 141 |
+
id: str
|
| 142 |
+
object: str = "model"
|
| 143 |
+
created: int
|
| 144 |
+
owned_by: str
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
class ModelsResponse(BaseModel):
|
| 148 |
+
"""Models list response model"""
|
| 149 |
+
|
| 150 |
+
object: str = "list"
|
| 151 |
+
data: List[Model]
|
app/models/token_db.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Token 数据库模型定义
|
| 3 |
+
使用 SQLite 存储各提供商的 Token
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
|
| 8 |
+
SQL_CREATE_TABLES = """
|
| 9 |
+
-- Token 配置表
|
| 10 |
+
CREATE TABLE IF NOT EXISTS tokens (
|
| 11 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 12 |
+
provider TEXT NOT NULL, -- 提供商: zai, k2think, longcat
|
| 13 |
+
token TEXT NOT NULL UNIQUE, -- Token 值(唯一)
|
| 14 |
+
token_type TEXT DEFAULT 'user', -- Token 类型: user, guest, unknown
|
| 15 |
+
is_enabled BOOLEAN DEFAULT 1, -- 是否启用
|
| 16 |
+
priority INTEGER DEFAULT 0, -- 优先级(用于排序)
|
| 17 |
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
| 18 |
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
| 19 |
+
UNIQUE(provider, token) -- 同一提供商内 Token 唯一
|
| 20 |
+
);
|
| 21 |
+
|
| 22 |
+
-- Token 使用统计表
|
| 23 |
+
CREATE TABLE IF NOT EXISTS token_stats (
|
| 24 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 25 |
+
token_id INTEGER NOT NULL,
|
| 26 |
+
total_requests INTEGER DEFAULT 0,
|
| 27 |
+
successful_requests INTEGER DEFAULT 0,
|
| 28 |
+
failed_requests INTEGER DEFAULT 0,
|
| 29 |
+
last_success_time DATETIME,
|
| 30 |
+
last_failure_time DATETIME,
|
| 31 |
+
FOREIGN KEY (token_id) REFERENCES tokens(id) ON DELETE CASCADE
|
| 32 |
+
);
|
| 33 |
+
|
| 34 |
+
-- 创建索引
|
| 35 |
+
CREATE INDEX IF NOT EXISTS idx_tokens_provider ON tokens(provider);
|
| 36 |
+
CREATE INDEX IF NOT EXISTS idx_tokens_enabled ON tokens(is_enabled);
|
| 37 |
+
CREATE INDEX IF NOT EXISTS idx_token_stats_token_id ON token_stats(token_id);
|
| 38 |
+
|
| 39 |
+
-- 触发器:自动更新 updated_at
|
| 40 |
+
CREATE TRIGGER IF NOT EXISTS update_tokens_timestamp
|
| 41 |
+
AFTER UPDATE ON tokens
|
| 42 |
+
BEGIN
|
| 43 |
+
UPDATE tokens SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
|
| 44 |
+
END;
|
| 45 |
+
"""
|
| 46 |
+
|
| 47 |
+
# 数据库文件路径 - 支持环境变量配置
|
| 48 |
+
DB_PATH = os.getenv("DB_PATH", "tokens.db")
|
app/providers/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
|
| 4 |
+
"""
|
| 5 |
+
多提供商架构包
|
| 6 |
+
提供统一的提供商接口和路由机制
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from app.providers.base import BaseProvider, ProviderConfig, provider_registry
|
| 10 |
+
from app.providers.zai_provider import ZAIProvider
|
| 11 |
+
from app.providers.k2think_provider import K2ThinkProvider
|
| 12 |
+
from app.providers.longcat_provider import LongCatProvider
|
| 13 |
+
from app.providers.provider_factory import ProviderFactory, ProviderRouter, get_provider_router, initialize_providers
|
| 14 |
+
|
| 15 |
+
__all__ = [
|
| 16 |
+
"BaseProvider",
|
| 17 |
+
"ProviderConfig",
|
| 18 |
+
"provider_registry",
|
| 19 |
+
"ZAIProvider",
|
| 20 |
+
"K2ThinkProvider",
|
| 21 |
+
"LongCatProvider",
|
| 22 |
+
"ProviderFactory",
|
| 23 |
+
"ProviderRouter",
|
| 24 |
+
"get_provider_router",
|
| 25 |
+
"initialize_providers"
|
| 26 |
+
]
|
app/providers/base.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
|
| 4 |
+
"""
|
| 5 |
+
基础提供商抽象层
|
| 6 |
+
定义统一的提供商接口规范
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
import time
|
| 11 |
+
import uuid
|
| 12 |
+
from abc import ABC, abstractmethod
|
| 13 |
+
from typing import Dict, List, Any, Optional, AsyncGenerator, Union
|
| 14 |
+
from dataclasses import dataclass
|
| 15 |
+
|
| 16 |
+
from app.models.schemas import OpenAIRequest, Message
|
| 17 |
+
from app.utils.logger import get_logger
|
| 18 |
+
|
| 19 |
+
logger = get_logger()
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@dataclass
|
| 23 |
+
class ProviderConfig:
|
| 24 |
+
"""提供商配置"""
|
| 25 |
+
name: str
|
| 26 |
+
api_endpoint: str
|
| 27 |
+
timeout: int = 30
|
| 28 |
+
headers: Optional[Dict[str, str]] = None
|
| 29 |
+
extra_config: Optional[Dict[str, Any]] = None
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@dataclass
|
| 33 |
+
class ProviderResponse:
|
| 34 |
+
"""提供商响应"""
|
| 35 |
+
success: bool
|
| 36 |
+
content: str = ""
|
| 37 |
+
error: Optional[str] = None
|
| 38 |
+
usage: Optional[Dict[str, int]] = None
|
| 39 |
+
extra_data: Optional[Dict[str, Any]] = None
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class BaseProvider(ABC):
|
| 43 |
+
"""基础提供商抽象类"""
|
| 44 |
+
|
| 45 |
+
def __init__(self, config: ProviderConfig):
|
| 46 |
+
"""初始化提供商"""
|
| 47 |
+
self.config = config
|
| 48 |
+
self.name = config.name
|
| 49 |
+
self.logger = get_logger()
|
| 50 |
+
|
| 51 |
+
@abstractmethod
|
| 52 |
+
async def chat_completion(
|
| 53 |
+
self,
|
| 54 |
+
request: OpenAIRequest,
|
| 55 |
+
**kwargs
|
| 56 |
+
) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
|
| 57 |
+
"""
|
| 58 |
+
聊天完成接口
|
| 59 |
+
|
| 60 |
+
Args:
|
| 61 |
+
request: OpenAI格式的请求
|
| 62 |
+
**kwargs: 额外参数
|
| 63 |
+
|
| 64 |
+
Returns:
|
| 65 |
+
非流式: Dict[str, Any] - OpenAI格式的响应
|
| 66 |
+
流式: AsyncGenerator[str, None] - SSE格式的流式响应
|
| 67 |
+
"""
|
| 68 |
+
pass
|
| 69 |
+
|
| 70 |
+
@abstractmethod
|
| 71 |
+
async def transform_request(self, request: OpenAIRequest) -> Dict[str, Any]:
|
| 72 |
+
"""
|
| 73 |
+
转换OpenAI请求为提供商特定格式
|
| 74 |
+
|
| 75 |
+
Args:
|
| 76 |
+
request: OpenAI格式的请求
|
| 77 |
+
|
| 78 |
+
Returns:
|
| 79 |
+
Dict[str, Any]: 提供商特定格式的请求
|
| 80 |
+
"""
|
| 81 |
+
pass
|
| 82 |
+
|
| 83 |
+
@abstractmethod
|
| 84 |
+
async def transform_response(
|
| 85 |
+
self,
|
| 86 |
+
response: Any,
|
| 87 |
+
request: OpenAIRequest
|
| 88 |
+
) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
|
| 89 |
+
"""
|
| 90 |
+
转换提供商响应为OpenAI格式
|
| 91 |
+
|
| 92 |
+
Args:
|
| 93 |
+
response: 提供商的原始响应
|
| 94 |
+
request: 原始请求(用于构造响应)
|
| 95 |
+
|
| 96 |
+
Returns:
|
| 97 |
+
Union[Dict[str, Any], AsyncGenerator[str, None]]: OpenAI格式的响应
|
| 98 |
+
"""
|
| 99 |
+
pass
|
| 100 |
+
|
| 101 |
+
def get_supported_models(self) -> List[str]:
|
| 102 |
+
"""获取支持的模型列表"""
|
| 103 |
+
return []
|
| 104 |
+
|
| 105 |
+
def create_chat_id(self) -> str:
|
| 106 |
+
"""生成聊天ID"""
|
| 107 |
+
return f"chatcmpl-{uuid.uuid4().hex}"
|
| 108 |
+
|
| 109 |
+
def create_openai_chunk(
|
| 110 |
+
self,
|
| 111 |
+
chat_id: str,
|
| 112 |
+
model: str,
|
| 113 |
+
delta: Dict[str, Any],
|
| 114 |
+
finish_reason: Optional[str] = None
|
| 115 |
+
) -> Dict[str, Any]:
|
| 116 |
+
"""创建OpenAI格式的流式响应块"""
|
| 117 |
+
return {
|
| 118 |
+
"id": chat_id,
|
| 119 |
+
"object": "chat.completion.chunk",
|
| 120 |
+
"created": int(time.time()),
|
| 121 |
+
"model": model,
|
| 122 |
+
"choices": [{
|
| 123 |
+
"index": 0,
|
| 124 |
+
"delta": delta,
|
| 125 |
+
"finish_reason": finish_reason,
|
| 126 |
+
"logprobs": None,
|
| 127 |
+
}],
|
| 128 |
+
"system_fingerprint": f"fp_{self.name}_001",
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
def create_openai_response(
|
| 132 |
+
self,
|
| 133 |
+
chat_id: str,
|
| 134 |
+
model: str,
|
| 135 |
+
content: str,
|
| 136 |
+
usage: Optional[Dict[str, int]] = None
|
| 137 |
+
) -> Dict[str, Any]:
|
| 138 |
+
"""创建OpenAI格式的非流式响应"""
|
| 139 |
+
return {
|
| 140 |
+
"id": chat_id,
|
| 141 |
+
"object": "chat.completion",
|
| 142 |
+
"created": int(time.time()),
|
| 143 |
+
"model": model,
|
| 144 |
+
"choices": [{
|
| 145 |
+
"index": 0,
|
| 146 |
+
"message": {
|
| 147 |
+
"role": "assistant",
|
| 148 |
+
"content": content
|
| 149 |
+
},
|
| 150 |
+
"finish_reason": "stop",
|
| 151 |
+
"logprobs": None,
|
| 152 |
+
}],
|
| 153 |
+
"usage": usage or {
|
| 154 |
+
"prompt_tokens": 0,
|
| 155 |
+
"completion_tokens": 0,
|
| 156 |
+
"total_tokens": 0
|
| 157 |
+
},
|
| 158 |
+
"system_fingerprint": f"fp_{self.name}_001",
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
def create_openai_response_with_reasoning(
|
| 162 |
+
self,
|
| 163 |
+
chat_id: str,
|
| 164 |
+
model: str,
|
| 165 |
+
content: str,
|
| 166 |
+
reasoning_content: str = None,
|
| 167 |
+
usage: Optional[Dict[str, int]] = None
|
| 168 |
+
) -> Dict[str, Any]:
|
| 169 |
+
"""创建包含推理内容的OpenAI格式非流式响应"""
|
| 170 |
+
message = {
|
| 171 |
+
"role": "assistant",
|
| 172 |
+
"content": content
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
# 只有当推理内容存在且不为空时才添加
|
| 176 |
+
if reasoning_content and reasoning_content.strip():
|
| 177 |
+
message["reasoning_content"] = reasoning_content
|
| 178 |
+
|
| 179 |
+
return {
|
| 180 |
+
"id": chat_id,
|
| 181 |
+
"object": "chat.completion",
|
| 182 |
+
"created": int(time.time()),
|
| 183 |
+
"model": model,
|
| 184 |
+
"choices": [{
|
| 185 |
+
"index": 0,
|
| 186 |
+
"message": message,
|
| 187 |
+
"finish_reason": "stop",
|
| 188 |
+
"logprobs": None,
|
| 189 |
+
}],
|
| 190 |
+
"usage": usage or {
|
| 191 |
+
"prompt_tokens": 0,
|
| 192 |
+
"completion_tokens": 0,
|
| 193 |
+
"total_tokens": 0
|
| 194 |
+
},
|
| 195 |
+
"system_fingerprint": f"fp_{self.name}_001",
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
async def format_sse_chunk(self, chunk: Dict[str, Any]) -> str:
|
| 199 |
+
"""格式化SSE响应块"""
|
| 200 |
+
return f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n"
|
| 201 |
+
|
| 202 |
+
async def format_sse_done(self) -> str:
|
| 203 |
+
"""格式化SSE结束标记"""
|
| 204 |
+
return "data: [DONE]\n\n"
|
| 205 |
+
|
| 206 |
+
def log_request(self, request: OpenAIRequest):
|
| 207 |
+
"""记录请求日志"""
|
| 208 |
+
self.logger.info(f"🔄 {self.name} 处理请求: {request.model}")
|
| 209 |
+
self.logger.debug(f" 消息数量: {len(request.messages)}")
|
| 210 |
+
self.logger.debug(f" 流式模式: {request.stream}")
|
| 211 |
+
|
| 212 |
+
def log_response(self, success: bool, error: Optional[str] = None):
|
| 213 |
+
"""记录响应日志"""
|
| 214 |
+
if success:
|
| 215 |
+
self.logger.info(f"✅ {self.name} 响应成功")
|
| 216 |
+
else:
|
| 217 |
+
self.logger.error(f"❌ {self.name} 响应失败: {error}")
|
| 218 |
+
|
| 219 |
+
def handle_error(self, error: Exception, context: str = "") -> Dict[str, Any]:
|
| 220 |
+
"""统一错误处理"""
|
| 221 |
+
error_msg = f"{self.name} {context} 错误: {str(error)}"
|
| 222 |
+
self.logger.error(error_msg)
|
| 223 |
+
|
| 224 |
+
return {
|
| 225 |
+
"error": {
|
| 226 |
+
"message": error_msg,
|
| 227 |
+
"type": "provider_error",
|
| 228 |
+
"code": "internal_error"
|
| 229 |
+
}
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
class ProviderRegistry:
|
| 234 |
+
"""提供商注册表"""
|
| 235 |
+
|
| 236 |
+
def __init__(self):
|
| 237 |
+
self._providers: Dict[str, BaseProvider] = {}
|
| 238 |
+
self._model_mapping: Dict[str, str] = {}
|
| 239 |
+
|
| 240 |
+
def register(self, provider: BaseProvider, models: List[str]):
|
| 241 |
+
"""注册提供商"""
|
| 242 |
+
self._providers[provider.name] = provider
|
| 243 |
+
for model in models:
|
| 244 |
+
self._model_mapping[model] = provider.name
|
| 245 |
+
logger.info(f"📝 注册提供商: {provider.name}, 模型: {models}")
|
| 246 |
+
|
| 247 |
+
def get_provider(self, model: str) -> Optional[BaseProvider]:
|
| 248 |
+
"""根据模型获取提供商"""
|
| 249 |
+
provider_name = self._model_mapping.get(model)
|
| 250 |
+
if provider_name:
|
| 251 |
+
return self._providers.get(provider_name)
|
| 252 |
+
return None
|
| 253 |
+
|
| 254 |
+
def get_provider_by_name(self, name: str) -> Optional[BaseProvider]:
|
| 255 |
+
"""根据名称获取提供商"""
|
| 256 |
+
return self._providers.get(name)
|
| 257 |
+
|
| 258 |
+
def list_models(self) -> List[str]:
|
| 259 |
+
"""列出所有支持的模型"""
|
| 260 |
+
return list(self._model_mapping.keys())
|
| 261 |
+
|
| 262 |
+
def list_providers(self) -> List[str]:
|
| 263 |
+
"""列出所有提供商"""
|
| 264 |
+
return list(self._providers.keys())
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
# 全局提供商注册表
|
| 268 |
+
provider_registry = ProviderRegistry()
|
app/providers/k2think_provider.py
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
|
| 4 |
+
"""
|
| 5 |
+
K2Think 提供商适配器
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
import re
|
| 10 |
+
import time
|
| 11 |
+
import uuid
|
| 12 |
+
import httpx
|
| 13 |
+
from typing import Dict, List, Any, Optional, AsyncGenerator, Union
|
| 14 |
+
|
| 15 |
+
from app.providers.base import BaseProvider, ProviderConfig
|
| 16 |
+
from app.models.schemas import OpenAIRequest, Message
|
| 17 |
+
from app.utils.logger import get_logger
|
| 18 |
+
|
| 19 |
+
logger = get_logger()
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class K2ThinkProvider(BaseProvider):
|
| 23 |
+
"""K2Think 提供商"""
|
| 24 |
+
|
| 25 |
+
def __init__(self):
|
| 26 |
+
config = ProviderConfig(
|
| 27 |
+
name="k2think",
|
| 28 |
+
api_endpoint="https://www.k2think.ai/api/guest/chat/completions",
|
| 29 |
+
timeout=30,
|
| 30 |
+
headers={
|
| 31 |
+
'Accept': 'text/event-stream',
|
| 32 |
+
'Accept-Encoding': 'gzip, deflate, br, zstd',
|
| 33 |
+
'Accept-Language': 'en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7',
|
| 34 |
+
'Content-Type': 'application/json',
|
| 35 |
+
'Origin': 'https://www.k2think.ai',
|
| 36 |
+
'Pragma': 'no-cache',
|
| 37 |
+
'Referer': 'https://www.k2think.ai/guest',
|
| 38 |
+
'Sec-Ch-Ua': '"Chromium";v="124", "Google Chrome";v="124", "Not-A.Brand";v="99"',
|
| 39 |
+
'Sec-Ch-Ua-Mobile': '?0',
|
| 40 |
+
'Sec-Ch-Ua-Platform': '"macOS"',
|
| 41 |
+
'Sec-Fetch-Dest': 'empty',
|
| 42 |
+
'Sec-Fetch-Mode': 'cors',
|
| 43 |
+
'Sec-Fetch-Site': 'same-origin',
|
| 44 |
+
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
| 45 |
+
}
|
| 46 |
+
)
|
| 47 |
+
super().__init__(config)
|
| 48 |
+
|
| 49 |
+
# K2Think 特定配置
|
| 50 |
+
self.handshake_url = "https://www.k2think.ai/guest"
|
| 51 |
+
self.new_chat_url = "https://www.k2think.ai/api/v1/chats/guest/new"
|
| 52 |
+
|
| 53 |
+
# 内容解析正则表达式 - 使用DOTALL标志确保.匹配换行符
|
| 54 |
+
self.reasoning_pattern = re.compile(r'<details type="reasoning"[^>]*>.*?<summary>.*?</summary>(.*?)</details>', re.DOTALL)
|
| 55 |
+
self.answer_pattern = re.compile(r'<answer>(.*?)</answer>', re.DOTALL)
|
| 56 |
+
|
| 57 |
+
def get_supported_models(self) -> List[str]:
|
| 58 |
+
"""获取支持的模型列表"""
|
| 59 |
+
return ["MBZUAI-IFM/K2-Think"]
|
| 60 |
+
|
| 61 |
+
def parse_cookies(self, headers) -> str:
|
| 62 |
+
"""解析Cookie"""
|
| 63 |
+
cookies = []
|
| 64 |
+
for key, value in headers.items():
|
| 65 |
+
if key.lower() == 'set-cookie':
|
| 66 |
+
cookies.append(value.split(';')[0])
|
| 67 |
+
return '; '.join(cookies)
|
| 68 |
+
|
| 69 |
+
def extract_reasoning_and_answer(self, content: str) -> tuple[str, str]:
|
| 70 |
+
"""提取推理内容和答案内容"""
|
| 71 |
+
if not content:
|
| 72 |
+
return "", ""
|
| 73 |
+
|
| 74 |
+
try:
|
| 75 |
+
reasoning_match = self.reasoning_pattern.search(content)
|
| 76 |
+
reasoning = reasoning_match.group(1).strip() if reasoning_match else ""
|
| 77 |
+
|
| 78 |
+
answer_match = self.answer_pattern.search(content)
|
| 79 |
+
answer = answer_match.group(1).strip() if answer_match else ""
|
| 80 |
+
|
| 81 |
+
return reasoning, answer
|
| 82 |
+
except Exception as e:
|
| 83 |
+
self.logger.error(f"提取K2内容错误: {e}")
|
| 84 |
+
return "", ""
|
| 85 |
+
|
| 86 |
+
def calculate_delta(self, previous: str, current: str) -> str:
|
| 87 |
+
"""计算内容增量"""
|
| 88 |
+
if not previous:
|
| 89 |
+
return current
|
| 90 |
+
if not current or len(current) < len(previous):
|
| 91 |
+
return ""
|
| 92 |
+
return current[len(previous):]
|
| 93 |
+
|
| 94 |
+
def parse_api_response(self, obj: Any) -> tuple[str, bool]:
|
| 95 |
+
"""解析API响应"""
|
| 96 |
+
if not obj or not isinstance(obj, dict):
|
| 97 |
+
return "", False
|
| 98 |
+
|
| 99 |
+
if obj.get("done") is True:
|
| 100 |
+
return "", True
|
| 101 |
+
|
| 102 |
+
choices = obj.get("choices", [])
|
| 103 |
+
if choices and len(choices) > 0:
|
| 104 |
+
delta = choices[0].get("delta", {})
|
| 105 |
+
return delta.get("content", ""), False
|
| 106 |
+
|
| 107 |
+
content = obj.get("content")
|
| 108 |
+
if isinstance(content, str):
|
| 109 |
+
return content, False
|
| 110 |
+
|
| 111 |
+
return "", False
|
| 112 |
+
|
| 113 |
+
async def get_k2_auth_data(self, request: OpenAIRequest) -> Dict[str, Any]:
|
| 114 |
+
"""获取K2Think认证数据"""
|
| 115 |
+
# 1. 握手请求 - 使用更简单的Accept-Encoding来避免Brotli问题
|
| 116 |
+
headers_for_handshake = {**self.config.headers}
|
| 117 |
+
headers_for_handshake['Accept-Encoding'] = 'gzip, deflate' # 移除br和zstd
|
| 118 |
+
|
| 119 |
+
async with httpx.AsyncClient() as client:
|
| 120 |
+
handshake_response = await client.get(
|
| 121 |
+
self.handshake_url,
|
| 122 |
+
headers=headers_for_handshake,
|
| 123 |
+
follow_redirects=True
|
| 124 |
+
)
|
| 125 |
+
if not handshake_response.is_success:
|
| 126 |
+
try:
|
| 127 |
+
# 使用httpx的text属性,它会自动处理解压缩和编码
|
| 128 |
+
error_text = handshake_response.text
|
| 129 |
+
raise Exception(f"K2 握手失败: {handshake_response.status_code} {error_text[:200]}")
|
| 130 |
+
except Exception as e:
|
| 131 |
+
raise Exception(f"K2 握手失败: {handshake_response.status_code}")
|
| 132 |
+
|
| 133 |
+
initial_cookies = self.parse_cookies(handshake_response.headers)
|
| 134 |
+
|
| 135 |
+
# 2. 准备消息
|
| 136 |
+
prepared_messages = self.prepare_k2_messages(request.messages)
|
| 137 |
+
first_user_message = next((m for m in prepared_messages if m["role"] == "user"), None)
|
| 138 |
+
if not first_user_message:
|
| 139 |
+
raise Exception("没有找到用户消息来初始化对话")
|
| 140 |
+
|
| 141 |
+
# 3. 创建新对话
|
| 142 |
+
message_id = str(uuid.uuid4())
|
| 143 |
+
now = int(time.time() * 1000)
|
| 144 |
+
model_id = request.model or "MBZUAI-IFM/K2-Think"
|
| 145 |
+
|
| 146 |
+
new_chat_payload = {
|
| 147 |
+
"chat": {
|
| 148 |
+
"id": "",
|
| 149 |
+
"title": "Guest Chat",
|
| 150 |
+
"models": [model_id],
|
| 151 |
+
"params": {},
|
| 152 |
+
"history": {
|
| 153 |
+
"messages": {
|
| 154 |
+
message_id: {
|
| 155 |
+
"id": message_id,
|
| 156 |
+
"parentId": None,
|
| 157 |
+
"childrenIds": [],
|
| 158 |
+
"role": "user",
|
| 159 |
+
"content": first_user_message["content"],
|
| 160 |
+
"timestamp": now // 1000,
|
| 161 |
+
"models": [model_id]
|
| 162 |
+
}
|
| 163 |
+
},
|
| 164 |
+
"currentId": message_id
|
| 165 |
+
},
|
| 166 |
+
"messages": [{
|
| 167 |
+
"id": message_id,
|
| 168 |
+
"parentId": None,
|
| 169 |
+
"childrenIds": [],
|
| 170 |
+
"role": "user",
|
| 171 |
+
"content": first_user_message["content"],
|
| 172 |
+
"timestamp": now // 1000,
|
| 173 |
+
"models": [model_id]
|
| 174 |
+
}],
|
| 175 |
+
"tags": [],
|
| 176 |
+
"timestamp": now
|
| 177 |
+
}
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
headers_with_cookies = {**self.config.headers, 'Cookie': initial_cookies}
|
| 181 |
+
headers_with_cookies['Accept-Encoding'] = 'gzip, deflate' # 移除br和zstd
|
| 182 |
+
|
| 183 |
+
async with httpx.AsyncClient() as client:
|
| 184 |
+
new_chat_response = await client.post(
|
| 185 |
+
self.new_chat_url,
|
| 186 |
+
headers=headers_with_cookies,
|
| 187 |
+
json=new_chat_payload,
|
| 188 |
+
follow_redirects=True
|
| 189 |
+
)
|
| 190 |
+
if not new_chat_response.is_success:
|
| 191 |
+
try:
|
| 192 |
+
# 使用httpx的text属性,它会自动处理解压缩和编码
|
| 193 |
+
error_text = new_chat_response.text
|
| 194 |
+
except Exception:
|
| 195 |
+
error_text = f"Status: {new_chat_response.status_code}"
|
| 196 |
+
raise Exception(f"K2 新对话创建失败: {new_chat_response.status_code} {error_text[:200]}")
|
| 197 |
+
|
| 198 |
+
try:
|
| 199 |
+
new_chat_data = new_chat_response.json()
|
| 200 |
+
except Exception as e:
|
| 201 |
+
# 如果JSON解析失败,尝试获取原始内容
|
| 202 |
+
try:
|
| 203 |
+
# 使用httpx的text属性,它会自动处理解压缩和编码
|
| 204 |
+
content_str = new_chat_response.text
|
| 205 |
+
self.logger.debug(f"K2 响应原始内容: {content_str[:500]}")
|
| 206 |
+
raise Exception(f"K2 响应JSON解析失败: {e}, 原始内容: {content_str[:200]}")
|
| 207 |
+
except Exception as decode_error:
|
| 208 |
+
# 如果text也失败,尝试手动处理
|
| 209 |
+
try:
|
| 210 |
+
raw_bytes = new_chat_response.content
|
| 211 |
+
content_str = raw_bytes.decode('utf-8', errors='replace')
|
| 212 |
+
raise Exception(f"K2 响应解析失败: {e}, 手动解码内容: {content_str[:200]}")
|
| 213 |
+
except Exception:
|
| 214 |
+
raise Exception(f"K2 响应解析完全失败: {e}, 解码错误: {decode_error}")
|
| 215 |
+
conversation_id = new_chat_data.get("id")
|
| 216 |
+
if not conversation_id:
|
| 217 |
+
raise Exception("无法从K2 /new端点获取conversation_id")
|
| 218 |
+
|
| 219 |
+
chat_specific_cookies = self.parse_cookies(new_chat_response.headers)
|
| 220 |
+
|
| 221 |
+
# 4. 组合最终Cookie
|
| 222 |
+
base_cookies = [initial_cookies, chat_specific_cookies]
|
| 223 |
+
base_cookies = [c for c in base_cookies if c]
|
| 224 |
+
final_cookie = '; '.join(base_cookies) + '; guest_conversation_count=1'
|
| 225 |
+
|
| 226 |
+
# 5. 构建最终请求载荷
|
| 227 |
+
final_payload = {
|
| 228 |
+
"stream": True,
|
| 229 |
+
"model": model_id,
|
| 230 |
+
"messages": prepared_messages,
|
| 231 |
+
"conversation_id": conversation_id,
|
| 232 |
+
"params": {}
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
# 添加可选参数
|
| 236 |
+
if request.temperature is not None:
|
| 237 |
+
final_payload["params"]["temperature"] = request.temperature
|
| 238 |
+
if request.max_tokens is not None:
|
| 239 |
+
final_payload["params"]["max_tokens"] = request.max_tokens
|
| 240 |
+
|
| 241 |
+
final_headers = {**self.config.headers, 'Cookie': final_cookie}
|
| 242 |
+
|
| 243 |
+
return {
|
| 244 |
+
"payload": final_payload,
|
| 245 |
+
"headers": final_headers
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
def prepare_k2_messages(self, messages: List[Message]) -> List[Dict[str, Any]]:
|
| 249 |
+
"""准备K2Think消息格式"""
|
| 250 |
+
result = []
|
| 251 |
+
system_content = ""
|
| 252 |
+
|
| 253 |
+
for msg in messages:
|
| 254 |
+
if msg.role == "system":
|
| 255 |
+
system_content = system_content + "\n\n" + msg.content if system_content else msg.content
|
| 256 |
+
else:
|
| 257 |
+
content = msg.content
|
| 258 |
+
if isinstance(content, list):
|
| 259 |
+
# 处理多模态内容,提取文本
|
| 260 |
+
text_parts = [part.text for part in content if hasattr(part, 'text') and part.text]
|
| 261 |
+
content = "\n".join(text_parts)
|
| 262 |
+
|
| 263 |
+
result.append({
|
| 264 |
+
"role": msg.role,
|
| 265 |
+
"content": content
|
| 266 |
+
})
|
| 267 |
+
|
| 268 |
+
# 将系统消息合并到第一个用户消息中
|
| 269 |
+
if system_content:
|
| 270 |
+
first_user_idx = next((i for i, m in enumerate(result) if m["role"] == "user"), -1)
|
| 271 |
+
if first_user_idx >= 0:
|
| 272 |
+
result[first_user_idx]["content"] = f"{system_content}\n\n{result[first_user_idx]['content']}"
|
| 273 |
+
else:
|
| 274 |
+
result.insert(0, {"role": "user", "content": system_content})
|
| 275 |
+
|
| 276 |
+
return result
|
| 277 |
+
|
| 278 |
+
async def _handle_stream_request(
|
| 279 |
+
self,
|
| 280 |
+
transformed: Dict[str, Any],
|
| 281 |
+
request: OpenAIRequest
|
| 282 |
+
) -> AsyncGenerator[str, None]:
|
| 283 |
+
"""处理流式请求 - 在client.stream上下文内直接处理"""
|
| 284 |
+
chat_id = self.create_chat_id()
|
| 285 |
+
model = transformed["model"]
|
| 286 |
+
|
| 287 |
+
# 准备请求头
|
| 288 |
+
headers_for_request = {**transformed["headers"]}
|
| 289 |
+
headers_for_request['Accept-Encoding'] = 'gzip, deflate'
|
| 290 |
+
|
| 291 |
+
self.logger.info(f"🌊 开始K2Think流式请求")
|
| 292 |
+
|
| 293 |
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
| 294 |
+
async with client.stream(
|
| 295 |
+
"POST",
|
| 296 |
+
transformed["url"],
|
| 297 |
+
headers=headers_for_request,
|
| 298 |
+
json=transformed["payload"]
|
| 299 |
+
) as response:
|
| 300 |
+
if not response.is_success:
|
| 301 |
+
error_msg = f"K2Think API 错误: {response.status_code}"
|
| 302 |
+
self.log_response(False, error_msg)
|
| 303 |
+
# 对于流式响应,我们需要yield错误信息
|
| 304 |
+
yield await self.format_sse_chunk({
|
| 305 |
+
"error": {
|
| 306 |
+
"message": error_msg,
|
| 307 |
+
"type": "provider_error",
|
| 308 |
+
"code": "api_error"
|
| 309 |
+
}
|
| 310 |
+
})
|
| 311 |
+
return
|
| 312 |
+
|
| 313 |
+
# 发送初始角色块
|
| 314 |
+
yield await self.format_sse_chunk(
|
| 315 |
+
self.create_openai_chunk(chat_id, model, {"role": "assistant"})
|
| 316 |
+
)
|
| 317 |
+
|
| 318 |
+
# 处理流式数据
|
| 319 |
+
accumulated_content = ""
|
| 320 |
+
previous_reasoning = ""
|
| 321 |
+
previous_answer = ""
|
| 322 |
+
reasoning_phase = True
|
| 323 |
+
chunk_count = 0
|
| 324 |
+
|
| 325 |
+
try:
|
| 326 |
+
async for line in response.aiter_lines():
|
| 327 |
+
chunk_count += 1
|
| 328 |
+
self.logger.debug(f"📦 收到数据块 #{chunk_count}: {line[:100]}...")
|
| 329 |
+
|
| 330 |
+
if not line.startswith("data:"):
|
| 331 |
+
continue
|
| 332 |
+
|
| 333 |
+
data_str = line[5:].strip()
|
| 334 |
+
if self._is_end_marker(data_str):
|
| 335 |
+
self.logger.debug(f"🏁 检测到结束标记: {data_str}")
|
| 336 |
+
continue
|
| 337 |
+
|
| 338 |
+
content = self._parse_data_string(data_str)
|
| 339 |
+
if not content:
|
| 340 |
+
continue
|
| 341 |
+
|
| 342 |
+
accumulated_content = content
|
| 343 |
+
current_reasoning, current_answer = self.extract_reasoning_and_answer(accumulated_content)
|
| 344 |
+
|
| 345 |
+
# 处理推理阶段
|
| 346 |
+
if reasoning_phase and current_reasoning:
|
| 347 |
+
delta = self.calculate_delta(previous_reasoning, current_reasoning)
|
| 348 |
+
if delta.strip():
|
| 349 |
+
self.logger.debug(f"🧠 推理增量: {delta[:50]}...")
|
| 350 |
+
yield await self.format_sse_chunk(
|
| 351 |
+
self.create_openai_chunk(chat_id, model, {"reasoning_content": delta})
|
| 352 |
+
)
|
| 353 |
+
previous_reasoning = current_reasoning
|
| 354 |
+
|
| 355 |
+
# 切换到答案阶段
|
| 356 |
+
if current_answer and reasoning_phase:
|
| 357 |
+
reasoning_phase = False
|
| 358 |
+
self.logger.debug("🔄 切换到答案阶段")
|
| 359 |
+
# 发送剩余的推理内容
|
| 360 |
+
final_reasoning_delta = self.calculate_delta(previous_reasoning, current_reasoning)
|
| 361 |
+
if final_reasoning_delta.strip():
|
| 362 |
+
yield await self.format_sse_chunk(
|
| 363 |
+
self.create_openai_chunk(chat_id, model, {"reasoning_content": final_reasoning_delta})
|
| 364 |
+
)
|
| 365 |
+
|
| 366 |
+
# 处理答案阶段
|
| 367 |
+
if not reasoning_phase and current_answer:
|
| 368 |
+
delta = self.calculate_delta(previous_answer, current_answer)
|
| 369 |
+
if delta.strip():
|
| 370 |
+
self.logger.debug(f"💬 答案增量: {delta[:50]}...")
|
| 371 |
+
yield await self.format_sse_chunk(
|
| 372 |
+
self.create_openai_chunk(chat_id, model, {"content": delta})
|
| 373 |
+
)
|
| 374 |
+
previous_answer = current_answer
|
| 375 |
+
|
| 376 |
+
except Exception as e:
|
| 377 |
+
self.logger.error(f"流式响应处理错误: {e}")
|
| 378 |
+
yield await self.format_sse_chunk({
|
| 379 |
+
"error": {
|
| 380 |
+
"message": f"流式处理错误: {str(e)}",
|
| 381 |
+
"type": "stream_error",
|
| 382 |
+
"code": "processing_error"
|
| 383 |
+
}
|
| 384 |
+
})
|
| 385 |
+
return
|
| 386 |
+
|
| 387 |
+
# 发送结束块
|
| 388 |
+
self.logger.info(f"✅ K2Think流式响应完成,共处理 {chunk_count} 个数据块")
|
| 389 |
+
yield await self.format_sse_chunk(
|
| 390 |
+
self.create_openai_chunk(chat_id, model, {}, "stop")
|
| 391 |
+
)
|
| 392 |
+
yield await self.format_sse_done()
|
| 393 |
+
|
| 394 |
+
async def transform_request(self, request: OpenAIRequest) -> Dict[str, Any]:
|
| 395 |
+
"""转换OpenAI请求为K2Think格式"""
|
| 396 |
+
self.logger.info(f"🔄 转换 OpenAI 请求到 K2Think 格式: {request.model}")
|
| 397 |
+
|
| 398 |
+
auth_data = await self.get_k2_auth_data(request)
|
| 399 |
+
|
| 400 |
+
return {
|
| 401 |
+
"url": self.config.api_endpoint,
|
| 402 |
+
"headers": auth_data["headers"],
|
| 403 |
+
"payload": auth_data["payload"],
|
| 404 |
+
"model": request.model
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
async def chat_completion(
|
| 408 |
+
self,
|
| 409 |
+
request: OpenAIRequest
|
| 410 |
+
) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
|
| 411 |
+
"""聊天完成接口"""
|
| 412 |
+
self.log_request(request)
|
| 413 |
+
|
| 414 |
+
try:
|
| 415 |
+
# 转换请求
|
| 416 |
+
transformed = await self.transform_request(request)
|
| 417 |
+
|
| 418 |
+
# 发送请求 - 使用更兼容的压缩设置
|
| 419 |
+
headers_for_request = {**transformed["headers"]}
|
| 420 |
+
headers_for_request['Accept-Encoding'] = 'gzip, deflate' # 移除br和zstd
|
| 421 |
+
|
| 422 |
+
if request.stream:
|
| 423 |
+
# 流式请求 - 直接在这里处理流式响应
|
| 424 |
+
return self._handle_stream_request(transformed, request)
|
| 425 |
+
else:
|
| 426 |
+
# 非流式请求 - 使用传统的 client.post()
|
| 427 |
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
| 428 |
+
response = await client.post(
|
| 429 |
+
transformed["url"],
|
| 430 |
+
headers=headers_for_request,
|
| 431 |
+
json=transformed["payload"]
|
| 432 |
+
)
|
| 433 |
+
|
| 434 |
+
if not response.is_success:
|
| 435 |
+
error_msg = f"K2Think API 错误: {response.status_code}"
|
| 436 |
+
self.log_response(False, error_msg)
|
| 437 |
+
return self.handle_error(Exception(error_msg))
|
| 438 |
+
|
| 439 |
+
# 转换非流式响应
|
| 440 |
+
return await self.transform_response(response, request, transformed)
|
| 441 |
+
|
| 442 |
+
except Exception as e:
|
| 443 |
+
self.log_response(False, str(e))
|
| 444 |
+
return self.handle_error(e, "请求处理")
|
| 445 |
+
|
| 446 |
+
async def transform_response(
|
| 447 |
+
self,
|
| 448 |
+
response: httpx.Response,
|
| 449 |
+
request: OpenAIRequest,
|
| 450 |
+
transformed: Dict[str, Any]
|
| 451 |
+
) -> Dict[str, Any]:
|
| 452 |
+
"""转换K2Think响应为OpenAI格式 - 仅用于非流式请求"""
|
| 453 |
+
chat_id = self.create_chat_id()
|
| 454 |
+
model = transformed["model"]
|
| 455 |
+
|
| 456 |
+
# 流式请求现在由 _handle_stream_request 直接处理
|
| 457 |
+
# 这里只处理非流式请求
|
| 458 |
+
return await self._handle_non_stream_response(response, chat_id, model)
|
| 459 |
+
|
| 460 |
+
def _is_end_marker(self, data: str) -> bool:
|
| 461 |
+
"""检查是否为结束标记"""
|
| 462 |
+
return not data or data in ["-1", "[DONE]", "DONE", "done"]
|
| 463 |
+
|
| 464 |
+
def _parse_data_string(self, data_str: str) -> str:
|
| 465 |
+
"""解析数据字符串"""
|
| 466 |
+
try:
|
| 467 |
+
obj = json.loads(data_str)
|
| 468 |
+
content, is_done = self.parse_api_response(obj)
|
| 469 |
+
return "" if is_done else content
|
| 470 |
+
except:
|
| 471 |
+
return data_str
|
| 472 |
+
|
| 473 |
+
async def _handle_non_stream_response(
|
| 474 |
+
self,
|
| 475 |
+
response: httpx.Response,
|
| 476 |
+
chat_id: str,
|
| 477 |
+
model: str
|
| 478 |
+
) -> Dict[str, Any]:
|
| 479 |
+
"""处理K2Think非流式响应"""
|
| 480 |
+
# 聚合流式内容 - 使用httpx的aiter_lines,它���自动处理解压缩
|
| 481 |
+
final_content = ""
|
| 482 |
+
|
| 483 |
+
try:
|
| 484 |
+
# 使用aiter_lines(),httpx会自动处理压缩和编码
|
| 485 |
+
async for line in response.aiter_lines():
|
| 486 |
+
if not line.startswith("data:"):
|
| 487 |
+
continue
|
| 488 |
+
|
| 489 |
+
data_str = line[5:].strip()
|
| 490 |
+
if self._is_end_marker(data_str):
|
| 491 |
+
continue
|
| 492 |
+
|
| 493 |
+
content = self._parse_data_string(data_str)
|
| 494 |
+
if content:
|
| 495 |
+
final_content = content
|
| 496 |
+
|
| 497 |
+
except Exception as e:
|
| 498 |
+
self.logger.error(f"非流式响应处理错误: {e}")
|
| 499 |
+
raise
|
| 500 |
+
|
| 501 |
+
# 提取推理内容和答案内容
|
| 502 |
+
reasoning, answer = self.extract_reasoning_and_answer(final_content)
|
| 503 |
+
|
| 504 |
+
# 清理内容格式
|
| 505 |
+
reasoning = reasoning.replace("\\n", "\n") if reasoning else ""
|
| 506 |
+
answer = answer.replace("\\n", "\n") if answer else final_content
|
| 507 |
+
|
| 508 |
+
# 创建包含推理内容的响应
|
| 509 |
+
return self.create_openai_response_with_reasoning(chat_id, model, answer, reasoning)
|
app/providers/longcat_provider.py
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
|
| 4 |
+
"""
|
| 5 |
+
LongCat 提供商适配器
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
import time
|
| 10 |
+
import httpx
|
| 11 |
+
import random
|
| 12 |
+
import asyncio
|
| 13 |
+
from typing import Dict, List, Any, Optional, AsyncGenerator, Union
|
| 14 |
+
|
| 15 |
+
from app.providers.base import BaseProvider, ProviderConfig
|
| 16 |
+
from app.models.schemas import OpenAIRequest, Message
|
| 17 |
+
from app.utils.logger import get_logger
|
| 18 |
+
from app.utils.user_agent import get_dynamic_headers
|
| 19 |
+
from app.core.config import settings
|
| 20 |
+
|
| 21 |
+
logger = get_logger()
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class LongCatProvider(BaseProvider):
|
| 25 |
+
"""LongCat 提供商"""
|
| 26 |
+
|
| 27 |
+
def __init__(self):
|
| 28 |
+
# 使用动态生成的 headers,不包含 User-Agent(将在请求时动态生成)
|
| 29 |
+
config = ProviderConfig(
|
| 30 |
+
name="longcat",
|
| 31 |
+
api_endpoint="https://longcat.chat/api/v1/chat-completion",
|
| 32 |
+
timeout=30,
|
| 33 |
+
headers={
|
| 34 |
+
'accept': 'text/event-stream,application/json',
|
| 35 |
+
'content-type': 'application/json',
|
| 36 |
+
'origin': 'https://longcat.chat',
|
| 37 |
+
'referer': 'https://longcat.chat/t',
|
| 38 |
+
}
|
| 39 |
+
)
|
| 40 |
+
super().__init__(config)
|
| 41 |
+
self.base_url = "https://longcat.chat"
|
| 42 |
+
self.session_create_url = f"{self.base_url}/api/v1/session-create"
|
| 43 |
+
self.session_delete_url = f"{self.base_url}/api/v1/session-delete"
|
| 44 |
+
|
| 45 |
+
def get_supported_models(self) -> List[str]:
|
| 46 |
+
"""获取支持的模型列表"""
|
| 47 |
+
return ["LongCat-Flash", "LongCat", "LongCat-Search"]
|
| 48 |
+
|
| 49 |
+
def get_passport_token(self) -> Optional[str]:
|
| 50 |
+
"""获取 LongCat passport token"""
|
| 51 |
+
# 优先使用环境变量中的单个token
|
| 52 |
+
if settings.LONGCAT_TOKEN:
|
| 53 |
+
return settings.LONGCAT_TOKEN
|
| 54 |
+
|
| 55 |
+
# 从token文件中随机选择一个
|
| 56 |
+
token_list = settings.longcat_token_list
|
| 57 |
+
if token_list:
|
| 58 |
+
return random.choice(token_list)
|
| 59 |
+
|
| 60 |
+
return None
|
| 61 |
+
|
| 62 |
+
def create_headers_with_auth(self, token: str, user_agent: str, referer: str = None) -> Dict[str, str]:
|
| 63 |
+
"""创建带认证的请求头"""
|
| 64 |
+
headers = {
|
| 65 |
+
"User-Agent": user_agent,
|
| 66 |
+
"Content-Type": "application/json",
|
| 67 |
+
"x-requested-with": "XMLHttpRequest",
|
| 68 |
+
"X-Client-Language": "zh",
|
| 69 |
+
"Cookie": f"passport_token_key={token}",
|
| 70 |
+
"Accept": "text/event-stream,application/json",
|
| 71 |
+
"Origin": "https://longcat.chat"
|
| 72 |
+
}
|
| 73 |
+
if referer:
|
| 74 |
+
headers["Referer"] = referer
|
| 75 |
+
else:
|
| 76 |
+
headers["Referer"] = f"{self.base_url}/"
|
| 77 |
+
return headers
|
| 78 |
+
|
| 79 |
+
async def create_session(self, token: str, user_agent: str) -> str:
|
| 80 |
+
"""创建会话并返回 conversation_id"""
|
| 81 |
+
headers = self.create_headers_with_auth(token, user_agent)
|
| 82 |
+
data = {"model": "", "agentId": ""}
|
| 83 |
+
|
| 84 |
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
| 85 |
+
response = await client.post(
|
| 86 |
+
self.session_create_url,
|
| 87 |
+
headers=headers,
|
| 88 |
+
json=data
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
if response.status_code != 200:
|
| 92 |
+
raise Exception(f"会话创建失败: {response.status_code}")
|
| 93 |
+
|
| 94 |
+
response_data = response.json()
|
| 95 |
+
if response_data.get("code") != 0:
|
| 96 |
+
raise Exception(f"会话创建错误: {response_data.get('message')}")
|
| 97 |
+
|
| 98 |
+
return response_data["data"]["conversationId"]
|
| 99 |
+
|
| 100 |
+
async def delete_session(self, conversation_id: str, token: str, user_agent: str) -> None:
|
| 101 |
+
"""删除会话"""
|
| 102 |
+
try:
|
| 103 |
+
headers = self.create_headers_with_auth(
|
| 104 |
+
token,
|
| 105 |
+
user_agent,
|
| 106 |
+
f"{self.base_url}/c/{conversation_id}"
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
| 110 |
+
url = f"{self.session_delete_url}?conversationId={conversation_id}"
|
| 111 |
+
response = await client.get(url, headers=headers)
|
| 112 |
+
|
| 113 |
+
if response.status_code == 200:
|
| 114 |
+
self.logger.debug(f"成功删除会话 {conversation_id}")
|
| 115 |
+
else:
|
| 116 |
+
self.logger.warning(f"删除会话失败: {response.status_code}")
|
| 117 |
+
except Exception as e:
|
| 118 |
+
self.logger.error(f"删除会话出错: {e}")
|
| 119 |
+
|
| 120 |
+
def schedule_session_deletion(self, conversation_id: str, token: str, user_agent: str):
|
| 121 |
+
"""异步删除会话(不等待)"""
|
| 122 |
+
asyncio.create_task(self.delete_session(conversation_id, token, user_agent))
|
| 123 |
+
|
| 124 |
+
def format_messages_for_longcat(self, messages: List[Message]) -> str:
|
| 125 |
+
"""格式化消息为 LongCat 格式"""
|
| 126 |
+
formatted_messages = []
|
| 127 |
+
for msg in messages:
|
| 128 |
+
content = msg.content
|
| 129 |
+
if isinstance(content, list):
|
| 130 |
+
# 处理多模态内容,提取文本
|
| 131 |
+
text_parts = []
|
| 132 |
+
for part in content:
|
| 133 |
+
if hasattr(part, 'text') and part.text:
|
| 134 |
+
text_parts.append(part.text)
|
| 135 |
+
content = "\n".join(text_parts)
|
| 136 |
+
formatted_messages.append(f"{msg.role}:{content}")
|
| 137 |
+
return ";".join(formatted_messages)
|
| 138 |
+
|
| 139 |
+
async def transform_request(self, request: OpenAIRequest) -> Dict[str, Any]:
|
| 140 |
+
"""转换OpenAI请求为LongCat格式"""
|
| 141 |
+
# 获取认证token
|
| 142 |
+
passport_token = self.get_passport_token()
|
| 143 |
+
if not passport_token:
|
| 144 |
+
raise Exception("未配置 LongCat passport token,请设置 LONGCAT_TOKEN 环境变量")
|
| 145 |
+
|
| 146 |
+
# 生成动态 User-Agent
|
| 147 |
+
dynamic_headers = get_dynamic_headers()
|
| 148 |
+
user_agent = dynamic_headers.get("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
| 149 |
+
|
| 150 |
+
# 创建会话
|
| 151 |
+
conversation_id = await self.create_session(passport_token, user_agent)
|
| 152 |
+
|
| 153 |
+
# 格式化消息内容
|
| 154 |
+
formatted_content = self.format_messages_for_longcat(request.messages)
|
| 155 |
+
|
| 156 |
+
# 构建LongCat请求载荷
|
| 157 |
+
payload = {
|
| 158 |
+
"conversationId": conversation_id,
|
| 159 |
+
"content": formatted_content,
|
| 160 |
+
"reasonEnabled": 0,
|
| 161 |
+
"searchEnabled": 1 if "search" in request.model.lower() else 0,
|
| 162 |
+
"parentMessageId": 0
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
# 创建带认证的请求头
|
| 166 |
+
headers = self.create_headers_with_auth(
|
| 167 |
+
passport_token,
|
| 168 |
+
user_agent,
|
| 169 |
+
f"{self.base_url}/c/{conversation_id}"
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
return {
|
| 173 |
+
"url": self.config.api_endpoint,
|
| 174 |
+
"headers": headers,
|
| 175 |
+
"payload": payload,
|
| 176 |
+
"model": request.model,
|
| 177 |
+
"conversation_id": conversation_id,
|
| 178 |
+
"passport_token": passport_token,
|
| 179 |
+
"user_agent": user_agent
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
async def chat_completion(
|
| 183 |
+
self,
|
| 184 |
+
request: OpenAIRequest,
|
| 185 |
+
**kwargs
|
| 186 |
+
) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
|
| 187 |
+
"""聊天完成接口"""
|
| 188 |
+
self.log_request(request)
|
| 189 |
+
|
| 190 |
+
try:
|
| 191 |
+
# 转换请求
|
| 192 |
+
transformed = await self.transform_request(request)
|
| 193 |
+
|
| 194 |
+
# 发送请求
|
| 195 |
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
| 196 |
+
response = await client.post(
|
| 197 |
+
transformed["url"],
|
| 198 |
+
headers=transformed["headers"],
|
| 199 |
+
json=transformed["payload"]
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
if not response.is_success:
|
| 203 |
+
error_msg = f"LongCat API 错误: {response.status_code}"
|
| 204 |
+
try:
|
| 205 |
+
error_detail = await response.atext()
|
| 206 |
+
self.logger.error(f"❌ API 错误详情: {error_detail}")
|
| 207 |
+
except:
|
| 208 |
+
pass
|
| 209 |
+
self.log_response(False, error_msg)
|
| 210 |
+
return self.handle_error(Exception(error_msg))
|
| 211 |
+
|
| 212 |
+
# 转换响应
|
| 213 |
+
return await self.transform_response(response, request, transformed)
|
| 214 |
+
|
| 215 |
+
except Exception as e:
|
| 216 |
+
self.logger.error(f"❌ LongCat 请求处理异常: {e}")
|
| 217 |
+
self.log_response(False, str(e))
|
| 218 |
+
return self.handle_error(e, "请求处理")
|
| 219 |
+
|
| 220 |
+
async def transform_response(
|
| 221 |
+
self,
|
| 222 |
+
response: httpx.Response,
|
| 223 |
+
request: OpenAIRequest,
|
| 224 |
+
transformed: Dict[str, Any]
|
| 225 |
+
) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
|
| 226 |
+
"""转换LongCat响应为OpenAI格式"""
|
| 227 |
+
chat_id = self.create_chat_id()
|
| 228 |
+
model = transformed["model"]
|
| 229 |
+
conversation_id = transformed["conversation_id"]
|
| 230 |
+
passport_token = transformed["passport_token"]
|
| 231 |
+
user_agent = transformed["user_agent"]
|
| 232 |
+
|
| 233 |
+
if request.stream:
|
| 234 |
+
return self._handle_stream_response(
|
| 235 |
+
response, chat_id, model, conversation_id, passport_token, user_agent
|
| 236 |
+
)
|
| 237 |
+
else:
|
| 238 |
+
return await self._handle_non_stream_response(
|
| 239 |
+
response, chat_id, model, conversation_id, passport_token, user_agent
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
async def _handle_stream_response(
|
| 243 |
+
self,
|
| 244 |
+
response: httpx.Response,
|
| 245 |
+
chat_id: str,
|
| 246 |
+
model: str,
|
| 247 |
+
conversation_id: str,
|
| 248 |
+
passport_token: str,
|
| 249 |
+
user_agent: str
|
| 250 |
+
) -> AsyncGenerator[str, None]:
|
| 251 |
+
"""处理LongCat流式响应"""
|
| 252 |
+
session_deleted = False
|
| 253 |
+
|
| 254 |
+
try:
|
| 255 |
+
# 发送初始角色块
|
| 256 |
+
yield await self.format_sse_chunk(
|
| 257 |
+
self.create_openai_chunk(chat_id, model, {"role": "assistant"})
|
| 258 |
+
)
|
| 259 |
+
|
| 260 |
+
stream_finished = False
|
| 261 |
+
|
| 262 |
+
async for line in response.aiter_lines():
|
| 263 |
+
line = line.strip()
|
| 264 |
+
|
| 265 |
+
# 首先检查是否是错误响应(JSON格式但不是SSE格式)
|
| 266 |
+
if not line.startswith('data:'):
|
| 267 |
+
# 尝试解析为JSON错误响应
|
| 268 |
+
try:
|
| 269 |
+
error_data = json.loads(line)
|
| 270 |
+
if isinstance(error_data, dict) and 'code' in error_data and 'message' in error_data:
|
| 271 |
+
# 这是一个错误响应
|
| 272 |
+
self.logger.error(f"❌ LongCat API 返回错误: {error_data}")
|
| 273 |
+
error_message = error_data.get('message', '未知错误')
|
| 274 |
+
error_code = error_data.get('code', 'unknown')
|
| 275 |
+
|
| 276 |
+
# 使用统一的错误处理函数
|
| 277 |
+
error_exception = Exception(f"LongCat API 错误 ({error_code}): {error_message}")
|
| 278 |
+
error_response = self.handle_error(error_exception, "API响应")
|
| 279 |
+
|
| 280 |
+
# 发送错误响应块
|
| 281 |
+
yield await self.format_sse_chunk(error_response)
|
| 282 |
+
yield await self.format_sse_done()
|
| 283 |
+
|
| 284 |
+
# 清理会话
|
| 285 |
+
if not session_deleted:
|
| 286 |
+
self.schedule_session_deletion(conversation_id, passport_token, user_agent)
|
| 287 |
+
session_deleted = True
|
| 288 |
+
return
|
| 289 |
+
except json.JSONDecodeError:
|
| 290 |
+
# 不是JSON,跳过这行
|
| 291 |
+
continue
|
| 292 |
+
|
| 293 |
+
# 如果不是错误响应,跳过
|
| 294 |
+
continue
|
| 295 |
+
|
| 296 |
+
data_str = line[5:].strip()
|
| 297 |
+
if data_str == '[DONE]':
|
| 298 |
+
# 如果还没有发送完成块,发送一个
|
| 299 |
+
if not stream_finished:
|
| 300 |
+
yield await self.format_sse_chunk(
|
| 301 |
+
self.create_openai_chunk(chat_id, model, {}, "stop")
|
| 302 |
+
)
|
| 303 |
+
yield await self.format_sse_done()
|
| 304 |
+
|
| 305 |
+
# 清理会话
|
| 306 |
+
if not session_deleted:
|
| 307 |
+
self.schedule_session_deletion(conversation_id, passport_token, user_agent)
|
| 308 |
+
session_deleted = True
|
| 309 |
+
break
|
| 310 |
+
|
| 311 |
+
try:
|
| 312 |
+
longcat_data = json.loads(data_str)
|
| 313 |
+
|
| 314 |
+
# 获取 delta 内容
|
| 315 |
+
choices = longcat_data.get("choices", [])
|
| 316 |
+
if not choices:
|
| 317 |
+
continue
|
| 318 |
+
|
| 319 |
+
delta = choices[0].get("delta", {})
|
| 320 |
+
content = delta.get("content")
|
| 321 |
+
finish_reason = choices[0].get("finishReason")
|
| 322 |
+
|
| 323 |
+
# 只有当内容不为空时才发送内容块
|
| 324 |
+
if content is not None and content != "":
|
| 325 |
+
openai_chunk = self.create_openai_chunk(
|
| 326 |
+
chat_id,
|
| 327 |
+
model,
|
| 328 |
+
{"content": content}
|
| 329 |
+
)
|
| 330 |
+
yield await self.format_sse_chunk(openai_chunk)
|
| 331 |
+
|
| 332 |
+
# 检查是否为流的结束
|
| 333 |
+
# LongCat 使用 lastOne=true 来标识最后一个块
|
| 334 |
+
if longcat_data.get("lastOne") and not stream_finished:
|
| 335 |
+
yield await self.format_sse_chunk(
|
| 336 |
+
self.create_openai_chunk(chat_id, model, {}, "stop")
|
| 337 |
+
)
|
| 338 |
+
yield await self.format_sse_done()
|
| 339 |
+
stream_finished = True
|
| 340 |
+
|
| 341 |
+
# 清理会话
|
| 342 |
+
if not session_deleted:
|
| 343 |
+
self.schedule_session_deletion(conversation_id, passport_token, user_agent)
|
| 344 |
+
session_deleted = True
|
| 345 |
+
break
|
| 346 |
+
|
| 347 |
+
# 备用检查:如果有 finishReason 但没有 lastOne,也可能是结束
|
| 348 |
+
elif finish_reason == "stop" and longcat_data.get("contentStatus") == "FINISHED" and not stream_finished:
|
| 349 |
+
yield await self.format_sse_chunk(
|
| 350 |
+
self.create_openai_chunk(chat_id, model, {}, "stop")
|
| 351 |
+
)
|
| 352 |
+
yield await self.format_sse_done()
|
| 353 |
+
stream_finished = True
|
| 354 |
+
|
| 355 |
+
# 清理会话
|
| 356 |
+
if not session_deleted:
|
| 357 |
+
self.schedule_session_deletion(conversation_id, passport_token, user_agent)
|
| 358 |
+
session_deleted = True
|
| 359 |
+
break
|
| 360 |
+
|
| 361 |
+
except json.JSONDecodeError as e:
|
| 362 |
+
self.logger.error(f"❌ 解析LongCat流数据错误: {e}")
|
| 363 |
+
continue
|
| 364 |
+
except Exception as e:
|
| 365 |
+
self.logger.error(f"❌ 处理LongCat流数据错误: {e}")
|
| 366 |
+
continue
|
| 367 |
+
|
| 368 |
+
except Exception as e:
|
| 369 |
+
self.logger.error(f"❌ LongCat流处理错误: {e}")
|
| 370 |
+
# 发送错误结束块(只有在还没有结束的情况下)
|
| 371 |
+
if not stream_finished:
|
| 372 |
+
yield await self.format_sse_chunk(
|
| 373 |
+
self.create_openai_chunk(chat_id, model, {}, "stop")
|
| 374 |
+
)
|
| 375 |
+
yield await self.format_sse_done()
|
| 376 |
+
finally:
|
| 377 |
+
# 确保会话被清理
|
| 378 |
+
if not session_deleted:
|
| 379 |
+
self.schedule_session_deletion(conversation_id, passport_token, user_agent)
|
| 380 |
+
|
| 381 |
+
async def _handle_non_stream_response(
|
| 382 |
+
self,
|
| 383 |
+
response: httpx.Response,
|
| 384 |
+
chat_id: str,
|
| 385 |
+
model: str,
|
| 386 |
+
conversation_id: str,
|
| 387 |
+
passport_token: str,
|
| 388 |
+
user_agent: str
|
| 389 |
+
) -> Dict[str, Any]:
|
| 390 |
+
"""处理LongCat非流式响应"""
|
| 391 |
+
full_content = ""
|
| 392 |
+
usage_info = {
|
| 393 |
+
"prompt_tokens": 0,
|
| 394 |
+
"completion_tokens": 0,
|
| 395 |
+
"total_tokens": 0
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
try:
|
| 399 |
+
async for line in response.aiter_lines():
|
| 400 |
+
line = line.strip()
|
| 401 |
+
if not line.startswith('data:'):
|
| 402 |
+
# 检查是否是错误响应
|
| 403 |
+
try:
|
| 404 |
+
error_data = json.loads(line)
|
| 405 |
+
if isinstance(error_data, dict) and 'code' in error_data and 'message' in error_data:
|
| 406 |
+
# 这是一个错误响应
|
| 407 |
+
self.logger.error(f"❌ LongCat API 返回错误: {error_data}")
|
| 408 |
+
error_message = error_data.get('message', '未知错误')
|
| 409 |
+
error_code = error_data.get('code', 'unknown')
|
| 410 |
+
|
| 411 |
+
# 使用统一的错误处理函数
|
| 412 |
+
error_exception = Exception(f"LongCat API 错误 ({error_code}): {error_message}")
|
| 413 |
+
|
| 414 |
+
# 清理会话
|
| 415 |
+
self.schedule_session_deletion(conversation_id, passport_token, user_agent)
|
| 416 |
+
|
| 417 |
+
return self.handle_error(error_exception, "API响应")
|
| 418 |
+
except json.JSONDecodeError:
|
| 419 |
+
# 不是JSON,跳过这行
|
| 420 |
+
pass
|
| 421 |
+
continue
|
| 422 |
+
|
| 423 |
+
data_str = line[5:].strip()
|
| 424 |
+
if data_str == '[DONE]':
|
| 425 |
+
break
|
| 426 |
+
|
| 427 |
+
try:
|
| 428 |
+
chunk = json.loads(data_str)
|
| 429 |
+
|
| 430 |
+
# 提取内容 - 只有当内容不为空时才添加
|
| 431 |
+
choices = chunk.get("choices", [])
|
| 432 |
+
if choices:
|
| 433 |
+
delta = choices[0].get("delta", {})
|
| 434 |
+
content = delta.get("content")
|
| 435 |
+
if content is not None and content != "":
|
| 436 |
+
full_content += content
|
| 437 |
+
|
| 438 |
+
# 提取使用信息(通常在最后的块中)
|
| 439 |
+
if chunk.get("tokenInfo"):
|
| 440 |
+
token_info = chunk["tokenInfo"]
|
| 441 |
+
usage_info = {
|
| 442 |
+
"prompt_tokens": token_info.get("promptTokens", 0),
|
| 443 |
+
"completion_tokens": token_info.get("completionTokens", 0),
|
| 444 |
+
"total_tokens": token_info.get("totalTokens", 0)
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
# 如果是最后一个块,可以提前结束
|
| 448 |
+
if chunk.get("lastOne"):
|
| 449 |
+
break
|
| 450 |
+
|
| 451 |
+
except json.JSONDecodeError:
|
| 452 |
+
continue
|
| 453 |
+
|
| 454 |
+
except Exception as e:
|
| 455 |
+
self.logger.error(f"❌ 处理LongCat非流式响应错误: {e}")
|
| 456 |
+
full_content = "处理响应时发生错误"
|
| 457 |
+
finally:
|
| 458 |
+
# 清理会话
|
| 459 |
+
self.schedule_session_deletion(conversation_id, passport_token, user_agent)
|
| 460 |
+
|
| 461 |
+
return self.create_openai_response(
|
| 462 |
+
chat_id,
|
| 463 |
+
model,
|
| 464 |
+
full_content.strip(),
|
| 465 |
+
usage_info
|
| 466 |
+
)
|
app/providers/provider_factory.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
|
| 4 |
+
"""
|
| 5 |
+
提供商工厂和路由机制
|
| 6 |
+
负责根据模型名称自动选择合适的提供商
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import time
|
| 10 |
+
from typing import Dict, List, Optional, Union, AsyncGenerator, Any
|
| 11 |
+
from app.providers.base import BaseProvider, provider_registry
|
| 12 |
+
from app.providers.zai_provider import ZAIProvider
|
| 13 |
+
from app.providers.k2think_provider import K2ThinkProvider
|
| 14 |
+
from app.providers.longcat_provider import LongCatProvider
|
| 15 |
+
from app.models.schemas import OpenAIRequest
|
| 16 |
+
from app.core.config import settings
|
| 17 |
+
from app.utils.logger import get_logger
|
| 18 |
+
|
| 19 |
+
logger = get_logger()
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class ProviderFactory:
|
| 23 |
+
"""提供商工厂"""
|
| 24 |
+
|
| 25 |
+
def __init__(self):
|
| 26 |
+
self._initialized = False
|
| 27 |
+
self._default_provider = "zai"
|
| 28 |
+
|
| 29 |
+
def initialize(self):
|
| 30 |
+
"""初始化所有提供商"""
|
| 31 |
+
if self._initialized:
|
| 32 |
+
return
|
| 33 |
+
|
| 34 |
+
try:
|
| 35 |
+
# 注册 Z.AI 提供商
|
| 36 |
+
zai_provider = ZAIProvider()
|
| 37 |
+
provider_registry.register(
|
| 38 |
+
zai_provider,
|
| 39 |
+
zai_provider.get_supported_models()
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
# 注册 K2Think 提供商
|
| 43 |
+
k2think_provider = K2ThinkProvider()
|
| 44 |
+
provider_registry.register(
|
| 45 |
+
k2think_provider,
|
| 46 |
+
k2think_provider.get_supported_models()
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
# 注册 LongCat 提供商
|
| 50 |
+
longcat_provider = LongCatProvider()
|
| 51 |
+
provider_registry.register(
|
| 52 |
+
longcat_provider,
|
| 53 |
+
longcat_provider.get_supported_models()
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
self._initialized = True
|
| 57 |
+
|
| 58 |
+
except Exception as e:
|
| 59 |
+
logger.error(f"❌ 提供商工厂初始化失败: {e}")
|
| 60 |
+
raise
|
| 61 |
+
|
| 62 |
+
def get_provider_for_model(self, model: str) -> Optional[BaseProvider]:
|
| 63 |
+
"""根据模型名称获取提供商"""
|
| 64 |
+
if not self._initialized:
|
| 65 |
+
self.initialize()
|
| 66 |
+
|
| 67 |
+
# 首先尝试从配置的映射中获取
|
| 68 |
+
provider_mapping = settings.provider_model_mapping
|
| 69 |
+
provider_name = provider_mapping.get(model)
|
| 70 |
+
|
| 71 |
+
if provider_name:
|
| 72 |
+
provider = provider_registry.get_provider_by_name(provider_name)
|
| 73 |
+
if provider:
|
| 74 |
+
logger.debug(f"🎯 模型 {model} 映射到提供商 {provider_name}")
|
| 75 |
+
return provider
|
| 76 |
+
|
| 77 |
+
# 尝试从注册表中直接获取
|
| 78 |
+
provider = provider_registry.get_provider(model)
|
| 79 |
+
if provider:
|
| 80 |
+
logger.debug(f"🎯 模型 {model} 找到提供商 {provider.name}")
|
| 81 |
+
return provider
|
| 82 |
+
|
| 83 |
+
# 使用默认提供商
|
| 84 |
+
default_provider = provider_registry.get_provider_by_name(self._default_provider)
|
| 85 |
+
if default_provider:
|
| 86 |
+
logger.warning(f"⚠️ 模型 {model} 未找到专用提供商,使用默认提供商 {self._default_provider}")
|
| 87 |
+
return default_provider
|
| 88 |
+
|
| 89 |
+
logger.error(f"❌ 无法为模型 {model} 找到任何提供商")
|
| 90 |
+
return None
|
| 91 |
+
|
| 92 |
+
def list_supported_models(self) -> List[str]:
|
| 93 |
+
"""列出所有支持的模型"""
|
| 94 |
+
if not self._initialized:
|
| 95 |
+
self.initialize()
|
| 96 |
+
return provider_registry.list_models()
|
| 97 |
+
|
| 98 |
+
def list_providers(self) -> List[str]:
|
| 99 |
+
"""列出所有提供商"""
|
| 100 |
+
if not self._initialized:
|
| 101 |
+
self.initialize()
|
| 102 |
+
return provider_registry.list_providers()
|
| 103 |
+
|
| 104 |
+
def get_models_for_provider(self, provider_name: str) -> List[str]:
|
| 105 |
+
"""获取指定提供商支持的模型"""
|
| 106 |
+
if not self._initialized:
|
| 107 |
+
self.initialize()
|
| 108 |
+
|
| 109 |
+
provider = provider_registry.get_provider_by_name(provider_name)
|
| 110 |
+
if provider:
|
| 111 |
+
return provider.get_supported_models()
|
| 112 |
+
return []
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
class ProviderRouter:
|
| 116 |
+
"""提供商路由器"""
|
| 117 |
+
|
| 118 |
+
def __init__(self):
|
| 119 |
+
self.factory = ProviderFactory()
|
| 120 |
+
|
| 121 |
+
async def route_request(
|
| 122 |
+
self,
|
| 123 |
+
request: OpenAIRequest,
|
| 124 |
+
**kwargs
|
| 125 |
+
) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
|
| 126 |
+
"""路由请求到合适的提供商"""
|
| 127 |
+
logger.info(f"🚦 路由请求: 模型={request.model}, 流式={request.stream}")
|
| 128 |
+
|
| 129 |
+
# 获取提供商
|
| 130 |
+
provider = self.factory.get_provider_for_model(request.model)
|
| 131 |
+
if not provider:
|
| 132 |
+
error_msg = f"不支持的模型: {request.model}"
|
| 133 |
+
logger.error(f"❌ {error_msg}")
|
| 134 |
+
return {
|
| 135 |
+
"error": {
|
| 136 |
+
"message": error_msg,
|
| 137 |
+
"type": "invalid_request_error",
|
| 138 |
+
"code": "model_not_found"
|
| 139 |
+
}
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
logger.info(f"✅ 使用提供商: {provider.name}")
|
| 143 |
+
|
| 144 |
+
try:
|
| 145 |
+
# 调用提供商处理请求
|
| 146 |
+
result = await provider.chat_completion(request, **kwargs)
|
| 147 |
+
logger.info(f"🎉 请求处理��成: {provider.name}")
|
| 148 |
+
return result
|
| 149 |
+
|
| 150 |
+
except Exception as e:
|
| 151 |
+
error_msg = f"提供商 {provider.name} 处理请求失败: {str(e)}"
|
| 152 |
+
logger.error(f"❌ {error_msg}")
|
| 153 |
+
return provider.handle_error(e, "路由处理")
|
| 154 |
+
|
| 155 |
+
def get_provider_for_model(self, model: str) -> Optional[Dict[str, str]]:
|
| 156 |
+
"""
|
| 157 |
+
获取模型对应的提供商信息
|
| 158 |
+
|
| 159 |
+
Returns:
|
| 160 |
+
包含提供商名称的字典,例如 {"provider": "zai"}
|
| 161 |
+
"""
|
| 162 |
+
provider = self.factory.get_provider_for_model(model)
|
| 163 |
+
if provider:
|
| 164 |
+
return {"provider": provider.name}
|
| 165 |
+
return None
|
| 166 |
+
|
| 167 |
+
def get_models_list(self) -> Dict[str, Any]:
|
| 168 |
+
"""获取模型列表(OpenAI格式)"""
|
| 169 |
+
models = []
|
| 170 |
+
current_time = int(time.time())
|
| 171 |
+
|
| 172 |
+
# 按提供商分组获取模型
|
| 173 |
+
for provider_name in self.factory.list_providers():
|
| 174 |
+
provider_models = self.factory.get_models_for_provider(provider_name)
|
| 175 |
+
for model in provider_models:
|
| 176 |
+
models.append({
|
| 177 |
+
"id": model,
|
| 178 |
+
"object": "model",
|
| 179 |
+
"created": current_time,
|
| 180 |
+
"owned_by": provider_name
|
| 181 |
+
})
|
| 182 |
+
|
| 183 |
+
return {
|
| 184 |
+
"object": "list",
|
| 185 |
+
"data": models
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
# 全局路由器实例
|
| 190 |
+
_router: Optional[ProviderRouter] = None
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def get_provider_router() -> ProviderRouter:
|
| 194 |
+
"""获取全局提供商路由器"""
|
| 195 |
+
global _router
|
| 196 |
+
if _router is None:
|
| 197 |
+
_router = ProviderRouter()
|
| 198 |
+
# 确保工厂已初始化
|
| 199 |
+
_router.factory.initialize()
|
| 200 |
+
return _router
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def initialize_providers():
|
| 204 |
+
"""初始化提供商系统"""
|
| 205 |
+
logger.info("🚀 初始化提供商系统...")
|
| 206 |
+
router = get_provider_router()
|
| 207 |
+
logger.info("✅ 提供商系统初始化完成")
|
| 208 |
+
return router
|
app/providers/zai_provider.py
ADDED
|
@@ -0,0 +1,1152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
|
| 4 |
+
"""
|
| 5 |
+
Z.AI 提供商适配器
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
import time
|
| 10 |
+
import uuid
|
| 11 |
+
import httpx
|
| 12 |
+
import hmac
|
| 13 |
+
import hashlib
|
| 14 |
+
import base64
|
| 15 |
+
from urllib.parse import urlencode
|
| 16 |
+
import os
|
| 17 |
+
import uuid
|
| 18 |
+
import random
|
| 19 |
+
from datetime import datetime
|
| 20 |
+
from typing import Dict, List, Any, Optional, AsyncGenerator, Union
|
| 21 |
+
from app.utils.user_agent import get_random_user_agent
|
| 22 |
+
from app.providers.base import BaseProvider, ProviderConfig
|
| 23 |
+
from app.models.schemas import OpenAIRequest, Message
|
| 24 |
+
from app.core.config import settings
|
| 25 |
+
from app.utils.logger import get_logger
|
| 26 |
+
from app.utils.token_pool import get_token_pool
|
| 27 |
+
from app.utils.tool_call_handler import (
|
| 28 |
+
process_messages_with_tools,
|
| 29 |
+
parse_and_extract_tool_calls,
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
logger = get_logger()
|
| 33 |
+
|
| 34 |
+
def generate_uuid() -> str:
|
| 35 |
+
"""生成UUID v4"""
|
| 36 |
+
return str(uuid.uuid4())
|
| 37 |
+
|
| 38 |
+
def get_zai_dynamic_headers(chat_id: str = "") -> Dict[str, str]:
|
| 39 |
+
"""生成 Z.AI 特定的动态浏览器 headers"""
|
| 40 |
+
browser_choices = ["chrome", "chrome", "chrome", "edge", "edge", "firefox", "safari"]
|
| 41 |
+
browser_type = random.choice(browser_choices)
|
| 42 |
+
user_agent = get_random_user_agent(browser_type)
|
| 43 |
+
|
| 44 |
+
chrome_version = "139"
|
| 45 |
+
edge_version = "139"
|
| 46 |
+
|
| 47 |
+
if "Chrome/" in user_agent:
|
| 48 |
+
try:
|
| 49 |
+
chrome_version = user_agent.split("Chrome/")[1].split(".")[0]
|
| 50 |
+
except:
|
| 51 |
+
pass
|
| 52 |
+
|
| 53 |
+
if "Edg/" in user_agent:
|
| 54 |
+
try:
|
| 55 |
+
edge_version = user_agent.split("Edg/")[1].split(".")[0]
|
| 56 |
+
sec_ch_ua = f'"Microsoft Edge";v="{edge_version}", "Chromium";v="{chrome_version}", "Not_A Brand";v="24"'
|
| 57 |
+
except:
|
| 58 |
+
sec_ch_ua = f'"Not_A Brand";v="8", "Chromium";v="{chrome_version}", "Google Chrome";v="{chrome_version}"'
|
| 59 |
+
elif "Firefox/" in user_agent:
|
| 60 |
+
sec_ch_ua = None
|
| 61 |
+
else:
|
| 62 |
+
sec_ch_ua = f'"Not_A Brand";v="8", "Chromium";v="{chrome_version}", "Google Chrome";v="{chrome_version}"'
|
| 63 |
+
|
| 64 |
+
headers = {
|
| 65 |
+
"Content-Type": "application/json",
|
| 66 |
+
"Accept": "application/json, text/event-stream",
|
| 67 |
+
"Connection": "keep-alive",
|
| 68 |
+
"Cache-Control": "no-cache",
|
| 69 |
+
"User-Agent": user_agent,
|
| 70 |
+
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
| 71 |
+
"X-FE-Version": "prod-fe-1.0.98",
|
| 72 |
+
"Origin": "https://chat.z.ai",
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
if sec_ch_ua:
|
| 76 |
+
headers["sec-ch-ua"] = sec_ch_ua
|
| 77 |
+
headers["sec-ch-ua-mobile"] = "?0"
|
| 78 |
+
headers["sec-ch-ua-platform"] = '"Windows"'
|
| 79 |
+
|
| 80 |
+
if chat_id:
|
| 81 |
+
headers["Referer"] = f"https://chat.z.ai/c/{chat_id}"
|
| 82 |
+
else:
|
| 83 |
+
headers["Referer"] = "https://chat.z.ai/"
|
| 84 |
+
|
| 85 |
+
return headers
|
| 86 |
+
|
| 87 |
+
def _urlsafe_b64decode(data: str) -> bytes:
|
| 88 |
+
"""Decode a URL-safe base64 string with proper padding."""
|
| 89 |
+
if isinstance(data, str):
|
| 90 |
+
data_bytes = data.encode("utf-8")
|
| 91 |
+
else:
|
| 92 |
+
data_bytes = data
|
| 93 |
+
padding = b"=" * (-len(data_bytes) % 4)
|
| 94 |
+
return base64.urlsafe_b64decode(data_bytes + padding)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _decode_jwt_payload(token: str) -> Dict[str, Any]:
|
| 98 |
+
"""Decode JWT payload without verification to extract metadata."""
|
| 99 |
+
try:
|
| 100 |
+
parts = token.split(".")
|
| 101 |
+
if len(parts) < 2:
|
| 102 |
+
return {}
|
| 103 |
+
payload_raw = _urlsafe_b64decode(parts[1])
|
| 104 |
+
return json.loads(payload_raw.decode("utf-8", errors="ignore"))
|
| 105 |
+
except Exception:
|
| 106 |
+
return {}
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _extract_user_id_from_token(token: str) -> str:
|
| 110 |
+
"""Extract user_id from a JWT's payload. Fallback to 'guest'."""
|
| 111 |
+
payload = _decode_jwt_payload(token) if token else {}
|
| 112 |
+
for key in ("id", "user_id", "uid", "sub"):
|
| 113 |
+
val = payload.get(key)
|
| 114 |
+
if isinstance(val, (str, int)) and str(val):
|
| 115 |
+
return str(val)
|
| 116 |
+
return "guest"
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def generate_signature(message_text: str, request_id: str, timestamp_ms: int, user_id: str, secret: str = "junjie") -> str:
|
| 120 |
+
"""Dual-layer HMAC-SHA256 signature.
|
| 121 |
+
|
| 122 |
+
Layer1: derived key = HMAC(secret, window_index)
|
| 123 |
+
Layer2: signature = HMAC(derived_key, canonical_string)
|
| 124 |
+
canonical_string = "requestId,<id>,timestamp,<ts>,user_id,<uid>|<msg>|<ts>"
|
| 125 |
+
"""
|
| 126 |
+
r = str(timestamp_ms)
|
| 127 |
+
e = f"requestId,{request_id},timestamp,{timestamp_ms},user_id,{user_id}"
|
| 128 |
+
t = message_text or ""
|
| 129 |
+
# Add content_base64 processing for new signature algorithm
|
| 130 |
+
content_base64 = base64.b64encode(t.encode('utf-8')).decode('ascii')
|
| 131 |
+
i = f"{e}|{content_base64}|{r}"
|
| 132 |
+
|
| 133 |
+
window_index = timestamp_ms // (5 * 60 * 1000)
|
| 134 |
+
root_key = (secret or "junjie").encode("utf-8")
|
| 135 |
+
derived_hex = hmac.new(root_key, str(window_index).encode("utf-8"), hashlib.sha256).hexdigest()
|
| 136 |
+
signature = hmac.new(derived_hex.encode("utf-8"), i.encode("utf-8"), hashlib.sha256).hexdigest()
|
| 137 |
+
return signature
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
class ZAIProvider(BaseProvider):
|
| 141 |
+
"""Z.AI 提供商"""
|
| 142 |
+
|
| 143 |
+
def __init__(self):
|
| 144 |
+
config = ProviderConfig(
|
| 145 |
+
name="zai",
|
| 146 |
+
api_endpoint=settings.API_ENDPOINT,
|
| 147 |
+
timeout=30,
|
| 148 |
+
headers=get_zai_dynamic_headers()
|
| 149 |
+
)
|
| 150 |
+
super().__init__(config)
|
| 151 |
+
|
| 152 |
+
# Z.AI 特定配置
|
| 153 |
+
self.base_url = "https://chat.z.ai"
|
| 154 |
+
self.auth_url = f"{self.base_url}/api/v1/auths/"
|
| 155 |
+
|
| 156 |
+
# 模型映射
|
| 157 |
+
self.model_mapping = {
|
| 158 |
+
settings.GLM45_MODEL: "0727-360B-API", # GLM-4.5
|
| 159 |
+
settings.GLM45_THINKING_MODEL: "0727-360B-API", # GLM-4.5-Thinking
|
| 160 |
+
settings.GLM45_SEARCH_MODEL: "0727-360B-API", # GLM-4.5-Search
|
| 161 |
+
settings.GLM45_AIR_MODEL: "0727-106B-API", # GLM-4.5-Air
|
| 162 |
+
settings.GLM45V_MODEL: "glm-4.5v", # GLM-4.5V多模态
|
| 163 |
+
settings.GLM46_MODEL: "GLM-4-6-API-V1", # GLM-4.6
|
| 164 |
+
settings.GLM46_THINKING_MODEL: "GLM-4-6-API-V1", # GLM-4.6-Thinking
|
| 165 |
+
settings.GLM46_SEARCH_MODEL: "GLM-4-6-API-V1", # GLM-4.6-Search
|
| 166 |
+
settings.GLM46_ADVANCED_SEARCH_MODEL: "GLM-4-6-API-V1", # GLM-4.6-advanced-search
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
def get_supported_models(self) -> List[str]:
|
| 170 |
+
"""获取支持的模型列表"""
|
| 171 |
+
return [
|
| 172 |
+
settings.GLM45_MODEL,
|
| 173 |
+
settings.GLM45_THINKING_MODEL,
|
| 174 |
+
settings.GLM45_SEARCH_MODEL,
|
| 175 |
+
settings.GLM45_AIR_MODEL,
|
| 176 |
+
settings.GLM45V_MODEL,
|
| 177 |
+
settings.GLM46_MODEL,
|
| 178 |
+
settings.GLM46_THINKING_MODEL,
|
| 179 |
+
settings.GLM46_SEARCH_MODEL,
|
| 180 |
+
settings.GLM46_ADVANCED_SEARCH_MODEL,
|
| 181 |
+
]
|
| 182 |
+
|
| 183 |
+
async def get_token(self) -> str:
|
| 184 |
+
"""获取认证令牌"""
|
| 185 |
+
# 如果启用匿名模式,只尝试获取访客令牌
|
| 186 |
+
if settings.ANONYMOUS_MODE:
|
| 187 |
+
try:
|
| 188 |
+
headers = get_zai_dynamic_headers()
|
| 189 |
+
async with httpx.AsyncClient() as client:
|
| 190 |
+
response = await client.get(self.auth_url, headers=headers, timeout=10.0)
|
| 191 |
+
if response.status_code == 200:
|
| 192 |
+
data = response.json()
|
| 193 |
+
token = data.get("token", "")
|
| 194 |
+
if token:
|
| 195 |
+
# 判断令牌类型(通过检查邮箱或user_id)
|
| 196 |
+
email = data.get("email", "")
|
| 197 |
+
is_guest = "@guest.com" in email or "Guest-" in email
|
| 198 |
+
token_type = "匿名用户" if is_guest else "认证用户"
|
| 199 |
+
self.logger.debug(f"获取令牌成功 ({token_type}): {token[:20]}...")
|
| 200 |
+
return token
|
| 201 |
+
except Exception as e:
|
| 202 |
+
self.logger.warning(f"异步获取访客令牌失败: {e}")
|
| 203 |
+
|
| 204 |
+
# 匿名模式下,如果获取访客令牌失败,直接返回空
|
| 205 |
+
self.logger.error("❌ 匿名模式下获取访客令牌失败")
|
| 206 |
+
return ""
|
| 207 |
+
|
| 208 |
+
# 非匿名模式:首先使用token池获取备份令牌
|
| 209 |
+
token_pool = get_token_pool()
|
| 210 |
+
if token_pool:
|
| 211 |
+
token = token_pool.get_next_token()
|
| 212 |
+
if token:
|
| 213 |
+
self.logger.debug(f"从token池获取令牌: {token[:20]}...")
|
| 214 |
+
return token
|
| 215 |
+
|
| 216 |
+
# 如果token池为空或没有可用token,使用配置的AUTH_TOKEN
|
| 217 |
+
if settings.AUTH_TOKEN and settings.AUTH_TOKEN != "sk-your-api-key":
|
| 218 |
+
self.logger.debug(f"使用配置的AUTH_TOKEN")
|
| 219 |
+
return settings.AUTH_TOKEN
|
| 220 |
+
|
| 221 |
+
self.logger.error("❌ 无法获取有效的认证令牌")
|
| 222 |
+
return ""
|
| 223 |
+
|
| 224 |
+
def mark_token_failure(self, token: str, error: Exception = None):
|
| 225 |
+
"""标记token使用失败"""
|
| 226 |
+
token_pool = get_token_pool()
|
| 227 |
+
if token_pool:
|
| 228 |
+
token_pool.mark_token_failure(token, error)
|
| 229 |
+
|
| 230 |
+
async def upload_image(self, data_url: str, chat_id: str, token: str, user_id: str) -> Optional[Dict]:
|
| 231 |
+
"""上传 base64 编码的图片到 Z.AI 服务器
|
| 232 |
+
|
| 233 |
+
Args:
|
| 234 |
+
data_url: data:image/xxx;base64,... 格式的图片数据
|
| 235 |
+
chat_id: 当前对话ID
|
| 236 |
+
token: 认证令牌
|
| 237 |
+
user_id: 用户ID
|
| 238 |
+
|
| 239 |
+
Returns:
|
| 240 |
+
上传成功返回完整的文件信息字典,失败返回None
|
| 241 |
+
"""
|
| 242 |
+
if settings.ANONYMOUS_MODE or not data_url.startswith("data:"):
|
| 243 |
+
return None
|
| 244 |
+
|
| 245 |
+
try:
|
| 246 |
+
# 解析 data URL
|
| 247 |
+
header, encoded = data_url.split(",", 1)
|
| 248 |
+
mime_type = header.split(";")[0].split(":")[1] if ":" in header else "image/jpeg"
|
| 249 |
+
|
| 250 |
+
# 解码 base64 数据
|
| 251 |
+
image_data = base64.b64decode(encoded)
|
| 252 |
+
filename = str(uuid.uuid4())
|
| 253 |
+
|
| 254 |
+
self.logger.debug(f"📤 上传图片: {filename}, 大小: {len(image_data)} bytes")
|
| 255 |
+
|
| 256 |
+
# 构建上传请求 - 使用简化的请求头配置
|
| 257 |
+
upload_url = f"{self.base_url}/api/v1/files/"
|
| 258 |
+
headers = {
|
| 259 |
+
"Accept": "*/*",
|
| 260 |
+
"Accept-Language": "zh-CN,zh;q=0.9",
|
| 261 |
+
"Cache-Control": "no-cache",
|
| 262 |
+
"Connection": "keep-alive",
|
| 263 |
+
"Origin": f"{self.base_url}",
|
| 264 |
+
"Pragma": "no-cache",
|
| 265 |
+
"Referer": f"{self.base_url}/c/{chat_id}",
|
| 266 |
+
"Sec-Ch-Ua": '"Microsoft Edge";v="141", "Not?A_Brand";v="8", "Chromium";v="141"',
|
| 267 |
+
"Sec-Ch-Ua-Mobile": "?0",
|
| 268 |
+
"Sec-Ch-Ua-Platform": '"Windows"',
|
| 269 |
+
"Sec-Fetch-Dest": "empty",
|
| 270 |
+
"Sec-Fetch-Mode": "cors",
|
| 271 |
+
"Sec-Fetch-Site": "same-origin",
|
| 272 |
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36 Edg/141.0.0.0",
|
| 273 |
+
"Authorization": f"Bearer {token}",
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
# 使用 httpx 上传文件
|
| 277 |
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
| 278 |
+
files = {
|
| 279 |
+
"file": (filename, image_data, mime_type)
|
| 280 |
+
}
|
| 281 |
+
response = await client.post(upload_url, files=files, headers=headers)
|
| 282 |
+
|
| 283 |
+
if response.status_code == 200:
|
| 284 |
+
result = response.json()
|
| 285 |
+
file_id = result.get("id")
|
| 286 |
+
file_name = result.get("filename")
|
| 287 |
+
file_size = len(image_data)
|
| 288 |
+
|
| 289 |
+
self.logger.info(f"✅ 图片上传成功: {file_id}_{file_name}")
|
| 290 |
+
|
| 291 |
+
# 返回符合 Z.AI 格式的文件信息
|
| 292 |
+
current_timestamp = int(time.time())
|
| 293 |
+
return {
|
| 294 |
+
"type": "image",
|
| 295 |
+
"file": {
|
| 296 |
+
"id": file_id,
|
| 297 |
+
"user_id": user_id,
|
| 298 |
+
"hash": None,
|
| 299 |
+
"filename": file_name,
|
| 300 |
+
"data": {},
|
| 301 |
+
"meta": {
|
| 302 |
+
"name": file_name,
|
| 303 |
+
"content_type": mime_type,
|
| 304 |
+
"size": file_size,
|
| 305 |
+
"data": {},
|
| 306 |
+
},
|
| 307 |
+
"created_at": current_timestamp,
|
| 308 |
+
"updated_at": current_timestamp
|
| 309 |
+
},
|
| 310 |
+
"id": file_id,
|
| 311 |
+
"url": f"/api/v1/files/{file_id}/content",
|
| 312 |
+
"name": file_name,
|
| 313 |
+
"status": "uploaded",
|
| 314 |
+
"size": file_size,
|
| 315 |
+
"error": "",
|
| 316 |
+
"itemId": str(uuid.uuid4()),
|
| 317 |
+
"media": "image"
|
| 318 |
+
}
|
| 319 |
+
else:
|
| 320 |
+
self.logger.error(f"❌ 图片上传失败: {response.status_code} - {response.text}")
|
| 321 |
+
return None
|
| 322 |
+
|
| 323 |
+
except Exception as e:
|
| 324 |
+
self.logger.error(f"❌ 图片上传异常: {e}")
|
| 325 |
+
return None
|
| 326 |
+
|
| 327 |
+
async def transform_request(self, request: OpenAIRequest) -> Dict[str, Any]:
|
| 328 |
+
"""转换OpenAI请求为Z.AI格式"""
|
| 329 |
+
self.logger.info(f"🔄 转换 OpenAI 请求到 Z.AI 格式: {request.model}")
|
| 330 |
+
|
| 331 |
+
# 获取认证令牌
|
| 332 |
+
token = await self.get_token()
|
| 333 |
+
user_id = _extract_user_id_from_token(token)
|
| 334 |
+
|
| 335 |
+
# 生成 chat_id(用于图片上传)
|
| 336 |
+
chat_id = generate_uuid()
|
| 337 |
+
|
| 338 |
+
# 处理消息格式 - Z.AI 使用单独的 files 字段传递图片
|
| 339 |
+
messages = []
|
| 340 |
+
files = [] # 存储上传的图片文件信息
|
| 341 |
+
|
| 342 |
+
for msg in request.messages:
|
| 343 |
+
if isinstance(msg.content, str):
|
| 344 |
+
# 纯文本消息
|
| 345 |
+
messages.append({
|
| 346 |
+
"role": msg.role,
|
| 347 |
+
"content": msg.content
|
| 348 |
+
})
|
| 349 |
+
elif isinstance(msg.content, list):
|
| 350 |
+
# 多模态内容:分离文本和图片
|
| 351 |
+
text_parts = []
|
| 352 |
+
image_parts = [] # 存储图片引用
|
| 353 |
+
|
| 354 |
+
for part in msg.content:
|
| 355 |
+
if hasattr(part, 'type'):
|
| 356 |
+
if part.type == 'text' and hasattr(part, 'text'):
|
| 357 |
+
# 文本部分
|
| 358 |
+
text_parts.append(part.text or '')
|
| 359 |
+
elif part.type == 'image_url' and hasattr(part, 'image_url'):
|
| 360 |
+
# 图片部分 - 提取并上传
|
| 361 |
+
image_url = None
|
| 362 |
+
if hasattr(part.image_url, 'url'):
|
| 363 |
+
image_url = part.image_url.url
|
| 364 |
+
elif isinstance(part.image_url, dict) and 'url' in part.image_url:
|
| 365 |
+
image_url = part.image_url['url']
|
| 366 |
+
|
| 367 |
+
if image_url:
|
| 368 |
+
self.logger.debug(f"✅ 检测到图片: {image_url[:50]}...")
|
| 369 |
+
|
| 370 |
+
# 如果是 base64 编码的图片,上传并添加到 files 数组
|
| 371 |
+
if image_url.startswith("data:") and not settings.ANONYMOUS_MODE:
|
| 372 |
+
self.logger.info(f"🔄 上传 base64 图片到 Z.AI 服务器")
|
| 373 |
+
file_info = await self.upload_image(image_url, chat_id, token, user_id)
|
| 374 |
+
|
| 375 |
+
if file_info:
|
| 376 |
+
files.append(file_info)
|
| 377 |
+
self.logger.info(f"✅ 图片已添加到 files 数组")
|
| 378 |
+
|
| 379 |
+
# 在消息中保留图片引用
|
| 380 |
+
image_ref = f"{file_info['id']}_{file_info['name']}"
|
| 381 |
+
image_parts.append({
|
| 382 |
+
"type": "image_url",
|
| 383 |
+
"image_url": {
|
| 384 |
+
"url": image_ref
|
| 385 |
+
}
|
| 386 |
+
})
|
| 387 |
+
self.logger.debug(f"📎 图片引用: {image_ref}")
|
| 388 |
+
else:
|
| 389 |
+
# 上传失败,添加错误提示
|
| 390 |
+
self.logger.warning(f"⚠️ 图片上传失败")
|
| 391 |
+
text_parts.append("[系统提示: 图片上传失败]")
|
| 392 |
+
else:
|
| 393 |
+
# 非 base64 图片或匿名模式,直接使用原URL
|
| 394 |
+
if not settings.ANONYMOUS_MODE:
|
| 395 |
+
self.logger.warning(f"⚠️ 非 base64 图片或匿名模式,保留原始URL")
|
| 396 |
+
image_parts.append({
|
| 397 |
+
"type": "image_url",
|
| 398 |
+
"image_url": {"url": image_url}
|
| 399 |
+
})
|
| 400 |
+
elif isinstance(part, dict):
|
| 401 |
+
# 直接是字典格式的内容
|
| 402 |
+
if part.get('type') == 'text':
|
| 403 |
+
text_parts.append(part.get('text', ''))
|
| 404 |
+
elif part.get('type') == 'image_url':
|
| 405 |
+
image_url = part.get('image_url', {}).get('url', '')
|
| 406 |
+
if image_url:
|
| 407 |
+
self.logger.debug(f"✅ 检测到图片: {image_url[:50]}...")
|
| 408 |
+
|
| 409 |
+
# 如果是 base64 编码的图片,上传并添加到 files 数组
|
| 410 |
+
if image_url.startswith("data:") and not settings.ANONYMOUS_MODE:
|
| 411 |
+
self.logger.info(f"🔄 上传 base64 图片到 Z.AI 服务器")
|
| 412 |
+
file_info = await self.upload_image(image_url, chat_id, token, user_id)
|
| 413 |
+
|
| 414 |
+
if file_info:
|
| 415 |
+
files.append(file_info)
|
| 416 |
+
self.logger.info(f"✅ 图片已添加到 files 数组")
|
| 417 |
+
|
| 418 |
+
# 在消息中保留图片引用
|
| 419 |
+
image_ref = f"{file_info['id']}_{file_info['name']}"
|
| 420 |
+
image_parts.append({
|
| 421 |
+
"type": "image_url",
|
| 422 |
+
"image_url": {
|
| 423 |
+
"url": image_ref
|
| 424 |
+
}
|
| 425 |
+
})
|
| 426 |
+
self.logger.debug(f"📎 图片引用: {image_ref}")
|
| 427 |
+
else:
|
| 428 |
+
# 上传失败,添加错误提示
|
| 429 |
+
self.logger.warning(f"⚠️ 图片上传失败")
|
| 430 |
+
text_parts.append("[系统提示: 图片上传失败]")
|
| 431 |
+
else:
|
| 432 |
+
# 非 base64 图片或匿名模式
|
| 433 |
+
if not settings.ANONYMOUS_MODE:
|
| 434 |
+
self.logger.warning(f"⚠️ 非 base64 图片或匿名模式,保留原始URL")
|
| 435 |
+
image_parts.append({
|
| 436 |
+
"type": "image_url",
|
| 437 |
+
"image_url": {"url": image_url}
|
| 438 |
+
})
|
| 439 |
+
elif isinstance(part, str):
|
| 440 |
+
# 纯字符串部分
|
| 441 |
+
text_parts.append(part)
|
| 442 |
+
|
| 443 |
+
# 构建多模态消息内容
|
| 444 |
+
message_content = []
|
| 445 |
+
|
| 446 |
+
# 添加文本部分
|
| 447 |
+
combined_text = " ".join(text_parts).strip()
|
| 448 |
+
if combined_text:
|
| 449 |
+
message_content.append({
|
| 450 |
+
"type": "text",
|
| 451 |
+
"text": combined_text
|
| 452 |
+
})
|
| 453 |
+
|
| 454 |
+
# 添加图片部分(保持图片引用在消息中)
|
| 455 |
+
message_content.extend(image_parts)
|
| 456 |
+
|
| 457 |
+
# 只有在有内容时才添加消息
|
| 458 |
+
if message_content:
|
| 459 |
+
messages.append({
|
| 460 |
+
"role": msg.role,
|
| 461 |
+
"content": message_content # ✅ 多模态内容数组
|
| 462 |
+
})
|
| 463 |
+
|
| 464 |
+
# 确定请求的模型特性
|
| 465 |
+
# Extract last user message text for signing (提取最后一条用户消息的文本用于签名)
|
| 466 |
+
last_user_text = ""
|
| 467 |
+
for m in reversed(messages):
|
| 468 |
+
if m.get("role") == "user":
|
| 469 |
+
content = m.get("content")
|
| 470 |
+
if isinstance(content, str):
|
| 471 |
+
# 纯文本消息
|
| 472 |
+
last_user_text = content
|
| 473 |
+
break
|
| 474 |
+
elif isinstance(content, list):
|
| 475 |
+
# 多模态消息:只提取文本部分用于签名
|
| 476 |
+
texts = [p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") == "text"]
|
| 477 |
+
last_user_text = " ".join([t for t in texts if t]).strip()
|
| 478 |
+
break
|
| 479 |
+
requested_model = request.model
|
| 480 |
+
is_thinking = "-thinking" in requested_model.casefold()
|
| 481 |
+
is_search = "-search" in requested_model.casefold()
|
| 482 |
+
is_advanced_search = requested_model == settings.GLM46_ADVANCED_SEARCH_MODEL
|
| 483 |
+
is_air = "-air" in requested_model.casefold()
|
| 484 |
+
|
| 485 |
+
# 获取上游模型ID
|
| 486 |
+
upstream_model_id = self.model_mapping.get(requested_model, "0727-360B-API")
|
| 487 |
+
|
| 488 |
+
# ⚠️ 重要:在构建 body 之前处理工具调用!
|
| 489 |
+
# 处理工具支持 - 使用提示词注入方式
|
| 490 |
+
if settings.TOOL_SUPPORT and not is_thinking and request.tools:
|
| 491 |
+
tool_choice = getattr(request, 'tool_choice', 'auto') or 'auto'
|
| 492 |
+
messages = process_messages_with_tools(
|
| 493 |
+
messages=messages,
|
| 494 |
+
tools=request.tools,
|
| 495 |
+
tool_choice=tool_choice
|
| 496 |
+
)
|
| 497 |
+
self.logger.info(f"🔧 工具调用已通过提示词注入: {len(request.tools)} 个工具")
|
| 498 |
+
|
| 499 |
+
# 构建MCP服务器列表
|
| 500 |
+
mcp_servers = []
|
| 501 |
+
if is_advanced_search:
|
| 502 |
+
mcp_servers.append("advanced-search")
|
| 503 |
+
self.logger.info("🔍 检测到高级搜索模型,添加 advanced-search MCP 服务器")
|
| 504 |
+
elif is_search and "-4.5" in requested_model:
|
| 505 |
+
mcp_servers.append("deep-web-search")
|
| 506 |
+
self.logger.info("🔍 检测到搜索模型,添加 deep-web-search MCP 服务器")
|
| 507 |
+
|
| 508 |
+
# 构建上游请求体(chat_id 已在前面生成)
|
| 509 |
+
|
| 510 |
+
body = {
|
| 511 |
+
"stream": True, # 总是使用流式
|
| 512 |
+
"model": upstream_model_id,
|
| 513 |
+
"messages": messages, # ✅ messages 已经包含工具提示词
|
| 514 |
+
"signature_prompt": last_user_text, # 用于签名的最后一条用户消息
|
| 515 |
+
"files": files, # 图片文件数组
|
| 516 |
+
"params": {},
|
| 517 |
+
"features": {
|
| 518 |
+
"image_generation": False,
|
| 519 |
+
"web_search": is_search or is_advanced_search,
|
| 520 |
+
"auto_web_search": is_search or is_advanced_search,
|
| 521 |
+
"preview_mode": is_search or is_advanced_search,
|
| 522 |
+
"flags": [],
|
| 523 |
+
"features": [
|
| 524 |
+
{
|
| 525 |
+
"type": "mcp",
|
| 526 |
+
"server": "vibe-coding",
|
| 527 |
+
"status": "hidden"
|
| 528 |
+
},
|
| 529 |
+
{
|
| 530 |
+
"type": "mcp",
|
| 531 |
+
"server": "ppt-maker",
|
| 532 |
+
"status": "hidden"
|
| 533 |
+
},
|
| 534 |
+
{
|
| 535 |
+
"type": "mcp",
|
| 536 |
+
"server": "image-search",
|
| 537 |
+
"status": "hidden"
|
| 538 |
+
},
|
| 539 |
+
{
|
| 540 |
+
"type": "mcp",
|
| 541 |
+
"server": "deep-research",
|
| 542 |
+
"status": "hidden"
|
| 543 |
+
},
|
| 544 |
+
{
|
| 545 |
+
"type": "tool_selector",
|
| 546 |
+
"server": "tool_selector",
|
| 547 |
+
"status": "hidden"
|
| 548 |
+
},
|
| 549 |
+
{
|
| 550 |
+
"type": "mcp",
|
| 551 |
+
"server": "advanced-search",
|
| 552 |
+
"status": "hidden"
|
| 553 |
+
}
|
| 554 |
+
],
|
| 555 |
+
"enable_thinking": is_thinking,
|
| 556 |
+
},
|
| 557 |
+
"background_tasks": {
|
| 558 |
+
"title_generation": False,
|
| 559 |
+
"tags_generation": False,
|
| 560 |
+
},
|
| 561 |
+
"mcp_servers": mcp_servers,
|
| 562 |
+
"variables": {
|
| 563 |
+
"{{USER_NAME}}": "Guest",
|
| 564 |
+
"{{USER_LOCATION}}": "Unknown",
|
| 565 |
+
"{{CURRENT_DATETIME}}": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
| 566 |
+
"{{CURRENT_DATE}}": datetime.now().strftime("%Y-%m-%d"),
|
| 567 |
+
"{{CURRENT_TIME}}": datetime.now().strftime("%H:%M:%S"),
|
| 568 |
+
"{{CURRENT_WEEKDAY}}": datetime.now().strftime("%A"),
|
| 569 |
+
"{{CURRENT_TIMEZONE}}": "Asia/Shanghai",
|
| 570 |
+
"{{USER_LANGUAGE}}": "zh-CN",
|
| 571 |
+
},
|
| 572 |
+
"model_item": {
|
| 573 |
+
"id": upstream_model_id,
|
| 574 |
+
"name": requested_model,
|
| 575 |
+
"owned_by": "z.ai"
|
| 576 |
+
},
|
| 577 |
+
"chat_id": chat_id,
|
| 578 |
+
"id": generate_uuid(),
|
| 579 |
+
}
|
| 580 |
+
|
| 581 |
+
# 不传递 tools 给上游,使用提示工程方式
|
| 582 |
+
body["tools"] = None
|
| 583 |
+
|
| 584 |
+
# 处理其他参数
|
| 585 |
+
if request.temperature is not None:
|
| 586 |
+
body["params"]["temperature"] = request.temperature
|
| 587 |
+
if request.max_tokens is not None:
|
| 588 |
+
body["params"]["max_tokens"] = request.max_tokens
|
| 589 |
+
|
| 590 |
+
# 构建请求头
|
| 591 |
+
headers = get_zai_dynamic_headers(chat_id)
|
| 592 |
+
if token:
|
| 593 |
+
headers["Authorization"] = f"Bearer {token}"
|
| 594 |
+
|
| 595 |
+
# Dual-layer HMAC signing metadata and header
|
| 596 |
+
user_id = _extract_user_id_from_token(token)
|
| 597 |
+
timestamp_ms = int(time.time() * 1000)
|
| 598 |
+
request_id = generate_uuid()
|
| 599 |
+
secret = os.getenv("ZAI_SIGNING_SECRET", "junjie") or "junjie"
|
| 600 |
+
signature = generate_signature(
|
| 601 |
+
message_text=last_user_text,
|
| 602 |
+
request_id=request_id,
|
| 603 |
+
timestamp_ms=timestamp_ms,
|
| 604 |
+
user_id=user_id,
|
| 605 |
+
secret=secret,
|
| 606 |
+
)
|
| 607 |
+
query_params = {
|
| 608 |
+
"timestamp": timestamp_ms,
|
| 609 |
+
"requestId": request_id,
|
| 610 |
+
"user_id": user_id,
|
| 611 |
+
"token": token or "",
|
| 612 |
+
"current_url": f"https://chat.z.ai/c/{chat_id}",
|
| 613 |
+
"pathname": f"/c/{chat_id}",
|
| 614 |
+
"signature_timestamp": timestamp_ms,
|
| 615 |
+
}
|
| 616 |
+
signed_url = f"{self.config.api_endpoint}?{urlencode(query_params)}"
|
| 617 |
+
headers["X-Signature"] = signature
|
| 618 |
+
|
| 619 |
+
# 存储当前token用于错误处理
|
| 620 |
+
self._current_token = token
|
| 621 |
+
|
| 622 |
+
return {
|
| 623 |
+
"url": signed_url,
|
| 624 |
+
"headers": headers,
|
| 625 |
+
"body": body,
|
| 626 |
+
"token": token,
|
| 627 |
+
"chat_id": chat_id,
|
| 628 |
+
"model": requested_model
|
| 629 |
+
}
|
| 630 |
+
|
| 631 |
+
async def chat_completion(
|
| 632 |
+
self,
|
| 633 |
+
request: OpenAIRequest,
|
| 634 |
+
**kwargs
|
| 635 |
+
) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
|
| 636 |
+
"""聊天完成接口"""
|
| 637 |
+
self.log_request(request)
|
| 638 |
+
|
| 639 |
+
try:
|
| 640 |
+
# 转换请求
|
| 641 |
+
transformed = await self.transform_request(request)
|
| 642 |
+
|
| 643 |
+
# 根据请求类型返回响应
|
| 644 |
+
if request.stream:
|
| 645 |
+
# 流式响应
|
| 646 |
+
return self._create_stream_response(request, transformed)
|
| 647 |
+
else:
|
| 648 |
+
# 非流式响应
|
| 649 |
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
| 650 |
+
response = await client.post(
|
| 651 |
+
transformed["url"],
|
| 652 |
+
headers=transformed["headers"],
|
| 653 |
+
json=transformed["body"]
|
| 654 |
+
)
|
| 655 |
+
|
| 656 |
+
if not response.is_success:
|
| 657 |
+
error_msg = f"Z.AI API 错误: {response.status_code}"
|
| 658 |
+
self.log_response(False, error_msg)
|
| 659 |
+
return self.handle_error(Exception(error_msg))
|
| 660 |
+
|
| 661 |
+
return await self.transform_response(response, request, transformed)
|
| 662 |
+
|
| 663 |
+
except Exception as e:
|
| 664 |
+
self.log_response(False, str(e))
|
| 665 |
+
return self.handle_error(e, "请求处理")
|
| 666 |
+
|
| 667 |
+
|
| 668 |
+
async def _create_stream_response(
|
| 669 |
+
self,
|
| 670 |
+
request: OpenAIRequest,
|
| 671 |
+
transformed: Dict[str, Any]
|
| 672 |
+
) -> AsyncGenerator[str, None]:
|
| 673 |
+
|
| 674 |
+
current_token = transformed.get("token", "")
|
| 675 |
+
try:
|
| 676 |
+
async with httpx.AsyncClient(
|
| 677 |
+
timeout=60.0,
|
| 678 |
+
http2=True,
|
| 679 |
+
) as client:
|
| 680 |
+
self.logger.info(f"🎯 发送请求到 Z.AI: {transformed['url']}")
|
| 681 |
+
# self.logger.info(f"📦 请求体 model: {transformed['body']['model']}")
|
| 682 |
+
# self.logger.info(f"📦 请求体 messages: {json.dumps(transformed['body']['messages'], ensure_ascii=False)}")
|
| 683 |
+
async with client.stream(
|
| 684 |
+
"POST",
|
| 685 |
+
transformed["url"],
|
| 686 |
+
json=transformed["body"],
|
| 687 |
+
headers=transformed["headers"],
|
| 688 |
+
) as response:
|
| 689 |
+
if response.status_code != 200:
|
| 690 |
+
self.logger.error(f"❌ 上游返回错误: {response.status_code}")
|
| 691 |
+
error_text = await response.aread()
|
| 692 |
+
error_msg = error_text.decode('utf-8', errors='ignore')
|
| 693 |
+
if error_msg:
|
| 694 |
+
self.logger.error(f"❌ 错误详情: {error_msg}")
|
| 695 |
+
error_response = {
|
| 696 |
+
"error": {
|
| 697 |
+
"message": f"Upstream error: {response.status_code}",
|
| 698 |
+
"type": "upstream_error",
|
| 699 |
+
"code": response.status_code
|
| 700 |
+
}
|
| 701 |
+
}
|
| 702 |
+
yield f"data: {json.dumps(error_response)}\n\n"
|
| 703 |
+
yield "data: [DONE]\n\n"
|
| 704 |
+
return
|
| 705 |
+
|
| 706 |
+
if current_token and not settings.ANONYMOUS_MODE:
|
| 707 |
+
token_pool = get_token_pool()
|
| 708 |
+
if token_pool:
|
| 709 |
+
token_pool.mark_token_success(current_token)
|
| 710 |
+
|
| 711 |
+
chat_id = transformed["chat_id"]
|
| 712 |
+
model = transformed["model"]
|
| 713 |
+
async for chunk in self._handle_stream_response(response, chat_id, model, request, transformed):
|
| 714 |
+
yield chunk
|
| 715 |
+
return
|
| 716 |
+
except Exception as e:
|
| 717 |
+
self.logger.error(f"❌ 流处理错误: {e}")
|
| 718 |
+
import traceback
|
| 719 |
+
self.logger.error(traceback.format_exc())
|
| 720 |
+
if current_token and not settings.ANONYMOUS_MODE:
|
| 721 |
+
self.mark_token_failure(current_token, e)
|
| 722 |
+
error_response = {
|
| 723 |
+
"error": {
|
| 724 |
+
"message": str(e),
|
| 725 |
+
"type": "stream_error"
|
| 726 |
+
}
|
| 727 |
+
}
|
| 728 |
+
yield f"data: {json.dumps(error_response)}\n\n"
|
| 729 |
+
yield "data: [DONE]\n\n"
|
| 730 |
+
return
|
| 731 |
+
|
| 732 |
+
async def transform_response(
|
| 733 |
+
self,
|
| 734 |
+
response: httpx.Response,
|
| 735 |
+
request: OpenAIRequest,
|
| 736 |
+
transformed: Dict[str, Any]
|
| 737 |
+
) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
|
| 738 |
+
"""转换Z.AI响应为OpenAI格式"""
|
| 739 |
+
chat_id = transformed["chat_id"]
|
| 740 |
+
model = transformed["model"]
|
| 741 |
+
|
| 742 |
+
if request.stream:
|
| 743 |
+
return self._handle_stream_response(response, chat_id, model, request, transformed)
|
| 744 |
+
else:
|
| 745 |
+
return await self._handle_non_stream_response(response, chat_id, model)
|
| 746 |
+
|
| 747 |
+
async def _handle_stream_response(
|
| 748 |
+
self,
|
| 749 |
+
response: httpx.Response,
|
| 750 |
+
chat_id: str,
|
| 751 |
+
model: str,
|
| 752 |
+
request: OpenAIRequest,
|
| 753 |
+
transformed: Dict[str, Any]
|
| 754 |
+
) -> AsyncGenerator[str, None]:
|
| 755 |
+
"""处理Z.AI流式响应"""
|
| 756 |
+
self.logger.info(f"✅ Z.AI 响应成功,开始处理 SSE 流")
|
| 757 |
+
|
| 758 |
+
# 检查是否启用了工具调用 (通过检查原始请求)
|
| 759 |
+
has_tools = settings.TOOL_SUPPORT and request.tools is not None and len(request.tools) > 0
|
| 760 |
+
|
| 761 |
+
# 累积内容缓冲区,用于提取工具调用
|
| 762 |
+
buffered_content = ""
|
| 763 |
+
has_sent_role = False
|
| 764 |
+
|
| 765 |
+
# 处理状态
|
| 766 |
+
has_thinking = False
|
| 767 |
+
thinking_signature = None
|
| 768 |
+
|
| 769 |
+
# 处理SSE流
|
| 770 |
+
buffer = ""
|
| 771 |
+
line_count = 0
|
| 772 |
+
self.logger.debug("📡 开始接收 SSE 流数据...")
|
| 773 |
+
|
| 774 |
+
try:
|
| 775 |
+
async for line in response.aiter_lines():
|
| 776 |
+
line_count += 1
|
| 777 |
+
if not line:
|
| 778 |
+
continue
|
| 779 |
+
|
| 780 |
+
# 累积到buffer处理完整的数据行
|
| 781 |
+
buffer += line + "\n"
|
| 782 |
+
|
| 783 |
+
# 检查是否有完整的data行
|
| 784 |
+
while "\n" in buffer:
|
| 785 |
+
current_line, buffer = buffer.split("\n", 1)
|
| 786 |
+
if not current_line.strip():
|
| 787 |
+
continue
|
| 788 |
+
|
| 789 |
+
if current_line.startswith("data:"):
|
| 790 |
+
chunk_str = current_line[5:].strip()
|
| 791 |
+
if not chunk_str or chunk_str == "[DONE]":
|
| 792 |
+
if chunk_str == "[DONE]":
|
| 793 |
+
yield "data: [DONE]\n\n"
|
| 794 |
+
continue
|
| 795 |
+
|
| 796 |
+
self.logger.debug(f"📦 解析数据块: {chunk_str[:1000]}..." if len(chunk_str) > 1000 else f"📦 解析数据块: {chunk_str}")
|
| 797 |
+
|
| 798 |
+
try:
|
| 799 |
+
chunk = json.loads(chunk_str)
|
| 800 |
+
|
| 801 |
+
if chunk.get("type") == "chat:completion":
|
| 802 |
+
data = chunk.get("data", {})
|
| 803 |
+
phase = data.get("phase")
|
| 804 |
+
|
| 805 |
+
# 记录每个阶段(只在阶段变化时记录)
|
| 806 |
+
if phase and phase != getattr(self, '_last_phase', None):
|
| 807 |
+
self.logger.info(f"📈 SSE 阶段: {phase}")
|
| 808 |
+
self._last_phase = phase
|
| 809 |
+
|
| 810 |
+
# 处理思考内容
|
| 811 |
+
if phase == "thinking":
|
| 812 |
+
if not has_thinking:
|
| 813 |
+
has_thinking = True
|
| 814 |
+
# 发送初始角色
|
| 815 |
+
role_chunk = self.create_openai_chunk(
|
| 816 |
+
chat_id,
|
| 817 |
+
model,
|
| 818 |
+
{"role": "assistant"}
|
| 819 |
+
)
|
| 820 |
+
yield await self.format_sse_chunk(role_chunk)
|
| 821 |
+
|
| 822 |
+
delta_content = data.get("delta_content", "")
|
| 823 |
+
if delta_content:
|
| 824 |
+
# 处理思考内容格式
|
| 825 |
+
if delta_content.startswith("<details"):
|
| 826 |
+
content = (
|
| 827 |
+
delta_content.split("</summary>\n>")[-1].strip()
|
| 828 |
+
if "</summary>\n>" in delta_content
|
| 829 |
+
else delta_content
|
| 830 |
+
)
|
| 831 |
+
else:
|
| 832 |
+
content = delta_content
|
| 833 |
+
|
| 834 |
+
thinking_chunk = self.create_openai_chunk(
|
| 835 |
+
chat_id,
|
| 836 |
+
model,
|
| 837 |
+
{
|
| 838 |
+
"role": "assistant",
|
| 839 |
+
"reasoning_content": content
|
| 840 |
+
}
|
| 841 |
+
)
|
| 842 |
+
yield await self.format_sse_chunk(thinking_chunk)
|
| 843 |
+
|
| 844 |
+
# 处理答案内容
|
| 845 |
+
elif phase == "answer":
|
| 846 |
+
delta_content = data.get("delta_content", "")
|
| 847 |
+
edit_content = data.get("edit_content", "")
|
| 848 |
+
|
| 849 |
+
# 累积内容(用于工具调用提取)
|
| 850 |
+
if delta_content:
|
| 851 |
+
buffered_content += delta_content
|
| 852 |
+
elif edit_content:
|
| 853 |
+
buffered_content = edit_content
|
| 854 |
+
|
| 855 |
+
# 如果包含 usage,说明流式结束
|
| 856 |
+
if data.get("usage"):
|
| 857 |
+
usage = data["usage"]
|
| 858 |
+
self.logger.info(f"📦 完成响应 - 使用统计: {json.dumps(usage)}")
|
| 859 |
+
|
| 860 |
+
# 尝试从缓冲区提取 tool_calls
|
| 861 |
+
tool_calls = None
|
| 862 |
+
cleaned_content = buffered_content
|
| 863 |
+
|
| 864 |
+
if has_tools:
|
| 865 |
+
tool_calls, cleaned_content = parse_and_extract_tool_calls(buffered_content)
|
| 866 |
+
|
| 867 |
+
if tool_calls:
|
| 868 |
+
# 发现工具调用
|
| 869 |
+
self.logger.info(f"🔧 从响应中提取到 {len(tool_calls)} 个工具调用")
|
| 870 |
+
|
| 871 |
+
if not has_sent_role:
|
| 872 |
+
role_chunk = self.create_openai_chunk(
|
| 873 |
+
chat_id,
|
| 874 |
+
model,
|
| 875 |
+
{"role": "assistant"}
|
| 876 |
+
)
|
| 877 |
+
yield await self.format_sse_chunk(role_chunk)
|
| 878 |
+
has_sent_role = True
|
| 879 |
+
|
| 880 |
+
# 发送工具调用
|
| 881 |
+
for idx, tc in enumerate(tool_calls):
|
| 882 |
+
tool_chunk = self.create_openai_chunk(
|
| 883 |
+
chat_id,
|
| 884 |
+
model,
|
| 885 |
+
{
|
| 886 |
+
"role": "assistant",
|
| 887 |
+
"tool_calls": [{
|
| 888 |
+
"index": idx,
|
| 889 |
+
"id": tc.get("id", f"call_{idx}"),
|
| 890 |
+
"type": "function",
|
| 891 |
+
"function": {
|
| 892 |
+
"name": tc.get("function", {}).get("name", ""),
|
| 893 |
+
"arguments": tc.get("function", {}).get("arguments", "")
|
| 894 |
+
}
|
| 895 |
+
}]
|
| 896 |
+
}
|
| 897 |
+
)
|
| 898 |
+
yield await self.format_sse_chunk(tool_chunk)
|
| 899 |
+
|
| 900 |
+
# 发送完成块
|
| 901 |
+
finish_chunk = self.create_openai_chunk(
|
| 902 |
+
chat_id,
|
| 903 |
+
model,
|
| 904 |
+
{"role": "assistant"},
|
| 905 |
+
"tool_calls"
|
| 906 |
+
)
|
| 907 |
+
finish_chunk["usage"] = usage
|
| 908 |
+
yield await self.format_sse_chunk(finish_chunk)
|
| 909 |
+
yield "data: [DONE]\n\n"
|
| 910 |
+
|
| 911 |
+
else:
|
| 912 |
+
# 没有工具调用,正常返回内容
|
| 913 |
+
# 处理思考结束和答案开始
|
| 914 |
+
if edit_content and "</details>\n" in edit_content:
|
| 915 |
+
if has_thinking:
|
| 916 |
+
# 发送思考签名
|
| 917 |
+
thinking_signature = str(int(time.time() * 1000))
|
| 918 |
+
sig_chunk = self.create_openai_chunk(
|
| 919 |
+
chat_id,
|
| 920 |
+
model,
|
| 921 |
+
{
|
| 922 |
+
"role": "assistant",
|
| 923 |
+
"thinking": {
|
| 924 |
+
"content": "",
|
| 925 |
+
"signature": thinking_signature,
|
| 926 |
+
}
|
| 927 |
+
}
|
| 928 |
+
)
|
| 929 |
+
yield await self.format_sse_chunk(sig_chunk)
|
| 930 |
+
|
| 931 |
+
# 提取答案内容
|
| 932 |
+
cleaned_content = edit_content.split("</details>\n")[-1]
|
| 933 |
+
|
| 934 |
+
if not has_sent_role and not has_thinking:
|
| 935 |
+
role_chunk = self.create_openai_chunk(
|
| 936 |
+
chat_id,
|
| 937 |
+
model,
|
| 938 |
+
{"role": "assistant"}
|
| 939 |
+
)
|
| 940 |
+
yield await self.format_sse_chunk(role_chunk)
|
| 941 |
+
has_sent_role = True
|
| 942 |
+
|
| 943 |
+
if cleaned_content:
|
| 944 |
+
content_chunk = self.create_openai_chunk(
|
| 945 |
+
chat_id,
|
| 946 |
+
model,
|
| 947 |
+
{
|
| 948 |
+
"role": "assistant",
|
| 949 |
+
"content": cleaned_content
|
| 950 |
+
}
|
| 951 |
+
)
|
| 952 |
+
yield await self.format_sse_chunk(content_chunk)
|
| 953 |
+
|
| 954 |
+
finish_chunk = self.create_openai_chunk(
|
| 955 |
+
chat_id,
|
| 956 |
+
model,
|
| 957 |
+
{"role": "assistant", "content": ""},
|
| 958 |
+
"stop"
|
| 959 |
+
)
|
| 960 |
+
finish_chunk["usage"] = usage
|
| 961 |
+
yield await self.format_sse_chunk(finish_chunk)
|
| 962 |
+
yield "data: [DONE]\n\n"
|
| 963 |
+
else:
|
| 964 |
+
# 流式过程中,输出答案内容(即使有工具调用也要显示)
|
| 965 |
+
# 处理思考结束和答案开始
|
| 966 |
+
if edit_content and "</details>\n" in edit_content:
|
| 967 |
+
if has_thinking:
|
| 968 |
+
# 发送思考签名
|
| 969 |
+
thinking_signature = str(int(time.time() * 1000))
|
| 970 |
+
sig_chunk = self.create_openai_chunk(
|
| 971 |
+
chat_id,
|
| 972 |
+
model,
|
| 973 |
+
{
|
| 974 |
+
"role": "assistant",
|
| 975 |
+
"thinking": {
|
| 976 |
+
"content": "",
|
| 977 |
+
"signature": thinking_signature,
|
| 978 |
+
}
|
| 979 |
+
}
|
| 980 |
+
)
|
| 981 |
+
yield await self.format_sse_chunk(sig_chunk)
|
| 982 |
+
|
| 983 |
+
# 提取答案内容
|
| 984 |
+
content_after = edit_content.split("</details>\n")[-1]
|
| 985 |
+
if content_after:
|
| 986 |
+
content_chunk = self.create_openai_chunk(
|
| 987 |
+
chat_id,
|
| 988 |
+
model,
|
| 989 |
+
{
|
| 990 |
+
"role": "assistant",
|
| 991 |
+
"content": content_after
|
| 992 |
+
}
|
| 993 |
+
)
|
| 994 |
+
yield await self.format_sse_chunk(content_chunk)
|
| 995 |
+
|
| 996 |
+
# 处理增量内容
|
| 997 |
+
elif delta_content:
|
| 998 |
+
if not has_sent_role and not has_thinking:
|
| 999 |
+
role_chunk = self.create_openai_chunk(
|
| 1000 |
+
chat_id,
|
| 1001 |
+
model,
|
| 1002 |
+
{"role": "assistant"}
|
| 1003 |
+
)
|
| 1004 |
+
yield await self.format_sse_chunk(role_chunk)
|
| 1005 |
+
has_sent_role = True
|
| 1006 |
+
|
| 1007 |
+
content_chunk = self.create_openai_chunk(
|
| 1008 |
+
chat_id,
|
| 1009 |
+
model,
|
| 1010 |
+
{
|
| 1011 |
+
"role": "assistant",
|
| 1012 |
+
"content": delta_content
|
| 1013 |
+
}
|
| 1014 |
+
)
|
| 1015 |
+
output_data = await self.format_sse_chunk(content_chunk)
|
| 1016 |
+
self.logger.debug(f"➡️ 输出内容块到客户端: {output_data}")
|
| 1017 |
+
yield output_data
|
| 1018 |
+
|
| 1019 |
+
except json.JSONDecodeError as e:
|
| 1020 |
+
self.logger.debug(f"❌ JSON解析错误: {e}, 内容: {chunk_str[:1000]}")
|
| 1021 |
+
except Exception as e:
|
| 1022 |
+
self.logger.error(f"❌ 处理chunk错误: {e}")
|
| 1023 |
+
|
| 1024 |
+
self.logger.info(f"✅ SSE 流处理完成,共处理 {line_count} 行数据")
|
| 1025 |
+
|
| 1026 |
+
except Exception as e:
|
| 1027 |
+
self.logger.error(f"❌ 流式响应处理错误: {e}")
|
| 1028 |
+
import traceback
|
| 1029 |
+
self.logger.error(traceback.format_exc())
|
| 1030 |
+
# 发送错误结束块
|
| 1031 |
+
yield await self.format_sse_chunk(
|
| 1032 |
+
self.create_openai_chunk(chat_id, model, {}, "stop")
|
| 1033 |
+
)
|
| 1034 |
+
yield "data: [DONE]\n\n"
|
| 1035 |
+
|
| 1036 |
+
async def _handle_non_stream_response(
|
| 1037 |
+
self,
|
| 1038 |
+
response: httpx.Response,
|
| 1039 |
+
chat_id: str,
|
| 1040 |
+
model: str
|
| 1041 |
+
) -> Dict[str, Any]:
|
| 1042 |
+
"""处理非流式响应
|
| 1043 |
+
|
| 1044 |
+
说明:上游始终以 SSE 形式返回(transform_request 固定 stream=True),
|
| 1045 |
+
因此这里需要聚合 aiter_lines() 的 data: 块,提取 usage、思考内容与答案内容,
|
| 1046 |
+
并最终产出一次性 OpenAI 格式响应。
|
| 1047 |
+
"""
|
| 1048 |
+
final_content = ""
|
| 1049 |
+
reasoning_content = ""
|
| 1050 |
+
usage_info: Dict[str, int] = {
|
| 1051 |
+
"prompt_tokens": 0,
|
| 1052 |
+
"completion_tokens": 0,
|
| 1053 |
+
"total_tokens": 0,
|
| 1054 |
+
}
|
| 1055 |
+
|
| 1056 |
+
try:
|
| 1057 |
+
async for line in response.aiter_lines():
|
| 1058 |
+
if not line:
|
| 1059 |
+
continue
|
| 1060 |
+
|
| 1061 |
+
line = line.strip()
|
| 1062 |
+
|
| 1063 |
+
# 仅处理以 data: 开头的 SSE 行,其余行尝试作为错误/JSON 忽略
|
| 1064 |
+
if not line.startswith("data:"):
|
| 1065 |
+
# 尝试解析为错误 JSON
|
| 1066 |
+
try:
|
| 1067 |
+
maybe_err = json.loads(line)
|
| 1068 |
+
if isinstance(maybe_err, dict) and (
|
| 1069 |
+
"error" in maybe_err or "code" in maybe_err or "message" in maybe_err
|
| 1070 |
+
):
|
| 1071 |
+
# 统一错误处理
|
| 1072 |
+
msg = (
|
| 1073 |
+
(maybe_err.get("error") or {}).get("message")
|
| 1074 |
+
if isinstance(maybe_err.get("error"), dict)
|
| 1075 |
+
else maybe_err.get("message")
|
| 1076 |
+
) or "上游返回错误"
|
| 1077 |
+
return self.handle_error(Exception(msg), "API响应")
|
| 1078 |
+
except Exception:
|
| 1079 |
+
pass
|
| 1080 |
+
continue
|
| 1081 |
+
|
| 1082 |
+
data_str = line[5:].strip()
|
| 1083 |
+
if not data_str or data_str in ("[DONE]", "DONE", "done"):
|
| 1084 |
+
continue
|
| 1085 |
+
|
| 1086 |
+
# 解析 SSE 数据块
|
| 1087 |
+
try:
|
| 1088 |
+
chunk = json.loads(data_str)
|
| 1089 |
+
except json.JSONDecodeError:
|
| 1090 |
+
continue
|
| 1091 |
+
|
| 1092 |
+
if chunk.get("type") != "chat:completion":
|
| 1093 |
+
continue
|
| 1094 |
+
|
| 1095 |
+
data = chunk.get("data", {})
|
| 1096 |
+
phase = data.get("phase")
|
| 1097 |
+
delta_content = data.get("delta_content", "")
|
| 1098 |
+
edit_content = data.get("edit_content", "")
|
| 1099 |
+
|
| 1100 |
+
# 记录用量(通常在最后块中出现,但这里每次覆盖保持最新)
|
| 1101 |
+
if data.get("usage"):
|
| 1102 |
+
try:
|
| 1103 |
+
usage_info = data["usage"]
|
| 1104 |
+
except Exception:
|
| 1105 |
+
pass
|
| 1106 |
+
|
| 1107 |
+
# 思考阶段聚合(去除 <details><summary>... 包裹头)
|
| 1108 |
+
if phase == "thinking":
|
| 1109 |
+
if delta_content:
|
| 1110 |
+
if delta_content.startswith("<details"):
|
| 1111 |
+
cleaned = (
|
| 1112 |
+
delta_content.split("</summary>\n>")[-1].strip()
|
| 1113 |
+
if "</summary>\n>" in delta_content
|
| 1114 |
+
else delta_content
|
| 1115 |
+
)
|
| 1116 |
+
else:
|
| 1117 |
+
cleaned = delta_content
|
| 1118 |
+
reasoning_content += cleaned
|
| 1119 |
+
|
| 1120 |
+
# 答案阶段聚合
|
| 1121 |
+
elif phase == "answer":
|
| 1122 |
+
# 当 edit_content 同时包含思考结束标记与答案时,提取答案部分
|
| 1123 |
+
if edit_content and "</details>\n" in edit_content:
|
| 1124 |
+
content_after = edit_content.split("</details>\n")[-1]
|
| 1125 |
+
if content_after:
|
| 1126 |
+
final_content += content_after
|
| 1127 |
+
elif delta_content:
|
| 1128 |
+
final_content += delta_content
|
| 1129 |
+
|
| 1130 |
+
except Exception as e:
|
| 1131 |
+
self.logger.error(f"❌ 非流式响应处理错误: {e}")
|
| 1132 |
+
import traceback
|
| 1133 |
+
self.logger.error(traceback.format_exc())
|
| 1134 |
+
# 返回统一错误响应
|
| 1135 |
+
return self.handle_error(e, "非流式聚合")
|
| 1136 |
+
|
| 1137 |
+
# 清理并返回
|
| 1138 |
+
final_content = (final_content or "").strip()
|
| 1139 |
+
reasoning_content = (reasoning_content or "").strip()
|
| 1140 |
+
|
| 1141 |
+
# 若没有聚合到答案,但有思考内容,则保底返回思考内容
|
| 1142 |
+
if not final_content and reasoning_content:
|
| 1143 |
+
final_content = reasoning_content
|
| 1144 |
+
|
| 1145 |
+
# 返回包含推理内容的标准响应(若无推理则不会携带)
|
| 1146 |
+
return self.create_openai_response_with_reasoning(
|
| 1147 |
+
chat_id,
|
| 1148 |
+
model,
|
| 1149 |
+
final_content,
|
| 1150 |
+
reasoning_content,
|
| 1151 |
+
usage_info,
|
| 1152 |
+
)
|
app/services/request_log_dao.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
请求日志数据访问层 (DAO)
|
| 3 |
+
提供请求日志的 CRUD 操作和查询功能
|
| 4 |
+
"""
|
| 5 |
+
import aiosqlite
|
| 6 |
+
import sqlite3
|
| 7 |
+
from typing import List, Dict, Optional
|
| 8 |
+
from datetime import datetime, timedelta
|
| 9 |
+
from contextlib import asynccontextmanager
|
| 10 |
+
import os
|
| 11 |
+
|
| 12 |
+
from app.models.request_log import SQL_CREATE_REQUEST_LOGS_TABLE, DB_PATH
|
| 13 |
+
from app.utils.logger import logger
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class RequestLogDAO:
|
| 17 |
+
"""请求日志数据访问对象"""
|
| 18 |
+
|
| 19 |
+
def __init__(self, db_path: str = DB_PATH):
|
| 20 |
+
"""初始化 DAO"""
|
| 21 |
+
self.db_path = db_path
|
| 22 |
+
self._ensure_db_directory()
|
| 23 |
+
self._init_db()
|
| 24 |
+
|
| 25 |
+
def _ensure_db_directory(self):
|
| 26 |
+
"""确保数据库目录存在"""
|
| 27 |
+
db_dir = os.path.dirname(self.db_path)
|
| 28 |
+
if db_dir and not os.path.exists(db_dir):
|
| 29 |
+
os.makedirs(db_dir, exist_ok=True)
|
| 30 |
+
|
| 31 |
+
def _init_db(self):
|
| 32 |
+
"""初始化数据库表"""
|
| 33 |
+
try:
|
| 34 |
+
conn = sqlite3.connect(self.db_path)
|
| 35 |
+
conn.executescript(SQL_CREATE_REQUEST_LOGS_TABLE)
|
| 36 |
+
conn.commit()
|
| 37 |
+
conn.close()
|
| 38 |
+
logger.debug("请求日志表初始化成功")
|
| 39 |
+
except Exception as e:
|
| 40 |
+
logger.error(f"初始化请求日志表失败: {e}")
|
| 41 |
+
|
| 42 |
+
@asynccontextmanager
|
| 43 |
+
async def get_connection(self):
|
| 44 |
+
"""获取异步数据库连接"""
|
| 45 |
+
conn = await aiosqlite.connect(self.db_path)
|
| 46 |
+
conn.row_factory = aiosqlite.Row
|
| 47 |
+
try:
|
| 48 |
+
yield conn
|
| 49 |
+
finally:
|
| 50 |
+
await conn.close()
|
| 51 |
+
|
| 52 |
+
async def add_log(
|
| 53 |
+
self,
|
| 54 |
+
provider: str,
|
| 55 |
+
model: str,
|
| 56 |
+
success: bool,
|
| 57 |
+
duration: float = 0.0,
|
| 58 |
+
first_token_time: float = 0.0,
|
| 59 |
+
input_tokens: int = 0,
|
| 60 |
+
output_tokens: int = 0,
|
| 61 |
+
error_message: str = None
|
| 62 |
+
) -> int:
|
| 63 |
+
"""
|
| 64 |
+
添加请求日志
|
| 65 |
+
|
| 66 |
+
Args:
|
| 67 |
+
provider: 提供商名称
|
| 68 |
+
model: 模型名称
|
| 69 |
+
success: 是否成功
|
| 70 |
+
duration: 总耗时(秒)
|
| 71 |
+
first_token_time: 首字延迟(秒)
|
| 72 |
+
input_tokens: 输入 token 数
|
| 73 |
+
output_tokens: 输出 token 数
|
| 74 |
+
error_message: 错误信息
|
| 75 |
+
|
| 76 |
+
Returns:
|
| 77 |
+
日志 ID
|
| 78 |
+
"""
|
| 79 |
+
total_tokens = input_tokens + output_tokens
|
| 80 |
+
|
| 81 |
+
async with self.get_connection() as conn:
|
| 82 |
+
cursor = await conn.execute(
|
| 83 |
+
"""
|
| 84 |
+
INSERT INTO request_logs
|
| 85 |
+
(provider, model, success, duration, first_token_time,
|
| 86 |
+
input_tokens, output_tokens, total_tokens, error_message)
|
| 87 |
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 88 |
+
""",
|
| 89 |
+
(provider, model, success, duration, first_token_time,
|
| 90 |
+
input_tokens, output_tokens, total_tokens, error_message)
|
| 91 |
+
)
|
| 92 |
+
await conn.commit()
|
| 93 |
+
return cursor.lastrowid
|
| 94 |
+
|
| 95 |
+
async def get_recent_logs(
|
| 96 |
+
self,
|
| 97 |
+
limit: int = 100,
|
| 98 |
+
provider: str = None,
|
| 99 |
+
model: str = None,
|
| 100 |
+
success: bool = None
|
| 101 |
+
) -> List[Dict]:
|
| 102 |
+
"""
|
| 103 |
+
获取最近的请求日志
|
| 104 |
+
|
| 105 |
+
Args:
|
| 106 |
+
limit: 返回数量限制
|
| 107 |
+
provider: 过滤提供商
|
| 108 |
+
model: 过滤模型
|
| 109 |
+
success: 过滤成功/失败状态
|
| 110 |
+
|
| 111 |
+
Returns:
|
| 112 |
+
日志列表
|
| 113 |
+
"""
|
| 114 |
+
query = "SELECT * FROM request_logs WHERE 1=1"
|
| 115 |
+
params = []
|
| 116 |
+
|
| 117 |
+
if provider:
|
| 118 |
+
query += " AND provider = ?"
|
| 119 |
+
params.append(provider)
|
| 120 |
+
|
| 121 |
+
if model:
|
| 122 |
+
query += " AND model = ?"
|
| 123 |
+
params.append(model)
|
| 124 |
+
|
| 125 |
+
if success is not None:
|
| 126 |
+
query += " AND success = ?"
|
| 127 |
+
params.append(success)
|
| 128 |
+
|
| 129 |
+
query += " ORDER BY timestamp DESC LIMIT ?"
|
| 130 |
+
params.append(limit)
|
| 131 |
+
|
| 132 |
+
async with self.get_connection() as conn:
|
| 133 |
+
cursor = await conn.execute(query, params)
|
| 134 |
+
rows = await cursor.fetchall()
|
| 135 |
+
return [dict(row) for row in rows]
|
| 136 |
+
|
| 137 |
+
async def get_logs_by_time_range(
|
| 138 |
+
self,
|
| 139 |
+
start_time: datetime,
|
| 140 |
+
end_time: datetime,
|
| 141 |
+
provider: str = None,
|
| 142 |
+
model: str = None
|
| 143 |
+
) -> List[Dict]:
|
| 144 |
+
"""
|
| 145 |
+
按时间范围获取日志
|
| 146 |
+
|
| 147 |
+
Args:
|
| 148 |
+
start_time: 开始时间
|
| 149 |
+
end_time: 结束时间
|
| 150 |
+
provider: 过滤提供商
|
| 151 |
+
model: 过滤模型
|
| 152 |
+
|
| 153 |
+
Returns:
|
| 154 |
+
日志列表
|
| 155 |
+
"""
|
| 156 |
+
query = "SELECT * FROM request_logs WHERE timestamp BETWEEN ? AND ?"
|
| 157 |
+
params = [start_time.isoformat(), end_time.isoformat()]
|
| 158 |
+
|
| 159 |
+
if provider:
|
| 160 |
+
query += " AND provider = ?"
|
| 161 |
+
params.append(provider)
|
| 162 |
+
|
| 163 |
+
if model:
|
| 164 |
+
query += " AND model = ?"
|
| 165 |
+
params.append(model)
|
| 166 |
+
|
| 167 |
+
query += " ORDER BY timestamp DESC"
|
| 168 |
+
|
| 169 |
+
async with self.get_connection() as conn:
|
| 170 |
+
cursor = await conn.execute(query, params)
|
| 171 |
+
rows = await cursor.fetchall()
|
| 172 |
+
return [dict(row) for row in rows]
|
| 173 |
+
|
| 174 |
+
async def get_model_stats_from_db(self, hours: int = 24) -> Dict:
|
| 175 |
+
"""
|
| 176 |
+
从数据库获取模型统计(最近N小时)
|
| 177 |
+
|
| 178 |
+
Args:
|
| 179 |
+
hours: 小时数
|
| 180 |
+
|
| 181 |
+
Returns:
|
| 182 |
+
模型统计数据
|
| 183 |
+
"""
|
| 184 |
+
start_time = datetime.now() - timedelta(hours=hours)
|
| 185 |
+
|
| 186 |
+
async with self.get_connection() as conn:
|
| 187 |
+
cursor = await conn.execute(
|
| 188 |
+
"""
|
| 189 |
+
SELECT
|
| 190 |
+
model,
|
| 191 |
+
COUNT(*) as total,
|
| 192 |
+
SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as success,
|
| 193 |
+
SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) as failed,
|
| 194 |
+
SUM(input_tokens) as input_tokens,
|
| 195 |
+
SUM(output_tokens) as output_tokens,
|
| 196 |
+
SUM(total_tokens) as total_tokens,
|
| 197 |
+
AVG(duration) as avg_duration,
|
| 198 |
+
AVG(first_token_time) as avg_first_token_time
|
| 199 |
+
FROM request_logs
|
| 200 |
+
WHERE timestamp >= ?
|
| 201 |
+
GROUP BY model
|
| 202 |
+
ORDER BY total DESC
|
| 203 |
+
""",
|
| 204 |
+
(start_time.isoformat(),)
|
| 205 |
+
)
|
| 206 |
+
rows = await cursor.fetchall()
|
| 207 |
+
|
| 208 |
+
result = {}
|
| 209 |
+
for row in rows:
|
| 210 |
+
model = row['model']
|
| 211 |
+
result[model] = {
|
| 212 |
+
'total': row['total'],
|
| 213 |
+
'success': row['success'],
|
| 214 |
+
'failed': row['failed'],
|
| 215 |
+
'input_tokens': row['input_tokens'] or 0,
|
| 216 |
+
'output_tokens': row['output_tokens'] or 0,
|
| 217 |
+
'total_tokens': row['total_tokens'] or 0,
|
| 218 |
+
'avg_duration': round(row['avg_duration'] or 0, 2),
|
| 219 |
+
'avg_first_token_time': round(row['avg_first_token_time'] or 0, 2),
|
| 220 |
+
'success_rate': round((row['success'] / row['total'] * 100) if row['total'] > 0 else 0, 1)
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
return result
|
| 224 |
+
|
| 225 |
+
async def delete_old_logs(self, days: int = 30) -> int:
|
| 226 |
+
"""
|
| 227 |
+
删除旧日志
|
| 228 |
+
|
| 229 |
+
Args:
|
| 230 |
+
days: 保留天数
|
| 231 |
+
|
| 232 |
+
Returns:
|
| 233 |
+
删除的记录数
|
| 234 |
+
"""
|
| 235 |
+
cutoff_time = datetime.now() - timedelta(days=days)
|
| 236 |
+
|
| 237 |
+
async with self.get_connection() as conn:
|
| 238 |
+
cursor = await conn.execute(
|
| 239 |
+
"DELETE FROM request_logs WHERE timestamp < ?",
|
| 240 |
+
(cutoff_time.isoformat(),)
|
| 241 |
+
)
|
| 242 |
+
await conn.commit()
|
| 243 |
+
return cursor.rowcount
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
# 全局单例实例
|
| 247 |
+
_request_log_dao: Optional[RequestLogDAO] = None
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def get_request_log_dao() -> RequestLogDAO:
|
| 251 |
+
"""
|
| 252 |
+
获取请求日志 DAO 单例
|
| 253 |
+
|
| 254 |
+
Returns:
|
| 255 |
+
RequestLogDAO 实例
|
| 256 |
+
"""
|
| 257 |
+
global _request_log_dao
|
| 258 |
+
if _request_log_dao is None:
|
| 259 |
+
_request_log_dao = RequestLogDAO()
|
| 260 |
+
return _request_log_dao
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def init_request_log_dao():
|
| 264 |
+
"""初始化请求日志 DAO"""
|
| 265 |
+
global _request_log_dao
|
| 266 |
+
_request_log_dao = RequestLogDAO()
|
| 267 |
+
return _request_log_dao
|
app/services/token_dao.py
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Token 数据访问层 (DAO)
|
| 3 |
+
提供 Token 的 CRUD 操作和查询功能
|
| 4 |
+
"""
|
| 5 |
+
import aiosqlite
|
| 6 |
+
import sqlite3
|
| 7 |
+
from typing import List, Optional, Dict, Tuple
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from contextlib import asynccontextmanager
|
| 10 |
+
import os
|
| 11 |
+
|
| 12 |
+
from app.models.token_db import SQL_CREATE_TABLES, DB_PATH
|
| 13 |
+
from app.utils.logger import logger
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class TokenDAO:
|
| 17 |
+
"""Token 数据访问对象"""
|
| 18 |
+
|
| 19 |
+
def __init__(self, db_path: str = DB_PATH):
|
| 20 |
+
"""初始化 DAO"""
|
| 21 |
+
self.db_path = db_path
|
| 22 |
+
self._ensure_db_directory()
|
| 23 |
+
|
| 24 |
+
def _ensure_db_directory(self):
|
| 25 |
+
"""确保数据库目录存在"""
|
| 26 |
+
db_dir = os.path.dirname(self.db_path)
|
| 27 |
+
if db_dir and not os.path.exists(db_dir):
|
| 28 |
+
os.makedirs(db_dir, exist_ok=True)
|
| 29 |
+
|
| 30 |
+
@asynccontextmanager
|
| 31 |
+
async def get_connection(self):
|
| 32 |
+
"""获取异步数据库连接"""
|
| 33 |
+
conn = await aiosqlite.connect(self.db_path)
|
| 34 |
+
conn.row_factory = aiosqlite.Row # 返回字典式结果
|
| 35 |
+
|
| 36 |
+
# 启用外键约束(SQLite 默认关闭)
|
| 37 |
+
await conn.execute("PRAGMA foreign_keys = ON")
|
| 38 |
+
|
| 39 |
+
try:
|
| 40 |
+
yield conn
|
| 41 |
+
finally:
|
| 42 |
+
await conn.close()
|
| 43 |
+
|
| 44 |
+
def get_sync_connection(self):
|
| 45 |
+
"""获取同步数据库连接(用于初始化)"""
|
| 46 |
+
conn = sqlite3.connect(self.db_path)
|
| 47 |
+
# 启用外键约束
|
| 48 |
+
conn.execute("PRAGMA foreign_keys = ON")
|
| 49 |
+
return conn
|
| 50 |
+
|
| 51 |
+
async def init_database(self):
|
| 52 |
+
"""初始化数据库表结构"""
|
| 53 |
+
try:
|
| 54 |
+
# 使用同步连接创建表(避免异步初始化问题)
|
| 55 |
+
conn = self.get_sync_connection()
|
| 56 |
+
conn.executescript(SQL_CREATE_TABLES)
|
| 57 |
+
conn.commit()
|
| 58 |
+
conn.close()
|
| 59 |
+
except Exception as e:
|
| 60 |
+
logger.error(f"❌ Token 数据库初始化失败: {e}")
|
| 61 |
+
raise
|
| 62 |
+
|
| 63 |
+
# ==================== Token CRUD 操作 ====================
|
| 64 |
+
|
| 65 |
+
async def add_token(
|
| 66 |
+
self,
|
| 67 |
+
provider: str,
|
| 68 |
+
token: str,
|
| 69 |
+
token_type: str = "user",
|
| 70 |
+
priority: int = 0,
|
| 71 |
+
validate: bool = True
|
| 72 |
+
) -> Optional[int]:
|
| 73 |
+
"""
|
| 74 |
+
添加新 Token(可选验证)
|
| 75 |
+
|
| 76 |
+
Args:
|
| 77 |
+
provider: 提供商名称
|
| 78 |
+
token: Token 值
|
| 79 |
+
token_type: Token 类型(如果 validate=True 将被验证结果覆盖)
|
| 80 |
+
priority: 优先级
|
| 81 |
+
validate: 是否验证 Token(仅针对 zai 提供商)
|
| 82 |
+
|
| 83 |
+
Returns:
|
| 84 |
+
token_id 或 None(验证失败或已存在)
|
| 85 |
+
"""
|
| 86 |
+
try:
|
| 87 |
+
# 对于 zai 提供商,强制验证 Token
|
| 88 |
+
if provider == "zai" and validate:
|
| 89 |
+
from app.utils.token_pool import ZAITokenValidator
|
| 90 |
+
|
| 91 |
+
validated_type, is_valid, error_msg = await ZAITokenValidator.validate_token(token)
|
| 92 |
+
|
| 93 |
+
# 拒绝 guest token
|
| 94 |
+
if validated_type == "guest":
|
| 95 |
+
logger.warning(f"🚫 拒绝添加匿名用户 Token: {token[:20]}... - {error_msg}")
|
| 96 |
+
return None
|
| 97 |
+
|
| 98 |
+
# 拒绝无效 token
|
| 99 |
+
if not is_valid:
|
| 100 |
+
logger.warning(f"🚫 Token 验证失败: {token[:20]}... - {error_msg}")
|
| 101 |
+
return None
|
| 102 |
+
|
| 103 |
+
# 使用验证后的类型
|
| 104 |
+
token_type = validated_type
|
| 105 |
+
|
| 106 |
+
async with self.get_connection() as conn:
|
| 107 |
+
cursor = await conn.execute("""
|
| 108 |
+
INSERT OR IGNORE INTO tokens (provider, token, token_type, priority)
|
| 109 |
+
VALUES (?, ?, ?, ?)
|
| 110 |
+
""", (provider, token, token_type, priority))
|
| 111 |
+
|
| 112 |
+
await conn.commit()
|
| 113 |
+
|
| 114 |
+
if cursor.lastrowid > 0:
|
| 115 |
+
# 同时创建统计记录
|
| 116 |
+
await conn.execute("""
|
| 117 |
+
INSERT INTO token_stats (token_id)
|
| 118 |
+
VALUES (?)
|
| 119 |
+
""", (cursor.lastrowid,))
|
| 120 |
+
await conn.commit()
|
| 121 |
+
logger.info(f"✅ 添加 Token: {provider} ({token_type}) - {token[:20]}...")
|
| 122 |
+
return cursor.lastrowid
|
| 123 |
+
else:
|
| 124 |
+
logger.warning(f"⚠️ Token 已存在: {provider} - {token[:20]}...")
|
| 125 |
+
return None
|
| 126 |
+
except Exception as e:
|
| 127 |
+
logger.error(f"❌ 添加 Token 失败: {e}")
|
| 128 |
+
return None
|
| 129 |
+
|
| 130 |
+
async def get_tokens_by_provider(self, provider: str, enabled_only: bool = True) -> List[Dict]:
|
| 131 |
+
"""
|
| 132 |
+
获取指定提供商的所有 Token
|
| 133 |
+
|
| 134 |
+
Args:
|
| 135 |
+
provider: 提供商名称
|
| 136 |
+
enabled_only: 是否只返回启用的 Token
|
| 137 |
+
"""
|
| 138 |
+
try:
|
| 139 |
+
async with self.get_connection() as conn:
|
| 140 |
+
query = """
|
| 141 |
+
SELECT t.*, ts.total_requests, ts.successful_requests, ts.failed_requests,
|
| 142 |
+
ts.last_success_time, ts.last_failure_time
|
| 143 |
+
FROM tokens t
|
| 144 |
+
LEFT JOIN token_stats ts ON t.id = ts.token_id
|
| 145 |
+
WHERE t.provider = ?
|
| 146 |
+
"""
|
| 147 |
+
params = [provider]
|
| 148 |
+
|
| 149 |
+
if enabled_only:
|
| 150 |
+
query += " AND t.is_enabled = 1"
|
| 151 |
+
|
| 152 |
+
query += " ORDER BY t.priority DESC, t.id ASC"
|
| 153 |
+
|
| 154 |
+
cursor = await conn.execute(query, params)
|
| 155 |
+
rows = await cursor.fetchall()
|
| 156 |
+
|
| 157 |
+
return [dict(row) for row in rows]
|
| 158 |
+
except Exception as e:
|
| 159 |
+
logger.error(f"❌ 查询 Token 失败: {e}")
|
| 160 |
+
return []
|
| 161 |
+
|
| 162 |
+
async def get_all_tokens(self, enabled_only: bool = False) -> List[Dict]:
|
| 163 |
+
"""获取所有 Token"""
|
| 164 |
+
try:
|
| 165 |
+
async with self.get_connection() as conn:
|
| 166 |
+
query = """
|
| 167 |
+
SELECT t.*, ts.total_requests, ts.successful_requests, ts.failed_requests,
|
| 168 |
+
ts.last_success_time, ts.last_failure_time
|
| 169 |
+
FROM tokens t
|
| 170 |
+
LEFT JOIN token_stats ts ON t.id = ts.token_id
|
| 171 |
+
"""
|
| 172 |
+
|
| 173 |
+
if enabled_only:
|
| 174 |
+
query += " WHERE t.is_enabled = 1"
|
| 175 |
+
|
| 176 |
+
query += " ORDER BY t.provider, t.priority DESC, t.id ASC"
|
| 177 |
+
|
| 178 |
+
cursor = await conn.execute(query)
|
| 179 |
+
rows = await cursor.fetchall()
|
| 180 |
+
|
| 181 |
+
return [dict(row) for row in rows]
|
| 182 |
+
except Exception as e:
|
| 183 |
+
logger.error(f"❌ 查询所有 Token 失败: {e}")
|
| 184 |
+
return []
|
| 185 |
+
|
| 186 |
+
async def update_token_status(self, token_id: int, is_enabled: bool):
|
| 187 |
+
"""更新 Token 启用状态"""
|
| 188 |
+
try:
|
| 189 |
+
async with self.get_connection() as conn:
|
| 190 |
+
await conn.execute("""
|
| 191 |
+
UPDATE tokens SET is_enabled = ? WHERE id = ?
|
| 192 |
+
""", (is_enabled, token_id))
|
| 193 |
+
await conn.commit()
|
| 194 |
+
logger.info(f"✅ 更新 Token 状态: id={token_id}, enabled={is_enabled}")
|
| 195 |
+
except Exception as e:
|
| 196 |
+
logger.error(f"❌ 更新 Token 状态失败: {e}")
|
| 197 |
+
|
| 198 |
+
async def update_token_type(self, token_id: int, token_type: str):
|
| 199 |
+
"""更新 Token 类型"""
|
| 200 |
+
try:
|
| 201 |
+
async with self.get_connection() as conn:
|
| 202 |
+
await conn.execute("""
|
| 203 |
+
UPDATE tokens SET token_type = ? WHERE id = ?
|
| 204 |
+
""", (token_type, token_id))
|
| 205 |
+
await conn.commit()
|
| 206 |
+
logger.info(f"✅ 更新 Token 类型: id={token_id}, type={token_type}")
|
| 207 |
+
except Exception as e:
|
| 208 |
+
logger.error(f"❌ 更新 Token 类型失败: {e}")
|
| 209 |
+
|
| 210 |
+
async def delete_token(self, token_id: int):
|
| 211 |
+
"""删除 Token(级联删除统计数据)"""
|
| 212 |
+
try:
|
| 213 |
+
async with self.get_connection() as conn:
|
| 214 |
+
await conn.execute("DELETE FROM tokens WHERE id = ?", (token_id,))
|
| 215 |
+
await conn.commit()
|
| 216 |
+
logger.info(f"✅ 删除 Token: id={token_id}")
|
| 217 |
+
except Exception as e:
|
| 218 |
+
logger.error(f"❌ 删除 Token 失败: {e}")
|
| 219 |
+
|
| 220 |
+
async def delete_tokens_by_provider(self, provider: str):
|
| 221 |
+
"""删除指定提供商的所有 Token"""
|
| 222 |
+
try:
|
| 223 |
+
async with self.get_connection() as conn:
|
| 224 |
+
await conn.execute("DELETE FROM tokens WHERE provider = ?", (provider,))
|
| 225 |
+
await conn.commit()
|
| 226 |
+
logger.info(f"✅ 删除提供商所有 Token: {provider}")
|
| 227 |
+
except Exception as e:
|
| 228 |
+
logger.error(f"❌ 删除提供商 Token 失败: {e}")
|
| 229 |
+
|
| 230 |
+
# ==================== Token 统计操作 ====================
|
| 231 |
+
|
| 232 |
+
async def record_success(self, token_id: int):
|
| 233 |
+
"""记录 Token 使用成功"""
|
| 234 |
+
try:
|
| 235 |
+
async with self.get_connection() as conn:
|
| 236 |
+
await conn.execute("""
|
| 237 |
+
UPDATE token_stats
|
| 238 |
+
SET total_requests = total_requests + 1,
|
| 239 |
+
successful_requests = successful_requests + 1,
|
| 240 |
+
last_success_time = CURRENT_TIMESTAMP
|
| 241 |
+
WHERE token_id = ?
|
| 242 |
+
""", (token_id,))
|
| 243 |
+
await conn.commit()
|
| 244 |
+
except Exception as e:
|
| 245 |
+
logger.error(f"❌ 记录成功失败: {e}")
|
| 246 |
+
|
| 247 |
+
async def record_failure(self, token_id: int):
|
| 248 |
+
"""记录 Token 使用失败"""
|
| 249 |
+
try:
|
| 250 |
+
async with self.get_connection() as conn:
|
| 251 |
+
await conn.execute("""
|
| 252 |
+
UPDATE token_stats
|
| 253 |
+
SET total_requests = total_requests + 1,
|
| 254 |
+
failed_requests = failed_requests + 1,
|
| 255 |
+
last_failure_time = CURRENT_TIMESTAMP
|
| 256 |
+
WHERE token_id = ?
|
| 257 |
+
""", (token_id,))
|
| 258 |
+
await conn.commit()
|
| 259 |
+
except Exception as e:
|
| 260 |
+
logger.error(f"❌ 记录失败失败: {e}")
|
| 261 |
+
|
| 262 |
+
async def get_token_stats(self, token_id: int) -> Optional[Dict]:
|
| 263 |
+
"""获取 Token 统计信息"""
|
| 264 |
+
try:
|
| 265 |
+
async with self.get_connection() as conn:
|
| 266 |
+
cursor = await conn.execute("""
|
| 267 |
+
SELECT * FROM token_stats WHERE token_id = ?
|
| 268 |
+
""", (token_id,))
|
| 269 |
+
row = await cursor.fetchone()
|
| 270 |
+
return dict(row) if row else None
|
| 271 |
+
except Exception as e:
|
| 272 |
+
logger.error(f"❌ 获取统计信息失败: {e}")
|
| 273 |
+
return None
|
| 274 |
+
|
| 275 |
+
# ==================== 批量操作 ====================
|
| 276 |
+
|
| 277 |
+
async def bulk_add_tokens(
|
| 278 |
+
self,
|
| 279 |
+
provider: str,
|
| 280 |
+
tokens: List[str],
|
| 281 |
+
token_type: str = "user",
|
| 282 |
+
validate: bool = True
|
| 283 |
+
) -> Tuple[int, int]:
|
| 284 |
+
"""
|
| 285 |
+
批量添加 Token(可选验证)
|
| 286 |
+
|
| 287 |
+
Args:
|
| 288 |
+
provider: 提供商名称
|
| 289 |
+
tokens: Token 列表
|
| 290 |
+
token_type: Token 类型(如果 validate=True 将被覆盖)
|
| 291 |
+
validate: 是否验证 Token(仅针对 zai)
|
| 292 |
+
|
| 293 |
+
Returns:
|
| 294 |
+
(成功添加数量, 失败数量)
|
| 295 |
+
"""
|
| 296 |
+
added_count = 0
|
| 297 |
+
failed_count = 0
|
| 298 |
+
|
| 299 |
+
for token in tokens:
|
| 300 |
+
if token.strip(): # 过滤空 token
|
| 301 |
+
token_id = await self.add_token(
|
| 302 |
+
provider,
|
| 303 |
+
token.strip(),
|
| 304 |
+
token_type,
|
| 305 |
+
validate=validate
|
| 306 |
+
)
|
| 307 |
+
if token_id:
|
| 308 |
+
added_count += 1
|
| 309 |
+
else:
|
| 310 |
+
failed_count += 1
|
| 311 |
+
|
| 312 |
+
logger.info(f"✅ 批量添加完成: {provider} - 成功 {added_count}/{len(tokens)},失败 {failed_count}")
|
| 313 |
+
return added_count, failed_count
|
| 314 |
+
|
| 315 |
+
async def replace_tokens(self, provider: str, tokens: List[str],
|
| 316 |
+
token_type: str = "user"):
|
| 317 |
+
"""
|
| 318 |
+
替换指定提供商的所有 Token(先删除后添加)
|
| 319 |
+
"""
|
| 320 |
+
# 删除旧 Token
|
| 321 |
+
await self.delete_tokens_by_provider(provider)
|
| 322 |
+
|
| 323 |
+
# 添加新 Token
|
| 324 |
+
added_count = await self.bulk_add_tokens(provider, tokens, token_type)
|
| 325 |
+
|
| 326 |
+
logger.info(f"✅ 替换 Token 完成: {provider} - {added_count} 个")
|
| 327 |
+
return added_count
|
| 328 |
+
|
| 329 |
+
# ==================== 实用方法 ====================
|
| 330 |
+
|
| 331 |
+
async def get_token_by_value(self, provider: str, token: str) -> Optional[Dict]:
|
| 332 |
+
"""根据 Token 值查询"""
|
| 333 |
+
try:
|
| 334 |
+
async with self.get_connection() as conn:
|
| 335 |
+
cursor = await conn.execute("""
|
| 336 |
+
SELECT t.*, ts.total_requests, ts.successful_requests, ts.failed_requests
|
| 337 |
+
FROM tokens t
|
| 338 |
+
LEFT JOIN token_stats ts ON t.id = ts.token_id
|
| 339 |
+
WHERE t.provider = ? AND t.token = ?
|
| 340 |
+
""", (provider, token))
|
| 341 |
+
row = await cursor.fetchone()
|
| 342 |
+
return dict(row) if row else None
|
| 343 |
+
except Exception as e:
|
| 344 |
+
logger.error(f"❌ 查询 Token 失败: {e}")
|
| 345 |
+
return None
|
| 346 |
+
|
| 347 |
+
async def get_provider_stats(self, provider: str) -> Dict:
|
| 348 |
+
"""获取提供商统计信息"""
|
| 349 |
+
try:
|
| 350 |
+
async with self.get_connection() as conn:
|
| 351 |
+
cursor = await conn.execute("""
|
| 352 |
+
SELECT
|
| 353 |
+
COUNT(*) as total_tokens,
|
| 354 |
+
SUM(CASE WHEN is_enabled = 1 THEN 1 ELSE 0 END) as enabled_tokens,
|
| 355 |
+
SUM(ts.total_requests) as total_requests,
|
| 356 |
+
SUM(ts.successful_requests) as successful_requests,
|
| 357 |
+
SUM(ts.failed_requests) as failed_requests
|
| 358 |
+
FROM tokens t
|
| 359 |
+
LEFT JOIN token_stats ts ON t.id = ts.token_id
|
| 360 |
+
WHERE t.provider = ?
|
| 361 |
+
""", (provider,))
|
| 362 |
+
row = await cursor.fetchone()
|
| 363 |
+
return dict(row) if row else {}
|
| 364 |
+
except Exception as e:
|
| 365 |
+
logger.error(f"❌ 获取提供商统计失败: {e}")
|
| 366 |
+
return {}
|
| 367 |
+
|
| 368 |
+
# ==================== Token 验证操作 ====================
|
| 369 |
+
|
| 370 |
+
async def validate_and_update_token(self, token_id: int) -> bool:
|
| 371 |
+
"""
|
| 372 |
+
验证单个 Token 并更新其类型
|
| 373 |
+
|
| 374 |
+
Args:
|
| 375 |
+
token_id: Token 数据库 ID
|
| 376 |
+
|
| 377 |
+
Returns:
|
| 378 |
+
是否为有效的认证用户 Token
|
| 379 |
+
"""
|
| 380 |
+
try:
|
| 381 |
+
# 获取 Token 信息
|
| 382 |
+
async with self.get_connection() as conn:
|
| 383 |
+
cursor = await conn.execute("""
|
| 384 |
+
SELECT provider, token FROM tokens WHERE id = ?
|
| 385 |
+
""", (token_id,))
|
| 386 |
+
row = await cursor.fetchone()
|
| 387 |
+
|
| 388 |
+
if not row:
|
| 389 |
+
logger.error(f"❌ Token ID {token_id} 不存在")
|
| 390 |
+
return False
|
| 391 |
+
|
| 392 |
+
provider = row["provider"]
|
| 393 |
+
token = row["token"]
|
| 394 |
+
|
| 395 |
+
# 仅对 zai 提供商验证
|
| 396 |
+
if provider != "zai":
|
| 397 |
+
logger.info(f"⏭️ 跳过非 zai 提供商的 Token 验证: {provider}")
|
| 398 |
+
return True
|
| 399 |
+
|
| 400 |
+
# 验证 Token
|
| 401 |
+
from app.utils.token_pool import ZAITokenValidator
|
| 402 |
+
|
| 403 |
+
token_type, is_valid, error_msg = await ZAITokenValidator.validate_token(token)
|
| 404 |
+
|
| 405 |
+
# 更新 Token 类型
|
| 406 |
+
await self.update_token_type(token_id, token_type)
|
| 407 |
+
|
| 408 |
+
if not is_valid:
|
| 409 |
+
logger.warning(f"⚠️ Token 验证失败: id={token_id}, type={token_type}, error={error_msg}")
|
| 410 |
+
|
| 411 |
+
return is_valid
|
| 412 |
+
|
| 413 |
+
except Exception as e:
|
| 414 |
+
logger.error(f"❌ 验证 Token 失败: {e}")
|
| 415 |
+
return False
|
| 416 |
+
|
| 417 |
+
async def validate_all_tokens(self, provider: str = "zai") -> Dict[str, int]:
|
| 418 |
+
"""
|
| 419 |
+
批量验证所有 Token
|
| 420 |
+
|
| 421 |
+
Args:
|
| 422 |
+
provider: 提供商名称(默认 zai)
|
| 423 |
+
|
| 424 |
+
Returns:
|
| 425 |
+
统计结果 {"valid": 数量, "guest": 数量, "invalid": 数量}
|
| 426 |
+
"""
|
| 427 |
+
try:
|
| 428 |
+
tokens = await self.get_tokens_by_provider(provider, enabled_only=False)
|
| 429 |
+
|
| 430 |
+
if not tokens:
|
| 431 |
+
logger.warning(f"⚠️ 没有需要验证的 {provider} Token")
|
| 432 |
+
return {"valid": 0, "guest": 0, "invalid": 0}
|
| 433 |
+
|
| 434 |
+
logger.info(f"🔍 开始批量验证 {len(tokens)} 个 {provider} Token...")
|
| 435 |
+
|
| 436 |
+
stats = {"valid": 0, "guest": 0, "invalid": 0}
|
| 437 |
+
|
| 438 |
+
for token_record in tokens:
|
| 439 |
+
token_id = token_record["id"]
|
| 440 |
+
is_valid = await self.validate_and_update_token(token_id)
|
| 441 |
+
|
| 442 |
+
# 重新查询更新后的类型
|
| 443 |
+
async with self.get_connection() as conn:
|
| 444 |
+
cursor = await conn.execute("""
|
| 445 |
+
SELECT token_type FROM tokens WHERE id = ?
|
| 446 |
+
""", (token_id,))
|
| 447 |
+
row = await cursor.fetchone()
|
| 448 |
+
token_type = row["token_type"] if row else "unknown"
|
| 449 |
+
|
| 450 |
+
if token_type == "user":
|
| 451 |
+
stats["valid"] += 1
|
| 452 |
+
elif token_type == "guest":
|
| 453 |
+
stats["guest"] += 1
|
| 454 |
+
else:
|
| 455 |
+
stats["invalid"] += 1
|
| 456 |
+
|
| 457 |
+
logger.info(f"✅ 批量验证完成: 有效 {stats['valid']}, 匿名 {stats['guest']}, 无效 {stats['invalid']}")
|
| 458 |
+
return stats
|
| 459 |
+
|
| 460 |
+
except Exception as e:
|
| 461 |
+
logger.error(f"❌ 批量验证失败: {e}")
|
| 462 |
+
return {"valid": 0, "guest": 0, "invalid": 0}
|
| 463 |
+
|
| 464 |
+
|
| 465 |
+
# 全局单例
|
| 466 |
+
_token_dao: Optional[TokenDAO] = None
|
| 467 |
+
|
| 468 |
+
|
| 469 |
+
def get_token_dao() -> TokenDAO:
|
| 470 |
+
"""获取全局 TokenDAO 实例"""
|
| 471 |
+
global _token_dao
|
| 472 |
+
if _token_dao is None:
|
| 473 |
+
_token_dao = TokenDAO()
|
| 474 |
+
return _token_dao
|
| 475 |
+
|
| 476 |
+
|
| 477 |
+
async def init_token_database():
|
| 478 |
+
"""初始化 Token 数据库"""
|
| 479 |
+
dao = get_token_dao()
|
| 480 |
+
await dao.init_database()
|
app/templates/base.html
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-CN" class="h-full bg-gray-50">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>{% block title %}管理后台{% endblock %} - Z.AI2API</title>
|
| 7 |
+
|
| 8 |
+
<!-- Tailwind CSS (CDN) -->
|
| 9 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 10 |
+
|
| 11 |
+
<!-- Alpine.js (CDN) -->
|
| 12 |
+
<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>
|
| 13 |
+
|
| 14 |
+
<!-- htmx (CDN) -->
|
| 15 |
+
<script src="https://unpkg.com/[email protected]"></script>
|
| 16 |
+
|
| 17 |
+
<!-- Chart.js (CDN) -->
|
| 18 |
+
<script src="https://cdn.jsdelivr.net/npm/[email protected]"></script>
|
| 19 |
+
|
| 20 |
+
<!-- 自定义样式 -->
|
| 21 |
+
<style>
|
| 22 |
+
/* 自定义滚动条 */
|
| 23 |
+
::-webkit-scrollbar {
|
| 24 |
+
width: 8px;
|
| 25 |
+
height: 8px;
|
| 26 |
+
}
|
| 27 |
+
::-webkit-scrollbar-track {
|
| 28 |
+
background: #f1f1f1;
|
| 29 |
+
}
|
| 30 |
+
::-webkit-scrollbar-thumb {
|
| 31 |
+
background: #888;
|
| 32 |
+
border-radius: 4px;
|
| 33 |
+
}
|
| 34 |
+
::-webkit-scrollbar-thumb:hover {
|
| 35 |
+
background: #555;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
/* htmx 加载指示器 */
|
| 39 |
+
.htmx-indicator {
|
| 40 |
+
display: none;
|
| 41 |
+
}
|
| 42 |
+
.htmx-request .htmx-indicator {
|
| 43 |
+
display: inline-block;
|
| 44 |
+
}
|
| 45 |
+
.htmx-request.htmx-indicator {
|
| 46 |
+
display: inline-block;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
/* 平滑过渡 */
|
| 50 |
+
.fade-in {
|
| 51 |
+
animation: fadeIn 0.3s ease-in;
|
| 52 |
+
}
|
| 53 |
+
@keyframes fadeIn {
|
| 54 |
+
from { opacity: 0; }
|
| 55 |
+
to { opacity: 1; }
|
| 56 |
+
}
|
| 57 |
+
</style>
|
| 58 |
+
|
| 59 |
+
{% block extra_head %}{% endblock %}
|
| 60 |
+
</head>
|
| 61 |
+
<body class="h-full" x-data="{
|
| 62 |
+
sidebarOpen: true,
|
| 63 |
+
async logout() {
|
| 64 |
+
if (confirm('确定要登出吗?')) {
|
| 65 |
+
try {
|
| 66 |
+
const response = await fetch('/admin/api/logout', {
|
| 67 |
+
method: 'POST'
|
| 68 |
+
});
|
| 69 |
+
if (response.ok) {
|
| 70 |
+
window.location.href = '/admin/login';
|
| 71 |
+
}
|
| 72 |
+
} catch (err) {
|
| 73 |
+
console.error('登出失败:', err);
|
| 74 |
+
alert('登出失败,请稍后重试');
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
}">
|
| 79 |
+
<div class="min-h-full">
|
| 80 |
+
<!-- 顶部导航栏 -->
|
| 81 |
+
<nav class="bg-indigo-600 shadow-lg">
|
| 82 |
+
<div class="mx-auto px-4 sm:px-6 lg:px-8">
|
| 83 |
+
<div class="flex h-16 items-center justify-between">
|
| 84 |
+
<!-- Logo 和切换按钮 -->
|
| 85 |
+
<div class="flex items-center">
|
| 86 |
+
<button @click="sidebarOpen = !sidebarOpen" class="text-white hover:bg-indigo-700 p-2 rounded-md">
|
| 87 |
+
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 88 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
| 89 |
+
</svg>
|
| 90 |
+
</button>
|
| 91 |
+
<div class="ml-4 flex items-center">
|
| 92 |
+
<h1 class="text-2xl font-bold text-white">API 管理后台</h1>
|
| 93 |
+
</div>
|
| 94 |
+
</div>
|
| 95 |
+
|
| 96 |
+
<!-- 右侧信息 -->
|
| 97 |
+
<div class="flex items-center space-x-4">
|
| 98 |
+
<!-- 实时状态指示器 -->
|
| 99 |
+
<div class="flex items-center text-white">
|
| 100 |
+
<span class="relative flex h-3 w-3">
|
| 101 |
+
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
| 102 |
+
<span class="relative inline-flex rounded-full h-3 w-3 bg-green-500"></span>
|
| 103 |
+
</span>
|
| 104 |
+
<span class="ml-2 text-sm">服务运行中</span>
|
| 105 |
+
</div>
|
| 106 |
+
|
| 107 |
+
<!-- 登出按钮 -->
|
| 108 |
+
<button
|
| 109 |
+
@click="logout()"
|
| 110 |
+
class="flex items-center text-white hover:bg-indigo-700 px-3 py-2 rounded-md text-sm font-medium transition-colors">
|
| 111 |
+
<svg class="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 112 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
| 113 |
+
</svg>
|
| 114 |
+
登出
|
| 115 |
+
</button>
|
| 116 |
+
</div>
|
| 117 |
+
</div>
|
| 118 |
+
</div>
|
| 119 |
+
</nav>
|
| 120 |
+
|
| 121 |
+
<div class="flex">
|
| 122 |
+
<!-- 侧边栏 -->
|
| 123 |
+
<aside
|
| 124 |
+
x-show="sidebarOpen"
|
| 125 |
+
x-transition:enter="transition ease-out duration-200"
|
| 126 |
+
x-transition:enter-start="transform -translate-x-full"
|
| 127 |
+
x-transition:enter-end="transform translate-x-0"
|
| 128 |
+
x-transition:leave="transition ease-in duration-200"
|
| 129 |
+
x-transition:leave-start="transform translate-x-0"
|
| 130 |
+
x-transition:leave-end="transform -translate-x-full"
|
| 131 |
+
class="w-64 bg-white shadow-lg min-h-screen">
|
| 132 |
+
<nav class="mt-5 px-2 space-y-1">
|
| 133 |
+
{% set current_path = request.url.path %}
|
| 134 |
+
|
| 135 |
+
<!-- 仪表盘 -->
|
| 136 |
+
<a href="/admin"
|
| 137 |
+
class="{% if current_path == '/admin' or current_path == '/admin/' %}bg-indigo-100 text-indigo-700{% else %}text-gray-700 hover:bg-gray-100{% endif %} group flex items-center px-3 py-2 text-sm font-medium rounded-md">
|
| 138 |
+
<svg class="mr-3 h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 139 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
| 140 |
+
</svg>
|
| 141 |
+
仪表盘
|
| 142 |
+
</a>
|
| 143 |
+
|
| 144 |
+
<!-- 配置管理 -->
|
| 145 |
+
<a href="/admin/config"
|
| 146 |
+
class="{% if '/config' in current_path %}bg-indigo-100 text-indigo-700{% else %}text-gray-700 hover:bg-gray-100{% endif %} group flex items-center px-3 py-2 text-sm font-medium rounded-md">
|
| 147 |
+
<svg class="mr-3 h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 148 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
| 149 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
| 150 |
+
</svg>
|
| 151 |
+
配置管理
|
| 152 |
+
</a>
|
| 153 |
+
|
| 154 |
+
<!-- 服务监控 -->
|
| 155 |
+
<a href="/admin/monitor"
|
| 156 |
+
class="{% if '/monitor' in current_path %}bg-indigo-100 text-indigo-700{% else %}text-gray-700 hover:bg-gray-100{% endif %} group flex items-center px-3 py-2 text-sm font-medium rounded-md">
|
| 157 |
+
<svg class="mr-3 h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 158 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
| 159 |
+
</svg>
|
| 160 |
+
服务监控
|
| 161 |
+
</a>
|
| 162 |
+
|
| 163 |
+
<!-- Token 管理 -->
|
| 164 |
+
<a href="/admin/tokens"
|
| 165 |
+
class="{% if '/tokens' in current_path %}bg-indigo-100 text-indigo-700{% else %}text-gray-700 hover:bg-gray-100{% endif %} group flex items-center px-3 py-2 text-sm font-medium rounded-md">
|
| 166 |
+
<svg class="mr-3 h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 167 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
| 168 |
+
</svg>
|
| 169 |
+
Token 管理
|
| 170 |
+
</a>
|
| 171 |
+
|
| 172 |
+
<!-- 分隔线 -->
|
| 173 |
+
<div class="border-t border-gray-200 my-4"></div>
|
| 174 |
+
|
| 175 |
+
<!-- API 文档 -->
|
| 176 |
+
<a href="/docs" target="_blank"
|
| 177 |
+
class="text-gray-700 hover:bg-gray-100 group flex items-center px-3 py-2 text-sm font-medium rounded-md">
|
| 178 |
+
<svg class="mr-3 h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 179 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
| 180 |
+
</svg>
|
| 181 |
+
API 文档
|
| 182 |
+
</a>
|
| 183 |
+
</nav>
|
| 184 |
+
</aside>
|
| 185 |
+
|
| 186 |
+
<!-- 主内容区 -->
|
| 187 |
+
<main class="flex-1 p-6">
|
| 188 |
+
<!-- 通知区域 -->
|
| 189 |
+
<div id="notification" class="mb-4"></div>
|
| 190 |
+
|
| 191 |
+
<!-- 页面内容 -->
|
| 192 |
+
<div class="fade-in">
|
| 193 |
+
{% block content %}{% endblock %}
|
| 194 |
+
</div>
|
| 195 |
+
</main>
|
| 196 |
+
</div>
|
| 197 |
+
</div>
|
| 198 |
+
|
| 199 |
+
{% block extra_scripts %}{% endblock %}
|
| 200 |
+
</body>
|
| 201 |
+
</html>
|
app/templates/components/provider_status.html
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!-- 提供商状态详情组件 -->
|
| 2 |
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
| 3 |
+
{% for provider in providers %}
|
| 4 |
+
<div class="border rounded-lg p-6 hover:shadow-md transition-shadow">
|
| 5 |
+
<!-- 提供商头部 -->
|
| 6 |
+
<div class="mb-4">
|
| 7 |
+
<h4 class="text-lg font-semibold text-gray-900">{{ provider.display_name }}</h4>
|
| 8 |
+
</div>
|
| 9 |
+
|
| 10 |
+
<!-- Token 统计 -->
|
| 11 |
+
<div class="space-y-3 mb-4">
|
| 12 |
+
<div class="bg-gray-50 rounded-md p-3">
|
| 13 |
+
<h5 class="text-xs font-medium text-gray-500 mb-2">Token 统计</h5>
|
| 14 |
+
<div class="space-y-2 text-sm">
|
| 15 |
+
<div class="flex justify-between">
|
| 16 |
+
<span class="text-gray-600">总计:</span>
|
| 17 |
+
<span class="font-medium text-gray-900">{{ provider.total_tokens }}</span>
|
| 18 |
+
</div>
|
| 19 |
+
<div class="flex justify-between">
|
| 20 |
+
<span class="text-gray-600">已启用:</span>
|
| 21 |
+
<span class="font-medium text-green-600">{{ provider.enabled_tokens }}</span>
|
| 22 |
+
</div>
|
| 23 |
+
<div class="flex justify-between text-xs text-gray-500">
|
| 24 |
+
<span>认证用户:</span>
|
| 25 |
+
<span>{{ provider.user_tokens }}</span>
|
| 26 |
+
</div>
|
| 27 |
+
<div class="flex justify-between text-xs text-gray-500">
|
| 28 |
+
<span>匿名用户:</span>
|
| 29 |
+
<span>{{ provider.guest_tokens }}</span>
|
| 30 |
+
</div>
|
| 31 |
+
<div class="flex justify-between text-xs text-gray-500">
|
| 32 |
+
<span>未知类型:</span>
|
| 33 |
+
<span>{{ provider.unknown_tokens }}</span>
|
| 34 |
+
</div>
|
| 35 |
+
</div>
|
| 36 |
+
</div>
|
| 37 |
+
|
| 38 |
+
<!-- 请求统计 -->
|
| 39 |
+
<div class="bg-gray-50 rounded-md p-3">
|
| 40 |
+
<h5 class="text-xs font-medium text-gray-500 mb-2">请求统计</h5>
|
| 41 |
+
<div class="space-y-2 text-sm">
|
| 42 |
+
<div class="flex justify-between">
|
| 43 |
+
<span class="text-gray-600">总请求数:</span>
|
| 44 |
+
<span class="font-medium text-gray-900">{{ provider.total_requests }}</span>
|
| 45 |
+
</div>
|
| 46 |
+
<div class="flex justify-between">
|
| 47 |
+
<span class="text-gray-600">成功:</span>
|
| 48 |
+
<span class="font-medium text-green-600">{{ provider.successful_requests }}</span>
|
| 49 |
+
</div>
|
| 50 |
+
<div class="flex justify-between">
|
| 51 |
+
<span class="text-gray-600">失败:</span>
|
| 52 |
+
<span class="font-medium text-red-600">{{ provider.failed_requests }}</span>
|
| 53 |
+
</div>
|
| 54 |
+
<div class="flex justify-between">
|
| 55 |
+
<span class="text-gray-600">成功率:</span>
|
| 56 |
+
<span class="font-medium text-indigo-600">{{ provider.success_rate }}</span>
|
| 57 |
+
</div>
|
| 58 |
+
</div>
|
| 59 |
+
</div>
|
| 60 |
+
</div>
|
| 61 |
+
|
| 62 |
+
<!-- 快速操作 -->
|
| 63 |
+
<div class="pt-3 border-t border-gray-200">
|
| 64 |
+
<a href="/admin/tokens?provider={{ provider.name }}"
|
| 65 |
+
class="text-sm text-indigo-600 hover:text-indigo-800 font-medium transition-colors">
|
| 66 |
+
管理 Token →
|
| 67 |
+
</a>
|
| 68 |
+
</div>
|
| 69 |
+
</div>
|
| 70 |
+
{% endfor %}
|
| 71 |
+
</div>
|
| 72 |
+
|
| 73 |
+
<!-- 无提供商状态 -->
|
| 74 |
+
{% if not providers %}
|
| 75 |
+
<div class="text-center py-8 text-gray-500">
|
| 76 |
+
<p>暂无提供商状态信息</p>
|
| 77 |
+
</div>
|
| 78 |
+
{% endif %}
|
app/templates/components/recent_logs.html
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!-- 最近请求日志列表 -->
|
| 2 |
+
<div class="overflow-x-auto">
|
| 3 |
+
<table class="min-w-full divide-y divide-gray-200">
|
| 4 |
+
<thead class="bg-gray-50">
|
| 5 |
+
<tr>
|
| 6 |
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">时间</th>
|
| 7 |
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">端点</th>
|
| 8 |
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">模型</th>
|
| 9 |
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">提供商</th>
|
| 10 |
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th>
|
| 11 |
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">耗时</th>
|
| 12 |
+
</tr>
|
| 13 |
+
</thead>
|
| 14 |
+
<tbody class="bg-white divide-y divide-gray-200">
|
| 15 |
+
{% for log in logs %}
|
| 16 |
+
<tr class="hover:bg-gray-50">
|
| 17 |
+
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ log.timestamp }}</td>
|
| 18 |
+
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ log.endpoint }}</td>
|
| 19 |
+
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ log.model }}</td>
|
| 20 |
+
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
| 21 |
+
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800">
|
| 22 |
+
{{ log.provider }}
|
| 23 |
+
</span>
|
| 24 |
+
</td>
|
| 25 |
+
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
| 26 |
+
{% if log.status == 200 %}
|
| 27 |
+
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800">
|
| 28 |
+
{{ log.status }}
|
| 29 |
+
</span>
|
| 30 |
+
{% else %}
|
| 31 |
+
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-red-100 text-red-800">
|
| 32 |
+
{{ log.status }}
|
| 33 |
+
</span>
|
| 34 |
+
{% endif %}
|
| 35 |
+
</td>
|
| 36 |
+
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ log.duration }}</td>
|
| 37 |
+
</tr>
|
| 38 |
+
{% endfor %}
|
| 39 |
+
</tbody>
|
| 40 |
+
</table>
|
| 41 |
+
|
| 42 |
+
{% if not logs %}
|
| 43 |
+
<div class="text-center py-8 text-gray-500">
|
| 44 |
+
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 45 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
| 46 |
+
</svg>
|
| 47 |
+
<p class="mt-2">暂无请求日志</p>
|
| 48 |
+
</div>
|
| 49 |
+
{% endif %}
|
| 50 |
+
</div>
|
app/templates/components/token_list.html
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!-- Token 列表表格 -->
|
| 2 |
+
<div class="overflow-x-auto">
|
| 3 |
+
<table class="min-w-full divide-y divide-gray-200">
|
| 4 |
+
<thead class="bg-gray-50">
|
| 5 |
+
<tr>
|
| 6 |
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID</th>
|
| 7 |
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Token</th>
|
| 8 |
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">类型</th>
|
| 9 |
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">健康度</th>
|
| 10 |
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th>
|
| 11 |
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">使用统计</th>
|
| 12 |
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">创建时间</th>
|
| 13 |
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">操作</th>
|
| 14 |
+
</tr>
|
| 15 |
+
</thead>
|
| 16 |
+
<tbody class="bg-white divide-y divide-gray-200">
|
| 17 |
+
{% for token in tokens %}
|
| 18 |
+
{% include "components/token_row.html" %}
|
| 19 |
+
{% endfor %}
|
| 20 |
+
</tbody>
|
| 21 |
+
</table>
|
| 22 |
+
|
| 23 |
+
{% if not tokens %}
|
| 24 |
+
<div class="text-center py-12 text-gray-500">
|
| 25 |
+
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 26 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
| 27 |
+
</svg>
|
| 28 |
+
<p class="mt-2 font-medium">暂无 Token</p>
|
| 29 |
+
<p class="mt-1 text-sm">点击右上角"添加 Token"按钮开始添加</p>
|
| 30 |
+
</div>
|
| 31 |
+
{% endif %}
|
| 32 |
+
</div>
|
| 33 |
+
|
| 34 |
+
<!-- Token 数量统计(更新页面标题) -->
|
| 35 |
+
<script>
|
| 36 |
+
(function() {
|
| 37 |
+
const tokenCount = {{ tokens|length }};
|
| 38 |
+
const countElement = document.getElementById('token-count');
|
| 39 |
+
if (countElement) {
|
| 40 |
+
countElement.textContent = `(共 ${tokenCount} 个)`;
|
| 41 |
+
}
|
| 42 |
+
})();
|
| 43 |
+
</script>
|
| 44 |
+
|
| 45 |
+
<!-- 复制到剪贴板函数 -->
|
| 46 |
+
<script>
|
| 47 |
+
function copyToClipboard(text) {
|
| 48 |
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
| 49 |
+
navigator.clipboard.writeText(text).then(() => {
|
| 50 |
+
// 显示临时提示
|
| 51 |
+
const notification = document.createElement('div');
|
| 52 |
+
notification.className = 'fixed bottom-4 right-4 bg-green-500 text-white px-4 py-2 rounded shadow-lg z-50';
|
| 53 |
+
notification.textContent = '✓ Token 已复制到剪贴板';
|
| 54 |
+
document.body.appendChild(notification);
|
| 55 |
+
|
| 56 |
+
setTimeout(() => {
|
| 57 |
+
notification.remove();
|
| 58 |
+
}, 2000);
|
| 59 |
+
}).catch(err => {
|
| 60 |
+
console.error('复制失败:', err);
|
| 61 |
+
alert('复制失败,请手动复制');
|
| 62 |
+
});
|
| 63 |
+
} else {
|
| 64 |
+
// 降级方案:使用 execCommand
|
| 65 |
+
const textArea = document.createElement('textarea');
|
| 66 |
+
textArea.value = text;
|
| 67 |
+
textArea.style.position = 'fixed';
|
| 68 |
+
textArea.style.left = '-999999px';
|
| 69 |
+
document.body.appendChild(textArea);
|
| 70 |
+
textArea.select();
|
| 71 |
+
try {
|
| 72 |
+
document.execCommand('copy');
|
| 73 |
+
alert('Token 已复制到剪贴板');
|
| 74 |
+
} catch (err) {
|
| 75 |
+
alert('复制失败,请手动复制');
|
| 76 |
+
}
|
| 77 |
+
document.body.removeChild(textArea);
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
</script>
|
app/templates/components/token_pool.html
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!-- Token 池状态卡片 -->
|
| 2 |
+
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
| 3 |
+
{% for token in tokens %}
|
| 4 |
+
<div class="border rounded-lg p-4 hover:shadow-lg transition-shadow">
|
| 5 |
+
<div class="flex items-center justify-between mb-2">
|
| 6 |
+
<span class="text-sm font-medium text-gray-700">Token #{{ token.index }}</span>
|
| 7 |
+
<span class="px-2 py-1 text-xs font-semibold rounded-full {{ token.status_color }}">
|
| 8 |
+
{{ token.status }}
|
| 9 |
+
</span>
|
| 10 |
+
</div>
|
| 11 |
+
<div class="space-y-1 text-sm text-gray-600">
|
| 12 |
+
<div class="truncate">
|
| 13 |
+
<span class="font-mono text-xs bg-gray-100 px-2 py-1 rounded">{{ token.key }}</span>
|
| 14 |
+
</div>
|
| 15 |
+
<div>类型:
|
| 16 |
+
{% if token.token_type == 'user' %}
|
| 17 |
+
<span class="text-green-600 font-semibold">认证用户</span>
|
| 18 |
+
{% elif token.token_type == 'guest' %}
|
| 19 |
+
<span class="text-yellow-600 font-semibold">匿名用户</span>
|
| 20 |
+
{% else %}
|
| 21 |
+
<span class="text-gray-600">未知</span>
|
| 22 |
+
{% endif %}
|
| 23 |
+
</div>
|
| 24 |
+
<div>成功率: <span class="font-medium">{{ token.success_rate }}</span></div>
|
| 25 |
+
<div>失败次数: <span class="font-medium">{{ token.failure_count }}</span></div>
|
| 26 |
+
<div class="text-xs text-gray-500">最后使用: {{ token.last_used }}</div>
|
| 27 |
+
</div>
|
| 28 |
+
</div>
|
| 29 |
+
{% endfor %}
|
| 30 |
+
|
| 31 |
+
{% if not tokens %}
|
| 32 |
+
<div class="col-span-full text-center py-8 text-gray-500">
|
| 33 |
+
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 34 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
| 35 |
+
</svg>
|
| 36 |
+
<p class="mt-2">暂无 Token 配置</p>
|
| 37 |
+
<p class="mt-1 text-sm">请在配置管理页面添加 Token</p>
|
| 38 |
+
</div>
|
| 39 |
+
{% endif %}
|
| 40 |
+
</div>
|
app/templates/components/token_row.html
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!-- 单个 Token 行模板 -->
|
| 2 |
+
{% set success_rate = (token.successful_requests / token.total_requests * 100) if token.total_requests else 0 %}
|
| 3 |
+
{% set is_healthy = (token.token_type == 'user' and token.is_enabled and (success_rate >= 50 or token.total_requests <= 3)) %}
|
| 4 |
+
<tr class="hover:bg-gray-50 transition-colors" id="token-row-{{ token.id }}">
|
| 5 |
+
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 font-medium">
|
| 6 |
+
{{ token.id }}
|
| 7 |
+
</td>
|
| 8 |
+
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
| 9 |
+
<div class="flex items-center space-x-2">
|
| 10 |
+
<span class="font-mono text-xs bg-gray-100 px-2 py-1 rounded">
|
| 11 |
+
{{ token.token[:30] }}...
|
| 12 |
+
</span>
|
| 13 |
+
<button onclick="copyToClipboard('{{ token.token }}')"
|
| 14 |
+
class="text-gray-400 hover:text-indigo-600 transition-colors"
|
| 15 |
+
title="复制完整 Token">
|
| 16 |
+
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 17 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
| 18 |
+
</svg>
|
| 19 |
+
</button>
|
| 20 |
+
</div>
|
| 21 |
+
</td>
|
| 22 |
+
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
| 23 |
+
{% if token.token_type == 'user' %}
|
| 24 |
+
<span class="inline-flex items-center px-2.5 py-0.5 text-xs font-semibold rounded-full bg-green-100 text-green-800">
|
| 25 |
+
<svg class="h-3 w-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
| 26 |
+
<path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clip-rule="evenodd" />
|
| 27 |
+
</svg>
|
| 28 |
+
认证用户
|
| 29 |
+
</span>
|
| 30 |
+
{% elif token.token_type == 'guest' %}
|
| 31 |
+
<span class="inline-flex items-center px-2.5 py-0.5 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800">
|
| 32 |
+
<svg class="h-3 w-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
| 33 |
+
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
| 34 |
+
</svg>
|
| 35 |
+
匿名用户
|
| 36 |
+
</span>
|
| 37 |
+
{% else %}
|
| 38 |
+
<span class="inline-flex items-center px-2.5 py-0.5 text-xs font-semibold rounded-full bg-gray-100 text-gray-800">
|
| 39 |
+
<svg class="h-3 w-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
| 40 |
+
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
|
| 41 |
+
</svg>
|
| 42 |
+
未知
|
| 43 |
+
</span>
|
| 44 |
+
{% endif %}
|
| 45 |
+
</td>
|
| 46 |
+
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
| 47 |
+
<!-- 健康度指示器 -->
|
| 48 |
+
<div class="flex items-center space-x-2">
|
| 49 |
+
{% if is_healthy %}
|
| 50 |
+
<div class="flex items-center">
|
| 51 |
+
<svg class="h-5 w-5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
| 52 |
+
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
| 53 |
+
</svg>
|
| 54 |
+
<span class="ml-1 text-green-700 font-medium">健康</span>
|
| 55 |
+
</div>
|
| 56 |
+
{% elif token.token_type == 'guest' %}
|
| 57 |
+
<div class="flex items-center">
|
| 58 |
+
<svg class="h-5 w-5 text-yellow-500" fill="currentColor" viewBox="0 0 20 20">
|
| 59 |
+
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
| 60 |
+
</svg>
|
| 61 |
+
<span class="ml-1 text-yellow-700 font-medium">匿名</span>
|
| 62 |
+
</div>
|
| 63 |
+
{% elif not token.is_enabled %}
|
| 64 |
+
<div class="flex items-center">
|
| 65 |
+
<svg class="h-5 w-5 text-gray-500" fill="currentColor" viewBox="0 0 20 20">
|
| 66 |
+
<path fill-rule="evenodd" d="M13.477 14.89A6 6 0 015.11 6.524l8.367 8.368zm1.414-1.414L6.524 5.11a6 6 0 018.367 8.367zM18 10a8 8 0 11-16 0 8 8 0 0116 0z" clip-rule="evenodd" />
|
| 67 |
+
</svg>
|
| 68 |
+
<span class="ml-1 text-gray-700 font-medium">已禁用</span>
|
| 69 |
+
</div>
|
| 70 |
+
{% else %}
|
| 71 |
+
<div class="flex items-center">
|
| 72 |
+
<svg class="h-5 w-5 text-red-500" fill="currentColor" viewBox="0 0 20 20">
|
| 73 |
+
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
| 74 |
+
</svg>
|
| 75 |
+
<span class="ml-1 text-red-700 font-medium">不健康</span>
|
| 76 |
+
</div>
|
| 77 |
+
{% endif %}
|
| 78 |
+
</div>
|
| 79 |
+
</td>
|
| 80 |
+
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
| 81 |
+
<button hx-post="/admin/api/tokens/toggle/{{ token.id }}?enabled={{ 'false' if token.is_enabled else 'true' }}"
|
| 82 |
+
hx-swap="outerHTML"
|
| 83 |
+
class="inline-flex items-center px-2.5 py-0.5 text-xs font-semibold rounded-full transition-colors {{ 'bg-green-100 text-green-800 hover:bg-green-200' if token.is_enabled else 'bg-red-100 text-red-800 hover:bg-red-200' }}">
|
| 84 |
+
<span class="h-2 w-2 rounded-full mr-1.5 {{ 'bg-green-500' if token.is_enabled else 'bg-red-500' }}"></span>
|
| 85 |
+
{{ '已启用' if token.is_enabled else '已禁用' }}
|
| 86 |
+
</button>
|
| 87 |
+
</td>
|
| 88 |
+
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
| 89 |
+
{% if token.total_requests %}
|
| 90 |
+
<div class="space-y-1">
|
| 91 |
+
<div class="flex items-center justify-between">
|
| 92 |
+
<span class="text-xs text-gray-600">成功:</span>
|
| 93 |
+
<span class="font-medium text-green-600">{{ token.successful_requests }}</span>
|
| 94 |
+
</div>
|
| 95 |
+
<div class="flex items-center justify-between">
|
| 96 |
+
<span class="text-xs text-gray-600">失败:</span>
|
| 97 |
+
<span class="font-medium text-red-600">{{ token.failed_requests }}</span>
|
| 98 |
+
</div>
|
| 99 |
+
<div class="flex items-center justify-between">
|
| 100 |
+
<span class="text-xs text-gray-600">成功率:</span>
|
| 101 |
+
<span class="font-medium {{ 'text-green-600' if success_rate >= 50 else 'text-red-600' }}">
|
| 102 |
+
{{ "%.1f"|format(success_rate) }}%
|
| 103 |
+
</span>
|
| 104 |
+
</div>
|
| 105 |
+
<!-- 成功率进度条 -->
|
| 106 |
+
<div class="w-full bg-gray-200 rounded-full h-1.5 mt-1">
|
| 107 |
+
<div class="h-1.5 rounded-full transition-all {{ 'bg-green-500' if success_rate >= 50 else 'bg-red-500' }}"
|
| 108 |
+
style="width: {{ success_rate }}%"></div>
|
| 109 |
+
</div>
|
| 110 |
+
</div>
|
| 111 |
+
{% else %}
|
| 112 |
+
<span class="text-gray-400 text-xs">未使用</span>
|
| 113 |
+
{% endif %}
|
| 114 |
+
</td>
|
| 115 |
+
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
| 116 |
+
<div class="flex flex-col space-y-1">
|
| 117 |
+
<span class="text-xs">{{ token.created_at[:10] if token.created_at else 'N/A' }}</span>
|
| 118 |
+
<span class="text-xs text-gray-400">{{ token.created_at[11:19] if token.created_at else '' }}</span>
|
| 119 |
+
</div>
|
| 120 |
+
</td>
|
| 121 |
+
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
| 122 |
+
<div class="flex items-center space-x-3">
|
| 123 |
+
<!-- 验证按钮 -->
|
| 124 |
+
<button hx-post="/admin/api/tokens/validate-single/{{ token.id }}"
|
| 125 |
+
hx-target="#token-row-{{ token.id }}"
|
| 126 |
+
hx-swap="outerHTML"
|
| 127 |
+
hx-indicator="#validate-spinner-{{ token.id }}"
|
| 128 |
+
class="text-blue-600 hover:text-blue-900 transition-colors relative validate-token-btn"
|
| 129 |
+
title="验证 Token"
|
| 130 |
+
data-token-id="{{ token.id }}">
|
| 131 |
+
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 132 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
| 133 |
+
</svg>
|
| 134 |
+
<!-- 加载指示器 -->
|
| 135 |
+
<svg id="validate-spinner-{{ token.id }}" class="htmx-indicator absolute inset-0 h-4 w-4 animate-spin text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
| 136 |
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
| 137 |
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
| 138 |
+
</svg>
|
| 139 |
+
</button>
|
| 140 |
+
<!-- 删除按钮 -->
|
| 141 |
+
<button hx-delete="/admin/api/tokens/delete/{{ token.id }}"
|
| 142 |
+
hx-target="#token-row-{{ token.id }}"
|
| 143 |
+
hx-swap="outerHTML swap:1s"
|
| 144 |
+
hx-confirm="确定要删除这个 Token 吗?"
|
| 145 |
+
class="text-red-600 hover:text-red-900 transition-colors"
|
| 146 |
+
title="删除 Token">
|
| 147 |
+
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 148 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
| 149 |
+
</svg>
|
| 150 |
+
</button>
|
| 151 |
+
</div>
|
| 152 |
+
</td>
|
| 153 |
+
</tr>
|
app/templates/components/token_stats.html
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!-- Token 统计面板 -->
|
| 2 |
+
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
| 3 |
+
<!-- 总数 -->
|
| 4 |
+
<div class="bg-white overflow-hidden shadow rounded-lg">
|
| 5 |
+
<div class="p-5">
|
| 6 |
+
<div class="flex items-center">
|
| 7 |
+
<div class="flex-shrink-0">
|
| 8 |
+
<svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 9 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
|
| 10 |
+
</svg>
|
| 11 |
+
</div>
|
| 12 |
+
<div class="ml-5 w-0 flex-1">
|
| 13 |
+
<dl>
|
| 14 |
+
<dt class="text-sm font-medium text-gray-500 truncate">Token 总数</dt>
|
| 15 |
+
<dd class="text-2xl font-bold text-gray-900">{{ stats.total_tokens }}</dd>
|
| 16 |
+
</dl>
|
| 17 |
+
</div>
|
| 18 |
+
</div>
|
| 19 |
+
</div>
|
| 20 |
+
</div>
|
| 21 |
+
|
| 22 |
+
<!-- 已启用 -->
|
| 23 |
+
<div class="bg-white overflow-hidden shadow rounded-lg">
|
| 24 |
+
<div class="p-5">
|
| 25 |
+
<div class="flex items-center">
|
| 26 |
+
<div class="flex-shrink-0">
|
| 27 |
+
<svg class="h-6 w-6 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 28 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
| 29 |
+
</svg>
|
| 30 |
+
</div>
|
| 31 |
+
<div class="ml-5 w-0 flex-1">
|
| 32 |
+
<dl>
|
| 33 |
+
<dt class="text-sm font-medium text-gray-500 truncate">已启用</dt>
|
| 34 |
+
<dd class="flex items-baseline">
|
| 35 |
+
<div class="text-2xl font-bold text-green-600">{{ stats.enabled_tokens }}</div>
|
| 36 |
+
{% if stats.total_tokens > 0 %}
|
| 37 |
+
<div class="ml-2 flex items-baseline text-sm font-semibold text-green-600">
|
| 38 |
+
{{ "%.0f"|format(stats.enabled_tokens / stats.total_tokens * 100) }}%
|
| 39 |
+
</div>
|
| 40 |
+
{% endif %}
|
| 41 |
+
</dd>
|
| 42 |
+
</dl>
|
| 43 |
+
</div>
|
| 44 |
+
</div>
|
| 45 |
+
</div>
|
| 46 |
+
</div>
|
| 47 |
+
|
| 48 |
+
<!-- 认证用户 -->
|
| 49 |
+
<div class="bg-white overflow-hidden shadow rounded-lg">
|
| 50 |
+
<div class="p-5">
|
| 51 |
+
<div class="flex items-center">
|
| 52 |
+
<div class="flex-shrink-0">
|
| 53 |
+
<svg class="h-6 w-6 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 54 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
| 55 |
+
</svg>
|
| 56 |
+
</div>
|
| 57 |
+
<div class="ml-5 w-0 flex-1">
|
| 58 |
+
<dl>
|
| 59 |
+
<dt class="text-sm font-medium text-gray-500 truncate">认证用户</dt>
|
| 60 |
+
<dd class="flex items-baseline">
|
| 61 |
+
<div class="text-2xl font-bold text-blue-600">{{ stats.user_tokens }}</div>
|
| 62 |
+
{% if stats.guest_tokens > 0 %}
|
| 63 |
+
<div class="ml-2 flex items-baseline text-sm font-semibold text-yellow-600">
|
| 64 |
+
<svg class="h-4 w-4 mr-0.5" fill="currentColor" viewBox="0 0 20 20">
|
| 65 |
+
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
| 66 |
+
</svg>
|
| 67 |
+
{{ stats.guest_tokens }} 个匿名
|
| 68 |
+
</div>
|
| 69 |
+
{% endif %}
|
| 70 |
+
</dd>
|
| 71 |
+
</dl>
|
| 72 |
+
</div>
|
| 73 |
+
</div>
|
| 74 |
+
</div>
|
| 75 |
+
</div>
|
| 76 |
+
|
| 77 |
+
<!-- 成功率 -->
|
| 78 |
+
<div class="bg-white overflow-hidden shadow rounded-lg">
|
| 79 |
+
<div class="p-5">
|
| 80 |
+
<div class="flex items-center">
|
| 81 |
+
<div class="flex-shrink-0">
|
| 82 |
+
{% if stats.total_requests > 0 %}
|
| 83 |
+
{% set success_rate = (stats.successful_requests / stats.total_requests * 100) %}
|
| 84 |
+
{% if success_rate >= 80 %}
|
| 85 |
+
<svg class="h-6 w-6 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 86 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
| 87 |
+
</svg>
|
| 88 |
+
{% elif success_rate >= 50 %}
|
| 89 |
+
<svg class="h-6 w-6 text-yellow-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 90 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
| 91 |
+
</svg>
|
| 92 |
+
{% else %}
|
| 93 |
+
<svg class="h-6 w-6 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 94 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 17h8m0 0v-8m0 8l-8-8-4 4-6-6" />
|
| 95 |
+
</svg>
|
| 96 |
+
{% endif %}
|
| 97 |
+
{% else %}
|
| 98 |
+
<svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 99 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
| 100 |
+
</svg>
|
| 101 |
+
{% endif %}
|
| 102 |
+
</div>
|
| 103 |
+
<div class="ml-5 w-0 flex-1">
|
| 104 |
+
<dl>
|
| 105 |
+
<dt class="text-sm font-medium text-gray-500 truncate">总成功率</dt>
|
| 106 |
+
<dd>
|
| 107 |
+
{% if stats.total_requests > 0 %}
|
| 108 |
+
{% set success_rate = (stats.successful_requests / stats.total_requests * 100) %}
|
| 109 |
+
<div class="text-2xl font-bold {{ 'text-green-600' if success_rate >= 80 else ('text-yellow-600' if success_rate >= 50 else 'text-red-600') }}">
|
| 110 |
+
{{ "%.1f"|format(success_rate) }}%
|
| 111 |
+
</div>
|
| 112 |
+
<div class="mt-1 text-xs text-gray-500">
|
| 113 |
+
{{ stats.successful_requests }} / {{ stats.total_requests }} 请求
|
| 114 |
+
</div>
|
| 115 |
+
{% else %}
|
| 116 |
+
<div class="text-2xl font-bold text-gray-400">N/A</div>
|
| 117 |
+
<div class="mt-1 text-xs text-gray-500">暂无请求</div>
|
| 118 |
+
{% endif %}
|
| 119 |
+
</dd>
|
| 120 |
+
</dl>
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
</div>
|
| 124 |
+
</div>
|
| 125 |
+
</div>
|
app/templates/config.html
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}配置管理{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<div class="space-y-6" x-data="{
|
| 7 |
+
showAdvanced: false,
|
| 8 |
+
tokenCount: 1,
|
| 9 |
+
saveStatus: ''
|
| 10 |
+
}">
|
| 11 |
+
<!-- 页面标题 -->
|
| 12 |
+
<div class="flex items-center justify-between">
|
| 13 |
+
<h2 class="text-3xl font-bold text-gray-900">配置管理</h2>
|
| 14 |
+
<button
|
| 15 |
+
@click="showAdvanced = !showAdvanced"
|
| 16 |
+
class="px-4 py-2 bg-gray-200 hover:bg-gray-300 rounded-md text-sm font-medium">
|
| 17 |
+
<span x-text="showAdvanced ? '隐藏高级选项' : '显示高级选项'"></span>
|
| 18 |
+
</button>
|
| 19 |
+
</div>
|
| 20 |
+
|
| 21 |
+
<!-- 基础配置 -->
|
| 22 |
+
<div class="bg-white shadow rounded-lg">
|
| 23 |
+
<div class="px-6 py-4 border-b border-gray-200">
|
| 24 |
+
<h3 class="text-lg font-medium text-gray-900">基础配置</h3>
|
| 25 |
+
</div>
|
| 26 |
+
<form hx-post="/admin/api/config/save"
|
| 27 |
+
hx-target="#save-notification"
|
| 28 |
+
hx-swap="innerHTML"
|
| 29 |
+
class="p-6 space-y-6">
|
| 30 |
+
|
| 31 |
+
<!-- 服务名称 -->
|
| 32 |
+
<div>
|
| 33 |
+
<label class="block text-sm font-medium text-gray-700">服务名称</label>
|
| 34 |
+
<input type="text"
|
| 35 |
+
name="service_name"
|
| 36 |
+
value="{{ config.SERVICE_NAME or 'Z.AI2API' }}"
|
| 37 |
+
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
|
| 38 |
+
<p class="mt-1 text-sm text-gray-500">显示在进程列表中的服务名称</p>
|
| 39 |
+
</div>
|
| 40 |
+
|
| 41 |
+
<!-- 监听端口 -->
|
| 42 |
+
<div>
|
| 43 |
+
<label class="block text-sm font-medium text-gray-700">监听端口</label>
|
| 44 |
+
<input type="number"
|
| 45 |
+
name="listen_port"
|
| 46 |
+
value="{{ config.LISTEN_PORT or 8000 }}"
|
| 47 |
+
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
|
| 48 |
+
<p class="mt-1 text-sm text-gray-500">服务监听的端口号</p>
|
| 49 |
+
</div>
|
| 50 |
+
|
| 51 |
+
<!-- 调试模式 -->
|
| 52 |
+
<div class="flex items-center">
|
| 53 |
+
<input type="checkbox"
|
| 54 |
+
name="debug_logging"
|
| 55 |
+
{{ 'checked' if config.DEBUG_LOGGING else '' }}
|
| 56 |
+
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
| 57 |
+
<label class="ml-2 block text-sm text-gray-900">启用调试日志</label>
|
| 58 |
+
</div>
|
| 59 |
+
|
| 60 |
+
<!-- 匿名模式 -->
|
| 61 |
+
<div class="flex items-center">
|
| 62 |
+
<input type="checkbox"
|
| 63 |
+
name="anonymous_mode"
|
| 64 |
+
{{ 'checked' if config.ANONYMOUS_MODE else '' }}
|
| 65 |
+
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
| 66 |
+
<label class="ml-2 block text-sm text-gray-900">启用匿名模式(自动获取临时 Token)</label>
|
| 67 |
+
</div>
|
| 68 |
+
|
| 69 |
+
<!-- 认证配置 -->
|
| 70 |
+
<div>
|
| 71 |
+
<label class="block text-sm font-medium text-gray-700">客户端认证密钥</label>
|
| 72 |
+
<input type="text"
|
| 73 |
+
name="auth_token"
|
| 74 |
+
value="{{ config.AUTH_TOKEN or 'sk-your-api-key' }}"
|
| 75 |
+
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
|
| 76 |
+
<p class="mt-1 text-sm text-gray-500">客户端访问本服务时使用的 API 密钥</p>
|
| 77 |
+
</div>
|
| 78 |
+
|
| 79 |
+
<!-- 跳过认证 -->
|
| 80 |
+
<div class="flex items-center">
|
| 81 |
+
<input type="checkbox"
|
| 82 |
+
name="skip_auth_token"
|
| 83 |
+
{{ 'checked' if config.SKIP_AUTH_TOKEN else '' }}
|
| 84 |
+
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
| 85 |
+
<label class="ml-2 block text-sm text-gray-900">跳过客户端认证(仅开发环境)</label>
|
| 86 |
+
</div>
|
| 87 |
+
|
| 88 |
+
<!-- 工具调用支持 -->
|
| 89 |
+
<div class="flex items-center">
|
| 90 |
+
<input type="checkbox"
|
| 91 |
+
name="tool_support"
|
| 92 |
+
{{ 'checked' if config.TOOL_SUPPORT else '' }}
|
| 93 |
+
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
| 94 |
+
<label class="ml-2 block text-sm text-gray-900">启用 Function Call(工具调用)功能</label>
|
| 95 |
+
</div>
|
| 96 |
+
|
| 97 |
+
<!-- 高级选项 -->
|
| 98 |
+
<div x-show="showAdvanced" x-transition class="border-t pt-6 space-y-6">
|
| 99 |
+
<h4 class="text-md font-medium text-gray-900">高级选项</h4>
|
| 100 |
+
|
| 101 |
+
<!-- Token 失败阈值 -->
|
| 102 |
+
<div>
|
| 103 |
+
<label class="block text-sm font-medium text-gray-700">Token 失败阈值</label>
|
| 104 |
+
<input type="number"
|
| 105 |
+
name="token_failure_threshold"
|
| 106 |
+
value="{{ config.TOKEN_FAILURE_THRESHOLD or 3 }}"
|
| 107 |
+
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
|
| 108 |
+
<p class="mt-1 text-sm text-gray-500">连续失败多少次后标记 Token 为失败状态</p>
|
| 109 |
+
</div>
|
| 110 |
+
|
| 111 |
+
<!-- Token 恢复超时 -->
|
| 112 |
+
<div>
|
| 113 |
+
<label class="block text-sm font-medium text-gray-700">Token 恢复超时(秒)</label>
|
| 114 |
+
<input type="number"
|
| 115 |
+
name="token_recovery_timeout"
|
| 116 |
+
value="{{ config.TOKEN_RECOVERY_TIMEOUT or 1800 }}"
|
| 117 |
+
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
|
| 118 |
+
<p class="mt-1 text-sm text-gray-500">Token 失败后多久自动恢复(默认 1800 秒 / 30 分钟)</p>
|
| 119 |
+
</div>
|
| 120 |
+
|
| 121 |
+
<!-- 工具调用扫描限制 -->
|
| 122 |
+
<div>
|
| 123 |
+
<label class="block text-sm font-medium text-gray-700">工具调用扫描限制(字符数)</label>
|
| 124 |
+
<input type="number"
|
| 125 |
+
name="scan_limit"
|
| 126 |
+
value="{{ config.SCAN_LIMIT or 200000 }}"
|
| 127 |
+
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
|
| 128 |
+
<p class="mt-1 text-sm text-gray-500">Function Call 功能扫描的最大字符数</p>
|
| 129 |
+
</div>
|
| 130 |
+
|
| 131 |
+
<!-- LongCat Token -->
|
| 132 |
+
<div>
|
| 133 |
+
<label class="block text-sm font-medium text-gray-700">LongCat Passport Token</label>
|
| 134 |
+
<input type="text"
|
| 135 |
+
name="longcat_token"
|
| 136 |
+
value="{{ config.LONGCAT_TOKEN or '' }}"
|
| 137 |
+
placeholder="可选,用于 LongCat 提供商"
|
| 138 |
+
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
|
| 139 |
+
<p class="mt-1 text-sm text-gray-500">LongCat 提供商的 passport token(可选)</p>
|
| 140 |
+
</div>
|
| 141 |
+
|
| 142 |
+
<!-- 默认提供商 -->
|
| 143 |
+
<div>
|
| 144 |
+
<label class="block text-sm font-medium text-gray-700">默认提供商</label>
|
| 145 |
+
<select name="default_provider"
|
| 146 |
+
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
|
| 147 |
+
<option value="zai" {{ 'selected' if config.DEFAULT_PROVIDER == 'zai' else '' }}>Z.AI</option>
|
| 148 |
+
<option value="k2think" {{ 'selected' if config.DEFAULT_PROVIDER == 'k2think' else '' }}>K2Think</option>
|
| 149 |
+
<option value="longcat" {{ 'selected' if config.DEFAULT_PROVIDER == 'longcat' else '' }}>LongCat</option>
|
| 150 |
+
</select>
|
| 151 |
+
</div>
|
| 152 |
+
</div>
|
| 153 |
+
|
| 154 |
+
<!-- 保存按钮 -->
|
| 155 |
+
<div class="flex items-center justify-between pt-6 border-t">
|
| 156 |
+
<div id="save-notification" class="flex-1"></div>
|
| 157 |
+
<div class="flex space-x-3">
|
| 158 |
+
<button type="button"
|
| 159 |
+
hx-get="/admin/config/reset"
|
| 160 |
+
hx-confirm="确定要重置所有配置吗?"
|
| 161 |
+
class="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50">
|
| 162 |
+
重置
|
| 163 |
+
</button>
|
| 164 |
+
<button type="submit"
|
| 165 |
+
class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
| 166 |
+
💾 保存并重载
|
| 167 |
+
</button>
|
| 168 |
+
</div>
|
| 169 |
+
</div>
|
| 170 |
+
|
| 171 |
+
<!-- 配置说明 -->
|
| 172 |
+
<div class="mt-4 bg-blue-50 border-l-4 border-blue-400 p-4">
|
| 173 |
+
<div class="flex">
|
| 174 |
+
<div class="flex-shrink-0">
|
| 175 |
+
<svg class="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor">
|
| 176 |
+
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
|
| 177 |
+
</svg>
|
| 178 |
+
</div>
|
| 179 |
+
<div class="ml-3">
|
| 180 |
+
<p class="text-sm text-blue-700">
|
| 181 |
+
<strong>提示:</strong>配置保存后会自动热重载,大部分配置无需重启服务即可生效。<br>
|
| 182 |
+
仅 <code class="bg-blue-100 px-1 py-0.5 rounded">监听端口</code> 和 <code class="bg-blue-100 px-1 py-0.5 rounded">服务名称</code> 需要手动重启服务。
|
| 183 |
+
</p>
|
| 184 |
+
</div>
|
| 185 |
+
</div>
|
| 186 |
+
</div>
|
| 187 |
+
</form>
|
| 188 |
+
</div>
|
| 189 |
+
|
| 190 |
+
<!-- 配置文件预览 -->
|
| 191 |
+
<div class="bg-white shadow rounded-lg">
|
| 192 |
+
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
| 193 |
+
<h3 class="text-lg font-medium text-gray-900">.env 文件预览</h3>
|
| 194 |
+
<button
|
| 195 |
+
hx-get="/admin/api/env-preview"
|
| 196 |
+
hx-target="#env-preview"
|
| 197 |
+
class="text-sm text-indigo-600 hover:text-indigo-700">
|
| 198 |
+
刷新
|
| 199 |
+
</button>
|
| 200 |
+
</div>
|
| 201 |
+
<div class="p-6">
|
| 202 |
+
<div id="env-preview" class="bg-gray-50 rounded-md p-4 font-mono text-sm overflow-x-auto">
|
| 203 |
+
<pre>{{ env_content or '加载中...' }}</pre>
|
| 204 |
+
</div>
|
| 205 |
+
</div>
|
| 206 |
+
</div>
|
| 207 |
+
</div>
|
| 208 |
+
{% endblock %}
|
| 209 |
+
|
| 210 |
+
{% block extra_scripts %}
|
| 211 |
+
<script>
|
| 212 |
+
// 配置保存成功后的处理
|
| 213 |
+
document.body.addEventListener('htmx:afterSwap', function(evt) {
|
| 214 |
+
if (evt.detail.target.id === 'save-notification') {
|
| 215 |
+
// 3秒后自动隐藏通知
|
| 216 |
+
setTimeout(() => {
|
| 217 |
+
evt.detail.target.innerHTML = '';
|
| 218 |
+
}, 3000);
|
| 219 |
+
}
|
| 220 |
+
});
|
| 221 |
+
</script>
|
| 222 |
+
{% endblock %}
|
app/templates/index.html
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}仪表盘{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<div class="space-y-6">
|
| 7 |
+
<!-- 页面标题 -->
|
| 8 |
+
<div class="flex items-center justify-between">
|
| 9 |
+
<h2 class="text-3xl font-bold text-gray-900">仪表盘</h2>
|
| 10 |
+
<div class="text-sm text-gray-500">
|
| 11 |
+
最后更新: <span id="last-update">{{ current_time }}</span>
|
| 12 |
+
</div>
|
| 13 |
+
</div>
|
| 14 |
+
|
| 15 |
+
<!-- 统计卡片 -->
|
| 16 |
+
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
| 17 |
+
<!-- 运行时间 -->
|
| 18 |
+
<div class="bg-white overflow-hidden shadow rounded-lg">
|
| 19 |
+
<div class="p-5">
|
| 20 |
+
<div class="flex items-center">
|
| 21 |
+
<div class="flex-shrink-0">
|
| 22 |
+
<svg class="h-6 w-6 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 23 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
| 24 |
+
</svg>
|
| 25 |
+
</div>
|
| 26 |
+
<div class="ml-5 w-0 flex-1">
|
| 27 |
+
<dl>
|
| 28 |
+
<dt class="text-sm font-medium text-gray-500 truncate">运行时间</dt>
|
| 29 |
+
<dd class="text-2xl font-semibold text-gray-900">{{ stats.uptime }}</dd>
|
| 30 |
+
</dl>
|
| 31 |
+
</div>
|
| 32 |
+
</div>
|
| 33 |
+
</div>
|
| 34 |
+
</div>
|
| 35 |
+
|
| 36 |
+
<!-- 总请求数 -->
|
| 37 |
+
<div class="bg-white overflow-hidden shadow rounded-lg">
|
| 38 |
+
<div class="p-5">
|
| 39 |
+
<div class="flex items-center">
|
| 40 |
+
<div class="flex-shrink-0">
|
| 41 |
+
<svg class="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 42 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
| 43 |
+
</svg>
|
| 44 |
+
</div>
|
| 45 |
+
<div class="ml-5 w-0 flex-1">
|
| 46 |
+
<dl>
|
| 47 |
+
<dt class="text-sm font-medium text-gray-500 truncate">总请求数</dt>
|
| 48 |
+
<dd class="text-2xl font-semibold text-gray-900">{{ stats.total_requests }}</dd>
|
| 49 |
+
</dl>
|
| 50 |
+
</div>
|
| 51 |
+
</div>
|
| 52 |
+
</div>
|
| 53 |
+
</div>
|
| 54 |
+
|
| 55 |
+
<!-- 成功率 -->
|
| 56 |
+
<div class="bg-white overflow-hidden shadow rounded-lg">
|
| 57 |
+
<div class="p-5">
|
| 58 |
+
<div class="flex items-center">
|
| 59 |
+
<div class="flex-shrink-0">
|
| 60 |
+
<svg class="h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 61 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
| 62 |
+
</svg>
|
| 63 |
+
</div>
|
| 64 |
+
<div class="ml-5 w-0 flex-1">
|
| 65 |
+
<dl>
|
| 66 |
+
<dt class="text-sm font-medium text-gray-500 truncate">成功率</dt>
|
| 67 |
+
<dd class="text-2xl font-semibold text-gray-900">{{ stats.success_rate }}%</dd>
|
| 68 |
+
</dl>
|
| 69 |
+
</div>
|
| 70 |
+
</div>
|
| 71 |
+
</div>
|
| 72 |
+
</div>
|
| 73 |
+
|
| 74 |
+
<!-- Token 池状态 -->
|
| 75 |
+
<div class="bg-white overflow-hidden shadow rounded-lg">
|
| 76 |
+
<div class="p-5">
|
| 77 |
+
<div class="flex items-center">
|
| 78 |
+
<div class="flex-shrink-0">
|
| 79 |
+
{% if stats.healthy_tokens >= stats.total_tokens * 0.8 %}
|
| 80 |
+
<svg class="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 81 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
| 82 |
+
</svg>
|
| 83 |
+
{% elif stats.healthy_tokens >= stats.total_tokens * 0.5 %}
|
| 84 |
+
<svg class="h-6 w-6 text-yellow-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 85 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
| 86 |
+
</svg>
|
| 87 |
+
{% else %}
|
| 88 |
+
<svg class="h-6 w-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 89 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
| 90 |
+
</svg>
|
| 91 |
+
{% endif %}
|
| 92 |
+
</div>
|
| 93 |
+
<div class="ml-5 w-0 flex-1">
|
| 94 |
+
<dl>
|
| 95 |
+
<dt class="text-sm font-medium text-gray-500 truncate">Token 池健康度</dt>
|
| 96 |
+
<dd class="flex items-baseline">
|
| 97 |
+
<span class="text-2xl font-semibold text-gray-900">{{ stats.healthy_tokens }}/{{ stats.total_tokens }}</span>
|
| 98 |
+
{% if stats.guest_tokens > 0 %}
|
| 99 |
+
<span class="ml-2 text-sm font-medium text-yellow-600">({{ stats.guest_tokens }} 个匿名)</span>
|
| 100 |
+
{% endif %}
|
| 101 |
+
</dd>
|
| 102 |
+
<dd class="mt-1 text-xs text-gray-500">可用: {{ stats.available_tokens }} | 认证: {{ stats.user_tokens }}</dd>
|
| 103 |
+
</dl>
|
| 104 |
+
</div>
|
| 105 |
+
</div>
|
| 106 |
+
</div>
|
| 107 |
+
</div>
|
| 108 |
+
</div>
|
| 109 |
+
|
| 110 |
+
<!-- Token 池详情 -->
|
| 111 |
+
<div class="bg-white shadow rounded-lg">
|
| 112 |
+
<div class="px-6 py-4 border-b border-gray-200">
|
| 113 |
+
<h3 class="text-lg font-medium text-gray-900">Token 池状态</h3>
|
| 114 |
+
</div>
|
| 115 |
+
<div class="p-6">
|
| 116 |
+
<div
|
| 117 |
+
id="token-pool-status"
|
| 118 |
+
hx-get="/admin/api/token-pool"
|
| 119 |
+
hx-trigger="load, every 5s"
|
| 120 |
+
hx-swap="innerHTML">
|
| 121 |
+
<!-- Token 池状态将通过 htmx 加载 -->
|
| 122 |
+
<div class="flex justify-center items-center py-12">
|
| 123 |
+
<svg class="animate-spin h-8 w-8 text-indigo-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
| 124 |
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
| 125 |
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
| 126 |
+
</svg>
|
| 127 |
+
<span class="ml-3 text-gray-500">加载中...</span>
|
| 128 |
+
</div>
|
| 129 |
+
</div>
|
| 130 |
+
</div>
|
| 131 |
+
</div>
|
| 132 |
+
|
| 133 |
+
<!-- 最近请求日志 -->
|
| 134 |
+
<div class="bg-white shadow rounded-lg">
|
| 135 |
+
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
| 136 |
+
<h3 class="text-lg font-medium text-gray-900">最近请求日志</h3>
|
| 137 |
+
<div class="flex items-center space-x-2" x-data="{ autoRefresh: true }">
|
| 138 |
+
<label class="flex items-center cursor-pointer">
|
| 139 |
+
<input type="checkbox" x-model="autoRefresh" class="form-checkbox h-4 w-4 text-indigo-600">
|
| 140 |
+
<span class="ml-2 text-sm text-gray-600">自动刷新</span>
|
| 141 |
+
</label>
|
| 142 |
+
</div>
|
| 143 |
+
</div>
|
| 144 |
+
<div class="p-6">
|
| 145 |
+
<div
|
| 146 |
+
id="recent-logs"
|
| 147 |
+
hx-get="/admin/api/recent-logs"
|
| 148 |
+
hx-trigger="load, every 3s"
|
| 149 |
+
hx-swap="innerHTML">
|
| 150 |
+
<!-- 日志内容将通过 htmx 加载 -->
|
| 151 |
+
<div class="flex justify-center items-center py-12">
|
| 152 |
+
<svg class="animate-spin h-8 w-8 text-indigo-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
| 153 |
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
| 154 |
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
| 155 |
+
</svg>
|
| 156 |
+
<span class="ml-3 text-gray-500">加载中...</span>
|
| 157 |
+
</div>
|
| 158 |
+
</div>
|
| 159 |
+
</div>
|
| 160 |
+
</div>
|
| 161 |
+
</div>
|
| 162 |
+
{% endblock %}
|
| 163 |
+
|
| 164 |
+
{% block extra_scripts %}
|
| 165 |
+
<script>
|
| 166 |
+
// 更新时间显示
|
| 167 |
+
function updateTime() {
|
| 168 |
+
const now = new Date();
|
| 169 |
+
document.getElementById('last-update').textContent = now.toLocaleString('zh-CN');
|
| 170 |
+
}
|
| 171 |
+
updateTime();
|
| 172 |
+
setInterval(updateTime, 1000);
|
| 173 |
+
</script>
|
| 174 |
+
{% endblock %}
|
app/templates/login.html
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-CN" class="h-full bg-gray-50">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>登录 - Z.AI2API 管理后台</title>
|
| 7 |
+
|
| 8 |
+
<!-- Tailwind CSS (CDN) -->
|
| 9 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 10 |
+
|
| 11 |
+
<!-- Alpine.js (CDN) -->
|
| 12 |
+
<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>
|
| 13 |
+
|
| 14 |
+
<style>
|
| 15 |
+
.gradient-bg {
|
| 16 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 17 |
+
}
|
| 18 |
+
</style>
|
| 19 |
+
</head>
|
| 20 |
+
<body class="h-full">
|
| 21 |
+
<div class="min-h-full flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
| 22 |
+
<div class="max-w-md w-full space-y-8">
|
| 23 |
+
<!-- Logo 和标题 -->
|
| 24 |
+
<div>
|
| 25 |
+
<div class="mx-auto h-16 w-16 flex items-center justify-center rounded-full gradient-bg">
|
| 26 |
+
<svg class="h-10 w-10 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 27 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
| 28 |
+
</svg>
|
| 29 |
+
</div>
|
| 30 |
+
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
| 31 |
+
Z.AI2API 管理后台
|
| 32 |
+
</h2>
|
| 33 |
+
<p class="mt-2 text-center text-sm text-gray-600">
|
| 34 |
+
请输入管理密码以继续
|
| 35 |
+
</p>
|
| 36 |
+
</div>
|
| 37 |
+
|
| 38 |
+
<!-- 登录表单 -->
|
| 39 |
+
<div class="mt-8 space-y-6"
|
| 40 |
+
x-data="{
|
| 41 |
+
password: '',
|
| 42 |
+
loading: false,
|
| 43 |
+
error: '',
|
| 44 |
+
async login() {
|
| 45 |
+
if (!this.password) {
|
| 46 |
+
this.error = '请输入密码';
|
| 47 |
+
return;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
this.loading = true;
|
| 51 |
+
this.error = '';
|
| 52 |
+
|
| 53 |
+
try {
|
| 54 |
+
const response = await fetch('/admin/api/login', {
|
| 55 |
+
method: 'POST',
|
| 56 |
+
headers: {
|
| 57 |
+
'Content-Type': 'application/json',
|
| 58 |
+
},
|
| 59 |
+
body: JSON.stringify({ password: this.password })
|
| 60 |
+
});
|
| 61 |
+
|
| 62 |
+
const data = await response.json();
|
| 63 |
+
|
| 64 |
+
if (response.ok && data.success) {
|
| 65 |
+
window.location.href = '/admin';
|
| 66 |
+
} else {
|
| 67 |
+
this.error = data.message || '密码错误,请重试';
|
| 68 |
+
}
|
| 69 |
+
} catch (err) {
|
| 70 |
+
this.error = '登录失败,请稍后重试';
|
| 71 |
+
} finally {
|
| 72 |
+
this.loading = false;
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
}">
|
| 76 |
+
|
| 77 |
+
<!-- 错误提示 -->
|
| 78 |
+
<div x-show="error"
|
| 79 |
+
x-transition
|
| 80 |
+
class="bg-red-50 border-l-4 border-red-400 p-4">
|
| 81 |
+
<div class="flex">
|
| 82 |
+
<div class="flex-shrink-0">
|
| 83 |
+
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
| 84 |
+
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
| 85 |
+
</svg>
|
| 86 |
+
</div>
|
| 87 |
+
<div class="ml-3">
|
| 88 |
+
<p class="text-sm text-red-700" x-text="error"></p>
|
| 89 |
+
</div>
|
| 90 |
+
</div>
|
| 91 |
+
</div>
|
| 92 |
+
|
| 93 |
+
<!-- 登录表单 -->
|
| 94 |
+
<form @submit.prevent="login" class="mt-8 space-y-6">
|
| 95 |
+
<div class="rounded-md shadow-sm -space-y-px">
|
| 96 |
+
<div>
|
| 97 |
+
<label for="password" class="sr-only">密码</label>
|
| 98 |
+
<input
|
| 99 |
+
id="password"
|
| 100 |
+
name="password"
|
| 101 |
+
type="password"
|
| 102 |
+
autocomplete="current-password"
|
| 103 |
+
required
|
| 104 |
+
x-model="password"
|
| 105 |
+
class="appearance-none rounded-md relative block w-full px-3 py-3 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
| 106 |
+
placeholder="请输入管理密码"
|
| 107 |
+
@keydown.enter="login">
|
| 108 |
+
</div>
|
| 109 |
+
</div>
|
| 110 |
+
|
| 111 |
+
<div>
|
| 112 |
+
<button
|
| 113 |
+
type="submit"
|
| 114 |
+
:disabled="loading"
|
| 115 |
+
class="group relative w-full flex justify-center py-3 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
|
| 116 |
+
<span class="absolute left-0 inset-y-0 flex items-center pl-3">
|
| 117 |
+
<svg class="h-5 w-5 text-indigo-500 group-hover:text-indigo-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
| 118 |
+
<path fill-rule="evenodd" d="M10 1a4.5 4.5 0 00-4.5 4.5V9H5a2 2 0 00-2 2v6a2 2 0 002 2h10a2 2 0 002-2v-6a2 2 0 00-2-2h-.5V5.5A4.5 4.5 0 0010 1zm3 8V5.5a3 3 0 10-6 0V9h6z" clip-rule="evenodd" />
|
| 119 |
+
</svg>
|
| 120 |
+
</span>
|
| 121 |
+
<span x-show="!loading">登录</span>
|
| 122 |
+
<span x-show="loading" class="flex items-center">
|
| 123 |
+
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
| 124 |
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
| 125 |
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
| 126 |
+
</svg>
|
| 127 |
+
登录中...
|
| 128 |
+
</span>
|
| 129 |
+
</button>
|
| 130 |
+
</div>
|
| 131 |
+
</form>
|
| 132 |
+
|
| 133 |
+
<!-- 提示信息 -->
|
| 134 |
+
<div class="text-center">
|
| 135 |
+
<p class="text-xs text-gray-500">
|
| 136 |
+
默认密码:admin123(请在 .env 中修改 ADMIN_PASSWORD)
|
| 137 |
+
</p>
|
| 138 |
+
</div>
|
| 139 |
+
</div>
|
| 140 |
+
</div>
|
| 141 |
+
</div>
|
| 142 |
+
</body>
|
| 143 |
+
</html>
|
app/templates/monitor.html
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}服务监控{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<div class="space-y-6">
|
| 7 |
+
<!-- 页面标题 -->
|
| 8 |
+
<div class="flex items-center justify-between">
|
| 9 |
+
<h2 class="text-3xl font-bold text-gray-900">服务监控</h2>
|
| 10 |
+
<div class="flex items-center space-x-4">
|
| 11 |
+
<!-- 手动刷新按钮 -->
|
| 12 |
+
<button
|
| 13 |
+
onclick="window.location.reload()"
|
| 14 |
+
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors">
|
| 15 |
+
刷新页面
|
| 16 |
+
</button>
|
| 17 |
+
</div>
|
| 18 |
+
</div>
|
| 19 |
+
|
| 20 |
+
<!-- 提供商状态详情 -->
|
| 21 |
+
<div class="bg-white shadow rounded-lg">
|
| 22 |
+
<div class="px-6 py-4 border-b border-gray-200">
|
| 23 |
+
<h3 class="text-lg font-medium text-gray-900">提供商状态详情</h3>
|
| 24 |
+
</div>
|
| 25 |
+
<div
|
| 26 |
+
id="provider-status"
|
| 27 |
+
hx-get="/admin/api/provider-status"
|
| 28 |
+
hx-trigger="load, every 5s"
|
| 29 |
+
hx-swap="innerHTML"
|
| 30 |
+
class="p-6">
|
| 31 |
+
<!-- 加载中状态 -->
|
| 32 |
+
<div class="flex justify-center items-center py-8">
|
| 33 |
+
<svg class="animate-spin h-8 w-8 text-indigo-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
| 34 |
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
| 35 |
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
| 36 |
+
</svg>
|
| 37 |
+
</div>
|
| 38 |
+
</div>
|
| 39 |
+
</div>
|
| 40 |
+
|
| 41 |
+
<!-- 实时日志流 -->
|
| 42 |
+
<div class="bg-white shadow rounded-lg">
|
| 43 |
+
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
| 44 |
+
<h3 class="text-lg font-medium text-gray-900">实时日志</h3>
|
| 45 |
+
<div class="flex space-x-2">
|
| 46 |
+
<button
|
| 47 |
+
onclick="document.getElementById('live-logs').innerHTML = '<div class=\'text-center text-gray-500 py-4\'>日志已清空</div>'"
|
| 48 |
+
class="px-3 py-1 text-sm bg-gray-200 hover:bg-gray-300 rounded-md transition-colors">
|
| 49 |
+
清空
|
| 50 |
+
</button>
|
| 51 |
+
</div>
|
| 52 |
+
</div>
|
| 53 |
+
<div class="p-6">
|
| 54 |
+
<div
|
| 55 |
+
id="live-logs"
|
| 56 |
+
hx-get="/admin/api/live-logs"
|
| 57 |
+
hx-trigger="load, every 3s"
|
| 58 |
+
hx-swap="innerHTML scroll:bottom"
|
| 59 |
+
class="bg-gray-900 text-gray-100 p-4 rounded-md font-mono text-sm overflow-y-auto"
|
| 60 |
+
style="max-height: 500px;">
|
| 61 |
+
<!-- 日志内容 -->
|
| 62 |
+
<div class="flex justify-center items-center py-8">
|
| 63 |
+
<span class="text-gray-500">加载中...</span>
|
| 64 |
+
</div>
|
| 65 |
+
</div>
|
| 66 |
+
</div>
|
| 67 |
+
</div>
|
| 68 |
+
</div>
|
| 69 |
+
{% endblock %}
|
| 70 |
+
|
| 71 |
+
{% block extra_scripts %}
|
| 72 |
+
<script>
|
| 73 |
+
// 自动滚动到日志底部(显示最新日志)
|
| 74 |
+
const logsContainer = document.getElementById('live-logs');
|
| 75 |
+
|
| 76 |
+
// 监听 htmx 更新事件
|
| 77 |
+
document.body.addEventListener('htmx:afterSwap', function(event) {
|
| 78 |
+
if (event.detail.target.id === 'live-logs') {
|
| 79 |
+
logsContainer.scrollTop = logsContainer.scrollHeight;
|
| 80 |
+
}
|
| 81 |
+
});
|
| 82 |
+
</script>
|
| 83 |
+
{% endblock %}
|
app/templates/tokens.html
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}Token 管理{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<div class="space-y-6" x-data="{
|
| 7 |
+
provider: 'zai',
|
| 8 |
+
showAddModal: false,
|
| 9 |
+
showValidateModal: false,
|
| 10 |
+
newToken: '',
|
| 11 |
+
bulkTokens: '',
|
| 12 |
+
isValidating: false,
|
| 13 |
+
init() {
|
| 14 |
+
// 从 URL 参数读取 provider
|
| 15 |
+
const urlParams = new URLSearchParams(window.location.search);
|
| 16 |
+
const providerParam = urlParams.get('provider');
|
| 17 |
+
if (providerParam && ['zai', 'k2think', 'longcat'].includes(providerParam.toLowerCase())) {
|
| 18 |
+
this.provider = providerParam.toLowerCase();
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
// 监听 provider 变化并触发刷新
|
| 22 |
+
this.$watch('provider', (newValue, oldValue) => {
|
| 23 |
+
if (newValue !== oldValue) {
|
| 24 |
+
console.log('Provider changed to:', newValue);
|
| 25 |
+
htmx.trigger('#token-list', 'providerChange');
|
| 26 |
+
htmx.trigger('#token-stats', 'providerChange');
|
| 27 |
+
}
|
| 28 |
+
});
|
| 29 |
+
}
|
| 30 |
+
}">
|
| 31 |
+
<!-- 页面标题 -->
|
| 32 |
+
<div class="flex items-center justify-between">
|
| 33 |
+
<div>
|
| 34 |
+
<h2 class="text-3xl font-bold text-gray-900">Token 管理</h2>
|
| 35 |
+
<p class="mt-1 text-sm text-gray-600">管理和监控 AI 提供商的 Token</p>
|
| 36 |
+
</div>
|
| 37 |
+
<div class="flex space-x-3">
|
| 38 |
+
<button @click="showValidateModal = true"
|
| 39 |
+
class="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 flex items-center">
|
| 40 |
+
<svg class="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 41 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
| 42 |
+
</svg>
|
| 43 |
+
批量验证
|
| 44 |
+
</button>
|
| 45 |
+
<button @click="showAddModal = true"
|
| 46 |
+
class="px-4 py-2 bg-indigo-600 text-white rounded-md text-sm font-medium hover:bg-indigo-700 flex items-center">
|
| 47 |
+
<svg class="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 48 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
| 49 |
+
</svg>
|
| 50 |
+
添加 Token
|
| 51 |
+
</button>
|
| 52 |
+
</div>
|
| 53 |
+
</div>
|
| 54 |
+
|
| 55 |
+
<!-- 统计面板 -->
|
| 56 |
+
<div id="token-stats"
|
| 57 |
+
hx-get="/admin/api/tokens/stats"
|
| 58 |
+
:hx-vals="JSON.stringify({provider: provider})"
|
| 59 |
+
hx-trigger="load, providerChange from:body, statsRefresh from:body"
|
| 60 |
+
hx-swap="innerHTML">
|
| 61 |
+
<!-- 加载中 -->
|
| 62 |
+
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
| 63 |
+
<div class="bg-white overflow-hidden shadow rounded-lg animate-pulse">
|
| 64 |
+
<div class="p-5">
|
| 65 |
+
<div class="h-4 bg-gray-200 rounded w-1/2 mb-2"></div>
|
| 66 |
+
<div class="h-8 bg-gray-200 rounded w-1/3"></div>
|
| 67 |
+
</div>
|
| 68 |
+
</div>
|
| 69 |
+
<div class="bg-white overflow-hidden shadow rounded-lg animate-pulse">
|
| 70 |
+
<div class="p-5">
|
| 71 |
+
<div class="h-4 bg-gray-200 rounded w-1/2 mb-2"></div>
|
| 72 |
+
<div class="h-8 bg-gray-200 rounded w-1/3"></div>
|
| 73 |
+
</div>
|
| 74 |
+
</div>
|
| 75 |
+
<div class="bg-white overflow-hidden shadow rounded-lg animate-pulse">
|
| 76 |
+
<div class="p-5">
|
| 77 |
+
<div class="h-4 bg-gray-200 rounded w-1/2 mb-2"></div>
|
| 78 |
+
<div class="h-8 bg-gray-200 rounded w-1/3"></div>
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
<div class="bg-white overflow-hidden shadow rounded-lg animate-pulse">
|
| 82 |
+
<div class="p-5">
|
| 83 |
+
<div class="h-4 bg-gray-200 rounded w-1/2 mb-2"></div>
|
| 84 |
+
<div class="h-8 bg-gray-200 rounded w-1/3"></div>
|
| 85 |
+
</div>
|
| 86 |
+
</div>
|
| 87 |
+
</div>
|
| 88 |
+
</div>
|
| 89 |
+
|
| 90 |
+
<!-- 提供商切换和操作栏 -->
|
| 91 |
+
<div class="bg-white shadow rounded-lg p-4">
|
| 92 |
+
<div class="flex items-center justify-between flex-wrap gap-4">
|
| 93 |
+
<!-- 提供商切换 -->
|
| 94 |
+
<div class="flex space-x-2">
|
| 95 |
+
<button @click="provider = 'zai'"
|
| 96 |
+
:class="provider === 'zai' ? 'bg-indigo-600 text-white' : 'bg-gray-200 text-gray-700'"
|
| 97 |
+
class="px-4 py-2 rounded-md text-sm font-medium transition-colors">
|
| 98 |
+
Z.AI
|
| 99 |
+
</button>
|
| 100 |
+
<button @click="provider = 'k2think'"
|
| 101 |
+
:class="provider === 'k2think' ? 'bg-indigo-600 text-white' : 'bg-gray-200 text-gray-700'"
|
| 102 |
+
class="px-4 py-2 rounded-md text-sm font-medium transition-colors">
|
| 103 |
+
K2Think
|
| 104 |
+
</button>
|
| 105 |
+
<button @click="provider = 'longcat'"
|
| 106 |
+
:class="provider === 'longcat' ? 'bg-indigo-600 text-white' : 'bg-gray-200 text-gray-700'"
|
| 107 |
+
class="px-4 py-2 rounded-md text-sm font-medium transition-colors">
|
| 108 |
+
LongCat
|
| 109 |
+
</button>
|
| 110 |
+
</div>
|
| 111 |
+
|
| 112 |
+
<!-- 刷新按钮 -->
|
| 113 |
+
<button hx-get="/admin/api/tokens/list"
|
| 114 |
+
:hx-vals="JSON.stringify({provider: provider})"
|
| 115 |
+
hx-target="#token-list"
|
| 116 |
+
class="flex items-center px-3 py-2 text-gray-600 hover:text-indigo-600 hover:bg-gray-50 rounded-md transition-colors"
|
| 117 |
+
title="刷新列表">
|
| 118 |
+
<svg class="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 119 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
| 120 |
+
</svg>
|
| 121 |
+
<span class="text-sm font-medium">刷新</span>
|
| 122 |
+
</button>
|
| 123 |
+
</div>
|
| 124 |
+
</div>
|
| 125 |
+
|
| 126 |
+
<!-- Token 列表 -->
|
| 127 |
+
<div class="bg-white shadow rounded-lg">
|
| 128 |
+
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
| 129 |
+
<h3 class="text-lg font-medium text-gray-900 flex items-center">
|
| 130 |
+
<span x-text="provider.toUpperCase()"></span> Token 列表
|
| 131 |
+
<span class="ml-2 text-sm font-normal text-gray-500" id="token-count"></span>
|
| 132 |
+
</h3>
|
| 133 |
+
<div class="flex items-center space-x-2">
|
| 134 |
+
<button hx-post="/admin/api/tokens/sync-pool"
|
| 135 |
+
:hx-vals="JSON.stringify({provider: provider})"
|
| 136 |
+
hx-target="#notification"
|
| 137 |
+
class="text-sm text-purple-600 hover:text-purple-700 flex items-center">
|
| 138 |
+
<svg class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 139 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
| 140 |
+
</svg>
|
| 141 |
+
同步 Token 池
|
| 142 |
+
</button>
|
| 143 |
+
<button hx-post="/admin/api/tokens/health-check"
|
| 144 |
+
:hx-vals="JSON.stringify({provider: provider})"
|
| 145 |
+
hx-target="#notification"
|
| 146 |
+
class="text-sm text-blue-600 hover:text-blue-700 flex items-center">
|
| 147 |
+
<svg class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 148 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
| 149 |
+
</svg>
|
| 150 |
+
健康检查
|
| 151 |
+
</button>
|
| 152 |
+
</div>
|
| 153 |
+
</div>
|
| 154 |
+
<div id="token-list"
|
| 155 |
+
hx-get="/admin/api/tokens/list"
|
| 156 |
+
:hx-vals="JSON.stringify({provider: provider})"
|
| 157 |
+
hx-trigger="load, providerChange from:body"
|
| 158 |
+
hx-swap="innerHTML">
|
| 159 |
+
<!-- Token 列表内容 -->
|
| 160 |
+
<div class="flex justify-center items-center py-12">
|
| 161 |
+
<svg class="animate-spin h-8 w-8 text-indigo-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
| 162 |
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
| 163 |
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
| 164 |
+
</svg>
|
| 165 |
+
</div>
|
| 166 |
+
</div>
|
| 167 |
+
</div>
|
| 168 |
+
|
| 169 |
+
<!-- 添加 Token 弹窗 -->
|
| 170 |
+
<div x-show="showAddModal"
|
| 171 |
+
x-transition:enter="transition ease-out duration-300"
|
| 172 |
+
x-transition:enter-start="opacity-0"
|
| 173 |
+
x-transition:enter-end="opacity-100"
|
| 174 |
+
class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50"
|
| 175 |
+
@click.self="showAddModal = false"
|
| 176 |
+
style="display: none;">
|
| 177 |
+
<div class="relative top-20 mx-auto p-5 border w-full max-w-2xl shadow-lg rounded-md bg-white">
|
| 178 |
+
<div class="flex items-center justify-between pb-3 border-b">
|
| 179 |
+
<h3 class="text-lg font-medium">添加 Token</h3>
|
| 180 |
+
<button @click="showAddModal = false" class="text-gray-400 hover:text-gray-600">
|
| 181 |
+
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 182 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
| 183 |
+
</svg>
|
| 184 |
+
</button>
|
| 185 |
+
</div>
|
| 186 |
+
|
| 187 |
+
<div class="mt-4 space-y-4">
|
| 188 |
+
<!-- 提示信息 -->
|
| 189 |
+
<div class="bg-blue-50 border-l-4 border-blue-400 p-4">
|
| 190 |
+
<div class="flex">
|
| 191 |
+
<div class="flex-shrink-0">
|
| 192 |
+
<svg class="h-5 w-5 text-blue-400" fill="currentColor" viewBox="0 0 20 20">
|
| 193 |
+
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
|
| 194 |
+
</svg>
|
| 195 |
+
</div>
|
| 196 |
+
<div class="ml-3">
|
| 197 |
+
<p class="text-sm text-blue-700">
|
| 198 |
+
<strong>Z.AI Token 验证:</strong>添加时将自动验证 Token 有效性,
|
| 199 |
+
<span class="font-semibold">匿名用户 Token (guest) 将被拒绝</span>。
|
| 200 |
+
</p>
|
| 201 |
+
</div>
|
| 202 |
+
</div>
|
| 203 |
+
</div>
|
| 204 |
+
|
| 205 |
+
<!-- 单个 Token -->
|
| 206 |
+
<div>
|
| 207 |
+
<label class="block text-sm font-medium text-gray-700">单个 Token</label>
|
| 208 |
+
<input type="text"
|
| 209 |
+
x-model="newToken"
|
| 210 |
+
placeholder="输入 Token(以 eyJ 开头的 JWT)"
|
| 211 |
+
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
|
| 212 |
+
</div>
|
| 213 |
+
|
| 214 |
+
<!-- 批量导入 -->
|
| 215 |
+
<div>
|
| 216 |
+
<label class="block text-sm font-medium text-gray-700">批量导入(每行一个)</label>
|
| 217 |
+
<textarea x-model="bulkTokens"
|
| 218 |
+
rows="6"
|
| 219 |
+
placeholder="每行一个 Token,支持逗号分隔 eyJhbGc... eyJhbGc... 或: token1, token2, token3"
|
| 220 |
+
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm font-mono text-xs"></textarea>
|
| 221 |
+
<p class="mt-1 text-sm text-gray-500">支持格式:每行一个 Token,或使用逗号分隔</p>
|
| 222 |
+
</div>
|
| 223 |
+
|
| 224 |
+
<!-- 提交按钮 -->
|
| 225 |
+
<div class="flex justify-end space-x-3 pt-4 border-t">
|
| 226 |
+
<button @click="showAddModal = false"
|
| 227 |
+
class="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50">
|
| 228 |
+
取消
|
| 229 |
+
</button>
|
| 230 |
+
<button hx-post="/admin/api/tokens/add"
|
| 231 |
+
:hx-vals="JSON.stringify({
|
| 232 |
+
provider: provider,
|
| 233 |
+
single_token: newToken,
|
| 234 |
+
bulk_tokens: bulkTokens
|
| 235 |
+
})"
|
| 236 |
+
hx-target="#notification"
|
| 237 |
+
@htmx:after-request="showAddModal = false; newToken = ''; bulkTokens = ''; htmx.trigger('#token-list', 'providerChange'); htmx.trigger('body', 'statsRefresh')"
|
| 238 |
+
class="px-4 py-2 bg-indigo-600 text-white rounded-md text-sm font-medium hover:bg-indigo-700">
|
| 239 |
+
添加
|
| 240 |
+
</button>
|
| 241 |
+
</div>
|
| 242 |
+
</div>
|
| 243 |
+
</div>
|
| 244 |
+
</div>
|
| 245 |
+
|
| 246 |
+
<!-- 批量验证弹窗 -->
|
| 247 |
+
<div x-show="showValidateModal"
|
| 248 |
+
x-transition:enter="transition ease-out duration-300"
|
| 249 |
+
x-transition:enter-start="opacity-0"
|
| 250 |
+
x-transition:enter-end="opacity-100"
|
| 251 |
+
class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50"
|
| 252 |
+
@click.self="showValidateModal = false"
|
| 253 |
+
style="display: none;">
|
| 254 |
+
<div class="relative top-20 mx-auto p-5 border w-full max-w-lg shadow-lg rounded-md bg-white">
|
| 255 |
+
<div class="flex items-center justify-between pb-3 border-b">
|
| 256 |
+
<h3 class="text-lg font-medium">批量验证 Token</h3>
|
| 257 |
+
<button @click="showValidateModal = false" class="text-gray-400 hover:text-gray-600">
|
| 258 |
+
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 259 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
| 260 |
+
</svg>
|
| 261 |
+
</button>
|
| 262 |
+
</div>
|
| 263 |
+
|
| 264 |
+
<div class="mt-4 space-y-4">
|
| 265 |
+
<!-- 警告信息 -->
|
| 266 |
+
<div class="bg-yellow-50 border-l-4 border-yellow-400 p-4">
|
| 267 |
+
<div class="flex">
|
| 268 |
+
<div class="flex-shrink-0">
|
| 269 |
+
<svg class="h-5 w-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
|
| 270 |
+
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
| 271 |
+
</svg>
|
| 272 |
+
</div>
|
| 273 |
+
<div class="ml-3">
|
| 274 |
+
<p class="text-sm text-yellow-700">
|
| 275 |
+
将验证所有 <strong x-text="provider.toUpperCase()"></strong> Token 的有效性。
|
| 276 |
+
<br>此操���可能需要较长时间,请耐心等待。
|
| 277 |
+
</p>
|
| 278 |
+
</div>
|
| 279 |
+
</div>
|
| 280 |
+
</div>
|
| 281 |
+
|
| 282 |
+
<!-- 验证说明 -->
|
| 283 |
+
<div class="text-sm text-gray-600 space-y-2">
|
| 284 |
+
<p><strong>验证内容:</strong></p>
|
| 285 |
+
<ul class="list-disc list-inside space-y-1 ml-4">
|
| 286 |
+
<li>检查 Token 是否有效</li>
|
| 287 |
+
<li>识别 Token 类型(认证用户 / 匿名用户)</li>
|
| 288 |
+
<li>更新数据库中的 Token 类型</li>
|
| 289 |
+
<li>匿名用户 Token 将被标记为不健康</li>
|
| 290 |
+
</ul>
|
| 291 |
+
</div>
|
| 292 |
+
|
| 293 |
+
<!-- 进度显示 -->
|
| 294 |
+
<div id="validate-progress" class="hidden">
|
| 295 |
+
<div class="flex items-center justify-center py-4">
|
| 296 |
+
<svg class="animate-spin h-8 w-8 text-indigo-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
| 297 |
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
| 298 |
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
| 299 |
+
</svg>
|
| 300 |
+
<span class="ml-3 text-gray-700">验证中...</span>
|
| 301 |
+
</div>
|
| 302 |
+
</div>
|
| 303 |
+
|
| 304 |
+
<!-- 提交按钮 -->
|
| 305 |
+
<div class="flex justify-end space-x-3 pt-4 border-t">
|
| 306 |
+
<button @click="showValidateModal = false"
|
| 307 |
+
:disabled="isValidating"
|
| 308 |
+
class="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed">
|
| 309 |
+
取消
|
| 310 |
+
</button>
|
| 311 |
+
<button hx-post="/admin/api/tokens/validate"
|
| 312 |
+
:hx-vals="JSON.stringify({provider: provider})"
|
| 313 |
+
hx-target="#notification"
|
| 314 |
+
@htmx:before-request="isValidating = true; document.getElementById('validate-progress').classList.remove('hidden')"
|
| 315 |
+
@htmx:after-request="isValidating = false; showValidateModal = false; document.getElementById('validate-progress').classList.add('hidden'); htmx.trigger('#token-list', 'providerChange'); htmx.trigger('body', 'statsRefresh')"
|
| 316 |
+
:disabled="isValidating"
|
| 317 |
+
class="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed">
|
| 318 |
+
开始验证
|
| 319 |
+
</button>
|
| 320 |
+
</div>
|
| 321 |
+
</div>
|
| 322 |
+
</div>
|
| 323 |
+
</div>
|
| 324 |
+
</div>
|
| 325 |
+
{% endblock %}
|
| 326 |
+
|
| 327 |
+
{% block extra_scripts %}
|
| 328 |
+
<script>
|
| 329 |
+
// 全局通知函数
|
| 330 |
+
function showNotification(message, type = 'success') {
|
| 331 |
+
const notification = document.createElement('div');
|
| 332 |
+
const bgColor = type === 'success' ? 'bg-green-500' : type === 'error' ? 'bg-red-500' : 'bg-blue-500';
|
| 333 |
+
notification.className = `fixed top-4 right-4 ${bgColor} text-white px-6 py-3 rounded-lg shadow-lg z-50 transition-all transform`;
|
| 334 |
+
notification.style.animation = 'slideInRight 0.3s ease-out';
|
| 335 |
+
notification.innerHTML = `
|
| 336 |
+
<div class="flex items-center space-x-2">
|
| 337 |
+
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
| 338 |
+
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
| 339 |
+
</svg>
|
| 340 |
+
<span class="font-medium">${message}</span>
|
| 341 |
+
</div>
|
| 342 |
+
`;
|
| 343 |
+
|
| 344 |
+
document.body.appendChild(notification);
|
| 345 |
+
|
| 346 |
+
// 3秒后自动消失
|
| 347 |
+
setTimeout(() => {
|
| 348 |
+
notification.style.opacity = '0';
|
| 349 |
+
notification.style.transform = 'translateX(100%)';
|
| 350 |
+
setTimeout(() => notification.remove(), 300);
|
| 351 |
+
}, 3000);
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
// 添加动画样式
|
| 355 |
+
if (!document.getElementById('notification-styles')) {
|
| 356 |
+
const style = document.createElement('style');
|
| 357 |
+
style.id = 'notification-styles';
|
| 358 |
+
style.textContent = `
|
| 359 |
+
@keyframes slideInRight {
|
| 360 |
+
from {
|
| 361 |
+
opacity: 0;
|
| 362 |
+
transform: translateX(100%);
|
| 363 |
+
}
|
| 364 |
+
to {
|
| 365 |
+
opacity: 1;
|
| 366 |
+
transform: translateX(0);
|
| 367 |
+
}
|
| 368 |
+
}
|
| 369 |
+
`;
|
| 370 |
+
document.head.appendChild(style);
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
// 监听验证按钮的完成事件
|
| 374 |
+
document.body.addEventListener('htmx:afterSwap', function(evt) {
|
| 375 |
+
// 检查是否是验证按钮触发的事件
|
| 376 |
+
if (evt.detail.target && evt.detail.target.id && evt.detail.target.id.startsWith('token-row-')) {
|
| 377 |
+
// 从目标元素提取 token ID
|
| 378 |
+
const tokenId = evt.detail.target.id.replace('token-row-', '');
|
| 379 |
+
|
| 380 |
+
// 检查是否是验证操作(通过查看触发元素)
|
| 381 |
+
const triggerElt = evt.detail.requestConfig?.elt;
|
| 382 |
+
if (triggerElt && triggerElt.classList.contains('validate-token-btn')) {
|
| 383 |
+
showNotification(`✓ Token ID ${tokenId} 验证完成`, 'success');
|
| 384 |
+
|
| 385 |
+
// 同时刷新统计数据
|
| 386 |
+
htmx.trigger('body', 'statsRefresh');
|
| 387 |
+
}
|
| 388 |
+
}
|
| 389 |
+
});
|
| 390 |
+
</script>
|
| 391 |
+
{% endblock %}
|
app/utils/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
|
| 4 |
+
from app.utils import sse_tool_handler, reload_config, logger
|
| 5 |
+
|
| 6 |
+
__all__ = ["sse_tool_handler", "reload_config", "logger"]
|
app/utils/logger.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
|
| 4 |
+
import sys
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from loguru import logger
|
| 7 |
+
|
| 8 |
+
# Global logger instance
|
| 9 |
+
app_logger = None
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def setup_logger(log_dir, log_retention_days=7, log_rotation="1 day", debug_mode=False):
|
| 13 |
+
"""
|
| 14 |
+
Create a logger instance
|
| 15 |
+
|
| 16 |
+
Parameters:
|
| 17 |
+
log_dir (str): 日志目录
|
| 18 |
+
log_retention_days (int): 日志保留天数
|
| 19 |
+
log_rotation (str): 日志轮转间隔
|
| 20 |
+
debug_mode (bool): 是否开启调试模式
|
| 21 |
+
"""
|
| 22 |
+
global app_logger
|
| 23 |
+
|
| 24 |
+
# 移除所有现有的日志处理器(支持热重载)
|
| 25 |
+
logger.remove()
|
| 26 |
+
|
| 27 |
+
log_level = "DEBUG" if debug_mode else "INFO"
|
| 28 |
+
|
| 29 |
+
console_format = (
|
| 30 |
+
"<green>{time:HH:mm:ss}</green> | <level>{level: <8}</level> | <level>{message}</level>"
|
| 31 |
+
if not debug_mode
|
| 32 |
+
else "<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | "
|
| 33 |
+
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> | <level>{message}</level>"
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
# 添加控制台输出(根据 debug_mode 设置级别)
|
| 37 |
+
logger.add(sys.stderr, level=log_level, format=console_format, colorize=True)
|
| 38 |
+
|
| 39 |
+
# 只有在 debug_mode 时才添加文件输出
|
| 40 |
+
if debug_mode:
|
| 41 |
+
try:
|
| 42 |
+
log_path = Path(log_dir)
|
| 43 |
+
log_path.mkdir(parents=True, exist_ok=True)
|
| 44 |
+
|
| 45 |
+
log_file = log_path / "{time:YYYY-MM-DD}.log"
|
| 46 |
+
file_format = "{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {name}:{function}:{line} | {message}"
|
| 47 |
+
|
| 48 |
+
logger.add(
|
| 49 |
+
str(log_file),
|
| 50 |
+
level=log_level,
|
| 51 |
+
format=file_format,
|
| 52 |
+
rotation=log_rotation,
|
| 53 |
+
retention=f"{log_retention_days} days",
|
| 54 |
+
encoding="utf-8",
|
| 55 |
+
compression="zip",
|
| 56 |
+
enqueue=True,
|
| 57 |
+
catch=True,
|
| 58 |
+
)
|
| 59 |
+
logger.info(f"✅ 日志文件输出已启用: {log_dir}")
|
| 60 |
+
except (PermissionError, OSError) as e:
|
| 61 |
+
# 如果无法创建日志目录或文件,降级为仅控制台输出
|
| 62 |
+
logger.warning(f"⚠️ 无法创建日志文件 ({e}),将仅使用控制台输出")
|
| 63 |
+
|
| 64 |
+
app_logger = logger
|
| 65 |
+
|
| 66 |
+
return logger
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def get_logger():
|
| 70 |
+
"""Get the logger instance"""
|
| 71 |
+
global app_logger
|
| 72 |
+
if app_logger is None:
|
| 73 |
+
# 如果没有设置过logger,使用默认配置
|
| 74 |
+
logger.remove() # 移除所有现有处理器
|
| 75 |
+
logger.add(sys.stderr, level="INFO", format="<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> | <level>{message}</level>")
|
| 76 |
+
app_logger = logger
|
| 77 |
+
return app_logger
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
if __name__ == "__main__":
|
| 81 |
+
"""Test the logger"""
|
| 82 |
+
import tempfile
|
| 83 |
+
|
| 84 |
+
with tempfile.TemporaryDirectory() as temp_dir:
|
| 85 |
+
try:
|
| 86 |
+
setup_logger(temp_dir, debug_mode=True)
|
| 87 |
+
|
| 88 |
+
logger.debug("这是一条调试日志")
|
| 89 |
+
logger.info("这是一条信息日志")
|
| 90 |
+
logger.warning("这是一条警告日志")
|
| 91 |
+
logger.error("这是一条错误日志")
|
| 92 |
+
logger.critical("这是一条严重日志")
|
| 93 |
+
|
| 94 |
+
try:
|
| 95 |
+
1 / 0
|
| 96 |
+
except ZeroDivisionError:
|
| 97 |
+
logger.exception("发生了除零异常")
|
| 98 |
+
|
| 99 |
+
print("✅ 日志测试完成")
|
| 100 |
+
|
| 101 |
+
logger.remove()
|
| 102 |
+
|
| 103 |
+
except Exception as e:
|
| 104 |
+
print(f"❌ 日志测试失败: {e}")
|
| 105 |
+
logger.remove()
|
| 106 |
+
raise
|
app/utils/reload_config.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
|
| 4 |
+
"""
|
| 5 |
+
热重载配置模块
|
| 6 |
+
定义 Granian 服务器热重载时需要忽略的目录和文件模式
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
# 忽略的目录列表
|
| 10 |
+
RELOAD_IGNORE_DIRS = [
|
| 11 |
+
"logs", # 忽略日志目录
|
| 12 |
+
"storage", # 忽略存储目录
|
| 13 |
+
"__pycache__", # 忽略 Python 缓存
|
| 14 |
+
".git", # 忽略 git 目录
|
| 15 |
+
"node_modules", # 忽略 node_modules
|
| 16 |
+
"migrations", # 忽略数据库迁移目录
|
| 17 |
+
".pytest_cache", # 忽略 pytest 缓存
|
| 18 |
+
".venv", # 忽略虚拟环境
|
| 19 |
+
"venv", # 忽略虚拟环境
|
| 20 |
+
"env", # 忽略环境目录
|
| 21 |
+
".mypy_cache", # 忽略 mypy 缓存
|
| 22 |
+
".ruff_cache", # 忽略 ruff 缓存
|
| 23 |
+
"dist", # 忽略构建分发目录
|
| 24 |
+
"build", # 忽略构建目录
|
| 25 |
+
".coverage", # 忽略测试覆盖率文件
|
| 26 |
+
"htmlcov", # 忽略覆盖率报告目录
|
| 27 |
+
"tests", # 忽略测试目录
|
| 28 |
+
"z-ai2api-server.pid", # 忽略 PID 文件
|
| 29 |
+
]
|
| 30 |
+
|
| 31 |
+
# 忽略的文件模式(正则表达式)
|
| 32 |
+
RELOAD_IGNORE_PATTERNS = [
|
| 33 |
+
# 日志文件
|
| 34 |
+
r".*\.log$",
|
| 35 |
+
r".*\.log\.\d+$",
|
| 36 |
+
# 数据库文件
|
| 37 |
+
r".*\.sqlite3.*",
|
| 38 |
+
r".*\.db$",
|
| 39 |
+
r".*\.db-.*$",
|
| 40 |
+
# Python 相关
|
| 41 |
+
r".*\.pyc$",
|
| 42 |
+
r".*\.pyo$",
|
| 43 |
+
r".*\.pyd$",
|
| 44 |
+
# 临时文件
|
| 45 |
+
r".*\.tmp$",
|
| 46 |
+
r".*\.temp$",
|
| 47 |
+
r".*\.swp$",
|
| 48 |
+
r".*\.swo$",
|
| 49 |
+
r".*~$",
|
| 50 |
+
# 系统文件
|
| 51 |
+
r".*\.DS_Store$",
|
| 52 |
+
r".*Thumbs\.db$",
|
| 53 |
+
r".*\.directory$",
|
| 54 |
+
# 编辑器文件
|
| 55 |
+
r".*\.vscode.*",
|
| 56 |
+
r".*\.idea.*",
|
| 57 |
+
# 测试和覆盖率
|
| 58 |
+
r".*\.coverage$",
|
| 59 |
+
r".*\.pytest_cache.*",
|
| 60 |
+
# 构建文件
|
| 61 |
+
r".*\.egg-info.*",
|
| 62 |
+
r".*\.wheel$",
|
| 63 |
+
r".*\.whl$",
|
| 64 |
+
# 版本控制
|
| 65 |
+
r".*\.git.*",
|
| 66 |
+
r".*\.gitignore$",
|
| 67 |
+
r".*\.gitkeep$",
|
| 68 |
+
# 配置文件备份
|
| 69 |
+
r".*\.bak$",
|
| 70 |
+
r".*\.backup$",
|
| 71 |
+
r".*\.orig$",
|
| 72 |
+
# 锁文件
|
| 73 |
+
r".*\.lock$",
|
| 74 |
+
r".*\.pid$",
|
| 75 |
+
]
|
| 76 |
+
|
| 77 |
+
# 监视的路径(只监视应用相关代码)
|
| 78 |
+
RELOAD_WATCH_PATHS = [
|
| 79 |
+
"app", # 应用主目录
|
| 80 |
+
"main.py", # 主入口文件
|
| 81 |
+
]
|
| 82 |
+
|
| 83 |
+
# 热重载配置
|
| 84 |
+
RELOAD_CONFIG = {
|
| 85 |
+
"reload_ignore_dirs": RELOAD_IGNORE_DIRS,
|
| 86 |
+
"reload_ignore_patterns": RELOAD_IGNORE_PATTERNS,
|
| 87 |
+
"reload_paths": RELOAD_WATCH_PATHS,
|
| 88 |
+
"reload_tick": 100, # 监视频率(毫秒)
|
| 89 |
+
}
|
app/utils/sse_tool_handler.py
ADDED
|
@@ -0,0 +1,612 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
|
| 4 |
+
"""
|
| 5 |
+
SSE Tool Handler
|
| 6 |
+
|
| 7 |
+
处理 Z.AI SSE 流数据并转换为 OpenAI 兼容格式的工具调用处理器。
|
| 8 |
+
|
| 9 |
+
主要功能:
|
| 10 |
+
- 解析 glm_block 格式的工具调用
|
| 11 |
+
- 从 metadata.arguments 提取完整参数
|
| 12 |
+
- 支持多阶段处理:thinking → tool_call → other → answer
|
| 13 |
+
- 输出符合 OpenAI API 规范的流式响应
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import json
|
| 17 |
+
import time
|
| 18 |
+
from typing import Dict, Any, Generator
|
| 19 |
+
from enum import Enum
|
| 20 |
+
|
| 21 |
+
from app.utils.logger import get_logger
|
| 22 |
+
|
| 23 |
+
logger = get_logger()
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class SSEPhase(Enum):
|
| 27 |
+
"""SSE 处理阶段枚举"""
|
| 28 |
+
THINKING = "thinking"
|
| 29 |
+
TOOL_CALL = "tool_call"
|
| 30 |
+
OTHER = "other"
|
| 31 |
+
ANSWER = "answer"
|
| 32 |
+
DONE = "done"
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class SSEToolHandler:
|
| 36 |
+
"""SSE 工具调用处理器"""
|
| 37 |
+
|
| 38 |
+
def __init__(self, model: str, stream: bool = True):
|
| 39 |
+
self.model = model
|
| 40 |
+
self.stream = stream
|
| 41 |
+
|
| 42 |
+
# 状态管理
|
| 43 |
+
self.current_phase = None
|
| 44 |
+
self.has_tool_call = False
|
| 45 |
+
|
| 46 |
+
# 工具调用状态
|
| 47 |
+
self.tool_id = ""
|
| 48 |
+
self.tool_name = ""
|
| 49 |
+
self.tool_args = ""
|
| 50 |
+
self.tool_call_usage = {}
|
| 51 |
+
self.content_index = 0 # 工具调用索引
|
| 52 |
+
|
| 53 |
+
# 性能优化:内容缓冲
|
| 54 |
+
self.content_buffer = ""
|
| 55 |
+
self.buffer_size = 0
|
| 56 |
+
self.last_flush_time = time.time()
|
| 57 |
+
self.flush_interval = 0.05 # 50ms 刷新间隔
|
| 58 |
+
self.max_buffer_size = 100 # 最大缓冲字符数
|
| 59 |
+
|
| 60 |
+
logger.debug(f"🔧 初始化工具处理器: model={model}, stream={stream}")
|
| 61 |
+
|
| 62 |
+
def process_sse_chunk(self, chunk_data: Dict[str, Any]) -> Generator[str, None, None]:
|
| 63 |
+
"""
|
| 64 |
+
处理 SSE 数据块,返回 OpenAI 格式的流式响应
|
| 65 |
+
|
| 66 |
+
Args:
|
| 67 |
+
chunk_data: Z.AI SSE 数据块
|
| 68 |
+
|
| 69 |
+
Yields:
|
| 70 |
+
str: OpenAI 格式的 SSE 响应行
|
| 71 |
+
"""
|
| 72 |
+
try:
|
| 73 |
+
phase = chunk_data.get("phase")
|
| 74 |
+
edit_content = chunk_data.get("edit_content", "")
|
| 75 |
+
delta_content = chunk_data.get("delta_content", "")
|
| 76 |
+
edit_index = chunk_data.get("edit_index")
|
| 77 |
+
usage = chunk_data.get("usage", {})
|
| 78 |
+
|
| 79 |
+
# 数据验证
|
| 80 |
+
if not phase:
|
| 81 |
+
logger.warning("⚠️ 收到无效的 SSE 块:缺少 phase 字段")
|
| 82 |
+
return
|
| 83 |
+
|
| 84 |
+
# 阶段变化检测和日志
|
| 85 |
+
if phase != self.current_phase:
|
| 86 |
+
# 阶段变化时强制刷新缓冲区
|
| 87 |
+
if hasattr(self, 'content_buffer') and self.content_buffer:
|
| 88 |
+
yield from self._flush_content_buffer()
|
| 89 |
+
|
| 90 |
+
logger.info(f"📈 SSE 阶段变化: {self.current_phase} → {phase}")
|
| 91 |
+
content_preview = edit_content or delta_content
|
| 92 |
+
if content_preview:
|
| 93 |
+
logger.debug(f" 📝 内容预览: {content_preview[:1000]}{'...' if len(content_preview) > 1000 else ''}")
|
| 94 |
+
if edit_index is not None:
|
| 95 |
+
logger.debug(f" 📍 edit_index: {edit_index}")
|
| 96 |
+
self.current_phase = phase
|
| 97 |
+
|
| 98 |
+
# 根据阶段处理
|
| 99 |
+
if phase == SSEPhase.THINKING.value:
|
| 100 |
+
yield from self._process_thinking_phase(delta_content)
|
| 101 |
+
|
| 102 |
+
elif phase == SSEPhase.TOOL_CALL.value:
|
| 103 |
+
yield from self._process_tool_call_phase(edit_content)
|
| 104 |
+
|
| 105 |
+
elif phase == SSEPhase.OTHER.value:
|
| 106 |
+
yield from self._process_other_phase(usage, edit_content)
|
| 107 |
+
|
| 108 |
+
elif phase == SSEPhase.ANSWER.value:
|
| 109 |
+
yield from self._process_answer_phase(delta_content)
|
| 110 |
+
|
| 111 |
+
elif phase == SSEPhase.DONE.value:
|
| 112 |
+
yield from self._process_done_phase(chunk_data)
|
| 113 |
+
else:
|
| 114 |
+
logger.warning(f"⚠️ 未知的 SSE 阶段: {phase}")
|
| 115 |
+
|
| 116 |
+
except Exception as e:
|
| 117 |
+
logger.error(f"❌ 处理 SSE 块时发生错误: {e}")
|
| 118 |
+
logger.debug(f" 📦 错误块数据: {chunk_data}")
|
| 119 |
+
# 不中断流,继续处理后续块
|
| 120 |
+
|
| 121 |
+
def _process_thinking_phase(self, delta_content: str) -> Generator[str, None, None]:
|
| 122 |
+
"""处理思考阶段"""
|
| 123 |
+
if not delta_content:
|
| 124 |
+
return
|
| 125 |
+
|
| 126 |
+
logger.debug(f"🤔 思考内容: +{len(delta_content)} 字符")
|
| 127 |
+
|
| 128 |
+
# 在流模式下输出思考内容
|
| 129 |
+
if self.stream:
|
| 130 |
+
chunk = self._create_content_chunk(delta_content)
|
| 131 |
+
yield f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n"
|
| 132 |
+
|
| 133 |
+
def _process_tool_call_phase(self, edit_content: str) -> Generator[str, None, None]:
|
| 134 |
+
"""处理工具调用阶段"""
|
| 135 |
+
if not edit_content:
|
| 136 |
+
return
|
| 137 |
+
|
| 138 |
+
logger.debug(f"🔧 进入工具调用阶段,内容长度: {len(edit_content)}")
|
| 139 |
+
|
| 140 |
+
# 检测 glm_block 标记
|
| 141 |
+
if "<glm_block " in edit_content:
|
| 142 |
+
yield from self._handle_glm_blocks(edit_content)
|
| 143 |
+
else:
|
| 144 |
+
# 没有 glm_block 标记,可能是参数补充
|
| 145 |
+
if self.has_tool_call:
|
| 146 |
+
# 只累积���数部分,找到第一个 ", "result"" 之前的内容
|
| 147 |
+
result_pos = edit_content.find('", "result"')
|
| 148 |
+
if result_pos > 0:
|
| 149 |
+
param_fragment = edit_content[:result_pos]
|
| 150 |
+
self.tool_args += param_fragment
|
| 151 |
+
logger.debug(f"📦 累积参数片段: {param_fragment}")
|
| 152 |
+
else:
|
| 153 |
+
# 如果没有找到结束标记,累积整个内容(可能是中间片段)
|
| 154 |
+
self.tool_args += edit_content
|
| 155 |
+
logger.debug(f"📦 累积参数片段: {edit_content[:100]}...")
|
| 156 |
+
|
| 157 |
+
def _handle_glm_blocks(self, edit_content: str) -> Generator[str, None, None]:
|
| 158 |
+
"""处理 glm_block 标记的内容"""
|
| 159 |
+
blocks = edit_content.split('<glm_block ')
|
| 160 |
+
logger.debug(f"📦 分割得到 {len(blocks)} 个块")
|
| 161 |
+
|
| 162 |
+
for index, block in enumerate(blocks):
|
| 163 |
+
if not block.strip():
|
| 164 |
+
continue
|
| 165 |
+
|
| 166 |
+
if index == 0:
|
| 167 |
+
# 第一个块:提取参数片段
|
| 168 |
+
if self.has_tool_call:
|
| 169 |
+
logger.debug(f"📦 从第一个块提取参数片段")
|
| 170 |
+
# 找到 "result" 的位置,提取之前的参数片段
|
| 171 |
+
result_pos = edit_content.find('"result"')
|
| 172 |
+
if result_pos > 0:
|
| 173 |
+
# 往前退3个字符去掉 ", "
|
| 174 |
+
param_fragment = edit_content[:result_pos - 3]
|
| 175 |
+
self.tool_args += param_fragment
|
| 176 |
+
logger.debug(f"📦 累积参数片段: {param_fragment}")
|
| 177 |
+
else:
|
| 178 |
+
# 没有活跃工具调用,跳过第一个块
|
| 179 |
+
continue
|
| 180 |
+
else:
|
| 181 |
+
# 后续块:处理新工具调用
|
| 182 |
+
if "</glm_block>" not in block:
|
| 183 |
+
continue
|
| 184 |
+
|
| 185 |
+
# 如果有活跃的工具调用,先完成它
|
| 186 |
+
if self.has_tool_call:
|
| 187 |
+
# 补全参数并完成工具调用
|
| 188 |
+
self.tool_args += '"' # 补全最后的引号
|
| 189 |
+
yield from self._finish_current_tool()
|
| 190 |
+
|
| 191 |
+
# 处理新工具调用
|
| 192 |
+
yield from self._process_metadata_block(block)
|
| 193 |
+
|
| 194 |
+
def _process_metadata_block(self, block: str) -> Generator[str, None, None]:
|
| 195 |
+
"""处理包含工具元数据的块"""
|
| 196 |
+
try:
|
| 197 |
+
# 提取 JSON 内容
|
| 198 |
+
start_pos = block.find('>')
|
| 199 |
+
end_pos = block.rfind('</glm_block>')
|
| 200 |
+
|
| 201 |
+
if start_pos == -1 or end_pos == -1:
|
| 202 |
+
logger.warning(f"❌ 无法找到 JSON 内容边界: {block[:1000]}...")
|
| 203 |
+
return
|
| 204 |
+
|
| 205 |
+
json_content = block[start_pos + 1:end_pos]
|
| 206 |
+
logger.debug(f"📦 提取的 JSON 内容: {json_content[:1000]}...")
|
| 207 |
+
|
| 208 |
+
# 解析工具元数据
|
| 209 |
+
metadata_obj = json.loads(json_content)
|
| 210 |
+
|
| 211 |
+
if "data" in metadata_obj and "metadata" in metadata_obj["data"]:
|
| 212 |
+
metadata = metadata_obj["data"]["metadata"]
|
| 213 |
+
|
| 214 |
+
# 开始新的工具调用
|
| 215 |
+
self.tool_id = metadata.get("id", f"call_{int(time.time() * 1000000)}")
|
| 216 |
+
self.tool_name = metadata.get("name", "unknown")
|
| 217 |
+
self.has_tool_call = True
|
| 218 |
+
|
| 219 |
+
# 只有在这是第二个及以后的工具调用时才递增 index
|
| 220 |
+
# 第一个工具调用应该使用 index 0
|
| 221 |
+
|
| 222 |
+
# 从 metadata.arguments 获取参数起始部分
|
| 223 |
+
if "arguments" in metadata:
|
| 224 |
+
arguments_str = metadata["arguments"]
|
| 225 |
+
# 去掉最后一个字符
|
| 226 |
+
self.tool_args = arguments_str[:-1] if arguments_str.endswith('"') else arguments_str
|
| 227 |
+
logger.debug(f"🎯 新工具调用: {self.tool_name}(id={self.tool_id}), 初始参数: {self.tool_args}")
|
| 228 |
+
else:
|
| 229 |
+
self.tool_args = "{}"
|
| 230 |
+
logger.debug(f"🎯 新工具调用: {self.tool_name}(id={self.tool_id}), 空参数")
|
| 231 |
+
|
| 232 |
+
except (json.JSONDecodeError, KeyError, AttributeError) as e:
|
| 233 |
+
logger.error(f"❌ 解析工具元数据失败: {e}, 块内容: {block[:1000]}...")
|
| 234 |
+
|
| 235 |
+
# 确保返回生成器(即使为空)
|
| 236 |
+
if False: # 永远不会执行,但确保函数是生成器
|
| 237 |
+
yield
|
| 238 |
+
|
| 239 |
+
def _process_other_phase(self, usage: Dict[str, Any], edit_content: str = "") -> Generator[str, None, None]:
|
| 240 |
+
"""处理其他阶段"""
|
| 241 |
+
# 保存使用统计信息
|
| 242 |
+
if usage:
|
| 243 |
+
self.tool_call_usage = usage
|
| 244 |
+
logger.debug(f"📊 保存使用统计: {usage}")
|
| 245 |
+
|
| 246 |
+
# 工具调用完成判断:检测到 "null," 开头的 edit_content
|
| 247 |
+
if self.has_tool_call and edit_content and edit_content.startswith("null,"):
|
| 248 |
+
logger.info(f"🏁 检测到工具调用结束标记")
|
| 249 |
+
|
| 250 |
+
# 完成当前工具调用
|
| 251 |
+
yield from self._finish_current_tool()
|
| 252 |
+
|
| 253 |
+
# 发��流结束标记
|
| 254 |
+
if self.stream:
|
| 255 |
+
yield "data: [DONE]\n\n"
|
| 256 |
+
|
| 257 |
+
# 重置状态
|
| 258 |
+
self._reset_all_state()
|
| 259 |
+
|
| 260 |
+
def _process_answer_phase(self, delta_content: str) -> Generator[str, None, None]:
|
| 261 |
+
"""处理回答阶段(优化版本)"""
|
| 262 |
+
if not delta_content:
|
| 263 |
+
return
|
| 264 |
+
|
| 265 |
+
logger.info(f"📝 工具处理器收到答案内容: {delta_content[:50]}...")
|
| 266 |
+
|
| 267 |
+
# 添加到缓冲区
|
| 268 |
+
self.content_buffer += delta_content
|
| 269 |
+
self.buffer_size += len(delta_content)
|
| 270 |
+
|
| 271 |
+
current_time = time.time()
|
| 272 |
+
time_since_last_flush = current_time - self.last_flush_time
|
| 273 |
+
|
| 274 |
+
# 检查是否需要刷新缓冲区
|
| 275 |
+
should_flush = (
|
| 276 |
+
self.buffer_size >= self.max_buffer_size or # 缓冲区满了
|
| 277 |
+
time_since_last_flush >= self.flush_interval or # 时间间隔到了
|
| 278 |
+
'\n' in delta_content or # 包含换行符
|
| 279 |
+
'。' in delta_content or '!' in delta_content or '?' in delta_content # 包含句子结束符
|
| 280 |
+
)
|
| 281 |
+
|
| 282 |
+
if should_flush and self.content_buffer:
|
| 283 |
+
yield from self._flush_content_buffer()
|
| 284 |
+
|
| 285 |
+
def _flush_content_buffer(self) -> Generator[str, None, None]:
|
| 286 |
+
"""刷新内容缓冲区"""
|
| 287 |
+
if not self.content_buffer:
|
| 288 |
+
return
|
| 289 |
+
|
| 290 |
+
logger.info(f"💬 工具处理器刷新缓冲区: {self.buffer_size} 字符 - {self.content_buffer[:50]}...")
|
| 291 |
+
|
| 292 |
+
if self.stream:
|
| 293 |
+
chunk = self._create_content_chunk(self.content_buffer)
|
| 294 |
+
output_data = f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n"
|
| 295 |
+
logger.info(f"➡️ 工具处理器输出: {output_data[:100]}...")
|
| 296 |
+
yield output_data
|
| 297 |
+
|
| 298 |
+
# 清空缓冲区
|
| 299 |
+
self.content_buffer = ""
|
| 300 |
+
self.buffer_size = 0
|
| 301 |
+
self.last_flush_time = time.time()
|
| 302 |
+
|
| 303 |
+
def _process_done_phase(self, chunk_data: Dict[str, Any]) -> Generator[str, None, None]:
|
| 304 |
+
"""处理完成阶段"""
|
| 305 |
+
logger.info("🏁 对话完成")
|
| 306 |
+
|
| 307 |
+
# 先刷新任何剩余的缓冲内容
|
| 308 |
+
if self.content_buffer:
|
| 309 |
+
yield from self._flush_content_buffer()
|
| 310 |
+
|
| 311 |
+
# 完成任何未完成的工具调用
|
| 312 |
+
if self.has_tool_call:
|
| 313 |
+
yield from self._finish_current_tool()
|
| 314 |
+
|
| 315 |
+
# 发送流结束标记
|
| 316 |
+
if self.stream:
|
| 317 |
+
# 创建最终的完成块
|
| 318 |
+
final_chunk = {
|
| 319 |
+
"id": f"chatcmpl-{int(time.time())}",
|
| 320 |
+
"object": "chat.completion.chunk",
|
| 321 |
+
"created": int(time.time()),
|
| 322 |
+
"model": self.model,
|
| 323 |
+
"choices": [{
|
| 324 |
+
"index": 0,
|
| 325 |
+
"delta": {},
|
| 326 |
+
"finish_reason": "stop"
|
| 327 |
+
}]
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
# 如果有 usage 信息,添加到最终块中
|
| 331 |
+
if "usage" in chunk_data:
|
| 332 |
+
final_chunk["usage"] = chunk_data["usage"]
|
| 333 |
+
|
| 334 |
+
yield f"data: {json.dumps(final_chunk, ensure_ascii=False)}\n\n"
|
| 335 |
+
yield "data: [DONE]\n\n"
|
| 336 |
+
|
| 337 |
+
# 重置所有状态
|
| 338 |
+
self._reset_all_state()
|
| 339 |
+
|
| 340 |
+
def _finish_current_tool(self) -> Generator[str, None, None]:
|
| 341 |
+
"""完成当前工具调用"""
|
| 342 |
+
if not self.has_tool_call:
|
| 343 |
+
return
|
| 344 |
+
|
| 345 |
+
# 修复参数格式
|
| 346 |
+
fixed_args = self._fix_tool_arguments(self.tool_args)
|
| 347 |
+
logger.debug(f"✅ 完成工具调用: {self.tool_name}, 参数: {fixed_args}")
|
| 348 |
+
|
| 349 |
+
# 输出工具调用(开始 + 参数 + 完成)
|
| 350 |
+
if self.stream:
|
| 351 |
+
# 发送工具开始块
|
| 352 |
+
start_chunk = self._create_tool_start_chunk()
|
| 353 |
+
yield f"data: {json.dumps(start_chunk, ensure_ascii=False)}\n\n"
|
| 354 |
+
|
| 355 |
+
# 发送参数块
|
| 356 |
+
args_chunk = self._create_tool_arguments_chunk(fixed_args)
|
| 357 |
+
yield f"data: {json.dumps(args_chunk, ensure_ascii=False)}\n\n"
|
| 358 |
+
|
| 359 |
+
# 发送完成块
|
| 360 |
+
finish_chunk = self._create_tool_finish_chunk()
|
| 361 |
+
yield f"data: {json.dumps(finish_chunk, ensure_ascii=False)}\n\n"
|
| 362 |
+
|
| 363 |
+
# 重置工具状态
|
| 364 |
+
self._reset_tool_state()
|
| 365 |
+
|
| 366 |
+
def _fix_tool_arguments(self, raw_args: str) -> str:
|
| 367 |
+
"""使用 json-repair 库修复工具参数格式"""
|
| 368 |
+
if not raw_args or raw_args == "{}":
|
| 369 |
+
return "{}"
|
| 370 |
+
|
| 371 |
+
logger.debug(f"🔧 开始修复参数: {raw_args[:1000]}{'...' if len(raw_args) > 1000 else ''}")
|
| 372 |
+
|
| 373 |
+
# 统一的修复流程:预处理 -> json-repair -> 后处理
|
| 374 |
+
try:
|
| 375 |
+
# 1. 预处理:只处理 json-repair 无法处理的问题
|
| 376 |
+
processed_args = self._preprocess_json_string(raw_args.strip())
|
| 377 |
+
|
| 378 |
+
# 2. 使用 json-repair 进行主要修复
|
| 379 |
+
from json_repair import repair_json
|
| 380 |
+
repaired_json = repair_json(processed_args)
|
| 381 |
+
logger.debug(f"🔧 json-repair 修复结果: {repaired_json}")
|
| 382 |
+
|
| 383 |
+
# 3. 解析并后处理
|
| 384 |
+
args_obj = json.loads(repaired_json)
|
| 385 |
+
args_obj = self._post_process_args(args_obj)
|
| 386 |
+
|
| 387 |
+
# 4. 生成最终结果
|
| 388 |
+
fixed_result = json.dumps(args_obj, ensure_ascii=False)
|
| 389 |
+
|
| 390 |
+
return fixed_result
|
| 391 |
+
|
| 392 |
+
except Exception as e:
|
| 393 |
+
logger.error(f"❌ JSON 修复失败: {e}, 原始参数: {raw_args[:1000]}..., 使用空参数")
|
| 394 |
+
return "{}"
|
| 395 |
+
|
| 396 |
+
def _post_process_args(self, args_obj: Dict[str, Any]) -> Dict[str, Any]:
|
| 397 |
+
"""统一的后处理方法"""
|
| 398 |
+
# 修复路径中的过度转义
|
| 399 |
+
args_obj = self._fix_path_escaping_in_args(args_obj)
|
| 400 |
+
|
| 401 |
+
# 修复命令中的多余引号
|
| 402 |
+
args_obj = self._fix_command_quotes(args_obj)
|
| 403 |
+
|
| 404 |
+
return args_obj
|
| 405 |
+
|
| 406 |
+
def _preprocess_json_string(self, text: str) -> str:
|
| 407 |
+
"""预处理 JSON 字符串,只处理 json-repair 无法处理的问题"""
|
| 408 |
+
import re
|
| 409 |
+
|
| 410 |
+
# 只保留 json-repair 无法处理的预处理步骤
|
| 411 |
+
|
| 412 |
+
# 1. 修复缺少开始括号的情况(json-repair 无法处理)
|
| 413 |
+
if not text.startswith('{') and text.endswith('}'):
|
| 414 |
+
text = '{' + text
|
| 415 |
+
logger.debug(f"🔧 补全开始括号")
|
| 416 |
+
|
| 417 |
+
# 2. 修复末尾多余的反斜杠和引号(json-repair 可能处理不当)
|
| 418 |
+
# 匹配模式:字符串值末尾的 \" 后面跟着 } 或 ,
|
| 419 |
+
# 例如:{"url":"https://www.bilibili.com\"} -> {"url":"https://www.bilibili.com"}
|
| 420 |
+
# 例如:{"url":"https://www.bilibili.com\",} -> {"url":"https://www.bilibili.com",}
|
| 421 |
+
pattern = r'([^\\])\\"([}\s,])'
|
| 422 |
+
if re.search(pattern, text):
|
| 423 |
+
text = re.sub(pattern, r'\1"\2', text)
|
| 424 |
+
logger.debug(f"🔧 修复末尾多余的反斜杠")
|
| 425 |
+
|
| 426 |
+
return text
|
| 427 |
+
|
| 428 |
+
def _fix_path_escaping_in_args(self, args_obj: Dict[str, Any]) -> Dict[str, Any]:
|
| 429 |
+
"""修复参数对象中路径的过度转义问题"""
|
| 430 |
+
import re
|
| 431 |
+
|
| 432 |
+
# 需要检查的路径字段
|
| 433 |
+
path_fields = ['file_path', 'path', 'directory', 'folder']
|
| 434 |
+
|
| 435 |
+
for field in path_fields:
|
| 436 |
+
if field in args_obj and isinstance(args_obj[field], str):
|
| 437 |
+
path_value = args_obj[field]
|
| 438 |
+
|
| 439 |
+
# 检查是否是Windows路径且包含过度转义
|
| 440 |
+
if path_value.startswith('C:') and '\\\\' in path_value:
|
| 441 |
+
logger.debug(f"🔍 检查路径字段 {field}: {repr(path_value)}")
|
| 442 |
+
|
| 443 |
+
# 分析路径结构:正常路径应该是 C:\Users\...
|
| 444 |
+
# 但过度转义的路径可能是 C:\Users\\Documents(多了一个反斜杠)
|
| 445 |
+
# 我们需要找到不正常的双反斜杠模式并修复
|
| 446 |
+
|
| 447 |
+
# 先检查是否有不正常的双反斜杠(不在路径开头)
|
| 448 |
+
# 正常:C:\Users\Documents
|
| 449 |
+
# 异常:C:\Users\\Documents 或 C:\Users\\\\Documents
|
| 450 |
+
|
| 451 |
+
# 使用更精确的模式:匹配路径分隔符后的额外反斜杠
|
| 452 |
+
# 但要保留正常的路径分隔符
|
| 453 |
+
fixed_path = path_value
|
| 454 |
+
|
| 455 |
+
# 检查是否有连续的多个反斜杠(超过正常的路径分隔符)
|
| 456 |
+
if '\\\\' in path_value:
|
| 457 |
+
# 计算反斜杠的数量,如果超过正常数量就修复
|
| 458 |
+
parts = path_value.split('\\')
|
| 459 |
+
# 重新组装路径,去除空的部分(由多余的反斜杠造成)
|
| 460 |
+
clean_parts = [part for part in parts if part]
|
| 461 |
+
if len(clean_parts) > 1:
|
| 462 |
+
fixed_path = '\\'.join(clean_parts)
|
| 463 |
+
|
| 464 |
+
logger.debug(f"🔍 修复后路径: {repr(fixed_path)}")
|
| 465 |
+
|
| 466 |
+
if fixed_path != path_value:
|
| 467 |
+
args_obj[field] = fixed_path
|
| 468 |
+
logger.debug(f"🔧 修复字段 {field} 的路径转义: {path_value} -> {fixed_path}")
|
| 469 |
+
else:
|
| 470 |
+
logger.debug(f"🔍 路径无需修复: {path_value}")
|
| 471 |
+
|
| 472 |
+
return args_obj
|
| 473 |
+
|
| 474 |
+
def _fix_command_quotes(self, args_obj: Dict[str, Any]) -> Dict[str, Any]:
|
| 475 |
+
"""修复命令中的多余引号问题"""
|
| 476 |
+
import re
|
| 477 |
+
|
| 478 |
+
# 检查命令字段
|
| 479 |
+
if 'command' in args_obj and isinstance(args_obj['command'], str):
|
| 480 |
+
command = args_obj['command']
|
| 481 |
+
|
| 482 |
+
# 检查是否以双引号结尾(多余的引号)
|
| 483 |
+
if command.endswith('""'):
|
| 484 |
+
logger.debug(f"🔧 发现命令末尾多余引号: {command}")
|
| 485 |
+
# 移除最后一个多余的引号
|
| 486 |
+
fixed_command = command[:-1]
|
| 487 |
+
args_obj['command'] = fixed_command
|
| 488 |
+
logger.debug(f"🔧 修复命令引号: {command} -> {fixed_command}")
|
| 489 |
+
|
| 490 |
+
# 检查其他可能的引号问题
|
| 491 |
+
# 例如:路径末尾的 \"" 模式
|
| 492 |
+
elif re.search(r'\\""+$', command):
|
| 493 |
+
logger.debug(f"🔧 发现命令末尾引号模式问题: {command}")
|
| 494 |
+
# 修复路径末尾的引号问题
|
| 495 |
+
fixed_command = re.sub(r'\\""+$', '\\"', command)
|
| 496 |
+
args_obj['command'] = fixed_command
|
| 497 |
+
logger.debug(f"🔧 修复命令引号模式: {command} -> {fixed_command}")
|
| 498 |
+
|
| 499 |
+
return args_obj
|
| 500 |
+
|
| 501 |
+
def _create_content_chunk(self, content: str) -> Dict[str, Any]:
|
| 502 |
+
"""创建内容块"""
|
| 503 |
+
return {
|
| 504 |
+
"id": f"chatcmpl-{int(time.time())}",
|
| 505 |
+
"object": "chat.completion.chunk",
|
| 506 |
+
"created": int(time.time()),
|
| 507 |
+
"model": self.model,
|
| 508 |
+
"choices": [{
|
| 509 |
+
"index": 0,
|
| 510 |
+
"delta": {
|
| 511 |
+
"role": "assistant",
|
| 512 |
+
"content": content
|
| 513 |
+
},
|
| 514 |
+
"finish_reason": None
|
| 515 |
+
}]
|
| 516 |
+
}
|
| 517 |
+
|
| 518 |
+
def _create_tool_start_chunk(self) -> Dict[str, Any]:
|
| 519 |
+
"""创建工具开始块"""
|
| 520 |
+
return {
|
| 521 |
+
"id": f"chatcmpl-{int(time.time())}",
|
| 522 |
+
"object": "chat.completion.chunk",
|
| 523 |
+
"created": int(time.time()),
|
| 524 |
+
"model": self.model,
|
| 525 |
+
"choices": [{
|
| 526 |
+
"index": 0,
|
| 527 |
+
"delta": {
|
| 528 |
+
"role": "assistant",
|
| 529 |
+
"tool_calls": [{
|
| 530 |
+
"index": self.content_index,
|
| 531 |
+
"id": self.tool_id,
|
| 532 |
+
"type": "function",
|
| 533 |
+
"function": {
|
| 534 |
+
"name": self.tool_name,
|
| 535 |
+
"arguments": ""
|
| 536 |
+
}
|
| 537 |
+
}]
|
| 538 |
+
},
|
| 539 |
+
"finish_reason": None
|
| 540 |
+
}]
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
+
def _create_tool_arguments_chunk(self, arguments: str) -> Dict[str, Any]:
|
| 544 |
+
"""创建工具参数块"""
|
| 545 |
+
return {
|
| 546 |
+
"id": f"chatcmpl-{int(time.time())}",
|
| 547 |
+
"object": "chat.completion.chunk",
|
| 548 |
+
"created": int(time.time()),
|
| 549 |
+
"model": self.model,
|
| 550 |
+
"choices": [{
|
| 551 |
+
"index": 0,
|
| 552 |
+
"delta": {
|
| 553 |
+
"tool_calls": [{
|
| 554 |
+
"index": self.content_index,
|
| 555 |
+
"id": self.tool_id,
|
| 556 |
+
"function": {
|
| 557 |
+
"arguments": arguments
|
| 558 |
+
}
|
| 559 |
+
}]
|
| 560 |
+
},
|
| 561 |
+
"finish_reason": None
|
| 562 |
+
}]
|
| 563 |
+
}
|
| 564 |
+
|
| 565 |
+
def _create_tool_finish_chunk(self) -> Dict[str, Any]:
|
| 566 |
+
"""创建工具完成块"""
|
| 567 |
+
chunk = {
|
| 568 |
+
"id": f"chatcmpl-{int(time.time())}",
|
| 569 |
+
"object": "chat.completion.chunk",
|
| 570 |
+
"created": int(time.time()),
|
| 571 |
+
"model": self.model,
|
| 572 |
+
"choices": [{
|
| 573 |
+
"index": 0,
|
| 574 |
+
"delta": {
|
| 575 |
+
"tool_calls": []
|
| 576 |
+
},
|
| 577 |
+
"finish_reason": "tool_calls"
|
| 578 |
+
}]
|
| 579 |
+
}
|
| 580 |
+
|
| 581 |
+
# 添加使用统计(如果有)
|
| 582 |
+
if self.tool_call_usage:
|
| 583 |
+
chunk["usage"] = self.tool_call_usage
|
| 584 |
+
|
| 585 |
+
return chunk
|
| 586 |
+
|
| 587 |
+
def _reset_tool_state(self):
|
| 588 |
+
"""重置工具状态"""
|
| 589 |
+
self.tool_id = ""
|
| 590 |
+
self.tool_name = ""
|
| 591 |
+
self.tool_args = ""
|
| 592 |
+
self.has_tool_call = False
|
| 593 |
+
# content_index 在单次对话中应该保持不变,只有在新的工具调用开始时才递增
|
| 594 |
+
|
| 595 |
+
def _reset_all_state(self):
|
| 596 |
+
"""重置所有状态"""
|
| 597 |
+
# 先刷新任何剩余的缓冲内容
|
| 598 |
+
if hasattr(self, 'content_buffer') and self.content_buffer:
|
| 599 |
+
list(self._flush_content_buffer()) # 消费生成器
|
| 600 |
+
|
| 601 |
+
self._reset_tool_state()
|
| 602 |
+
self.current_phase = None
|
| 603 |
+
self.tool_call_usage = {}
|
| 604 |
+
|
| 605 |
+
# 重置缓冲区
|
| 606 |
+
self.content_buffer = ""
|
| 607 |
+
self.buffer_size = 0
|
| 608 |
+
self.last_flush_time = time.time()
|
| 609 |
+
|
| 610 |
+
# content_index 重置为 0,为下一轮对话做准备
|
| 611 |
+
self.content_index = 0
|
| 612 |
+
logger.debug("🔄 重置所有处理器状态")
|
app/utils/token_pool.py
ADDED
|
@@ -0,0 +1,598 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
|
| 4 |
+
"""
|
| 5 |
+
Token 池管理器 - 基于数据库的 Token 轮询和健康检查
|
| 6 |
+
|
| 7 |
+
核心功能:
|
| 8 |
+
1. Token 轮询机制 - 负载均衡和容错
|
| 9 |
+
2. Z.AI 官方认证接口验证 - 基于 role 字段区分用户类型
|
| 10 |
+
3. Token 健康度监控 - 自动禁用失败 Token
|
| 11 |
+
4. 数据库集成 - 与 TokenDAO 协同工作
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import asyncio
|
| 15 |
+
import time
|
| 16 |
+
from typing import Dict, List, Optional, Tuple
|
| 17 |
+
from dataclasses import dataclass, field
|
| 18 |
+
from threading import Lock
|
| 19 |
+
import httpx
|
| 20 |
+
|
| 21 |
+
from app.utils.logger import logger
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# ==================== Token 状态管理 ====================
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@dataclass
|
| 28 |
+
class TokenStatus:
|
| 29 |
+
"""Token 运行时状态(内存中)"""
|
| 30 |
+
token: str
|
| 31 |
+
token_id: int # 数据库 ID,用于同步统计
|
| 32 |
+
token_type: str = "unknown" # "user", "guest", "unknown"
|
| 33 |
+
is_available: bool = True
|
| 34 |
+
failure_count: int = 0
|
| 35 |
+
last_failure_time: float = 0.0
|
| 36 |
+
last_success_time: float = 0.0
|
| 37 |
+
total_requests: int = 0
|
| 38 |
+
successful_requests: int = 0
|
| 39 |
+
|
| 40 |
+
@property
|
| 41 |
+
def success_rate(self) -> float:
|
| 42 |
+
"""成功率"""
|
| 43 |
+
if self.total_requests == 0:
|
| 44 |
+
return 1.0
|
| 45 |
+
return self.successful_requests / self.total_requests
|
| 46 |
+
|
| 47 |
+
@property
|
| 48 |
+
def is_healthy(self) -> bool:
|
| 49 |
+
"""
|
| 50 |
+
Token 健康状态判断
|
| 51 |
+
|
| 52 |
+
健康标准:
|
| 53 |
+
1. 必须是认证用户 Token (token_type = "user")
|
| 54 |
+
2. 当前可用 (is_available = True)
|
| 55 |
+
3. 成功率 >= 50% 或总请求数 <= 3(新 Token 容错)
|
| 56 |
+
|
| 57 |
+
注意:
|
| 58 |
+
- guest Token 永远不健康
|
| 59 |
+
- unknown Token 永远不健康
|
| 60 |
+
"""
|
| 61 |
+
# guest 和 unknown token 永远不健康
|
| 62 |
+
if self.token_type != "user":
|
| 63 |
+
return False
|
| 64 |
+
|
| 65 |
+
# 不可用的 token 不健康
|
| 66 |
+
if not self.is_available:
|
| 67 |
+
return False
|
| 68 |
+
|
| 69 |
+
# 新 token 容错:请求数很少时,只要没失败就健康
|
| 70 |
+
if self.total_requests <= 3:
|
| 71 |
+
return self.failure_count == 0
|
| 72 |
+
|
| 73 |
+
# 基于成功率判断
|
| 74 |
+
return self.success_rate >= 0.5
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# ==================== Token 验证服务 ====================
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class ZAITokenValidator:
|
| 81 |
+
"""Z.AI Token 验证器(使用官方认证接口)"""
|
| 82 |
+
|
| 83 |
+
AUTH_URL = "https://chat.z.ai/api/v1/auths/"
|
| 84 |
+
|
| 85 |
+
@staticmethod
|
| 86 |
+
def get_headers(token: str) -> Dict[str, str]:
|
| 87 |
+
"""构建认证请求头"""
|
| 88 |
+
return {
|
| 89 |
+
"Accept": "*/*",
|
| 90 |
+
"Accept-Language": "zh-CN,zh;q=0.9",
|
| 91 |
+
"Authorization": f"Bearer {token}",
|
| 92 |
+
"Connection": "keep-alive",
|
| 93 |
+
"Content-Type": "application/json",
|
| 94 |
+
"DNT": "1",
|
| 95 |
+
"Referer": "https://chat.z.ai/",
|
| 96 |
+
"Sec-Fetch-Dest": "empty",
|
| 97 |
+
"Sec-Fetch-Mode": "cors",
|
| 98 |
+
"Sec-Fetch-Site": "same-origin",
|
| 99 |
+
"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",
|
| 100 |
+
"sec-ch-ua": '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"',
|
| 101 |
+
"sec-ch-ua-mobile": "?0",
|
| 102 |
+
"sec-ch-ua-platform": '"Windows"'
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
@classmethod
|
| 106 |
+
async def validate_token(cls, token: str) -> Tuple[str, bool, Optional[str]]:
|
| 107 |
+
"""
|
| 108 |
+
验证 Token 有效性并返回类型
|
| 109 |
+
|
| 110 |
+
Args:
|
| 111 |
+
token: 待验证的 Token
|
| 112 |
+
|
| 113 |
+
Returns:
|
| 114 |
+
(token_type, is_valid, error_message)
|
| 115 |
+
- token_type: "user" | "guest" | "unknown"
|
| 116 |
+
- is_valid: True 表示是有效的认证用户 Token
|
| 117 |
+
- error_message: 失败原因(仅在 is_valid=False 时有值)
|
| 118 |
+
"""
|
| 119 |
+
try:
|
| 120 |
+
async with httpx.AsyncClient(timeout=15.0) as client:
|
| 121 |
+
response = await client.get(
|
| 122 |
+
cls.AUTH_URL,
|
| 123 |
+
headers=cls.get_headers(token)
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
# 解析响应
|
| 127 |
+
return cls._parse_auth_response(response)
|
| 128 |
+
|
| 129 |
+
except httpx.TimeoutException:
|
| 130 |
+
return ("unknown", False, "请求超时")
|
| 131 |
+
except httpx.ConnectError:
|
| 132 |
+
return ("unknown", False, "连接失败")
|
| 133 |
+
except Exception as e:
|
| 134 |
+
return ("unknown", False, f"验证异常: {str(e)}")
|
| 135 |
+
|
| 136 |
+
@staticmethod
|
| 137 |
+
def _parse_auth_response(response: httpx.Response) -> Tuple[str, bool, Optional[str]]:
|
| 138 |
+
"""
|
| 139 |
+
解析 Z.AI 认证接口响应
|
| 140 |
+
|
| 141 |
+
响应格式示例:
|
| 142 |
+
{
|
| 143 |
+
"id": "...",
|
| 144 |
+
"email": "[email protected]",
|
| 145 |
+
"role": "user" # 或 "guest"
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
验证规则:
|
| 149 |
+
- role: "user" → 认证用户 Token(有效,可添加)
|
| 150 |
+
- role: "guest" → 匿名用户 Token(无效,拒绝添加)
|
| 151 |
+
- 其他情况 → 无效 Token
|
| 152 |
+
"""
|
| 153 |
+
# 检查 HTTP 状态码
|
| 154 |
+
if response.status_code != 200:
|
| 155 |
+
return ("unknown", False, f"HTTP {response.status_code}")
|
| 156 |
+
|
| 157 |
+
try:
|
| 158 |
+
data = response.json()
|
| 159 |
+
|
| 160 |
+
# 验证响应格式
|
| 161 |
+
if not isinstance(data, dict):
|
| 162 |
+
return ("unknown", False, "无效的响应格式")
|
| 163 |
+
|
| 164 |
+
# 检查是否包含错误信息
|
| 165 |
+
if "error" in data or "message" in data:
|
| 166 |
+
error_msg = data.get("error") or data.get("message", "未知错误")
|
| 167 |
+
return ("unknown", False, str(error_msg))
|
| 168 |
+
|
| 169 |
+
# 核心验证:检查 role 字段
|
| 170 |
+
role = data.get("role")
|
| 171 |
+
|
| 172 |
+
if role == "user":
|
| 173 |
+
return ("user", True, None)
|
| 174 |
+
elif role == "guest":
|
| 175 |
+
return ("guest", False, "匿名用户 Token 不允许添加")
|
| 176 |
+
else:
|
| 177 |
+
return ("unknown", False, f"未知 role: {role}")
|
| 178 |
+
|
| 179 |
+
except (ValueError, Exception) as e:
|
| 180 |
+
return ("unknown", False, f"解析响应失败: {str(e)}")
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
# ==================== Token 池管理器 ====================
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
class TokenPool:
|
| 187 |
+
"""Token 池管理器(数据库驱动)"""
|
| 188 |
+
|
| 189 |
+
def __init__(
|
| 190 |
+
self,
|
| 191 |
+
tokens: List[Tuple[int, str, str]], # [(token_id, token_value, token_type), ...]
|
| 192 |
+
failure_threshold: int = 3,
|
| 193 |
+
recovery_timeout: int = 1800
|
| 194 |
+
):
|
| 195 |
+
"""
|
| 196 |
+
初始化 Token 池
|
| 197 |
+
|
| 198 |
+
Args:
|
| 199 |
+
tokens: Token 列表 [(token_id, token_value, token_type), ...]
|
| 200 |
+
failure_threshold: 失败阈值,超过此次数将标记为不可用
|
| 201 |
+
recovery_timeout: 恢复超时时间(秒),失败 Token 在此时间后重新尝试
|
| 202 |
+
"""
|
| 203 |
+
self.failure_threshold = failure_threshold
|
| 204 |
+
self.recovery_timeout = recovery_timeout
|
| 205 |
+
self._lock = Lock()
|
| 206 |
+
self._current_index = 0
|
| 207 |
+
|
| 208 |
+
# 初始化 Token 状态(内存中)
|
| 209 |
+
self.token_statuses: Dict[str, TokenStatus] = {}
|
| 210 |
+
self.token_id_map: Dict[str, int] = {} # token -> token_id 映射
|
| 211 |
+
|
| 212 |
+
for token_id, token_value, token_type in tokens:
|
| 213 |
+
if token_value and token_value not in self.token_statuses:
|
| 214 |
+
self.token_statuses[token_value] = TokenStatus(
|
| 215 |
+
token=token_value,
|
| 216 |
+
token_id=token_id,
|
| 217 |
+
token_type=token_type
|
| 218 |
+
)
|
| 219 |
+
self.token_id_map[token_value] = token_id
|
| 220 |
+
|
| 221 |
+
if not self.token_statuses:
|
| 222 |
+
logger.warning("⚠️ Token 池为空,将依赖匿名模式")
|
| 223 |
+
|
| 224 |
+
def get_next_token(self) -> Optional[str]:
|
| 225 |
+
"""
|
| 226 |
+
获取下一个可用的认证用户 Token(轮询算法)
|
| 227 |
+
|
| 228 |
+
Returns:
|
| 229 |
+
可用的 Token 字符串,如果没有可用 Token 则返回 None
|
| 230 |
+
"""
|
| 231 |
+
with self._lock:
|
| 232 |
+
if not self.token_statuses:
|
| 233 |
+
return None
|
| 234 |
+
|
| 235 |
+
available_tokens = self._get_available_user_tokens()
|
| 236 |
+
if not available_tokens:
|
| 237 |
+
# 尝试恢复过期的失败 Token
|
| 238 |
+
self._try_recover_failed_tokens()
|
| 239 |
+
available_tokens = self._get_available_user_tokens()
|
| 240 |
+
|
| 241 |
+
if not available_tokens:
|
| 242 |
+
logger.warning("⚠️ 没有可用的认证用户 Token")
|
| 243 |
+
return None
|
| 244 |
+
|
| 245 |
+
# 轮询选择
|
| 246 |
+
token = available_tokens[self._current_index % len(available_tokens)]
|
| 247 |
+
self._current_index = (self._current_index + 1) % len(available_tokens)
|
| 248 |
+
|
| 249 |
+
return token
|
| 250 |
+
|
| 251 |
+
def _get_available_user_tokens(self) -> List[str]:
|
| 252 |
+
"""
|
| 253 |
+
获取当前可用的认证用户 Token 列表
|
| 254 |
+
|
| 255 |
+
过滤条件:
|
| 256 |
+
1. is_available = True
|
| 257 |
+
2. token_type == "user"
|
| 258 |
+
"""
|
| 259 |
+
available_user_tokens = [
|
| 260 |
+
status.token for status in self.token_statuses.values()
|
| 261 |
+
if status.is_available and status.token_type == "user"
|
| 262 |
+
]
|
| 263 |
+
|
| 264 |
+
# 警告:如果有 guest token 但没有 user token
|
| 265 |
+
if not available_user_tokens and self.token_statuses:
|
| 266 |
+
guest_count = sum(
|
| 267 |
+
1 for status in self.token_statuses.values()
|
| 268 |
+
if status.token_type == "guest"
|
| 269 |
+
)
|
| 270 |
+
if guest_count > 0:
|
| 271 |
+
logger.warning(f"⚠️ 检测到 {guest_count} 个匿名用户 Token,轮询机制将跳过这些 Token")
|
| 272 |
+
|
| 273 |
+
return available_user_tokens
|
| 274 |
+
|
| 275 |
+
def _try_recover_failed_tokens(self):
|
| 276 |
+
"""尝试恢复失败的 Token(仅针对认证用户 Token)"""
|
| 277 |
+
current_time = time.time()
|
| 278 |
+
recovered_count = 0
|
| 279 |
+
|
| 280 |
+
for status in self.token_statuses.values():
|
| 281 |
+
# 只恢复认证用户 Token
|
| 282 |
+
if (
|
| 283 |
+
status.token_type == "user"
|
| 284 |
+
and not status.is_available
|
| 285 |
+
and current_time - status.last_failure_time > self.recovery_timeout
|
| 286 |
+
):
|
| 287 |
+
status.is_available = True
|
| 288 |
+
status.failure_count = 0
|
| 289 |
+
recovered_count += 1
|
| 290 |
+
logger.info(f"🔄 恢复失败 Token: {status.token[:20]}...")
|
| 291 |
+
|
| 292 |
+
if recovered_count > 0:
|
| 293 |
+
logger.info(f"✅ 恢复了 {recovered_count} 个失败的 Token")
|
| 294 |
+
|
| 295 |
+
def mark_token_success(self, token: str):
|
| 296 |
+
"""标记 Token 使用成功"""
|
| 297 |
+
with self._lock:
|
| 298 |
+
if token in self.token_statuses:
|
| 299 |
+
status = self.token_statuses[token]
|
| 300 |
+
status.total_requests += 1
|
| 301 |
+
status.successful_requests += 1
|
| 302 |
+
status.last_success_time = time.time()
|
| 303 |
+
status.failure_count = 0 # 重置失败计数
|
| 304 |
+
|
| 305 |
+
if not status.is_available:
|
| 306 |
+
status.is_available = True
|
| 307 |
+
logger.info(f"✅ Token 恢复可用: {token[:20]}...")
|
| 308 |
+
|
| 309 |
+
def mark_token_failure(self, token: str, error: Exception = None):
|
| 310 |
+
"""标记 Token 使用失败"""
|
| 311 |
+
with self._lock:
|
| 312 |
+
if token in self.token_statuses:
|
| 313 |
+
status = self.token_statuses[token]
|
| 314 |
+
status.total_requests += 1
|
| 315 |
+
status.failure_count += 1
|
| 316 |
+
status.last_failure_time = time.time()
|
| 317 |
+
|
| 318 |
+
if status.failure_count >= self.failure_threshold:
|
| 319 |
+
status.is_available = False
|
| 320 |
+
logger.warning(f"🚫 Token 已禁用: {token[:20]}... (失败 {status.failure_count} 次)")
|
| 321 |
+
|
| 322 |
+
def get_token_id(self, token: str) -> Optional[int]:
|
| 323 |
+
"""获取 Token 的数据库 ID"""
|
| 324 |
+
return self.token_id_map.get(token)
|
| 325 |
+
|
| 326 |
+
def get_pool_status(self) -> Dict:
|
| 327 |
+
"""获取 Token 池状态信息"""
|
| 328 |
+
with self._lock:
|
| 329 |
+
available_count = len(self._get_available_user_tokens())
|
| 330 |
+
total_count = len(self.token_statuses)
|
| 331 |
+
healthy_count = sum(1 for status in self.token_statuses.values() if status.is_healthy)
|
| 332 |
+
|
| 333 |
+
# 统计各类型 Token
|
| 334 |
+
user_count = sum(1 for s in self.token_statuses.values() if s.token_type == "user")
|
| 335 |
+
guest_count = sum(1 for s in self.token_statuses.values() if s.token_type == "guest")
|
| 336 |
+
unknown_count = sum(1 for s in self.token_statuses.values() if s.token_type == "unknown")
|
| 337 |
+
|
| 338 |
+
status_info = {
|
| 339 |
+
"total_tokens": total_count,
|
| 340 |
+
"available_tokens": available_count,
|
| 341 |
+
"unavailable_tokens": total_count - available_count,
|
| 342 |
+
"healthy_tokens": healthy_count,
|
| 343 |
+
"unhealthy_tokens": total_count - healthy_count,
|
| 344 |
+
"user_tokens": user_count,
|
| 345 |
+
"guest_tokens": guest_count,
|
| 346 |
+
"unknown_tokens": unknown_count,
|
| 347 |
+
"current_index": self._current_index,
|
| 348 |
+
"tokens": []
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
for token, status in self.token_statuses.items():
|
| 352 |
+
status_info["tokens"].append({
|
| 353 |
+
"token": f"{token[:10]}...{token[-10:]}",
|
| 354 |
+
"token_id": status.token_id,
|
| 355 |
+
"token_type": status.token_type,
|
| 356 |
+
"is_available": status.is_available,
|
| 357 |
+
"failure_count": status.failure_count,
|
| 358 |
+
"success_count": status.successful_requests,
|
| 359 |
+
"success_rate": f"{status.success_rate:.2%}",
|
| 360 |
+
"total_requests": status.total_requests,
|
| 361 |
+
"is_healthy": status.is_healthy,
|
| 362 |
+
"last_failure_time": status.last_failure_time,
|
| 363 |
+
"last_success_time": status.last_success_time
|
| 364 |
+
})
|
| 365 |
+
|
| 366 |
+
return status_info
|
| 367 |
+
|
| 368 |
+
def update_token_type(self, token: str, token_type: str):
|
| 369 |
+
"""更新 Token 类型(用于健康检查后更新)"""
|
| 370 |
+
with self._lock:
|
| 371 |
+
if token in self.token_statuses:
|
| 372 |
+
old_type = self.token_statuses[token].token_type
|
| 373 |
+
self.token_statuses[token].token_type = token_type
|
| 374 |
+
|
| 375 |
+
if old_type != token_type:
|
| 376 |
+
logger.info(f"🔄 更新 Token 类型: {token[:20]}... {old_type} → {token_type}")
|
| 377 |
+
|
| 378 |
+
async def health_check_token(self, token: str) -> bool:
|
| 379 |
+
"""
|
| 380 |
+
异步健康检查单个 Token(使用 Z.AI 官方认证接口)
|
| 381 |
+
|
| 382 |
+
Args:
|
| 383 |
+
token: 要检查的 Token
|
| 384 |
+
|
| 385 |
+
Returns:
|
| 386 |
+
Token 是否健康(True = 有效的认证用户 Token)
|
| 387 |
+
"""
|
| 388 |
+
token_type, is_valid, error_message = await ZAITokenValidator.validate_token(token)
|
| 389 |
+
|
| 390 |
+
# 更新 Token 类型
|
| 391 |
+
self.update_token_type(token, token_type)
|
| 392 |
+
|
| 393 |
+
# 更新状态
|
| 394 |
+
if is_valid:
|
| 395 |
+
self.mark_token_success(token)
|
| 396 |
+
else:
|
| 397 |
+
self.mark_token_failure(token, Exception(error_message or "验证失败"))
|
| 398 |
+
|
| 399 |
+
return is_valid
|
| 400 |
+
|
| 401 |
+
async def health_check_all(self):
|
| 402 |
+
"""异步健康检查所有 Token"""
|
| 403 |
+
if not self.token_statuses:
|
| 404 |
+
logger.warning("⚠️ Token 池为空,跳过健康检查")
|
| 405 |
+
return
|
| 406 |
+
|
| 407 |
+
total_tokens = len(self.token_statuses)
|
| 408 |
+
logger.info(f"🔍 开始 Token 池健康检查... (共 {total_tokens} 个 Token)")
|
| 409 |
+
|
| 410 |
+
# 并发执行所有 Token 的健康检查
|
| 411 |
+
tasks = [
|
| 412 |
+
self.health_check_token(token)
|
| 413 |
+
for token in self.token_statuses.keys()
|
| 414 |
+
]
|
| 415 |
+
|
| 416 |
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
| 417 |
+
|
| 418 |
+
# 统计结果
|
| 419 |
+
healthy_count = sum(1 for r in results if r is True)
|
| 420 |
+
failed_count = sum(1 for r in results if r is False)
|
| 421 |
+
exception_count = sum(1 for r in results if isinstance(r, Exception))
|
| 422 |
+
|
| 423 |
+
health_rate = (healthy_count / total_tokens) * 100 if total_tokens > 0 else 0
|
| 424 |
+
|
| 425 |
+
if healthy_count == 0 and total_tokens > 0:
|
| 426 |
+
logger.warning(f"⚠️ 健康检查完成: 0/{total_tokens} 个 Token 健康 - 请检查 Token 配置")
|
| 427 |
+
elif failed_count > 0:
|
| 428 |
+
logger.warning(f"⚠️ 健康检查完成: {healthy_count}/{total_tokens} 个 Token 健康 ({health_rate:.1f}%)")
|
| 429 |
+
else:
|
| 430 |
+
logger.info(f"✅ 健康检查完成: {healthy_count}/{total_tokens} 个 Token 健康")
|
| 431 |
+
|
| 432 |
+
if exception_count > 0:
|
| 433 |
+
logger.error(f"💥 {exception_count} 个 Token 检查异常")
|
| 434 |
+
|
| 435 |
+
async def sync_from_database(self, provider: str = "zai"):
|
| 436 |
+
"""
|
| 437 |
+
从数据库同步 Token 状态(禁用/启用状态)
|
| 438 |
+
|
| 439 |
+
Args:
|
| 440 |
+
provider: 提供商名称
|
| 441 |
+
|
| 442 |
+
说明:
|
| 443 |
+
- 从数据库读取最新的 Token 启用状态
|
| 444 |
+
- 如果数据库中 Token 被禁用,则从池中移除
|
| 445 |
+
- 如果数据库中有新增的启用 Token,则添加到池中
|
| 446 |
+
- 保留现有 Token 的运行时统计(请求数、成功率等)
|
| 447 |
+
"""
|
| 448 |
+
from app.services.token_dao import get_token_dao
|
| 449 |
+
|
| 450 |
+
dao = get_token_dao()
|
| 451 |
+
|
| 452 |
+
# 从数据库加载所有启用的认证用户 Token
|
| 453 |
+
token_records = await dao.get_tokens_by_provider(provider, enabled_only=True)
|
| 454 |
+
|
| 455 |
+
# 构建数据库中的 Token 映射
|
| 456 |
+
db_tokens = {
|
| 457 |
+
record["token"]: (record["id"], record.get("token_type", "unknown"))
|
| 458 |
+
for record in token_records
|
| 459 |
+
if record.get("token_type") != "guest" # 过滤 guest token
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
with self._lock:
|
| 463 |
+
# 1. 移除已在数据库中禁用的 Token
|
| 464 |
+
tokens_to_remove = []
|
| 465 |
+
for token_value in list(self.token_statuses.keys()):
|
| 466 |
+
if token_value not in db_tokens:
|
| 467 |
+
tokens_to_remove.append(token_value)
|
| 468 |
+
|
| 469 |
+
for token_value in tokens_to_remove:
|
| 470 |
+
del self.token_statuses[token_value]
|
| 471 |
+
del self.token_id_map[token_value]
|
| 472 |
+
logger.info(f"🗑️ 从池中移除已禁用 Token: {token_value[:20]}...")
|
| 473 |
+
|
| 474 |
+
# 2. 添加新启用的 Token
|
| 475 |
+
new_tokens_count = 0
|
| 476 |
+
for token_value, (token_id, token_type) in db_tokens.items():
|
| 477 |
+
if token_value not in self.token_statuses:
|
| 478 |
+
self.token_statuses[token_value] = TokenStatus(
|
| 479 |
+
token=token_value,
|
| 480 |
+
token_id=token_id,
|
| 481 |
+
token_type=token_type
|
| 482 |
+
)
|
| 483 |
+
self.token_id_map[token_value] = token_id
|
| 484 |
+
new_tokens_count += 1
|
| 485 |
+
logger.info(f"➕ 添加新启用 Token: {token_value[:20]}...")
|
| 486 |
+
|
| 487 |
+
# 3. 更新现有 Token 的类型(如果数据库中有更新)
|
| 488 |
+
for token_value, (token_id, token_type) in db_tokens.items():
|
| 489 |
+
if token_value in self.token_statuses:
|
| 490 |
+
old_type = self.token_statuses[token_value].token_type
|
| 491 |
+
if old_type != token_type:
|
| 492 |
+
self.token_statuses[token_value].token_type = token_type
|
| 493 |
+
logger.info(f"🔄 更新 Token 类型: {token_value[:20]}... {old_type} → {token_type}")
|
| 494 |
+
|
| 495 |
+
logger.info(
|
| 496 |
+
f"✅ Token 池同步完成: "
|
| 497 |
+
f"当前 {len(self.token_statuses)} 个 Token "
|
| 498 |
+
f"(移除 {len(tokens_to_remove)}, 新增 {new_tokens_count})"
|
| 499 |
+
)
|
| 500 |
+
|
| 501 |
+
|
| 502 |
+
# ==================== 全局实例管理 ====================
|
| 503 |
+
|
| 504 |
+
|
| 505 |
+
_token_pool: Optional[TokenPool] = None
|
| 506 |
+
_pool_lock = Lock()
|
| 507 |
+
|
| 508 |
+
|
| 509 |
+
def get_token_pool() -> Optional[TokenPool]:
|
| 510 |
+
"""获取全局 Token 池实例"""
|
| 511 |
+
return _token_pool
|
| 512 |
+
|
| 513 |
+
|
| 514 |
+
async def initialize_token_pool_from_db(
|
| 515 |
+
provider: str = "zai",
|
| 516 |
+
failure_threshold: int = 3,
|
| 517 |
+
recovery_timeout: int = 1800
|
| 518 |
+
) -> Optional[TokenPool]:
|
| 519 |
+
"""
|
| 520 |
+
从数据库初始化全局 Token 池
|
| 521 |
+
|
| 522 |
+
Args:
|
| 523 |
+
provider: 提供商名称 (zai, k2think, longcat)
|
| 524 |
+
failure_threshold: 失败阈值
|
| 525 |
+
recovery_timeout: 恢复超时时间(秒)
|
| 526 |
+
|
| 527 |
+
Returns:
|
| 528 |
+
TokenPool 实例(即使没有 Token 也会创建空池)
|
| 529 |
+
"""
|
| 530 |
+
global _token_pool
|
| 531 |
+
|
| 532 |
+
from app.services.token_dao import get_token_dao
|
| 533 |
+
|
| 534 |
+
dao = get_token_dao()
|
| 535 |
+
|
| 536 |
+
# 从数据库加载 Token(只加载启用的认证用户 Token)
|
| 537 |
+
token_records = await dao.get_tokens_by_provider(provider, enabled_only=True)
|
| 538 |
+
|
| 539 |
+
# 转换为 TokenPool 所需格式
|
| 540 |
+
tokens = []
|
| 541 |
+
if token_records:
|
| 542 |
+
tokens = [
|
| 543 |
+
(record["id"], record["token"], record.get("token_type", "unknown"))
|
| 544 |
+
for record in token_records
|
| 545 |
+
]
|
| 546 |
+
|
| 547 |
+
# 过滤掉 guest token(不应该在数据库中,但防御性检查)
|
| 548 |
+
user_tokens = [
|
| 549 |
+
(tid, tval, ttype) for tid, tval, ttype in tokens
|
| 550 |
+
if ttype != "guest"
|
| 551 |
+
]
|
| 552 |
+
|
| 553 |
+
if len(user_tokens) < len(tokens):
|
| 554 |
+
guest_count = len(tokens) - len(user_tokens)
|
| 555 |
+
logger.warning(f"⚠️ 过滤了 {guest_count} 个匿名用户 Token")
|
| 556 |
+
|
| 557 |
+
tokens = user_tokens
|
| 558 |
+
|
| 559 |
+
# 始终创建 Token 池实例(即使为空)
|
| 560 |
+
with _pool_lock:
|
| 561 |
+
_token_pool = TokenPool(tokens, failure_threshold, recovery_timeout)
|
| 562 |
+
|
| 563 |
+
if not tokens:
|
| 564 |
+
logger.warning(f"⚠️ {provider} 没有有效的认证用户 Token,已创建空 Token 池")
|
| 565 |
+
else:
|
| 566 |
+
logger.info(f"🔧 从数据库初始化 Token 池({provider}),共 {len(tokens)} 个 Token")
|
| 567 |
+
|
| 568 |
+
return _token_pool
|
| 569 |
+
|
| 570 |
+
|
| 571 |
+
async def sync_token_stats_to_db():
|
| 572 |
+
"""
|
| 573 |
+
将内存中的 Token 统计同步到数据库
|
| 574 |
+
|
| 575 |
+
应在服务关闭或定期调用,确保统计数据不丢失
|
| 576 |
+
"""
|
| 577 |
+
pool = get_token_pool()
|
| 578 |
+
if not pool:
|
| 579 |
+
return
|
| 580 |
+
|
| 581 |
+
from app.services.token_dao import get_token_dao
|
| 582 |
+
|
| 583 |
+
dao = get_token_dao()
|
| 584 |
+
|
| 585 |
+
with pool._lock:
|
| 586 |
+
for token, status in pool.token_statuses.items():
|
| 587 |
+
token_id = status.token_id
|
| 588 |
+
|
| 589 |
+
# 更新数据库统计(简化版,实际可能需要增量更新)
|
| 590 |
+
if status.successful_requests > 0:
|
| 591 |
+
for _ in range(status.successful_requests):
|
| 592 |
+
await dao.record_success(token_id)
|
| 593 |
+
|
| 594 |
+
if status.total_requests - status.successful_requests > 0:
|
| 595 |
+
for _ in range(status.total_requests - status.successful_requests):
|
| 596 |
+
await dao.record_failure(token_id)
|
| 597 |
+
|
| 598 |
+
logger.info("✅ Token 统计已同步到数据库")
|
app/utils/tool_call_handler.py
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
"""
|
| 4 |
+
工具调用处理模块
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import json
|
| 8 |
+
import re
|
| 9 |
+
from typing import Dict, List, Any, Optional, Tuple
|
| 10 |
+
from app.utils.logger import get_logger
|
| 11 |
+
|
| 12 |
+
logger = get_logger()
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def generate_tool_prompt(tools: Optional[List[Dict[str, Any]]]) -> str:
|
| 16 |
+
"""
|
| 17 |
+
生成工具调用提示词
|
| 18 |
+
将 OpenAI tools 定义转换为 Markdown 格式的说明文档
|
| 19 |
+
|
| 20 |
+
Args:
|
| 21 |
+
tools: OpenAI 格式的工具定义列表
|
| 22 |
+
|
| 23 |
+
Returns:
|
| 24 |
+
str: Markdown 格式的工具使用说明
|
| 25 |
+
"""
|
| 26 |
+
if not tools or len(tools) == 0:
|
| 27 |
+
return ""
|
| 28 |
+
|
| 29 |
+
tool_definitions = []
|
| 30 |
+
|
| 31 |
+
for tool in tools:
|
| 32 |
+
if tool.get("type") != "function":
|
| 33 |
+
continue
|
| 34 |
+
|
| 35 |
+
function_spec = tool.get("function", {})
|
| 36 |
+
function_name = function_spec.get("name", "unknown")
|
| 37 |
+
function_description = function_spec.get("description", "")
|
| 38 |
+
parameters = function_spec.get("parameters", {})
|
| 39 |
+
|
| 40 |
+
# 创建结构化的工具定义
|
| 41 |
+
tool_info = [
|
| 42 |
+
f"## {function_name}",
|
| 43 |
+
f"**Purpose**: {function_description}"
|
| 44 |
+
]
|
| 45 |
+
|
| 46 |
+
# 添加参数详情
|
| 47 |
+
parameter_properties = parameters.get("properties", {})
|
| 48 |
+
required_parameters = set(parameters.get("required", []))
|
| 49 |
+
|
| 50 |
+
if parameter_properties:
|
| 51 |
+
tool_info.append("**Parameters**:")
|
| 52 |
+
for param_name, param_info in parameter_properties.items():
|
| 53 |
+
param_type = param_info.get("type", "string")
|
| 54 |
+
param_desc = param_info.get("description", "")
|
| 55 |
+
is_required = param_name in required_parameters
|
| 56 |
+
required_str = " (required)" if is_required else " (optional)"
|
| 57 |
+
tool_info.append(f"- `{param_name}` ({param_type}){required_str}: {param_desc}")
|
| 58 |
+
|
| 59 |
+
tool_definitions.append("\n".join(tool_info))
|
| 60 |
+
|
| 61 |
+
# 组合完整的提示词
|
| 62 |
+
prompt = (
|
| 63 |
+
"\n\n---\n"
|
| 64 |
+
"# Available Tools\n\n"
|
| 65 |
+
+ "\n\n".join(tool_definitions) +
|
| 66 |
+
"\n\n"
|
| 67 |
+
"**Tool Invocation Format**:\n"
|
| 68 |
+
"To use a tool, include a JSON block with this structure:\n"
|
| 69 |
+
'{"tool_calls": [{"id": "call_ID", "type": "function", "function": {"name": "TOOL_NAME", "arguments": "JSON_STRING"}}]}\n\n'
|
| 70 |
+
"**Rules**:\n"
|
| 71 |
+
"- Use tool ONLY when user explicitly requests an action that matches a tool's purpose\n"
|
| 72 |
+
"- For normal conversation, respond naturally WITHOUT any tool calls\n"
|
| 73 |
+
"- The `arguments` must be a JSON string, not an object\n"
|
| 74 |
+
"- Multiple tools can be called by adding more items to the array\n"
|
| 75 |
+
"---\n\n"
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
logger.debug(f"生成工具提示词,包含 {len(tool_definitions)} 个工具定义")
|
| 79 |
+
return prompt
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def process_messages_with_tools(
|
| 83 |
+
messages: List[Dict[str, Any]],
|
| 84 |
+
tools: Optional[List[Dict[str, Any]]],
|
| 85 |
+
tool_choice: str = "auto"
|
| 86 |
+
) -> List[Dict[str, Any]]:
|
| 87 |
+
"""
|
| 88 |
+
将工具定义注入到消息列表中
|
| 89 |
+
|
| 90 |
+
Args:
|
| 91 |
+
messages: 原始消息列表
|
| 92 |
+
tools: 工具定义列表
|
| 93 |
+
tool_choice: 工具选择策略 ("auto", "none", 等)
|
| 94 |
+
|
| 95 |
+
Returns:
|
| 96 |
+
List[Dict]: 处理后的消息列表
|
| 97 |
+
"""
|
| 98 |
+
if not tools or tool_choice == "none":
|
| 99 |
+
return messages
|
| 100 |
+
|
| 101 |
+
tools_prompt = generate_tool_prompt(tools)
|
| 102 |
+
if not tools_prompt:
|
| 103 |
+
return messages
|
| 104 |
+
|
| 105 |
+
processed = []
|
| 106 |
+
has_system = any(m.get("role") == "system" for m in messages)
|
| 107 |
+
|
| 108 |
+
if has_system:
|
| 109 |
+
# 如果有 system 消息,将工具提示追加到第一个 system 消息
|
| 110 |
+
for msg in messages:
|
| 111 |
+
if msg.get("role") == "system":
|
| 112 |
+
new_msg = msg.copy()
|
| 113 |
+
content = new_msg.get("content", "")
|
| 114 |
+
if isinstance(content, list):
|
| 115 |
+
# 多模态内容
|
| 116 |
+
content_str = " ".join([
|
| 117 |
+
item.get("text", "") if item.get("type") == "text" else ""
|
| 118 |
+
for item in content
|
| 119 |
+
])
|
| 120 |
+
else:
|
| 121 |
+
content_str = str(content)
|
| 122 |
+
new_msg["content"] = content_str + tools_prompt
|
| 123 |
+
processed.append(new_msg)
|
| 124 |
+
else:
|
| 125 |
+
processed.append(msg)
|
| 126 |
+
else:
|
| 127 |
+
# 没有 system 消息,创建一个新的 system 消息
|
| 128 |
+
processed.append({
|
| 129 |
+
"role": "system",
|
| 130 |
+
"content": f"You are a helpful assistant with access to tools.{tools_prompt}"
|
| 131 |
+
})
|
| 132 |
+
processed.extend(messages)
|
| 133 |
+
|
| 134 |
+
logger.debug(f"工具提示已注入到消息列表,共 {len(processed)} 条消息")
|
| 135 |
+
return processed
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def parse_and_extract_tool_calls(content: str) -> Tuple[Optional[List[Dict[str, Any]]], str]:
|
| 139 |
+
"""
|
| 140 |
+
从响应内容中提取 tool_calls JSON
|
| 141 |
+
|
| 142 |
+
Args:
|
| 143 |
+
content: 模型返回的文本内容
|
| 144 |
+
|
| 145 |
+
Returns:
|
| 146 |
+
Tuple[Optional[List], str]: (提取的 tool_calls 列表, 清理后的内容)
|
| 147 |
+
"""
|
| 148 |
+
if not content or not content.strip():
|
| 149 |
+
return None, content
|
| 150 |
+
|
| 151 |
+
tool_calls = None
|
| 152 |
+
cleaned_content = content
|
| 153 |
+
|
| 154 |
+
# 方法1: 尝试解析 JSON 代码块中的 tool_calls
|
| 155 |
+
# 匹配 ```json ... ``` 或 ```...```
|
| 156 |
+
json_block_pattern = r'```(?:json)?\s*\n?(\{[\s\S]*?\})\s*\n?```'
|
| 157 |
+
json_blocks = re.findall(json_block_pattern, content)
|
| 158 |
+
|
| 159 |
+
for json_str in json_blocks:
|
| 160 |
+
try:
|
| 161 |
+
parsed_data = json.loads(json_str)
|
| 162 |
+
if "tool_calls" in parsed_data:
|
| 163 |
+
tool_calls = parsed_data["tool_calls"]
|
| 164 |
+
if tool_calls and isinstance(tool_calls, list):
|
| 165 |
+
# 确保 arguments 字段是字符串
|
| 166 |
+
for tc in tool_calls:
|
| 167 |
+
if tc.get("function"):
|
| 168 |
+
func = tc["function"]
|
| 169 |
+
if func.get("arguments"):
|
| 170 |
+
if isinstance(func["arguments"], dict):
|
| 171 |
+
# 转换对象为 JSON 字符串
|
| 172 |
+
func["arguments"] = json.dumps(func["arguments"], ensure_ascii=False)
|
| 173 |
+
elif not isinstance(func["arguments"], str):
|
| 174 |
+
func["arguments"] = str(func["arguments"])
|
| 175 |
+
logger.debug(f"从 JSON 代码块中提取到 {len(tool_calls)} 个工具调用")
|
| 176 |
+
break
|
| 177 |
+
except json.JSONDecodeError:
|
| 178 |
+
continue
|
| 179 |
+
|
| 180 |
+
# 方法2: 尝试从文本中直接查找 JSON 对象
|
| 181 |
+
if not tool_calls:
|
| 182 |
+
# 查找包含 "tool_calls" 的 JSON 对象
|
| 183 |
+
i = 0
|
| 184 |
+
scannable_text = content
|
| 185 |
+
while i < len(scannable_text):
|
| 186 |
+
if scannable_text[i] == '{':
|
| 187 |
+
# 尝试找到匹配的闭合括号
|
| 188 |
+
brace_count = 1
|
| 189 |
+
j = i + 1
|
| 190 |
+
in_string = False
|
| 191 |
+
escape_next = False
|
| 192 |
+
|
| 193 |
+
while j < len(scannable_text) and brace_count > 0:
|
| 194 |
+
if escape_next:
|
| 195 |
+
escape_next = False
|
| 196 |
+
elif scannable_text[j] == '\\':
|
| 197 |
+
escape_next = True
|
| 198 |
+
elif scannable_text[j] == '"':
|
| 199 |
+
in_string = not in_string
|
| 200 |
+
elif not in_string:
|
| 201 |
+
if scannable_text[j] == '{':
|
| 202 |
+
brace_count += 1
|
| 203 |
+
elif scannable_text[j] == '}':
|
| 204 |
+
brace_count -= 1
|
| 205 |
+
j += 1
|
| 206 |
+
|
| 207 |
+
if brace_count == 0:
|
| 208 |
+
# 找到完整的 JSON 对象
|
| 209 |
+
json_candidate = scannable_text[i:j]
|
| 210 |
+
try:
|
| 211 |
+
parsed_data = json.loads(json_candidate)
|
| 212 |
+
if "tool_calls" in parsed_data:
|
| 213 |
+
tool_calls = parsed_data["tool_calls"]
|
| 214 |
+
if tool_calls and isinstance(tool_calls, list):
|
| 215 |
+
# 确保 arguments 字段是字符串
|
| 216 |
+
for tc in tool_calls:
|
| 217 |
+
if tc.get("function"):
|
| 218 |
+
func = tc["function"]
|
| 219 |
+
if func.get("arguments"):
|
| 220 |
+
if isinstance(func["arguments"], dict):
|
| 221 |
+
func["arguments"] = json.dumps(func["arguments"], ensure_ascii=False)
|
| 222 |
+
elif not isinstance(func["arguments"], str):
|
| 223 |
+
func["arguments"] = str(func["arguments"])
|
| 224 |
+
logger.debug(f"从内联 JSON 中提取到 {len(tool_calls)} 个工具调用")
|
| 225 |
+
break
|
| 226 |
+
except json.JSONDecodeError:
|
| 227 |
+
pass
|
| 228 |
+
|
| 229 |
+
i = j
|
| 230 |
+
else:
|
| 231 |
+
i += 1
|
| 232 |
+
|
| 233 |
+
# 清理内容 - 移除包含 tool_calls 的 JSON
|
| 234 |
+
if tool_calls:
|
| 235 |
+
cleaned_content = remove_tool_json_content(content)
|
| 236 |
+
|
| 237 |
+
return tool_calls, cleaned_content
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def remove_tool_json_content(content: str) -> str:
|
| 241 |
+
"""
|
| 242 |
+
从响应内容中移除工具调用 JSON
|
| 243 |
+
|
| 244 |
+
Args:
|
| 245 |
+
content: 原始响应内容
|
| 246 |
+
|
| 247 |
+
Returns:
|
| 248 |
+
str: 清理后的内容
|
| 249 |
+
"""
|
| 250 |
+
if not content:
|
| 251 |
+
return content
|
| 252 |
+
|
| 253 |
+
# 步骤1: 移除 JSON 代码块中包含 tool_calls 的部分
|
| 254 |
+
cleaned_text = content
|
| 255 |
+
|
| 256 |
+
# 匹配 ```json ... ``` 或 ```...```
|
| 257 |
+
def replace_json_block(match):
|
| 258 |
+
json_content = match.group(1)
|
| 259 |
+
try:
|
| 260 |
+
parsed_data = json.loads(json_content)
|
| 261 |
+
if "tool_calls" in parsed_data:
|
| 262 |
+
return "" # 移除整个代码块
|
| 263 |
+
except json.JSONDecodeError:
|
| 264 |
+
pass
|
| 265 |
+
return match.group(0) # 保留原文
|
| 266 |
+
|
| 267 |
+
json_block_pattern = r'```(?:json)?\s*\n?(\{[\s\S]*?\})\s*\n?```'
|
| 268 |
+
cleaned_text = re.sub(json_block_pattern, replace_json_block, cleaned_text)
|
| 269 |
+
|
| 270 |
+
# 步骤2: 移除内联的 tool JSON - 使用括号平衡方法
|
| 271 |
+
result = []
|
| 272 |
+
i = 0
|
| 273 |
+
|
| 274 |
+
while i < len(cleaned_text):
|
| 275 |
+
if cleaned_text[i] == '{':
|
| 276 |
+
# 尝试找到匹配的闭合括号
|
| 277 |
+
brace_count = 1
|
| 278 |
+
j = i + 1
|
| 279 |
+
in_string = False
|
| 280 |
+
escape_next = False
|
| 281 |
+
|
| 282 |
+
while j < len(cleaned_text) and brace_count > 0:
|
| 283 |
+
if escape_next:
|
| 284 |
+
escape_next = False
|
| 285 |
+
elif cleaned_text[j] == '\\':
|
| 286 |
+
escape_next = True
|
| 287 |
+
elif cleaned_text[j] == '"':
|
| 288 |
+
in_string = not in_string
|
| 289 |
+
elif not in_string:
|
| 290 |
+
if cleaned_text[j] == '{':
|
| 291 |
+
brace_count += 1
|
| 292 |
+
elif cleaned_text[j] == '}':
|
| 293 |
+
brace_count -= 1
|
| 294 |
+
j += 1
|
| 295 |
+
|
| 296 |
+
if brace_count == 0:
|
| 297 |
+
# 找到完整的 JSON 对象,检查是否包含 tool_calls
|
| 298 |
+
json_candidate = cleaned_text[i:j]
|
| 299 |
+
try:
|
| 300 |
+
parsed = json.loads(json_candidate)
|
| 301 |
+
if "tool_calls" in parsed:
|
| 302 |
+
# 这是一个工具调用,跳过它
|
| 303 |
+
i = j
|
| 304 |
+
continue
|
| 305 |
+
except json.JSONDecodeError:
|
| 306 |
+
pass
|
| 307 |
+
|
| 308 |
+
# 不是工具调用或无法解析,保留这个字符
|
| 309 |
+
result.append(cleaned_text[i])
|
| 310 |
+
i += 1
|
| 311 |
+
else:
|
| 312 |
+
result.append(cleaned_text[i])
|
| 313 |
+
i += 1
|
| 314 |
+
|
| 315 |
+
cleaned_result = "".join(result).strip()
|
| 316 |
+
|
| 317 |
+
# 移除多余的空白行
|
| 318 |
+
cleaned_result = re.sub(r'\n{3,}', '\n\n', cleaned_result)
|
| 319 |
+
|
| 320 |
+
logger.debug(f"内容清理完成,原始长度: {len(content)}, 清理后长度: {len(cleaned_result)}")
|
| 321 |
+
return cleaned_result
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
def content_to_string(content: Any) -> str:
|
| 325 |
+
"""
|
| 326 |
+
将消息内容转换为字符串
|
| 327 |
+
|
| 328 |
+
Args:
|
| 329 |
+
content: 消息内容,可能是字符串或列表(多模态)
|
| 330 |
+
|
| 331 |
+
Returns:
|
| 332 |
+
str: 字符串格式的内容
|
| 333 |
+
"""
|
| 334 |
+
if isinstance(content, str):
|
| 335 |
+
return content
|
| 336 |
+
elif isinstance(content, list):
|
| 337 |
+
# 多模态内容,提取文本部分
|
| 338 |
+
text_parts = []
|
| 339 |
+
for item in content:
|
| 340 |
+
if isinstance(item, dict):
|
| 341 |
+
if item.get("type") == "text":
|
| 342 |
+
text_parts.append(item.get("text", ""))
|
| 343 |
+
elif isinstance(item, str):
|
| 344 |
+
text_parts.append(item)
|
| 345 |
+
return " ".join(text_parts)
|
| 346 |
+
else:
|
| 347 |
+
return str(content)
|
app/utils/user_agent.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
|
| 4 |
+
"""
|
| 5 |
+
用户代理工具模块
|
| 6 |
+
提供动态随机用户代理生成功能
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import random
|
| 10 |
+
from typing import Dict, Optional
|
| 11 |
+
from fake_useragent import UserAgent
|
| 12 |
+
|
| 13 |
+
# 全局 UserAgent 实例(单例模式)
|
| 14 |
+
_user_agent_instance: Optional[UserAgent] = None
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def get_user_agent_instance() -> UserAgent:
|
| 18 |
+
"""获取或创建 UserAgent 实例(单例模式)"""
|
| 19 |
+
global _user_agent_instance
|
| 20 |
+
if _user_agent_instance is None:
|
| 21 |
+
_user_agent_instance = UserAgent()
|
| 22 |
+
return _user_agent_instance
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def get_random_user_agent(browser_type: Optional[str] = None) -> str:
|
| 26 |
+
"""
|
| 27 |
+
获取随机用户代理字符串
|
| 28 |
+
|
| 29 |
+
Args:
|
| 30 |
+
browser_type: 指定浏览器类型 ('chrome', 'firefox', 'safari', 'edge')
|
| 31 |
+
如果为 None,则随机选择
|
| 32 |
+
|
| 33 |
+
Returns:
|
| 34 |
+
str: 用户代理字符串
|
| 35 |
+
"""
|
| 36 |
+
ua = get_user_agent_instance()
|
| 37 |
+
|
| 38 |
+
# 如果没有指定浏览器类型,随机选择一个(偏向 Chrome 和 Edge)
|
| 39 |
+
if browser_type is None:
|
| 40 |
+
browser_choices = ["chrome", "chrome", "chrome", "edge", "edge", "firefox", "safari"]
|
| 41 |
+
browser_type = random.choice(browser_choices)
|
| 42 |
+
|
| 43 |
+
# 根据浏览器类型获取用户代理
|
| 44 |
+
if browser_type == "chrome":
|
| 45 |
+
user_agent = ua.chrome
|
| 46 |
+
elif browser_type == "edge":
|
| 47 |
+
user_agent = ua.edge
|
| 48 |
+
elif browser_type == "firefox":
|
| 49 |
+
user_agent = ua.firefox
|
| 50 |
+
elif browser_type == "safari":
|
| 51 |
+
user_agent = ua.safari
|
| 52 |
+
else:
|
| 53 |
+
user_agent = ua.random
|
| 54 |
+
|
| 55 |
+
return user_agent
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# 通用 UserAgent headers 生成函数
|
| 59 |
+
def get_dynamic_headers(
|
| 60 |
+
referer: Optional[str] = None,
|
| 61 |
+
origin: Optional[str] = None,
|
| 62 |
+
browser_type: Optional[str] = None,
|
| 63 |
+
additional_headers: Optional[Dict[str, str]] = None
|
| 64 |
+
) -> Dict[str, str]:
|
| 65 |
+
"""
|
| 66 |
+
生成动态浏览器 headers,包含随机 User-Agent
|
| 67 |
+
|
| 68 |
+
Args:
|
| 69 |
+
referer: 引用页面 URL
|
| 70 |
+
origin: 源站 URL
|
| 71 |
+
browser_type: 指定浏览器类型
|
| 72 |
+
additional_headers: 额外的 headers
|
| 73 |
+
|
| 74 |
+
Returns:
|
| 75 |
+
Dict[str, str]: 包含动态 User-Agent 的 headers
|
| 76 |
+
"""
|
| 77 |
+
user_agent = get_random_user_agent(browser_type)
|
| 78 |
+
|
| 79 |
+
# 基础 headers
|
| 80 |
+
headers = {
|
| 81 |
+
"User-Agent": user_agent,
|
| 82 |
+
"Accept": "application/json, text/event-stream",
|
| 83 |
+
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
| 84 |
+
"Accept-Encoding": "gzip, deflate, br",
|
| 85 |
+
"Cache-Control": "no-cache",
|
| 86 |
+
"Connection": "keep-alive",
|
| 87 |
+
"Pragma": "no-cache",
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
# 添加可选的 headers
|
| 91 |
+
if referer:
|
| 92 |
+
headers["Referer"] = referer
|
| 93 |
+
|
| 94 |
+
if origin:
|
| 95 |
+
headers["Origin"] = origin
|
| 96 |
+
|
| 97 |
+
# 根据用户代理添加浏览器特定的 headers
|
| 98 |
+
if "Chrome/" in user_agent or "Edg/" in user_agent:
|
| 99 |
+
# Chrome/Edge 特定的 headers
|
| 100 |
+
chrome_version = "139"
|
| 101 |
+
edge_version = "139"
|
| 102 |
+
|
| 103 |
+
try:
|
| 104 |
+
if "Chrome/" in user_agent:
|
| 105 |
+
chrome_version = user_agent.split("Chrome/")[1].split(".")[0]
|
| 106 |
+
except:
|
| 107 |
+
pass
|
| 108 |
+
|
| 109 |
+
try:
|
| 110 |
+
if "Edg/" in user_agent:
|
| 111 |
+
edge_version = user_agent.split("Edg/")[1].split(".")[0]
|
| 112 |
+
sec_ch_ua = f'"Microsoft Edge";v="{edge_version}", "Chromium";v="{chrome_version}", "Not_A Brand";v="24"'
|
| 113 |
+
else:
|
| 114 |
+
sec_ch_ua = f'"Not_A Brand";v="8", "Chromium";v="{chrome_version}", "Google Chrome";v="{chrome_version}"'
|
| 115 |
+
except:
|
| 116 |
+
sec_ch_ua = f'"Not_A Brand";v="8", "Chromium";v="{chrome_version}", "Google Chrome";v="{chrome_version}"'
|
| 117 |
+
|
| 118 |
+
headers.update({
|
| 119 |
+
"sec-ch-ua": sec_ch_ua,
|
| 120 |
+
"sec-ch-ua-mobile": "?0",
|
| 121 |
+
"sec-ch-ua-platform": '"Windows"',
|
| 122 |
+
"Sec-Fetch-Dest": "empty",
|
| 123 |
+
"Sec-Fetch-Mode": "cors",
|
| 124 |
+
"Sec-Fetch-Site": "same-origin",
|
| 125 |
+
})
|
| 126 |
+
|
| 127 |
+
# 添加额外的 headers
|
| 128 |
+
if additional_headers:
|
| 129 |
+
headers.update(additional_headers)
|
| 130 |
+
|
| 131 |
+
return headers
|
| 132 |
+
|
| 133 |
+
|
deploy/.dockerignore
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Git files
|
| 2 |
+
.git
|
| 3 |
+
.gitignore
|
| 4 |
+
.gitattributes
|
| 5 |
+
|
| 6 |
+
# Python cache
|
| 7 |
+
__pycache__
|
| 8 |
+
*.py[cod]
|
| 9 |
+
*$py.class
|
| 10 |
+
*.so
|
| 11 |
+
.Python
|
| 12 |
+
|
| 13 |
+
# Virtual environments
|
| 14 |
+
venv/
|
| 15 |
+
env/
|
| 16 |
+
ENV/
|
| 17 |
+
|
| 18 |
+
# IDE
|
| 19 |
+
.vscode/
|
| 20 |
+
.idea/
|
| 21 |
+
*.swp
|
| 22 |
+
*.swo
|
| 23 |
+
*~
|
| 24 |
+
|
| 25 |
+
# Documentation
|
| 26 |
+
*.md
|
| 27 |
+
!README.md
|
| 28 |
+
docs/
|
| 29 |
+
|
| 30 |
+
# Test files
|
| 31 |
+
tests/
|
| 32 |
+
pytest.ini
|
| 33 |
+
.pytest_cache/
|
| 34 |
+
|
| 35 |
+
# Local data (will be mounted as volumes)
|
| 36 |
+
*.db
|
| 37 |
+
*.sqlite
|
| 38 |
+
*.sqlite3
|
| 39 |
+
logs/
|
| 40 |
+
data/
|
| 41 |
+
|
| 42 |
+
# Build artifacts
|
| 43 |
+
build/
|
| 44 |
+
dist/
|
| 45 |
+
*.egg-info/
|
| 46 |
+
|
| 47 |
+
# Docker files in parent directory
|
| 48 |
+
Dockerfile
|
| 49 |
+
docker-compose.yml
|
| 50 |
+
.dockerignore
|
| 51 |
+
|
| 52 |
+
# Other
|
| 53 |
+
.env.local
|
| 54 |
+
.DS_Store
|
deploy/.env.example
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ==============================================
|
| 2 |
+
# Z.AI API Server - Docker 环境变量配置示例
|
| 3 |
+
# ==============================================
|
| 4 |
+
|
| 5 |
+
# 管理后台密码
|
| 6 |
+
ADMIN_PASSWORD=admin123
|
| 7 |
+
|
| 8 |
+
# API 认证密钥 (用于验证客户端请求)
|
| 9 |
+
AUTH_TOKEN=sk-your-api-key-here
|
| 10 |
+
|
| 11 |
+
# 是否跳过 API Key 验证 (开发环境可设为 true)
|
| 12 |
+
SKIP_AUTH_TOKEN=false
|
| 13 |
+
|
| 14 |
+
# 调试日志 (生产环境建议设为 false)
|
| 15 |
+
DEBUG_LOGGING=true
|
| 16 |
+
|
| 17 |
+
# 匿名模式 (允许无 token 访问,需要配合 SKIP_AUTH_TOKEN=true)
|
| 18 |
+
ANONYMOUS_MODE=false
|
| 19 |
+
|
| 20 |
+
# Function Call 功能开关 (是否支持工具调用)
|
| 21 |
+
TOOL_SUPPORT=true
|
| 22 |
+
|
| 23 |
+
# 工具调用扫描限制 (字符数)
|
| 24 |
+
SCAN_LIMIT=200000
|
| 25 |
+
|
| 26 |
+
# 数据库路径 (Docker 环境使用持久化卷)
|
| 27 |
+
DB_PATH=/app/data/tokens.db
|
| 28 |
+
|
| 29 |
+
# Token 池配置
|
| 30 |
+
TOKEN_FAILURE_THRESHOLD=3
|
| 31 |
+
TOKEN_RECOVERY_TIMEOUT=300
|
| 32 |
+
|
| 33 |
+
# 服务配置
|
| 34 |
+
SERVICE_NAME=Z.AI_API_Server
|
| 35 |
+
LISTEN_PORT=7860
|
deploy/Dockerfile
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
|
| 3 |
+
# Set working directory
|
| 4 |
+
WORKDIR /app
|
| 5 |
+
|
| 6 |
+
# Create data and logs directories with proper permissions
|
| 7 |
+
RUN mkdir -p /app/data /app/logs && \
|
| 8 |
+
chmod 755 /app/data /app/logs
|
| 9 |
+
|
| 10 |
+
# Install dependencies
|
| 11 |
+
COPY requirements.txt .
|
| 12 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 13 |
+
|
| 14 |
+
# Copy application code
|
| 15 |
+
COPY . .
|
| 16 |
+
|
| 17 |
+
# Set environment variable for database path
|
| 18 |
+
ENV DB_PATH=/app/data/tokens.db
|
| 19 |
+
|
| 20 |
+
# Expose port
|
| 21 |
+
EXPOSE 7860
|
| 22 |
+
|
| 23 |
+
# Run the application
|
| 24 |
+
CMD ["python", "main.py"]
|
deploy/NGINX_SETUP.md
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Nginx 反向代理部署指南
|
| 2 |
+
|
| 3 |
+
本文档说明如何在 Nginx 反向代理后部署 Z.AI2API,支持自定义路径前缀。
|
| 4 |
+
|
| 5 |
+
## 问题说明
|
| 6 |
+
|
| 7 |
+
在使用 Nginx 反向代理时,如果需要将服务部署在自定义路径前缀下(例如 `http://domain.com/ai2api`),
|
| 8 |
+
需要正确配置 `ROOT_PATH` 环境变量,否则会出现以下问题:
|
| 9 |
+
|
| 10 |
+
- 后台管理页面跳转错误(缺少路径前缀)
|
| 11 |
+
- API 接口请求 404(路径不完整)
|
| 12 |
+
- 静态资源加载失败
|
| 13 |
+
|
| 14 |
+
## 解决方案
|
| 15 |
+
|
| 16 |
+
### 1. 配置环境变量
|
| 17 |
+
|
| 18 |
+
在 `.env` 文件中设置 `ROOT_PATH` 变量,值为 Nginx 配置的 location 路径:
|
| 19 |
+
|
| 20 |
+
```bash
|
| 21 |
+
# 示例:部署在 /ai2api 路径下
|
| 22 |
+
ROOT_PATH=/ai2api
|
| 23 |
+
```
|
| 24 |
+
|
| 25 |
+
**重要**: `ROOT_PATH` 必须与 Nginx 配置中的 `location` 路径完全一致。
|
| 26 |
+
|
| 27 |
+
### 2. 配置 Nginx
|
| 28 |
+
|
| 29 |
+
参考 `deploy/nginx.conf.example` 文件,选择合适的配置模板。
|
| 30 |
+
|
| 31 |
+
#### 基础配置示例
|
| 32 |
+
|
| 33 |
+
```nginx
|
| 34 |
+
server {
|
| 35 |
+
listen 80;
|
| 36 |
+
server_name your-domain.com;
|
| 37 |
+
|
| 38 |
+
location /ai2api {
|
| 39 |
+
# 代理到后端服务
|
| 40 |
+
proxy_pass http://127.0.0.1:7860;
|
| 41 |
+
|
| 42 |
+
# 传递原始请求信息
|
| 43 |
+
proxy_set_header Host $host;
|
| 44 |
+
proxy_set_header X-Real-IP $remote_addr;
|
| 45 |
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
| 46 |
+
proxy_set_header X-Forwarded-Proto $scheme;
|
| 47 |
+
|
| 48 |
+
# SSE 流式响应支持
|
| 49 |
+
proxy_http_version 1.1;
|
| 50 |
+
proxy_set_header Upgrade $http_upgrade;
|
| 51 |
+
proxy_set_header Connection "upgrade";
|
| 52 |
+
proxy_buffering off;
|
| 53 |
+
proxy_cache off;
|
| 54 |
+
|
| 55 |
+
# 超时设置
|
| 56 |
+
proxy_read_timeout 300s;
|
| 57 |
+
}
|
| 58 |
+
}
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
### 3. Docker Compose 配置
|
| 62 |
+
|
| 63 |
+
如果使用 Docker 部署,需要在 `docker-compose.yml` 中添加 `ROOT_PATH` 环境变量:
|
| 64 |
+
|
| 65 |
+
```yaml
|
| 66 |
+
version: '3.8'
|
| 67 |
+
services:
|
| 68 |
+
ai2api:
|
| 69 |
+
image: z-ai2api:latest
|
| 70 |
+
environment:
|
| 71 |
+
- ROOT_PATH=/ai2api
|
| 72 |
+
- LISTEN_PORT=7860
|
| 73 |
+
# ... 其他环境变量
|
| 74 |
+
ports:
|
| 75 |
+
- "7860:7860"
|
| 76 |
+
```
|
| 77 |
+
|
| 78 |
+
### 4. 重启服务
|
| 79 |
+
|
| 80 |
+
```bash
|
| 81 |
+
# 重载 Nginx 配置
|
| 82 |
+
sudo nginx -t
|
| 83 |
+
sudo systemctl reload nginx
|
| 84 |
+
|
| 85 |
+
# 重启应用(Docker)
|
| 86 |
+
docker-compose restart
|
| 87 |
+
|
| 88 |
+
# 或重启应用(直接运行)
|
| 89 |
+
# 停止服务后重新启动
|
| 90 |
+
```
|
| 91 |
+
|
| 92 |
+
## 访问地址
|
| 93 |
+
|
| 94 |
+
配置完成后,服务访问地址如下:
|
| 95 |
+
|
| 96 |
+
- **API 端点**: `http://your-domain.com/ai2api/v1/chat/completions`
|
| 97 |
+
- **模型列表**: `http://your-domain.com/ai2api/v1/models`
|
| 98 |
+
- **管理后台**: `http://your-domain.com/ai2api/admin/login`
|
| 99 |
+
- **根路径**: `http://your-domain.com/ai2api/`
|
| 100 |
+
|
| 101 |
+
## 配置示例
|
| 102 |
+
|
| 103 |
+
### 示例 1: 部署在 /api 路径下
|
| 104 |
+
|
| 105 |
+
**.env 配置**:
|
| 106 |
+
```bash
|
| 107 |
+
ROOT_PATH=/api
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
**Nginx 配置**:
|
| 111 |
+
```nginx
|
| 112 |
+
location /api {
|
| 113 |
+
proxy_pass http://127.0.0.1:7860;
|
| 114 |
+
# ... 其他配置
|
| 115 |
+
}
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
**访问地址**: `http://domain.com/api/admin/login`
|
| 119 |
+
|
| 120 |
+
### 示例 2: 部署在根路径(无前缀)
|
| 121 |
+
|
| 122 |
+
**.env 配置**:
|
| 123 |
+
```bash
|
| 124 |
+
ROOT_PATH=
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
**Nginx 配置**:
|
| 128 |
+
```nginx
|
| 129 |
+
location / {
|
| 130 |
+
proxy_pass http://127.0.0.1:7860;
|
| 131 |
+
# ... 其他配置
|
| 132 |
+
}
|
| 133 |
+
```
|
| 134 |
+
|
| 135 |
+
**访问地址**: `http://domain.com/admin/login`
|
| 136 |
+
|
| 137 |
+
### 示例 3: 多级路径前缀
|
| 138 |
+
|
| 139 |
+
**.env 配置**:
|
| 140 |
+
```bash
|
| 141 |
+
ROOT_PATH=/services/ai/chat
|
| 142 |
+
```
|
| 143 |
+
|
| 144 |
+
**Nginx 配置**:
|
| 145 |
+
```nginx
|
| 146 |
+
location /services/ai/chat {
|
| 147 |
+
proxy_pass http://127.0.0.1:7860;
|
| 148 |
+
# ... 其他配置
|
| 149 |
+
}
|
| 150 |
+
```
|
| 151 |
+
|
| 152 |
+
**访问地址**: `http://domain.com/services/ai/chat/admin/login`
|
| 153 |
+
|
| 154 |
+
## 常见问题排查
|
| 155 |
+
|
| 156 |
+
### 1. 404 错误
|
| 157 |
+
|
| 158 |
+
**现象**: 访问页面或 API 时返回 404
|
| 159 |
+
|
| 160 |
+
**可能原因**:
|
| 161 |
+
- `ROOT_PATH` 配置与 Nginx location 路径不匹配
|
| 162 |
+
- Nginx 配置错误或未重载
|
| 163 |
+
|
| 164 |
+
**解决方法**:
|
| 165 |
+
- 检查 `.env` 中的 `ROOT_PATH` 是否与 Nginx `location` 完全一致
|
| 166 |
+
- 确认 Nginx 配置无误: `sudo nginx -t`
|
| 167 |
+
- 重载 Nginx: `sudo systemctl reload nginx`
|
| 168 |
+
- 重启应用服务
|
| 169 |
+
|
| 170 |
+
### 2. 静态资源加载失败
|
| 171 |
+
|
| 172 |
+
**现象**: 管理后台页面样式错乱,控制台显示 CSS/JS 404
|
| 173 |
+
|
| 174 |
+
**可能原因**:
|
| 175 |
+
- `ROOT_PATH` 未配置或配置错误
|
| 176 |
+
- 静态文件路径未包含前缀
|
| 177 |
+
|
| 178 |
+
**解决方法**:
|
| 179 |
+
- 确保 `ROOT_PATH` 正确配置并重启服务
|
| 180 |
+
- 检查浏览器开发者工具中的资源请求路径
|
| 181 |
+
|
| 182 |
+
### 3. 流式响应中断
|
| 183 |
+
|
| 184 |
+
**现象**: SSE 流式响应提前终止或无法正常工作
|
| 185 |
+
|
| 186 |
+
**可能原因**:
|
| 187 |
+
- Nginx 启用了缓冲
|
| 188 |
+
- 超时时间设置过短
|
| 189 |
+
|
| 190 |
+
**解决方法**:
|
| 191 |
+
在 Nginx 配置中添加:
|
| 192 |
+
```nginx
|
| 193 |
+
proxy_buffering off;
|
| 194 |
+
proxy_cache off;
|
| 195 |
+
proxy_read_timeout 300s;
|
| 196 |
+
```
|
| 197 |
+
|
| 198 |
+
### 4. CORS 错误
|
| 199 |
+
|
| 200 |
+
**现象**: 浏览器控制台显示跨域请求被阻止
|
| 201 |
+
|
| 202 |
+
**可能原因**:
|
| 203 |
+
- Nginx 未正确传递请求头
|
| 204 |
+
|
| 205 |
+
**解决方法**:
|
| 206 |
+
确保 Nginx 配置中包含:
|
| 207 |
+
```nginx
|
| 208 |
+
proxy_set_header Host $host;
|
| 209 |
+
proxy_set_header X-Forwarded-Proto $scheme;
|
| 210 |
+
```
|
| 211 |
+
|
| 212 |
+
## 验证配置
|
| 213 |
+
|
| 214 |
+
配置完成后,可以通过以下方式验证:
|
| 215 |
+
|
| 216 |
+
1. **访问健康检查端点**:
|
| 217 |
+
```bash
|
| 218 |
+
curl http://your-domain.com/ai2api/v1/models
|
| 219 |
+
```
|
| 220 |
+
|
| 221 |
+
2. **访问管理后台**:
|
| 222 |
+
在浏览器打开 `http://your-domain.com/ai2api/admin/login`
|
| 223 |
+
|
| 224 |
+
3. **测试 API 请求**:
|
| 225 |
+
```bash
|
| 226 |
+
curl -X POST http://your-domain.com/ai2api/v1/chat/completions \
|
| 227 |
+
-H "Content-Type: application/json" \
|
| 228 |
+
-H "Authorization: Bearer your-api-key" \
|
| 229 |
+
-d '{
|
| 230 |
+
"model": "GLM-4.6",
|
| 231 |
+
"messages": [{"role": "user", "content": "Hello"}],
|
| 232 |
+
"stream": false
|
| 233 |
+
}'
|
| 234 |
+
```
|
| 235 |
+
|
| 236 |
+
## 进阶配置
|
| 237 |
+
|
| 238 |
+
### HTTPS 配置
|
| 239 |
+
|
| 240 |
+
```nginx
|
| 241 |
+
server {
|
| 242 |
+
listen 443 ssl http2;
|
| 243 |
+
server_name your-domain.com;
|
| 244 |
+
|
| 245 |
+
ssl_certificate /path/to/cert.pem;
|
| 246 |
+
ssl_certificate_key /path/to/key.pem;
|
| 247 |
+
|
| 248 |
+
location /ai2api {
|
| 249 |
+
proxy_pass http://127.0.0.1:7860;
|
| 250 |
+
proxy_set_header X-Forwarded-Proto https;
|
| 251 |
+
# ... 其他配置
|
| 252 |
+
}
|
| 253 |
+
}
|
| 254 |
+
```
|
| 255 |
+
|
| 256 |
+
### 负载均衡
|
| 257 |
+
|
| 258 |
+
```nginx
|
| 259 |
+
upstream ai2api_backend {
|
| 260 |
+
server 127.0.0.1:7860;
|
| 261 |
+
server 127.0.0.1:8081;
|
| 262 |
+
server 127.0.0.1:8082;
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
server {
|
| 266 |
+
listen 80;
|
| 267 |
+
location /ai2api {
|
| 268 |
+
proxy_pass http://ai2api_backend;
|
| 269 |
+
# ... 其他配置
|
| 270 |
+
}
|
| 271 |
+
}
|
| 272 |
+
```
|
| 273 |
+
|
| 274 |
+
## 参考资料
|
| 275 |
+
|
| 276 |
+
- [FastAPI Behind a Proxy](https://fastapi.tiangolo.com/advanced/behind-a-proxy/)
|
| 277 |
+
- [Nginx Proxy Module](http://nginx.org/en/docs/http/ngx_http_proxy_module.html)
|
| 278 |
+
- 完整配置示例: `deploy/nginx.conf.example`
|
deploy/README_DOCKER.md
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Docker 部署文档
|
| 2 |
+
|
| 3 |
+
## 快速部署
|
| 4 |
+
|
| 5 |
+
### 方式一: 使用预构建镜像 (推荐)
|
| 6 |
+
|
| 7 |
+
从 Docker Hub 拉取镜像:
|
| 8 |
+
|
| 9 |
+
```bash
|
| 10 |
+
# 拉取最新镜像
|
| 11 |
+
docker pull zyphrzero/z-ai2api-python:latest
|
| 12 |
+
|
| 13 |
+
# 创建数据目录
|
| 14 |
+
mkdir -p data logs
|
| 15 |
+
|
| 16 |
+
# 快速启动
|
| 17 |
+
docker run -d \
|
| 18 |
+
--name z-ai-api-server \
|
| 19 |
+
-p 7860:7860 \
|
| 20 |
+
-e ADMIN_PASSWORD=admin123 \
|
| 21 |
+
-e AUTH_TOKEN=sk-your-api-key \
|
| 22 |
+
-e ANONYMOUS_MODE=true \
|
| 23 |
+
-e DB_PATH=/app/data/tokens.db \
|
| 24 |
+
-v $(pwd)/data:/app/data \
|
| 25 |
+
-v $(pwd)/logs:/app/logs \
|
| 26 |
+
--restart unless-stopped \
|
| 27 |
+
zyphrzero/z-ai2api-python:latest
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
**优势**:
|
| 31 |
+
- ✅ 无需本地构建,节省时间
|
| 32 |
+
- ✅ GitHub Actions 自动化构建,保证质量
|
| 33 |
+
- ✅ 多架构支持 (amd64/arm64)
|
| 34 |
+
- ✅ 镜像已优化,体积更小
|
| 35 |
+
|
| 36 |
+
### 方式二: 使用本地构建
|
| 37 |
+
|
| 38 |
+
适用于需要自定义修改代码的场景:
|
| 39 |
+
|
| 40 |
+
```bash
|
| 41 |
+
# 进入部署目录
|
| 42 |
+
cd deploy
|
| 43 |
+
|
| 44 |
+
# 启动服务 (会自动构建镜像)
|
| 45 |
+
docker compose up -d
|
| 46 |
+
|
| 47 |
+
# 查看日志
|
| 48 |
+
docker compose logs -f api-server
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
服务将在 `http://localhost:7860` 启动。
|
| 52 |
+
|
| 53 |
+
## 架构说明
|
| 54 |
+
|
| 55 |
+
### 持久化存储
|
| 56 |
+
|
| 57 |
+
容器使用卷映射实现数据持久化:
|
| 58 |
+
|
| 59 |
+
```yaml
|
| 60 |
+
volumes:
|
| 61 |
+
- ./data:/app/data # 数据库存储 (tokens.db)
|
| 62 |
+
- ./logs:/app/logs # 应用日志
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
**目录结构**:
|
| 66 |
+
```
|
| 67 |
+
deploy/
|
| 68 |
+
├── data/
|
| 69 |
+
│ └── tokens.db # SQLite 数据库 (自动创建)
|
| 70 |
+
├── logs/ # 应用日志 (自动创建)
|
| 71 |
+
├── docker-compose.yml
|
| 72 |
+
├── Dockerfile
|
| 73 |
+
└── README_DOCKER.md
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
### 环境变量
|
| 77 |
+
|
| 78 |
+
核心配置参数 (在 `docker-compose.yml` 中设置):
|
| 79 |
+
|
| 80 |
+
| 变量 | 默认值 | 说明 |
|
| 81 |
+
|------|--------|------|
|
| 82 |
+
| `DB_PATH` | `/app/data/tokens.db` | 数据库文件路径 |
|
| 83 |
+
| `ADMIN_PASSWORD` | `admin123` | 管理后台密码 |
|
| 84 |
+
| `AUTH_TOKEN` | `sk-your-api-key` | API 认证密钥 |
|
| 85 |
+
| `SKIP_AUTH_TOKEN` | `false` | 跳过 API 验证 |
|
| 86 |
+
| `ANONYMOUS_MODE` | `true` | 匿名访问模式 |
|
| 87 |
+
| `DEBUG_LOGGING` | `true` | 调试日志开关 |
|
| 88 |
+
| `TOOL_SUPPORT` | `true` | Function Call 支持 |
|
| 89 |
+
|
| 90 |
+
**生产环境建议**:
|
| 91 |
+
- 修改 `ADMIN_PASSWORD` 和 `AUTH_TOKEN`
|
| 92 |
+
- 设置 `DEBUG_LOGGING=false`
|
| 93 |
+
- 设置 `ANONYMOUS_MODE=false`
|
| 94 |
+
|
| 95 |
+
## 运维操作
|
| 96 |
+
|
| 97 |
+
### 服务管理
|
| 98 |
+
|
| 99 |
+
```bash
|
| 100 |
+
# 启动服务
|
| 101 |
+
docker compose up -d
|
| 102 |
+
|
| 103 |
+
# 停止服务
|
| 104 |
+
docker compose down
|
| 105 |
+
|
| 106 |
+
# 重启服务
|
| 107 |
+
docker compose restart
|
| 108 |
+
|
| 109 |
+
# 查看状态
|
| 110 |
+
docker compose ps
|
| 111 |
+
|
| 112 |
+
# 实时日志
|
| 113 |
+
docker compose logs -f
|
| 114 |
+
```
|
| 115 |
+
|
| 116 |
+
### 更新应用
|
| 117 |
+
|
| 118 |
+
**使用预构建镜像**:
|
| 119 |
+
|
| 120 |
+
```bash
|
| 121 |
+
# 停止当前容器
|
| 122 |
+
docker compose down
|
| 123 |
+
|
| 124 |
+
# 拉取最新镜像
|
| 125 |
+
docker pull zyphrzero/z-ai2api-python:latest
|
| 126 |
+
|
| 127 |
+
# 启动新版本 (数据会自动保留)
|
| 128 |
+
docker compose up -d
|
| 129 |
+
|
| 130 |
+
# 清理旧镜像
|
| 131 |
+
docker image prune -f
|
| 132 |
+
```
|
| 133 |
+
|
| 134 |
+
**使用本地构建**:
|
| 135 |
+
|
| 136 |
+
```bash
|
| 137 |
+
# 拉取最新代码
|
| 138 |
+
git pull
|
| 139 |
+
|
| 140 |
+
# 重新构建并启动 (数据会保留)
|
| 141 |
+
docker compose up -d --build
|
| 142 |
+
|
| 143 |
+
# 清理旧镜像
|
| 144 |
+
docker image prune -f
|
| 145 |
+
```
|
| 146 |
+
|
| 147 |
+
### 数据备份与恢复
|
| 148 |
+
|
| 149 |
+
**备份**:
|
| 150 |
+
```bash
|
| 151 |
+
# 备份数据库
|
| 152 |
+
cp ./data/tokens.db ./data/tokens.db.backup.$(date +%Y%m%d_%H%M%S)
|
| 153 |
+
|
| 154 |
+
# 完整备份
|
| 155 |
+
tar -czf backup_$(date +%Y%m%d_%H%M%S).tar.gz ./data ./logs
|
| 156 |
+
```
|
| 157 |
+
|
| 158 |
+
**恢复**:
|
| 159 |
+
```bash
|
| 160 |
+
# 停止服务
|
| 161 |
+
docker compose down
|
| 162 |
+
|
| 163 |
+
# 恢复数据库
|
| 164 |
+
cp ./data/tokens.db.backup.20250116_120000 ./data/tokens.db
|
| 165 |
+
|
| 166 |
+
# 启动服务
|
| 167 |
+
docker compose up -d
|
| 168 |
+
```
|
| 169 |
+
|
| 170 |
+
### 数据库迁移
|
| 171 |
+
|
| 172 |
+
如需从其他位置迁移现有数据库:
|
| 173 |
+
|
| 174 |
+
```bash
|
| 175 |
+
# 使用迁移脚本
|
| 176 |
+
./migrate_db.sh /path/to/existing/tokens.db
|
| 177 |
+
|
| 178 |
+
# 或手动复制
|
| 179 |
+
cp /opt/1panel/docker/compose/k2think/tokens.db ./data/
|
| 180 |
+
chmod 644 ./data/tokens.db
|
| 181 |
+
|
| 182 |
+
# 启动服务
|
| 183 |
+
docker compose up -d
|
| 184 |
+
```
|
| 185 |
+
|
| 186 |
+
## 故障排查
|
| 187 |
+
|
| 188 |
+
### 数据库初始化失败
|
| 189 |
+
|
| 190 |
+
**错误**: `unable to open database file`
|
| 191 |
+
|
| 192 |
+
**原因**: 目录权限或卷映射问题
|
| 193 |
+
|
| 194 |
+
**解决**:
|
| 195 |
+
```bash
|
| 196 |
+
# 停止容器
|
| 197 |
+
docker compose down
|
| 198 |
+
|
| 199 |
+
# 确保目录存在
|
| 200 |
+
mkdir -p ./data ./logs
|
| 201 |
+
|
| 202 |
+
# 设置权限
|
| 203 |
+
chmod 755 ./data ./logs
|
| 204 |
+
|
| 205 |
+
# 重新构建并启动
|
| 206 |
+
docker compose up -d --build
|
| 207 |
+
```
|
| 208 |
+
|
| 209 |
+
### 容器无法启动
|
| 210 |
+
|
| 211 |
+
**检查步骤**:
|
| 212 |
+
```bash
|
| 213 |
+
# 查看详细日志
|
| 214 |
+
docker compose logs api-server
|
| 215 |
+
|
| 216 |
+
# 检查容器状态
|
| 217 |
+
docker compose ps
|
| 218 |
+
|
| 219 |
+
# 验证配置文件
|
| 220 |
+
docker compose config
|
| 221 |
+
```
|
| 222 |
+
|
| 223 |
+
### 端口冲突
|
| 224 |
+
|
| 225 |
+
如端口 7860 被占用,修改 `docker-compose.yml`:
|
| 226 |
+
```yaml
|
| 227 |
+
ports:
|
| 228 |
+
- "8081:7860" # 映射到宿主机 8081 端口
|
| 229 |
+
```
|
| 230 |
+
|
| 231 |
+
### 健康检查失败
|
| 232 |
+
|
| 233 |
+
```bash
|
| 234 |
+
# 检查健康状态
|
| 235 |
+
docker compose ps
|
| 236 |
+
|
| 237 |
+
# 手动测试接口
|
| 238 |
+
curl http://localhost:7860/v1/models
|
| 239 |
+
|
| 240 |
+
# 进入容器排查
|
| 241 |
+
docker exec -it z-ai-api-server bash
|
| 242 |
+
```
|
| 243 |
+
|
| 244 |
+
## API 访问
|
| 245 |
+
|
| 246 |
+
| 端点 | 地址 | 说明 |
|
| 247 |
+
|------|------|------|
|
| 248 |
+
| API 根路径 | `http://localhost:7860` | OpenAI 兼容 API |
|
| 249 |
+
| 模型列表 | `http://localhost:7860/v1/models` | 获取可用模型 |
|
| 250 |
+
| 管理后台 | `http://localhost:7860/admin` | Web 管理界面 |
|
| 251 |
+
| API 文档 | `http://localhost:7860/docs` | OpenAPI/Swagger 文档 |
|
| 252 |
+
| 健康检查 | `http://localhost:7860/v1/models` | 服务健康状态 |
|
| 253 |
+
|
| 254 |
+
## 高级配置
|
| 255 |
+
|
| 256 |
+
### 自定义数据库路径
|
| 257 |
+
|
| 258 |
+
修改 `docker-compose.yml` 使用��部路径:
|
| 259 |
+
|
| 260 |
+
```yaml
|
| 261 |
+
volumes:
|
| 262 |
+
- /opt/mydata:/app/data # 使用绝对路径
|
| 263 |
+
|
| 264 |
+
environment:
|
| 265 |
+
- DB_PATH=/app/data/tokens.db
|
| 266 |
+
```
|
| 267 |
+
|
| 268 |
+
### 使用 .env 文件
|
| 269 |
+
|
| 270 |
+
创建 `.env` 文件 (基于 `.env.example`):
|
| 271 |
+
|
| 272 |
+
```bash
|
| 273 |
+
cp .env.example .env
|
| 274 |
+
# 编辑配置
|
| 275 |
+
vim .env
|
| 276 |
+
```
|
| 277 |
+
|
| 278 |
+
修改 `docker-compose.yml`:
|
| 279 |
+
```yaml
|
| 280 |
+
services:
|
| 281 |
+
api-server:
|
| 282 |
+
env_file: .env
|
| 283 |
+
```
|
| 284 |
+
|
| 285 |
+
### 启用日志轮转
|
| 286 |
+
|
| 287 |
+
在生产环境配置 Docker 日志驱动:
|
| 288 |
+
|
| 289 |
+
```yaml
|
| 290 |
+
services:
|
| 291 |
+
api-server:
|
| 292 |
+
logging:
|
| 293 |
+
driver: "json-file"
|
| 294 |
+
options:
|
| 295 |
+
max-size: "10m"
|
| 296 |
+
max-file: "3"
|
| 297 |
+
```
|
| 298 |
+
|
| 299 |
+
### 资源限制
|
| 300 |
+
|
| 301 |
+
限制容器资源使用:
|
| 302 |
+
|
| 303 |
+
```yaml
|
| 304 |
+
services:
|
| 305 |
+
api-server:
|
| 306 |
+
deploy:
|
| 307 |
+
resources:
|
| 308 |
+
limits:
|
| 309 |
+
cpus: '2'
|
| 310 |
+
memory: 2G
|
| 311 |
+
reservations:
|
| 312 |
+
cpus: '0.5'
|
| 313 |
+
memory: 512M
|
| 314 |
+
```
|
| 315 |
+
|
| 316 |
+
## 监控与日志
|
| 317 |
+
|
| 318 |
+
### 查看日志
|
| 319 |
+
|
| 320 |
+
```bash
|
| 321 |
+
# 实时日志
|
| 322 |
+
docker compose logs -f
|
| 323 |
+
|
| 324 |
+
# 最近100行
|
| 325 |
+
docker compose logs --tail=100
|
| 326 |
+
|
| 327 |
+
# 特定时间段
|
| 328 |
+
docker compose logs --since 30m
|
| 329 |
+
|
| 330 |
+
# 导出日志
|
| 331 |
+
docker compose logs > app.log
|
| 332 |
+
```
|
| 333 |
+
|
| 334 |
+
### 容器指标
|
| 335 |
+
|
| 336 |
+
```bash
|
| 337 |
+
# 资源使用情况
|
| 338 |
+
docker stats z-ai-api-server
|
| 339 |
+
|
| 340 |
+
# 容器详情
|
| 341 |
+
docker inspect z-ai-api-server
|
| 342 |
+
```
|
| 343 |
+
|
| 344 |
+
## 安全建议
|
| 345 |
+
|
| 346 |
+
1. **修改默认密码**: 更改 `ADMIN_PASSWORD` 和 `AUTH_TOKEN`
|
| 347 |
+
2. **限制网络访问**: 生产环境使用反向代理 (Nginx/Caddy)
|
| 348 |
+
3. **启用 HTTPS**: 配置 SSL 证书
|
| 349 |
+
4. **定期备份**: 自动化数据库备份任务
|
| 350 |
+
5. **日志审计**: 定期检查 `request_logs` 表
|
| 351 |
+
6. **最小权限**: 避免以 root 运行容器
|
| 352 |
+
|
| 353 |
+
## 参考资料
|
| 354 |
+
|
| 355 |
+
- [Docker Compose 文档](https://docs.docker.com/compose/)
|
| 356 |
+
- [项目主 README](../README.md)
|
| 357 |
+
- [配置示例](.env.example)
|