Spaces:
Sleeping
Sleeping
Gemini CLI commited on
Commit ·
7864524
0
Parent(s):
Configure for Hugging Face Spaces
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +72 -0
- .gitattributes +2 -0
- .github/workflows/docker.yml +64 -0
- .gitignore +181 -0
- Dockerfile +30 -0
- LICENSE +21 -0
- README.md +132 -0
- README_EN.md +123 -0
- app/__init__.py +6 -0
- app/admin/__init__.py +3 -0
- app/admin/api.py +1111 -0
- app/admin/auth.py +129 -0
- app/admin/config_manager.py +682 -0
- app/admin/routes.py +109 -0
- app/admin/stats.py +184 -0
- app/core/__init__.py +6 -0
- app/core/claude.py +582 -0
- app/core/claude_compat.py +352 -0
- app/core/config.py +95 -0
- app/core/openai.py +224 -0
- app/core/openai_compat.py +139 -0
- app/core/upstream.py +2245 -0
- app/models/__init__.py +6 -0
- app/models/request_log.py +35 -0
- app/models/schemas.py +166 -0
- app/models/token_db.py +44 -0
- app/services/request_log_dao.py +630 -0
- app/services/token_automation.py +278 -0
- app/services/token_dao.py +664 -0
- app/services/token_importer.py +138 -0
- app/templates/base.html +201 -0
- app/templates/components/recent_logs.html +128 -0
- app/templates/components/token_list.html +114 -0
- app/templates/components/token_pool.html +40 -0
- app/templates/components/token_row.html +154 -0
- app/templates/components/token_stats.html +125 -0
- app/templates/config.html +344 -0
- app/templates/index.html +588 -0
- app/templates/login.html +143 -0
- app/templates/logs.html +59 -0
- app/templates/tokens.html +487 -0
- app/utils/__init__.py +6 -0
- app/utils/env_file.py +59 -0
- app/utils/fe_version.py +112 -0
- app/utils/guest_session_pool.py +646 -0
- app/utils/logger.py +105 -0
- app/utils/reload_config.py +94 -0
- app/utils/request_logging.py +337 -0
- app/utils/request_source.py +129 -0
- app/utils/signature.py +56 -0
.env.example
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 代理服务配置文件示例
|
| 2 |
+
# 复制此文件为 .env 并根据需要修改配置值
|
| 3 |
+
|
| 4 |
+
# ========== API 基础配置 ==========
|
| 5 |
+
# 客户端访问本服务使用的 Bearer 密钥,不是上游 Z.AI 用户 Token
|
| 6 |
+
# 上游用户 Token 请在管理后台导入,由数据库 Token 池统一管理
|
| 7 |
+
AUTH_TOKEN=sk-your-api-key
|
| 8 |
+
|
| 9 |
+
# 跳过客户端认证(仅开发环境使用)
|
| 10 |
+
SKIP_AUTH_TOKEN=false
|
| 11 |
+
|
| 12 |
+
# ========== 用户 Token 池配置 ==========
|
| 13 |
+
# 仅作用于管理后台导入的 Z.AI 用户 Token
|
| 14 |
+
# 失败多少次后标记为不可用
|
| 15 |
+
TOKEN_FAILURE_THRESHOLD=3
|
| 16 |
+
|
| 17 |
+
# 失败 Token 多久后重新参与调度(秒)
|
| 18 |
+
TOKEN_RECOVERY_TIMEOUT=1800
|
| 19 |
+
|
| 20 |
+
# 定时扫描服务端目录导入 Token
|
| 21 |
+
TOKEN_AUTO_IMPORT_ENABLED=false
|
| 22 |
+
|
| 23 |
+
# 自动导入的服务端本地目录
|
| 24 |
+
TOKEN_AUTO_IMPORT_SOURCE_DIR=
|
| 25 |
+
|
| 26 |
+
# 自动导入扫描间隔(秒)
|
| 27 |
+
TOKEN_AUTO_IMPORT_INTERVAL=300
|
| 28 |
+
|
| 29 |
+
# 定时维护 Token 池
|
| 30 |
+
TOKEN_AUTO_MAINTENANCE_ENABLED=false
|
| 31 |
+
|
| 32 |
+
# 自动维护执行间隔(秒)
|
| 33 |
+
TOKEN_AUTO_MAINTENANCE_INTERVAL=1800
|
| 34 |
+
|
| 35 |
+
# 自动维护动作开关
|
| 36 |
+
TOKEN_AUTO_REMOVE_DUPLICATES=true
|
| 37 |
+
TOKEN_AUTO_HEALTH_CHECK=true
|
| 38 |
+
TOKEN_AUTO_DELETE_INVALID=false
|
| 39 |
+
|
| 40 |
+
# ========== 匿名 Guest 会话池 ==========
|
| 41 |
+
# false: 禁用 guest 匿名池,仅使用后台导入的用户 Token 池
|
| 42 |
+
# true: 启用 guest 匿名池;当没有可用用户 Token 时允许匿名会话
|
| 43 |
+
ANONYMOUS_MODE=true
|
| 44 |
+
|
| 45 |
+
# 预热和维持的 guest 会话数量
|
| 46 |
+
GUEST_POOL_SIZE=10
|
| 47 |
+
|
| 48 |
+
# ========== 服务器配置 ==========
|
| 49 |
+
LISTEN_PORT=8080
|
| 50 |
+
SERVICE_NAME=api-proxy-server
|
| 51 |
+
DEBUG_LOGGING=false
|
| 52 |
+
|
| 53 |
+
# Nginx 反向代理路径前缀(可选)
|
| 54 |
+
ROOT_PATH=
|
| 55 |
+
|
| 56 |
+
# Function Call 功能开关
|
| 57 |
+
TOOL_SUPPORT=true
|
| 58 |
+
|
| 59 |
+
# 工具调用扫描限制(字符数)
|
| 60 |
+
SCAN_LIMIT=200000
|
| 61 |
+
|
| 62 |
+
# SQLite 数据库路径
|
| 63 |
+
DB_PATH=tokens.db
|
| 64 |
+
|
| 65 |
+
# ========== 代理配置 ==========
|
| 66 |
+
# HTTP_PROXY=http://127.0.0.1:7890
|
| 67 |
+
# HTTPS_PROXY=http://127.0.0.1:7890
|
| 68 |
+
# SOCKS5_PROXY=socks5://127.0.0.1:1080
|
| 69 |
+
|
| 70 |
+
# ========== 管理后台认证 ==========
|
| 71 |
+
ADMIN_PASSWORD=admin123
|
| 72 |
+
SESSION_SECRET_KEY=your-secret-key-change-in-production
|
.gitattributes
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 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,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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/
|
| 181 |
+
.ace-tool/
|
Dockerfile
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
|
| 3 |
+
# Set environment variables
|
| 4 |
+
ENV LISTEN_PORT=7860
|
| 5 |
+
ENV DB_PATH=/app/data/tokens.db
|
| 6 |
+
ENV PYTHONUNBUFFERED=1
|
| 7 |
+
|
| 8 |
+
# Set working directory
|
| 9 |
+
WORKDIR /app
|
| 10 |
+
|
| 11 |
+
# Create data and logs directories and set permissions
|
| 12 |
+
# HF Spaces runs as user 1000, so we make sure it can write to these directories
|
| 13 |
+
RUN mkdir -p /app/data /app/logs && \
|
| 14 |
+
chmod -R 777 /app/data /app/logs
|
| 15 |
+
|
| 16 |
+
# Install dependencies
|
| 17 |
+
COPY requirements.txt .
|
| 18 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 19 |
+
|
| 20 |
+
# Copy application code
|
| 21 |
+
COPY . .
|
| 22 |
+
|
| 23 |
+
# Ensure all files are accessible
|
| 24 |
+
RUN chmod -R 777 /app
|
| 25 |
+
|
| 26 |
+
# Expose port
|
| 27 |
+
EXPOSE 7860
|
| 28 |
+
|
| 29 |
+
# Run the application
|
| 30 |
+
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.
|
README.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Z.ai API
|
| 3 |
+
emoji: 🚀
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
# z-ai2api_python
|
| 11 |
+
|
| 12 |
+
基于 FastAPI + Granian 的 GLM 代理服务
|
| 13 |
+
适合本地开发、自托管代理、Token 池管理和兼容客户端接入
|
| 14 |
+
|
| 15 |
+
中文简体 / [English](README_EN.md)
|
| 16 |
+
|
| 17 |
+
## 特性
|
| 18 |
+
|
| 19 |
+
- 兼容 `OpenAI`、`Claude Code`、`Anthropic` 风格请求
|
| 20 |
+
- 支持流式响应、工具调用、Thinking 模型
|
| 21 |
+
- 内置 Token 池,支持轮询、失败熔断、恢复和健康检查
|
| 22 |
+
- 提供后台页面:仪表盘、Token 管理、配置管理、实时日志
|
| 23 |
+
- 使用 SQLite 存储 Token 和请求日志,部署简单
|
| 24 |
+
- 支持本地运行和 Docker / Docker Compose 部署
|
| 25 |
+
|
| 26 |
+
## 快速开始
|
| 27 |
+
|
| 28 |
+
### 环境要求
|
| 29 |
+
|
| 30 |
+
- Python `3.9` 到 `3.12`
|
| 31 |
+
- 推荐使用 `uv`
|
| 32 |
+
|
| 33 |
+
### 本地启动
|
| 34 |
+
|
| 35 |
+
```bash
|
| 36 |
+
git clone https://github.com/ZyphrZero/z.ai2api_python.git
|
| 37 |
+
cd z.ai2api_python
|
| 38 |
+
|
| 39 |
+
uv sync
|
| 40 |
+
cp .env.example .env
|
| 41 |
+
uv run python main.py
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
首次启动会自动初始化数据库。
|
| 45 |
+
|
| 46 |
+
默认地址:
|
| 47 |
+
|
| 48 |
+
- API 根路径:`http://127.0.0.1:8080`
|
| 49 |
+
- OpenAI 文档:`http://127.0.0.1:8080/docs`
|
| 50 |
+
- 管理后台:`http://127.0.0.1:8080/admin`
|
| 51 |
+
|
| 52 |
+
### Docker Compose
|
| 53 |
+
|
| 54 |
+
```bash
|
| 55 |
+
docker compose -f deploy/docker-compose.yml up -d --build
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
更多部署说明见 [deploy/README_DOCKER.md](deploy/README_DOCKER.md)。
|
| 59 |
+
|
| 60 |
+
## 最小配置
|
| 61 |
+
|
| 62 |
+
至少建议确认这些环境变量:
|
| 63 |
+
|
| 64 |
+
| 变量 | 说明 |
|
| 65 |
+
| --- | --- |
|
| 66 |
+
| `AUTH_TOKEN` | 客户端访问本服务使用的 Bearer Token |
|
| 67 |
+
| `ADMIN_PASSWORD` | 管理后台登录密码,默认值必须修改 |
|
| 68 |
+
| `LISTEN_PORT` | 服务监听端口,默认 `8080` |
|
| 69 |
+
| `ANONYMOUS_MODE` | 是否启用匿名模式 |
|
| 70 |
+
| `GUEST_POOL_SIZE` | 匿名池容量 |
|
| 71 |
+
| `DB_PATH` | SQLite 数据库路径 |
|
| 72 |
+
| `TOKEN_FAILURE_THRESHOLD` | Token 连续失败阈值 |
|
| 73 |
+
| `TOKEN_RECOVERY_TIMEOUT` | Token 恢复等待时间 |
|
| 74 |
+
|
| 75 |
+
完整配置请看 [.env.example](.env.example)。
|
| 76 |
+
|
| 77 |
+
## 管理后台
|
| 78 |
+
|
| 79 |
+
管理后台统一入口:
|
| 80 |
+
|
| 81 |
+
- `/admin`:仪表盘
|
| 82 |
+
- `/admin/tokens`:Token 管理
|
| 83 |
+
- `/admin/config`:配置管理
|
| 84 |
+
- `/admin/logs`:实时日志
|
| 85 |
+
|
| 86 |
+
## 常用命令
|
| 87 |
+
|
| 88 |
+
```bash
|
| 89 |
+
# 启动服务
|
| 90 |
+
uv run python main.py
|
| 91 |
+
|
| 92 |
+
# 运行测试
|
| 93 |
+
uv run pytest
|
| 94 |
+
|
| 95 |
+
# 运行一个现有 smoke test
|
| 96 |
+
uv run python tests/test_simple_signature.py
|
| 97 |
+
|
| 98 |
+
# Lint
|
| 99 |
+
uv run ruff check app tests main.py
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
## 兼容接口
|
| 103 |
+
|
| 104 |
+
常见接口入口:
|
| 105 |
+
|
| 106 |
+
- OpenAI 兼容:`/v1/chat/completions`
|
| 107 |
+
- Anthropic 兼容:`/v1/messages`
|
| 108 |
+
- Claude Code 兼容:`/anthropic/v1/messages`
|
| 109 |
+
|
| 110 |
+
模型映射和默认模型可在 `.env` 或后台配置页中调整。
|
| 111 |
+
|
| 112 |
+
## ⭐ Star History
|
| 113 |
+
|
| 114 |
+
[](https://star-history.com/#ZyphrZero/z.ai2api_python&Date)
|
| 115 |
+
|
| 116 |
+
## 许可证
|
| 117 |
+
|
| 118 |
+
本项目采用 MIT 许可证 - 详见 [LICENSE](LICENSE) 文件。
|
| 119 |
+
|
| 120 |
+
## 免责声明
|
| 121 |
+
|
| 122 |
+
- **本项目仅供学习和研究使用,切勿用于其他用途**
|
| 123 |
+
- 本项目与 Z.AI 官方无关
|
| 124 |
+
- 使用前请确保遵守 Z.AI 的服务条款
|
| 125 |
+
- 请勿用于商业用途或违反使用条款的场景
|
| 126 |
+
- 用户需自行承担使用风险
|
| 127 |
+
|
| 128 |
+
---
|
| 129 |
+
|
| 130 |
+
<div align="center">
|
| 131 |
+
Made with ❤️ by the community
|
| 132 |
+
</div>
|
README_EN.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# z-ai2api_python
|
| 2 |
+
|
| 3 |
+
GLM proxy service based on FastAPI + Granian
|
| 4 |
+
Suitable for local development, self-hosted proxy, Token pool management, and compatible client access
|
| 5 |
+
|
| 6 |
+
English / [中文简体](README.md)
|
| 7 |
+
|
| 8 |
+
## Features
|
| 9 |
+
|
| 10 |
+
- Compatible with `OpenAI`, `Claude Code`, `Anthropic` style requests
|
| 11 |
+
- Supports streaming responses, tool calls, Thinking models
|
| 12 |
+
- Built-in Token pool, supports polling, failure circuit breaker, recovery, and health checks
|
| 13 |
+
- Provides admin panel: Dashboard, Token management, Configuration management, Real-time logs
|
| 14 |
+
- Uses SQLite to store Tokens and request logs, simple deployment
|
| 15 |
+
- Supports local running and Docker / Docker Compose deployment
|
| 16 |
+
|
| 17 |
+
## Quick Start
|
| 18 |
+
|
| 19 |
+
### Environment Requirements
|
| 20 |
+
|
| 21 |
+
- Python `3.9` to `3.12`
|
| 22 |
+
- Recommend using `uv`
|
| 23 |
+
|
| 24 |
+
### Local Startup
|
| 25 |
+
|
| 26 |
+
```bash
|
| 27 |
+
git clone https://github.com/ZyphrZero/z.ai2api_python.git
|
| 28 |
+
cd z.ai2api_python
|
| 29 |
+
|
| 30 |
+
uv sync
|
| 31 |
+
cp .env.example .env
|
| 32 |
+
uv run python main.py
|
| 33 |
+
```
|
| 34 |
+
|
| 35 |
+
First startup will automatically initialize the database.
|
| 36 |
+
|
| 37 |
+
Default addresses:
|
| 38 |
+
|
| 39 |
+
- API root path: `http://127.0.0.1:8080`
|
| 40 |
+
- OpenAI docs: `http://127.0.0.1:8080/docs`
|
| 41 |
+
- Admin panel: `http://127.0.0.1:8080/admin`
|
| 42 |
+
|
| 43 |
+
### Docker Compose
|
| 44 |
+
|
| 45 |
+
```bash
|
| 46 |
+
docker compose -f deploy/docker-compose.yml up -d --build
|
| 47 |
+
```
|
| 48 |
+
|
| 49 |
+
More deployment instructions see [deploy/README_DOCKER.md](deploy/README_DOCKER.md).
|
| 50 |
+
|
| 51 |
+
## Minimum Configuration
|
| 52 |
+
|
| 53 |
+
At least suggest confirming these environment variables:
|
| 54 |
+
|
| 55 |
+
| Variable | Description |
|
| 56 |
+
| --- | --- |
|
| 57 |
+
| `AUTH_TOKEN` | Bearer Token used by clients to access this service |
|
| 58 |
+
| `ADMIN_PASSWORD` | Admin panel login password, default value must be changed |
|
| 59 |
+
| `LISTEN_PORT` | Service listening port, default `8080` |
|
| 60 |
+
| `ANONYMOUS_MODE` | Whether to enable anonymous mode |
|
| 61 |
+
| `GUEST_POOL_SIZE` | Anonymous pool capacity |
|
| 62 |
+
| `DB_PATH` | SQLite database path |
|
| 63 |
+
| `TOKEN_FAILURE_THRESHOLD` | Token consecutive failure threshold |
|
| 64 |
+
| `TOKEN_RECOVERY_TIMEOUT` | Token recovery wait time |
|
| 65 |
+
|
| 66 |
+
Complete configuration please see [.env.example](.env.example).
|
| 67 |
+
|
| 68 |
+
## Admin Panel
|
| 69 |
+
|
| 70 |
+
Admin panel unified entry:
|
| 71 |
+
|
| 72 |
+
- `/admin`: Dashboard
|
| 73 |
+
- `/admin/tokens`: Token management
|
| 74 |
+
- `/admin/config`: Configuration management
|
| 75 |
+
- `/admin/logs`: Real-time logs
|
| 76 |
+
|
| 77 |
+
## Common Commands
|
| 78 |
+
|
| 79 |
+
```bash
|
| 80 |
+
# Start service
|
| 81 |
+
uv run python main.py
|
| 82 |
+
|
| 83 |
+
# Run tests
|
| 84 |
+
uv run pytest
|
| 85 |
+
|
| 86 |
+
# Run an existing smoke test
|
| 87 |
+
uv run python tests/test_simple_signature.py
|
| 88 |
+
|
| 89 |
+
# Lint
|
| 90 |
+
uv run ruff check app tests main.py
|
| 91 |
+
```
|
| 92 |
+
|
| 93 |
+
## Compatible Interfaces
|
| 94 |
+
|
| 95 |
+
Common interface entries:
|
| 96 |
+
|
| 97 |
+
- OpenAI compatible: `/v1/chat/completions`
|
| 98 |
+
- Anthropic compatible: `/v1/messages`
|
| 99 |
+
- Claude Code compatible: `/anthropic/v1/messages`
|
| 100 |
+
|
| 101 |
+
Model mapping and default model can be adjusted in `.env` or admin configuration page.
|
| 102 |
+
|
| 103 |
+
## ⭐ Star History
|
| 104 |
+
|
| 105 |
+
[](https://star-history.com/#ZyphrZero/z.ai2api_python&Date)
|
| 106 |
+
|
| 107 |
+
## License
|
| 108 |
+
|
| 109 |
+
This project uses MIT license - see [LICENSE](LICENSE) file for details.
|
| 110 |
+
|
| 111 |
+
## Disclaimer
|
| 112 |
+
|
| 113 |
+
- **This project is for learning and research use only, do not use for other purposes**
|
| 114 |
+
- This project is not affiliated with Z.AI official
|
| 115 |
+
- Please ensure compliance with Z.AI's terms of service before use
|
| 116 |
+
- Do not use for commercial purposes or scenarios that violate terms of service
|
| 117 |
+
- Users must bear their own usage risks
|
| 118 |
+
|
| 119 |
+
---
|
| 120 |
+
|
| 121 |
+
<div align="center">
|
| 122 |
+
Made with ❤️ by the community
|
| 123 |
+
</div>
|
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,1111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
管理后台 API 接口
|
| 3 |
+
用于 htmx 调用的 HTML 片段返回
|
| 4 |
+
"""
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from html import escape
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
import re
|
| 9 |
+
from typing import Optional
|
| 10 |
+
|
| 11 |
+
from fastapi import APIRouter, Depends, Request
|
| 12 |
+
from fastapi.responses import HTMLResponse, JSONResponse
|
| 13 |
+
from fastapi.templating import Jinja2Templates
|
| 14 |
+
|
| 15 |
+
from app.admin.auth import require_auth
|
| 16 |
+
from app.admin.config_manager import (
|
| 17 |
+
read_env_content,
|
| 18 |
+
reset_env_to_example,
|
| 19 |
+
save_form_config,
|
| 20 |
+
save_source_config,
|
| 21 |
+
)
|
| 22 |
+
from app.admin.stats import collect_admin_stats, normalize_trend_window
|
| 23 |
+
from app.services.request_log_dao import get_request_log_dao
|
| 24 |
+
from app.utils.logger import logger
|
| 25 |
+
|
| 26 |
+
router = APIRouter(prefix="/admin/api", tags=["admin-api"])
|
| 27 |
+
templates = Jinja2Templates(directory="app/templates")
|
| 28 |
+
DEFAULT_TOKEN_NAMESPACE = "zai"
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# ==================== 认证 API ====================
|
| 32 |
+
|
| 33 |
+
@router.post("/login")
|
| 34 |
+
async def login(request: Request):
|
| 35 |
+
"""管理后台登录"""
|
| 36 |
+
from app.admin.auth import create_session
|
| 37 |
+
|
| 38 |
+
try:
|
| 39 |
+
data = await request.json()
|
| 40 |
+
password = data.get("password", "")
|
| 41 |
+
|
| 42 |
+
# 创建 session
|
| 43 |
+
session_token = create_session(password)
|
| 44 |
+
|
| 45 |
+
if session_token:
|
| 46 |
+
# 登录成功,设置 cookie
|
| 47 |
+
response = JSONResponse({
|
| 48 |
+
"success": True,
|
| 49 |
+
"message": "登录成功"
|
| 50 |
+
})
|
| 51 |
+
response.set_cookie(
|
| 52 |
+
key="admin_session",
|
| 53 |
+
value=session_token,
|
| 54 |
+
httponly=True,
|
| 55 |
+
max_age=86400, # 24小时
|
| 56 |
+
samesite="lax"
|
| 57 |
+
)
|
| 58 |
+
logger.info("✅ 管理后台登录成功")
|
| 59 |
+
return response
|
| 60 |
+
else:
|
| 61 |
+
# 密码错误
|
| 62 |
+
logger.warning("❌ 管理后台登录失败:密码错误")
|
| 63 |
+
return JSONResponse({
|
| 64 |
+
"success": False,
|
| 65 |
+
"message": "密码错误"
|
| 66 |
+
}, status_code=401)
|
| 67 |
+
|
| 68 |
+
except Exception as e:
|
| 69 |
+
logger.error(f"❌ 登录异常: {e}")
|
| 70 |
+
return JSONResponse({
|
| 71 |
+
"success": False,
|
| 72 |
+
"message": "登录失败"
|
| 73 |
+
}, status_code=500)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@router.post("/logout")
|
| 77 |
+
async def logout(request: Request):
|
| 78 |
+
"""管理后台登出"""
|
| 79 |
+
from app.admin.auth import delete_session, get_session_token_from_request
|
| 80 |
+
|
| 81 |
+
session_token = get_session_token_from_request(request)
|
| 82 |
+
delete_session(session_token)
|
| 83 |
+
|
| 84 |
+
# 清除 cookie
|
| 85 |
+
response = JSONResponse({
|
| 86 |
+
"success": True,
|
| 87 |
+
"message": "已登出"
|
| 88 |
+
})
|
| 89 |
+
response.delete_cookie("admin_session")
|
| 90 |
+
logger.info("✅ 管理后台已登出")
|
| 91 |
+
return response
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
async def reload_settings():
|
| 95 |
+
"""热重载配置(重新加载环境变量并更新 settings 对象)"""
|
| 96 |
+
from dotenv import load_dotenv
|
| 97 |
+
|
| 98 |
+
from app.core.config import settings
|
| 99 |
+
from app.utils.logger import setup_logger
|
| 100 |
+
|
| 101 |
+
# 重新加载 .env 文件
|
| 102 |
+
load_dotenv(override=True)
|
| 103 |
+
|
| 104 |
+
# 重新创建 Settings 对象并更新全局配置
|
| 105 |
+
new_settings = type(settings)()
|
| 106 |
+
|
| 107 |
+
# 更新全局 settings 的所有属性
|
| 108 |
+
for field_name in new_settings.model_fields.keys():
|
| 109 |
+
setattr(settings, field_name, getattr(new_settings, field_name))
|
| 110 |
+
|
| 111 |
+
# 重新初始化 logger(使用新的 DEBUG_LOGGING 配置)
|
| 112 |
+
setup_logger(log_dir="logs", debug_mode=settings.DEBUG_LOGGING)
|
| 113 |
+
|
| 114 |
+
logger.info(f"🔄 配置已热重载 (DEBUG_LOGGING={settings.DEBUG_LOGGING})")
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _build_alert(
|
| 118 |
+
message: str,
|
| 119 |
+
*,
|
| 120 |
+
title: str,
|
| 121 |
+
level: str,
|
| 122 |
+
status_code: int = 200,
|
| 123 |
+
) -> HTMLResponse:
|
| 124 |
+
level_classes = {
|
| 125 |
+
"success": "bg-green-100 border-green-400 text-green-700",
|
| 126 |
+
"warning": "bg-yellow-100 border-yellow-400 text-yellow-700",
|
| 127 |
+
"error": "bg-red-100 border-red-400 text-red-700",
|
| 128 |
+
"info": "bg-blue-100 border-blue-400 text-blue-700",
|
| 129 |
+
}
|
| 130 |
+
classes = level_classes.get(level, level_classes["info"])
|
| 131 |
+
safe_title = escape(title)
|
| 132 |
+
safe_message = escape(message)
|
| 133 |
+
return HTMLResponse(
|
| 134 |
+
f"""
|
| 135 |
+
<div class="{classes} border px-4 py-3 rounded relative" role="alert">
|
| 136 |
+
<strong class="font-bold">{safe_title}</strong>
|
| 137 |
+
<span class="block sm:inline">{safe_message}</span>
|
| 138 |
+
</div>
|
| 139 |
+
""",
|
| 140 |
+
status_code=status_code,
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def _with_hx_trigger(response: HTMLResponse, event_name: str) -> HTMLResponse:
|
| 145 |
+
response.headers["HX-Trigger"] = event_name
|
| 146 |
+
return response
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _get_int_query_param(
|
| 150 |
+
request: Request,
|
| 151 |
+
name: str,
|
| 152 |
+
default: int,
|
| 153 |
+
*,
|
| 154 |
+
minimum: int = 1,
|
| 155 |
+
maximum: Optional[int] = None,
|
| 156 |
+
) -> int:
|
| 157 |
+
"""解析查询参数中的正整数,非法值回退到默认值。"""
|
| 158 |
+
raw_value = request.query_params.get(name)
|
| 159 |
+
if raw_value is None:
|
| 160 |
+
return default
|
| 161 |
+
|
| 162 |
+
try:
|
| 163 |
+
value = int(str(raw_value).strip())
|
| 164 |
+
except (TypeError, ValueError):
|
| 165 |
+
return default
|
| 166 |
+
|
| 167 |
+
value = max(minimum, value)
|
| 168 |
+
if maximum is not None:
|
| 169 |
+
value = min(value, maximum)
|
| 170 |
+
return value
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def _build_pagination(
|
| 174 |
+
*,
|
| 175 |
+
total_items: int,
|
| 176 |
+
page: int,
|
| 177 |
+
page_size: int,
|
| 178 |
+
) -> dict:
|
| 179 |
+
"""构建分页上下文。"""
|
| 180 |
+
total_items = max(0, int(total_items))
|
| 181 |
+
page_size = max(1, int(page_size))
|
| 182 |
+
total_pages = max(1, (total_items + page_size - 1) // page_size)
|
| 183 |
+
current_page = min(max(1, int(page)), total_pages)
|
| 184 |
+
|
| 185 |
+
if total_items == 0:
|
| 186 |
+
start_item = 0
|
| 187 |
+
end_item = 0
|
| 188 |
+
else:
|
| 189 |
+
start_item = (current_page - 1) * page_size + 1
|
| 190 |
+
end_item = min(total_items, current_page * page_size)
|
| 191 |
+
|
| 192 |
+
return {
|
| 193 |
+
"current_page": current_page,
|
| 194 |
+
"page_size": page_size,
|
| 195 |
+
"total_items": total_items,
|
| 196 |
+
"total_pages": total_pages,
|
| 197 |
+
"has_previous": current_page > 1,
|
| 198 |
+
"has_next": current_page < total_pages,
|
| 199 |
+
"previous_page": max(1, current_page - 1),
|
| 200 |
+
"next_page": min(total_pages, current_page + 1),
|
| 201 |
+
"start_item": start_item,
|
| 202 |
+
"end_item": end_item,
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def _normalize_display_value(value: str) -> str:
|
| 207 |
+
normalized = re.sub(r"[^a-z0-9]+", "", str(value or "").casefold())
|
| 208 |
+
return normalized
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def _is_redundant_source(source: str, client_name: str) -> bool:
|
| 212 |
+
normalized_source = _normalize_display_value(source)
|
| 213 |
+
normalized_client = _normalize_display_value(client_name)
|
| 214 |
+
if not normalized_source:
|
| 215 |
+
return True
|
| 216 |
+
if not normalized_client:
|
| 217 |
+
return False
|
| 218 |
+
return normalized_source == normalized_client
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def _humanize_protocol(protocol: str) -> str:
|
| 222 |
+
normalized = str(protocol or "").strip().lower()
|
| 223 |
+
if normalized == "openai":
|
| 224 |
+
return "OpenAI"
|
| 225 |
+
if normalized == "anthropic":
|
| 226 |
+
return "Anthropic"
|
| 227 |
+
if normalized == "unknown":
|
| 228 |
+
return "Unknown"
|
| 229 |
+
return normalized or "Unknown"
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
@router.get(
|
| 233 |
+
"/dashboard/usage-trend",
|
| 234 |
+
response_class=JSONResponse,
|
| 235 |
+
dependencies=[Depends(require_auth)],
|
| 236 |
+
)
|
| 237 |
+
async def get_dashboard_usage_trend(request: Request):
|
| 238 |
+
"""返回仪表盘趋势图数据。"""
|
| 239 |
+
trend_window = normalize_trend_window(
|
| 240 |
+
request.query_params.get("window")
|
| 241 |
+
)
|
| 242 |
+
dao = get_request_log_dao()
|
| 243 |
+
trend_points = await dao.get_provider_usage_trend(
|
| 244 |
+
DEFAULT_TOKEN_NAMESPACE,
|
| 245 |
+
window=trend_window,
|
| 246 |
+
)
|
| 247 |
+
return JSONResponse(
|
| 248 |
+
{
|
| 249 |
+
"window": trend_window,
|
| 250 |
+
"points": trend_points,
|
| 251 |
+
}
|
| 252 |
+
)
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def _validate_directory_path(source_dir: str) -> str:
|
| 256 |
+
if not source_dir:
|
| 257 |
+
raise ValueError("请先填写服务端可访问的本地目录路径。")
|
| 258 |
+
|
| 259 |
+
source_path = Path(source_dir).expanduser()
|
| 260 |
+
if not source_path.exists():
|
| 261 |
+
raise ValueError(f"导入目录不存在: {source_path}")
|
| 262 |
+
if not source_path.is_dir():
|
| 263 |
+
raise ValueError(f"导入路径不是目录: {source_path}")
|
| 264 |
+
|
| 265 |
+
return str(source_path)
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
@router.get("/token-pool", response_class=HTMLResponse)
|
| 269 |
+
async def get_token_pool_status(request: Request):
|
| 270 |
+
"""获取 Token 池状态(HTML 片段)"""
|
| 271 |
+
from app.utils.token_pool import get_token_pool
|
| 272 |
+
|
| 273 |
+
token_pool = get_token_pool()
|
| 274 |
+
|
| 275 |
+
if not token_pool:
|
| 276 |
+
# Token 池未初始化
|
| 277 |
+
context = {
|
| 278 |
+
"request": request,
|
| 279 |
+
"tokens": [],
|
| 280 |
+
}
|
| 281 |
+
return templates.TemplateResponse("components/token_pool.html", context)
|
| 282 |
+
|
| 283 |
+
# 获取 token 状态统计
|
| 284 |
+
pool_status = token_pool.get_pool_status()
|
| 285 |
+
tokens_info = []
|
| 286 |
+
|
| 287 |
+
for idx, token_info in enumerate(pool_status.get("tokens", []), 1):
|
| 288 |
+
is_available = token_info.get("is_available", False)
|
| 289 |
+
is_healthy = token_info.get("is_healthy", False)
|
| 290 |
+
|
| 291 |
+
# 确定状态和颜色
|
| 292 |
+
if is_healthy:
|
| 293 |
+
status = "健康"
|
| 294 |
+
status_color = "bg-green-100 text-green-800"
|
| 295 |
+
elif is_available:
|
| 296 |
+
status = "可用"
|
| 297 |
+
status_color = "bg-yellow-100 text-yellow-800"
|
| 298 |
+
else:
|
| 299 |
+
status = "失败"
|
| 300 |
+
status_color = "bg-red-100 text-red-800"
|
| 301 |
+
|
| 302 |
+
# 格式化最后使用时间
|
| 303 |
+
last_success = token_info.get("last_success_time", 0)
|
| 304 |
+
if last_success > 0:
|
| 305 |
+
from datetime import datetime
|
| 306 |
+
last_used = datetime.fromtimestamp(last_success).strftime("%Y-%m-%d %H:%M:%S")
|
| 307 |
+
else:
|
| 308 |
+
last_used = "从未使用"
|
| 309 |
+
|
| 310 |
+
tokens_info.append({
|
| 311 |
+
"index": idx,
|
| 312 |
+
"key": token_info.get("token", "")[:20] + "...",
|
| 313 |
+
"status": status,
|
| 314 |
+
"status_color": status_color,
|
| 315 |
+
"last_used": last_used,
|
| 316 |
+
"failure_count": token_info.get("failure_count", 0),
|
| 317 |
+
"success_rate": token_info.get("success_rate", "0%"),
|
| 318 |
+
"token_type": token_info.get("token_type", "unknown"),
|
| 319 |
+
})
|
| 320 |
+
|
| 321 |
+
context = {
|
| 322 |
+
"request": request,
|
| 323 |
+
"tokens": tokens_info,
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
return templates.TemplateResponse("components/token_pool.html", context)
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
@router.get("/recent-logs", response_class=HTMLResponse)
|
| 330 |
+
async def get_recent_logs(request: Request):
|
| 331 |
+
"""获取最近的请求日志(HTML 片段)"""
|
| 332 |
+
dao = get_request_log_dao()
|
| 333 |
+
page_size = _get_int_query_param(
|
| 334 |
+
request,
|
| 335 |
+
"page_size",
|
| 336 |
+
12,
|
| 337 |
+
maximum=50,
|
| 338 |
+
)
|
| 339 |
+
requested_page = _get_int_query_param(request, "page", 1, maximum=100000)
|
| 340 |
+
total_count = await dao.count_logs()
|
| 341 |
+
pagination = _build_pagination(
|
| 342 |
+
total_items=total_count,
|
| 343 |
+
page=requested_page,
|
| 344 |
+
page_size=page_size,
|
| 345 |
+
)
|
| 346 |
+
|
| 347 |
+
rows = await dao.get_recent_logs(
|
| 348 |
+
limit=page_size,
|
| 349 |
+
offset=(pagination["current_page"] - 1) * page_size,
|
| 350 |
+
)
|
| 351 |
+
logs = []
|
| 352 |
+
for row in rows:
|
| 353 |
+
timestamp = (
|
| 354 |
+
row.get("timestamp")
|
| 355 |
+
or row.get("created_at")
|
| 356 |
+
or datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
| 357 |
+
)
|
| 358 |
+
success = bool(row.get("success"))
|
| 359 |
+
status_code = int(
|
| 360 |
+
row.get("status_code") or (200 if success else 500)
|
| 361 |
+
)
|
| 362 |
+
duration_value = float(row.get("duration") or 0.0)
|
| 363 |
+
first_token_value = float(row.get("first_token_time") or 0.0)
|
| 364 |
+
source = row.get("source") or "unknown"
|
| 365 |
+
client_name = row.get("client_name") or "Unknown"
|
| 366 |
+
provider = row.get("provider") or "-"
|
| 367 |
+
source_display = (
|
| 368 |
+
""
|
| 369 |
+
if _is_redundant_source(source, client_name)
|
| 370 |
+
else source
|
| 371 |
+
)
|
| 372 |
+
provider_display = "" if provider == "zai" else provider
|
| 373 |
+
logs.append(
|
| 374 |
+
{
|
| 375 |
+
"timestamp": timestamp,
|
| 376 |
+
"endpoint": row.get("endpoint") or "-",
|
| 377 |
+
"model": row.get("model") or "-",
|
| 378 |
+
"provider": provider,
|
| 379 |
+
"provider_display": provider_display,
|
| 380 |
+
"source": source,
|
| 381 |
+
"source_display": source_display,
|
| 382 |
+
"protocol": row.get("protocol") or "unknown",
|
| 383 |
+
"protocol_display": _humanize_protocol(
|
| 384 |
+
row.get("protocol") or "unknown"
|
| 385 |
+
),
|
| 386 |
+
"client_name": client_name,
|
| 387 |
+
"success": success,
|
| 388 |
+
"status_code": status_code,
|
| 389 |
+
"duration_display": f"{duration_value:.2f}s",
|
| 390 |
+
"first_token_display": (
|
| 391 |
+
f"{first_token_value:.2f}s"
|
| 392 |
+
if first_token_value > 0
|
| 393 |
+
else "--"
|
| 394 |
+
),
|
| 395 |
+
"input_tokens": int(row.get("input_tokens") or 0),
|
| 396 |
+
"output_tokens": int(row.get("output_tokens") or 0),
|
| 397 |
+
"cache_creation_tokens": int(
|
| 398 |
+
row.get("cache_creation_tokens") or 0
|
| 399 |
+
),
|
| 400 |
+
"cache_read_tokens": int(
|
| 401 |
+
row.get("cache_read_tokens") or 0
|
| 402 |
+
),
|
| 403 |
+
"error_message": row.get("error_message") or "",
|
| 404 |
+
}
|
| 405 |
+
)
|
| 406 |
+
|
| 407 |
+
context = {
|
| 408 |
+
"request": request,
|
| 409 |
+
"logs": logs,
|
| 410 |
+
"page": pagination,
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
return templates.TemplateResponse("components/recent_logs.html", context)
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
@router.post("/config/save", dependencies=[Depends(require_auth)])
|
| 417 |
+
async def save_config(request: Request):
|
| 418 |
+
"""保存结构化配置并热重载。"""
|
| 419 |
+
try:
|
| 420 |
+
form_data = await request.form()
|
| 421 |
+
await save_form_config(
|
| 422 |
+
form_data,
|
| 423 |
+
reload_callback=reload_settings,
|
| 424 |
+
)
|
| 425 |
+
logger.info("✅ 结构化配置已保存")
|
| 426 |
+
return _with_hx_trigger(
|
| 427 |
+
_build_alert(
|
| 428 |
+
"配置已保存并热重载,页面即将刷新。",
|
| 429 |
+
title="保存成功!",
|
| 430 |
+
level="success",
|
| 431 |
+
),
|
| 432 |
+
"admin-config-refresh",
|
| 433 |
+
)
|
| 434 |
+
except ValueError as exc:
|
| 435 |
+
return _build_alert(
|
| 436 |
+
str(exc),
|
| 437 |
+
title="校验失败!",
|
| 438 |
+
level="error",
|
| 439 |
+
status_code=400,
|
| 440 |
+
)
|
| 441 |
+
except Exception as exc:
|
| 442 |
+
logger.error(f"❌ 配置保存失败: {exc}")
|
| 443 |
+
return _build_alert(
|
| 444 |
+
f"保存失败: {exc}",
|
| 445 |
+
title="错误!",
|
| 446 |
+
level="error",
|
| 447 |
+
status_code=500,
|
| 448 |
+
)
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
@router.post("/config/source", dependencies=[Depends(require_auth)])
|
| 452 |
+
async def save_config_source(request: Request):
|
| 453 |
+
"""保存 .env 源文件并热重载。"""
|
| 454 |
+
try:
|
| 455 |
+
form_data = await request.form()
|
| 456 |
+
await save_source_config(
|
| 457 |
+
str(form_data.get("env_content", "")),
|
| 458 |
+
reload_callback=reload_settings,
|
| 459 |
+
)
|
| 460 |
+
logger.info("✅ 配置源文件已保存")
|
| 461 |
+
return _with_hx_trigger(
|
| 462 |
+
_build_alert(
|
| 463 |
+
".env 源文件已保存并热重载,页面即将刷新。",
|
| 464 |
+
title="保存成功!",
|
| 465 |
+
level="success",
|
| 466 |
+
),
|
| 467 |
+
"admin-config-refresh",
|
| 468 |
+
)
|
| 469 |
+
except ValueError as exc:
|
| 470 |
+
return _build_alert(
|
| 471 |
+
str(exc),
|
| 472 |
+
title="源文件校验失败!",
|
| 473 |
+
level="error",
|
| 474 |
+
status_code=400,
|
| 475 |
+
)
|
| 476 |
+
except Exception as exc:
|
| 477 |
+
logger.error(f"❌ 源��件保存失败: {exc}")
|
| 478 |
+
return _build_alert(
|
| 479 |
+
f"源文件保存失败: {exc}",
|
| 480 |
+
title="错误!",
|
| 481 |
+
level="error",
|
| 482 |
+
status_code=500,
|
| 483 |
+
)
|
| 484 |
+
|
| 485 |
+
|
| 486 |
+
@router.post("/config/reset", dependencies=[Depends(require_auth)])
|
| 487 |
+
async def reset_config():
|
| 488 |
+
"""将配置重置为 .env.example 并热重载。"""
|
| 489 |
+
try:
|
| 490 |
+
await reset_env_to_example(reload_callback=reload_settings)
|
| 491 |
+
logger.info("✅ 配置已重置为 .env.example 默认值")
|
| 492 |
+
return _with_hx_trigger(
|
| 493 |
+
_build_alert(
|
| 494 |
+
"配置已恢复为 .env.example 默认值,页面即将刷新。",
|
| 495 |
+
title="已重置!",
|
| 496 |
+
level="success",
|
| 497 |
+
),
|
| 498 |
+
"admin-config-refresh",
|
| 499 |
+
)
|
| 500 |
+
except FileNotFoundError:
|
| 501 |
+
logger.error("❌ 未找到 .env.example,无法重置配置")
|
| 502 |
+
return _build_alert(
|
| 503 |
+
"未找到 .env.example,无法重置配置。",
|
| 504 |
+
title="错误!",
|
| 505 |
+
level="error",
|
| 506 |
+
status_code=404,
|
| 507 |
+
)
|
| 508 |
+
except Exception as exc:
|
| 509 |
+
logger.error(f"❌ 配置重置失败: {exc}")
|
| 510 |
+
return _build_alert(
|
| 511 |
+
f"重置失败: {exc}",
|
| 512 |
+
title="错误!",
|
| 513 |
+
level="error",
|
| 514 |
+
status_code=500,
|
| 515 |
+
)
|
| 516 |
+
|
| 517 |
+
|
| 518 |
+
@router.get("/env-preview", dependencies=[Depends(require_auth)])
|
| 519 |
+
async def get_env_preview():
|
| 520 |
+
"""获取 .env 文件预览"""
|
| 521 |
+
try:
|
| 522 |
+
content = read_env_content()
|
| 523 |
+
if not content:
|
| 524 |
+
content = "# .env 文件不存在"
|
| 525 |
+
return HTMLResponse(f"<pre>{escape(content)}</pre>")
|
| 526 |
+
except Exception as exc:
|
| 527 |
+
return HTMLResponse(f"<pre># 读取失败: {escape(str(exc))}</pre>")
|
| 528 |
+
|
| 529 |
+
|
| 530 |
+
@router.get("/live-logs", response_class=HTMLResponse)
|
| 531 |
+
async def get_live_logs():
|
| 532 |
+
"""获取实时日志(最新 50 行)"""
|
| 533 |
+
import os
|
| 534 |
+
from datetime import datetime
|
| 535 |
+
|
| 536 |
+
logs = []
|
| 537 |
+
|
| 538 |
+
# 尝试读取日志文件
|
| 539 |
+
log_dir = "logs"
|
| 540 |
+
if os.path.exists(log_dir):
|
| 541 |
+
log_files = sorted([f for f in os.listdir(log_dir) if f.endswith('.log')], reverse=True)
|
| 542 |
+
if log_files:
|
| 543 |
+
log_file = os.path.join(log_dir, log_files[0])
|
| 544 |
+
try:
|
| 545 |
+
with open(log_file, 'r', encoding='utf-8') as f:
|
| 546 |
+
# 读取最后 50 行
|
| 547 |
+
lines = f.readlines()[-50:]
|
| 548 |
+
logs = lines
|
| 549 |
+
except Exception as e:
|
| 550 |
+
logs = [f"# [{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] 读取日志失败: {str(e)}"]
|
| 551 |
+
|
| 552 |
+
if not logs:
|
| 553 |
+
logs = [f"# [{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] 暂无日志数据"]
|
| 554 |
+
|
| 555 |
+
html = ""
|
| 556 |
+
for log in logs:
|
| 557 |
+
log_line = log.strip()
|
| 558 |
+
if not log_line:
|
| 559 |
+
continue
|
| 560 |
+
|
| 561 |
+
# 根据日志级别设置颜色和样式
|
| 562 |
+
if "ERROR" in log_line or "CRITICAL" in log_line:
|
| 563 |
+
color_class = "text-red-400 font-semibold"
|
| 564 |
+
icon = "❌"
|
| 565 |
+
elif "WARNING" in log_line or "WARN" in log_line:
|
| 566 |
+
color_class = "text-yellow-400"
|
| 567 |
+
icon = "⚠️"
|
| 568 |
+
elif "SUCCESS" in log_line or "✅" in log_line:
|
| 569 |
+
color_class = "text-green-400"
|
| 570 |
+
icon = "✅"
|
| 571 |
+
elif "INFO" in log_line:
|
| 572 |
+
color_class = "text-blue-400"
|
| 573 |
+
icon = "ℹ️"
|
| 574 |
+
elif "DEBUG" in log_line:
|
| 575 |
+
color_class = "text-gray-400 text-xs"
|
| 576 |
+
icon = "🔍"
|
| 577 |
+
else:
|
| 578 |
+
color_class = "text-gray-300"
|
| 579 |
+
icon = "•"
|
| 580 |
+
|
| 581 |
+
# 转义 HTML 特殊字符
|
| 582 |
+
log_escaped = log_line.replace('<', '<').replace('>', '>')
|
| 583 |
+
|
| 584 |
+
html += f'<div class="{color_class} py-0.5 hover:bg-gray-800 px-2 rounded transition-colors">{icon} {log_escaped}</div>'
|
| 585 |
+
|
| 586 |
+
return HTMLResponse(html)
|
| 587 |
+
|
| 588 |
+
|
| 589 |
+
# ==================== Token 管理 API ====================
|
| 590 |
+
|
| 591 |
+
@router.get("/tokens/list", response_class=HTMLResponse)
|
| 592 |
+
async def get_tokens_list(request: Request):
|
| 593 |
+
"""获取 Token 列表(HTML 片段)"""
|
| 594 |
+
from app.services.token_dao import get_token_dao
|
| 595 |
+
|
| 596 |
+
dao = get_token_dao()
|
| 597 |
+
page_size = _get_int_query_param(
|
| 598 |
+
request,
|
| 599 |
+
"page_size",
|
| 600 |
+
20,
|
| 601 |
+
maximum=100,
|
| 602 |
+
)
|
| 603 |
+
requested_page = _get_int_query_param(request, "page", 1, maximum=100000)
|
| 604 |
+
total_count = await dao.count_tokens_by_provider(
|
| 605 |
+
DEFAULT_TOKEN_NAMESPACE,
|
| 606 |
+
enabled_only=False,
|
| 607 |
+
)
|
| 608 |
+
pagination = _build_pagination(
|
| 609 |
+
total_items=total_count,
|
| 610 |
+
page=requested_page,
|
| 611 |
+
page_size=page_size,
|
| 612 |
+
)
|
| 613 |
+
tokens = await dao.get_tokens_by_provider(
|
| 614 |
+
DEFAULT_TOKEN_NAMESPACE,
|
| 615 |
+
enabled_only=False,
|
| 616 |
+
limit=page_size,
|
| 617 |
+
offset=(pagination["current_page"] - 1) * page_size,
|
| 618 |
+
)
|
| 619 |
+
|
| 620 |
+
context = {
|
| 621 |
+
"request": request,
|
| 622 |
+
"tokens": tokens,
|
| 623 |
+
"page": pagination,
|
| 624 |
+
}
|
| 625 |
+
|
| 626 |
+
return templates.TemplateResponse("components/token_list.html", context)
|
| 627 |
+
|
| 628 |
+
|
| 629 |
+
@router.post("/tokens/add")
|
| 630 |
+
async def add_tokens(request: Request):
|
| 631 |
+
"""添�� Token"""
|
| 632 |
+
from app.services.token_dao import get_token_dao
|
| 633 |
+
from app.utils.token_pool import get_token_pool
|
| 634 |
+
|
| 635 |
+
form_data = await request.form()
|
| 636 |
+
single_token = form_data.get("single_token", "").strip()
|
| 637 |
+
bulk_tokens = form_data.get("bulk_tokens", "").strip()
|
| 638 |
+
|
| 639 |
+
dao = get_token_dao()
|
| 640 |
+
added_count = 0
|
| 641 |
+
failed_count = 0
|
| 642 |
+
|
| 643 |
+
# 添加单个 Token(带验证)
|
| 644 |
+
if single_token:
|
| 645 |
+
token_id = await dao.add_token(
|
| 646 |
+
DEFAULT_TOKEN_NAMESPACE,
|
| 647 |
+
single_token,
|
| 648 |
+
validate=True,
|
| 649 |
+
)
|
| 650 |
+
if token_id:
|
| 651 |
+
added_count += 1
|
| 652 |
+
else:
|
| 653 |
+
failed_count += 1
|
| 654 |
+
|
| 655 |
+
# 批量添加 Token(带验证)
|
| 656 |
+
if bulk_tokens:
|
| 657 |
+
# 支持换行和逗号分隔
|
| 658 |
+
tokens = []
|
| 659 |
+
for line in bulk_tokens.split('\n'):
|
| 660 |
+
line = line.strip()
|
| 661 |
+
if ',' in line:
|
| 662 |
+
tokens.extend([t.strip() for t in line.split(',') if t.strip()])
|
| 663 |
+
elif line:
|
| 664 |
+
tokens.append(line)
|
| 665 |
+
|
| 666 |
+
success, failed = await dao.bulk_add_tokens(
|
| 667 |
+
DEFAULT_TOKEN_NAMESPACE,
|
| 668 |
+
tokens,
|
| 669 |
+
validate=True,
|
| 670 |
+
)
|
| 671 |
+
added_count += success
|
| 672 |
+
failed_count += failed
|
| 673 |
+
|
| 674 |
+
# 同步 Token 池状态(如果有新增成功的 Token)
|
| 675 |
+
if added_count > 0:
|
| 676 |
+
pool = get_token_pool()
|
| 677 |
+
if pool:
|
| 678 |
+
await pool.sync_from_database(DEFAULT_TOKEN_NAMESPACE)
|
| 679 |
+
logger.info(f"✅ Token 池已同步,新增 {added_count} 个 Token")
|
| 680 |
+
|
| 681 |
+
# 生成响应
|
| 682 |
+
if added_count > 0 and failed_count == 0:
|
| 683 |
+
return HTMLResponse(f"""
|
| 684 |
+
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded relative" role="alert">
|
| 685 |
+
<strong class="font-bold">成功!</strong>
|
| 686 |
+
<span class="block sm:inline">已添加 {added_count} 个有效 Token</span>
|
| 687 |
+
</div>
|
| 688 |
+
""")
|
| 689 |
+
elif added_count > 0 and failed_count > 0:
|
| 690 |
+
return HTMLResponse(f"""
|
| 691 |
+
<div class="bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded relative" role="alert">
|
| 692 |
+
<strong class="font-bold">部分成功!</strong>
|
| 693 |
+
<span class="block sm:inline">已添加 {added_count} 个 Token,{failed_count} 个失败(可能是重复、无效或匿名 Token)</span>
|
| 694 |
+
</div>
|
| 695 |
+
""")
|
| 696 |
+
else:
|
| 697 |
+
return HTMLResponse("""
|
| 698 |
+
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
|
| 699 |
+
<strong class="font-bold">失败!</strong>
|
| 700 |
+
<span class="block sm:inline">所有 Token 添加失败(可能是重复、无效或匿名 Token)</span>
|
| 701 |
+
</div>
|
| 702 |
+
""")
|
| 703 |
+
|
| 704 |
+
|
| 705 |
+
@router.post("/tokens/import-directory", dependencies=[Depends(require_auth)])
|
| 706 |
+
async def import_tokens_from_directory_api(request: Request):
|
| 707 |
+
"""从本地目录导入 token 文件。"""
|
| 708 |
+
from app.core.config import settings
|
| 709 |
+
from app.services.token_automation import run_directory_import
|
| 710 |
+
|
| 711 |
+
form_data = await request.form()
|
| 712 |
+
source_dir = str(
|
| 713 |
+
form_data.get("source_dir")
|
| 714 |
+
or settings.TOKEN_AUTO_IMPORT_SOURCE_DIR
|
| 715 |
+
or ""
|
| 716 |
+
).strip()
|
| 717 |
+
try:
|
| 718 |
+
source_dir = _validate_directory_path(source_dir)
|
| 719 |
+
except ValueError as exc:
|
| 720 |
+
return _build_alert(
|
| 721 |
+
str(exc),
|
| 722 |
+
title="导入失败!",
|
| 723 |
+
level="error",
|
| 724 |
+
status_code=400,
|
| 725 |
+
)
|
| 726 |
+
|
| 727 |
+
try:
|
| 728 |
+
summary = await run_directory_import(
|
| 729 |
+
source_dir,
|
| 730 |
+
provider=DEFAULT_TOKEN_NAMESPACE,
|
| 731 |
+
validate=True,
|
| 732 |
+
)
|
| 733 |
+
except (FileNotFoundError, NotADirectoryError) as exc:
|
| 734 |
+
return _build_alert(
|
| 735 |
+
str(exc),
|
| 736 |
+
title="导入失败!",
|
| 737 |
+
level="error",
|
| 738 |
+
status_code=400,
|
| 739 |
+
)
|
| 740 |
+
except RuntimeError as exc:
|
| 741 |
+
return _build_alert(
|
| 742 |
+
str(exc),
|
| 743 |
+
title="导入稍后重试",
|
| 744 |
+
level="warning",
|
| 745 |
+
status_code=409,
|
| 746 |
+
)
|
| 747 |
+
except Exception as exc:
|
| 748 |
+
logger.exception(f"❌ 本地目录导入 Token 失败: {exc}")
|
| 749 |
+
return _build_alert(
|
| 750 |
+
f"目录扫描或入库异常: {exc}",
|
| 751 |
+
title="导入失败!",
|
| 752 |
+
level="error",
|
| 753 |
+
status_code=500,
|
| 754 |
+
)
|
| 755 |
+
|
| 756 |
+
if summary.imported_count > 0:
|
| 757 |
+
title = "导入成功!" if summary.failed_count == 0 else "导入完成!"
|
| 758 |
+
detail = (
|
| 759 |
+
f"目录 {summary.source_dir} 共扫描 {summary.scanned_files} 个文件,"
|
| 760 |
+
f"成功导入 {summary.imported_count} 个 Token,"
|
| 761 |
+
f"重复 {summary.duplicate_count} 个,"
|
| 762 |
+
f"无效 JSON {summary.invalid_json_count} 个,"
|
| 763 |
+
f"缺少 token {summary.missing_token_count} 个,"
|
| 764 |
+
f"验证失败 {summary.invalid_token_count} 个。"
|
| 765 |
+
)
|
| 766 |
+
return _build_alert(
|
| 767 |
+
detail,
|
| 768 |
+
title=title,
|
| 769 |
+
level="success" if summary.failed_count == 0 else "warning",
|
| 770 |
+
)
|
| 771 |
+
|
| 772 |
+
return _build_alert(
|
| 773 |
+
(
|
| 774 |
+
f"目录 {summary.source_dir} 共扫描 {summary.scanned_files} 个文件,"
|
| 775 |
+
f"其中重复 {summary.duplicate_count} 个,无效 JSON {summary.invalid_json_count} 个,"
|
| 776 |
+
f"缺少 token {summary.missing_token_count} 个,验证失败 {summary.invalid_token_count} 个。"
|
| 777 |
+
),
|
| 778 |
+
title="未导入任何 Token!",
|
| 779 |
+
level="warning",
|
| 780 |
+
)
|
| 781 |
+
|
| 782 |
+
|
| 783 |
+
@router.post("/tokens/auto-import/save", dependencies=[Depends(require_auth)])
|
| 784 |
+
async def save_auto_import_settings(request: Request):
|
| 785 |
+
"""兼容旧入口,提示用户改到配置管理页。"""
|
| 786 |
+
return _build_alert(
|
| 787 |
+
"自动导入配置入口已迁移到 /admin/config#tokens,当前页面仅保留手动执行入口。",
|
| 788 |
+
title="入口已迁移",
|
| 789 |
+
level="info",
|
| 790 |
+
)
|
| 791 |
+
|
| 792 |
+
|
| 793 |
+
@router.post("/tokens/maintenance/save", dependencies=[Depends(require_auth)])
|
| 794 |
+
async def save_auto_maintenance_settings(request: Request):
|
| 795 |
+
"""兼容旧入口,提示用户改到配置管理页。"""
|
| 796 |
+
return _build_alert(
|
| 797 |
+
"自动维护配置入口已迁移到 /admin/config#tokens,当前页面仅保留手动执行入口。",
|
| 798 |
+
title="入口已迁移",
|
| 799 |
+
level="info",
|
| 800 |
+
)
|
| 801 |
+
|
| 802 |
+
|
| 803 |
+
@router.post("/tokens/maintenance/run", dependencies=[Depends(require_auth)])
|
| 804 |
+
async def run_token_maintenance_api(request: Request):
|
| 805 |
+
"""立即执行一次 Token 维护。"""
|
| 806 |
+
from app.core.config import settings
|
| 807 |
+
from app.services.token_automation import run_token_maintenance
|
| 808 |
+
|
| 809 |
+
form_data = await request.form()
|
| 810 |
+
action_fields = (
|
| 811 |
+
"auto_remove_duplicates",
|
| 812 |
+
"auto_health_check",
|
| 813 |
+
"auto_delete_invalid",
|
| 814 |
+
)
|
| 815 |
+
has_explicit_actions = any(field in form_data for field in action_fields)
|
| 816 |
+
|
| 817 |
+
if has_explicit_actions:
|
| 818 |
+
remove_duplicates = "auto_remove_duplicates" in form_data
|
| 819 |
+
run_health_check = "auto_health_check" in form_data
|
| 820 |
+
delete_invalid = "auto_delete_invalid" in form_data
|
| 821 |
+
else:
|
| 822 |
+
remove_duplicates = settings.TOKEN_AUTO_REMOVE_DUPLICATES
|
| 823 |
+
run_health_check = settings.TOKEN_AUTO_HEALTH_CHECK
|
| 824 |
+
delete_invalid = settings.TOKEN_AUTO_DELETE_INVALID
|
| 825 |
+
|
| 826 |
+
if not any((remove_duplicates, run_health_check, delete_invalid)):
|
| 827 |
+
return _build_alert(
|
| 828 |
+
"当前没有可执行的维护动作,请先到 /admin/config#tokens 配置至少一个维护动作。",
|
| 829 |
+
title="未执行维护!",
|
| 830 |
+
level="warning",
|
| 831 |
+
status_code=400,
|
| 832 |
+
)
|
| 833 |
+
|
| 834 |
+
try:
|
| 835 |
+
summary = await run_token_maintenance(
|
| 836 |
+
provider=DEFAULT_TOKEN_NAMESPACE,
|
| 837 |
+
remove_duplicates=remove_duplicates,
|
| 838 |
+
run_health_check=run_health_check,
|
| 839 |
+
delete_invalid_tokens=delete_invalid,
|
| 840 |
+
)
|
| 841 |
+
except RuntimeError as exc:
|
| 842 |
+
return _build_alert(
|
| 843 |
+
str(exc),
|
| 844 |
+
title="维护稍后重试",
|
| 845 |
+
level="warning",
|
| 846 |
+
status_code=409,
|
| 847 |
+
)
|
| 848 |
+
except Exception as exc:
|
| 849 |
+
logger.exception(f"❌ 手动执行 Token 维护失败: {exc}")
|
| 850 |
+
return _build_alert(
|
| 851 |
+
f"Token 维护失败: {exc}",
|
| 852 |
+
title="维护失败!",
|
| 853 |
+
level="error",
|
| 854 |
+
status_code=500,
|
| 855 |
+
)
|
| 856 |
+
|
| 857 |
+
return _build_alert(
|
| 858 |
+
(
|
| 859 |
+
f"本次维护共去重 {summary.duplicate_removed_count} 个,"
|
| 860 |
+
f"测活 {summary.checked_count} 个(有效 {summary.valid_count} / "
|
| 861 |
+
f"匿名 {summary.guest_count} / 无效 {summary.invalid_count}),"
|
| 862 |
+
f"删除失效 Token {summary.deleted_invalid_count} 个。"
|
| 863 |
+
),
|
| 864 |
+
title="维护完成!",
|
| 865 |
+
level="success",
|
| 866 |
+
)
|
| 867 |
+
|
| 868 |
+
|
| 869 |
+
@router.post("/tokens/toggle/{token_id}")
|
| 870 |
+
async def toggle_token(token_id: int, enabled: bool):
|
| 871 |
+
"""切换 Token 启用状态"""
|
| 872 |
+
from app.services.token_dao import get_token_dao
|
| 873 |
+
from app.utils.token_pool import get_token_pool
|
| 874 |
+
|
| 875 |
+
dao = get_token_dao()
|
| 876 |
+
await dao.update_token_status(token_id, enabled)
|
| 877 |
+
|
| 878 |
+
# 同步 Token 池状态
|
| 879 |
+
pool = get_token_pool()
|
| 880 |
+
if pool:
|
| 881 |
+
# 获取 Token 的提供商信息
|
| 882 |
+
async with dao.get_connection() as conn:
|
| 883 |
+
cursor = await conn.execute("SELECT provider FROM tokens WHERE id = ?", (token_id,))
|
| 884 |
+
row = await cursor.fetchone()
|
| 885 |
+
if row:
|
| 886 |
+
provider = row[0]
|
| 887 |
+
await pool.sync_from_database(provider)
|
| 888 |
+
logger.info("✅ Token 池已同步")
|
| 889 |
+
|
| 890 |
+
# 根据状态返回不同样式的按钮
|
| 891 |
+
if enabled:
|
| 892 |
+
button_class = "bg-green-100 text-green-800 hover:bg-green-200"
|
| 893 |
+
indicator_class = "bg-green-500"
|
| 894 |
+
label = "已启用"
|
| 895 |
+
next_state = "false"
|
| 896 |
+
else:
|
| 897 |
+
button_class = "bg-red-100 text-red-800 hover:bg-red-200"
|
| 898 |
+
indicator_class = "bg-red-500"
|
| 899 |
+
label = "已禁用"
|
| 900 |
+
next_state = "true"
|
| 901 |
+
|
| 902 |
+
return HTMLResponse(f"""
|
| 903 |
+
<button hx-post="/admin/api/tokens/toggle/{token_id}?enabled={next_state}"
|
| 904 |
+
hx-swap="outerHTML"
|
| 905 |
+
class="inline-flex items-center px-2.5 py-0.5 text-xs font-semibold rounded-full transition-colors {button_class}">
|
| 906 |
+
<span class="h-2 w-2 rounded-full mr-1.5 {indicator_class}"></span>
|
| 907 |
+
{label}
|
| 908 |
+
</button>
|
| 909 |
+
""")
|
| 910 |
+
|
| 911 |
+
|
| 912 |
+
@router.delete("/tokens/delete/{token_id}")
|
| 913 |
+
async def delete_token(token_id: int):
|
| 914 |
+
"""删除 Token"""
|
| 915 |
+
from app.services.token_dao import get_token_dao
|
| 916 |
+
from app.utils.token_pool import get_token_pool
|
| 917 |
+
|
| 918 |
+
dao = get_token_dao()
|
| 919 |
+
|
| 920 |
+
# 获取 Token 信息以确定提供商
|
| 921 |
+
async with dao.get_connection() as conn:
|
| 922 |
+
cursor = await conn.execute("SELECT provider FROM tokens WHERE id = ?", (token_id,))
|
| 923 |
+
row = await cursor.fetchone()
|
| 924 |
+
provider = row[0] if row else "zai"
|
| 925 |
+
|
| 926 |
+
await dao.delete_token(token_id)
|
| 927 |
+
|
| 928 |
+
# 同步 Token 池状态
|
| 929 |
+
pool = get_token_pool()
|
| 930 |
+
if pool:
|
| 931 |
+
await pool.sync_from_database(provider)
|
| 932 |
+
logger.info("✅ Token 池已同步")
|
| 933 |
+
|
| 934 |
+
return HTMLResponse("") # 返回空内容,让 htmx 移除元素
|
| 935 |
+
|
| 936 |
+
|
| 937 |
+
@router.get("/tokens/stats", response_class=HTMLResponse)
|
| 938 |
+
async def get_tokens_stats(request: Request):
|
| 939 |
+
"""获取 Token 统计信息(HTML 片段)"""
|
| 940 |
+
stats_data = await collect_admin_stats(DEFAULT_TOKEN_NAMESPACE)
|
| 941 |
+
|
| 942 |
+
context = {
|
| 943 |
+
"request": request,
|
| 944 |
+
"stats": stats_data,
|
| 945 |
+
}
|
| 946 |
+
|
| 947 |
+
return templates.TemplateResponse("components/token_stats.html", context)
|
| 948 |
+
|
| 949 |
+
|
| 950 |
+
@router.post("/tokens/validate")
|
| 951 |
+
async def validate_tokens():
|
| 952 |
+
"""批量验证 Token"""
|
| 953 |
+
from app.services.token_dao import get_token_dao
|
| 954 |
+
from app.utils.token_pool import get_token_pool
|
| 955 |
+
|
| 956 |
+
dao = get_token_dao()
|
| 957 |
+
|
| 958 |
+
# 执行批量验证
|
| 959 |
+
stats = await dao.validate_all_tokens(DEFAULT_TOKEN_NAMESPACE)
|
| 960 |
+
|
| 961 |
+
pool = get_token_pool()
|
| 962 |
+
if pool:
|
| 963 |
+
await pool.sync_from_database(DEFAULT_TOKEN_NAMESPACE)
|
| 964 |
+
|
| 965 |
+
valid_count = stats.get("valid", 0)
|
| 966 |
+
guest_count = stats.get("guest", 0)
|
| 967 |
+
invalid_count = stats.get("invalid", 0)
|
| 968 |
+
|
| 969 |
+
# 生成通知消息
|
| 970 |
+
if guest_count > 0:
|
| 971 |
+
message_class = "bg-yellow-100 border-yellow-400 text-yellow-700"
|
| 972 |
+
message = f"验证完成:有效 {valid_count} 个,匿名 {guest_count} 个,无效 {invalid_count} 个。匿名 Token 已标记。"
|
| 973 |
+
elif invalid_count > 0:
|
| 974 |
+
message_class = "bg-blue-100 border-blue-400 text-blue-700"
|
| 975 |
+
message = f"验证完成:有效 {valid_count} 个,无效 {invalid_count} 个。"
|
| 976 |
+
else:
|
| 977 |
+
message_class = "bg-green-100 border-green-400 text-green-700"
|
| 978 |
+
message = f"验证完成:所有 {valid_count} 个 Token 均有效!"
|
| 979 |
+
|
| 980 |
+
return HTMLResponse(f"""
|
| 981 |
+
<div class="{message_class} border px-4 py-3 rounded relative" role="alert">
|
| 982 |
+
<strong class="font-bold">批量验证完成!</strong>
|
| 983 |
+
<span class="block sm:inline">{message}</span>
|
| 984 |
+
</div>
|
| 985 |
+
""")
|
| 986 |
+
|
| 987 |
+
|
| 988 |
+
@router.post("/tokens/validate-single/{token_id}")
|
| 989 |
+
async def validate_single_token(request: Request, token_id: int):
|
| 990 |
+
"""验证单个 Token 并返回更新后的行"""
|
| 991 |
+
from app.services.token_dao import get_token_dao
|
| 992 |
+
from app.utils.token_pool import get_token_pool
|
| 993 |
+
|
| 994 |
+
dao = get_token_dao()
|
| 995 |
+
|
| 996 |
+
# 验证 Token
|
| 997 |
+
await dao.validate_and_update_token(token_id)
|
| 998 |
+
|
| 999 |
+
pool = get_token_pool()
|
| 1000 |
+
if pool:
|
| 1001 |
+
await pool.sync_from_database(DEFAULT_TOKEN_NAMESPACE)
|
| 1002 |
+
|
| 1003 |
+
# 获取更新后的 Token 信息
|
| 1004 |
+
async with dao.get_connection() as conn:
|
| 1005 |
+
cursor = await conn.execute("""
|
| 1006 |
+
SELECT t.*, ts.total_requests, ts.successful_requests, ts.failed_requests,
|
| 1007 |
+
ts.last_success_time, ts.last_failure_time
|
| 1008 |
+
FROM tokens t
|
| 1009 |
+
LEFT JOIN token_stats ts ON t.id = ts.token_id
|
| 1010 |
+
WHERE t.id = ?
|
| 1011 |
+
""", (token_id,))
|
| 1012 |
+
row = await cursor.fetchone()
|
| 1013 |
+
|
| 1014 |
+
if row:
|
| 1015 |
+
# 返回更新后的单行 HTML
|
| 1016 |
+
token = dict(row)
|
| 1017 |
+
context = {
|
| 1018 |
+
"request": request,
|
| 1019 |
+
"token": token,
|
| 1020 |
+
}
|
| 1021 |
+
# 使用单行模板渲染
|
| 1022 |
+
return templates.TemplateResponse("components/token_row.html", context)
|
| 1023 |
+
else:
|
| 1024 |
+
return HTMLResponse("")
|
| 1025 |
+
|
| 1026 |
+
|
| 1027 |
+
@router.post("/tokens/health-check")
|
| 1028 |
+
async def health_check_tokens():
|
| 1029 |
+
"""执行 Token 池健康检查"""
|
| 1030 |
+
from app.utils.token_pool import get_token_pool
|
| 1031 |
+
|
| 1032 |
+
pool = get_token_pool()
|
| 1033 |
+
|
| 1034 |
+
if not pool:
|
| 1035 |
+
return HTMLResponse("""
|
| 1036 |
+
<div class="bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded relative" role="alert">
|
| 1037 |
+
<strong class="font-bold">提示!</strong>
|
| 1038 |
+
<span class="block sm:inline">Token 池未初始化,请重启服务。</span>
|
| 1039 |
+
</div>
|
| 1040 |
+
""")
|
| 1041 |
+
|
| 1042 |
+
# 执行健康检查
|
| 1043 |
+
await pool.health_check_all()
|
| 1044 |
+
|
| 1045 |
+
# 获取健康状态
|
| 1046 |
+
status = pool.get_pool_status()
|
| 1047 |
+
healthy_count = status.get("healthy_tokens", 0)
|
| 1048 |
+
total_count = status.get("total_tokens", 0)
|
| 1049 |
+
|
| 1050 |
+
if healthy_count == total_count:
|
| 1051 |
+
message_class = "bg-green-100 border-green-400 text-green-700"
|
| 1052 |
+
message = f"所有 {total_count} 个 Token 均健康!"
|
| 1053 |
+
elif healthy_count > 0:
|
| 1054 |
+
message_class = "bg-blue-100 border-blue-400 text-blue-700"
|
| 1055 |
+
message = f"健康检查完成:{healthy_count}/{total_count} 个 Token 健康。"
|
| 1056 |
+
else:
|
| 1057 |
+
message_class = "bg-red-100 border-red-400 text-red-700"
|
| 1058 |
+
message = f"警告:0/{total_count} 个 Token 健康,请检查配置。"
|
| 1059 |
+
|
| 1060 |
+
return HTMLResponse(f"""
|
| 1061 |
+
<div class="{message_class} border px-4 py-3 rounded relative" role="alert">
|
| 1062 |
+
<strong class="font-bold">健康检查完成!</strong>
|
| 1063 |
+
<span class="block sm:inline">{message}</span>
|
| 1064 |
+
</div>
|
| 1065 |
+
""")
|
| 1066 |
+
|
| 1067 |
+
|
| 1068 |
+
@router.post("/tokens/sync-pool")
|
| 1069 |
+
async def sync_token_pool():
|
| 1070 |
+
"""手动同步 Token 池(从数据库重新加载)"""
|
| 1071 |
+
from app.utils.token_pool import get_token_pool
|
| 1072 |
+
|
| 1073 |
+
pool = get_token_pool()
|
| 1074 |
+
|
| 1075 |
+
if not pool:
|
| 1076 |
+
return HTMLResponse("""
|
| 1077 |
+
<div class="bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded relative" role="alert">
|
| 1078 |
+
<strong class="font-bold">提示!</strong>
|
| 1079 |
+
<span class="block sm:inline">Token 池未初始化,请重启服务。</span>
|
| 1080 |
+
</div>
|
| 1081 |
+
""")
|
| 1082 |
+
|
| 1083 |
+
# 从数据库同步
|
| 1084 |
+
await pool.sync_from_database(DEFAULT_TOKEN_NAMESPACE)
|
| 1085 |
+
|
| 1086 |
+
# 获取同步后的状态
|
| 1087 |
+
status = pool.get_pool_status()
|
| 1088 |
+
total_count = status.get("total_tokens", 0)
|
| 1089 |
+
available_count = status.get("available_tokens", 0)
|
| 1090 |
+
user_count = status.get("user_tokens", 0)
|
| 1091 |
+
|
| 1092 |
+
logger.info(
|
| 1093 |
+
f"✅ Token 池手动同步完成,总计 {total_count} 个 Token, 可用 {available_count} 个, 认证用户 {user_count} 个"
|
| 1094 |
+
)
|
| 1095 |
+
|
| 1096 |
+
if total_count == 0:
|
| 1097 |
+
message_class = "bg-yellow-100 border-yellow-400 text-yellow-700"
|
| 1098 |
+
message = "同步完成:当前没有可用 Token,请在数据库中启用 Token。"
|
| 1099 |
+
elif available_count == 0:
|
| 1100 |
+
message_class = "bg-orange-100 border-orange-400 text-orange-700"
|
| 1101 |
+
message = f"同步完成:共 {total_count} 个 Token,但无可用 Token(可能都已禁用)。"
|
| 1102 |
+
else:
|
| 1103 |
+
message_class = "bg-green-100 border-green-400 text-green-700"
|
| 1104 |
+
message = f"同步完成:共 {total_count} 个 Token,{available_count} 个可用,{user_count} 个认证用户。"
|
| 1105 |
+
|
| 1106 |
+
return HTMLResponse(f"""
|
| 1107 |
+
<div class="{message_class} border px-4 py-3 rounded relative" role="alert">
|
| 1108 |
+
<strong class="font-bold">Token 池同步完成!</strong>
|
| 1109 |
+
<span class="block sm:inline">{message}</span>
|
| 1110 |
+
</div>
|
| 1111 |
+
""")
|
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/config_manager.py
ADDED
|
@@ -0,0 +1,682 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Admin config metadata and helpers for the configuration console."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import re
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Any, Awaitable, Callable, Mapping
|
| 9 |
+
|
| 10 |
+
from dotenv import dotenv_values
|
| 11 |
+
|
| 12 |
+
from app.core.config import settings
|
| 13 |
+
from app.utils.env_file import update_env_file
|
| 14 |
+
from app.utils.logger import logger
|
| 15 |
+
|
| 16 |
+
ENV_PATH = Path(".env")
|
| 17 |
+
ENV_EXAMPLE_PATH = Path(".env.example")
|
| 18 |
+
_ENV_SOURCE_LINE_PATTERN = re.compile(
|
| 19 |
+
r"^\s*(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=.*$"
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass(frozen=True)
|
| 24 |
+
class ConfigFieldSpec:
|
| 25 |
+
key: str
|
| 26 |
+
label: str
|
| 27 |
+
description: str
|
| 28 |
+
value_type: str
|
| 29 |
+
default_value: object
|
| 30 |
+
input_type: str = "text"
|
| 31 |
+
placeholder: str = ""
|
| 32 |
+
required: bool = False
|
| 33 |
+
wide: bool = False
|
| 34 |
+
sensitive: bool = False
|
| 35 |
+
restart_required: bool = False
|
| 36 |
+
min_value: int | None = None
|
| 37 |
+
max_value: int | None = None
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@dataclass(frozen=True)
|
| 41 |
+
class ConfigSectionSpec:
|
| 42 |
+
id: str
|
| 43 |
+
title: str
|
| 44 |
+
description: str
|
| 45 |
+
fields: tuple[ConfigFieldSpec, ...]
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
CONFIG_SECTIONS: tuple[ConfigSectionSpec, ...] = (
|
| 49 |
+
ConfigSectionSpec(
|
| 50 |
+
id="access",
|
| 51 |
+
title="接入与认证",
|
| 52 |
+
description="控制上游接口地址、客户端鉴权和 Function Call 行为。",
|
| 53 |
+
fields=(
|
| 54 |
+
ConfigFieldSpec(
|
| 55 |
+
key="API_ENDPOINT",
|
| 56 |
+
label="上游 API 地址",
|
| 57 |
+
description="代理请求实际转发到的上游聊天完成接口。",
|
| 58 |
+
value_type="str",
|
| 59 |
+
default_value="https://chat.z.ai/api/v2/chat/completions",
|
| 60 |
+
input_type="url",
|
| 61 |
+
placeholder="https://chat.z.ai/api/v2/chat/completions",
|
| 62 |
+
required=True,
|
| 63 |
+
wide=True,
|
| 64 |
+
),
|
| 65 |
+
ConfigFieldSpec(
|
| 66 |
+
key="AUTH_TOKEN",
|
| 67 |
+
label="客户端认证密钥",
|
| 68 |
+
description="客户端访问本服务时使用的 Bearer Token。",
|
| 69 |
+
value_type="str",
|
| 70 |
+
default_value="sk-your-api-key",
|
| 71 |
+
input_type="password",
|
| 72 |
+
placeholder="sk-your-api-key",
|
| 73 |
+
wide=True,
|
| 74 |
+
sensitive=True,
|
| 75 |
+
),
|
| 76 |
+
ConfigFieldSpec(
|
| 77 |
+
key="SKIP_AUTH_TOKEN",
|
| 78 |
+
label="跳过客户端认证",
|
| 79 |
+
description="仅建议开发环境使用,开启后不校验 AUTH_TOKEN。",
|
| 80 |
+
value_type="bool",
|
| 81 |
+
default_value=False,
|
| 82 |
+
),
|
| 83 |
+
ConfigFieldSpec(
|
| 84 |
+
key="TOOL_SUPPORT",
|
| 85 |
+
label="启用 Function Call",
|
| 86 |
+
description="允许 OpenAI 兼容接口使用工具调用能力。",
|
| 87 |
+
value_type="bool",
|
| 88 |
+
default_value=True,
|
| 89 |
+
),
|
| 90 |
+
ConfigFieldSpec(
|
| 91 |
+
key="SCAN_LIMIT",
|
| 92 |
+
label="工具调用扫描限制",
|
| 93 |
+
description="Function Call 扫描的最大字符数。",
|
| 94 |
+
value_type="int",
|
| 95 |
+
default_value=200000,
|
| 96 |
+
input_type="number",
|
| 97 |
+
min_value=1,
|
| 98 |
+
placeholder="200000",
|
| 99 |
+
),
|
| 100 |
+
),
|
| 101 |
+
),
|
| 102 |
+
ConfigSectionSpec(
|
| 103 |
+
id="server",
|
| 104 |
+
title="服务运行",
|
| 105 |
+
description="服务监听、日志、数据库路径和反向代理前缀。",
|
| 106 |
+
fields=(
|
| 107 |
+
ConfigFieldSpec(
|
| 108 |
+
key="SERVICE_NAME",
|
| 109 |
+
label="服务名称",
|
| 110 |
+
description="显示在进程列表中的服务名称。",
|
| 111 |
+
value_type="str",
|
| 112 |
+
default_value="api-proxy-server",
|
| 113 |
+
placeholder="api-proxy-server",
|
| 114 |
+
required=True,
|
| 115 |
+
restart_required=True,
|
| 116 |
+
),
|
| 117 |
+
ConfigFieldSpec(
|
| 118 |
+
key="LISTEN_PORT",
|
| 119 |
+
label="监听端口",
|
| 120 |
+
description="HTTP 服务监听端口。",
|
| 121 |
+
value_type="int",
|
| 122 |
+
default_value=8080,
|
| 123 |
+
input_type="number",
|
| 124 |
+
min_value=1,
|
| 125 |
+
max_value=65535,
|
| 126 |
+
required=True,
|
| 127 |
+
restart_required=True,
|
| 128 |
+
placeholder="8080",
|
| 129 |
+
),
|
| 130 |
+
ConfigFieldSpec(
|
| 131 |
+
key="ROOT_PATH",
|
| 132 |
+
label="反向代理路径前缀",
|
| 133 |
+
description="例如 /api,部署在子路径时使用。",
|
| 134 |
+
value_type="str",
|
| 135 |
+
default_value="",
|
| 136 |
+
placeholder="/api",
|
| 137 |
+
restart_required=True,
|
| 138 |
+
),
|
| 139 |
+
ConfigFieldSpec(
|
| 140 |
+
key="DEBUG_LOGGING",
|
| 141 |
+
label="启用调试日志",
|
| 142 |
+
description="开启后会输出更详细的调试信息。",
|
| 143 |
+
value_type="bool",
|
| 144 |
+
default_value=False,
|
| 145 |
+
),
|
| 146 |
+
ConfigFieldSpec(
|
| 147 |
+
key="DB_PATH",
|
| 148 |
+
label="数据库路径",
|
| 149 |
+
description="SQLite 数据库文件位置。",
|
| 150 |
+
value_type="str",
|
| 151 |
+
default_value="tokens.db",
|
| 152 |
+
placeholder="tokens.db",
|
| 153 |
+
required=True,
|
| 154 |
+
wide=True,
|
| 155 |
+
restart_required=True,
|
| 156 |
+
),
|
| 157 |
+
),
|
| 158 |
+
),
|
| 159 |
+
ConfigSectionSpec(
|
| 160 |
+
id="tokens",
|
| 161 |
+
title="Token 池策略",
|
| 162 |
+
description="失败判定、恢复时间和自动导入、自动维护计划任务。",
|
| 163 |
+
fields=(
|
| 164 |
+
ConfigFieldSpec(
|
| 165 |
+
key="TOKEN_FAILURE_THRESHOLD",
|
| 166 |
+
label="失败阈值",
|
| 167 |
+
description="连续失败多少次后将 Token 标记为不可用。",
|
| 168 |
+
value_type="int",
|
| 169 |
+
default_value=3,
|
| 170 |
+
input_type="number",
|
| 171 |
+
min_value=1,
|
| 172 |
+
required=True,
|
| 173 |
+
restart_required=True,
|
| 174 |
+
),
|
| 175 |
+
ConfigFieldSpec(
|
| 176 |
+
key="TOKEN_RECOVERY_TIMEOUT",
|
| 177 |
+
label="恢复超时(秒)",
|
| 178 |
+
description="失败 Token 重新参与调度前的等待时间。",
|
| 179 |
+
value_type="int",
|
| 180 |
+
default_value=1800,
|
| 181 |
+
input_type="number",
|
| 182 |
+
min_value=1,
|
| 183 |
+
required=True,
|
| 184 |
+
restart_required=True,
|
| 185 |
+
),
|
| 186 |
+
ConfigFieldSpec(
|
| 187 |
+
key="TOKEN_AUTO_IMPORT_ENABLED",
|
| 188 |
+
label="启用自动导入",
|
| 189 |
+
description="按固定周期扫描服务端目录并导入 Token。",
|
| 190 |
+
value_type="bool",
|
| 191 |
+
default_value=False,
|
| 192 |
+
),
|
| 193 |
+
ConfigFieldSpec(
|
| 194 |
+
key="TOKEN_AUTO_IMPORT_SOURCE_DIR",
|
| 195 |
+
label="自动导入目录",
|
| 196 |
+
description="服务端本地目录,开启自动导入时需要可访问。",
|
| 197 |
+
value_type="str",
|
| 198 |
+
default_value="",
|
| 199 |
+
placeholder="E:\\tokens\\input",
|
| 200 |
+
wide=True,
|
| 201 |
+
),
|
| 202 |
+
ConfigFieldSpec(
|
| 203 |
+
key="TOKEN_AUTO_IMPORT_INTERVAL",
|
| 204 |
+
label="自动导入间隔(秒)",
|
| 205 |
+
description="自动导入的扫描周期。",
|
| 206 |
+
value_type="int",
|
| 207 |
+
default_value=300,
|
| 208 |
+
input_type="number",
|
| 209 |
+
min_value=1,
|
| 210 |
+
required=True,
|
| 211 |
+
),
|
| 212 |
+
ConfigFieldSpec(
|
| 213 |
+
key="TOKEN_AUTO_MAINTENANCE_ENABLED",
|
| 214 |
+
label="启用自动维护",
|
| 215 |
+
description="定时执行去重、健康检查和删除失效 Token。",
|
| 216 |
+
value_type="bool",
|
| 217 |
+
default_value=False,
|
| 218 |
+
),
|
| 219 |
+
ConfigFieldSpec(
|
| 220 |
+
key="TOKEN_AUTO_MAINTENANCE_INTERVAL",
|
| 221 |
+
label="自动维护间隔(秒)",
|
| 222 |
+
description="自动维护的执行周期。",
|
| 223 |
+
value_type="int",
|
| 224 |
+
default_value=1800,
|
| 225 |
+
input_type="number",
|
| 226 |
+
min_value=1,
|
| 227 |
+
required=True,
|
| 228 |
+
),
|
| 229 |
+
ConfigFieldSpec(
|
| 230 |
+
key="TOKEN_AUTO_REMOVE_DUPLICATES",
|
| 231 |
+
label="自动去重",
|
| 232 |
+
description="自动维护时清理重复 Token。",
|
| 233 |
+
value_type="bool",
|
| 234 |
+
default_value=True,
|
| 235 |
+
),
|
| 236 |
+
ConfigFieldSpec(
|
| 237 |
+
key="TOKEN_AUTO_HEALTH_CHECK",
|
| 238 |
+
label="自动健康检查",
|
| 239 |
+
description="自动维护时验证 Token 可用性。",
|
| 240 |
+
value_type="bool",
|
| 241 |
+
default_value=True,
|
| 242 |
+
),
|
| 243 |
+
ConfigFieldSpec(
|
| 244 |
+
key="TOKEN_AUTO_DELETE_INVALID",
|
| 245 |
+
label="自动删除失效 Token",
|
| 246 |
+
description="自动维护时移除已验证为无效的 Token。",
|
| 247 |
+
value_type="bool",
|
| 248 |
+
default_value=False,
|
| 249 |
+
),
|
| 250 |
+
),
|
| 251 |
+
),
|
| 252 |
+
ConfigSectionSpec(
|
| 253 |
+
id="guest",
|
| 254 |
+
title="匿名 Guest 会话池",
|
| 255 |
+
description="没有用户 Token 时,仅控制是否启用匿名池和池容量。",
|
| 256 |
+
fields=(
|
| 257 |
+
ConfigFieldSpec(
|
| 258 |
+
key="ANONYMOUS_MODE",
|
| 259 |
+
label="启用匿名模式",
|
| 260 |
+
description="无可用用户 Token 时允许使用匿名会话。",
|
| 261 |
+
value_type="bool",
|
| 262 |
+
default_value=True,
|
| 263 |
+
restart_required=True,
|
| 264 |
+
),
|
| 265 |
+
ConfigFieldSpec(
|
| 266 |
+
key="GUEST_POOL_SIZE",
|
| 267 |
+
label="Guest 池容量",
|
| 268 |
+
description="启动和维持的 guest 会话数量。",
|
| 269 |
+
value_type="int",
|
| 270 |
+
default_value=3,
|
| 271 |
+
input_type="number",
|
| 272 |
+
min_value=1,
|
| 273 |
+
required=True,
|
| 274 |
+
restart_required=True,
|
| 275 |
+
),
|
| 276 |
+
),
|
| 277 |
+
),
|
| 278 |
+
ConfigSectionSpec(
|
| 279 |
+
id="models",
|
| 280 |
+
title="模型映射",
|
| 281 |
+
description="映射 OpenAI 兼容模型名到上游 Z.AI 实际模型名。",
|
| 282 |
+
fields=(
|
| 283 |
+
ConfigFieldSpec(
|
| 284 |
+
key="GLM45_MODEL",
|
| 285 |
+
label="GLM 4.5",
|
| 286 |
+
description="标准 GLM 4.5 模型标识。",
|
| 287 |
+
value_type="str",
|
| 288 |
+
default_value="GLM-4.5",
|
| 289 |
+
placeholder="GLM-4.5",
|
| 290 |
+
required=True,
|
| 291 |
+
),
|
| 292 |
+
ConfigFieldSpec(
|
| 293 |
+
key="GLM45_THINKING_MODEL",
|
| 294 |
+
label="GLM 4.5 Thinking",
|
| 295 |
+
description="推理增强版 GLM 4.5 模型标识。",
|
| 296 |
+
value_type="str",
|
| 297 |
+
default_value="GLM-4.5-Thinking",
|
| 298 |
+
placeholder="GLM-4.5-Thinking",
|
| 299 |
+
required=True,
|
| 300 |
+
),
|
| 301 |
+
ConfigFieldSpec(
|
| 302 |
+
key="GLM45_SEARCH_MODEL",
|
| 303 |
+
label="GLM 4.5 Search",
|
| 304 |
+
description="搜索增强版 GLM 4.5 模型标识。",
|
| 305 |
+
value_type="str",
|
| 306 |
+
default_value="GLM-4.5-Search",
|
| 307 |
+
placeholder="GLM-4.5-Search",
|
| 308 |
+
required=True,
|
| 309 |
+
),
|
| 310 |
+
ConfigFieldSpec(
|
| 311 |
+
key="GLM45_AIR_MODEL",
|
| 312 |
+
label="GLM 4.5 Air",
|
| 313 |
+
description="轻量版 GLM 4.5 模型标识。",
|
| 314 |
+
value_type="str",
|
| 315 |
+
default_value="GLM-4.5-Air",
|
| 316 |
+
placeholder="GLM-4.5-Air",
|
| 317 |
+
required=True,
|
| 318 |
+
),
|
| 319 |
+
ConfigFieldSpec(
|
| 320 |
+
key="GLM46V_MODEL",
|
| 321 |
+
label="GLM 4.6V",
|
| 322 |
+
description="视觉模型标识。",
|
| 323 |
+
value_type="str",
|
| 324 |
+
default_value="GLM-4.6V",
|
| 325 |
+
placeholder="GLM-4.6V",
|
| 326 |
+
required=True,
|
| 327 |
+
),
|
| 328 |
+
ConfigFieldSpec(
|
| 329 |
+
key="GLM5_MODEL",
|
| 330 |
+
label="GLM 5",
|
| 331 |
+
description="GLM 5 模型标识。",
|
| 332 |
+
value_type="str",
|
| 333 |
+
default_value="GLM-5",
|
| 334 |
+
placeholder="GLM-5",
|
| 335 |
+
required=True,
|
| 336 |
+
),
|
| 337 |
+
ConfigFieldSpec(
|
| 338 |
+
key="GLM47_MODEL",
|
| 339 |
+
label="GLM 4.7",
|
| 340 |
+
description="GLM 4.7 主模型标识。",
|
| 341 |
+
value_type="str",
|
| 342 |
+
default_value="GLM-4.7",
|
| 343 |
+
placeholder="GLM-4.7",
|
| 344 |
+
required=True,
|
| 345 |
+
),
|
| 346 |
+
ConfigFieldSpec(
|
| 347 |
+
key="GLM47_THINKING_MODEL",
|
| 348 |
+
label="GLM 4.7 Thinking",
|
| 349 |
+
description="GLM 4.7 推理版模型标识。",
|
| 350 |
+
value_type="str",
|
| 351 |
+
default_value="GLM-4.7-Thinking",
|
| 352 |
+
placeholder="GLM-4.7-Thinking",
|
| 353 |
+
required=True,
|
| 354 |
+
),
|
| 355 |
+
ConfigFieldSpec(
|
| 356 |
+
key="GLM47_SEARCH_MODEL",
|
| 357 |
+
label="GLM 4.7 Search",
|
| 358 |
+
description="GLM 4.7 搜索版模型标识。",
|
| 359 |
+
value_type="str",
|
| 360 |
+
default_value="GLM-4.7-Search",
|
| 361 |
+
placeholder="GLM-4.7-Search",
|
| 362 |
+
required=True,
|
| 363 |
+
),
|
| 364 |
+
ConfigFieldSpec(
|
| 365 |
+
key="GLM47_ADVANCED_SEARCH_MODEL",
|
| 366 |
+
label="GLM 4.7 Advanced Search",
|
| 367 |
+
description="GLM 4.7 高级搜索模型标识。",
|
| 368 |
+
value_type="str",
|
| 369 |
+
default_value="GLM-4.7-advanced-search",
|
| 370 |
+
placeholder="GLM-4.7-advanced-search",
|
| 371 |
+
required=True,
|
| 372 |
+
wide=True,
|
| 373 |
+
),
|
| 374 |
+
),
|
| 375 |
+
),
|
| 376 |
+
ConfigSectionSpec(
|
| 377 |
+
id="proxy",
|
| 378 |
+
title="代理网络",
|
| 379 |
+
description="上游访问使用的 HTTP、HTTPS 和 SOCKS5 代理。",
|
| 380 |
+
fields=(
|
| 381 |
+
ConfigFieldSpec(
|
| 382 |
+
key="HTTP_PROXY",
|
| 383 |
+
label="HTTP 代理",
|
| 384 |
+
description="例如 http://127.0.0.1:7890。",
|
| 385 |
+
value_type="str",
|
| 386 |
+
default_value="",
|
| 387 |
+
placeholder="http://127.0.0.1:7890",
|
| 388 |
+
wide=True,
|
| 389 |
+
),
|
| 390 |
+
ConfigFieldSpec(
|
| 391 |
+
key="HTTPS_PROXY",
|
| 392 |
+
label="HTTPS 代理",
|
| 393 |
+
description="例如 http://127.0.0.1:7890。",
|
| 394 |
+
value_type="str",
|
| 395 |
+
default_value="",
|
| 396 |
+
placeholder="http://127.0.0.1:7890",
|
| 397 |
+
wide=True,
|
| 398 |
+
),
|
| 399 |
+
ConfigFieldSpec(
|
| 400 |
+
key="SOCKS5_PROXY",
|
| 401 |
+
label="SOCKS5 代理",
|
| 402 |
+
description="例如 socks5://127.0.0.1:1080。",
|
| 403 |
+
value_type="str",
|
| 404 |
+
default_value="",
|
| 405 |
+
placeholder="socks5://127.0.0.1:1080",
|
| 406 |
+
wide=True,
|
| 407 |
+
),
|
| 408 |
+
),
|
| 409 |
+
),
|
| 410 |
+
ConfigSectionSpec(
|
| 411 |
+
id="admin",
|
| 412 |
+
title="后台安全",
|
| 413 |
+
description="管理后台密码和会话密钥。修改后建议重新登录。",
|
| 414 |
+
fields=(
|
| 415 |
+
ConfigFieldSpec(
|
| 416 |
+
key="ADMIN_PASSWORD",
|
| 417 |
+
label="后台密码",
|
| 418 |
+
description="管理后台登录密码。",
|
| 419 |
+
value_type="str",
|
| 420 |
+
default_value="admin123",
|
| 421 |
+
input_type="password",
|
| 422 |
+
placeholder="admin123",
|
| 423 |
+
required=True,
|
| 424 |
+
sensitive=True,
|
| 425 |
+
),
|
| 426 |
+
ConfigFieldSpec(
|
| 427 |
+
key="SESSION_SECRET_KEY",
|
| 428 |
+
label="会话密钥",
|
| 429 |
+
description="用于后台会话签名的密钥。",
|
| 430 |
+
value_type="str",
|
| 431 |
+
default_value="your-secret-key-change-in-production",
|
| 432 |
+
input_type="password",
|
| 433 |
+
placeholder="your-secret-key-change-in-production",
|
| 434 |
+
required=True,
|
| 435 |
+
sensitive=True,
|
| 436 |
+
wide=True,
|
| 437 |
+
),
|
| 438 |
+
),
|
| 439 |
+
),
|
| 440 |
+
)
|
| 441 |
+
|
| 442 |
+
CONFIG_FIELD_SPECS = {
|
| 443 |
+
field.key: field
|
| 444 |
+
for section in CONFIG_SECTIONS
|
| 445 |
+
for field in section.fields
|
| 446 |
+
}
|
| 447 |
+
MANAGED_ENV_KEYS = tuple(CONFIG_FIELD_SPECS.keys())
|
| 448 |
+
ReloadCallback = Callable[[], Awaitable[None]]
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
def read_env_content(env_path: str | Path = ENV_PATH) -> str:
|
| 452 |
+
path = Path(env_path)
|
| 453 |
+
if not path.exists():
|
| 454 |
+
return ""
|
| 455 |
+
return path.read_text(encoding="utf-8")
|
| 456 |
+
|
| 457 |
+
|
| 458 |
+
def validate_env_source(content: str) -> str:
|
| 459 |
+
normalized = content.replace("\r\n", "\n").replace("\r", "\n")
|
| 460 |
+
|
| 461 |
+
for line_number, line in enumerate(normalized.splitlines(), start=1):
|
| 462 |
+
stripped = line.strip()
|
| 463 |
+
if not stripped or stripped.startswith("#"):
|
| 464 |
+
continue
|
| 465 |
+
if not _ENV_SOURCE_LINE_PATTERN.match(line):
|
| 466 |
+
raise ValueError(
|
| 467 |
+
f"第 {line_number} 行不是合法的 KEY=VALUE 格式。"
|
| 468 |
+
)
|
| 469 |
+
|
| 470 |
+
return normalized
|
| 471 |
+
|
| 472 |
+
|
| 473 |
+
def build_config_page_data(
|
| 474 |
+
*,
|
| 475 |
+
settings_obj: Any = settings,
|
| 476 |
+
env_path: str | Path = ENV_PATH,
|
| 477 |
+
env_example_path: str | Path = ENV_EXAMPLE_PATH,
|
| 478 |
+
) -> dict[str, Any]:
|
| 479 |
+
env_file = Path(env_path)
|
| 480 |
+
env_content = read_env_content(env_file)
|
| 481 |
+
env_values = dotenv_values(env_file) if env_file.exists() else {}
|
| 482 |
+
sections: list[dict[str, Any]] = []
|
| 483 |
+
total_fields = 0
|
| 484 |
+
overridden_fields = 0
|
| 485 |
+
sensitive_fields = 0
|
| 486 |
+
restart_required_fields = 0
|
| 487 |
+
|
| 488 |
+
for section in CONFIG_SECTIONS:
|
| 489 |
+
rendered_fields: list[dict[str, Any]] = []
|
| 490 |
+
for field in section.fields:
|
| 491 |
+
total_fields += 1
|
| 492 |
+
if field.sensitive:
|
| 493 |
+
sensitive_fields += 1
|
| 494 |
+
if field.restart_required:
|
| 495 |
+
restart_required_fields += 1
|
| 496 |
+
|
| 497 |
+
is_overridden = field.key in env_values
|
| 498 |
+
if is_overridden:
|
| 499 |
+
overridden_fields += 1
|
| 500 |
+
|
| 501 |
+
value = getattr(settings_obj, field.key, field.default_value)
|
| 502 |
+
if value is None:
|
| 503 |
+
value = ""
|
| 504 |
+
|
| 505 |
+
rendered_fields.append(
|
| 506 |
+
{
|
| 507 |
+
"key": field.key,
|
| 508 |
+
"label": field.label,
|
| 509 |
+
"description": field.description,
|
| 510 |
+
"value_type": field.value_type,
|
| 511 |
+
"value": value,
|
| 512 |
+
"input_type": field.input_type,
|
| 513 |
+
"placeholder": field.placeholder,
|
| 514 |
+
"required": field.required,
|
| 515 |
+
"wide": field.wide,
|
| 516 |
+
"sensitive": field.sensitive,
|
| 517 |
+
"restart_required": field.restart_required,
|
| 518 |
+
"min_value": field.min_value,
|
| 519 |
+
"max_value": field.max_value,
|
| 520 |
+
"source_label": ".env" if is_overridden else "默认值",
|
| 521 |
+
"source_badge_class": (
|
| 522 |
+
"bg-emerald-50 text-emerald-700 ring-emerald-200"
|
| 523 |
+
if is_overridden
|
| 524 |
+
else "bg-slate-100 text-slate-600 ring-slate-200"
|
| 525 |
+
),
|
| 526 |
+
}
|
| 527 |
+
)
|
| 528 |
+
|
| 529 |
+
sections.append(
|
| 530 |
+
{
|
| 531 |
+
"id": section.id,
|
| 532 |
+
"title": section.title,
|
| 533 |
+
"description": section.description,
|
| 534 |
+
"fields": rendered_fields,
|
| 535 |
+
"field_count": len(rendered_fields),
|
| 536 |
+
}
|
| 537 |
+
)
|
| 538 |
+
|
| 539 |
+
return {
|
| 540 |
+
"sections": sections,
|
| 541 |
+
"env_content": env_content,
|
| 542 |
+
"overview": {
|
| 543 |
+
"total_sections": len(CONFIG_SECTIONS),
|
| 544 |
+
"total_fields": total_fields,
|
| 545 |
+
"overridden_fields": overridden_fields,
|
| 546 |
+
"default_fields": total_fields - overridden_fields,
|
| 547 |
+
"sensitive_fields": sensitive_fields,
|
| 548 |
+
"restart_required_fields": restart_required_fields,
|
| 549 |
+
"env_exists": env_file.exists(),
|
| 550 |
+
"env_path": str(env_file.resolve()),
|
| 551 |
+
"env_line_count": len(env_content.splitlines()) if env_content else 0,
|
| 552 |
+
"example_exists": Path(env_example_path).exists(),
|
| 553 |
+
},
|
| 554 |
+
}
|
| 555 |
+
|
| 556 |
+
|
| 557 |
+
def build_form_updates(form_data: Mapping[str, Any]) -> dict[str, object]:
|
| 558 |
+
updates: dict[str, object] = {}
|
| 559 |
+
|
| 560 |
+
for key in MANAGED_ENV_KEYS:
|
| 561 |
+
field = CONFIG_FIELD_SPECS[key]
|
| 562 |
+
|
| 563 |
+
if field.value_type == "bool":
|
| 564 |
+
updates[key] = key in form_data
|
| 565 |
+
continue
|
| 566 |
+
|
| 567 |
+
raw_value = str(form_data.get(key, "") or "").strip()
|
| 568 |
+
if field.required and raw_value == "":
|
| 569 |
+
raise ValueError(f"{field.label} 不能为空。")
|
| 570 |
+
|
| 571 |
+
if field.value_type == "int":
|
| 572 |
+
try:
|
| 573 |
+
parsed = int(raw_value)
|
| 574 |
+
except ValueError as exc:
|
| 575 |
+
raise ValueError(f"{field.label} 必须是整数。") from exc
|
| 576 |
+
|
| 577 |
+
if field.min_value is not None and parsed < field.min_value:
|
| 578 |
+
raise ValueError(
|
| 579 |
+
f"{field.label} 不能小于 {field.min_value}。"
|
| 580 |
+
)
|
| 581 |
+
if field.max_value is not None and parsed > field.max_value:
|
| 582 |
+
raise ValueError(
|
| 583 |
+
f"{field.label} 不能大于 {field.max_value}。"
|
| 584 |
+
)
|
| 585 |
+
updates[key] = parsed
|
| 586 |
+
continue
|
| 587 |
+
|
| 588 |
+
updates[key] = raw_value
|
| 589 |
+
|
| 590 |
+
return updates
|
| 591 |
+
|
| 592 |
+
|
| 593 |
+
async def _apply_env_change(
|
| 594 |
+
writer: Callable[[Path], None],
|
| 595 |
+
*,
|
| 596 |
+
reload_callback: ReloadCallback,
|
| 597 |
+
env_path: str | Path = ENV_PATH,
|
| 598 |
+
) -> None:
|
| 599 |
+
path = Path(env_path)
|
| 600 |
+
had_existing_file = path.exists()
|
| 601 |
+
previous_content = read_env_content(path) if had_existing_file else ""
|
| 602 |
+
|
| 603 |
+
try:
|
| 604 |
+
writer(path)
|
| 605 |
+
await reload_callback()
|
| 606 |
+
except Exception:
|
| 607 |
+
if had_existing_file:
|
| 608 |
+
path.write_text(previous_content, encoding="utf-8")
|
| 609 |
+
elif path.exists():
|
| 610 |
+
path.unlink()
|
| 611 |
+
|
| 612 |
+
try:
|
| 613 |
+
await reload_callback()
|
| 614 |
+
except Exception as restore_exc:
|
| 615 |
+
logger.warning(f"⚠️ 回滚配置后重新加载失败: {restore_exc}")
|
| 616 |
+
raise
|
| 617 |
+
|
| 618 |
+
|
| 619 |
+
async def save_form_config(
|
| 620 |
+
form_data: Mapping[str, Any],
|
| 621 |
+
*,
|
| 622 |
+
reload_callback: ReloadCallback,
|
| 623 |
+
env_path: str | Path = ENV_PATH,
|
| 624 |
+
) -> dict[str, object]:
|
| 625 |
+
updates = build_form_updates(form_data)
|
| 626 |
+
|
| 627 |
+
async def _reload() -> None:
|
| 628 |
+
await reload_callback()
|
| 629 |
+
|
| 630 |
+
def _writer(target_path: Path) -> None:
|
| 631 |
+
update_env_file(updates, env_path=target_path)
|
| 632 |
+
|
| 633 |
+
await _apply_env_change(_writer, reload_callback=_reload, env_path=env_path)
|
| 634 |
+
return updates
|
| 635 |
+
|
| 636 |
+
|
| 637 |
+
async def save_source_config(
|
| 638 |
+
env_content: str,
|
| 639 |
+
*,
|
| 640 |
+
reload_callback: ReloadCallback,
|
| 641 |
+
env_path: str | Path = ENV_PATH,
|
| 642 |
+
) -> None:
|
| 643 |
+
normalized = validate_env_source(env_content)
|
| 644 |
+
|
| 645 |
+
def _writer(target_path: Path) -> None:
|
| 646 |
+
content = normalized.rstrip("\n")
|
| 647 |
+
target_path.write_text(
|
| 648 |
+
f"{content}\n" if content else "",
|
| 649 |
+
encoding="utf-8",
|
| 650 |
+
)
|
| 651 |
+
|
| 652 |
+
await _apply_env_change(
|
| 653 |
+
_writer,
|
| 654 |
+
reload_callback=reload_callback,
|
| 655 |
+
env_path=env_path,
|
| 656 |
+
)
|
| 657 |
+
|
| 658 |
+
|
| 659 |
+
async def reset_env_to_example(
|
| 660 |
+
*,
|
| 661 |
+
reload_callback: ReloadCallback,
|
| 662 |
+
env_path: str | Path = ENV_PATH,
|
| 663 |
+
env_example_path: str | Path = ENV_EXAMPLE_PATH,
|
| 664 |
+
) -> None:
|
| 665 |
+
example_path = Path(env_example_path)
|
| 666 |
+
if not example_path.exists():
|
| 667 |
+
raise FileNotFoundError(".env.example 不存在")
|
| 668 |
+
|
| 669 |
+
example_content = example_path.read_text(encoding="utf-8")
|
| 670 |
+
|
| 671 |
+
def _writer(target_path: Path) -> None:
|
| 672 |
+
content = example_content.rstrip("\n")
|
| 673 |
+
target_path.write_text(
|
| 674 |
+
f"{content}\n" if content else "",
|
| 675 |
+
encoding="utf-8",
|
| 676 |
+
)
|
| 677 |
+
|
| 678 |
+
await _apply_env_change(
|
| 679 |
+
_writer,
|
| 680 |
+
reload_callback=reload_callback,
|
| 681 |
+
env_path=env_path,
|
| 682 |
+
)
|
app/admin/routes.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
管理后台路由模块
|
| 3 |
+
"""
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter, Depends, Request
|
| 7 |
+
from fastapi.responses import HTMLResponse
|
| 8 |
+
from fastapi.templating import Jinja2Templates
|
| 9 |
+
|
| 10 |
+
from app.admin.auth import require_auth
|
| 11 |
+
from app.admin.config_manager import build_config_page_data
|
| 12 |
+
from app.admin.stats import (
|
| 13 |
+
DEFAULT_TREND_WINDOW,
|
| 14 |
+
TREND_WINDOW_OPTIONS,
|
| 15 |
+
collect_admin_stats,
|
| 16 |
+
get_process_uptime,
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
router = APIRouter(prefix="/admin", tags=["admin"])
|
| 20 |
+
templates = Jinja2Templates(directory="app/templates")
|
| 21 |
+
DEFAULT_TOKEN_NAMESPACE = "zai"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@router.get("/login", response_class=HTMLResponse)
|
| 25 |
+
async def login_page(request: Request):
|
| 26 |
+
"""登录页面"""
|
| 27 |
+
return templates.TemplateResponse("login.html", {"request": request})
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@router.get("/", response_class=HTMLResponse, dependencies=[Depends(require_auth)])
|
| 31 |
+
async def dashboard(request: Request):
|
| 32 |
+
"""仪表盘首页"""
|
| 33 |
+
stats = await collect_admin_stats(
|
| 34 |
+
DEFAULT_TOKEN_NAMESPACE,
|
| 35 |
+
trend_window=DEFAULT_TREND_WINDOW,
|
| 36 |
+
)
|
| 37 |
+
stats["uptime"] = get_process_uptime()
|
| 38 |
+
|
| 39 |
+
context = {
|
| 40 |
+
"request": request,
|
| 41 |
+
"stats": stats,
|
| 42 |
+
"current_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
| 43 |
+
"trend_windows": TREND_WINDOW_OPTIONS,
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
return templates.TemplateResponse("index.html", context)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@router.get(
|
| 50 |
+
"/config",
|
| 51 |
+
response_class=HTMLResponse,
|
| 52 |
+
dependencies=[Depends(require_auth)],
|
| 53 |
+
)
|
| 54 |
+
async def config_page(request: Request):
|
| 55 |
+
"""配置管理页面"""
|
| 56 |
+
page_data = build_config_page_data()
|
| 57 |
+
|
| 58 |
+
context = {
|
| 59 |
+
"request": request,
|
| 60 |
+
"sections": page_data["sections"],
|
| 61 |
+
"env_content": page_data["env_content"],
|
| 62 |
+
"overview": page_data["overview"],
|
| 63 |
+
}
|
| 64 |
+
return templates.TemplateResponse("config.html", context)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
@router.get("/logs", response_class=HTMLResponse, dependencies=[Depends(require_auth)])
|
| 68 |
+
async def logs_page(request: Request):
|
| 69 |
+
"""实时日志页面"""
|
| 70 |
+
context = {
|
| 71 |
+
"request": request,
|
| 72 |
+
}
|
| 73 |
+
return templates.TemplateResponse("logs.html", context)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@router.get(
|
| 77 |
+
"/tokens",
|
| 78 |
+
response_class=HTMLResponse,
|
| 79 |
+
dependencies=[Depends(require_auth)],
|
| 80 |
+
)
|
| 81 |
+
async def tokens_page(request: Request):
|
| 82 |
+
"""Token 管理页面"""
|
| 83 |
+
from app.core.config import settings
|
| 84 |
+
|
| 85 |
+
maintenance_actions: list[str] = []
|
| 86 |
+
if settings.TOKEN_AUTO_REMOVE_DUPLICATES:
|
| 87 |
+
maintenance_actions.append("删除重复 Token")
|
| 88 |
+
if settings.TOKEN_AUTO_HEALTH_CHECK:
|
| 89 |
+
maintenance_actions.append("批量测活")
|
| 90 |
+
if settings.TOKEN_AUTO_DELETE_INVALID:
|
| 91 |
+
maintenance_actions.append("删除失效 Token")
|
| 92 |
+
|
| 93 |
+
context = {
|
| 94 |
+
"request": request,
|
| 95 |
+
"automation": {
|
| 96 |
+
"config_url": "/admin/config#tokens",
|
| 97 |
+
"import_enabled": settings.TOKEN_AUTO_IMPORT_ENABLED,
|
| 98 |
+
"import_source_dir": settings.TOKEN_AUTO_IMPORT_SOURCE_DIR,
|
| 99 |
+
"import_interval": settings.TOKEN_AUTO_IMPORT_INTERVAL,
|
| 100 |
+
"has_import_source_dir": bool(
|
| 101 |
+
settings.TOKEN_AUTO_IMPORT_SOURCE_DIR.strip()
|
| 102 |
+
),
|
| 103 |
+
"maintenance_enabled": settings.TOKEN_AUTO_MAINTENANCE_ENABLED,
|
| 104 |
+
"maintenance_interval": settings.TOKEN_AUTO_MAINTENANCE_INTERVAL,
|
| 105 |
+
"maintenance_actions": maintenance_actions,
|
| 106 |
+
"has_maintenance_actions": bool(maintenance_actions),
|
| 107 |
+
},
|
| 108 |
+
}
|
| 109 |
+
return templates.TemplateResponse("tokens.html", context)
|
app/admin/stats.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""管理后台统计聚合辅助函数。"""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
import time
|
| 7 |
+
from typing import Any, Dict, Optional
|
| 8 |
+
|
| 9 |
+
import psutil
|
| 10 |
+
|
| 11 |
+
from app.services.request_log_dao import RequestLogDAO, get_request_log_dao
|
| 12 |
+
from app.services.token_dao import TokenDAO, get_token_dao
|
| 13 |
+
from app.utils.token_pool import TokenPool, get_token_pool
|
| 14 |
+
|
| 15 |
+
_TOKEN_POOL_SENTINEL = object()
|
| 16 |
+
DEFAULT_TREND_WINDOW = "7d"
|
| 17 |
+
TREND_WINDOW_OPTIONS = (
|
| 18 |
+
{"key": "24h", "label": "24 小时"},
|
| 19 |
+
{"key": "7d", "label": "7 天"},
|
| 20 |
+
{"key": "30d", "label": "30 天"},
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _coerce_int(value: Any) -> int:
|
| 25 |
+
"""将数据库聚合结果安全转换为整数。"""
|
| 26 |
+
return int(value or 0)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def calculate_success_rate(
|
| 30 |
+
successful_requests: int,
|
| 31 |
+
total_requests: int,
|
| 32 |
+
) -> float:
|
| 33 |
+
"""计算成功率百分比。"""
|
| 34 |
+
if total_requests <= 0:
|
| 35 |
+
return 0.0
|
| 36 |
+
return round(successful_requests / total_requests * 100, 1)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def format_compact_number(value: Any) -> str:
|
| 40 |
+
"""格式化大数字,便于仪表盘展示。"""
|
| 41 |
+
number = int(value or 0)
|
| 42 |
+
if number >= 1_000_000:
|
| 43 |
+
return f"{number / 1_000_000:.1f}M"
|
| 44 |
+
if number >= 10_000:
|
| 45 |
+
return f"{number / 10_000:.1f}万"
|
| 46 |
+
if number >= 1_000:
|
| 47 |
+
return f"{number / 1_000:.1f}k"
|
| 48 |
+
return str(number)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def normalize_trend_window(value: Any) -> str:
|
| 52 |
+
"""规范化趋势窗口参数,非法值回退到默认值。"""
|
| 53 |
+
normalized = str(value or "").strip().lower()
|
| 54 |
+
if normalized in {"24h", "7d", "30d"}:
|
| 55 |
+
return normalized
|
| 56 |
+
if normalized == "1d":
|
| 57 |
+
return "24h"
|
| 58 |
+
return DEFAULT_TREND_WINDOW
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def format_uptime(total_seconds: int) -> str:
|
| 62 |
+
"""格式化运行时长。"""
|
| 63 |
+
total_seconds = max(0, int(total_seconds))
|
| 64 |
+
days, remainder = divmod(total_seconds, 86400)
|
| 65 |
+
hours, remainder = divmod(remainder, 3600)
|
| 66 |
+
minutes, seconds = divmod(remainder, 60)
|
| 67 |
+
|
| 68 |
+
parts = []
|
| 69 |
+
if days:
|
| 70 |
+
parts.append(f"{days}天")
|
| 71 |
+
if days or hours:
|
| 72 |
+
parts.append(f"{hours}小时")
|
| 73 |
+
if days or hours or minutes:
|
| 74 |
+
parts.append(f"{minutes}分钟")
|
| 75 |
+
parts.append(f"{seconds}秒")
|
| 76 |
+
|
| 77 |
+
return " ".join(parts)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def get_process_uptime() -> str:
|
| 81 |
+
"""获取当前进程运行时长。"""
|
| 82 |
+
created_at = psutil.Process(os.getpid()).create_time()
|
| 83 |
+
return format_uptime(int(time.time() - created_at))
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
async def collect_admin_stats(
|
| 87 |
+
provider: str,
|
| 88 |
+
*,
|
| 89 |
+
token_dao: Optional[TokenDAO] = None,
|
| 90 |
+
request_log_dao: Optional[RequestLogDAO] = None,
|
| 91 |
+
token_pool: Any = _TOKEN_POOL_SENTINEL,
|
| 92 |
+
trend_window: str = DEFAULT_TREND_WINDOW,
|
| 93 |
+
) -> Dict[str, Any]:
|
| 94 |
+
"""聚合管理后台所需的 Token 与请求统计。"""
|
| 95 |
+
token_dao = token_dao or get_token_dao()
|
| 96 |
+
request_log_dao = request_log_dao or get_request_log_dao()
|
| 97 |
+
if token_pool is _TOKEN_POOL_SENTINEL:
|
| 98 |
+
token_pool = get_token_pool()
|
| 99 |
+
trend_window = normalize_trend_window(trend_window)
|
| 100 |
+
|
| 101 |
+
token_counts = await token_dao.get_provider_token_counts(provider)
|
| 102 |
+
request_stats = await request_log_dao.get_provider_request_stats(provider)
|
| 103 |
+
usage_trend = await request_log_dao.get_provider_usage_trend(
|
| 104 |
+
provider,
|
| 105 |
+
window=trend_window,
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
pool_status: Dict[str, Any] = {}
|
| 109 |
+
if isinstance(token_pool, TokenPool) or hasattr(token_pool, "get_pool_status"):
|
| 110 |
+
pool_status = token_pool.get_pool_status() if token_pool else {}
|
| 111 |
+
|
| 112 |
+
total_tokens = _coerce_int(token_counts.get("total_tokens"))
|
| 113 |
+
enabled_tokens = _coerce_int(token_counts.get("enabled_tokens"))
|
| 114 |
+
user_tokens = _coerce_int(token_counts.get("user_tokens"))
|
| 115 |
+
guest_tokens = _coerce_int(token_counts.get("guest_tokens"))
|
| 116 |
+
unknown_tokens = _coerce_int(token_counts.get("unknown_tokens"))
|
| 117 |
+
|
| 118 |
+
pool_total_tokens = _coerce_int(pool_status.get("total_tokens"))
|
| 119 |
+
if pool_total_tokens == 0 and token_pool is None:
|
| 120 |
+
pool_total_tokens = max(0, enabled_tokens - guest_tokens)
|
| 121 |
+
|
| 122 |
+
available_tokens = _coerce_int(pool_status.get("available_tokens"))
|
| 123 |
+
healthy_tokens = _coerce_int(pool_status.get("healthy_tokens"))
|
| 124 |
+
unhealthy_tokens = _coerce_int(pool_status.get("unhealthy_tokens"))
|
| 125 |
+
|
| 126 |
+
total_requests = _coerce_int(request_stats.get("total_requests"))
|
| 127 |
+
successful_requests = _coerce_int(request_stats.get("successful_requests"))
|
| 128 |
+
failed_requests = _coerce_int(request_stats.get("failed_requests"))
|
| 129 |
+
input_tokens = _coerce_int(request_stats.get("input_tokens"))
|
| 130 |
+
output_tokens = _coerce_int(request_stats.get("output_tokens"))
|
| 131 |
+
total_consumed_tokens = _coerce_int(request_stats.get("total_tokens"))
|
| 132 |
+
cache_creation_tokens = _coerce_int(
|
| 133 |
+
request_stats.get("cache_creation_tokens")
|
| 134 |
+
)
|
| 135 |
+
cache_read_tokens = _coerce_int(request_stats.get("cache_read_tokens"))
|
| 136 |
+
cache_creation_requests = _coerce_int(
|
| 137 |
+
request_stats.get("cache_creation_requests")
|
| 138 |
+
)
|
| 139 |
+
cache_hit_requests = _coerce_int(request_stats.get("cache_hit_requests"))
|
| 140 |
+
average_latency = round(float(request_stats.get("avg_duration") or 0.0), 2)
|
| 141 |
+
average_first_token_latency = round(
|
| 142 |
+
float(request_stats.get("avg_first_token_time") or 0.0),
|
| 143 |
+
2,
|
| 144 |
+
)
|
| 145 |
+
total_cache_tokens = cache_creation_tokens + cache_read_tokens
|
| 146 |
+
|
| 147 |
+
return {
|
| 148 |
+
"total_tokens": total_tokens,
|
| 149 |
+
"enabled_tokens": enabled_tokens,
|
| 150 |
+
"user_tokens": user_tokens,
|
| 151 |
+
"guest_tokens": guest_tokens,
|
| 152 |
+
"unknown_tokens": unknown_tokens,
|
| 153 |
+
"pool_total_tokens": pool_total_tokens,
|
| 154 |
+
"available_tokens": available_tokens,
|
| 155 |
+
"healthy_tokens": healthy_tokens,
|
| 156 |
+
"unhealthy_tokens": unhealthy_tokens,
|
| 157 |
+
"total_requests": total_requests,
|
| 158 |
+
"successful_requests": successful_requests,
|
| 159 |
+
"failed_requests": failed_requests,
|
| 160 |
+
"input_tokens": input_tokens,
|
| 161 |
+
"output_tokens": output_tokens,
|
| 162 |
+
"total_consumed_tokens": total_consumed_tokens,
|
| 163 |
+
"cache_creation_tokens": cache_creation_tokens,
|
| 164 |
+
"cache_read_tokens": cache_read_tokens,
|
| 165 |
+
"total_cache_tokens": total_cache_tokens,
|
| 166 |
+
"cache_creation_requests": cache_creation_requests,
|
| 167 |
+
"cache_hit_requests": cache_hit_requests,
|
| 168 |
+
"average_latency": average_latency,
|
| 169 |
+
"average_first_token_latency": average_first_token_latency,
|
| 170 |
+
"trend_window": trend_window,
|
| 171 |
+
"usage_trend": usage_trend,
|
| 172 |
+
"total_consumed_tokens_display": format_compact_number(
|
| 173 |
+
total_consumed_tokens
|
| 174 |
+
),
|
| 175 |
+
"total_cache_tokens_display": format_compact_number(
|
| 176 |
+
total_cache_tokens
|
| 177 |
+
),
|
| 178 |
+
"input_tokens_display": format_compact_number(input_tokens),
|
| 179 |
+
"output_tokens_display": format_compact_number(output_tokens),
|
| 180 |
+
"success_rate": calculate_success_rate(
|
| 181 |
+
successful_requests,
|
| 182 |
+
total_requests,
|
| 183 |
+
),
|
| 184 |
+
}
|
app/core/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
|
| 4 |
+
from app.core import claude, config, openai
|
| 5 |
+
|
| 6 |
+
__all__ = ["claude", "config", "openai"]
|
app/core/claude.py
ADDED
|
@@ -0,0 +1,582 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
import math
|
| 6 |
+
import time
|
| 7 |
+
import uuid
|
| 8 |
+
from typing import Any, AsyncGenerator, Dict, List, Optional
|
| 9 |
+
|
| 10 |
+
from fastapi import APIRouter, Header, Request
|
| 11 |
+
from fastapi.responses import JSONResponse, StreamingResponse
|
| 12 |
+
|
| 13 |
+
from app.core.claude_compat import (
|
| 14 |
+
build_non_stream_response,
|
| 15 |
+
claude_messages_to_openai,
|
| 16 |
+
claude_tool_choice_to_openai,
|
| 17 |
+
claude_tools_to_openai,
|
| 18 |
+
extract_text,
|
| 19 |
+
make_claude_id,
|
| 20 |
+
sse_content_block_delta,
|
| 21 |
+
sse_content_block_start,
|
| 22 |
+
sse_content_block_stop,
|
| 23 |
+
sse_error,
|
| 24 |
+
sse_message_delta,
|
| 25 |
+
sse_message_start,
|
| 26 |
+
sse_message_stop,
|
| 27 |
+
sse_ping,
|
| 28 |
+
)
|
| 29 |
+
from app.core.config import settings
|
| 30 |
+
from app.core.openai import get_upstream_client
|
| 31 |
+
from app.models.schemas import Message, OpenAIRequest
|
| 32 |
+
from app.utils.logger import get_logger
|
| 33 |
+
from app.utils.request_logging import (
|
| 34 |
+
extract_openai_usage,
|
| 35 |
+
extract_claude_usage,
|
| 36 |
+
wrap_claude_stream_with_logging,
|
| 37 |
+
write_request_log,
|
| 38 |
+
)
|
| 39 |
+
from app.utils.request_source import detect_request_source, format_request_source
|
| 40 |
+
|
| 41 |
+
logger = get_logger()
|
| 42 |
+
router = APIRouter()
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _resolve_claude_model(model: Any) -> str:
|
| 46 |
+
"""Map Claude/Claude Code model aliases to local upstream-supported models."""
|
| 47 |
+
if not isinstance(model, str) or not model.strip():
|
| 48 |
+
return settings.GLM5_MODEL
|
| 49 |
+
|
| 50 |
+
raw_model = model.strip()
|
| 51 |
+
normalized = raw_model.casefold()
|
| 52 |
+
if normalized.endswith("[1m]"):
|
| 53 |
+
normalized = normalized[:-4].rstrip()
|
| 54 |
+
|
| 55 |
+
direct_models = {
|
| 56 |
+
settings.GLM45_MODEL.casefold(): settings.GLM45_MODEL,
|
| 57 |
+
settings.GLM45_THINKING_MODEL.casefold(): settings.GLM45_THINKING_MODEL,
|
| 58 |
+
settings.GLM45_SEARCH_MODEL.casefold(): settings.GLM45_SEARCH_MODEL,
|
| 59 |
+
settings.GLM45_AIR_MODEL.casefold(): settings.GLM45_AIR_MODEL,
|
| 60 |
+
settings.GLM46V_MODEL.casefold(): settings.GLM46V_MODEL,
|
| 61 |
+
settings.GLM5_MODEL.casefold(): settings.GLM5_MODEL,
|
| 62 |
+
settings.GLM47_MODEL.casefold(): settings.GLM47_MODEL,
|
| 63 |
+
settings.GLM47_THINKING_MODEL.casefold(): settings.GLM47_THINKING_MODEL,
|
| 64 |
+
settings.GLM47_SEARCH_MODEL.casefold(): settings.GLM47_SEARCH_MODEL,
|
| 65 |
+
settings.GLM47_ADVANCED_SEARCH_MODEL.casefold(): settings.GLM47_ADVANCED_SEARCH_MODEL,
|
| 66 |
+
}
|
| 67 |
+
if normalized in direct_models:
|
| 68 |
+
return direct_models[normalized]
|
| 69 |
+
|
| 70 |
+
alias_map = {
|
| 71 |
+
"default": settings.GLM5_MODEL,
|
| 72 |
+
"sonnet": settings.GLM5_MODEL,
|
| 73 |
+
"haiku": settings.GLM45_AIR_MODEL,
|
| 74 |
+
"opus": settings.GLM5_MODEL,
|
| 75 |
+
"opusplan": settings.GLM47_THINKING_MODEL,
|
| 76 |
+
}
|
| 77 |
+
if normalized in alias_map:
|
| 78 |
+
return alias_map[normalized]
|
| 79 |
+
|
| 80 |
+
if normalized.startswith("claude-sonnet") or normalized.startswith("claude-3-7-sonnet") or normalized.startswith("claude-3-5-sonnet"):
|
| 81 |
+
return settings.GLM5_MODEL
|
| 82 |
+
if normalized.startswith("claude-opus") or normalized.startswith("claude-4-opus"):
|
| 83 |
+
return settings.GLM5_MODEL
|
| 84 |
+
if normalized.startswith("claude-haiku") or normalized.startswith("claude-3-5-haiku"):
|
| 85 |
+
return settings.GLM45_AIR_MODEL
|
| 86 |
+
|
| 87 |
+
return raw_model
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _estimate_tokens(text: str) -> int:
|
| 91 |
+
if not text:
|
| 92 |
+
return 0
|
| 93 |
+
return max(1, math.ceil(len(text) / 2))
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _extract_api_key(
|
| 97 |
+
authorization: Optional[str],
|
| 98 |
+
x_api_key: Optional[str],
|
| 99 |
+
) -> Optional[str]:
|
| 100 |
+
if x_api_key:
|
| 101 |
+
return x_api_key
|
| 102 |
+
if authorization and authorization.startswith("Bearer "):
|
| 103 |
+
return authorization[7:]
|
| 104 |
+
return None
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _claude_error_response(
|
| 108 |
+
message: str,
|
| 109 |
+
status_code: int,
|
| 110 |
+
error_type: str,
|
| 111 |
+
) -> JSONResponse:
|
| 112 |
+
return JSONResponse(
|
| 113 |
+
status_code=status_code,
|
| 114 |
+
content={
|
| 115 |
+
"type": "error",
|
| 116 |
+
"error": {"type": error_type, "message": message},
|
| 117 |
+
},
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def _build_openai_request(body: Dict[str, Any]) -> OpenAIRequest:
|
| 122 |
+
system = body.get("system")
|
| 123 |
+
claude_messages = body.get("messages", [])
|
| 124 |
+
openai_messages = claude_messages_to_openai(system, claude_messages)
|
| 125 |
+
openai_tools = claude_tools_to_openai(body.get("tools"))
|
| 126 |
+
tool_choice = claude_tool_choice_to_openai(body.get("tool_choice"))
|
| 127 |
+
|
| 128 |
+
thinking = body.get("thinking")
|
| 129 |
+
enable_thinking = None
|
| 130 |
+
if isinstance(thinking, dict):
|
| 131 |
+
thinking_type = thinking.get("type")
|
| 132 |
+
if thinking_type == "enabled":
|
| 133 |
+
enable_thinking = True
|
| 134 |
+
elif thinking_type == "disabled":
|
| 135 |
+
enable_thinking = False
|
| 136 |
+
|
| 137 |
+
messages = [Message.model_validate(message) for message in openai_messages]
|
| 138 |
+
resolved_model = _resolve_claude_model(body.get("model", settings.GLM5_MODEL))
|
| 139 |
+
if resolved_model != body.get("model", settings.GLM5_MODEL):
|
| 140 |
+
logger.info(
|
| 141 |
+
f"🔀 Claude 模型映射: "
|
| 142 |
+
f"{body.get('model', settings.GLM5_MODEL)} -> {resolved_model}"
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
return OpenAIRequest(
|
| 146 |
+
model=resolved_model,
|
| 147 |
+
messages=messages,
|
| 148 |
+
stream=bool(body.get("stream", False)),
|
| 149 |
+
temperature=body.get("temperature"),
|
| 150 |
+
max_tokens=body.get("max_tokens"),
|
| 151 |
+
tools=openai_tools,
|
| 152 |
+
tool_choice=tool_choice,
|
| 153 |
+
enable_thinking=enable_thinking,
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def _build_prompt_text(body: Dict[str, Any]) -> str:
|
| 158 |
+
prompt_parts: List[str] = []
|
| 159 |
+
system = body.get("system")
|
| 160 |
+
if system:
|
| 161 |
+
prompt_parts.append(extract_text(system))
|
| 162 |
+
|
| 163 |
+
for message in body.get("messages", []):
|
| 164 |
+
content = message.get("content") if isinstance(message, dict) else None
|
| 165 |
+
text = extract_text(content)
|
| 166 |
+
if text:
|
| 167 |
+
prompt_parts.append(text)
|
| 168 |
+
|
| 169 |
+
return "\n".join(part for part in prompt_parts if part)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def _normalize_tool_calls(tool_calls: Any) -> List[Dict[str, Any]]:
|
| 173 |
+
if not isinstance(tool_calls, list):
|
| 174 |
+
return []
|
| 175 |
+
|
| 176 |
+
normalized: List[Dict[str, Any]] = []
|
| 177 |
+
seen_ids = set()
|
| 178 |
+
for tool_call in tool_calls:
|
| 179 |
+
if not isinstance(tool_call, dict):
|
| 180 |
+
continue
|
| 181 |
+
|
| 182 |
+
tool_call_id = tool_call.get("id") or f"call_{uuid.uuid4().hex[:24]}"
|
| 183 |
+
if tool_call_id in seen_ids:
|
| 184 |
+
continue
|
| 185 |
+
seen_ids.add(tool_call_id)
|
| 186 |
+
|
| 187 |
+
function_data = (
|
| 188 |
+
tool_call.get("function")
|
| 189 |
+
if isinstance(tool_call.get("function"), dict)
|
| 190 |
+
else {}
|
| 191 |
+
)
|
| 192 |
+
arguments = function_data.get("arguments", "{}")
|
| 193 |
+
if not isinstance(arguments, str):
|
| 194 |
+
try:
|
| 195 |
+
arguments = json.dumps(arguments, ensure_ascii=False)
|
| 196 |
+
except Exception:
|
| 197 |
+
arguments = "{}"
|
| 198 |
+
|
| 199 |
+
normalized.append(
|
| 200 |
+
{
|
| 201 |
+
"id": tool_call_id,
|
| 202 |
+
"type": "function",
|
| 203 |
+
"function": {
|
| 204 |
+
"name": function_data.get("name", ""),
|
| 205 |
+
"arguments": arguments,
|
| 206 |
+
},
|
| 207 |
+
}
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
return normalized
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def _convert_openai_response_to_claude(response: Dict[str, Any], msg_id: str) -> Dict[str, Any]:
|
| 214 |
+
choice = ((response.get("choices") or [{}])[0]) if isinstance(response, dict) else {}
|
| 215 |
+
message = choice.get("message") or {}
|
| 216 |
+
reasoning = message.get("reasoning_content")
|
| 217 |
+
usage = extract_openai_usage(response)
|
| 218 |
+
return build_non_stream_response(
|
| 219 |
+
msg_id=msg_id,
|
| 220 |
+
model=response.get("model", settings.GLM5_MODEL),
|
| 221 |
+
reasoning_parts=[reasoning] if isinstance(reasoning, str) and reasoning else [],
|
| 222 |
+
answer_text=message.get("content") or "",
|
| 223 |
+
tool_calls=_normalize_tool_calls(message.get("tool_calls")),
|
| 224 |
+
input_tokens=usage["input_tokens"],
|
| 225 |
+
output_tokens=usage["output_tokens"],
|
| 226 |
+
cache_creation_tokens=usage["cache_creation_tokens"],
|
| 227 |
+
cache_read_tokens=usage["cache_read_tokens"],
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
async def _stream_openai_to_claude(
|
| 232 |
+
openai_stream: AsyncGenerator[str, None],
|
| 233 |
+
msg_id: str,
|
| 234 |
+
model: str,
|
| 235 |
+
input_tokens: int,
|
| 236 |
+
) -> AsyncGenerator[str, None]:
|
| 237 |
+
reasoning_parts: List[str] = []
|
| 238 |
+
answer_parts: List[str] = []
|
| 239 |
+
tool_calls: List[Dict[str, Any]] = []
|
| 240 |
+
block_index = 0
|
| 241 |
+
thinking_started = False
|
| 242 |
+
final_input_tokens = input_tokens
|
| 243 |
+
final_output_tokens = 0
|
| 244 |
+
cache_creation_tokens = 0
|
| 245 |
+
cache_read_tokens = 0
|
| 246 |
+
|
| 247 |
+
yield sse_message_start(msg_id, model, input_tokens)
|
| 248 |
+
yield sse_ping()
|
| 249 |
+
|
| 250 |
+
try:
|
| 251 |
+
async for chunk in openai_stream:
|
| 252 |
+
if not chunk.startswith("data: "):
|
| 253 |
+
continue
|
| 254 |
+
|
| 255 |
+
payload_text = chunk[6:].strip()
|
| 256 |
+
if not payload_text or payload_text == "[DONE]":
|
| 257 |
+
continue
|
| 258 |
+
|
| 259 |
+
payload = json.loads(payload_text)
|
| 260 |
+
if isinstance(payload, dict) and "error" in payload:
|
| 261 |
+
error = payload.get("error") or {}
|
| 262 |
+
yield sse_error(
|
| 263 |
+
error.get("type", "api_error"),
|
| 264 |
+
error.get("message", "Upstream error"),
|
| 265 |
+
)
|
| 266 |
+
return
|
| 267 |
+
|
| 268 |
+
choice = ((payload.get("choices") or [{}])[0]) if isinstance(payload, dict) else {}
|
| 269 |
+
delta = choice.get("delta") or {}
|
| 270 |
+
|
| 271 |
+
reasoning_delta = delta.get("reasoning_content")
|
| 272 |
+
if reasoning_delta:
|
| 273 |
+
if not thinking_started:
|
| 274 |
+
yield sse_content_block_start(
|
| 275 |
+
block_index,
|
| 276 |
+
{"type": "thinking", "thinking": ""},
|
| 277 |
+
)
|
| 278 |
+
thinking_started = True
|
| 279 |
+
|
| 280 |
+
reasoning_parts.append(reasoning_delta)
|
| 281 |
+
yield sse_content_block_delta(
|
| 282 |
+
block_index,
|
| 283 |
+
{"type": "thinking_delta", "thinking": reasoning_delta},
|
| 284 |
+
)
|
| 285 |
+
|
| 286 |
+
content_delta = delta.get("content")
|
| 287 |
+
if content_delta:
|
| 288 |
+
answer_parts.append(content_delta)
|
| 289 |
+
|
| 290 |
+
if payload.get("usage"):
|
| 291 |
+
usage = extract_openai_usage(payload)
|
| 292 |
+
if usage["input_tokens"] > 0:
|
| 293 |
+
final_input_tokens = usage["input_tokens"]
|
| 294 |
+
if usage["output_tokens"] > 0:
|
| 295 |
+
final_output_tokens = usage["output_tokens"]
|
| 296 |
+
if usage["cache_creation_tokens"] > 0:
|
| 297 |
+
cache_creation_tokens = usage["cache_creation_tokens"]
|
| 298 |
+
if usage["cache_read_tokens"] > 0:
|
| 299 |
+
cache_read_tokens = usage["cache_read_tokens"]
|
| 300 |
+
|
| 301 |
+
tool_calls.extend(_normalize_tool_calls(delta.get("tool_calls")))
|
| 302 |
+
|
| 303 |
+
if thinking_started:
|
| 304 |
+
yield sse_content_block_stop(block_index)
|
| 305 |
+
block_index += 1
|
| 306 |
+
|
| 307 |
+
answer_text = "".join(answer_parts)
|
| 308 |
+
if answer_text:
|
| 309 |
+
yield sse_content_block_start(block_index, {"type": "text", "text": ""})
|
| 310 |
+
yield sse_content_block_delta(
|
| 311 |
+
block_index,
|
| 312 |
+
{"type": "text_delta", "text": answer_text},
|
| 313 |
+
)
|
| 314 |
+
yield sse_content_block_stop(block_index)
|
| 315 |
+
block_index += 1
|
| 316 |
+
|
| 317 |
+
if tool_calls:
|
| 318 |
+
for tool_call in tool_calls:
|
| 319 |
+
function_data = tool_call.get("function") or {}
|
| 320 |
+
tool_id = tool_call.get(
|
| 321 |
+
"id",
|
| 322 |
+
f"toolu_{uuid.uuid4().hex[:20]}",
|
| 323 |
+
).replace("call_", "toolu_")
|
| 324 |
+
yield sse_content_block_start(
|
| 325 |
+
block_index,
|
| 326 |
+
{
|
| 327 |
+
"type": "tool_use",
|
| 328 |
+
"id": tool_id,
|
| 329 |
+
"name": function_data.get("name", ""),
|
| 330 |
+
"input": {},
|
| 331 |
+
},
|
| 332 |
+
)
|
| 333 |
+
yield sse_content_block_delta(
|
| 334 |
+
block_index,
|
| 335 |
+
{
|
| 336 |
+
"type": "input_json_delta",
|
| 337 |
+
"partial_json": function_data.get("arguments", "{}"),
|
| 338 |
+
},
|
| 339 |
+
)
|
| 340 |
+
yield sse_content_block_stop(block_index)
|
| 341 |
+
block_index += 1
|
| 342 |
+
|
| 343 |
+
if not final_output_tokens:
|
| 344 |
+
final_output_tokens = _estimate_tokens(
|
| 345 |
+
"".join(reasoning_parts) + answer_text
|
| 346 |
+
)
|
| 347 |
+
|
| 348 |
+
yield sse_message_delta(
|
| 349 |
+
"tool_use" if tool_calls else "end_turn",
|
| 350 |
+
final_output_tokens,
|
| 351 |
+
input_tokens=final_input_tokens,
|
| 352 |
+
cache_creation_tokens=cache_creation_tokens,
|
| 353 |
+
cache_read_tokens=cache_read_tokens,
|
| 354 |
+
)
|
| 355 |
+
yield sse_message_stop()
|
| 356 |
+
except Exception as exc:
|
| 357 |
+
logger.error(f"❌ Claude 流式响应转换失败: {exc}")
|
| 358 |
+
yield sse_error("api_error", str(exc))
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
@router.post("/v1/messages")
|
| 362 |
+
@router.post("/anthropic/v1/messages")
|
| 363 |
+
async def claude_messages(
|
| 364 |
+
request: Request,
|
| 365 |
+
authorization: Optional[str] = Header(None),
|
| 366 |
+
x_api_key: Optional[str] = Header(None, alias="x-api-key"),
|
| 367 |
+
):
|
| 368 |
+
source_info = detect_request_source(
|
| 369 |
+
request,
|
| 370 |
+
protocol_hint="anthropic",
|
| 371 |
+
)
|
| 372 |
+
source_prefix = format_request_source(source_info)
|
| 373 |
+
started_at = time.perf_counter()
|
| 374 |
+
requested_model = "unknown"
|
| 375 |
+
|
| 376 |
+
try:
|
| 377 |
+
body = await request.json()
|
| 378 |
+
except Exception:
|
| 379 |
+
await write_request_log(
|
| 380 |
+
provider="zai",
|
| 381 |
+
model=requested_model,
|
| 382 |
+
source_info=source_info,
|
| 383 |
+
success=False,
|
| 384 |
+
started_at=started_at,
|
| 385 |
+
status_code=400,
|
| 386 |
+
error_message="Invalid JSON body",
|
| 387 |
+
)
|
| 388 |
+
return _claude_error_response(
|
| 389 |
+
"Invalid JSON body",
|
| 390 |
+
400,
|
| 391 |
+
"invalid_request_error",
|
| 392 |
+
)
|
| 393 |
+
|
| 394 |
+
requested_model = str(body.get("model") or "unknown")
|
| 395 |
+
source_info = detect_request_source(
|
| 396 |
+
request,
|
| 397 |
+
protocol_hint="anthropic",
|
| 398 |
+
model_hint=body.get("model"),
|
| 399 |
+
)
|
| 400 |
+
source_prefix = format_request_source(source_info)
|
| 401 |
+
|
| 402 |
+
if not settings.SKIP_AUTH_TOKEN:
|
| 403 |
+
api_key = _extract_api_key(authorization, x_api_key)
|
| 404 |
+
if not api_key:
|
| 405 |
+
await write_request_log(
|
| 406 |
+
provider="zai",
|
| 407 |
+
model=requested_model,
|
| 408 |
+
source_info=source_info,
|
| 409 |
+
success=False,
|
| 410 |
+
started_at=started_at,
|
| 411 |
+
status_code=401,
|
| 412 |
+
error_message="Missing API key",
|
| 413 |
+
)
|
| 414 |
+
return _claude_error_response(
|
| 415 |
+
"Missing API key",
|
| 416 |
+
401,
|
| 417 |
+
"authentication_error",
|
| 418 |
+
)
|
| 419 |
+
if api_key != settings.AUTH_TOKEN:
|
| 420 |
+
await write_request_log(
|
| 421 |
+
provider="zai",
|
| 422 |
+
model=requested_model,
|
| 423 |
+
source_info=source_info,
|
| 424 |
+
success=False,
|
| 425 |
+
started_at=started_at,
|
| 426 |
+
status_code=401,
|
| 427 |
+
error_message="Invalid API key",
|
| 428 |
+
)
|
| 429 |
+
return _claude_error_response(
|
| 430 |
+
"Invalid API key",
|
| 431 |
+
401,
|
| 432 |
+
"authentication_error",
|
| 433 |
+
)
|
| 434 |
+
|
| 435 |
+
try:
|
| 436 |
+
openai_request = _build_openai_request(body)
|
| 437 |
+
except Exception as exc:
|
| 438 |
+
await write_request_log(
|
| 439 |
+
provider="zai",
|
| 440 |
+
model=requested_model,
|
| 441 |
+
source_info=source_info,
|
| 442 |
+
success=False,
|
| 443 |
+
started_at=started_at,
|
| 444 |
+
status_code=400,
|
| 445 |
+
error_message=f"Invalid request: {exc}",
|
| 446 |
+
)
|
| 447 |
+
return _claude_error_response(
|
| 448 |
+
f"Invalid request: {exc}",
|
| 449 |
+
400,
|
| 450 |
+
"invalid_request_error",
|
| 451 |
+
)
|
| 452 |
+
|
| 453 |
+
if not openai_request.messages:
|
| 454 |
+
await write_request_log(
|
| 455 |
+
provider="zai",
|
| 456 |
+
model=openai_request.model,
|
| 457 |
+
source_info=source_info,
|
| 458 |
+
success=False,
|
| 459 |
+
started_at=started_at,
|
| 460 |
+
status_code=400,
|
| 461 |
+
error_message="messages is required",
|
| 462 |
+
)
|
| 463 |
+
return _claude_error_response(
|
| 464 |
+
"messages is required",
|
| 465 |
+
400,
|
| 466 |
+
"invalid_request_error",
|
| 467 |
+
)
|
| 468 |
+
logger.info(
|
| 469 |
+
f"{source_prefix} 🤖 收到 Claude 请求 - 模型: {body.get('model')}, 映射模型: {openai_request.model}, 流式: {openai_request.stream}, 消息数: {len(openai_request.messages)}, 工具数: {len(openai_request.tools) if openai_request.tools else 0}"
|
| 470 |
+
)
|
| 471 |
+
|
| 472 |
+
msg_id = make_claude_id()
|
| 473 |
+
input_tokens = _estimate_tokens(_build_prompt_text(body))
|
| 474 |
+
|
| 475 |
+
try:
|
| 476 |
+
client = get_upstream_client()
|
| 477 |
+
result = await client.chat_completion(openai_request)
|
| 478 |
+
except Exception as exc:
|
| 479 |
+
logger.error(f"{source_prefix} ❌ Claude 请求处理失败: {exc}")
|
| 480 |
+
await write_request_log(
|
| 481 |
+
provider="zai",
|
| 482 |
+
model=openai_request.model,
|
| 483 |
+
source_info=source_info,
|
| 484 |
+
success=False,
|
| 485 |
+
started_at=started_at,
|
| 486 |
+
status_code=500,
|
| 487 |
+
error_message=str(exc),
|
| 488 |
+
)
|
| 489 |
+
return _claude_error_response(str(exc), 500, "api_error")
|
| 490 |
+
|
| 491 |
+
if isinstance(result, dict) and "error" in result:
|
| 492 |
+
error = result.get("error") or {}
|
| 493 |
+
error_code = error.get("code")
|
| 494 |
+
status_code = error_code if isinstance(error_code, int) else 500
|
| 495 |
+
await write_request_log(
|
| 496 |
+
provider="zai",
|
| 497 |
+
model=openai_request.model,
|
| 498 |
+
source_info=source_info,
|
| 499 |
+
success=False,
|
| 500 |
+
started_at=started_at,
|
| 501 |
+
status_code=status_code,
|
| 502 |
+
error_message=error.get("message", "Unknown upstream error"),
|
| 503 |
+
)
|
| 504 |
+
return _claude_error_response(
|
| 505 |
+
error.get("message", "Unknown upstream error"),
|
| 506 |
+
status_code,
|
| 507 |
+
error.get("type", "api_error"),
|
| 508 |
+
)
|
| 509 |
+
|
| 510 |
+
if openai_request.stream:
|
| 511 |
+
if not hasattr(result, "__aiter__"):
|
| 512 |
+
await write_request_log(
|
| 513 |
+
provider="zai",
|
| 514 |
+
model=openai_request.model,
|
| 515 |
+
source_info=source_info,
|
| 516 |
+
success=False,
|
| 517 |
+
started_at=started_at,
|
| 518 |
+
status_code=500,
|
| 519 |
+
error_message="Expected streaming response",
|
| 520 |
+
)
|
| 521 |
+
return _claude_error_response(
|
| 522 |
+
"Expected streaming response",
|
| 523 |
+
500,
|
| 524 |
+
"api_error",
|
| 525 |
+
)
|
| 526 |
+
|
| 527 |
+
return StreamingResponse(
|
| 528 |
+
wrap_claude_stream_with_logging(
|
| 529 |
+
_stream_openai_to_claude(
|
| 530 |
+
result,
|
| 531 |
+
msg_id,
|
| 532 |
+
openai_request.model,
|
| 533 |
+
input_tokens,
|
| 534 |
+
),
|
| 535 |
+
provider="zai",
|
| 536 |
+
model=openai_request.model,
|
| 537 |
+
source_info=source_info,
|
| 538 |
+
started_at=started_at,
|
| 539 |
+
input_tokens=input_tokens,
|
| 540 |
+
),
|
| 541 |
+
media_type="text/event-stream",
|
| 542 |
+
headers={
|
| 543 |
+
"Cache-Control": "no-cache",
|
| 544 |
+
"Connection": "keep-alive",
|
| 545 |
+
"Access-Control-Allow-Origin": "*",
|
| 546 |
+
},
|
| 547 |
+
)
|
| 548 |
+
|
| 549 |
+
if not isinstance(result, dict):
|
| 550 |
+
await write_request_log(
|
| 551 |
+
provider="zai",
|
| 552 |
+
model=openai_request.model,
|
| 553 |
+
source_info=source_info,
|
| 554 |
+
success=False,
|
| 555 |
+
started_at=started_at,
|
| 556 |
+
status_code=500,
|
| 557 |
+
error_message="Expected non-streaming response payload",
|
| 558 |
+
)
|
| 559 |
+
return _claude_error_response(
|
| 560 |
+
"Expected non-streaming response payload",
|
| 561 |
+
500,
|
| 562 |
+
"api_error",
|
| 563 |
+
)
|
| 564 |
+
|
| 565 |
+
response_data = _convert_openai_response_to_claude(result, msg_id)
|
| 566 |
+
if not response_data.get("usage", {}).get("input_tokens"):
|
| 567 |
+
response_data["usage"]["input_tokens"] = input_tokens
|
| 568 |
+
usage = extract_claude_usage(response_data)
|
| 569 |
+
await write_request_log(
|
| 570 |
+
provider="zai",
|
| 571 |
+
model=openai_request.model,
|
| 572 |
+
source_info=source_info,
|
| 573 |
+
success=True,
|
| 574 |
+
started_at=started_at,
|
| 575 |
+
status_code=200,
|
| 576 |
+
input_tokens=usage["input_tokens"],
|
| 577 |
+
output_tokens=usage["output_tokens"],
|
| 578 |
+
cache_creation_tokens=usage["cache_creation_tokens"],
|
| 579 |
+
cache_read_tokens=usage["cache_read_tokens"],
|
| 580 |
+
total_tokens=usage["total_tokens"],
|
| 581 |
+
)
|
| 582 |
+
return JSONResponse(content=response_data)
|
app/core/claude_compat.py
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
|
| 4 |
+
"""Claude Messages API 兼容辅助函数。"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
import uuid
|
| 10 |
+
from typing import Any, Optional
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def extract_text(content: Any) -> str:
|
| 14 |
+
"""Extract plain text from Claude/OpenAI mixed content blocks."""
|
| 15 |
+
if isinstance(content, str):
|
| 16 |
+
return content
|
| 17 |
+
|
| 18 |
+
if isinstance(content, list):
|
| 19 |
+
return " ".join(
|
| 20 |
+
str(block.get("text", ""))
|
| 21 |
+
for block in content
|
| 22 |
+
if isinstance(block, dict) and block.get("type") == "text"
|
| 23 |
+
).strip()
|
| 24 |
+
|
| 25 |
+
return str(content) if content else ""
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def claude_messages_to_openai(system: Any, messages: list[dict]) -> list[dict]:
|
| 29 |
+
"""Convert Claude messages payload into OpenAI-style messages."""
|
| 30 |
+
converted: list[dict] = []
|
| 31 |
+
|
| 32 |
+
if system:
|
| 33 |
+
if isinstance(system, str):
|
| 34 |
+
converted.append({"role": "system", "content": system})
|
| 35 |
+
elif isinstance(system, list):
|
| 36 |
+
system_text = [
|
| 37 |
+
block.get("text", "")
|
| 38 |
+
for block in system
|
| 39 |
+
if isinstance(block, dict) and block.get("type") == "text"
|
| 40 |
+
]
|
| 41 |
+
if system_text:
|
| 42 |
+
converted.append({
|
| 43 |
+
"role": "system",
|
| 44 |
+
"content": "\n".join(system_text),
|
| 45 |
+
})
|
| 46 |
+
|
| 47 |
+
for message in messages:
|
| 48 |
+
role = message.get("role", "user")
|
| 49 |
+
content = message.get("content", "")
|
| 50 |
+
|
| 51 |
+
if role == "assistant" and isinstance(content, list):
|
| 52 |
+
text_parts: list[str] = []
|
| 53 |
+
tool_calls: list[dict] = []
|
| 54 |
+
|
| 55 |
+
for block in content:
|
| 56 |
+
if not isinstance(block, dict):
|
| 57 |
+
continue
|
| 58 |
+
|
| 59 |
+
block_type = block.get("type")
|
| 60 |
+
if block_type == "text":
|
| 61 |
+
text_parts.append(block.get("text", ""))
|
| 62 |
+
elif block_type == "tool_use":
|
| 63 |
+
tool_calls.append(
|
| 64 |
+
{
|
| 65 |
+
"id": block.get(
|
| 66 |
+
"id",
|
| 67 |
+
f"call_{uuid.uuid4().hex[:24]}",
|
| 68 |
+
),
|
| 69 |
+
"type": "function",
|
| 70 |
+
"function": {
|
| 71 |
+
"name": block.get("name", ""),
|
| 72 |
+
"arguments": json.dumps(
|
| 73 |
+
block.get("input", {}),
|
| 74 |
+
ensure_ascii=False,
|
| 75 |
+
),
|
| 76 |
+
},
|
| 77 |
+
}
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
openai_message: dict = {
|
| 81 |
+
"role": "assistant",
|
| 82 |
+
"content": " ".join(text_parts).strip() or None,
|
| 83 |
+
}
|
| 84 |
+
if tool_calls:
|
| 85 |
+
openai_message["tool_calls"] = tool_calls
|
| 86 |
+
converted.append(openai_message)
|
| 87 |
+
continue
|
| 88 |
+
|
| 89 |
+
if role == "user" and isinstance(content, list):
|
| 90 |
+
has_tool_result = any(
|
| 91 |
+
isinstance(block, dict) and block.get("type") == "tool_result"
|
| 92 |
+
for block in content
|
| 93 |
+
)
|
| 94 |
+
if has_tool_result:
|
| 95 |
+
for block in content:
|
| 96 |
+
if not isinstance(block, dict):
|
| 97 |
+
continue
|
| 98 |
+
|
| 99 |
+
block_type = block.get("type")
|
| 100 |
+
if block_type == "tool_result":
|
| 101 |
+
result_content = block.get("content", "")
|
| 102 |
+
if isinstance(result_content, str):
|
| 103 |
+
rendered = result_content
|
| 104 |
+
elif isinstance(result_content, list):
|
| 105 |
+
rendered = " ".join(
|
| 106 |
+
item.get("text", "")
|
| 107 |
+
for item in result_content
|
| 108 |
+
if isinstance(item, dict)
|
| 109 |
+
and item.get("type") == "text"
|
| 110 |
+
)
|
| 111 |
+
else:
|
| 112 |
+
rendered = str(result_content)
|
| 113 |
+
|
| 114 |
+
converted.append(
|
| 115 |
+
{
|
| 116 |
+
"role": "tool",
|
| 117 |
+
"tool_call_id": block.get("tool_use_id", ""),
|
| 118 |
+
"content": rendered,
|
| 119 |
+
}
|
| 120 |
+
)
|
| 121 |
+
elif block_type == "text":
|
| 122 |
+
converted.append(
|
| 123 |
+
{"role": "user", "content": block.get("text", "")}
|
| 124 |
+
)
|
| 125 |
+
continue
|
| 126 |
+
|
| 127 |
+
converted.append({"role": role, "content": extract_text(content)})
|
| 128 |
+
|
| 129 |
+
return converted
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def claude_tools_to_openai(tools: Optional[list[dict]]) -> Optional[list[dict]]:
|
| 133 |
+
"""Convert Claude tool schemas into OpenAI function tools."""
|
| 134 |
+
if not tools:
|
| 135 |
+
return None
|
| 136 |
+
|
| 137 |
+
converted = [
|
| 138 |
+
{
|
| 139 |
+
"type": "function",
|
| 140 |
+
"function": {
|
| 141 |
+
"name": tool.get("name", ""),
|
| 142 |
+
"description": tool.get("description", ""),
|
| 143 |
+
"parameters": tool.get("input_schema", {}),
|
| 144 |
+
},
|
| 145 |
+
}
|
| 146 |
+
for tool in tools
|
| 147 |
+
if isinstance(tool, dict)
|
| 148 |
+
]
|
| 149 |
+
return converted or None
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def claude_tool_choice_to_openai(tool_choice: Any) -> Any:
|
| 153 |
+
"""Convert Claude tool_choice payload into OpenAI-compatible form."""
|
| 154 |
+
if not isinstance(tool_choice, dict):
|
| 155 |
+
return tool_choice
|
| 156 |
+
|
| 157 |
+
tool_choice_type = tool_choice.get("type", "auto")
|
| 158 |
+
if tool_choice_type == "auto":
|
| 159 |
+
return "auto"
|
| 160 |
+
if tool_choice_type == "any":
|
| 161 |
+
return "required"
|
| 162 |
+
if tool_choice_type == "none":
|
| 163 |
+
return "none"
|
| 164 |
+
if tool_choice_type == "tool":
|
| 165 |
+
name = tool_choice.get("name", "")
|
| 166 |
+
if name:
|
| 167 |
+
return {"type": "function", "function": {"name": name}}
|
| 168 |
+
return tool_choice
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def make_claude_id() -> str:
|
| 172 |
+
"""Generate a Claude-style message id."""
|
| 173 |
+
return f"msg_{uuid.uuid4().hex[:24]}"
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def build_tool_call_blocks(tool_calls: list[dict]) -> list[dict]:
|
| 177 |
+
"""Convert OpenAI tool calls to Claude tool_use blocks."""
|
| 178 |
+
blocks = []
|
| 179 |
+
for tool_call in tool_calls:
|
| 180 |
+
function_data = (
|
| 181 |
+
tool_call.get("function")
|
| 182 |
+
if isinstance(tool_call.get("function"), dict)
|
| 183 |
+
else {}
|
| 184 |
+
)
|
| 185 |
+
arguments = function_data.get("arguments", "{}")
|
| 186 |
+
try:
|
| 187 |
+
input_data = json.loads(arguments) if isinstance(arguments, str) else arguments
|
| 188 |
+
except Exception:
|
| 189 |
+
input_data = {}
|
| 190 |
+
|
| 191 |
+
blocks.append(
|
| 192 |
+
{
|
| 193 |
+
"type": "tool_use",
|
| 194 |
+
"id": tool_call.get(
|
| 195 |
+
"id",
|
| 196 |
+
f"toolu_{uuid.uuid4().hex[:20]}",
|
| 197 |
+
).replace("call_", "toolu_"),
|
| 198 |
+
"name": function_data.get("name", ""),
|
| 199 |
+
"input": input_data,
|
| 200 |
+
}
|
| 201 |
+
)
|
| 202 |
+
return blocks
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def build_non_stream_response(
|
| 206 |
+
msg_id: str,
|
| 207 |
+
model: str,
|
| 208 |
+
reasoning_parts: list[str],
|
| 209 |
+
answer_text: str,
|
| 210 |
+
tool_calls: Optional[list[dict]],
|
| 211 |
+
input_tokens: int,
|
| 212 |
+
output_tokens: int,
|
| 213 |
+
cache_creation_tokens: int = 0,
|
| 214 |
+
cache_read_tokens: int = 0,
|
| 215 |
+
) -> dict:
|
| 216 |
+
"""Build a Claude non-streaming message response."""
|
| 217 |
+
content: list[dict] = []
|
| 218 |
+
if reasoning_parts:
|
| 219 |
+
content.append(
|
| 220 |
+
{"type": "thinking", "thinking": "".join(reasoning_parts)}
|
| 221 |
+
)
|
| 222 |
+
if answer_text:
|
| 223 |
+
content.append({"type": "text", "text": answer_text})
|
| 224 |
+
elif not tool_calls:
|
| 225 |
+
content.append({"type": "text", "text": ""})
|
| 226 |
+
if tool_calls:
|
| 227 |
+
content.extend(build_tool_call_blocks(tool_calls))
|
| 228 |
+
|
| 229 |
+
return {
|
| 230 |
+
"id": msg_id,
|
| 231 |
+
"type": "message",
|
| 232 |
+
"role": "assistant",
|
| 233 |
+
"content": content,
|
| 234 |
+
"model": model,
|
| 235 |
+
"stop_reason": "tool_use" if tool_calls else "end_turn",
|
| 236 |
+
"stop_sequence": None,
|
| 237 |
+
"usage": {
|
| 238 |
+
"input_tokens": input_tokens,
|
| 239 |
+
"output_tokens": output_tokens,
|
| 240 |
+
"cache_creation_input_tokens": cache_creation_tokens,
|
| 241 |
+
"cache_read_input_tokens": cache_read_tokens,
|
| 242 |
+
},
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def sse(event: str, data: dict) -> str:
|
| 247 |
+
"""Format a Claude SSE event."""
|
| 248 |
+
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def sse_message_start(
|
| 252 |
+
msg_id: str,
|
| 253 |
+
model: str,
|
| 254 |
+
input_tokens: int,
|
| 255 |
+
cache_creation_tokens: int = 0,
|
| 256 |
+
cache_read_tokens: int = 0,
|
| 257 |
+
) -> str:
|
| 258 |
+
"""Create Claude message_start SSE event."""
|
| 259 |
+
return sse(
|
| 260 |
+
"message_start",
|
| 261 |
+
{
|
| 262 |
+
"type": "message_start",
|
| 263 |
+
"message": {
|
| 264 |
+
"id": msg_id,
|
| 265 |
+
"type": "message",
|
| 266 |
+
"role": "assistant",
|
| 267 |
+
"content": [],
|
| 268 |
+
"model": model,
|
| 269 |
+
"stop_reason": None,
|
| 270 |
+
"stop_sequence": None,
|
| 271 |
+
"usage": {
|
| 272 |
+
"input_tokens": input_tokens,
|
| 273 |
+
"cache_creation_input_tokens": cache_creation_tokens,
|
| 274 |
+
"cache_read_input_tokens": cache_read_tokens,
|
| 275 |
+
"output_tokens": 0,
|
| 276 |
+
},
|
| 277 |
+
},
|
| 278 |
+
},
|
| 279 |
+
)
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def sse_ping() -> str:
|
| 283 |
+
"""Create Claude ping SSE event."""
|
| 284 |
+
return sse("ping", {"type": "ping"})
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def sse_content_block_start(index: int, block: dict) -> str:
|
| 288 |
+
"""Create Claude content_block_start SSE event."""
|
| 289 |
+
return sse(
|
| 290 |
+
"content_block_start",
|
| 291 |
+
{
|
| 292 |
+
"type": "content_block_start",
|
| 293 |
+
"index": index,
|
| 294 |
+
"content_block": block,
|
| 295 |
+
},
|
| 296 |
+
)
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
def sse_content_block_delta(index: int, delta: dict) -> str:
|
| 300 |
+
"""Create Claude content_block_delta SSE event."""
|
| 301 |
+
return sse(
|
| 302 |
+
"content_block_delta",
|
| 303 |
+
{"type": "content_block_delta", "index": index, "delta": delta},
|
| 304 |
+
)
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def sse_content_block_stop(index: int) -> str:
|
| 308 |
+
"""Create Claude content_block_stop SSE event."""
|
| 309 |
+
return sse(
|
| 310 |
+
"content_block_stop",
|
| 311 |
+
{"type": "content_block_stop", "index": index},
|
| 312 |
+
)
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
def sse_message_delta(
|
| 316 |
+
stop_reason: str,
|
| 317 |
+
output_tokens: int,
|
| 318 |
+
*,
|
| 319 |
+
input_tokens: int = 0,
|
| 320 |
+
cache_creation_tokens: int = 0,
|
| 321 |
+
cache_read_tokens: int = 0,
|
| 322 |
+
) -> str:
|
| 323 |
+
"""Create Claude message_delta SSE event."""
|
| 324 |
+
return sse(
|
| 325 |
+
"message_delta",
|
| 326 |
+
{
|
| 327 |
+
"type": "message_delta",
|
| 328 |
+
"delta": {"stop_reason": stop_reason, "stop_sequence": None},
|
| 329 |
+
"usage": {
|
| 330 |
+
"input_tokens": input_tokens,
|
| 331 |
+
"output_tokens": output_tokens,
|
| 332 |
+
"cache_creation_input_tokens": cache_creation_tokens,
|
| 333 |
+
"cache_read_input_tokens": cache_read_tokens,
|
| 334 |
+
},
|
| 335 |
+
},
|
| 336 |
+
)
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
def sse_message_stop() -> str:
|
| 340 |
+
"""Create Claude message_stop SSE event."""
|
| 341 |
+
return sse("message_stop", {"type": "message_stop"})
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
def sse_error(error_type: str, message: str) -> str:
|
| 345 |
+
"""Create Claude error SSE event."""
|
| 346 |
+
return sse(
|
| 347 |
+
"error",
|
| 348 |
+
{
|
| 349 |
+
"type": "error",
|
| 350 |
+
"error": {"type": error_type, "message": message},
|
| 351 |
+
},
|
| 352 |
+
)
|
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 Optional
|
| 6 |
+
|
| 7 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class Settings(BaseSettings):
|
| 11 |
+
"""Application settings"""
|
| 12 |
+
|
| 13 |
+
# API Configuration
|
| 14 |
+
API_ENDPOINT: str = "https://chat.z.ai/api/v2/chat/completions"
|
| 15 |
+
|
| 16 |
+
# Authentication
|
| 17 |
+
AUTH_TOKEN: Optional[str] = os.getenv("AUTH_TOKEN")
|
| 18 |
+
|
| 19 |
+
# Token池配置
|
| 20 |
+
TOKEN_FAILURE_THRESHOLD: int = int(
|
| 21 |
+
os.getenv("TOKEN_FAILURE_THRESHOLD", "3")
|
| 22 |
+
)
|
| 23 |
+
TOKEN_RECOVERY_TIMEOUT: int = int(
|
| 24 |
+
os.getenv("TOKEN_RECOVERY_TIMEOUT", "1800")
|
| 25 |
+
)
|
| 26 |
+
TOKEN_AUTO_IMPORT_ENABLED: bool = (
|
| 27 |
+
os.getenv("TOKEN_AUTO_IMPORT_ENABLED", "false").lower() == "true"
|
| 28 |
+
)
|
| 29 |
+
TOKEN_AUTO_IMPORT_SOURCE_DIR: str = os.getenv("TOKEN_AUTO_IMPORT_SOURCE_DIR", "")
|
| 30 |
+
TOKEN_AUTO_IMPORT_INTERVAL: int = int(
|
| 31 |
+
os.getenv("TOKEN_AUTO_IMPORT_INTERVAL", "300")
|
| 32 |
+
)
|
| 33 |
+
TOKEN_AUTO_MAINTENANCE_ENABLED: bool = (
|
| 34 |
+
os.getenv("TOKEN_AUTO_MAINTENANCE_ENABLED", "false").lower() == "true"
|
| 35 |
+
)
|
| 36 |
+
TOKEN_AUTO_MAINTENANCE_INTERVAL: int = int(
|
| 37 |
+
os.getenv("TOKEN_AUTO_MAINTENANCE_INTERVAL", "1800")
|
| 38 |
+
)
|
| 39 |
+
TOKEN_AUTO_REMOVE_DUPLICATES: bool = (
|
| 40 |
+
os.getenv("TOKEN_AUTO_REMOVE_DUPLICATES", "true").lower() == "true"
|
| 41 |
+
)
|
| 42 |
+
TOKEN_AUTO_HEALTH_CHECK: bool = (
|
| 43 |
+
os.getenv("TOKEN_AUTO_HEALTH_CHECK", "true").lower() == "true"
|
| 44 |
+
)
|
| 45 |
+
TOKEN_AUTO_DELETE_INVALID: bool = (
|
| 46 |
+
os.getenv("TOKEN_AUTO_DELETE_INVALID", "false").lower() == "true"
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
# Model Configuration
|
| 50 |
+
GLM45_MODEL: str = os.getenv("GLM45_MODEL", "GLM-4.5")
|
| 51 |
+
GLM45_THINKING_MODEL: str = os.getenv("GLM45_THINKING_MODEL", "GLM-4.5-Thinking")
|
| 52 |
+
GLM45_SEARCH_MODEL: str = os.getenv("GLM45_SEARCH_MODEL", "GLM-4.5-Search")
|
| 53 |
+
GLM45_AIR_MODEL: str = os.getenv("GLM45_AIR_MODEL", "GLM-4.5-Air")
|
| 54 |
+
GLM46V_MODEL: str = os.getenv("GLM46V_MODEL", "GLM-4.6V")
|
| 55 |
+
GLM5_MODEL: str = os.getenv("GLM5_MODEL", "GLM-5")
|
| 56 |
+
GLM47_MODEL: str = os.getenv("GLM47_MODEL", "GLM-4.7")
|
| 57 |
+
GLM47_THINKING_MODEL: str = os.getenv("GLM47_THINKING_MODEL", "GLM-4.7-Thinking")
|
| 58 |
+
GLM47_SEARCH_MODEL: str = os.getenv("GLM47_SEARCH_MODEL", "GLM-4.7-Search")
|
| 59 |
+
GLM47_ADVANCED_SEARCH_MODEL: str = os.getenv(
|
| 60 |
+
"GLM47_ADVANCED_SEARCH_MODEL",
|
| 61 |
+
"GLM-4.7-advanced-search",
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
# Server Configuration
|
| 65 |
+
LISTEN_PORT: int = int(os.getenv("LISTEN_PORT", "8080"))
|
| 66 |
+
DEBUG_LOGGING: bool = os.getenv("DEBUG_LOGGING", "true").lower() == "true"
|
| 67 |
+
SERVICE_NAME: str = os.getenv("SERVICE_NAME", "api-proxy-server")
|
| 68 |
+
ROOT_PATH: str = os.getenv("ROOT_PATH", "")
|
| 69 |
+
|
| 70 |
+
ANONYMOUS_MODE: bool = os.getenv("ANONYMOUS_MODE", "true").lower() == "true"
|
| 71 |
+
GUEST_POOL_SIZE: int = int(os.getenv("GUEST_POOL_SIZE", "3"))
|
| 72 |
+
TOOL_SUPPORT: bool = os.getenv("TOOL_SUPPORT", "true").lower() == "true"
|
| 73 |
+
SCAN_LIMIT: int = int(os.getenv("SCAN_LIMIT", "200000"))
|
| 74 |
+
SKIP_AUTH_TOKEN: bool = os.getenv("SKIP_AUTH_TOKEN", "false").lower() == "true"
|
| 75 |
+
|
| 76 |
+
# Proxy Configuration
|
| 77 |
+
HTTP_PROXY: Optional[str] = os.getenv("HTTP_PROXY")
|
| 78 |
+
HTTPS_PROXY: Optional[str] = os.getenv("HTTPS_PROXY")
|
| 79 |
+
SOCKS5_PROXY: Optional[str] = os.getenv("SOCKS5_PROXY")
|
| 80 |
+
|
| 81 |
+
# Admin Panel Authentication
|
| 82 |
+
ADMIN_PASSWORD: str = os.getenv("ADMIN_PASSWORD", "admin123")
|
| 83 |
+
SESSION_SECRET_KEY: str = os.getenv(
|
| 84 |
+
"SESSION_SECRET_KEY",
|
| 85 |
+
"your-secret-key-change-in-production",
|
| 86 |
+
)
|
| 87 |
+
DB_PATH: str = os.getenv("DB_PATH", "tokens.db")
|
| 88 |
+
|
| 89 |
+
model_config = SettingsConfigDict(
|
| 90 |
+
env_file=".env",
|
| 91 |
+
extra="ignore", # 忽略额外字段,防止环境变量中的未知字段导致验证错误
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
settings = Settings()
|
app/core/openai.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
import time
|
| 6 |
+
from typing import Optional
|
| 7 |
+
|
| 8 |
+
from fastapi import APIRouter, Header, HTTPException, Request
|
| 9 |
+
from fastapi.responses import JSONResponse, StreamingResponse
|
| 10 |
+
|
| 11 |
+
from app.core.config import settings
|
| 12 |
+
from app.models.schemas import (
|
| 13 |
+
Choice,
|
| 14 |
+
Message,
|
| 15 |
+
Model,
|
| 16 |
+
ModelsResponse,
|
| 17 |
+
OpenAIRequest,
|
| 18 |
+
OpenAIResponse,
|
| 19 |
+
Usage,
|
| 20 |
+
)
|
| 21 |
+
from app.core.upstream import UpstreamClient
|
| 22 |
+
from app.utils.logger import get_logger
|
| 23 |
+
from app.utils.request_logging import (
|
| 24 |
+
extract_openai_usage,
|
| 25 |
+
wrap_openai_stream_with_logging,
|
| 26 |
+
write_request_log,
|
| 27 |
+
)
|
| 28 |
+
from app.utils.request_source import detect_request_source, format_request_source
|
| 29 |
+
|
| 30 |
+
logger = get_logger()
|
| 31 |
+
router = APIRouter()
|
| 32 |
+
|
| 33 |
+
_upstream_client: Optional[UpstreamClient] = None
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def get_upstream_client() -> UpstreamClient:
|
| 37 |
+
"""获取懒加载的上游适配器单例。"""
|
| 38 |
+
global _upstream_client
|
| 39 |
+
if _upstream_client is None:
|
| 40 |
+
_upstream_client = UpstreamClient()
|
| 41 |
+
return _upstream_client
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
async def handle_non_stream_response(stream_response, request: OpenAIRequest) -> JSONResponse:
|
| 45 |
+
"""处理非流式响应。"""
|
| 46 |
+
logger.info("📄 开始处理非流式响应")
|
| 47 |
+
|
| 48 |
+
full_content = []
|
| 49 |
+
async for chunk_data in stream_response():
|
| 50 |
+
if chunk_data.startswith("data: "):
|
| 51 |
+
chunk_str = chunk_data[6:].strip()
|
| 52 |
+
if chunk_str and chunk_str != "[DONE]":
|
| 53 |
+
try:
|
| 54 |
+
chunk = json.loads(chunk_str)
|
| 55 |
+
if "choices" in chunk and chunk["choices"]:
|
| 56 |
+
choice = chunk["choices"][0]
|
| 57 |
+
if "delta" in choice and "content" in choice["delta"]:
|
| 58 |
+
content = choice["delta"]["content"]
|
| 59 |
+
if content:
|
| 60 |
+
full_content.append(content)
|
| 61 |
+
except json.JSONDecodeError:
|
| 62 |
+
continue
|
| 63 |
+
|
| 64 |
+
response_data = OpenAIResponse(
|
| 65 |
+
id=f"chatcmpl-{int(time.time())}",
|
| 66 |
+
object="chat.completion",
|
| 67 |
+
created=int(time.time()),
|
| 68 |
+
model=request.model,
|
| 69 |
+
choices=[
|
| 70 |
+
Choice(
|
| 71 |
+
index=0,
|
| 72 |
+
message=Message(
|
| 73 |
+
role="assistant",
|
| 74 |
+
content="".join(full_content),
|
| 75 |
+
tool_calls=None,
|
| 76 |
+
),
|
| 77 |
+
finish_reason="stop",
|
| 78 |
+
)
|
| 79 |
+
],
|
| 80 |
+
usage=Usage(prompt_tokens=0, completion_tokens=0, total_tokens=0),
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
logger.info("✅ 非流式响应处理完成")
|
| 84 |
+
return JSONResponse(content=response_data.model_dump(exclude_none=True))
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
@router.get("/v1/models")
|
| 88 |
+
async def list_models():
|
| 89 |
+
"""返回当前服务支持的模型列表。"""
|
| 90 |
+
try:
|
| 91 |
+
client = get_upstream_client()
|
| 92 |
+
current_time = int(time.time())
|
| 93 |
+
response = ModelsResponse(
|
| 94 |
+
data=[
|
| 95 |
+
Model(id=model_id, created=current_time, owned_by=settings.SERVICE_NAME)
|
| 96 |
+
for model_id in client.get_supported_models()
|
| 97 |
+
]
|
| 98 |
+
)
|
| 99 |
+
return JSONResponse(content=response.model_dump(exclude_none=True))
|
| 100 |
+
except Exception as exc:
|
| 101 |
+
logger.error(f"❌ 获取模型列表失败: {exc}")
|
| 102 |
+
raise HTTPException(status_code=500, detail=f"Failed to list models: {exc}")
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
@router.post("/v1/chat/completions")
|
| 106 |
+
async def chat_completions(
|
| 107 |
+
body: OpenAIRequest,
|
| 108 |
+
http_request: Request,
|
| 109 |
+
authorization: Optional[str] = Header(None),
|
| 110 |
+
):
|
| 111 |
+
"""直接调用上游适配器处理请求。"""
|
| 112 |
+
source_info = detect_request_source(
|
| 113 |
+
http_request,
|
| 114 |
+
protocol_hint="openai",
|
| 115 |
+
model_hint=body.model,
|
| 116 |
+
)
|
| 117 |
+
source_prefix = format_request_source(source_info)
|
| 118 |
+
started_at = time.perf_counter()
|
| 119 |
+
|
| 120 |
+
role = body.messages[0].role if body.messages else "unknown"
|
| 121 |
+
logger.info(
|
| 122 |
+
f"{source_prefix} 😶🌫️ 收到客户端请求 - 模型: {body.model}, 流式: {body.stream}, 消息数: {len(body.messages)}, 角色: {role}, 工具数: {len(body.tools) if body.tools else 0}"
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
try:
|
| 126 |
+
if not settings.SKIP_AUTH_TOKEN:
|
| 127 |
+
if not authorization or not authorization.startswith("Bearer "):
|
| 128 |
+
raise HTTPException(status_code=401, detail="Missing or invalid Authorization header")
|
| 129 |
+
|
| 130 |
+
api_key = authorization[7:]
|
| 131 |
+
if api_key != settings.AUTH_TOKEN:
|
| 132 |
+
raise HTTPException(status_code=401, detail="Invalid API key")
|
| 133 |
+
|
| 134 |
+
client = get_upstream_client()
|
| 135 |
+
result = await client.chat_completion(body)
|
| 136 |
+
|
| 137 |
+
if isinstance(result, dict) and "error" in result:
|
| 138 |
+
error_info = result["error"]
|
| 139 |
+
error_message = error_info.get("message", "Unknown upstream error")
|
| 140 |
+
error_code = error_info.get("code")
|
| 141 |
+
status_code = 404 if error_code == "model_not_found" else 500
|
| 142 |
+
raise HTTPException(status_code=status_code, detail=error_message)
|
| 143 |
+
|
| 144 |
+
if body.stream:
|
| 145 |
+
if hasattr(result, "__aiter__"):
|
| 146 |
+
return StreamingResponse(
|
| 147 |
+
wrap_openai_stream_with_logging(
|
| 148 |
+
result,
|
| 149 |
+
provider="zai",
|
| 150 |
+
model=body.model,
|
| 151 |
+
source_info=source_info,
|
| 152 |
+
started_at=started_at,
|
| 153 |
+
),
|
| 154 |
+
media_type="text/event-stream",
|
| 155 |
+
headers={
|
| 156 |
+
"Cache-Control": "no-cache",
|
| 157 |
+
"Connection": "keep-alive",
|
| 158 |
+
"Access-Control-Allow-Origin": "*",
|
| 159 |
+
},
|
| 160 |
+
)
|
| 161 |
+
raise HTTPException(
|
| 162 |
+
status_code=500,
|
| 163 |
+
detail="Expected streaming response but got non-streaming result",
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
if isinstance(result, dict):
|
| 167 |
+
usage = extract_openai_usage(result)
|
| 168 |
+
await write_request_log(
|
| 169 |
+
provider="zai",
|
| 170 |
+
model=body.model,
|
| 171 |
+
source_info=source_info,
|
| 172 |
+
success="error" not in result,
|
| 173 |
+
started_at=started_at,
|
| 174 |
+
status_code=200 if "error" not in result else 500,
|
| 175 |
+
input_tokens=usage["input_tokens"],
|
| 176 |
+
output_tokens=usage["output_tokens"],
|
| 177 |
+
cache_creation_tokens=usage["cache_creation_tokens"],
|
| 178 |
+
cache_read_tokens=usage["cache_read_tokens"],
|
| 179 |
+
total_tokens=usage["total_tokens"],
|
| 180 |
+
error_message=(result.get("error") or {}).get("message") if isinstance(result, dict) else None,
|
| 181 |
+
)
|
| 182 |
+
return JSONResponse(content=result)
|
| 183 |
+
|
| 184 |
+
response = await handle_non_stream_response(result, body)
|
| 185 |
+
response_body = json.loads(response.body)
|
| 186 |
+
usage = extract_openai_usage(response_body)
|
| 187 |
+
await write_request_log(
|
| 188 |
+
provider="zai",
|
| 189 |
+
model=body.model,
|
| 190 |
+
source_info=source_info,
|
| 191 |
+
success=True,
|
| 192 |
+
started_at=started_at,
|
| 193 |
+
status_code=200,
|
| 194 |
+
input_tokens=usage["input_tokens"],
|
| 195 |
+
output_tokens=usage["output_tokens"],
|
| 196 |
+
cache_creation_tokens=usage["cache_creation_tokens"],
|
| 197 |
+
cache_read_tokens=usage["cache_read_tokens"],
|
| 198 |
+
total_tokens=usage["total_tokens"],
|
| 199 |
+
)
|
| 200 |
+
return response
|
| 201 |
+
|
| 202 |
+
except HTTPException as exc:
|
| 203 |
+
await write_request_log(
|
| 204 |
+
provider="zai",
|
| 205 |
+
model=body.model,
|
| 206 |
+
source_info=source_info,
|
| 207 |
+
success=False,
|
| 208 |
+
started_at=started_at,
|
| 209 |
+
status_code=exc.status_code,
|
| 210 |
+
error_message=str(exc.detail),
|
| 211 |
+
)
|
| 212 |
+
raise
|
| 213 |
+
except Exception as exc:
|
| 214 |
+
logger.error(f"{source_prefix} ❌ 请求处理失败: {exc}")
|
| 215 |
+
await write_request_log(
|
| 216 |
+
provider="zai",
|
| 217 |
+
model=body.model,
|
| 218 |
+
source_info=source_info,
|
| 219 |
+
success=False,
|
| 220 |
+
started_at=started_at,
|
| 221 |
+
status_code=500,
|
| 222 |
+
error_message=str(exc),
|
| 223 |
+
)
|
| 224 |
+
raise HTTPException(status_code=500, detail=f"Internal server error: {str(exc)}")
|
app/core/openai_compat.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
|
| 4 |
+
"""OpenAI 兼容响应辅助函数。"""
|
| 5 |
+
|
| 6 |
+
import json
|
| 7 |
+
import time
|
| 8 |
+
import uuid
|
| 9 |
+
from typing import Any, Dict, List, Optional
|
| 10 |
+
|
| 11 |
+
from app.utils.logger import get_logger
|
| 12 |
+
|
| 13 |
+
logger = get_logger()
|
| 14 |
+
SYSTEM_FINGERPRINT = "fp_api_proxy_001"
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def create_chat_id() -> str:
|
| 18 |
+
"""生成聊天 ID。"""
|
| 19 |
+
return f"chatcmpl-{uuid.uuid4().hex}"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def create_openai_chunk(
|
| 23 |
+
chat_id: str,
|
| 24 |
+
model: str,
|
| 25 |
+
delta: Dict[str, Any],
|
| 26 |
+
finish_reason: Optional[str] = None,
|
| 27 |
+
) -> Dict[str, Any]:
|
| 28 |
+
"""创建 OpenAI 格式的流式响应块。"""
|
| 29 |
+
return {
|
| 30 |
+
"id": chat_id,
|
| 31 |
+
"object": "chat.completion.chunk",
|
| 32 |
+
"created": int(time.time()),
|
| 33 |
+
"model": model,
|
| 34 |
+
"choices": [
|
| 35 |
+
{
|
| 36 |
+
"index": 0,
|
| 37 |
+
"delta": delta,
|
| 38 |
+
"finish_reason": finish_reason,
|
| 39 |
+
"logprobs": None,
|
| 40 |
+
}
|
| 41 |
+
],
|
| 42 |
+
"system_fingerprint": SYSTEM_FINGERPRINT,
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def create_openai_response(
|
| 47 |
+
chat_id: str,
|
| 48 |
+
model: str,
|
| 49 |
+
content: str,
|
| 50 |
+
usage: Optional[Dict[str, int]] = None,
|
| 51 |
+
) -> Dict[str, Any]:
|
| 52 |
+
"""创建 OpenAI 格式的非流式响应。"""
|
| 53 |
+
return {
|
| 54 |
+
"id": chat_id,
|
| 55 |
+
"object": "chat.completion",
|
| 56 |
+
"created": int(time.time()),
|
| 57 |
+
"model": model,
|
| 58 |
+
"choices": [
|
| 59 |
+
{
|
| 60 |
+
"index": 0,
|
| 61 |
+
"message": {"role": "assistant", "content": content},
|
| 62 |
+
"finish_reason": "stop",
|
| 63 |
+
"logprobs": None,
|
| 64 |
+
}
|
| 65 |
+
],
|
| 66 |
+
"usage": usage
|
| 67 |
+
or {
|
| 68 |
+
"prompt_tokens": 0,
|
| 69 |
+
"completion_tokens": 0,
|
| 70 |
+
"total_tokens": 0,
|
| 71 |
+
},
|
| 72 |
+
"system_fingerprint": SYSTEM_FINGERPRINT,
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def create_openai_response_with_reasoning(
|
| 77 |
+
chat_id: str,
|
| 78 |
+
model: str,
|
| 79 |
+
content: str,
|
| 80 |
+
reasoning_content: Optional[str] = None,
|
| 81 |
+
usage: Optional[Dict[str, int]] = None,
|
| 82 |
+
tool_calls: Optional[List[Dict[str, Any]]] = None,
|
| 83 |
+
) -> Dict[str, Any]:
|
| 84 |
+
"""创建包含 reasoning/tool_calls 的 OpenAI 响应。"""
|
| 85 |
+
message: Dict[str, Any] = {
|
| 86 |
+
"role": "assistant",
|
| 87 |
+
"content": content,
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
if reasoning_content and reasoning_content.strip():
|
| 91 |
+
message["reasoning_content"] = reasoning_content
|
| 92 |
+
|
| 93 |
+
if tool_calls:
|
| 94 |
+
message["tool_calls"] = tool_calls
|
| 95 |
+
|
| 96 |
+
return {
|
| 97 |
+
"id": chat_id,
|
| 98 |
+
"object": "chat.completion",
|
| 99 |
+
"created": int(time.time()),
|
| 100 |
+
"model": model,
|
| 101 |
+
"choices": [
|
| 102 |
+
{
|
| 103 |
+
"index": 0,
|
| 104 |
+
"message": message,
|
| 105 |
+
"finish_reason": "tool_calls" if tool_calls else "stop",
|
| 106 |
+
"logprobs": None,
|
| 107 |
+
}
|
| 108 |
+
],
|
| 109 |
+
"usage": usage
|
| 110 |
+
or {
|
| 111 |
+
"prompt_tokens": 0,
|
| 112 |
+
"completion_tokens": 0,
|
| 113 |
+
"total_tokens": 0,
|
| 114 |
+
},
|
| 115 |
+
"system_fingerprint": SYSTEM_FINGERPRINT,
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
async def format_sse_chunk(chunk: Dict[str, Any]) -> str:
|
| 120 |
+
"""格式化 SSE 响应块。"""
|
| 121 |
+
return f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n"
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
async def format_sse_done() -> str:
|
| 125 |
+
"""格式化 SSE 结束标记。"""
|
| 126 |
+
return "data: [DONE]\n\n"
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def handle_error(error: Exception, context: str = "") -> Dict[str, Any]:
|
| 130 |
+
"""统一错误处理。"""
|
| 131 |
+
error_msg = f"上游{context}错误: {str(error)}" if context else f"上游错误: {str(error)}"
|
| 132 |
+
logger.error(error_msg)
|
| 133 |
+
return {
|
| 134 |
+
"error": {
|
| 135 |
+
"message": error_msg,
|
| 136 |
+
"type": "upstream_error",
|
| 137 |
+
"code": "internal_error",
|
| 138 |
+
}
|
| 139 |
+
}
|
app/core/upstream.py
ADDED
|
@@ -0,0 +1,2245 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
|
| 4 |
+
"""上游适配器。"""
|
| 5 |
+
|
| 6 |
+
import asyncio
|
| 7 |
+
import base64
|
| 8 |
+
import json
|
| 9 |
+
import random
|
| 10 |
+
import time
|
| 11 |
+
import uuid
|
| 12 |
+
from datetime import datetime, timezone
|
| 13 |
+
from typing import Any, AsyncGenerator, Dict, List, Optional, Set, Tuple, Union
|
| 14 |
+
from urllib.parse import urlencode
|
| 15 |
+
|
| 16 |
+
import httpx
|
| 17 |
+
|
| 18 |
+
from app.core.config import settings
|
| 19 |
+
from app.core.openai_compat import (
|
| 20 |
+
create_openai_chunk,
|
| 21 |
+
create_openai_response_with_reasoning,
|
| 22 |
+
format_sse_chunk,
|
| 23 |
+
handle_error,
|
| 24 |
+
)
|
| 25 |
+
from app.models.schemas import OpenAIRequest
|
| 26 |
+
from app.utils.fe_version import get_latest_fe_version
|
| 27 |
+
from app.utils.guest_session_pool import get_guest_session_pool
|
| 28 |
+
from app.utils.logger import get_logger
|
| 29 |
+
from app.utils.signature import generate_signature
|
| 30 |
+
from app.utils.token_pool import get_token_pool
|
| 31 |
+
from app.utils.tool_call_handler import (
|
| 32 |
+
parse_and_extract_tool_calls,
|
| 33 |
+
)
|
| 34 |
+
from app.utils.user_agent import get_random_user_agent
|
| 35 |
+
|
| 36 |
+
logger = get_logger()
|
| 37 |
+
|
| 38 |
+
DEFAULT_ZAI_BASE_URL = "https://chat.z.ai"
|
| 39 |
+
CHAT_BOOTSTRAP_MAX_CONTENT_LEN = 500
|
| 40 |
+
DEFAULT_PLATFORM = "web"
|
| 41 |
+
DEFAULT_CLIENT_VERSION = "0.0.1"
|
| 42 |
+
DEFAULT_TIMEZONE = "Asia/Shanghai"
|
| 43 |
+
DEFAULT_LANGUAGE = "zh-CN"
|
| 44 |
+
DEFAULT_SCREEN_WIDTH = "1920"
|
| 45 |
+
DEFAULT_SCREEN_HEIGHT = "1080"
|
| 46 |
+
DEFAULT_VIEWPORT_WIDTH = "944"
|
| 47 |
+
DEFAULT_VIEWPORT_HEIGHT = "919"
|
| 48 |
+
DEFAULT_VIEWPORT_SIZE = f"{DEFAULT_VIEWPORT_WIDTH}x{DEFAULT_VIEWPORT_HEIGHT}"
|
| 49 |
+
DEFAULT_SCREEN_RESOLUTION = f"{DEFAULT_SCREEN_WIDTH}x{DEFAULT_SCREEN_HEIGHT}"
|
| 50 |
+
DEFAULT_COLOR_DEPTH = "24"
|
| 51 |
+
DEFAULT_PIXEL_RATIO = "1.25"
|
| 52 |
+
DEFAULT_MAX_TOUCH_POINTS = "10"
|
| 53 |
+
DEFAULT_TIMEZONE_OFFSET = "-480"
|
| 54 |
+
DEFAULT_PAGE_TITLE = "Z.ai Chat Proxy"
|
| 55 |
+
DEFAULT_COMPLETION_FEATURES = [
|
| 56 |
+
{"type": "mcp", "server": "vibe-coding", "status": "hidden"},
|
| 57 |
+
{"type": "mcp", "server": "ppt-maker", "status": "hidden"},
|
| 58 |
+
{"type": "mcp", "server": "image-search", "status": "hidden"},
|
| 59 |
+
{"type": "mcp", "server": "deep-research", "status": "hidden"},
|
| 60 |
+
{"type": "tool_selector", "server": "tool_selector", "status": "hidden"},
|
| 61 |
+
{"type": "mcp", "server": "advanced-search", "status": "hidden"},
|
| 62 |
+
]
|
| 63 |
+
GLM46V_MCP_SERVERS = [
|
| 64 |
+
"vlm-image-search",
|
| 65 |
+
"vlm-image-recognition",
|
| 66 |
+
"vlm-image-processing",
|
| 67 |
+
]
|
| 68 |
+
GLM46V_SELECTED_FEATURES = [
|
| 69 |
+
{"type": "mcp", "server": "vlm-image-search", "status": "selected"},
|
| 70 |
+
{"type": "mcp", "server": "vlm-image-recognition", "status": "selected"},
|
| 71 |
+
{"type": "mcp", "server": "vlm-image-processing", "status": "selected"},
|
| 72 |
+
]
|
| 73 |
+
|
| 74 |
+
def generate_uuid() -> str:
|
| 75 |
+
"""生成UUID v4"""
|
| 76 |
+
return str(uuid.uuid4())
|
| 77 |
+
|
| 78 |
+
def get_dynamic_headers(
|
| 79 |
+
chat_id: str = "",
|
| 80 |
+
browser_type: Optional[str] = None,
|
| 81 |
+
) -> Dict[str, str]:
|
| 82 |
+
"""生成上游请求所需的动态浏览器 headers。"""
|
| 83 |
+
browser_choices = [
|
| 84 |
+
"chrome",
|
| 85 |
+
"chrome",
|
| 86 |
+
"chrome",
|
| 87 |
+
"edge",
|
| 88 |
+
"edge",
|
| 89 |
+
"firefox",
|
| 90 |
+
"safari",
|
| 91 |
+
]
|
| 92 |
+
selected_browser = browser_type or random.choice(browser_choices)
|
| 93 |
+
user_agent = get_random_user_agent(selected_browser)
|
| 94 |
+
fe_version = get_latest_fe_version()
|
| 95 |
+
|
| 96 |
+
chrome_version = "139"
|
| 97 |
+
edge_version = "139"
|
| 98 |
+
|
| 99 |
+
if "Chrome/" in user_agent:
|
| 100 |
+
try:
|
| 101 |
+
chrome_version = user_agent.split("Chrome/")[1].split(".")[0]
|
| 102 |
+
except Exception:
|
| 103 |
+
pass
|
| 104 |
+
|
| 105 |
+
if "Edg/" in user_agent:
|
| 106 |
+
try:
|
| 107 |
+
edge_version = user_agent.split("Edg/")[1].split(".")[0]
|
| 108 |
+
sec_ch_ua = (
|
| 109 |
+
f'"Microsoft Edge";v="{edge_version}", '
|
| 110 |
+
f'"Chromium";v="{chrome_version}", "Not_A Brand";v="24"'
|
| 111 |
+
)
|
| 112 |
+
except Exception:
|
| 113 |
+
sec_ch_ua = (
|
| 114 |
+
f'"Not_A Brand";v="8", "Chromium";v="{chrome_version}", '
|
| 115 |
+
f'"Google Chrome";v="{chrome_version}"'
|
| 116 |
+
)
|
| 117 |
+
elif "Firefox/" in user_agent:
|
| 118 |
+
sec_ch_ua = None
|
| 119 |
+
else:
|
| 120 |
+
sec_ch_ua = (
|
| 121 |
+
f'"Not_A Brand";v="8", "Chromium";v="{chrome_version}", '
|
| 122 |
+
f'"Google Chrome";v="{chrome_version}"'
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
headers = {
|
| 126 |
+
"Content-Type": "application/json",
|
| 127 |
+
"Accept": "application/json, text/event-stream",
|
| 128 |
+
"Connection": "keep-alive",
|
| 129 |
+
"Cache-Control": "no-cache",
|
| 130 |
+
"User-Agent": user_agent,
|
| 131 |
+
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
| 132 |
+
"X-FE-Version": fe_version,
|
| 133 |
+
"Origin": "https://chat.z.ai",
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
if sec_ch_ua:
|
| 137 |
+
headers["sec-ch-ua"] = sec_ch_ua
|
| 138 |
+
headers["sec-ch-ua-mobile"] = "?0"
|
| 139 |
+
headers["sec-ch-ua-platform"] = '"Windows"'
|
| 140 |
+
|
| 141 |
+
if chat_id:
|
| 142 |
+
headers["Referer"] = f"https://chat.z.ai/c/{chat_id}"
|
| 143 |
+
else:
|
| 144 |
+
headers["Referer"] = "https://chat.z.ai/"
|
| 145 |
+
|
| 146 |
+
return headers
|
| 147 |
+
|
| 148 |
+
def _urlsafe_b64decode(data: str) -> bytes:
|
| 149 |
+
"""Decode a URL-safe base64 string with proper padding."""
|
| 150 |
+
if isinstance(data, str):
|
| 151 |
+
data_bytes = data.encode("utf-8")
|
| 152 |
+
else:
|
| 153 |
+
data_bytes = data
|
| 154 |
+
padding = b"=" * (-len(data_bytes) % 4)
|
| 155 |
+
return base64.urlsafe_b64decode(data_bytes + padding)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def _decode_jwt_payload(token: str) -> Dict[str, Any]:
|
| 159 |
+
"""Decode JWT payload without verification to extract metadata."""
|
| 160 |
+
try:
|
| 161 |
+
parts = token.split(".")
|
| 162 |
+
if len(parts) < 2:
|
| 163 |
+
return {}
|
| 164 |
+
payload_raw = _urlsafe_b64decode(parts[1])
|
| 165 |
+
return json.loads(payload_raw.decode("utf-8", errors="ignore"))
|
| 166 |
+
except Exception:
|
| 167 |
+
return {}
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def _extract_user_id_from_token(token: str) -> str:
|
| 171 |
+
"""Extract user_id from a JWT's payload. Fallback to 'guest'."""
|
| 172 |
+
payload = _decode_jwt_payload(token) if token else {}
|
| 173 |
+
for key in ("id", "user_id", "uid", "sub"):
|
| 174 |
+
val = payload.get(key)
|
| 175 |
+
if isinstance(val, (str, int)) and str(val):
|
| 176 |
+
return str(val)
|
| 177 |
+
return "guest"
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def _extract_text_from_content(content: Any) -> str:
|
| 181 |
+
"""Extract text parts from OpenAI-compatible content payloads."""
|
| 182 |
+
if isinstance(content, str):
|
| 183 |
+
return content
|
| 184 |
+
|
| 185 |
+
if isinstance(content, list):
|
| 186 |
+
parts: List[str] = []
|
| 187 |
+
for item in content:
|
| 188 |
+
if isinstance(item, dict) and item.get("type") == "text":
|
| 189 |
+
parts.append(str(item.get("text", "")))
|
| 190 |
+
return " ".join(part for part in parts if part).strip()
|
| 191 |
+
|
| 192 |
+
if content is None:
|
| 193 |
+
return ""
|
| 194 |
+
|
| 195 |
+
try:
|
| 196 |
+
return json.dumps(content, ensure_ascii=False)
|
| 197 |
+
except Exception:
|
| 198 |
+
return str(content)
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def _stringify_tool_arguments(arguments: Any) -> str:
|
| 202 |
+
"""Normalize tool-call arguments into a JSON string."""
|
| 203 |
+
if isinstance(arguments, str):
|
| 204 |
+
return arguments
|
| 205 |
+
|
| 206 |
+
try:
|
| 207 |
+
return json.dumps(arguments or {}, ensure_ascii=False)
|
| 208 |
+
except Exception:
|
| 209 |
+
return "{}"
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def _build_tool_call_index(
|
| 213 |
+
messages: List[Dict[str, Any]],
|
| 214 |
+
) -> Dict[str, Dict[str, str]]:
|
| 215 |
+
"""Index assistant tool calls by id for later tool-result messages."""
|
| 216 |
+
index: Dict[str, Dict[str, str]] = {}
|
| 217 |
+
|
| 218 |
+
for message in messages:
|
| 219 |
+
if message.get("role") != "assistant":
|
| 220 |
+
continue
|
| 221 |
+
|
| 222 |
+
tool_calls = message.get("tool_calls")
|
| 223 |
+
if not isinstance(tool_calls, list):
|
| 224 |
+
continue
|
| 225 |
+
|
| 226 |
+
for tool_call in tool_calls:
|
| 227 |
+
if not isinstance(tool_call, dict):
|
| 228 |
+
continue
|
| 229 |
+
|
| 230 |
+
tool_call_id = tool_call.get("id")
|
| 231 |
+
function_data = (
|
| 232 |
+
tool_call.get("function")
|
| 233 |
+
if isinstance(tool_call.get("function"), dict)
|
| 234 |
+
else {}
|
| 235 |
+
)
|
| 236 |
+
name = str(function_data.get("name", "")).strip()
|
| 237 |
+
if not isinstance(tool_call_id, str) or not name:
|
| 238 |
+
continue
|
| 239 |
+
|
| 240 |
+
index[tool_call_id] = {
|
| 241 |
+
"name": name,
|
| 242 |
+
"arguments": _stringify_tool_arguments(
|
| 243 |
+
function_data.get("arguments")
|
| 244 |
+
),
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
return index
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def _format_tool_result_message(
|
| 251 |
+
tool_name: str,
|
| 252 |
+
tool_arguments: str,
|
| 253 |
+
result_content: str,
|
| 254 |
+
) -> str:
|
| 255 |
+
"""Serialize a tool result into a text block the upstream can consume."""
|
| 256 |
+
return (
|
| 257 |
+
"<tool_execution_result>\n"
|
| 258 |
+
f"<tool_name>{tool_name}</tool_name>\n"
|
| 259 |
+
f"<tool_arguments>{tool_arguments}</tool_arguments>\n"
|
| 260 |
+
f"<tool_output>{result_content}</tool_output>\n"
|
| 261 |
+
"</tool_execution_result>"
|
| 262 |
+
)
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def _format_assistant_tool_calls(tool_calls: List[Dict[str, Any]]) -> str:
|
| 266 |
+
"""Serialize historical assistant tool calls into a text block."""
|
| 267 |
+
blocks: List[str] = []
|
| 268 |
+
|
| 269 |
+
for tool_call in tool_calls:
|
| 270 |
+
if not isinstance(tool_call, dict):
|
| 271 |
+
continue
|
| 272 |
+
|
| 273 |
+
function_data = (
|
| 274 |
+
tool_call.get("function")
|
| 275 |
+
if isinstance(tool_call.get("function"), dict)
|
| 276 |
+
else {}
|
| 277 |
+
)
|
| 278 |
+
name = str(function_data.get("name", "")).strip()
|
| 279 |
+
if not name:
|
| 280 |
+
continue
|
| 281 |
+
|
| 282 |
+
arguments = _stringify_tool_arguments(function_data.get("arguments"))
|
| 283 |
+
blocks.append(
|
| 284 |
+
"<function_call>\n"
|
| 285 |
+
f"<name>{name}</name>\n"
|
| 286 |
+
f"<args_json>{arguments}</args_json>\n"
|
| 287 |
+
"</function_call>"
|
| 288 |
+
)
|
| 289 |
+
|
| 290 |
+
if not blocks:
|
| 291 |
+
return ""
|
| 292 |
+
|
| 293 |
+
return "<function_calls>\n" + "\n".join(blocks) + "\n</function_calls>"
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
def _preprocess_openai_messages(
|
| 297 |
+
messages: List[Dict[str, Any]],
|
| 298 |
+
) -> List[Dict[str, Any]]:
|
| 299 |
+
"""Normalize OpenAI history into shapes accepted by the upstream service."""
|
| 300 |
+
tool_call_index = _build_tool_call_index(messages)
|
| 301 |
+
normalized: List[Dict[str, Any]] = []
|
| 302 |
+
|
| 303 |
+
for message in messages:
|
| 304 |
+
if not isinstance(message, dict):
|
| 305 |
+
continue
|
| 306 |
+
|
| 307 |
+
role = message.get("role")
|
| 308 |
+
|
| 309 |
+
if role == "developer":
|
| 310 |
+
converted = dict(message)
|
| 311 |
+
converted["role"] = "system"
|
| 312 |
+
normalized.append(converted)
|
| 313 |
+
continue
|
| 314 |
+
|
| 315 |
+
if role == "tool":
|
| 316 |
+
tool_call_id = message.get("tool_call_id")
|
| 317 |
+
content = _extract_text_from_content(message.get("content"))
|
| 318 |
+
tool_info = tool_call_index.get(
|
| 319 |
+
tool_call_id,
|
| 320 |
+
{
|
| 321 |
+
"name": str(message.get("name") or "unknown_tool"),
|
| 322 |
+
"arguments": "{}",
|
| 323 |
+
},
|
| 324 |
+
)
|
| 325 |
+
normalized.append(
|
| 326 |
+
{
|
| 327 |
+
"role": "user",
|
| 328 |
+
"content": _format_tool_result_message(
|
| 329 |
+
tool_info["name"],
|
| 330 |
+
tool_info["arguments"],
|
| 331 |
+
content,
|
| 332 |
+
),
|
| 333 |
+
}
|
| 334 |
+
)
|
| 335 |
+
continue
|
| 336 |
+
|
| 337 |
+
if role == "assistant" and isinstance(message.get("tool_calls"), list):
|
| 338 |
+
content = _extract_text_from_content(message.get("content"))
|
| 339 |
+
tool_calls_text = _format_assistant_tool_calls(message["tool_calls"])
|
| 340 |
+
merged_content = "\n".join(
|
| 341 |
+
part for part in (content, tool_calls_text) if part
|
| 342 |
+
).strip()
|
| 343 |
+
normalized.append({"role": "assistant", "content": merged_content})
|
| 344 |
+
continue
|
| 345 |
+
|
| 346 |
+
normalized.append(dict(message))
|
| 347 |
+
|
| 348 |
+
return normalized
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
def _extract_last_user_text(messages: List[Dict[str, Any]]) -> str:
|
| 352 |
+
"""Extract the last user text from the original OpenAI message history."""
|
| 353 |
+
for message in reversed(messages):
|
| 354 |
+
if message.get("role") != "user":
|
| 355 |
+
continue
|
| 356 |
+
content = _extract_text_from_content(message.get("content"))
|
| 357 |
+
if content:
|
| 358 |
+
return content
|
| 359 |
+
return ""
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
class UpstreamClient:
|
| 364 |
+
"""当前服务使用的上游适配器。"""
|
| 365 |
+
|
| 366 |
+
def __init__(self):
|
| 367 |
+
self.name = "upstream"
|
| 368 |
+
self.logger = logger
|
| 369 |
+
self.api_endpoint = settings.API_ENDPOINT
|
| 370 |
+
|
| 371 |
+
# 当前上游特定配置
|
| 372 |
+
self.base_url = DEFAULT_ZAI_BASE_URL
|
| 373 |
+
self.auth_url = f"{self.base_url}/api/v1/auths/"
|
| 374 |
+
|
| 375 |
+
# 模型映射
|
| 376 |
+
self.model_mapping = {
|
| 377 |
+
settings.GLM45_MODEL: "0727-360B-API", # GLM-4.5
|
| 378 |
+
settings.GLM45_THINKING_MODEL: "0727-360B-API", # GLM-4.5-Thinking
|
| 379 |
+
settings.GLM45_SEARCH_MODEL: "0727-360B-API", # GLM-4.5-Search
|
| 380 |
+
settings.GLM45_AIR_MODEL: "0727-106B-API", # GLM-4.5-Air
|
| 381 |
+
settings.GLM46V_MODEL: "glm-4.6v", # GLM-4.6V多模态
|
| 382 |
+
settings.GLM5_MODEL: "glm-5", # GLM-5
|
| 383 |
+
settings.GLM47_MODEL: "glm-4.7", # GLM-4.7
|
| 384 |
+
settings.GLM47_THINKING_MODEL: "glm-4.7", # GLM-4.7-Thinking
|
| 385 |
+
settings.GLM47_SEARCH_MODEL: "glm-4.7", # GLM-4.7-Search
|
| 386 |
+
settings.GLM47_ADVANCED_SEARCH_MODEL: "glm-4.7", # GLM-4.7-advanced-search
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
def _get_guest_retry_limit(self) -> int:
|
| 390 |
+
"""匿名号池可提供的最大重试预算。"""
|
| 391 |
+
if not settings.ANONYMOUS_MODE:
|
| 392 |
+
return 0
|
| 393 |
+
|
| 394 |
+
guest_pool = get_guest_session_pool()
|
| 395 |
+
if not guest_pool:
|
| 396 |
+
return max(2, settings.GUEST_POOL_SIZE + 1)
|
| 397 |
+
|
| 398 |
+
pool_status = guest_pool.get_pool_status()
|
| 399 |
+
available_sessions = int(
|
| 400 |
+
pool_status.get("valid_sessions")
|
| 401 |
+
or pool_status.get("available_sessions")
|
| 402 |
+
or 0
|
| 403 |
+
)
|
| 404 |
+
return max(2, available_sessions + 1)
|
| 405 |
+
|
| 406 |
+
def _get_authenticated_retry_limit(self) -> int:
|
| 407 |
+
"""认证号池与静态 Token 可提供的最大重试预算。"""
|
| 408 |
+
available_tokens = 0
|
| 409 |
+
token_pool = get_token_pool()
|
| 410 |
+
if token_pool:
|
| 411 |
+
available_tokens = int(
|
| 412 |
+
token_pool.get_pool_status().get("available_tokens", 0) or 0
|
| 413 |
+
)
|
| 414 |
+
|
| 415 |
+
return max(0, available_tokens)
|
| 416 |
+
|
| 417 |
+
def _get_total_retry_limit(self) -> int:
|
| 418 |
+
"""综合认证号池与匿名号池的最大尝试次数。"""
|
| 419 |
+
return max(
|
| 420 |
+
1,
|
| 421 |
+
self._get_authenticated_retry_limit() + self._get_guest_retry_limit(),
|
| 422 |
+
)
|
| 423 |
+
|
| 424 |
+
def _is_guest_auth(self, transformed: Dict[str, Any]) -> bool:
|
| 425 |
+
"""判断当前请求是否使用匿名会话。"""
|
| 426 |
+
return str(transformed.get("auth_mode") or "") == "guest"
|
| 427 |
+
|
| 428 |
+
def _should_retry_guest_session(
|
| 429 |
+
self,
|
| 430 |
+
status_code: int,
|
| 431 |
+
is_concurrency_limited: bool,
|
| 432 |
+
attempt: int,
|
| 433 |
+
max_attempts: int,
|
| 434 |
+
transformed: Dict[str, Any],
|
| 435 |
+
) -> bool:
|
| 436 |
+
"""判断匿名号池是否需要刷新会话后重试。"""
|
| 437 |
+
return (
|
| 438 |
+
self._is_guest_auth(transformed)
|
| 439 |
+
and (status_code == 401 or is_concurrency_limited)
|
| 440 |
+
and attempt + 1 < max_attempts
|
| 441 |
+
)
|
| 442 |
+
|
| 443 |
+
def _should_retry_authenticated_session(
|
| 444 |
+
self,
|
| 445 |
+
status_code: int,
|
| 446 |
+
is_concurrency_limited: bool,
|
| 447 |
+
attempt: int,
|
| 448 |
+
max_attempts: int,
|
| 449 |
+
transformed: Dict[str, Any],
|
| 450 |
+
) -> bool:
|
| 451 |
+
"""判断认证号池是否需要切号重试。"""
|
| 452 |
+
current_token = str(transformed.get("token") or "")
|
| 453 |
+
return (
|
| 454 |
+
not self._is_guest_auth(transformed)
|
| 455 |
+
and bool(current_token)
|
| 456 |
+
and (status_code == 401 or is_concurrency_limited)
|
| 457 |
+
and attempt + 1 < max_attempts
|
| 458 |
+
)
|
| 459 |
+
|
| 460 |
+
async def _release_guest_session(self, transformed: Dict[str, Any]):
|
| 461 |
+
"""释放当前匿名会话占用。"""
|
| 462 |
+
if not self._is_guest_auth(transformed):
|
| 463 |
+
return
|
| 464 |
+
|
| 465 |
+
guest_pool = get_guest_session_pool()
|
| 466 |
+
guest_user_id = str(
|
| 467 |
+
transformed.get("guest_user_id") or transformed.get("user_id") or ""
|
| 468 |
+
)
|
| 469 |
+
if guest_pool and guest_user_id:
|
| 470 |
+
guest_pool.release(guest_user_id)
|
| 471 |
+
|
| 472 |
+
async def _report_guest_session_failure(
|
| 473 |
+
self,
|
| 474 |
+
transformed: Dict[str, Any],
|
| 475 |
+
*,
|
| 476 |
+
is_concurrency_limited: bool = False,
|
| 477 |
+
):
|
| 478 |
+
"""上报匿名会话失败并补齐新会话。"""
|
| 479 |
+
if not self._is_guest_auth(transformed):
|
| 480 |
+
return
|
| 481 |
+
|
| 482 |
+
guest_pool = get_guest_session_pool()
|
| 483 |
+
guest_user_id = str(
|
| 484 |
+
transformed.get("guest_user_id") or transformed.get("user_id") or ""
|
| 485 |
+
)
|
| 486 |
+
if not guest_pool or not guest_user_id:
|
| 487 |
+
return
|
| 488 |
+
|
| 489 |
+
if is_concurrency_limited:
|
| 490 |
+
await guest_pool.cleanup_idle_chats()
|
| 491 |
+
|
| 492 |
+
await guest_pool.report_failure(guest_user_id)
|
| 493 |
+
|
| 494 |
+
async def _refresh_guest_request(
|
| 495 |
+
self,
|
| 496 |
+
request: OpenAIRequest,
|
| 497 |
+
attempt: int,
|
| 498 |
+
excluded_tokens: Set[str],
|
| 499 |
+
excluded_guest_user_ids: Set[str],
|
| 500 |
+
failed_transformed: Dict[str, Any],
|
| 501 |
+
is_concurrency_limited: bool = False,
|
| 502 |
+
) -> Dict[str, Any]:
|
| 503 |
+
"""匿名会话失效或并发受限后切换会话并重签请求。"""
|
| 504 |
+
retry_number = attempt + 2
|
| 505 |
+
self.logger.warning(
|
| 506 |
+
"🔄 匿名会话不可用,正在切换匿名会话并进行第 "
|
| 507 |
+
f"{retry_number} 次请求"
|
| 508 |
+
)
|
| 509 |
+
await self._report_guest_session_failure(
|
| 510 |
+
failed_transformed,
|
| 511 |
+
is_concurrency_limited=is_concurrency_limited,
|
| 512 |
+
)
|
| 513 |
+
return await self.transform_request(
|
| 514 |
+
request,
|
| 515 |
+
excluded_tokens=excluded_tokens,
|
| 516 |
+
excluded_guest_user_ids=excluded_guest_user_ids,
|
| 517 |
+
)
|
| 518 |
+
|
| 519 |
+
async def _refresh_authenticated_request(
|
| 520 |
+
self,
|
| 521 |
+
request: OpenAIRequest,
|
| 522 |
+
attempt: int,
|
| 523 |
+
excluded_tokens: Set[str],
|
| 524 |
+
excluded_guest_user_ids: Set[str],
|
| 525 |
+
) -> Dict[str, Any]:
|
| 526 |
+
"""认证模式下切换到下一枚 Token,并允许回退匿名池。"""
|
| 527 |
+
retry_number = attempt + 2
|
| 528 |
+
self.logger.warning(
|
| 529 |
+
"🔄 检测到认证会话不可用,正在切换认证 Token/回退匿名池并进行第 "
|
| 530 |
+
f"{retry_number} 次请求"
|
| 531 |
+
)
|
| 532 |
+
return await self.transform_request(
|
| 533 |
+
request,
|
| 534 |
+
excluded_tokens=excluded_tokens,
|
| 535 |
+
excluded_guest_user_ids=excluded_guest_user_ids,
|
| 536 |
+
)
|
| 537 |
+
|
| 538 |
+
def _extract_upstream_error_details(
|
| 539 |
+
self,
|
| 540 |
+
status_code: int,
|
| 541 |
+
error_text: str,
|
| 542 |
+
) -> Tuple[Optional[int], str]:
|
| 543 |
+
"""解析上游错误响应中的 code/message。"""
|
| 544 |
+
parsed_code: Optional[int] = None
|
| 545 |
+
parsed_message = (error_text or "").strip()
|
| 546 |
+
|
| 547 |
+
try:
|
| 548 |
+
payload = json.loads(error_text)
|
| 549 |
+
except Exception:
|
| 550 |
+
return parsed_code, parsed_message
|
| 551 |
+
|
| 552 |
+
if not isinstance(payload, dict):
|
| 553 |
+
return parsed_code, parsed_message
|
| 554 |
+
|
| 555 |
+
candidates = [
|
| 556 |
+
payload,
|
| 557 |
+
payload.get("error") if isinstance(payload.get("error"), dict) else None,
|
| 558 |
+
payload.get("detail") if isinstance(payload.get("detail"), dict) else None,
|
| 559 |
+
payload.get("data") if isinstance(payload.get("data"), dict) else None,
|
| 560 |
+
]
|
| 561 |
+
|
| 562 |
+
for candidate in candidates:
|
| 563 |
+
if not isinstance(candidate, dict):
|
| 564 |
+
continue
|
| 565 |
+
|
| 566 |
+
code = candidate.get("code")
|
| 567 |
+
if isinstance(code, int):
|
| 568 |
+
parsed_code = code
|
| 569 |
+
elif isinstance(code, str) and code.isdigit():
|
| 570 |
+
parsed_code = int(code)
|
| 571 |
+
|
| 572 |
+
for key in ("message", "msg", "detail", "error"):
|
| 573 |
+
value = candidate.get(key)
|
| 574 |
+
if isinstance(value, str) and value.strip():
|
| 575 |
+
parsed_message = value.strip()
|
| 576 |
+
break
|
| 577 |
+
|
| 578 |
+
if parsed_code is not None or parsed_message:
|
| 579 |
+
break
|
| 580 |
+
|
| 581 |
+
return parsed_code, parsed_message
|
| 582 |
+
|
| 583 |
+
def _is_concurrency_limited(
|
| 584 |
+
self,
|
| 585 |
+
status_code: int,
|
| 586 |
+
error_code: Optional[int],
|
| 587 |
+
error_message: str,
|
| 588 |
+
) -> bool:
|
| 589 |
+
"""判断是否为上游并发限制/429 场景。"""
|
| 590 |
+
message = (error_message or "").casefold()
|
| 591 |
+
return (
|
| 592 |
+
status_code == 429
|
| 593 |
+
or error_code == 429
|
| 594 |
+
or "concurrency" in message
|
| 595 |
+
or "too many requests" in message
|
| 596 |
+
or "并发" in error_message
|
| 597 |
+
)
|
| 598 |
+
|
| 599 |
+
def get_supported_models(self) -> List[str]:
|
| 600 |
+
"""获取支持的模型列表"""
|
| 601 |
+
return [
|
| 602 |
+
settings.GLM45_MODEL,
|
| 603 |
+
settings.GLM45_THINKING_MODEL,
|
| 604 |
+
settings.GLM45_SEARCH_MODEL,
|
| 605 |
+
settings.GLM45_AIR_MODEL,
|
| 606 |
+
settings.GLM46V_MODEL,
|
| 607 |
+
settings.GLM5_MODEL,
|
| 608 |
+
settings.GLM47_MODEL,
|
| 609 |
+
settings.GLM47_THINKING_MODEL,
|
| 610 |
+
settings.GLM47_SEARCH_MODEL,
|
| 611 |
+
settings.GLM47_ADVANCED_SEARCH_MODEL,
|
| 612 |
+
]
|
| 613 |
+
|
| 614 |
+
def _requires_persisted_chat(self, upstream_model_id: str) -> bool:
|
| 615 |
+
"""需要挂载真实 chat 会话的上游模型。"""
|
| 616 |
+
return bool(
|
| 617 |
+
self._get_model_request_profile(upstream_model_id)["use_persisted_chat"]
|
| 618 |
+
)
|
| 619 |
+
|
| 620 |
+
def _get_model_request_profile(self, upstream_model_id: str) -> Dict[str, Any]:
|
| 621 |
+
"""返回模型专属的请求配置。"""
|
| 622 |
+
if upstream_model_id == "glm-4.6v":
|
| 623 |
+
return {
|
| 624 |
+
"use_persisted_chat": True,
|
| 625 |
+
"preview_mode": False,
|
| 626 |
+
"mcp_servers": list(GLM46V_MCP_SERVERS),
|
| 627 |
+
"feature_entries": [dict(item) for item in GLM46V_SELECTED_FEATURES],
|
| 628 |
+
"default_enable_thinking": True,
|
| 629 |
+
}
|
| 630 |
+
|
| 631 |
+
if upstream_model_id == "glm-5":
|
| 632 |
+
return {
|
| 633 |
+
"use_persisted_chat": False,
|
| 634 |
+
"preview_mode": True,
|
| 635 |
+
"mcp_servers": [],
|
| 636 |
+
"feature_entries": [],
|
| 637 |
+
"default_enable_thinking": True,
|
| 638 |
+
}
|
| 639 |
+
|
| 640 |
+
return {
|
| 641 |
+
"use_persisted_chat": upstream_model_id == "glm-4.7",
|
| 642 |
+
"preview_mode": True,
|
| 643 |
+
"mcp_servers": [],
|
| 644 |
+
"feature_entries": [],
|
| 645 |
+
"default_enable_thinking": None,
|
| 646 |
+
}
|
| 647 |
+
|
| 648 |
+
def _build_request_variables(self) -> Dict[str, str]:
|
| 649 |
+
"""构建上游请求需要的运行时变量。"""
|
| 650 |
+
now = datetime.now()
|
| 651 |
+
return {
|
| 652 |
+
"{{USER_NAME}}": "Guest",
|
| 653 |
+
"{{USER_LOCATION}}": "Unknown",
|
| 654 |
+
"{{CURRENT_DATETIME}}": now.strftime("%Y-%m-%d %H:%M:%S"),
|
| 655 |
+
"{{CURRENT_DATE}}": now.strftime("%Y-%m-%d"),
|
| 656 |
+
"{{CURRENT_TIME}}": now.strftime("%H:%M:%S"),
|
| 657 |
+
"{{CURRENT_WEEKDAY}}": now.strftime("%A"),
|
| 658 |
+
"{{CURRENT_TIMEZONE}}": DEFAULT_TIMEZONE,
|
| 659 |
+
"{{USER_LANGUAGE}}": DEFAULT_LANGUAGE,
|
| 660 |
+
}
|
| 661 |
+
|
| 662 |
+
def _build_browser_query_params(
|
| 663 |
+
self,
|
| 664 |
+
*,
|
| 665 |
+
chat_id: str,
|
| 666 |
+
token: str,
|
| 667 |
+
user_id: str,
|
| 668 |
+
user_agent: str,
|
| 669 |
+
timestamp_ms: int,
|
| 670 |
+
) -> Dict[str, str]:
|
| 671 |
+
"""构建 GLM-4.7 所需的浏览器指纹查询参数。"""
|
| 672 |
+
now = datetime.now(timezone.utc)
|
| 673 |
+
browser_name = "Chrome"
|
| 674 |
+
if "Edg/" in user_agent:
|
| 675 |
+
browser_name = "Microsoft Edge"
|
| 676 |
+
elif "Firefox/" in user_agent:
|
| 677 |
+
browser_name = "Firefox"
|
| 678 |
+
elif "Safari/" in user_agent and "Chrome/" not in user_agent:
|
| 679 |
+
browser_name = "Safari"
|
| 680 |
+
|
| 681 |
+
return {
|
| 682 |
+
"version": DEFAULT_CLIENT_VERSION,
|
| 683 |
+
"platform": DEFAULT_PLATFORM,
|
| 684 |
+
"token": token,
|
| 685 |
+
"user_agent": user_agent,
|
| 686 |
+
"language": DEFAULT_LANGUAGE,
|
| 687 |
+
"languages": DEFAULT_LANGUAGE,
|
| 688 |
+
"timezone": DEFAULT_TIMEZONE,
|
| 689 |
+
"cookie_enabled": "true",
|
| 690 |
+
"screen_width": DEFAULT_SCREEN_WIDTH,
|
| 691 |
+
"screen_height": DEFAULT_SCREEN_HEIGHT,
|
| 692 |
+
"screen_resolution": DEFAULT_SCREEN_RESOLUTION,
|
| 693 |
+
"viewport_height": DEFAULT_VIEWPORT_HEIGHT,
|
| 694 |
+
"viewport_width": DEFAULT_VIEWPORT_WIDTH,
|
| 695 |
+
"viewport_size": DEFAULT_VIEWPORT_SIZE,
|
| 696 |
+
"color_depth": DEFAULT_COLOR_DEPTH,
|
| 697 |
+
"pixel_ratio": DEFAULT_PIXEL_RATIO,
|
| 698 |
+
"current_url": f"{self.base_url}/c/{chat_id}",
|
| 699 |
+
"pathname": f"/c/{chat_id}",
|
| 700 |
+
"search": "",
|
| 701 |
+
"hash": "",
|
| 702 |
+
"host": "chat.z.ai",
|
| 703 |
+
"hostname": "chat.z.ai",
|
| 704 |
+
"protocol": "https:",
|
| 705 |
+
"referrer": "",
|
| 706 |
+
"title": DEFAULT_PAGE_TITLE,
|
| 707 |
+
"timezone_offset": DEFAULT_TIMEZONE_OFFSET,
|
| 708 |
+
"local_time": (
|
| 709 |
+
now.strftime("%Y-%m-%dT%H:%M:%S.")
|
| 710 |
+
+ f"{now.microsecond // 1000:03d}Z"
|
| 711 |
+
),
|
| 712 |
+
"utc_time": now.strftime("%a, %d %b %Y %H:%M:%S GMT"),
|
| 713 |
+
"is_mobile": "false",
|
| 714 |
+
"is_touch": "false",
|
| 715 |
+
"max_touch_points": DEFAULT_MAX_TOUCH_POINTS,
|
| 716 |
+
"browser_name": browser_name,
|
| 717 |
+
"os_name": "Windows",
|
| 718 |
+
"signature_timestamp": str(timestamp_ms),
|
| 719 |
+
}
|
| 720 |
+
|
| 721 |
+
def _build_signed_completion_request(
|
| 722 |
+
self,
|
| 723 |
+
*,
|
| 724 |
+
prompt: str,
|
| 725 |
+
chat_id: str,
|
| 726 |
+
token: str,
|
| 727 |
+
user_id: str,
|
| 728 |
+
user_agent: str,
|
| 729 |
+
use_browser_fingerprint: bool,
|
| 730 |
+
) -> Tuple[str, str, str]:
|
| 731 |
+
"""构建上游 completions 的签名 URL 与请求头元数据。"""
|
| 732 |
+
timestamp_ms = int(time.time() * 1000)
|
| 733 |
+
request_id = generate_uuid()
|
| 734 |
+
core_params = {
|
| 735 |
+
"requestId": request_id,
|
| 736 |
+
"timestamp": str(timestamp_ms),
|
| 737 |
+
"user_id": user_id,
|
| 738 |
+
}
|
| 739 |
+
canonical_payload = ",".join(
|
| 740 |
+
f"{key},{value}" for key, value in sorted(core_params.items())
|
| 741 |
+
)
|
| 742 |
+
signature = generate_signature(
|
| 743 |
+
e=canonical_payload,
|
| 744 |
+
t=prompt or "",
|
| 745 |
+
s=timestamp_ms,
|
| 746 |
+
)["signature"]
|
| 747 |
+
query_params = dict(core_params)
|
| 748 |
+
if use_browser_fingerprint:
|
| 749 |
+
query_params.update(
|
| 750 |
+
self._build_browser_query_params(
|
| 751 |
+
chat_id=chat_id,
|
| 752 |
+
token=token,
|
| 753 |
+
user_id=user_id,
|
| 754 |
+
user_agent=user_agent,
|
| 755 |
+
timestamp_ms=timestamp_ms,
|
| 756 |
+
)
|
| 757 |
+
)
|
| 758 |
+
else:
|
| 759 |
+
query_params.update(
|
| 760 |
+
{
|
| 761 |
+
"token": token,
|
| 762 |
+
"version": DEFAULT_CLIENT_VERSION,
|
| 763 |
+
"platform": DEFAULT_PLATFORM,
|
| 764 |
+
"current_url": f"{self.base_url}/c/{chat_id}",
|
| 765 |
+
"pathname": f"/c/{chat_id}",
|
| 766 |
+
"signature_timestamp": str(timestamp_ms),
|
| 767 |
+
}
|
| 768 |
+
)
|
| 769 |
+
|
| 770 |
+
return (
|
| 771 |
+
f"{self.api_endpoint}?{urlencode(query_params)}",
|
| 772 |
+
signature,
|
| 773 |
+
str(timestamp_ms),
|
| 774 |
+
)
|
| 775 |
+
|
| 776 |
+
async def _create_upstream_chat(
|
| 777 |
+
self,
|
| 778 |
+
*,
|
| 779 |
+
prompt: str,
|
| 780 |
+
model: str,
|
| 781 |
+
token: str,
|
| 782 |
+
headers: Dict[str, str],
|
| 783 |
+
enable_thinking: bool,
|
| 784 |
+
web_search: bool,
|
| 785 |
+
user_message_id: Optional[str] = None,
|
| 786 |
+
files: Optional[List[Dict[str, Any]]] = None,
|
| 787 |
+
feature_entries: Optional[List[Dict[str, Any]]] = None,
|
| 788 |
+
mcp_servers: Optional[List[str]] = None,
|
| 789 |
+
) -> str:
|
| 790 |
+
"""为 GLM-4.7 系列创建上游真实 chat 会话。"""
|
| 791 |
+
init_content = prompt[:CHAT_BOOTSTRAP_MAX_CONTENT_LEN]
|
| 792 |
+
if len(prompt) > CHAT_BOOTSTRAP_MAX_CONTENT_LEN:
|
| 793 |
+
init_content = init_content + "..."
|
| 794 |
+
|
| 795 |
+
message_id = user_message_id or generate_uuid()
|
| 796 |
+
timestamp_seconds = int(time.time())
|
| 797 |
+
chat_features = (
|
| 798 |
+
[dict(item) for item in feature_entries]
|
| 799 |
+
if feature_entries
|
| 800 |
+
else [
|
| 801 |
+
{
|
| 802 |
+
"type": "tool_selector",
|
| 803 |
+
"server": "tool_selector_h",
|
| 804 |
+
"status": "hidden",
|
| 805 |
+
}
|
| 806 |
+
]
|
| 807 |
+
)
|
| 808 |
+
body = {
|
| 809 |
+
"chat": {
|
| 810 |
+
"id": "",
|
| 811 |
+
"title": "新聊天",
|
| 812 |
+
"models": [model],
|
| 813 |
+
"params": {},
|
| 814 |
+
"history": {
|
| 815 |
+
"messages": {
|
| 816 |
+
message_id: {
|
| 817 |
+
"id": message_id,
|
| 818 |
+
"parentId": None,
|
| 819 |
+
"childrenIds": [],
|
| 820 |
+
"role": "user",
|
| 821 |
+
"content": init_content,
|
| 822 |
+
**({"files": [dict(item) for item in files]} if files else {}),
|
| 823 |
+
"timestamp": timestamp_seconds,
|
| 824 |
+
"models": [model],
|
| 825 |
+
}
|
| 826 |
+
},
|
| 827 |
+
"currentId": message_id,
|
| 828 |
+
},
|
| 829 |
+
"tags": [],
|
| 830 |
+
"flags": [],
|
| 831 |
+
"features": chat_features,
|
| 832 |
+
"mcp_servers": list(mcp_servers or []),
|
| 833 |
+
"enable_thinking": enable_thinking,
|
| 834 |
+
"auto_web_search": web_search,
|
| 835 |
+
"message_version": 1,
|
| 836 |
+
"extra": {},
|
| 837 |
+
"timestamp": int(time.time() * 1000),
|
| 838 |
+
}
|
| 839 |
+
}
|
| 840 |
+
request_headers = {
|
| 841 |
+
"Content-Type": "application/json",
|
| 842 |
+
"Accept": "application/json",
|
| 843 |
+
"Authorization": f"Bearer {token}",
|
| 844 |
+
"User-Agent": headers["User-Agent"],
|
| 845 |
+
"Accept-Language": headers.get("Accept-Language", DEFAULT_LANGUAGE),
|
| 846 |
+
"Origin": self.base_url,
|
| 847 |
+
"Referer": f"{self.base_url}/",
|
| 848 |
+
}
|
| 849 |
+
async with httpx.AsyncClient(
|
| 850 |
+
base_url=self.base_url,
|
| 851 |
+
timeout=self._build_timeout(),
|
| 852 |
+
limits=self._build_limits(),
|
| 853 |
+
proxy=self._get_proxy_config(),
|
| 854 |
+
follow_redirects=True,
|
| 855 |
+
) as client:
|
| 856 |
+
response = await client.post(
|
| 857 |
+
"/api/v1/chats/new",
|
| 858 |
+
headers=request_headers,
|
| 859 |
+
json=body,
|
| 860 |
+
)
|
| 861 |
+
|
| 862 |
+
if response.status_code != 200:
|
| 863 |
+
raise RuntimeError(
|
| 864 |
+
f"上游创建 chat 失败: {response.status_code} {response.text}"
|
| 865 |
+
)
|
| 866 |
+
|
| 867 |
+
payload = response.json()
|
| 868 |
+
chat_id = str(payload.get("id") or payload.get("chat", {}).get("id") or "")
|
| 869 |
+
if not chat_id:
|
| 870 |
+
raise RuntimeError("上游创建 chat 成功但未返回 chat_id")
|
| 871 |
+
return chat_id
|
| 872 |
+
|
| 873 |
+
def _build_glm47_completion_body(
|
| 874 |
+
self,
|
| 875 |
+
*,
|
| 876 |
+
model: str,
|
| 877 |
+
messages: List[Dict[str, Any]],
|
| 878 |
+
prompt: str,
|
| 879 |
+
chat_id: str,
|
| 880 |
+
enable_thinking: bool,
|
| 881 |
+
web_search: bool,
|
| 882 |
+
files: List[Dict[str, Any]],
|
| 883 |
+
tools: Optional[List[Dict[str, Any]]],
|
| 884 |
+
tool_choice: Any,
|
| 885 |
+
temperature: Optional[float],
|
| 886 |
+
max_tokens: Optional[int],
|
| 887 |
+
mcp_servers: List[str],
|
| 888 |
+
preview_mode: bool,
|
| 889 |
+
feature_entries: Optional[List[Dict[str, Any]]],
|
| 890 |
+
message_id: str,
|
| 891 |
+
current_user_message_id: str,
|
| 892 |
+
current_user_message_parent_id: Optional[str],
|
| 893 |
+
) -> Dict[str, Any]:
|
| 894 |
+
"""构建兼容持久化 chat 模型的精简 completions 请求体。"""
|
| 895 |
+
params: Dict[str, Any] = {}
|
| 896 |
+
if temperature is not None:
|
| 897 |
+
params["temperature"] = temperature
|
| 898 |
+
if max_tokens is not None:
|
| 899 |
+
params["max_tokens"] = max_tokens
|
| 900 |
+
|
| 901 |
+
body: Dict[str, Any] = {
|
| 902 |
+
"stream": True,
|
| 903 |
+
"model": model,
|
| 904 |
+
"messages": messages,
|
| 905 |
+
"signature_prompt": prompt,
|
| 906 |
+
"params": params,
|
| 907 |
+
"extra": {},
|
| 908 |
+
"features": {
|
| 909 |
+
"image_generation": False,
|
| 910 |
+
"web_search": web_search,
|
| 911 |
+
"auto_web_search": web_search,
|
| 912 |
+
"preview_mode": preview_mode,
|
| 913 |
+
"flags": [],
|
| 914 |
+
"enable_thinking": enable_thinking,
|
| 915 |
+
},
|
| 916 |
+
"variables": self._build_request_variables(),
|
| 917 |
+
"chat_id": chat_id,
|
| 918 |
+
"id": message_id,
|
| 919 |
+
"current_user_message_id": current_user_message_id,
|
| 920 |
+
"current_user_message_parent_id": current_user_message_parent_id,
|
| 921 |
+
"background_tasks": {
|
| 922 |
+
"title_generation": True,
|
| 923 |
+
"tags_generation": True,
|
| 924 |
+
},
|
| 925 |
+
}
|
| 926 |
+
if files:
|
| 927 |
+
body["files"] = files
|
| 928 |
+
if mcp_servers:
|
| 929 |
+
body["mcp_servers"] = mcp_servers
|
| 930 |
+
if tools:
|
| 931 |
+
body["tools"] = tools
|
| 932 |
+
if tool_choice is not None:
|
| 933 |
+
body["tool_choice"] = tool_choice
|
| 934 |
+
return body
|
| 935 |
+
|
| 936 |
+
def _clean_reasoning_delta(self, delta_content: str) -> str:
|
| 937 |
+
"""清理思考阶段的 details 包裹内容。"""
|
| 938 |
+
if not delta_content:
|
| 939 |
+
return ""
|
| 940 |
+
|
| 941 |
+
if delta_content.startswith("<details"):
|
| 942 |
+
if "</summary>\n>" in delta_content:
|
| 943 |
+
return delta_content.split("</summary>\n>")[-1].strip()
|
| 944 |
+
if "</summary>\n" in delta_content:
|
| 945 |
+
return delta_content.split("</summary>\n")[-1].lstrip("> ").strip()
|
| 946 |
+
|
| 947 |
+
return delta_content
|
| 948 |
+
|
| 949 |
+
def _extract_answer_content(self, text: str) -> str:
|
| 950 |
+
"""提取思考结束后的答案正文。"""
|
| 951 |
+
if not text:
|
| 952 |
+
return ""
|
| 953 |
+
|
| 954 |
+
if "</details>\n" in text:
|
| 955 |
+
return text.split("</details>\n")[-1]
|
| 956 |
+
|
| 957 |
+
if "</details>" in text:
|
| 958 |
+
return text.split("</details>")[-1].lstrip()
|
| 959 |
+
|
| 960 |
+
return text
|
| 961 |
+
|
| 962 |
+
def _normalize_tool_calls(
|
| 963 |
+
self,
|
| 964 |
+
raw_tool_calls: Any,
|
| 965 |
+
start_index: int = 0,
|
| 966 |
+
) -> List[Dict[str, Any]]:
|
| 967 |
+
"""标准化上游工具调用为 OpenAI 兼容格式。"""
|
| 968 |
+
if not raw_tool_calls:
|
| 969 |
+
return []
|
| 970 |
+
|
| 971 |
+
tool_calls = raw_tool_calls if isinstance(raw_tool_calls, list) else [raw_tool_calls]
|
| 972 |
+
normalized: List[Dict[str, Any]] = []
|
| 973 |
+
|
| 974 |
+
for offset, tool_call in enumerate(tool_calls):
|
| 975 |
+
if not isinstance(tool_call, dict):
|
| 976 |
+
continue
|
| 977 |
+
|
| 978 |
+
function_data = tool_call.get("function") or {}
|
| 979 |
+
normalized.append(
|
| 980 |
+
{
|
| 981 |
+
"index": tool_call.get("index", start_index + offset),
|
| 982 |
+
"id": tool_call.get("id") or f"call_{uuid.uuid4().hex[:24]}",
|
| 983 |
+
"type": "function",
|
| 984 |
+
"function": {
|
| 985 |
+
"name": function_data.get("name", ""),
|
| 986 |
+
"arguments": function_data.get("arguments", ""),
|
| 987 |
+
},
|
| 988 |
+
}
|
| 989 |
+
)
|
| 990 |
+
|
| 991 |
+
return normalized
|
| 992 |
+
|
| 993 |
+
def _format_search_results(self, data: Dict[str, Any]) -> str:
|
| 994 |
+
"""将上游搜索结果格式化为可追加的 Markdown 引用。"""
|
| 995 |
+
search_info = data.get("results") or data.get("sources") or data.get("citations")
|
| 996 |
+
if not isinstance(search_info, list) or not search_info:
|
| 997 |
+
return ""
|
| 998 |
+
|
| 999 |
+
citations = []
|
| 1000 |
+
for index, item in enumerate(search_info, 1):
|
| 1001 |
+
if not isinstance(item, dict):
|
| 1002 |
+
continue
|
| 1003 |
+
|
| 1004 |
+
title = item.get("title") or item.get("name") or f"Result {index}"
|
| 1005 |
+
url = item.get("url") or item.get("link")
|
| 1006 |
+
if url:
|
| 1007 |
+
citations.append(f"[{index}] [{title}]({url})")
|
| 1008 |
+
|
| 1009 |
+
if not citations:
|
| 1010 |
+
return ""
|
| 1011 |
+
|
| 1012 |
+
return "\n\n---\n" + "\n".join(citations)
|
| 1013 |
+
|
| 1014 |
+
def _get_proxy_config(self) -> Optional[str]:
|
| 1015 |
+
"""Get proxy configuration from settings"""
|
| 1016 |
+
# In httpx 0.28.1, proxy parameter expects a single URL string
|
| 1017 |
+
# Support HTTP_PROXY, HTTPS_PROXY and SOCKS5_PROXY
|
| 1018 |
+
|
| 1019 |
+
if settings.HTTPS_PROXY:
|
| 1020 |
+
self.logger.info(f"🔄 使用HTTPS代理: {settings.HTTPS_PROXY}")
|
| 1021 |
+
return settings.HTTPS_PROXY
|
| 1022 |
+
|
| 1023 |
+
if settings.HTTP_PROXY:
|
| 1024 |
+
self.logger.info(f"🔄 使用HTTP代理: {settings.HTTP_PROXY}")
|
| 1025 |
+
return settings.HTTP_PROXY
|
| 1026 |
+
|
| 1027 |
+
if settings.SOCKS5_PROXY:
|
| 1028 |
+
self.logger.info(f"🔄 使用SOCKS5代理: {settings.SOCKS5_PROXY}")
|
| 1029 |
+
return settings.SOCKS5_PROXY
|
| 1030 |
+
|
| 1031 |
+
return None
|
| 1032 |
+
|
| 1033 |
+
def _build_timeout(self, read_timeout: float = 30.0) -> httpx.Timeout:
|
| 1034 |
+
"""Create httpx timeout settings tuned for upstream chat traffic."""
|
| 1035 |
+
return httpx.Timeout(
|
| 1036 |
+
connect=5.0,
|
| 1037 |
+
read=read_timeout,
|
| 1038 |
+
write=10.0,
|
| 1039 |
+
pool=5.0,
|
| 1040 |
+
)
|
| 1041 |
+
|
| 1042 |
+
def _build_limits(self) -> httpx.Limits:
|
| 1043 |
+
"""Create conservative connection-pool limits for upstream requests."""
|
| 1044 |
+
return httpx.Limits(
|
| 1045 |
+
max_keepalive_connections=5,
|
| 1046 |
+
max_connections=10,
|
| 1047 |
+
)
|
| 1048 |
+
|
| 1049 |
+
async def _fetch_direct_guest_auth(self) -> Dict[str, Any]:
|
| 1050 |
+
"""匿名号池缺席时,兜底直连拉取一个访客令牌。"""
|
| 1051 |
+
max_retries = 3
|
| 1052 |
+
|
| 1053 |
+
for retry_count in range(max_retries):
|
| 1054 |
+
try:
|
| 1055 |
+
headers = get_dynamic_headers()
|
| 1056 |
+
self.logger.debug(
|
| 1057 |
+
f"尝试获取访客令牌 (第{retry_count + 1}次): {self.auth_url}"
|
| 1058 |
+
)
|
| 1059 |
+
|
| 1060 |
+
proxies = self._get_proxy_config()
|
| 1061 |
+
async with httpx.AsyncClient(
|
| 1062 |
+
timeout=self._build_timeout(),
|
| 1063 |
+
follow_redirects=True,
|
| 1064 |
+
limits=self._build_limits(),
|
| 1065 |
+
proxy=proxies,
|
| 1066 |
+
) as client:
|
| 1067 |
+
response = await client.get(self.auth_url, headers=headers)
|
| 1068 |
+
|
| 1069 |
+
if response.status_code == 200:
|
| 1070 |
+
data = response.json()
|
| 1071 |
+
token = str(data.get("token") or "").strip()
|
| 1072 |
+
if token:
|
| 1073 |
+
user_id = str(
|
| 1074 |
+
data.get("id")
|
| 1075 |
+
or data.get("user_id")
|
| 1076 |
+
or _extract_user_id_from_token(token)
|
| 1077 |
+
)
|
| 1078 |
+
username = str(
|
| 1079 |
+
data.get("name")
|
| 1080 |
+
or str(data.get("email") or "").split("@")[0]
|
| 1081 |
+
or "Guest"
|
| 1082 |
+
)
|
| 1083 |
+
self.logger.info(
|
| 1084 |
+
f"✅ 直连获取匿名令牌成功: {token[:20]}..."
|
| 1085 |
+
)
|
| 1086 |
+
return {
|
| 1087 |
+
"token": token,
|
| 1088 |
+
"user_id": user_id,
|
| 1089 |
+
"username": username or "Guest",
|
| 1090 |
+
"auth_mode": "guest",
|
| 1091 |
+
"token_source": "guest_direct",
|
| 1092 |
+
"guest_user_id": user_id,
|
| 1093 |
+
}
|
| 1094 |
+
|
| 1095 |
+
self.logger.warning(f"响应中未找到 token 字段: {data}")
|
| 1096 |
+
elif response.status_code == 405:
|
| 1097 |
+
self.logger.error(
|
| 1098 |
+
"🚫 请求被 WAF 拦截 (405),无法直连获取匿名令牌"
|
| 1099 |
+
)
|
| 1100 |
+
break
|
| 1101 |
+
else:
|
| 1102 |
+
self.logger.warning(
|
| 1103 |
+
f"直连获取匿名令牌失败,状态码: {response.status_code}"
|
| 1104 |
+
)
|
| 1105 |
+
except httpx.TimeoutException as exc:
|
| 1106 |
+
self.logger.warning(
|
| 1107 |
+
f"直连获取匿名令牌超时 (第{retry_count + 1}次): {exc}"
|
| 1108 |
+
)
|
| 1109 |
+
except httpx.ConnectError as exc:
|
| 1110 |
+
self.logger.warning(
|
| 1111 |
+
f"直连获取匿名令牌连接错误 (第{retry_count + 1}次): {exc}"
|
| 1112 |
+
)
|
| 1113 |
+
except json.JSONDecodeError as exc:
|
| 1114 |
+
self.logger.warning(
|
| 1115 |
+
f"直连获取匿名令牌 JSON 解析错误 (第{retry_count + 1}次): {exc}"
|
| 1116 |
+
)
|
| 1117 |
+
except Exception as exc:
|
| 1118 |
+
self.logger.warning(
|
| 1119 |
+
f"直连获取匿名令牌失败 (第{retry_count + 1}次): {exc}"
|
| 1120 |
+
)
|
| 1121 |
+
|
| 1122 |
+
if retry_count + 1 < max_retries:
|
| 1123 |
+
await asyncio.sleep(2)
|
| 1124 |
+
|
| 1125 |
+
return {
|
| 1126 |
+
"token": "",
|
| 1127 |
+
"user_id": "guest",
|
| 1128 |
+
"username": "Guest",
|
| 1129 |
+
"auth_mode": "guest",
|
| 1130 |
+
"token_source": "guest_direct",
|
| 1131 |
+
"guest_user_id": None,
|
| 1132 |
+
}
|
| 1133 |
+
|
| 1134 |
+
async def get_auth_info(
|
| 1135 |
+
self,
|
| 1136 |
+
excluded_tokens: Optional[Set[str]] = None,
|
| 1137 |
+
excluded_guest_user_ids: Optional[Set[str]] = None,
|
| 1138 |
+
) -> Dict[str, Any]:
|
| 1139 |
+
"""优先获取认证 Token,必要时回退匿名会话池。"""
|
| 1140 |
+
token_pool = get_token_pool()
|
| 1141 |
+
if token_pool:
|
| 1142 |
+
token = token_pool.get_next_token(exclude_tokens=excluded_tokens)
|
| 1143 |
+
if token:
|
| 1144 |
+
user_id = _extract_user_id_from_token(token)
|
| 1145 |
+
self.logger.debug(f"从认证号池获取令牌: {token[:20]}...")
|
| 1146 |
+
return {
|
| 1147 |
+
"token": token,
|
| 1148 |
+
"user_id": user_id,
|
| 1149 |
+
"username": "User",
|
| 1150 |
+
"auth_mode": "authenticated",
|
| 1151 |
+
"token_source": "auth_pool",
|
| 1152 |
+
"guest_user_id": None,
|
| 1153 |
+
}
|
| 1154 |
+
|
| 1155 |
+
if settings.ANONYMOUS_MODE:
|
| 1156 |
+
guest_pool = get_guest_session_pool()
|
| 1157 |
+
if guest_pool:
|
| 1158 |
+
try:
|
| 1159 |
+
session = await guest_pool.acquire(
|
| 1160 |
+
exclude_user_ids=excluded_guest_user_ids
|
| 1161 |
+
)
|
| 1162 |
+
self.logger.info(
|
| 1163 |
+
"🫥 认证池不可用,回退匿名会话池: "
|
| 1164 |
+
f"user_id={session.user_id}"
|
| 1165 |
+
)
|
| 1166 |
+
return {
|
| 1167 |
+
"token": session.token,
|
| 1168 |
+
"user_id": session.user_id,
|
| 1169 |
+
"username": session.username,
|
| 1170 |
+
"auth_mode": "guest",
|
| 1171 |
+
"token_source": "guest_pool",
|
| 1172 |
+
"guest_user_id": session.user_id,
|
| 1173 |
+
}
|
| 1174 |
+
except Exception as exc:
|
| 1175 |
+
self.logger.warning(f"匿名会话池获取失败,转为直连访客鉴权: {exc}")
|
| 1176 |
+
|
| 1177 |
+
return await self._fetch_direct_guest_auth()
|
| 1178 |
+
|
| 1179 |
+
self.logger.error("❌ 无法获取有效的上游令牌")
|
| 1180 |
+
return {
|
| 1181 |
+
"token": "",
|
| 1182 |
+
"user_id": "",
|
| 1183 |
+
"username": "",
|
| 1184 |
+
"auth_mode": "authenticated",
|
| 1185 |
+
"token_source": "none",
|
| 1186 |
+
"guest_user_id": None,
|
| 1187 |
+
}
|
| 1188 |
+
|
| 1189 |
+
async def mark_token_failure(self, token: str, error: Exception = None):
|
| 1190 |
+
"""标记token使用失败"""
|
| 1191 |
+
token_pool = get_token_pool()
|
| 1192 |
+
if token_pool:
|
| 1193 |
+
await token_pool.record_token_failure(token, error)
|
| 1194 |
+
|
| 1195 |
+
async def upload_image(
|
| 1196 |
+
self,
|
| 1197 |
+
data_url: str,
|
| 1198 |
+
chat_id: str,
|
| 1199 |
+
token: str,
|
| 1200 |
+
user_id: str,
|
| 1201 |
+
auth_mode: str = "authenticated",
|
| 1202 |
+
) -> Optional[Dict]:
|
| 1203 |
+
"""上传 base64 编码的图片到上游服务器。
|
| 1204 |
+
|
| 1205 |
+
Args:
|
| 1206 |
+
data_url: data:image/xxx;base64,... 格式的图片数据
|
| 1207 |
+
chat_id: 当前对话ID
|
| 1208 |
+
token: 认证令牌
|
| 1209 |
+
user_id: 用户ID
|
| 1210 |
+
auth_mode: 当前鉴权模式,guest 模式下禁止上传
|
| 1211 |
+
|
| 1212 |
+
Returns:
|
| 1213 |
+
上传成功返回完整的文件信息字典,失败返回 None
|
| 1214 |
+
"""
|
| 1215 |
+
if auth_mode == "guest" or not data_url.startswith("data:"):
|
| 1216 |
+
return None
|
| 1217 |
+
|
| 1218 |
+
try:
|
| 1219 |
+
# 解析 data URL
|
| 1220 |
+
header, encoded = data_url.split(",", 1)
|
| 1221 |
+
mime_type = header.split(";")[0].split(":")[1] if ":" in header else "image/jpeg"
|
| 1222 |
+
|
| 1223 |
+
# 解码 base64 数据
|
| 1224 |
+
image_data = base64.b64decode(encoded)
|
| 1225 |
+
filename = str(uuid.uuid4())
|
| 1226 |
+
|
| 1227 |
+
self.logger.debug(f"📤 上传图片: {filename}, 大小: {len(image_data)} bytes")
|
| 1228 |
+
|
| 1229 |
+
# 构建上传请求
|
| 1230 |
+
upload_url = f"{self.base_url}/api/v1/files/"
|
| 1231 |
+
headers = {
|
| 1232 |
+
"Accept": "*/*",
|
| 1233 |
+
"Accept-Language": "zh-CN,zh;q=0.9",
|
| 1234 |
+
"Cache-Control": "no-cache",
|
| 1235 |
+
"Connection": "keep-alive",
|
| 1236 |
+
"Origin": f"{self.base_url}",
|
| 1237 |
+
"Pragma": "no-cache",
|
| 1238 |
+
"Referer": (
|
| 1239 |
+
f"{self.base_url}/c/{chat_id}" if chat_id else f"{self.base_url}/"
|
| 1240 |
+
),
|
| 1241 |
+
"Sec-Ch-Ua": '"Microsoft Edge";v="141", "Not?A_Brand";v="8", "Chromium";v="141"',
|
| 1242 |
+
"Sec-Ch-Ua-Mobile": "?0",
|
| 1243 |
+
"Sec-Ch-Ua-Platform": '"Windows"',
|
| 1244 |
+
"Sec-Fetch-Dest": "empty",
|
| 1245 |
+
"Sec-Fetch-Mode": "cors",
|
| 1246 |
+
"Sec-Fetch-Site": "same-origin",
|
| 1247 |
+
"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",
|
| 1248 |
+
"Authorization": f"Bearer {token}",
|
| 1249 |
+
}
|
| 1250 |
+
|
| 1251 |
+
# Get proxy configuration
|
| 1252 |
+
proxies = self._get_proxy_config()
|
| 1253 |
+
|
| 1254 |
+
# 使用 httpx 上传文件
|
| 1255 |
+
async with httpx.AsyncClient(
|
| 1256 |
+
timeout=self._build_timeout(),
|
| 1257 |
+
limits=self._build_limits(),
|
| 1258 |
+
proxy=proxies,
|
| 1259 |
+
) as client:
|
| 1260 |
+
files = {
|
| 1261 |
+
"file": (filename, image_data, mime_type)
|
| 1262 |
+
}
|
| 1263 |
+
response = await client.post(upload_url, files=files, headers=headers)
|
| 1264 |
+
|
| 1265 |
+
if response.status_code == 200:
|
| 1266 |
+
result = response.json()
|
| 1267 |
+
file_id = result.get("id")
|
| 1268 |
+
file_name = result.get("filename")
|
| 1269 |
+
file_size = len(image_data)
|
| 1270 |
+
|
| 1271 |
+
self.logger.info(f"✅ 图片上传成功: {file_id}_{file_name}")
|
| 1272 |
+
|
| 1273 |
+
# 返回符合上游格式的文件信息
|
| 1274 |
+
current_timestamp = int(time.time())
|
| 1275 |
+
return {
|
| 1276 |
+
"type": "image",
|
| 1277 |
+
"file": {
|
| 1278 |
+
"id": file_id,
|
| 1279 |
+
"user_id": user_id,
|
| 1280 |
+
"hash": None,
|
| 1281 |
+
"filename": file_name,
|
| 1282 |
+
"data": {},
|
| 1283 |
+
"meta": {
|
| 1284 |
+
"name": file_name,
|
| 1285 |
+
"content_type": mime_type,
|
| 1286 |
+
"size": file_size,
|
| 1287 |
+
"data": {},
|
| 1288 |
+
},
|
| 1289 |
+
"created_at": current_timestamp,
|
| 1290 |
+
"updated_at": current_timestamp
|
| 1291 |
+
},
|
| 1292 |
+
"id": file_id,
|
| 1293 |
+
"url": f"/api/v1/files/{file_id}/content",
|
| 1294 |
+
"name": file_name,
|
| 1295 |
+
"status": "uploaded",
|
| 1296 |
+
"size": file_size,
|
| 1297 |
+
"error": "",
|
| 1298 |
+
"itemId": str(uuid.uuid4()),
|
| 1299 |
+
"media": "image"
|
| 1300 |
+
}
|
| 1301 |
+
else:
|
| 1302 |
+
self.logger.error(f"❌ 图片上传失败: {response.status_code} - {response.text}")
|
| 1303 |
+
return None
|
| 1304 |
+
|
| 1305 |
+
except Exception as e:
|
| 1306 |
+
self.logger.error(f"❌ 图片上传异常: {e}")
|
| 1307 |
+
return None
|
| 1308 |
+
|
| 1309 |
+
async def transform_request(
|
| 1310 |
+
self,
|
| 1311 |
+
request: OpenAIRequest,
|
| 1312 |
+
excluded_tokens: Optional[Set[str]] = None,
|
| 1313 |
+
excluded_guest_user_ids: Optional[Set[str]] = None,
|
| 1314 |
+
) -> Dict[str, Any]:
|
| 1315 |
+
"""转换 OpenAI 请求为上游格式。"""
|
| 1316 |
+
self.logger.info(f"🔄 转换 OpenAI 请求到上游格式: {request.model}")
|
| 1317 |
+
|
| 1318 |
+
raw_messages = [
|
| 1319 |
+
message.model_dump(exclude_none=True)
|
| 1320 |
+
for message in request.messages
|
| 1321 |
+
]
|
| 1322 |
+
normalized_messages = _preprocess_openai_messages(raw_messages)
|
| 1323 |
+
|
| 1324 |
+
auth_info = await self.get_auth_info(
|
| 1325 |
+
excluded_tokens=excluded_tokens,
|
| 1326 |
+
excluded_guest_user_ids=excluded_guest_user_ids,
|
| 1327 |
+
)
|
| 1328 |
+
token = str(auth_info.get("token") or "")
|
| 1329 |
+
if not token:
|
| 1330 |
+
raise RuntimeError("无法获取上游认证令牌")
|
| 1331 |
+
|
| 1332 |
+
user_id = str(auth_info.get("user_id") or _extract_user_id_from_token(token))
|
| 1333 |
+
auth_mode = str(auth_info.get("auth_mode") or "authenticated")
|
| 1334 |
+
token_source = str(auth_info.get("token_source") or "unknown")
|
| 1335 |
+
guest_user_id = auth_info.get("guest_user_id")
|
| 1336 |
+
# 确定请求的模型特性
|
| 1337 |
+
last_user_text = _extract_last_user_text(raw_messages)
|
| 1338 |
+
requested_model = request.model
|
| 1339 |
+
is_thinking_model = "-thinking" in requested_model.casefold()
|
| 1340 |
+
is_search_model = "-search" in requested_model.casefold()
|
| 1341 |
+
is_advanced_search = requested_model == settings.GLM47_ADVANCED_SEARCH_MODEL
|
| 1342 |
+
upstream_model_id = self.model_mapping.get(requested_model, "0727-360B-API")
|
| 1343 |
+
tools = request.tools if settings.TOOL_SUPPORT and request.tools else None
|
| 1344 |
+
tool_choice = getattr(request, "tool_choice", None)
|
| 1345 |
+
model_profile = self._get_model_request_profile(upstream_model_id)
|
| 1346 |
+
enable_thinking = request.enable_thinking
|
| 1347 |
+
if enable_thinking is None:
|
| 1348 |
+
default_enable_thinking = model_profile["default_enable_thinking"]
|
| 1349 |
+
enable_thinking = (
|
| 1350 |
+
default_enable_thinking
|
| 1351 |
+
if default_enable_thinking is not None
|
| 1352 |
+
else is_thinking_model
|
| 1353 |
+
)
|
| 1354 |
+
|
| 1355 |
+
web_search = request.web_search
|
| 1356 |
+
if web_search is None:
|
| 1357 |
+
web_search = is_search_model or is_advanced_search
|
| 1358 |
+
|
| 1359 |
+
use_persisted_chat = bool(model_profile["use_persisted_chat"])
|
| 1360 |
+
preview_mode = bool(model_profile["preview_mode"])
|
| 1361 |
+
feature_entries = list(model_profile["feature_entries"])
|
| 1362 |
+
persisted_user_message_id = generate_uuid() if use_persisted_chat else None
|
| 1363 |
+
persisted_assistant_message_id = generate_uuid() if use_persisted_chat else None
|
| 1364 |
+
|
| 1365 |
+
mcp_servers = list(model_profile["mcp_servers"])
|
| 1366 |
+
if is_advanced_search and "advanced-search" not in mcp_servers:
|
| 1367 |
+
mcp_servers.append("advanced-search")
|
| 1368 |
+
self.logger.info("🔍 检测到高级搜索模型,添加 advanced-search MCP 服务器")
|
| 1369 |
+
|
| 1370 |
+
headers = get_dynamic_headers(
|
| 1371 |
+
browser_type="chrome" if use_persisted_chat else None,
|
| 1372 |
+
)
|
| 1373 |
+
chat_id = generate_uuid()
|
| 1374 |
+
|
| 1375 |
+
# 处理消息格式 - 上游使用单独的 files 字段传递图片
|
| 1376 |
+
messages = []
|
| 1377 |
+
files = []
|
| 1378 |
+
upload_chat_id = "" if use_persisted_chat else chat_id
|
| 1379 |
+
|
| 1380 |
+
for msg in normalized_messages:
|
| 1381 |
+
role = str(msg.get("role", "user"))
|
| 1382 |
+
content = msg.get("content")
|
| 1383 |
+
|
| 1384 |
+
if isinstance(content, str):
|
| 1385 |
+
messages.append({"role": role, "content": content})
|
| 1386 |
+
continue
|
| 1387 |
+
|
| 1388 |
+
if not isinstance(content, list):
|
| 1389 |
+
continue
|
| 1390 |
+
|
| 1391 |
+
text_parts = []
|
| 1392 |
+
image_parts = []
|
| 1393 |
+
for part in content:
|
| 1394 |
+
image_url = None
|
| 1395 |
+
if hasattr(part, "type"):
|
| 1396 |
+
if part.type == "text" and hasattr(part, "text"):
|
| 1397 |
+
text_parts.append(part.text or "")
|
| 1398 |
+
elif part.type == "image_url" and hasattr(part, "image_url"):
|
| 1399 |
+
if hasattr(part.image_url, "url"):
|
| 1400 |
+
image_url = part.image_url.url
|
| 1401 |
+
elif (
|
| 1402 |
+
isinstance(part.image_url, dict)
|
| 1403 |
+
and "url" in part.image_url
|
| 1404 |
+
):
|
| 1405 |
+
image_url = part.image_url["url"]
|
| 1406 |
+
elif isinstance(part, dict):
|
| 1407 |
+
if part.get("type") == "text":
|
| 1408 |
+
text_parts.append(part.get("text", ""))
|
| 1409 |
+
elif part.get("type") == "image_url":
|
| 1410 |
+
image_url = part.get("image_url", {}).get("url", "")
|
| 1411 |
+
elif isinstance(part, str):
|
| 1412 |
+
text_parts.append(part)
|
| 1413 |
+
|
| 1414 |
+
if not image_url:
|
| 1415 |
+
continue
|
| 1416 |
+
|
| 1417 |
+
self.logger.debug(f"✅ 检测到图片: {image_url[:50]}...")
|
| 1418 |
+
if image_url.startswith("data:") and auth_mode != "guest":
|
| 1419 |
+
self.logger.info("🔄 上传 base64 图片到上游服务")
|
| 1420 |
+
file_info = await self.upload_image(
|
| 1421 |
+
image_url,
|
| 1422 |
+
upload_chat_id,
|
| 1423 |
+
token,
|
| 1424 |
+
user_id,
|
| 1425 |
+
auth_mode=auth_mode,
|
| 1426 |
+
)
|
| 1427 |
+
if not file_info:
|
| 1428 |
+
self.logger.warning("⚠️ 图片上传失败")
|
| 1429 |
+
text_parts.append("[系统提示: 图片上传失败]")
|
| 1430 |
+
continue
|
| 1431 |
+
|
| 1432 |
+
files.append(file_info)
|
| 1433 |
+
self.logger.info("✅ 图片已添加到 files 数组")
|
| 1434 |
+
if persisted_user_message_id:
|
| 1435 |
+
file_info["ref_user_msg_id"] = persisted_user_message_id
|
| 1436 |
+
image_ref = str(file_info["id"])
|
| 1437 |
+
image_parts.append(
|
| 1438 |
+
{
|
| 1439 |
+
"type": "image_url",
|
| 1440 |
+
"image_url": {"url": image_ref},
|
| 1441 |
+
}
|
| 1442 |
+
)
|
| 1443 |
+
self.logger.debug(f"📎 图片引用: {image_ref}")
|
| 1444 |
+
continue
|
| 1445 |
+
|
| 1446 |
+
if auth_mode != "guest":
|
| 1447 |
+
self.logger.warning("⚠️ 非 base64 图片或匿名模式,保留原始URL")
|
| 1448 |
+
image_parts.append(
|
| 1449 |
+
{
|
| 1450 |
+
"type": "image_url",
|
| 1451 |
+
"image_url": {"url": image_url},
|
| 1452 |
+
}
|
| 1453 |
+
)
|
| 1454 |
+
|
| 1455 |
+
message_content = []
|
| 1456 |
+
combined_text = " ".join(text_parts).strip()
|
| 1457 |
+
if combined_text:
|
| 1458 |
+
message_content.append({"type": "text", "text": combined_text})
|
| 1459 |
+
message_content.extend(image_parts)
|
| 1460 |
+
if message_content:
|
| 1461 |
+
messages.append({"role": role, "content": message_content})
|
| 1462 |
+
|
| 1463 |
+
if use_persisted_chat:
|
| 1464 |
+
chat_id = await self._create_upstream_chat(
|
| 1465 |
+
prompt=last_user_text,
|
| 1466 |
+
model=upstream_model_id,
|
| 1467 |
+
token=token,
|
| 1468 |
+
headers=headers,
|
| 1469 |
+
enable_thinking=enable_thinking,
|
| 1470 |
+
web_search=web_search,
|
| 1471 |
+
user_message_id=persisted_user_message_id,
|
| 1472 |
+
files=files or None,
|
| 1473 |
+
feature_entries=feature_entries or None,
|
| 1474 |
+
mcp_servers=mcp_servers or None,
|
| 1475 |
+
)
|
| 1476 |
+
self.logger.info(f"🧩 已为 {requested_model} 创建上游 chat: {chat_id}")
|
| 1477 |
+
headers["Referer"] = f"{self.base_url}/c/{chat_id}"
|
| 1478 |
+
|
| 1479 |
+
if use_persisted_chat:
|
| 1480 |
+
body = self._build_glm47_completion_body(
|
| 1481 |
+
model=upstream_model_id,
|
| 1482 |
+
messages=messages,
|
| 1483 |
+
prompt=last_user_text,
|
| 1484 |
+
chat_id=chat_id,
|
| 1485 |
+
enable_thinking=enable_thinking,
|
| 1486 |
+
web_search=web_search,
|
| 1487 |
+
files=files,
|
| 1488 |
+
tools=tools,
|
| 1489 |
+
tool_choice=tool_choice,
|
| 1490 |
+
temperature=request.temperature,
|
| 1491 |
+
max_tokens=request.max_tokens,
|
| 1492 |
+
mcp_servers=mcp_servers,
|
| 1493 |
+
preview_mode=preview_mode,
|
| 1494 |
+
feature_entries=feature_entries or None,
|
| 1495 |
+
message_id=persisted_assistant_message_id or generate_uuid(),
|
| 1496 |
+
current_user_message_id=persisted_user_message_id or generate_uuid(),
|
| 1497 |
+
current_user_message_parent_id=None,
|
| 1498 |
+
)
|
| 1499 |
+
else:
|
| 1500 |
+
message_id = generate_uuid()
|
| 1501 |
+
session_id = generate_uuid()
|
| 1502 |
+
body = {
|
| 1503 |
+
"stream": True,
|
| 1504 |
+
"model": upstream_model_id,
|
| 1505 |
+
"messages": messages,
|
| 1506 |
+
"signature_prompt": last_user_text,
|
| 1507 |
+
"files": files,
|
| 1508 |
+
"params": {},
|
| 1509 |
+
"extra": {},
|
| 1510 |
+
"features": {
|
| 1511 |
+
"image_generation": False,
|
| 1512 |
+
"web_search": web_search,
|
| 1513 |
+
"auto_web_search": web_search,
|
| 1514 |
+
"preview_mode": preview_mode,
|
| 1515 |
+
"flags": [],
|
| 1516 |
+
"features": [
|
| 1517 |
+
dict(item)
|
| 1518 |
+
for item in (feature_entries or DEFAULT_COMPLETION_FEATURES)
|
| 1519 |
+
],
|
| 1520 |
+
"enable_thinking": enable_thinking,
|
| 1521 |
+
},
|
| 1522 |
+
"background_tasks": {
|
| 1523 |
+
"title_generation": False,
|
| 1524 |
+
"tags_generation": False,
|
| 1525 |
+
},
|
| 1526 |
+
"mcp_servers": mcp_servers,
|
| 1527 |
+
"variables": self._build_request_variables(),
|
| 1528 |
+
"model_item": {
|
| 1529 |
+
"id": upstream_model_id,
|
| 1530 |
+
"name": requested_model,
|
| 1531 |
+
"owned_by": settings.SERVICE_NAME,
|
| 1532 |
+
},
|
| 1533 |
+
"chat_id": chat_id,
|
| 1534 |
+
"id": message_id,
|
| 1535 |
+
"session_id": session_id,
|
| 1536 |
+
"current_user_message_id": message_id,
|
| 1537 |
+
"current_user_message_parent_id": None,
|
| 1538 |
+
}
|
| 1539 |
+
if tools:
|
| 1540 |
+
body["tools"] = tools
|
| 1541 |
+
if tool_choice is not None:
|
| 1542 |
+
body["tool_choice"] = tool_choice
|
| 1543 |
+
self.logger.info(f"🔧 工具调用将直接透传到上游: {len(tools)} 个工具")
|
| 1544 |
+
else:
|
| 1545 |
+
body["tools"] = None
|
| 1546 |
+
if request.temperature is not None:
|
| 1547 |
+
body["params"]["temperature"] = request.temperature
|
| 1548 |
+
if request.max_tokens is not None:
|
| 1549 |
+
body["params"]["max_tokens"] = request.max_tokens
|
| 1550 |
+
|
| 1551 |
+
try:
|
| 1552 |
+
signed_url, signature, timestamp_ms = self._build_signed_completion_request(
|
| 1553 |
+
prompt=last_user_text,
|
| 1554 |
+
chat_id=chat_id,
|
| 1555 |
+
token=token,
|
| 1556 |
+
user_id=user_id,
|
| 1557 |
+
user_agent=headers["User-Agent"],
|
| 1558 |
+
use_browser_fingerprint=use_persisted_chat,
|
| 1559 |
+
)
|
| 1560 |
+
logger.debug(
|
| 1561 |
+
"[上游] 生成签名成功: %s... (user_id=%s, timestamp=%s)",
|
| 1562 |
+
signature[:16],
|
| 1563 |
+
user_id,
|
| 1564 |
+
timestamp_ms,
|
| 1565 |
+
)
|
| 1566 |
+
except Exception as e:
|
| 1567 |
+
logger.error(f"[上游] 签名生成失败: {e}")
|
| 1568 |
+
signature = ""
|
| 1569 |
+
timestamp_ms = "0"
|
| 1570 |
+
signed_url = self.api_endpoint
|
| 1571 |
+
|
| 1572 |
+
fe_version = headers.get("X-FE-Version") or get_latest_fe_version()
|
| 1573 |
+
headers.update(
|
| 1574 |
+
{
|
| 1575 |
+
"Authorization": f"Bearer {token}",
|
| 1576 |
+
"Content-Type": "application/json",
|
| 1577 |
+
"Accept": "*/*" if use_persisted_chat else "application/json",
|
| 1578 |
+
"X-FE-Version": fe_version,
|
| 1579 |
+
"X-Signature": signature,
|
| 1580 |
+
}
|
| 1581 |
+
)
|
| 1582 |
+
|
| 1583 |
+
logger.debug(
|
| 1584 |
+
"[上游] 请求头: Authorization=Bearer *****, X-Signature=%s...",
|
| 1585 |
+
signature[:16] if signature else "(空)",
|
| 1586 |
+
)
|
| 1587 |
+
logger.debug(
|
| 1588 |
+
"[上游] URL 参数: timestamp=%s, user_id=%s, persisted_chat=%s",
|
| 1589 |
+
timestamp_ms,
|
| 1590 |
+
user_id,
|
| 1591 |
+
use_persisted_chat,
|
| 1592 |
+
)
|
| 1593 |
+
|
| 1594 |
+
# 存储当前token用于错误处理
|
| 1595 |
+
self._current_token = token
|
| 1596 |
+
|
| 1597 |
+
return {
|
| 1598 |
+
"url": signed_url,
|
| 1599 |
+
"headers": headers,
|
| 1600 |
+
"body": body,
|
| 1601 |
+
"token": token,
|
| 1602 |
+
"chat_id": chat_id,
|
| 1603 |
+
"model": requested_model,
|
| 1604 |
+
"user_id": user_id,
|
| 1605 |
+
"auth_mode": auth_mode,
|
| 1606 |
+
"token_source": token_source,
|
| 1607 |
+
"guest_user_id": guest_user_id,
|
| 1608 |
+
}
|
| 1609 |
+
|
| 1610 |
+
async def chat_completion(
|
| 1611 |
+
self,
|
| 1612 |
+
request: OpenAIRequest,
|
| 1613 |
+
**kwargs
|
| 1614 |
+
) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
|
| 1615 |
+
"""聊天完成接口。"""
|
| 1616 |
+
self.logger.info(f"🔄 {self.name} 处理请求: {request.model}")
|
| 1617 |
+
self.logger.debug(f" 消息数量: {len(request.messages)}")
|
| 1618 |
+
self.logger.debug(f" 流式模式: {request.stream}")
|
| 1619 |
+
|
| 1620 |
+
try:
|
| 1621 |
+
transformed = await self.transform_request(request)
|
| 1622 |
+
|
| 1623 |
+
if request.stream:
|
| 1624 |
+
return self._create_stream_response(request, transformed)
|
| 1625 |
+
|
| 1626 |
+
proxies = self._get_proxy_config()
|
| 1627 |
+
max_attempts = self._get_total_retry_limit()
|
| 1628 |
+
excluded_tokens: Set[str] = set()
|
| 1629 |
+
excluded_guest_user_ids: Set[str] = set()
|
| 1630 |
+
|
| 1631 |
+
for attempt in range(max_attempts):
|
| 1632 |
+
async with httpx.AsyncClient(
|
| 1633 |
+
timeout=self._build_timeout(read_timeout=60.0),
|
| 1634 |
+
limits=self._build_limits(),
|
| 1635 |
+
proxy=proxies,
|
| 1636 |
+
) as client:
|
| 1637 |
+
response = await client.post(
|
| 1638 |
+
transformed["url"],
|
| 1639 |
+
headers=transformed["headers"],
|
| 1640 |
+
json=transformed["body"],
|
| 1641 |
+
)
|
| 1642 |
+
|
| 1643 |
+
error_code, error_message = self._extract_upstream_error_details(
|
| 1644 |
+
response.status_code,
|
| 1645 |
+
response.text,
|
| 1646 |
+
)
|
| 1647 |
+
is_concurrency_limited = self._is_concurrency_limited(
|
| 1648 |
+
response.status_code,
|
| 1649 |
+
error_code,
|
| 1650 |
+
error_message,
|
| 1651 |
+
)
|
| 1652 |
+
|
| 1653 |
+
if self._should_retry_guest_session(
|
| 1654 |
+
response.status_code,
|
| 1655 |
+
is_concurrency_limited,
|
| 1656 |
+
attempt,
|
| 1657 |
+
max_attempts,
|
| 1658 |
+
transformed,
|
| 1659 |
+
):
|
| 1660 |
+
guest_user_id = str(
|
| 1661 |
+
transformed.get("guest_user_id")
|
| 1662 |
+
or transformed.get("user_id")
|
| 1663 |
+
or ""
|
| 1664 |
+
)
|
| 1665 |
+
if guest_user_id:
|
| 1666 |
+
excluded_guest_user_ids.add(guest_user_id)
|
| 1667 |
+
transformed = await self._refresh_guest_request(
|
| 1668 |
+
request,
|
| 1669 |
+
attempt,
|
| 1670 |
+
excluded_tokens,
|
| 1671 |
+
excluded_guest_user_ids,
|
| 1672 |
+
transformed,
|
| 1673 |
+
is_concurrency_limited=is_concurrency_limited,
|
| 1674 |
+
)
|
| 1675 |
+
continue
|
| 1676 |
+
|
| 1677 |
+
if self._should_retry_authenticated_session(
|
| 1678 |
+
response.status_code,
|
| 1679 |
+
is_concurrency_limited,
|
| 1680 |
+
attempt,
|
| 1681 |
+
max_attempts,
|
| 1682 |
+
transformed,
|
| 1683 |
+
):
|
| 1684 |
+
current_token = str(transformed.get("token") or "")
|
| 1685 |
+
if current_token:
|
| 1686 |
+
excluded_tokens.add(current_token)
|
| 1687 |
+
await self.mark_token_failure(
|
| 1688 |
+
current_token,
|
| 1689 |
+
Exception(error_message or "上游认证会话不可用"),
|
| 1690 |
+
)
|
| 1691 |
+
self.logger.warning(
|
| 1692 |
+
"⚠️ 认证会话不可用,准备切换认证 Token/回退匿名池: "
|
| 1693 |
+
f"{current_token[:20]}..."
|
| 1694 |
+
)
|
| 1695 |
+
transformed = await self._refresh_authenticated_request(
|
| 1696 |
+
request,
|
| 1697 |
+
attempt,
|
| 1698 |
+
excluded_tokens,
|
| 1699 |
+
excluded_guest_user_ids,
|
| 1700 |
+
)
|
| 1701 |
+
continue
|
| 1702 |
+
|
| 1703 |
+
if not response.is_success:
|
| 1704 |
+
error_msg = f"上游 API 错误: {response.status_code}"
|
| 1705 |
+
if not self._is_guest_auth(transformed):
|
| 1706 |
+
current_token = str(transformed.get("token") or "")
|
| 1707 |
+
if current_token:
|
| 1708 |
+
await self.mark_token_failure(
|
| 1709 |
+
current_token,
|
| 1710 |
+
Exception(error_message or error_msg),
|
| 1711 |
+
)
|
| 1712 |
+
await self._release_guest_session(transformed)
|
| 1713 |
+
self.logger.error(f"❌ {self.name} 响应失败: {error_msg}")
|
| 1714 |
+
return handle_error(Exception(error_message or error_msg))
|
| 1715 |
+
|
| 1716 |
+
try:
|
| 1717 |
+
result = await self.transform_response(response, request, transformed)
|
| 1718 |
+
finally:
|
| 1719 |
+
await self._release_guest_session(transformed)
|
| 1720 |
+
|
| 1721 |
+
if not self._is_guest_auth(transformed):
|
| 1722 |
+
current_token = str(transformed.get("token") or "")
|
| 1723 |
+
if current_token:
|
| 1724 |
+
token_pool = get_token_pool()
|
| 1725 |
+
if token_pool:
|
| 1726 |
+
await token_pool.record_token_success(current_token)
|
| 1727 |
+
|
| 1728 |
+
return result
|
| 1729 |
+
|
| 1730 |
+
except Exception as e:
|
| 1731 |
+
self.logger.error(f"❌ {self.name} 响应失败: {str(e)}")
|
| 1732 |
+
return handle_error(e, "请求处理")
|
| 1733 |
+
|
| 1734 |
+
async def _create_stream_response(
|
| 1735 |
+
self,
|
| 1736 |
+
request: OpenAIRequest,
|
| 1737 |
+
transformed: Dict[str, Any]
|
| 1738 |
+
) -> AsyncGenerator[str, None]:
|
| 1739 |
+
"""创建流式响应,并在首包前支持双池重试。"""
|
| 1740 |
+
max_attempts = self._get_total_retry_limit()
|
| 1741 |
+
excluded_tokens: Set[str] = set()
|
| 1742 |
+
excluded_guest_user_ids: Set[str] = set()
|
| 1743 |
+
current_token = str(transformed.get("token") or "")
|
| 1744 |
+
|
| 1745 |
+
try:
|
| 1746 |
+
proxies = self._get_proxy_config()
|
| 1747 |
+
|
| 1748 |
+
async with httpx.AsyncClient(
|
| 1749 |
+
timeout=self._build_timeout(read_timeout=180.0),
|
| 1750 |
+
http2=True,
|
| 1751 |
+
limits=self._build_limits(),
|
| 1752 |
+
proxy=proxies,
|
| 1753 |
+
) as client:
|
| 1754 |
+
for attempt in range(max_attempts):
|
| 1755 |
+
self.logger.info(f"�� 发送请求到上游: {transformed['url']}")
|
| 1756 |
+
async with client.stream(
|
| 1757 |
+
"POST",
|
| 1758 |
+
transformed["url"],
|
| 1759 |
+
json=transformed["body"],
|
| 1760 |
+
headers=transformed["headers"],
|
| 1761 |
+
) as response:
|
| 1762 |
+
error_text = await response.aread() if response.status_code != 200 else b""
|
| 1763 |
+
error_msg = error_text.decode("utf-8", errors="ignore")
|
| 1764 |
+
error_code, parsed_error_message = (
|
| 1765 |
+
self._extract_upstream_error_details(
|
| 1766 |
+
response.status_code,
|
| 1767 |
+
error_msg,
|
| 1768 |
+
)
|
| 1769 |
+
if response.status_code != 200
|
| 1770 |
+
else (None, "")
|
| 1771 |
+
)
|
| 1772 |
+
is_concurrency_limited = self._is_concurrency_limited(
|
| 1773 |
+
response.status_code,
|
| 1774 |
+
error_code,
|
| 1775 |
+
parsed_error_message,
|
| 1776 |
+
)
|
| 1777 |
+
|
| 1778 |
+
if self._should_retry_guest_session(
|
| 1779 |
+
response.status_code,
|
| 1780 |
+
is_concurrency_limited,
|
| 1781 |
+
attempt,
|
| 1782 |
+
max_attempts,
|
| 1783 |
+
transformed,
|
| 1784 |
+
):
|
| 1785 |
+
guest_user_id = str(
|
| 1786 |
+
transformed.get("guest_user_id")
|
| 1787 |
+
or transformed.get("user_id")
|
| 1788 |
+
or ""
|
| 1789 |
+
)
|
| 1790 |
+
if guest_user_id:
|
| 1791 |
+
excluded_guest_user_ids.add(guest_user_id)
|
| 1792 |
+
transformed = await self._refresh_guest_request(
|
| 1793 |
+
request,
|
| 1794 |
+
attempt,
|
| 1795 |
+
excluded_tokens,
|
| 1796 |
+
excluded_guest_user_ids,
|
| 1797 |
+
transformed,
|
| 1798 |
+
is_concurrency_limited=is_concurrency_limited,
|
| 1799 |
+
)
|
| 1800 |
+
current_token = str(transformed.get("token") or "")
|
| 1801 |
+
continue
|
| 1802 |
+
|
| 1803 |
+
if self._should_retry_authenticated_session(
|
| 1804 |
+
response.status_code,
|
| 1805 |
+
is_concurrency_limited,
|
| 1806 |
+
attempt,
|
| 1807 |
+
max_attempts,
|
| 1808 |
+
transformed,
|
| 1809 |
+
):
|
| 1810 |
+
if current_token:
|
| 1811 |
+
excluded_tokens.add(current_token)
|
| 1812 |
+
await self.mark_token_failure(
|
| 1813 |
+
current_token,
|
| 1814 |
+
Exception(
|
| 1815 |
+
parsed_error_message or "上游认证会话不可用"
|
| 1816 |
+
),
|
| 1817 |
+
)
|
| 1818 |
+
self.logger.warning(
|
| 1819 |
+
"⚠️ 流式请求命中认证会话限制,准备切号/回退匿名池: "
|
| 1820 |
+
f"{current_token[:20]}..."
|
| 1821 |
+
)
|
| 1822 |
+
transformed = await self._refresh_authenticated_request(
|
| 1823 |
+
request,
|
| 1824 |
+
attempt,
|
| 1825 |
+
excluded_tokens,
|
| 1826 |
+
excluded_guest_user_ids,
|
| 1827 |
+
)
|
| 1828 |
+
current_token = str(transformed.get("token") or "")
|
| 1829 |
+
continue
|
| 1830 |
+
|
| 1831 |
+
if response.status_code != 200:
|
| 1832 |
+
self.logger.error(f"❌ 上游返回错误: {response.status_code}")
|
| 1833 |
+
if error_msg:
|
| 1834 |
+
self.logger.error(f"❌ 错误详情: {error_msg}")
|
| 1835 |
+
|
| 1836 |
+
if not self._is_guest_auth(transformed) and current_token:
|
| 1837 |
+
await self.mark_token_failure(
|
| 1838 |
+
current_token,
|
| 1839 |
+
Exception(
|
| 1840 |
+
parsed_error_message
|
| 1841 |
+
or f"Upstream error: {response.status_code}"
|
| 1842 |
+
),
|
| 1843 |
+
)
|
| 1844 |
+
await self._release_guest_session(transformed)
|
| 1845 |
+
|
| 1846 |
+
if response.status_code == 405:
|
| 1847 |
+
self.logger.error(
|
| 1848 |
+
"🚫 请求被上游 WAF 拦截,可能是请求头或签名异常"
|
| 1849 |
+
)
|
| 1850 |
+
error_response = {
|
| 1851 |
+
"error": {
|
| 1852 |
+
"message": (
|
| 1853 |
+
"请求被上游WAF拦截(405 Method Not Allowed),"
|
| 1854 |
+
"可能是请求头或签名异常,请稍后重试..."
|
| 1855 |
+
),
|
| 1856 |
+
"type": "waf_blocked",
|
| 1857 |
+
"code": 405,
|
| 1858 |
+
}
|
| 1859 |
+
}
|
| 1860 |
+
else:
|
| 1861 |
+
error_response = {
|
| 1862 |
+
"error": {
|
| 1863 |
+
"message": parsed_error_message
|
| 1864 |
+
or f"Upstream error: {response.status_code}",
|
| 1865 |
+
"type": "upstream_error",
|
| 1866 |
+
"code": error_code or response.status_code,
|
| 1867 |
+
}
|
| 1868 |
+
}
|
| 1869 |
+
yield f"data: {json.dumps(error_response)}\n\n"
|
| 1870 |
+
yield "data: [DONE]\n\n"
|
| 1871 |
+
return
|
| 1872 |
+
|
| 1873 |
+
chat_id = transformed["chat_id"]
|
| 1874 |
+
model = transformed["model"]
|
| 1875 |
+
try:
|
| 1876 |
+
async for chunk in self._handle_stream_response(
|
| 1877 |
+
response,
|
| 1878 |
+
chat_id,
|
| 1879 |
+
model,
|
| 1880 |
+
request,
|
| 1881 |
+
transformed,
|
| 1882 |
+
):
|
| 1883 |
+
yield chunk
|
| 1884 |
+
finally:
|
| 1885 |
+
await self._release_guest_session(transformed)
|
| 1886 |
+
|
| 1887 |
+
if not self._is_guest_auth(transformed) and current_token:
|
| 1888 |
+
token_pool = get_token_pool()
|
| 1889 |
+
if token_pool:
|
| 1890 |
+
await token_pool.record_token_success(current_token)
|
| 1891 |
+
return
|
| 1892 |
+
except Exception as e:
|
| 1893 |
+
self.logger.error(f"❌ 流处理错误: {e}")
|
| 1894 |
+
import traceback
|
| 1895 |
+
self.logger.error(traceback.format_exc())
|
| 1896 |
+
if self._is_guest_auth(transformed):
|
| 1897 |
+
await self._release_guest_session(transformed)
|
| 1898 |
+
elif current_token:
|
| 1899 |
+
await self.mark_token_failure(current_token, e)
|
| 1900 |
+
|
| 1901 |
+
error_response = {
|
| 1902 |
+
"error": {
|
| 1903 |
+
"message": str(e),
|
| 1904 |
+
"type": "stream_error"
|
| 1905 |
+
}
|
| 1906 |
+
}
|
| 1907 |
+
yield f"data: {json.dumps(error_response)}\n\n"
|
| 1908 |
+
yield "data: [DONE]\n\n"
|
| 1909 |
+
return
|
| 1910 |
+
|
| 1911 |
+
async def transform_response(
|
| 1912 |
+
self,
|
| 1913 |
+
response: httpx.Response,
|
| 1914 |
+
request: OpenAIRequest,
|
| 1915 |
+
transformed: Dict[str, Any]
|
| 1916 |
+
) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
|
| 1917 |
+
"""转换上游响应为 OpenAI 格式。"""
|
| 1918 |
+
chat_id = transformed["chat_id"]
|
| 1919 |
+
model = transformed["model"]
|
| 1920 |
+
|
| 1921 |
+
if request.stream:
|
| 1922 |
+
return self._handle_stream_response(response, chat_id, model, request, transformed)
|
| 1923 |
+
else:
|
| 1924 |
+
return await self._handle_non_stream_response(response, chat_id, model)
|
| 1925 |
+
|
| 1926 |
+
async def _handle_stream_response(
|
| 1927 |
+
self,
|
| 1928 |
+
response: httpx.Response,
|
| 1929 |
+
chat_id: str,
|
| 1930 |
+
model: str,
|
| 1931 |
+
request: OpenAIRequest,
|
| 1932 |
+
transformed: Dict[str, Any]
|
| 1933 |
+
) -> AsyncGenerator[str, None]:
|
| 1934 |
+
"""处理上游流式响应"""
|
| 1935 |
+
self.logger.info("✅ 上游响应成功,开始处理 SSE 流")
|
| 1936 |
+
|
| 1937 |
+
has_tools = settings.TOOL_SUPPORT and bool(request.tools)
|
| 1938 |
+
buffered_content = ""
|
| 1939 |
+
usage_info: Dict[str, int] = {
|
| 1940 |
+
"prompt_tokens": 0,
|
| 1941 |
+
"completion_tokens": 0,
|
| 1942 |
+
"total_tokens": 0,
|
| 1943 |
+
}
|
| 1944 |
+
tool_calls_accum: List[Dict[str, Any]] = []
|
| 1945 |
+
has_sent_role = False
|
| 1946 |
+
finished = False
|
| 1947 |
+
line_count = 0
|
| 1948 |
+
|
| 1949 |
+
async def ensure_role_sent() -> Optional[str]:
|
| 1950 |
+
nonlocal has_sent_role
|
| 1951 |
+
if has_sent_role:
|
| 1952 |
+
return None
|
| 1953 |
+
|
| 1954 |
+
has_sent_role = True
|
| 1955 |
+
return await format_sse_chunk(
|
| 1956 |
+
create_openai_chunk(chat_id, model, {"role": "assistant"})
|
| 1957 |
+
)
|
| 1958 |
+
|
| 1959 |
+
async def finalize_stream() -> AsyncGenerator[str, None]:
|
| 1960 |
+
nonlocal finished, tool_calls_accum
|
| 1961 |
+
if finished:
|
| 1962 |
+
return
|
| 1963 |
+
|
| 1964 |
+
if has_tools and not tool_calls_accum:
|
| 1965 |
+
parsed_tool_calls, _ = parse_and_extract_tool_calls(buffered_content)
|
| 1966 |
+
normalized = self._normalize_tool_calls(parsed_tool_calls)
|
| 1967 |
+
if normalized:
|
| 1968 |
+
tool_calls_accum = normalized
|
| 1969 |
+
role_output = await ensure_role_sent()
|
| 1970 |
+
if role_output:
|
| 1971 |
+
yield role_output
|
| 1972 |
+
for tool_call in normalized:
|
| 1973 |
+
yield await format_sse_chunk(
|
| 1974 |
+
create_openai_chunk(
|
| 1975 |
+
chat_id,
|
| 1976 |
+
model,
|
| 1977 |
+
{"tool_calls": [tool_call]},
|
| 1978 |
+
)
|
| 1979 |
+
)
|
| 1980 |
+
|
| 1981 |
+
if not has_sent_role:
|
| 1982 |
+
role_output = await ensure_role_sent()
|
| 1983 |
+
if role_output:
|
| 1984 |
+
yield role_output
|
| 1985 |
+
|
| 1986 |
+
finish_reason = "tool_calls" if tool_calls_accum else "stop"
|
| 1987 |
+
finish_chunk = create_openai_chunk(
|
| 1988 |
+
chat_id,
|
| 1989 |
+
model,
|
| 1990 |
+
{},
|
| 1991 |
+
finish_reason,
|
| 1992 |
+
)
|
| 1993 |
+
finish_chunk["usage"] = usage_info
|
| 1994 |
+
yield await format_sse_chunk(finish_chunk)
|
| 1995 |
+
yield "data: [DONE]\n\n"
|
| 1996 |
+
finished = True
|
| 1997 |
+
|
| 1998 |
+
try:
|
| 1999 |
+
async for line in response.aiter_lines():
|
| 2000 |
+
line_count += 1
|
| 2001 |
+
if not line:
|
| 2002 |
+
continue
|
| 2003 |
+
|
| 2004 |
+
current_line = line.strip()
|
| 2005 |
+
if not current_line.startswith("data:"):
|
| 2006 |
+
continue
|
| 2007 |
+
|
| 2008 |
+
chunk_str = current_line[5:].strip()
|
| 2009 |
+
if not chunk_str:
|
| 2010 |
+
continue
|
| 2011 |
+
|
| 2012 |
+
if chunk_str == "[DONE]":
|
| 2013 |
+
async for final_chunk in finalize_stream():
|
| 2014 |
+
yield final_chunk
|
| 2015 |
+
continue
|
| 2016 |
+
|
| 2017 |
+
try:
|
| 2018 |
+
chunk = json.loads(chunk_str)
|
| 2019 |
+
except json.JSONDecodeError as error:
|
| 2020 |
+
self.logger.debug(f"❌ JSON解析错误: {error}, 内容: {chunk_str[:1000]}")
|
| 2021 |
+
continue
|
| 2022 |
+
|
| 2023 |
+
chunk_type = chunk.get("type")
|
| 2024 |
+
data = chunk.get("data", {}) if chunk_type == "chat:completion" else chunk
|
| 2025 |
+
if not isinstance(data, dict):
|
| 2026 |
+
continue
|
| 2027 |
+
|
| 2028 |
+
phase = data.get("phase")
|
| 2029 |
+
delta_content = data.get("delta_content", "")
|
| 2030 |
+
edit_content = data.get("edit_content", "")
|
| 2031 |
+
|
| 2032 |
+
if phase and phase != getattr(self, "_last_phase", None):
|
| 2033 |
+
self.logger.info(f"📈 SSE 阶段: {phase}")
|
| 2034 |
+
self._last_phase = phase
|
| 2035 |
+
|
| 2036 |
+
if data.get("usage"):
|
| 2037 |
+
usage_info = data["usage"]
|
| 2038 |
+
|
| 2039 |
+
if delta_content:
|
| 2040 |
+
buffered_content += delta_content
|
| 2041 |
+
elif edit_content:
|
| 2042 |
+
buffered_content += edit_content
|
| 2043 |
+
|
| 2044 |
+
direct_tool_calls = self._normalize_tool_calls(
|
| 2045 |
+
data.get("tool_calls"),
|
| 2046 |
+
len(tool_calls_accum),
|
| 2047 |
+
)
|
| 2048 |
+
if direct_tool_calls:
|
| 2049 |
+
role_output = await ensure_role_sent()
|
| 2050 |
+
if role_output:
|
| 2051 |
+
yield role_output
|
| 2052 |
+
tool_calls_accum.extend(direct_tool_calls)
|
| 2053 |
+
for tool_call in direct_tool_calls:
|
| 2054 |
+
yield await format_sse_chunk(
|
| 2055 |
+
create_openai_chunk(
|
| 2056 |
+
chat_id,
|
| 2057 |
+
model,
|
| 2058 |
+
{"tool_calls": [tool_call]},
|
| 2059 |
+
)
|
| 2060 |
+
)
|
| 2061 |
+
|
| 2062 |
+
if phase == "thinking" and delta_content:
|
| 2063 |
+
cleaned = self._clean_reasoning_delta(delta_content)
|
| 2064 |
+
if cleaned:
|
| 2065 |
+
role_output = await ensure_role_sent()
|
| 2066 |
+
if role_output:
|
| 2067 |
+
yield role_output
|
| 2068 |
+
yield await format_sse_chunk(
|
| 2069 |
+
create_openai_chunk(
|
| 2070 |
+
chat_id,
|
| 2071 |
+
model,
|
| 2072 |
+
{"reasoning_content": cleaned},
|
| 2073 |
+
)
|
| 2074 |
+
)
|
| 2075 |
+
|
| 2076 |
+
elif phase == "answer":
|
| 2077 |
+
text = delta_content or self._extract_answer_content(edit_content)
|
| 2078 |
+
if text:
|
| 2079 |
+
role_output = await ensure_role_sent()
|
| 2080 |
+
if role_output:
|
| 2081 |
+
yield role_output
|
| 2082 |
+
yield await format_sse_chunk(
|
| 2083 |
+
create_openai_chunk(
|
| 2084 |
+
chat_id,
|
| 2085 |
+
model,
|
| 2086 |
+
{"content": text},
|
| 2087 |
+
)
|
| 2088 |
+
)
|
| 2089 |
+
|
| 2090 |
+
elif phase == "other":
|
| 2091 |
+
other_text = self._extract_answer_content(edit_content)
|
| 2092 |
+
if other_text:
|
| 2093 |
+
role_output = await ensure_role_sent()
|
| 2094 |
+
if role_output:
|
| 2095 |
+
yield role_output
|
| 2096 |
+
yield await format_sse_chunk(
|
| 2097 |
+
create_openai_chunk(
|
| 2098 |
+
chat_id,
|
| 2099 |
+
model,
|
| 2100 |
+
{"content": other_text},
|
| 2101 |
+
)
|
| 2102 |
+
)
|
| 2103 |
+
|
| 2104 |
+
elif phase == "search" or chunk_type == "web_search":
|
| 2105 |
+
citation_text = self._format_search_results(data)
|
| 2106 |
+
if citation_text:
|
| 2107 |
+
role_output = await ensure_role_sent()
|
| 2108 |
+
if role_output:
|
| 2109 |
+
yield role_output
|
| 2110 |
+
yield await format_sse_chunk(
|
| 2111 |
+
create_openai_chunk(
|
| 2112 |
+
chat_id,
|
| 2113 |
+
model,
|
| 2114 |
+
{"content": citation_text},
|
| 2115 |
+
)
|
| 2116 |
+
)
|
| 2117 |
+
|
| 2118 |
+
if data.get("done"):
|
| 2119 |
+
async for final_chunk in finalize_stream():
|
| 2120 |
+
yield final_chunk
|
| 2121 |
+
return
|
| 2122 |
+
|
| 2123 |
+
self.logger.info(f"✅ SSE 流处理完成,共处理 {line_count} 行数据")
|
| 2124 |
+
|
| 2125 |
+
if not finished:
|
| 2126 |
+
async for final_chunk in finalize_stream():
|
| 2127 |
+
yield final_chunk
|
| 2128 |
+
|
| 2129 |
+
except Exception as e:
|
| 2130 |
+
self.logger.error(f"❌ 流式响应处理错误: {e}")
|
| 2131 |
+
import traceback
|
| 2132 |
+
self.logger.error(traceback.format_exc())
|
| 2133 |
+
yield await format_sse_chunk(
|
| 2134 |
+
create_openai_chunk(chat_id, model, {}, "stop")
|
| 2135 |
+
)
|
| 2136 |
+
yield "data: [DONE]\n\n"
|
| 2137 |
+
|
| 2138 |
+
async def _handle_non_stream_response(
|
| 2139 |
+
self,
|
| 2140 |
+
response: httpx.Response,
|
| 2141 |
+
chat_id: str,
|
| 2142 |
+
model: str
|
| 2143 |
+
) -> Dict[str, Any]:
|
| 2144 |
+
"""处理非流式响应,聚合上游 SSE 为一次性 OpenAI 响应。"""
|
| 2145 |
+
final_content = ""
|
| 2146 |
+
reasoning_content = ""
|
| 2147 |
+
tool_calls_accum: List[Dict[str, Any]] = []
|
| 2148 |
+
usage_info: Dict[str, int] = {
|
| 2149 |
+
"prompt_tokens": 0,
|
| 2150 |
+
"completion_tokens": 0,
|
| 2151 |
+
"total_tokens": 0,
|
| 2152 |
+
}
|
| 2153 |
+
|
| 2154 |
+
try:
|
| 2155 |
+
async for line in response.aiter_lines():
|
| 2156 |
+
if not line:
|
| 2157 |
+
continue
|
| 2158 |
+
|
| 2159 |
+
line = line.strip()
|
| 2160 |
+
if not line.startswith("data:"):
|
| 2161 |
+
try:
|
| 2162 |
+
maybe_err = json.loads(line)
|
| 2163 |
+
if isinstance(maybe_err, dict) and (
|
| 2164 |
+
"error" in maybe_err or "code" in maybe_err or "message" in maybe_err
|
| 2165 |
+
):
|
| 2166 |
+
msg = (
|
| 2167 |
+
(maybe_err.get("error") or {}).get("message")
|
| 2168 |
+
if isinstance(maybe_err.get("error"), dict)
|
| 2169 |
+
else maybe_err.get("message")
|
| 2170 |
+
) or "上游返回错误"
|
| 2171 |
+
return handle_error(Exception(msg), "API响应")
|
| 2172 |
+
except Exception:
|
| 2173 |
+
pass
|
| 2174 |
+
continue
|
| 2175 |
+
|
| 2176 |
+
data_str = line[5:].strip()
|
| 2177 |
+
if not data_str or data_str in ("[DONE]", "DONE", "done"):
|
| 2178 |
+
continue
|
| 2179 |
+
|
| 2180 |
+
try:
|
| 2181 |
+
chunk = json.loads(data_str)
|
| 2182 |
+
except json.JSONDecodeError:
|
| 2183 |
+
continue
|
| 2184 |
+
|
| 2185 |
+
chunk_type = chunk.get("type")
|
| 2186 |
+
data = chunk.get("data", {}) if chunk_type == "chat:completion" else chunk
|
| 2187 |
+
if not isinstance(data, dict):
|
| 2188 |
+
continue
|
| 2189 |
+
|
| 2190 |
+
phase = data.get("phase")
|
| 2191 |
+
delta_content = data.get("delta_content", "")
|
| 2192 |
+
edit_content = data.get("edit_content", "")
|
| 2193 |
+
|
| 2194 |
+
if data.get("usage"):
|
| 2195 |
+
usage_info = data["usage"]
|
| 2196 |
+
|
| 2197 |
+
if phase == "thinking" and delta_content:
|
| 2198 |
+
reasoning_content += self._clean_reasoning_delta(delta_content)
|
| 2199 |
+
|
| 2200 |
+
elif phase == "answer":
|
| 2201 |
+
if delta_content:
|
| 2202 |
+
final_content += delta_content
|
| 2203 |
+
elif edit_content:
|
| 2204 |
+
final_content += self._extract_answer_content(edit_content)
|
| 2205 |
+
|
| 2206 |
+
elif phase == "other" and edit_content:
|
| 2207 |
+
final_content += self._extract_answer_content(edit_content)
|
| 2208 |
+
|
| 2209 |
+
elif phase == "search" or chunk_type == "web_search":
|
| 2210 |
+
final_content += self._format_search_results(data)
|
| 2211 |
+
|
| 2212 |
+
tool_calls_accum.extend(
|
| 2213 |
+
self._normalize_tool_calls(
|
| 2214 |
+
data.get("tool_calls"),
|
| 2215 |
+
len(tool_calls_accum),
|
| 2216 |
+
)
|
| 2217 |
+
)
|
| 2218 |
+
|
| 2219 |
+
except Exception as e:
|
| 2220 |
+
self.logger.error(f"❌ 非流式响应处理错误: {e}")
|
| 2221 |
+
import traceback
|
| 2222 |
+
self.logger.error(traceback.format_exc())
|
| 2223 |
+
return handle_error(e, "非流式聚合")
|
| 2224 |
+
|
| 2225 |
+
if not tool_calls_accum:
|
| 2226 |
+
parsed_tool_calls, cleaned_content = parse_and_extract_tool_calls(final_content)
|
| 2227 |
+
normalized = self._normalize_tool_calls(parsed_tool_calls)
|
| 2228 |
+
if normalized:
|
| 2229 |
+
tool_calls_accum = normalized
|
| 2230 |
+
final_content = cleaned_content
|
| 2231 |
+
|
| 2232 |
+
final_content = (final_content or "").strip()
|
| 2233 |
+
reasoning_content = (reasoning_content or "").strip()
|
| 2234 |
+
|
| 2235 |
+
if not final_content and reasoning_content:
|
| 2236 |
+
final_content = reasoning_content
|
| 2237 |
+
|
| 2238 |
+
return create_openai_response_with_reasoning(
|
| 2239 |
+
chat_id,
|
| 2240 |
+
model,
|
| 2241 |
+
final_content,
|
| 2242 |
+
reasoning_content,
|
| 2243 |
+
usage_info,
|
| 2244 |
+
tool_calls_accum or None,
|
| 2245 |
+
)
|
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,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""请求日志数据库模型。"""
|
| 2 |
+
|
| 3 |
+
from app.core.config import settings
|
| 4 |
+
|
| 5 |
+
DB_PATH = settings.DB_PATH
|
| 6 |
+
|
| 7 |
+
# 创建请求日志表的SQL
|
| 8 |
+
SQL_CREATE_REQUEST_LOGS_TABLE = """
|
| 9 |
+
CREATE TABLE IF NOT EXISTS request_logs (
|
| 10 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 11 |
+
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
| 12 |
+
provider TEXT NOT NULL,
|
| 13 |
+
endpoint TEXT DEFAULT '',
|
| 14 |
+
source TEXT DEFAULT 'unknown',
|
| 15 |
+
protocol TEXT DEFAULT 'unknown',
|
| 16 |
+
client_name TEXT DEFAULT 'Unknown',
|
| 17 |
+
model TEXT NOT NULL,
|
| 18 |
+
status_code INTEGER DEFAULT 200,
|
| 19 |
+
success BOOLEAN NOT NULL,
|
| 20 |
+
duration REAL,
|
| 21 |
+
first_token_time REAL,
|
| 22 |
+
input_tokens INTEGER DEFAULT 0,
|
| 23 |
+
output_tokens INTEGER DEFAULT 0,
|
| 24 |
+
cache_creation_tokens INTEGER DEFAULT 0,
|
| 25 |
+
cache_read_tokens INTEGER DEFAULT 0,
|
| 26 |
+
total_tokens INTEGER DEFAULT 0,
|
| 27 |
+
error_message TEXT,
|
| 28 |
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
| 29 |
+
);
|
| 30 |
+
|
| 31 |
+
CREATE INDEX IF NOT EXISTS idx_request_logs_timestamp ON request_logs(timestamp);
|
| 32 |
+
CREATE INDEX IF NOT EXISTS idx_request_logs_model ON request_logs(model);
|
| 33 |
+
CREATE INDEX IF NOT EXISTS idx_request_logs_provider ON request_logs(provider);
|
| 34 |
+
CREATE INDEX IF NOT EXISTS idx_request_logs_source ON request_logs(source);
|
| 35 |
+
"""
|
app/models/schemas.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
tool_call_id: Optional[str] = None
|
| 29 |
+
name: Optional[str] = None
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class OpenAIRequest(BaseModel):
|
| 33 |
+
"""OpenAI-compatible request model"""
|
| 34 |
+
|
| 35 |
+
model: str
|
| 36 |
+
messages: List[Message]
|
| 37 |
+
stream: Optional[bool] = False
|
| 38 |
+
temperature: Optional[float] = None
|
| 39 |
+
max_tokens: Optional[int] = None
|
| 40 |
+
tools: Optional[List[Dict[str, Any]]] = None
|
| 41 |
+
tool_choice: Optional[Any] = None
|
| 42 |
+
enable_thinking: Optional[bool] = None
|
| 43 |
+
web_search: Optional[bool] = None
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class ModelItem(BaseModel):
|
| 47 |
+
"""Model information item"""
|
| 48 |
+
|
| 49 |
+
id: str
|
| 50 |
+
name: str
|
| 51 |
+
owned_by: str
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class UpstreamRequest(BaseModel):
|
| 55 |
+
"""Upstream service request model"""
|
| 56 |
+
|
| 57 |
+
stream: bool
|
| 58 |
+
model: str
|
| 59 |
+
messages: List[Message]
|
| 60 |
+
params: Dict[str, Any] = {}
|
| 61 |
+
features: Dict[str, Any] = {}
|
| 62 |
+
signature_prompt: Optional[str] = None
|
| 63 |
+
files: Optional[List[Dict[str, Any]]] = None
|
| 64 |
+
extra: Optional[Dict[str, Any]] = None
|
| 65 |
+
background_tasks: Optional[Dict[str, bool]] = None
|
| 66 |
+
chat_id: Optional[str] = None
|
| 67 |
+
id: Optional[str] = None
|
| 68 |
+
session_id: Optional[str] = None
|
| 69 |
+
current_user_message_id: Optional[str] = None
|
| 70 |
+
current_user_message_parent_id: Optional[str] = None
|
| 71 |
+
mcp_servers: Optional[List[str]] = None
|
| 72 |
+
model_item: Optional[Dict[str, Any]] = {} # Model item dictionary
|
| 73 |
+
tools: Optional[List[Dict[str, Any]]] = None # Add tools field for OpenAI compatibility
|
| 74 |
+
tool_choice: Optional[Any] = None
|
| 75 |
+
variables: Optional[Dict[str, str]] = None
|
| 76 |
+
model_config = {"protected_namespaces": ()}
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
class Delta(BaseModel):
|
| 80 |
+
"""Stream delta model"""
|
| 81 |
+
|
| 82 |
+
role: Optional[str] = None
|
| 83 |
+
content: Optional[str] = "" or None
|
| 84 |
+
reasoning_content: Optional[str] = None
|
| 85 |
+
tool_calls: Optional[List[Dict[str, Any]]] = None
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class Choice(BaseModel):
|
| 89 |
+
"""Response choice model"""
|
| 90 |
+
|
| 91 |
+
index: int
|
| 92 |
+
message: Optional[Message] = None
|
| 93 |
+
delta: Optional[Delta] = None
|
| 94 |
+
finish_reason: Optional[str] = None
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
class Usage(BaseModel):
|
| 98 |
+
"""Token usage statistics"""
|
| 99 |
+
|
| 100 |
+
prompt_tokens: int = 0
|
| 101 |
+
completion_tokens: int = 0
|
| 102 |
+
total_tokens: int = 0
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
class OpenAIResponse(BaseModel):
|
| 106 |
+
"""OpenAI-compatible response model"""
|
| 107 |
+
|
| 108 |
+
id: str
|
| 109 |
+
object: str
|
| 110 |
+
created: int
|
| 111 |
+
model: str
|
| 112 |
+
choices: List[Choice]
|
| 113 |
+
usage: Optional[Usage] = None
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
class UpstreamError(BaseModel):
|
| 117 |
+
"""Upstream error model"""
|
| 118 |
+
|
| 119 |
+
detail: str
|
| 120 |
+
code: int
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
class UpstreamDataInner(BaseModel):
|
| 124 |
+
"""Inner upstream data model"""
|
| 125 |
+
|
| 126 |
+
error: Optional[UpstreamError] = None
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
class UpstreamDataData(BaseModel):
|
| 130 |
+
"""Upstream data content model"""
|
| 131 |
+
|
| 132 |
+
delta_content: str = ""
|
| 133 |
+
edit_content: str = ""
|
| 134 |
+
phase: str = ""
|
| 135 |
+
done: bool = False
|
| 136 |
+
results: Optional[List[Dict[str, Any]]] = None
|
| 137 |
+
sources: Optional[List[Dict[str, Any]]] = None
|
| 138 |
+
citations: Optional[List[Dict[str, Any]]] = None
|
| 139 |
+
tool_calls: Optional[List[Dict[str, Any]]] = None
|
| 140 |
+
usage: Optional[Usage] = None
|
| 141 |
+
error: Optional[UpstreamError] = None
|
| 142 |
+
inner: Optional[UpstreamDataInner] = None
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
class UpstreamData(BaseModel):
|
| 146 |
+
"""Upstream data model"""
|
| 147 |
+
|
| 148 |
+
type: str
|
| 149 |
+
data: UpstreamDataData
|
| 150 |
+
error: Optional[UpstreamError] = None
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
class Model(BaseModel):
|
| 154 |
+
"""Model information for listing"""
|
| 155 |
+
|
| 156 |
+
id: str
|
| 157 |
+
object: str = "model"
|
| 158 |
+
created: int
|
| 159 |
+
owned_by: str
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
class ModelsResponse(BaseModel):
|
| 163 |
+
"""Models list response model"""
|
| 164 |
+
|
| 165 |
+
object: str = "list"
|
| 166 |
+
data: List[Model]
|
app/models/token_db.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Token 数据库模型定义。"""
|
| 2 |
+
|
| 3 |
+
from app.core.config import settings
|
| 4 |
+
|
| 5 |
+
SQL_CREATE_TABLES = """
|
| 6 |
+
-- Token 配置表
|
| 7 |
+
CREATE TABLE IF NOT EXISTS tokens (
|
| 8 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 9 |
+
provider TEXT NOT NULL, -- 提供商: zai
|
| 10 |
+
token TEXT NOT NULL UNIQUE, -- Token 值(唯一)
|
| 11 |
+
token_type TEXT DEFAULT 'user', -- Token 类型: user, guest, unknown
|
| 12 |
+
is_enabled BOOLEAN DEFAULT 1, -- 是否启用
|
| 13 |
+
priority INTEGER DEFAULT 0, -- 优先级(用于排序)
|
| 14 |
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
| 15 |
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
| 16 |
+
UNIQUE(provider, token) -- 同一提供商内 Token 唯一
|
| 17 |
+
);
|
| 18 |
+
|
| 19 |
+
-- Token 使用统计表
|
| 20 |
+
CREATE TABLE IF NOT EXISTS token_stats (
|
| 21 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 22 |
+
token_id INTEGER NOT NULL,
|
| 23 |
+
total_requests INTEGER DEFAULT 0,
|
| 24 |
+
successful_requests INTEGER DEFAULT 0,
|
| 25 |
+
failed_requests INTEGER DEFAULT 0,
|
| 26 |
+
last_success_time DATETIME,
|
| 27 |
+
last_failure_time DATETIME,
|
| 28 |
+
FOREIGN KEY (token_id) REFERENCES tokens(id) ON DELETE CASCADE
|
| 29 |
+
);
|
| 30 |
+
|
| 31 |
+
-- 创建索引
|
| 32 |
+
CREATE INDEX IF NOT EXISTS idx_tokens_provider ON tokens(provider);
|
| 33 |
+
CREATE INDEX IF NOT EXISTS idx_tokens_enabled ON tokens(is_enabled);
|
| 34 |
+
CREATE INDEX IF NOT EXISTS idx_token_stats_token_id ON token_stats(token_id);
|
| 35 |
+
|
| 36 |
+
-- 触发器:自动更新 updated_at
|
| 37 |
+
CREATE TRIGGER IF NOT EXISTS update_tokens_timestamp
|
| 38 |
+
AFTER UPDATE ON tokens
|
| 39 |
+
BEGIN
|
| 40 |
+
UPDATE tokens SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
|
| 41 |
+
END;
|
| 42 |
+
"""
|
| 43 |
+
|
| 44 |
+
DB_PATH = settings.DB_PATH
|
app/services/request_log_dao.py
ADDED
|
@@ -0,0 +1,630 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
请求日志数据访问层 (DAO)
|
| 3 |
+
提供请求日志的 CRUD 操作和查询功能
|
| 4 |
+
"""
|
| 5 |
+
import os
|
| 6 |
+
import sqlite3
|
| 7 |
+
from contextlib import asynccontextmanager
|
| 8 |
+
from datetime import datetime, timedelta
|
| 9 |
+
from typing import Dict, List, Optional
|
| 10 |
+
|
| 11 |
+
import aiosqlite
|
| 12 |
+
|
| 13 |
+
from app.models.request_log import DB_PATH, SQL_CREATE_REQUEST_LOGS_TABLE
|
| 14 |
+
from app.utils.logger import logger
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _format_sqlite_datetime(value: datetime) -> str:
|
| 18 |
+
"""格式化为 SQLite `CURRENT_TIMESTAMP` 兼容的时间字符串。"""
|
| 19 |
+
return value.strftime("%Y-%m-%d %H:%M:%S")
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _normalize_trend_window(window: Optional[str], days: Optional[int]) -> str:
|
| 23 |
+
"""统一趋势窗口参数,兼容旧版 `days` 调用。"""
|
| 24 |
+
if window:
|
| 25 |
+
normalized = str(window).strip().lower()
|
| 26 |
+
elif days == 30:
|
| 27 |
+
normalized = "30d"
|
| 28 |
+
elif days == 1:
|
| 29 |
+
normalized = "24h"
|
| 30 |
+
else:
|
| 31 |
+
normalized = "7d"
|
| 32 |
+
|
| 33 |
+
if normalized in {"24h", "7d", "30d"}:
|
| 34 |
+
return normalized
|
| 35 |
+
if normalized == "1d":
|
| 36 |
+
return "24h"
|
| 37 |
+
return "7d"
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class RequestLogDAO:
|
| 41 |
+
"""请求日志数据访问对象"""
|
| 42 |
+
|
| 43 |
+
def __init__(self, db_path: str = DB_PATH):
|
| 44 |
+
"""初始化 DAO"""
|
| 45 |
+
self.db_path = db_path
|
| 46 |
+
self._ensure_db_directory()
|
| 47 |
+
self._init_db()
|
| 48 |
+
|
| 49 |
+
def _ensure_db_directory(self):
|
| 50 |
+
"""确保数据库目录存在"""
|
| 51 |
+
db_dir = os.path.dirname(self.db_path)
|
| 52 |
+
if db_dir and not os.path.exists(db_dir):
|
| 53 |
+
os.makedirs(db_dir, exist_ok=True)
|
| 54 |
+
|
| 55 |
+
def _init_db(self):
|
| 56 |
+
"""初始化数据库表"""
|
| 57 |
+
try:
|
| 58 |
+
conn = sqlite3.connect(self.db_path)
|
| 59 |
+
conn.executescript(SQL_CREATE_REQUEST_LOGS_TABLE)
|
| 60 |
+
self._ensure_columns(conn)
|
| 61 |
+
conn.commit()
|
| 62 |
+
conn.close()
|
| 63 |
+
logger.debug("请求日志表初始化成功")
|
| 64 |
+
except Exception as e:
|
| 65 |
+
logger.error(f"初始化请求日志表失败: {e}")
|
| 66 |
+
|
| 67 |
+
def _ensure_columns(self, conn: sqlite3.Connection):
|
| 68 |
+
"""为旧数据库补齐新增列。"""
|
| 69 |
+
cursor = conn.execute("PRAGMA table_info(request_logs)")
|
| 70 |
+
existing_columns = {row[1] for row in cursor.fetchall()}
|
| 71 |
+
required_columns = {
|
| 72 |
+
"endpoint": "TEXT DEFAULT ''",
|
| 73 |
+
"source": "TEXT DEFAULT 'unknown'",
|
| 74 |
+
"protocol": "TEXT DEFAULT 'unknown'",
|
| 75 |
+
"client_name": "TEXT DEFAULT 'Unknown'",
|
| 76 |
+
"status_code": "INTEGER DEFAULT 200",
|
| 77 |
+
"cache_creation_tokens": "INTEGER DEFAULT 0",
|
| 78 |
+
"cache_read_tokens": "INTEGER DEFAULT 0",
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
for column, definition in required_columns.items():
|
| 82 |
+
if column in existing_columns:
|
| 83 |
+
continue
|
| 84 |
+
conn.execute(
|
| 85 |
+
f"ALTER TABLE request_logs ADD COLUMN {column} {definition}"
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
@asynccontextmanager
|
| 89 |
+
async def get_connection(self):
|
| 90 |
+
"""获取异步数据库连接"""
|
| 91 |
+
conn = await aiosqlite.connect(self.db_path)
|
| 92 |
+
conn.row_factory = aiosqlite.Row
|
| 93 |
+
try:
|
| 94 |
+
yield conn
|
| 95 |
+
finally:
|
| 96 |
+
await conn.close()
|
| 97 |
+
|
| 98 |
+
async def add_log(
|
| 99 |
+
self,
|
| 100 |
+
provider: str,
|
| 101 |
+
endpoint: str,
|
| 102 |
+
source: str,
|
| 103 |
+
protocol: str,
|
| 104 |
+
client_name: str,
|
| 105 |
+
model: str,
|
| 106 |
+
status_code: int,
|
| 107 |
+
success: bool,
|
| 108 |
+
duration: float = 0.0,
|
| 109 |
+
first_token_time: float = 0.0,
|
| 110 |
+
input_tokens: int = 0,
|
| 111 |
+
output_tokens: int = 0,
|
| 112 |
+
cache_creation_tokens: int = 0,
|
| 113 |
+
cache_read_tokens: int = 0,
|
| 114 |
+
total_tokens: Optional[int] = None,
|
| 115 |
+
error_message: str = None
|
| 116 |
+
) -> int:
|
| 117 |
+
"""
|
| 118 |
+
添加请求日志
|
| 119 |
+
|
| 120 |
+
Args:
|
| 121 |
+
provider: 提供商名称
|
| 122 |
+
endpoint: 请求端点
|
| 123 |
+
source: 请求来源标识
|
| 124 |
+
protocol: 协议类型
|
| 125 |
+
client_name: 客户端名称
|
| 126 |
+
model: 模型名称
|
| 127 |
+
status_code: 请求状态码
|
| 128 |
+
success: 是否成功
|
| 129 |
+
duration: 总耗时(秒)
|
| 130 |
+
first_token_time: 首字延迟(秒)
|
| 131 |
+
input_tokens: 输入 token 数
|
| 132 |
+
output_tokens: 输出 token 数
|
| 133 |
+
cache_creation_tokens: 缓存创建 token 数
|
| 134 |
+
cache_read_tokens: 缓存命中 token 数
|
| 135 |
+
total_tokens: 总 token 数
|
| 136 |
+
error_message: 错误信息
|
| 137 |
+
|
| 138 |
+
Returns:
|
| 139 |
+
日志 ID
|
| 140 |
+
"""
|
| 141 |
+
if total_tokens is None:
|
| 142 |
+
total_tokens = input_tokens + output_tokens
|
| 143 |
+
|
| 144 |
+
async with self.get_connection() as conn:
|
| 145 |
+
cursor = await conn.execute(
|
| 146 |
+
"""
|
| 147 |
+
INSERT INTO request_logs
|
| 148 |
+
(provider, endpoint, source, protocol, client_name, model,
|
| 149 |
+
status_code, success, duration, first_token_time,
|
| 150 |
+
input_tokens, output_tokens, cache_creation_tokens,
|
| 151 |
+
cache_read_tokens, total_tokens, error_message)
|
| 152 |
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 153 |
+
""",
|
| 154 |
+
(
|
| 155 |
+
provider,
|
| 156 |
+
endpoint,
|
| 157 |
+
source,
|
| 158 |
+
protocol,
|
| 159 |
+
client_name,
|
| 160 |
+
model,
|
| 161 |
+
status_code,
|
| 162 |
+
success,
|
| 163 |
+
duration,
|
| 164 |
+
first_token_time,
|
| 165 |
+
input_tokens,
|
| 166 |
+
output_tokens,
|
| 167 |
+
cache_creation_tokens,
|
| 168 |
+
cache_read_tokens,
|
| 169 |
+
total_tokens,
|
| 170 |
+
error_message,
|
| 171 |
+
)
|
| 172 |
+
)
|
| 173 |
+
await conn.commit()
|
| 174 |
+
return cursor.lastrowid
|
| 175 |
+
|
| 176 |
+
async def get_recent_logs(
|
| 177 |
+
self,
|
| 178 |
+
limit: int = 100,
|
| 179 |
+
offset: int = 0,
|
| 180 |
+
provider: str = None,
|
| 181 |
+
model: str = None,
|
| 182 |
+
success: bool = None,
|
| 183 |
+
source: str = None,
|
| 184 |
+
) -> List[Dict]:
|
| 185 |
+
"""
|
| 186 |
+
获取最近的请求日志
|
| 187 |
+
|
| 188 |
+
Args:
|
| 189 |
+
limit: 返回数量限制
|
| 190 |
+
provider: 过滤提供商
|
| 191 |
+
model: 过滤模型
|
| 192 |
+
success: 过滤成功/失败状态
|
| 193 |
+
|
| 194 |
+
Returns:
|
| 195 |
+
日志列表
|
| 196 |
+
"""
|
| 197 |
+
query = "SELECT * FROM request_logs WHERE 1=1"
|
| 198 |
+
params = []
|
| 199 |
+
|
| 200 |
+
if provider:
|
| 201 |
+
query += " AND provider = ?"
|
| 202 |
+
params.append(provider)
|
| 203 |
+
|
| 204 |
+
if model:
|
| 205 |
+
query += " AND model = ?"
|
| 206 |
+
params.append(model)
|
| 207 |
+
|
| 208 |
+
if success is not None:
|
| 209 |
+
query += " AND success = ?"
|
| 210 |
+
params.append(success)
|
| 211 |
+
|
| 212 |
+
if source:
|
| 213 |
+
query += " AND source = ?"
|
| 214 |
+
params.append(source)
|
| 215 |
+
|
| 216 |
+
query += " ORDER BY timestamp DESC, id DESC LIMIT ? OFFSET ?"
|
| 217 |
+
params.extend([limit, max(0, offset)])
|
| 218 |
+
|
| 219 |
+
async with self.get_connection() as conn:
|
| 220 |
+
cursor = await conn.execute(query, params)
|
| 221 |
+
rows = await cursor.fetchall()
|
| 222 |
+
return [dict(row) for row in rows]
|
| 223 |
+
|
| 224 |
+
async def count_logs(
|
| 225 |
+
self,
|
| 226 |
+
provider: str = None,
|
| 227 |
+
model: str = None,
|
| 228 |
+
success: bool = None,
|
| 229 |
+
source: str = None,
|
| 230 |
+
) -> int:
|
| 231 |
+
"""统计日志总数。"""
|
| 232 |
+
query = "SELECT COUNT(*) AS total_count FROM request_logs WHERE 1=1"
|
| 233 |
+
params = []
|
| 234 |
+
|
| 235 |
+
if provider:
|
| 236 |
+
query += " AND provider = ?"
|
| 237 |
+
params.append(provider)
|
| 238 |
+
|
| 239 |
+
if model:
|
| 240 |
+
query += " AND model = ?"
|
| 241 |
+
params.append(model)
|
| 242 |
+
|
| 243 |
+
if success is not None:
|
| 244 |
+
query += " AND success = ?"
|
| 245 |
+
params.append(success)
|
| 246 |
+
|
| 247 |
+
if source:
|
| 248 |
+
query += " AND source = ?"
|
| 249 |
+
params.append(source)
|
| 250 |
+
|
| 251 |
+
async with self.get_connection() as conn:
|
| 252 |
+
cursor = await conn.execute(query, params)
|
| 253 |
+
row = await cursor.fetchone()
|
| 254 |
+
return int(row["total_count"] or 0) if row else 0
|
| 255 |
+
|
| 256 |
+
async def get_logs_by_time_range(
|
| 257 |
+
self,
|
| 258 |
+
start_time: datetime,
|
| 259 |
+
end_time: datetime,
|
| 260 |
+
provider: str = None,
|
| 261 |
+
model: str = None
|
| 262 |
+
) -> List[Dict]:
|
| 263 |
+
"""
|
| 264 |
+
按时间范围获取日志
|
| 265 |
+
|
| 266 |
+
Args:
|
| 267 |
+
start_time: 开始时间
|
| 268 |
+
end_time: 结束时间
|
| 269 |
+
provider: 过滤提供商
|
| 270 |
+
model: 过滤模型
|
| 271 |
+
|
| 272 |
+
Returns:
|
| 273 |
+
日志列表
|
| 274 |
+
"""
|
| 275 |
+
query = "SELECT * FROM request_logs WHERE timestamp BETWEEN ? AND ?"
|
| 276 |
+
params = [
|
| 277 |
+
_format_sqlite_datetime(start_time),
|
| 278 |
+
_format_sqlite_datetime(end_time),
|
| 279 |
+
]
|
| 280 |
+
|
| 281 |
+
if provider:
|
| 282 |
+
query += " AND provider = ?"
|
| 283 |
+
params.append(provider)
|
| 284 |
+
|
| 285 |
+
if model:
|
| 286 |
+
query += " AND model = ?"
|
| 287 |
+
params.append(model)
|
| 288 |
+
|
| 289 |
+
query += " ORDER BY timestamp DESC, id DESC"
|
| 290 |
+
|
| 291 |
+
async with self.get_connection() as conn:
|
| 292 |
+
cursor = await conn.execute(query, params)
|
| 293 |
+
rows = await cursor.fetchall()
|
| 294 |
+
return [dict(row) for row in rows]
|
| 295 |
+
|
| 296 |
+
async def get_provider_request_stats(self, provider: Optional[str] = None) -> Dict:
|
| 297 |
+
"""聚合请求日志统计,可按提供商过滤。"""
|
| 298 |
+
query = """
|
| 299 |
+
SELECT
|
| 300 |
+
COUNT(*) as total_requests,
|
| 301 |
+
SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as successful_requests,
|
| 302 |
+
SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) as failed_requests,
|
| 303 |
+
SUM(input_tokens) as input_tokens,
|
| 304 |
+
SUM(output_tokens) as output_tokens,
|
| 305 |
+
SUM(total_tokens) as total_tokens,
|
| 306 |
+
SUM(cache_creation_tokens) as cache_creation_tokens,
|
| 307 |
+
SUM(cache_read_tokens) as cache_read_tokens,
|
| 308 |
+
SUM(
|
| 309 |
+
CASE WHEN cache_creation_tokens > 0 THEN 1 ELSE 0 END
|
| 310 |
+
) as cache_creation_requests,
|
| 311 |
+
SUM(
|
| 312 |
+
CASE WHEN cache_read_tokens > 0 THEN 1 ELSE 0 END
|
| 313 |
+
) as cache_hit_requests,
|
| 314 |
+
AVG(duration) as avg_duration,
|
| 315 |
+
AVG(
|
| 316 |
+
CASE
|
| 317 |
+
WHEN first_token_time > 0 THEN first_token_time
|
| 318 |
+
ELSE NULL
|
| 319 |
+
END
|
| 320 |
+
) as avg_first_token_time
|
| 321 |
+
FROM request_logs
|
| 322 |
+
"""
|
| 323 |
+
params: List[object] = []
|
| 324 |
+
|
| 325 |
+
if provider:
|
| 326 |
+
query += " WHERE provider = ?"
|
| 327 |
+
params.append(provider)
|
| 328 |
+
|
| 329 |
+
try:
|
| 330 |
+
async with self.get_connection() as conn:
|
| 331 |
+
cursor = await conn.execute(query, params)
|
| 332 |
+
row = await cursor.fetchone()
|
| 333 |
+
|
| 334 |
+
if not row:
|
| 335 |
+
return {
|
| 336 |
+
"total_requests": 0,
|
| 337 |
+
"successful_requests": 0,
|
| 338 |
+
"failed_requests": 0,
|
| 339 |
+
"input_tokens": 0,
|
| 340 |
+
"output_tokens": 0,
|
| 341 |
+
"total_tokens": 0,
|
| 342 |
+
"cache_creation_tokens": 0,
|
| 343 |
+
"cache_read_tokens": 0,
|
| 344 |
+
"cache_creation_requests": 0,
|
| 345 |
+
"cache_hit_requests": 0,
|
| 346 |
+
"avg_duration": 0.0,
|
| 347 |
+
"avg_first_token_time": 0.0,
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
return {
|
| 351 |
+
"total_requests": int(row["total_requests"] or 0),
|
| 352 |
+
"successful_requests": int(row["successful_requests"] or 0),
|
| 353 |
+
"failed_requests": int(row["failed_requests"] or 0),
|
| 354 |
+
"input_tokens": int(row["input_tokens"] or 0),
|
| 355 |
+
"output_tokens": int(row["output_tokens"] or 0),
|
| 356 |
+
"total_tokens": int(row["total_tokens"] or 0),
|
| 357 |
+
"cache_creation_tokens": int(
|
| 358 |
+
row["cache_creation_tokens"] or 0
|
| 359 |
+
),
|
| 360 |
+
"cache_read_tokens": int(row["cache_read_tokens"] or 0),
|
| 361 |
+
"cache_creation_requests": int(
|
| 362 |
+
row["cache_creation_requests"] or 0
|
| 363 |
+
),
|
| 364 |
+
"cache_hit_requests": int(row["cache_hit_requests"] or 0),
|
| 365 |
+
"avg_duration": float(row["avg_duration"] or 0.0),
|
| 366 |
+
"avg_first_token_time": float(
|
| 367 |
+
row["avg_first_token_time"] or 0.0
|
| 368 |
+
),
|
| 369 |
+
}
|
| 370 |
+
except Exception as e:
|
| 371 |
+
logger.error(f"❌ 获取请求统计失败: {e}")
|
| 372 |
+
return {
|
| 373 |
+
"total_requests": 0,
|
| 374 |
+
"successful_requests": 0,
|
| 375 |
+
"failed_requests": 0,
|
| 376 |
+
"input_tokens": 0,
|
| 377 |
+
"output_tokens": 0,
|
| 378 |
+
"total_tokens": 0,
|
| 379 |
+
"cache_creation_tokens": 0,
|
| 380 |
+
"cache_read_tokens": 0,
|
| 381 |
+
"cache_creation_requests": 0,
|
| 382 |
+
"cache_hit_requests": 0,
|
| 383 |
+
"avg_duration": 0.0,
|
| 384 |
+
"avg_first_token_time": 0.0,
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
async def get_provider_usage_trend(
|
| 388 |
+
self,
|
| 389 |
+
provider: Optional[str] = None,
|
| 390 |
+
days: Optional[int] = None,
|
| 391 |
+
*,
|
| 392 |
+
window: Optional[str] = None,
|
| 393 |
+
now: Optional[datetime] = None,
|
| 394 |
+
) -> List[Dict]:
|
| 395 |
+
"""按窗口聚合最近一段时间的请求与 token 趋势。"""
|
| 396 |
+
trend_window = _normalize_trend_window(window, days)
|
| 397 |
+
current_time = now or datetime.utcnow()
|
| 398 |
+
|
| 399 |
+
if trend_window == "24h":
|
| 400 |
+
bucket_count = 24
|
| 401 |
+
current_hour = current_time.replace(
|
| 402 |
+
minute=0,
|
| 403 |
+
second=0,
|
| 404 |
+
microsecond=0,
|
| 405 |
+
)
|
| 406 |
+
start_time = current_hour - timedelta(hours=bucket_count - 1)
|
| 407 |
+
bucket_expression = "strftime('%Y-%m-%d %H:00:00', timestamp)"
|
| 408 |
+
row_key = "trend_bucket"
|
| 409 |
+
label_format = "%H:%M"
|
| 410 |
+
tooltip_format = "%Y-%m-%d %H:00"
|
| 411 |
+
rows = await self._query_usage_trend_rows(
|
| 412 |
+
provider,
|
| 413 |
+
start_time,
|
| 414 |
+
bucket_expression,
|
| 415 |
+
row_key,
|
| 416 |
+
)
|
| 417 |
+
rows_by_bucket = {str(row[row_key]): dict(row) for row in rows}
|
| 418 |
+
trend: List[Dict] = []
|
| 419 |
+
|
| 420 |
+
for offset in range(bucket_count):
|
| 421 |
+
bucket_time = start_time + timedelta(hours=offset)
|
| 422 |
+
bucket_key = bucket_time.strftime("%Y-%m-%d %H:00:00")
|
| 423 |
+
trend.append(
|
| 424 |
+
self._build_usage_trend_point(
|
| 425 |
+
row=rows_by_bucket.get(bucket_key, {}),
|
| 426 |
+
bucket=bucket_key,
|
| 427 |
+
label=bucket_time.strftime(label_format),
|
| 428 |
+
tooltip_label=bucket_time.strftime(tooltip_format),
|
| 429 |
+
)
|
| 430 |
+
)
|
| 431 |
+
|
| 432 |
+
return trend
|
| 433 |
+
|
| 434 |
+
bucket_count = 30 if trend_window == "30d" else 7
|
| 435 |
+
current_date = current_time.date()
|
| 436 |
+
start_date = current_date - timedelta(days=bucket_count - 1)
|
| 437 |
+
start_time = datetime.combine(start_date, datetime.min.time())
|
| 438 |
+
rows = await self._query_usage_trend_rows(
|
| 439 |
+
provider,
|
| 440 |
+
start_time,
|
| 441 |
+
"DATE(timestamp)",
|
| 442 |
+
"trend_bucket",
|
| 443 |
+
)
|
| 444 |
+
rows_by_bucket = {
|
| 445 |
+
str(row["trend_bucket"]): dict(row)
|
| 446 |
+
for row in rows
|
| 447 |
+
}
|
| 448 |
+
trend = []
|
| 449 |
+
|
| 450 |
+
for offset in range(bucket_count):
|
| 451 |
+
bucket_date = start_date + timedelta(days=offset)
|
| 452 |
+
bucket_key = bucket_date.isoformat()
|
| 453 |
+
trend.append(
|
| 454 |
+
self._build_usage_trend_point(
|
| 455 |
+
row=rows_by_bucket.get(bucket_key, {}),
|
| 456 |
+
bucket=bucket_key,
|
| 457 |
+
label=bucket_date.strftime("%m-%d"),
|
| 458 |
+
tooltip_label=bucket_date.strftime("%Y-%m-%d"),
|
| 459 |
+
)
|
| 460 |
+
)
|
| 461 |
+
|
| 462 |
+
return trend
|
| 463 |
+
|
| 464 |
+
async def _query_usage_trend_rows(
|
| 465 |
+
self,
|
| 466 |
+
provider: Optional[str],
|
| 467 |
+
start_time: datetime,
|
| 468 |
+
bucket_expression: str,
|
| 469 |
+
bucket_alias: str,
|
| 470 |
+
) -> list[aiosqlite.Row]:
|
| 471 |
+
query = f"""
|
| 472 |
+
SELECT
|
| 473 |
+
{bucket_expression} as {bucket_alias},
|
| 474 |
+
COUNT(*) as total_requests,
|
| 475 |
+
SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as successful_requests,
|
| 476 |
+
SUM(input_tokens) as input_tokens,
|
| 477 |
+
SUM(output_tokens) as output_tokens,
|
| 478 |
+
SUM(total_tokens) as total_tokens,
|
| 479 |
+
SUM(cache_creation_tokens) as cache_creation_tokens,
|
| 480 |
+
SUM(cache_read_tokens) as cache_read_tokens
|
| 481 |
+
FROM request_logs
|
| 482 |
+
WHERE timestamp >= ?
|
| 483 |
+
"""
|
| 484 |
+
params: List[object] = [_format_sqlite_datetime(start_time)]
|
| 485 |
+
|
| 486 |
+
if provider:
|
| 487 |
+
query += " AND provider = ?"
|
| 488 |
+
params.append(provider)
|
| 489 |
+
|
| 490 |
+
query += f" GROUP BY {bucket_expression} ORDER BY {bucket_alias} ASC"
|
| 491 |
+
|
| 492 |
+
async with self.get_connection() as conn:
|
| 493 |
+
cursor = await conn.execute(query, params)
|
| 494 |
+
return await cursor.fetchall()
|
| 495 |
+
|
| 496 |
+
def _build_usage_trend_point(
|
| 497 |
+
self,
|
| 498 |
+
*,
|
| 499 |
+
row: Dict,
|
| 500 |
+
bucket: str,
|
| 501 |
+
label: str,
|
| 502 |
+
tooltip_label: str,
|
| 503 |
+
) -> Dict:
|
| 504 |
+
total_requests = int(row.get("total_requests") or 0)
|
| 505 |
+
successful_requests = int(row.get("successful_requests") or 0)
|
| 506 |
+
cache_creation_tokens = int(row.get("cache_creation_tokens") or 0)
|
| 507 |
+
cache_read_tokens = int(row.get("cache_read_tokens") or 0)
|
| 508 |
+
|
| 509 |
+
return {
|
| 510 |
+
"bucket": bucket,
|
| 511 |
+
"label": label,
|
| 512 |
+
"tooltip_label": tooltip_label,
|
| 513 |
+
"total_requests": total_requests,
|
| 514 |
+
"successful_requests": successful_requests,
|
| 515 |
+
"failed_requests": max(0, total_requests - successful_requests),
|
| 516 |
+
"input_tokens": int(row.get("input_tokens") or 0),
|
| 517 |
+
"output_tokens": int(row.get("output_tokens") or 0),
|
| 518 |
+
"total_tokens": int(row.get("total_tokens") or 0),
|
| 519 |
+
"cache_creation_tokens": cache_creation_tokens,
|
| 520 |
+
"cache_read_tokens": cache_read_tokens,
|
| 521 |
+
"cache_total_tokens": (
|
| 522 |
+
cache_creation_tokens + cache_read_tokens
|
| 523 |
+
),
|
| 524 |
+
"success_rate": round(
|
| 525 |
+
(
|
| 526 |
+
successful_requests / total_requests * 100
|
| 527 |
+
) if total_requests > 0 else 0,
|
| 528 |
+
1,
|
| 529 |
+
),
|
| 530 |
+
}
|
| 531 |
+
|
| 532 |
+
async def get_model_stats_from_db(self, hours: int = 24) -> Dict:
|
| 533 |
+
"""
|
| 534 |
+
从数据库获取模型统计(最近N小时)
|
| 535 |
+
|
| 536 |
+
Args:
|
| 537 |
+
hours: 小时数
|
| 538 |
+
|
| 539 |
+
Returns:
|
| 540 |
+
模型统计数据
|
| 541 |
+
"""
|
| 542 |
+
start_time = datetime.utcnow() - timedelta(hours=hours)
|
| 543 |
+
|
| 544 |
+
async with self.get_connection() as conn:
|
| 545 |
+
cursor = await conn.execute(
|
| 546 |
+
"""
|
| 547 |
+
SELECT
|
| 548 |
+
model,
|
| 549 |
+
COUNT(*) as total,
|
| 550 |
+
SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as success,
|
| 551 |
+
SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) as failed,
|
| 552 |
+
SUM(input_tokens) as input_tokens,
|
| 553 |
+
SUM(output_tokens) as output_tokens,
|
| 554 |
+
SUM(total_tokens) as total_tokens,
|
| 555 |
+
AVG(duration) as avg_duration,
|
| 556 |
+
AVG(first_token_time) as avg_first_token_time
|
| 557 |
+
FROM request_logs
|
| 558 |
+
WHERE timestamp >= ?
|
| 559 |
+
GROUP BY model
|
| 560 |
+
ORDER BY total DESC
|
| 561 |
+
""",
|
| 562 |
+
(_format_sqlite_datetime(start_time),)
|
| 563 |
+
)
|
| 564 |
+
rows = await cursor.fetchall()
|
| 565 |
+
|
| 566 |
+
result = {}
|
| 567 |
+
for row in rows:
|
| 568 |
+
model = row['model']
|
| 569 |
+
result[model] = {
|
| 570 |
+
'total': row['total'],
|
| 571 |
+
'success': row['success'],
|
| 572 |
+
'failed': row['failed'],
|
| 573 |
+
'input_tokens': row['input_tokens'] or 0,
|
| 574 |
+
'output_tokens': row['output_tokens'] or 0,
|
| 575 |
+
'total_tokens': row['total_tokens'] or 0,
|
| 576 |
+
'avg_duration': round(row['avg_duration'] or 0, 2),
|
| 577 |
+
'avg_first_token_time': round(row['avg_first_token_time'] or 0, 2),
|
| 578 |
+
'success_rate': round(
|
| 579 |
+
(row['success'] / row['total'] * 100)
|
| 580 |
+
if row['total'] > 0
|
| 581 |
+
else 0,
|
| 582 |
+
1,
|
| 583 |
+
),
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
return result
|
| 587 |
+
|
| 588 |
+
async def delete_old_logs(self, days: int = 30) -> int:
|
| 589 |
+
"""
|
| 590 |
+
删除旧日志
|
| 591 |
+
|
| 592 |
+
Args:
|
| 593 |
+
days: 保留天数
|
| 594 |
+
|
| 595 |
+
Returns:
|
| 596 |
+
删除的记录数
|
| 597 |
+
"""
|
| 598 |
+
cutoff_time = datetime.utcnow() - timedelta(days=days)
|
| 599 |
+
|
| 600 |
+
async with self.get_connection() as conn:
|
| 601 |
+
cursor = await conn.execute(
|
| 602 |
+
"DELETE FROM request_logs WHERE timestamp < ?",
|
| 603 |
+
(_format_sqlite_datetime(cutoff_time),)
|
| 604 |
+
)
|
| 605 |
+
await conn.commit()
|
| 606 |
+
return cursor.rowcount
|
| 607 |
+
|
| 608 |
+
|
| 609 |
+
# 全局单例实例
|
| 610 |
+
_request_log_dao: Optional[RequestLogDAO] = None
|
| 611 |
+
|
| 612 |
+
|
| 613 |
+
def get_request_log_dao() -> RequestLogDAO:
|
| 614 |
+
"""
|
| 615 |
+
获取请求日志 DAO 单例
|
| 616 |
+
|
| 617 |
+
Returns:
|
| 618 |
+
RequestLogDAO 实例
|
| 619 |
+
"""
|
| 620 |
+
global _request_log_dao
|
| 621 |
+
if _request_log_dao is None:
|
| 622 |
+
_request_log_dao = RequestLogDAO()
|
| 623 |
+
return _request_log_dao
|
| 624 |
+
|
| 625 |
+
|
| 626 |
+
def init_request_log_dao():
|
| 627 |
+
"""初始化请求日志 DAO"""
|
| 628 |
+
global _request_log_dao
|
| 629 |
+
_request_log_dao = RequestLogDAO()
|
| 630 |
+
return _request_log_dao
|
app/services/token_automation.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Background automation for token import and maintenance."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import asyncio
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
from typing import Optional
|
| 8 |
+
|
| 9 |
+
from app.core.config import settings
|
| 10 |
+
from app.services.token_dao import TokenDAO, get_token_dao
|
| 11 |
+
from app.services.token_importer import TokenImportSummary, import_tokens_from_directory
|
| 12 |
+
from app.utils.logger import logger
|
| 13 |
+
from app.utils.token_pool import TokenPool, get_token_pool
|
| 14 |
+
|
| 15 |
+
DEFAULT_TOKEN_PROVIDER = "zai"
|
| 16 |
+
_AUTO_IMPORT_LOCK = asyncio.Lock()
|
| 17 |
+
_AUTO_MAINTENANCE_LOCK = asyncio.Lock()
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@dataclass(frozen=True)
|
| 21 |
+
class TokenMaintenanceSummary:
|
| 22 |
+
provider: str
|
| 23 |
+
checked_count: int = 0
|
| 24 |
+
duplicate_removed_count: int = 0
|
| 25 |
+
valid_count: int = 0
|
| 26 |
+
guest_count: int = 0
|
| 27 |
+
invalid_count: int = 0
|
| 28 |
+
deleted_invalid_count: int = 0
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
async def run_directory_import(
|
| 32 |
+
source_dir: str,
|
| 33 |
+
*,
|
| 34 |
+
provider: str = DEFAULT_TOKEN_PROVIDER,
|
| 35 |
+
validate: bool = True,
|
| 36 |
+
dao: Optional[TokenDAO] = None,
|
| 37 |
+
pool: Optional[TokenPool] = None,
|
| 38 |
+
) -> TokenImportSummary:
|
| 39 |
+
"""Import tokens from a configured directory and refresh the pool if needed."""
|
| 40 |
+
if _AUTO_IMPORT_LOCK.locked():
|
| 41 |
+
raise RuntimeError("目录导入任务正在执行,请稍后再试")
|
| 42 |
+
|
| 43 |
+
async with _AUTO_IMPORT_LOCK:
|
| 44 |
+
summary = await import_tokens_from_directory(
|
| 45 |
+
source_dir,
|
| 46 |
+
provider=provider,
|
| 47 |
+
validate=validate,
|
| 48 |
+
dao=dao,
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
active_pool = pool if pool is not None else get_token_pool()
|
| 52 |
+
if active_pool and summary.imported_count > 0:
|
| 53 |
+
await active_pool.sync_from_database(provider)
|
| 54 |
+
logger.info("✅ 目录导入后已同步 Token 池")
|
| 55 |
+
|
| 56 |
+
return summary
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
async def run_token_maintenance(
|
| 60 |
+
*,
|
| 61 |
+
provider: str = DEFAULT_TOKEN_PROVIDER,
|
| 62 |
+
remove_duplicates: bool = True,
|
| 63 |
+
run_health_check: bool = True,
|
| 64 |
+
delete_invalid_tokens: bool = False,
|
| 65 |
+
dao: Optional[TokenDAO] = None,
|
| 66 |
+
pool: Optional[TokenPool] = None,
|
| 67 |
+
) -> TokenMaintenanceSummary:
|
| 68 |
+
"""Run dedupe, validation, and invalid-token cleanup as one maintenance cycle."""
|
| 69 |
+
if _AUTO_MAINTENANCE_LOCK.locked():
|
| 70 |
+
raise RuntimeError("Token 自动维护任务正在执行,请稍后再试")
|
| 71 |
+
|
| 72 |
+
token_dao = dao or get_token_dao()
|
| 73 |
+
duplicate_removed_count = 0
|
| 74 |
+
checked_count = 0
|
| 75 |
+
valid_count = 0
|
| 76 |
+
guest_count = 0
|
| 77 |
+
invalid_count = 0
|
| 78 |
+
deleted_invalid_count = 0
|
| 79 |
+
|
| 80 |
+
async with _AUTO_MAINTENANCE_LOCK:
|
| 81 |
+
if remove_duplicates:
|
| 82 |
+
duplicate_removed_count = await token_dao.remove_duplicate_tokens(provider)
|
| 83 |
+
|
| 84 |
+
should_validate = run_health_check or delete_invalid_tokens
|
| 85 |
+
invalid_token_ids: list[int] = []
|
| 86 |
+
|
| 87 |
+
if should_validate:
|
| 88 |
+
validation_result = await token_dao.validate_tokens_detailed(provider)
|
| 89 |
+
checked_count = int(validation_result.get("checked", 0) or 0)
|
| 90 |
+
valid_count = int(validation_result.get("valid", 0) or 0)
|
| 91 |
+
guest_count = int(validation_result.get("guest", 0) or 0)
|
| 92 |
+
invalid_count = int(validation_result.get("invalid", 0) or 0)
|
| 93 |
+
invalid_token_ids = list(
|
| 94 |
+
validation_result.get("invalid_token_ids", []) or []
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
if delete_invalid_tokens and invalid_token_ids:
|
| 98 |
+
deleted_invalid_count = await token_dao.delete_tokens_by_ids(
|
| 99 |
+
invalid_token_ids
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
active_pool = pool if pool is not None else get_token_pool()
|
| 103 |
+
if active_pool:
|
| 104 |
+
await active_pool.sync_from_database(provider)
|
| 105 |
+
logger.info("✅ Token 维护后已同步 Token 池")
|
| 106 |
+
|
| 107 |
+
return TokenMaintenanceSummary(
|
| 108 |
+
provider=provider,
|
| 109 |
+
checked_count=checked_count,
|
| 110 |
+
duplicate_removed_count=duplicate_removed_count,
|
| 111 |
+
valid_count=valid_count,
|
| 112 |
+
guest_count=guest_count,
|
| 113 |
+
invalid_count=invalid_count,
|
| 114 |
+
deleted_invalid_count=deleted_invalid_count,
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
class TokenAutomationScheduler:
|
| 119 |
+
"""Run token import and maintenance loops in the application background."""
|
| 120 |
+
|
| 121 |
+
def __init__(self) -> None:
|
| 122 |
+
self._stop_event = asyncio.Event()
|
| 123 |
+
self._tasks: list[asyncio.Task] = []
|
| 124 |
+
self._import_warning: Optional[str] = None
|
| 125 |
+
self._maintenance_warning: Optional[str] = None
|
| 126 |
+
|
| 127 |
+
async def start(self) -> None:
|
| 128 |
+
if self._tasks:
|
| 129 |
+
return
|
| 130 |
+
|
| 131 |
+
self._stop_event.clear()
|
| 132 |
+
self._tasks = [
|
| 133 |
+
asyncio.create_task(
|
| 134 |
+
self._auto_import_loop(),
|
| 135 |
+
name="token-auto-import",
|
| 136 |
+
),
|
| 137 |
+
asyncio.create_task(
|
| 138 |
+
self._auto_maintenance_loop(),
|
| 139 |
+
name="token-auto-maintenance",
|
| 140 |
+
),
|
| 141 |
+
]
|
| 142 |
+
logger.info("✅ Token 自动任务调度器已启动")
|
| 143 |
+
|
| 144 |
+
async def stop(self) -> None:
|
| 145 |
+
if not self._tasks:
|
| 146 |
+
return
|
| 147 |
+
|
| 148 |
+
self._stop_event.set()
|
| 149 |
+
for task in self._tasks:
|
| 150 |
+
task.cancel()
|
| 151 |
+
|
| 152 |
+
await asyncio.gather(*self._tasks, return_exceptions=True)
|
| 153 |
+
self._tasks.clear()
|
| 154 |
+
self._import_warning = None
|
| 155 |
+
self._maintenance_warning = None
|
| 156 |
+
logger.info("🛑 Token 自动任务调度器已停止")
|
| 157 |
+
|
| 158 |
+
async def _auto_import_loop(self) -> None:
|
| 159 |
+
while not self._stop_event.is_set():
|
| 160 |
+
wait_seconds = 15
|
| 161 |
+
try:
|
| 162 |
+
if settings.TOKEN_AUTO_IMPORT_ENABLED:
|
| 163 |
+
wait_seconds = max(int(settings.TOKEN_AUTO_IMPORT_INTERVAL), 30)
|
| 164 |
+
source_dir = settings.TOKEN_AUTO_IMPORT_SOURCE_DIR.strip()
|
| 165 |
+
if not source_dir:
|
| 166 |
+
self._log_import_warning_once(
|
| 167 |
+
"已启用自动导入,但未配置导入目录"
|
| 168 |
+
)
|
| 169 |
+
else:
|
| 170 |
+
self._import_warning = None
|
| 171 |
+
summary = await run_directory_import(
|
| 172 |
+
source_dir,
|
| 173 |
+
provider=DEFAULT_TOKEN_PROVIDER,
|
| 174 |
+
)
|
| 175 |
+
logger.info(
|
| 176 |
+
"🔄 自动导入完成: scanned={} imported={} duplicate={} invalid={}",
|
| 177 |
+
summary.scanned_files,
|
| 178 |
+
summary.imported_count,
|
| 179 |
+
summary.duplicate_count,
|
| 180 |
+
summary.invalid_json_count + summary.invalid_token_count,
|
| 181 |
+
)
|
| 182 |
+
except asyncio.CancelledError:
|
| 183 |
+
raise
|
| 184 |
+
except RuntimeError as exc:
|
| 185 |
+
logger.info(f"⏭️ 跳过本轮自动导入: {exc}")
|
| 186 |
+
except (FileNotFoundError, NotADirectoryError) as exc:
|
| 187 |
+
self._log_import_warning_once(str(exc))
|
| 188 |
+
except Exception as exc:
|
| 189 |
+
logger.exception(f"❌ 自动导入 Token 失败: {exc}")
|
| 190 |
+
|
| 191 |
+
await self._wait_or_stop(wait_seconds)
|
| 192 |
+
|
| 193 |
+
async def _auto_maintenance_loop(self) -> None:
|
| 194 |
+
while not self._stop_event.is_set():
|
| 195 |
+
wait_seconds = 15
|
| 196 |
+
try:
|
| 197 |
+
if settings.TOKEN_AUTO_MAINTENANCE_ENABLED:
|
| 198 |
+
wait_seconds = max(
|
| 199 |
+
int(settings.TOKEN_AUTO_MAINTENANCE_INTERVAL),
|
| 200 |
+
30,
|
| 201 |
+
)
|
| 202 |
+
if not self._has_enabled_maintenance_action():
|
| 203 |
+
self._log_maintenance_warning_once(
|
| 204 |
+
"已启用自动维护,但未选择任何维护动作"
|
| 205 |
+
)
|
| 206 |
+
else:
|
| 207 |
+
self._maintenance_warning = None
|
| 208 |
+
summary = await run_token_maintenance(
|
| 209 |
+
provider=DEFAULT_TOKEN_PROVIDER,
|
| 210 |
+
remove_duplicates=settings.TOKEN_AUTO_REMOVE_DUPLICATES,
|
| 211 |
+
run_health_check=settings.TOKEN_AUTO_HEALTH_CHECK,
|
| 212 |
+
delete_invalid_tokens=settings.TOKEN_AUTO_DELETE_INVALID,
|
| 213 |
+
)
|
| 214 |
+
logger.info(
|
| 215 |
+
"🧹 自动维护完成: dedupe={} checked={} valid={} guest={} invalid={} deleted={}",
|
| 216 |
+
summary.duplicate_removed_count,
|
| 217 |
+
summary.checked_count,
|
| 218 |
+
summary.valid_count,
|
| 219 |
+
summary.guest_count,
|
| 220 |
+
summary.invalid_count,
|
| 221 |
+
summary.deleted_invalid_count,
|
| 222 |
+
)
|
| 223 |
+
except asyncio.CancelledError:
|
| 224 |
+
raise
|
| 225 |
+
except RuntimeError as exc:
|
| 226 |
+
logger.info(f"⏭️ 跳过本轮自动维护: {exc}")
|
| 227 |
+
except Exception as exc:
|
| 228 |
+
logger.exception(f"❌ Token 自动维护失败: {exc}")
|
| 229 |
+
|
| 230 |
+
await self._wait_or_stop(wait_seconds)
|
| 231 |
+
|
| 232 |
+
async def _wait_or_stop(self, timeout: int) -> None:
|
| 233 |
+
try:
|
| 234 |
+
await asyncio.wait_for(self._stop_event.wait(), timeout=timeout)
|
| 235 |
+
except asyncio.TimeoutError:
|
| 236 |
+
return
|
| 237 |
+
|
| 238 |
+
def _has_enabled_maintenance_action(self) -> bool:
|
| 239 |
+
return any(
|
| 240 |
+
(
|
| 241 |
+
settings.TOKEN_AUTO_REMOVE_DUPLICATES,
|
| 242 |
+
settings.TOKEN_AUTO_HEALTH_CHECK,
|
| 243 |
+
settings.TOKEN_AUTO_DELETE_INVALID,
|
| 244 |
+
)
|
| 245 |
+
)
|
| 246 |
+
|
| 247 |
+
def _log_import_warning_once(self, message: str) -> None:
|
| 248 |
+
if self._import_warning == message:
|
| 249 |
+
return
|
| 250 |
+
self._import_warning = message
|
| 251 |
+
logger.warning(f"⚠️ {message}")
|
| 252 |
+
|
| 253 |
+
def _log_maintenance_warning_once(self, message: str) -> None:
|
| 254 |
+
if self._maintenance_warning == message:
|
| 255 |
+
return
|
| 256 |
+
self._maintenance_warning = message
|
| 257 |
+
logger.warning(f"⚠️ {message}")
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
_scheduler: Optional[TokenAutomationScheduler] = None
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def get_token_automation_scheduler() -> TokenAutomationScheduler:
|
| 264 |
+
global _scheduler
|
| 265 |
+
if _scheduler is None:
|
| 266 |
+
_scheduler = TokenAutomationScheduler()
|
| 267 |
+
return _scheduler
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
async def start_token_automation_scheduler() -> None:
|
| 271 |
+
await get_token_automation_scheduler().start()
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
async def stop_token_automation_scheduler() -> None:
|
| 275 |
+
global _scheduler
|
| 276 |
+
if _scheduler is None:
|
| 277 |
+
return
|
| 278 |
+
await _scheduler.stop()
|
app/services/token_dao.py
ADDED
|
@@ -0,0 +1,664 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Token 数据访问层 (DAO)
|
| 3 |
+
提供 Token 的 CRUD 操作和查询功能
|
| 4 |
+
"""
|
| 5 |
+
import os
|
| 6 |
+
import sqlite3
|
| 7 |
+
from contextlib import asynccontextmanager
|
| 8 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 9 |
+
|
| 10 |
+
import aiosqlite
|
| 11 |
+
|
| 12 |
+
from app.models.token_db import DB_PATH, SQL_CREATE_TABLES
|
| 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(
|
| 131 |
+
self,
|
| 132 |
+
provider: str,
|
| 133 |
+
enabled_only: bool = True,
|
| 134 |
+
limit: Optional[int] = None,
|
| 135 |
+
offset: int = 0,
|
| 136 |
+
) -> List[Dict]:
|
| 137 |
+
"""
|
| 138 |
+
获取指定提供商的所有 Token
|
| 139 |
+
|
| 140 |
+
Args:
|
| 141 |
+
provider: 提供商名称
|
| 142 |
+
enabled_only: 是否只返回启用的 Token
|
| 143 |
+
"""
|
| 144 |
+
try:
|
| 145 |
+
async with self.get_connection() as conn:
|
| 146 |
+
query = """
|
| 147 |
+
SELECT t.*, ts.total_requests, ts.successful_requests, ts.failed_requests,
|
| 148 |
+
ts.last_success_time, ts.last_failure_time
|
| 149 |
+
FROM tokens t
|
| 150 |
+
LEFT JOIN token_stats ts ON t.id = ts.token_id
|
| 151 |
+
WHERE t.provider = ?
|
| 152 |
+
"""
|
| 153 |
+
params = [provider]
|
| 154 |
+
|
| 155 |
+
if enabled_only:
|
| 156 |
+
query += " AND t.is_enabled = 1"
|
| 157 |
+
|
| 158 |
+
query += " ORDER BY t.priority DESC, t.id ASC"
|
| 159 |
+
|
| 160 |
+
if limit is not None:
|
| 161 |
+
query += " LIMIT ? OFFSET ?"
|
| 162 |
+
params.extend([limit, max(0, offset)])
|
| 163 |
+
|
| 164 |
+
cursor = await conn.execute(query, params)
|
| 165 |
+
rows = await cursor.fetchall()
|
| 166 |
+
|
| 167 |
+
return [dict(row) for row in rows]
|
| 168 |
+
except Exception as e:
|
| 169 |
+
logger.error(f"❌ 查询 Token 失败: {e}")
|
| 170 |
+
return []
|
| 171 |
+
|
| 172 |
+
async def get_all_tokens(self, enabled_only: bool = False) -> List[Dict]:
|
| 173 |
+
"""获取所有 Token"""
|
| 174 |
+
try:
|
| 175 |
+
async with self.get_connection() as conn:
|
| 176 |
+
query = """
|
| 177 |
+
SELECT t.*, ts.total_requests, ts.successful_requests, ts.failed_requests,
|
| 178 |
+
ts.last_success_time, ts.last_failure_time
|
| 179 |
+
FROM tokens t
|
| 180 |
+
LEFT JOIN token_stats ts ON t.id = ts.token_id
|
| 181 |
+
"""
|
| 182 |
+
|
| 183 |
+
if enabled_only:
|
| 184 |
+
query += " WHERE t.is_enabled = 1"
|
| 185 |
+
|
| 186 |
+
query += " ORDER BY t.provider, t.priority DESC, t.id ASC"
|
| 187 |
+
|
| 188 |
+
cursor = await conn.execute(query)
|
| 189 |
+
rows = await cursor.fetchall()
|
| 190 |
+
|
| 191 |
+
return [dict(row) for row in rows]
|
| 192 |
+
except Exception as e:
|
| 193 |
+
logger.error(f"❌ 查询所有 Token 失败: {e}")
|
| 194 |
+
return []
|
| 195 |
+
|
| 196 |
+
async def update_token_status(self, token_id: int, is_enabled: bool):
|
| 197 |
+
"""更新 Token 启用状态"""
|
| 198 |
+
try:
|
| 199 |
+
async with self.get_connection() as conn:
|
| 200 |
+
await conn.execute("""
|
| 201 |
+
UPDATE tokens SET is_enabled = ? WHERE id = ?
|
| 202 |
+
""", (is_enabled, token_id))
|
| 203 |
+
await conn.commit()
|
| 204 |
+
logger.info(f"✅ 更新 Token 状态: id={token_id}, enabled={is_enabled}")
|
| 205 |
+
except Exception as e:
|
| 206 |
+
logger.error(f"❌ 更新 Token 状态失败: {e}")
|
| 207 |
+
|
| 208 |
+
async def update_token_type(self, token_id: int, token_type: str):
|
| 209 |
+
"""更新 Token 类型"""
|
| 210 |
+
try:
|
| 211 |
+
async with self.get_connection() as conn:
|
| 212 |
+
await conn.execute("""
|
| 213 |
+
UPDATE tokens SET token_type = ? WHERE id = ?
|
| 214 |
+
""", (token_type, token_id))
|
| 215 |
+
await conn.commit()
|
| 216 |
+
logger.info(f"✅ 更新 Token 类型: id={token_id}, type={token_type}")
|
| 217 |
+
except Exception as e:
|
| 218 |
+
logger.error(f"❌ 更新 Token 类型失败: {e}")
|
| 219 |
+
|
| 220 |
+
async def delete_token(self, token_id: int):
|
| 221 |
+
"""删除 Token(级联删除统计数据)"""
|
| 222 |
+
try:
|
| 223 |
+
async with self.get_connection() as conn:
|
| 224 |
+
await conn.execute("DELETE FROM tokens WHERE id = ?", (token_id,))
|
| 225 |
+
await conn.commit()
|
| 226 |
+
logger.info(f"✅ 删除 Token: id={token_id}")
|
| 227 |
+
except Exception as e:
|
| 228 |
+
logger.error(f"❌ 删除 Token 失败: {e}")
|
| 229 |
+
|
| 230 |
+
async def delete_tokens_by_ids(self, token_ids: List[int]) -> int:
|
| 231 |
+
"""批量删除 Token(级联删除统计数据)"""
|
| 232 |
+
if not token_ids:
|
| 233 |
+
return 0
|
| 234 |
+
|
| 235 |
+
try:
|
| 236 |
+
placeholders = ",".join("?" for _ in token_ids)
|
| 237 |
+
async with self.get_connection() as conn:
|
| 238 |
+
await conn.execute(
|
| 239 |
+
f"DELETE FROM tokens WHERE id IN ({placeholders})",
|
| 240 |
+
token_ids,
|
| 241 |
+
)
|
| 242 |
+
cursor = await conn.execute("SELECT changes()")
|
| 243 |
+
row = await cursor.fetchone()
|
| 244 |
+
await conn.commit()
|
| 245 |
+
|
| 246 |
+
deleted_count = int(row[0] if row else 0)
|
| 247 |
+
logger.info(f"✅ 批量删除 Token: {deleted_count} 个")
|
| 248 |
+
return deleted_count
|
| 249 |
+
except Exception as e:
|
| 250 |
+
logger.error(f"❌ 批量删除 Token 失败: {e}")
|
| 251 |
+
return 0
|
| 252 |
+
|
| 253 |
+
async def delete_tokens_by_provider(self, provider: str):
|
| 254 |
+
"""删除指定提供商的所有 Token"""
|
| 255 |
+
try:
|
| 256 |
+
async with self.get_connection() as conn:
|
| 257 |
+
await conn.execute("DELETE FROM tokens WHERE provider = ?", (provider,))
|
| 258 |
+
await conn.commit()
|
| 259 |
+
logger.info(f"✅ 删除提供商所有 Token: {provider}")
|
| 260 |
+
except Exception as e:
|
| 261 |
+
logger.error(f"❌ 删除提供商 Token 失败: {e}")
|
| 262 |
+
|
| 263 |
+
# ==================== Token 统计操作 ====================
|
| 264 |
+
|
| 265 |
+
async def record_success(self, token_id: int):
|
| 266 |
+
"""记录 Token 使用成功"""
|
| 267 |
+
try:
|
| 268 |
+
async with self.get_connection() as conn:
|
| 269 |
+
await conn.execute("""
|
| 270 |
+
UPDATE token_stats
|
| 271 |
+
SET total_requests = total_requests + 1,
|
| 272 |
+
successful_requests = successful_requests + 1,
|
| 273 |
+
last_success_time = CURRENT_TIMESTAMP
|
| 274 |
+
WHERE token_id = ?
|
| 275 |
+
""", (token_id,))
|
| 276 |
+
await conn.commit()
|
| 277 |
+
except Exception as e:
|
| 278 |
+
logger.error(f"❌ 记录成功失败: {e}")
|
| 279 |
+
|
| 280 |
+
async def record_failure(self, token_id: int):
|
| 281 |
+
"""记录 Token 使用失败"""
|
| 282 |
+
try:
|
| 283 |
+
async with self.get_connection() as conn:
|
| 284 |
+
await conn.execute("""
|
| 285 |
+
UPDATE token_stats
|
| 286 |
+
SET total_requests = total_requests + 1,
|
| 287 |
+
failed_requests = failed_requests + 1,
|
| 288 |
+
last_failure_time = CURRENT_TIMESTAMP
|
| 289 |
+
WHERE token_id = ?
|
| 290 |
+
""", (token_id,))
|
| 291 |
+
await conn.commit()
|
| 292 |
+
except Exception as e:
|
| 293 |
+
logger.error(f"❌ 记录失败失败: {e}")
|
| 294 |
+
|
| 295 |
+
async def get_token_stats(self, token_id: int) -> Optional[Dict]:
|
| 296 |
+
"""获取 Token 统计信息"""
|
| 297 |
+
try:
|
| 298 |
+
async with self.get_connection() as conn:
|
| 299 |
+
cursor = await conn.execute("""
|
| 300 |
+
SELECT * FROM token_stats WHERE token_id = ?
|
| 301 |
+
""", (token_id,))
|
| 302 |
+
row = await cursor.fetchone()
|
| 303 |
+
return dict(row) if row else None
|
| 304 |
+
except Exception as e:
|
| 305 |
+
logger.error(f"❌ 获取统计信息失败: {e}")
|
| 306 |
+
return None
|
| 307 |
+
|
| 308 |
+
# ==================== 批量操作 ====================
|
| 309 |
+
|
| 310 |
+
async def bulk_add_tokens(
|
| 311 |
+
self,
|
| 312 |
+
provider: str,
|
| 313 |
+
tokens: List[str],
|
| 314 |
+
token_type: str = "user",
|
| 315 |
+
validate: bool = True
|
| 316 |
+
) -> Tuple[int, int]:
|
| 317 |
+
"""
|
| 318 |
+
批量添加 Token(可选验证)
|
| 319 |
+
|
| 320 |
+
Args:
|
| 321 |
+
provider: 提供商名称
|
| 322 |
+
tokens: Token 列表
|
| 323 |
+
token_type: Token 类型(如果 validate=True 将被覆盖)
|
| 324 |
+
validate: 是否验证 Token(仅针对 zai)
|
| 325 |
+
|
| 326 |
+
Returns:
|
| 327 |
+
(成功添加数量, 失败数量)
|
| 328 |
+
"""
|
| 329 |
+
added_count = 0
|
| 330 |
+
failed_count = 0
|
| 331 |
+
|
| 332 |
+
for token in tokens:
|
| 333 |
+
if token.strip(): # 过滤空 token
|
| 334 |
+
token_id = await self.add_token(
|
| 335 |
+
provider,
|
| 336 |
+
token.strip(),
|
| 337 |
+
token_type,
|
| 338 |
+
validate=validate
|
| 339 |
+
)
|
| 340 |
+
if token_id:
|
| 341 |
+
added_count += 1
|
| 342 |
+
else:
|
| 343 |
+
failed_count += 1
|
| 344 |
+
|
| 345 |
+
logger.info(f"✅ 批量添加完成: {provider} - 成功 {added_count}/{len(tokens)},失败 {failed_count}")
|
| 346 |
+
return added_count, failed_count
|
| 347 |
+
|
| 348 |
+
async def replace_tokens(self, provider: str, tokens: List[str],
|
| 349 |
+
token_type: str = "user"):
|
| 350 |
+
"""
|
| 351 |
+
替换指定提供商的所有 Token(先删除后添加)
|
| 352 |
+
"""
|
| 353 |
+
# 删除旧 Token
|
| 354 |
+
await self.delete_tokens_by_provider(provider)
|
| 355 |
+
|
| 356 |
+
# 添加新 Token
|
| 357 |
+
added_count = await self.bulk_add_tokens(provider, tokens, token_type)
|
| 358 |
+
|
| 359 |
+
logger.info(f"✅ 替换 Token 完成: {provider} - {added_count} 个")
|
| 360 |
+
return added_count
|
| 361 |
+
|
| 362 |
+
async def remove_duplicate_tokens(self, provider: Optional[str] = None) -> int:
|
| 363 |
+
"""
|
| 364 |
+
删除重复 Token,保留每个 provider/token 组合中排序靠前的一条记录。
|
| 365 |
+
|
| 366 |
+
正常情况下唯一约束会阻止重复数据,这里主要处理历史数据或手工导入异常。
|
| 367 |
+
"""
|
| 368 |
+
try:
|
| 369 |
+
tokens = (
|
| 370 |
+
await self.get_tokens_by_provider(provider, enabled_only=False)
|
| 371 |
+
if provider
|
| 372 |
+
else await self.get_all_tokens(enabled_only=False)
|
| 373 |
+
)
|
| 374 |
+
|
| 375 |
+
seen_keys: set[tuple[str, str]] = set()
|
| 376 |
+
duplicate_ids: list[int] = []
|
| 377 |
+
|
| 378 |
+
for token_record in tokens:
|
| 379 |
+
token_value = str(token_record.get("token") or "").strip()
|
| 380 |
+
token_provider = str(token_record.get("provider") or "")
|
| 381 |
+
key = (token_provider, token_value)
|
| 382 |
+
|
| 383 |
+
if key in seen_keys:
|
| 384 |
+
duplicate_ids.append(int(token_record["id"]))
|
| 385 |
+
continue
|
| 386 |
+
|
| 387 |
+
seen_keys.add(key)
|
| 388 |
+
|
| 389 |
+
deleted_count = await self.delete_tokens_by_ids(duplicate_ids)
|
| 390 |
+
if deleted_count > 0:
|
| 391 |
+
logger.info(f"✅ 已清理重复 Token: {deleted_count} 个")
|
| 392 |
+
return deleted_count
|
| 393 |
+
except Exception as e:
|
| 394 |
+
logger.error(f"❌ 清理重复 Token 失败: {e}")
|
| 395 |
+
return 0
|
| 396 |
+
|
| 397 |
+
# ==================== 实用方法 ====================
|
| 398 |
+
|
| 399 |
+
async def get_token_by_value(self, provider: str, token: str) -> Optional[Dict]:
|
| 400 |
+
"""根据 Token 值查询"""
|
| 401 |
+
try:
|
| 402 |
+
async with self.get_connection() as conn:
|
| 403 |
+
cursor = await conn.execute("""
|
| 404 |
+
SELECT t.*, ts.total_requests, ts.successful_requests, ts.failed_requests
|
| 405 |
+
FROM tokens t
|
| 406 |
+
LEFT JOIN token_stats ts ON t.id = ts.token_id
|
| 407 |
+
WHERE t.provider = ? AND t.token = ?
|
| 408 |
+
""", (provider, token))
|
| 409 |
+
row = await cursor.fetchone()
|
| 410 |
+
return dict(row) if row else None
|
| 411 |
+
except Exception as e:
|
| 412 |
+
logger.error(f"❌ 查询 Token 失败: {e}")
|
| 413 |
+
return None
|
| 414 |
+
|
| 415 |
+
async def get_provider_stats(self, provider: str) -> Dict:
|
| 416 |
+
"""获取提供商统计信息"""
|
| 417 |
+
try:
|
| 418 |
+
async with self.get_connection() as conn:
|
| 419 |
+
cursor = await conn.execute("""
|
| 420 |
+
SELECT
|
| 421 |
+
COUNT(*) as total_tokens,
|
| 422 |
+
SUM(CASE WHEN is_enabled = 1 THEN 1 ELSE 0 END) as enabled_tokens,
|
| 423 |
+
SUM(ts.total_requests) as total_requests,
|
| 424 |
+
SUM(ts.successful_requests) as successful_requests,
|
| 425 |
+
SUM(ts.failed_requests) as failed_requests
|
| 426 |
+
FROM tokens t
|
| 427 |
+
LEFT JOIN token_stats ts ON t.id = ts.token_id
|
| 428 |
+
WHERE t.provider = ?
|
| 429 |
+
""", (provider,))
|
| 430 |
+
row = await cursor.fetchone()
|
| 431 |
+
return dict(row) if row else {}
|
| 432 |
+
except Exception as e:
|
| 433 |
+
logger.error(f"❌ 获取提供商统计失败: {e}")
|
| 434 |
+
return {}
|
| 435 |
+
|
| 436 |
+
async def get_provider_token_counts(self, provider: str) -> Dict[str, int]:
|
| 437 |
+
"""聚合提供商的 Token 数量与类型分布。"""
|
| 438 |
+
try:
|
| 439 |
+
async with self.get_connection() as conn:
|
| 440 |
+
cursor = await conn.execute(
|
| 441 |
+
"""
|
| 442 |
+
SELECT
|
| 443 |
+
COUNT(*) as total_tokens,
|
| 444 |
+
SUM(CASE WHEN is_enabled = 1 THEN 1 ELSE 0 END) as enabled_tokens,
|
| 445 |
+
SUM(CASE WHEN token_type = 'user' THEN 1 ELSE 0 END) as user_tokens,
|
| 446 |
+
SUM(CASE WHEN token_type = 'guest' THEN 1 ELSE 0 END) as guest_tokens,
|
| 447 |
+
SUM(CASE WHEN token_type = 'unknown' THEN 1 ELSE 0 END) as unknown_tokens
|
| 448 |
+
FROM tokens
|
| 449 |
+
WHERE provider = ?
|
| 450 |
+
""",
|
| 451 |
+
(provider,),
|
| 452 |
+
)
|
| 453 |
+
row = await cursor.fetchone()
|
| 454 |
+
|
| 455 |
+
if not row:
|
| 456 |
+
return {
|
| 457 |
+
"total_tokens": 0,
|
| 458 |
+
"enabled_tokens": 0,
|
| 459 |
+
"user_tokens": 0,
|
| 460 |
+
"guest_tokens": 0,
|
| 461 |
+
"unknown_tokens": 0,
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
return {
|
| 465 |
+
"total_tokens": int(row["total_tokens"] or 0),
|
| 466 |
+
"enabled_tokens": int(row["enabled_tokens"] or 0),
|
| 467 |
+
"user_tokens": int(row["user_tokens"] or 0),
|
| 468 |
+
"guest_tokens": int(row["guest_tokens"] or 0),
|
| 469 |
+
"unknown_tokens": int(row["unknown_tokens"] or 0),
|
| 470 |
+
}
|
| 471 |
+
except Exception as e:
|
| 472 |
+
logger.error(f"❌ 获取 Token 数量统计失败: {e}")
|
| 473 |
+
return {
|
| 474 |
+
"total_tokens": 0,
|
| 475 |
+
"enabled_tokens": 0,
|
| 476 |
+
"user_tokens": 0,
|
| 477 |
+
"guest_tokens": 0,
|
| 478 |
+
"unknown_tokens": 0,
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
async def count_tokens_by_provider(
|
| 482 |
+
self,
|
| 483 |
+
provider: str,
|
| 484 |
+
enabled_only: bool = False,
|
| 485 |
+
) -> int:
|
| 486 |
+
"""统计提供商下的 Token 总数。"""
|
| 487 |
+
try:
|
| 488 |
+
async with self.get_connection() as conn:
|
| 489 |
+
query = "SELECT COUNT(*) AS total_count FROM tokens WHERE provider = ?"
|
| 490 |
+
params: List[object] = [provider]
|
| 491 |
+
if enabled_only:
|
| 492 |
+
query += " AND is_enabled = 1"
|
| 493 |
+
|
| 494 |
+
cursor = await conn.execute(query, params)
|
| 495 |
+
row = await cursor.fetchone()
|
| 496 |
+
|
| 497 |
+
return int(row["total_count"] or 0) if row else 0
|
| 498 |
+
except Exception as e:
|
| 499 |
+
logger.error(f"❌ 统计 Token 总数失败: {e}")
|
| 500 |
+
return 0
|
| 501 |
+
|
| 502 |
+
# ==================== Token 验证操作 ====================
|
| 503 |
+
|
| 504 |
+
async def validate_and_update_token(self, token_id: int) -> bool:
|
| 505 |
+
"""
|
| 506 |
+
验证单个 Token 并更新其类型
|
| 507 |
+
|
| 508 |
+
Args:
|
| 509 |
+
token_id: Token 数据库 ID
|
| 510 |
+
|
| 511 |
+
Returns:
|
| 512 |
+
是否为有效的认证用户 Token
|
| 513 |
+
"""
|
| 514 |
+
try:
|
| 515 |
+
# 获取 Token 信息
|
| 516 |
+
async with self.get_connection() as conn:
|
| 517 |
+
cursor = await conn.execute("""
|
| 518 |
+
SELECT provider, token FROM tokens WHERE id = ?
|
| 519 |
+
""", (token_id,))
|
| 520 |
+
row = await cursor.fetchone()
|
| 521 |
+
|
| 522 |
+
if not row:
|
| 523 |
+
logger.error(f"❌ Token ID {token_id} 不存在")
|
| 524 |
+
return False
|
| 525 |
+
|
| 526 |
+
provider = row["provider"]
|
| 527 |
+
token = row["token"]
|
| 528 |
+
|
| 529 |
+
if provider != "zai":
|
| 530 |
+
logger.info(f"⏭️ 跳过非 zai 提供商的 Token 验证: {provider}")
|
| 531 |
+
return True
|
| 532 |
+
|
| 533 |
+
# 验证 Token
|
| 534 |
+
from app.utils.token_pool import ZAITokenValidator
|
| 535 |
+
|
| 536 |
+
token_type, is_valid, error_msg = await ZAITokenValidator.validate_token(token)
|
| 537 |
+
|
| 538 |
+
# 更新 Token 类型
|
| 539 |
+
await self.update_token_type(token_id, token_type)
|
| 540 |
+
|
| 541 |
+
if not is_valid:
|
| 542 |
+
logger.warning(f"⚠️ Token 验证失败: id={token_id}, type={token_type}, error={error_msg}")
|
| 543 |
+
|
| 544 |
+
return is_valid
|
| 545 |
+
|
| 546 |
+
except Exception as e:
|
| 547 |
+
logger.error(f"❌ 验证 Token 失败: {e}")
|
| 548 |
+
return False
|
| 549 |
+
|
| 550 |
+
async def validate_tokens_detailed(self, provider: str = "zai") -> Dict[str, Any]:
|
| 551 |
+
"""
|
| 552 |
+
批量验证所有 Token,并返回详细结果。
|
| 553 |
+
|
| 554 |
+
Returns:
|
| 555 |
+
{
|
| 556 |
+
"checked": 数量,
|
| 557 |
+
"valid": 数量,
|
| 558 |
+
"guest": 数量,
|
| 559 |
+
"invalid": 数量,
|
| 560 |
+
"invalid_token_ids": [id, ...],
|
| 561 |
+
}
|
| 562 |
+
"""
|
| 563 |
+
try:
|
| 564 |
+
tokens = await self.get_tokens_by_provider(provider, enabled_only=False)
|
| 565 |
+
|
| 566 |
+
if not tokens:
|
| 567 |
+
logger.warning(f"⚠️ 没有需要验证的 {provider} Token")
|
| 568 |
+
return {
|
| 569 |
+
"checked": 0,
|
| 570 |
+
"valid": 0,
|
| 571 |
+
"guest": 0,
|
| 572 |
+
"invalid": 0,
|
| 573 |
+
"invalid_token_ids": [],
|
| 574 |
+
}
|
| 575 |
+
|
| 576 |
+
logger.info(f"🔍 开始批量验证 {len(tokens)} 个 {provider} Token...")
|
| 577 |
+
|
| 578 |
+
from app.utils.token_pool import ZAITokenValidator
|
| 579 |
+
|
| 580 |
+
stats: Dict[str, Any] = {
|
| 581 |
+
"checked": len(tokens),
|
| 582 |
+
"valid": 0,
|
| 583 |
+
"guest": 0,
|
| 584 |
+
"invalid": 0,
|
| 585 |
+
"invalid_token_ids": [],
|
| 586 |
+
}
|
| 587 |
+
|
| 588 |
+
for token_record in tokens:
|
| 589 |
+
token_id = int(token_record["id"])
|
| 590 |
+
token = str(token_record["token"])
|
| 591 |
+
|
| 592 |
+
token_type, is_valid, error_msg = await ZAITokenValidator.validate_token(
|
| 593 |
+
token
|
| 594 |
+
)
|
| 595 |
+
await self.update_token_type(token_id, token_type)
|
| 596 |
+
|
| 597 |
+
if token_type == "user" and is_valid:
|
| 598 |
+
stats["valid"] += 1
|
| 599 |
+
elif token_type == "guest":
|
| 600 |
+
stats["guest"] += 1
|
| 601 |
+
stats["invalid_token_ids"].append(token_id)
|
| 602 |
+
else:
|
| 603 |
+
stats["invalid"] += 1
|
| 604 |
+
stats["invalid_token_ids"].append(token_id)
|
| 605 |
+
if error_msg:
|
| 606 |
+
logger.warning(
|
| 607 |
+
"⚠️ Token 验证失败: id={}, type={}, error={}",
|
| 608 |
+
token_id,
|
| 609 |
+
token_type,
|
| 610 |
+
error_msg,
|
| 611 |
+
)
|
| 612 |
+
|
| 613 |
+
logger.info(
|
| 614 |
+
"✅ 批量验证完成: 有效 {}, 匿名 {}, 无效 {}",
|
| 615 |
+
stats["valid"],
|
| 616 |
+
stats["guest"],
|
| 617 |
+
stats["invalid"],
|
| 618 |
+
)
|
| 619 |
+
return stats
|
| 620 |
+
|
| 621 |
+
except Exception as e:
|
| 622 |
+
logger.error(f"❌ 批量验证失败: {e}")
|
| 623 |
+
return {
|
| 624 |
+
"checked": 0,
|
| 625 |
+
"valid": 0,
|
| 626 |
+
"guest": 0,
|
| 627 |
+
"invalid": 0,
|
| 628 |
+
"invalid_token_ids": [],
|
| 629 |
+
}
|
| 630 |
+
|
| 631 |
+
async def validate_all_tokens(self, provider: str = "zai") -> Dict[str, int]:
|
| 632 |
+
"""
|
| 633 |
+
批量验证所有 Token
|
| 634 |
+
|
| 635 |
+
Args:
|
| 636 |
+
provider: 提供商名称(默认 zai)
|
| 637 |
+
|
| 638 |
+
Returns:
|
| 639 |
+
统计结果 {"valid": 数量, "guest": 数量, "invalid": 数量}
|
| 640 |
+
"""
|
| 641 |
+
stats = await self.validate_tokens_detailed(provider)
|
| 642 |
+
return {
|
| 643 |
+
"valid": int(stats.get("valid", 0) or 0),
|
| 644 |
+
"guest": int(stats.get("guest", 0) or 0),
|
| 645 |
+
"invalid": int(stats.get("invalid", 0) or 0),
|
| 646 |
+
}
|
| 647 |
+
|
| 648 |
+
|
| 649 |
+
# 全局单例
|
| 650 |
+
_token_dao: Optional[TokenDAO] = None
|
| 651 |
+
|
| 652 |
+
|
| 653 |
+
def get_token_dao() -> TokenDAO:
|
| 654 |
+
"""获取全局 TokenDAO 实例"""
|
| 655 |
+
global _token_dao
|
| 656 |
+
if _token_dao is None:
|
| 657 |
+
_token_dao = TokenDAO()
|
| 658 |
+
return _token_dao
|
| 659 |
+
|
| 660 |
+
|
| 661 |
+
async def init_token_database():
|
| 662 |
+
"""初始化 Token 数据库"""
|
| 663 |
+
dao = get_token_dao()
|
| 664 |
+
await dao.init_database()
|
app/services/token_importer.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""本地目录 token 导入服务。"""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Optional
|
| 9 |
+
|
| 10 |
+
from app.services.token_dao import TokenDAO, get_token_dao
|
| 11 |
+
from app.utils.logger import logger
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass(frozen=True)
|
| 15 |
+
class TokenImportSummary:
|
| 16 |
+
source_dir: str
|
| 17 |
+
scanned_files: int
|
| 18 |
+
imported_count: int
|
| 19 |
+
duplicate_count: int
|
| 20 |
+
invalid_json_count: int
|
| 21 |
+
missing_token_count: int
|
| 22 |
+
invalid_token_count: int
|
| 23 |
+
|
| 24 |
+
@property
|
| 25 |
+
def failed_count(self) -> int:
|
| 26 |
+
return (
|
| 27 |
+
self.duplicate_count
|
| 28 |
+
+ self.invalid_json_count
|
| 29 |
+
+ self.missing_token_count
|
| 30 |
+
+ self.invalid_token_count
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _load_token_payload(file_path: Path) -> dict:
|
| 35 |
+
try:
|
| 36 |
+
return json.loads(file_path.read_text(encoding="utf-8"))
|
| 37 |
+
except json.JSONDecodeError as exc:
|
| 38 |
+
raise ValueError(f"JSON 解析失败: {exc}") from exc
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
async def import_tokens_from_directory(
|
| 42 |
+
source_dir: str | Path,
|
| 43 |
+
*,
|
| 44 |
+
provider: str = "zai",
|
| 45 |
+
validate: bool = True,
|
| 46 |
+
dao: Optional[TokenDAO] = None,
|
| 47 |
+
) -> TokenImportSummary:
|
| 48 |
+
"""
|
| 49 |
+
从本地目录导入 token。
|
| 50 |
+
|
| 51 |
+
目录中的每个 JSON 文件应至少包含 `token` 字段。
|
| 52 |
+
"""
|
| 53 |
+
source_path = Path(source_dir).expanduser().resolve()
|
| 54 |
+
if not source_path.exists():
|
| 55 |
+
raise FileNotFoundError(f"导入目录不存在: {source_path}")
|
| 56 |
+
if not source_path.is_dir():
|
| 57 |
+
raise NotADirectoryError(f"导入路径不是目录: {source_path}")
|
| 58 |
+
|
| 59 |
+
token_dao = dao or get_token_dao()
|
| 60 |
+
token_files = sorted(source_path.rglob("*.json"))
|
| 61 |
+
seen_tokens: set[str] = set()
|
| 62 |
+
imported_count = 0
|
| 63 |
+
duplicate_count = 0
|
| 64 |
+
invalid_json_count = 0
|
| 65 |
+
missing_token_count = 0
|
| 66 |
+
invalid_token_count = 0
|
| 67 |
+
|
| 68 |
+
for file_path in token_files:
|
| 69 |
+
try:
|
| 70 |
+
payload = _load_token_payload(file_path)
|
| 71 |
+
except ValueError as exc:
|
| 72 |
+
invalid_json_count += 1
|
| 73 |
+
logger.warning(f"⚠️ 跳过无效 JSON 文件: {file_path} - {exc}")
|
| 74 |
+
continue
|
| 75 |
+
|
| 76 |
+
if not isinstance(payload, dict):
|
| 77 |
+
invalid_json_count += 1
|
| 78 |
+
logger.warning(f"⚠️ 跳过非对象 JSON 文件: {file_path}")
|
| 79 |
+
continue
|
| 80 |
+
|
| 81 |
+
token = str(payload.get("token") or "").strip()
|
| 82 |
+
email = str(payload.get("email") or "").strip()
|
| 83 |
+
if not token:
|
| 84 |
+
missing_token_count += 1
|
| 85 |
+
logger.warning(f"⚠️ 文件缺少 token 字段: {file_path}")
|
| 86 |
+
continue
|
| 87 |
+
|
| 88 |
+
if token in seen_tokens:
|
| 89 |
+
duplicate_count += 1
|
| 90 |
+
logger.info(f"↩️ 跳过本批次重复 Token: {file_path.name}")
|
| 91 |
+
continue
|
| 92 |
+
seen_tokens.add(token)
|
| 93 |
+
|
| 94 |
+
existing = await token_dao.get_token_by_value(provider, token)
|
| 95 |
+
if existing is not None:
|
| 96 |
+
duplicate_count += 1
|
| 97 |
+
logger.info(
|
| 98 |
+
"↩️ Token 已存在,跳过导入: {} ({})",
|
| 99 |
+
file_path.name,
|
| 100 |
+
email or "unknown",
|
| 101 |
+
)
|
| 102 |
+
continue
|
| 103 |
+
|
| 104 |
+
token_id = await token_dao.add_token(
|
| 105 |
+
provider=provider,
|
| 106 |
+
token=token,
|
| 107 |
+
token_type="user",
|
| 108 |
+
validate=validate,
|
| 109 |
+
)
|
| 110 |
+
if token_id is None:
|
| 111 |
+
invalid_token_count += 1
|
| 112 |
+
logger.warning(f"⚠️ Token 导入失败: {file_path.name} ({email or 'unknown'})")
|
| 113 |
+
continue
|
| 114 |
+
|
| 115 |
+
imported_count += 1
|
| 116 |
+
logger.info(f"✅ 已导入 Token: {file_path.name} ({email or 'unknown'})")
|
| 117 |
+
|
| 118 |
+
summary = TokenImportSummary(
|
| 119 |
+
source_dir=str(source_path),
|
| 120 |
+
scanned_files=len(token_files),
|
| 121 |
+
imported_count=imported_count,
|
| 122 |
+
duplicate_count=duplicate_count,
|
| 123 |
+
invalid_json_count=invalid_json_count,
|
| 124 |
+
missing_token_count=missing_token_count,
|
| 125 |
+
invalid_token_count=invalid_token_count,
|
| 126 |
+
)
|
| 127 |
+
logger.info(
|
| 128 |
+
"✅ Token 目录导入完成: "
|
| 129 |
+
"scanned={}, imported={}, duplicate={}, invalid_json={}, "
|
| 130 |
+
"missing_token={}, invalid_token={}",
|
| 131 |
+
summary.scanned_files,
|
| 132 |
+
summary.imported_count,
|
| 133 |
+
summary.duplicate_count,
|
| 134 |
+
summary.invalid_json_count,
|
| 135 |
+
summary.missing_token_count,
|
| 136 |
+
summary.invalid_token_count,
|
| 137 |
+
)
|
| 138 |
+
return summary
|
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 %} - API 控制台</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/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
| 13 |
+
|
| 14 |
+
<!-- htmx (CDN) -->
|
| 15 |
+
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
| 16 |
+
|
| 17 |
+
<!-- Chart.js (CDN) -->
|
| 18 |
+
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1"></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/logs"
|
| 156 |
+
class="{% if '/logs' 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/recent_logs.html
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!-- 最近请求日志列表 -->
|
| 2 |
+
<script>
|
| 3 |
+
window.dashboardRecentLogsPage = {{ page.current_page }};
|
| 4 |
+
window.dashboardRecentLogsPageSize = {{ page.page_size }};
|
| 5 |
+
</script>
|
| 6 |
+
|
| 7 |
+
<div class="space-y-4">
|
| 8 |
+
<div class="max-h-[30rem] overflow-auto rounded-2xl border border-slate-200">
|
| 9 |
+
{% if logs %}
|
| 10 |
+
<table class="min-w-[1240px] divide-y divide-slate-200">
|
| 11 |
+
<thead class="bg-slate-50/95 backdrop-blur">
|
| 12 |
+
<tr>
|
| 13 |
+
<th class="sticky top-0 bg-slate-50/95 px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-slate-500">时间</th>
|
| 14 |
+
<th class="sticky top-0 bg-slate-50/95 px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-slate-500">请求</th>
|
| 15 |
+
<th class="sticky top-0 bg-slate-50/95 px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-slate-500">标记</th>
|
| 16 |
+
<th class="sticky top-0 bg-slate-50/95 px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-slate-500">输入 / 输出</th>
|
| 17 |
+
<th class="sticky top-0 bg-slate-50/95 px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-slate-500">缓存创建 / 命中</th>
|
| 18 |
+
<th class="sticky top-0 bg-slate-50/95 px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-slate-500">用时 / 首字</th>
|
| 19 |
+
<th class="sticky top-0 bg-slate-50/95 px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-slate-500">状态</th>
|
| 20 |
+
</tr>
|
| 21 |
+
</thead>
|
| 22 |
+
<tbody class="divide-y divide-slate-100 bg-white">
|
| 23 |
+
{% for log in logs %}
|
| 24 |
+
<tr class="transition-colors hover:bg-slate-50/70">
|
| 25 |
+
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium text-slate-900">
|
| 26 |
+
{{ log.timestamp }}
|
| 27 |
+
</td>
|
| 28 |
+
<td class="px-4 py-3 text-sm text-slate-600">
|
| 29 |
+
<div class="flex max-w-[28rem] items-center gap-2 overflow-hidden whitespace-nowrap">
|
| 30 |
+
<span class="inline-flex items-center rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-700">
|
| 31 |
+
{{ log.model }}
|
| 32 |
+
</span>
|
| 33 |
+
<span class="truncate font-mono text-xs text-slate-500">
|
| 34 |
+
{{ log.endpoint }}
|
| 35 |
+
</span>
|
| 36 |
+
</div>
|
| 37 |
+
{% if log.error_message %}
|
| 38 |
+
<p class="mt-1 max-w-[28rem] truncate text-xs text-red-500">
|
| 39 |
+
{{ log.error_message }}
|
| 40 |
+
</p>
|
| 41 |
+
{% endif %}
|
| 42 |
+
</td>
|
| 43 |
+
<td class="px-4 py-3 text-sm text-slate-600">
|
| 44 |
+
<div class="flex max-w-[20rem] items-center gap-2 overflow-hidden whitespace-nowrap">
|
| 45 |
+
<span class="inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-700">
|
| 46 |
+
{{ log.client_name }}
|
| 47 |
+
</span>
|
| 48 |
+
<span class="inline-flex items-center rounded-full bg-sky-50 px-2 py-0.5 text-xs font-medium text-sky-700">
|
| 49 |
+
{{ log.protocol_display }}
|
| 50 |
+
</span>
|
| 51 |
+
{% if log.source_display %}
|
| 52 |
+
<span class="inline-flex items-center rounded-full bg-violet-50 px-2 py-0.5 text-xs font-medium text-violet-700">
|
| 53 |
+
{{ log.source_display }}
|
| 54 |
+
</span>
|
| 55 |
+
{% endif %}
|
| 56 |
+
{% if log.provider_display %}
|
| 57 |
+
<span class="inline-flex items-center rounded-full bg-indigo-50 px-2 py-0.5 text-xs font-medium text-indigo-700">
|
| 58 |
+
{{ log.provider_display }}
|
| 59 |
+
</span>
|
| 60 |
+
{% endif %}
|
| 61 |
+
</div>
|
| 62 |
+
</td>
|
| 63 |
+
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium tabular-nums text-slate-700">
|
| 64 |
+
<span class="text-violet-700">输入 {{ log.input_tokens }}</span>
|
| 65 |
+
<span class="mx-2 text-slate-300">/</span>
|
| 66 |
+
<span class="text-rose-700">输出 {{ log.output_tokens }}</span>
|
| 67 |
+
</td>
|
| 68 |
+
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium tabular-nums text-slate-700">
|
| 69 |
+
<span class="text-emerald-700">创建 {{ log.cache_creation_tokens }}</span>
|
| 70 |
+
<span class="mx-2 text-slate-300">/</span>
|
| 71 |
+
<span class="text-amber-700">命中 {{ log.cache_read_tokens }}</span>
|
| 72 |
+
</td>
|
| 73 |
+
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium tabular-nums text-slate-700">
|
| 74 |
+
<span>用时 {{ log.duration_display }}</span>
|
| 75 |
+
<span class="mx-2 text-slate-300">/</span>
|
| 76 |
+
<span class="text-sky-700">首字 {{ log.first_token_display }}</span>
|
| 77 |
+
</td>
|
| 78 |
+
<td class="px-4 py-3 whitespace-nowrap text-sm">
|
| 79 |
+
<span class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-semibold {% if log.success %}bg-emerald-100 text-emerald-800{% else %}bg-red-100 text-red-800{% endif %}">
|
| 80 |
+
{{ "成功" if log.success else "失败" }}
|
| 81 |
+
</span>
|
| 82 |
+
<span class="ml-2 text-xs font-medium {% if log.success %}text-emerald-700{% else %}text-red-700{% endif %}">
|
| 83 |
+
HTTP {{ log.status_code }}
|
| 84 |
+
</span>
|
| 85 |
+
</td>
|
| 86 |
+
</tr>
|
| 87 |
+
{% endfor %}
|
| 88 |
+
</tbody>
|
| 89 |
+
</table>
|
| 90 |
+
{% else %}
|
| 91 |
+
<div class="py-8 text-center text-gray-500">
|
| 92 |
+
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 93 |
+
<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" />
|
| 94 |
+
</svg>
|
| 95 |
+
<p class="mt-2">暂无请求日志</p>
|
| 96 |
+
</div>
|
| 97 |
+
{% endif %}
|
| 98 |
+
</div>
|
| 99 |
+
|
| 100 |
+
<div class="flex flex-col gap-3 border-t border-gray-100 pt-4 text-sm text-gray-600 sm:flex-row sm:items-center sm:justify-between">
|
| 101 |
+
<div>
|
| 102 |
+
{% if page.total_items > 0 %}
|
| 103 |
+
显示第 {{ page.start_item }} - {{ page.end_item }} 条,共 {{ page.total_items }} 条
|
| 104 |
+
{% else %}
|
| 105 |
+
暂无日志数据
|
| 106 |
+
{% endif %}
|
| 107 |
+
</div>
|
| 108 |
+
<div class="flex items-center gap-2">
|
| 109 |
+
<button
|
| 110 |
+
type="button"
|
| 111 |
+
onclick="loadRecentLogsPage({{ page.previous_page }})"
|
| 112 |
+
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium transition-colors {% if page.has_previous %}bg-white text-gray-700 hover:bg-gray-50{% else %}cursor-not-allowed bg-gray-100 text-gray-400{% endif %}"
|
| 113 |
+
{% if not page.has_previous %}disabled{% endif %}>
|
| 114 |
+
上一页
|
| 115 |
+
</button>
|
| 116 |
+
<span class="min-w-[88px] text-center text-xs text-gray-500">
|
| 117 |
+
第 {{ page.current_page }} / {{ page.total_pages }} 页
|
| 118 |
+
</span>
|
| 119 |
+
<button
|
| 120 |
+
type="button"
|
| 121 |
+
onclick="loadRecentLogsPage({{ page.next_page }})"
|
| 122 |
+
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium transition-colors {% if page.has_next %}bg-white text-gray-700 hover:bg-gray-50{% else %}cursor-not-allowed bg-gray-100 text-gray-400{% endif %}"
|
| 123 |
+
{% if not page.has_next %}disabled{% endif %}>
|
| 124 |
+
下一页
|
| 125 |
+
</button>
|
| 126 |
+
</div>
|
| 127 |
+
</div>
|
| 128 |
+
</div>
|
app/templates/components/token_list.html
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!-- Token 列表表格 -->
|
| 2 |
+
<script>
|
| 3 |
+
window.tokenListPage = {{ page.current_page }};
|
| 4 |
+
window.tokenListPageSize = {{ page.page_size }};
|
| 5 |
+
</script>
|
| 6 |
+
|
| 7 |
+
<div class="space-y-4">
|
| 8 |
+
<div class="max-h-[42rem] overflow-auto rounded-lg border border-gray-100">
|
| 9 |
+
{% if tokens %}
|
| 10 |
+
<table class="min-w-full divide-y divide-gray-200">
|
| 11 |
+
<thead class="bg-gray-50">
|
| 12 |
+
<tr>
|
| 13 |
+
<th class="sticky top-0 bg-gray-50 px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">ID</th>
|
| 14 |
+
<th class="sticky top-0 bg-gray-50 px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">Token</th>
|
| 15 |
+
<th class="sticky top-0 bg-gray-50 px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">类型</th>
|
| 16 |
+
<th class="sticky top-0 bg-gray-50 px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">健康度</th>
|
| 17 |
+
<th class="sticky top-0 bg-gray-50 px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">状态</th>
|
| 18 |
+
<th class="sticky top-0 bg-gray-50 px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">使用统计</th>
|
| 19 |
+
<th class="sticky top-0 bg-gray-50 px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">创建时间</th>
|
| 20 |
+
<th class="sticky top-0 bg-gray-50 px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">操作</th>
|
| 21 |
+
</tr>
|
| 22 |
+
</thead>
|
| 23 |
+
<tbody class="bg-white divide-y divide-gray-200">
|
| 24 |
+
{% for token in tokens %}
|
| 25 |
+
{% include "components/token_row.html" %}
|
| 26 |
+
{% endfor %}
|
| 27 |
+
</tbody>
|
| 28 |
+
</table>
|
| 29 |
+
{% else %}
|
| 30 |
+
<div class="text-center py-12 text-gray-500">
|
| 31 |
+
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 32 |
+
<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" />
|
| 33 |
+
</svg>
|
| 34 |
+
<p class="mt-2 font-medium">暂无 Token</p>
|
| 35 |
+
<p class="mt-1 text-sm">点击右上角"添加 Token"按钮开始添加</p>
|
| 36 |
+
</div>
|
| 37 |
+
{% endif %}
|
| 38 |
+
</div>
|
| 39 |
+
|
| 40 |
+
<div class="flex flex-col gap-3 border-t border-gray-100 pt-4 text-sm text-gray-600 sm:flex-row sm:items-center sm:justify-between">
|
| 41 |
+
<div>
|
| 42 |
+
{% if page.total_items > 0 %}
|
| 43 |
+
显示第 {{ page.start_item }} - {{ page.end_item }} 条,共 {{ page.total_items }} 个 Token
|
| 44 |
+
{% else %}
|
| 45 |
+
暂无 Token 数据
|
| 46 |
+
{% endif %}
|
| 47 |
+
</div>
|
| 48 |
+
<div class="flex items-center gap-2">
|
| 49 |
+
<button
|
| 50 |
+
type="button"
|
| 51 |
+
onclick="loadTokenListPage({{ page.previous_page }})"
|
| 52 |
+
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium transition-colors {% if page.has_previous %}bg-white text-gray-700 hover:bg-gray-50{% else %}cursor-not-allowed bg-gray-100 text-gray-400{% endif %}"
|
| 53 |
+
{% if not page.has_previous %}disabled{% endif %}>
|
| 54 |
+
上一页
|
| 55 |
+
</button>
|
| 56 |
+
<span class="min-w-[88px] text-center text-xs text-gray-500">
|
| 57 |
+
第 {{ page.current_page }} / {{ page.total_pages }} 页
|
| 58 |
+
</span>
|
| 59 |
+
<button
|
| 60 |
+
type="button"
|
| 61 |
+
onclick="loadTokenListPage({{ page.next_page }})"
|
| 62 |
+
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium transition-colors {% if page.has_next %}bg-white text-gray-700 hover:bg-gray-50{% else %}cursor-not-allowed bg-gray-100 text-gray-400{% endif %}"
|
| 63 |
+
{% if not page.has_next %}disabled{% endif %}>
|
| 64 |
+
下一页
|
| 65 |
+
</button>
|
| 66 |
+
</div>
|
| 67 |
+
</div>
|
| 68 |
+
</div>
|
| 69 |
+
|
| 70 |
+
<!-- Token 数量统计(更新页面标题) -->
|
| 71 |
+
<script>
|
| 72 |
+
(function() {
|
| 73 |
+
const tokenCount = {{ page.total_items }};
|
| 74 |
+
const countElement = document.getElementById('token-count');
|
| 75 |
+
if (countElement) {
|
| 76 |
+
countElement.textContent = `(共 ${tokenCount} 个)`;
|
| 77 |
+
}
|
| 78 |
+
})();
|
| 79 |
+
</script>
|
| 80 |
+
|
| 81 |
+
<!-- 复制到剪贴板函数 -->
|
| 82 |
+
<script>
|
| 83 |
+
function copyToClipboard(text) {
|
| 84 |
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
| 85 |
+
navigator.clipboard.writeText(text).then(() => {
|
| 86 |
+
const notification = document.createElement('div');
|
| 87 |
+
notification.className = 'fixed bottom-4 right-4 bg-green-500 text-white px-4 py-2 rounded shadow-lg z-50';
|
| 88 |
+
notification.textContent = '✓ Token 已复制到剪贴板';
|
| 89 |
+
document.body.appendChild(notification);
|
| 90 |
+
|
| 91 |
+
setTimeout(() => {
|
| 92 |
+
notification.remove();
|
| 93 |
+
}, 2000);
|
| 94 |
+
}).catch(err => {
|
| 95 |
+
console.error('复制失败:', err);
|
| 96 |
+
alert('复制失败,请手动复制');
|
| 97 |
+
});
|
| 98 |
+
} else {
|
| 99 |
+
const textArea = document.createElement('textarea');
|
| 100 |
+
textArea.value = text;
|
| 101 |
+
textArea.style.position = 'fixed';
|
| 102 |
+
textArea.style.left = '-999999px';
|
| 103 |
+
document.body.appendChild(textArea);
|
| 104 |
+
textArea.select();
|
| 105 |
+
try {
|
| 106 |
+
document.execCommand('copy');
|
| 107 |
+
alert('Token 已复制到剪贴板');
|
| 108 |
+
} catch (err) {
|
| 109 |
+
alert('复制失败,请手动复制');
|
| 110 |
+
}
|
| 111 |
+
document.body.removeChild(textArea);
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
</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,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-on::after-request="if (event.detail.successful) { htmx.trigger(document.body, 'tokenListRefresh'); htmx.trigger(document.body, 'statsRefresh'); }"
|
| 145 |
+
hx-confirm="确定要删除这个 Token 吗?"
|
| 146 |
+
class="text-red-600 hover:text-red-900 transition-colors"
|
| 147 |
+
title="删除 Token">
|
| 148 |
+
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 149 |
+
<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" />
|
| 150 |
+
</svg>
|
| 151 |
+
</button>
|
| 152 |
+
</div>
|
| 153 |
+
</td>
|
| 154 |
+
</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,344 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}配置管理{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block extra_head %}
|
| 6 |
+
<style>
|
| 7 |
+
[x-cloak] {
|
| 8 |
+
display: none !important;
|
| 9 |
+
}
|
| 10 |
+
.config-grid {
|
| 11 |
+
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
| 12 |
+
}
|
| 13 |
+
</style>
|
| 14 |
+
{% endblock %}
|
| 15 |
+
|
| 16 |
+
{% macro section_link(section) -%}
|
| 17 |
+
<button
|
| 18 |
+
type="button"
|
| 19 |
+
@click.prevent="document.getElementById('{{ section.id }}')?.scrollIntoView({ behavior: 'smooth', block: 'start' })"
|
| 20 |
+
class="inline-flex items-center rounded-full border border-slate-200 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 transition hover:border-indigo-300 hover:text-indigo-700">
|
| 21 |
+
{{ section.title }}
|
| 22 |
+
</button>
|
| 23 |
+
{%- endmacro %}
|
| 24 |
+
|
| 25 |
+
{% macro render_field(field) -%}
|
| 26 |
+
<div class="{{ 'md:col-span-2' if field.wide else '' }} rounded-2xl border border-slate-200 bg-slate-50/70 p-4 shadow-sm">
|
| 27 |
+
{% if field.value_type == 'bool' %}
|
| 28 |
+
<label class="flex min-h-[44px] cursor-pointer items-start gap-3">
|
| 29 |
+
<input
|
| 30 |
+
type="checkbox"
|
| 31 |
+
name="{{ field.key }}"
|
| 32 |
+
class="mt-1 h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
|
| 33 |
+
{{ 'checked' if field.value else '' }}>
|
| 34 |
+
<span class="flex-1">
|
| 35 |
+
<span class="block text-sm font-semibold text-slate-900">{{ field.label }}</span>
|
| 36 |
+
<span class="mt-1 block text-sm leading-6 text-slate-600">{{ field.description }}</span>
|
| 37 |
+
</span>
|
| 38 |
+
</label>
|
| 39 |
+
{% else %}
|
| 40 |
+
<div {% if field.sensitive %}x-data="{ reveal: false }"{% endif %}>
|
| 41 |
+
<div class="flex items-start justify-between gap-3">
|
| 42 |
+
<label for="{{ field.key }}" class="block text-sm font-semibold text-slate-900">
|
| 43 |
+
{{ field.label }}
|
| 44 |
+
</label>
|
| 45 |
+
{% if field.sensitive %}
|
| 46 |
+
<button
|
| 47 |
+
type="button"
|
| 48 |
+
@click="reveal = !reveal"
|
| 49 |
+
class="inline-flex items-center rounded-full border border-slate-200 bg-white px-2.5 py-1 text-xs font-medium text-slate-600 transition hover:border-indigo-300 hover:text-indigo-700">
|
| 50 |
+
<span x-text="reveal ? '隐藏' : '显示'"></span>
|
| 51 |
+
</button>
|
| 52 |
+
{% endif %}
|
| 53 |
+
</div>
|
| 54 |
+
<p class="mt-1 text-sm leading-6 text-slate-600">{{ field.description }}</p>
|
| 55 |
+
<input
|
| 56 |
+
id="{{ field.key }}"
|
| 57 |
+
name="{{ field.key }}"
|
| 58 |
+
{% if field.sensitive %}
|
| 59 |
+
:type="reveal ? 'text' : '{{ field.input_type }}'"
|
| 60 |
+
{% else %}
|
| 61 |
+
type="{{ field.input_type }}"
|
| 62 |
+
{% endif %}
|
| 63 |
+
value="{{ field.value }}"
|
| 64 |
+
placeholder="{{ field.placeholder }}"
|
| 65 |
+
{% if field.required %}required{% endif %}
|
| 66 |
+
{% if field.min_value is not none %}min="{{ field.min_value }}"{% endif %}
|
| 67 |
+
{% if field.max_value is not none %}max="{{ field.max_value }}"{% endif %}
|
| 68 |
+
class="mt-3 block w-full rounded-xl border-slate-300 bg-white px-3 py-2.5 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
| 69 |
+
</div>
|
| 70 |
+
{% endif %}
|
| 71 |
+
|
| 72 |
+
<div class="mt-3 flex flex-wrap gap-2 text-xs">
|
| 73 |
+
<span class="inline-flex items-center rounded-full px-2.5 py-1 font-medium ring-1 ring-inset {{ field.source_badge_class }}">
|
| 74 |
+
{{ field.source_label }}
|
| 75 |
+
</span>
|
| 76 |
+
{% if field.restart_required %}
|
| 77 |
+
<span class="inline-flex items-center rounded-full bg-amber-50 px-2.5 py-1 font-medium text-amber-700 ring-1 ring-inset ring-amber-200">
|
| 78 |
+
需重启
|
| 79 |
+
</span>
|
| 80 |
+
{% endif %}
|
| 81 |
+
{% if field.sensitive %}
|
| 82 |
+
<span class="inline-flex items-center rounded-full bg-rose-50 px-2.5 py-1 font-medium text-rose-700 ring-1 ring-inset ring-rose-200">
|
| 83 |
+
敏感字段
|
| 84 |
+
</span>
|
| 85 |
+
{% endif %}
|
| 86 |
+
</div>
|
| 87 |
+
</div>
|
| 88 |
+
{%- endmacro %}
|
| 89 |
+
|
| 90 |
+
{% block content %}
|
| 91 |
+
<div x-data="configPage()" class="space-y-6">
|
| 92 |
+
<div class="overflow-hidden rounded-3xl bg-slate-900 shadow-2xl">
|
| 93 |
+
<div class="bg-[radial-gradient(circle_at_top_right,_rgba(99,102,241,0.35),_transparent_35%),linear-gradient(135deg,_#0f172a,_#1e293b_55%,_#172554)] px-6 py-8 sm:px-8">
|
| 94 |
+
<div class="flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
|
| 95 |
+
<div class="max-w-3xl">
|
| 96 |
+
<p class="text-sm font-semibold uppercase tracking-[0.2em] text-indigo-200">Admin Config Center</p>
|
| 97 |
+
<h2 class="mt-3 text-3xl font-bold text-white sm:text-4xl">集中管理运行参数,并支持直接编辑 `.env` 源文件</h2>
|
| 98 |
+
<p class="mt-3 max-w-2xl text-sm leading-6 text-slate-300 sm:text-base">
|
| 99 |
+
结构化表单适合日常操作,源文件模式适合批量调整、复制完整配置或保留注释。两种模式都会在保存后立即热重载。
|
| 100 |
+
</p>
|
| 101 |
+
</div>
|
| 102 |
+
|
| 103 |
+
<div class="flex flex-wrap items-center gap-3">
|
| 104 |
+
<button
|
| 105 |
+
type="button"
|
| 106 |
+
@click="setView('form')"
|
| 107 |
+
:class="activeView === 'form'
|
| 108 |
+
? 'bg-white text-slate-900 shadow-lg'
|
| 109 |
+
: 'bg-white/10 text-white hover:bg-white/20'"
|
| 110 |
+
class="inline-flex min-h-[44px] items-center rounded-2xl px-4 py-2 text-sm font-semibold transition">
|
| 111 |
+
结构化表单
|
| 112 |
+
</button>
|
| 113 |
+
<button
|
| 114 |
+
type="button"
|
| 115 |
+
@click="setView('source')"
|
| 116 |
+
:class="activeView === 'source'
|
| 117 |
+
? 'bg-white text-slate-900 shadow-lg'
|
| 118 |
+
: 'bg-white/10 text-white hover:bg-white/20'"
|
| 119 |
+
class="inline-flex min-h-[44px] items-center rounded-2xl px-4 py-2 text-sm font-semibold transition">
|
| 120 |
+
源文件编辑
|
| 121 |
+
</button>
|
| 122 |
+
<button
|
| 123 |
+
type="button"
|
| 124 |
+
hx-post="/admin/api/config/reset"
|
| 125 |
+
hx-target="#config-feedback"
|
| 126 |
+
hx-swap="innerHTML"
|
| 127 |
+
hx-confirm="确定要用 .env.example 覆盖当前 .env 吗?"
|
| 128 |
+
class="inline-flex min-h-[44px] items-center rounded-2xl border border-white/15 bg-white/5 px-4 py-2 text-sm font-semibold text-white transition hover:bg-white/15">
|
| 129 |
+
重置为示例配置
|
| 130 |
+
</button>
|
| 131 |
+
</div>
|
| 132 |
+
</div>
|
| 133 |
+
|
| 134 |
+
<div class="config-grid mt-8 grid gap-3">
|
| 135 |
+
<div class="rounded-2xl border border-white/10 bg-white/10 p-4 backdrop-blur">
|
| 136 |
+
<p class="text-xs uppercase tracking-[0.2em] text-indigo-200">受管字段</p>
|
| 137 |
+
<p class="mt-2 text-3xl font-bold text-white">{{ overview.total_fields }}</p>
|
| 138 |
+
<p class="mt-1 text-sm text-slate-300">{{ overview.total_sections }} 个分组</p>
|
| 139 |
+
</div>
|
| 140 |
+
<div class="rounded-2xl border border-white/10 bg-white/10 p-4 backdrop-blur">
|
| 141 |
+
<p class="text-xs uppercase tracking-[0.2em] text-indigo-200">.env 覆写</p>
|
| 142 |
+
<p class="mt-2 text-3xl font-bold text-white">{{ overview.overridden_fields }}</p>
|
| 143 |
+
<p class="mt-1 text-sm text-slate-300">{{ overview.default_fields }} 个字段仍在使用默认值</p>
|
| 144 |
+
</div>
|
| 145 |
+
<div class="rounded-2xl border border-white/10 bg-white/10 p-4 backdrop-blur">
|
| 146 |
+
<p class="text-xs uppercase tracking-[0.2em] text-indigo-200">敏感字段</p>
|
| 147 |
+
<p class="mt-2 text-3xl font-bold text-white">{{ overview.sensitive_fields }}</p>
|
| 148 |
+
<p class="mt-1 text-sm text-slate-300">{{ overview.restart_required_fields }} 个字段修改后建议重启</p>
|
| 149 |
+
</div>
|
| 150 |
+
<div class="rounded-2xl border border-white/10 bg-white/10 p-4 backdrop-blur">
|
| 151 |
+
<p class="text-xs uppercase tracking-[0.2em] text-indigo-200">源文件状态</p>
|
| 152 |
+
<p class="mt-2 text-lg font-bold text-white">{{ '.env 已存在' if overview.env_exists else '.env 尚未创建' }}</p>
|
| 153 |
+
<p class="mt-1 text-sm text-slate-300">{{ overview.env_line_count }} 行,{{ '.env.example 可用' if overview.example_exists else '缺少 .env.example' }}</p>
|
| 154 |
+
</div>
|
| 155 |
+
</div>
|
| 156 |
+
</div>
|
| 157 |
+
</div>
|
| 158 |
+
|
| 159 |
+
<div id="config-feedback"></div>
|
| 160 |
+
|
| 161 |
+
{% if not overview.env_exists %}
|
| 162 |
+
<div class="rounded-2xl border border-amber-200 bg-amber-50 px-5 py-4 text-sm text-amber-900">
|
| 163 |
+
当前工作目录中尚未找到 `.env` 文件。你可以直接保存表单或源文件,系统会自动创建它。
|
| 164 |
+
</div>
|
| 165 |
+
{% endif %}
|
| 166 |
+
|
| 167 |
+
<div class="grid gap-6 xl:grid-cols-[280px,minmax(0,1fr)]">
|
| 168 |
+
<aside class="space-y-4">
|
| 169 |
+
<div class="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200">
|
| 170 |
+
<div class="flex items-center justify-between">
|
| 171 |
+
<h3 class="text-base font-semibold text-slate-900">工作区</h3>
|
| 172 |
+
</div>
|
| 173 |
+
<div class="mt-3 rounded-2xl bg-slate-100 px-3 py-2 text-[11px] leading-5 text-slate-600 break-all">
|
| 174 |
+
{{ overview.env_path }}
|
| 175 |
+
</div>
|
| 176 |
+
<div class="mt-4 space-y-3 text-sm text-slate-600">
|
| 177 |
+
<p>表单模式会只更新受管字段,并保留 `.env` 中未知配置和注释顺序。</p>
|
| 178 |
+
<p>源文件模式会直接覆盖整个 `.env`,适合批量调整和粘贴完整配置。</p>
|
| 179 |
+
</div>
|
| 180 |
+
</div>
|
| 181 |
+
|
| 182 |
+
<div x-show="activeView === 'form'" x-cloak class="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200">
|
| 183 |
+
<h3 class="text-base font-semibold text-slate-900">分组导航</h3>
|
| 184 |
+
<div class="mt-4 flex flex-wrap gap-2">
|
| 185 |
+
{% for section in sections %}
|
| 186 |
+
{{ section_link(section) }}
|
| 187 |
+
{% endfor %}
|
| 188 |
+
</div>
|
| 189 |
+
</div>
|
| 190 |
+
|
| 191 |
+
<div x-show="activeView === 'source'" x-cloak class="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200">
|
| 192 |
+
<h3 class="text-base font-semibold text-slate-900">源文件提示</h3>
|
| 193 |
+
<div class="mt-4 space-y-3 text-sm leading-6 text-slate-600">
|
| 194 |
+
<p>每一行应遵循 `KEY=VALUE` 格式,空行和以 `#` 开头的注释会被保留。</p>
|
| 195 |
+
<p>直接修改源文件时,结构化表单不会实时同步,保存后页面会自动刷新到当前视图。</p>
|
| 196 |
+
<p>如果你需要保留完整注释、顺序或复制整套配置,优先使用这一模式。</p>
|
| 197 |
+
</div>
|
| 198 |
+
</div>
|
| 199 |
+
|
| 200 |
+
<div class="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200">
|
| 201 |
+
<h3 class="text-base font-semibold text-slate-900">热重载说明</h3>
|
| 202 |
+
<div class="mt-4 space-y-3 text-sm leading-6 text-slate-600">
|
| 203 |
+
<p>大多数字段保存后会立即生效。</p>
|
| 204 |
+
<p>标记为“需重启”的字段涉及监听端口、进程名称或底层持久化路径,建议手动重启服务。</p>
|
| 205 |
+
<p>修改后台密码或会话密钥后,建议重新登录后台验证新配置已生效。</p>
|
| 206 |
+
</div>
|
| 207 |
+
</div>
|
| 208 |
+
</aside>
|
| 209 |
+
|
| 210 |
+
<div class="space-y-6">
|
| 211 |
+
<form
|
| 212 |
+
x-show="activeView === 'form'"
|
| 213 |
+
x-cloak
|
| 214 |
+
hx-post="/admin/api/config/save"
|
| 215 |
+
hx-target="#config-feedback"
|
| 216 |
+
hx-swap="innerHTML"
|
| 217 |
+
class="space-y-6">
|
| 218 |
+
{% for section in sections %}
|
| 219 |
+
<section id="{{ section.id }}" class="overflow-hidden rounded-3xl bg-white shadow-sm ring-1 ring-slate-200">
|
| 220 |
+
<div class="border-b border-slate-200 bg-slate-50/70 px-6 py-5">
|
| 221 |
+
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
| 222 |
+
<div>
|
| 223 |
+
<h3 class="text-lg font-semibold text-slate-900">{{ section.title }}</h3>
|
| 224 |
+
<p class="mt-1 text-sm leading-6 text-slate-600">{{ section.description }}</p>
|
| 225 |
+
</div>
|
| 226 |
+
<span class="inline-flex items-center rounded-full bg-slate-900 px-3 py-1 text-xs font-semibold text-white">
|
| 227 |
+
{{ section.field_count }} 个字段
|
| 228 |
+
</span>
|
| 229 |
+
</div>
|
| 230 |
+
</div>
|
| 231 |
+
<div class="grid gap-4 p-6 md:grid-cols-2">
|
| 232 |
+
{% for field in section.fields %}
|
| 233 |
+
{{ render_field(field) }}
|
| 234 |
+
{% endfor %}
|
| 235 |
+
</div>
|
| 236 |
+
</section>
|
| 237 |
+
{% endfor %}
|
| 238 |
+
|
| 239 |
+
<div class="sticky bottom-4 z-10 rounded-3xl border border-slate-200 bg-white/95 p-4 shadow-2xl backdrop-blur">
|
| 240 |
+
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
| 241 |
+
<p class="text-sm text-slate-600">
|
| 242 |
+
表单模式会保留未知配置,只更新当前页面管理的字段。
|
| 243 |
+
</p>
|
| 244 |
+
<div class="flex flex-wrap gap-3">
|
| 245 |
+
<button
|
| 246 |
+
type="button"
|
| 247 |
+
@click="setView('source')"
|
| 248 |
+
class="inline-flex min-h-[44px] items-center rounded-2xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 transition hover:border-indigo-300 hover:text-indigo-700">
|
| 249 |
+
切换到源文件编辑
|
| 250 |
+
</button>
|
| 251 |
+
<button
|
| 252 |
+
type="submit"
|
| 253 |
+
class="inline-flex min-h-[44px] items-center rounded-2xl bg-indigo-600 px-5 py-2 text-sm font-semibold text-white shadow-lg shadow-indigo-600/20 transition hover:bg-indigo-700">
|
| 254 |
+
保存表单并热重载
|
| 255 |
+
</button>
|
| 256 |
+
</div>
|
| 257 |
+
</div>
|
| 258 |
+
</div>
|
| 259 |
+
</form>
|
| 260 |
+
|
| 261 |
+
<form
|
| 262 |
+
x-show="activeView === 'source'"
|
| 263 |
+
x-cloak
|
| 264 |
+
hx-post="/admin/api/config/source"
|
| 265 |
+
hx-target="#config-feedback"
|
| 266 |
+
hx-swap="innerHTML"
|
| 267 |
+
class="space-y-6">
|
| 268 |
+
<section class="overflow-hidden rounded-3xl bg-white shadow-sm ring-1 ring-slate-200">
|
| 269 |
+
<div class="border-b border-slate-200 bg-slate-50/70 px-6 py-5">
|
| 270 |
+
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
| 271 |
+
<div>
|
| 272 |
+
<h3 class="text-lg font-semibold text-slate-900">`.env` 源文件编辑器</h3>
|
| 273 |
+
<p class="mt-1 text-sm leading-6 text-slate-600">
|
| 274 |
+
直接编辑源文件内容。该模式会原样覆盖整个 `.env`,适合保留注释、批量调整和整体替换。
|
| 275 |
+
</p>
|
| 276 |
+
</div>
|
| 277 |
+
<span class="inline-flex max-w-full items-center rounded-2xl bg-slate-100 px-3 py-2 text-xs font-medium leading-5 text-slate-700 break-all">
|
| 278 |
+
{{ overview.env_path }}
|
| 279 |
+
</span>
|
| 280 |
+
</div>
|
| 281 |
+
</div>
|
| 282 |
+
<div class="space-y-4 p-6">
|
| 283 |
+
<div class="rounded-2xl border border-indigo-100 bg-indigo-50 px-4 py-3 text-sm leading-6 text-indigo-900">
|
| 284 |
+
保存前会进行基础语法检查,确保每一行是合法的 `KEY=VALUE` 结构;如果热重载失败,系统会自动回滚到原来的 `.env`。
|
| 285 |
+
</div>
|
| 286 |
+
<textarea
|
| 287 |
+
name="env_content"
|
| 288 |
+
rows="28"
|
| 289 |
+
spellcheck="false"
|
| 290 |
+
class="block min-h-[560px] w-full rounded-2xl border border-slate-300 bg-slate-950 px-4 py-4 font-mono text-sm leading-6 text-slate-100 shadow-inner focus:border-indigo-500 focus:ring-indigo-500">{{ env_content }}</textarea>
|
| 291 |
+
</div>
|
| 292 |
+
</section>
|
| 293 |
+
|
| 294 |
+
<div class="sticky bottom-4 z-10 rounded-3xl border border-slate-200 bg-white/95 p-4 shadow-2xl backdrop-blur">
|
| 295 |
+
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
| 296 |
+
<p class="text-sm text-slate-600">
|
| 297 |
+
源文件模式会直接覆盖整个 `.env`。保存后会自动刷新页面并保留当前视图。
|
| 298 |
+
</p>
|
| 299 |
+
<div class="flex flex-wrap gap-3">
|
| 300 |
+
<button
|
| 301 |
+
type="button"
|
| 302 |
+
@click="setView('form')"
|
| 303 |
+
class="inline-flex min-h-[44px] items-center rounded-2xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 transition hover:border-indigo-300 hover:text-indigo-700">
|
| 304 |
+
返回表单视图
|
| 305 |
+
</button>
|
| 306 |
+
<button
|
| 307 |
+
type="submit"
|
| 308 |
+
class="inline-flex min-h-[44px] items-center rounded-2xl bg-slate-900 px-5 py-2 text-sm font-semibold text-white shadow-lg transition hover:bg-slate-800">
|
| 309 |
+
保存源文件并热重载
|
| 310 |
+
</button>
|
| 311 |
+
</div>
|
| 312 |
+
</div>
|
| 313 |
+
</div>
|
| 314 |
+
</form>
|
| 315 |
+
</div>
|
| 316 |
+
</div>
|
| 317 |
+
</div>
|
| 318 |
+
{% endblock %}
|
| 319 |
+
|
| 320 |
+
{% block extra_scripts %}
|
| 321 |
+
<script>
|
| 322 |
+
function configPage() {
|
| 323 |
+
return {
|
| 324 |
+
activeView: localStorage.getItem('admin-config-active-view') || 'form',
|
| 325 |
+
setView(view) {
|
| 326 |
+
this.activeView = view;
|
| 327 |
+
localStorage.setItem('admin-config-active-view', view);
|
| 328 |
+
}
|
| 329 |
+
};
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
document.body.addEventListener('admin-config-refresh', () => {
|
| 333 |
+
setTimeout(() => {
|
| 334 |
+
window.location.reload();
|
| 335 |
+
}, 450);
|
| 336 |
+
});
|
| 337 |
+
|
| 338 |
+
document.body.addEventListener('htmx:afterSwap', function(evt) {
|
| 339 |
+
if (evt.detail.target.id === 'config-feedback') {
|
| 340 |
+
window.scrollTo({ top: 0, behavior: 'smooth' });
|
| 341 |
+
}
|
| 342 |
+
});
|
| 343 |
+
</script>
|
| 344 |
+
{% endblock %}
|
app/templates/index.html
ADDED
|
@@ -0,0 +1,588 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}仪表盘{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<div class="space-y-6">
|
| 7 |
+
<div class="overflow-hidden rounded-3xl bg-slate-900 shadow-2xl">
|
| 8 |
+
<div class="bg-[radial-gradient(circle_at_top_right,_rgba(45,212,191,0.22),_transparent_30%),radial-gradient(circle_at_left,_rgba(59,130,246,0.18),_transparent_28%),linear-gradient(135deg,_#0f172a,_#111827_58%,_#1e293b)] px-6 py-7 sm:px-8">
|
| 9 |
+
<div class="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
|
| 10 |
+
<div class="max-w-3xl">
|
| 11 |
+
<p class="text-sm font-semibold uppercase tracking-[0.2em] text-cyan-200">Usage Dashboard</p>
|
| 12 |
+
<h2 class="mt-3 text-3xl font-bold text-white sm:text-4xl">查看请求消耗、缓存效果、延迟表现和使用趋势</h2>
|
| 13 |
+
<p class="mt-3 text-sm leading-6 text-slate-300 sm:text-base">
|
| 14 |
+
统计来源于请求日志数据库,覆盖输入输出 Token、缓存创建与命中、成功率、平均延迟,以及最近 24 小时、7 天、30 天的使用趋势。
|
| 15 |
+
</p>
|
| 16 |
+
</div>
|
| 17 |
+
<div class="rounded-2xl border border-white/10 bg-white/10 px-5 py-4 backdrop-blur">
|
| 18 |
+
<p class="text-xs uppercase tracking-[0.2em] text-cyan-200">Last Update</p>
|
| 19 |
+
<p id="last-update" class="mt-2 text-lg font-semibold text-white">{{ current_time }}</p>
|
| 20 |
+
<p class="mt-1 text-sm text-slate-300">运行时间 {{ stats.uptime }}</p>
|
| 21 |
+
</div>
|
| 22 |
+
</div>
|
| 23 |
+
</div>
|
| 24 |
+
</div>
|
| 25 |
+
|
| 26 |
+
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
| 27 |
+
<div class="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200">
|
| 28 |
+
<div class="flex items-start justify-between gap-4">
|
| 29 |
+
<div>
|
| 30 |
+
<p class="text-sm font-medium text-slate-500">总请求数</p>
|
| 31 |
+
<p class="mt-2 text-3xl font-bold text-slate-900">{{ stats.total_requests }}</p>
|
| 32 |
+
</div>
|
| 33 |
+
<span class="rounded-2xl bg-slate-100 p-3 text-slate-600">
|
| 34 |
+
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 35 |
+
<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" />
|
| 36 |
+
</svg>
|
| 37 |
+
</span>
|
| 38 |
+
</div>
|
| 39 |
+
<p class="mt-3 text-sm text-slate-500">成功 {{ stats.successful_requests }} / 失败 {{ stats.failed_requests }}</p>
|
| 40 |
+
</div>
|
| 41 |
+
|
| 42 |
+
<div class="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200">
|
| 43 |
+
<div class="flex items-start justify-between gap-4">
|
| 44 |
+
<div>
|
| 45 |
+
<p class="text-sm font-medium text-slate-500">总消耗 Token 数</p>
|
| 46 |
+
<p class="mt-2 text-3xl font-bold text-slate-900">{{ stats.total_consumed_tokens_display }}</p>
|
| 47 |
+
</div>
|
| 48 |
+
<span class="rounded-2xl bg-emerald-50 p-3 text-emerald-600">
|
| 49 |
+
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 50 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 1.343-3 3v6h6v-6c0-1.657-1.343-3-3-3zm0 0V5m0 3a4 4 0 00-4 4v5h8v-5a4 4 0 00-4-4z" />
|
| 51 |
+
</svg>
|
| 52 |
+
</span>
|
| 53 |
+
</div>
|
| 54 |
+
<p class="mt-3 text-sm text-slate-500">累计 {{ stats.total_consumed_tokens }} Tokens</p>
|
| 55 |
+
</div>
|
| 56 |
+
|
| 57 |
+
<div class="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200">
|
| 58 |
+
<div class="flex items-start justify-between gap-4">
|
| 59 |
+
<div>
|
| 60 |
+
<p class="text-sm font-medium text-slate-500">缓存 Token</p>
|
| 61 |
+
<p class="mt-2 text-3xl font-bold text-slate-900">{{ stats.total_cache_tokens_display }}</p>
|
| 62 |
+
</div>
|
| 63 |
+
<span class="rounded-2xl bg-amber-50 p-3 text-amber-600">
|
| 64 |
+
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 65 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7h16M4 12h10m-10 5h16" />
|
| 66 |
+
</svg>
|
| 67 |
+
</span>
|
| 68 |
+
</div>
|
| 69 |
+
<p class="mt-3 text-sm text-slate-500">创建 {{ stats.cache_creation_tokens }} / 命中 {{ stats.cache_read_tokens }}</p>
|
| 70 |
+
</div>
|
| 71 |
+
|
| 72 |
+
<div class="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200">
|
| 73 |
+
<div class="flex items-start justify-between gap-4">
|
| 74 |
+
<div>
|
| 75 |
+
<p class="text-sm font-medium text-slate-500">成功率</p>
|
| 76 |
+
<p class="mt-2 text-3xl font-bold text-slate-900">{{ stats.success_rate }}%</p>
|
| 77 |
+
</div>
|
| 78 |
+
<span class="rounded-2xl bg-sky-50 p-3 text-sky-600">
|
| 79 |
+
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 80 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
| 81 |
+
</svg>
|
| 82 |
+
</span>
|
| 83 |
+
</div>
|
| 84 |
+
<p class="mt-3 text-sm text-slate-500">图表支持切换 24 小时 / 7 天 / 30 天</p>
|
| 85 |
+
</div>
|
| 86 |
+
|
| 87 |
+
<div class="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200">
|
| 88 |
+
<div class="flex items-start justify-between gap-4">
|
| 89 |
+
<div>
|
| 90 |
+
<p class="text-sm font-medium text-slate-500">输入 Token</p>
|
| 91 |
+
<p class="mt-2 text-3xl font-bold text-slate-900">{{ stats.input_tokens_display }}</p>
|
| 92 |
+
</div>
|
| 93 |
+
<span class="rounded-2xl bg-violet-50 p-3 text-violet-600">
|
| 94 |
+
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 95 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 12H4m0 0l6-6m-6 6l6 6" />
|
| 96 |
+
</svg>
|
| 97 |
+
</span>
|
| 98 |
+
</div>
|
| 99 |
+
<p class="mt-3 text-sm text-slate-500">累计 {{ stats.input_tokens }} Tokens</p>
|
| 100 |
+
</div>
|
| 101 |
+
|
| 102 |
+
<div class="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200">
|
| 103 |
+
<div class="flex items-start justify-between gap-4">
|
| 104 |
+
<div>
|
| 105 |
+
<p class="text-sm font-medium text-slate-500">输出 Token</p>
|
| 106 |
+
<p class="mt-2 text-3xl font-bold text-slate-900">{{ stats.output_tokens_display }}</p>
|
| 107 |
+
</div>
|
| 108 |
+
<span class="rounded-2xl bg-rose-50 p-3 text-rose-600">
|
| 109 |
+
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 110 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 12h16m0 0l-6-6m6 6l-6 6" />
|
| 111 |
+
</svg>
|
| 112 |
+
</span>
|
| 113 |
+
</div>
|
| 114 |
+
<p class="mt-3 text-sm text-slate-500">累计 {{ stats.output_tokens }} Tokens</p>
|
| 115 |
+
</div>
|
| 116 |
+
|
| 117 |
+
<div class="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200">
|
| 118 |
+
<div class="flex items-start justify-between gap-4">
|
| 119 |
+
<div>
|
| 120 |
+
<p class="text-sm font-medium text-slate-500">平均延迟</p>
|
| 121 |
+
<p class="mt-2 text-3xl font-bold text-slate-900">{{ "%.2f"|format(stats.average_latency) }}s</p>
|
| 122 |
+
</div>
|
| 123 |
+
<span class="rounded-2xl bg-orange-50 p-3 text-orange-600">
|
| 124 |
+
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 125 |
+
<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" />
|
| 126 |
+
</svg>
|
| 127 |
+
</span>
|
| 128 |
+
</div>
|
| 129 |
+
<p class="mt-3 text-sm text-slate-500">平均首字延迟 {{ "%.2f"|format(stats.average_first_token_latency) }}s</p>
|
| 130 |
+
</div>
|
| 131 |
+
|
| 132 |
+
<div class="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200">
|
| 133 |
+
<div class="flex items-start justify-between gap-4">
|
| 134 |
+
<div>
|
| 135 |
+
<p class="text-sm font-medium text-slate-500">Token 池健康度</p>
|
| 136 |
+
<p class="mt-2 text-3xl font-bold text-slate-900">{{ stats.healthy_tokens }}/{{ stats.pool_total_tokens }}</p>
|
| 137 |
+
</div>
|
| 138 |
+
<span class="rounded-2xl {% if stats.pool_total_tokens == 0 %}bg-slate-100 text-slate-500{% elif stats.healthy_tokens >= stats.pool_total_tokens * 0.8 %}bg-emerald-50 text-emerald-600{% elif stats.healthy_tokens >= stats.pool_total_tokens * 0.5 %}bg-amber-50 text-amber-600{% else %}bg-red-50 text-red-600{% endif %} p-3">
|
| 139 |
+
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 140 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
| 141 |
+
</svg>
|
| 142 |
+
</span>
|
| 143 |
+
</div>
|
| 144 |
+
<p class="mt-3 text-sm text-slate-500">可用 {{ stats.available_tokens }} / 已启用 {{ stats.enabled_tokens }} / 认证 {{ stats.user_tokens }}</p>
|
| 145 |
+
</div>
|
| 146 |
+
</div>
|
| 147 |
+
|
| 148 |
+
<div class="grid grid-cols-1 gap-6 xl:grid-cols-3">
|
| 149 |
+
<section class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-slate-200 xl:col-span-2">
|
| 150 |
+
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
| 151 |
+
<div>
|
| 152 |
+
<h3 class="text-lg font-semibold text-slate-900">使用趋势图</h3>
|
| 153 |
+
<p
|
| 154 |
+
id="usage-trend-description"
|
| 155 |
+
class="mt-1 text-sm text-slate-600"
|
| 156 |
+
>
|
| 157 |
+
最近 7 天按天聚合的请求量、输入输出与缓存变化。
|
| 158 |
+
</p>
|
| 159 |
+
</div>
|
| 160 |
+
<div
|
| 161 |
+
id="usage-trend-window-switcher"
|
| 162 |
+
class="flex flex-wrap gap-2"
|
| 163 |
+
>
|
| 164 |
+
{% set trend_window_options = trend_windows if trend_windows is defined and trend_windows else [
|
| 165 |
+
{'key': '24h', 'label': '24 小时'},
|
| 166 |
+
{'key': '7d', 'label': '7 天'},
|
| 167 |
+
{'key': '30d', 'label': '30 天'}
|
| 168 |
+
] %}
|
| 169 |
+
{% for option in trend_window_options %}
|
| 170 |
+
<button
|
| 171 |
+
type="button"
|
| 172 |
+
data-trend-window="{{ option.key }}"
|
| 173 |
+
class="inline-flex items-center rounded-full border border-slate-200 bg-white px-3 py-1.5 text-sm font-medium text-slate-600 transition-colors hover:border-sky-300 hover:text-sky-700"
|
| 174 |
+
>
|
| 175 |
+
{{ option.label }}
|
| 176 |
+
</button>
|
| 177 |
+
{% endfor %}
|
| 178 |
+
</div>
|
| 179 |
+
</div>
|
| 180 |
+
<div class="mt-4 flex flex-wrap gap-2 text-xs">
|
| 181 |
+
<span class="inline-flex items-center rounded-full bg-slate-100 px-3 py-1 font-medium text-slate-600">蓝柱: 请求量</span>
|
| 182 |
+
<span class="inline-flex items-center rounded-full bg-violet-50 px-3 py-1 font-medium text-violet-700">紫线: 输入</span>
|
| 183 |
+
<span class="inline-flex items-center rounded-full bg-rose-50 px-3 py-1 font-medium text-rose-700">红线: 输出</span>
|
| 184 |
+
<span class="inline-flex items-center rounded-full bg-emerald-50 px-3 py-1 font-medium text-emerald-700">绿线: 缓存创建</span>
|
| 185 |
+
<span class="inline-flex items-center rounded-full bg-amber-50 px-3 py-1 font-medium text-amber-700">黄线: 缓存命中</span>
|
| 186 |
+
</div>
|
| 187 |
+
<div class="mt-6 h-[320px]">
|
| 188 |
+
<canvas id="usage-trend-chart"></canvas>
|
| 189 |
+
</div>
|
| 190 |
+
<p
|
| 191 |
+
id="usage-trend-error"
|
| 192 |
+
class="mt-3 hidden rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"
|
| 193 |
+
></p>
|
| 194 |
+
</section>
|
| 195 |
+
|
| 196 |
+
<div class="space-y-6">
|
| 197 |
+
<section class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-slate-200">
|
| 198 |
+
<h3 class="text-lg font-semibold text-slate-900">缓存创建 / 命中</h3>
|
| 199 |
+
<p class="mt-1 text-sm text-slate-600">按请求次数和 Token 数量查看缓存是否真的生效。</p>
|
| 200 |
+
<div class="mt-5 grid grid-cols-1 gap-4">
|
| 201 |
+
<div class="rounded-2xl border border-emerald-100 bg-emerald-50 p-4">
|
| 202 |
+
<p class="text-sm font-medium text-emerald-700">缓存创建</p>
|
| 203 |
+
<p class="mt-2 text-2xl font-bold text-emerald-900">{{ stats.cache_creation_requests }}</p>
|
| 204 |
+
<p class="mt-1 text-sm text-emerald-700">共创建 {{ stats.cache_creation_tokens }} Tokens</p>
|
| 205 |
+
</div>
|
| 206 |
+
<div class="rounded-2xl border border-amber-100 bg-amber-50 p-4">
|
| 207 |
+
<p class="text-sm font-medium text-amber-700">缓存命中</p>
|
| 208 |
+
<p class="mt-2 text-2xl font-bold text-amber-900">{{ stats.cache_hit_requests }}</p>
|
| 209 |
+
<p class="mt-1 text-sm text-amber-700">共命中 {{ stats.cache_read_tokens }} Tokens</p>
|
| 210 |
+
</div>
|
| 211 |
+
</div>
|
| 212 |
+
</section>
|
| 213 |
+
|
| 214 |
+
<section class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-slate-200">
|
| 215 |
+
<h3 class="text-lg font-semibold text-slate-900">输入 / 输出画像</h3>
|
| 216 |
+
<p class="mt-1 text-sm text-slate-600">对比 Prompt 与 Completion 的消耗分布。</p>
|
| 217 |
+
{% set usage_total = stats.input_tokens + stats.output_tokens %}
|
| 218 |
+
{% set input_ratio = (stats.input_tokens / usage_total * 100) if usage_total > 0 else 0 %}
|
| 219 |
+
{% set output_ratio = (stats.output_tokens / usage_total * 100) if usage_total > 0 else 0 %}
|
| 220 |
+
<div class="mt-5 space-y-4">
|
| 221 |
+
<div>
|
| 222 |
+
<div class="flex items-center justify-between text-sm">
|
| 223 |
+
<span class="font-medium text-slate-700">输入 Token</span>
|
| 224 |
+
<span class="text-slate-500">{{ "%.1f"|format(input_ratio) }}%</span>
|
| 225 |
+
</div>
|
| 226 |
+
<div class="mt-2 h-2 rounded-full bg-slate-100">
|
| 227 |
+
<div class="h-2 rounded-full bg-violet-500" style="width: {{ input_ratio }}%"></div>
|
| 228 |
+
</div>
|
| 229 |
+
<p class="mt-2 text-sm text-slate-500">{{ stats.input_tokens }} Tokens</p>
|
| 230 |
+
</div>
|
| 231 |
+
<div>
|
| 232 |
+
<div class="flex items-center justify-between text-sm">
|
| 233 |
+
<span class="font-medium text-slate-700">输出 Token</span>
|
| 234 |
+
<span class="text-slate-500">{{ "%.1f"|format(output_ratio) }}%</span>
|
| 235 |
+
</div>
|
| 236 |
+
<div class="mt-2 h-2 rounded-full bg-slate-100">
|
| 237 |
+
<div class="h-2 rounded-full bg-rose-500" style="width: {{ output_ratio }}%"></div>
|
| 238 |
+
</div>
|
| 239 |
+
<p class="mt-2 text-sm text-slate-500">{{ stats.output_tokens }} Tokens</p>
|
| 240 |
+
</div>
|
| 241 |
+
</div>
|
| 242 |
+
</section>
|
| 243 |
+
</div>
|
| 244 |
+
</div>
|
| 245 |
+
|
| 246 |
+
<div class="bg-white shadow rounded-lg">
|
| 247 |
+
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
| 248 |
+
<h3 class="text-lg font-medium text-gray-900">最近请求日志</h3>
|
| 249 |
+
<div class="flex items-center space-x-2">
|
| 250 |
+
<label class="flex items-center cursor-pointer">
|
| 251 |
+
<input id="recent-logs-auto-refresh" type="checkbox" checked class="form-checkbox h-4 w-4 text-indigo-600">
|
| 252 |
+
<span class="ml-2 text-sm text-gray-600">自动刷新</span>
|
| 253 |
+
</label>
|
| 254 |
+
</div>
|
| 255 |
+
</div>
|
| 256 |
+
<div class="p-6">
|
| 257 |
+
<div
|
| 258 |
+
id="recent-logs"
|
| 259 |
+
hx-get="/admin/api/recent-logs?page=1&page_size=12"
|
| 260 |
+
hx-trigger="load"
|
| 261 |
+
hx-swap="innerHTML">
|
| 262 |
+
<div class="flex justify-center items-center py-12">
|
| 263 |
+
<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">
|
| 264 |
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
| 265 |
+
<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>
|
| 266 |
+
</svg>
|
| 267 |
+
<span class="ml-3 text-gray-500">加载中...</span>
|
| 268 |
+
</div>
|
| 269 |
+
</div>
|
| 270 |
+
</div>
|
| 271 |
+
</div>
|
| 272 |
+
</div>
|
| 273 |
+
{% endblock %}
|
| 274 |
+
|
| 275 |
+
{% block extra_scripts %}
|
| 276 |
+
<script>
|
| 277 |
+
window.dashboardRecentLogsPage = 1;
|
| 278 |
+
window.dashboardRecentLogsPageSize = 12;
|
| 279 |
+
window.dashboardTrendWindow = {{ stats.get('trend_window', '7d') | tojson }};
|
| 280 |
+
window.dashboardTrendData = {{ stats.get('usage_trend', []) | tojson }};
|
| 281 |
+
window.dashboardTrendChart = null;
|
| 282 |
+
|
| 283 |
+
const trendWindowDescriptions = {
|
| 284 |
+
'24h': '最近 24 小时按小时聚合的请求量、输入输出与缓存变化。',
|
| 285 |
+
'7d': '最近 7 天按天聚合的请求量、输入输出与缓存变化。',
|
| 286 |
+
'30d': '最近 30 天按天聚合的请求量、输入输出与缓存变化。'
|
| 287 |
+
};
|
| 288 |
+
|
| 289 |
+
function loadRecentLogsPage(page) {
|
| 290 |
+
const nextPage = Math.max(1, Number(page) || 1);
|
| 291 |
+
window.dashboardRecentLogsPage = nextPage;
|
| 292 |
+
const url = `/admin/api/recent-logs?page=${nextPage}&page_size=${window.dashboardRecentLogsPageSize}`;
|
| 293 |
+
htmx.ajax('GET', url, {
|
| 294 |
+
target: '#recent-logs',
|
| 295 |
+
swap: 'innerHTML'
|
| 296 |
+
});
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
function updateTime() {
|
| 300 |
+
const now = new Date();
|
| 301 |
+
document.getElementById('last-update').textContent = now.toLocaleString('zh-CN');
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
function updateTrendDescription() {
|
| 305 |
+
const description = document.getElementById('usage-trend-description');
|
| 306 |
+
if (!description) {
|
| 307 |
+
return;
|
| 308 |
+
}
|
| 309 |
+
description.textContent = (
|
| 310 |
+
trendWindowDescriptions[window.dashboardTrendWindow]
|
| 311 |
+
|| trendWindowDescriptions['7d']
|
| 312 |
+
);
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
function updateTrendWindowButtons() {
|
| 316 |
+
document
|
| 317 |
+
.querySelectorAll('[data-trend-window]')
|
| 318 |
+
.forEach((button) => {
|
| 319 |
+
const isActive = (
|
| 320 |
+
button.dataset.trendWindow === window.dashboardTrendWindow
|
| 321 |
+
);
|
| 322 |
+
button.disabled = false;
|
| 323 |
+
button.classList.toggle('bg-sky-600', isActive);
|
| 324 |
+
button.classList.toggle('border-sky-600', isActive);
|
| 325 |
+
button.classList.toggle('text-white', isActive);
|
| 326 |
+
button.classList.toggle('shadow-sm', isActive);
|
| 327 |
+
button.classList.toggle('bg-white', !isActive);
|
| 328 |
+
button.classList.toggle('border-slate-200', !isActive);
|
| 329 |
+
button.classList.toggle('text-slate-600', !isActive);
|
| 330 |
+
});
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
function setTrendLoadingState(isLoading) {
|
| 334 |
+
document
|
| 335 |
+
.querySelectorAll('[data-trend-window]')
|
| 336 |
+
.forEach((button) => {
|
| 337 |
+
button.disabled = isLoading;
|
| 338 |
+
button.classList.toggle('opacity-60', isLoading);
|
| 339 |
+
button.classList.toggle('cursor-not-allowed', isLoading);
|
| 340 |
+
});
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
function showTrendError(message) {
|
| 344 |
+
const errorBox = document.getElementById('usage-trend-error');
|
| 345 |
+
if (!errorBox) {
|
| 346 |
+
return;
|
| 347 |
+
}
|
| 348 |
+
if (!message) {
|
| 349 |
+
errorBox.classList.add('hidden');
|
| 350 |
+
errorBox.textContent = '';
|
| 351 |
+
return;
|
| 352 |
+
}
|
| 353 |
+
errorBox.textContent = message;
|
| 354 |
+
errorBox.classList.remove('hidden');
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
function renderUsageTrendChart() {
|
| 358 |
+
const canvas = document.getElementById('usage-trend-chart');
|
| 359 |
+
if (!canvas) {
|
| 360 |
+
return;
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
const usageTrend = window.dashboardTrendData || [];
|
| 364 |
+
const labels = usageTrend.map((item) => item.label);
|
| 365 |
+
const requestSeries = usageTrend.map((item) => item.total_requests);
|
| 366 |
+
const inputSeries = usageTrend.map((item) => item.input_tokens);
|
| 367 |
+
const outputSeries = usageTrend.map((item) => item.output_tokens);
|
| 368 |
+
const cacheCreationSeries = usageTrend.map(
|
| 369 |
+
(item) => item.cache_creation_tokens
|
| 370 |
+
);
|
| 371 |
+
const cacheReadSeries = usageTrend.map(
|
| 372 |
+
(item) => item.cache_read_tokens
|
| 373 |
+
);
|
| 374 |
+
|
| 375 |
+
if (window.dashboardTrendChart) {
|
| 376 |
+
window.dashboardTrendChart.destroy();
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
window.dashboardTrendChart = new Chart(canvas, {
|
| 380 |
+
type: 'bar',
|
| 381 |
+
data: {
|
| 382 |
+
labels,
|
| 383 |
+
datasets: [
|
| 384 |
+
{
|
| 385 |
+
type: 'bar',
|
| 386 |
+
label: '请求量',
|
| 387 |
+
data: requestSeries,
|
| 388 |
+
yAxisID: 'yRequests',
|
| 389 |
+
backgroundColor: 'rgba(59, 130, 246, 0.18)',
|
| 390 |
+
borderColor: 'rgba(59, 130, 246, 0.85)',
|
| 391 |
+
borderWidth: 1.5,
|
| 392 |
+
borderRadius: 10
|
| 393 |
+
},
|
| 394 |
+
{
|
| 395 |
+
type: 'line',
|
| 396 |
+
label: '输入 Token',
|
| 397 |
+
data: inputSeries,
|
| 398 |
+
yAxisID: 'yTokens',
|
| 399 |
+
borderColor: '#8b5cf6',
|
| 400 |
+
backgroundColor: 'rgba(139, 92, 246, 0.16)',
|
| 401 |
+
borderWidth: 3,
|
| 402 |
+
tension: 0.35,
|
| 403 |
+
pointRadius: 3,
|
| 404 |
+
pointHoverRadius: 5,
|
| 405 |
+
fill: false
|
| 406 |
+
},
|
| 407 |
+
{
|
| 408 |
+
type: 'line',
|
| 409 |
+
label: '输出 Token',
|
| 410 |
+
data: outputSeries,
|
| 411 |
+
yAxisID: 'yTokens',
|
| 412 |
+
borderColor: '#f43f5e',
|
| 413 |
+
backgroundColor: 'rgba(244, 63, 94, 0.16)',
|
| 414 |
+
borderWidth: 3,
|
| 415 |
+
tension: 0.35,
|
| 416 |
+
pointRadius: 3,
|
| 417 |
+
pointHoverRadius: 5,
|
| 418 |
+
fill: false
|
| 419 |
+
},
|
| 420 |
+
{
|
| 421 |
+
type: 'line',
|
| 422 |
+
label: '缓存创建',
|
| 423 |
+
data: cacheCreationSeries,
|
| 424 |
+
yAxisID: 'yTokens',
|
| 425 |
+
borderColor: '#10b981',
|
| 426 |
+
backgroundColor: 'rgba(16, 185, 129, 0.16)',
|
| 427 |
+
borderWidth: 2,
|
| 428 |
+
borderDash: [6, 4],
|
| 429 |
+
tension: 0.35,
|
| 430 |
+
pointRadius: 2,
|
| 431 |
+
pointHoverRadius: 4,
|
| 432 |
+
fill: false
|
| 433 |
+
},
|
| 434 |
+
{
|
| 435 |
+
type: 'line',
|
| 436 |
+
label: '缓存命中',
|
| 437 |
+
data: cacheReadSeries,
|
| 438 |
+
yAxisID: 'yTokens',
|
| 439 |
+
borderColor: '#f59e0b',
|
| 440 |
+
backgroundColor: 'rgba(245, 158, 11, 0.15)',
|
| 441 |
+
borderWidth: 2,
|
| 442 |
+
borderDash: [3, 4],
|
| 443 |
+
tension: 0.35,
|
| 444 |
+
pointRadius: 2,
|
| 445 |
+
pointHoverRadius: 4,
|
| 446 |
+
fill: false
|
| 447 |
+
}
|
| 448 |
+
]
|
| 449 |
+
},
|
| 450 |
+
options: {
|
| 451 |
+
maintainAspectRatio: false,
|
| 452 |
+
interaction: {
|
| 453 |
+
mode: 'index',
|
| 454 |
+
intersect: false
|
| 455 |
+
},
|
| 456 |
+
plugins: {
|
| 457 |
+
legend: {
|
| 458 |
+
position: 'bottom',
|
| 459 |
+
labels: {
|
| 460 |
+
usePointStyle: true,
|
| 461 |
+
boxWidth: 10,
|
| 462 |
+
color: '#475569'
|
| 463 |
+
}
|
| 464 |
+
},
|
| 465 |
+
tooltip: {
|
| 466 |
+
callbacks: {
|
| 467 |
+
title(context) {
|
| 468 |
+
const index = context[0]?.dataIndex ?? 0;
|
| 469 |
+
return (
|
| 470 |
+
usageTrend[index]?.tooltip_label
|
| 471 |
+
|| usageTrend[index]?.label
|
| 472 |
+
|| ''
|
| 473 |
+
);
|
| 474 |
+
},
|
| 475 |
+
label(context) {
|
| 476 |
+
const value = Number(context.parsed.y || 0);
|
| 477 |
+
if (context.dataset.label === '请求量') {
|
| 478 |
+
return `${context.dataset.label}: ${value} 次`;
|
| 479 |
+
}
|
| 480 |
+
return (
|
| 481 |
+
`${context.dataset.label}: `
|
| 482 |
+
+ `${value.toLocaleString('zh-CN')} Tokens`
|
| 483 |
+
);
|
| 484 |
+
}
|
| 485 |
+
}
|
| 486 |
+
}
|
| 487 |
+
},
|
| 488 |
+
scales: {
|
| 489 |
+
x: {
|
| 490 |
+
grid: {
|
| 491 |
+
display: false
|
| 492 |
+
}
|
| 493 |
+
},
|
| 494 |
+
yRequests: {
|
| 495 |
+
position: 'left',
|
| 496 |
+
beginAtZero: true,
|
| 497 |
+
ticks: {
|
| 498 |
+
precision: 0
|
| 499 |
+
},
|
| 500 |
+
grid: {
|
| 501 |
+
color: 'rgba(148, 163, 184, 0.14)'
|
| 502 |
+
}
|
| 503 |
+
},
|
| 504 |
+
yTokens: {
|
| 505 |
+
position: 'right',
|
| 506 |
+
beginAtZero: true,
|
| 507 |
+
grid: {
|
| 508 |
+
drawOnChartArea: false
|
| 509 |
+
},
|
| 510 |
+
ticks: {
|
| 511 |
+
callback(value) {
|
| 512 |
+
return Number(value).toLocaleString('zh-CN');
|
| 513 |
+
}
|
| 514 |
+
}
|
| 515 |
+
}
|
| 516 |
+
}
|
| 517 |
+
}
|
| 518 |
+
});
|
| 519 |
+
|
| 520 |
+
updateTrendDescription();
|
| 521 |
+
updateTrendWindowButtons();
|
| 522 |
+
}
|
| 523 |
+
|
| 524 |
+
async function loadUsageTrend(windowKey) {
|
| 525 |
+
const nextWindow = String(windowKey || '').trim();
|
| 526 |
+
if (!nextWindow) {
|
| 527 |
+
return;
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
setTrendLoadingState(true);
|
| 531 |
+
showTrendError('');
|
| 532 |
+
|
| 533 |
+
try {
|
| 534 |
+
const response = await fetch(
|
| 535 |
+
`/admin/api/dashboard/usage-trend?window=${encodeURIComponent(nextWindow)}`,
|
| 536 |
+
{
|
| 537 |
+
headers: {
|
| 538 |
+
'X-Requested-With': 'XMLHttpRequest'
|
| 539 |
+
}
|
| 540 |
+
}
|
| 541 |
+
);
|
| 542 |
+
|
| 543 |
+
if (!response.ok) {
|
| 544 |
+
throw new Error(`HTTP ${response.status}`);
|
| 545 |
+
}
|
| 546 |
+
|
| 547 |
+
const payload = await response.json();
|
| 548 |
+
window.dashboardTrendWindow = payload.window || '7d';
|
| 549 |
+
window.dashboardTrendData = payload.points || [];
|
| 550 |
+
renderUsageTrendChart();
|
| 551 |
+
} catch (error) {
|
| 552 |
+
showTrendError('趋势图加载失败,请稍后重试。');
|
| 553 |
+
console.error('Failed to load dashboard trend:', error);
|
| 554 |
+
} finally {
|
| 555 |
+
setTrendLoadingState(false);
|
| 556 |
+
}
|
| 557 |
+
}
|
| 558 |
+
|
| 559 |
+
function initTrendWindowSwitcher() {
|
| 560 |
+
updateTrendDescription();
|
| 561 |
+
updateTrendWindowButtons();
|
| 562 |
+
|
| 563 |
+
document
|
| 564 |
+
.querySelectorAll('[data-trend-window]')
|
| 565 |
+
.forEach((button) => {
|
| 566 |
+
button.addEventListener('click', () => {
|
| 567 |
+
const nextWindow = button.dataset.trendWindow;
|
| 568 |
+
if (nextWindow === window.dashboardTrendWindow) {
|
| 569 |
+
return;
|
| 570 |
+
}
|
| 571 |
+
loadUsageTrend(nextWindow);
|
| 572 |
+
});
|
| 573 |
+
});
|
| 574 |
+
}
|
| 575 |
+
|
| 576 |
+
updateTime();
|
| 577 |
+
initTrendWindowSwitcher();
|
| 578 |
+
renderUsageTrendChart();
|
| 579 |
+
setInterval(updateTime, 1000);
|
| 580 |
+
|
| 581 |
+
setInterval(() => {
|
| 582 |
+
const checkbox = document.getElementById('recent-logs-auto-refresh');
|
| 583 |
+
if (checkbox && checkbox.checked) {
|
| 584 |
+
loadRecentLogsPage(window.dashboardRecentLogsPage || 1);
|
| 585 |
+
}
|
| 586 |
+
}, 3000);
|
| 587 |
+
</script>
|
| 588 |
+
{% 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>登录 - API 控制台</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/alpinejs@3.x.x/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 |
+
API 管理后台
|
| 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/logs.html
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}实时日志{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<div class="space-y-6">
|
| 7 |
+
<div class="flex items-center justify-between">
|
| 8 |
+
<div>
|
| 9 |
+
<h2 class="text-3xl font-bold text-gray-900">实时日志</h2>
|
| 10 |
+
<p class="mt-1 text-sm text-gray-600">滚动查看服务当前输出的最新日志</p>
|
| 11 |
+
</div>
|
| 12 |
+
<div class="flex items-center space-x-3">
|
| 13 |
+
<button
|
| 14 |
+
onclick="window.location.reload()"
|
| 15 |
+
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors">
|
| 16 |
+
刷新页面
|
| 17 |
+
</button>
|
| 18 |
+
</div>
|
| 19 |
+
</div>
|
| 20 |
+
|
| 21 |
+
<div class="bg-white shadow rounded-lg">
|
| 22 |
+
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
| 23 |
+
<h3 class="text-lg font-medium text-gray-900">日志流</h3>
|
| 24 |
+
<div class="flex space-x-2">
|
| 25 |
+
<button
|
| 26 |
+
onclick="document.getElementById('live-logs').innerHTML = '<div class=\'text-center text-gray-500 py-4\'>日志已清空</div>'"
|
| 27 |
+
class="px-3 py-1 text-sm bg-gray-200 hover:bg-gray-300 rounded-md transition-colors">
|
| 28 |
+
清空
|
| 29 |
+
</button>
|
| 30 |
+
</div>
|
| 31 |
+
</div>
|
| 32 |
+
<div class="p-6">
|
| 33 |
+
<div
|
| 34 |
+
id="live-logs"
|
| 35 |
+
hx-get="/admin/api/live-logs"
|
| 36 |
+
hx-trigger="load, every 3s"
|
| 37 |
+
hx-swap="innerHTML scroll:bottom"
|
| 38 |
+
class="bg-gray-900 text-gray-100 p-4 rounded-md font-mono text-sm overflow-y-auto"
|
| 39 |
+
style="max-height: 600px;">
|
| 40 |
+
<div class="flex justify-center items-center py-8">
|
| 41 |
+
<span class="text-gray-500">加载中...</span>
|
| 42 |
+
</div>
|
| 43 |
+
</div>
|
| 44 |
+
</div>
|
| 45 |
+
</div>
|
| 46 |
+
</div>
|
| 47 |
+
{% endblock %}
|
| 48 |
+
|
| 49 |
+
{% block extra_scripts %}
|
| 50 |
+
<script>
|
| 51 |
+
const logsContainer = document.getElementById('live-logs');
|
| 52 |
+
|
| 53 |
+
document.body.addEventListener('htmx:afterSwap', function(event) {
|
| 54 |
+
if (event.detail.target.id === 'live-logs') {
|
| 55 |
+
logsContainer.scrollTop = logsContainer.scrollHeight;
|
| 56 |
+
}
|
| 57 |
+
});
|
| 58 |
+
</script>
|
| 59 |
+
{% endblock %}
|
app/templates/tokens.html
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}Token 管理{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<div class="space-y-6" x-data='{
|
| 7 |
+
showAddModal: false,
|
| 8 |
+
showValidateModal: false,
|
| 9 |
+
newToken: "",
|
| 10 |
+
bulkTokens: "",
|
| 11 |
+
hasImportSourceDir: {{ "true" if automation.has_import_source_dir else "false" }},
|
| 12 |
+
hasMaintenanceActions: {{ "true" if automation.has_maintenance_actions else "false" }},
|
| 13 |
+
isImporting: false,
|
| 14 |
+
isRunningMaintenance: false,
|
| 15 |
+
isValidating: false
|
| 16 |
+
}'>
|
| 17 |
+
<!-- 页面标题 -->
|
| 18 |
+
<div class="flex items-center justify-between">
|
| 19 |
+
<div>
|
| 20 |
+
<h2 class="text-3xl font-bold text-gray-900">Token 管理</h2>
|
| 21 |
+
<p class="mt-1 text-sm text-gray-600">管理和维护当前服务使用的 Token</p>
|
| 22 |
+
</div>
|
| 23 |
+
<div class="flex flex-wrap gap-3">
|
| 24 |
+
<a href="{{ automation.config_url }}"
|
| 25 |
+
class="px-4 py-2 bg-slate-900 text-white rounded-md text-sm font-medium hover:bg-slate-800 flex items-center">
|
| 26 |
+
<svg class="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 27 |
+
<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" />
|
| 28 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
| 29 |
+
</svg>
|
| 30 |
+
打开配置中心
|
| 31 |
+
</a>
|
| 32 |
+
<button @click="showValidateModal = true"
|
| 33 |
+
class="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 flex items-center">
|
| 34 |
+
<svg class="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 35 |
+
<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" />
|
| 36 |
+
</svg>
|
| 37 |
+
批量验证
|
| 38 |
+
</button>
|
| 39 |
+
<button @click="showAddModal = true"
|
| 40 |
+
class="px-4 py-2 bg-indigo-600 text-white rounded-md text-sm font-medium hover:bg-indigo-700 flex items-center">
|
| 41 |
+
<svg class="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 42 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
| 43 |
+
</svg>
|
| 44 |
+
添加 Token
|
| 45 |
+
</button>
|
| 46 |
+
</div>
|
| 47 |
+
</div>
|
| 48 |
+
|
| 49 |
+
<!-- 统计面板 -->
|
| 50 |
+
<div id="token-stats"
|
| 51 |
+
hx-get="/admin/api/tokens/stats"
|
| 52 |
+
hx-trigger="load, statsRefresh from:body"
|
| 53 |
+
hx-swap="innerHTML">
|
| 54 |
+
<!-- 加载中 -->
|
| 55 |
+
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
| 56 |
+
<div class="bg-white overflow-hidden shadow rounded-lg animate-pulse">
|
| 57 |
+
<div class="p-5">
|
| 58 |
+
<div class="h-4 bg-gray-200 rounded w-1/2 mb-2"></div>
|
| 59 |
+
<div class="h-8 bg-gray-200 rounded w-1/3"></div>
|
| 60 |
+
</div>
|
| 61 |
+
</div>
|
| 62 |
+
<div class="bg-white overflow-hidden shadow rounded-lg animate-pulse">
|
| 63 |
+
<div class="p-5">
|
| 64 |
+
<div class="h-4 bg-gray-200 rounded w-1/2 mb-2"></div>
|
| 65 |
+
<div class="h-8 bg-gray-200 rounded w-1/3"></div>
|
| 66 |
+
</div>
|
| 67 |
+
</div>
|
| 68 |
+
<div class="bg-white overflow-hidden shadow rounded-lg animate-pulse">
|
| 69 |
+
<div class="p-5">
|
| 70 |
+
<div class="h-4 bg-gray-200 rounded w-1/2 mb-2"></div>
|
| 71 |
+
<div class="h-8 bg-gray-200 rounded w-1/3"></div>
|
| 72 |
+
</div>
|
| 73 |
+
</div>
|
| 74 |
+
<div class="bg-white overflow-hidden shadow rounded-lg animate-pulse">
|
| 75 |
+
<div class="p-5">
|
| 76 |
+
<div class="h-4 bg-gray-200 rounded w-1/2 mb-2"></div>
|
| 77 |
+
<div class="h-8 bg-gray-200 rounded w-1/3"></div>
|
| 78 |
+
</div>
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
</div>
|
| 82 |
+
|
| 83 |
+
<!-- 自动目录导入与维护 -->
|
| 84 |
+
<div id="token-automation" class="grid grid-cols-1 gap-6 xl:grid-cols-2">
|
| 85 |
+
<section class="bg-white shadow rounded-lg p-6 space-y-5">
|
| 86 |
+
<div class="flex items-start justify-between gap-4">
|
| 87 |
+
<div>
|
| 88 |
+
<h3 class="text-lg font-medium text-gray-900">目录导入策略</h3>
|
| 89 |
+
<p class="mt-1 text-sm text-gray-600">
|
| 90 |
+
配置入口已迁移到配置管理页。这里仅展示当前策略,并允许立即执行一次导入。
|
| 91 |
+
</p>
|
| 92 |
+
</div>
|
| 93 |
+
<span class="inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold {% if automation.import_enabled %}bg-emerald-100 text-emerald-700{% else %}bg-gray-100 text-gray-600{% endif %}">
|
| 94 |
+
{{ '定时已开启' if automation.import_enabled else '定时已关闭' }}
|
| 95 |
+
</span>
|
| 96 |
+
</div>
|
| 97 |
+
|
| 98 |
+
{% if automation.has_import_source_dir %}
|
| 99 |
+
<div class="rounded-lg border border-emerald-100 bg-emerald-50 p-4 text-sm text-emerald-700">
|
| 100 |
+
手动导入会复用当前配置的目录和验证逻辑,重复 Token 会自动跳过。
|
| 101 |
+
</div>
|
| 102 |
+
{% else %}
|
| 103 |
+
<div class="rounded-lg border border-amber-100 bg-amber-50 p-4 text-sm text-amber-700">
|
| 104 |
+
还没有配置导入目录,无法执行手动导入。请先到配置管理页设置 `TOKEN_AUTO_IMPORT_SOURCE_DIR`。
|
| 105 |
+
</div>
|
| 106 |
+
{% endif %}
|
| 107 |
+
|
| 108 |
+
<dl class="space-y-4 text-sm">
|
| 109 |
+
<div class="rounded-lg border border-gray-200 p-4">
|
| 110 |
+
<dt class="font-medium text-gray-700">Token 目录</dt>
|
| 111 |
+
<dd class="mt-2 rounded-md bg-gray-50 px-3 py-2 font-mono text-xs text-gray-700 break-all">
|
| 112 |
+
{{ automation.import_source_dir or '未配置' }}
|
| 113 |
+
</dd>
|
| 114 |
+
</div>
|
| 115 |
+
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
| 116 |
+
<div class="rounded-lg border border-gray-200 p-4">
|
| 117 |
+
<dt class="font-medium text-gray-700">扫描间隔</dt>
|
| 118 |
+
<dd class="mt-2 text-gray-900">{{ automation.import_interval }} 秒</dd>
|
| 119 |
+
</div>
|
| 120 |
+
<div class="rounded-lg border border-gray-200 p-4">
|
| 121 |
+
<dt class="font-medium text-gray-700">配置位置</dt>
|
| 122 |
+
<dd class="mt-2 text-gray-900">配置管理 / Token 池策略</dd>
|
| 123 |
+
</div>
|
| 124 |
+
</div>
|
| 125 |
+
</dl>
|
| 126 |
+
|
| 127 |
+
<div class="flex flex-wrap gap-3 pt-2">
|
| 128 |
+
<a href="{{ automation.config_url }}"
|
| 129 |
+
class="px-4 py-2 border border-emerald-200 text-emerald-700 rounded-md text-sm font-medium hover:bg-emerald-50">
|
| 130 |
+
去配置中心修改
|
| 131 |
+
</a>
|
| 132 |
+
<button type="button"
|
| 133 |
+
hx-post="/admin/api/tokens/import-directory"
|
| 134 |
+
hx-target="#notification"
|
| 135 |
+
@htmx:before-request="isImporting = true"
|
| 136 |
+
@htmx:after-request="isImporting = false; if ($event.detail.successful) { htmx.trigger('body', 'tokenListRefresh'); htmx.trigger('body', 'statsRefresh'); }"
|
| 137 |
+
:disabled="isImporting || !hasImportSourceDir"
|
| 138 |
+
class="px-4 py-2 bg-emerald-600 text-white rounded-md text-sm font-medium hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed">
|
| 139 |
+
立即导入当前目录
|
| 140 |
+
</button>
|
| 141 |
+
</div>
|
| 142 |
+
</section>
|
| 143 |
+
|
| 144 |
+
<section class="bg-white shadow rounded-lg p-6 space-y-5">
|
| 145 |
+
<div class="flex items-start justify-between gap-4">
|
| 146 |
+
<div>
|
| 147 |
+
<h3 class="text-lg font-medium text-gray-900">自动维护策略</h3>
|
| 148 |
+
<p class="mt-1 text-sm text-gray-600">
|
| 149 |
+
维护动作和定时间隔统一在配置管理页设置。这里仅执行当前已配置的维护策略。
|
| 150 |
+
</p>
|
| 151 |
+
</div>
|
| 152 |
+
<span class="inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold {% if automation.maintenance_enabled %}bg-blue-100 text-blue-700{% else %}bg-gray-100 text-gray-600{% endif %}">
|
| 153 |
+
{{ '定时已开启' if automation.maintenance_enabled else '定时已关闭' }}
|
| 154 |
+
</span>
|
| 155 |
+
</div>
|
| 156 |
+
|
| 157 |
+
{% if automation.has_maintenance_actions %}
|
| 158 |
+
<div class="rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-700">
|
| 159 |
+
手动维护会按当前配置顺序执行去重、测活和失效清理,不再在本页单独维护另一套选项。
|
| 160 |
+
</div>
|
| 161 |
+
{% else %}
|
| 162 |
+
<div class="rounded-lg border border-amber-100 bg-amber-50 p-4 text-sm text-amber-700">
|
| 163 |
+
当前没有配置任何维护动作。请先到配置管理页勾选至少一个维护动作。
|
| 164 |
+
</div>
|
| 165 |
+
{% endif %}
|
| 166 |
+
|
| 167 |
+
<dl class="space-y-4 text-sm">
|
| 168 |
+
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
| 169 |
+
<div class="rounded-lg border border-gray-200 p-4">
|
| 170 |
+
<dt class="font-medium text-gray-700">维护间隔</dt>
|
| 171 |
+
<dd class="mt-2 text-gray-900">{{ automation.maintenance_interval }} 秒</dd>
|
| 172 |
+
</div>
|
| 173 |
+
<div class="rounded-lg border border-gray-200 p-4">
|
| 174 |
+
<dt class="font-medium text-gray-700">配置位置</dt>
|
| 175 |
+
<dd class="mt-2 text-gray-900">配置管理 / Token 池策略</dd>
|
| 176 |
+
</div>
|
| 177 |
+
</div>
|
| 178 |
+
<div class="rounded-lg border border-gray-200 p-4">
|
| 179 |
+
<dt class="font-medium text-gray-700">当前维护动作</dt>
|
| 180 |
+
<dd class="mt-2 flex flex-wrap gap-2">
|
| 181 |
+
{% if automation.maintenance_actions %}
|
| 182 |
+
{% for action in automation.maintenance_actions %}
|
| 183 |
+
<span class="inline-flex items-center rounded-full bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-700">{{ action }}</span>
|
| 184 |
+
{% endfor %}
|
| 185 |
+
{% else %}
|
| 186 |
+
<span class="text-gray-500">未配置</span>
|
| 187 |
+
{% endif %}
|
| 188 |
+
</dd>
|
| 189 |
+
</div>
|
| 190 |
+
</dl>
|
| 191 |
+
|
| 192 |
+
<div class="flex flex-wrap gap-3 pt-2">
|
| 193 |
+
<a href="{{ automation.config_url }}"
|
| 194 |
+
class="px-4 py-2 border border-blue-200 text-blue-700 rounded-md text-sm font-medium hover:bg-blue-50">
|
| 195 |
+
去配置中心修改
|
| 196 |
+
</a>
|
| 197 |
+
<button type="button"
|
| 198 |
+
hx-post="/admin/api/tokens/maintenance/run"
|
| 199 |
+
hx-target="#notification"
|
| 200 |
+
@htmx:before-request="isRunningMaintenance = true"
|
| 201 |
+
@htmx:after-request="isRunningMaintenance = false; if ($event.detail.successful) { htmx.trigger('body', 'tokenListRefresh'); htmx.trigger('body', 'statsRefresh'); }"
|
| 202 |
+
:disabled="isRunningMaintenance || !hasMaintenanceActions"
|
| 203 |
+
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">
|
| 204 |
+
执行当前维护策略
|
| 205 |
+
</button>
|
| 206 |
+
</div>
|
| 207 |
+
</section>
|
| 208 |
+
</div>
|
| 209 |
+
|
| 210 |
+
<!-- Token 列表 -->
|
| 211 |
+
<div class="bg-white shadow rounded-lg">
|
| 212 |
+
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
| 213 |
+
<h3 class="text-lg font-medium text-gray-900 flex items-center">
|
| 214 |
+
Token 列表
|
| 215 |
+
<span class="ml-2 text-sm font-normal text-gray-500" id="token-count"></span>
|
| 216 |
+
</h3>
|
| 217 |
+
<div class="flex items-center space-x-2">
|
| 218 |
+
<button hx-post="/admin/api/tokens/sync-pool"
|
| 219 |
+
hx-target="#notification"
|
| 220 |
+
class="text-sm text-purple-600 hover:text-purple-700 flex items-center">
|
| 221 |
+
<svg class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 222 |
+
<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" />
|
| 223 |
+
</svg>
|
| 224 |
+
同步 Token 池
|
| 225 |
+
</button>
|
| 226 |
+
<button hx-post="/admin/api/tokens/health-check"
|
| 227 |
+
hx-target="#notification"
|
| 228 |
+
class="text-sm text-blue-600 hover:text-blue-700 flex items-center">
|
| 229 |
+
<svg class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 230 |
+
<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" />
|
| 231 |
+
</svg>
|
| 232 |
+
健康检查
|
| 233 |
+
</button>
|
| 234 |
+
</div>
|
| 235 |
+
</div>
|
| 236 |
+
<div id="token-list"
|
| 237 |
+
hx-get="/admin/api/tokens/list?page=1&page_size=20"
|
| 238 |
+
hx-trigger="load"
|
| 239 |
+
hx-swap="innerHTML">
|
| 240 |
+
<!-- Token 列表内容 -->
|
| 241 |
+
<div class="flex justify-center items-center py-12">
|
| 242 |
+
<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">
|
| 243 |
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
| 244 |
+
<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>
|
| 245 |
+
</svg>
|
| 246 |
+
</div>
|
| 247 |
+
</div>
|
| 248 |
+
</div>
|
| 249 |
+
|
| 250 |
+
<!-- 添加 Token 弹窗 -->
|
| 251 |
+
<div x-show="showAddModal"
|
| 252 |
+
x-transition:enter="transition ease-out duration-300"
|
| 253 |
+
x-transition:enter-start="opacity-0"
|
| 254 |
+
x-transition:enter-end="opacity-100"
|
| 255 |
+
class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50"
|
| 256 |
+
@click.self="showAddModal = false"
|
| 257 |
+
style="display: none;">
|
| 258 |
+
<div class="relative top-20 mx-auto p-5 border w-full max-w-2xl shadow-lg rounded-md bg-white">
|
| 259 |
+
<div class="flex items-center justify-between pb-3 border-b">
|
| 260 |
+
<h3 class="text-lg font-medium">添加 Token</h3>
|
| 261 |
+
<button @click="showAddModal = false" class="text-gray-400 hover:text-gray-600">
|
| 262 |
+
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 263 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
| 264 |
+
</svg>
|
| 265 |
+
</button>
|
| 266 |
+
</div>
|
| 267 |
+
|
| 268 |
+
<div class="mt-4 space-y-4">
|
| 269 |
+
<!-- 提示信息 -->
|
| 270 |
+
<div class="bg-blue-50 border-l-4 border-blue-400 p-4">
|
| 271 |
+
<div class="flex">
|
| 272 |
+
<div class="flex-shrink-0">
|
| 273 |
+
<svg class="h-5 w-5 text-blue-400" fill="currentColor" viewBox="0 0 20 20">
|
| 274 |
+
<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" />
|
| 275 |
+
</svg>
|
| 276 |
+
</div>
|
| 277 |
+
<div class="ml-3">
|
| 278 |
+
<p class="text-sm text-blue-700">
|
| 279 |
+
<strong>Token 验证:</strong>添加时将自动验证 Token 有效性,
|
| 280 |
+
<span class="font-semibold">匿名用户 Token (guest) 将被拒绝</span>。
|
| 281 |
+
</p>
|
| 282 |
+
</div>
|
| 283 |
+
</div>
|
| 284 |
+
</div>
|
| 285 |
+
|
| 286 |
+
<!-- 单个 Token -->
|
| 287 |
+
<div>
|
| 288 |
+
<label class="block text-sm font-medium text-gray-700">单个 Token</label>
|
| 289 |
+
<input type="text"
|
| 290 |
+
x-model="newToken"
|
| 291 |
+
placeholder="输入 Token(以 eyJ 开头的 JWT)"
|
| 292 |
+
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
|
| 293 |
+
</div>
|
| 294 |
+
|
| 295 |
+
<!-- 批量导入 -->
|
| 296 |
+
<div>
|
| 297 |
+
<label class="block text-sm font-medium text-gray-700">批量导入(每行一个)</label>
|
| 298 |
+
<textarea x-model="bulkTokens"
|
| 299 |
+
rows="6"
|
| 300 |
+
placeholder="每行一个 Token,支持逗号分隔 eyJhbGc... eyJhbGc... 或: token1, token2, token3"
|
| 301 |
+
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>
|
| 302 |
+
<p class="mt-1 text-sm text-gray-500">支持格式:每行一个 Token,或使用逗号分隔</p>
|
| 303 |
+
</div>
|
| 304 |
+
|
| 305 |
+
<!-- 提交按钮 -->
|
| 306 |
+
<div class="flex justify-end space-x-3 pt-4 border-t">
|
| 307 |
+
<button @click="showAddModal = false"
|
| 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">
|
| 309 |
+
取消
|
| 310 |
+
</button>
|
| 311 |
+
<button hx-post="/admin/api/tokens/add"
|
| 312 |
+
:hx-vals="JSON.stringify({
|
| 313 |
+
single_token: newToken,
|
| 314 |
+
bulk_tokens: bulkTokens
|
| 315 |
+
})"
|
| 316 |
+
hx-target="#notification"
|
| 317 |
+
@htmx:after-request="showAddModal = false; newToken = ''; bulkTokens = ''; htmx.trigger('body', 'tokenListRefresh'); htmx.trigger('body', 'statsRefresh')"
|
| 318 |
+
class="px-4 py-2 bg-indigo-600 text-white rounded-md text-sm font-medium hover:bg-indigo-700">
|
| 319 |
+
添加
|
| 320 |
+
</button>
|
| 321 |
+
</div>
|
| 322 |
+
</div>
|
| 323 |
+
</div>
|
| 324 |
+
</div>
|
| 325 |
+
|
| 326 |
+
<!-- 批量验证弹窗 -->
|
| 327 |
+
<div x-show="showValidateModal"
|
| 328 |
+
x-transition:enter="transition ease-out duration-300"
|
| 329 |
+
x-transition:enter-start="opacity-0"
|
| 330 |
+
x-transition:enter-end="opacity-100"
|
| 331 |
+
class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50"
|
| 332 |
+
@click.self="showValidateModal = false"
|
| 333 |
+
style="display: none;">
|
| 334 |
+
<div class="relative top-20 mx-auto p-5 border w-full max-w-lg shadow-lg rounded-md bg-white">
|
| 335 |
+
<div class="flex items-center justify-between pb-3 border-b">
|
| 336 |
+
<h3 class="text-lg font-medium">批量验证 Token</h3>
|
| 337 |
+
<button @click="showValidateModal = false" class="text-gray-400 hover:text-gray-600">
|
| 338 |
+
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 339 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
| 340 |
+
</svg>
|
| 341 |
+
</button>
|
| 342 |
+
</div>
|
| 343 |
+
|
| 344 |
+
<div class="mt-4 space-y-4">
|
| 345 |
+
<!-- 警告信息 -->
|
| 346 |
+
<div class="bg-yellow-50 border-l-4 border-yellow-400 p-4">
|
| 347 |
+
<div class="flex">
|
| 348 |
+
<div class="flex-shrink-0">
|
| 349 |
+
<svg class="h-5 w-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
|
| 350 |
+
<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" />
|
| 351 |
+
</svg>
|
| 352 |
+
</div>
|
| 353 |
+
<div class="ml-3">
|
| 354 |
+
<p class="text-sm text-yellow-700">
|
| 355 |
+
将验证当前通道的所有 Token 的有效性。
|
| 356 |
+
<br>此操作可能需要较长时间,请耐心等待。
|
| 357 |
+
</p>
|
| 358 |
+
</div>
|
| 359 |
+
</div>
|
| 360 |
+
</div>
|
| 361 |
+
|
| 362 |
+
<!-- 验证说明 -->
|
| 363 |
+
<div class="text-sm text-gray-600 space-y-2">
|
| 364 |
+
<p><strong>验证内容:</strong></p>
|
| 365 |
+
<ul class="list-disc list-inside space-y-1 ml-4">
|
| 366 |
+
<li>检查 Token 是否有效</li>
|
| 367 |
+
<li>识别 Token 类型(认证用户 / 匿名用户)</li>
|
| 368 |
+
<li>更新数据库中的 Token 类型</li>
|
| 369 |
+
<li>匿名用户 Token 将被标记为不健康</li>
|
| 370 |
+
</ul>
|
| 371 |
+
</div>
|
| 372 |
+
|
| 373 |
+
<!-- 进度显示 -->
|
| 374 |
+
<div id="validate-progress" class="hidden">
|
| 375 |
+
<div class="flex items-center justify-center py-4">
|
| 376 |
+
<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">
|
| 377 |
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
| 378 |
+
<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>
|
| 379 |
+
</svg>
|
| 380 |
+
<span class="ml-3 text-gray-700">验证中...</span>
|
| 381 |
+
</div>
|
| 382 |
+
</div>
|
| 383 |
+
|
| 384 |
+
<!-- 提交按钮 -->
|
| 385 |
+
<div class="flex justify-end space-x-3 pt-4 border-t">
|
| 386 |
+
<button @click="showValidateModal = false"
|
| 387 |
+
:disabled="isValidating"
|
| 388 |
+
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">
|
| 389 |
+
取消
|
| 390 |
+
</button>
|
| 391 |
+
<button hx-post="/admin/api/tokens/validate"
|
| 392 |
+
hx-target="#notification"
|
| 393 |
+
@htmx:before-request="isValidating = true; document.getElementById('validate-progress').classList.remove('hidden')"
|
| 394 |
+
@htmx:after-request="isValidating = false; showValidateModal = false; document.getElementById('validate-progress').classList.add('hidden'); htmx.trigger('body', 'tokenListRefresh'); htmx.trigger('body', 'statsRefresh')"
|
| 395 |
+
:disabled="isValidating"
|
| 396 |
+
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">
|
| 397 |
+
开始验证
|
| 398 |
+
</button>
|
| 399 |
+
</div>
|
| 400 |
+
</div>
|
| 401 |
+
</div>
|
| 402 |
+
</div>
|
| 403 |
+
</div>
|
| 404 |
+
{% endblock %}
|
| 405 |
+
|
| 406 |
+
{% block extra_scripts %}
|
| 407 |
+
<script>
|
| 408 |
+
window.tokenListPage = 1;
|
| 409 |
+
window.tokenListPageSize = 20;
|
| 410 |
+
|
| 411 |
+
function loadTokenListPage(page) {
|
| 412 |
+
const nextPage = Math.max(1, Number(page) || 1);
|
| 413 |
+
window.tokenListPage = nextPage;
|
| 414 |
+
const url = `/admin/api/tokens/list?page=${nextPage}&page_size=${window.tokenListPageSize}`;
|
| 415 |
+
htmx.ajax('GET', url, {
|
| 416 |
+
target: '#token-list',
|
| 417 |
+
swap: 'innerHTML'
|
| 418 |
+
});
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
// 全局通知函数
|
| 422 |
+
function showNotification(message, type = 'success') {
|
| 423 |
+
const notification = document.createElement('div');
|
| 424 |
+
const bgColor = type === 'success' ? 'bg-green-500' : type === 'error' ? 'bg-red-500' : 'bg-blue-500';
|
| 425 |
+
notification.className = `fixed top-4 right-4 ${bgColor} text-white px-6 py-3 rounded-lg shadow-lg z-50 transition-all transform`;
|
| 426 |
+
notification.style.animation = 'slideInRight 0.3s ease-out';
|
| 427 |
+
notification.innerHTML = `
|
| 428 |
+
<div class="flex items-center space-x-2">
|
| 429 |
+
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
| 430 |
+
<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" />
|
| 431 |
+
</svg>
|
| 432 |
+
<span class="font-medium">${message}</span>
|
| 433 |
+
</div>
|
| 434 |
+
`;
|
| 435 |
+
|
| 436 |
+
document.body.appendChild(notification);
|
| 437 |
+
|
| 438 |
+
// 3秒后自动消失
|
| 439 |
+
setTimeout(() => {
|
| 440 |
+
notification.style.opacity = '0';
|
| 441 |
+
notification.style.transform = 'translateX(100%)';
|
| 442 |
+
setTimeout(() => notification.remove(), 300);
|
| 443 |
+
}, 3000);
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
// 添加动画样式
|
| 447 |
+
if (!document.getElementById('notification-styles')) {
|
| 448 |
+
const style = document.createElement('style');
|
| 449 |
+
style.id = 'notification-styles';
|
| 450 |
+
style.textContent = `
|
| 451 |
+
@keyframes slideInRight {
|
| 452 |
+
from {
|
| 453 |
+
opacity: 0;
|
| 454 |
+
transform: translateX(100%);
|
| 455 |
+
}
|
| 456 |
+
to {
|
| 457 |
+
opacity: 1;
|
| 458 |
+
transform: translateX(0);
|
| 459 |
+
}
|
| 460 |
+
}
|
| 461 |
+
`;
|
| 462 |
+
document.head.appendChild(style);
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
// 监听验证按钮的完成事件
|
| 466 |
+
document.body.addEventListener('htmx:afterSwap', function(evt) {
|
| 467 |
+
// 检查是否是验证按钮触发的事件
|
| 468 |
+
if (evt.detail.target && evt.detail.target.id && evt.detail.target.id.startsWith('token-row-')) {
|
| 469 |
+
// 从目标元素提取 token ID
|
| 470 |
+
const tokenId = evt.detail.target.id.replace('token-row-', '');
|
| 471 |
+
|
| 472 |
+
// 检查是否是验证操作(通过查看触发元素)
|
| 473 |
+
const triggerElt = evt.detail.requestConfig?.elt;
|
| 474 |
+
if (triggerElt && triggerElt.classList.contains('validate-token-btn')) {
|
| 475 |
+
showNotification(`✓ Token ID ${tokenId} 验证完成`, 'success');
|
| 476 |
+
|
| 477 |
+
// 同时刷新统计数据
|
| 478 |
+
htmx.trigger('body', 'statsRefresh');
|
| 479 |
+
}
|
| 480 |
+
}
|
| 481 |
+
});
|
| 482 |
+
|
| 483 |
+
document.body.addEventListener('tokenListRefresh', function() {
|
| 484 |
+
loadTokenListPage(window.tokenListPage || 1);
|
| 485 |
+
});
|
| 486 |
+
</script>
|
| 487 |
+
{% 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 reload_config, logger
|
| 5 |
+
|
| 6 |
+
__all__ = ["reload_config", "logger"]
|
app/utils/env_file.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Helpers for updating .env files without dropping unrelated settings."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import re
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Mapping
|
| 8 |
+
|
| 9 |
+
_ENV_KEY_PATTERN = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _serialize_env_value(value: object) -> str:
|
| 13 |
+
if isinstance(value, bool):
|
| 14 |
+
return "true" if value else "false"
|
| 15 |
+
|
| 16 |
+
text = "" if value is None else str(value)
|
| 17 |
+
if not text:
|
| 18 |
+
return ""
|
| 19 |
+
|
| 20 |
+
if any(char.isspace() for char in text) or any(
|
| 21 |
+
char in text for char in ["#", '"', "\\", "'"]
|
| 22 |
+
):
|
| 23 |
+
if "'" not in text:
|
| 24 |
+
return f"'{text}'"
|
| 25 |
+
|
| 26 |
+
escaped = text.replace("\\", "\\\\").replace('"', '\\"')
|
| 27 |
+
return f'"{escaped}"'
|
| 28 |
+
|
| 29 |
+
return text
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def update_env_file(
|
| 33 |
+
updates: Mapping[str, object],
|
| 34 |
+
env_path: str | Path = ".env",
|
| 35 |
+
) -> None:
|
| 36 |
+
"""Update selected keys inside a .env file while preserving other lines."""
|
| 37 |
+
path = Path(env_path)
|
| 38 |
+
lines = path.read_text(encoding="utf-8").splitlines() if path.exists() else []
|
| 39 |
+
remaining_updates = {key: _serialize_env_value(value) for key, value in updates.items()}
|
| 40 |
+
|
| 41 |
+
for index, line in enumerate(lines):
|
| 42 |
+
match = _ENV_KEY_PATTERN.match(line)
|
| 43 |
+
if not match:
|
| 44 |
+
continue
|
| 45 |
+
|
| 46 |
+
key = match.group(1)
|
| 47 |
+
if key not in remaining_updates:
|
| 48 |
+
continue
|
| 49 |
+
|
| 50 |
+
lines[index] = f"{key}={remaining_updates.pop(key)}"
|
| 51 |
+
|
| 52 |
+
if remaining_updates:
|
| 53 |
+
if lines and lines[-1].strip():
|
| 54 |
+
lines.append("")
|
| 55 |
+
for key, value in remaining_updates.items():
|
| 56 |
+
lines.append(f"{key}={value}")
|
| 57 |
+
|
| 58 |
+
content = "\n".join(lines).rstrip()
|
| 59 |
+
path.write_text(f"{content}\n" if content else "", encoding="utf-8")
|
app/utils/fe_version.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
|
| 4 |
+
"""
|
| 5 |
+
Utility helpers for resolving the latest X-FE-Version value from chat.z.ai.
|
| 6 |
+
|
| 7 |
+
The upstream service embeds the current front-end release identifier inside
|
| 8 |
+
its landing page static asset URLs (e.g. `prod-fe-1.0.107`). The helpers in
|
| 9 |
+
this module fetch the landing page, extract the version string, and cache it
|
| 10 |
+
with a configurable TTL so the expensive network fetch only happens when
|
| 11 |
+
necessary.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import re
|
| 17 |
+
import time
|
| 18 |
+
from typing import Optional
|
| 19 |
+
|
| 20 |
+
import httpx
|
| 21 |
+
|
| 22 |
+
from app.utils.logger import get_logger
|
| 23 |
+
from app.utils.user_agent import get_random_user_agent
|
| 24 |
+
|
| 25 |
+
# Base URL to probe for the version string.
|
| 26 |
+
FE_VERSION_SOURCE_URL = "https://chat.z.ai"
|
| 27 |
+
|
| 28 |
+
# Cache TTL in seconds (default: 30 minutes).
|
| 29 |
+
CACHE_TTL_SECONDS = 1800
|
| 30 |
+
|
| 31 |
+
_logger = get_logger()
|
| 32 |
+
_version_pattern = re.compile(r"prod-fe-\d+\.\d+\.\d+")
|
| 33 |
+
|
| 34 |
+
_cached_version: str = ""
|
| 35 |
+
_cached_at: float = 0.0
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _extract_version(page_content: str) -> Optional[str]:
|
| 39 |
+
"""Extract the version string from the page content."""
|
| 40 |
+
if not page_content:
|
| 41 |
+
return None
|
| 42 |
+
|
| 43 |
+
matches = _version_pattern.findall(page_content)
|
| 44 |
+
if not matches:
|
| 45 |
+
return None
|
| 46 |
+
|
| 47 |
+
# Choose the highest lexical value to guard against mixed versions.
|
| 48 |
+
return max(matches)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _should_use_cache(force_refresh: bool) -> bool:
|
| 54 |
+
"""Determine whether the cached value can be reused."""
|
| 55 |
+
if force_refresh:
|
| 56 |
+
return False
|
| 57 |
+
if not _cached_version:
|
| 58 |
+
return False
|
| 59 |
+
if _cached_at <= 0:
|
| 60 |
+
return False
|
| 61 |
+
return (time.time() - _cached_at) < CACHE_TTL_SECONDS
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def get_latest_fe_version(force_refresh: bool = False) -> str:
|
| 65 |
+
"""
|
| 66 |
+
Resolve the latest X-FE-Version value from chat.z.ai.
|
| 67 |
+
|
| 68 |
+
The lookup order is:
|
| 69 |
+
1. Cached value within TTL.
|
| 70 |
+
2. Remote fetch from chat.z.ai.
|
| 71 |
+
|
| 72 |
+
Raises:
|
| 73 |
+
Exception: If unable to fetch the version from the remote source.
|
| 74 |
+
"""
|
| 75 |
+
global _cached_version, _cached_at
|
| 76 |
+
|
| 77 |
+
if _should_use_cache(force_refresh):
|
| 78 |
+
return _cached_version
|
| 79 |
+
|
| 80 |
+
try:
|
| 81 |
+
headers = {"User-Agent": get_random_user_agent("chrome")}
|
| 82 |
+
except Exception:
|
| 83 |
+
headers = {
|
| 84 |
+
"User-Agent": (
|
| 85 |
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
| 86 |
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
| 87 |
+
"Chrome/120.0.0.0 Safari/537.36"
|
| 88 |
+
)
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
try:
|
| 92 |
+
with httpx.Client(timeout=10.0, follow_redirects=True) as client:
|
| 93 |
+
response = client.get(FE_VERSION_SOURCE_URL, headers=headers)
|
| 94 |
+
response.raise_for_status()
|
| 95 |
+
version = _extract_version(response.text)
|
| 96 |
+
if version:
|
| 97 |
+
if version != _cached_version:
|
| 98 |
+
_logger.info(f"[Z.AI] Detected X-FE-Version update: {version}")
|
| 99 |
+
_cached_version = version
|
| 100 |
+
_cached_at = time.time()
|
| 101 |
+
return version
|
| 102 |
+
|
| 103 |
+
_logger.error("[Z.AI] Unable to locate X-FE-Version in landing page")
|
| 104 |
+
raise Exception("Unable to locate X-FE-Version in landing page")
|
| 105 |
+
except Exception as exc:
|
| 106 |
+
_logger.error(f"[Z.AI] Failed to fetch X-FE-Version from {FE_VERSION_SOURCE_URL}: {exc}")
|
| 107 |
+
raise Exception(f"Failed to fetch X-FE-Version: {exc}")
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def refresh_fe_version() -> str:
|
| 111 |
+
"""Force refresh the cached version by bypassing the TTL."""
|
| 112 |
+
return get_latest_fe_version(force_refresh=True)
|
app/utils/guest_session_pool.py
ADDED
|
@@ -0,0 +1,646 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
|
| 4 |
+
"""匿名访客会话池。"""
|
| 5 |
+
|
| 6 |
+
import asyncio
|
| 7 |
+
import random
|
| 8 |
+
import time
|
| 9 |
+
from dataclasses import dataclass, field
|
| 10 |
+
from threading import Lock
|
| 11 |
+
from typing import Dict, List, Optional, Set
|
| 12 |
+
|
| 13 |
+
import httpx
|
| 14 |
+
|
| 15 |
+
from app.core.config import settings
|
| 16 |
+
from app.utils.fe_version import get_latest_fe_version
|
| 17 |
+
from app.utils.logger import logger
|
| 18 |
+
from app.utils.user_agent import get_random_user_agent
|
| 19 |
+
|
| 20 |
+
AUTH_URL = "https://chat.z.ai/api/v1/auths/"
|
| 21 |
+
CHATS_URL = "https://chat.z.ai/api/v1/chats/"
|
| 22 |
+
AUTH_HTTP_MAX_KEEPALIVE_CONNECTIONS = 20
|
| 23 |
+
AUTH_HTTP_MAX_CONNECTIONS = 50
|
| 24 |
+
GUEST_SESSION_TTL_SECONDS = 480
|
| 25 |
+
GUEST_SESSION_TTL_JITTER_SECONDS = 60
|
| 26 |
+
GUEST_SESSION_MIN_TTL_SECONDS = 180
|
| 27 |
+
GUEST_POOL_MAINTENANCE_INTERVAL_SECONDS = 30
|
| 28 |
+
GUEST_CLEANUP_PARALLELISM = 4
|
| 29 |
+
CAPACITY_FILL_ATTEMPT_MULTIPLIER = 3
|
| 30 |
+
CAPACITY_FILL_MIN_ATTEMPTS = 3
|
| 31 |
+
MAX_DUPLICATE_LOG_USER_IDS = 3
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _get_proxy_config() -> Optional[str]:
|
| 35 |
+
"""获取代理配置。"""
|
| 36 |
+
if settings.HTTPS_PROXY:
|
| 37 |
+
return settings.HTTPS_PROXY
|
| 38 |
+
if settings.HTTP_PROXY:
|
| 39 |
+
return settings.HTTP_PROXY
|
| 40 |
+
if settings.SOCKS5_PROXY:
|
| 41 |
+
return settings.SOCKS5_PROXY
|
| 42 |
+
return None
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _build_timeout(read_timeout: float = 30.0) -> httpx.Timeout:
|
| 46 |
+
"""构建访客会话相关请求超时。"""
|
| 47 |
+
return httpx.Timeout(
|
| 48 |
+
connect=5.0,
|
| 49 |
+
read=read_timeout,
|
| 50 |
+
write=10.0,
|
| 51 |
+
pool=5.0,
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _build_limits() -> httpx.Limits:
|
| 56 |
+
"""构建访客会话相关连接池限制。"""
|
| 57 |
+
return httpx.Limits(
|
| 58 |
+
max_keepalive_connections=AUTH_HTTP_MAX_KEEPALIVE_CONNECTIONS,
|
| 59 |
+
max_connections=AUTH_HTTP_MAX_CONNECTIONS,
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _build_async_client(read_timeout: float = 30.0) -> httpx.AsyncClient:
|
| 64 |
+
"""构建访客会话相关 HTTP 客户端。"""
|
| 65 |
+
return httpx.AsyncClient(
|
| 66 |
+
timeout=_build_timeout(read_timeout),
|
| 67 |
+
follow_redirects=True,
|
| 68 |
+
limits=_build_limits(),
|
| 69 |
+
proxy=_get_proxy_config(),
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _build_dynamic_headers(chat_id: str = "") -> Dict[str, str]:
|
| 74 |
+
"""生成匿名访客鉴权所需浏览器请求头。"""
|
| 75 |
+
browser_choices = [
|
| 76 |
+
"chrome",
|
| 77 |
+
"chrome",
|
| 78 |
+
"chrome",
|
| 79 |
+
"edge",
|
| 80 |
+
"edge",
|
| 81 |
+
"firefox",
|
| 82 |
+
"safari",
|
| 83 |
+
]
|
| 84 |
+
browser_type = random.choice(browser_choices)
|
| 85 |
+
user_agent = get_random_user_agent(browser_type)
|
| 86 |
+
fe_version = get_latest_fe_version()
|
| 87 |
+
|
| 88 |
+
chrome_version = "139"
|
| 89 |
+
edge_version = "139"
|
| 90 |
+
|
| 91 |
+
if "Chrome/" in user_agent:
|
| 92 |
+
try:
|
| 93 |
+
chrome_version = user_agent.split("Chrome/")[1].split(".")[0]
|
| 94 |
+
except Exception:
|
| 95 |
+
pass
|
| 96 |
+
|
| 97 |
+
if "Edg/" in user_agent:
|
| 98 |
+
try:
|
| 99 |
+
edge_version = user_agent.split("Edg/")[1].split(".")[0]
|
| 100 |
+
sec_ch_ua = (
|
| 101 |
+
f'"Microsoft Edge";v="{edge_version}", '
|
| 102 |
+
f'"Chromium";v="{chrome_version}", "Not_A Brand";v="24"'
|
| 103 |
+
)
|
| 104 |
+
except Exception:
|
| 105 |
+
sec_ch_ua = (
|
| 106 |
+
f'"Not_A Brand";v="8", "Chromium";v="{chrome_version}", '
|
| 107 |
+
f'"Google Chrome";v="{chrome_version}"'
|
| 108 |
+
)
|
| 109 |
+
elif "Firefox/" in user_agent:
|
| 110 |
+
sec_ch_ua = None
|
| 111 |
+
else:
|
| 112 |
+
sec_ch_ua = (
|
| 113 |
+
f'"Not_A Brand";v="8", "Chromium";v="{chrome_version}", '
|
| 114 |
+
f'"Google Chrome";v="{chrome_version}"'
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
headers = {
|
| 118 |
+
"Content-Type": "application/json",
|
| 119 |
+
"Accept": "application/json, text/event-stream",
|
| 120 |
+
"Connection": "keep-alive",
|
| 121 |
+
"Cache-Control": "no-cache",
|
| 122 |
+
"User-Agent": user_agent,
|
| 123 |
+
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
| 124 |
+
"X-FE-Version": fe_version,
|
| 125 |
+
"Origin": "https://chat.z.ai",
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
if sec_ch_ua:
|
| 129 |
+
headers["sec-ch-ua"] = sec_ch_ua
|
| 130 |
+
headers["sec-ch-ua-mobile"] = "?0"
|
| 131 |
+
headers["sec-ch-ua-platform"] = '"Windows"'
|
| 132 |
+
|
| 133 |
+
if chat_id:
|
| 134 |
+
headers["Referer"] = f"https://chat.z.ai/c/{chat_id}"
|
| 135 |
+
else:
|
| 136 |
+
headers["Referer"] = "https://chat.z.ai/"
|
| 137 |
+
|
| 138 |
+
return headers
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def _build_session_expiry() -> float:
|
| 142 |
+
"""为新会话分配带抖动的过期时间,避免整池同时失效。"""
|
| 143 |
+
jitter = random.uniform(
|
| 144 |
+
-GUEST_SESSION_TTL_JITTER_SECONDS,
|
| 145 |
+
GUEST_SESSION_TTL_JITTER_SECONDS,
|
| 146 |
+
)
|
| 147 |
+
ttl_seconds = max(
|
| 148 |
+
GUEST_SESSION_MIN_TTL_SECONDS,
|
| 149 |
+
GUEST_SESSION_TTL_SECONDS + jitter,
|
| 150 |
+
)
|
| 151 |
+
return time.time() + ttl_seconds
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
@dataclass
|
| 155 |
+
class GuestSession:
|
| 156 |
+
"""单个匿名访客会话。"""
|
| 157 |
+
|
| 158 |
+
token: str
|
| 159 |
+
user_id: str
|
| 160 |
+
username: str
|
| 161 |
+
created_at: float = field(default_factory=time.time)
|
| 162 |
+
expires_at: float = field(default_factory=_build_session_expiry)
|
| 163 |
+
active_requests: int = 0
|
| 164 |
+
valid: bool = True
|
| 165 |
+
failure_count: int = 0
|
| 166 |
+
last_failure_time: float = 0.0
|
| 167 |
+
|
| 168 |
+
@property
|
| 169 |
+
def age(self) -> float:
|
| 170 |
+
"""会话存活时间。"""
|
| 171 |
+
return time.time() - self.created_at
|
| 172 |
+
|
| 173 |
+
@property
|
| 174 |
+
def is_expired(self) -> bool:
|
| 175 |
+
"""判断会话是否��过期。"""
|
| 176 |
+
return time.time() >= self.expires_at
|
| 177 |
+
|
| 178 |
+
def snapshot(self) -> Dict[str, str]:
|
| 179 |
+
"""获取当前会话快照。"""
|
| 180 |
+
return {
|
| 181 |
+
"token": self.token,
|
| 182 |
+
"user_id": self.user_id,
|
| 183 |
+
"username": self.username,
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
class GuestSessionPool:
|
| 188 |
+
"""匿名访客会话池,支持最小负载获取与失败替换。"""
|
| 189 |
+
|
| 190 |
+
def __init__(self, pool_size: int = 3):
|
| 191 |
+
self.pool_size = max(1, pool_size)
|
| 192 |
+
self._lock = Lock()
|
| 193 |
+
self._sessions: Dict[str, GuestSession] = {}
|
| 194 |
+
self._maintenance_task: Optional[asyncio.Task] = None
|
| 195 |
+
self._http_client: Optional[httpx.AsyncClient] = None
|
| 196 |
+
self._client_lock = asyncio.Lock()
|
| 197 |
+
self._capacity_lock = asyncio.Lock()
|
| 198 |
+
self._background_tasks: Set[asyncio.Task] = set()
|
| 199 |
+
self._cleanup_parallelism = GUEST_CLEANUP_PARALLELISM
|
| 200 |
+
self._maintenance_interval = GUEST_POOL_MAINTENANCE_INTERVAL_SECONDS
|
| 201 |
+
|
| 202 |
+
async def _get_http_client(self) -> httpx.AsyncClient:
|
| 203 |
+
"""获取可复用的 HTTP 客户端,减少频繁建连开销。"""
|
| 204 |
+
if self._http_client is not None:
|
| 205 |
+
return self._http_client
|
| 206 |
+
|
| 207 |
+
async with self._client_lock:
|
| 208 |
+
if self._http_client is None:
|
| 209 |
+
self._http_client = _build_async_client()
|
| 210 |
+
return self._http_client
|
| 211 |
+
|
| 212 |
+
async def _close_http_client(self):
|
| 213 |
+
"""关闭可复用的 HTTP 客户端。"""
|
| 214 |
+
async with self._client_lock:
|
| 215 |
+
client = self._http_client
|
| 216 |
+
self._http_client = None
|
| 217 |
+
|
| 218 |
+
if client is not None:
|
| 219 |
+
await client.aclose()
|
| 220 |
+
|
| 221 |
+
def _track_background_task(self, coro) -> asyncio.Task:
|
| 222 |
+
"""跟踪后台任务,避免清理阻塞前台重试路径。"""
|
| 223 |
+
task = asyncio.create_task(coro)
|
| 224 |
+
self._background_tasks.add(task)
|
| 225 |
+
|
| 226 |
+
def _on_done(done_task: asyncio.Task):
|
| 227 |
+
self._background_tasks.discard(done_task)
|
| 228 |
+
try:
|
| 229 |
+
done_task.result()
|
| 230 |
+
except asyncio.CancelledError:
|
| 231 |
+
pass
|
| 232 |
+
except Exception as exc:
|
| 233 |
+
logger.warning(f"⚠️ 匿名会话后台任务异常: {exc}")
|
| 234 |
+
|
| 235 |
+
task.add_done_callback(_on_done)
|
| 236 |
+
return task
|
| 237 |
+
|
| 238 |
+
async def _wait_background_tasks(self):
|
| 239 |
+
"""等待当前已注册的后台任务结束。"""
|
| 240 |
+
pending = list(self._background_tasks)
|
| 241 |
+
if pending:
|
| 242 |
+
await asyncio.gather(*pending, return_exceptions=True)
|
| 243 |
+
|
| 244 |
+
async def _delete_sessions_concurrently(self, sessions: List[GuestSession]):
|
| 245 |
+
"""并发清理多枚匿名会话,加快池维护速度。"""
|
| 246 |
+
if not sessions:
|
| 247 |
+
return
|
| 248 |
+
|
| 249 |
+
semaphore = asyncio.Semaphore(self._cleanup_parallelism)
|
| 250 |
+
|
| 251 |
+
async def _cleanup(session: GuestSession):
|
| 252 |
+
async with semaphore:
|
| 253 |
+
await self._delete_all_chats(session)
|
| 254 |
+
|
| 255 |
+
await asyncio.gather(*(_cleanup(session) for session in sessions))
|
| 256 |
+
|
| 257 |
+
async def _create_session(self) -> GuestSession:
|
| 258 |
+
"""创建一个新的匿名访客会话。"""
|
| 259 |
+
headers = _build_dynamic_headers()
|
| 260 |
+
|
| 261 |
+
# 访客鉴权会写入 cookie,复用同一个 client 会把“新建会话”粘回旧访客身份。
|
| 262 |
+
async with _build_async_client() as auth_client:
|
| 263 |
+
response = await auth_client.get(AUTH_URL, headers=headers)
|
| 264 |
+
|
| 265 |
+
if response.status_code != 200:
|
| 266 |
+
raise RuntimeError(
|
| 267 |
+
f"匿名会话创建失败: HTTP {response.status_code} {response.text[:200]}"
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
data = response.json()
|
| 271 |
+
token = str(data.get("token") or "").strip()
|
| 272 |
+
user_id = str(
|
| 273 |
+
data.get("id") or data.get("user_id") or data.get("uid") or ""
|
| 274 |
+
).strip()
|
| 275 |
+
username = str(
|
| 276 |
+
data.get("name")
|
| 277 |
+
or str(data.get("email") or "").split("@")[0]
|
| 278 |
+
or f"guest-{user_id[:8] or 'session'}"
|
| 279 |
+
).strip()
|
| 280 |
+
|
| 281 |
+
if not token:
|
| 282 |
+
raise RuntimeError(f"匿名会话创建失败: 未返回 token {data}")
|
| 283 |
+
if not user_id:
|
| 284 |
+
user_id = f"guest-{token[:12]}"
|
| 285 |
+
|
| 286 |
+
logger.info(
|
| 287 |
+
f"🫥 创建匿名会话成功: user_id={user_id}, username={username or 'Guest'}"
|
| 288 |
+
)
|
| 289 |
+
return GuestSession(
|
| 290 |
+
token=token,
|
| 291 |
+
user_id=user_id,
|
| 292 |
+
username=username or "Guest",
|
| 293 |
+
)
|
| 294 |
+
|
| 295 |
+
async def _delete_all_chats(self, session: GuestSession) -> bool:
|
| 296 |
+
"""删除匿名会话的全部对话,尽量释放并发占用。"""
|
| 297 |
+
headers = _build_dynamic_headers()
|
| 298 |
+
headers.update(
|
| 299 |
+
{
|
| 300 |
+
"Authorization": f"Bearer {session.token}",
|
| 301 |
+
"Accept": "application/json",
|
| 302 |
+
"Content-Type": "application/json",
|
| 303 |
+
}
|
| 304 |
+
)
|
| 305 |
+
|
| 306 |
+
try:
|
| 307 |
+
client = await self._get_http_client()
|
| 308 |
+
response = await client.delete(CHATS_URL, headers=headers)
|
| 309 |
+
|
| 310 |
+
if response.status_code == 200:
|
| 311 |
+
logger.info(f"🧹 已���理匿名会话聊天记录: {session.user_id}")
|
| 312 |
+
return True
|
| 313 |
+
|
| 314 |
+
logger.warning(
|
| 315 |
+
f"⚠️ 清理匿名会话聊天记录失败: {session.user_id}, "
|
| 316 |
+
f"HTTP {response.status_code}, body={response.text[:200]}"
|
| 317 |
+
)
|
| 318 |
+
except Exception as exc:
|
| 319 |
+
logger.warning(f"⚠️ 清理匿名会话聊天记录异常: {session.user_id}, {exc}")
|
| 320 |
+
|
| 321 |
+
return False
|
| 322 |
+
|
| 323 |
+
def _list_valid_sessions(
|
| 324 |
+
self,
|
| 325 |
+
exclude_user_ids: Optional[Set[str]] = None,
|
| 326 |
+
) -> List[GuestSession]:
|
| 327 |
+
"""获取有效匿名会话列表。"""
|
| 328 |
+
excluded = exclude_user_ids or set()
|
| 329 |
+
with self._lock:
|
| 330 |
+
return [
|
| 331 |
+
session
|
| 332 |
+
for session in self._sessions.values()
|
| 333 |
+
if self._is_session_usable(session)
|
| 334 |
+
and session.user_id not in excluded
|
| 335 |
+
]
|
| 336 |
+
|
| 337 |
+
def _is_session_usable(self, session: GuestSession) -> bool:
|
| 338 |
+
"""判断会话当前是否还能继续分配。"""
|
| 339 |
+
return session.valid and not session.is_expired
|
| 340 |
+
|
| 341 |
+
def _should_retire_session(self, session: GuestSession) -> bool:
|
| 342 |
+
"""判断会话是否应当从池中回收。"""
|
| 343 |
+
return session.active_requests == 0 and not self._is_session_usable(session)
|
| 344 |
+
|
| 345 |
+
def _can_replace_session(self, session: GuestSession) -> bool:
|
| 346 |
+
"""判断当前池内会话是否允许被新的同 user_id 会话替换。"""
|
| 347 |
+
return self._should_retire_session(session)
|
| 348 |
+
|
| 349 |
+
def _store_session(self, session: GuestSession) -> bool:
|
| 350 |
+
"""仅在会话唯一或旧会话已过期时写入会话池。"""
|
| 351 |
+
with self._lock:
|
| 352 |
+
existing = self._sessions.get(session.user_id)
|
| 353 |
+
if existing and not self._can_replace_session(existing):
|
| 354 |
+
return False
|
| 355 |
+
self._sessions[session.user_id] = session
|
| 356 |
+
return True
|
| 357 |
+
|
| 358 |
+
def _log_duplicate_sessions(self, action: str, user_ids: List[str]):
|
| 359 |
+
"""记录重复会话,避免补池时静默覆盖。"""
|
| 360 |
+
if not user_ids:
|
| 361 |
+
return
|
| 362 |
+
|
| 363 |
+
sample = ", ".join(user_ids[:MAX_DUPLICATE_LOG_USER_IDS])
|
| 364 |
+
logger.warning(
|
| 365 |
+
f"⚠️ 匿名会话池{action}收到重复会话,已忽略: "
|
| 366 |
+
f"count={len(user_ids)}, user_ids={sample}"
|
| 367 |
+
)
|
| 368 |
+
|
| 369 |
+
def _register_create_results(self, action: str, results: List[object]) -> int:
|
| 370 |
+
"""写入新创建的会话,并显式忽略重复 user_id。"""
|
| 371 |
+
created = 0
|
| 372 |
+
duplicate_user_ids: List[str] = []
|
| 373 |
+
|
| 374 |
+
for result in results:
|
| 375 |
+
if isinstance(result, GuestSession):
|
| 376 |
+
if self._store_session(result):
|
| 377 |
+
created += 1
|
| 378 |
+
else:
|
| 379 |
+
duplicate_user_ids.append(result.user_id)
|
| 380 |
+
continue
|
| 381 |
+
|
| 382 |
+
if isinstance(result, Exception):
|
| 383 |
+
logger.warning(f"⚠️ 匿名会话池{action}失败: {result}")
|
| 384 |
+
|
| 385 |
+
self._log_duplicate_sessions(action, duplicate_user_ids)
|
| 386 |
+
return created
|
| 387 |
+
|
| 388 |
+
def _get_fill_attempt_budget(self, missing_count: int) -> int:
|
| 389 |
+
"""为补池/获取会话计算显式尝试上限,避免重复会话导致死循环。"""
|
| 390 |
+
scaled_budget = max(1, missing_count) * CAPACITY_FILL_ATTEMPT_MULTIPLIER
|
| 391 |
+
minimum_budget = max(1, missing_count) + CAPACITY_FILL_MIN_ATTEMPTS
|
| 392 |
+
return max(scaled_budget, minimum_budget)
|
| 393 |
+
|
| 394 |
+
def _pop_retired_sessions(self) -> List[GuestSession]:
|
| 395 |
+
"""移除当前所有可回收的失效会话。"""
|
| 396 |
+
retired_sessions: List[GuestSession] = []
|
| 397 |
+
|
| 398 |
+
with self._lock:
|
| 399 |
+
for user_id, session in list(self._sessions.items()):
|
| 400 |
+
if self._should_retire_session(session):
|
| 401 |
+
retired_sessions.append(self._sessions.pop(user_id))
|
| 402 |
+
|
| 403 |
+
return retired_sessions
|
| 404 |
+
|
| 405 |
+
async def _ensure_capacity(self):
|
| 406 |
+
"""补齐匿名会话池容量。"""
|
| 407 |
+
async with self._capacity_lock:
|
| 408 |
+
attempts_left = self._get_fill_attempt_budget(
|
| 409 |
+
self.pool_size - len(self._list_valid_sessions())
|
| 410 |
+
)
|
| 411 |
+
|
| 412 |
+
while attempts_left > 0:
|
| 413 |
+
need = self.pool_size - len(self._list_valid_sessions())
|
| 414 |
+
if need <= 0:
|
| 415 |
+
return
|
| 416 |
+
|
| 417 |
+
batch_size = min(need, attempts_left)
|
| 418 |
+
results = await asyncio.gather(
|
| 419 |
+
*[self._create_session() for _ in range(batch_size)],
|
| 420 |
+
return_exceptions=True,
|
| 421 |
+
)
|
| 422 |
+
attempts_left -= batch_size
|
| 423 |
+
|
| 424 |
+
created = self._register_create_results("补齐", results)
|
| 425 |
+
if created == 0 and attempts_left == 0:
|
| 426 |
+
break
|
| 427 |
+
|
| 428 |
+
remaining = self.pool_size - len(self._list_valid_sessions())
|
| 429 |
+
if remaining > 0:
|
| 430 |
+
logger.warning(
|
| 431 |
+
"⚠️ 匿名会话池补齐未达到目标容量: "
|
| 432 |
+
f"missing={remaining}, current={len(self._list_valid_sessions())}"
|
| 433 |
+
)
|
| 434 |
+
|
| 435 |
+
async def _maintenance_loop(self):
|
| 436 |
+
"""后台维护:回收过期/失效会话,并补齐池容量。"""
|
| 437 |
+
while True:
|
| 438 |
+
try:
|
| 439 |
+
await asyncio.sleep(self._maintenance_interval)
|
| 440 |
+
retired_sessions = self._pop_retired_sessions()
|
| 441 |
+
await self._delete_sessions_concurrently(retired_sessions)
|
| 442 |
+
|
| 443 |
+
await self._ensure_capacity()
|
| 444 |
+
except asyncio.CancelledError:
|
| 445 |
+
return
|
| 446 |
+
except Exception as exc:
|
| 447 |
+
logger.warning(f"⚠️ 匿名会话池后台维护异常: {exc}")
|
| 448 |
+
|
| 449 |
+
async def initialize(self):
|
| 450 |
+
"""初始化匿名会话池。"""
|
| 451 |
+
if self._maintenance_task:
|
| 452 |
+
return
|
| 453 |
+
|
| 454 |
+
await self._ensure_capacity()
|
| 455 |
+
created = len(self._list_valid_sessions())
|
| 456 |
+
|
| 457 |
+
if created == 0:
|
| 458 |
+
fallback = await self._create_session()
|
| 459 |
+
if not self._store_session(fallback):
|
| 460 |
+
raise RuntimeError(
|
| 461 |
+
"匿名会话池初始化失败: 无法写入唯一匿名会话"
|
| 462 |
+
)
|
| 463 |
+
created = len(self._list_valid_sessions())
|
| 464 |
+
|
| 465 |
+
logger.info(f"✅ 匿名会话池初始化完成: {created} 个会话")
|
| 466 |
+
self._maintenance_task = asyncio.create_task(self._maintenance_loop())
|
| 467 |
+
|
| 468 |
+
async def close(self):
|
| 469 |
+
"""关闭匿名会话池。"""
|
| 470 |
+
if self._maintenance_task:
|
| 471 |
+
self._maintenance_task.cancel()
|
| 472 |
+
try:
|
| 473 |
+
await self._maintenance_task
|
| 474 |
+
except asyncio.CancelledError:
|
| 475 |
+
pass
|
| 476 |
+
self._maintenance_task = None
|
| 477 |
+
|
| 478 |
+
with self._lock:
|
| 479 |
+
sessions = list(self._sessions.values())
|
| 480 |
+
self._sessions.clear()
|
| 481 |
+
|
| 482 |
+
await self._wait_background_tasks()
|
| 483 |
+
idle_sessions = [
|
| 484 |
+
session for session in sessions if session.active_requests == 0
|
| 485 |
+
]
|
| 486 |
+
await self._delete_sessions_concurrently(idle_sessions)
|
| 487 |
+
await self._close_http_client()
|
| 488 |
+
|
| 489 |
+
async def acquire(
|
| 490 |
+
self,
|
| 491 |
+
exclude_user_ids: Optional[Set[str]] = None,
|
| 492 |
+
) -> GuestSession:
|
| 493 |
+
"""按最小忙碌度获取一个可用匿名会话。"""
|
| 494 |
+
excluded = exclude_user_ids or set()
|
| 495 |
+
attempts_left = self._get_fill_attempt_budget(len(excluded) + 1)
|
| 496 |
+
|
| 497 |
+
while attempts_left > 0:
|
| 498 |
+
candidates = self._list_valid_sessions(exclude_user_ids=excluded)
|
| 499 |
+
if candidates:
|
| 500 |
+
session = min(
|
| 501 |
+
candidates,
|
| 502 |
+
key=lambda item: (item.active_requests, item.created_at),
|
| 503 |
+
)
|
| 504 |
+
with self._lock:
|
| 505 |
+
current = self._sessions.get(session.user_id)
|
| 506 |
+
if (
|
| 507 |
+
current
|
| 508 |
+
and self._is_session_usable(current)
|
| 509 |
+
and current.user_id not in excluded
|
| 510 |
+
):
|
| 511 |
+
current.active_requests += 1
|
| 512 |
+
return current
|
| 513 |
+
|
| 514 |
+
new_session = await self._create_session()
|
| 515 |
+
attempts_left -= 1
|
| 516 |
+
if new_session.user_id in excluded:
|
| 517 |
+
logger.warning(
|
| 518 |
+
"⚠️ 获取匿名会话时命中排除 user_id,已忽略: "
|
| 519 |
+
f"{new_session.user_id}"
|
| 520 |
+
)
|
| 521 |
+
continue
|
| 522 |
+
|
| 523 |
+
if not self._store_session(new_session):
|
| 524 |
+
logger.warning(
|
| 525 |
+
"⚠️ 获取匿名会话时命中重复 user_id,已重试: "
|
| 526 |
+
f"{new_session.user_id}"
|
| 527 |
+
)
|
| 528 |
+
continue
|
| 529 |
+
|
| 530 |
+
with self._lock:
|
| 531 |
+
current = self._sessions.get(new_session.user_id)
|
| 532 |
+
if current and self._is_session_usable(current):
|
| 533 |
+
current.active_requests += 1
|
| 534 |
+
return current
|
| 535 |
+
|
| 536 |
+
raise RuntimeError("匿名会话池获取失败: 未能创建唯一匿名会话")
|
| 537 |
+
|
| 538 |
+
def release(self, user_id: str):
|
| 539 |
+
"""释放一个匿名会话占用。"""
|
| 540 |
+
retired_session: Optional[GuestSession] = None
|
| 541 |
+
|
| 542 |
+
with self._lock:
|
| 543 |
+
session = self._sessions.get(user_id)
|
| 544 |
+
if session:
|
| 545 |
+
session.active_requests = max(0, session.active_requests - 1)
|
| 546 |
+
if self._should_retire_session(session):
|
| 547 |
+
retired_session = self._sessions.pop(user_id)
|
| 548 |
+
|
| 549 |
+
if retired_session:
|
| 550 |
+
logger.info(f"🧹 已回收过期匿名会话: {retired_session.user_id}")
|
| 551 |
+
self._track_background_task(self._delete_all_chats(retired_session))
|
| 552 |
+
self._track_background_task(self._ensure_capacity())
|
| 553 |
+
|
| 554 |
+
async def report_failure(self, user_id: Optional[str] = None):
|
| 555 |
+
"""标记匿名会话失效,并尝试补一个新会话。"""
|
| 556 |
+
session: Optional[GuestSession] = None
|
| 557 |
+
|
| 558 |
+
if user_id:
|
| 559 |
+
with self._lock:
|
| 560 |
+
session = self._sessions.pop(user_id, None)
|
| 561 |
+
if session:
|
| 562 |
+
session.valid = False
|
| 563 |
+
session.failure_count += 1
|
| 564 |
+
session.last_failure_time = time.time()
|
| 565 |
+
session.active_requests = 0
|
| 566 |
+
|
| 567 |
+
if session:
|
| 568 |
+
self._track_background_task(self._delete_all_chats(session))
|
| 569 |
+
logger.warning(f"⚠️ 已淘汰匿名会话: {session.user_id}")
|
| 570 |
+
|
| 571 |
+
await self._ensure_capacity()
|
| 572 |
+
|
| 573 |
+
async def refresh_auth(self, failed_user_id: Optional[str] = None):
|
| 574 |
+
"""兼容 glm-demo 命名:刷新匿名会话。"""
|
| 575 |
+
await self.report_failure(failed_user_id)
|
| 576 |
+
|
| 577 |
+
async def cleanup_idle_chats(self):
|
| 578 |
+
"""清理当前空闲匿名会话的聊天记录。"""
|
| 579 |
+
with self._lock:
|
| 580 |
+
idle_sessions = [
|
| 581 |
+
session
|
| 582 |
+
for session in self._sessions.values()
|
| 583 |
+
if self._is_session_usable(session) and session.active_requests == 0
|
| 584 |
+
]
|
| 585 |
+
|
| 586 |
+
await self._delete_sessions_concurrently(idle_sessions)
|
| 587 |
+
|
| 588 |
+
def get_pool_status(self) -> Dict[str, int]:
|
| 589 |
+
"""获取匿名会话池状态。"""
|
| 590 |
+
with self._lock:
|
| 591 |
+
sessions = list(self._sessions.values())
|
| 592 |
+
|
| 593 |
+
valid_sessions = [
|
| 594 |
+
session for session in sessions if self._is_session_usable(session)
|
| 595 |
+
]
|
| 596 |
+
busy_sessions = [
|
| 597 |
+
session for session in valid_sessions if session.active_requests > 0
|
| 598 |
+
]
|
| 599 |
+
|
| 600 |
+
return {
|
| 601 |
+
"total_sessions": len(sessions),
|
| 602 |
+
"valid_sessions": len(valid_sessions),
|
| 603 |
+
"available_sessions": len(
|
| 604 |
+
[session for session in valid_sessions if session.active_requests == 0]
|
| 605 |
+
),
|
| 606 |
+
"busy_sessions": len(busy_sessions),
|
| 607 |
+
"expired_sessions": len(
|
| 608 |
+
[session for session in sessions if session.is_expired]
|
| 609 |
+
),
|
| 610 |
+
}
|
| 611 |
+
|
| 612 |
+
|
| 613 |
+
_guest_session_pool: Optional[GuestSessionPool] = None
|
| 614 |
+
_guest_pool_lock = Lock()
|
| 615 |
+
|
| 616 |
+
|
| 617 |
+
def get_guest_session_pool() -> Optional[GuestSessionPool]:
|
| 618 |
+
"""获取全局匿名会话池。"""
|
| 619 |
+
return _guest_session_pool
|
| 620 |
+
|
| 621 |
+
|
| 622 |
+
async def initialize_guest_session_pool(
|
| 623 |
+
pool_size: int = 3,
|
| 624 |
+
) -> GuestSessionPool:
|
| 625 |
+
"""初始化全局匿名会话池。"""
|
| 626 |
+
global _guest_session_pool
|
| 627 |
+
|
| 628 |
+
with _guest_pool_lock:
|
| 629 |
+
if _guest_session_pool is None:
|
| 630 |
+
_guest_session_pool = GuestSessionPool(pool_size=pool_size)
|
| 631 |
+
pool = _guest_session_pool
|
| 632 |
+
|
| 633 |
+
await pool.initialize()
|
| 634 |
+
return pool
|
| 635 |
+
|
| 636 |
+
|
| 637 |
+
async def close_guest_session_pool():
|
| 638 |
+
"""关闭全局匿名会话池。"""
|
| 639 |
+
global _guest_session_pool
|
| 640 |
+
|
| 641 |
+
with _guest_pool_lock:
|
| 642 |
+
pool = _guest_session_pool
|
| 643 |
+
_guest_session_pool = None
|
| 644 |
+
|
| 645 |
+
if pool:
|
| 646 |
+
await pool.close()
|
app/utils/logger.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
except (PermissionError, OSError) as e:
|
| 60 |
+
# 如果无法创建日志目录或文件,降级为仅控制台输出
|
| 61 |
+
logger.warning(f"⚠️ 无法创建日志文件 ({e}),将仅使用控制台输出")
|
| 62 |
+
|
| 63 |
+
app_logger = logger
|
| 64 |
+
|
| 65 |
+
return logger
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def get_logger():
|
| 69 |
+
"""Get the logger instance"""
|
| 70 |
+
global app_logger
|
| 71 |
+
if app_logger is None:
|
| 72 |
+
# 如果没有设置过logger,使用默认配置
|
| 73 |
+
logger.remove() # 移除所有现有处理器
|
| 74 |
+
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>")
|
| 75 |
+
app_logger = logger
|
| 76 |
+
return app_logger
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
if __name__ == "__main__":
|
| 80 |
+
"""Test the logger"""
|
| 81 |
+
import tempfile
|
| 82 |
+
|
| 83 |
+
with tempfile.TemporaryDirectory() as temp_dir:
|
| 84 |
+
try:
|
| 85 |
+
setup_logger(temp_dir, debug_mode=True)
|
| 86 |
+
|
| 87 |
+
logger.debug("这是一条调试日志")
|
| 88 |
+
logger.info("这是一条信息日志")
|
| 89 |
+
logger.warning("这是一条警告日志")
|
| 90 |
+
logger.error("这是一条错误日志")
|
| 91 |
+
logger.critical("这是一条严重日志")
|
| 92 |
+
|
| 93 |
+
try:
|
| 94 |
+
1 / 0
|
| 95 |
+
except ZeroDivisionError:
|
| 96 |
+
logger.exception("发生了除零异常")
|
| 97 |
+
|
| 98 |
+
print("✅ 日志测试完成")
|
| 99 |
+
|
| 100 |
+
logger.remove()
|
| 101 |
+
|
| 102 |
+
except Exception as e:
|
| 103 |
+
print(f"❌ 日志测试失败: {e}")
|
| 104 |
+
logger.remove()
|
| 105 |
+
raise
|
app/utils/reload_config.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
".github", # 忽略 GitHub 相关目录
|
| 16 |
+
".vscode", # 忽略 VSCode 配置目录
|
| 17 |
+
"deploy", # 忽略部署相关目录
|
| 18 |
+
".idea", # 忽略 IntelliJ IDEA 配置目录
|
| 19 |
+
"node_modules", # 忽略 node_modules
|
| 20 |
+
"migrations", # 忽略数据库迁移目录
|
| 21 |
+
".pytest_cache", # 忽略 pytest 缓存
|
| 22 |
+
".venv", # 忽略虚拟环境
|
| 23 |
+
"venv", # 忽略虚拟环境
|
| 24 |
+
"env", # 忽略环境目录
|
| 25 |
+
".mypy_cache", # 忽略 mypy 缓存
|
| 26 |
+
".ruff_cache", # 忽略 ruff 缓存
|
| 27 |
+
"dist", # 忽略构建分发目录
|
| 28 |
+
"build", # 忽略构建目录
|
| 29 |
+
".coverage", # 忽略测试覆盖率文件
|
| 30 |
+
"htmlcov", # 忽略覆盖率报告目录
|
| 31 |
+
"tests", # 忽略测试目录
|
| 32 |
+
"z-ai2api-server.pid", # 忽略 PID 文件
|
| 33 |
+
"app\\templates" # 忽略模板目录
|
| 34 |
+
]
|
| 35 |
+
|
| 36 |
+
# 忽略的文件模式(正则表达式)
|
| 37 |
+
RELOAD_IGNORE_PATTERNS = [
|
| 38 |
+
# 日志文件
|
| 39 |
+
r".*\.log$",
|
| 40 |
+
r".*\.log\.\d+$",
|
| 41 |
+
# 数据库文件
|
| 42 |
+
r".*\.sqlite3.*",
|
| 43 |
+
r".*\.db$",
|
| 44 |
+
r".*\.db-.*$",
|
| 45 |
+
# Python 相关
|
| 46 |
+
r".*\.pyc$",
|
| 47 |
+
r".*\.pyo$",
|
| 48 |
+
r".*\.pyd$",
|
| 49 |
+
# 临时文件
|
| 50 |
+
r".*\.tmp$",
|
| 51 |
+
r".*\.temp$",
|
| 52 |
+
r".*\.swp$",
|
| 53 |
+
r".*\.swo$",
|
| 54 |
+
r".*~$",
|
| 55 |
+
# 系统文件
|
| 56 |
+
r".*\.DS_Store$",
|
| 57 |
+
r".*Thumbs\.db$",
|
| 58 |
+
r".*\.directory$",
|
| 59 |
+
# 编辑器文件
|
| 60 |
+
r".*\.vscode.*",
|
| 61 |
+
r".*\.idea.*",
|
| 62 |
+
# 测试和覆盖率
|
| 63 |
+
r".*\.coverage$",
|
| 64 |
+
r".*\.pytest_cache.*",
|
| 65 |
+
# 构建文件
|
| 66 |
+
r".*\.egg-info.*",
|
| 67 |
+
r".*\.wheel$",
|
| 68 |
+
r".*\.whl$",
|
| 69 |
+
# 版本控制
|
| 70 |
+
r".*\.git.*",
|
| 71 |
+
r".*\.gitignore$",
|
| 72 |
+
r".*\.gitkeep$",
|
| 73 |
+
# 配置文件备份
|
| 74 |
+
r".*\.bak$",
|
| 75 |
+
r".*\.backup$",
|
| 76 |
+
r".*\.orig$",
|
| 77 |
+
# 锁文件
|
| 78 |
+
r".*\.lock$",
|
| 79 |
+
r".*\.pid$",
|
| 80 |
+
]
|
| 81 |
+
|
| 82 |
+
# 监视的路径(只监视应用相关代码)
|
| 83 |
+
RELOAD_WATCH_PATHS = [
|
| 84 |
+
"app", # 应用主目录
|
| 85 |
+
"main.py", # 主入口文件
|
| 86 |
+
]
|
| 87 |
+
|
| 88 |
+
# 热重载配置
|
| 89 |
+
RELOAD_CONFIG = {
|
| 90 |
+
"reload_ignore_dirs": RELOAD_IGNORE_DIRS,
|
| 91 |
+
"reload_ignore_patterns": RELOAD_IGNORE_PATTERNS,
|
| 92 |
+
"reload_paths": RELOAD_WATCH_PATHS,
|
| 93 |
+
"reload_tick": 500, # 监视频率(毫秒)
|
| 94 |
+
}
|
app/utils/request_logging.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
|
| 4 |
+
"""请求日志写库与流式日志包装。"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
import time
|
| 10 |
+
from typing import Any, AsyncGenerator, Dict, Optional
|
| 11 |
+
|
| 12 |
+
from app.services.request_log_dao import get_request_log_dao
|
| 13 |
+
from app.utils.logger import get_logger
|
| 14 |
+
from app.utils.request_source import RequestSourceInfo
|
| 15 |
+
|
| 16 |
+
logger = get_logger()
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _coerce_int(value: Any) -> int:
|
| 20 |
+
try:
|
| 21 |
+
return int(value or 0)
|
| 22 |
+
except (TypeError, ValueError):
|
| 23 |
+
return 0
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _merge_usage(
|
| 27 |
+
current: Dict[str, int],
|
| 28 |
+
update: Dict[str, int],
|
| 29 |
+
*,
|
| 30 |
+
include_cache_in_total: bool,
|
| 31 |
+
) -> Dict[str, int]:
|
| 32 |
+
merged = dict(current)
|
| 33 |
+
|
| 34 |
+
for key in (
|
| 35 |
+
"input_tokens",
|
| 36 |
+
"output_tokens",
|
| 37 |
+
"cache_creation_tokens",
|
| 38 |
+
"cache_read_tokens",
|
| 39 |
+
):
|
| 40 |
+
value = _coerce_int(update.get(key))
|
| 41 |
+
if value > 0:
|
| 42 |
+
merged[key] = value
|
| 43 |
+
|
| 44 |
+
total_tokens = _coerce_int(update.get("total_tokens"))
|
| 45 |
+
if total_tokens > 0:
|
| 46 |
+
merged["total_tokens"] = total_tokens
|
| 47 |
+
return merged
|
| 48 |
+
|
| 49 |
+
merged["total_tokens"] = (
|
| 50 |
+
merged["input_tokens"] + merged["output_tokens"]
|
| 51 |
+
)
|
| 52 |
+
if include_cache_in_total:
|
| 53 |
+
merged["total_tokens"] += (
|
| 54 |
+
merged["cache_creation_tokens"] + merged["cache_read_tokens"]
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
return merged
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def extract_openai_usage(response: Dict[str, Any]) -> Dict[str, int]:
|
| 61 |
+
"""Extract usage from an OpenAI-compatible response payload."""
|
| 62 |
+
usage = response.get("usage") or {}
|
| 63 |
+
prompt_details = usage.get("prompt_tokens_details") or {}
|
| 64 |
+
input_details = usage.get("input_token_details") or {}
|
| 65 |
+
|
| 66 |
+
input_tokens = _coerce_int(
|
| 67 |
+
usage.get("prompt_tokens") or usage.get("input_tokens")
|
| 68 |
+
)
|
| 69 |
+
output_tokens = _coerce_int(
|
| 70 |
+
usage.get("completion_tokens") or usage.get("output_tokens")
|
| 71 |
+
)
|
| 72 |
+
cache_creation_tokens = _coerce_int(
|
| 73 |
+
usage.get("cache_creation_input_tokens")
|
| 74 |
+
or prompt_details.get("cache_creation_tokens")
|
| 75 |
+
or input_details.get("cache_creation_input_tokens")
|
| 76 |
+
or input_details.get("cache_creation_tokens")
|
| 77 |
+
)
|
| 78 |
+
cache_read_tokens = _coerce_int(
|
| 79 |
+
usage.get("cache_read_input_tokens")
|
| 80 |
+
or prompt_details.get("cached_tokens")
|
| 81 |
+
or prompt_details.get("cache_read_tokens")
|
| 82 |
+
or input_details.get("cached_tokens")
|
| 83 |
+
or input_details.get("cache_read_input_tokens")
|
| 84 |
+
or input_details.get("cache_read_tokens")
|
| 85 |
+
)
|
| 86 |
+
total_tokens = _coerce_int(usage.get("total_tokens"))
|
| 87 |
+
if total_tokens <= 0:
|
| 88 |
+
total_tokens = input_tokens + output_tokens
|
| 89 |
+
|
| 90 |
+
return {
|
| 91 |
+
"input_tokens": input_tokens,
|
| 92 |
+
"output_tokens": output_tokens,
|
| 93 |
+
"cache_creation_tokens": cache_creation_tokens,
|
| 94 |
+
"cache_read_tokens": cache_read_tokens,
|
| 95 |
+
"total_tokens": total_tokens,
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def extract_claude_usage(response: Dict[str, Any]) -> Dict[str, int]:
|
| 100 |
+
"""Extract usage from a Claude-compatible response payload."""
|
| 101 |
+
usage = response.get("usage") or {}
|
| 102 |
+
input_tokens = _coerce_int(
|
| 103 |
+
usage.get("input_tokens") or usage.get("prompt_tokens")
|
| 104 |
+
)
|
| 105 |
+
output_tokens = _coerce_int(
|
| 106 |
+
usage.get("output_tokens") or usage.get("completion_tokens")
|
| 107 |
+
)
|
| 108 |
+
cache_creation_tokens = _coerce_int(
|
| 109 |
+
usage.get("cache_creation_input_tokens")
|
| 110 |
+
or usage.get("cache_creation_tokens")
|
| 111 |
+
)
|
| 112 |
+
cache_read_tokens = _coerce_int(
|
| 113 |
+
usage.get("cache_read_input_tokens")
|
| 114 |
+
or usage.get("cached_tokens")
|
| 115 |
+
or usage.get("cache_read_tokens")
|
| 116 |
+
)
|
| 117 |
+
total_tokens = _coerce_int(usage.get("total_tokens"))
|
| 118 |
+
if total_tokens <= 0:
|
| 119 |
+
total_tokens = (
|
| 120 |
+
input_tokens
|
| 121 |
+
+ output_tokens
|
| 122 |
+
+ cache_creation_tokens
|
| 123 |
+
+ cache_read_tokens
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
return {
|
| 127 |
+
"input_tokens": input_tokens,
|
| 128 |
+
"output_tokens": output_tokens,
|
| 129 |
+
"cache_creation_tokens": cache_creation_tokens,
|
| 130 |
+
"cache_read_tokens": cache_read_tokens,
|
| 131 |
+
"total_tokens": total_tokens,
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
async def write_request_log(
|
| 136 |
+
*,
|
| 137 |
+
provider: str,
|
| 138 |
+
model: str,
|
| 139 |
+
source_info: RequestSourceInfo,
|
| 140 |
+
success: bool,
|
| 141 |
+
started_at: float,
|
| 142 |
+
status_code: int = 200,
|
| 143 |
+
first_token_time: float = 0.0,
|
| 144 |
+
input_tokens: int = 0,
|
| 145 |
+
output_tokens: int = 0,
|
| 146 |
+
cache_creation_tokens: int = 0,
|
| 147 |
+
cache_read_tokens: int = 0,
|
| 148 |
+
total_tokens: Optional[int] = None,
|
| 149 |
+
error_message: Optional[str] = None,
|
| 150 |
+
) -> None:
|
| 151 |
+
"""Persist a request log entry without breaking request handling."""
|
| 152 |
+
duration = max(0.0, time.perf_counter() - started_at)
|
| 153 |
+
try:
|
| 154 |
+
dao = get_request_log_dao()
|
| 155 |
+
await dao.add_log(
|
| 156 |
+
provider=provider,
|
| 157 |
+
endpoint=source_info.endpoint,
|
| 158 |
+
source=source_info.source,
|
| 159 |
+
protocol=source_info.protocol,
|
| 160 |
+
client_name=source_info.client_name,
|
| 161 |
+
model=model,
|
| 162 |
+
status_code=status_code,
|
| 163 |
+
success=success,
|
| 164 |
+
duration=duration,
|
| 165 |
+
first_token_time=first_token_time,
|
| 166 |
+
input_tokens=input_tokens,
|
| 167 |
+
output_tokens=output_tokens,
|
| 168 |
+
cache_creation_tokens=cache_creation_tokens,
|
| 169 |
+
cache_read_tokens=cache_read_tokens,
|
| 170 |
+
total_tokens=total_tokens,
|
| 171 |
+
error_message=error_message,
|
| 172 |
+
)
|
| 173 |
+
except Exception as exc:
|
| 174 |
+
logger.error(f"写入请求日志失败: {exc}")
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def _openai_payload_has_output(payload: Dict[str, Any]) -> bool:
|
| 178 |
+
choice = ((payload.get("choices") or [{}])[0]) if isinstance(payload, dict) else {}
|
| 179 |
+
delta = choice.get("delta") or {}
|
| 180 |
+
return bool(
|
| 181 |
+
delta.get("content")
|
| 182 |
+
or delta.get("reasoning_content")
|
| 183 |
+
or delta.get("tool_calls")
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
async def wrap_openai_stream_with_logging(
|
| 188 |
+
stream: AsyncGenerator[str, None],
|
| 189 |
+
*,
|
| 190 |
+
provider: str,
|
| 191 |
+
model: str,
|
| 192 |
+
source_info: RequestSourceInfo,
|
| 193 |
+
started_at: float,
|
| 194 |
+
) -> AsyncGenerator[str, None]:
|
| 195 |
+
"""Wrap OpenAI SSE stream and persist completion metadata."""
|
| 196 |
+
success = True
|
| 197 |
+
status_code = 200
|
| 198 |
+
error_message: Optional[str] = None
|
| 199 |
+
first_token_time = 0.0
|
| 200 |
+
usage = {
|
| 201 |
+
"input_tokens": 0,
|
| 202 |
+
"output_tokens": 0,
|
| 203 |
+
"cache_creation_tokens": 0,
|
| 204 |
+
"cache_read_tokens": 0,
|
| 205 |
+
"total_tokens": 0,
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
try:
|
| 209 |
+
async for chunk in stream:
|
| 210 |
+
if chunk.startswith("data: "):
|
| 211 |
+
payload_text = chunk[6:].strip()
|
| 212 |
+
if payload_text and payload_text != "[DONE]":
|
| 213 |
+
try:
|
| 214 |
+
payload = json.loads(payload_text)
|
| 215 |
+
except json.JSONDecodeError:
|
| 216 |
+
payload = None
|
| 217 |
+
|
| 218 |
+
if isinstance(payload, dict):
|
| 219 |
+
if "error" in payload:
|
| 220 |
+
success = False
|
| 221 |
+
error = payload.get("error") or {}
|
| 222 |
+
error_message = (
|
| 223 |
+
error.get("message")
|
| 224 |
+
or "Unknown stream error"
|
| 225 |
+
)
|
| 226 |
+
status_code = int(error.get("code") or 500)
|
| 227 |
+
else:
|
| 228 |
+
if (
|
| 229 |
+
not first_token_time
|
| 230 |
+
and _openai_payload_has_output(payload)
|
| 231 |
+
):
|
| 232 |
+
first_token_time = max(
|
| 233 |
+
0.0,
|
| 234 |
+
time.perf_counter() - started_at,
|
| 235 |
+
)
|
| 236 |
+
if payload.get("usage"):
|
| 237 |
+
usage = _merge_usage(
|
| 238 |
+
usage,
|
| 239 |
+
extract_openai_usage(payload),
|
| 240 |
+
include_cache_in_total=False,
|
| 241 |
+
)
|
| 242 |
+
|
| 243 |
+
yield chunk
|
| 244 |
+
except Exception as exc:
|
| 245 |
+
success = False
|
| 246 |
+
status_code = 500
|
| 247 |
+
error_message = str(exc)
|
| 248 |
+
raise
|
| 249 |
+
finally:
|
| 250 |
+
await write_request_log(
|
| 251 |
+
provider=provider,
|
| 252 |
+
model=model,
|
| 253 |
+
source_info=source_info,
|
| 254 |
+
success=success,
|
| 255 |
+
started_at=started_at,
|
| 256 |
+
status_code=status_code,
|
| 257 |
+
first_token_time=first_token_time,
|
| 258 |
+
input_tokens=usage["input_tokens"],
|
| 259 |
+
output_tokens=usage["output_tokens"],
|
| 260 |
+
cache_creation_tokens=usage["cache_creation_tokens"],
|
| 261 |
+
cache_read_tokens=usage["cache_read_tokens"],
|
| 262 |
+
total_tokens=usage["total_tokens"],
|
| 263 |
+
error_message=error_message,
|
| 264 |
+
)
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
async def wrap_claude_stream_with_logging(
|
| 268 |
+
stream: AsyncGenerator[str, None],
|
| 269 |
+
*,
|
| 270 |
+
provider: str,
|
| 271 |
+
model: str,
|
| 272 |
+
source_info: RequestSourceInfo,
|
| 273 |
+
started_at: float,
|
| 274 |
+
input_tokens: int,
|
| 275 |
+
) -> AsyncGenerator[str, None]:
|
| 276 |
+
"""Wrap Claude SSE stream and persist completion metadata."""
|
| 277 |
+
success = True
|
| 278 |
+
status_code = 200
|
| 279 |
+
error_message: Optional[str] = None
|
| 280 |
+
first_token_time = 0.0
|
| 281 |
+
usage = {
|
| 282 |
+
"input_tokens": input_tokens,
|
| 283 |
+
"output_tokens": 0,
|
| 284 |
+
"cache_creation_tokens": 0,
|
| 285 |
+
"cache_read_tokens": 0,
|
| 286 |
+
"total_tokens": input_tokens,
|
| 287 |
+
}
|
| 288 |
+
current_event: Optional[str] = None
|
| 289 |
+
|
| 290 |
+
try:
|
| 291 |
+
async for chunk in stream:
|
| 292 |
+
if chunk.startswith("event: "):
|
| 293 |
+
current_event = chunk[7:].strip()
|
| 294 |
+
elif chunk.startswith("data: "):
|
| 295 |
+
payload_text = chunk[6:].strip()
|
| 296 |
+
try:
|
| 297 |
+
payload = json.loads(payload_text)
|
| 298 |
+
except json.JSONDecodeError:
|
| 299 |
+
payload = None
|
| 300 |
+
|
| 301 |
+
if isinstance(payload, dict):
|
| 302 |
+
if current_event == "content_block_delta" and not first_token_time:
|
| 303 |
+
first_token_time = max(0.0, time.perf_counter() - started_at)
|
| 304 |
+
if payload.get("usage"):
|
| 305 |
+
usage = _merge_usage(
|
| 306 |
+
usage,
|
| 307 |
+
extract_claude_usage(payload),
|
| 308 |
+
include_cache_in_total=True,
|
| 309 |
+
)
|
| 310 |
+
elif current_event == "error":
|
| 311 |
+
success = False
|
| 312 |
+
status_code = 500
|
| 313 |
+
error = payload.get("error") or {}
|
| 314 |
+
error_message = error.get("message") or "Claude stream error"
|
| 315 |
+
|
| 316 |
+
yield chunk
|
| 317 |
+
except Exception as exc:
|
| 318 |
+
success = False
|
| 319 |
+
status_code = 500
|
| 320 |
+
error_message = str(exc)
|
| 321 |
+
raise
|
| 322 |
+
finally:
|
| 323 |
+
await write_request_log(
|
| 324 |
+
provider=provider,
|
| 325 |
+
model=model,
|
| 326 |
+
source_info=source_info,
|
| 327 |
+
success=success,
|
| 328 |
+
started_at=started_at,
|
| 329 |
+
status_code=status_code,
|
| 330 |
+
first_token_time=first_token_time,
|
| 331 |
+
input_tokens=usage["input_tokens"],
|
| 332 |
+
output_tokens=usage["output_tokens"],
|
| 333 |
+
cache_creation_tokens=usage["cache_creation_tokens"],
|
| 334 |
+
cache_read_tokens=usage["cache_read_tokens"],
|
| 335 |
+
total_tokens=usage["total_tokens"],
|
| 336 |
+
error_message=error_message,
|
| 337 |
+
)
|
app/utils/request_source.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
|
| 4 |
+
"""请求来源识别辅助函数。"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import re
|
| 9 |
+
from dataclasses import dataclass
|
| 10 |
+
from typing import Any, Optional
|
| 11 |
+
|
| 12 |
+
from fastapi import Request
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
ANTHROPIC_MODEL_PREFIXES = (
|
| 16 |
+
"claude-",
|
| 17 |
+
"claude.",
|
| 18 |
+
)
|
| 19 |
+
ANTHROPIC_MODEL_ALIASES = {
|
| 20 |
+
"sonnet",
|
| 21 |
+
"opus",
|
| 22 |
+
"haiku",
|
| 23 |
+
"opusplan",
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@dataclass(frozen=True)
|
| 28 |
+
class RequestSourceInfo:
|
| 29 |
+
"""Normalized request-source metadata for logging."""
|
| 30 |
+
|
| 31 |
+
source: str
|
| 32 |
+
protocol: str
|
| 33 |
+
client_name: str
|
| 34 |
+
endpoint: str
|
| 35 |
+
user_agent: str
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _normalize_source_name(value: str) -> str:
|
| 39 |
+
normalized = re.sub(r"[^a-zA-Z0-9._-]+", "_", value.strip().lower())
|
| 40 |
+
return normalized.strip("_") or "unknown"
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _looks_like_anthropic_model(model_hint: Optional[str]) -> bool:
|
| 44 |
+
if not isinstance(model_hint, str):
|
| 45 |
+
return False
|
| 46 |
+
|
| 47 |
+
normalized = model_hint.strip().casefold()
|
| 48 |
+
if normalized in ANTHROPIC_MODEL_ALIASES:
|
| 49 |
+
return True
|
| 50 |
+
|
| 51 |
+
return normalized.startswith(ANTHROPIC_MODEL_PREFIXES)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def detect_request_source(
|
| 55 |
+
request: Request,
|
| 56 |
+
protocol_hint: Optional[str] = None,
|
| 57 |
+
model_hint: Optional[str] = None,
|
| 58 |
+
) -> RequestSourceInfo:
|
| 59 |
+
"""Detect the request source from headers, path, and model hints."""
|
| 60 |
+
headers = request.headers
|
| 61 |
+
endpoint = request.url.path
|
| 62 |
+
user_agent = (headers.get("user-agent") or "").strip()
|
| 63 |
+
user_agent_normalized = user_agent.casefold()
|
| 64 |
+
|
| 65 |
+
protocol = (protocol_hint or "").strip().lower()
|
| 66 |
+
if not protocol:
|
| 67 |
+
if headers.get("anthropic-version") or "/messages" in endpoint:
|
| 68 |
+
protocol = "anthropic"
|
| 69 |
+
elif "/chat/completions" in endpoint:
|
| 70 |
+
protocol = "openai"
|
| 71 |
+
else:
|
| 72 |
+
protocol = "unknown"
|
| 73 |
+
|
| 74 |
+
explicit_source = headers.get("x-request-source") or headers.get("x-client-source")
|
| 75 |
+
if explicit_source:
|
| 76 |
+
source = _normalize_source_name(explicit_source)
|
| 77 |
+
return RequestSourceInfo(
|
| 78 |
+
source=source,
|
| 79 |
+
protocol=protocol,
|
| 80 |
+
client_name=explicit_source.strip(),
|
| 81 |
+
endpoint=endpoint,
|
| 82 |
+
user_agent=user_agent,
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
if any(token in user_agent_normalized for token in ("claude-code", "claude code", "claude-cli", "claude/")):
|
| 86 |
+
source = "claude_code"
|
| 87 |
+
client_name = "Claude Code"
|
| 88 |
+
elif "anthropic" in user_agent_normalized:
|
| 89 |
+
source = "anthropic_sdk"
|
| 90 |
+
client_name = "Anthropic SDK"
|
| 91 |
+
elif "openai" in user_agent_normalized:
|
| 92 |
+
source = "openai_sdk"
|
| 93 |
+
client_name = "OpenAI SDK"
|
| 94 |
+
elif "curl/" in user_agent_normalized:
|
| 95 |
+
source = "curl"
|
| 96 |
+
client_name = "curl"
|
| 97 |
+
elif any(token in user_agent_normalized for token in ("python-httpx", "httpx/", "python-requests", "requests/")):
|
| 98 |
+
source = "custom_http_client"
|
| 99 |
+
client_name = "HTTP Client"
|
| 100 |
+
elif "mozilla/" in user_agent_normalized:
|
| 101 |
+
source = "browser"
|
| 102 |
+
client_name = "Browser"
|
| 103 |
+
elif protocol == "anthropic":
|
| 104 |
+
source = "claude_family" if _looks_like_anthropic_model(model_hint) else "anthropic_compatible"
|
| 105 |
+
client_name = "Claude/Anthropic Compatible"
|
| 106 |
+
elif protocol == "openai":
|
| 107 |
+
source = "openai_compatible"
|
| 108 |
+
client_name = "OpenAI Compatible"
|
| 109 |
+
else:
|
| 110 |
+
source = "unknown"
|
| 111 |
+
client_name = "Unknown"
|
| 112 |
+
|
| 113 |
+
return RequestSourceInfo(
|
| 114 |
+
source=source,
|
| 115 |
+
protocol=protocol,
|
| 116 |
+
client_name=client_name,
|
| 117 |
+
endpoint=endpoint,
|
| 118 |
+
user_agent=user_agent,
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def format_request_source(info: RequestSourceInfo) -> str:
|
| 123 |
+
"""Render request-source metadata into a compact log prefix."""
|
| 124 |
+
return (
|
| 125 |
+
f"[source={info.source}]"
|
| 126 |
+
f"[protocol={info.protocol}]"
|
| 127 |
+
f"[client={info.client_name}]"
|
| 128 |
+
f"[endpoint={info.endpoint}]"
|
| 129 |
+
)
|
app/utils/signature.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
|
| 4 |
+
"""
|
| 5 |
+
Z.AI 签名工具模块
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import hmac
|
| 9 |
+
import hashlib
|
| 10 |
+
import base64
|
| 11 |
+
from typing import Dict
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def generate_signature(e: str, t: str, s: int) -> dict:
|
| 15 |
+
"""Generate signature matching JavaScript zs function.
|
| 16 |
+
|
| 17 |
+
Args:
|
| 18 |
+
e: canonical metadata string, e.g. "requestId,<uuid>,timestamp,<ms>,user_id,<id>"
|
| 19 |
+
t: latest user message text that feeds into the signature prompt (may be empty)
|
| 20 |
+
s: timestamp in milliseconds
|
| 21 |
+
|
| 22 |
+
Returns:
|
| 23 |
+
Dictionary with signature and timestamp
|
| 24 |
+
"""
|
| 25 |
+
# r = Number(s) - convert to number (already a number in Python)
|
| 26 |
+
r = s
|
| 27 |
+
# i = s - timestamp as string
|
| 28 |
+
i = str(s)
|
| 29 |
+
|
| 30 |
+
# n = new TextEncoder
|
| 31 |
+
# a = n.encode(t)
|
| 32 |
+
a = t.encode('utf-8')
|
| 33 |
+
|
| 34 |
+
# w = btoa(String.fromCharCode(...a))
|
| 35 |
+
# This is equivalent to base64 encoding the UTF-8 bytes
|
| 36 |
+
w = base64.b64encode(a).decode('ascii')
|
| 37 |
+
|
| 38 |
+
# c = `${e}|${w}|${i}`
|
| 39 |
+
c = f"{e}|{w}|{i}"
|
| 40 |
+
|
| 41 |
+
# E = Math.floor(r / (5 * 60 * 1e3))
|
| 42 |
+
E = r // (5 * 60 * 1000)
|
| 43 |
+
|
| 44 |
+
# A = CryptoJS.HmacSHA256(`${E}`, "key-@@@@)))()((9))-xxxx&&&%%%%%")
|
| 45 |
+
secret = "key-@@@@)))()((9))-xxxx&&&%%%%%"
|
| 46 |
+
A = hmac.new(secret.encode('utf-8'), str(E).encode('utf-8'), hashlib.sha256).hexdigest()
|
| 47 |
+
|
| 48 |
+
# k = CryptoJS.HmacSHA256(c, A).toString()
|
| 49 |
+
k = hmac.new(A.encode('utf-8'), c.encode('utf-8'), hashlib.sha256).hexdigest()
|
| 50 |
+
|
| 51 |
+
# return n.encode(c), { signature: k, timestamp: i }
|
| 52 |
+
# Note: n.encode(c) is not used in the return value, so we ignore it
|
| 53 |
+
return {
|
| 54 |
+
"signature": k,
|
| 55 |
+
"timestamp": i
|
| 56 |
+
}
|