sanbo110 commited on
Commit
47258ea
·
1 Parent(s): 2b3b428

update sth at 2025-10-16 14:55:36

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.example +53 -0
  2. .gitattributes +2 -35
  3. .github/workflows/docker.yml +64 -0
  4. .gitignore +180 -0
  5. Dockerfile +16 -16
  6. LICENSE +21 -0
  7. app/__init__.py +6 -0
  8. app/admin/__init__.py +3 -0
  9. app/admin/api.py +728 -0
  10. app/admin/auth.py +129 -0
  11. app/admin/routes.py +116 -0
  12. app/core/__init__.py +6 -0
  13. app/core/config.py +95 -0
  14. app/core/openai.py +189 -0
  15. app/models/__init__.py +6 -0
  16. app/models/request_log.py +31 -0
  17. app/models/schemas.py +151 -0
  18. app/models/token_db.py +48 -0
  19. app/providers/__init__.py +26 -0
  20. app/providers/base.py +268 -0
  21. app/providers/k2think_provider.py +509 -0
  22. app/providers/longcat_provider.py +466 -0
  23. app/providers/provider_factory.py +208 -0
  24. app/providers/zai_provider.py +1152 -0
  25. app/services/request_log_dao.py +267 -0
  26. app/services/token_dao.py +480 -0
  27. app/templates/base.html +201 -0
  28. app/templates/components/provider_status.html +78 -0
  29. app/templates/components/recent_logs.html +50 -0
  30. app/templates/components/token_list.html +80 -0
  31. app/templates/components/token_pool.html +40 -0
  32. app/templates/components/token_row.html +153 -0
  33. app/templates/components/token_stats.html +125 -0
  34. app/templates/config.html +222 -0
  35. app/templates/index.html +174 -0
  36. app/templates/login.html +143 -0
  37. app/templates/monitor.html +83 -0
  38. app/templates/tokens.html +391 -0
  39. app/utils/__init__.py +6 -0
  40. app/utils/logger.py +106 -0
  41. app/utils/reload_config.py +89 -0
  42. app/utils/sse_tool_handler.py +612 -0
  43. app/utils/token_pool.py +598 -0
  44. app/utils/tool_call_handler.py +347 -0
  45. app/utils/user_agent.py +133 -0
  46. deploy/.dockerignore +54 -0
  47. deploy/.env.example +35 -0
  48. deploy/Dockerfile +24 -0
  49. deploy/NGINX_SETUP.md +278 -0
  50. deploy/README_DOCKER.md +357 -0
.env.example ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 代理服务配置文件示例
2
+ # 复制此文件为 .env 并根据需要修改配置值
3
+
4
+ # ========== API 基础配置 ==========
5
+ # 客户端认证密钥(您自定义的 API 密钥,用于客户端访问本服务)
6
+ AUTH_TOKEN=sk-your-api-key
7
+
8
+ # 跳过客户端认证(仅开发环境使用)
9
+ SKIP_AUTH_TOKEN=false
10
+
11
+ # ========== Z.ai Token池配置 ==========
12
+ # Token失败阈值(失败多少次后标记为不可用)
13
+ TOKEN_FAILURE_THRESHOLD=3
14
+
15
+ # Token恢复超时时间(秒,失败token在此时间后重新尝试)
16
+ TOKEN_RECOVERY_TIMEOUT=1800
17
+
18
+ # Z.AI 匿名用户模式
19
+ # false: 使用认证 Token 令牌,失败时自动降级为匿名请求
20
+ # true: 自动从 Z.ai 获取临时访问令牌,避免对话历史共享
21
+ ANONYMOUS_MODE=true
22
+
23
+ # ========== LongCat 配置 ==========
24
+ # LongCat token(单个token)
25
+ # LONGCAT_TOKEN=your_passport_token_here
26
+
27
+ # ========== 服务器配置 ==========
28
+ # 服务监听端口
29
+ LISTEN_PORT=7860
30
+
31
+ # 服务名称
32
+ SERVICE_NAME=z-ai2api-server
33
+
34
+ # 调试日志
35
+ DEBUG_LOGGING=false
36
+
37
+ # Nginx 反向代理路径前缀(可选,用于在子路径下部署)
38
+ # 例如:ROOT_PATH=/ai2api 则服务部署在 http://domain.com/ai2api
39
+ # 留空表示部署在根路径
40
+ ROOT_PATH=
41
+
42
+ # Function Call 功能开关
43
+ TOOL_SUPPORT=true
44
+
45
+ # 工具调用扫描限制(字符数)
46
+ SCAN_LIMIT=200000
47
+
48
+ # ========== 管理后台认证 ==========
49
+ # 管理后台登录密码(建议修改为复杂密码)
50
+ ADMIN_PASSWORD=admin123
51
+
52
+ # Session 密钥(用于加密会话,建议生成随机字符串)
53
+ SESSION_SECRET_KEY=your-secret-key-change-in-production
.gitattributes CHANGED
@@ -1,35 +1,2 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
1
+ # Auto detect text files and perform LF normalization
2
+ * text=auto
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.github/workflows/docker.yml ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Build and Push Docker Image
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ tags:
8
+ - 'v*'
9
+
10
+ env:
11
+ IMAGE_NAME: z-ai2api-python
12
+
13
+ jobs:
14
+ docker:
15
+ runs-on: ubuntu-latest
16
+ permissions:
17
+ contents: read
18
+ packages: write
19
+
20
+ steps:
21
+ - name: Checkout
22
+ uses: actions/checkout@v4
23
+
24
+ - name: Set up Docker Buildx
25
+ uses: docker/setup-buildx-action@v3
26
+
27
+ - name: Login to GitHub Container Registry
28
+ uses: docker/login-action@v3
29
+ with:
30
+ registry: ghcr.io
31
+ username: ${{ github.actor }}
32
+ password: ${{ secrets.GITHUB_TOKEN }}
33
+
34
+ - name: Login to Docker Hub
35
+ if: github.event_name != 'pull_request'
36
+ uses: docker/login-action@v3
37
+ with:
38
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
39
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
40
+
41
+ - name: Extract metadata
42
+ id: meta
43
+ uses: docker/metadata-action@v5
44
+ with:
45
+ images: |
46
+ ghcr.io/${{ github.repository }}
47
+ ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}
48
+ tags: |
49
+ type=ref,event=branch
50
+ type=semver,pattern={{version}}
51
+ type=semver,pattern={{major}}.{{minor}}
52
+ type=raw,value=latest,enable={{is_default_branch}}
53
+
54
+ - name: Build and push
55
+ uses: docker/build-push-action@v5
56
+ with:
57
+ context: .
58
+ file: ./deploy/Dockerfile
59
+ platforms: linux/amd64,linux/arm64
60
+ push: true
61
+ tags: ${{ steps.meta.outputs.tags }}
62
+ labels: ${{ steps.meta.outputs.labels }}
63
+ cache-from: type=gha
64
+ cache-to: type=gha,mode=max
.gitignore ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Custom
2
+ .vs/
3
+ .vscode/
4
+ .idea/
5
+ .conda/
6
+ *.zip
7
+ *.txt
8
+ *.pid
9
+ docs/
10
+ output/
11
+ main.build/
12
+ main.dist/
13
+ main.onefile-build/
14
+ *report.xml
15
+ *.yaml
16
+ logs/
17
+ backup/
18
+ uv.lock
19
+ AGENTS.md
20
+ *.db
21
+
22
+ # AI Toolset
23
+ .augment/
24
+ .cursor/
25
+ .claude/
26
+ CLAUDE.md
27
+
28
+ # Byte-compiled / optimized / DLL files
29
+ __pycache__/
30
+ *.py[cod]
31
+ *$py.class
32
+
33
+ # C extensions
34
+ *.so
35
+
36
+ # Distribution / packaging
37
+ .Python
38
+ build/
39
+ develop-eggs/
40
+ dist/
41
+ downloads/
42
+ eggs/
43
+ .eggs/
44
+ lib/
45
+ lib64/
46
+ parts/
47
+ sdist/
48
+ var/
49
+ wheels/
50
+ share/python-wheels/
51
+ *.egg-info/
52
+ .installed.cfg
53
+ *.egg
54
+ MANIFEST
55
+
56
+ # PyInstaller
57
+ # Usually these files are written by a python script from a template
58
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
59
+ *.manifest
60
+ *.spec
61
+
62
+ # Installer logs
63
+ pip-log.txt
64
+ pip-delete-this-directory.txt
65
+
66
+ # Unit test / coverage reports
67
+ htmlcov/
68
+ .tox/
69
+ .nox/
70
+ .coverage
71
+ .coverage.*
72
+ .cache
73
+ nosetests.xml
74
+ coverage.xml
75
+ *.cover
76
+ *.py,cover
77
+ .hypothesis/
78
+ .pytest_cache/
79
+ cover/
80
+
81
+ # Translations
82
+ *.mo
83
+ *.pot
84
+
85
+ # Django stuff:
86
+ *.log
87
+ local_settings.py
88
+ db.sqlite3
89
+ db.sqlite3-journal
90
+
91
+ # Flask stuff:
92
+ instance/
93
+ .webassets-cache
94
+
95
+ # Scrapy stuff:
96
+ .scrapy
97
+
98
+ # Sphinx documentation
99
+ docs/_build/
100
+
101
+ # PyBuilder
102
+ .pybuilder/
103
+ target/
104
+
105
+ # Jupyter Notebook
106
+ .ipynb_checkpoints
107
+
108
+ # IPython
109
+ profile_default/
110
+ ipython_config.py
111
+
112
+ # pyenv
113
+ # For a library or package, you might want to ignore these files since the code is
114
+ # intended to run in multiple environments; otherwise, check them in:
115
+ # .python-version
116
+
117
+ # pipenv
118
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
119
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
120
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
121
+ # install all needed dependencies.
122
+ #Pipfile.lock
123
+
124
+ # poetry
125
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
126
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
127
+ # commonly ignored for libraries.
128
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
129
+ #poetry.lock
130
+
131
+ # pdm
132
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
133
+ #pdm.lock
134
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
135
+ # in version control.
136
+ # https://pdm.fming.dev/#use-with-ide
137
+ .pdm.toml
138
+
139
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
140
+ __pypackages__/
141
+
142
+ # Celery stuff
143
+ celerybeat-schedule
144
+ celerybeat.pid
145
+
146
+ # SageMath parsed files
147
+ *.sage.py
148
+
149
+ # Environments
150
+ .env
151
+ .venv
152
+ env/
153
+ venv/
154
+ ENV/
155
+ env.bak/
156
+ venv.bak/
157
+
158
+ # Spyder project settings
159
+ .spyderproject
160
+ .spyproject
161
+
162
+ # Rope project settings
163
+ .ropeproject
164
+
165
+ # mkdocs documentation
166
+ /site
167
+
168
+ # mypy
169
+ .mypy_cache/
170
+ .dmypy.json
171
+ dmypy.json
172
+
173
+ # Pyre type checker
174
+ .pyre/
175
+
176
+ # pytype static type analyzer
177
+ .pytype/
178
+
179
+ # Cython debug symbols
180
+ cython_debug/
Dockerfile CHANGED
@@ -1,24 +1,24 @@
1
- # Build stage
2
- FROM golang:1.24-alpine AS builder
3
- WORKDIR /app
4
- COPY go.mod go.sum ./
5
- RUN go mod download
6
- COPY . .
7
- RUN CGO_ENABLED=0 go build -o main .
8
 
9
- # Final stage
10
- FROM alpine:latest
11
- RUN apk --no-cache add ca-certificates
12
  WORKDIR /app
13
- COPY --from=builder /app/main .
14
 
15
- # Labels
16
- LABEL maintainer="z2"
17
- LABEL description="z2 API"
18
- LABEL version="1.0.5"
 
 
 
 
 
 
 
 
 
19
 
20
  # Expose port
21
  EXPOSE 7860
22
 
23
  # Run the application
24
- CMD ["./main"]
 
1
+ FROM python:3.12-slim
 
 
 
 
 
 
2
 
3
+ # Set working directory
 
 
4
  WORKDIR /app
 
5
 
6
+ # Create data and logs directories with proper permissions
7
+ RUN mkdir -p /app/data /app/logs && \
8
+ chmod 755 /app/data /app/logs
9
+
10
+ # Install dependencies
11
+ COPY requirements.txt .
12
+ RUN pip install --no-cache-dir -r requirements.txt
13
+
14
+ # Copy application code
15
+ COPY . .
16
+
17
+ # Set environment variable for database path
18
+ ENV DB_PATH=/app/data/tokens.db
19
 
20
  # Expose port
21
  EXPOSE 7860
22
 
23
  # Run the application
24
+ CMD ["python", "main.py"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2025 ZyphrZero
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
app/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ from app import core, models, utils
5
+
6
+ __all__ = ["core", "models", "utils"]
app/admin/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """
2
+ 管理后台模块初始化
3
+ """
app/admin/api.py ADDED
@@ -0,0 +1,728 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 管理后台 API 接口
3
+ 用于 htmx 调用的 HTML 片段返回
4
+ """
5
+ from fastapi import APIRouter, Request
6
+ from fastapi.templating import Jinja2Templates
7
+ from fastapi.responses import HTMLResponse, JSONResponse, Response
8
+ from datetime import datetime
9
+ from app.utils.logger import logger
10
+ import os
11
+
12
+ router = APIRouter(prefix="/admin/api", tags=["admin-api"])
13
+ templates = Jinja2Templates(directory="app/templates")
14
+
15
+
16
+ # ==================== 认证 API ====================
17
+
18
+ @router.post("/login")
19
+ async def login(request: Request):
20
+ """管理后台登录"""
21
+ from app.admin.auth import create_session
22
+
23
+ try:
24
+ data = await request.json()
25
+ password = data.get("password", "")
26
+
27
+ # 创建 session
28
+ session_token = create_session(password)
29
+
30
+ if session_token:
31
+ # 登录成功,设置 cookie
32
+ response = JSONResponse({
33
+ "success": True,
34
+ "message": "登录成功"
35
+ })
36
+ response.set_cookie(
37
+ key="admin_session",
38
+ value=session_token,
39
+ httponly=True,
40
+ max_age=86400, # 24小时
41
+ samesite="lax"
42
+ )
43
+ logger.info("✅ 管理后台登录成功")
44
+ return response
45
+ else:
46
+ # 密码错误
47
+ logger.warning("❌ 管理后台登录失败:密码错误")
48
+ return JSONResponse({
49
+ "success": False,
50
+ "message": "密码错误"
51
+ }, status_code=401)
52
+
53
+ except Exception as e:
54
+ logger.error(f"❌ 登录异常: {e}")
55
+ return JSONResponse({
56
+ "success": False,
57
+ "message": "登录失败"
58
+ }, status_code=500)
59
+
60
+
61
+ @router.post("/logout")
62
+ async def logout(request: Request):
63
+ """管理后台登出"""
64
+ from app.admin.auth import delete_session, get_session_token_from_request
65
+
66
+ session_token = get_session_token_from_request(request)
67
+ delete_session(session_token)
68
+
69
+ # 清除 cookie
70
+ response = JSONResponse({
71
+ "success": True,
72
+ "message": "已登出"
73
+ })
74
+ response.delete_cookie("admin_session")
75
+ logger.info("✅ 管理后台已登出")
76
+ return response
77
+
78
+
79
+ async def reload_settings():
80
+ """热重载配置(重新加载环境变量并更新 settings 对象)"""
81
+ from app.core.config import settings
82
+ from app.utils.logger import setup_logger
83
+ from dotenv import load_dotenv
84
+
85
+ # 重新加载 .env 文件
86
+ load_dotenv(override=True)
87
+
88
+ # 重新创建 Settings 对象并更新全局配置
89
+ new_settings = type(settings)()
90
+
91
+ # 更新全局 settings 的所有属性
92
+ for field_name in new_settings.model_fields.keys():
93
+ setattr(settings, field_name, getattr(new_settings, field_name))
94
+
95
+ # 重新初始化 logger(使用新的 DEBUG_LOGGING 配置)
96
+ setup_logger(log_dir="logs", debug_mode=settings.DEBUG_LOGGING)
97
+
98
+ logger.info(f"🔄 配置已热重载 (DEBUG_LOGGING={settings.DEBUG_LOGGING})")
99
+
100
+
101
+ @router.get("/token-pool", response_class=HTMLResponse)
102
+ async def get_token_pool_status(request: Request):
103
+ """获取 Token 池状态(HTML 片段)"""
104
+ from app.utils.token_pool import get_token_pool
105
+
106
+ token_pool = get_token_pool()
107
+
108
+ if not token_pool:
109
+ # Token 池未初始化
110
+ context = {
111
+ "request": request,
112
+ "tokens": [],
113
+ }
114
+ return templates.TemplateResponse("components/token_pool.html", context)
115
+
116
+ # 获取 token 状态统计
117
+ pool_status = token_pool.get_pool_status()
118
+ tokens_info = []
119
+
120
+ for idx, token_info in enumerate(pool_status.get("tokens", []), 1):
121
+ is_available = token_info.get("is_available", False)
122
+ is_healthy = token_info.get("is_healthy", False)
123
+
124
+ # 确定状态和颜色
125
+ if is_healthy:
126
+ status = "健康"
127
+ status_color = "bg-green-100 text-green-800"
128
+ elif is_available:
129
+ status = "可用"
130
+ status_color = "bg-yellow-100 text-yellow-800"
131
+ else:
132
+ status = "失败"
133
+ status_color = "bg-red-100 text-red-800"
134
+
135
+ # 格式化最后使用时间
136
+ last_success = token_info.get("last_success_time", 0)
137
+ if last_success > 0:
138
+ from datetime import datetime
139
+ last_used = datetime.fromtimestamp(last_success).strftime("%Y-%m-%d %H:%M:%S")
140
+ else:
141
+ last_used = "从未使用"
142
+
143
+ tokens_info.append({
144
+ "index": idx,
145
+ "key": token_info.get("token", "")[:20] + "...",
146
+ "status": status,
147
+ "status_color": status_color,
148
+ "last_used": last_used,
149
+ "failure_count": token_info.get("failure_count", 0),
150
+ "success_rate": token_info.get("success_rate", "0%"),
151
+ "token_type": token_info.get("token_type", "unknown"),
152
+ })
153
+
154
+ context = {
155
+ "request": request,
156
+ "tokens": tokens_info,
157
+ }
158
+
159
+ return templates.TemplateResponse("components/token_pool.html", context)
160
+
161
+
162
+ @router.get("/recent-logs", response_class=HTMLResponse)
163
+ async def get_recent_logs(request: Request):
164
+ """获取最近的请求日志(HTML 片段)"""
165
+ # TODO: 从数据库或日志文件读取
166
+ logs = [
167
+ {
168
+ "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
169
+ "endpoint": "/v1/chat/completions",
170
+ "model": "gpt-4o",
171
+ "status": 200,
172
+ "duration": "1.23s",
173
+ "provider": "zai",
174
+ }
175
+ ]
176
+
177
+ context = {
178
+ "request": request,
179
+ "logs": logs,
180
+ }
181
+
182
+ return templates.TemplateResponse("components/recent_logs.html", context)
183
+
184
+
185
+ @router.post("/config/save")
186
+ async def save_config(request: Request):
187
+ """保存配置到 .env 文件并热重载"""
188
+ try:
189
+ form_data = await request.form()
190
+
191
+ # 构建 .env 内容
192
+ env_lines = [
193
+ "# Z.AI2API 配置文件",
194
+ "",
195
+ "# ========== 服务器配置 ==========",
196
+ f"SERVICE_NAME={form_data.get('service_name', 'Z.AI2API')}",
197
+ f"LISTEN_PORT={form_data.get('listen_port', '7860')}",
198
+ f"DEBUG_LOGGING={'true' if 'debug_logging' in form_data else 'false'}",
199
+ "",
200
+ "# ========== 认证配置 ==========",
201
+ f"AUTH_TOKEN={form_data.get('auth_token', 'sk-your-api-key')}",
202
+ f"SKIP_AUTH_TOKEN={'true' if 'skip_auth_token' in form_data else 'false'}",
203
+ f"ANONYMOUS_MODE={'true' if 'anonymous_mode' in form_data else 'false'}",
204
+ "",
205
+ "# ========== 功能配置 ==========",
206
+ f"TOOL_SUPPORT={'true' if 'tool_support' in form_data else 'false'}",
207
+ f"SCAN_LIMIT={form_data.get('scan_limit', '200000')}",
208
+ "",
209
+ "# ========== Token 池配置 ==========",
210
+ f"TOKEN_FAILURE_THRESHOLD={form_data.get('token_failure_threshold', '3')}",
211
+ f"TOKEN_RECOVERY_TIMEOUT={form_data.get('token_recovery_timeout', '1800')}",
212
+ "",
213
+ "# ========== 提供商配置 ==========",
214
+ f"DEFAULT_PROVIDER={form_data.get('default_provider', 'zai')}",
215
+ ]
216
+
217
+ # LongCat Token(可选)
218
+ longcat_token = form_data.get('longcat_token', '').strip()
219
+ if longcat_token:
220
+ env_lines.append(f"LONGCAT_TOKEN={longcat_token}")
221
+
222
+ # 写入 .env 文件
223
+ with open(".env", "w", encoding="utf-8") as f:
224
+ f.write("\n".join(env_lines))
225
+
226
+ logger.info("✅ 配置文件已保存")
227
+
228
+ # 热重载配置
229
+ await reload_settings()
230
+
231
+ return HTMLResponse("""
232
+ <div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded relative" role="alert">
233
+ <strong class="font-bold">成功!</strong>
234
+ <span class="block sm:inline">配置已保存并重载成功</span>
235
+ </div>
236
+ """)
237
+
238
+ except Exception as e:
239
+ logger.error(f"❌ 配置保存失败: {str(e)}")
240
+ return HTMLResponse(f"""
241
+ <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
242
+ <strong class="font-bold">错误!</strong>
243
+ <span class="block sm:inline">保存失败: {str(e)}</span>
244
+ </div>
245
+ """)
246
+
247
+
248
+ @router.get("/env-preview")
249
+ async def get_env_preview():
250
+ """获取 .env 文件预览"""
251
+ try:
252
+ with open(".env", "r", encoding="utf-8") as f:
253
+ content = f.read()
254
+ return HTMLResponse(f"<pre>{content}</pre>")
255
+ except FileNotFoundError:
256
+ return HTMLResponse("<pre># .env 文件不存在</pre>")
257
+ except Exception as e:
258
+ return HTMLResponse(f"<pre># 读取失败: {str(e)}</pre>")
259
+
260
+
261
+ @router.get("/provider-status", response_class=HTMLResponse)
262
+ async def get_provider_status(request: Request):
263
+ """获取提供商状态详情(HTML 片段)"""
264
+ from app.services.token_dao import get_token_dao
265
+
266
+ dao = get_token_dao()
267
+
268
+ # 获取所有提供商的统计信息
269
+ providers = ["zai", "k2think", "longcat"]
270
+ provider_stats_list = []
271
+
272
+ for provider in providers:
273
+ stats = await dao.get_provider_stats(provider)
274
+ tokens = await dao.get_tokens_by_provider(provider, enabled_only=False)
275
+
276
+ # 计算成功率
277
+ total_requests = stats.get("total_requests", 0) or 0
278
+ successful_requests = stats.get("successful_requests", 0) or 0
279
+ failed_requests = stats.get("failed_requests", 0) or 0
280
+
281
+ if total_requests > 0:
282
+ success_rate = f"{(successful_requests / total_requests * 100):.1f}%"
283
+ else:
284
+ success_rate = "N/A"
285
+
286
+ # Token 类型统计
287
+ user_tokens = sum(1 for t in tokens if t.get("token_type") == "user")
288
+ guest_tokens = sum(1 for t in tokens if t.get("token_type") == "guest")
289
+ unknown_tokens = sum(1 for t in tokens if t.get("token_type") == "unknown")
290
+
291
+ provider_stats_list.append({
292
+ "name": provider, # 小写名称(用于 URL 参数)
293
+ "name_upper": provider.upper(), # 大写名称(用于显示)
294
+ "display_name": {
295
+ "zai": "Z.AI",
296
+ "k2think": "K2Think",
297
+ "longcat": "LongCat"
298
+ }.get(provider, provider.upper()),
299
+ "total_tokens": stats.get("total_tokens", 0) or 0,
300
+ "enabled_tokens": stats.get("enabled_tokens", 0) or 0,
301
+ "user_tokens": user_tokens,
302
+ "guest_tokens": guest_tokens,
303
+ "unknown_tokens": unknown_tokens,
304
+ "total_requests": total_requests,
305
+ "successful_requests": successful_requests,
306
+ "failed_requests": failed_requests,
307
+ "success_rate": success_rate,
308
+ })
309
+
310
+ context = {
311
+ "request": request,
312
+ "providers": provider_stats_list,
313
+ }
314
+
315
+ return templates.TemplateResponse("components/provider_status.html", context)
316
+
317
+
318
+ @router.get("/live-logs", response_class=HTMLResponse)
319
+ async def get_live_logs():
320
+ """获取实时日志(最新 50 行)"""
321
+ import os
322
+ from datetime import datetime
323
+
324
+ logs = []
325
+
326
+ # 尝试读取日志文件
327
+ log_dir = "logs"
328
+ if os.path.exists(log_dir):
329
+ log_files = sorted([f for f in os.listdir(log_dir) if f.endswith('.log')], reverse=True)
330
+ if log_files:
331
+ log_file = os.path.join(log_dir, log_files[0])
332
+ try:
333
+ with open(log_file, 'r', encoding='utf-8') as f:
334
+ # 读取最后 50 行
335
+ lines = f.readlines()[-50:]
336
+ logs = lines
337
+ except Exception as e:
338
+ logs = [f"# [{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] 读取日志失败: {str(e)}"]
339
+
340
+ if not logs:
341
+ logs = [f"# [{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] 暂无日志数据"]
342
+
343
+ html = ""
344
+ for log in logs:
345
+ log_line = log.strip()
346
+ if not log_line:
347
+ continue
348
+
349
+ # 根据日志级别设置颜色和样式
350
+ if "ERROR" in log_line or "CRITICAL" in log_line:
351
+ color_class = "text-red-400 font-semibold"
352
+ icon = "❌"
353
+ elif "WARNING" in log_line or "WARN" in log_line:
354
+ color_class = "text-yellow-400"
355
+ icon = "⚠️"
356
+ elif "SUCCESS" in log_line or "✅" in log_line:
357
+ color_class = "text-green-400"
358
+ icon = "✅"
359
+ elif "INFO" in log_line:
360
+ color_class = "text-blue-400"
361
+ icon = "ℹ️"
362
+ elif "DEBUG" in log_line:
363
+ color_class = "text-gray-400 text-xs"
364
+ icon = "🔍"
365
+ else:
366
+ color_class = "text-gray-300"
367
+ icon = "•"
368
+
369
+ # 转义 HTML 特殊字符
370
+ log_escaped = log_line.replace('<', '&lt;').replace('>', '&gt;')
371
+
372
+ html += f'<div class="{color_class} py-0.5 hover:bg-gray-800 px-2 rounded transition-colors">{icon} {log_escaped}</div>'
373
+
374
+ return HTMLResponse(html)
375
+
376
+
377
+ # ==================== Token 管理 API ====================
378
+
379
+ @router.get("/tokens/list", response_class=HTMLResponse)
380
+ async def get_tokens_list(request: Request, provider: str = "zai"):
381
+ """获取 Token 列表(HTML 片段)"""
382
+ from app.services.token_dao import get_token_dao
383
+
384
+ dao = get_token_dao()
385
+ tokens = await dao.get_tokens_by_provider(provider, enabled_only=False)
386
+
387
+ context = {
388
+ "request": request,
389
+ "tokens": tokens,
390
+ "provider": provider
391
+ }
392
+
393
+ return templates.TemplateResponse("components/token_list.html", context)
394
+
395
+
396
+ @router.post("/tokens/add")
397
+ async def add_tokens(request: Request):
398
+ """添加 Token"""
399
+ from app.services.token_dao import get_token_dao
400
+ from app.utils.token_pool import get_token_pool
401
+
402
+ form_data = await request.form()
403
+ provider = form_data.get("provider", "zai")
404
+ single_token = form_data.get("single_token", "").strip()
405
+ bulk_tokens = form_data.get("bulk_tokens", "").strip()
406
+
407
+ dao = get_token_dao()
408
+ added_count = 0
409
+ failed_count = 0
410
+
411
+ # 添加单个 Token(带验证)
412
+ if single_token:
413
+ token_id = await dao.add_token(provider, single_token, validate=True)
414
+ if token_id:
415
+ added_count += 1
416
+ else:
417
+ failed_count += 1
418
+
419
+ # 批量添加 Token(带验证)
420
+ if bulk_tokens:
421
+ # 支持换行和逗号分隔
422
+ tokens = []
423
+ for line in bulk_tokens.split('\n'):
424
+ line = line.strip()
425
+ if ',' in line:
426
+ tokens.extend([t.strip() for t in line.split(',') if t.strip()])
427
+ elif line:
428
+ tokens.append(line)
429
+
430
+ success, failed = await dao.bulk_add_tokens(provider, tokens, validate=True)
431
+ added_count += success
432
+ failed_count += failed
433
+
434
+ # 同步 Token 池状态(如果有新增成功的 Token)
435
+ if added_count > 0:
436
+ pool = get_token_pool()
437
+ if pool:
438
+ await pool.sync_from_database(provider)
439
+ logger.info(f"✅ Token 池已同步,新增 {added_count} 个 Token ({provider})")
440
+
441
+ # 生成响应
442
+ if added_count > 0 and failed_count == 0:
443
+ return HTMLResponse(f"""
444
+ <div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded relative" role="alert">
445
+ <strong class="font-bold">成功!</strong>
446
+ <span class="block sm:inline">已添加 {added_count} 个有效 Token</span>
447
+ </div>
448
+ """)
449
+ elif added_count > 0 and failed_count > 0:
450
+ return HTMLResponse(f"""
451
+ <div class="bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded relative" role="alert">
452
+ <strong class="font-bold">部分成功!</strong>
453
+ <span class="block sm:inline">已添加 {added_count} 个 Token,{failed_count} 个失败(可能是重复、无效或匿名 Token)</span>
454
+ </div>
455
+ """)
456
+ else:
457
+ return HTMLResponse("""
458
+ <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
459
+ <strong class="font-bold">失败!</strong>
460
+ <span class="block sm:inline">所有 Token 添加失败(可能是重复、无效或匿名 Token)</span>
461
+ </div>
462
+ """)
463
+
464
+
465
+ @router.post("/tokens/toggle/{token_id}")
466
+ async def toggle_token(token_id: int, enabled: bool):
467
+ """切换 Token 启用状态"""
468
+ from app.services.token_dao import get_token_dao
469
+ from app.utils.token_pool import get_token_pool
470
+
471
+ dao = get_token_dao()
472
+ await dao.update_token_status(token_id, enabled)
473
+
474
+ # 同步 Token 池状态
475
+ pool = get_token_pool()
476
+ if pool:
477
+ # 获取 Token 的提供商信息
478
+ async with dao.get_connection() as conn:
479
+ cursor = await conn.execute("SELECT provider FROM tokens WHERE id = ?", (token_id,))
480
+ row = await cursor.fetchone()
481
+ if row:
482
+ provider = row[0]
483
+ await pool.sync_from_database(provider)
484
+ logger.info(f"✅ Token 池已同步 ({provider})")
485
+
486
+ # 根据状态返回不同样式的按钮
487
+ if enabled:
488
+ button_class = "bg-green-100 text-green-800 hover:bg-green-200"
489
+ indicator_class = "bg-green-500"
490
+ label = "已启用"
491
+ next_state = "false"
492
+ else:
493
+ button_class = "bg-red-100 text-red-800 hover:bg-red-200"
494
+ indicator_class = "bg-red-500"
495
+ label = "已禁用"
496
+ next_state = "true"
497
+
498
+ return HTMLResponse(f"""
499
+ <button hx-post="/admin/api/tokens/toggle/{token_id}?enabled={next_state}"
500
+ hx-swap="outerHTML"
501
+ class="inline-flex items-center px-2.5 py-0.5 text-xs font-semibold rounded-full transition-colors {button_class}">
502
+ <span class="h-2 w-2 rounded-full mr-1.5 {indicator_class}"></span>
503
+ {label}
504
+ </button>
505
+ """)
506
+
507
+
508
+ @router.delete("/tokens/delete/{token_id}")
509
+ async def delete_token(token_id: int):
510
+ """删除 Token"""
511
+ from app.services.token_dao import get_token_dao
512
+ from app.utils.token_pool import get_token_pool
513
+
514
+ dao = get_token_dao()
515
+
516
+ # 获取 Token 信息以确定提供商
517
+ async with dao.get_connection() as conn:
518
+ cursor = await conn.execute("SELECT provider FROM tokens WHERE id = ?", (token_id,))
519
+ row = await cursor.fetchone()
520
+ provider = row[0] if row else "zai"
521
+
522
+ await dao.delete_token(token_id)
523
+
524
+ # 同步 Token 池状态
525
+ pool = get_token_pool()
526
+ if pool:
527
+ await pool.sync_from_database(provider)
528
+ logger.info(f"✅ Token 池已同步 ({provider})")
529
+
530
+ return HTMLResponse("") # 返回空内容,让 htmx 移除元素
531
+
532
+
533
+ @router.get("/tokens/stats", response_class=HTMLResponse)
534
+ async def get_tokens_stats(request: Request, provider: str = "zai"):
535
+ """获取 Token 统计信息(HTML 片段)"""
536
+ from app.services.token_dao import get_token_dao
537
+
538
+ dao = get_token_dao()
539
+
540
+ # 获取提供商统计
541
+ stats = await dao.get_provider_stats(provider)
542
+
543
+ # 获取所有 Token 进行类型统计
544
+ tokens = await dao.get_tokens_by_provider(provider, enabled_only=False)
545
+
546
+ user_tokens = sum(1 for t in tokens if t.get("token_type") == "user")
547
+ guest_tokens = sum(1 for t in tokens if t.get("token_type") == "guest")
548
+ unknown_tokens = sum(1 for t in tokens if t.get("token_type") == "unknown")
549
+
550
+ stats_data = {
551
+ "total_tokens": stats.get("total_tokens", 0) or 0,
552
+ "enabled_tokens": stats.get("enabled_tokens", 0) or 0,
553
+ "user_tokens": user_tokens,
554
+ "guest_tokens": guest_tokens,
555
+ "unknown_tokens": unknown_tokens,
556
+ "total_requests": stats.get("total_requests", 0) or 0,
557
+ "successful_requests": stats.get("successful_requests", 0) or 0,
558
+ "failed_requests": stats.get("failed_requests", 0) or 0,
559
+ }
560
+
561
+ context = {
562
+ "request": request,
563
+ "stats": stats_data,
564
+ "provider": provider
565
+ }
566
+
567
+ return templates.TemplateResponse("components/token_stats.html", context)
568
+
569
+
570
+ @router.post("/tokens/validate")
571
+ async def validate_tokens(request: Request):
572
+ """批量验证 Token"""
573
+ from app.services.token_dao import get_token_dao
574
+
575
+ form_data = await request.form()
576
+ provider = form_data.get("provider", "zai")
577
+
578
+ dao = get_token_dao()
579
+
580
+ # 执行批量验证
581
+ stats = await dao.validate_all_tokens(provider)
582
+
583
+ valid_count = stats.get("valid", 0)
584
+ guest_count = stats.get("guest", 0)
585
+ invalid_count = stats.get("invalid", 0)
586
+
587
+ # 生成通知消息
588
+ if guest_count > 0:
589
+ message_class = "bg-yellow-100 border-yellow-400 text-yellow-700"
590
+ message = f"验证完成:有效 {valid_count} 个,匿名 {guest_count} 个,无效 {invalid_count} 个。匿名 Token 已标记。"
591
+ elif invalid_count > 0:
592
+ message_class = "bg-blue-100 border-blue-400 text-blue-700"
593
+ message = f"验证完成:有效 {valid_count} 个,无效 {invalid_count} 个。"
594
+ else:
595
+ message_class = "bg-green-100 border-green-400 text-green-700"
596
+ message = f"验证完成:所有 {valid_count} 个 Token 均有效!"
597
+
598
+ return HTMLResponse(f"""
599
+ <div class="{message_class} border px-4 py-3 rounded relative" role="alert">
600
+ <strong class="font-bold">批量验证完成!</strong>
601
+ <span class="block sm:inline">{message}</span>
602
+ </div>
603
+ """)
604
+
605
+
606
+ @router.post("/tokens/validate-single/{token_id}")
607
+ async def validate_single_token(request: Request, token_id: int):
608
+ """验证单个 Token 并返回更新后的行"""
609
+ from app.services.token_dao import get_token_dao
610
+
611
+ dao = get_token_dao()
612
+
613
+ # 验证 Token
614
+ is_valid = await dao.validate_and_update_token(token_id)
615
+
616
+ # 获取更新后的 Token 信息
617
+ async with dao.get_connection() as conn:
618
+ cursor = await conn.execute("""
619
+ SELECT t.*, ts.total_requests, ts.successful_requests, ts.failed_requests,
620
+ ts.last_success_time, ts.last_failure_time
621
+ FROM tokens t
622
+ LEFT JOIN token_stats ts ON t.id = ts.token_id
623
+ WHERE t.id = ?
624
+ """, (token_id,))
625
+ row = await cursor.fetchone()
626
+
627
+ if row:
628
+ # 返回更新后的单行 HTML
629
+ token = dict(row)
630
+ context = {
631
+ "request": request,
632
+ "token": token,
633
+ }
634
+ # 使用单行模板渲染
635
+ return templates.TemplateResponse("components/token_row.html", context)
636
+ else:
637
+ return HTMLResponse("")
638
+
639
+
640
+ @router.post("/tokens/health-check")
641
+ async def health_check_tokens(request: Request):
642
+ """执行 Token 池健康检查"""
643
+ from app.utils.token_pool import get_token_pool
644
+
645
+ form_data = await request.form()
646
+ provider = form_data.get("provider", "zai")
647
+
648
+ pool = get_token_pool()
649
+
650
+ if not pool:
651
+ return HTMLResponse("""
652
+ <div class="bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded relative" role="alert">
653
+ <strong class="font-bold">提示!</strong>
654
+ <span class="block sm:inline">Token 池未初始化,请重启服务。</span>
655
+ </div>
656
+ """)
657
+
658
+ # 执行健康检查
659
+ await pool.health_check_all()
660
+
661
+ # 获取健康状态
662
+ status = pool.get_pool_status()
663
+ healthy_count = status.get("healthy_tokens", 0)
664
+ total_count = status.get("total_tokens", 0)
665
+
666
+ if healthy_count == total_count:
667
+ message_class = "bg-green-100 border-green-400 text-green-700"
668
+ message = f"所有 {total_count} 个 Token 均健康!"
669
+ elif healthy_count > 0:
670
+ message_class = "bg-blue-100 border-blue-400 text-blue-700"
671
+ message = f"健康检查完成:{healthy_count}/{total_count} 个 Token 健康。"
672
+ else:
673
+ message_class = "bg-red-100 border-red-400 text-red-700"
674
+ message = f"警告:0/{total_count} 个 Token 健康,请检查配置。"
675
+
676
+ return HTMLResponse(f"""
677
+ <div class="{message_class} border px-4 py-3 rounded relative" role="alert">
678
+ <strong class="font-bold">健康检查完成!</strong>
679
+ <span class="block sm:inline">{message}</span>
680
+ </div>
681
+ """)
682
+
683
+
684
+ @router.post("/tokens/sync-pool")
685
+ async def sync_token_pool(request: Request):
686
+ """手动同步 Token 池(从数据库重新加载)"""
687
+ from app.utils.token_pool import get_token_pool
688
+
689
+ form_data = await request.form()
690
+ provider = form_data.get("provider", "zai")
691
+
692
+ pool = get_token_pool()
693
+
694
+ if not pool:
695
+ return HTMLResponse("""
696
+ <div class="bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded relative" role="alert">
697
+ <strong class="font-bold">提示!</strong>
698
+ <span class="block sm:inline">Token 池未初始化,请重启服务。</span>
699
+ </div>
700
+ """)
701
+
702
+ # 从数据库同步
703
+ await pool.sync_from_database(provider)
704
+
705
+ # 获取同步后的状态
706
+ status = pool.get_pool_status()
707
+ total_count = status.get("total_tokens", 0)
708
+ available_count = status.get("available_tokens", 0)
709
+ user_count = status.get("user_tokens", 0)
710
+
711
+ logger.info(f"✅ Token 池手动同步完成: {provider}, 总计 {total_count} 个 Token, 可用 {available_count} 个, 认证用户 {user_count} 个")
712
+
713
+ if total_count == 0:
714
+ message_class = "bg-yellow-100 border-yellow-400 text-yellow-700"
715
+ message = f"同步完成:当前没有可用的 {provider.upper()} Token,请在数据库中启用 Token。"
716
+ elif available_count == 0:
717
+ message_class = "bg-orange-100 border-orange-400 text-orange-700"
718
+ message = f"同步完成:共 {total_count} 个 Token,但无可用 Token(可能都已禁用)。"
719
+ else:
720
+ message_class = "bg-green-100 border-green-400 text-green-700"
721
+ message = f"同步完成:共 {total_count} 个 Token,{available_count} 个可用,{user_count} 个认证用户。"
722
+
723
+ return HTMLResponse(f"""
724
+ <div class="{message_class} border px-4 py-3 rounded relative" role="alert">
725
+ <strong class="font-bold">Token 池同步完成!</strong>
726
+ <span class="block sm:inline">{message}</span>
727
+ </div>
728
+ """)
app/admin/auth.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 管理后台认证中间件
3
+ """
4
+ from fastapi import Request, HTTPException, status
5
+ from fastapi.responses import RedirectResponse
6
+ from typing import Optional
7
+ import hashlib
8
+ import secrets
9
+ from datetime import datetime, timedelta
10
+
11
+ from app.core.config import settings
12
+
13
+ # 简单的内存 Session 存储(生产环境建议使用 Redis)
14
+ _sessions = {}
15
+
16
+ # Session 有效期(小时)
17
+ SESSION_EXPIRE_HOURS = 24
18
+
19
+
20
+ def generate_session_token() -> str:
21
+ """生成随机 session token"""
22
+ return secrets.token_urlsafe(32)
23
+
24
+
25
+ def create_session(password: str) -> Optional[str]:
26
+ """
27
+ 创建 session
28
+
29
+ Args:
30
+ password: 用户输入的密码
31
+
32
+ Returns:
33
+ session_token 或 None(密码错误)
34
+ """
35
+ # 验证密码
36
+ if password != settings.ADMIN_PASSWORD:
37
+ return None
38
+
39
+ # 生成 session token
40
+ session_token = generate_session_token()
41
+
42
+ # 存储 session(包含过期时间)
43
+ _sessions[session_token] = {
44
+ "created_at": datetime.now(),
45
+ "expires_at": datetime.now() + timedelta(hours=SESSION_EXPIRE_HOURS),
46
+ "authenticated": True
47
+ }
48
+
49
+ return session_token
50
+
51
+
52
+ def verify_session(session_token: Optional[str]) -> bool:
53
+ """
54
+ 验证 session 是否有效
55
+
56
+ Args:
57
+ session_token: Session token
58
+
59
+ Returns:
60
+ 是否已认证
61
+ """
62
+ if not session_token:
63
+ return False
64
+
65
+ session = _sessions.get(session_token)
66
+ if not session:
67
+ return False
68
+
69
+ # 检查是否过期
70
+ if datetime.now() > session["expires_at"]:
71
+ # 删除过期 session
72
+ del _sessions[session_token]
73
+ return False
74
+
75
+ return session.get("authenticated", False)
76
+
77
+
78
+ def delete_session(session_token: Optional[str]):
79
+ """删除 session(登出)"""
80
+ if session_token and session_token in _sessions:
81
+ del _sessions[session_token]
82
+
83
+
84
+ def get_session_token_from_request(request: Request) -> Optional[str]:
85
+ """从请求中获取 session token"""
86
+ return request.cookies.get("admin_session")
87
+
88
+
89
+ async def require_auth(request: Request):
90
+ """
91
+ 认证依赖项:要求用户已登录
92
+
93
+ 在路由中使用:
94
+ @router.get("/admin", dependencies=[Depends(require_auth)])
95
+ """
96
+ session_token = get_session_token_from_request(request)
97
+
98
+ if not verify_session(session_token):
99
+ # 未认证,重定向到登录页
100
+ raise HTTPException(
101
+ status_code=status.HTTP_303_SEE_OTHER,
102
+ detail="未登录",
103
+ headers={"Location": "/admin/login"}
104
+ )
105
+
106
+
107
+ def get_authenticated_user(request: Request) -> bool:
108
+ """
109
+ 获取当前认证状态(用于模板)
110
+
111
+ Returns:
112
+ 是否已认证
113
+ """
114
+ session_token = get_session_token_from_request(request)
115
+ return verify_session(session_token)
116
+
117
+
118
+ def cleanup_expired_sessions():
119
+ """清理过期的 session(定时任务调用)"""
120
+ now = datetime.now()
121
+ expired_tokens = [
122
+ token for token, session in _sessions.items()
123
+ if now > session["expires_at"]
124
+ ]
125
+
126
+ for token in expired_tokens:
127
+ del _sessions[token]
128
+
129
+ return len(expired_tokens)
app/admin/routes.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 管理后台路由模块
3
+ """
4
+ from fastapi import APIRouter, Request, Form, Depends
5
+ from fastapi.templating import Jinja2Templates
6
+ from fastapi.responses import HTMLResponse
7
+ from datetime import datetime
8
+ import os
9
+
10
+ from app.admin.auth import require_auth
11
+
12
+ router = APIRouter(prefix="/admin", tags=["admin"])
13
+ templates = Jinja2Templates(directory="app/templates")
14
+
15
+
16
+ @router.get("/login", response_class=HTMLResponse)
17
+ async def login_page(request: Request):
18
+ """登录页面"""
19
+ return templates.TemplateResponse("login.html", {"request": request})
20
+
21
+
22
+ @router.get("/", response_class=HTMLResponse, dependencies=[Depends(require_auth)])
23
+ async def dashboard(request: Request):
24
+ """仪表盘首页"""
25
+ from app.utils.token_pool import get_token_pool
26
+ from app.services.token_dao import get_token_dao
27
+
28
+ token_pool = get_token_pool()
29
+ dao = get_token_dao()
30
+
31
+ # 统计 Token 池状态(内存中)
32
+ if token_pool:
33
+ pool_status = token_pool.get_pool_status()
34
+ available_tokens = pool_status.get("available_tokens", 0)
35
+ total_tokens = pool_status.get("total_tokens", 0)
36
+ healthy_tokens = pool_status.get("healthy_tokens", 0)
37
+ user_tokens = pool_status.get("user_tokens", 0)
38
+ guest_tokens = pool_status.get("guest_tokens", 0)
39
+ else:
40
+ available_tokens = 0
41
+ total_tokens = 0
42
+ healthy_tokens = 0
43
+ user_tokens = 0
44
+ guest_tokens = 0
45
+
46
+ # 基础统计信息
47
+ stats = {
48
+ "uptime": "N/A",
49
+ "total_requests": 0,
50
+ "success_rate": 0,
51
+ "available_tokens": available_tokens,
52
+ "total_tokens": total_tokens,
53
+ "healthy_tokens": healthy_tokens,
54
+ "user_tokens": user_tokens,
55
+ "guest_tokens": guest_tokens,
56
+ }
57
+
58
+ context = {
59
+ "request": request,
60
+ "stats": stats,
61
+ "current_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
62
+ }
63
+
64
+ return templates.TemplateResponse("index.html", context)
65
+
66
+
67
+ @router.get("/config", response_class=HTMLResponse, dependencies=[Depends(require_auth)])
68
+ async def config_page(request: Request):
69
+ """配置管理页面"""
70
+ from app.core.config import settings
71
+
72
+ # 读取 .env 文件内容
73
+ env_content = ""
74
+ try:
75
+ with open(".env", "r", encoding="utf-8") as f:
76
+ env_content = f.read()
77
+ except FileNotFoundError:
78
+ env_content = "# .env 文件不存在"
79
+
80
+ context = {
81
+ "request": request,
82
+ "config": {
83
+ "SERVICE_NAME": settings.SERVICE_NAME,
84
+ "LISTEN_PORT": settings.LISTEN_PORT,
85
+ "DEBUG_LOGGING": settings.DEBUG_LOGGING,
86
+ "ANONYMOUS_MODE": settings.ANONYMOUS_MODE,
87
+ "AUTH_TOKEN": settings.AUTH_TOKEN,
88
+ "SKIP_AUTH_TOKEN": settings.SKIP_AUTH_TOKEN,
89
+ "TOOL_SUPPORT": settings.TOOL_SUPPORT,
90
+ "TOKEN_FAILURE_THRESHOLD": settings.TOKEN_FAILURE_THRESHOLD,
91
+ "TOKEN_RECOVERY_TIMEOUT": settings.TOKEN_RECOVERY_TIMEOUT,
92
+ "SCAN_LIMIT": settings.SCAN_LIMIT,
93
+ "LONGCAT_TOKEN": settings.LONGCAT_TOKEN or "",
94
+ "DEFAULT_PROVIDER": settings.DEFAULT_PROVIDER,
95
+ },
96
+ "env_content": env_content,
97
+ }
98
+ return templates.TemplateResponse("config.html", context)
99
+
100
+
101
+ @router.get("/monitor", response_class=HTMLResponse, dependencies=[Depends(require_auth)])
102
+ async def monitor_page(request: Request):
103
+ """服务监控页面"""
104
+ context = {
105
+ "request": request,
106
+ }
107
+ return templates.TemplateResponse("monitor.html", context)
108
+
109
+
110
+ @router.get("/tokens", response_class=HTMLResponse, dependencies=[Depends(require_auth)])
111
+ async def tokens_page(request: Request):
112
+ """Token 管理页面"""
113
+ context = {
114
+ "request": request,
115
+ }
116
+ return templates.TemplateResponse("tokens.html", context)
app/core/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ from app.core import config, openai
5
+
6
+ __all__ = ["config", "openai"]
app/core/config.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ import os
5
+ from typing import Dict, List, Optional
6
+ from pydantic_settings import BaseSettings
7
+
8
+
9
+ class Settings(BaseSettings):
10
+ """Application settings"""
11
+
12
+ # API Configuration
13
+ API_ENDPOINT: str = "https://chat.z.ai/api/chat/completions"
14
+
15
+ # Authentication
16
+ AUTH_TOKEN: Optional[str] = os.getenv("AUTH_TOKEN")
17
+
18
+ # Token池配置
19
+ TOKEN_FAILURE_THRESHOLD: int = int(os.getenv("TOKEN_FAILURE_THRESHOLD", "3")) # 失败3次后标记为不可用
20
+ TOKEN_RECOVERY_TIMEOUT: int = int(os.getenv("TOKEN_RECOVERY_TIMEOUT", "1800")) # 30分钟后重试失败的token
21
+
22
+ # Model Configuration
23
+ GLM45_MODEL: str = os.getenv("GLM45_MODEL", "GLM-4.5")
24
+ GLM45_THINKING_MODEL: str = os.getenv("GLM45_THINKING_MODEL", "GLM-4.5-Thinking")
25
+ GLM45_SEARCH_MODEL: str = os.getenv("GLM45_SEARCH_MODEL", "GLM-4.5-Search")
26
+ GLM45_AIR_MODEL: str = os.getenv("GLM45_AIR_MODEL", "GLM-4.5-Air")
27
+ GLM45V_MODEL: str = os.getenv("GLM45V_MODEL", "GLM-4.5V")
28
+ GLM46_MODEL: str = os.getenv("GLM46_MODEL", "GLM-4.6")
29
+ GLM46_THINKING_MODEL: str = os.getenv("GLM46_THINKING_MODEL", "GLM-4.6-Thinking")
30
+ GLM46_SEARCH_MODEL: str = os.getenv("GLM46_SEARCH_MODEL", "GLM-4.6-Search")
31
+ GLM46_ADVANCED_SEARCH_MODEL: str = os.getenv("GLM46_ADVANCED_SEARCH_MODEL", "GLM-4.6-advanced-search")
32
+
33
+ # Provider Model Mapping
34
+ @property
35
+ def provider_model_mapping(self) -> Dict[str, str]:
36
+ """模型到提供商的映射"""
37
+ return {
38
+ # Z.AI models
39
+ "GLM-4.5": "zai",
40
+ "GLM-4.5-Thinking": "zai",
41
+ "GLM-4.5-Search": "zai",
42
+ "GLM-4.5-Air": "zai",
43
+ "GLM-4.5V": "zai",
44
+ "GLM-4.6": "zai",
45
+ "GLM-4.6-Thinking": "zai",
46
+ "GLM-4.6-Search": "zai",
47
+ "GLM-4.6-advanced-search": "zai",
48
+ # K2Think models
49
+ "MBZUAI-IFM/K2-Think": "k2think",
50
+ # LongCat models
51
+ "LongCat-Flash": "longcat",
52
+ "LongCat": "longcat",
53
+ "LongCat-Search": "longcat",
54
+ }
55
+
56
+ # Server Configuration
57
+ LISTEN_PORT: int = int(os.getenv("LISTEN_PORT", "7860"))
58
+ DEBUG_LOGGING: bool = os.getenv("DEBUG_LOGGING", "true").lower() == "true"
59
+ SERVICE_NAME: str = os.getenv("SERVICE_NAME", "z-ai2api-server")
60
+ ROOT_PATH: str = os.getenv("ROOT_PATH", "") # For Nginx reverse proxy path prefix, e.g., "/api" or "/path-prefix"
61
+
62
+ ANONYMOUS_MODE: bool = os.getenv("ANONYMOUS_MODE", "true").lower() == "true"
63
+ TOOL_SUPPORT: bool = os.getenv("TOOL_SUPPORT", "true").lower() == "true"
64
+ SCAN_LIMIT: int = int(os.getenv("SCAN_LIMIT", "200000"))
65
+ SKIP_AUTH_TOKEN: bool = os.getenv("SKIP_AUTH_TOKEN", "false").lower() == "true"
66
+
67
+ # LongCat Configuration
68
+ LONGCAT_TOKEN: Optional[str] = os.getenv("LONGCAT_TOKEN")
69
+
70
+ # Provider Configuration
71
+ DEFAULT_PROVIDER: str = os.getenv("DEFAULT_PROVIDER", "zai") # 默认提供商:zai/k2think/longcat
72
+
73
+ # Admin Panel Authentication
74
+ ADMIN_PASSWORD: str = os.getenv("ADMIN_PASSWORD", "admin123") # 管理后台密码
75
+ SESSION_SECRET_KEY: str = os.getenv("SESSION_SECRET_KEY", "your-secret-key-change-in-production") # Session 密钥
76
+
77
+ # Browser Headers
78
+ CLIENT_HEADERS: Dict[str, str] = {
79
+ "Content-Type": "application/json",
80
+ "Accept": "application/json, text/event-stream",
81
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0",
82
+ "Accept-Language": "zh-CN",
83
+ "sec-ch-ua": '"Not;A=Brand";v="99", "Microsoft Edge";v="139", "Chromium";v="139"',
84
+ "sec-ch-ua-mobile": "?0",
85
+ "sec-ch-ua-platform": '"Windows"',
86
+ "X-FE-Version": "prod-fe-1.0.98",
87
+ "Origin": "https://chat.z.ai",
88
+ }
89
+
90
+ class Config:
91
+ env_file = ".env"
92
+ extra = "ignore" # 忽略额外字段,防止环境变量中的未知字段导致验证错误
93
+
94
+
95
+ settings = Settings()
app/core/openai.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ import time
5
+ import json
6
+ from typing import List, Dict, Any
7
+ from fastapi import APIRouter, Header, HTTPException
8
+ from fastapi.responses import StreamingResponse, JSONResponse
9
+
10
+ from app.core.config import settings
11
+ from app.models.schemas import OpenAIRequest, Message, ModelsResponse, Model, OpenAIResponse, Choice, Usage
12
+ from app.utils.logger import get_logger
13
+ from app.providers import get_provider_router
14
+ from app.utils.token_pool import get_token_pool
15
+
16
+ logger = get_logger()
17
+ router = APIRouter()
18
+
19
+ # 全局提供商路由器实例
20
+ provider_router = None
21
+
22
+
23
+ def get_provider_router_instance():
24
+ """获取提供商路由器实例"""
25
+ global provider_router
26
+ if provider_router is None:
27
+ provider_router = get_provider_router()
28
+ return provider_router
29
+
30
+
31
+ def create_chunk(chat_id: str, model: str, delta: Dict[str, Any], finish_reason: str = None) -> Dict[str, Any]:
32
+ """创建标准的 OpenAI chunk 结构"""
33
+ return {
34
+ "choices": [{
35
+ "delta": delta,
36
+ "finish_reason": finish_reason,
37
+ "index": 0,
38
+ "logprobs": None,
39
+ }],
40
+ "created": int(time.time()),
41
+ "id": chat_id,
42
+ "model": model,
43
+ "object": "chat.completion.chunk",
44
+ "system_fingerprint": "fp_zai_001",
45
+ }
46
+
47
+
48
+ async def handle_non_stream_response(stream_response, request: OpenAIRequest) -> JSONResponse:
49
+ """处理非流式响应"""
50
+ logger.info("📄 开始处理非流式响应")
51
+
52
+ # 收集所有流式数据
53
+ full_content = []
54
+ async for chunk_data in stream_response():
55
+ if chunk_data.startswith("data: "):
56
+ chunk_str = chunk_data[6:].strip()
57
+ if chunk_str and chunk_str != "[DONE]":
58
+ try:
59
+ chunk = json.loads(chunk_str)
60
+ if "choices" in chunk and chunk["choices"]:
61
+ choice = chunk["choices"][0]
62
+ if "delta" in choice and "content" in choice["delta"]:
63
+ content = choice["delta"]["content"]
64
+ if content:
65
+ full_content.append(content)
66
+ except json.JSONDecodeError:
67
+ continue
68
+
69
+ # 构建响应
70
+ response_data = OpenAIResponse(
71
+ id=f"chatcmpl-{int(time.time())}",
72
+ object="chat.completion",
73
+ created=int(time.time()),
74
+ model=request.model,
75
+ choices=[Choice(
76
+ index=0,
77
+ message=Message(
78
+ role="assistant",
79
+ content="".join(full_content),
80
+ tool_calls=None
81
+ ),
82
+ finish_reason="stop"
83
+ )],
84
+ usage=Usage(
85
+ prompt_tokens=0,
86
+ completion_tokens=0,
87
+ total_tokens=0
88
+ )
89
+ )
90
+
91
+ logger.info("✅ 非流式响应处理完成")
92
+ return JSONResponse(content=response_data.model_dump(exclude_none=True))
93
+
94
+
95
+ @router.get("/v1/models")
96
+ @router.get("/api/v1/models")
97
+ @router.get("/hf/v1/models")
98
+ async def list_models():
99
+ """List available models from all providers"""
100
+ try:
101
+ router_instance = get_provider_router_instance()
102
+ models_data = router_instance.get_models_list()
103
+ return JSONResponse(content=models_data)
104
+ except Exception as e:
105
+ logger.error(f"❌ 获取模型列表失败: {e}")
106
+ # 返回默认模型列表作为后备
107
+ current_time = int(time.time())
108
+ fallback_response = ModelsResponse(
109
+ data=[
110
+ Model(id=settings.GLM46_MODEL, created=current_time, owned_by="z.ai"),
111
+ Model(id=settings.GLM46_THINKING_MODEL, created=current_time, owned_by="z.ai"),
112
+ Model(id=settings.GLM46_SEARCH_MODEL, created=current_time, owned_by="z.ai"),
113
+ Model(id=settings.GLM45_AIR_MODEL, created=current_time, owned_by="z.ai"),
114
+ ]
115
+ )
116
+ return fallback_response
117
+
118
+
119
+ @router.post("/v1/chat/completions")
120
+ @router.post("/api/v1/chat/completions")
121
+ @router.post("/hf/v1/chat/completions")
122
+ async def chat_completions(request: OpenAIRequest, authorization: str = Header(...)):
123
+ """Handle chat completion requests with multi-provider architecture"""
124
+ role = request.messages[0].role if request.messages else "unknown"
125
+ logger.info(f"😶‍🌫️ 收到客户端请求 - 模型: {request.model}, 流式: {request.stream}, 消息数: {len(request.messages)}, 角色: {role}, 工具数: {len(request.tools) if request.tools else 0}")
126
+
127
+ # 获取提供商信息(用于统计)
128
+ provider = "unknown"
129
+
130
+ try:
131
+ # Validate API key (skip if SKIP_AUTH_TOKEN is enabled)
132
+ if not settings.SKIP_AUTH_TOKEN:
133
+ if not authorization.startswith("Bearer "):
134
+ raise HTTPException(status_code=401, detail="Missing or invalid Authorization header")
135
+
136
+ api_key = authorization[7:]
137
+ if api_key != settings.AUTH_TOKEN:
138
+ raise HTTPException(status_code=401, detail="Invalid API key")
139
+
140
+ # 使用多提供商路由器处理请求
141
+ router_instance = get_provider_router_instance()
142
+
143
+ # 从路由器获取提供商信息
144
+ provider_info = router_instance.get_provider_for_model(request.model)
145
+ if provider_info:
146
+ provider = provider_info.get("provider", "unknown")
147
+
148
+ result = await router_instance.route_request(request)
149
+
150
+ # 检查是否有错误
151
+ if isinstance(result, dict) and "error" in result:
152
+ error_info = result["error"]
153
+
154
+ if error_info.get("code") == "model_not_found":
155
+ raise HTTPException(status_code=404, detail=error_info["message"])
156
+ else:
157
+ raise HTTPException(status_code=500, detail=error_info["message"])
158
+
159
+ # 处理响应
160
+ if request.stream:
161
+ # 流式响应
162
+ if hasattr(result, '__aiter__'):
163
+ # 结果是异步生成器
164
+ return StreamingResponse(
165
+ result,
166
+ media_type="text/event-stream",
167
+ headers={
168
+ "Cache-Control": "no-cache",
169
+ "Connection": "keep-alive",
170
+ "Access-Control-Allow-Origin": "*",
171
+ }
172
+ )
173
+ else:
174
+ # 结果是字典,可能包含错误
175
+ raise HTTPException(status_code=500, detail="Expected streaming response but got non-streaming result")
176
+ else:
177
+ # 非流式响应
178
+ if isinstance(result, dict):
179
+ return JSONResponse(content=result)
180
+ else:
181
+ # 如果是异步生成器,需要收集所有内容
182
+ return await handle_non_stream_response(result, request)
183
+
184
+ except HTTPException as http_exc:
185
+ # 重新抛出 HTTP 异常
186
+ raise
187
+ except Exception as e:
188
+ logger.error(f"❌ 请求处理失败: {e}")
189
+ raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
app/models/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ from app.models import schemas
5
+
6
+ __all__ = ["schemas"]
app/models/request_log.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 请求日志数据库模型
3
+ 用于存储API请求的详细记录
4
+ """
5
+
6
+ import os
7
+
8
+ # 数据库路径 - 支持环境变量配置
9
+ DB_PATH = os.getenv("DB_PATH", "tokens.db") # 复用 tokens 数据库
10
+
11
+ # 创建请求日志表的SQL
12
+ SQL_CREATE_REQUEST_LOGS_TABLE = """
13
+ CREATE TABLE IF NOT EXISTS request_logs (
14
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
15
+ timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
16
+ provider TEXT NOT NULL,
17
+ model TEXT NOT NULL,
18
+ success BOOLEAN NOT NULL,
19
+ duration REAL,
20
+ first_token_time REAL,
21
+ input_tokens INTEGER DEFAULT 0,
22
+ output_tokens INTEGER DEFAULT 0,
23
+ total_tokens INTEGER DEFAULT 0,
24
+ error_message TEXT,
25
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
26
+ );
27
+
28
+ CREATE INDEX IF NOT EXISTS idx_request_logs_timestamp ON request_logs(timestamp);
29
+ CREATE INDEX IF NOT EXISTS idx_request_logs_model ON request_logs(model);
30
+ CREATE INDEX IF NOT EXISTS idx_request_logs_provider ON request_logs(provider);
31
+ """
app/models/schemas.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ from typing import Dict, List, Optional, Any, Union, Literal
5
+ from pydantic import BaseModel
6
+
7
+
8
+ class ImageUrl(BaseModel):
9
+ """Image URL model for vision content"""
10
+ url: str
11
+
12
+
13
+ class ContentPart(BaseModel):
14
+ """Content part model for OpenAI's new content format"""
15
+
16
+ type: str
17
+ text: Optional[str] = None
18
+ image_url: Optional[ImageUrl] = None # 添加 image_url 字段
19
+
20
+
21
+ class Message(BaseModel):
22
+ """Chat message model"""
23
+
24
+ role: str
25
+ content: Optional[Union[str, List[ContentPart]]] = None
26
+ reasoning_content: Optional[str] = None
27
+ tool_calls: Optional[List[Dict[str, Any]]] = None
28
+
29
+
30
+ class OpenAIRequest(BaseModel):
31
+ """OpenAI-compatible request model"""
32
+
33
+ model: str
34
+ messages: List[Message]
35
+ stream: Optional[bool] = False
36
+ temperature: Optional[float] = None
37
+ max_tokens: Optional[int] = None
38
+ tools: Optional[List[Dict[str, Any]]] = None
39
+ tool_choice: Optional[Any] = None
40
+
41
+
42
+ class ModelItem(BaseModel):
43
+ """Model information item"""
44
+
45
+ id: str
46
+ name: str
47
+ owned_by: str
48
+
49
+
50
+ class UpstreamRequest(BaseModel):
51
+ """Upstream service request model"""
52
+
53
+ stream: bool
54
+ model: str
55
+ messages: List[Message]
56
+ params: Dict[str, Any] = {}
57
+ features: Dict[str, Any] = {}
58
+ background_tasks: Optional[Dict[str, bool]] = None
59
+ chat_id: Optional[str] = None
60
+ id: Optional[str] = None
61
+ mcp_servers: Optional[List[str]] = None
62
+ model_item: Optional[Dict[str, Any]] = {} # Model item dictionary
63
+ tools: Optional[List[Dict[str, Any]]] = None # Add tools field for OpenAI compatibility
64
+ variables: Optional[Dict[str, str]] = None
65
+ model_config = {"protected_namespaces": ()}
66
+
67
+
68
+ class Delta(BaseModel):
69
+ """Stream delta model"""
70
+
71
+ role: Optional[str] = None
72
+ content: Optional[str] = "" or None
73
+ reasoning_content: Optional[str] = None
74
+ tool_calls: Optional[List[Dict[str, Any]]] = None
75
+
76
+
77
+ class Choice(BaseModel):
78
+ """Response choice model"""
79
+
80
+ index: int
81
+ message: Optional[Message] = None
82
+ delta: Optional[Delta] = None
83
+ finish_reason: Optional[str] = None
84
+
85
+
86
+ class Usage(BaseModel):
87
+ """Token usage statistics"""
88
+
89
+ prompt_tokens: int = 0
90
+ completion_tokens: int = 0
91
+ total_tokens: int = 0
92
+
93
+
94
+ class OpenAIResponse(BaseModel):
95
+ """OpenAI-compatible response model"""
96
+
97
+ id: str
98
+ object: str
99
+ created: int
100
+ model: str
101
+ choices: List[Choice]
102
+ usage: Optional[Usage] = None
103
+
104
+
105
+ class UpstreamError(BaseModel):
106
+ """Upstream error model"""
107
+
108
+ detail: str
109
+ code: int
110
+
111
+
112
+ class UpstreamDataInner(BaseModel):
113
+ """Inner upstream data model"""
114
+
115
+ error: Optional[UpstreamError] = None
116
+
117
+
118
+ class UpstreamDataData(BaseModel):
119
+ """Upstream data content model"""
120
+
121
+ delta_content: str = ""
122
+ edit_content: str = ""
123
+ phase: str = ""
124
+ done: bool = False
125
+ usage: Optional[Usage] = None
126
+ error: Optional[UpstreamError] = None
127
+ inner: Optional[UpstreamDataInner] = None
128
+
129
+
130
+ class UpstreamData(BaseModel):
131
+ """Upstream data model"""
132
+
133
+ type: str
134
+ data: UpstreamDataData
135
+ error: Optional[UpstreamError] = None
136
+
137
+
138
+ class Model(BaseModel):
139
+ """Model information for listing"""
140
+
141
+ id: str
142
+ object: str = "model"
143
+ created: int
144
+ owned_by: str
145
+
146
+
147
+ class ModelsResponse(BaseModel):
148
+ """Models list response model"""
149
+
150
+ object: str = "list"
151
+ data: List[Model]
app/models/token_db.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Token 数据库模型定义
3
+ 使用 SQLite 存储各提供商的 Token
4
+ """
5
+
6
+ import os
7
+
8
+ SQL_CREATE_TABLES = """
9
+ -- Token 配置表
10
+ CREATE TABLE IF NOT EXISTS tokens (
11
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
12
+ provider TEXT NOT NULL, -- 提供商: zai, k2think, longcat
13
+ token TEXT NOT NULL UNIQUE, -- Token 值(唯一)
14
+ token_type TEXT DEFAULT 'user', -- Token 类型: user, guest, unknown
15
+ is_enabled BOOLEAN DEFAULT 1, -- 是否启用
16
+ priority INTEGER DEFAULT 0, -- 优先级(用于排序)
17
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
18
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
19
+ UNIQUE(provider, token) -- 同一提供商内 Token 唯一
20
+ );
21
+
22
+ -- Token 使用统计表
23
+ CREATE TABLE IF NOT EXISTS token_stats (
24
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
25
+ token_id INTEGER NOT NULL,
26
+ total_requests INTEGER DEFAULT 0,
27
+ successful_requests INTEGER DEFAULT 0,
28
+ failed_requests INTEGER DEFAULT 0,
29
+ last_success_time DATETIME,
30
+ last_failure_time DATETIME,
31
+ FOREIGN KEY (token_id) REFERENCES tokens(id) ON DELETE CASCADE
32
+ );
33
+
34
+ -- 创建索引
35
+ CREATE INDEX IF NOT EXISTS idx_tokens_provider ON tokens(provider);
36
+ CREATE INDEX IF NOT EXISTS idx_tokens_enabled ON tokens(is_enabled);
37
+ CREATE INDEX IF NOT EXISTS idx_token_stats_token_id ON token_stats(token_id);
38
+
39
+ -- 触发器:自动更新 updated_at
40
+ CREATE TRIGGER IF NOT EXISTS update_tokens_timestamp
41
+ AFTER UPDATE ON tokens
42
+ BEGIN
43
+ UPDATE tokens SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
44
+ END;
45
+ """
46
+
47
+ # 数据库文件路径 - 支持环境变量配置
48
+ DB_PATH = os.getenv("DB_PATH", "tokens.db")
app/providers/__init__.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """
5
+ 多提供商架构包
6
+ 提供统一的提供商接口和路由机制
7
+ """
8
+
9
+ from app.providers.base import BaseProvider, ProviderConfig, provider_registry
10
+ from app.providers.zai_provider import ZAIProvider
11
+ from app.providers.k2think_provider import K2ThinkProvider
12
+ from app.providers.longcat_provider import LongCatProvider
13
+ from app.providers.provider_factory import ProviderFactory, ProviderRouter, get_provider_router, initialize_providers
14
+
15
+ __all__ = [
16
+ "BaseProvider",
17
+ "ProviderConfig",
18
+ "provider_registry",
19
+ "ZAIProvider",
20
+ "K2ThinkProvider",
21
+ "LongCatProvider",
22
+ "ProviderFactory",
23
+ "ProviderRouter",
24
+ "get_provider_router",
25
+ "initialize_providers"
26
+ ]
app/providers/base.py ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """
5
+ 基础提供商抽象层
6
+ 定义统一的提供商接口规范
7
+ """
8
+
9
+ import json
10
+ import time
11
+ import uuid
12
+ from abc import ABC, abstractmethod
13
+ from typing import Dict, List, Any, Optional, AsyncGenerator, Union
14
+ from dataclasses import dataclass
15
+
16
+ from app.models.schemas import OpenAIRequest, Message
17
+ from app.utils.logger import get_logger
18
+
19
+ logger = get_logger()
20
+
21
+
22
+ @dataclass
23
+ class ProviderConfig:
24
+ """提供商配置"""
25
+ name: str
26
+ api_endpoint: str
27
+ timeout: int = 30
28
+ headers: Optional[Dict[str, str]] = None
29
+ extra_config: Optional[Dict[str, Any]] = None
30
+
31
+
32
+ @dataclass
33
+ class ProviderResponse:
34
+ """提供商响应"""
35
+ success: bool
36
+ content: str = ""
37
+ error: Optional[str] = None
38
+ usage: Optional[Dict[str, int]] = None
39
+ extra_data: Optional[Dict[str, Any]] = None
40
+
41
+
42
+ class BaseProvider(ABC):
43
+ """基础提供商抽象类"""
44
+
45
+ def __init__(self, config: ProviderConfig):
46
+ """初始化提供商"""
47
+ self.config = config
48
+ self.name = config.name
49
+ self.logger = get_logger()
50
+
51
+ @abstractmethod
52
+ async def chat_completion(
53
+ self,
54
+ request: OpenAIRequest,
55
+ **kwargs
56
+ ) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
57
+ """
58
+ 聊天完成接口
59
+
60
+ Args:
61
+ request: OpenAI格式的请求
62
+ **kwargs: 额外参数
63
+
64
+ Returns:
65
+ 非流式: Dict[str, Any] - OpenAI格式的响应
66
+ 流式: AsyncGenerator[str, None] - SSE格式的流式响应
67
+ """
68
+ pass
69
+
70
+ @abstractmethod
71
+ async def transform_request(self, request: OpenAIRequest) -> Dict[str, Any]:
72
+ """
73
+ 转换OpenAI请求为提供商特定格式
74
+
75
+ Args:
76
+ request: OpenAI格式的请求
77
+
78
+ Returns:
79
+ Dict[str, Any]: 提供商特定格式的请求
80
+ """
81
+ pass
82
+
83
+ @abstractmethod
84
+ async def transform_response(
85
+ self,
86
+ response: Any,
87
+ request: OpenAIRequest
88
+ ) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
89
+ """
90
+ 转换提供商响应为OpenAI格式
91
+
92
+ Args:
93
+ response: 提供商的原始响应
94
+ request: 原始请求(用于构造响应)
95
+
96
+ Returns:
97
+ Union[Dict[str, Any], AsyncGenerator[str, None]]: OpenAI格式的响应
98
+ """
99
+ pass
100
+
101
+ def get_supported_models(self) -> List[str]:
102
+ """获取支持的模型列表"""
103
+ return []
104
+
105
+ def create_chat_id(self) -> str:
106
+ """生成聊天ID"""
107
+ return f"chatcmpl-{uuid.uuid4().hex}"
108
+
109
+ def create_openai_chunk(
110
+ self,
111
+ chat_id: str,
112
+ model: str,
113
+ delta: Dict[str, Any],
114
+ finish_reason: Optional[str] = None
115
+ ) -> Dict[str, Any]:
116
+ """创建OpenAI格式的流式响应块"""
117
+ return {
118
+ "id": chat_id,
119
+ "object": "chat.completion.chunk",
120
+ "created": int(time.time()),
121
+ "model": model,
122
+ "choices": [{
123
+ "index": 0,
124
+ "delta": delta,
125
+ "finish_reason": finish_reason,
126
+ "logprobs": None,
127
+ }],
128
+ "system_fingerprint": f"fp_{self.name}_001",
129
+ }
130
+
131
+ def create_openai_response(
132
+ self,
133
+ chat_id: str,
134
+ model: str,
135
+ content: str,
136
+ usage: Optional[Dict[str, int]] = None
137
+ ) -> Dict[str, Any]:
138
+ """创建OpenAI格式的非流式响应"""
139
+ return {
140
+ "id": chat_id,
141
+ "object": "chat.completion",
142
+ "created": int(time.time()),
143
+ "model": model,
144
+ "choices": [{
145
+ "index": 0,
146
+ "message": {
147
+ "role": "assistant",
148
+ "content": content
149
+ },
150
+ "finish_reason": "stop",
151
+ "logprobs": None,
152
+ }],
153
+ "usage": usage or {
154
+ "prompt_tokens": 0,
155
+ "completion_tokens": 0,
156
+ "total_tokens": 0
157
+ },
158
+ "system_fingerprint": f"fp_{self.name}_001",
159
+ }
160
+
161
+ def create_openai_response_with_reasoning(
162
+ self,
163
+ chat_id: str,
164
+ model: str,
165
+ content: str,
166
+ reasoning_content: str = None,
167
+ usage: Optional[Dict[str, int]] = None
168
+ ) -> Dict[str, Any]:
169
+ """创建包含推理内容的OpenAI格式非流式响应"""
170
+ message = {
171
+ "role": "assistant",
172
+ "content": content
173
+ }
174
+
175
+ # 只有当推理内容存在且不为空时才添加
176
+ if reasoning_content and reasoning_content.strip():
177
+ message["reasoning_content"] = reasoning_content
178
+
179
+ return {
180
+ "id": chat_id,
181
+ "object": "chat.completion",
182
+ "created": int(time.time()),
183
+ "model": model,
184
+ "choices": [{
185
+ "index": 0,
186
+ "message": message,
187
+ "finish_reason": "stop",
188
+ "logprobs": None,
189
+ }],
190
+ "usage": usage or {
191
+ "prompt_tokens": 0,
192
+ "completion_tokens": 0,
193
+ "total_tokens": 0
194
+ },
195
+ "system_fingerprint": f"fp_{self.name}_001",
196
+ }
197
+
198
+ async def format_sse_chunk(self, chunk: Dict[str, Any]) -> str:
199
+ """格式化SSE响应块"""
200
+ return f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n"
201
+
202
+ async def format_sse_done(self) -> str:
203
+ """格式化SSE结束标记"""
204
+ return "data: [DONE]\n\n"
205
+
206
+ def log_request(self, request: OpenAIRequest):
207
+ """记录请求日志"""
208
+ self.logger.info(f"🔄 {self.name} 处理请求: {request.model}")
209
+ self.logger.debug(f" 消息数量: {len(request.messages)}")
210
+ self.logger.debug(f" 流式模式: {request.stream}")
211
+
212
+ def log_response(self, success: bool, error: Optional[str] = None):
213
+ """记录响应日志"""
214
+ if success:
215
+ self.logger.info(f"✅ {self.name} 响应成功")
216
+ else:
217
+ self.logger.error(f"❌ {self.name} 响应失败: {error}")
218
+
219
+ def handle_error(self, error: Exception, context: str = "") -> Dict[str, Any]:
220
+ """统一错误处理"""
221
+ error_msg = f"{self.name} {context} 错误: {str(error)}"
222
+ self.logger.error(error_msg)
223
+
224
+ return {
225
+ "error": {
226
+ "message": error_msg,
227
+ "type": "provider_error",
228
+ "code": "internal_error"
229
+ }
230
+ }
231
+
232
+
233
+ class ProviderRegistry:
234
+ """提供商注册表"""
235
+
236
+ def __init__(self):
237
+ self._providers: Dict[str, BaseProvider] = {}
238
+ self._model_mapping: Dict[str, str] = {}
239
+
240
+ def register(self, provider: BaseProvider, models: List[str]):
241
+ """注册提供商"""
242
+ self._providers[provider.name] = provider
243
+ for model in models:
244
+ self._model_mapping[model] = provider.name
245
+ logger.info(f"📝 注册提供商: {provider.name}, 模型: {models}")
246
+
247
+ def get_provider(self, model: str) -> Optional[BaseProvider]:
248
+ """根据模型获取提供商"""
249
+ provider_name = self._model_mapping.get(model)
250
+ if provider_name:
251
+ return self._providers.get(provider_name)
252
+ return None
253
+
254
+ def get_provider_by_name(self, name: str) -> Optional[BaseProvider]:
255
+ """根据名称获取提供商"""
256
+ return self._providers.get(name)
257
+
258
+ def list_models(self) -> List[str]:
259
+ """列出所有支持的模型"""
260
+ return list(self._model_mapping.keys())
261
+
262
+ def list_providers(self) -> List[str]:
263
+ """列出所有提供商"""
264
+ return list(self._providers.keys())
265
+
266
+
267
+ # 全局提供商注册表
268
+ provider_registry = ProviderRegistry()
app/providers/k2think_provider.py ADDED
@@ -0,0 +1,509 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """
5
+ K2Think 提供商适配器
6
+ """
7
+
8
+ import json
9
+ import re
10
+ import time
11
+ import uuid
12
+ import httpx
13
+ from typing import Dict, List, Any, Optional, AsyncGenerator, Union
14
+
15
+ from app.providers.base import BaseProvider, ProviderConfig
16
+ from app.models.schemas import OpenAIRequest, Message
17
+ from app.utils.logger import get_logger
18
+
19
+ logger = get_logger()
20
+
21
+
22
+ class K2ThinkProvider(BaseProvider):
23
+ """K2Think 提供商"""
24
+
25
+ def __init__(self):
26
+ config = ProviderConfig(
27
+ name="k2think",
28
+ api_endpoint="https://www.k2think.ai/api/guest/chat/completions",
29
+ timeout=30,
30
+ headers={
31
+ 'Accept': 'text/event-stream',
32
+ 'Accept-Encoding': 'gzip, deflate, br, zstd',
33
+ 'Accept-Language': 'en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7',
34
+ 'Content-Type': 'application/json',
35
+ 'Origin': 'https://www.k2think.ai',
36
+ 'Pragma': 'no-cache',
37
+ 'Referer': 'https://www.k2think.ai/guest',
38
+ 'Sec-Ch-Ua': '"Chromium";v="124", "Google Chrome";v="124", "Not-A.Brand";v="99"',
39
+ 'Sec-Ch-Ua-Mobile': '?0',
40
+ 'Sec-Ch-Ua-Platform': '"macOS"',
41
+ 'Sec-Fetch-Dest': 'empty',
42
+ 'Sec-Fetch-Mode': 'cors',
43
+ 'Sec-Fetch-Site': 'same-origin',
44
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
45
+ }
46
+ )
47
+ super().__init__(config)
48
+
49
+ # K2Think 特定配置
50
+ self.handshake_url = "https://www.k2think.ai/guest"
51
+ self.new_chat_url = "https://www.k2think.ai/api/v1/chats/guest/new"
52
+
53
+ # 内容解析正则表达式 - 使用DOTALL标志确保.匹配换行符
54
+ self.reasoning_pattern = re.compile(r'<details type="reasoning"[^>]*>.*?<summary>.*?</summary>(.*?)</details>', re.DOTALL)
55
+ self.answer_pattern = re.compile(r'<answer>(.*?)</answer>', re.DOTALL)
56
+
57
+ def get_supported_models(self) -> List[str]:
58
+ """获取支持的模型列表"""
59
+ return ["MBZUAI-IFM/K2-Think"]
60
+
61
+ def parse_cookies(self, headers) -> str:
62
+ """解析Cookie"""
63
+ cookies = []
64
+ for key, value in headers.items():
65
+ if key.lower() == 'set-cookie':
66
+ cookies.append(value.split(';')[0])
67
+ return '; '.join(cookies)
68
+
69
+ def extract_reasoning_and_answer(self, content: str) -> tuple[str, str]:
70
+ """提取推理内容和答案内容"""
71
+ if not content:
72
+ return "", ""
73
+
74
+ try:
75
+ reasoning_match = self.reasoning_pattern.search(content)
76
+ reasoning = reasoning_match.group(1).strip() if reasoning_match else ""
77
+
78
+ answer_match = self.answer_pattern.search(content)
79
+ answer = answer_match.group(1).strip() if answer_match else ""
80
+
81
+ return reasoning, answer
82
+ except Exception as e:
83
+ self.logger.error(f"提取K2内容错误: {e}")
84
+ return "", ""
85
+
86
+ def calculate_delta(self, previous: str, current: str) -> str:
87
+ """计算内容增量"""
88
+ if not previous:
89
+ return current
90
+ if not current or len(current) < len(previous):
91
+ return ""
92
+ return current[len(previous):]
93
+
94
+ def parse_api_response(self, obj: Any) -> tuple[str, bool]:
95
+ """解析API响应"""
96
+ if not obj or not isinstance(obj, dict):
97
+ return "", False
98
+
99
+ if obj.get("done") is True:
100
+ return "", True
101
+
102
+ choices = obj.get("choices", [])
103
+ if choices and len(choices) > 0:
104
+ delta = choices[0].get("delta", {})
105
+ return delta.get("content", ""), False
106
+
107
+ content = obj.get("content")
108
+ if isinstance(content, str):
109
+ return content, False
110
+
111
+ return "", False
112
+
113
+ async def get_k2_auth_data(self, request: OpenAIRequest) -> Dict[str, Any]:
114
+ """获取K2Think认证数据"""
115
+ # 1. 握手请求 - 使用更简单的Accept-Encoding来避免Brotli问题
116
+ headers_for_handshake = {**self.config.headers}
117
+ headers_for_handshake['Accept-Encoding'] = 'gzip, deflate' # 移除br和zstd
118
+
119
+ async with httpx.AsyncClient() as client:
120
+ handshake_response = await client.get(
121
+ self.handshake_url,
122
+ headers=headers_for_handshake,
123
+ follow_redirects=True
124
+ )
125
+ if not handshake_response.is_success:
126
+ try:
127
+ # 使用httpx的text属性,它会自动处理解压缩和编码
128
+ error_text = handshake_response.text
129
+ raise Exception(f"K2 握手失败: {handshake_response.status_code} {error_text[:200]}")
130
+ except Exception as e:
131
+ raise Exception(f"K2 握手失败: {handshake_response.status_code}")
132
+
133
+ initial_cookies = self.parse_cookies(handshake_response.headers)
134
+
135
+ # 2. 准备消息
136
+ prepared_messages = self.prepare_k2_messages(request.messages)
137
+ first_user_message = next((m for m in prepared_messages if m["role"] == "user"), None)
138
+ if not first_user_message:
139
+ raise Exception("没有找到用户消息来初始化对话")
140
+
141
+ # 3. 创建新对话
142
+ message_id = str(uuid.uuid4())
143
+ now = int(time.time() * 1000)
144
+ model_id = request.model or "MBZUAI-IFM/K2-Think"
145
+
146
+ new_chat_payload = {
147
+ "chat": {
148
+ "id": "",
149
+ "title": "Guest Chat",
150
+ "models": [model_id],
151
+ "params": {},
152
+ "history": {
153
+ "messages": {
154
+ message_id: {
155
+ "id": message_id,
156
+ "parentId": None,
157
+ "childrenIds": [],
158
+ "role": "user",
159
+ "content": first_user_message["content"],
160
+ "timestamp": now // 1000,
161
+ "models": [model_id]
162
+ }
163
+ },
164
+ "currentId": message_id
165
+ },
166
+ "messages": [{
167
+ "id": message_id,
168
+ "parentId": None,
169
+ "childrenIds": [],
170
+ "role": "user",
171
+ "content": first_user_message["content"],
172
+ "timestamp": now // 1000,
173
+ "models": [model_id]
174
+ }],
175
+ "tags": [],
176
+ "timestamp": now
177
+ }
178
+ }
179
+
180
+ headers_with_cookies = {**self.config.headers, 'Cookie': initial_cookies}
181
+ headers_with_cookies['Accept-Encoding'] = 'gzip, deflate' # 移除br和zstd
182
+
183
+ async with httpx.AsyncClient() as client:
184
+ new_chat_response = await client.post(
185
+ self.new_chat_url,
186
+ headers=headers_with_cookies,
187
+ json=new_chat_payload,
188
+ follow_redirects=True
189
+ )
190
+ if not new_chat_response.is_success:
191
+ try:
192
+ # 使用httpx的text属性,它会自动处理解压缩和编码
193
+ error_text = new_chat_response.text
194
+ except Exception:
195
+ error_text = f"Status: {new_chat_response.status_code}"
196
+ raise Exception(f"K2 新对话创建失败: {new_chat_response.status_code} {error_text[:200]}")
197
+
198
+ try:
199
+ new_chat_data = new_chat_response.json()
200
+ except Exception as e:
201
+ # 如果JSON解析失败,尝试获取原始内容
202
+ try:
203
+ # 使用httpx的text属性,它会自动处理解压缩和编码
204
+ content_str = new_chat_response.text
205
+ self.logger.debug(f"K2 响应原始内容: {content_str[:500]}")
206
+ raise Exception(f"K2 响应JSON解析失败: {e}, 原始内容: {content_str[:200]}")
207
+ except Exception as decode_error:
208
+ # 如果text也失败,尝试手动处理
209
+ try:
210
+ raw_bytes = new_chat_response.content
211
+ content_str = raw_bytes.decode('utf-8', errors='replace')
212
+ raise Exception(f"K2 响应解析失败: {e}, 手动解码内容: {content_str[:200]}")
213
+ except Exception:
214
+ raise Exception(f"K2 响应解析完全失败: {e}, 解码错误: {decode_error}")
215
+ conversation_id = new_chat_data.get("id")
216
+ if not conversation_id:
217
+ raise Exception("无法从K2 /new端点获取conversation_id")
218
+
219
+ chat_specific_cookies = self.parse_cookies(new_chat_response.headers)
220
+
221
+ # 4. 组合最终Cookie
222
+ base_cookies = [initial_cookies, chat_specific_cookies]
223
+ base_cookies = [c for c in base_cookies if c]
224
+ final_cookie = '; '.join(base_cookies) + '; guest_conversation_count=1'
225
+
226
+ # 5. 构建最终请求载荷
227
+ final_payload = {
228
+ "stream": True,
229
+ "model": model_id,
230
+ "messages": prepared_messages,
231
+ "conversation_id": conversation_id,
232
+ "params": {}
233
+ }
234
+
235
+ # 添加可选参数
236
+ if request.temperature is not None:
237
+ final_payload["params"]["temperature"] = request.temperature
238
+ if request.max_tokens is not None:
239
+ final_payload["params"]["max_tokens"] = request.max_tokens
240
+
241
+ final_headers = {**self.config.headers, 'Cookie': final_cookie}
242
+
243
+ return {
244
+ "payload": final_payload,
245
+ "headers": final_headers
246
+ }
247
+
248
+ def prepare_k2_messages(self, messages: List[Message]) -> List[Dict[str, Any]]:
249
+ """准备K2Think消息格式"""
250
+ result = []
251
+ system_content = ""
252
+
253
+ for msg in messages:
254
+ if msg.role == "system":
255
+ system_content = system_content + "\n\n" + msg.content if system_content else msg.content
256
+ else:
257
+ content = msg.content
258
+ if isinstance(content, list):
259
+ # 处理多模态内容,提取文本
260
+ text_parts = [part.text for part in content if hasattr(part, 'text') and part.text]
261
+ content = "\n".join(text_parts)
262
+
263
+ result.append({
264
+ "role": msg.role,
265
+ "content": content
266
+ })
267
+
268
+ # 将系统消息合并到第一个用户消息中
269
+ if system_content:
270
+ first_user_idx = next((i for i, m in enumerate(result) if m["role"] == "user"), -1)
271
+ if first_user_idx >= 0:
272
+ result[first_user_idx]["content"] = f"{system_content}\n\n{result[first_user_idx]['content']}"
273
+ else:
274
+ result.insert(0, {"role": "user", "content": system_content})
275
+
276
+ return result
277
+
278
+ async def _handle_stream_request(
279
+ self,
280
+ transformed: Dict[str, Any],
281
+ request: OpenAIRequest
282
+ ) -> AsyncGenerator[str, None]:
283
+ """处理流式请求 - 在client.stream上下文内直接处理"""
284
+ chat_id = self.create_chat_id()
285
+ model = transformed["model"]
286
+
287
+ # 准备请求头
288
+ headers_for_request = {**transformed["headers"]}
289
+ headers_for_request['Accept-Encoding'] = 'gzip, deflate'
290
+
291
+ self.logger.info(f"🌊 开始K2Think流式请求")
292
+
293
+ async with httpx.AsyncClient(timeout=30.0) as client:
294
+ async with client.stream(
295
+ "POST",
296
+ transformed["url"],
297
+ headers=headers_for_request,
298
+ json=transformed["payload"]
299
+ ) as response:
300
+ if not response.is_success:
301
+ error_msg = f"K2Think API 错误: {response.status_code}"
302
+ self.log_response(False, error_msg)
303
+ # 对于流式响应,我们需要yield错误信息
304
+ yield await self.format_sse_chunk({
305
+ "error": {
306
+ "message": error_msg,
307
+ "type": "provider_error",
308
+ "code": "api_error"
309
+ }
310
+ })
311
+ return
312
+
313
+ # 发送初始角色块
314
+ yield await self.format_sse_chunk(
315
+ self.create_openai_chunk(chat_id, model, {"role": "assistant"})
316
+ )
317
+
318
+ # 处理流式数据
319
+ accumulated_content = ""
320
+ previous_reasoning = ""
321
+ previous_answer = ""
322
+ reasoning_phase = True
323
+ chunk_count = 0
324
+
325
+ try:
326
+ async for line in response.aiter_lines():
327
+ chunk_count += 1
328
+ self.logger.debug(f"📦 收到数据块 #{chunk_count}: {line[:100]}...")
329
+
330
+ if not line.startswith("data:"):
331
+ continue
332
+
333
+ data_str = line[5:].strip()
334
+ if self._is_end_marker(data_str):
335
+ self.logger.debug(f"🏁 检测到结束标记: {data_str}")
336
+ continue
337
+
338
+ content = self._parse_data_string(data_str)
339
+ if not content:
340
+ continue
341
+
342
+ accumulated_content = content
343
+ current_reasoning, current_answer = self.extract_reasoning_and_answer(accumulated_content)
344
+
345
+ # 处理推理阶段
346
+ if reasoning_phase and current_reasoning:
347
+ delta = self.calculate_delta(previous_reasoning, current_reasoning)
348
+ if delta.strip():
349
+ self.logger.debug(f"🧠 推理增量: {delta[:50]}...")
350
+ yield await self.format_sse_chunk(
351
+ self.create_openai_chunk(chat_id, model, {"reasoning_content": delta})
352
+ )
353
+ previous_reasoning = current_reasoning
354
+
355
+ # 切换到答案阶段
356
+ if current_answer and reasoning_phase:
357
+ reasoning_phase = False
358
+ self.logger.debug("🔄 切换到答案阶段")
359
+ # 发送剩余的推理内容
360
+ final_reasoning_delta = self.calculate_delta(previous_reasoning, current_reasoning)
361
+ if final_reasoning_delta.strip():
362
+ yield await self.format_sse_chunk(
363
+ self.create_openai_chunk(chat_id, model, {"reasoning_content": final_reasoning_delta})
364
+ )
365
+
366
+ # 处理答案阶段
367
+ if not reasoning_phase and current_answer:
368
+ delta = self.calculate_delta(previous_answer, current_answer)
369
+ if delta.strip():
370
+ self.logger.debug(f"💬 答案增量: {delta[:50]}...")
371
+ yield await self.format_sse_chunk(
372
+ self.create_openai_chunk(chat_id, model, {"content": delta})
373
+ )
374
+ previous_answer = current_answer
375
+
376
+ except Exception as e:
377
+ self.logger.error(f"流式响应处理错误: {e}")
378
+ yield await self.format_sse_chunk({
379
+ "error": {
380
+ "message": f"流式处理错误: {str(e)}",
381
+ "type": "stream_error",
382
+ "code": "processing_error"
383
+ }
384
+ })
385
+ return
386
+
387
+ # 发送结束块
388
+ self.logger.info(f"✅ K2Think流式响应完成,共处理 {chunk_count} 个数据块")
389
+ yield await self.format_sse_chunk(
390
+ self.create_openai_chunk(chat_id, model, {}, "stop")
391
+ )
392
+ yield await self.format_sse_done()
393
+
394
+ async def transform_request(self, request: OpenAIRequest) -> Dict[str, Any]:
395
+ """转换OpenAI请求为K2Think格式"""
396
+ self.logger.info(f"🔄 转换 OpenAI 请求到 K2Think 格式: {request.model}")
397
+
398
+ auth_data = await self.get_k2_auth_data(request)
399
+
400
+ return {
401
+ "url": self.config.api_endpoint,
402
+ "headers": auth_data["headers"],
403
+ "payload": auth_data["payload"],
404
+ "model": request.model
405
+ }
406
+
407
+ async def chat_completion(
408
+ self,
409
+ request: OpenAIRequest
410
+ ) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
411
+ """聊天完成接口"""
412
+ self.log_request(request)
413
+
414
+ try:
415
+ # 转换请求
416
+ transformed = await self.transform_request(request)
417
+
418
+ # 发送请求 - 使用更兼容的压缩设置
419
+ headers_for_request = {**transformed["headers"]}
420
+ headers_for_request['Accept-Encoding'] = 'gzip, deflate' # 移除br和zstd
421
+
422
+ if request.stream:
423
+ # 流式请求 - 直接在这里处理流式响应
424
+ return self._handle_stream_request(transformed, request)
425
+ else:
426
+ # 非流式请求 - 使用传统的 client.post()
427
+ async with httpx.AsyncClient(timeout=30.0) as client:
428
+ response = await client.post(
429
+ transformed["url"],
430
+ headers=headers_for_request,
431
+ json=transformed["payload"]
432
+ )
433
+
434
+ if not response.is_success:
435
+ error_msg = f"K2Think API 错误: {response.status_code}"
436
+ self.log_response(False, error_msg)
437
+ return self.handle_error(Exception(error_msg))
438
+
439
+ # 转换非流式响应
440
+ return await self.transform_response(response, request, transformed)
441
+
442
+ except Exception as e:
443
+ self.log_response(False, str(e))
444
+ return self.handle_error(e, "请求处理")
445
+
446
+ async def transform_response(
447
+ self,
448
+ response: httpx.Response,
449
+ request: OpenAIRequest,
450
+ transformed: Dict[str, Any]
451
+ ) -> Dict[str, Any]:
452
+ """转换K2Think响应为OpenAI格式 - 仅用于非流式请求"""
453
+ chat_id = self.create_chat_id()
454
+ model = transformed["model"]
455
+
456
+ # 流式请求现在由 _handle_stream_request 直接处理
457
+ # 这里只处理非流式请求
458
+ return await self._handle_non_stream_response(response, chat_id, model)
459
+
460
+ def _is_end_marker(self, data: str) -> bool:
461
+ """检查是否为结束标记"""
462
+ return not data or data in ["-1", "[DONE]", "DONE", "done"]
463
+
464
+ def _parse_data_string(self, data_str: str) -> str:
465
+ """解析数据字符串"""
466
+ try:
467
+ obj = json.loads(data_str)
468
+ content, is_done = self.parse_api_response(obj)
469
+ return "" if is_done else content
470
+ except:
471
+ return data_str
472
+
473
+ async def _handle_non_stream_response(
474
+ self,
475
+ response: httpx.Response,
476
+ chat_id: str,
477
+ model: str
478
+ ) -> Dict[str, Any]:
479
+ """处理K2Think非流式响应"""
480
+ # 聚合流式内容 - 使用httpx的aiter_lines,它���自动处理解压缩
481
+ final_content = ""
482
+
483
+ try:
484
+ # 使用aiter_lines(),httpx会自动处理压缩和编码
485
+ async for line in response.aiter_lines():
486
+ if not line.startswith("data:"):
487
+ continue
488
+
489
+ data_str = line[5:].strip()
490
+ if self._is_end_marker(data_str):
491
+ continue
492
+
493
+ content = self._parse_data_string(data_str)
494
+ if content:
495
+ final_content = content
496
+
497
+ except Exception as e:
498
+ self.logger.error(f"非流式响应处理错误: {e}")
499
+ raise
500
+
501
+ # 提取推理内容和答案内容
502
+ reasoning, answer = self.extract_reasoning_and_answer(final_content)
503
+
504
+ # 清理内容格式
505
+ reasoning = reasoning.replace("\\n", "\n") if reasoning else ""
506
+ answer = answer.replace("\\n", "\n") if answer else final_content
507
+
508
+ # 创建包含推理内容的响应
509
+ return self.create_openai_response_with_reasoning(chat_id, model, answer, reasoning)
app/providers/longcat_provider.py ADDED
@@ -0,0 +1,466 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """
5
+ LongCat 提供商适配器
6
+ """
7
+
8
+ import json
9
+ import time
10
+ import httpx
11
+ import random
12
+ import asyncio
13
+ from typing import Dict, List, Any, Optional, AsyncGenerator, Union
14
+
15
+ from app.providers.base import BaseProvider, ProviderConfig
16
+ from app.models.schemas import OpenAIRequest, Message
17
+ from app.utils.logger import get_logger
18
+ from app.utils.user_agent import get_dynamic_headers
19
+ from app.core.config import settings
20
+
21
+ logger = get_logger()
22
+
23
+
24
+ class LongCatProvider(BaseProvider):
25
+ """LongCat 提供商"""
26
+
27
+ def __init__(self):
28
+ # 使用动态生成的 headers,不包含 User-Agent(将在请求时动态生成)
29
+ config = ProviderConfig(
30
+ name="longcat",
31
+ api_endpoint="https://longcat.chat/api/v1/chat-completion",
32
+ timeout=30,
33
+ headers={
34
+ 'accept': 'text/event-stream,application/json',
35
+ 'content-type': 'application/json',
36
+ 'origin': 'https://longcat.chat',
37
+ 'referer': 'https://longcat.chat/t',
38
+ }
39
+ )
40
+ super().__init__(config)
41
+ self.base_url = "https://longcat.chat"
42
+ self.session_create_url = f"{self.base_url}/api/v1/session-create"
43
+ self.session_delete_url = f"{self.base_url}/api/v1/session-delete"
44
+
45
+ def get_supported_models(self) -> List[str]:
46
+ """获取支持的模型列表"""
47
+ return ["LongCat-Flash", "LongCat", "LongCat-Search"]
48
+
49
+ def get_passport_token(self) -> Optional[str]:
50
+ """获取 LongCat passport token"""
51
+ # 优先使用环境变量中的单个token
52
+ if settings.LONGCAT_TOKEN:
53
+ return settings.LONGCAT_TOKEN
54
+
55
+ # 从token文件中随机选择一个
56
+ token_list = settings.longcat_token_list
57
+ if token_list:
58
+ return random.choice(token_list)
59
+
60
+ return None
61
+
62
+ def create_headers_with_auth(self, token: str, user_agent: str, referer: str = None) -> Dict[str, str]:
63
+ """创建带认证的请求头"""
64
+ headers = {
65
+ "User-Agent": user_agent,
66
+ "Content-Type": "application/json",
67
+ "x-requested-with": "XMLHttpRequest",
68
+ "X-Client-Language": "zh",
69
+ "Cookie": f"passport_token_key={token}",
70
+ "Accept": "text/event-stream,application/json",
71
+ "Origin": "https://longcat.chat"
72
+ }
73
+ if referer:
74
+ headers["Referer"] = referer
75
+ else:
76
+ headers["Referer"] = f"{self.base_url}/"
77
+ return headers
78
+
79
+ async def create_session(self, token: str, user_agent: str) -> str:
80
+ """创建会话并返回 conversation_id"""
81
+ headers = self.create_headers_with_auth(token, user_agent)
82
+ data = {"model": "", "agentId": ""}
83
+
84
+ async with httpx.AsyncClient(timeout=30.0) as client:
85
+ response = await client.post(
86
+ self.session_create_url,
87
+ headers=headers,
88
+ json=data
89
+ )
90
+
91
+ if response.status_code != 200:
92
+ raise Exception(f"会话创建失败: {response.status_code}")
93
+
94
+ response_data = response.json()
95
+ if response_data.get("code") != 0:
96
+ raise Exception(f"会话创建错误: {response_data.get('message')}")
97
+
98
+ return response_data["data"]["conversationId"]
99
+
100
+ async def delete_session(self, conversation_id: str, token: str, user_agent: str) -> None:
101
+ """删除会话"""
102
+ try:
103
+ headers = self.create_headers_with_auth(
104
+ token,
105
+ user_agent,
106
+ f"{self.base_url}/c/{conversation_id}"
107
+ )
108
+
109
+ async with httpx.AsyncClient(timeout=30.0) as client:
110
+ url = f"{self.session_delete_url}?conversationId={conversation_id}"
111
+ response = await client.get(url, headers=headers)
112
+
113
+ if response.status_code == 200:
114
+ self.logger.debug(f"成功删除会话 {conversation_id}")
115
+ else:
116
+ self.logger.warning(f"删除会话失败: {response.status_code}")
117
+ except Exception as e:
118
+ self.logger.error(f"删除会话出错: {e}")
119
+
120
+ def schedule_session_deletion(self, conversation_id: str, token: str, user_agent: str):
121
+ """异步删除会话(不等待)"""
122
+ asyncio.create_task(self.delete_session(conversation_id, token, user_agent))
123
+
124
+ def format_messages_for_longcat(self, messages: List[Message]) -> str:
125
+ """格式化消息为 LongCat 格式"""
126
+ formatted_messages = []
127
+ for msg in messages:
128
+ content = msg.content
129
+ if isinstance(content, list):
130
+ # 处理多模态内容,提取文本
131
+ text_parts = []
132
+ for part in content:
133
+ if hasattr(part, 'text') and part.text:
134
+ text_parts.append(part.text)
135
+ content = "\n".join(text_parts)
136
+ formatted_messages.append(f"{msg.role}:{content}")
137
+ return ";".join(formatted_messages)
138
+
139
+ async def transform_request(self, request: OpenAIRequest) -> Dict[str, Any]:
140
+ """转换OpenAI请求为LongCat格式"""
141
+ # 获取认证token
142
+ passport_token = self.get_passport_token()
143
+ if not passport_token:
144
+ raise Exception("未配置 LongCat passport token,请设置 LONGCAT_TOKEN 环境变量")
145
+
146
+ # 生成动态 User-Agent
147
+ dynamic_headers = get_dynamic_headers()
148
+ user_agent = dynamic_headers.get("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
149
+
150
+ # 创建会话
151
+ conversation_id = await self.create_session(passport_token, user_agent)
152
+
153
+ # 格式化消息内容
154
+ formatted_content = self.format_messages_for_longcat(request.messages)
155
+
156
+ # 构建LongCat请求载荷
157
+ payload = {
158
+ "conversationId": conversation_id,
159
+ "content": formatted_content,
160
+ "reasonEnabled": 0,
161
+ "searchEnabled": 1 if "search" in request.model.lower() else 0,
162
+ "parentMessageId": 0
163
+ }
164
+
165
+ # 创建带认证的请求头
166
+ headers = self.create_headers_with_auth(
167
+ passport_token,
168
+ user_agent,
169
+ f"{self.base_url}/c/{conversation_id}"
170
+ )
171
+
172
+ return {
173
+ "url": self.config.api_endpoint,
174
+ "headers": headers,
175
+ "payload": payload,
176
+ "model": request.model,
177
+ "conversation_id": conversation_id,
178
+ "passport_token": passport_token,
179
+ "user_agent": user_agent
180
+ }
181
+
182
+ async def chat_completion(
183
+ self,
184
+ request: OpenAIRequest,
185
+ **kwargs
186
+ ) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
187
+ """聊天完成接口"""
188
+ self.log_request(request)
189
+
190
+ try:
191
+ # 转换请求
192
+ transformed = await self.transform_request(request)
193
+
194
+ # 发送请求
195
+ async with httpx.AsyncClient(timeout=30.0) as client:
196
+ response = await client.post(
197
+ transformed["url"],
198
+ headers=transformed["headers"],
199
+ json=transformed["payload"]
200
+ )
201
+
202
+ if not response.is_success:
203
+ error_msg = f"LongCat API 错误: {response.status_code}"
204
+ try:
205
+ error_detail = await response.atext()
206
+ self.logger.error(f"❌ API 错误详情: {error_detail}")
207
+ except:
208
+ pass
209
+ self.log_response(False, error_msg)
210
+ return self.handle_error(Exception(error_msg))
211
+
212
+ # 转换响应
213
+ return await self.transform_response(response, request, transformed)
214
+
215
+ except Exception as e:
216
+ self.logger.error(f"❌ LongCat 请求处理异常: {e}")
217
+ self.log_response(False, str(e))
218
+ return self.handle_error(e, "请求处理")
219
+
220
+ async def transform_response(
221
+ self,
222
+ response: httpx.Response,
223
+ request: OpenAIRequest,
224
+ transformed: Dict[str, Any]
225
+ ) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
226
+ """转换LongCat响应为OpenAI格式"""
227
+ chat_id = self.create_chat_id()
228
+ model = transformed["model"]
229
+ conversation_id = transformed["conversation_id"]
230
+ passport_token = transformed["passport_token"]
231
+ user_agent = transformed["user_agent"]
232
+
233
+ if request.stream:
234
+ return self._handle_stream_response(
235
+ response, chat_id, model, conversation_id, passport_token, user_agent
236
+ )
237
+ else:
238
+ return await self._handle_non_stream_response(
239
+ response, chat_id, model, conversation_id, passport_token, user_agent
240
+ )
241
+
242
+ async def _handle_stream_response(
243
+ self,
244
+ response: httpx.Response,
245
+ chat_id: str,
246
+ model: str,
247
+ conversation_id: str,
248
+ passport_token: str,
249
+ user_agent: str
250
+ ) -> AsyncGenerator[str, None]:
251
+ """处理LongCat流式响应"""
252
+ session_deleted = False
253
+
254
+ try:
255
+ # 发送初始角色块
256
+ yield await self.format_sse_chunk(
257
+ self.create_openai_chunk(chat_id, model, {"role": "assistant"})
258
+ )
259
+
260
+ stream_finished = False
261
+
262
+ async for line in response.aiter_lines():
263
+ line = line.strip()
264
+
265
+ # 首先检查是否是错误响应(JSON格式但不是SSE格式)
266
+ if not line.startswith('data:'):
267
+ # 尝试解析为JSON错误响应
268
+ try:
269
+ error_data = json.loads(line)
270
+ if isinstance(error_data, dict) and 'code' in error_data and 'message' in error_data:
271
+ # 这是一个错误响应
272
+ self.logger.error(f"❌ LongCat API 返回错误: {error_data}")
273
+ error_message = error_data.get('message', '未知错误')
274
+ error_code = error_data.get('code', 'unknown')
275
+
276
+ # 使用统一的错误处理函数
277
+ error_exception = Exception(f"LongCat API 错误 ({error_code}): {error_message}")
278
+ error_response = self.handle_error(error_exception, "API响应")
279
+
280
+ # 发送错误响应块
281
+ yield await self.format_sse_chunk(error_response)
282
+ yield await self.format_sse_done()
283
+
284
+ # 清理会话
285
+ if not session_deleted:
286
+ self.schedule_session_deletion(conversation_id, passport_token, user_agent)
287
+ session_deleted = True
288
+ return
289
+ except json.JSONDecodeError:
290
+ # 不是JSON,跳过这行
291
+ continue
292
+
293
+ # 如果不是错误响应,跳过
294
+ continue
295
+
296
+ data_str = line[5:].strip()
297
+ if data_str == '[DONE]':
298
+ # 如果还没有发送完成块,发送一个
299
+ if not stream_finished:
300
+ yield await self.format_sse_chunk(
301
+ self.create_openai_chunk(chat_id, model, {}, "stop")
302
+ )
303
+ yield await self.format_sse_done()
304
+
305
+ # 清理会话
306
+ if not session_deleted:
307
+ self.schedule_session_deletion(conversation_id, passport_token, user_agent)
308
+ session_deleted = True
309
+ break
310
+
311
+ try:
312
+ longcat_data = json.loads(data_str)
313
+
314
+ # 获取 delta 内容
315
+ choices = longcat_data.get("choices", [])
316
+ if not choices:
317
+ continue
318
+
319
+ delta = choices[0].get("delta", {})
320
+ content = delta.get("content")
321
+ finish_reason = choices[0].get("finishReason")
322
+
323
+ # 只有当内容不为空时才发送内容块
324
+ if content is not None and content != "":
325
+ openai_chunk = self.create_openai_chunk(
326
+ chat_id,
327
+ model,
328
+ {"content": content}
329
+ )
330
+ yield await self.format_sse_chunk(openai_chunk)
331
+
332
+ # 检查是否为流的结束
333
+ # LongCat 使用 lastOne=true 来标识最后一个块
334
+ if longcat_data.get("lastOne") and not stream_finished:
335
+ yield await self.format_sse_chunk(
336
+ self.create_openai_chunk(chat_id, model, {}, "stop")
337
+ )
338
+ yield await self.format_sse_done()
339
+ stream_finished = True
340
+
341
+ # 清理会话
342
+ if not session_deleted:
343
+ self.schedule_session_deletion(conversation_id, passport_token, user_agent)
344
+ session_deleted = True
345
+ break
346
+
347
+ # 备用检查:如果有 finishReason 但没有 lastOne,也可能是结束
348
+ elif finish_reason == "stop" and longcat_data.get("contentStatus") == "FINISHED" and not stream_finished:
349
+ yield await self.format_sse_chunk(
350
+ self.create_openai_chunk(chat_id, model, {}, "stop")
351
+ )
352
+ yield await self.format_sse_done()
353
+ stream_finished = True
354
+
355
+ # 清理会话
356
+ if not session_deleted:
357
+ self.schedule_session_deletion(conversation_id, passport_token, user_agent)
358
+ session_deleted = True
359
+ break
360
+
361
+ except json.JSONDecodeError as e:
362
+ self.logger.error(f"❌ 解析LongCat流数据错误: {e}")
363
+ continue
364
+ except Exception as e:
365
+ self.logger.error(f"❌ 处理LongCat流数据错误: {e}")
366
+ continue
367
+
368
+ except Exception as e:
369
+ self.logger.error(f"❌ LongCat流处理错误: {e}")
370
+ # 发送错误结束块(只有在还没有结束的情况下)
371
+ if not stream_finished:
372
+ yield await self.format_sse_chunk(
373
+ self.create_openai_chunk(chat_id, model, {}, "stop")
374
+ )
375
+ yield await self.format_sse_done()
376
+ finally:
377
+ # 确保会话被清理
378
+ if not session_deleted:
379
+ self.schedule_session_deletion(conversation_id, passport_token, user_agent)
380
+
381
+ async def _handle_non_stream_response(
382
+ self,
383
+ response: httpx.Response,
384
+ chat_id: str,
385
+ model: str,
386
+ conversation_id: str,
387
+ passport_token: str,
388
+ user_agent: str
389
+ ) -> Dict[str, Any]:
390
+ """处理LongCat非流式响应"""
391
+ full_content = ""
392
+ usage_info = {
393
+ "prompt_tokens": 0,
394
+ "completion_tokens": 0,
395
+ "total_tokens": 0
396
+ }
397
+
398
+ try:
399
+ async for line in response.aiter_lines():
400
+ line = line.strip()
401
+ if not line.startswith('data:'):
402
+ # 检查是否是错误响应
403
+ try:
404
+ error_data = json.loads(line)
405
+ if isinstance(error_data, dict) and 'code' in error_data and 'message' in error_data:
406
+ # 这是一个错误响应
407
+ self.logger.error(f"❌ LongCat API 返回错误: {error_data}")
408
+ error_message = error_data.get('message', '未知错误')
409
+ error_code = error_data.get('code', 'unknown')
410
+
411
+ # 使用统一的错误处理函数
412
+ error_exception = Exception(f"LongCat API 错误 ({error_code}): {error_message}")
413
+
414
+ # 清理会话
415
+ self.schedule_session_deletion(conversation_id, passport_token, user_agent)
416
+
417
+ return self.handle_error(error_exception, "API响应")
418
+ except json.JSONDecodeError:
419
+ # 不是JSON,跳过这行
420
+ pass
421
+ continue
422
+
423
+ data_str = line[5:].strip()
424
+ if data_str == '[DONE]':
425
+ break
426
+
427
+ try:
428
+ chunk = json.loads(data_str)
429
+
430
+ # 提取内容 - 只有当内容不为空时才添加
431
+ choices = chunk.get("choices", [])
432
+ if choices:
433
+ delta = choices[0].get("delta", {})
434
+ content = delta.get("content")
435
+ if content is not None and content != "":
436
+ full_content += content
437
+
438
+ # 提取使用信息(通常在最后的块中)
439
+ if chunk.get("tokenInfo"):
440
+ token_info = chunk["tokenInfo"]
441
+ usage_info = {
442
+ "prompt_tokens": token_info.get("promptTokens", 0),
443
+ "completion_tokens": token_info.get("completionTokens", 0),
444
+ "total_tokens": token_info.get("totalTokens", 0)
445
+ }
446
+
447
+ # 如果是最后一个块,可以提前结束
448
+ if chunk.get("lastOne"):
449
+ break
450
+
451
+ except json.JSONDecodeError:
452
+ continue
453
+
454
+ except Exception as e:
455
+ self.logger.error(f"❌ 处理LongCat非流式响应错误: {e}")
456
+ full_content = "处理响应时发生错误"
457
+ finally:
458
+ # 清理会话
459
+ self.schedule_session_deletion(conversation_id, passport_token, user_agent)
460
+
461
+ return self.create_openai_response(
462
+ chat_id,
463
+ model,
464
+ full_content.strip(),
465
+ usage_info
466
+ )
app/providers/provider_factory.py ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """
5
+ 提供商工厂和路由机制
6
+ 负责根据模型名称自动选择合适的提供商
7
+ """
8
+
9
+ import time
10
+ from typing import Dict, List, Optional, Union, AsyncGenerator, Any
11
+ from app.providers.base import BaseProvider, provider_registry
12
+ from app.providers.zai_provider import ZAIProvider
13
+ from app.providers.k2think_provider import K2ThinkProvider
14
+ from app.providers.longcat_provider import LongCatProvider
15
+ from app.models.schemas import OpenAIRequest
16
+ from app.core.config import settings
17
+ from app.utils.logger import get_logger
18
+
19
+ logger = get_logger()
20
+
21
+
22
+ class ProviderFactory:
23
+ """提供商工厂"""
24
+
25
+ def __init__(self):
26
+ self._initialized = False
27
+ self._default_provider = "zai"
28
+
29
+ def initialize(self):
30
+ """初始化所有提供商"""
31
+ if self._initialized:
32
+ return
33
+
34
+ try:
35
+ # 注册 Z.AI 提供商
36
+ zai_provider = ZAIProvider()
37
+ provider_registry.register(
38
+ zai_provider,
39
+ zai_provider.get_supported_models()
40
+ )
41
+
42
+ # 注册 K2Think 提供商
43
+ k2think_provider = K2ThinkProvider()
44
+ provider_registry.register(
45
+ k2think_provider,
46
+ k2think_provider.get_supported_models()
47
+ )
48
+
49
+ # 注册 LongCat 提供商
50
+ longcat_provider = LongCatProvider()
51
+ provider_registry.register(
52
+ longcat_provider,
53
+ longcat_provider.get_supported_models()
54
+ )
55
+
56
+ self._initialized = True
57
+
58
+ except Exception as e:
59
+ logger.error(f"❌ 提供商工厂初始化失败: {e}")
60
+ raise
61
+
62
+ def get_provider_for_model(self, model: str) -> Optional[BaseProvider]:
63
+ """根据模型名称获取提供商"""
64
+ if not self._initialized:
65
+ self.initialize()
66
+
67
+ # 首先尝试从配置的映射中获取
68
+ provider_mapping = settings.provider_model_mapping
69
+ provider_name = provider_mapping.get(model)
70
+
71
+ if provider_name:
72
+ provider = provider_registry.get_provider_by_name(provider_name)
73
+ if provider:
74
+ logger.debug(f"🎯 模型 {model} 映射到提供商 {provider_name}")
75
+ return provider
76
+
77
+ # 尝试从注册表中直接获取
78
+ provider = provider_registry.get_provider(model)
79
+ if provider:
80
+ logger.debug(f"🎯 模型 {model} 找到提供商 {provider.name}")
81
+ return provider
82
+
83
+ # 使用默认提供商
84
+ default_provider = provider_registry.get_provider_by_name(self._default_provider)
85
+ if default_provider:
86
+ logger.warning(f"⚠️ 模型 {model} 未找到专用提供商,使用默认提供商 {self._default_provider}")
87
+ return default_provider
88
+
89
+ logger.error(f"❌ 无法为模型 {model} 找到任何提供商")
90
+ return None
91
+
92
+ def list_supported_models(self) -> List[str]:
93
+ """列出所有支持的模型"""
94
+ if not self._initialized:
95
+ self.initialize()
96
+ return provider_registry.list_models()
97
+
98
+ def list_providers(self) -> List[str]:
99
+ """列出所有提供商"""
100
+ if not self._initialized:
101
+ self.initialize()
102
+ return provider_registry.list_providers()
103
+
104
+ def get_models_for_provider(self, provider_name: str) -> List[str]:
105
+ """获取指定提供商支持的模型"""
106
+ if not self._initialized:
107
+ self.initialize()
108
+
109
+ provider = provider_registry.get_provider_by_name(provider_name)
110
+ if provider:
111
+ return provider.get_supported_models()
112
+ return []
113
+
114
+
115
+ class ProviderRouter:
116
+ """提供商路由器"""
117
+
118
+ def __init__(self):
119
+ self.factory = ProviderFactory()
120
+
121
+ async def route_request(
122
+ self,
123
+ request: OpenAIRequest,
124
+ **kwargs
125
+ ) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
126
+ """路由请求到合适的提供商"""
127
+ logger.info(f"🚦 路由请求: 模型={request.model}, 流式={request.stream}")
128
+
129
+ # 获取提供商
130
+ provider = self.factory.get_provider_for_model(request.model)
131
+ if not provider:
132
+ error_msg = f"不支持的模型: {request.model}"
133
+ logger.error(f"❌ {error_msg}")
134
+ return {
135
+ "error": {
136
+ "message": error_msg,
137
+ "type": "invalid_request_error",
138
+ "code": "model_not_found"
139
+ }
140
+ }
141
+
142
+ logger.info(f"✅ 使用提供商: {provider.name}")
143
+
144
+ try:
145
+ # 调用提供商处理请求
146
+ result = await provider.chat_completion(request, **kwargs)
147
+ logger.info(f"🎉 请求处理��成: {provider.name}")
148
+ return result
149
+
150
+ except Exception as e:
151
+ error_msg = f"提供商 {provider.name} 处理请求失败: {str(e)}"
152
+ logger.error(f"❌ {error_msg}")
153
+ return provider.handle_error(e, "路由处理")
154
+
155
+ def get_provider_for_model(self, model: str) -> Optional[Dict[str, str]]:
156
+ """
157
+ 获取模型对应的提供商信息
158
+
159
+ Returns:
160
+ 包含提供商名称的字典,例如 {"provider": "zai"}
161
+ """
162
+ provider = self.factory.get_provider_for_model(model)
163
+ if provider:
164
+ return {"provider": provider.name}
165
+ return None
166
+
167
+ def get_models_list(self) -> Dict[str, Any]:
168
+ """获取模型列表(OpenAI格式)"""
169
+ models = []
170
+ current_time = int(time.time())
171
+
172
+ # 按提供商分组获取模型
173
+ for provider_name in self.factory.list_providers():
174
+ provider_models = self.factory.get_models_for_provider(provider_name)
175
+ for model in provider_models:
176
+ models.append({
177
+ "id": model,
178
+ "object": "model",
179
+ "created": current_time,
180
+ "owned_by": provider_name
181
+ })
182
+
183
+ return {
184
+ "object": "list",
185
+ "data": models
186
+ }
187
+
188
+
189
+ # 全局路由器实例
190
+ _router: Optional[ProviderRouter] = None
191
+
192
+
193
+ def get_provider_router() -> ProviderRouter:
194
+ """获取全局提供商路由器"""
195
+ global _router
196
+ if _router is None:
197
+ _router = ProviderRouter()
198
+ # 确保工厂已初始化
199
+ _router.factory.initialize()
200
+ return _router
201
+
202
+
203
+ def initialize_providers():
204
+ """初始化提供商系统"""
205
+ logger.info("🚀 初始化提供商系统...")
206
+ router = get_provider_router()
207
+ logger.info("✅ 提供商系统初始化完成")
208
+ return router
app/providers/zai_provider.py ADDED
@@ -0,0 +1,1152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """
5
+ Z.AI 提供商适配器
6
+ """
7
+
8
+ import json
9
+ import time
10
+ import uuid
11
+ import httpx
12
+ import hmac
13
+ import hashlib
14
+ import base64
15
+ from urllib.parse import urlencode
16
+ import os
17
+ import uuid
18
+ import random
19
+ from datetime import datetime
20
+ from typing import Dict, List, Any, Optional, AsyncGenerator, Union
21
+ from app.utils.user_agent import get_random_user_agent
22
+ from app.providers.base import BaseProvider, ProviderConfig
23
+ from app.models.schemas import OpenAIRequest, Message
24
+ from app.core.config import settings
25
+ from app.utils.logger import get_logger
26
+ from app.utils.token_pool import get_token_pool
27
+ from app.utils.tool_call_handler import (
28
+ process_messages_with_tools,
29
+ parse_and_extract_tool_calls,
30
+ )
31
+
32
+ logger = get_logger()
33
+
34
+ def generate_uuid() -> str:
35
+ """生成UUID v4"""
36
+ return str(uuid.uuid4())
37
+
38
+ def get_zai_dynamic_headers(chat_id: str = "") -> Dict[str, str]:
39
+ """生成 Z.AI 特定的动态浏览器 headers"""
40
+ browser_choices = ["chrome", "chrome", "chrome", "edge", "edge", "firefox", "safari"]
41
+ browser_type = random.choice(browser_choices)
42
+ user_agent = get_random_user_agent(browser_type)
43
+
44
+ chrome_version = "139"
45
+ edge_version = "139"
46
+
47
+ if "Chrome/" in user_agent:
48
+ try:
49
+ chrome_version = user_agent.split("Chrome/")[1].split(".")[0]
50
+ except:
51
+ pass
52
+
53
+ if "Edg/" in user_agent:
54
+ try:
55
+ edge_version = user_agent.split("Edg/")[1].split(".")[0]
56
+ sec_ch_ua = f'"Microsoft Edge";v="{edge_version}", "Chromium";v="{chrome_version}", "Not_A Brand";v="24"'
57
+ except:
58
+ sec_ch_ua = f'"Not_A Brand";v="8", "Chromium";v="{chrome_version}", "Google Chrome";v="{chrome_version}"'
59
+ elif "Firefox/" in user_agent:
60
+ sec_ch_ua = None
61
+ else:
62
+ sec_ch_ua = f'"Not_A Brand";v="8", "Chromium";v="{chrome_version}", "Google Chrome";v="{chrome_version}"'
63
+
64
+ headers = {
65
+ "Content-Type": "application/json",
66
+ "Accept": "application/json, text/event-stream",
67
+ "Connection": "keep-alive",
68
+ "Cache-Control": "no-cache",
69
+ "User-Agent": user_agent,
70
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
71
+ "X-FE-Version": "prod-fe-1.0.98",
72
+ "Origin": "https://chat.z.ai",
73
+ }
74
+
75
+ if sec_ch_ua:
76
+ headers["sec-ch-ua"] = sec_ch_ua
77
+ headers["sec-ch-ua-mobile"] = "?0"
78
+ headers["sec-ch-ua-platform"] = '"Windows"'
79
+
80
+ if chat_id:
81
+ headers["Referer"] = f"https://chat.z.ai/c/{chat_id}"
82
+ else:
83
+ headers["Referer"] = "https://chat.z.ai/"
84
+
85
+ return headers
86
+
87
+ def _urlsafe_b64decode(data: str) -> bytes:
88
+ """Decode a URL-safe base64 string with proper padding."""
89
+ if isinstance(data, str):
90
+ data_bytes = data.encode("utf-8")
91
+ else:
92
+ data_bytes = data
93
+ padding = b"=" * (-len(data_bytes) % 4)
94
+ return base64.urlsafe_b64decode(data_bytes + padding)
95
+
96
+
97
+ def _decode_jwt_payload(token: str) -> Dict[str, Any]:
98
+ """Decode JWT payload without verification to extract metadata."""
99
+ try:
100
+ parts = token.split(".")
101
+ if len(parts) < 2:
102
+ return {}
103
+ payload_raw = _urlsafe_b64decode(parts[1])
104
+ return json.loads(payload_raw.decode("utf-8", errors="ignore"))
105
+ except Exception:
106
+ return {}
107
+
108
+
109
+ def _extract_user_id_from_token(token: str) -> str:
110
+ """Extract user_id from a JWT's payload. Fallback to 'guest'."""
111
+ payload = _decode_jwt_payload(token) if token else {}
112
+ for key in ("id", "user_id", "uid", "sub"):
113
+ val = payload.get(key)
114
+ if isinstance(val, (str, int)) and str(val):
115
+ return str(val)
116
+ return "guest"
117
+
118
+
119
+ def generate_signature(message_text: str, request_id: str, timestamp_ms: int, user_id: str, secret: str = "junjie") -> str:
120
+ """Dual-layer HMAC-SHA256 signature.
121
+
122
+ Layer1: derived key = HMAC(secret, window_index)
123
+ Layer2: signature = HMAC(derived_key, canonical_string)
124
+ canonical_string = "requestId,<id>,timestamp,<ts>,user_id,<uid>|<msg>|<ts>"
125
+ """
126
+ r = str(timestamp_ms)
127
+ e = f"requestId,{request_id},timestamp,{timestamp_ms},user_id,{user_id}"
128
+ t = message_text or ""
129
+ # Add content_base64 processing for new signature algorithm
130
+ content_base64 = base64.b64encode(t.encode('utf-8')).decode('ascii')
131
+ i = f"{e}|{content_base64}|{r}"
132
+
133
+ window_index = timestamp_ms // (5 * 60 * 1000)
134
+ root_key = (secret or "junjie").encode("utf-8")
135
+ derived_hex = hmac.new(root_key, str(window_index).encode("utf-8"), hashlib.sha256).hexdigest()
136
+ signature = hmac.new(derived_hex.encode("utf-8"), i.encode("utf-8"), hashlib.sha256).hexdigest()
137
+ return signature
138
+
139
+
140
+ class ZAIProvider(BaseProvider):
141
+ """Z.AI 提供商"""
142
+
143
+ def __init__(self):
144
+ config = ProviderConfig(
145
+ name="zai",
146
+ api_endpoint=settings.API_ENDPOINT,
147
+ timeout=30,
148
+ headers=get_zai_dynamic_headers()
149
+ )
150
+ super().__init__(config)
151
+
152
+ # Z.AI 特定配置
153
+ self.base_url = "https://chat.z.ai"
154
+ self.auth_url = f"{self.base_url}/api/v1/auths/"
155
+
156
+ # 模型映射
157
+ self.model_mapping = {
158
+ settings.GLM45_MODEL: "0727-360B-API", # GLM-4.5
159
+ settings.GLM45_THINKING_MODEL: "0727-360B-API", # GLM-4.5-Thinking
160
+ settings.GLM45_SEARCH_MODEL: "0727-360B-API", # GLM-4.5-Search
161
+ settings.GLM45_AIR_MODEL: "0727-106B-API", # GLM-4.5-Air
162
+ settings.GLM45V_MODEL: "glm-4.5v", # GLM-4.5V多模态
163
+ settings.GLM46_MODEL: "GLM-4-6-API-V1", # GLM-4.6
164
+ settings.GLM46_THINKING_MODEL: "GLM-4-6-API-V1", # GLM-4.6-Thinking
165
+ settings.GLM46_SEARCH_MODEL: "GLM-4-6-API-V1", # GLM-4.6-Search
166
+ settings.GLM46_ADVANCED_SEARCH_MODEL: "GLM-4-6-API-V1", # GLM-4.6-advanced-search
167
+ }
168
+
169
+ def get_supported_models(self) -> List[str]:
170
+ """获取支持的模型列表"""
171
+ return [
172
+ settings.GLM45_MODEL,
173
+ settings.GLM45_THINKING_MODEL,
174
+ settings.GLM45_SEARCH_MODEL,
175
+ settings.GLM45_AIR_MODEL,
176
+ settings.GLM45V_MODEL,
177
+ settings.GLM46_MODEL,
178
+ settings.GLM46_THINKING_MODEL,
179
+ settings.GLM46_SEARCH_MODEL,
180
+ settings.GLM46_ADVANCED_SEARCH_MODEL,
181
+ ]
182
+
183
+ async def get_token(self) -> str:
184
+ """获取认证令牌"""
185
+ # 如果启用匿名模式,只尝试获取访客令牌
186
+ if settings.ANONYMOUS_MODE:
187
+ try:
188
+ headers = get_zai_dynamic_headers()
189
+ async with httpx.AsyncClient() as client:
190
+ response = await client.get(self.auth_url, headers=headers, timeout=10.0)
191
+ if response.status_code == 200:
192
+ data = response.json()
193
+ token = data.get("token", "")
194
+ if token:
195
+ # 判断令牌类型(通过检查邮箱或user_id)
196
+ email = data.get("email", "")
197
+ is_guest = "@guest.com" in email or "Guest-" in email
198
+ token_type = "匿名用户" if is_guest else "认证用户"
199
+ self.logger.debug(f"获取令牌成功 ({token_type}): {token[:20]}...")
200
+ return token
201
+ except Exception as e:
202
+ self.logger.warning(f"异步获取访客令牌失败: {e}")
203
+
204
+ # 匿名模式下,如果获取访客令牌失败,直接返回空
205
+ self.logger.error("❌ 匿名模式下获取访客令牌失败")
206
+ return ""
207
+
208
+ # 非匿名模式:首先使用token池获取备份令牌
209
+ token_pool = get_token_pool()
210
+ if token_pool:
211
+ token = token_pool.get_next_token()
212
+ if token:
213
+ self.logger.debug(f"从token池获取令牌: {token[:20]}...")
214
+ return token
215
+
216
+ # 如果token池为空或没有可用token,使用配置的AUTH_TOKEN
217
+ if settings.AUTH_TOKEN and settings.AUTH_TOKEN != "sk-your-api-key":
218
+ self.logger.debug(f"使用配置的AUTH_TOKEN")
219
+ return settings.AUTH_TOKEN
220
+
221
+ self.logger.error("❌ 无法获取有效的认证令牌")
222
+ return ""
223
+
224
+ def mark_token_failure(self, token: str, error: Exception = None):
225
+ """标记token使用失败"""
226
+ token_pool = get_token_pool()
227
+ if token_pool:
228
+ token_pool.mark_token_failure(token, error)
229
+
230
+ async def upload_image(self, data_url: str, chat_id: str, token: str, user_id: str) -> Optional[Dict]:
231
+ """上传 base64 编码的图片到 Z.AI 服务器
232
+
233
+ Args:
234
+ data_url: data:image/xxx;base64,... 格式的图片数据
235
+ chat_id: 当前对话ID
236
+ token: 认证令牌
237
+ user_id: 用户ID
238
+
239
+ Returns:
240
+ 上传成功返回完整的文件信息字典,失败返回None
241
+ """
242
+ if settings.ANONYMOUS_MODE or not data_url.startswith("data:"):
243
+ return None
244
+
245
+ try:
246
+ # 解析 data URL
247
+ header, encoded = data_url.split(",", 1)
248
+ mime_type = header.split(";")[0].split(":")[1] if ":" in header else "image/jpeg"
249
+
250
+ # 解码 base64 数据
251
+ image_data = base64.b64decode(encoded)
252
+ filename = str(uuid.uuid4())
253
+
254
+ self.logger.debug(f"📤 上传图片: {filename}, 大小: {len(image_data)} bytes")
255
+
256
+ # 构建上传请求 - 使用简化的请求头配置
257
+ upload_url = f"{self.base_url}/api/v1/files/"
258
+ headers = {
259
+ "Accept": "*/*",
260
+ "Accept-Language": "zh-CN,zh;q=0.9",
261
+ "Cache-Control": "no-cache",
262
+ "Connection": "keep-alive",
263
+ "Origin": f"{self.base_url}",
264
+ "Pragma": "no-cache",
265
+ "Referer": f"{self.base_url}/c/{chat_id}",
266
+ "Sec-Ch-Ua": '"Microsoft Edge";v="141", "Not?A_Brand";v="8", "Chromium";v="141"',
267
+ "Sec-Ch-Ua-Mobile": "?0",
268
+ "Sec-Ch-Ua-Platform": '"Windows"',
269
+ "Sec-Fetch-Dest": "empty",
270
+ "Sec-Fetch-Mode": "cors",
271
+ "Sec-Fetch-Site": "same-origin",
272
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36 Edg/141.0.0.0",
273
+ "Authorization": f"Bearer {token}",
274
+ }
275
+
276
+ # 使用 httpx 上传文件
277
+ async with httpx.AsyncClient(timeout=30.0) as client:
278
+ files = {
279
+ "file": (filename, image_data, mime_type)
280
+ }
281
+ response = await client.post(upload_url, files=files, headers=headers)
282
+
283
+ if response.status_code == 200:
284
+ result = response.json()
285
+ file_id = result.get("id")
286
+ file_name = result.get("filename")
287
+ file_size = len(image_data)
288
+
289
+ self.logger.info(f"✅ 图片上传成功: {file_id}_{file_name}")
290
+
291
+ # 返回符合 Z.AI 格式的文件信息
292
+ current_timestamp = int(time.time())
293
+ return {
294
+ "type": "image",
295
+ "file": {
296
+ "id": file_id,
297
+ "user_id": user_id,
298
+ "hash": None,
299
+ "filename": file_name,
300
+ "data": {},
301
+ "meta": {
302
+ "name": file_name,
303
+ "content_type": mime_type,
304
+ "size": file_size,
305
+ "data": {},
306
+ },
307
+ "created_at": current_timestamp,
308
+ "updated_at": current_timestamp
309
+ },
310
+ "id": file_id,
311
+ "url": f"/api/v1/files/{file_id}/content",
312
+ "name": file_name,
313
+ "status": "uploaded",
314
+ "size": file_size,
315
+ "error": "",
316
+ "itemId": str(uuid.uuid4()),
317
+ "media": "image"
318
+ }
319
+ else:
320
+ self.logger.error(f"❌ 图片上传失败: {response.status_code} - {response.text}")
321
+ return None
322
+
323
+ except Exception as e:
324
+ self.logger.error(f"❌ 图片上传异常: {e}")
325
+ return None
326
+
327
+ async def transform_request(self, request: OpenAIRequest) -> Dict[str, Any]:
328
+ """转换OpenAI请求为Z.AI格式"""
329
+ self.logger.info(f"🔄 转换 OpenAI 请求到 Z.AI 格式: {request.model}")
330
+
331
+ # 获取认证令牌
332
+ token = await self.get_token()
333
+ user_id = _extract_user_id_from_token(token)
334
+
335
+ # 生成 chat_id(用于图片上传)
336
+ chat_id = generate_uuid()
337
+
338
+ # 处理消息格式 - Z.AI 使用单独的 files 字段传递图片
339
+ messages = []
340
+ files = [] # 存储上传的图片文件信息
341
+
342
+ for msg in request.messages:
343
+ if isinstance(msg.content, str):
344
+ # 纯文本消息
345
+ messages.append({
346
+ "role": msg.role,
347
+ "content": msg.content
348
+ })
349
+ elif isinstance(msg.content, list):
350
+ # 多模态内容:分离文本和图片
351
+ text_parts = []
352
+ image_parts = [] # 存储图片引用
353
+
354
+ for part in msg.content:
355
+ if hasattr(part, 'type'):
356
+ if part.type == 'text' and hasattr(part, 'text'):
357
+ # 文本部分
358
+ text_parts.append(part.text or '')
359
+ elif part.type == 'image_url' and hasattr(part, 'image_url'):
360
+ # 图片部分 - 提取并上传
361
+ image_url = None
362
+ if hasattr(part.image_url, 'url'):
363
+ image_url = part.image_url.url
364
+ elif isinstance(part.image_url, dict) and 'url' in part.image_url:
365
+ image_url = part.image_url['url']
366
+
367
+ if image_url:
368
+ self.logger.debug(f"✅ 检测到图片: {image_url[:50]}...")
369
+
370
+ # 如果是 base64 编码的图片,上传并添加到 files 数组
371
+ if image_url.startswith("data:") and not settings.ANONYMOUS_MODE:
372
+ self.logger.info(f"🔄 上传 base64 图片到 Z.AI 服务器")
373
+ file_info = await self.upload_image(image_url, chat_id, token, user_id)
374
+
375
+ if file_info:
376
+ files.append(file_info)
377
+ self.logger.info(f"✅ 图片已添加到 files 数组")
378
+
379
+ # 在消息中保留图片引用
380
+ image_ref = f"{file_info['id']}_{file_info['name']}"
381
+ image_parts.append({
382
+ "type": "image_url",
383
+ "image_url": {
384
+ "url": image_ref
385
+ }
386
+ })
387
+ self.logger.debug(f"📎 图片引用: {image_ref}")
388
+ else:
389
+ # 上传失败,添加错误提示
390
+ self.logger.warning(f"⚠️ 图片上传失败")
391
+ text_parts.append("[系统提示: 图片上传失败]")
392
+ else:
393
+ # 非 base64 图片或匿名模式,直接使用原URL
394
+ if not settings.ANONYMOUS_MODE:
395
+ self.logger.warning(f"⚠️ 非 base64 图片或匿名模式,保留原始URL")
396
+ image_parts.append({
397
+ "type": "image_url",
398
+ "image_url": {"url": image_url}
399
+ })
400
+ elif isinstance(part, dict):
401
+ # 直接是字典格式的内容
402
+ if part.get('type') == 'text':
403
+ text_parts.append(part.get('text', ''))
404
+ elif part.get('type') == 'image_url':
405
+ image_url = part.get('image_url', {}).get('url', '')
406
+ if image_url:
407
+ self.logger.debug(f"✅ 检测到图片: {image_url[:50]}...")
408
+
409
+ # 如果是 base64 编码的图片,上传并添加到 files 数组
410
+ if image_url.startswith("data:") and not settings.ANONYMOUS_MODE:
411
+ self.logger.info(f"🔄 上传 base64 图片到 Z.AI 服务器")
412
+ file_info = await self.upload_image(image_url, chat_id, token, user_id)
413
+
414
+ if file_info:
415
+ files.append(file_info)
416
+ self.logger.info(f"✅ 图片已添加到 files 数组")
417
+
418
+ # 在消息中保留图片引用
419
+ image_ref = f"{file_info['id']}_{file_info['name']}"
420
+ image_parts.append({
421
+ "type": "image_url",
422
+ "image_url": {
423
+ "url": image_ref
424
+ }
425
+ })
426
+ self.logger.debug(f"📎 图片引用: {image_ref}")
427
+ else:
428
+ # 上传失败,添加错误提示
429
+ self.logger.warning(f"⚠️ 图片上传失败")
430
+ text_parts.append("[系统提示: 图片上传失败]")
431
+ else:
432
+ # 非 base64 图片或匿名模式
433
+ if not settings.ANONYMOUS_MODE:
434
+ self.logger.warning(f"⚠️ 非 base64 图片或匿名模式,保留原始URL")
435
+ image_parts.append({
436
+ "type": "image_url",
437
+ "image_url": {"url": image_url}
438
+ })
439
+ elif isinstance(part, str):
440
+ # 纯字符串部分
441
+ text_parts.append(part)
442
+
443
+ # 构建多模态消息内容
444
+ message_content = []
445
+
446
+ # 添加文本部分
447
+ combined_text = " ".join(text_parts).strip()
448
+ if combined_text:
449
+ message_content.append({
450
+ "type": "text",
451
+ "text": combined_text
452
+ })
453
+
454
+ # 添加图片部分(保持图片引用在消息中)
455
+ message_content.extend(image_parts)
456
+
457
+ # 只有在有内容时才添加消息
458
+ if message_content:
459
+ messages.append({
460
+ "role": msg.role,
461
+ "content": message_content # ✅ 多模态内容数组
462
+ })
463
+
464
+ # 确定请求的模型特性
465
+ # Extract last user message text for signing (提取最后一条用户消息的文本用于签名)
466
+ last_user_text = ""
467
+ for m in reversed(messages):
468
+ if m.get("role") == "user":
469
+ content = m.get("content")
470
+ if isinstance(content, str):
471
+ # 纯文本消息
472
+ last_user_text = content
473
+ break
474
+ elif isinstance(content, list):
475
+ # 多模态消息:只提取文本部分用于签名
476
+ texts = [p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") == "text"]
477
+ last_user_text = " ".join([t for t in texts if t]).strip()
478
+ break
479
+ requested_model = request.model
480
+ is_thinking = "-thinking" in requested_model.casefold()
481
+ is_search = "-search" in requested_model.casefold()
482
+ is_advanced_search = requested_model == settings.GLM46_ADVANCED_SEARCH_MODEL
483
+ is_air = "-air" in requested_model.casefold()
484
+
485
+ # 获取上游模型ID
486
+ upstream_model_id = self.model_mapping.get(requested_model, "0727-360B-API")
487
+
488
+ # ⚠️ 重要:在构建 body 之前处理工具调用!
489
+ # 处理工具支持 - 使用提示词注入方式
490
+ if settings.TOOL_SUPPORT and not is_thinking and request.tools:
491
+ tool_choice = getattr(request, 'tool_choice', 'auto') or 'auto'
492
+ messages = process_messages_with_tools(
493
+ messages=messages,
494
+ tools=request.tools,
495
+ tool_choice=tool_choice
496
+ )
497
+ self.logger.info(f"🔧 工具调用已通过提示词注入: {len(request.tools)} 个工具")
498
+
499
+ # 构建MCP服务器列表
500
+ mcp_servers = []
501
+ if is_advanced_search:
502
+ mcp_servers.append("advanced-search")
503
+ self.logger.info("🔍 检测到高级搜索模型,添加 advanced-search MCP 服务器")
504
+ elif is_search and "-4.5" in requested_model:
505
+ mcp_servers.append("deep-web-search")
506
+ self.logger.info("🔍 检测到搜索模型,添加 deep-web-search MCP 服务器")
507
+
508
+ # 构建上游请求体(chat_id 已在前面生成)
509
+
510
+ body = {
511
+ "stream": True, # 总是使用流式
512
+ "model": upstream_model_id,
513
+ "messages": messages, # ✅ messages 已经包含工具提示词
514
+ "signature_prompt": last_user_text, # 用于签名的最后一条用户消息
515
+ "files": files, # 图片文件数组
516
+ "params": {},
517
+ "features": {
518
+ "image_generation": False,
519
+ "web_search": is_search or is_advanced_search,
520
+ "auto_web_search": is_search or is_advanced_search,
521
+ "preview_mode": is_search or is_advanced_search,
522
+ "flags": [],
523
+ "features": [
524
+ {
525
+ "type": "mcp",
526
+ "server": "vibe-coding",
527
+ "status": "hidden"
528
+ },
529
+ {
530
+ "type": "mcp",
531
+ "server": "ppt-maker",
532
+ "status": "hidden"
533
+ },
534
+ {
535
+ "type": "mcp",
536
+ "server": "image-search",
537
+ "status": "hidden"
538
+ },
539
+ {
540
+ "type": "mcp",
541
+ "server": "deep-research",
542
+ "status": "hidden"
543
+ },
544
+ {
545
+ "type": "tool_selector",
546
+ "server": "tool_selector",
547
+ "status": "hidden"
548
+ },
549
+ {
550
+ "type": "mcp",
551
+ "server": "advanced-search",
552
+ "status": "hidden"
553
+ }
554
+ ],
555
+ "enable_thinking": is_thinking,
556
+ },
557
+ "background_tasks": {
558
+ "title_generation": False,
559
+ "tags_generation": False,
560
+ },
561
+ "mcp_servers": mcp_servers,
562
+ "variables": {
563
+ "{{USER_NAME}}": "Guest",
564
+ "{{USER_LOCATION}}": "Unknown",
565
+ "{{CURRENT_DATETIME}}": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
566
+ "{{CURRENT_DATE}}": datetime.now().strftime("%Y-%m-%d"),
567
+ "{{CURRENT_TIME}}": datetime.now().strftime("%H:%M:%S"),
568
+ "{{CURRENT_WEEKDAY}}": datetime.now().strftime("%A"),
569
+ "{{CURRENT_TIMEZONE}}": "Asia/Shanghai",
570
+ "{{USER_LANGUAGE}}": "zh-CN",
571
+ },
572
+ "model_item": {
573
+ "id": upstream_model_id,
574
+ "name": requested_model,
575
+ "owned_by": "z.ai"
576
+ },
577
+ "chat_id": chat_id,
578
+ "id": generate_uuid(),
579
+ }
580
+
581
+ # 不传递 tools 给上游,使用提示工程方式
582
+ body["tools"] = None
583
+
584
+ # 处理其他参数
585
+ if request.temperature is not None:
586
+ body["params"]["temperature"] = request.temperature
587
+ if request.max_tokens is not None:
588
+ body["params"]["max_tokens"] = request.max_tokens
589
+
590
+ # 构建请求头
591
+ headers = get_zai_dynamic_headers(chat_id)
592
+ if token:
593
+ headers["Authorization"] = f"Bearer {token}"
594
+
595
+ # Dual-layer HMAC signing metadata and header
596
+ user_id = _extract_user_id_from_token(token)
597
+ timestamp_ms = int(time.time() * 1000)
598
+ request_id = generate_uuid()
599
+ secret = os.getenv("ZAI_SIGNING_SECRET", "junjie") or "junjie"
600
+ signature = generate_signature(
601
+ message_text=last_user_text,
602
+ request_id=request_id,
603
+ timestamp_ms=timestamp_ms,
604
+ user_id=user_id,
605
+ secret=secret,
606
+ )
607
+ query_params = {
608
+ "timestamp": timestamp_ms,
609
+ "requestId": request_id,
610
+ "user_id": user_id,
611
+ "token": token or "",
612
+ "current_url": f"https://chat.z.ai/c/{chat_id}",
613
+ "pathname": f"/c/{chat_id}",
614
+ "signature_timestamp": timestamp_ms,
615
+ }
616
+ signed_url = f"{self.config.api_endpoint}?{urlencode(query_params)}"
617
+ headers["X-Signature"] = signature
618
+
619
+ # 存储当前token用于错误处理
620
+ self._current_token = token
621
+
622
+ return {
623
+ "url": signed_url,
624
+ "headers": headers,
625
+ "body": body,
626
+ "token": token,
627
+ "chat_id": chat_id,
628
+ "model": requested_model
629
+ }
630
+
631
+ async def chat_completion(
632
+ self,
633
+ request: OpenAIRequest,
634
+ **kwargs
635
+ ) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
636
+ """聊天完成接口"""
637
+ self.log_request(request)
638
+
639
+ try:
640
+ # 转换请求
641
+ transformed = await self.transform_request(request)
642
+
643
+ # 根据请求类型返回响应
644
+ if request.stream:
645
+ # 流式响应
646
+ return self._create_stream_response(request, transformed)
647
+ else:
648
+ # 非流式响应
649
+ async with httpx.AsyncClient(timeout=30.0) as client:
650
+ response = await client.post(
651
+ transformed["url"],
652
+ headers=transformed["headers"],
653
+ json=transformed["body"]
654
+ )
655
+
656
+ if not response.is_success:
657
+ error_msg = f"Z.AI API 错误: {response.status_code}"
658
+ self.log_response(False, error_msg)
659
+ return self.handle_error(Exception(error_msg))
660
+
661
+ return await self.transform_response(response, request, transformed)
662
+
663
+ except Exception as e:
664
+ self.log_response(False, str(e))
665
+ return self.handle_error(e, "请求处理")
666
+
667
+
668
+ async def _create_stream_response(
669
+ self,
670
+ request: OpenAIRequest,
671
+ transformed: Dict[str, Any]
672
+ ) -> AsyncGenerator[str, None]:
673
+
674
+ current_token = transformed.get("token", "")
675
+ try:
676
+ async with httpx.AsyncClient(
677
+ timeout=60.0,
678
+ http2=True,
679
+ ) as client:
680
+ self.logger.info(f"🎯 发送请求到 Z.AI: {transformed['url']}")
681
+ # self.logger.info(f"📦 请求体 model: {transformed['body']['model']}")
682
+ # self.logger.info(f"📦 请求体 messages: {json.dumps(transformed['body']['messages'], ensure_ascii=False)}")
683
+ async with client.stream(
684
+ "POST",
685
+ transformed["url"],
686
+ json=transformed["body"],
687
+ headers=transformed["headers"],
688
+ ) as response:
689
+ if response.status_code != 200:
690
+ self.logger.error(f"❌ 上游返回错误: {response.status_code}")
691
+ error_text = await response.aread()
692
+ error_msg = error_text.decode('utf-8', errors='ignore')
693
+ if error_msg:
694
+ self.logger.error(f"❌ 错误详情: {error_msg}")
695
+ error_response = {
696
+ "error": {
697
+ "message": f"Upstream error: {response.status_code}",
698
+ "type": "upstream_error",
699
+ "code": response.status_code
700
+ }
701
+ }
702
+ yield f"data: {json.dumps(error_response)}\n\n"
703
+ yield "data: [DONE]\n\n"
704
+ return
705
+
706
+ if current_token and not settings.ANONYMOUS_MODE:
707
+ token_pool = get_token_pool()
708
+ if token_pool:
709
+ token_pool.mark_token_success(current_token)
710
+
711
+ chat_id = transformed["chat_id"]
712
+ model = transformed["model"]
713
+ async for chunk in self._handle_stream_response(response, chat_id, model, request, transformed):
714
+ yield chunk
715
+ return
716
+ except Exception as e:
717
+ self.logger.error(f"❌ 流处理错误: {e}")
718
+ import traceback
719
+ self.logger.error(traceback.format_exc())
720
+ if current_token and not settings.ANONYMOUS_MODE:
721
+ self.mark_token_failure(current_token, e)
722
+ error_response = {
723
+ "error": {
724
+ "message": str(e),
725
+ "type": "stream_error"
726
+ }
727
+ }
728
+ yield f"data: {json.dumps(error_response)}\n\n"
729
+ yield "data: [DONE]\n\n"
730
+ return
731
+
732
+ async def transform_response(
733
+ self,
734
+ response: httpx.Response,
735
+ request: OpenAIRequest,
736
+ transformed: Dict[str, Any]
737
+ ) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
738
+ """转换Z.AI响应为OpenAI格式"""
739
+ chat_id = transformed["chat_id"]
740
+ model = transformed["model"]
741
+
742
+ if request.stream:
743
+ return self._handle_stream_response(response, chat_id, model, request, transformed)
744
+ else:
745
+ return await self._handle_non_stream_response(response, chat_id, model)
746
+
747
+ async def _handle_stream_response(
748
+ self,
749
+ response: httpx.Response,
750
+ chat_id: str,
751
+ model: str,
752
+ request: OpenAIRequest,
753
+ transformed: Dict[str, Any]
754
+ ) -> AsyncGenerator[str, None]:
755
+ """处理Z.AI流式响应"""
756
+ self.logger.info(f"✅ Z.AI 响应成功,开始处理 SSE 流")
757
+
758
+ # 检查是否启用了工具调用 (通过检查原始请求)
759
+ has_tools = settings.TOOL_SUPPORT and request.tools is not None and len(request.tools) > 0
760
+
761
+ # 累积内容缓冲区,用于提取工具调用
762
+ buffered_content = ""
763
+ has_sent_role = False
764
+
765
+ # 处理状态
766
+ has_thinking = False
767
+ thinking_signature = None
768
+
769
+ # 处理SSE流
770
+ buffer = ""
771
+ line_count = 0
772
+ self.logger.debug("📡 开始接收 SSE 流数据...")
773
+
774
+ try:
775
+ async for line in response.aiter_lines():
776
+ line_count += 1
777
+ if not line:
778
+ continue
779
+
780
+ # 累积到buffer处理完整的数据行
781
+ buffer += line + "\n"
782
+
783
+ # 检查是否有完整的data行
784
+ while "\n" in buffer:
785
+ current_line, buffer = buffer.split("\n", 1)
786
+ if not current_line.strip():
787
+ continue
788
+
789
+ if current_line.startswith("data:"):
790
+ chunk_str = current_line[5:].strip()
791
+ if not chunk_str or chunk_str == "[DONE]":
792
+ if chunk_str == "[DONE]":
793
+ yield "data: [DONE]\n\n"
794
+ continue
795
+
796
+ self.logger.debug(f"📦 解析数据块: {chunk_str[:1000]}..." if len(chunk_str) > 1000 else f"📦 解析数据块: {chunk_str}")
797
+
798
+ try:
799
+ chunk = json.loads(chunk_str)
800
+
801
+ if chunk.get("type") == "chat:completion":
802
+ data = chunk.get("data", {})
803
+ phase = data.get("phase")
804
+
805
+ # 记录每个阶段(只在阶段变化时记录)
806
+ if phase and phase != getattr(self, '_last_phase', None):
807
+ self.logger.info(f"📈 SSE 阶段: {phase}")
808
+ self._last_phase = phase
809
+
810
+ # 处理思考内容
811
+ if phase == "thinking":
812
+ if not has_thinking:
813
+ has_thinking = True
814
+ # 发送初始角色
815
+ role_chunk = self.create_openai_chunk(
816
+ chat_id,
817
+ model,
818
+ {"role": "assistant"}
819
+ )
820
+ yield await self.format_sse_chunk(role_chunk)
821
+
822
+ delta_content = data.get("delta_content", "")
823
+ if delta_content:
824
+ # 处理思考内容格式
825
+ if delta_content.startswith("<details"):
826
+ content = (
827
+ delta_content.split("</summary>\n>")[-1].strip()
828
+ if "</summary>\n>" in delta_content
829
+ else delta_content
830
+ )
831
+ else:
832
+ content = delta_content
833
+
834
+ thinking_chunk = self.create_openai_chunk(
835
+ chat_id,
836
+ model,
837
+ {
838
+ "role": "assistant",
839
+ "reasoning_content": content
840
+ }
841
+ )
842
+ yield await self.format_sse_chunk(thinking_chunk)
843
+
844
+ # 处理答案内容
845
+ elif phase == "answer":
846
+ delta_content = data.get("delta_content", "")
847
+ edit_content = data.get("edit_content", "")
848
+
849
+ # 累积内容(用于工具调用提取)
850
+ if delta_content:
851
+ buffered_content += delta_content
852
+ elif edit_content:
853
+ buffered_content = edit_content
854
+
855
+ # 如果包含 usage,说明流式结束
856
+ if data.get("usage"):
857
+ usage = data["usage"]
858
+ self.logger.info(f"📦 完成响应 - 使用统计: {json.dumps(usage)}")
859
+
860
+ # 尝试从缓冲区提取 tool_calls
861
+ tool_calls = None
862
+ cleaned_content = buffered_content
863
+
864
+ if has_tools:
865
+ tool_calls, cleaned_content = parse_and_extract_tool_calls(buffered_content)
866
+
867
+ if tool_calls:
868
+ # 发现工具调用
869
+ self.logger.info(f"🔧 从响应中提取到 {len(tool_calls)} 个工具调用")
870
+
871
+ if not has_sent_role:
872
+ role_chunk = self.create_openai_chunk(
873
+ chat_id,
874
+ model,
875
+ {"role": "assistant"}
876
+ )
877
+ yield await self.format_sse_chunk(role_chunk)
878
+ has_sent_role = True
879
+
880
+ # 发送工具调用
881
+ for idx, tc in enumerate(tool_calls):
882
+ tool_chunk = self.create_openai_chunk(
883
+ chat_id,
884
+ model,
885
+ {
886
+ "role": "assistant",
887
+ "tool_calls": [{
888
+ "index": idx,
889
+ "id": tc.get("id", f"call_{idx}"),
890
+ "type": "function",
891
+ "function": {
892
+ "name": tc.get("function", {}).get("name", ""),
893
+ "arguments": tc.get("function", {}).get("arguments", "")
894
+ }
895
+ }]
896
+ }
897
+ )
898
+ yield await self.format_sse_chunk(tool_chunk)
899
+
900
+ # 发送完成块
901
+ finish_chunk = self.create_openai_chunk(
902
+ chat_id,
903
+ model,
904
+ {"role": "assistant"},
905
+ "tool_calls"
906
+ )
907
+ finish_chunk["usage"] = usage
908
+ yield await self.format_sse_chunk(finish_chunk)
909
+ yield "data: [DONE]\n\n"
910
+
911
+ else:
912
+ # 没有工具调用,正常返回内容
913
+ # 处理思考结束和答案开始
914
+ if edit_content and "</details>\n" in edit_content:
915
+ if has_thinking:
916
+ # 发送思考签名
917
+ thinking_signature = str(int(time.time() * 1000))
918
+ sig_chunk = self.create_openai_chunk(
919
+ chat_id,
920
+ model,
921
+ {
922
+ "role": "assistant",
923
+ "thinking": {
924
+ "content": "",
925
+ "signature": thinking_signature,
926
+ }
927
+ }
928
+ )
929
+ yield await self.format_sse_chunk(sig_chunk)
930
+
931
+ # 提取答案内容
932
+ cleaned_content = edit_content.split("</details>\n")[-1]
933
+
934
+ if not has_sent_role and not has_thinking:
935
+ role_chunk = self.create_openai_chunk(
936
+ chat_id,
937
+ model,
938
+ {"role": "assistant"}
939
+ )
940
+ yield await self.format_sse_chunk(role_chunk)
941
+ has_sent_role = True
942
+
943
+ if cleaned_content:
944
+ content_chunk = self.create_openai_chunk(
945
+ chat_id,
946
+ model,
947
+ {
948
+ "role": "assistant",
949
+ "content": cleaned_content
950
+ }
951
+ )
952
+ yield await self.format_sse_chunk(content_chunk)
953
+
954
+ finish_chunk = self.create_openai_chunk(
955
+ chat_id,
956
+ model,
957
+ {"role": "assistant", "content": ""},
958
+ "stop"
959
+ )
960
+ finish_chunk["usage"] = usage
961
+ yield await self.format_sse_chunk(finish_chunk)
962
+ yield "data: [DONE]\n\n"
963
+ else:
964
+ # 流式过程中,输出答案内容(即使有工具调用也要显示)
965
+ # 处理思考结束和答案开始
966
+ if edit_content and "</details>\n" in edit_content:
967
+ if has_thinking:
968
+ # 发送思考签名
969
+ thinking_signature = str(int(time.time() * 1000))
970
+ sig_chunk = self.create_openai_chunk(
971
+ chat_id,
972
+ model,
973
+ {
974
+ "role": "assistant",
975
+ "thinking": {
976
+ "content": "",
977
+ "signature": thinking_signature,
978
+ }
979
+ }
980
+ )
981
+ yield await self.format_sse_chunk(sig_chunk)
982
+
983
+ # 提取答案内容
984
+ content_after = edit_content.split("</details>\n")[-1]
985
+ if content_after:
986
+ content_chunk = self.create_openai_chunk(
987
+ chat_id,
988
+ model,
989
+ {
990
+ "role": "assistant",
991
+ "content": content_after
992
+ }
993
+ )
994
+ yield await self.format_sse_chunk(content_chunk)
995
+
996
+ # 处理增量内容
997
+ elif delta_content:
998
+ if not has_sent_role and not has_thinking:
999
+ role_chunk = self.create_openai_chunk(
1000
+ chat_id,
1001
+ model,
1002
+ {"role": "assistant"}
1003
+ )
1004
+ yield await self.format_sse_chunk(role_chunk)
1005
+ has_sent_role = True
1006
+
1007
+ content_chunk = self.create_openai_chunk(
1008
+ chat_id,
1009
+ model,
1010
+ {
1011
+ "role": "assistant",
1012
+ "content": delta_content
1013
+ }
1014
+ )
1015
+ output_data = await self.format_sse_chunk(content_chunk)
1016
+ self.logger.debug(f"➡️ 输出内容块到客户端: {output_data}")
1017
+ yield output_data
1018
+
1019
+ except json.JSONDecodeError as e:
1020
+ self.logger.debug(f"❌ JSON解析错误: {e}, 内容: {chunk_str[:1000]}")
1021
+ except Exception as e:
1022
+ self.logger.error(f"❌ 处理chunk错误: {e}")
1023
+
1024
+ self.logger.info(f"✅ SSE 流处理完成,共处理 {line_count} 行数据")
1025
+
1026
+ except Exception as e:
1027
+ self.logger.error(f"❌ 流式响应处理错误: {e}")
1028
+ import traceback
1029
+ self.logger.error(traceback.format_exc())
1030
+ # 发送错误结束块
1031
+ yield await self.format_sse_chunk(
1032
+ self.create_openai_chunk(chat_id, model, {}, "stop")
1033
+ )
1034
+ yield "data: [DONE]\n\n"
1035
+
1036
+ async def _handle_non_stream_response(
1037
+ self,
1038
+ response: httpx.Response,
1039
+ chat_id: str,
1040
+ model: str
1041
+ ) -> Dict[str, Any]:
1042
+ """处理非流式响应
1043
+
1044
+ 说明:上游始终以 SSE 形式返回(transform_request 固定 stream=True),
1045
+ 因此这里需要聚合 aiter_lines() 的 data: 块,提取 usage、思考内容与答案内容,
1046
+ 并最终产出一次性 OpenAI 格式响应。
1047
+ """
1048
+ final_content = ""
1049
+ reasoning_content = ""
1050
+ usage_info: Dict[str, int] = {
1051
+ "prompt_tokens": 0,
1052
+ "completion_tokens": 0,
1053
+ "total_tokens": 0,
1054
+ }
1055
+
1056
+ try:
1057
+ async for line in response.aiter_lines():
1058
+ if not line:
1059
+ continue
1060
+
1061
+ line = line.strip()
1062
+
1063
+ # 仅处理以 data: 开头的 SSE 行,其余行尝试作为错误/JSON 忽略
1064
+ if not line.startswith("data:"):
1065
+ # 尝试解析为错误 JSON
1066
+ try:
1067
+ maybe_err = json.loads(line)
1068
+ if isinstance(maybe_err, dict) and (
1069
+ "error" in maybe_err or "code" in maybe_err or "message" in maybe_err
1070
+ ):
1071
+ # 统一错误处理
1072
+ msg = (
1073
+ (maybe_err.get("error") or {}).get("message")
1074
+ if isinstance(maybe_err.get("error"), dict)
1075
+ else maybe_err.get("message")
1076
+ ) or "上游返回错误"
1077
+ return self.handle_error(Exception(msg), "API响应")
1078
+ except Exception:
1079
+ pass
1080
+ continue
1081
+
1082
+ data_str = line[5:].strip()
1083
+ if not data_str or data_str in ("[DONE]", "DONE", "done"):
1084
+ continue
1085
+
1086
+ # 解析 SSE 数据块
1087
+ try:
1088
+ chunk = json.loads(data_str)
1089
+ except json.JSONDecodeError:
1090
+ continue
1091
+
1092
+ if chunk.get("type") != "chat:completion":
1093
+ continue
1094
+
1095
+ data = chunk.get("data", {})
1096
+ phase = data.get("phase")
1097
+ delta_content = data.get("delta_content", "")
1098
+ edit_content = data.get("edit_content", "")
1099
+
1100
+ # 记录用量(通常在最后块中出现,但这里每次覆盖保持最新)
1101
+ if data.get("usage"):
1102
+ try:
1103
+ usage_info = data["usage"]
1104
+ except Exception:
1105
+ pass
1106
+
1107
+ # 思考阶段聚合(去除 <details><summary>... 包裹头)
1108
+ if phase == "thinking":
1109
+ if delta_content:
1110
+ if delta_content.startswith("<details"):
1111
+ cleaned = (
1112
+ delta_content.split("</summary>\n>")[-1].strip()
1113
+ if "</summary>\n>" in delta_content
1114
+ else delta_content
1115
+ )
1116
+ else:
1117
+ cleaned = delta_content
1118
+ reasoning_content += cleaned
1119
+
1120
+ # 答案阶段聚合
1121
+ elif phase == "answer":
1122
+ # 当 edit_content 同时包含思考结束标记与答案时,提取答案部分
1123
+ if edit_content and "</details>\n" in edit_content:
1124
+ content_after = edit_content.split("</details>\n")[-1]
1125
+ if content_after:
1126
+ final_content += content_after
1127
+ elif delta_content:
1128
+ final_content += delta_content
1129
+
1130
+ except Exception as e:
1131
+ self.logger.error(f"❌ 非流式响应处理错误: {e}")
1132
+ import traceback
1133
+ self.logger.error(traceback.format_exc())
1134
+ # 返回统一错误响应
1135
+ return self.handle_error(e, "非流式聚合")
1136
+
1137
+ # 清理并返回
1138
+ final_content = (final_content or "").strip()
1139
+ reasoning_content = (reasoning_content or "").strip()
1140
+
1141
+ # 若没有聚合到答案,但有思考内容,则保底返回思考内容
1142
+ if not final_content and reasoning_content:
1143
+ final_content = reasoning_content
1144
+
1145
+ # 返回包含推理内容的标准响应(若无推理则不会携带)
1146
+ return self.create_openai_response_with_reasoning(
1147
+ chat_id,
1148
+ model,
1149
+ final_content,
1150
+ reasoning_content,
1151
+ usage_info,
1152
+ )
app/services/request_log_dao.py ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 请求日志数据访问层 (DAO)
3
+ 提供请求日志的 CRUD 操作和查询功能
4
+ """
5
+ import aiosqlite
6
+ import sqlite3
7
+ from typing import List, Dict, Optional
8
+ from datetime import datetime, timedelta
9
+ from contextlib import asynccontextmanager
10
+ import os
11
+
12
+ from app.models.request_log import SQL_CREATE_REQUEST_LOGS_TABLE, DB_PATH
13
+ from app.utils.logger import logger
14
+
15
+
16
+ class RequestLogDAO:
17
+ """请求日志数据访问对象"""
18
+
19
+ def __init__(self, db_path: str = DB_PATH):
20
+ """初始化 DAO"""
21
+ self.db_path = db_path
22
+ self._ensure_db_directory()
23
+ self._init_db()
24
+
25
+ def _ensure_db_directory(self):
26
+ """确保数据库目录存在"""
27
+ db_dir = os.path.dirname(self.db_path)
28
+ if db_dir and not os.path.exists(db_dir):
29
+ os.makedirs(db_dir, exist_ok=True)
30
+
31
+ def _init_db(self):
32
+ """初始化数据库表"""
33
+ try:
34
+ conn = sqlite3.connect(self.db_path)
35
+ conn.executescript(SQL_CREATE_REQUEST_LOGS_TABLE)
36
+ conn.commit()
37
+ conn.close()
38
+ logger.debug("请求日志表初始化成功")
39
+ except Exception as e:
40
+ logger.error(f"初始化请求日志表失败: {e}")
41
+
42
+ @asynccontextmanager
43
+ async def get_connection(self):
44
+ """获取异步数据库连接"""
45
+ conn = await aiosqlite.connect(self.db_path)
46
+ conn.row_factory = aiosqlite.Row
47
+ try:
48
+ yield conn
49
+ finally:
50
+ await conn.close()
51
+
52
+ async def add_log(
53
+ self,
54
+ provider: str,
55
+ model: str,
56
+ success: bool,
57
+ duration: float = 0.0,
58
+ first_token_time: float = 0.0,
59
+ input_tokens: int = 0,
60
+ output_tokens: int = 0,
61
+ error_message: str = None
62
+ ) -> int:
63
+ """
64
+ 添加请求日志
65
+
66
+ Args:
67
+ provider: 提供商名称
68
+ model: 模型名称
69
+ success: 是否成功
70
+ duration: 总耗时(秒)
71
+ first_token_time: 首字延迟(秒)
72
+ input_tokens: 输入 token 数
73
+ output_tokens: 输出 token 数
74
+ error_message: 错误信息
75
+
76
+ Returns:
77
+ 日志 ID
78
+ """
79
+ total_tokens = input_tokens + output_tokens
80
+
81
+ async with self.get_connection() as conn:
82
+ cursor = await conn.execute(
83
+ """
84
+ INSERT INTO request_logs
85
+ (provider, model, success, duration, first_token_time,
86
+ input_tokens, output_tokens, total_tokens, error_message)
87
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
88
+ """,
89
+ (provider, model, success, duration, first_token_time,
90
+ input_tokens, output_tokens, total_tokens, error_message)
91
+ )
92
+ await conn.commit()
93
+ return cursor.lastrowid
94
+
95
+ async def get_recent_logs(
96
+ self,
97
+ limit: int = 100,
98
+ provider: str = None,
99
+ model: str = None,
100
+ success: bool = None
101
+ ) -> List[Dict]:
102
+ """
103
+ 获取最近的请求日志
104
+
105
+ Args:
106
+ limit: 返回数量限制
107
+ provider: 过滤提供商
108
+ model: 过滤模型
109
+ success: 过滤成功/失败状态
110
+
111
+ Returns:
112
+ 日志列表
113
+ """
114
+ query = "SELECT * FROM request_logs WHERE 1=1"
115
+ params = []
116
+
117
+ if provider:
118
+ query += " AND provider = ?"
119
+ params.append(provider)
120
+
121
+ if model:
122
+ query += " AND model = ?"
123
+ params.append(model)
124
+
125
+ if success is not None:
126
+ query += " AND success = ?"
127
+ params.append(success)
128
+
129
+ query += " ORDER BY timestamp DESC LIMIT ?"
130
+ params.append(limit)
131
+
132
+ async with self.get_connection() as conn:
133
+ cursor = await conn.execute(query, params)
134
+ rows = await cursor.fetchall()
135
+ return [dict(row) for row in rows]
136
+
137
+ async def get_logs_by_time_range(
138
+ self,
139
+ start_time: datetime,
140
+ end_time: datetime,
141
+ provider: str = None,
142
+ model: str = None
143
+ ) -> List[Dict]:
144
+ """
145
+ 按时间范围获取日志
146
+
147
+ Args:
148
+ start_time: 开始时间
149
+ end_time: 结束时间
150
+ provider: 过滤提供商
151
+ model: 过滤模型
152
+
153
+ Returns:
154
+ 日志列表
155
+ """
156
+ query = "SELECT * FROM request_logs WHERE timestamp BETWEEN ? AND ?"
157
+ params = [start_time.isoformat(), end_time.isoformat()]
158
+
159
+ if provider:
160
+ query += " AND provider = ?"
161
+ params.append(provider)
162
+
163
+ if model:
164
+ query += " AND model = ?"
165
+ params.append(model)
166
+
167
+ query += " ORDER BY timestamp DESC"
168
+
169
+ async with self.get_connection() as conn:
170
+ cursor = await conn.execute(query, params)
171
+ rows = await cursor.fetchall()
172
+ return [dict(row) for row in rows]
173
+
174
+ async def get_model_stats_from_db(self, hours: int = 24) -> Dict:
175
+ """
176
+ 从数据库获取模型统计(最近N小时)
177
+
178
+ Args:
179
+ hours: 小时数
180
+
181
+ Returns:
182
+ 模型统计数据
183
+ """
184
+ start_time = datetime.now() - timedelta(hours=hours)
185
+
186
+ async with self.get_connection() as conn:
187
+ cursor = await conn.execute(
188
+ """
189
+ SELECT
190
+ model,
191
+ COUNT(*) as total,
192
+ SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as success,
193
+ SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) as failed,
194
+ SUM(input_tokens) as input_tokens,
195
+ SUM(output_tokens) as output_tokens,
196
+ SUM(total_tokens) as total_tokens,
197
+ AVG(duration) as avg_duration,
198
+ AVG(first_token_time) as avg_first_token_time
199
+ FROM request_logs
200
+ WHERE timestamp >= ?
201
+ GROUP BY model
202
+ ORDER BY total DESC
203
+ """,
204
+ (start_time.isoformat(),)
205
+ )
206
+ rows = await cursor.fetchall()
207
+
208
+ result = {}
209
+ for row in rows:
210
+ model = row['model']
211
+ result[model] = {
212
+ 'total': row['total'],
213
+ 'success': row['success'],
214
+ 'failed': row['failed'],
215
+ 'input_tokens': row['input_tokens'] or 0,
216
+ 'output_tokens': row['output_tokens'] or 0,
217
+ 'total_tokens': row['total_tokens'] or 0,
218
+ 'avg_duration': round(row['avg_duration'] or 0, 2),
219
+ 'avg_first_token_time': round(row['avg_first_token_time'] or 0, 2),
220
+ 'success_rate': round((row['success'] / row['total'] * 100) if row['total'] > 0 else 0, 1)
221
+ }
222
+
223
+ return result
224
+
225
+ async def delete_old_logs(self, days: int = 30) -> int:
226
+ """
227
+ 删除旧日志
228
+
229
+ Args:
230
+ days: 保留天数
231
+
232
+ Returns:
233
+ 删除的记录数
234
+ """
235
+ cutoff_time = datetime.now() - timedelta(days=days)
236
+
237
+ async with self.get_connection() as conn:
238
+ cursor = await conn.execute(
239
+ "DELETE FROM request_logs WHERE timestamp < ?",
240
+ (cutoff_time.isoformat(),)
241
+ )
242
+ await conn.commit()
243
+ return cursor.rowcount
244
+
245
+
246
+ # 全局单例实例
247
+ _request_log_dao: Optional[RequestLogDAO] = None
248
+
249
+
250
+ def get_request_log_dao() -> RequestLogDAO:
251
+ """
252
+ 获取请求日志 DAO 单例
253
+
254
+ Returns:
255
+ RequestLogDAO 实例
256
+ """
257
+ global _request_log_dao
258
+ if _request_log_dao is None:
259
+ _request_log_dao = RequestLogDAO()
260
+ return _request_log_dao
261
+
262
+
263
+ def init_request_log_dao():
264
+ """初始化请求日志 DAO"""
265
+ global _request_log_dao
266
+ _request_log_dao = RequestLogDAO()
267
+ return _request_log_dao
app/services/token_dao.py ADDED
@@ -0,0 +1,480 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Token 数据访问层 (DAO)
3
+ 提供 Token 的 CRUD 操作和查询功能
4
+ """
5
+ import aiosqlite
6
+ import sqlite3
7
+ from typing import List, Optional, Dict, Tuple
8
+ from datetime import datetime
9
+ from contextlib import asynccontextmanager
10
+ import os
11
+
12
+ from app.models.token_db import SQL_CREATE_TABLES, DB_PATH
13
+ from app.utils.logger import logger
14
+
15
+
16
+ class TokenDAO:
17
+ """Token 数据访问对象"""
18
+
19
+ def __init__(self, db_path: str = DB_PATH):
20
+ """初始化 DAO"""
21
+ self.db_path = db_path
22
+ self._ensure_db_directory()
23
+
24
+ def _ensure_db_directory(self):
25
+ """确保数据库目录存在"""
26
+ db_dir = os.path.dirname(self.db_path)
27
+ if db_dir and not os.path.exists(db_dir):
28
+ os.makedirs(db_dir, exist_ok=True)
29
+
30
+ @asynccontextmanager
31
+ async def get_connection(self):
32
+ """获取异步数据库连接"""
33
+ conn = await aiosqlite.connect(self.db_path)
34
+ conn.row_factory = aiosqlite.Row # 返回字典式结果
35
+
36
+ # 启用外键约束(SQLite 默认关闭)
37
+ await conn.execute("PRAGMA foreign_keys = ON")
38
+
39
+ try:
40
+ yield conn
41
+ finally:
42
+ await conn.close()
43
+
44
+ def get_sync_connection(self):
45
+ """获取同步数据库连接(用于初始化)"""
46
+ conn = sqlite3.connect(self.db_path)
47
+ # 启用外键约束
48
+ conn.execute("PRAGMA foreign_keys = ON")
49
+ return conn
50
+
51
+ async def init_database(self):
52
+ """初始化数据库表结构"""
53
+ try:
54
+ # 使用同步连接创建表(避免异步初始化问题)
55
+ conn = self.get_sync_connection()
56
+ conn.executescript(SQL_CREATE_TABLES)
57
+ conn.commit()
58
+ conn.close()
59
+ except Exception as e:
60
+ logger.error(f"❌ Token 数据库初始化失败: {e}")
61
+ raise
62
+
63
+ # ==================== Token CRUD 操作 ====================
64
+
65
+ async def add_token(
66
+ self,
67
+ provider: str,
68
+ token: str,
69
+ token_type: str = "user",
70
+ priority: int = 0,
71
+ validate: bool = True
72
+ ) -> Optional[int]:
73
+ """
74
+ 添加新 Token(可选验证)
75
+
76
+ Args:
77
+ provider: 提供商名称
78
+ token: Token 值
79
+ token_type: Token 类型(如果 validate=True 将被验证结果覆盖)
80
+ priority: 优先级
81
+ validate: 是否验证 Token(仅针对 zai 提供商)
82
+
83
+ Returns:
84
+ token_id 或 None(验证失败或已存在)
85
+ """
86
+ try:
87
+ # 对于 zai 提供商,强制验证 Token
88
+ if provider == "zai" and validate:
89
+ from app.utils.token_pool import ZAITokenValidator
90
+
91
+ validated_type, is_valid, error_msg = await ZAITokenValidator.validate_token(token)
92
+
93
+ # 拒绝 guest token
94
+ if validated_type == "guest":
95
+ logger.warning(f"🚫 拒绝添加匿名用户 Token: {token[:20]}... - {error_msg}")
96
+ return None
97
+
98
+ # 拒绝无效 token
99
+ if not is_valid:
100
+ logger.warning(f"🚫 Token 验证失败: {token[:20]}... - {error_msg}")
101
+ return None
102
+
103
+ # 使用验证后的类型
104
+ token_type = validated_type
105
+
106
+ async with self.get_connection() as conn:
107
+ cursor = await conn.execute("""
108
+ INSERT OR IGNORE INTO tokens (provider, token, token_type, priority)
109
+ VALUES (?, ?, ?, ?)
110
+ """, (provider, token, token_type, priority))
111
+
112
+ await conn.commit()
113
+
114
+ if cursor.lastrowid > 0:
115
+ # 同时创建统计记录
116
+ await conn.execute("""
117
+ INSERT INTO token_stats (token_id)
118
+ VALUES (?)
119
+ """, (cursor.lastrowid,))
120
+ await conn.commit()
121
+ logger.info(f"✅ 添加 Token: {provider} ({token_type}) - {token[:20]}...")
122
+ return cursor.lastrowid
123
+ else:
124
+ logger.warning(f"⚠️ Token 已存在: {provider} - {token[:20]}...")
125
+ return None
126
+ except Exception as e:
127
+ logger.error(f"❌ 添加 Token 失败: {e}")
128
+ return None
129
+
130
+ async def get_tokens_by_provider(self, provider: str, enabled_only: bool = True) -> List[Dict]:
131
+ """
132
+ 获取指定提供商的所有 Token
133
+
134
+ Args:
135
+ provider: 提供商名称
136
+ enabled_only: 是否只返回启用的 Token
137
+ """
138
+ try:
139
+ async with self.get_connection() as conn:
140
+ query = """
141
+ SELECT t.*, ts.total_requests, ts.successful_requests, ts.failed_requests,
142
+ ts.last_success_time, ts.last_failure_time
143
+ FROM tokens t
144
+ LEFT JOIN token_stats ts ON t.id = ts.token_id
145
+ WHERE t.provider = ?
146
+ """
147
+ params = [provider]
148
+
149
+ if enabled_only:
150
+ query += " AND t.is_enabled = 1"
151
+
152
+ query += " ORDER BY t.priority DESC, t.id ASC"
153
+
154
+ cursor = await conn.execute(query, params)
155
+ rows = await cursor.fetchall()
156
+
157
+ return [dict(row) for row in rows]
158
+ except Exception as e:
159
+ logger.error(f"❌ 查询 Token 失败: {e}")
160
+ return []
161
+
162
+ async def get_all_tokens(self, enabled_only: bool = False) -> List[Dict]:
163
+ """获取所有 Token"""
164
+ try:
165
+ async with self.get_connection() as conn:
166
+ query = """
167
+ SELECT t.*, ts.total_requests, ts.successful_requests, ts.failed_requests,
168
+ ts.last_success_time, ts.last_failure_time
169
+ FROM tokens t
170
+ LEFT JOIN token_stats ts ON t.id = ts.token_id
171
+ """
172
+
173
+ if enabled_only:
174
+ query += " WHERE t.is_enabled = 1"
175
+
176
+ query += " ORDER BY t.provider, t.priority DESC, t.id ASC"
177
+
178
+ cursor = await conn.execute(query)
179
+ rows = await cursor.fetchall()
180
+
181
+ return [dict(row) for row in rows]
182
+ except Exception as e:
183
+ logger.error(f"❌ 查询所有 Token 失败: {e}")
184
+ return []
185
+
186
+ async def update_token_status(self, token_id: int, is_enabled: bool):
187
+ """更新 Token 启用状态"""
188
+ try:
189
+ async with self.get_connection() as conn:
190
+ await conn.execute("""
191
+ UPDATE tokens SET is_enabled = ? WHERE id = ?
192
+ """, (is_enabled, token_id))
193
+ await conn.commit()
194
+ logger.info(f"✅ 更新 Token 状态: id={token_id}, enabled={is_enabled}")
195
+ except Exception as e:
196
+ logger.error(f"❌ 更新 Token 状态失败: {e}")
197
+
198
+ async def update_token_type(self, token_id: int, token_type: str):
199
+ """更新 Token 类型"""
200
+ try:
201
+ async with self.get_connection() as conn:
202
+ await conn.execute("""
203
+ UPDATE tokens SET token_type = ? WHERE id = ?
204
+ """, (token_type, token_id))
205
+ await conn.commit()
206
+ logger.info(f"✅ 更新 Token 类型: id={token_id}, type={token_type}")
207
+ except Exception as e:
208
+ logger.error(f"❌ 更新 Token 类型失败: {e}")
209
+
210
+ async def delete_token(self, token_id: int):
211
+ """删除 Token(级联删除统计数据)"""
212
+ try:
213
+ async with self.get_connection() as conn:
214
+ await conn.execute("DELETE FROM tokens WHERE id = ?", (token_id,))
215
+ await conn.commit()
216
+ logger.info(f"✅ 删除 Token: id={token_id}")
217
+ except Exception as e:
218
+ logger.error(f"❌ 删除 Token 失败: {e}")
219
+
220
+ async def delete_tokens_by_provider(self, provider: str):
221
+ """删除指定提供商的所有 Token"""
222
+ try:
223
+ async with self.get_connection() as conn:
224
+ await conn.execute("DELETE FROM tokens WHERE provider = ?", (provider,))
225
+ await conn.commit()
226
+ logger.info(f"✅ 删除提供商所有 Token: {provider}")
227
+ except Exception as e:
228
+ logger.error(f"❌ 删除提供商 Token 失败: {e}")
229
+
230
+ # ==================== Token 统计操作 ====================
231
+
232
+ async def record_success(self, token_id: int):
233
+ """记录 Token 使用成功"""
234
+ try:
235
+ async with self.get_connection() as conn:
236
+ await conn.execute("""
237
+ UPDATE token_stats
238
+ SET total_requests = total_requests + 1,
239
+ successful_requests = successful_requests + 1,
240
+ last_success_time = CURRENT_TIMESTAMP
241
+ WHERE token_id = ?
242
+ """, (token_id,))
243
+ await conn.commit()
244
+ except Exception as e:
245
+ logger.error(f"❌ 记录成功失败: {e}")
246
+
247
+ async def record_failure(self, token_id: int):
248
+ """记录 Token 使用失败"""
249
+ try:
250
+ async with self.get_connection() as conn:
251
+ await conn.execute("""
252
+ UPDATE token_stats
253
+ SET total_requests = total_requests + 1,
254
+ failed_requests = failed_requests + 1,
255
+ last_failure_time = CURRENT_TIMESTAMP
256
+ WHERE token_id = ?
257
+ """, (token_id,))
258
+ await conn.commit()
259
+ except Exception as e:
260
+ logger.error(f"❌ 记录失败失败: {e}")
261
+
262
+ async def get_token_stats(self, token_id: int) -> Optional[Dict]:
263
+ """获取 Token 统计信息"""
264
+ try:
265
+ async with self.get_connection() as conn:
266
+ cursor = await conn.execute("""
267
+ SELECT * FROM token_stats WHERE token_id = ?
268
+ """, (token_id,))
269
+ row = await cursor.fetchone()
270
+ return dict(row) if row else None
271
+ except Exception as e:
272
+ logger.error(f"❌ 获取统计信息失败: {e}")
273
+ return None
274
+
275
+ # ==================== 批量操作 ====================
276
+
277
+ async def bulk_add_tokens(
278
+ self,
279
+ provider: str,
280
+ tokens: List[str],
281
+ token_type: str = "user",
282
+ validate: bool = True
283
+ ) -> Tuple[int, int]:
284
+ """
285
+ 批量添加 Token(可选验证)
286
+
287
+ Args:
288
+ provider: 提供商名称
289
+ tokens: Token 列表
290
+ token_type: Token 类型(如果 validate=True 将被覆盖)
291
+ validate: 是否验证 Token(仅针对 zai)
292
+
293
+ Returns:
294
+ (成功添加数量, 失败数量)
295
+ """
296
+ added_count = 0
297
+ failed_count = 0
298
+
299
+ for token in tokens:
300
+ if token.strip(): # 过滤空 token
301
+ token_id = await self.add_token(
302
+ provider,
303
+ token.strip(),
304
+ token_type,
305
+ validate=validate
306
+ )
307
+ if token_id:
308
+ added_count += 1
309
+ else:
310
+ failed_count += 1
311
+
312
+ logger.info(f"✅ 批量添加完成: {provider} - 成功 {added_count}/{len(tokens)},失败 {failed_count}")
313
+ return added_count, failed_count
314
+
315
+ async def replace_tokens(self, provider: str, tokens: List[str],
316
+ token_type: str = "user"):
317
+ """
318
+ 替换指定提供商的所有 Token(先删除后添加)
319
+ """
320
+ # 删除旧 Token
321
+ await self.delete_tokens_by_provider(provider)
322
+
323
+ # 添加新 Token
324
+ added_count = await self.bulk_add_tokens(provider, tokens, token_type)
325
+
326
+ logger.info(f"✅ 替换 Token 完成: {provider} - {added_count} 个")
327
+ return added_count
328
+
329
+ # ==================== 实用方法 ====================
330
+
331
+ async def get_token_by_value(self, provider: str, token: str) -> Optional[Dict]:
332
+ """根据 Token 值查询"""
333
+ try:
334
+ async with self.get_connection() as conn:
335
+ cursor = await conn.execute("""
336
+ SELECT t.*, ts.total_requests, ts.successful_requests, ts.failed_requests
337
+ FROM tokens t
338
+ LEFT JOIN token_stats ts ON t.id = ts.token_id
339
+ WHERE t.provider = ? AND t.token = ?
340
+ """, (provider, token))
341
+ row = await cursor.fetchone()
342
+ return dict(row) if row else None
343
+ except Exception as e:
344
+ logger.error(f"❌ 查询 Token 失败: {e}")
345
+ return None
346
+
347
+ async def get_provider_stats(self, provider: str) -> Dict:
348
+ """获取提供商统计信息"""
349
+ try:
350
+ async with self.get_connection() as conn:
351
+ cursor = await conn.execute("""
352
+ SELECT
353
+ COUNT(*) as total_tokens,
354
+ SUM(CASE WHEN is_enabled = 1 THEN 1 ELSE 0 END) as enabled_tokens,
355
+ SUM(ts.total_requests) as total_requests,
356
+ SUM(ts.successful_requests) as successful_requests,
357
+ SUM(ts.failed_requests) as failed_requests
358
+ FROM tokens t
359
+ LEFT JOIN token_stats ts ON t.id = ts.token_id
360
+ WHERE t.provider = ?
361
+ """, (provider,))
362
+ row = await cursor.fetchone()
363
+ return dict(row) if row else {}
364
+ except Exception as e:
365
+ logger.error(f"❌ 获取提供商统计失败: {e}")
366
+ return {}
367
+
368
+ # ==================== Token 验证操作 ====================
369
+
370
+ async def validate_and_update_token(self, token_id: int) -> bool:
371
+ """
372
+ 验证单个 Token 并更新其类型
373
+
374
+ Args:
375
+ token_id: Token 数据库 ID
376
+
377
+ Returns:
378
+ 是否为有效的认证用户 Token
379
+ """
380
+ try:
381
+ # 获取 Token 信息
382
+ async with self.get_connection() as conn:
383
+ cursor = await conn.execute("""
384
+ SELECT provider, token FROM tokens WHERE id = ?
385
+ """, (token_id,))
386
+ row = await cursor.fetchone()
387
+
388
+ if not row:
389
+ logger.error(f"❌ Token ID {token_id} 不存在")
390
+ return False
391
+
392
+ provider = row["provider"]
393
+ token = row["token"]
394
+
395
+ # 仅对 zai 提供商验证
396
+ if provider != "zai":
397
+ logger.info(f"⏭️ 跳过非 zai 提供商的 Token 验证: {provider}")
398
+ return True
399
+
400
+ # 验证 Token
401
+ from app.utils.token_pool import ZAITokenValidator
402
+
403
+ token_type, is_valid, error_msg = await ZAITokenValidator.validate_token(token)
404
+
405
+ # 更新 Token 类型
406
+ await self.update_token_type(token_id, token_type)
407
+
408
+ if not is_valid:
409
+ logger.warning(f"⚠️ Token 验证失败: id={token_id}, type={token_type}, error={error_msg}")
410
+
411
+ return is_valid
412
+
413
+ except Exception as e:
414
+ logger.error(f"❌ 验证 Token 失败: {e}")
415
+ return False
416
+
417
+ async def validate_all_tokens(self, provider: str = "zai") -> Dict[str, int]:
418
+ """
419
+ 批量验证所有 Token
420
+
421
+ Args:
422
+ provider: 提供商名称(默认 zai)
423
+
424
+ Returns:
425
+ 统计结果 {"valid": 数量, "guest": 数量, "invalid": 数量}
426
+ """
427
+ try:
428
+ tokens = await self.get_tokens_by_provider(provider, enabled_only=False)
429
+
430
+ if not tokens:
431
+ logger.warning(f"⚠️ 没有需要验证的 {provider} Token")
432
+ return {"valid": 0, "guest": 0, "invalid": 0}
433
+
434
+ logger.info(f"🔍 开始批量验证 {len(tokens)} 个 {provider} Token...")
435
+
436
+ stats = {"valid": 0, "guest": 0, "invalid": 0}
437
+
438
+ for token_record in tokens:
439
+ token_id = token_record["id"]
440
+ is_valid = await self.validate_and_update_token(token_id)
441
+
442
+ # 重新查询更新后的类型
443
+ async with self.get_connection() as conn:
444
+ cursor = await conn.execute("""
445
+ SELECT token_type FROM tokens WHERE id = ?
446
+ """, (token_id,))
447
+ row = await cursor.fetchone()
448
+ token_type = row["token_type"] if row else "unknown"
449
+
450
+ if token_type == "user":
451
+ stats["valid"] += 1
452
+ elif token_type == "guest":
453
+ stats["guest"] += 1
454
+ else:
455
+ stats["invalid"] += 1
456
+
457
+ logger.info(f"✅ 批量验证完成: 有效 {stats['valid']}, 匿名 {stats['guest']}, 无效 {stats['invalid']}")
458
+ return stats
459
+
460
+ except Exception as e:
461
+ logger.error(f"❌ 批量验证失败: {e}")
462
+ return {"valid": 0, "guest": 0, "invalid": 0}
463
+
464
+
465
+ # 全局单例
466
+ _token_dao: Optional[TokenDAO] = None
467
+
468
+
469
+ def get_token_dao() -> TokenDAO:
470
+ """获取全局 TokenDAO 实例"""
471
+ global _token_dao
472
+ if _token_dao is None:
473
+ _token_dao = TokenDAO()
474
+ return _token_dao
475
+
476
+
477
+ async def init_token_database():
478
+ """初始化 Token 数据库"""
479
+ dao = get_token_dao()
480
+ await dao.init_database()
app/templates/base.html ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN" class="h-full bg-gray-50">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>{% block title %}管理后台{% endblock %} - Z.AI2API</title>
7
+
8
+ <!-- Tailwind CSS (CDN) -->
9
+ <script src="https://cdn.tailwindcss.com"></script>
10
+
11
+ <!-- Alpine.js (CDN) -->
12
+ <script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>
13
+
14
+ <!-- htmx (CDN) -->
15
+ <script src="https://unpkg.com/[email protected]"></script>
16
+
17
+ <!-- Chart.js (CDN) -->
18
+ <script src="https://cdn.jsdelivr.net/npm/[email protected]"></script>
19
+
20
+ <!-- 自定义样式 -->
21
+ <style>
22
+ /* 自定义滚动条 */
23
+ ::-webkit-scrollbar {
24
+ width: 8px;
25
+ height: 8px;
26
+ }
27
+ ::-webkit-scrollbar-track {
28
+ background: #f1f1f1;
29
+ }
30
+ ::-webkit-scrollbar-thumb {
31
+ background: #888;
32
+ border-radius: 4px;
33
+ }
34
+ ::-webkit-scrollbar-thumb:hover {
35
+ background: #555;
36
+ }
37
+
38
+ /* htmx 加载指示器 */
39
+ .htmx-indicator {
40
+ display: none;
41
+ }
42
+ .htmx-request .htmx-indicator {
43
+ display: inline-block;
44
+ }
45
+ .htmx-request.htmx-indicator {
46
+ display: inline-block;
47
+ }
48
+
49
+ /* 平滑过渡 */
50
+ .fade-in {
51
+ animation: fadeIn 0.3s ease-in;
52
+ }
53
+ @keyframes fadeIn {
54
+ from { opacity: 0; }
55
+ to { opacity: 1; }
56
+ }
57
+ </style>
58
+
59
+ {% block extra_head %}{% endblock %}
60
+ </head>
61
+ <body class="h-full" x-data="{
62
+ sidebarOpen: true,
63
+ async logout() {
64
+ if (confirm('确定要登出吗?')) {
65
+ try {
66
+ const response = await fetch('/admin/api/logout', {
67
+ method: 'POST'
68
+ });
69
+ if (response.ok) {
70
+ window.location.href = '/admin/login';
71
+ }
72
+ } catch (err) {
73
+ console.error('登出失败:', err);
74
+ alert('登出失败,请稍后重试');
75
+ }
76
+ }
77
+ }
78
+ }">
79
+ <div class="min-h-full">
80
+ <!-- 顶部导航栏 -->
81
+ <nav class="bg-indigo-600 shadow-lg">
82
+ <div class="mx-auto px-4 sm:px-6 lg:px-8">
83
+ <div class="flex h-16 items-center justify-between">
84
+ <!-- Logo 和切换按钮 -->
85
+ <div class="flex items-center">
86
+ <button @click="sidebarOpen = !sidebarOpen" class="text-white hover:bg-indigo-700 p-2 rounded-md">
87
+ <svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
88
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
89
+ </svg>
90
+ </button>
91
+ <div class="ml-4 flex items-center">
92
+ <h1 class="text-2xl font-bold text-white">API 管理后台</h1>
93
+ </div>
94
+ </div>
95
+
96
+ <!-- 右侧信息 -->
97
+ <div class="flex items-center space-x-4">
98
+ <!-- 实时状态指示器 -->
99
+ <div class="flex items-center text-white">
100
+ <span class="relative flex h-3 w-3">
101
+ <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
102
+ <span class="relative inline-flex rounded-full h-3 w-3 bg-green-500"></span>
103
+ </span>
104
+ <span class="ml-2 text-sm">服务运行中</span>
105
+ </div>
106
+
107
+ <!-- 登出按钮 -->
108
+ <button
109
+ @click="logout()"
110
+ class="flex items-center text-white hover:bg-indigo-700 px-3 py-2 rounded-md text-sm font-medium transition-colors">
111
+ <svg class="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
112
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
113
+ </svg>
114
+ 登出
115
+ </button>
116
+ </div>
117
+ </div>
118
+ </div>
119
+ </nav>
120
+
121
+ <div class="flex">
122
+ <!-- 侧边栏 -->
123
+ <aside
124
+ x-show="sidebarOpen"
125
+ x-transition:enter="transition ease-out duration-200"
126
+ x-transition:enter-start="transform -translate-x-full"
127
+ x-transition:enter-end="transform translate-x-0"
128
+ x-transition:leave="transition ease-in duration-200"
129
+ x-transition:leave-start="transform translate-x-0"
130
+ x-transition:leave-end="transform -translate-x-full"
131
+ class="w-64 bg-white shadow-lg min-h-screen">
132
+ <nav class="mt-5 px-2 space-y-1">
133
+ {% set current_path = request.url.path %}
134
+
135
+ <!-- 仪表盘 -->
136
+ <a href="/admin"
137
+ class="{% if current_path == '/admin' or current_path == '/admin/' %}bg-indigo-100 text-indigo-700{% else %}text-gray-700 hover:bg-gray-100{% endif %} group flex items-center px-3 py-2 text-sm font-medium rounded-md">
138
+ <svg class="mr-3 h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
139
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
140
+ </svg>
141
+ 仪表盘
142
+ </a>
143
+
144
+ <!-- 配置管理 -->
145
+ <a href="/admin/config"
146
+ class="{% if '/config' in current_path %}bg-indigo-100 text-indigo-700{% else %}text-gray-700 hover:bg-gray-100{% endif %} group flex items-center px-3 py-2 text-sm font-medium rounded-md">
147
+ <svg class="mr-3 h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
148
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
149
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
150
+ </svg>
151
+ 配置管理
152
+ </a>
153
+
154
+ <!-- 服务监控 -->
155
+ <a href="/admin/monitor"
156
+ class="{% if '/monitor' in current_path %}bg-indigo-100 text-indigo-700{% else %}text-gray-700 hover:bg-gray-100{% endif %} group flex items-center px-3 py-2 text-sm font-medium rounded-md">
157
+ <svg class="mr-3 h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
158
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
159
+ </svg>
160
+ 服务监控
161
+ </a>
162
+
163
+ <!-- Token 管理 -->
164
+ <a href="/admin/tokens"
165
+ class="{% if '/tokens' in current_path %}bg-indigo-100 text-indigo-700{% else %}text-gray-700 hover:bg-gray-100{% endif %} group flex items-center px-3 py-2 text-sm font-medium rounded-md">
166
+ <svg class="mr-3 h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
167
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
168
+ </svg>
169
+ Token 管理
170
+ </a>
171
+
172
+ <!-- 分隔线 -->
173
+ <div class="border-t border-gray-200 my-4"></div>
174
+
175
+ <!-- API 文档 -->
176
+ <a href="/docs" target="_blank"
177
+ class="text-gray-700 hover:bg-gray-100 group flex items-center px-3 py-2 text-sm font-medium rounded-md">
178
+ <svg class="mr-3 h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
179
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
180
+ </svg>
181
+ API 文档
182
+ </a>
183
+ </nav>
184
+ </aside>
185
+
186
+ <!-- 主内容区 -->
187
+ <main class="flex-1 p-6">
188
+ <!-- 通知区域 -->
189
+ <div id="notification" class="mb-4"></div>
190
+
191
+ <!-- 页面内容 -->
192
+ <div class="fade-in">
193
+ {% block content %}{% endblock %}
194
+ </div>
195
+ </main>
196
+ </div>
197
+ </div>
198
+
199
+ {% block extra_scripts %}{% endblock %}
200
+ </body>
201
+ </html>
app/templates/components/provider_status.html ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!-- 提供商状态详情组件 -->
2
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-6">
3
+ {% for provider in providers %}
4
+ <div class="border rounded-lg p-6 hover:shadow-md transition-shadow">
5
+ <!-- 提供商头部 -->
6
+ <div class="mb-4">
7
+ <h4 class="text-lg font-semibold text-gray-900">{{ provider.display_name }}</h4>
8
+ </div>
9
+
10
+ <!-- Token 统计 -->
11
+ <div class="space-y-3 mb-4">
12
+ <div class="bg-gray-50 rounded-md p-3">
13
+ <h5 class="text-xs font-medium text-gray-500 mb-2">Token 统计</h5>
14
+ <div class="space-y-2 text-sm">
15
+ <div class="flex justify-between">
16
+ <span class="text-gray-600">总计:</span>
17
+ <span class="font-medium text-gray-900">{{ provider.total_tokens }}</span>
18
+ </div>
19
+ <div class="flex justify-between">
20
+ <span class="text-gray-600">已启用:</span>
21
+ <span class="font-medium text-green-600">{{ provider.enabled_tokens }}</span>
22
+ </div>
23
+ <div class="flex justify-between text-xs text-gray-500">
24
+ <span>认证用户:</span>
25
+ <span>{{ provider.user_tokens }}</span>
26
+ </div>
27
+ <div class="flex justify-between text-xs text-gray-500">
28
+ <span>匿名用户:</span>
29
+ <span>{{ provider.guest_tokens }}</span>
30
+ </div>
31
+ <div class="flex justify-between text-xs text-gray-500">
32
+ <span>未知类型:</span>
33
+ <span>{{ provider.unknown_tokens }}</span>
34
+ </div>
35
+ </div>
36
+ </div>
37
+
38
+ <!-- 请求统计 -->
39
+ <div class="bg-gray-50 rounded-md p-3">
40
+ <h5 class="text-xs font-medium text-gray-500 mb-2">请求统计</h5>
41
+ <div class="space-y-2 text-sm">
42
+ <div class="flex justify-between">
43
+ <span class="text-gray-600">总请求数:</span>
44
+ <span class="font-medium text-gray-900">{{ provider.total_requests }}</span>
45
+ </div>
46
+ <div class="flex justify-between">
47
+ <span class="text-gray-600">成功:</span>
48
+ <span class="font-medium text-green-600">{{ provider.successful_requests }}</span>
49
+ </div>
50
+ <div class="flex justify-between">
51
+ <span class="text-gray-600">失败:</span>
52
+ <span class="font-medium text-red-600">{{ provider.failed_requests }}</span>
53
+ </div>
54
+ <div class="flex justify-between">
55
+ <span class="text-gray-600">成功率:</span>
56
+ <span class="font-medium text-indigo-600">{{ provider.success_rate }}</span>
57
+ </div>
58
+ </div>
59
+ </div>
60
+ </div>
61
+
62
+ <!-- 快速操作 -->
63
+ <div class="pt-3 border-t border-gray-200">
64
+ <a href="/admin/tokens?provider={{ provider.name }}"
65
+ class="text-sm text-indigo-600 hover:text-indigo-800 font-medium transition-colors">
66
+ 管理 Token →
67
+ </a>
68
+ </div>
69
+ </div>
70
+ {% endfor %}
71
+ </div>
72
+
73
+ <!-- 无提供商状态 -->
74
+ {% if not providers %}
75
+ <div class="text-center py-8 text-gray-500">
76
+ <p>暂无提供商状态信息</p>
77
+ </div>
78
+ {% endif %}
app/templates/components/recent_logs.html ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!-- 最近请求日志列表 -->
2
+ <div class="overflow-x-auto">
3
+ <table class="min-w-full divide-y divide-gray-200">
4
+ <thead class="bg-gray-50">
5
+ <tr>
6
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">时间</th>
7
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">端点</th>
8
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">模型</th>
9
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">提供商</th>
10
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th>
11
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">耗时</th>
12
+ </tr>
13
+ </thead>
14
+ <tbody class="bg-white divide-y divide-gray-200">
15
+ {% for log in logs %}
16
+ <tr class="hover:bg-gray-50">
17
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ log.timestamp }}</td>
18
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ log.endpoint }}</td>
19
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ log.model }}</td>
20
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
21
+ <span class="px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800">
22
+ {{ log.provider }}
23
+ </span>
24
+ </td>
25
+ <td class="px-6 py-4 whitespace-nowrap text-sm">
26
+ {% if log.status == 200 %}
27
+ <span class="px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800">
28
+ {{ log.status }}
29
+ </span>
30
+ {% else %}
31
+ <span class="px-2 py-1 text-xs font-semibold rounded-full bg-red-100 text-red-800">
32
+ {{ log.status }}
33
+ </span>
34
+ {% endif %}
35
+ </td>
36
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ log.duration }}</td>
37
+ </tr>
38
+ {% endfor %}
39
+ </tbody>
40
+ </table>
41
+
42
+ {% if not logs %}
43
+ <div class="text-center py-8 text-gray-500">
44
+ <svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
45
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
46
+ </svg>
47
+ <p class="mt-2">暂无请求日志</p>
48
+ </div>
49
+ {% endif %}
50
+ </div>
app/templates/components/token_list.html ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!-- Token 列表表格 -->
2
+ <div class="overflow-x-auto">
3
+ <table class="min-w-full divide-y divide-gray-200">
4
+ <thead class="bg-gray-50">
5
+ <tr>
6
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID</th>
7
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Token</th>
8
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">类型</th>
9
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">健康度</th>
10
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th>
11
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">使用统计</th>
12
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">创建时间</th>
13
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">操作</th>
14
+ </tr>
15
+ </thead>
16
+ <tbody class="bg-white divide-y divide-gray-200">
17
+ {% for token in tokens %}
18
+ {% include "components/token_row.html" %}
19
+ {% endfor %}
20
+ </tbody>
21
+ </table>
22
+
23
+ {% if not tokens %}
24
+ <div class="text-center py-12 text-gray-500">
25
+ <svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
26
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
27
+ </svg>
28
+ <p class="mt-2 font-medium">暂无 Token</p>
29
+ <p class="mt-1 text-sm">点击右上角"添加 Token"按钮开始添加</p>
30
+ </div>
31
+ {% endif %}
32
+ </div>
33
+
34
+ <!-- Token 数量统计(更新页面标题) -->
35
+ <script>
36
+ (function() {
37
+ const tokenCount = {{ tokens|length }};
38
+ const countElement = document.getElementById('token-count');
39
+ if (countElement) {
40
+ countElement.textContent = `(共 ${tokenCount} 个)`;
41
+ }
42
+ })();
43
+ </script>
44
+
45
+ <!-- 复制到剪贴板函数 -->
46
+ <script>
47
+ function copyToClipboard(text) {
48
+ if (navigator.clipboard && navigator.clipboard.writeText) {
49
+ navigator.clipboard.writeText(text).then(() => {
50
+ // 显示临时提示
51
+ const notification = document.createElement('div');
52
+ notification.className = 'fixed bottom-4 right-4 bg-green-500 text-white px-4 py-2 rounded shadow-lg z-50';
53
+ notification.textContent = '✓ Token 已复制到剪贴板';
54
+ document.body.appendChild(notification);
55
+
56
+ setTimeout(() => {
57
+ notification.remove();
58
+ }, 2000);
59
+ }).catch(err => {
60
+ console.error('复制失败:', err);
61
+ alert('复制失败,请手动复制');
62
+ });
63
+ } else {
64
+ // 降级方案:使用 execCommand
65
+ const textArea = document.createElement('textarea');
66
+ textArea.value = text;
67
+ textArea.style.position = 'fixed';
68
+ textArea.style.left = '-999999px';
69
+ document.body.appendChild(textArea);
70
+ textArea.select();
71
+ try {
72
+ document.execCommand('copy');
73
+ alert('Token 已复制到剪贴板');
74
+ } catch (err) {
75
+ alert('复制失败,请手动复制');
76
+ }
77
+ document.body.removeChild(textArea);
78
+ }
79
+ }
80
+ </script>
app/templates/components/token_pool.html ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!-- Token 池状态卡片 -->
2
+ <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
3
+ {% for token in tokens %}
4
+ <div class="border rounded-lg p-4 hover:shadow-lg transition-shadow">
5
+ <div class="flex items-center justify-between mb-2">
6
+ <span class="text-sm font-medium text-gray-700">Token #{{ token.index }}</span>
7
+ <span class="px-2 py-1 text-xs font-semibold rounded-full {{ token.status_color }}">
8
+ {{ token.status }}
9
+ </span>
10
+ </div>
11
+ <div class="space-y-1 text-sm text-gray-600">
12
+ <div class="truncate">
13
+ <span class="font-mono text-xs bg-gray-100 px-2 py-1 rounded">{{ token.key }}</span>
14
+ </div>
15
+ <div>类型:
16
+ {% if token.token_type == 'user' %}
17
+ <span class="text-green-600 font-semibold">认证用户</span>
18
+ {% elif token.token_type == 'guest' %}
19
+ <span class="text-yellow-600 font-semibold">匿名用户</span>
20
+ {% else %}
21
+ <span class="text-gray-600">未知</span>
22
+ {% endif %}
23
+ </div>
24
+ <div>成功率: <span class="font-medium">{{ token.success_rate }}</span></div>
25
+ <div>失败次数: <span class="font-medium">{{ token.failure_count }}</span></div>
26
+ <div class="text-xs text-gray-500">最后使用: {{ token.last_used }}</div>
27
+ </div>
28
+ </div>
29
+ {% endfor %}
30
+
31
+ {% if not tokens %}
32
+ <div class="col-span-full text-center py-8 text-gray-500">
33
+ <svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
34
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
35
+ </svg>
36
+ <p class="mt-2">暂无 Token 配置</p>
37
+ <p class="mt-1 text-sm">请在配置管理页面添加 Token</p>
38
+ </div>
39
+ {% endif %}
40
+ </div>
app/templates/components/token_row.html ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!-- 单个 Token 行模板 -->
2
+ {% set success_rate = (token.successful_requests / token.total_requests * 100) if token.total_requests else 0 %}
3
+ {% set is_healthy = (token.token_type == 'user' and token.is_enabled and (success_rate >= 50 or token.total_requests <= 3)) %}
4
+ <tr class="hover:bg-gray-50 transition-colors" id="token-row-{{ token.id }}">
5
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 font-medium">
6
+ {{ token.id }}
7
+ </td>
8
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
9
+ <div class="flex items-center space-x-2">
10
+ <span class="font-mono text-xs bg-gray-100 px-2 py-1 rounded">
11
+ {{ token.token[:30] }}...
12
+ </span>
13
+ <button onclick="copyToClipboard('{{ token.token }}')"
14
+ class="text-gray-400 hover:text-indigo-600 transition-colors"
15
+ title="复制完整 Token">
16
+ <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
17
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
18
+ </svg>
19
+ </button>
20
+ </div>
21
+ </td>
22
+ <td class="px-6 py-4 whitespace-nowrap text-sm">
23
+ {% if token.token_type == 'user' %}
24
+ <span class="inline-flex items-center px-2.5 py-0.5 text-xs font-semibold rounded-full bg-green-100 text-green-800">
25
+ <svg class="h-3 w-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
26
+ <path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clip-rule="evenodd" />
27
+ </svg>
28
+ 认证用户
29
+ </span>
30
+ {% elif token.token_type == 'guest' %}
31
+ <span class="inline-flex items-center px-2.5 py-0.5 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800">
32
+ <svg class="h-3 w-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
33
+ <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
34
+ </svg>
35
+ 匿名用户
36
+ </span>
37
+ {% else %}
38
+ <span class="inline-flex items-center px-2.5 py-0.5 text-xs font-semibold rounded-full bg-gray-100 text-gray-800">
39
+ <svg class="h-3 w-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
40
+ <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
41
+ </svg>
42
+ 未知
43
+ </span>
44
+ {% endif %}
45
+ </td>
46
+ <td class="px-6 py-4 whitespace-nowrap text-sm">
47
+ <!-- 健康度指示器 -->
48
+ <div class="flex items-center space-x-2">
49
+ {% if is_healthy %}
50
+ <div class="flex items-center">
51
+ <svg class="h-5 w-5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
52
+ <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
53
+ </svg>
54
+ <span class="ml-1 text-green-700 font-medium">健康</span>
55
+ </div>
56
+ {% elif token.token_type == 'guest' %}
57
+ <div class="flex items-center">
58
+ <svg class="h-5 w-5 text-yellow-500" fill="currentColor" viewBox="0 0 20 20">
59
+ <path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
60
+ </svg>
61
+ <span class="ml-1 text-yellow-700 font-medium">匿名</span>
62
+ </div>
63
+ {% elif not token.is_enabled %}
64
+ <div class="flex items-center">
65
+ <svg class="h-5 w-5 text-gray-500" fill="currentColor" viewBox="0 0 20 20">
66
+ <path fill-rule="evenodd" d="M13.477 14.89A6 6 0 015.11 6.524l8.367 8.368zm1.414-1.414L6.524 5.11a6 6 0 018.367 8.367zM18 10a8 8 0 11-16 0 8 8 0 0116 0z" clip-rule="evenodd" />
67
+ </svg>
68
+ <span class="ml-1 text-gray-700 font-medium">已禁用</span>
69
+ </div>
70
+ {% else %}
71
+ <div class="flex items-center">
72
+ <svg class="h-5 w-5 text-red-500" fill="currentColor" viewBox="0 0 20 20">
73
+ <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
74
+ </svg>
75
+ <span class="ml-1 text-red-700 font-medium">不健康</span>
76
+ </div>
77
+ {% endif %}
78
+ </div>
79
+ </td>
80
+ <td class="px-6 py-4 whitespace-nowrap text-sm">
81
+ <button hx-post="/admin/api/tokens/toggle/{{ token.id }}?enabled={{ 'false' if token.is_enabled else 'true' }}"
82
+ hx-swap="outerHTML"
83
+ class="inline-flex items-center px-2.5 py-0.5 text-xs font-semibold rounded-full transition-colors {{ 'bg-green-100 text-green-800 hover:bg-green-200' if token.is_enabled else 'bg-red-100 text-red-800 hover:bg-red-200' }}">
84
+ <span class="h-2 w-2 rounded-full mr-1.5 {{ 'bg-green-500' if token.is_enabled else 'bg-red-500' }}"></span>
85
+ {{ '已启用' if token.is_enabled else '已禁用' }}
86
+ </button>
87
+ </td>
88
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
89
+ {% if token.total_requests %}
90
+ <div class="space-y-1">
91
+ <div class="flex items-center justify-between">
92
+ <span class="text-xs text-gray-600">成功:</span>
93
+ <span class="font-medium text-green-600">{{ token.successful_requests }}</span>
94
+ </div>
95
+ <div class="flex items-center justify-between">
96
+ <span class="text-xs text-gray-600">失败:</span>
97
+ <span class="font-medium text-red-600">{{ token.failed_requests }}</span>
98
+ </div>
99
+ <div class="flex items-center justify-between">
100
+ <span class="text-xs text-gray-600">成功率:</span>
101
+ <span class="font-medium {{ 'text-green-600' if success_rate >= 50 else 'text-red-600' }}">
102
+ {{ "%.1f"|format(success_rate) }}%
103
+ </span>
104
+ </div>
105
+ <!-- 成功率进度条 -->
106
+ <div class="w-full bg-gray-200 rounded-full h-1.5 mt-1">
107
+ <div class="h-1.5 rounded-full transition-all {{ 'bg-green-500' if success_rate >= 50 else 'bg-red-500' }}"
108
+ style="width: {{ success_rate }}%"></div>
109
+ </div>
110
+ </div>
111
+ {% else %}
112
+ <span class="text-gray-400 text-xs">未使用</span>
113
+ {% endif %}
114
+ </td>
115
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
116
+ <div class="flex flex-col space-y-1">
117
+ <span class="text-xs">{{ token.created_at[:10] if token.created_at else 'N/A' }}</span>
118
+ <span class="text-xs text-gray-400">{{ token.created_at[11:19] if token.created_at else '' }}</span>
119
+ </div>
120
+ </td>
121
+ <td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
122
+ <div class="flex items-center space-x-3">
123
+ <!-- 验证按钮 -->
124
+ <button hx-post="/admin/api/tokens/validate-single/{{ token.id }}"
125
+ hx-target="#token-row-{{ token.id }}"
126
+ hx-swap="outerHTML"
127
+ hx-indicator="#validate-spinner-{{ token.id }}"
128
+ class="text-blue-600 hover:text-blue-900 transition-colors relative validate-token-btn"
129
+ title="验证 Token"
130
+ data-token-id="{{ token.id }}">
131
+ <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
132
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
133
+ </svg>
134
+ <!-- 加载指示器 -->
135
+ <svg id="validate-spinner-{{ token.id }}" class="htmx-indicator absolute inset-0 h-4 w-4 animate-spin text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
136
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
137
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
138
+ </svg>
139
+ </button>
140
+ <!-- 删除按钮 -->
141
+ <button hx-delete="/admin/api/tokens/delete/{{ token.id }}"
142
+ hx-target="#token-row-{{ token.id }}"
143
+ hx-swap="outerHTML swap:1s"
144
+ hx-confirm="确定要删除这个 Token 吗?"
145
+ class="text-red-600 hover:text-red-900 transition-colors"
146
+ title="删除 Token">
147
+ <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
148
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
149
+ </svg>
150
+ </button>
151
+ </div>
152
+ </td>
153
+ </tr>
app/templates/components/token_stats.html ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!-- Token 统计面板 -->
2
+ <div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
3
+ <!-- 总数 -->
4
+ <div class="bg-white overflow-hidden shadow rounded-lg">
5
+ <div class="p-5">
6
+ <div class="flex items-center">
7
+ <div class="flex-shrink-0">
8
+ <svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
9
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
10
+ </svg>
11
+ </div>
12
+ <div class="ml-5 w-0 flex-1">
13
+ <dl>
14
+ <dt class="text-sm font-medium text-gray-500 truncate">Token 总数</dt>
15
+ <dd class="text-2xl font-bold text-gray-900">{{ stats.total_tokens }}</dd>
16
+ </dl>
17
+ </div>
18
+ </div>
19
+ </div>
20
+ </div>
21
+
22
+ <!-- 已启用 -->
23
+ <div class="bg-white overflow-hidden shadow rounded-lg">
24
+ <div class="p-5">
25
+ <div class="flex items-center">
26
+ <div class="flex-shrink-0">
27
+ <svg class="h-6 w-6 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
28
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
29
+ </svg>
30
+ </div>
31
+ <div class="ml-5 w-0 flex-1">
32
+ <dl>
33
+ <dt class="text-sm font-medium text-gray-500 truncate">已启用</dt>
34
+ <dd class="flex items-baseline">
35
+ <div class="text-2xl font-bold text-green-600">{{ stats.enabled_tokens }}</div>
36
+ {% if stats.total_tokens > 0 %}
37
+ <div class="ml-2 flex items-baseline text-sm font-semibold text-green-600">
38
+ {{ "%.0f"|format(stats.enabled_tokens / stats.total_tokens * 100) }}%
39
+ </div>
40
+ {% endif %}
41
+ </dd>
42
+ </dl>
43
+ </div>
44
+ </div>
45
+ </div>
46
+ </div>
47
+
48
+ <!-- 认证用户 -->
49
+ <div class="bg-white overflow-hidden shadow rounded-lg">
50
+ <div class="p-5">
51
+ <div class="flex items-center">
52
+ <div class="flex-shrink-0">
53
+ <svg class="h-6 w-6 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
54
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
55
+ </svg>
56
+ </div>
57
+ <div class="ml-5 w-0 flex-1">
58
+ <dl>
59
+ <dt class="text-sm font-medium text-gray-500 truncate">认证用户</dt>
60
+ <dd class="flex items-baseline">
61
+ <div class="text-2xl font-bold text-blue-600">{{ stats.user_tokens }}</div>
62
+ {% if stats.guest_tokens > 0 %}
63
+ <div class="ml-2 flex items-baseline text-sm font-semibold text-yellow-600">
64
+ <svg class="h-4 w-4 mr-0.5" fill="currentColor" viewBox="0 0 20 20">
65
+ <path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
66
+ </svg>
67
+ {{ stats.guest_tokens }} 个匿名
68
+ </div>
69
+ {% endif %}
70
+ </dd>
71
+ </dl>
72
+ </div>
73
+ </div>
74
+ </div>
75
+ </div>
76
+
77
+ <!-- 成功率 -->
78
+ <div class="bg-white overflow-hidden shadow rounded-lg">
79
+ <div class="p-5">
80
+ <div class="flex items-center">
81
+ <div class="flex-shrink-0">
82
+ {% if stats.total_requests > 0 %}
83
+ {% set success_rate = (stats.successful_requests / stats.total_requests * 100) %}
84
+ {% if success_rate >= 80 %}
85
+ <svg class="h-6 w-6 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
86
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
87
+ </svg>
88
+ {% elif success_rate >= 50 %}
89
+ <svg class="h-6 w-6 text-yellow-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
90
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
91
+ </svg>
92
+ {% else %}
93
+ <svg class="h-6 w-6 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
94
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 17h8m0 0v-8m0 8l-8-8-4 4-6-6" />
95
+ </svg>
96
+ {% endif %}
97
+ {% else %}
98
+ <svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
99
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
100
+ </svg>
101
+ {% endif %}
102
+ </div>
103
+ <div class="ml-5 w-0 flex-1">
104
+ <dl>
105
+ <dt class="text-sm font-medium text-gray-500 truncate">总成功率</dt>
106
+ <dd>
107
+ {% if stats.total_requests > 0 %}
108
+ {% set success_rate = (stats.successful_requests / stats.total_requests * 100) %}
109
+ <div class="text-2xl font-bold {{ 'text-green-600' if success_rate >= 80 else ('text-yellow-600' if success_rate >= 50 else 'text-red-600') }}">
110
+ {{ "%.1f"|format(success_rate) }}%
111
+ </div>
112
+ <div class="mt-1 text-xs text-gray-500">
113
+ {{ stats.successful_requests }} / {{ stats.total_requests }} 请求
114
+ </div>
115
+ {% else %}
116
+ <div class="text-2xl font-bold text-gray-400">N/A</div>
117
+ <div class="mt-1 text-xs text-gray-500">暂无请求</div>
118
+ {% endif %}
119
+ </dd>
120
+ </dl>
121
+ </div>
122
+ </div>
123
+ </div>
124
+ </div>
125
+ </div>
app/templates/config.html ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}配置管理{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="space-y-6" x-data="{
7
+ showAdvanced: false,
8
+ tokenCount: 1,
9
+ saveStatus: ''
10
+ }">
11
+ <!-- 页面标题 -->
12
+ <div class="flex items-center justify-between">
13
+ <h2 class="text-3xl font-bold text-gray-900">配置管理</h2>
14
+ <button
15
+ @click="showAdvanced = !showAdvanced"
16
+ class="px-4 py-2 bg-gray-200 hover:bg-gray-300 rounded-md text-sm font-medium">
17
+ <span x-text="showAdvanced ? '隐藏高级选项' : '显示高级选项'"></span>
18
+ </button>
19
+ </div>
20
+
21
+ <!-- 基础配置 -->
22
+ <div class="bg-white shadow rounded-lg">
23
+ <div class="px-6 py-4 border-b border-gray-200">
24
+ <h3 class="text-lg font-medium text-gray-900">基础配置</h3>
25
+ </div>
26
+ <form hx-post="/admin/api/config/save"
27
+ hx-target="#save-notification"
28
+ hx-swap="innerHTML"
29
+ class="p-6 space-y-6">
30
+
31
+ <!-- 服务名称 -->
32
+ <div>
33
+ <label class="block text-sm font-medium text-gray-700">服务名称</label>
34
+ <input type="text"
35
+ name="service_name"
36
+ value="{{ config.SERVICE_NAME or 'Z.AI2API' }}"
37
+ class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
38
+ <p class="mt-1 text-sm text-gray-500">显示在进程列表中的服务名称</p>
39
+ </div>
40
+
41
+ <!-- 监听端口 -->
42
+ <div>
43
+ <label class="block text-sm font-medium text-gray-700">监听端口</label>
44
+ <input type="number"
45
+ name="listen_port"
46
+ value="{{ config.LISTEN_PORT or 8000 }}"
47
+ class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
48
+ <p class="mt-1 text-sm text-gray-500">服务监听的端口号</p>
49
+ </div>
50
+
51
+ <!-- 调试模式 -->
52
+ <div class="flex items-center">
53
+ <input type="checkbox"
54
+ name="debug_logging"
55
+ {{ 'checked' if config.DEBUG_LOGGING else '' }}
56
+ class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
57
+ <label class="ml-2 block text-sm text-gray-900">启用调试日志</label>
58
+ </div>
59
+
60
+ <!-- 匿名模式 -->
61
+ <div class="flex items-center">
62
+ <input type="checkbox"
63
+ name="anonymous_mode"
64
+ {{ 'checked' if config.ANONYMOUS_MODE else '' }}
65
+ class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
66
+ <label class="ml-2 block text-sm text-gray-900">启用匿名模式(自动获取临时 Token)</label>
67
+ </div>
68
+
69
+ <!-- 认证配置 -->
70
+ <div>
71
+ <label class="block text-sm font-medium text-gray-700">客户端认证密钥</label>
72
+ <input type="text"
73
+ name="auth_token"
74
+ value="{{ config.AUTH_TOKEN or 'sk-your-api-key' }}"
75
+ class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
76
+ <p class="mt-1 text-sm text-gray-500">客户端访问本服务时使用的 API 密钥</p>
77
+ </div>
78
+
79
+ <!-- 跳过认证 -->
80
+ <div class="flex items-center">
81
+ <input type="checkbox"
82
+ name="skip_auth_token"
83
+ {{ 'checked' if config.SKIP_AUTH_TOKEN else '' }}
84
+ class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
85
+ <label class="ml-2 block text-sm text-gray-900">跳过客户端认证(仅开发环境)</label>
86
+ </div>
87
+
88
+ <!-- 工具调用支持 -->
89
+ <div class="flex items-center">
90
+ <input type="checkbox"
91
+ name="tool_support"
92
+ {{ 'checked' if config.TOOL_SUPPORT else '' }}
93
+ class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
94
+ <label class="ml-2 block text-sm text-gray-900">启用 Function Call(工具调用)功能</label>
95
+ </div>
96
+
97
+ <!-- 高级选项 -->
98
+ <div x-show="showAdvanced" x-transition class="border-t pt-6 space-y-6">
99
+ <h4 class="text-md font-medium text-gray-900">高级选项</h4>
100
+
101
+ <!-- Token 失败阈值 -->
102
+ <div>
103
+ <label class="block text-sm font-medium text-gray-700">Token 失败阈值</label>
104
+ <input type="number"
105
+ name="token_failure_threshold"
106
+ value="{{ config.TOKEN_FAILURE_THRESHOLD or 3 }}"
107
+ class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
108
+ <p class="mt-1 text-sm text-gray-500">连续失败多少次后标记 Token 为失败状态</p>
109
+ </div>
110
+
111
+ <!-- Token 恢复超时 -->
112
+ <div>
113
+ <label class="block text-sm font-medium text-gray-700">Token 恢复超时(秒)</label>
114
+ <input type="number"
115
+ name="token_recovery_timeout"
116
+ value="{{ config.TOKEN_RECOVERY_TIMEOUT or 1800 }}"
117
+ class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
118
+ <p class="mt-1 text-sm text-gray-500">Token 失败后多久自动恢复(默认 1800 秒 / 30 分钟)</p>
119
+ </div>
120
+
121
+ <!-- 工具调用扫描限制 -->
122
+ <div>
123
+ <label class="block text-sm font-medium text-gray-700">工具调用扫描限制(字符数)</label>
124
+ <input type="number"
125
+ name="scan_limit"
126
+ value="{{ config.SCAN_LIMIT or 200000 }}"
127
+ class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
128
+ <p class="mt-1 text-sm text-gray-500">Function Call 功能扫描的最大字符数</p>
129
+ </div>
130
+
131
+ <!-- LongCat Token -->
132
+ <div>
133
+ <label class="block text-sm font-medium text-gray-700">LongCat Passport Token</label>
134
+ <input type="text"
135
+ name="longcat_token"
136
+ value="{{ config.LONGCAT_TOKEN or '' }}"
137
+ placeholder="可选,用于 LongCat 提供商"
138
+ class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
139
+ <p class="mt-1 text-sm text-gray-500">LongCat 提供商的 passport token(可选)</p>
140
+ </div>
141
+
142
+ <!-- 默认提供商 -->
143
+ <div>
144
+ <label class="block text-sm font-medium text-gray-700">默认提供商</label>
145
+ <select name="default_provider"
146
+ class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
147
+ <option value="zai" {{ 'selected' if config.DEFAULT_PROVIDER == 'zai' else '' }}>Z.AI</option>
148
+ <option value="k2think" {{ 'selected' if config.DEFAULT_PROVIDER == 'k2think' else '' }}>K2Think</option>
149
+ <option value="longcat" {{ 'selected' if config.DEFAULT_PROVIDER == 'longcat' else '' }}>LongCat</option>
150
+ </select>
151
+ </div>
152
+ </div>
153
+
154
+ <!-- 保存按钮 -->
155
+ <div class="flex items-center justify-between pt-6 border-t">
156
+ <div id="save-notification" class="flex-1"></div>
157
+ <div class="flex space-x-3">
158
+ <button type="button"
159
+ hx-get="/admin/config/reset"
160
+ hx-confirm="确定要重置所有配置吗?"
161
+ class="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50">
162
+ 重置
163
+ </button>
164
+ <button type="submit"
165
+ class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
166
+ 💾 保存并重载
167
+ </button>
168
+ </div>
169
+ </div>
170
+
171
+ <!-- 配置说明 -->
172
+ <div class="mt-4 bg-blue-50 border-l-4 border-blue-400 p-4">
173
+ <div class="flex">
174
+ <div class="flex-shrink-0">
175
+ <svg class="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor">
176
+ <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
177
+ </svg>
178
+ </div>
179
+ <div class="ml-3">
180
+ <p class="text-sm text-blue-700">
181
+ <strong>提示:</strong>配置保存后会自动热重载,大部分配置无需重启服务即可生效。<br>
182
+ 仅 <code class="bg-blue-100 px-1 py-0.5 rounded">监听端口</code> 和 <code class="bg-blue-100 px-1 py-0.5 rounded">服务名称</code> 需要手动重启服务。
183
+ </p>
184
+ </div>
185
+ </div>
186
+ </div>
187
+ </form>
188
+ </div>
189
+
190
+ <!-- 配置文件预览 -->
191
+ <div class="bg-white shadow rounded-lg">
192
+ <div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
193
+ <h3 class="text-lg font-medium text-gray-900">.env 文件预览</h3>
194
+ <button
195
+ hx-get="/admin/api/env-preview"
196
+ hx-target="#env-preview"
197
+ class="text-sm text-indigo-600 hover:text-indigo-700">
198
+ 刷新
199
+ </button>
200
+ </div>
201
+ <div class="p-6">
202
+ <div id="env-preview" class="bg-gray-50 rounded-md p-4 font-mono text-sm overflow-x-auto">
203
+ <pre>{{ env_content or '加载中...' }}</pre>
204
+ </div>
205
+ </div>
206
+ </div>
207
+ </div>
208
+ {% endblock %}
209
+
210
+ {% block extra_scripts %}
211
+ <script>
212
+ // 配置保存成功后的处理
213
+ document.body.addEventListener('htmx:afterSwap', function(evt) {
214
+ if (evt.detail.target.id === 'save-notification') {
215
+ // 3秒后自动隐藏通知
216
+ setTimeout(() => {
217
+ evt.detail.target.innerHTML = '';
218
+ }, 3000);
219
+ }
220
+ });
221
+ </script>
222
+ {% endblock %}
app/templates/index.html ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}仪表盘{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="space-y-6">
7
+ <!-- 页面标题 -->
8
+ <div class="flex items-center justify-between">
9
+ <h2 class="text-3xl font-bold text-gray-900">仪表盘</h2>
10
+ <div class="text-sm text-gray-500">
11
+ 最后更新: <span id="last-update">{{ current_time }}</span>
12
+ </div>
13
+ </div>
14
+
15
+ <!-- 统计卡片 -->
16
+ <div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
17
+ <!-- 运行时间 -->
18
+ <div class="bg-white overflow-hidden shadow rounded-lg">
19
+ <div class="p-5">
20
+ <div class="flex items-center">
21
+ <div class="flex-shrink-0">
22
+ <svg class="h-6 w-6 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
23
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
24
+ </svg>
25
+ </div>
26
+ <div class="ml-5 w-0 flex-1">
27
+ <dl>
28
+ <dt class="text-sm font-medium text-gray-500 truncate">运行时间</dt>
29
+ <dd class="text-2xl font-semibold text-gray-900">{{ stats.uptime }}</dd>
30
+ </dl>
31
+ </div>
32
+ </div>
33
+ </div>
34
+ </div>
35
+
36
+ <!-- 总请求数 -->
37
+ <div class="bg-white overflow-hidden shadow rounded-lg">
38
+ <div class="p-5">
39
+ <div class="flex items-center">
40
+ <div class="flex-shrink-0">
41
+ <svg class="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
42
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
43
+ </svg>
44
+ </div>
45
+ <div class="ml-5 w-0 flex-1">
46
+ <dl>
47
+ <dt class="text-sm font-medium text-gray-500 truncate">总请求数</dt>
48
+ <dd class="text-2xl font-semibold text-gray-900">{{ stats.total_requests }}</dd>
49
+ </dl>
50
+ </div>
51
+ </div>
52
+ </div>
53
+ </div>
54
+
55
+ <!-- 成功率 -->
56
+ <div class="bg-white overflow-hidden shadow rounded-lg">
57
+ <div class="p-5">
58
+ <div class="flex items-center">
59
+ <div class="flex-shrink-0">
60
+ <svg class="h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
61
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
62
+ </svg>
63
+ </div>
64
+ <div class="ml-5 w-0 flex-1">
65
+ <dl>
66
+ <dt class="text-sm font-medium text-gray-500 truncate">成功率</dt>
67
+ <dd class="text-2xl font-semibold text-gray-900">{{ stats.success_rate }}%</dd>
68
+ </dl>
69
+ </div>
70
+ </div>
71
+ </div>
72
+ </div>
73
+
74
+ <!-- Token 池状态 -->
75
+ <div class="bg-white overflow-hidden shadow rounded-lg">
76
+ <div class="p-5">
77
+ <div class="flex items-center">
78
+ <div class="flex-shrink-0">
79
+ {% if stats.healthy_tokens >= stats.total_tokens * 0.8 %}
80
+ <svg class="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
81
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
82
+ </svg>
83
+ {% elif stats.healthy_tokens >= stats.total_tokens * 0.5 %}
84
+ <svg class="h-6 w-6 text-yellow-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
85
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
86
+ </svg>
87
+ {% else %}
88
+ <svg class="h-6 w-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
89
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
90
+ </svg>
91
+ {% endif %}
92
+ </div>
93
+ <div class="ml-5 w-0 flex-1">
94
+ <dl>
95
+ <dt class="text-sm font-medium text-gray-500 truncate">Token 池健康度</dt>
96
+ <dd class="flex items-baseline">
97
+ <span class="text-2xl font-semibold text-gray-900">{{ stats.healthy_tokens }}/{{ stats.total_tokens }}</span>
98
+ {% if stats.guest_tokens > 0 %}
99
+ <span class="ml-2 text-sm font-medium text-yellow-600">({{ stats.guest_tokens }} 个匿名)</span>
100
+ {% endif %}
101
+ </dd>
102
+ <dd class="mt-1 text-xs text-gray-500">可用: {{ stats.available_tokens }} | 认证: {{ stats.user_tokens }}</dd>
103
+ </dl>
104
+ </div>
105
+ </div>
106
+ </div>
107
+ </div>
108
+ </div>
109
+
110
+ <!-- Token 池详情 -->
111
+ <div class="bg-white shadow rounded-lg">
112
+ <div class="px-6 py-4 border-b border-gray-200">
113
+ <h3 class="text-lg font-medium text-gray-900">Token 池状态</h3>
114
+ </div>
115
+ <div class="p-6">
116
+ <div
117
+ id="token-pool-status"
118
+ hx-get="/admin/api/token-pool"
119
+ hx-trigger="load, every 5s"
120
+ hx-swap="innerHTML">
121
+ <!-- Token 池状态将通过 htmx 加载 -->
122
+ <div class="flex justify-center items-center py-12">
123
+ <svg class="animate-spin h-8 w-8 text-indigo-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
124
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
125
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
126
+ </svg>
127
+ <span class="ml-3 text-gray-500">加载中...</span>
128
+ </div>
129
+ </div>
130
+ </div>
131
+ </div>
132
+
133
+ <!-- 最近请求日志 -->
134
+ <div class="bg-white shadow rounded-lg">
135
+ <div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
136
+ <h3 class="text-lg font-medium text-gray-900">最近请求日志</h3>
137
+ <div class="flex items-center space-x-2" x-data="{ autoRefresh: true }">
138
+ <label class="flex items-center cursor-pointer">
139
+ <input type="checkbox" x-model="autoRefresh" class="form-checkbox h-4 w-4 text-indigo-600">
140
+ <span class="ml-2 text-sm text-gray-600">自动刷新</span>
141
+ </label>
142
+ </div>
143
+ </div>
144
+ <div class="p-6">
145
+ <div
146
+ id="recent-logs"
147
+ hx-get="/admin/api/recent-logs"
148
+ hx-trigger="load, every 3s"
149
+ hx-swap="innerHTML">
150
+ <!-- 日志内容将通过 htmx 加载 -->
151
+ <div class="flex justify-center items-center py-12">
152
+ <svg class="animate-spin h-8 w-8 text-indigo-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
153
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
154
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
155
+ </svg>
156
+ <span class="ml-3 text-gray-500">加载中...</span>
157
+ </div>
158
+ </div>
159
+ </div>
160
+ </div>
161
+ </div>
162
+ {% endblock %}
163
+
164
+ {% block extra_scripts %}
165
+ <script>
166
+ // 更新时间显示
167
+ function updateTime() {
168
+ const now = new Date();
169
+ document.getElementById('last-update').textContent = now.toLocaleString('zh-CN');
170
+ }
171
+ updateTime();
172
+ setInterval(updateTime, 1000);
173
+ </script>
174
+ {% endblock %}
app/templates/login.html ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN" class="h-full bg-gray-50">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>登录 - Z.AI2API 管理后台</title>
7
+
8
+ <!-- Tailwind CSS (CDN) -->
9
+ <script src="https://cdn.tailwindcss.com"></script>
10
+
11
+ <!-- Alpine.js (CDN) -->
12
+ <script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>
13
+
14
+ <style>
15
+ .gradient-bg {
16
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
17
+ }
18
+ </style>
19
+ </head>
20
+ <body class="h-full">
21
+ <div class="min-h-full flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
22
+ <div class="max-w-md w-full space-y-8">
23
+ <!-- Logo 和标题 -->
24
+ <div>
25
+ <div class="mx-auto h-16 w-16 flex items-center justify-center rounded-full gradient-bg">
26
+ <svg class="h-10 w-10 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
27
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
28
+ </svg>
29
+ </div>
30
+ <h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
31
+ Z.AI2API 管理后台
32
+ </h2>
33
+ <p class="mt-2 text-center text-sm text-gray-600">
34
+ 请输入管理密码以继续
35
+ </p>
36
+ </div>
37
+
38
+ <!-- 登录表单 -->
39
+ <div class="mt-8 space-y-6"
40
+ x-data="{
41
+ password: '',
42
+ loading: false,
43
+ error: '',
44
+ async login() {
45
+ if (!this.password) {
46
+ this.error = '请输入密码';
47
+ return;
48
+ }
49
+
50
+ this.loading = true;
51
+ this.error = '';
52
+
53
+ try {
54
+ const response = await fetch('/admin/api/login', {
55
+ method: 'POST',
56
+ headers: {
57
+ 'Content-Type': 'application/json',
58
+ },
59
+ body: JSON.stringify({ password: this.password })
60
+ });
61
+
62
+ const data = await response.json();
63
+
64
+ if (response.ok && data.success) {
65
+ window.location.href = '/admin';
66
+ } else {
67
+ this.error = data.message || '密码错误,请重试';
68
+ }
69
+ } catch (err) {
70
+ this.error = '登录失败,请稍后重试';
71
+ } finally {
72
+ this.loading = false;
73
+ }
74
+ }
75
+ }">
76
+
77
+ <!-- 错误提示 -->
78
+ <div x-show="error"
79
+ x-transition
80
+ class="bg-red-50 border-l-4 border-red-400 p-4">
81
+ <div class="flex">
82
+ <div class="flex-shrink-0">
83
+ <svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
84
+ <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
85
+ </svg>
86
+ </div>
87
+ <div class="ml-3">
88
+ <p class="text-sm text-red-700" x-text="error"></p>
89
+ </div>
90
+ </div>
91
+ </div>
92
+
93
+ <!-- 登录表单 -->
94
+ <form @submit.prevent="login" class="mt-8 space-y-6">
95
+ <div class="rounded-md shadow-sm -space-y-px">
96
+ <div>
97
+ <label for="password" class="sr-only">密码</label>
98
+ <input
99
+ id="password"
100
+ name="password"
101
+ type="password"
102
+ autocomplete="current-password"
103
+ required
104
+ x-model="password"
105
+ class="appearance-none rounded-md relative block w-full px-3 py-3 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
106
+ placeholder="请输入管理密码"
107
+ @keydown.enter="login">
108
+ </div>
109
+ </div>
110
+
111
+ <div>
112
+ <button
113
+ type="submit"
114
+ :disabled="loading"
115
+ class="group relative w-full flex justify-center py-3 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
116
+ <span class="absolute left-0 inset-y-0 flex items-center pl-3">
117
+ <svg class="h-5 w-5 text-indigo-500 group-hover:text-indigo-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
118
+ <path fill-rule="evenodd" d="M10 1a4.5 4.5 0 00-4.5 4.5V9H5a2 2 0 00-2 2v6a2 2 0 002 2h10a2 2 0 002-2v-6a2 2 0 00-2-2h-.5V5.5A4.5 4.5 0 0010 1zm3 8V5.5a3 3 0 10-6 0V9h6z" clip-rule="evenodd" />
119
+ </svg>
120
+ </span>
121
+ <span x-show="!loading">登录</span>
122
+ <span x-show="loading" class="flex items-center">
123
+ <svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
124
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
125
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
126
+ </svg>
127
+ 登录中...
128
+ </span>
129
+ </button>
130
+ </div>
131
+ </form>
132
+
133
+ <!-- 提示信息 -->
134
+ <div class="text-center">
135
+ <p class="text-xs text-gray-500">
136
+ 默认密码:admin123(请在 .env 中修改 ADMIN_PASSWORD)
137
+ </p>
138
+ </div>
139
+ </div>
140
+ </div>
141
+ </div>
142
+ </body>
143
+ </html>
app/templates/monitor.html ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}服务监控{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="space-y-6">
7
+ <!-- 页面标题 -->
8
+ <div class="flex items-center justify-between">
9
+ <h2 class="text-3xl font-bold text-gray-900">服务监控</h2>
10
+ <div class="flex items-center space-x-4">
11
+ <!-- 手动刷新按钮 -->
12
+ <button
13
+ onclick="window.location.reload()"
14
+ class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors">
15
+ 刷新页面
16
+ </button>
17
+ </div>
18
+ </div>
19
+
20
+ <!-- 提供商状态详情 -->
21
+ <div class="bg-white shadow rounded-lg">
22
+ <div class="px-6 py-4 border-b border-gray-200">
23
+ <h3 class="text-lg font-medium text-gray-900">提供商状态详情</h3>
24
+ </div>
25
+ <div
26
+ id="provider-status"
27
+ hx-get="/admin/api/provider-status"
28
+ hx-trigger="load, every 5s"
29
+ hx-swap="innerHTML"
30
+ class="p-6">
31
+ <!-- 加载中状态 -->
32
+ <div class="flex justify-center items-center py-8">
33
+ <svg class="animate-spin h-8 w-8 text-indigo-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
34
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
35
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
36
+ </svg>
37
+ </div>
38
+ </div>
39
+ </div>
40
+
41
+ <!-- 实时日志流 -->
42
+ <div class="bg-white shadow rounded-lg">
43
+ <div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
44
+ <h3 class="text-lg font-medium text-gray-900">实时日志</h3>
45
+ <div class="flex space-x-2">
46
+ <button
47
+ onclick="document.getElementById('live-logs').innerHTML = '<div class=\'text-center text-gray-500 py-4\'>日志已清空</div>'"
48
+ class="px-3 py-1 text-sm bg-gray-200 hover:bg-gray-300 rounded-md transition-colors">
49
+ 清空
50
+ </button>
51
+ </div>
52
+ </div>
53
+ <div class="p-6">
54
+ <div
55
+ id="live-logs"
56
+ hx-get="/admin/api/live-logs"
57
+ hx-trigger="load, every 3s"
58
+ hx-swap="innerHTML scroll:bottom"
59
+ class="bg-gray-900 text-gray-100 p-4 rounded-md font-mono text-sm overflow-y-auto"
60
+ style="max-height: 500px;">
61
+ <!-- 日志内容 -->
62
+ <div class="flex justify-center items-center py-8">
63
+ <span class="text-gray-500">加载中...</span>
64
+ </div>
65
+ </div>
66
+ </div>
67
+ </div>
68
+ </div>
69
+ {% endblock %}
70
+
71
+ {% block extra_scripts %}
72
+ <script>
73
+ // 自动滚动到日志底部(显示最新日志)
74
+ const logsContainer = document.getElementById('live-logs');
75
+
76
+ // 监听 htmx 更新事件
77
+ document.body.addEventListener('htmx:afterSwap', function(event) {
78
+ if (event.detail.target.id === 'live-logs') {
79
+ logsContainer.scrollTop = logsContainer.scrollHeight;
80
+ }
81
+ });
82
+ </script>
83
+ {% endblock %}
app/templates/tokens.html ADDED
@@ -0,0 +1,391 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}Token 管理{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="space-y-6" x-data="{
7
+ provider: 'zai',
8
+ showAddModal: false,
9
+ showValidateModal: false,
10
+ newToken: '',
11
+ bulkTokens: '',
12
+ isValidating: false,
13
+ init() {
14
+ // 从 URL 参数读取 provider
15
+ const urlParams = new URLSearchParams(window.location.search);
16
+ const providerParam = urlParams.get('provider');
17
+ if (providerParam && ['zai', 'k2think', 'longcat'].includes(providerParam.toLowerCase())) {
18
+ this.provider = providerParam.toLowerCase();
19
+ }
20
+
21
+ // 监听 provider 变化并触发刷新
22
+ this.$watch('provider', (newValue, oldValue) => {
23
+ if (newValue !== oldValue) {
24
+ console.log('Provider changed to:', newValue);
25
+ htmx.trigger('#token-list', 'providerChange');
26
+ htmx.trigger('#token-stats', 'providerChange');
27
+ }
28
+ });
29
+ }
30
+ }">
31
+ <!-- 页面标题 -->
32
+ <div class="flex items-center justify-between">
33
+ <div>
34
+ <h2 class="text-3xl font-bold text-gray-900">Token 管理</h2>
35
+ <p class="mt-1 text-sm text-gray-600">管理和监控 AI 提供商的 Token</p>
36
+ </div>
37
+ <div class="flex space-x-3">
38
+ <button @click="showValidateModal = true"
39
+ class="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 flex items-center">
40
+ <svg class="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
41
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
42
+ </svg>
43
+ 批量验证
44
+ </button>
45
+ <button @click="showAddModal = true"
46
+ class="px-4 py-2 bg-indigo-600 text-white rounded-md text-sm font-medium hover:bg-indigo-700 flex items-center">
47
+ <svg class="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
48
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
49
+ </svg>
50
+ 添加 Token
51
+ </button>
52
+ </div>
53
+ </div>
54
+
55
+ <!-- 统计面板 -->
56
+ <div id="token-stats"
57
+ hx-get="/admin/api/tokens/stats"
58
+ :hx-vals="JSON.stringify({provider: provider})"
59
+ hx-trigger="load, providerChange from:body, statsRefresh from:body"
60
+ hx-swap="innerHTML">
61
+ <!-- 加载中 -->
62
+ <div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
63
+ <div class="bg-white overflow-hidden shadow rounded-lg animate-pulse">
64
+ <div class="p-5">
65
+ <div class="h-4 bg-gray-200 rounded w-1/2 mb-2"></div>
66
+ <div class="h-8 bg-gray-200 rounded w-1/3"></div>
67
+ </div>
68
+ </div>
69
+ <div class="bg-white overflow-hidden shadow rounded-lg animate-pulse">
70
+ <div class="p-5">
71
+ <div class="h-4 bg-gray-200 rounded w-1/2 mb-2"></div>
72
+ <div class="h-8 bg-gray-200 rounded w-1/3"></div>
73
+ </div>
74
+ </div>
75
+ <div class="bg-white overflow-hidden shadow rounded-lg animate-pulse">
76
+ <div class="p-5">
77
+ <div class="h-4 bg-gray-200 rounded w-1/2 mb-2"></div>
78
+ <div class="h-8 bg-gray-200 rounded w-1/3"></div>
79
+ </div>
80
+ </div>
81
+ <div class="bg-white overflow-hidden shadow rounded-lg animate-pulse">
82
+ <div class="p-5">
83
+ <div class="h-4 bg-gray-200 rounded w-1/2 mb-2"></div>
84
+ <div class="h-8 bg-gray-200 rounded w-1/3"></div>
85
+ </div>
86
+ </div>
87
+ </div>
88
+ </div>
89
+
90
+ <!-- 提供商切换和操作栏 -->
91
+ <div class="bg-white shadow rounded-lg p-4">
92
+ <div class="flex items-center justify-between flex-wrap gap-4">
93
+ <!-- 提供商切换 -->
94
+ <div class="flex space-x-2">
95
+ <button @click="provider = 'zai'"
96
+ :class="provider === 'zai' ? 'bg-indigo-600 text-white' : 'bg-gray-200 text-gray-700'"
97
+ class="px-4 py-2 rounded-md text-sm font-medium transition-colors">
98
+ Z.AI
99
+ </button>
100
+ <button @click="provider = 'k2think'"
101
+ :class="provider === 'k2think' ? 'bg-indigo-600 text-white' : 'bg-gray-200 text-gray-700'"
102
+ class="px-4 py-2 rounded-md text-sm font-medium transition-colors">
103
+ K2Think
104
+ </button>
105
+ <button @click="provider = 'longcat'"
106
+ :class="provider === 'longcat' ? 'bg-indigo-600 text-white' : 'bg-gray-200 text-gray-700'"
107
+ class="px-4 py-2 rounded-md text-sm font-medium transition-colors">
108
+ LongCat
109
+ </button>
110
+ </div>
111
+
112
+ <!-- 刷新按钮 -->
113
+ <button hx-get="/admin/api/tokens/list"
114
+ :hx-vals="JSON.stringify({provider: provider})"
115
+ hx-target="#token-list"
116
+ class="flex items-center px-3 py-2 text-gray-600 hover:text-indigo-600 hover:bg-gray-50 rounded-md transition-colors"
117
+ title="刷新列表">
118
+ <svg class="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
119
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
120
+ </svg>
121
+ <span class="text-sm font-medium">刷新</span>
122
+ </button>
123
+ </div>
124
+ </div>
125
+
126
+ <!-- Token 列表 -->
127
+ <div class="bg-white shadow rounded-lg">
128
+ <div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
129
+ <h3 class="text-lg font-medium text-gray-900 flex items-center">
130
+ <span x-text="provider.toUpperCase()"></span> Token 列表
131
+ <span class="ml-2 text-sm font-normal text-gray-500" id="token-count"></span>
132
+ </h3>
133
+ <div class="flex items-center space-x-2">
134
+ <button hx-post="/admin/api/tokens/sync-pool"
135
+ :hx-vals="JSON.stringify({provider: provider})"
136
+ hx-target="#notification"
137
+ class="text-sm text-purple-600 hover:text-purple-700 flex items-center">
138
+ <svg class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
139
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
140
+ </svg>
141
+ 同步 Token 池
142
+ </button>
143
+ <button hx-post="/admin/api/tokens/health-check"
144
+ :hx-vals="JSON.stringify({provider: provider})"
145
+ hx-target="#notification"
146
+ class="text-sm text-blue-600 hover:text-blue-700 flex items-center">
147
+ <svg class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
148
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
149
+ </svg>
150
+ 健康检查
151
+ </button>
152
+ </div>
153
+ </div>
154
+ <div id="token-list"
155
+ hx-get="/admin/api/tokens/list"
156
+ :hx-vals="JSON.stringify({provider: provider})"
157
+ hx-trigger="load, providerChange from:body"
158
+ hx-swap="innerHTML">
159
+ <!-- Token 列表内容 -->
160
+ <div class="flex justify-center items-center py-12">
161
+ <svg class="animate-spin h-8 w-8 text-indigo-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
162
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
163
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
164
+ </svg>
165
+ </div>
166
+ </div>
167
+ </div>
168
+
169
+ <!-- 添加 Token 弹窗 -->
170
+ <div x-show="showAddModal"
171
+ x-transition:enter="transition ease-out duration-300"
172
+ x-transition:enter-start="opacity-0"
173
+ x-transition:enter-end="opacity-100"
174
+ class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50"
175
+ @click.self="showAddModal = false"
176
+ style="display: none;">
177
+ <div class="relative top-20 mx-auto p-5 border w-full max-w-2xl shadow-lg rounded-md bg-white">
178
+ <div class="flex items-center justify-between pb-3 border-b">
179
+ <h3 class="text-lg font-medium">添加 Token</h3>
180
+ <button @click="showAddModal = false" class="text-gray-400 hover:text-gray-600">
181
+ <svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
182
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
183
+ </svg>
184
+ </button>
185
+ </div>
186
+
187
+ <div class="mt-4 space-y-4">
188
+ <!-- 提示信息 -->
189
+ <div class="bg-blue-50 border-l-4 border-blue-400 p-4">
190
+ <div class="flex">
191
+ <div class="flex-shrink-0">
192
+ <svg class="h-5 w-5 text-blue-400" fill="currentColor" viewBox="0 0 20 20">
193
+ <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
194
+ </svg>
195
+ </div>
196
+ <div class="ml-3">
197
+ <p class="text-sm text-blue-700">
198
+ <strong>Z.AI Token 验证:</strong>添加时将自动验证 Token 有效性,
199
+ <span class="font-semibold">匿名用户 Token (guest) 将被拒绝</span>。
200
+ </p>
201
+ </div>
202
+ </div>
203
+ </div>
204
+
205
+ <!-- 单个 Token -->
206
+ <div>
207
+ <label class="block text-sm font-medium text-gray-700">单个 Token</label>
208
+ <input type="text"
209
+ x-model="newToken"
210
+ placeholder="输入 Token(以 eyJ 开头的 JWT)"
211
+ class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
212
+ </div>
213
+
214
+ <!-- 批量导入 -->
215
+ <div>
216
+ <label class="block text-sm font-medium text-gray-700">批量导入(每行一个)</label>
217
+ <textarea x-model="bulkTokens"
218
+ rows="6"
219
+ placeholder="每行一个 Token,支持逗号分隔&#10;eyJhbGc...&#10;eyJhbGc...&#10;或: token1, token2, token3"
220
+ class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm font-mono text-xs"></textarea>
221
+ <p class="mt-1 text-sm text-gray-500">支持格式:每行一个 Token,或使用逗号分隔</p>
222
+ </div>
223
+
224
+ <!-- 提交按钮 -->
225
+ <div class="flex justify-end space-x-3 pt-4 border-t">
226
+ <button @click="showAddModal = false"
227
+ class="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50">
228
+ 取消
229
+ </button>
230
+ <button hx-post="/admin/api/tokens/add"
231
+ :hx-vals="JSON.stringify({
232
+ provider: provider,
233
+ single_token: newToken,
234
+ bulk_tokens: bulkTokens
235
+ })"
236
+ hx-target="#notification"
237
+ @htmx:after-request="showAddModal = false; newToken = ''; bulkTokens = ''; htmx.trigger('#token-list', 'providerChange'); htmx.trigger('body', 'statsRefresh')"
238
+ class="px-4 py-2 bg-indigo-600 text-white rounded-md text-sm font-medium hover:bg-indigo-700">
239
+ 添加
240
+ </button>
241
+ </div>
242
+ </div>
243
+ </div>
244
+ </div>
245
+
246
+ <!-- 批量验证弹窗 -->
247
+ <div x-show="showValidateModal"
248
+ x-transition:enter="transition ease-out duration-300"
249
+ x-transition:enter-start="opacity-0"
250
+ x-transition:enter-end="opacity-100"
251
+ class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50"
252
+ @click.self="showValidateModal = false"
253
+ style="display: none;">
254
+ <div class="relative top-20 mx-auto p-5 border w-full max-w-lg shadow-lg rounded-md bg-white">
255
+ <div class="flex items-center justify-between pb-3 border-b">
256
+ <h3 class="text-lg font-medium">批量验证 Token</h3>
257
+ <button @click="showValidateModal = false" class="text-gray-400 hover:text-gray-600">
258
+ <svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
259
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
260
+ </svg>
261
+ </button>
262
+ </div>
263
+
264
+ <div class="mt-4 space-y-4">
265
+ <!-- 警告信息 -->
266
+ <div class="bg-yellow-50 border-l-4 border-yellow-400 p-4">
267
+ <div class="flex">
268
+ <div class="flex-shrink-0">
269
+ <svg class="h-5 w-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
270
+ <path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
271
+ </svg>
272
+ </div>
273
+ <div class="ml-3">
274
+ <p class="text-sm text-yellow-700">
275
+ 将验证所有 <strong x-text="provider.toUpperCase()"></strong> Token 的有效性。
276
+ <br>此操���可能需要较长时间,请耐心等待。
277
+ </p>
278
+ </div>
279
+ </div>
280
+ </div>
281
+
282
+ <!-- 验证说明 -->
283
+ <div class="text-sm text-gray-600 space-y-2">
284
+ <p><strong>验证内容:</strong></p>
285
+ <ul class="list-disc list-inside space-y-1 ml-4">
286
+ <li>检查 Token 是否有效</li>
287
+ <li>识别 Token 类型(认证用户 / 匿名用户)</li>
288
+ <li>更新数据库中的 Token 类型</li>
289
+ <li>匿名用户 Token 将被标记为不健康</li>
290
+ </ul>
291
+ </div>
292
+
293
+ <!-- 进度显示 -->
294
+ <div id="validate-progress" class="hidden">
295
+ <div class="flex items-center justify-center py-4">
296
+ <svg class="animate-spin h-8 w-8 text-indigo-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
297
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
298
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
299
+ </svg>
300
+ <span class="ml-3 text-gray-700">验证中...</span>
301
+ </div>
302
+ </div>
303
+
304
+ <!-- 提交按钮 -->
305
+ <div class="flex justify-end space-x-3 pt-4 border-t">
306
+ <button @click="showValidateModal = false"
307
+ :disabled="isValidating"
308
+ class="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed">
309
+ 取消
310
+ </button>
311
+ <button hx-post="/admin/api/tokens/validate"
312
+ :hx-vals="JSON.stringify({provider: provider})"
313
+ hx-target="#notification"
314
+ @htmx:before-request="isValidating = true; document.getElementById('validate-progress').classList.remove('hidden')"
315
+ @htmx:after-request="isValidating = false; showValidateModal = false; document.getElementById('validate-progress').classList.add('hidden'); htmx.trigger('#token-list', 'providerChange'); htmx.trigger('body', 'statsRefresh')"
316
+ :disabled="isValidating"
317
+ class="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed">
318
+ 开始验证
319
+ </button>
320
+ </div>
321
+ </div>
322
+ </div>
323
+ </div>
324
+ </div>
325
+ {% endblock %}
326
+
327
+ {% block extra_scripts %}
328
+ <script>
329
+ // 全局通知函数
330
+ function showNotification(message, type = 'success') {
331
+ const notification = document.createElement('div');
332
+ const bgColor = type === 'success' ? 'bg-green-500' : type === 'error' ? 'bg-red-500' : 'bg-blue-500';
333
+ notification.className = `fixed top-4 right-4 ${bgColor} text-white px-6 py-3 rounded-lg shadow-lg z-50 transition-all transform`;
334
+ notification.style.animation = 'slideInRight 0.3s ease-out';
335
+ notification.innerHTML = `
336
+ <div class="flex items-center space-x-2">
337
+ <svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
338
+ <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
339
+ </svg>
340
+ <span class="font-medium">${message}</span>
341
+ </div>
342
+ `;
343
+
344
+ document.body.appendChild(notification);
345
+
346
+ // 3秒后自动消失
347
+ setTimeout(() => {
348
+ notification.style.opacity = '0';
349
+ notification.style.transform = 'translateX(100%)';
350
+ setTimeout(() => notification.remove(), 300);
351
+ }, 3000);
352
+ }
353
+
354
+ // 添加动画样式
355
+ if (!document.getElementById('notification-styles')) {
356
+ const style = document.createElement('style');
357
+ style.id = 'notification-styles';
358
+ style.textContent = `
359
+ @keyframes slideInRight {
360
+ from {
361
+ opacity: 0;
362
+ transform: translateX(100%);
363
+ }
364
+ to {
365
+ opacity: 1;
366
+ transform: translateX(0);
367
+ }
368
+ }
369
+ `;
370
+ document.head.appendChild(style);
371
+ }
372
+
373
+ // 监听验证按钮的完成事件
374
+ document.body.addEventListener('htmx:afterSwap', function(evt) {
375
+ // 检查是否是验证按钮触发的事件
376
+ if (evt.detail.target && evt.detail.target.id && evt.detail.target.id.startsWith('token-row-')) {
377
+ // 从目标元素提取 token ID
378
+ const tokenId = evt.detail.target.id.replace('token-row-', '');
379
+
380
+ // 检查是否是验证操作(通过查看触发元素)
381
+ const triggerElt = evt.detail.requestConfig?.elt;
382
+ if (triggerElt && triggerElt.classList.contains('validate-token-btn')) {
383
+ showNotification(`✓ Token ID ${tokenId} 验证完成`, 'success');
384
+
385
+ // 同时刷新统计数据
386
+ htmx.trigger('body', 'statsRefresh');
387
+ }
388
+ }
389
+ });
390
+ </script>
391
+ {% endblock %}
app/utils/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ from app.utils import sse_tool_handler, reload_config, logger
5
+
6
+ __all__ = ["sse_tool_handler", "reload_config", "logger"]
app/utils/logger.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ import sys
5
+ from pathlib import Path
6
+ from loguru import logger
7
+
8
+ # Global logger instance
9
+ app_logger = None
10
+
11
+
12
+ def setup_logger(log_dir, log_retention_days=7, log_rotation="1 day", debug_mode=False):
13
+ """
14
+ Create a logger instance
15
+
16
+ Parameters:
17
+ log_dir (str): 日志目录
18
+ log_retention_days (int): 日志保留天数
19
+ log_rotation (str): 日志轮转间隔
20
+ debug_mode (bool): 是否开启调试模式
21
+ """
22
+ global app_logger
23
+
24
+ # 移除所有现有的日志处理器(支持热重载)
25
+ logger.remove()
26
+
27
+ log_level = "DEBUG" if debug_mode else "INFO"
28
+
29
+ console_format = (
30
+ "<green>{time:HH:mm:ss}</green> | <level>{level: <8}</level> | <level>{message}</level>"
31
+ if not debug_mode
32
+ else "<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | "
33
+ "<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> | <level>{message}</level>"
34
+ )
35
+
36
+ # 添加控制台输出(根据 debug_mode 设置级别)
37
+ logger.add(sys.stderr, level=log_level, format=console_format, colorize=True)
38
+
39
+ # 只有在 debug_mode 时才添加文件输出
40
+ if debug_mode:
41
+ try:
42
+ log_path = Path(log_dir)
43
+ log_path.mkdir(parents=True, exist_ok=True)
44
+
45
+ log_file = log_path / "{time:YYYY-MM-DD}.log"
46
+ file_format = "{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {name}:{function}:{line} | {message}"
47
+
48
+ logger.add(
49
+ str(log_file),
50
+ level=log_level,
51
+ format=file_format,
52
+ rotation=log_rotation,
53
+ retention=f"{log_retention_days} days",
54
+ encoding="utf-8",
55
+ compression="zip",
56
+ enqueue=True,
57
+ catch=True,
58
+ )
59
+ logger.info(f"✅ 日志文件输出已启用: {log_dir}")
60
+ except (PermissionError, OSError) as e:
61
+ # 如果无法创建日志目录或文件,降级为仅控制台输出
62
+ logger.warning(f"⚠️ 无法创建日志文件 ({e}),将仅使用控制台输出")
63
+
64
+ app_logger = logger
65
+
66
+ return logger
67
+
68
+
69
+ def get_logger():
70
+ """Get the logger instance"""
71
+ global app_logger
72
+ if app_logger is None:
73
+ # 如果没有设置过logger,使用默认配置
74
+ logger.remove() # 移除所有现有处理器
75
+ logger.add(sys.stderr, level="INFO", format="<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> | <level>{message}</level>")
76
+ app_logger = logger
77
+ return app_logger
78
+
79
+
80
+ if __name__ == "__main__":
81
+ """Test the logger"""
82
+ import tempfile
83
+
84
+ with tempfile.TemporaryDirectory() as temp_dir:
85
+ try:
86
+ setup_logger(temp_dir, debug_mode=True)
87
+
88
+ logger.debug("这是一条调试日志")
89
+ logger.info("这是一条信息日志")
90
+ logger.warning("这是一条警告日志")
91
+ logger.error("这是一条错误日志")
92
+ logger.critical("这是一条严重日志")
93
+
94
+ try:
95
+ 1 / 0
96
+ except ZeroDivisionError:
97
+ logger.exception("发生了除零异常")
98
+
99
+ print("✅ 日志测试完成")
100
+
101
+ logger.remove()
102
+
103
+ except Exception as e:
104
+ print(f"❌ 日志测试失败: {e}")
105
+ logger.remove()
106
+ raise
app/utils/reload_config.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """
5
+ 热重载配置模块
6
+ 定义 Granian 服务器热重载时需要忽略的目录和文件模式
7
+ """
8
+
9
+ # 忽略的目录列表
10
+ RELOAD_IGNORE_DIRS = [
11
+ "logs", # 忽略日志目录
12
+ "storage", # 忽略存储目录
13
+ "__pycache__", # 忽略 Python 缓存
14
+ ".git", # 忽略 git 目录
15
+ "node_modules", # 忽略 node_modules
16
+ "migrations", # 忽略数据库迁移目录
17
+ ".pytest_cache", # 忽略 pytest 缓存
18
+ ".venv", # 忽略虚拟环境
19
+ "venv", # 忽略虚拟环境
20
+ "env", # 忽略环境目录
21
+ ".mypy_cache", # 忽略 mypy 缓存
22
+ ".ruff_cache", # 忽略 ruff 缓存
23
+ "dist", # 忽略构建分发目录
24
+ "build", # 忽略构建目录
25
+ ".coverage", # 忽略测试覆盖率文件
26
+ "htmlcov", # 忽略覆盖率报告目录
27
+ "tests", # 忽略测试目录
28
+ "z-ai2api-server.pid", # 忽略 PID 文件
29
+ ]
30
+
31
+ # 忽略的文件模式(正则表达式)
32
+ RELOAD_IGNORE_PATTERNS = [
33
+ # 日志文件
34
+ r".*\.log$",
35
+ r".*\.log\.\d+$",
36
+ # 数据库文件
37
+ r".*\.sqlite3.*",
38
+ r".*\.db$",
39
+ r".*\.db-.*$",
40
+ # Python 相关
41
+ r".*\.pyc$",
42
+ r".*\.pyo$",
43
+ r".*\.pyd$",
44
+ # 临时文件
45
+ r".*\.tmp$",
46
+ r".*\.temp$",
47
+ r".*\.swp$",
48
+ r".*\.swo$",
49
+ r".*~$",
50
+ # 系统文件
51
+ r".*\.DS_Store$",
52
+ r".*Thumbs\.db$",
53
+ r".*\.directory$",
54
+ # 编辑器文件
55
+ r".*\.vscode.*",
56
+ r".*\.idea.*",
57
+ # 测试和覆盖率
58
+ r".*\.coverage$",
59
+ r".*\.pytest_cache.*",
60
+ # 构建文件
61
+ r".*\.egg-info.*",
62
+ r".*\.wheel$",
63
+ r".*\.whl$",
64
+ # 版本控制
65
+ r".*\.git.*",
66
+ r".*\.gitignore$",
67
+ r".*\.gitkeep$",
68
+ # 配置文件备份
69
+ r".*\.bak$",
70
+ r".*\.backup$",
71
+ r".*\.orig$",
72
+ # 锁文件
73
+ r".*\.lock$",
74
+ r".*\.pid$",
75
+ ]
76
+
77
+ # 监视的路径(只监视应用相关代码)
78
+ RELOAD_WATCH_PATHS = [
79
+ "app", # 应用主目录
80
+ "main.py", # 主入口文件
81
+ ]
82
+
83
+ # 热重载配置
84
+ RELOAD_CONFIG = {
85
+ "reload_ignore_dirs": RELOAD_IGNORE_DIRS,
86
+ "reload_ignore_patterns": RELOAD_IGNORE_PATTERNS,
87
+ "reload_paths": RELOAD_WATCH_PATHS,
88
+ "reload_tick": 100, # 监视频率(毫秒)
89
+ }
app/utils/sse_tool_handler.py ADDED
@@ -0,0 +1,612 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """
5
+ SSE Tool Handler
6
+
7
+ 处理 Z.AI SSE 流数据并转换为 OpenAI 兼容格式的工具调用处理器。
8
+
9
+ 主要功能:
10
+ - 解析 glm_block 格式的工具调用
11
+ - 从 metadata.arguments 提取完整参数
12
+ - 支持多阶段处理:thinking → tool_call → other → answer
13
+ - 输出符合 OpenAI API 规范的流式响应
14
+ """
15
+
16
+ import json
17
+ import time
18
+ from typing import Dict, Any, Generator
19
+ from enum import Enum
20
+
21
+ from app.utils.logger import get_logger
22
+
23
+ logger = get_logger()
24
+
25
+
26
+ class SSEPhase(Enum):
27
+ """SSE 处理阶段枚举"""
28
+ THINKING = "thinking"
29
+ TOOL_CALL = "tool_call"
30
+ OTHER = "other"
31
+ ANSWER = "answer"
32
+ DONE = "done"
33
+
34
+
35
+ class SSEToolHandler:
36
+ """SSE 工具调用处理器"""
37
+
38
+ def __init__(self, model: str, stream: bool = True):
39
+ self.model = model
40
+ self.stream = stream
41
+
42
+ # 状态管理
43
+ self.current_phase = None
44
+ self.has_tool_call = False
45
+
46
+ # 工具调用状态
47
+ self.tool_id = ""
48
+ self.tool_name = ""
49
+ self.tool_args = ""
50
+ self.tool_call_usage = {}
51
+ self.content_index = 0 # 工具调用索引
52
+
53
+ # 性能优化:内容缓冲
54
+ self.content_buffer = ""
55
+ self.buffer_size = 0
56
+ self.last_flush_time = time.time()
57
+ self.flush_interval = 0.05 # 50ms 刷新间隔
58
+ self.max_buffer_size = 100 # 最大缓冲字符数
59
+
60
+ logger.debug(f"🔧 初始化工具处理器: model={model}, stream={stream}")
61
+
62
+ def process_sse_chunk(self, chunk_data: Dict[str, Any]) -> Generator[str, None, None]:
63
+ """
64
+ 处理 SSE 数据块,返回 OpenAI 格式的流式响应
65
+
66
+ Args:
67
+ chunk_data: Z.AI SSE 数据块
68
+
69
+ Yields:
70
+ str: OpenAI 格式的 SSE 响应行
71
+ """
72
+ try:
73
+ phase = chunk_data.get("phase")
74
+ edit_content = chunk_data.get("edit_content", "")
75
+ delta_content = chunk_data.get("delta_content", "")
76
+ edit_index = chunk_data.get("edit_index")
77
+ usage = chunk_data.get("usage", {})
78
+
79
+ # 数据验证
80
+ if not phase:
81
+ logger.warning("⚠️ 收到无效的 SSE 块:缺少 phase 字段")
82
+ return
83
+
84
+ # 阶段变化检测和日志
85
+ if phase != self.current_phase:
86
+ # 阶段变化时强制刷新缓冲区
87
+ if hasattr(self, 'content_buffer') and self.content_buffer:
88
+ yield from self._flush_content_buffer()
89
+
90
+ logger.info(f"📈 SSE 阶段变化: {self.current_phase} → {phase}")
91
+ content_preview = edit_content or delta_content
92
+ if content_preview:
93
+ logger.debug(f" 📝 内容预览: {content_preview[:1000]}{'...' if len(content_preview) > 1000 else ''}")
94
+ if edit_index is not None:
95
+ logger.debug(f" 📍 edit_index: {edit_index}")
96
+ self.current_phase = phase
97
+
98
+ # 根据阶段处理
99
+ if phase == SSEPhase.THINKING.value:
100
+ yield from self._process_thinking_phase(delta_content)
101
+
102
+ elif phase == SSEPhase.TOOL_CALL.value:
103
+ yield from self._process_tool_call_phase(edit_content)
104
+
105
+ elif phase == SSEPhase.OTHER.value:
106
+ yield from self._process_other_phase(usage, edit_content)
107
+
108
+ elif phase == SSEPhase.ANSWER.value:
109
+ yield from self._process_answer_phase(delta_content)
110
+
111
+ elif phase == SSEPhase.DONE.value:
112
+ yield from self._process_done_phase(chunk_data)
113
+ else:
114
+ logger.warning(f"⚠️ 未知的 SSE 阶段: {phase}")
115
+
116
+ except Exception as e:
117
+ logger.error(f"❌ 处理 SSE 块时发生错误: {e}")
118
+ logger.debug(f" 📦 错误块数据: {chunk_data}")
119
+ # 不中断流,继续处理后续块
120
+
121
+ def _process_thinking_phase(self, delta_content: str) -> Generator[str, None, None]:
122
+ """处理思考阶段"""
123
+ if not delta_content:
124
+ return
125
+
126
+ logger.debug(f"🤔 思考内容: +{len(delta_content)} 字符")
127
+
128
+ # 在流模式下输出思考内容
129
+ if self.stream:
130
+ chunk = self._create_content_chunk(delta_content)
131
+ yield f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n"
132
+
133
+ def _process_tool_call_phase(self, edit_content: str) -> Generator[str, None, None]:
134
+ """处理工具调用阶段"""
135
+ if not edit_content:
136
+ return
137
+
138
+ logger.debug(f"🔧 进入工具调用阶段,内容长度: {len(edit_content)}")
139
+
140
+ # 检测 glm_block 标记
141
+ if "<glm_block " in edit_content:
142
+ yield from self._handle_glm_blocks(edit_content)
143
+ else:
144
+ # 没有 glm_block 标记,可能是参数补充
145
+ if self.has_tool_call:
146
+ # 只累积���数部分,找到第一个 ", "result"" 之前的内容
147
+ result_pos = edit_content.find('", "result"')
148
+ if result_pos > 0:
149
+ param_fragment = edit_content[:result_pos]
150
+ self.tool_args += param_fragment
151
+ logger.debug(f"📦 累积参数片段: {param_fragment}")
152
+ else:
153
+ # 如果没有找到结束标记,累积整个内容(可能是中间片段)
154
+ self.tool_args += edit_content
155
+ logger.debug(f"📦 累积参数片段: {edit_content[:100]}...")
156
+
157
+ def _handle_glm_blocks(self, edit_content: str) -> Generator[str, None, None]:
158
+ """处理 glm_block 标记的内容"""
159
+ blocks = edit_content.split('<glm_block ')
160
+ logger.debug(f"📦 分割得到 {len(blocks)} 个块")
161
+
162
+ for index, block in enumerate(blocks):
163
+ if not block.strip():
164
+ continue
165
+
166
+ if index == 0:
167
+ # 第一个块:提取参数片段
168
+ if self.has_tool_call:
169
+ logger.debug(f"📦 从第一个块提取参数片段")
170
+ # 找到 "result" 的位置,提取之前的参数片段
171
+ result_pos = edit_content.find('"result"')
172
+ if result_pos > 0:
173
+ # 往前退3个字符去掉 ", "
174
+ param_fragment = edit_content[:result_pos - 3]
175
+ self.tool_args += param_fragment
176
+ logger.debug(f"📦 累积参数片段: {param_fragment}")
177
+ else:
178
+ # 没有活跃工具调用,跳过第一个块
179
+ continue
180
+ else:
181
+ # 后续块:处理新工具调用
182
+ if "</glm_block>" not in block:
183
+ continue
184
+
185
+ # 如果有活跃的工具调用,先完成它
186
+ if self.has_tool_call:
187
+ # 补全参数并完成工具调用
188
+ self.tool_args += '"' # 补全最后的引号
189
+ yield from self._finish_current_tool()
190
+
191
+ # 处理新工具调用
192
+ yield from self._process_metadata_block(block)
193
+
194
+ def _process_metadata_block(self, block: str) -> Generator[str, None, None]:
195
+ """处理包含工具元数据的块"""
196
+ try:
197
+ # 提取 JSON 内容
198
+ start_pos = block.find('>')
199
+ end_pos = block.rfind('</glm_block>')
200
+
201
+ if start_pos == -1 or end_pos == -1:
202
+ logger.warning(f"❌ 无法找到 JSON 内容边界: {block[:1000]}...")
203
+ return
204
+
205
+ json_content = block[start_pos + 1:end_pos]
206
+ logger.debug(f"📦 提取的 JSON 内容: {json_content[:1000]}...")
207
+
208
+ # 解析工具元数据
209
+ metadata_obj = json.loads(json_content)
210
+
211
+ if "data" in metadata_obj and "metadata" in metadata_obj["data"]:
212
+ metadata = metadata_obj["data"]["metadata"]
213
+
214
+ # 开始新的工具调用
215
+ self.tool_id = metadata.get("id", f"call_{int(time.time() * 1000000)}")
216
+ self.tool_name = metadata.get("name", "unknown")
217
+ self.has_tool_call = True
218
+
219
+ # 只有在这是第二个及以后的工具调用时才递增 index
220
+ # 第一个工具调用应该使用 index 0
221
+
222
+ # 从 metadata.arguments 获取参数起始部分
223
+ if "arguments" in metadata:
224
+ arguments_str = metadata["arguments"]
225
+ # 去掉最后一个字符
226
+ self.tool_args = arguments_str[:-1] if arguments_str.endswith('"') else arguments_str
227
+ logger.debug(f"🎯 新工具调用: {self.tool_name}(id={self.tool_id}), 初始参数: {self.tool_args}")
228
+ else:
229
+ self.tool_args = "{}"
230
+ logger.debug(f"🎯 新工具调用: {self.tool_name}(id={self.tool_id}), 空参数")
231
+
232
+ except (json.JSONDecodeError, KeyError, AttributeError) as e:
233
+ logger.error(f"❌ 解析工具元数据失败: {e}, 块内容: {block[:1000]}...")
234
+
235
+ # 确保返回生成器(即使为空)
236
+ if False: # 永远不会执行,但确保函数是生成器
237
+ yield
238
+
239
+ def _process_other_phase(self, usage: Dict[str, Any], edit_content: str = "") -> Generator[str, None, None]:
240
+ """处理其他阶段"""
241
+ # 保存使用统计信息
242
+ if usage:
243
+ self.tool_call_usage = usage
244
+ logger.debug(f"📊 保存使用统计: {usage}")
245
+
246
+ # 工具调用完成判断:检测到 "null," 开头的 edit_content
247
+ if self.has_tool_call and edit_content and edit_content.startswith("null,"):
248
+ logger.info(f"🏁 检测到工具调用结束标记")
249
+
250
+ # 完成当前工具调用
251
+ yield from self._finish_current_tool()
252
+
253
+ # 发��流结束标记
254
+ if self.stream:
255
+ yield "data: [DONE]\n\n"
256
+
257
+ # 重置状态
258
+ self._reset_all_state()
259
+
260
+ def _process_answer_phase(self, delta_content: str) -> Generator[str, None, None]:
261
+ """处理回答阶段(优化版本)"""
262
+ if not delta_content:
263
+ return
264
+
265
+ logger.info(f"📝 工具处理器收到答案内容: {delta_content[:50]}...")
266
+
267
+ # 添加到缓冲区
268
+ self.content_buffer += delta_content
269
+ self.buffer_size += len(delta_content)
270
+
271
+ current_time = time.time()
272
+ time_since_last_flush = current_time - self.last_flush_time
273
+
274
+ # 检查是否需要刷新缓冲区
275
+ should_flush = (
276
+ self.buffer_size >= self.max_buffer_size or # 缓冲区满了
277
+ time_since_last_flush >= self.flush_interval or # 时间间隔到了
278
+ '\n' in delta_content or # 包含换行符
279
+ '。' in delta_content or '!' in delta_content or '?' in delta_content # 包含句子结束符
280
+ )
281
+
282
+ if should_flush and self.content_buffer:
283
+ yield from self._flush_content_buffer()
284
+
285
+ def _flush_content_buffer(self) -> Generator[str, None, None]:
286
+ """刷新内容缓冲区"""
287
+ if not self.content_buffer:
288
+ return
289
+
290
+ logger.info(f"💬 工具处理器刷新缓冲区: {self.buffer_size} 字符 - {self.content_buffer[:50]}...")
291
+
292
+ if self.stream:
293
+ chunk = self._create_content_chunk(self.content_buffer)
294
+ output_data = f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n"
295
+ logger.info(f"➡️ 工具处理器输出: {output_data[:100]}...")
296
+ yield output_data
297
+
298
+ # 清空缓冲区
299
+ self.content_buffer = ""
300
+ self.buffer_size = 0
301
+ self.last_flush_time = time.time()
302
+
303
+ def _process_done_phase(self, chunk_data: Dict[str, Any]) -> Generator[str, None, None]:
304
+ """处理完成阶段"""
305
+ logger.info("🏁 对话完成")
306
+
307
+ # 先刷新任何剩余的缓冲内容
308
+ if self.content_buffer:
309
+ yield from self._flush_content_buffer()
310
+
311
+ # 完成任何未完成的工具调用
312
+ if self.has_tool_call:
313
+ yield from self._finish_current_tool()
314
+
315
+ # 发送流结束标记
316
+ if self.stream:
317
+ # 创建最终的完成块
318
+ final_chunk = {
319
+ "id": f"chatcmpl-{int(time.time())}",
320
+ "object": "chat.completion.chunk",
321
+ "created": int(time.time()),
322
+ "model": self.model,
323
+ "choices": [{
324
+ "index": 0,
325
+ "delta": {},
326
+ "finish_reason": "stop"
327
+ }]
328
+ }
329
+
330
+ # 如果有 usage 信息,添加到最终块中
331
+ if "usage" in chunk_data:
332
+ final_chunk["usage"] = chunk_data["usage"]
333
+
334
+ yield f"data: {json.dumps(final_chunk, ensure_ascii=False)}\n\n"
335
+ yield "data: [DONE]\n\n"
336
+
337
+ # 重置所有状态
338
+ self._reset_all_state()
339
+
340
+ def _finish_current_tool(self) -> Generator[str, None, None]:
341
+ """完成当前工具调用"""
342
+ if not self.has_tool_call:
343
+ return
344
+
345
+ # 修复参数格式
346
+ fixed_args = self._fix_tool_arguments(self.tool_args)
347
+ logger.debug(f"✅ 完成工具调用: {self.tool_name}, 参数: {fixed_args}")
348
+
349
+ # 输出工具调用(开始 + 参数 + 完成)
350
+ if self.stream:
351
+ # 发送工具开始块
352
+ start_chunk = self._create_tool_start_chunk()
353
+ yield f"data: {json.dumps(start_chunk, ensure_ascii=False)}\n\n"
354
+
355
+ # 发送参数块
356
+ args_chunk = self._create_tool_arguments_chunk(fixed_args)
357
+ yield f"data: {json.dumps(args_chunk, ensure_ascii=False)}\n\n"
358
+
359
+ # 发送完成块
360
+ finish_chunk = self._create_tool_finish_chunk()
361
+ yield f"data: {json.dumps(finish_chunk, ensure_ascii=False)}\n\n"
362
+
363
+ # 重置工具状态
364
+ self._reset_tool_state()
365
+
366
+ def _fix_tool_arguments(self, raw_args: str) -> str:
367
+ """使用 json-repair 库修复工具参数格式"""
368
+ if not raw_args or raw_args == "{}":
369
+ return "{}"
370
+
371
+ logger.debug(f"🔧 开始修复参数: {raw_args[:1000]}{'...' if len(raw_args) > 1000 else ''}")
372
+
373
+ # 统一的修复流程:预处理 -> json-repair -> 后处理
374
+ try:
375
+ # 1. 预处理:只处理 json-repair 无法处理的问题
376
+ processed_args = self._preprocess_json_string(raw_args.strip())
377
+
378
+ # 2. 使用 json-repair 进行主要修复
379
+ from json_repair import repair_json
380
+ repaired_json = repair_json(processed_args)
381
+ logger.debug(f"🔧 json-repair 修复结果: {repaired_json}")
382
+
383
+ # 3. 解析并后处理
384
+ args_obj = json.loads(repaired_json)
385
+ args_obj = self._post_process_args(args_obj)
386
+
387
+ # 4. 生成最终结果
388
+ fixed_result = json.dumps(args_obj, ensure_ascii=False)
389
+
390
+ return fixed_result
391
+
392
+ except Exception as e:
393
+ logger.error(f"❌ JSON 修复失败: {e}, 原始参数: {raw_args[:1000]}..., 使用空参数")
394
+ return "{}"
395
+
396
+ def _post_process_args(self, args_obj: Dict[str, Any]) -> Dict[str, Any]:
397
+ """统一的后处理方法"""
398
+ # 修复路径中的过度转义
399
+ args_obj = self._fix_path_escaping_in_args(args_obj)
400
+
401
+ # 修复命令中的多余引号
402
+ args_obj = self._fix_command_quotes(args_obj)
403
+
404
+ return args_obj
405
+
406
+ def _preprocess_json_string(self, text: str) -> str:
407
+ """预处理 JSON 字符串,只处理 json-repair 无法处理的问题"""
408
+ import re
409
+
410
+ # 只保留 json-repair 无法处理的预处理步骤
411
+
412
+ # 1. 修复缺少开始括号的情况(json-repair 无法处理)
413
+ if not text.startswith('{') and text.endswith('}'):
414
+ text = '{' + text
415
+ logger.debug(f"🔧 补全开始括号")
416
+
417
+ # 2. 修复末尾多余的反斜杠和引号(json-repair 可能处理不当)
418
+ # 匹配模式:字符串值末尾的 \" 后面跟着 } 或 ,
419
+ # 例如:{"url":"https://www.bilibili.com\"} -> {"url":"https://www.bilibili.com"}
420
+ # 例如:{"url":"https://www.bilibili.com\",} -> {"url":"https://www.bilibili.com",}
421
+ pattern = r'([^\\])\\"([}\s,])'
422
+ if re.search(pattern, text):
423
+ text = re.sub(pattern, r'\1"\2', text)
424
+ logger.debug(f"🔧 修复末尾多余的反斜杠")
425
+
426
+ return text
427
+
428
+ def _fix_path_escaping_in_args(self, args_obj: Dict[str, Any]) -> Dict[str, Any]:
429
+ """修复参数对象中路径的过度转义问题"""
430
+ import re
431
+
432
+ # 需要检查的路径字段
433
+ path_fields = ['file_path', 'path', 'directory', 'folder']
434
+
435
+ for field in path_fields:
436
+ if field in args_obj and isinstance(args_obj[field], str):
437
+ path_value = args_obj[field]
438
+
439
+ # 检查是否是Windows路径且包含过度转义
440
+ if path_value.startswith('C:') and '\\\\' in path_value:
441
+ logger.debug(f"🔍 检查路径字段 {field}: {repr(path_value)}")
442
+
443
+ # 分析路径结构:正常路径应该是 C:\Users\...
444
+ # 但过度转义的路径可能是 C:\Users\\Documents(多了一个反斜杠)
445
+ # 我们需要找到不正常的双反斜杠模式并修复
446
+
447
+ # 先检查是否有不正常的双反斜杠(不在路径开头)
448
+ # 正常:C:\Users\Documents
449
+ # 异常:C:\Users\\Documents 或 C:\Users\\\\Documents
450
+
451
+ # 使用更精确的模式:匹配路径分隔符后的额外反斜杠
452
+ # 但要保留正常的路径分隔符
453
+ fixed_path = path_value
454
+
455
+ # 检查是否有连续的多个反斜杠(超过正常的路径分隔符)
456
+ if '\\\\' in path_value:
457
+ # 计算反斜杠的数量,如果超过正常数量就修复
458
+ parts = path_value.split('\\')
459
+ # 重新组装路径,去除空的部分(由多余的反斜杠造成)
460
+ clean_parts = [part for part in parts if part]
461
+ if len(clean_parts) > 1:
462
+ fixed_path = '\\'.join(clean_parts)
463
+
464
+ logger.debug(f"🔍 修复后路径: {repr(fixed_path)}")
465
+
466
+ if fixed_path != path_value:
467
+ args_obj[field] = fixed_path
468
+ logger.debug(f"🔧 修复字段 {field} 的路径转义: {path_value} -> {fixed_path}")
469
+ else:
470
+ logger.debug(f"🔍 路径无需修复: {path_value}")
471
+
472
+ return args_obj
473
+
474
+ def _fix_command_quotes(self, args_obj: Dict[str, Any]) -> Dict[str, Any]:
475
+ """修复命令中的多余引号问题"""
476
+ import re
477
+
478
+ # 检查命令字段
479
+ if 'command' in args_obj and isinstance(args_obj['command'], str):
480
+ command = args_obj['command']
481
+
482
+ # 检查是否以双引号结尾(多余的引号)
483
+ if command.endswith('""'):
484
+ logger.debug(f"🔧 发现命令末尾多余引号: {command}")
485
+ # 移除最后一个多余的引号
486
+ fixed_command = command[:-1]
487
+ args_obj['command'] = fixed_command
488
+ logger.debug(f"🔧 修复命令引号: {command} -> {fixed_command}")
489
+
490
+ # 检查其他可能的引号问题
491
+ # 例如:路径末尾的 \"" 模式
492
+ elif re.search(r'\\""+$', command):
493
+ logger.debug(f"🔧 发现命令末尾引号模式问题: {command}")
494
+ # 修复路径末尾的引号问题
495
+ fixed_command = re.sub(r'\\""+$', '\\"', command)
496
+ args_obj['command'] = fixed_command
497
+ logger.debug(f"🔧 修复命令引号模式: {command} -> {fixed_command}")
498
+
499
+ return args_obj
500
+
501
+ def _create_content_chunk(self, content: str) -> Dict[str, Any]:
502
+ """创建内容块"""
503
+ return {
504
+ "id": f"chatcmpl-{int(time.time())}",
505
+ "object": "chat.completion.chunk",
506
+ "created": int(time.time()),
507
+ "model": self.model,
508
+ "choices": [{
509
+ "index": 0,
510
+ "delta": {
511
+ "role": "assistant",
512
+ "content": content
513
+ },
514
+ "finish_reason": None
515
+ }]
516
+ }
517
+
518
+ def _create_tool_start_chunk(self) -> Dict[str, Any]:
519
+ """创建工具开始块"""
520
+ return {
521
+ "id": f"chatcmpl-{int(time.time())}",
522
+ "object": "chat.completion.chunk",
523
+ "created": int(time.time()),
524
+ "model": self.model,
525
+ "choices": [{
526
+ "index": 0,
527
+ "delta": {
528
+ "role": "assistant",
529
+ "tool_calls": [{
530
+ "index": self.content_index,
531
+ "id": self.tool_id,
532
+ "type": "function",
533
+ "function": {
534
+ "name": self.tool_name,
535
+ "arguments": ""
536
+ }
537
+ }]
538
+ },
539
+ "finish_reason": None
540
+ }]
541
+ }
542
+
543
+ def _create_tool_arguments_chunk(self, arguments: str) -> Dict[str, Any]:
544
+ """创建工具参数块"""
545
+ return {
546
+ "id": f"chatcmpl-{int(time.time())}",
547
+ "object": "chat.completion.chunk",
548
+ "created": int(time.time()),
549
+ "model": self.model,
550
+ "choices": [{
551
+ "index": 0,
552
+ "delta": {
553
+ "tool_calls": [{
554
+ "index": self.content_index,
555
+ "id": self.tool_id,
556
+ "function": {
557
+ "arguments": arguments
558
+ }
559
+ }]
560
+ },
561
+ "finish_reason": None
562
+ }]
563
+ }
564
+
565
+ def _create_tool_finish_chunk(self) -> Dict[str, Any]:
566
+ """创建工具完成块"""
567
+ chunk = {
568
+ "id": f"chatcmpl-{int(time.time())}",
569
+ "object": "chat.completion.chunk",
570
+ "created": int(time.time()),
571
+ "model": self.model,
572
+ "choices": [{
573
+ "index": 0,
574
+ "delta": {
575
+ "tool_calls": []
576
+ },
577
+ "finish_reason": "tool_calls"
578
+ }]
579
+ }
580
+
581
+ # 添加使用统计(如果有)
582
+ if self.tool_call_usage:
583
+ chunk["usage"] = self.tool_call_usage
584
+
585
+ return chunk
586
+
587
+ def _reset_tool_state(self):
588
+ """重置工具状态"""
589
+ self.tool_id = ""
590
+ self.tool_name = ""
591
+ self.tool_args = ""
592
+ self.has_tool_call = False
593
+ # content_index 在单次对话中应该保持不变,只有在新的工具调用开始时才递增
594
+
595
+ def _reset_all_state(self):
596
+ """重置所有状态"""
597
+ # 先刷新任何剩余的缓冲内容
598
+ if hasattr(self, 'content_buffer') and self.content_buffer:
599
+ list(self._flush_content_buffer()) # 消费生成器
600
+
601
+ self._reset_tool_state()
602
+ self.current_phase = None
603
+ self.tool_call_usage = {}
604
+
605
+ # 重置缓冲区
606
+ self.content_buffer = ""
607
+ self.buffer_size = 0
608
+ self.last_flush_time = time.time()
609
+
610
+ # content_index 重置为 0,为下一轮对话做准备
611
+ self.content_index = 0
612
+ logger.debug("🔄 重置所有处理器状态")
app/utils/token_pool.py ADDED
@@ -0,0 +1,598 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """
5
+ Token 池管理器 - 基于数据库的 Token 轮询和健康检查
6
+
7
+ 核心功能:
8
+ 1. Token 轮询机制 - 负载均衡和容错
9
+ 2. Z.AI 官方认证接口验证 - 基于 role 字段区分用户类型
10
+ 3. Token 健康度监控 - 自动禁用失败 Token
11
+ 4. 数据库集成 - 与 TokenDAO 协同工作
12
+ """
13
+
14
+ import asyncio
15
+ import time
16
+ from typing import Dict, List, Optional, Tuple
17
+ from dataclasses import dataclass, field
18
+ from threading import Lock
19
+ import httpx
20
+
21
+ from app.utils.logger import logger
22
+
23
+
24
+ # ==================== Token 状态管理 ====================
25
+
26
+
27
+ @dataclass
28
+ class TokenStatus:
29
+ """Token 运行时状态(内存中)"""
30
+ token: str
31
+ token_id: int # 数据库 ID,用于同步统计
32
+ token_type: str = "unknown" # "user", "guest", "unknown"
33
+ is_available: bool = True
34
+ failure_count: int = 0
35
+ last_failure_time: float = 0.0
36
+ last_success_time: float = 0.0
37
+ total_requests: int = 0
38
+ successful_requests: int = 0
39
+
40
+ @property
41
+ def success_rate(self) -> float:
42
+ """成功率"""
43
+ if self.total_requests == 0:
44
+ return 1.0
45
+ return self.successful_requests / self.total_requests
46
+
47
+ @property
48
+ def is_healthy(self) -> bool:
49
+ """
50
+ Token 健康状态判断
51
+
52
+ 健康标准:
53
+ 1. 必须是认证用户 Token (token_type = "user")
54
+ 2. 当前可用 (is_available = True)
55
+ 3. 成功率 >= 50% 或总请求数 <= 3(新 Token 容错)
56
+
57
+ 注意:
58
+ - guest Token 永远不健康
59
+ - unknown Token 永远不健康
60
+ """
61
+ # guest 和 unknown token 永远不健康
62
+ if self.token_type != "user":
63
+ return False
64
+
65
+ # 不可用的 token 不健康
66
+ if not self.is_available:
67
+ return False
68
+
69
+ # 新 token 容错:请求数很少时,只要没失败就健康
70
+ if self.total_requests <= 3:
71
+ return self.failure_count == 0
72
+
73
+ # 基于成功率判断
74
+ return self.success_rate >= 0.5
75
+
76
+
77
+ # ==================== Token 验证服务 ====================
78
+
79
+
80
+ class ZAITokenValidator:
81
+ """Z.AI Token 验证器(使用官方认证接口)"""
82
+
83
+ AUTH_URL = "https://chat.z.ai/api/v1/auths/"
84
+
85
+ @staticmethod
86
+ def get_headers(token: str) -> Dict[str, str]:
87
+ """构建认证请求头"""
88
+ return {
89
+ "Accept": "*/*",
90
+ "Accept-Language": "zh-CN,zh;q=0.9",
91
+ "Authorization": f"Bearer {token}",
92
+ "Connection": "keep-alive",
93
+ "Content-Type": "application/json",
94
+ "DNT": "1",
95
+ "Referer": "https://chat.z.ai/",
96
+ "Sec-Fetch-Dest": "empty",
97
+ "Sec-Fetch-Mode": "cors",
98
+ "Sec-Fetch-Site": "same-origin",
99
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36",
100
+ "sec-ch-ua": '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"',
101
+ "sec-ch-ua-mobile": "?0",
102
+ "sec-ch-ua-platform": '"Windows"'
103
+ }
104
+
105
+ @classmethod
106
+ async def validate_token(cls, token: str) -> Tuple[str, bool, Optional[str]]:
107
+ """
108
+ 验证 Token 有效性并返回类型
109
+
110
+ Args:
111
+ token: 待验证的 Token
112
+
113
+ Returns:
114
+ (token_type, is_valid, error_message)
115
+ - token_type: "user" | "guest" | "unknown"
116
+ - is_valid: True 表示是有效的认证用户 Token
117
+ - error_message: 失败原因(仅在 is_valid=False 时有值)
118
+ """
119
+ try:
120
+ async with httpx.AsyncClient(timeout=15.0) as client:
121
+ response = await client.get(
122
+ cls.AUTH_URL,
123
+ headers=cls.get_headers(token)
124
+ )
125
+
126
+ # 解析响应
127
+ return cls._parse_auth_response(response)
128
+
129
+ except httpx.TimeoutException:
130
+ return ("unknown", False, "请求超时")
131
+ except httpx.ConnectError:
132
+ return ("unknown", False, "连接失败")
133
+ except Exception as e:
134
+ return ("unknown", False, f"验证异常: {str(e)}")
135
+
136
+ @staticmethod
137
+ def _parse_auth_response(response: httpx.Response) -> Tuple[str, bool, Optional[str]]:
138
+ """
139
+ 解析 Z.AI 认证接口响应
140
+
141
+ 响应格式示例:
142
+ {
143
+ "id": "...",
144
+ "email": "[email protected]",
145
+ "role": "user" # 或 "guest"
146
+ }
147
+
148
+ 验证规则:
149
+ - role: "user" → 认证用户 Token(有效,可添加)
150
+ - role: "guest" → 匿名用户 Token(无效,拒绝添加)
151
+ - 其他情况 → 无效 Token
152
+ """
153
+ # 检查 HTTP 状态码
154
+ if response.status_code != 200:
155
+ return ("unknown", False, f"HTTP {response.status_code}")
156
+
157
+ try:
158
+ data = response.json()
159
+
160
+ # 验证响应格式
161
+ if not isinstance(data, dict):
162
+ return ("unknown", False, "无效的响应格式")
163
+
164
+ # 检查是否包含错误信息
165
+ if "error" in data or "message" in data:
166
+ error_msg = data.get("error") or data.get("message", "未知错误")
167
+ return ("unknown", False, str(error_msg))
168
+
169
+ # 核心验证:检查 role 字段
170
+ role = data.get("role")
171
+
172
+ if role == "user":
173
+ return ("user", True, None)
174
+ elif role == "guest":
175
+ return ("guest", False, "匿名用户 Token 不允许添加")
176
+ else:
177
+ return ("unknown", False, f"未知 role: {role}")
178
+
179
+ except (ValueError, Exception) as e:
180
+ return ("unknown", False, f"解析响应失败: {str(e)}")
181
+
182
+
183
+ # ==================== Token 池管理器 ====================
184
+
185
+
186
+ class TokenPool:
187
+ """Token 池管理器(数据库驱动)"""
188
+
189
+ def __init__(
190
+ self,
191
+ tokens: List[Tuple[int, str, str]], # [(token_id, token_value, token_type), ...]
192
+ failure_threshold: int = 3,
193
+ recovery_timeout: int = 1800
194
+ ):
195
+ """
196
+ 初始化 Token 池
197
+
198
+ Args:
199
+ tokens: Token 列表 [(token_id, token_value, token_type), ...]
200
+ failure_threshold: 失败阈值,超过此次数将标记为不可用
201
+ recovery_timeout: 恢复超时时间(秒),失败 Token 在此时间后重新尝试
202
+ """
203
+ self.failure_threshold = failure_threshold
204
+ self.recovery_timeout = recovery_timeout
205
+ self._lock = Lock()
206
+ self._current_index = 0
207
+
208
+ # 初始化 Token 状态(内存中)
209
+ self.token_statuses: Dict[str, TokenStatus] = {}
210
+ self.token_id_map: Dict[str, int] = {} # token -> token_id 映射
211
+
212
+ for token_id, token_value, token_type in tokens:
213
+ if token_value and token_value not in self.token_statuses:
214
+ self.token_statuses[token_value] = TokenStatus(
215
+ token=token_value,
216
+ token_id=token_id,
217
+ token_type=token_type
218
+ )
219
+ self.token_id_map[token_value] = token_id
220
+
221
+ if not self.token_statuses:
222
+ logger.warning("⚠️ Token 池为空,将依赖匿名模式")
223
+
224
+ def get_next_token(self) -> Optional[str]:
225
+ """
226
+ 获取下一个可用的认证用户 Token(轮询算法)
227
+
228
+ Returns:
229
+ 可用的 Token 字符串,如果没有可用 Token 则返回 None
230
+ """
231
+ with self._lock:
232
+ if not self.token_statuses:
233
+ return None
234
+
235
+ available_tokens = self._get_available_user_tokens()
236
+ if not available_tokens:
237
+ # 尝试恢复过期的失败 Token
238
+ self._try_recover_failed_tokens()
239
+ available_tokens = self._get_available_user_tokens()
240
+
241
+ if not available_tokens:
242
+ logger.warning("⚠️ 没有可用的认证用户 Token")
243
+ return None
244
+
245
+ # 轮询选择
246
+ token = available_tokens[self._current_index % len(available_tokens)]
247
+ self._current_index = (self._current_index + 1) % len(available_tokens)
248
+
249
+ return token
250
+
251
+ def _get_available_user_tokens(self) -> List[str]:
252
+ """
253
+ 获取当前可用的认证用户 Token 列表
254
+
255
+ 过滤条件:
256
+ 1. is_available = True
257
+ 2. token_type == "user"
258
+ """
259
+ available_user_tokens = [
260
+ status.token for status in self.token_statuses.values()
261
+ if status.is_available and status.token_type == "user"
262
+ ]
263
+
264
+ # 警告:如果有 guest token 但没有 user token
265
+ if not available_user_tokens and self.token_statuses:
266
+ guest_count = sum(
267
+ 1 for status in self.token_statuses.values()
268
+ if status.token_type == "guest"
269
+ )
270
+ if guest_count > 0:
271
+ logger.warning(f"⚠️ 检测到 {guest_count} 个匿名用户 Token,轮询机制将跳过这些 Token")
272
+
273
+ return available_user_tokens
274
+
275
+ def _try_recover_failed_tokens(self):
276
+ """尝试恢复失败的 Token(仅针对认证用户 Token)"""
277
+ current_time = time.time()
278
+ recovered_count = 0
279
+
280
+ for status in self.token_statuses.values():
281
+ # 只恢复认证用户 Token
282
+ if (
283
+ status.token_type == "user"
284
+ and not status.is_available
285
+ and current_time - status.last_failure_time > self.recovery_timeout
286
+ ):
287
+ status.is_available = True
288
+ status.failure_count = 0
289
+ recovered_count += 1
290
+ logger.info(f"🔄 恢复失败 Token: {status.token[:20]}...")
291
+
292
+ if recovered_count > 0:
293
+ logger.info(f"✅ 恢复了 {recovered_count} 个失败的 Token")
294
+
295
+ def mark_token_success(self, token: str):
296
+ """标记 Token 使用成功"""
297
+ with self._lock:
298
+ if token in self.token_statuses:
299
+ status = self.token_statuses[token]
300
+ status.total_requests += 1
301
+ status.successful_requests += 1
302
+ status.last_success_time = time.time()
303
+ status.failure_count = 0 # 重置失败计数
304
+
305
+ if not status.is_available:
306
+ status.is_available = True
307
+ logger.info(f"✅ Token 恢复可用: {token[:20]}...")
308
+
309
+ def mark_token_failure(self, token: str, error: Exception = None):
310
+ """标记 Token 使用失败"""
311
+ with self._lock:
312
+ if token in self.token_statuses:
313
+ status = self.token_statuses[token]
314
+ status.total_requests += 1
315
+ status.failure_count += 1
316
+ status.last_failure_time = time.time()
317
+
318
+ if status.failure_count >= self.failure_threshold:
319
+ status.is_available = False
320
+ logger.warning(f"🚫 Token 已禁用: {token[:20]}... (失败 {status.failure_count} 次)")
321
+
322
+ def get_token_id(self, token: str) -> Optional[int]:
323
+ """获取 Token 的数据库 ID"""
324
+ return self.token_id_map.get(token)
325
+
326
+ def get_pool_status(self) -> Dict:
327
+ """获取 Token 池状态信息"""
328
+ with self._lock:
329
+ available_count = len(self._get_available_user_tokens())
330
+ total_count = len(self.token_statuses)
331
+ healthy_count = sum(1 for status in self.token_statuses.values() if status.is_healthy)
332
+
333
+ # 统计各类型 Token
334
+ user_count = sum(1 for s in self.token_statuses.values() if s.token_type == "user")
335
+ guest_count = sum(1 for s in self.token_statuses.values() if s.token_type == "guest")
336
+ unknown_count = sum(1 for s in self.token_statuses.values() if s.token_type == "unknown")
337
+
338
+ status_info = {
339
+ "total_tokens": total_count,
340
+ "available_tokens": available_count,
341
+ "unavailable_tokens": total_count - available_count,
342
+ "healthy_tokens": healthy_count,
343
+ "unhealthy_tokens": total_count - healthy_count,
344
+ "user_tokens": user_count,
345
+ "guest_tokens": guest_count,
346
+ "unknown_tokens": unknown_count,
347
+ "current_index": self._current_index,
348
+ "tokens": []
349
+ }
350
+
351
+ for token, status in self.token_statuses.items():
352
+ status_info["tokens"].append({
353
+ "token": f"{token[:10]}...{token[-10:]}",
354
+ "token_id": status.token_id,
355
+ "token_type": status.token_type,
356
+ "is_available": status.is_available,
357
+ "failure_count": status.failure_count,
358
+ "success_count": status.successful_requests,
359
+ "success_rate": f"{status.success_rate:.2%}",
360
+ "total_requests": status.total_requests,
361
+ "is_healthy": status.is_healthy,
362
+ "last_failure_time": status.last_failure_time,
363
+ "last_success_time": status.last_success_time
364
+ })
365
+
366
+ return status_info
367
+
368
+ def update_token_type(self, token: str, token_type: str):
369
+ """更新 Token 类型(用于健康检查后更新)"""
370
+ with self._lock:
371
+ if token in self.token_statuses:
372
+ old_type = self.token_statuses[token].token_type
373
+ self.token_statuses[token].token_type = token_type
374
+
375
+ if old_type != token_type:
376
+ logger.info(f"🔄 更新 Token 类型: {token[:20]}... {old_type} → {token_type}")
377
+
378
+ async def health_check_token(self, token: str) -> bool:
379
+ """
380
+ 异步健康检查单个 Token(使用 Z.AI 官方认证接口)
381
+
382
+ Args:
383
+ token: 要检查的 Token
384
+
385
+ Returns:
386
+ Token 是否健康(True = 有效的认证用户 Token)
387
+ """
388
+ token_type, is_valid, error_message = await ZAITokenValidator.validate_token(token)
389
+
390
+ # 更新 Token 类型
391
+ self.update_token_type(token, token_type)
392
+
393
+ # 更新状态
394
+ if is_valid:
395
+ self.mark_token_success(token)
396
+ else:
397
+ self.mark_token_failure(token, Exception(error_message or "验证失败"))
398
+
399
+ return is_valid
400
+
401
+ async def health_check_all(self):
402
+ """异步健康检查所有 Token"""
403
+ if not self.token_statuses:
404
+ logger.warning("⚠️ Token 池为空,跳过健康检查")
405
+ return
406
+
407
+ total_tokens = len(self.token_statuses)
408
+ logger.info(f"🔍 开始 Token 池健康检查... (共 {total_tokens} 个 Token)")
409
+
410
+ # 并发执行所有 Token 的健康检查
411
+ tasks = [
412
+ self.health_check_token(token)
413
+ for token in self.token_statuses.keys()
414
+ ]
415
+
416
+ results = await asyncio.gather(*tasks, return_exceptions=True)
417
+
418
+ # 统计结果
419
+ healthy_count = sum(1 for r in results if r is True)
420
+ failed_count = sum(1 for r in results if r is False)
421
+ exception_count = sum(1 for r in results if isinstance(r, Exception))
422
+
423
+ health_rate = (healthy_count / total_tokens) * 100 if total_tokens > 0 else 0
424
+
425
+ if healthy_count == 0 and total_tokens > 0:
426
+ logger.warning(f"⚠️ 健康检查完成: 0/{total_tokens} 个 Token 健康 - 请检查 Token 配置")
427
+ elif failed_count > 0:
428
+ logger.warning(f"⚠️ 健康检查完成: {healthy_count}/{total_tokens} 个 Token 健康 ({health_rate:.1f}%)")
429
+ else:
430
+ logger.info(f"✅ 健康检查完成: {healthy_count}/{total_tokens} 个 Token 健康")
431
+
432
+ if exception_count > 0:
433
+ logger.error(f"💥 {exception_count} 个 Token 检查异常")
434
+
435
+ async def sync_from_database(self, provider: str = "zai"):
436
+ """
437
+ 从数据库同步 Token 状态(禁用/启用状态)
438
+
439
+ Args:
440
+ provider: 提供商名称
441
+
442
+ 说明:
443
+ - 从数据库读取最新的 Token 启用状态
444
+ - 如果数据库中 Token 被禁用,则从池中移除
445
+ - 如果数据库中有新增的启用 Token,则添加到池中
446
+ - 保留现有 Token 的运行时统计(请求数、成功率等)
447
+ """
448
+ from app.services.token_dao import get_token_dao
449
+
450
+ dao = get_token_dao()
451
+
452
+ # 从数据库加载所有启用的认证用户 Token
453
+ token_records = await dao.get_tokens_by_provider(provider, enabled_only=True)
454
+
455
+ # 构建数据库中的 Token 映射
456
+ db_tokens = {
457
+ record["token"]: (record["id"], record.get("token_type", "unknown"))
458
+ for record in token_records
459
+ if record.get("token_type") != "guest" # 过滤 guest token
460
+ }
461
+
462
+ with self._lock:
463
+ # 1. 移除已在数据库中禁用的 Token
464
+ tokens_to_remove = []
465
+ for token_value in list(self.token_statuses.keys()):
466
+ if token_value not in db_tokens:
467
+ tokens_to_remove.append(token_value)
468
+
469
+ for token_value in tokens_to_remove:
470
+ del self.token_statuses[token_value]
471
+ del self.token_id_map[token_value]
472
+ logger.info(f"🗑️ 从池中移除已禁用 Token: {token_value[:20]}...")
473
+
474
+ # 2. 添加新启用的 Token
475
+ new_tokens_count = 0
476
+ for token_value, (token_id, token_type) in db_tokens.items():
477
+ if token_value not in self.token_statuses:
478
+ self.token_statuses[token_value] = TokenStatus(
479
+ token=token_value,
480
+ token_id=token_id,
481
+ token_type=token_type
482
+ )
483
+ self.token_id_map[token_value] = token_id
484
+ new_tokens_count += 1
485
+ logger.info(f"➕ 添加新启用 Token: {token_value[:20]}...")
486
+
487
+ # 3. 更新现有 Token 的类型(如果数据库中有更新)
488
+ for token_value, (token_id, token_type) in db_tokens.items():
489
+ if token_value in self.token_statuses:
490
+ old_type = self.token_statuses[token_value].token_type
491
+ if old_type != token_type:
492
+ self.token_statuses[token_value].token_type = token_type
493
+ logger.info(f"🔄 更新 Token 类型: {token_value[:20]}... {old_type} → {token_type}")
494
+
495
+ logger.info(
496
+ f"✅ Token 池同步完成: "
497
+ f"当前 {len(self.token_statuses)} 个 Token "
498
+ f"(移除 {len(tokens_to_remove)}, 新增 {new_tokens_count})"
499
+ )
500
+
501
+
502
+ # ==================== 全局实例管理 ====================
503
+
504
+
505
+ _token_pool: Optional[TokenPool] = None
506
+ _pool_lock = Lock()
507
+
508
+
509
+ def get_token_pool() -> Optional[TokenPool]:
510
+ """获取全局 Token 池实例"""
511
+ return _token_pool
512
+
513
+
514
+ async def initialize_token_pool_from_db(
515
+ provider: str = "zai",
516
+ failure_threshold: int = 3,
517
+ recovery_timeout: int = 1800
518
+ ) -> Optional[TokenPool]:
519
+ """
520
+ 从数据库初始化全局 Token 池
521
+
522
+ Args:
523
+ provider: 提供商名称 (zai, k2think, longcat)
524
+ failure_threshold: 失败阈值
525
+ recovery_timeout: 恢复超时时间(秒)
526
+
527
+ Returns:
528
+ TokenPool 实例(即使没有 Token 也会创建空池)
529
+ """
530
+ global _token_pool
531
+
532
+ from app.services.token_dao import get_token_dao
533
+
534
+ dao = get_token_dao()
535
+
536
+ # 从数据库加载 Token(只加载启用的认证用户 Token)
537
+ token_records = await dao.get_tokens_by_provider(provider, enabled_only=True)
538
+
539
+ # 转换为 TokenPool 所需格式
540
+ tokens = []
541
+ if token_records:
542
+ tokens = [
543
+ (record["id"], record["token"], record.get("token_type", "unknown"))
544
+ for record in token_records
545
+ ]
546
+
547
+ # 过滤掉 guest token(不应该在数据库中,但防御性检查)
548
+ user_tokens = [
549
+ (tid, tval, ttype) for tid, tval, ttype in tokens
550
+ if ttype != "guest"
551
+ ]
552
+
553
+ if len(user_tokens) < len(tokens):
554
+ guest_count = len(tokens) - len(user_tokens)
555
+ logger.warning(f"⚠️ 过滤了 {guest_count} 个匿名用户 Token")
556
+
557
+ tokens = user_tokens
558
+
559
+ # 始终创建 Token 池实例(即使为空)
560
+ with _pool_lock:
561
+ _token_pool = TokenPool(tokens, failure_threshold, recovery_timeout)
562
+
563
+ if not tokens:
564
+ logger.warning(f"⚠️ {provider} 没有有效的认证用户 Token,已创建空 Token 池")
565
+ else:
566
+ logger.info(f"🔧 从数据库初始化 Token 池({provider}),共 {len(tokens)} 个 Token")
567
+
568
+ return _token_pool
569
+
570
+
571
+ async def sync_token_stats_to_db():
572
+ """
573
+ 将内存中的 Token 统计同步到数据库
574
+
575
+ 应在服务关闭或定期调用,确保统计数据不丢失
576
+ """
577
+ pool = get_token_pool()
578
+ if not pool:
579
+ return
580
+
581
+ from app.services.token_dao import get_token_dao
582
+
583
+ dao = get_token_dao()
584
+
585
+ with pool._lock:
586
+ for token, status in pool.token_statuses.items():
587
+ token_id = status.token_id
588
+
589
+ # 更新数据库统计(简化版,实际可能需要增量更新)
590
+ if status.successful_requests > 0:
591
+ for _ in range(status.successful_requests):
592
+ await dao.record_success(token_id)
593
+
594
+ if status.total_requests - status.successful_requests > 0:
595
+ for _ in range(status.total_requests - status.successful_requests):
596
+ await dao.record_failure(token_id)
597
+
598
+ logger.info("✅ Token 统计已同步到数据库")
app/utils/tool_call_handler.py ADDED
@@ -0,0 +1,347 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ 工具调用处理模块
5
+ """
6
+
7
+ import json
8
+ import re
9
+ from typing import Dict, List, Any, Optional, Tuple
10
+ from app.utils.logger import get_logger
11
+
12
+ logger = get_logger()
13
+
14
+
15
+ def generate_tool_prompt(tools: Optional[List[Dict[str, Any]]]) -> str:
16
+ """
17
+ 生成工具调用提示词
18
+ 将 OpenAI tools 定义转换为 Markdown 格式的说明文档
19
+
20
+ Args:
21
+ tools: OpenAI 格式的工具定义列表
22
+
23
+ Returns:
24
+ str: Markdown 格式的工具使用说明
25
+ """
26
+ if not tools or len(tools) == 0:
27
+ return ""
28
+
29
+ tool_definitions = []
30
+
31
+ for tool in tools:
32
+ if tool.get("type") != "function":
33
+ continue
34
+
35
+ function_spec = tool.get("function", {})
36
+ function_name = function_spec.get("name", "unknown")
37
+ function_description = function_spec.get("description", "")
38
+ parameters = function_spec.get("parameters", {})
39
+
40
+ # 创建结构化的工具定义
41
+ tool_info = [
42
+ f"## {function_name}",
43
+ f"**Purpose**: {function_description}"
44
+ ]
45
+
46
+ # 添加参数详情
47
+ parameter_properties = parameters.get("properties", {})
48
+ required_parameters = set(parameters.get("required", []))
49
+
50
+ if parameter_properties:
51
+ tool_info.append("**Parameters**:")
52
+ for param_name, param_info in parameter_properties.items():
53
+ param_type = param_info.get("type", "string")
54
+ param_desc = param_info.get("description", "")
55
+ is_required = param_name in required_parameters
56
+ required_str = " (required)" if is_required else " (optional)"
57
+ tool_info.append(f"- `{param_name}` ({param_type}){required_str}: {param_desc}")
58
+
59
+ tool_definitions.append("\n".join(tool_info))
60
+
61
+ # 组合完整的提示词
62
+ prompt = (
63
+ "\n\n---\n"
64
+ "# Available Tools\n\n"
65
+ + "\n\n".join(tool_definitions) +
66
+ "\n\n"
67
+ "**Tool Invocation Format**:\n"
68
+ "To use a tool, include a JSON block with this structure:\n"
69
+ '{"tool_calls": [{"id": "call_ID", "type": "function", "function": {"name": "TOOL_NAME", "arguments": "JSON_STRING"}}]}\n\n'
70
+ "**Rules**:\n"
71
+ "- Use tool ONLY when user explicitly requests an action that matches a tool's purpose\n"
72
+ "- For normal conversation, respond naturally WITHOUT any tool calls\n"
73
+ "- The `arguments` must be a JSON string, not an object\n"
74
+ "- Multiple tools can be called by adding more items to the array\n"
75
+ "---\n\n"
76
+ )
77
+
78
+ logger.debug(f"生成工具提示词,包含 {len(tool_definitions)} 个工具定义")
79
+ return prompt
80
+
81
+
82
+ def process_messages_with_tools(
83
+ messages: List[Dict[str, Any]],
84
+ tools: Optional[List[Dict[str, Any]]],
85
+ tool_choice: str = "auto"
86
+ ) -> List[Dict[str, Any]]:
87
+ """
88
+ 将工具定义注入到消息列表中
89
+
90
+ Args:
91
+ messages: 原始消息列表
92
+ tools: 工具定义列表
93
+ tool_choice: 工具选择策略 ("auto", "none", 等)
94
+
95
+ Returns:
96
+ List[Dict]: 处理后的消息列表
97
+ """
98
+ if not tools or tool_choice == "none":
99
+ return messages
100
+
101
+ tools_prompt = generate_tool_prompt(tools)
102
+ if not tools_prompt:
103
+ return messages
104
+
105
+ processed = []
106
+ has_system = any(m.get("role") == "system" for m in messages)
107
+
108
+ if has_system:
109
+ # 如果有 system 消息,将工具提示追加到第一个 system 消息
110
+ for msg in messages:
111
+ if msg.get("role") == "system":
112
+ new_msg = msg.copy()
113
+ content = new_msg.get("content", "")
114
+ if isinstance(content, list):
115
+ # 多模态内容
116
+ content_str = " ".join([
117
+ item.get("text", "") if item.get("type") == "text" else ""
118
+ for item in content
119
+ ])
120
+ else:
121
+ content_str = str(content)
122
+ new_msg["content"] = content_str + tools_prompt
123
+ processed.append(new_msg)
124
+ else:
125
+ processed.append(msg)
126
+ else:
127
+ # 没有 system 消息,创建一个新的 system 消息
128
+ processed.append({
129
+ "role": "system",
130
+ "content": f"You are a helpful assistant with access to tools.{tools_prompt}"
131
+ })
132
+ processed.extend(messages)
133
+
134
+ logger.debug(f"工具提示已注入到消息列表,共 {len(processed)} 条消息")
135
+ return processed
136
+
137
+
138
+ def parse_and_extract_tool_calls(content: str) -> Tuple[Optional[List[Dict[str, Any]]], str]:
139
+ """
140
+ 从响应内容中提取 tool_calls JSON
141
+
142
+ Args:
143
+ content: 模型返回的文本内容
144
+
145
+ Returns:
146
+ Tuple[Optional[List], str]: (提取的 tool_calls 列表, 清理后的内容)
147
+ """
148
+ if not content or not content.strip():
149
+ return None, content
150
+
151
+ tool_calls = None
152
+ cleaned_content = content
153
+
154
+ # 方法1: 尝试解析 JSON 代码块中的 tool_calls
155
+ # 匹配 ```json ... ``` 或 ```...```
156
+ json_block_pattern = r'```(?:json)?\s*\n?(\{[\s\S]*?\})\s*\n?```'
157
+ json_blocks = re.findall(json_block_pattern, content)
158
+
159
+ for json_str in json_blocks:
160
+ try:
161
+ parsed_data = json.loads(json_str)
162
+ if "tool_calls" in parsed_data:
163
+ tool_calls = parsed_data["tool_calls"]
164
+ if tool_calls and isinstance(tool_calls, list):
165
+ # 确保 arguments 字段是字符串
166
+ for tc in tool_calls:
167
+ if tc.get("function"):
168
+ func = tc["function"]
169
+ if func.get("arguments"):
170
+ if isinstance(func["arguments"], dict):
171
+ # 转换对象为 JSON 字符串
172
+ func["arguments"] = json.dumps(func["arguments"], ensure_ascii=False)
173
+ elif not isinstance(func["arguments"], str):
174
+ func["arguments"] = str(func["arguments"])
175
+ logger.debug(f"从 JSON 代码块中提取到 {len(tool_calls)} 个工具调用")
176
+ break
177
+ except json.JSONDecodeError:
178
+ continue
179
+
180
+ # 方法2: 尝试从文本中直接查找 JSON 对象
181
+ if not tool_calls:
182
+ # 查找包含 "tool_calls" 的 JSON 对象
183
+ i = 0
184
+ scannable_text = content
185
+ while i < len(scannable_text):
186
+ if scannable_text[i] == '{':
187
+ # 尝试找到匹配的闭合括号
188
+ brace_count = 1
189
+ j = i + 1
190
+ in_string = False
191
+ escape_next = False
192
+
193
+ while j < len(scannable_text) and brace_count > 0:
194
+ if escape_next:
195
+ escape_next = False
196
+ elif scannable_text[j] == '\\':
197
+ escape_next = True
198
+ elif scannable_text[j] == '"':
199
+ in_string = not in_string
200
+ elif not in_string:
201
+ if scannable_text[j] == '{':
202
+ brace_count += 1
203
+ elif scannable_text[j] == '}':
204
+ brace_count -= 1
205
+ j += 1
206
+
207
+ if brace_count == 0:
208
+ # 找到完整的 JSON 对象
209
+ json_candidate = scannable_text[i:j]
210
+ try:
211
+ parsed_data = json.loads(json_candidate)
212
+ if "tool_calls" in parsed_data:
213
+ tool_calls = parsed_data["tool_calls"]
214
+ if tool_calls and isinstance(tool_calls, list):
215
+ # 确保 arguments 字段是字符串
216
+ for tc in tool_calls:
217
+ if tc.get("function"):
218
+ func = tc["function"]
219
+ if func.get("arguments"):
220
+ if isinstance(func["arguments"], dict):
221
+ func["arguments"] = json.dumps(func["arguments"], ensure_ascii=False)
222
+ elif not isinstance(func["arguments"], str):
223
+ func["arguments"] = str(func["arguments"])
224
+ logger.debug(f"从内联 JSON 中提取到 {len(tool_calls)} 个工具调用")
225
+ break
226
+ except json.JSONDecodeError:
227
+ pass
228
+
229
+ i = j
230
+ else:
231
+ i += 1
232
+
233
+ # 清理内容 - 移除包含 tool_calls 的 JSON
234
+ if tool_calls:
235
+ cleaned_content = remove_tool_json_content(content)
236
+
237
+ return tool_calls, cleaned_content
238
+
239
+
240
+ def remove_tool_json_content(content: str) -> str:
241
+ """
242
+ 从响应内容中移除工具调用 JSON
243
+
244
+ Args:
245
+ content: 原始响应内容
246
+
247
+ Returns:
248
+ str: 清理后的内容
249
+ """
250
+ if not content:
251
+ return content
252
+
253
+ # 步骤1: 移除 JSON 代码块中包含 tool_calls 的部分
254
+ cleaned_text = content
255
+
256
+ # 匹配 ```json ... ``` 或 ```...```
257
+ def replace_json_block(match):
258
+ json_content = match.group(1)
259
+ try:
260
+ parsed_data = json.loads(json_content)
261
+ if "tool_calls" in parsed_data:
262
+ return "" # 移除整个代码块
263
+ except json.JSONDecodeError:
264
+ pass
265
+ return match.group(0) # 保留原文
266
+
267
+ json_block_pattern = r'```(?:json)?\s*\n?(\{[\s\S]*?\})\s*\n?```'
268
+ cleaned_text = re.sub(json_block_pattern, replace_json_block, cleaned_text)
269
+
270
+ # 步骤2: 移除内联的 tool JSON - 使用括号平衡方法
271
+ result = []
272
+ i = 0
273
+
274
+ while i < len(cleaned_text):
275
+ if cleaned_text[i] == '{':
276
+ # 尝试找到匹配的闭合括号
277
+ brace_count = 1
278
+ j = i + 1
279
+ in_string = False
280
+ escape_next = False
281
+
282
+ while j < len(cleaned_text) and brace_count > 0:
283
+ if escape_next:
284
+ escape_next = False
285
+ elif cleaned_text[j] == '\\':
286
+ escape_next = True
287
+ elif cleaned_text[j] == '"':
288
+ in_string = not in_string
289
+ elif not in_string:
290
+ if cleaned_text[j] == '{':
291
+ brace_count += 1
292
+ elif cleaned_text[j] == '}':
293
+ brace_count -= 1
294
+ j += 1
295
+
296
+ if brace_count == 0:
297
+ # 找到完整的 JSON 对象,检查是否包含 tool_calls
298
+ json_candidate = cleaned_text[i:j]
299
+ try:
300
+ parsed = json.loads(json_candidate)
301
+ if "tool_calls" in parsed:
302
+ # 这是一个工具调用,跳过它
303
+ i = j
304
+ continue
305
+ except json.JSONDecodeError:
306
+ pass
307
+
308
+ # 不是工具调用或无法解析,保留这个字符
309
+ result.append(cleaned_text[i])
310
+ i += 1
311
+ else:
312
+ result.append(cleaned_text[i])
313
+ i += 1
314
+
315
+ cleaned_result = "".join(result).strip()
316
+
317
+ # 移除多余的空白行
318
+ cleaned_result = re.sub(r'\n{3,}', '\n\n', cleaned_result)
319
+
320
+ logger.debug(f"内容清理完成,原始长度: {len(content)}, 清理后长度: {len(cleaned_result)}")
321
+ return cleaned_result
322
+
323
+
324
+ def content_to_string(content: Any) -> str:
325
+ """
326
+ 将消息内容转换为字符串
327
+
328
+ Args:
329
+ content: 消息内容,可能是字符串或列表(多模态)
330
+
331
+ Returns:
332
+ str: 字符串格式的内容
333
+ """
334
+ if isinstance(content, str):
335
+ return content
336
+ elif isinstance(content, list):
337
+ # 多模态内容,提取文本部分
338
+ text_parts = []
339
+ for item in content:
340
+ if isinstance(item, dict):
341
+ if item.get("type") == "text":
342
+ text_parts.append(item.get("text", ""))
343
+ elif isinstance(item, str):
344
+ text_parts.append(item)
345
+ return " ".join(text_parts)
346
+ else:
347
+ return str(content)
app/utils/user_agent.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """
5
+ 用户代理工具模块
6
+ 提供动态随机用户代理生成功能
7
+ """
8
+
9
+ import random
10
+ from typing import Dict, Optional
11
+ from fake_useragent import UserAgent
12
+
13
+ # 全局 UserAgent 实例(单例模式)
14
+ _user_agent_instance: Optional[UserAgent] = None
15
+
16
+
17
+ def get_user_agent_instance() -> UserAgent:
18
+ """获取或创建 UserAgent 实例(单例模式)"""
19
+ global _user_agent_instance
20
+ if _user_agent_instance is None:
21
+ _user_agent_instance = UserAgent()
22
+ return _user_agent_instance
23
+
24
+
25
+ def get_random_user_agent(browser_type: Optional[str] = None) -> str:
26
+ """
27
+ 获取随机用户代理字符串
28
+
29
+ Args:
30
+ browser_type: 指定浏览器类型 ('chrome', 'firefox', 'safari', 'edge')
31
+ 如果为 None,则随机选择
32
+
33
+ Returns:
34
+ str: 用户代理字符串
35
+ """
36
+ ua = get_user_agent_instance()
37
+
38
+ # 如果没有指定浏览器类型,随机选择一个(偏向 Chrome 和 Edge)
39
+ if browser_type is None:
40
+ browser_choices = ["chrome", "chrome", "chrome", "edge", "edge", "firefox", "safari"]
41
+ browser_type = random.choice(browser_choices)
42
+
43
+ # 根据浏览器类型获取用户代理
44
+ if browser_type == "chrome":
45
+ user_agent = ua.chrome
46
+ elif browser_type == "edge":
47
+ user_agent = ua.edge
48
+ elif browser_type == "firefox":
49
+ user_agent = ua.firefox
50
+ elif browser_type == "safari":
51
+ user_agent = ua.safari
52
+ else:
53
+ user_agent = ua.random
54
+
55
+ return user_agent
56
+
57
+
58
+ # 通用 UserAgent headers 生成函数
59
+ def get_dynamic_headers(
60
+ referer: Optional[str] = None,
61
+ origin: Optional[str] = None,
62
+ browser_type: Optional[str] = None,
63
+ additional_headers: Optional[Dict[str, str]] = None
64
+ ) -> Dict[str, str]:
65
+ """
66
+ 生成动态浏览器 headers,包含随机 User-Agent
67
+
68
+ Args:
69
+ referer: 引用页面 URL
70
+ origin: 源站 URL
71
+ browser_type: 指定浏览器类型
72
+ additional_headers: 额外的 headers
73
+
74
+ Returns:
75
+ Dict[str, str]: 包含动态 User-Agent 的 headers
76
+ """
77
+ user_agent = get_random_user_agent(browser_type)
78
+
79
+ # 基础 headers
80
+ headers = {
81
+ "User-Agent": user_agent,
82
+ "Accept": "application/json, text/event-stream",
83
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
84
+ "Accept-Encoding": "gzip, deflate, br",
85
+ "Cache-Control": "no-cache",
86
+ "Connection": "keep-alive",
87
+ "Pragma": "no-cache",
88
+ }
89
+
90
+ # 添加可选的 headers
91
+ if referer:
92
+ headers["Referer"] = referer
93
+
94
+ if origin:
95
+ headers["Origin"] = origin
96
+
97
+ # 根据用户代理添加浏览器特定的 headers
98
+ if "Chrome/" in user_agent or "Edg/" in user_agent:
99
+ # Chrome/Edge 特定的 headers
100
+ chrome_version = "139"
101
+ edge_version = "139"
102
+
103
+ try:
104
+ if "Chrome/" in user_agent:
105
+ chrome_version = user_agent.split("Chrome/")[1].split(".")[0]
106
+ except:
107
+ pass
108
+
109
+ try:
110
+ if "Edg/" in user_agent:
111
+ edge_version = user_agent.split("Edg/")[1].split(".")[0]
112
+ sec_ch_ua = f'"Microsoft Edge";v="{edge_version}", "Chromium";v="{chrome_version}", "Not_A Brand";v="24"'
113
+ else:
114
+ sec_ch_ua = f'"Not_A Brand";v="8", "Chromium";v="{chrome_version}", "Google Chrome";v="{chrome_version}"'
115
+ except:
116
+ sec_ch_ua = f'"Not_A Brand";v="8", "Chromium";v="{chrome_version}", "Google Chrome";v="{chrome_version}"'
117
+
118
+ headers.update({
119
+ "sec-ch-ua": sec_ch_ua,
120
+ "sec-ch-ua-mobile": "?0",
121
+ "sec-ch-ua-platform": '"Windows"',
122
+ "Sec-Fetch-Dest": "empty",
123
+ "Sec-Fetch-Mode": "cors",
124
+ "Sec-Fetch-Site": "same-origin",
125
+ })
126
+
127
+ # 添加额外的 headers
128
+ if additional_headers:
129
+ headers.update(additional_headers)
130
+
131
+ return headers
132
+
133
+
deploy/.dockerignore ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Git files
2
+ .git
3
+ .gitignore
4
+ .gitattributes
5
+
6
+ # Python cache
7
+ __pycache__
8
+ *.py[cod]
9
+ *$py.class
10
+ *.so
11
+ .Python
12
+
13
+ # Virtual environments
14
+ venv/
15
+ env/
16
+ ENV/
17
+
18
+ # IDE
19
+ .vscode/
20
+ .idea/
21
+ *.swp
22
+ *.swo
23
+ *~
24
+
25
+ # Documentation
26
+ *.md
27
+ !README.md
28
+ docs/
29
+
30
+ # Test files
31
+ tests/
32
+ pytest.ini
33
+ .pytest_cache/
34
+
35
+ # Local data (will be mounted as volumes)
36
+ *.db
37
+ *.sqlite
38
+ *.sqlite3
39
+ logs/
40
+ data/
41
+
42
+ # Build artifacts
43
+ build/
44
+ dist/
45
+ *.egg-info/
46
+
47
+ # Docker files in parent directory
48
+ Dockerfile
49
+ docker-compose.yml
50
+ .dockerignore
51
+
52
+ # Other
53
+ .env.local
54
+ .DS_Store
deploy/.env.example ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ==============================================
2
+ # Z.AI API Server - Docker 环境变量配置示例
3
+ # ==============================================
4
+
5
+ # 管理后台密码
6
+ ADMIN_PASSWORD=admin123
7
+
8
+ # API 认证密钥 (用于验证客户端请求)
9
+ AUTH_TOKEN=sk-your-api-key-here
10
+
11
+ # 是否跳过 API Key 验证 (开发环境可设为 true)
12
+ SKIP_AUTH_TOKEN=false
13
+
14
+ # 调试日志 (生产环境建议设为 false)
15
+ DEBUG_LOGGING=true
16
+
17
+ # 匿名模式 (允许无 token 访问,需要配合 SKIP_AUTH_TOKEN=true)
18
+ ANONYMOUS_MODE=false
19
+
20
+ # Function Call 功能开关 (是否支持工具调用)
21
+ TOOL_SUPPORT=true
22
+
23
+ # 工具调用扫描限制 (字符数)
24
+ SCAN_LIMIT=200000
25
+
26
+ # 数据库路径 (Docker 环境使用持久化卷)
27
+ DB_PATH=/app/data/tokens.db
28
+
29
+ # Token 池配置
30
+ TOKEN_FAILURE_THRESHOLD=3
31
+ TOKEN_RECOVERY_TIMEOUT=300
32
+
33
+ # 服务配置
34
+ SERVICE_NAME=Z.AI_API_Server
35
+ LISTEN_PORT=7860
deploy/Dockerfile ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ # Set working directory
4
+ WORKDIR /app
5
+
6
+ # Create data and logs directories with proper permissions
7
+ RUN mkdir -p /app/data /app/logs && \
8
+ chmod 755 /app/data /app/logs
9
+
10
+ # Install dependencies
11
+ COPY requirements.txt .
12
+ RUN pip install --no-cache-dir -r requirements.txt
13
+
14
+ # Copy application code
15
+ COPY . .
16
+
17
+ # Set environment variable for database path
18
+ ENV DB_PATH=/app/data/tokens.db
19
+
20
+ # Expose port
21
+ EXPOSE 7860
22
+
23
+ # Run the application
24
+ CMD ["python", "main.py"]
deploy/NGINX_SETUP.md ADDED
@@ -0,0 +1,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Nginx 反向代理部署指南
2
+
3
+ 本文档说明如何在 Nginx 反向代理后部署 Z.AI2API,支持自定义路径前缀。
4
+
5
+ ## 问题说明
6
+
7
+ 在使用 Nginx 反向代理时,如果需要将服务部署在自定义路径前缀下(例如 `http://domain.com/ai2api`),
8
+ 需要正确配置 `ROOT_PATH` 环境变量,否则会出现以下问题:
9
+
10
+ - 后台管理页面跳转错误(缺少路径前缀)
11
+ - API 接口请求 404(路径不完整)
12
+ - 静态资源加载失败
13
+
14
+ ## 解决方案
15
+
16
+ ### 1. 配置环境变量
17
+
18
+ 在 `.env` 文件中设置 `ROOT_PATH` 变量,值为 Nginx 配置的 location 路径:
19
+
20
+ ```bash
21
+ # 示例:部署在 /ai2api 路径下
22
+ ROOT_PATH=/ai2api
23
+ ```
24
+
25
+ **重要**: `ROOT_PATH` 必须与 Nginx 配置中的 `location` 路径完全一致。
26
+
27
+ ### 2. 配置 Nginx
28
+
29
+ 参考 `deploy/nginx.conf.example` 文件,选择合适的配置模板。
30
+
31
+ #### 基础配置示例
32
+
33
+ ```nginx
34
+ server {
35
+ listen 80;
36
+ server_name your-domain.com;
37
+
38
+ location /ai2api {
39
+ # 代理到后端服务
40
+ proxy_pass http://127.0.0.1:7860;
41
+
42
+ # 传递原始请求信息
43
+ proxy_set_header Host $host;
44
+ proxy_set_header X-Real-IP $remote_addr;
45
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
46
+ proxy_set_header X-Forwarded-Proto $scheme;
47
+
48
+ # SSE 流式响应支持
49
+ proxy_http_version 1.1;
50
+ proxy_set_header Upgrade $http_upgrade;
51
+ proxy_set_header Connection "upgrade";
52
+ proxy_buffering off;
53
+ proxy_cache off;
54
+
55
+ # 超时设置
56
+ proxy_read_timeout 300s;
57
+ }
58
+ }
59
+ ```
60
+
61
+ ### 3. Docker Compose 配置
62
+
63
+ 如果使用 Docker 部署,需要在 `docker-compose.yml` 中添加 `ROOT_PATH` 环境变量:
64
+
65
+ ```yaml
66
+ version: '3.8'
67
+ services:
68
+ ai2api:
69
+ image: z-ai2api:latest
70
+ environment:
71
+ - ROOT_PATH=/ai2api
72
+ - LISTEN_PORT=7860
73
+ # ... 其他环境变量
74
+ ports:
75
+ - "7860:7860"
76
+ ```
77
+
78
+ ### 4. 重启服务
79
+
80
+ ```bash
81
+ # 重载 Nginx 配置
82
+ sudo nginx -t
83
+ sudo systemctl reload nginx
84
+
85
+ # 重启应用(Docker)
86
+ docker-compose restart
87
+
88
+ # 或重启应用(直接运行)
89
+ # 停止服务后重新启动
90
+ ```
91
+
92
+ ## 访问地址
93
+
94
+ 配置完成后,服务访问地址如下:
95
+
96
+ - **API 端点**: `http://your-domain.com/ai2api/v1/chat/completions`
97
+ - **模型列表**: `http://your-domain.com/ai2api/v1/models`
98
+ - **管理后台**: `http://your-domain.com/ai2api/admin/login`
99
+ - **根路径**: `http://your-domain.com/ai2api/`
100
+
101
+ ## 配置示例
102
+
103
+ ### 示例 1: 部署在 /api 路径下
104
+
105
+ **.env 配置**:
106
+ ```bash
107
+ ROOT_PATH=/api
108
+ ```
109
+
110
+ **Nginx 配置**:
111
+ ```nginx
112
+ location /api {
113
+ proxy_pass http://127.0.0.1:7860;
114
+ # ... 其他配置
115
+ }
116
+ ```
117
+
118
+ **访问地址**: `http://domain.com/api/admin/login`
119
+
120
+ ### 示例 2: 部署在根路径(无前缀)
121
+
122
+ **.env 配置**:
123
+ ```bash
124
+ ROOT_PATH=
125
+ ```
126
+
127
+ **Nginx 配置**:
128
+ ```nginx
129
+ location / {
130
+ proxy_pass http://127.0.0.1:7860;
131
+ # ... 其他配置
132
+ }
133
+ ```
134
+
135
+ **访问地址**: `http://domain.com/admin/login`
136
+
137
+ ### 示例 3: 多级路径前缀
138
+
139
+ **.env 配置**:
140
+ ```bash
141
+ ROOT_PATH=/services/ai/chat
142
+ ```
143
+
144
+ **Nginx 配置**:
145
+ ```nginx
146
+ location /services/ai/chat {
147
+ proxy_pass http://127.0.0.1:7860;
148
+ # ... 其他配置
149
+ }
150
+ ```
151
+
152
+ **访问地址**: `http://domain.com/services/ai/chat/admin/login`
153
+
154
+ ## 常见问题排查
155
+
156
+ ### 1. 404 错误
157
+
158
+ **现象**: 访问页面或 API 时返回 404
159
+
160
+ **可能原因**:
161
+ - `ROOT_PATH` 配置与 Nginx location 路径不匹配
162
+ - Nginx 配置错误或未重载
163
+
164
+ **解决方法**:
165
+ - 检查 `.env` 中的 `ROOT_PATH` 是否与 Nginx `location` 完全一致
166
+ - 确认 Nginx 配置无误: `sudo nginx -t`
167
+ - 重载 Nginx: `sudo systemctl reload nginx`
168
+ - 重启应用服务
169
+
170
+ ### 2. 静态资源加载失败
171
+
172
+ **现象**: 管理后台页面样式错乱,控制台显示 CSS/JS 404
173
+
174
+ **可能原因**:
175
+ - `ROOT_PATH` 未配置或配置错误
176
+ - 静态文件路径未包含前缀
177
+
178
+ **解决方法**:
179
+ - 确保 `ROOT_PATH` 正确配置并重启服务
180
+ - 检查浏览器开发者工具中的资源请求路径
181
+
182
+ ### 3. 流式响应中断
183
+
184
+ **现象**: SSE 流式响应提前终止或无法正常工作
185
+
186
+ **可能原因**:
187
+ - Nginx 启用了缓冲
188
+ - 超时时间设置过短
189
+
190
+ **解决方法**:
191
+ 在 Nginx 配置中添加:
192
+ ```nginx
193
+ proxy_buffering off;
194
+ proxy_cache off;
195
+ proxy_read_timeout 300s;
196
+ ```
197
+
198
+ ### 4. CORS 错误
199
+
200
+ **现象**: 浏览器控制台显示跨域请求被阻止
201
+
202
+ **可能原因**:
203
+ - Nginx 未正确传递请求头
204
+
205
+ **解决方法**:
206
+ 确保 Nginx 配置中包含:
207
+ ```nginx
208
+ proxy_set_header Host $host;
209
+ proxy_set_header X-Forwarded-Proto $scheme;
210
+ ```
211
+
212
+ ## 验证配置
213
+
214
+ 配置完成后,可以通过以下方式验证:
215
+
216
+ 1. **访问健康检查端点**:
217
+ ```bash
218
+ curl http://your-domain.com/ai2api/v1/models
219
+ ```
220
+
221
+ 2. **访问管理后台**:
222
+ 在浏览器打开 `http://your-domain.com/ai2api/admin/login`
223
+
224
+ 3. **测试 API 请求**:
225
+ ```bash
226
+ curl -X POST http://your-domain.com/ai2api/v1/chat/completions \
227
+ -H "Content-Type: application/json" \
228
+ -H "Authorization: Bearer your-api-key" \
229
+ -d '{
230
+ "model": "GLM-4.6",
231
+ "messages": [{"role": "user", "content": "Hello"}],
232
+ "stream": false
233
+ }'
234
+ ```
235
+
236
+ ## 进阶配置
237
+
238
+ ### HTTPS 配置
239
+
240
+ ```nginx
241
+ server {
242
+ listen 443 ssl http2;
243
+ server_name your-domain.com;
244
+
245
+ ssl_certificate /path/to/cert.pem;
246
+ ssl_certificate_key /path/to/key.pem;
247
+
248
+ location /ai2api {
249
+ proxy_pass http://127.0.0.1:7860;
250
+ proxy_set_header X-Forwarded-Proto https;
251
+ # ... 其他配置
252
+ }
253
+ }
254
+ ```
255
+
256
+ ### 负载均衡
257
+
258
+ ```nginx
259
+ upstream ai2api_backend {
260
+ server 127.0.0.1:7860;
261
+ server 127.0.0.1:8081;
262
+ server 127.0.0.1:8082;
263
+ }
264
+
265
+ server {
266
+ listen 80;
267
+ location /ai2api {
268
+ proxy_pass http://ai2api_backend;
269
+ # ... 其他配置
270
+ }
271
+ }
272
+ ```
273
+
274
+ ## 参考资料
275
+
276
+ - [FastAPI Behind a Proxy](https://fastapi.tiangolo.com/advanced/behind-a-proxy/)
277
+ - [Nginx Proxy Module](http://nginx.org/en/docs/http/ngx_http_proxy_module.html)
278
+ - 完整配置示例: `deploy/nginx.conf.example`
deploy/README_DOCKER.md ADDED
@@ -0,0 +1,357 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Docker 部署文档
2
+
3
+ ## 快速部署
4
+
5
+ ### 方式一: 使用预构建镜像 (推荐)
6
+
7
+ 从 Docker Hub 拉取镜像:
8
+
9
+ ```bash
10
+ # 拉取最新镜像
11
+ docker pull zyphrzero/z-ai2api-python:latest
12
+
13
+ # 创建数据目录
14
+ mkdir -p data logs
15
+
16
+ # 快速启动
17
+ docker run -d \
18
+ --name z-ai-api-server \
19
+ -p 7860:7860 \
20
+ -e ADMIN_PASSWORD=admin123 \
21
+ -e AUTH_TOKEN=sk-your-api-key \
22
+ -e ANONYMOUS_MODE=true \
23
+ -e DB_PATH=/app/data/tokens.db \
24
+ -v $(pwd)/data:/app/data \
25
+ -v $(pwd)/logs:/app/logs \
26
+ --restart unless-stopped \
27
+ zyphrzero/z-ai2api-python:latest
28
+ ```
29
+
30
+ **优势**:
31
+ - ✅ 无需本地构建,节省时间
32
+ - ✅ GitHub Actions 自动化构建,保证质量
33
+ - ✅ 多架构支持 (amd64/arm64)
34
+ - ✅ 镜像已优化,体积更小
35
+
36
+ ### 方式二: 使用本地构建
37
+
38
+ 适用于需要自定义修改代码的场景:
39
+
40
+ ```bash
41
+ # 进入部署目录
42
+ cd deploy
43
+
44
+ # 启动服务 (会自动构建镜像)
45
+ docker compose up -d
46
+
47
+ # 查看日志
48
+ docker compose logs -f api-server
49
+ ```
50
+
51
+ 服务将在 `http://localhost:7860` 启动。
52
+
53
+ ## 架构说明
54
+
55
+ ### 持久化存储
56
+
57
+ 容器使用卷映射实现数据持久化:
58
+
59
+ ```yaml
60
+ volumes:
61
+ - ./data:/app/data # 数据库存储 (tokens.db)
62
+ - ./logs:/app/logs # 应用日志
63
+ ```
64
+
65
+ **目录结构**:
66
+ ```
67
+ deploy/
68
+ ├── data/
69
+ │ └── tokens.db # SQLite 数据库 (自动创建)
70
+ ├── logs/ # 应用日志 (自动创建)
71
+ ├── docker-compose.yml
72
+ ├── Dockerfile
73
+ └── README_DOCKER.md
74
+ ```
75
+
76
+ ### 环境变量
77
+
78
+ 核心配置参数 (在 `docker-compose.yml` 中设置):
79
+
80
+ | 变量 | 默认值 | 说明 |
81
+ |------|--------|------|
82
+ | `DB_PATH` | `/app/data/tokens.db` | 数据库文件路径 |
83
+ | `ADMIN_PASSWORD` | `admin123` | 管理后台密码 |
84
+ | `AUTH_TOKEN` | `sk-your-api-key` | API 认证密钥 |
85
+ | `SKIP_AUTH_TOKEN` | `false` | 跳过 API 验证 |
86
+ | `ANONYMOUS_MODE` | `true` | 匿名访问模式 |
87
+ | `DEBUG_LOGGING` | `true` | 调试日志开关 |
88
+ | `TOOL_SUPPORT` | `true` | Function Call 支持 |
89
+
90
+ **生产环境建议**:
91
+ - 修改 `ADMIN_PASSWORD` 和 `AUTH_TOKEN`
92
+ - 设置 `DEBUG_LOGGING=false`
93
+ - 设置 `ANONYMOUS_MODE=false`
94
+
95
+ ## 运维操作
96
+
97
+ ### 服务管理
98
+
99
+ ```bash
100
+ # 启动服务
101
+ docker compose up -d
102
+
103
+ # 停止服务
104
+ docker compose down
105
+
106
+ # 重启服务
107
+ docker compose restart
108
+
109
+ # 查看状态
110
+ docker compose ps
111
+
112
+ # 实时日志
113
+ docker compose logs -f
114
+ ```
115
+
116
+ ### 更新应用
117
+
118
+ **使用预构建镜像**:
119
+
120
+ ```bash
121
+ # 停止当前容器
122
+ docker compose down
123
+
124
+ # 拉取最新镜像
125
+ docker pull zyphrzero/z-ai2api-python:latest
126
+
127
+ # 启动新版本 (数据会自动保留)
128
+ docker compose up -d
129
+
130
+ # 清理旧镜像
131
+ docker image prune -f
132
+ ```
133
+
134
+ **使用本地构建**:
135
+
136
+ ```bash
137
+ # 拉取最新代码
138
+ git pull
139
+
140
+ # 重新构建并启动 (数据会保留)
141
+ docker compose up -d --build
142
+
143
+ # 清理旧镜像
144
+ docker image prune -f
145
+ ```
146
+
147
+ ### 数据备份与恢复
148
+
149
+ **备份**:
150
+ ```bash
151
+ # 备份数据库
152
+ cp ./data/tokens.db ./data/tokens.db.backup.$(date +%Y%m%d_%H%M%S)
153
+
154
+ # 完整备份
155
+ tar -czf backup_$(date +%Y%m%d_%H%M%S).tar.gz ./data ./logs
156
+ ```
157
+
158
+ **恢复**:
159
+ ```bash
160
+ # 停止服务
161
+ docker compose down
162
+
163
+ # 恢复数据库
164
+ cp ./data/tokens.db.backup.20250116_120000 ./data/tokens.db
165
+
166
+ # 启动服务
167
+ docker compose up -d
168
+ ```
169
+
170
+ ### 数据库迁移
171
+
172
+ 如需从其他位置迁移现有数据库:
173
+
174
+ ```bash
175
+ # 使用迁移脚本
176
+ ./migrate_db.sh /path/to/existing/tokens.db
177
+
178
+ # 或手动复制
179
+ cp /opt/1panel/docker/compose/k2think/tokens.db ./data/
180
+ chmod 644 ./data/tokens.db
181
+
182
+ # 启动服务
183
+ docker compose up -d
184
+ ```
185
+
186
+ ## 故障排查
187
+
188
+ ### 数据库初始化失败
189
+
190
+ **错误**: `unable to open database file`
191
+
192
+ **原因**: 目录权限或卷映射问题
193
+
194
+ **解决**:
195
+ ```bash
196
+ # 停止容器
197
+ docker compose down
198
+
199
+ # 确保目录存在
200
+ mkdir -p ./data ./logs
201
+
202
+ # 设置权限
203
+ chmod 755 ./data ./logs
204
+
205
+ # 重新构建并启动
206
+ docker compose up -d --build
207
+ ```
208
+
209
+ ### 容器无法启动
210
+
211
+ **检查步骤**:
212
+ ```bash
213
+ # 查看详细日志
214
+ docker compose logs api-server
215
+
216
+ # 检查容器状态
217
+ docker compose ps
218
+
219
+ # 验证配置文件
220
+ docker compose config
221
+ ```
222
+
223
+ ### 端口冲突
224
+
225
+ 如端口 7860 被占用,修改 `docker-compose.yml`:
226
+ ```yaml
227
+ ports:
228
+ - "8081:7860" # 映射到宿主机 8081 端口
229
+ ```
230
+
231
+ ### 健康检查失败
232
+
233
+ ```bash
234
+ # 检查健康状态
235
+ docker compose ps
236
+
237
+ # 手动测试接口
238
+ curl http://localhost:7860/v1/models
239
+
240
+ # 进入容器排查
241
+ docker exec -it z-ai-api-server bash
242
+ ```
243
+
244
+ ## API 访问
245
+
246
+ | 端点 | 地址 | 说明 |
247
+ |------|------|------|
248
+ | API 根路径 | `http://localhost:7860` | OpenAI 兼容 API |
249
+ | 模型列表 | `http://localhost:7860/v1/models` | 获取可用模型 |
250
+ | 管理后台 | `http://localhost:7860/admin` | Web 管理界面 |
251
+ | API 文档 | `http://localhost:7860/docs` | OpenAPI/Swagger 文档 |
252
+ | 健康检查 | `http://localhost:7860/v1/models` | 服务健康状态 |
253
+
254
+ ## 高级配置
255
+
256
+ ### 自定义数据库路径
257
+
258
+ 修改 `docker-compose.yml` 使用��部路径:
259
+
260
+ ```yaml
261
+ volumes:
262
+ - /opt/mydata:/app/data # 使用绝对路径
263
+
264
+ environment:
265
+ - DB_PATH=/app/data/tokens.db
266
+ ```
267
+
268
+ ### 使用 .env 文件
269
+
270
+ 创建 `.env` 文件 (基于 `.env.example`):
271
+
272
+ ```bash
273
+ cp .env.example .env
274
+ # 编辑配置
275
+ vim .env
276
+ ```
277
+
278
+ 修改 `docker-compose.yml`:
279
+ ```yaml
280
+ services:
281
+ api-server:
282
+ env_file: .env
283
+ ```
284
+
285
+ ### 启用日志轮转
286
+
287
+ 在生产环境配置 Docker 日志驱动:
288
+
289
+ ```yaml
290
+ services:
291
+ api-server:
292
+ logging:
293
+ driver: "json-file"
294
+ options:
295
+ max-size: "10m"
296
+ max-file: "3"
297
+ ```
298
+
299
+ ### 资源限制
300
+
301
+ 限制容器资源使用:
302
+
303
+ ```yaml
304
+ services:
305
+ api-server:
306
+ deploy:
307
+ resources:
308
+ limits:
309
+ cpus: '2'
310
+ memory: 2G
311
+ reservations:
312
+ cpus: '0.5'
313
+ memory: 512M
314
+ ```
315
+
316
+ ## 监控与日志
317
+
318
+ ### 查看日志
319
+
320
+ ```bash
321
+ # 实时日志
322
+ docker compose logs -f
323
+
324
+ # 最近100行
325
+ docker compose logs --tail=100
326
+
327
+ # 特定时间段
328
+ docker compose logs --since 30m
329
+
330
+ # 导出日志
331
+ docker compose logs > app.log
332
+ ```
333
+
334
+ ### 容器指标
335
+
336
+ ```bash
337
+ # 资源使用情况
338
+ docker stats z-ai-api-server
339
+
340
+ # 容器详情
341
+ docker inspect z-ai-api-server
342
+ ```
343
+
344
+ ## 安全建议
345
+
346
+ 1. **修改默认密码**: 更改 `ADMIN_PASSWORD` 和 `AUTH_TOKEN`
347
+ 2. **限制网络访问**: 生产环境使用反向代理 (Nginx/Caddy)
348
+ 3. **启用 HTTPS**: 配置 SSL 证书
349
+ 4. **定期备份**: 自动化数据库备份任务
350
+ 5. **日志审计**: 定期检查 `request_logs` 表
351
+ 6. **最小权限**: 避免以 root 运行容器
352
+
353
+ ## 参考资料
354
+
355
+ - [Docker Compose 文档](https://docs.docker.com/compose/)
356
+ - [项目主 README](../README.md)
357
+ - [配置示例](.env.example)