Gemini CLI commited on
Commit
7864524
·
0 Parent(s):

Configure for Hugging Face Spaces

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 +72 -0
  2. .gitattributes +2 -0
  3. .github/workflows/docker.yml +64 -0
  4. .gitignore +181 -0
  5. Dockerfile +30 -0
  6. LICENSE +21 -0
  7. README.md +132 -0
  8. README_EN.md +123 -0
  9. app/__init__.py +6 -0
  10. app/admin/__init__.py +3 -0
  11. app/admin/api.py +1111 -0
  12. app/admin/auth.py +129 -0
  13. app/admin/config_manager.py +682 -0
  14. app/admin/routes.py +109 -0
  15. app/admin/stats.py +184 -0
  16. app/core/__init__.py +6 -0
  17. app/core/claude.py +582 -0
  18. app/core/claude_compat.py +352 -0
  19. app/core/config.py +95 -0
  20. app/core/openai.py +224 -0
  21. app/core/openai_compat.py +139 -0
  22. app/core/upstream.py +2245 -0
  23. app/models/__init__.py +6 -0
  24. app/models/request_log.py +35 -0
  25. app/models/schemas.py +166 -0
  26. app/models/token_db.py +44 -0
  27. app/services/request_log_dao.py +630 -0
  28. app/services/token_automation.py +278 -0
  29. app/services/token_dao.py +664 -0
  30. app/services/token_importer.py +138 -0
  31. app/templates/base.html +201 -0
  32. app/templates/components/recent_logs.html +128 -0
  33. app/templates/components/token_list.html +114 -0
  34. app/templates/components/token_pool.html +40 -0
  35. app/templates/components/token_row.html +154 -0
  36. app/templates/components/token_stats.html +125 -0
  37. app/templates/config.html +344 -0
  38. app/templates/index.html +588 -0
  39. app/templates/login.html +143 -0
  40. app/templates/logs.html +59 -0
  41. app/templates/tokens.html +487 -0
  42. app/utils/__init__.py +6 -0
  43. app/utils/env_file.py +59 -0
  44. app/utils/fe_version.py +112 -0
  45. app/utils/guest_session_pool.py +646 -0
  46. app/utils/logger.py +105 -0
  47. app/utils/reload_config.py +94 -0
  48. app/utils/request_logging.py +337 -0
  49. app/utils/request_source.py +129 -0
  50. app/utils/signature.py +56 -0
.env.example ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 代理服务配置文件示例
2
+ # 复制此文件为 .env 并根据需要修改配置值
3
+
4
+ # ========== API 基础配置 ==========
5
+ # 客户端访问本服务使用的 Bearer 密钥,不是上游 Z.AI 用户 Token
6
+ # 上游用户 Token 请在管理后台导入,由数据库 Token 池统一管理
7
+ AUTH_TOKEN=sk-your-api-key
8
+
9
+ # 跳过客户端认证(仅开发环境使用)
10
+ SKIP_AUTH_TOKEN=false
11
+
12
+ # ========== 用户 Token 池配置 ==========
13
+ # 仅作用于管理后台导入的 Z.AI 用户 Token
14
+ # 失败多少次后标记为不可用
15
+ TOKEN_FAILURE_THRESHOLD=3
16
+
17
+ # 失败 Token 多久后重新参与调度(秒)
18
+ TOKEN_RECOVERY_TIMEOUT=1800
19
+
20
+ # 定时扫描服务端目录导入 Token
21
+ TOKEN_AUTO_IMPORT_ENABLED=false
22
+
23
+ # 自动导入的服务端本地目录
24
+ TOKEN_AUTO_IMPORT_SOURCE_DIR=
25
+
26
+ # 自动导入扫描间隔(秒)
27
+ TOKEN_AUTO_IMPORT_INTERVAL=300
28
+
29
+ # 定时维护 Token 池
30
+ TOKEN_AUTO_MAINTENANCE_ENABLED=false
31
+
32
+ # 自动维护执行间隔(秒)
33
+ TOKEN_AUTO_MAINTENANCE_INTERVAL=1800
34
+
35
+ # 自动维护动作开关
36
+ TOKEN_AUTO_REMOVE_DUPLICATES=true
37
+ TOKEN_AUTO_HEALTH_CHECK=true
38
+ TOKEN_AUTO_DELETE_INVALID=false
39
+
40
+ # ========== 匿名 Guest 会话池 ==========
41
+ # false: 禁用 guest 匿名池,仅使用后台导入的用户 Token 池
42
+ # true: 启用 guest 匿名池;当没有可用用户 Token 时允许匿名会话
43
+ ANONYMOUS_MODE=true
44
+
45
+ # 预热和维持的 guest 会话数量
46
+ GUEST_POOL_SIZE=10
47
+
48
+ # ========== 服务器配置 ==========
49
+ LISTEN_PORT=8080
50
+ SERVICE_NAME=api-proxy-server
51
+ DEBUG_LOGGING=false
52
+
53
+ # Nginx 反向代理路径前缀(可选)
54
+ ROOT_PATH=
55
+
56
+ # Function Call 功能开关
57
+ TOOL_SUPPORT=true
58
+
59
+ # 工具调用扫描限制(字符数)
60
+ SCAN_LIMIT=200000
61
+
62
+ # SQLite 数据库路径
63
+ DB_PATH=tokens.db
64
+
65
+ # ========== 代理配置 ==========
66
+ # HTTP_PROXY=http://127.0.0.1:7890
67
+ # HTTPS_PROXY=http://127.0.0.1:7890
68
+ # SOCKS5_PROXY=socks5://127.0.0.1:1080
69
+
70
+ # ========== 管理后台认证 ==========
71
+ ADMIN_PASSWORD=admin123
72
+ SESSION_SECRET_KEY=your-secret-key-change-in-production
.gitattributes ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # Auto detect text files and perform LF normalization
2
+ * text=auto
.github/workflows/docker.yml ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Build and Push Docker Image
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ tags:
8
+ - 'v*'
9
+
10
+ env:
11
+ IMAGE_NAME: z-ai2api-python
12
+
13
+ jobs:
14
+ docker:
15
+ runs-on: ubuntu-latest
16
+ permissions:
17
+ contents: read
18
+ packages: write
19
+
20
+ steps:
21
+ - name: Checkout
22
+ uses: actions/checkout@v4
23
+
24
+ - name: Set up Docker Buildx
25
+ uses: docker/setup-buildx-action@v3
26
+
27
+ - name: Login to GitHub Container Registry
28
+ uses: docker/login-action@v3
29
+ with:
30
+ registry: ghcr.io
31
+ username: ${{ github.actor }}
32
+ password: ${{ secrets.GITHUB_TOKEN }}
33
+
34
+ - name: Login to Docker Hub
35
+ if: github.event_name != 'pull_request'
36
+ uses: docker/login-action@v3
37
+ with:
38
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
39
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
40
+
41
+ - name: Extract metadata
42
+ id: meta
43
+ uses: docker/metadata-action@v5
44
+ with:
45
+ images: |
46
+ ghcr.io/${{ github.repository }}
47
+ ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}
48
+ tags: |
49
+ type=ref,event=branch
50
+ type=semver,pattern={{version}}
51
+ type=semver,pattern={{major}}.{{minor}}
52
+ type=raw,value=latest,enable={{is_default_branch}}
53
+
54
+ - name: Build and push
55
+ uses: docker/build-push-action@v5
56
+ with:
57
+ context: .
58
+ file: ./deploy/Dockerfile
59
+ platforms: linux/amd64,linux/arm64
60
+ push: true
61
+ tags: ${{ steps.meta.outputs.tags }}
62
+ labels: ${{ steps.meta.outputs.labels }}
63
+ cache-from: type=gha
64
+ cache-to: type=gha,mode=max
.gitignore ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Custom
2
+ .vs/
3
+ .vscode/
4
+ .idea/
5
+ .conda/
6
+ *.zip
7
+ *.txt
8
+ *.pid
9
+ docs/
10
+ output/
11
+ main.build/
12
+ main.dist/
13
+ main.onefile-build/
14
+ *report.xml
15
+ *.yaml
16
+ logs/
17
+ backup/
18
+ uv.lock
19
+ AGENTS.md
20
+ *.db
21
+
22
+ # AI Toolset
23
+ .augment/
24
+ .cursor/
25
+ .claude/
26
+ CLAUDE.md
27
+
28
+ # Byte-compiled / optimized / DLL files
29
+ __pycache__/
30
+ *.py[cod]
31
+ *$py.class
32
+
33
+ # C extensions
34
+ *.so
35
+
36
+ # Distribution / packaging
37
+ .Python
38
+ build/
39
+ develop-eggs/
40
+ dist/
41
+ downloads/
42
+ eggs/
43
+ .eggs/
44
+ lib/
45
+ lib64/
46
+ parts/
47
+ sdist/
48
+ var/
49
+ wheels/
50
+ share/python-wheels/
51
+ *.egg-info/
52
+ .installed.cfg
53
+ *.egg
54
+ MANIFEST
55
+
56
+ # PyInstaller
57
+ # Usually these files are written by a python script from a template
58
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
59
+ *.manifest
60
+ *.spec
61
+
62
+ # Installer logs
63
+ pip-log.txt
64
+ pip-delete-this-directory.txt
65
+
66
+ # Unit test / coverage reports
67
+ htmlcov/
68
+ .tox/
69
+ .nox/
70
+ .coverage
71
+ .coverage.*
72
+ .cache
73
+ nosetests.xml
74
+ coverage.xml
75
+ *.cover
76
+ *.py,cover
77
+ .hypothesis/
78
+ .pytest_cache/
79
+ cover/
80
+
81
+ # Translations
82
+ *.mo
83
+ *.pot
84
+
85
+ # Django stuff:
86
+ *.log
87
+ local_settings.py
88
+ db.sqlite3
89
+ db.sqlite3-journal
90
+
91
+ # Flask stuff:
92
+ instance/
93
+ .webassets-cache
94
+
95
+ # Scrapy stuff:
96
+ .scrapy
97
+
98
+ # Sphinx documentation
99
+ docs/_build/
100
+
101
+ # PyBuilder
102
+ .pybuilder/
103
+ target/
104
+
105
+ # Jupyter Notebook
106
+ .ipynb_checkpoints
107
+
108
+ # IPython
109
+ profile_default/
110
+ ipython_config.py
111
+
112
+ # pyenv
113
+ # For a library or package, you might want to ignore these files since the code is
114
+ # intended to run in multiple environments; otherwise, check them in:
115
+ # .python-version
116
+
117
+ # pipenv
118
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
119
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
120
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
121
+ # install all needed dependencies.
122
+ #Pipfile.lock
123
+
124
+ # poetry
125
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
126
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
127
+ # commonly ignored for libraries.
128
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
129
+ #poetry.lock
130
+
131
+ # pdm
132
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
133
+ #pdm.lock
134
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
135
+ # in version control.
136
+ # https://pdm.fming.dev/#use-with-ide
137
+ .pdm.toml
138
+
139
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
140
+ __pypackages__/
141
+
142
+ # Celery stuff
143
+ celerybeat-schedule
144
+ celerybeat.pid
145
+
146
+ # SageMath parsed files
147
+ *.sage.py
148
+
149
+ # Environments
150
+ .env
151
+ .venv
152
+ env/
153
+ venv/
154
+ ENV/
155
+ env.bak/
156
+ venv.bak/
157
+
158
+ # Spyder project settings
159
+ .spyderproject
160
+ .spyproject
161
+
162
+ # Rope project settings
163
+ .ropeproject
164
+
165
+ # mkdocs documentation
166
+ /site
167
+
168
+ # mypy
169
+ .mypy_cache/
170
+ .dmypy.json
171
+ dmypy.json
172
+
173
+ # Pyre type checker
174
+ .pyre/
175
+
176
+ # pytype static type analyzer
177
+ .pytype/
178
+
179
+ # Cython debug symbols
180
+ cython_debug/
181
+ .ace-tool/
Dockerfile ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ # Set environment variables
4
+ ENV LISTEN_PORT=7860
5
+ ENV DB_PATH=/app/data/tokens.db
6
+ ENV PYTHONUNBUFFERED=1
7
+
8
+ # Set working directory
9
+ WORKDIR /app
10
+
11
+ # Create data and logs directories and set permissions
12
+ # HF Spaces runs as user 1000, so we make sure it can write to these directories
13
+ RUN mkdir -p /app/data /app/logs && \
14
+ chmod -R 777 /app/data /app/logs
15
+
16
+ # Install dependencies
17
+ COPY requirements.txt .
18
+ RUN pip install --no-cache-dir -r requirements.txt
19
+
20
+ # Copy application code
21
+ COPY . .
22
+
23
+ # Ensure all files are accessible
24
+ RUN chmod -R 777 /app
25
+
26
+ # Expose port
27
+ EXPOSE 7860
28
+
29
+ # Run the application
30
+ CMD ["python", "main.py"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2025 ZyphrZero
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Z.ai API
3
+ emoji: 🚀
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: docker
7
+ app_port: 7860
8
+ ---
9
+
10
+ # z-ai2api_python
11
+
12
+ 基于 FastAPI + Granian 的 GLM 代理服务
13
+ 适合本地开发、自托管代理、Token 池管理和兼容客户端接入
14
+
15
+ 中文简体 / [English](README_EN.md)
16
+
17
+ ## 特性
18
+
19
+ - 兼容 `OpenAI`、`Claude Code`、`Anthropic` 风格请求
20
+ - 支持流式响应、工具调用、Thinking 模型
21
+ - 内置 Token 池,支持轮询、失败熔断、恢复和健康检查
22
+ - 提供后台页面:仪表盘、Token 管理、配置管理、实时日志
23
+ - 使用 SQLite 存储 Token 和请求日志,部署简单
24
+ - 支持本地运行和 Docker / Docker Compose 部署
25
+
26
+ ## 快速开始
27
+
28
+ ### 环境要求
29
+
30
+ - Python `3.9` 到 `3.12`
31
+ - 推荐使用 `uv`
32
+
33
+ ### 本地启动
34
+
35
+ ```bash
36
+ git clone https://github.com/ZyphrZero/z.ai2api_python.git
37
+ cd z.ai2api_python
38
+
39
+ uv sync
40
+ cp .env.example .env
41
+ uv run python main.py
42
+ ```
43
+
44
+ 首次启动会自动初始化数据库。
45
+
46
+ 默认地址:
47
+
48
+ - API 根路径:`http://127.0.0.1:8080`
49
+ - OpenAI 文档:`http://127.0.0.1:8080/docs`
50
+ - 管理后台:`http://127.0.0.1:8080/admin`
51
+
52
+ ### Docker Compose
53
+
54
+ ```bash
55
+ docker compose -f deploy/docker-compose.yml up -d --build
56
+ ```
57
+
58
+ 更多部署说明见 [deploy/README_DOCKER.md](deploy/README_DOCKER.md)。
59
+
60
+ ## 最小配置
61
+
62
+ 至少建议确认这些环境变量:
63
+
64
+ | 变量 | 说明 |
65
+ | --- | --- |
66
+ | `AUTH_TOKEN` | 客户端访问本服务使用的 Bearer Token |
67
+ | `ADMIN_PASSWORD` | 管理后台登录密码,默认值必须修改 |
68
+ | `LISTEN_PORT` | 服务监听端口,默认 `8080` |
69
+ | `ANONYMOUS_MODE` | 是否启用匿名模式 |
70
+ | `GUEST_POOL_SIZE` | 匿名池容量 |
71
+ | `DB_PATH` | SQLite 数据库路径 |
72
+ | `TOKEN_FAILURE_THRESHOLD` | Token 连续失败阈值 |
73
+ | `TOKEN_RECOVERY_TIMEOUT` | Token 恢复等待时间 |
74
+
75
+ 完整配置请看 [.env.example](.env.example)。
76
+
77
+ ## 管理后台
78
+
79
+ 管理后台统一入口:
80
+
81
+ - `/admin`:仪表盘
82
+ - `/admin/tokens`:Token 管理
83
+ - `/admin/config`:配置管理
84
+ - `/admin/logs`:实时日志
85
+
86
+ ## 常用命令
87
+
88
+ ```bash
89
+ # 启动服务
90
+ uv run python main.py
91
+
92
+ # 运行测试
93
+ uv run pytest
94
+
95
+ # 运行一个现有 smoke test
96
+ uv run python tests/test_simple_signature.py
97
+
98
+ # Lint
99
+ uv run ruff check app tests main.py
100
+ ```
101
+
102
+ ## 兼容接口
103
+
104
+ 常见接口入口:
105
+
106
+ - OpenAI 兼容:`/v1/chat/completions`
107
+ - Anthropic 兼容:`/v1/messages`
108
+ - Claude Code 兼容:`/anthropic/v1/messages`
109
+
110
+ 模型映射和默认模型可在 `.env` 或后台配置页中调整。
111
+
112
+ ## ⭐ Star History
113
+
114
+ [![Star History Chart](https://api.star-history.com/svg?repos=ZyphrZero/z.ai2api_python&type=Date)](https://star-history.com/#ZyphrZero/z.ai2api_python&Date)
115
+
116
+ ## 许可证
117
+
118
+ 本项目采用 MIT 许可证 - 详见 [LICENSE](LICENSE) 文件。
119
+
120
+ ## 免责声明
121
+
122
+ - **本项目仅供学习和研究使用,切勿用于其他用途**
123
+ - 本项目与 Z.AI 官方无关
124
+ - 使用前请确保遵守 Z.AI 的服务条款
125
+ - 请勿用于商业用途或违反使用条款的场景
126
+ - 用户需自行承担使用风险
127
+
128
+ ---
129
+
130
+ <div align="center">
131
+ Made with ❤️ by the community
132
+ </div>
README_EN.md ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # z-ai2api_python
2
+
3
+ GLM proxy service based on FastAPI + Granian
4
+ Suitable for local development, self-hosted proxy, Token pool management, and compatible client access
5
+
6
+ English / [中文简体](README.md)
7
+
8
+ ## Features
9
+
10
+ - Compatible with `OpenAI`, `Claude Code`, `Anthropic` style requests
11
+ - Supports streaming responses, tool calls, Thinking models
12
+ - Built-in Token pool, supports polling, failure circuit breaker, recovery, and health checks
13
+ - Provides admin panel: Dashboard, Token management, Configuration management, Real-time logs
14
+ - Uses SQLite to store Tokens and request logs, simple deployment
15
+ - Supports local running and Docker / Docker Compose deployment
16
+
17
+ ## Quick Start
18
+
19
+ ### Environment Requirements
20
+
21
+ - Python `3.9` to `3.12`
22
+ - Recommend using `uv`
23
+
24
+ ### Local Startup
25
+
26
+ ```bash
27
+ git clone https://github.com/ZyphrZero/z.ai2api_python.git
28
+ cd z.ai2api_python
29
+
30
+ uv sync
31
+ cp .env.example .env
32
+ uv run python main.py
33
+ ```
34
+
35
+ First startup will automatically initialize the database.
36
+
37
+ Default addresses:
38
+
39
+ - API root path: `http://127.0.0.1:8080`
40
+ - OpenAI docs: `http://127.0.0.1:8080/docs`
41
+ - Admin panel: `http://127.0.0.1:8080/admin`
42
+
43
+ ### Docker Compose
44
+
45
+ ```bash
46
+ docker compose -f deploy/docker-compose.yml up -d --build
47
+ ```
48
+
49
+ More deployment instructions see [deploy/README_DOCKER.md](deploy/README_DOCKER.md).
50
+
51
+ ## Minimum Configuration
52
+
53
+ At least suggest confirming these environment variables:
54
+
55
+ | Variable | Description |
56
+ | --- | --- |
57
+ | `AUTH_TOKEN` | Bearer Token used by clients to access this service |
58
+ | `ADMIN_PASSWORD` | Admin panel login password, default value must be changed |
59
+ | `LISTEN_PORT` | Service listening port, default `8080` |
60
+ | `ANONYMOUS_MODE` | Whether to enable anonymous mode |
61
+ | `GUEST_POOL_SIZE` | Anonymous pool capacity |
62
+ | `DB_PATH` | SQLite database path |
63
+ | `TOKEN_FAILURE_THRESHOLD` | Token consecutive failure threshold |
64
+ | `TOKEN_RECOVERY_TIMEOUT` | Token recovery wait time |
65
+
66
+ Complete configuration please see [.env.example](.env.example).
67
+
68
+ ## Admin Panel
69
+
70
+ Admin panel unified entry:
71
+
72
+ - `/admin`: Dashboard
73
+ - `/admin/tokens`: Token management
74
+ - `/admin/config`: Configuration management
75
+ - `/admin/logs`: Real-time logs
76
+
77
+ ## Common Commands
78
+
79
+ ```bash
80
+ # Start service
81
+ uv run python main.py
82
+
83
+ # Run tests
84
+ uv run pytest
85
+
86
+ # Run an existing smoke test
87
+ uv run python tests/test_simple_signature.py
88
+
89
+ # Lint
90
+ uv run ruff check app tests main.py
91
+ ```
92
+
93
+ ## Compatible Interfaces
94
+
95
+ Common interface entries:
96
+
97
+ - OpenAI compatible: `/v1/chat/completions`
98
+ - Anthropic compatible: `/v1/messages`
99
+ - Claude Code compatible: `/anthropic/v1/messages`
100
+
101
+ Model mapping and default model can be adjusted in `.env` or admin configuration page.
102
+
103
+ ## ⭐ Star History
104
+
105
+ [![Star History Chart](https://api.star-history.com/svg?repos=ZyphrZero/z.ai2api_python&type=Date)](https://star-history.com/#ZyphrZero/z.ai2api_python&Date)
106
+
107
+ ## License
108
+
109
+ This project uses MIT license - see [LICENSE](LICENSE) file for details.
110
+
111
+ ## Disclaimer
112
+
113
+ - **This project is for learning and research use only, do not use for other purposes**
114
+ - This project is not affiliated with Z.AI official
115
+ - Please ensure compliance with Z.AI's terms of service before use
116
+ - Do not use for commercial purposes or scenarios that violate terms of service
117
+ - Users must bear their own usage risks
118
+
119
+ ---
120
+
121
+ <div align="center">
122
+ Made with ❤️ by the community
123
+ </div>
app/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ from app import core, models, utils
5
+
6
+ __all__ = ["core", "models", "utils"]
app/admin/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """
2
+ 管理后台模块初始化
3
+ """
app/admin/api.py ADDED
@@ -0,0 +1,1111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 管理后台 API 接口
3
+ 用于 htmx 调用的 HTML 片段返回
4
+ """
5
+ from datetime import datetime
6
+ from html import escape
7
+ from pathlib import Path
8
+ import re
9
+ from typing import Optional
10
+
11
+ from fastapi import APIRouter, Depends, Request
12
+ from fastapi.responses import HTMLResponse, JSONResponse
13
+ from fastapi.templating import Jinja2Templates
14
+
15
+ from app.admin.auth import require_auth
16
+ from app.admin.config_manager import (
17
+ read_env_content,
18
+ reset_env_to_example,
19
+ save_form_config,
20
+ save_source_config,
21
+ )
22
+ from app.admin.stats import collect_admin_stats, normalize_trend_window
23
+ from app.services.request_log_dao import get_request_log_dao
24
+ from app.utils.logger import logger
25
+
26
+ router = APIRouter(prefix="/admin/api", tags=["admin-api"])
27
+ templates = Jinja2Templates(directory="app/templates")
28
+ DEFAULT_TOKEN_NAMESPACE = "zai"
29
+
30
+
31
+ # ==================== 认证 API ====================
32
+
33
+ @router.post("/login")
34
+ async def login(request: Request):
35
+ """管理后台登录"""
36
+ from app.admin.auth import create_session
37
+
38
+ try:
39
+ data = await request.json()
40
+ password = data.get("password", "")
41
+
42
+ # 创建 session
43
+ session_token = create_session(password)
44
+
45
+ if session_token:
46
+ # 登录成功,设置 cookie
47
+ response = JSONResponse({
48
+ "success": True,
49
+ "message": "登录成功"
50
+ })
51
+ response.set_cookie(
52
+ key="admin_session",
53
+ value=session_token,
54
+ httponly=True,
55
+ max_age=86400, # 24小时
56
+ samesite="lax"
57
+ )
58
+ logger.info("✅ 管理后台登录成功")
59
+ return response
60
+ else:
61
+ # 密码错误
62
+ logger.warning("❌ 管理后台登录失败:密码错误")
63
+ return JSONResponse({
64
+ "success": False,
65
+ "message": "密码错误"
66
+ }, status_code=401)
67
+
68
+ except Exception as e:
69
+ logger.error(f"❌ 登录异常: {e}")
70
+ return JSONResponse({
71
+ "success": False,
72
+ "message": "登录失败"
73
+ }, status_code=500)
74
+
75
+
76
+ @router.post("/logout")
77
+ async def logout(request: Request):
78
+ """管理后台登出"""
79
+ from app.admin.auth import delete_session, get_session_token_from_request
80
+
81
+ session_token = get_session_token_from_request(request)
82
+ delete_session(session_token)
83
+
84
+ # 清除 cookie
85
+ response = JSONResponse({
86
+ "success": True,
87
+ "message": "已登出"
88
+ })
89
+ response.delete_cookie("admin_session")
90
+ logger.info("✅ 管理后台已登出")
91
+ return response
92
+
93
+
94
+ async def reload_settings():
95
+ """热重载配置(重新加载环境变量并更新 settings 对象)"""
96
+ from dotenv import load_dotenv
97
+
98
+ from app.core.config import settings
99
+ from app.utils.logger import setup_logger
100
+
101
+ # 重新加载 .env 文件
102
+ load_dotenv(override=True)
103
+
104
+ # 重新创建 Settings 对象并更新全局配置
105
+ new_settings = type(settings)()
106
+
107
+ # 更新全局 settings 的所有属性
108
+ for field_name in new_settings.model_fields.keys():
109
+ setattr(settings, field_name, getattr(new_settings, field_name))
110
+
111
+ # 重新初始化 logger(使用新的 DEBUG_LOGGING 配置)
112
+ setup_logger(log_dir="logs", debug_mode=settings.DEBUG_LOGGING)
113
+
114
+ logger.info(f"🔄 配置已热重载 (DEBUG_LOGGING={settings.DEBUG_LOGGING})")
115
+
116
+
117
+ def _build_alert(
118
+ message: str,
119
+ *,
120
+ title: str,
121
+ level: str,
122
+ status_code: int = 200,
123
+ ) -> HTMLResponse:
124
+ level_classes = {
125
+ "success": "bg-green-100 border-green-400 text-green-700",
126
+ "warning": "bg-yellow-100 border-yellow-400 text-yellow-700",
127
+ "error": "bg-red-100 border-red-400 text-red-700",
128
+ "info": "bg-blue-100 border-blue-400 text-blue-700",
129
+ }
130
+ classes = level_classes.get(level, level_classes["info"])
131
+ safe_title = escape(title)
132
+ safe_message = escape(message)
133
+ return HTMLResponse(
134
+ f"""
135
+ <div class="{classes} border px-4 py-3 rounded relative" role="alert">
136
+ <strong class="font-bold">{safe_title}</strong>
137
+ <span class="block sm:inline">{safe_message}</span>
138
+ </div>
139
+ """,
140
+ status_code=status_code,
141
+ )
142
+
143
+
144
+ def _with_hx_trigger(response: HTMLResponse, event_name: str) -> HTMLResponse:
145
+ response.headers["HX-Trigger"] = event_name
146
+ return response
147
+
148
+
149
+ def _get_int_query_param(
150
+ request: Request,
151
+ name: str,
152
+ default: int,
153
+ *,
154
+ minimum: int = 1,
155
+ maximum: Optional[int] = None,
156
+ ) -> int:
157
+ """解析查询参数中的正整数,非法值回退到默认值。"""
158
+ raw_value = request.query_params.get(name)
159
+ if raw_value is None:
160
+ return default
161
+
162
+ try:
163
+ value = int(str(raw_value).strip())
164
+ except (TypeError, ValueError):
165
+ return default
166
+
167
+ value = max(minimum, value)
168
+ if maximum is not None:
169
+ value = min(value, maximum)
170
+ return value
171
+
172
+
173
+ def _build_pagination(
174
+ *,
175
+ total_items: int,
176
+ page: int,
177
+ page_size: int,
178
+ ) -> dict:
179
+ """构建分页上下文。"""
180
+ total_items = max(0, int(total_items))
181
+ page_size = max(1, int(page_size))
182
+ total_pages = max(1, (total_items + page_size - 1) // page_size)
183
+ current_page = min(max(1, int(page)), total_pages)
184
+
185
+ if total_items == 0:
186
+ start_item = 0
187
+ end_item = 0
188
+ else:
189
+ start_item = (current_page - 1) * page_size + 1
190
+ end_item = min(total_items, current_page * page_size)
191
+
192
+ return {
193
+ "current_page": current_page,
194
+ "page_size": page_size,
195
+ "total_items": total_items,
196
+ "total_pages": total_pages,
197
+ "has_previous": current_page > 1,
198
+ "has_next": current_page < total_pages,
199
+ "previous_page": max(1, current_page - 1),
200
+ "next_page": min(total_pages, current_page + 1),
201
+ "start_item": start_item,
202
+ "end_item": end_item,
203
+ }
204
+
205
+
206
+ def _normalize_display_value(value: str) -> str:
207
+ normalized = re.sub(r"[^a-z0-9]+", "", str(value or "").casefold())
208
+ return normalized
209
+
210
+
211
+ def _is_redundant_source(source: str, client_name: str) -> bool:
212
+ normalized_source = _normalize_display_value(source)
213
+ normalized_client = _normalize_display_value(client_name)
214
+ if not normalized_source:
215
+ return True
216
+ if not normalized_client:
217
+ return False
218
+ return normalized_source == normalized_client
219
+
220
+
221
+ def _humanize_protocol(protocol: str) -> str:
222
+ normalized = str(protocol or "").strip().lower()
223
+ if normalized == "openai":
224
+ return "OpenAI"
225
+ if normalized == "anthropic":
226
+ return "Anthropic"
227
+ if normalized == "unknown":
228
+ return "Unknown"
229
+ return normalized or "Unknown"
230
+
231
+
232
+ @router.get(
233
+ "/dashboard/usage-trend",
234
+ response_class=JSONResponse,
235
+ dependencies=[Depends(require_auth)],
236
+ )
237
+ async def get_dashboard_usage_trend(request: Request):
238
+ """返回仪表盘趋势图数据。"""
239
+ trend_window = normalize_trend_window(
240
+ request.query_params.get("window")
241
+ )
242
+ dao = get_request_log_dao()
243
+ trend_points = await dao.get_provider_usage_trend(
244
+ DEFAULT_TOKEN_NAMESPACE,
245
+ window=trend_window,
246
+ )
247
+ return JSONResponse(
248
+ {
249
+ "window": trend_window,
250
+ "points": trend_points,
251
+ }
252
+ )
253
+
254
+
255
+ def _validate_directory_path(source_dir: str) -> str:
256
+ if not source_dir:
257
+ raise ValueError("请先填写服务端可访问的本地目录路径。")
258
+
259
+ source_path = Path(source_dir).expanduser()
260
+ if not source_path.exists():
261
+ raise ValueError(f"导入目录不存在: {source_path}")
262
+ if not source_path.is_dir():
263
+ raise ValueError(f"导入路径不是目录: {source_path}")
264
+
265
+ return str(source_path)
266
+
267
+
268
+ @router.get("/token-pool", response_class=HTMLResponse)
269
+ async def get_token_pool_status(request: Request):
270
+ """获取 Token 池状态(HTML 片段)"""
271
+ from app.utils.token_pool import get_token_pool
272
+
273
+ token_pool = get_token_pool()
274
+
275
+ if not token_pool:
276
+ # Token 池未初始化
277
+ context = {
278
+ "request": request,
279
+ "tokens": [],
280
+ }
281
+ return templates.TemplateResponse("components/token_pool.html", context)
282
+
283
+ # 获取 token 状态统计
284
+ pool_status = token_pool.get_pool_status()
285
+ tokens_info = []
286
+
287
+ for idx, token_info in enumerate(pool_status.get("tokens", []), 1):
288
+ is_available = token_info.get("is_available", False)
289
+ is_healthy = token_info.get("is_healthy", False)
290
+
291
+ # 确定状态和颜色
292
+ if is_healthy:
293
+ status = "健康"
294
+ status_color = "bg-green-100 text-green-800"
295
+ elif is_available:
296
+ status = "可用"
297
+ status_color = "bg-yellow-100 text-yellow-800"
298
+ else:
299
+ status = "失败"
300
+ status_color = "bg-red-100 text-red-800"
301
+
302
+ # 格式化最后使用时间
303
+ last_success = token_info.get("last_success_time", 0)
304
+ if last_success > 0:
305
+ from datetime import datetime
306
+ last_used = datetime.fromtimestamp(last_success).strftime("%Y-%m-%d %H:%M:%S")
307
+ else:
308
+ last_used = "从未使用"
309
+
310
+ tokens_info.append({
311
+ "index": idx,
312
+ "key": token_info.get("token", "")[:20] + "...",
313
+ "status": status,
314
+ "status_color": status_color,
315
+ "last_used": last_used,
316
+ "failure_count": token_info.get("failure_count", 0),
317
+ "success_rate": token_info.get("success_rate", "0%"),
318
+ "token_type": token_info.get("token_type", "unknown"),
319
+ })
320
+
321
+ context = {
322
+ "request": request,
323
+ "tokens": tokens_info,
324
+ }
325
+
326
+ return templates.TemplateResponse("components/token_pool.html", context)
327
+
328
+
329
+ @router.get("/recent-logs", response_class=HTMLResponse)
330
+ async def get_recent_logs(request: Request):
331
+ """获取最近的请求日志(HTML 片段)"""
332
+ dao = get_request_log_dao()
333
+ page_size = _get_int_query_param(
334
+ request,
335
+ "page_size",
336
+ 12,
337
+ maximum=50,
338
+ )
339
+ requested_page = _get_int_query_param(request, "page", 1, maximum=100000)
340
+ total_count = await dao.count_logs()
341
+ pagination = _build_pagination(
342
+ total_items=total_count,
343
+ page=requested_page,
344
+ page_size=page_size,
345
+ )
346
+
347
+ rows = await dao.get_recent_logs(
348
+ limit=page_size,
349
+ offset=(pagination["current_page"] - 1) * page_size,
350
+ )
351
+ logs = []
352
+ for row in rows:
353
+ timestamp = (
354
+ row.get("timestamp")
355
+ or row.get("created_at")
356
+ or datetime.now().strftime("%Y-%m-%d %H:%M:%S")
357
+ )
358
+ success = bool(row.get("success"))
359
+ status_code = int(
360
+ row.get("status_code") or (200 if success else 500)
361
+ )
362
+ duration_value = float(row.get("duration") or 0.0)
363
+ first_token_value = float(row.get("first_token_time") or 0.0)
364
+ source = row.get("source") or "unknown"
365
+ client_name = row.get("client_name") or "Unknown"
366
+ provider = row.get("provider") or "-"
367
+ source_display = (
368
+ ""
369
+ if _is_redundant_source(source, client_name)
370
+ else source
371
+ )
372
+ provider_display = "" if provider == "zai" else provider
373
+ logs.append(
374
+ {
375
+ "timestamp": timestamp,
376
+ "endpoint": row.get("endpoint") or "-",
377
+ "model": row.get("model") or "-",
378
+ "provider": provider,
379
+ "provider_display": provider_display,
380
+ "source": source,
381
+ "source_display": source_display,
382
+ "protocol": row.get("protocol") or "unknown",
383
+ "protocol_display": _humanize_protocol(
384
+ row.get("protocol") or "unknown"
385
+ ),
386
+ "client_name": client_name,
387
+ "success": success,
388
+ "status_code": status_code,
389
+ "duration_display": f"{duration_value:.2f}s",
390
+ "first_token_display": (
391
+ f"{first_token_value:.2f}s"
392
+ if first_token_value > 0
393
+ else "--"
394
+ ),
395
+ "input_tokens": int(row.get("input_tokens") or 0),
396
+ "output_tokens": int(row.get("output_tokens") or 0),
397
+ "cache_creation_tokens": int(
398
+ row.get("cache_creation_tokens") or 0
399
+ ),
400
+ "cache_read_tokens": int(
401
+ row.get("cache_read_tokens") or 0
402
+ ),
403
+ "error_message": row.get("error_message") or "",
404
+ }
405
+ )
406
+
407
+ context = {
408
+ "request": request,
409
+ "logs": logs,
410
+ "page": pagination,
411
+ }
412
+
413
+ return templates.TemplateResponse("components/recent_logs.html", context)
414
+
415
+
416
+ @router.post("/config/save", dependencies=[Depends(require_auth)])
417
+ async def save_config(request: Request):
418
+ """保存结构化配置并热重载。"""
419
+ try:
420
+ form_data = await request.form()
421
+ await save_form_config(
422
+ form_data,
423
+ reload_callback=reload_settings,
424
+ )
425
+ logger.info("✅ 结构化配置已保存")
426
+ return _with_hx_trigger(
427
+ _build_alert(
428
+ "配置已保存并热重载,页面即将刷新。",
429
+ title="保存成功!",
430
+ level="success",
431
+ ),
432
+ "admin-config-refresh",
433
+ )
434
+ except ValueError as exc:
435
+ return _build_alert(
436
+ str(exc),
437
+ title="校验失败!",
438
+ level="error",
439
+ status_code=400,
440
+ )
441
+ except Exception as exc:
442
+ logger.error(f"❌ 配置保存失败: {exc}")
443
+ return _build_alert(
444
+ f"保存失败: {exc}",
445
+ title="错误!",
446
+ level="error",
447
+ status_code=500,
448
+ )
449
+
450
+
451
+ @router.post("/config/source", dependencies=[Depends(require_auth)])
452
+ async def save_config_source(request: Request):
453
+ """保存 .env 源文件并热重载。"""
454
+ try:
455
+ form_data = await request.form()
456
+ await save_source_config(
457
+ str(form_data.get("env_content", "")),
458
+ reload_callback=reload_settings,
459
+ )
460
+ logger.info("✅ 配置源文件已保存")
461
+ return _with_hx_trigger(
462
+ _build_alert(
463
+ ".env 源文件已保存并热重载,页面即将刷新。",
464
+ title="保存成功!",
465
+ level="success",
466
+ ),
467
+ "admin-config-refresh",
468
+ )
469
+ except ValueError as exc:
470
+ return _build_alert(
471
+ str(exc),
472
+ title="源文件校验失败!",
473
+ level="error",
474
+ status_code=400,
475
+ )
476
+ except Exception as exc:
477
+ logger.error(f"❌ 源��件保存失败: {exc}")
478
+ return _build_alert(
479
+ f"源文件保存失败: {exc}",
480
+ title="错误!",
481
+ level="error",
482
+ status_code=500,
483
+ )
484
+
485
+
486
+ @router.post("/config/reset", dependencies=[Depends(require_auth)])
487
+ async def reset_config():
488
+ """将配置重置为 .env.example 并热重载。"""
489
+ try:
490
+ await reset_env_to_example(reload_callback=reload_settings)
491
+ logger.info("✅ 配置已重置为 .env.example 默认值")
492
+ return _with_hx_trigger(
493
+ _build_alert(
494
+ "配置已恢复为 .env.example 默认值,页面即将刷新。",
495
+ title="已重置!",
496
+ level="success",
497
+ ),
498
+ "admin-config-refresh",
499
+ )
500
+ except FileNotFoundError:
501
+ logger.error("❌ 未找到 .env.example,无法重置配置")
502
+ return _build_alert(
503
+ "未找到 .env.example,无法重置配置。",
504
+ title="错误!",
505
+ level="error",
506
+ status_code=404,
507
+ )
508
+ except Exception as exc:
509
+ logger.error(f"❌ 配置重置失败: {exc}")
510
+ return _build_alert(
511
+ f"重置失败: {exc}",
512
+ title="错误!",
513
+ level="error",
514
+ status_code=500,
515
+ )
516
+
517
+
518
+ @router.get("/env-preview", dependencies=[Depends(require_auth)])
519
+ async def get_env_preview():
520
+ """获取 .env 文件预览"""
521
+ try:
522
+ content = read_env_content()
523
+ if not content:
524
+ content = "# .env 文件不存在"
525
+ return HTMLResponse(f"<pre>{escape(content)}</pre>")
526
+ except Exception as exc:
527
+ return HTMLResponse(f"<pre># 读取失败: {escape(str(exc))}</pre>")
528
+
529
+
530
+ @router.get("/live-logs", response_class=HTMLResponse)
531
+ async def get_live_logs():
532
+ """获取实时日志(最新 50 行)"""
533
+ import os
534
+ from datetime import datetime
535
+
536
+ logs = []
537
+
538
+ # 尝试读取日志文件
539
+ log_dir = "logs"
540
+ if os.path.exists(log_dir):
541
+ log_files = sorted([f for f in os.listdir(log_dir) if f.endswith('.log')], reverse=True)
542
+ if log_files:
543
+ log_file = os.path.join(log_dir, log_files[0])
544
+ try:
545
+ with open(log_file, 'r', encoding='utf-8') as f:
546
+ # 读取最后 50 行
547
+ lines = f.readlines()[-50:]
548
+ logs = lines
549
+ except Exception as e:
550
+ logs = [f"# [{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] 读取日志失败: {str(e)}"]
551
+
552
+ if not logs:
553
+ logs = [f"# [{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] 暂无日志数据"]
554
+
555
+ html = ""
556
+ for log in logs:
557
+ log_line = log.strip()
558
+ if not log_line:
559
+ continue
560
+
561
+ # 根据日志级别设置颜色和样式
562
+ if "ERROR" in log_line or "CRITICAL" in log_line:
563
+ color_class = "text-red-400 font-semibold"
564
+ icon = "❌"
565
+ elif "WARNING" in log_line or "WARN" in log_line:
566
+ color_class = "text-yellow-400"
567
+ icon = "⚠️"
568
+ elif "SUCCESS" in log_line or "✅" in log_line:
569
+ color_class = "text-green-400"
570
+ icon = "✅"
571
+ elif "INFO" in log_line:
572
+ color_class = "text-blue-400"
573
+ icon = "ℹ️"
574
+ elif "DEBUG" in log_line:
575
+ color_class = "text-gray-400 text-xs"
576
+ icon = "🔍"
577
+ else:
578
+ color_class = "text-gray-300"
579
+ icon = "•"
580
+
581
+ # 转义 HTML 特殊字符
582
+ log_escaped = log_line.replace('<', '&lt;').replace('>', '&gt;')
583
+
584
+ html += f'<div class="{color_class} py-0.5 hover:bg-gray-800 px-2 rounded transition-colors">{icon} {log_escaped}</div>'
585
+
586
+ return HTMLResponse(html)
587
+
588
+
589
+ # ==================== Token 管理 API ====================
590
+
591
+ @router.get("/tokens/list", response_class=HTMLResponse)
592
+ async def get_tokens_list(request: Request):
593
+ """获取 Token 列表(HTML 片段)"""
594
+ from app.services.token_dao import get_token_dao
595
+
596
+ dao = get_token_dao()
597
+ page_size = _get_int_query_param(
598
+ request,
599
+ "page_size",
600
+ 20,
601
+ maximum=100,
602
+ )
603
+ requested_page = _get_int_query_param(request, "page", 1, maximum=100000)
604
+ total_count = await dao.count_tokens_by_provider(
605
+ DEFAULT_TOKEN_NAMESPACE,
606
+ enabled_only=False,
607
+ )
608
+ pagination = _build_pagination(
609
+ total_items=total_count,
610
+ page=requested_page,
611
+ page_size=page_size,
612
+ )
613
+ tokens = await dao.get_tokens_by_provider(
614
+ DEFAULT_TOKEN_NAMESPACE,
615
+ enabled_only=False,
616
+ limit=page_size,
617
+ offset=(pagination["current_page"] - 1) * page_size,
618
+ )
619
+
620
+ context = {
621
+ "request": request,
622
+ "tokens": tokens,
623
+ "page": pagination,
624
+ }
625
+
626
+ return templates.TemplateResponse("components/token_list.html", context)
627
+
628
+
629
+ @router.post("/tokens/add")
630
+ async def add_tokens(request: Request):
631
+ """添�� Token"""
632
+ from app.services.token_dao import get_token_dao
633
+ from app.utils.token_pool import get_token_pool
634
+
635
+ form_data = await request.form()
636
+ single_token = form_data.get("single_token", "").strip()
637
+ bulk_tokens = form_data.get("bulk_tokens", "").strip()
638
+
639
+ dao = get_token_dao()
640
+ added_count = 0
641
+ failed_count = 0
642
+
643
+ # 添加单个 Token(带验证)
644
+ if single_token:
645
+ token_id = await dao.add_token(
646
+ DEFAULT_TOKEN_NAMESPACE,
647
+ single_token,
648
+ validate=True,
649
+ )
650
+ if token_id:
651
+ added_count += 1
652
+ else:
653
+ failed_count += 1
654
+
655
+ # 批量添加 Token(带验证)
656
+ if bulk_tokens:
657
+ # 支持换行和逗号分隔
658
+ tokens = []
659
+ for line in bulk_tokens.split('\n'):
660
+ line = line.strip()
661
+ if ',' in line:
662
+ tokens.extend([t.strip() for t in line.split(',') if t.strip()])
663
+ elif line:
664
+ tokens.append(line)
665
+
666
+ success, failed = await dao.bulk_add_tokens(
667
+ DEFAULT_TOKEN_NAMESPACE,
668
+ tokens,
669
+ validate=True,
670
+ )
671
+ added_count += success
672
+ failed_count += failed
673
+
674
+ # 同步 Token 池状态(如果有新增成功的 Token)
675
+ if added_count > 0:
676
+ pool = get_token_pool()
677
+ if pool:
678
+ await pool.sync_from_database(DEFAULT_TOKEN_NAMESPACE)
679
+ logger.info(f"✅ Token 池已同步,新增 {added_count} 个 Token")
680
+
681
+ # 生成响应
682
+ if added_count > 0 and failed_count == 0:
683
+ return HTMLResponse(f"""
684
+ <div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded relative" role="alert">
685
+ <strong class="font-bold">成功!</strong>
686
+ <span class="block sm:inline">已添加 {added_count} 个有效 Token</span>
687
+ </div>
688
+ """)
689
+ elif added_count > 0 and failed_count > 0:
690
+ return HTMLResponse(f"""
691
+ <div class="bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded relative" role="alert">
692
+ <strong class="font-bold">部分成功!</strong>
693
+ <span class="block sm:inline">已添加 {added_count} 个 Token,{failed_count} 个失败(可能是重复、无效或匿名 Token)</span>
694
+ </div>
695
+ """)
696
+ else:
697
+ return HTMLResponse("""
698
+ <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
699
+ <strong class="font-bold">失败!</strong>
700
+ <span class="block sm:inline">所有 Token 添加失败(可能是重复、无效或匿名 Token)</span>
701
+ </div>
702
+ """)
703
+
704
+
705
+ @router.post("/tokens/import-directory", dependencies=[Depends(require_auth)])
706
+ async def import_tokens_from_directory_api(request: Request):
707
+ """从本地目录导入 token 文件。"""
708
+ from app.core.config import settings
709
+ from app.services.token_automation import run_directory_import
710
+
711
+ form_data = await request.form()
712
+ source_dir = str(
713
+ form_data.get("source_dir")
714
+ or settings.TOKEN_AUTO_IMPORT_SOURCE_DIR
715
+ or ""
716
+ ).strip()
717
+ try:
718
+ source_dir = _validate_directory_path(source_dir)
719
+ except ValueError as exc:
720
+ return _build_alert(
721
+ str(exc),
722
+ title="导入失败!",
723
+ level="error",
724
+ status_code=400,
725
+ )
726
+
727
+ try:
728
+ summary = await run_directory_import(
729
+ source_dir,
730
+ provider=DEFAULT_TOKEN_NAMESPACE,
731
+ validate=True,
732
+ )
733
+ except (FileNotFoundError, NotADirectoryError) as exc:
734
+ return _build_alert(
735
+ str(exc),
736
+ title="导入失败!",
737
+ level="error",
738
+ status_code=400,
739
+ )
740
+ except RuntimeError as exc:
741
+ return _build_alert(
742
+ str(exc),
743
+ title="导入稍后重试",
744
+ level="warning",
745
+ status_code=409,
746
+ )
747
+ except Exception as exc:
748
+ logger.exception(f"❌ 本地目录导入 Token 失败: {exc}")
749
+ return _build_alert(
750
+ f"目录扫描或入库异常: {exc}",
751
+ title="导入失败!",
752
+ level="error",
753
+ status_code=500,
754
+ )
755
+
756
+ if summary.imported_count > 0:
757
+ title = "导入成功!" if summary.failed_count == 0 else "导入完成!"
758
+ detail = (
759
+ f"目录 {summary.source_dir} 共扫描 {summary.scanned_files} 个文件,"
760
+ f"成功导入 {summary.imported_count} 个 Token,"
761
+ f"重复 {summary.duplicate_count} 个,"
762
+ f"无效 JSON {summary.invalid_json_count} 个,"
763
+ f"缺少 token {summary.missing_token_count} 个,"
764
+ f"验证失败 {summary.invalid_token_count} 个。"
765
+ )
766
+ return _build_alert(
767
+ detail,
768
+ title=title,
769
+ level="success" if summary.failed_count == 0 else "warning",
770
+ )
771
+
772
+ return _build_alert(
773
+ (
774
+ f"目录 {summary.source_dir} 共扫描 {summary.scanned_files} 个文件,"
775
+ f"其中重复 {summary.duplicate_count} 个,无效 JSON {summary.invalid_json_count} 个,"
776
+ f"缺少 token {summary.missing_token_count} 个,验证失败 {summary.invalid_token_count} 个。"
777
+ ),
778
+ title="未导入任何 Token!",
779
+ level="warning",
780
+ )
781
+
782
+
783
+ @router.post("/tokens/auto-import/save", dependencies=[Depends(require_auth)])
784
+ async def save_auto_import_settings(request: Request):
785
+ """兼容旧入口,提示用户改到配置管理页。"""
786
+ return _build_alert(
787
+ "自动导入配置入口已迁移到 /admin/config#tokens,当前页面仅保留手动执行入口。",
788
+ title="入口已迁移",
789
+ level="info",
790
+ )
791
+
792
+
793
+ @router.post("/tokens/maintenance/save", dependencies=[Depends(require_auth)])
794
+ async def save_auto_maintenance_settings(request: Request):
795
+ """兼容旧入口,提示用户改到配置管理页。"""
796
+ return _build_alert(
797
+ "自动维护配置入口已迁移到 /admin/config#tokens,当前页面仅保留手动执行入口。",
798
+ title="入口已迁移",
799
+ level="info",
800
+ )
801
+
802
+
803
+ @router.post("/tokens/maintenance/run", dependencies=[Depends(require_auth)])
804
+ async def run_token_maintenance_api(request: Request):
805
+ """立即执行一次 Token 维护。"""
806
+ from app.core.config import settings
807
+ from app.services.token_automation import run_token_maintenance
808
+
809
+ form_data = await request.form()
810
+ action_fields = (
811
+ "auto_remove_duplicates",
812
+ "auto_health_check",
813
+ "auto_delete_invalid",
814
+ )
815
+ has_explicit_actions = any(field in form_data for field in action_fields)
816
+
817
+ if has_explicit_actions:
818
+ remove_duplicates = "auto_remove_duplicates" in form_data
819
+ run_health_check = "auto_health_check" in form_data
820
+ delete_invalid = "auto_delete_invalid" in form_data
821
+ else:
822
+ remove_duplicates = settings.TOKEN_AUTO_REMOVE_DUPLICATES
823
+ run_health_check = settings.TOKEN_AUTO_HEALTH_CHECK
824
+ delete_invalid = settings.TOKEN_AUTO_DELETE_INVALID
825
+
826
+ if not any((remove_duplicates, run_health_check, delete_invalid)):
827
+ return _build_alert(
828
+ "当前没有可执行的维护动作,请先到 /admin/config#tokens 配置至少一个维护动作。",
829
+ title="未执行维护!",
830
+ level="warning",
831
+ status_code=400,
832
+ )
833
+
834
+ try:
835
+ summary = await run_token_maintenance(
836
+ provider=DEFAULT_TOKEN_NAMESPACE,
837
+ remove_duplicates=remove_duplicates,
838
+ run_health_check=run_health_check,
839
+ delete_invalid_tokens=delete_invalid,
840
+ )
841
+ except RuntimeError as exc:
842
+ return _build_alert(
843
+ str(exc),
844
+ title="维护稍后重试",
845
+ level="warning",
846
+ status_code=409,
847
+ )
848
+ except Exception as exc:
849
+ logger.exception(f"❌ 手动执行 Token 维护失败: {exc}")
850
+ return _build_alert(
851
+ f"Token 维护失败: {exc}",
852
+ title="维护失败!",
853
+ level="error",
854
+ status_code=500,
855
+ )
856
+
857
+ return _build_alert(
858
+ (
859
+ f"本次维护共去重 {summary.duplicate_removed_count} 个,"
860
+ f"测活 {summary.checked_count} 个(有效 {summary.valid_count} / "
861
+ f"匿名 {summary.guest_count} / 无效 {summary.invalid_count}),"
862
+ f"删除失效 Token {summary.deleted_invalid_count} 个。"
863
+ ),
864
+ title="维护完成!",
865
+ level="success",
866
+ )
867
+
868
+
869
+ @router.post("/tokens/toggle/{token_id}")
870
+ async def toggle_token(token_id: int, enabled: bool):
871
+ """切换 Token 启用状态"""
872
+ from app.services.token_dao import get_token_dao
873
+ from app.utils.token_pool import get_token_pool
874
+
875
+ dao = get_token_dao()
876
+ await dao.update_token_status(token_id, enabled)
877
+
878
+ # 同步 Token 池状态
879
+ pool = get_token_pool()
880
+ if pool:
881
+ # 获取 Token 的提供商信息
882
+ async with dao.get_connection() as conn:
883
+ cursor = await conn.execute("SELECT provider FROM tokens WHERE id = ?", (token_id,))
884
+ row = await cursor.fetchone()
885
+ if row:
886
+ provider = row[0]
887
+ await pool.sync_from_database(provider)
888
+ logger.info("✅ Token 池已同步")
889
+
890
+ # 根据状态返回不同样式的按钮
891
+ if enabled:
892
+ button_class = "bg-green-100 text-green-800 hover:bg-green-200"
893
+ indicator_class = "bg-green-500"
894
+ label = "已启用"
895
+ next_state = "false"
896
+ else:
897
+ button_class = "bg-red-100 text-red-800 hover:bg-red-200"
898
+ indicator_class = "bg-red-500"
899
+ label = "已禁用"
900
+ next_state = "true"
901
+
902
+ return HTMLResponse(f"""
903
+ <button hx-post="/admin/api/tokens/toggle/{token_id}?enabled={next_state}"
904
+ hx-swap="outerHTML"
905
+ class="inline-flex items-center px-2.5 py-0.5 text-xs font-semibold rounded-full transition-colors {button_class}">
906
+ <span class="h-2 w-2 rounded-full mr-1.5 {indicator_class}"></span>
907
+ {label}
908
+ </button>
909
+ """)
910
+
911
+
912
+ @router.delete("/tokens/delete/{token_id}")
913
+ async def delete_token(token_id: int):
914
+ """删除 Token"""
915
+ from app.services.token_dao import get_token_dao
916
+ from app.utils.token_pool import get_token_pool
917
+
918
+ dao = get_token_dao()
919
+
920
+ # 获取 Token 信息以确定提供商
921
+ async with dao.get_connection() as conn:
922
+ cursor = await conn.execute("SELECT provider FROM tokens WHERE id = ?", (token_id,))
923
+ row = await cursor.fetchone()
924
+ provider = row[0] if row else "zai"
925
+
926
+ await dao.delete_token(token_id)
927
+
928
+ # 同步 Token 池状态
929
+ pool = get_token_pool()
930
+ if pool:
931
+ await pool.sync_from_database(provider)
932
+ logger.info("✅ Token 池已同步")
933
+
934
+ return HTMLResponse("") # 返回空内容,让 htmx 移除元素
935
+
936
+
937
+ @router.get("/tokens/stats", response_class=HTMLResponse)
938
+ async def get_tokens_stats(request: Request):
939
+ """获取 Token 统计信息(HTML 片段)"""
940
+ stats_data = await collect_admin_stats(DEFAULT_TOKEN_NAMESPACE)
941
+
942
+ context = {
943
+ "request": request,
944
+ "stats": stats_data,
945
+ }
946
+
947
+ return templates.TemplateResponse("components/token_stats.html", context)
948
+
949
+
950
+ @router.post("/tokens/validate")
951
+ async def validate_tokens():
952
+ """批量验证 Token"""
953
+ from app.services.token_dao import get_token_dao
954
+ from app.utils.token_pool import get_token_pool
955
+
956
+ dao = get_token_dao()
957
+
958
+ # 执行批量验证
959
+ stats = await dao.validate_all_tokens(DEFAULT_TOKEN_NAMESPACE)
960
+
961
+ pool = get_token_pool()
962
+ if pool:
963
+ await pool.sync_from_database(DEFAULT_TOKEN_NAMESPACE)
964
+
965
+ valid_count = stats.get("valid", 0)
966
+ guest_count = stats.get("guest", 0)
967
+ invalid_count = stats.get("invalid", 0)
968
+
969
+ # 生成通知消息
970
+ if guest_count > 0:
971
+ message_class = "bg-yellow-100 border-yellow-400 text-yellow-700"
972
+ message = f"验证完成:有效 {valid_count} 个,匿名 {guest_count} 个,无效 {invalid_count} 个。匿名 Token 已标记。"
973
+ elif invalid_count > 0:
974
+ message_class = "bg-blue-100 border-blue-400 text-blue-700"
975
+ message = f"验证完成:有效 {valid_count} 个,无效 {invalid_count} 个。"
976
+ else:
977
+ message_class = "bg-green-100 border-green-400 text-green-700"
978
+ message = f"验证完成:所有 {valid_count} 个 Token 均有效!"
979
+
980
+ return HTMLResponse(f"""
981
+ <div class="{message_class} border px-4 py-3 rounded relative" role="alert">
982
+ <strong class="font-bold">批量验证完成!</strong>
983
+ <span class="block sm:inline">{message}</span>
984
+ </div>
985
+ """)
986
+
987
+
988
+ @router.post("/tokens/validate-single/{token_id}")
989
+ async def validate_single_token(request: Request, token_id: int):
990
+ """验证单个 Token 并返回更新后的行"""
991
+ from app.services.token_dao import get_token_dao
992
+ from app.utils.token_pool import get_token_pool
993
+
994
+ dao = get_token_dao()
995
+
996
+ # 验证 Token
997
+ await dao.validate_and_update_token(token_id)
998
+
999
+ pool = get_token_pool()
1000
+ if pool:
1001
+ await pool.sync_from_database(DEFAULT_TOKEN_NAMESPACE)
1002
+
1003
+ # 获取更新后的 Token 信息
1004
+ async with dao.get_connection() as conn:
1005
+ cursor = await conn.execute("""
1006
+ SELECT t.*, ts.total_requests, ts.successful_requests, ts.failed_requests,
1007
+ ts.last_success_time, ts.last_failure_time
1008
+ FROM tokens t
1009
+ LEFT JOIN token_stats ts ON t.id = ts.token_id
1010
+ WHERE t.id = ?
1011
+ """, (token_id,))
1012
+ row = await cursor.fetchone()
1013
+
1014
+ if row:
1015
+ # 返回更新后的单行 HTML
1016
+ token = dict(row)
1017
+ context = {
1018
+ "request": request,
1019
+ "token": token,
1020
+ }
1021
+ # 使用单行模板渲染
1022
+ return templates.TemplateResponse("components/token_row.html", context)
1023
+ else:
1024
+ return HTMLResponse("")
1025
+
1026
+
1027
+ @router.post("/tokens/health-check")
1028
+ async def health_check_tokens():
1029
+ """执行 Token 池健康检查"""
1030
+ from app.utils.token_pool import get_token_pool
1031
+
1032
+ pool = get_token_pool()
1033
+
1034
+ if not pool:
1035
+ return HTMLResponse("""
1036
+ <div class="bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded relative" role="alert">
1037
+ <strong class="font-bold">提示!</strong>
1038
+ <span class="block sm:inline">Token 池未初始化,请重启服务。</span>
1039
+ </div>
1040
+ """)
1041
+
1042
+ # 执行健康检查
1043
+ await pool.health_check_all()
1044
+
1045
+ # 获取健康状态
1046
+ status = pool.get_pool_status()
1047
+ healthy_count = status.get("healthy_tokens", 0)
1048
+ total_count = status.get("total_tokens", 0)
1049
+
1050
+ if healthy_count == total_count:
1051
+ message_class = "bg-green-100 border-green-400 text-green-700"
1052
+ message = f"所有 {total_count} 个 Token 均健康!"
1053
+ elif healthy_count > 0:
1054
+ message_class = "bg-blue-100 border-blue-400 text-blue-700"
1055
+ message = f"健康检查完成:{healthy_count}/{total_count} 个 Token 健康。"
1056
+ else:
1057
+ message_class = "bg-red-100 border-red-400 text-red-700"
1058
+ message = f"警告:0/{total_count} 个 Token 健康,请检查配置。"
1059
+
1060
+ return HTMLResponse(f"""
1061
+ <div class="{message_class} border px-4 py-3 rounded relative" role="alert">
1062
+ <strong class="font-bold">健康检查完成!</strong>
1063
+ <span class="block sm:inline">{message}</span>
1064
+ </div>
1065
+ """)
1066
+
1067
+
1068
+ @router.post("/tokens/sync-pool")
1069
+ async def sync_token_pool():
1070
+ """手动同步 Token 池(从数据库重新加载)"""
1071
+ from app.utils.token_pool import get_token_pool
1072
+
1073
+ pool = get_token_pool()
1074
+
1075
+ if not pool:
1076
+ return HTMLResponse("""
1077
+ <div class="bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded relative" role="alert">
1078
+ <strong class="font-bold">提示!</strong>
1079
+ <span class="block sm:inline">Token 池未初始化,请重启服务。</span>
1080
+ </div>
1081
+ """)
1082
+
1083
+ # 从数据库同步
1084
+ await pool.sync_from_database(DEFAULT_TOKEN_NAMESPACE)
1085
+
1086
+ # 获取同步后的状态
1087
+ status = pool.get_pool_status()
1088
+ total_count = status.get("total_tokens", 0)
1089
+ available_count = status.get("available_tokens", 0)
1090
+ user_count = status.get("user_tokens", 0)
1091
+
1092
+ logger.info(
1093
+ f"✅ Token 池手动同步完成,总计 {total_count} 个 Token, 可用 {available_count} 个, 认证用户 {user_count} 个"
1094
+ )
1095
+
1096
+ if total_count == 0:
1097
+ message_class = "bg-yellow-100 border-yellow-400 text-yellow-700"
1098
+ message = "同步完成:当前没有可用 Token,请在数据库中启用 Token。"
1099
+ elif available_count == 0:
1100
+ message_class = "bg-orange-100 border-orange-400 text-orange-700"
1101
+ message = f"同步完成:共 {total_count} 个 Token,但无可用 Token(可能都已禁用)。"
1102
+ else:
1103
+ message_class = "bg-green-100 border-green-400 text-green-700"
1104
+ message = f"同步完成:共 {total_count} 个 Token,{available_count} 个可用,{user_count} 个认证用户。"
1105
+
1106
+ return HTMLResponse(f"""
1107
+ <div class="{message_class} border px-4 py-3 rounded relative" role="alert">
1108
+ <strong class="font-bold">Token 池同步完成!</strong>
1109
+ <span class="block sm:inline">{message}</span>
1110
+ </div>
1111
+ """)
app/admin/auth.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 管理后台认证中间件
3
+ """
4
+ from fastapi import Request, HTTPException, status
5
+ from fastapi.responses import RedirectResponse
6
+ from typing import Optional
7
+ import hashlib
8
+ import secrets
9
+ from datetime import datetime, timedelta
10
+
11
+ from app.core.config import settings
12
+
13
+ # 简单的内存 Session 存储(生产环境建议使用 Redis)
14
+ _sessions = {}
15
+
16
+ # Session 有效期(小时)
17
+ SESSION_EXPIRE_HOURS = 24
18
+
19
+
20
+ def generate_session_token() -> str:
21
+ """生成随机 session token"""
22
+ return secrets.token_urlsafe(32)
23
+
24
+
25
+ def create_session(password: str) -> Optional[str]:
26
+ """
27
+ 创建 session
28
+
29
+ Args:
30
+ password: 用户输入的密码
31
+
32
+ Returns:
33
+ session_token 或 None(密码错误)
34
+ """
35
+ # 验证密码
36
+ if password != settings.ADMIN_PASSWORD:
37
+ return None
38
+
39
+ # 生成 session token
40
+ session_token = generate_session_token()
41
+
42
+ # 存储 session(包含过期时间)
43
+ _sessions[session_token] = {
44
+ "created_at": datetime.now(),
45
+ "expires_at": datetime.now() + timedelta(hours=SESSION_EXPIRE_HOURS),
46
+ "authenticated": True
47
+ }
48
+
49
+ return session_token
50
+
51
+
52
+ def verify_session(session_token: Optional[str]) -> bool:
53
+ """
54
+ 验证 session 是否有效
55
+
56
+ Args:
57
+ session_token: Session token
58
+
59
+ Returns:
60
+ 是否已认证
61
+ """
62
+ if not session_token:
63
+ return False
64
+
65
+ session = _sessions.get(session_token)
66
+ if not session:
67
+ return False
68
+
69
+ # 检查是否过期
70
+ if datetime.now() > session["expires_at"]:
71
+ # 删除过期 session
72
+ del _sessions[session_token]
73
+ return False
74
+
75
+ return session.get("authenticated", False)
76
+
77
+
78
+ def delete_session(session_token: Optional[str]):
79
+ """删除 session(登出)"""
80
+ if session_token and session_token in _sessions:
81
+ del _sessions[session_token]
82
+
83
+
84
+ def get_session_token_from_request(request: Request) -> Optional[str]:
85
+ """从请求中获取 session token"""
86
+ return request.cookies.get("admin_session")
87
+
88
+
89
+ async def require_auth(request: Request):
90
+ """
91
+ 认证依赖项:要求用户已登录
92
+
93
+ 在路由中使用:
94
+ @router.get("/admin", dependencies=[Depends(require_auth)])
95
+ """
96
+ session_token = get_session_token_from_request(request)
97
+
98
+ if not verify_session(session_token):
99
+ # 未认证,重定向到登录页
100
+ raise HTTPException(
101
+ status_code=status.HTTP_303_SEE_OTHER,
102
+ detail="未登录",
103
+ headers={"Location": "/admin/login"}
104
+ )
105
+
106
+
107
+ def get_authenticated_user(request: Request) -> bool:
108
+ """
109
+ 获取当前认证状态(用于模板)
110
+
111
+ Returns:
112
+ 是否已认证
113
+ """
114
+ session_token = get_session_token_from_request(request)
115
+ return verify_session(session_token)
116
+
117
+
118
+ def cleanup_expired_sessions():
119
+ """清理过期的 session(定时任务调用)"""
120
+ now = datetime.now()
121
+ expired_tokens = [
122
+ token for token, session in _sessions.items()
123
+ if now > session["expires_at"]
124
+ ]
125
+
126
+ for token in expired_tokens:
127
+ del _sessions[token]
128
+
129
+ return len(expired_tokens)
app/admin/config_manager.py ADDED
@@ -0,0 +1,682 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Admin config metadata and helpers for the configuration console."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any, Awaitable, Callable, Mapping
9
+
10
+ from dotenv import dotenv_values
11
+
12
+ from app.core.config import settings
13
+ from app.utils.env_file import update_env_file
14
+ from app.utils.logger import logger
15
+
16
+ ENV_PATH = Path(".env")
17
+ ENV_EXAMPLE_PATH = Path(".env.example")
18
+ _ENV_SOURCE_LINE_PATTERN = re.compile(
19
+ r"^\s*(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=.*$"
20
+ )
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class ConfigFieldSpec:
25
+ key: str
26
+ label: str
27
+ description: str
28
+ value_type: str
29
+ default_value: object
30
+ input_type: str = "text"
31
+ placeholder: str = ""
32
+ required: bool = False
33
+ wide: bool = False
34
+ sensitive: bool = False
35
+ restart_required: bool = False
36
+ min_value: int | None = None
37
+ max_value: int | None = None
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class ConfigSectionSpec:
42
+ id: str
43
+ title: str
44
+ description: str
45
+ fields: tuple[ConfigFieldSpec, ...]
46
+
47
+
48
+ CONFIG_SECTIONS: tuple[ConfigSectionSpec, ...] = (
49
+ ConfigSectionSpec(
50
+ id="access",
51
+ title="接入与认证",
52
+ description="控制上游接口地址、客户端鉴权和 Function Call 行为。",
53
+ fields=(
54
+ ConfigFieldSpec(
55
+ key="API_ENDPOINT",
56
+ label="上游 API 地址",
57
+ description="代理请求实际转发到的上游聊天完成接口。",
58
+ value_type="str",
59
+ default_value="https://chat.z.ai/api/v2/chat/completions",
60
+ input_type="url",
61
+ placeholder="https://chat.z.ai/api/v2/chat/completions",
62
+ required=True,
63
+ wide=True,
64
+ ),
65
+ ConfigFieldSpec(
66
+ key="AUTH_TOKEN",
67
+ label="客户端认证密钥",
68
+ description="客户端访问本服务时使用的 Bearer Token。",
69
+ value_type="str",
70
+ default_value="sk-your-api-key",
71
+ input_type="password",
72
+ placeholder="sk-your-api-key",
73
+ wide=True,
74
+ sensitive=True,
75
+ ),
76
+ ConfigFieldSpec(
77
+ key="SKIP_AUTH_TOKEN",
78
+ label="跳过客户端认证",
79
+ description="仅建议开发环境使用,开启后不校验 AUTH_TOKEN。",
80
+ value_type="bool",
81
+ default_value=False,
82
+ ),
83
+ ConfigFieldSpec(
84
+ key="TOOL_SUPPORT",
85
+ label="启用 Function Call",
86
+ description="允许 OpenAI 兼容接口使用工具调用能力。",
87
+ value_type="bool",
88
+ default_value=True,
89
+ ),
90
+ ConfigFieldSpec(
91
+ key="SCAN_LIMIT",
92
+ label="工具调用扫描限制",
93
+ description="Function Call 扫描的最大字符数。",
94
+ value_type="int",
95
+ default_value=200000,
96
+ input_type="number",
97
+ min_value=1,
98
+ placeholder="200000",
99
+ ),
100
+ ),
101
+ ),
102
+ ConfigSectionSpec(
103
+ id="server",
104
+ title="服务运行",
105
+ description="服务监听、日志、数据库路径和反向代理前缀。",
106
+ fields=(
107
+ ConfigFieldSpec(
108
+ key="SERVICE_NAME",
109
+ label="服务名称",
110
+ description="显示在进程列表中的服务名称。",
111
+ value_type="str",
112
+ default_value="api-proxy-server",
113
+ placeholder="api-proxy-server",
114
+ required=True,
115
+ restart_required=True,
116
+ ),
117
+ ConfigFieldSpec(
118
+ key="LISTEN_PORT",
119
+ label="监听端口",
120
+ description="HTTP 服务监听端口。",
121
+ value_type="int",
122
+ default_value=8080,
123
+ input_type="number",
124
+ min_value=1,
125
+ max_value=65535,
126
+ required=True,
127
+ restart_required=True,
128
+ placeholder="8080",
129
+ ),
130
+ ConfigFieldSpec(
131
+ key="ROOT_PATH",
132
+ label="反向代理路径前缀",
133
+ description="例如 /api,部署在子路径时使用。",
134
+ value_type="str",
135
+ default_value="",
136
+ placeholder="/api",
137
+ restart_required=True,
138
+ ),
139
+ ConfigFieldSpec(
140
+ key="DEBUG_LOGGING",
141
+ label="启用调试日志",
142
+ description="开启后会输出更详细的调试信息。",
143
+ value_type="bool",
144
+ default_value=False,
145
+ ),
146
+ ConfigFieldSpec(
147
+ key="DB_PATH",
148
+ label="数据库路径",
149
+ description="SQLite 数据库文件位置。",
150
+ value_type="str",
151
+ default_value="tokens.db",
152
+ placeholder="tokens.db",
153
+ required=True,
154
+ wide=True,
155
+ restart_required=True,
156
+ ),
157
+ ),
158
+ ),
159
+ ConfigSectionSpec(
160
+ id="tokens",
161
+ title="Token 池策略",
162
+ description="失败判定、恢复时间和自动导入、自动维护计划任务。",
163
+ fields=(
164
+ ConfigFieldSpec(
165
+ key="TOKEN_FAILURE_THRESHOLD",
166
+ label="失败阈值",
167
+ description="连续失败多少次后将 Token 标记为不可用。",
168
+ value_type="int",
169
+ default_value=3,
170
+ input_type="number",
171
+ min_value=1,
172
+ required=True,
173
+ restart_required=True,
174
+ ),
175
+ ConfigFieldSpec(
176
+ key="TOKEN_RECOVERY_TIMEOUT",
177
+ label="恢复超时(秒)",
178
+ description="失败 Token 重新参与调度前的等待时间。",
179
+ value_type="int",
180
+ default_value=1800,
181
+ input_type="number",
182
+ min_value=1,
183
+ required=True,
184
+ restart_required=True,
185
+ ),
186
+ ConfigFieldSpec(
187
+ key="TOKEN_AUTO_IMPORT_ENABLED",
188
+ label="启用自动导入",
189
+ description="按固定周期扫描服务端目录并导入 Token。",
190
+ value_type="bool",
191
+ default_value=False,
192
+ ),
193
+ ConfigFieldSpec(
194
+ key="TOKEN_AUTO_IMPORT_SOURCE_DIR",
195
+ label="自动导入目录",
196
+ description="服务端本地目录,开启自动导入时需要可访问。",
197
+ value_type="str",
198
+ default_value="",
199
+ placeholder="E:\\tokens\\input",
200
+ wide=True,
201
+ ),
202
+ ConfigFieldSpec(
203
+ key="TOKEN_AUTO_IMPORT_INTERVAL",
204
+ label="自动导入间隔(秒)",
205
+ description="自动导入的扫描周期。",
206
+ value_type="int",
207
+ default_value=300,
208
+ input_type="number",
209
+ min_value=1,
210
+ required=True,
211
+ ),
212
+ ConfigFieldSpec(
213
+ key="TOKEN_AUTO_MAINTENANCE_ENABLED",
214
+ label="启用自动维护",
215
+ description="定时执行去重、健康检查和删除失效 Token。",
216
+ value_type="bool",
217
+ default_value=False,
218
+ ),
219
+ ConfigFieldSpec(
220
+ key="TOKEN_AUTO_MAINTENANCE_INTERVAL",
221
+ label="自动维护间隔(秒)",
222
+ description="自动维护的执行周期。",
223
+ value_type="int",
224
+ default_value=1800,
225
+ input_type="number",
226
+ min_value=1,
227
+ required=True,
228
+ ),
229
+ ConfigFieldSpec(
230
+ key="TOKEN_AUTO_REMOVE_DUPLICATES",
231
+ label="自动去重",
232
+ description="自动维护时清理重复 Token。",
233
+ value_type="bool",
234
+ default_value=True,
235
+ ),
236
+ ConfigFieldSpec(
237
+ key="TOKEN_AUTO_HEALTH_CHECK",
238
+ label="自动健康检查",
239
+ description="自动维护时验证 Token 可用性。",
240
+ value_type="bool",
241
+ default_value=True,
242
+ ),
243
+ ConfigFieldSpec(
244
+ key="TOKEN_AUTO_DELETE_INVALID",
245
+ label="自动删除失效 Token",
246
+ description="自动维护时移除已验证为无效的 Token。",
247
+ value_type="bool",
248
+ default_value=False,
249
+ ),
250
+ ),
251
+ ),
252
+ ConfigSectionSpec(
253
+ id="guest",
254
+ title="匿名 Guest 会话池",
255
+ description="没有用户 Token 时,仅控制是否启用匿名池和池容量。",
256
+ fields=(
257
+ ConfigFieldSpec(
258
+ key="ANONYMOUS_MODE",
259
+ label="启用匿名模式",
260
+ description="无可用用户 Token 时允许使用匿名会话。",
261
+ value_type="bool",
262
+ default_value=True,
263
+ restart_required=True,
264
+ ),
265
+ ConfigFieldSpec(
266
+ key="GUEST_POOL_SIZE",
267
+ label="Guest 池容量",
268
+ description="启动和维持的 guest 会话数量。",
269
+ value_type="int",
270
+ default_value=3,
271
+ input_type="number",
272
+ min_value=1,
273
+ required=True,
274
+ restart_required=True,
275
+ ),
276
+ ),
277
+ ),
278
+ ConfigSectionSpec(
279
+ id="models",
280
+ title="模型映射",
281
+ description="映射 OpenAI 兼容模型名到上游 Z.AI 实际模型名。",
282
+ fields=(
283
+ ConfigFieldSpec(
284
+ key="GLM45_MODEL",
285
+ label="GLM 4.5",
286
+ description="标准 GLM 4.5 模型标识。",
287
+ value_type="str",
288
+ default_value="GLM-4.5",
289
+ placeholder="GLM-4.5",
290
+ required=True,
291
+ ),
292
+ ConfigFieldSpec(
293
+ key="GLM45_THINKING_MODEL",
294
+ label="GLM 4.5 Thinking",
295
+ description="推理增强版 GLM 4.5 模型标识。",
296
+ value_type="str",
297
+ default_value="GLM-4.5-Thinking",
298
+ placeholder="GLM-4.5-Thinking",
299
+ required=True,
300
+ ),
301
+ ConfigFieldSpec(
302
+ key="GLM45_SEARCH_MODEL",
303
+ label="GLM 4.5 Search",
304
+ description="搜索增强版 GLM 4.5 模型标识。",
305
+ value_type="str",
306
+ default_value="GLM-4.5-Search",
307
+ placeholder="GLM-4.5-Search",
308
+ required=True,
309
+ ),
310
+ ConfigFieldSpec(
311
+ key="GLM45_AIR_MODEL",
312
+ label="GLM 4.5 Air",
313
+ description="轻量版 GLM 4.5 模型标识。",
314
+ value_type="str",
315
+ default_value="GLM-4.5-Air",
316
+ placeholder="GLM-4.5-Air",
317
+ required=True,
318
+ ),
319
+ ConfigFieldSpec(
320
+ key="GLM46V_MODEL",
321
+ label="GLM 4.6V",
322
+ description="视觉模型标识。",
323
+ value_type="str",
324
+ default_value="GLM-4.6V",
325
+ placeholder="GLM-4.6V",
326
+ required=True,
327
+ ),
328
+ ConfigFieldSpec(
329
+ key="GLM5_MODEL",
330
+ label="GLM 5",
331
+ description="GLM 5 模型标识。",
332
+ value_type="str",
333
+ default_value="GLM-5",
334
+ placeholder="GLM-5",
335
+ required=True,
336
+ ),
337
+ ConfigFieldSpec(
338
+ key="GLM47_MODEL",
339
+ label="GLM 4.7",
340
+ description="GLM 4.7 主模型标识。",
341
+ value_type="str",
342
+ default_value="GLM-4.7",
343
+ placeholder="GLM-4.7",
344
+ required=True,
345
+ ),
346
+ ConfigFieldSpec(
347
+ key="GLM47_THINKING_MODEL",
348
+ label="GLM 4.7 Thinking",
349
+ description="GLM 4.7 推理版模型标识。",
350
+ value_type="str",
351
+ default_value="GLM-4.7-Thinking",
352
+ placeholder="GLM-4.7-Thinking",
353
+ required=True,
354
+ ),
355
+ ConfigFieldSpec(
356
+ key="GLM47_SEARCH_MODEL",
357
+ label="GLM 4.7 Search",
358
+ description="GLM 4.7 搜索版模型标识。",
359
+ value_type="str",
360
+ default_value="GLM-4.7-Search",
361
+ placeholder="GLM-4.7-Search",
362
+ required=True,
363
+ ),
364
+ ConfigFieldSpec(
365
+ key="GLM47_ADVANCED_SEARCH_MODEL",
366
+ label="GLM 4.7 Advanced Search",
367
+ description="GLM 4.7 高级搜索模型标识。",
368
+ value_type="str",
369
+ default_value="GLM-4.7-advanced-search",
370
+ placeholder="GLM-4.7-advanced-search",
371
+ required=True,
372
+ wide=True,
373
+ ),
374
+ ),
375
+ ),
376
+ ConfigSectionSpec(
377
+ id="proxy",
378
+ title="代理网络",
379
+ description="上游访问使用的 HTTP、HTTPS 和 SOCKS5 代理。",
380
+ fields=(
381
+ ConfigFieldSpec(
382
+ key="HTTP_PROXY",
383
+ label="HTTP 代理",
384
+ description="例如 http://127.0.0.1:7890。",
385
+ value_type="str",
386
+ default_value="",
387
+ placeholder="http://127.0.0.1:7890",
388
+ wide=True,
389
+ ),
390
+ ConfigFieldSpec(
391
+ key="HTTPS_PROXY",
392
+ label="HTTPS 代理",
393
+ description="例如 http://127.0.0.1:7890。",
394
+ value_type="str",
395
+ default_value="",
396
+ placeholder="http://127.0.0.1:7890",
397
+ wide=True,
398
+ ),
399
+ ConfigFieldSpec(
400
+ key="SOCKS5_PROXY",
401
+ label="SOCKS5 代理",
402
+ description="例如 socks5://127.0.0.1:1080。",
403
+ value_type="str",
404
+ default_value="",
405
+ placeholder="socks5://127.0.0.1:1080",
406
+ wide=True,
407
+ ),
408
+ ),
409
+ ),
410
+ ConfigSectionSpec(
411
+ id="admin",
412
+ title="后台安全",
413
+ description="管理后台密码和会话密钥。修改后建议重新登录。",
414
+ fields=(
415
+ ConfigFieldSpec(
416
+ key="ADMIN_PASSWORD",
417
+ label="后台密码",
418
+ description="管理后台登录密码。",
419
+ value_type="str",
420
+ default_value="admin123",
421
+ input_type="password",
422
+ placeholder="admin123",
423
+ required=True,
424
+ sensitive=True,
425
+ ),
426
+ ConfigFieldSpec(
427
+ key="SESSION_SECRET_KEY",
428
+ label="会话密钥",
429
+ description="用于后台会话签名的密钥。",
430
+ value_type="str",
431
+ default_value="your-secret-key-change-in-production",
432
+ input_type="password",
433
+ placeholder="your-secret-key-change-in-production",
434
+ required=True,
435
+ sensitive=True,
436
+ wide=True,
437
+ ),
438
+ ),
439
+ ),
440
+ )
441
+
442
+ CONFIG_FIELD_SPECS = {
443
+ field.key: field
444
+ for section in CONFIG_SECTIONS
445
+ for field in section.fields
446
+ }
447
+ MANAGED_ENV_KEYS = tuple(CONFIG_FIELD_SPECS.keys())
448
+ ReloadCallback = Callable[[], Awaitable[None]]
449
+
450
+
451
+ def read_env_content(env_path: str | Path = ENV_PATH) -> str:
452
+ path = Path(env_path)
453
+ if not path.exists():
454
+ return ""
455
+ return path.read_text(encoding="utf-8")
456
+
457
+
458
+ def validate_env_source(content: str) -> str:
459
+ normalized = content.replace("\r\n", "\n").replace("\r", "\n")
460
+
461
+ for line_number, line in enumerate(normalized.splitlines(), start=1):
462
+ stripped = line.strip()
463
+ if not stripped or stripped.startswith("#"):
464
+ continue
465
+ if not _ENV_SOURCE_LINE_PATTERN.match(line):
466
+ raise ValueError(
467
+ f"第 {line_number} 行不是合法的 KEY=VALUE 格式。"
468
+ )
469
+
470
+ return normalized
471
+
472
+
473
+ def build_config_page_data(
474
+ *,
475
+ settings_obj: Any = settings,
476
+ env_path: str | Path = ENV_PATH,
477
+ env_example_path: str | Path = ENV_EXAMPLE_PATH,
478
+ ) -> dict[str, Any]:
479
+ env_file = Path(env_path)
480
+ env_content = read_env_content(env_file)
481
+ env_values = dotenv_values(env_file) if env_file.exists() else {}
482
+ sections: list[dict[str, Any]] = []
483
+ total_fields = 0
484
+ overridden_fields = 0
485
+ sensitive_fields = 0
486
+ restart_required_fields = 0
487
+
488
+ for section in CONFIG_SECTIONS:
489
+ rendered_fields: list[dict[str, Any]] = []
490
+ for field in section.fields:
491
+ total_fields += 1
492
+ if field.sensitive:
493
+ sensitive_fields += 1
494
+ if field.restart_required:
495
+ restart_required_fields += 1
496
+
497
+ is_overridden = field.key in env_values
498
+ if is_overridden:
499
+ overridden_fields += 1
500
+
501
+ value = getattr(settings_obj, field.key, field.default_value)
502
+ if value is None:
503
+ value = ""
504
+
505
+ rendered_fields.append(
506
+ {
507
+ "key": field.key,
508
+ "label": field.label,
509
+ "description": field.description,
510
+ "value_type": field.value_type,
511
+ "value": value,
512
+ "input_type": field.input_type,
513
+ "placeholder": field.placeholder,
514
+ "required": field.required,
515
+ "wide": field.wide,
516
+ "sensitive": field.sensitive,
517
+ "restart_required": field.restart_required,
518
+ "min_value": field.min_value,
519
+ "max_value": field.max_value,
520
+ "source_label": ".env" if is_overridden else "默认值",
521
+ "source_badge_class": (
522
+ "bg-emerald-50 text-emerald-700 ring-emerald-200"
523
+ if is_overridden
524
+ else "bg-slate-100 text-slate-600 ring-slate-200"
525
+ ),
526
+ }
527
+ )
528
+
529
+ sections.append(
530
+ {
531
+ "id": section.id,
532
+ "title": section.title,
533
+ "description": section.description,
534
+ "fields": rendered_fields,
535
+ "field_count": len(rendered_fields),
536
+ }
537
+ )
538
+
539
+ return {
540
+ "sections": sections,
541
+ "env_content": env_content,
542
+ "overview": {
543
+ "total_sections": len(CONFIG_SECTIONS),
544
+ "total_fields": total_fields,
545
+ "overridden_fields": overridden_fields,
546
+ "default_fields": total_fields - overridden_fields,
547
+ "sensitive_fields": sensitive_fields,
548
+ "restart_required_fields": restart_required_fields,
549
+ "env_exists": env_file.exists(),
550
+ "env_path": str(env_file.resolve()),
551
+ "env_line_count": len(env_content.splitlines()) if env_content else 0,
552
+ "example_exists": Path(env_example_path).exists(),
553
+ },
554
+ }
555
+
556
+
557
+ def build_form_updates(form_data: Mapping[str, Any]) -> dict[str, object]:
558
+ updates: dict[str, object] = {}
559
+
560
+ for key in MANAGED_ENV_KEYS:
561
+ field = CONFIG_FIELD_SPECS[key]
562
+
563
+ if field.value_type == "bool":
564
+ updates[key] = key in form_data
565
+ continue
566
+
567
+ raw_value = str(form_data.get(key, "") or "").strip()
568
+ if field.required and raw_value == "":
569
+ raise ValueError(f"{field.label} 不能为空。")
570
+
571
+ if field.value_type == "int":
572
+ try:
573
+ parsed = int(raw_value)
574
+ except ValueError as exc:
575
+ raise ValueError(f"{field.label} 必须是整数。") from exc
576
+
577
+ if field.min_value is not None and parsed < field.min_value:
578
+ raise ValueError(
579
+ f"{field.label} 不能小于 {field.min_value}。"
580
+ )
581
+ if field.max_value is not None and parsed > field.max_value:
582
+ raise ValueError(
583
+ f"{field.label} 不能大于 {field.max_value}。"
584
+ )
585
+ updates[key] = parsed
586
+ continue
587
+
588
+ updates[key] = raw_value
589
+
590
+ return updates
591
+
592
+
593
+ async def _apply_env_change(
594
+ writer: Callable[[Path], None],
595
+ *,
596
+ reload_callback: ReloadCallback,
597
+ env_path: str | Path = ENV_PATH,
598
+ ) -> None:
599
+ path = Path(env_path)
600
+ had_existing_file = path.exists()
601
+ previous_content = read_env_content(path) if had_existing_file else ""
602
+
603
+ try:
604
+ writer(path)
605
+ await reload_callback()
606
+ except Exception:
607
+ if had_existing_file:
608
+ path.write_text(previous_content, encoding="utf-8")
609
+ elif path.exists():
610
+ path.unlink()
611
+
612
+ try:
613
+ await reload_callback()
614
+ except Exception as restore_exc:
615
+ logger.warning(f"⚠️ 回滚配置后重新加载失败: {restore_exc}")
616
+ raise
617
+
618
+
619
+ async def save_form_config(
620
+ form_data: Mapping[str, Any],
621
+ *,
622
+ reload_callback: ReloadCallback,
623
+ env_path: str | Path = ENV_PATH,
624
+ ) -> dict[str, object]:
625
+ updates = build_form_updates(form_data)
626
+
627
+ async def _reload() -> None:
628
+ await reload_callback()
629
+
630
+ def _writer(target_path: Path) -> None:
631
+ update_env_file(updates, env_path=target_path)
632
+
633
+ await _apply_env_change(_writer, reload_callback=_reload, env_path=env_path)
634
+ return updates
635
+
636
+
637
+ async def save_source_config(
638
+ env_content: str,
639
+ *,
640
+ reload_callback: ReloadCallback,
641
+ env_path: str | Path = ENV_PATH,
642
+ ) -> None:
643
+ normalized = validate_env_source(env_content)
644
+
645
+ def _writer(target_path: Path) -> None:
646
+ content = normalized.rstrip("\n")
647
+ target_path.write_text(
648
+ f"{content}\n" if content else "",
649
+ encoding="utf-8",
650
+ )
651
+
652
+ await _apply_env_change(
653
+ _writer,
654
+ reload_callback=reload_callback,
655
+ env_path=env_path,
656
+ )
657
+
658
+
659
+ async def reset_env_to_example(
660
+ *,
661
+ reload_callback: ReloadCallback,
662
+ env_path: str | Path = ENV_PATH,
663
+ env_example_path: str | Path = ENV_EXAMPLE_PATH,
664
+ ) -> None:
665
+ example_path = Path(env_example_path)
666
+ if not example_path.exists():
667
+ raise FileNotFoundError(".env.example 不存在")
668
+
669
+ example_content = example_path.read_text(encoding="utf-8")
670
+
671
+ def _writer(target_path: Path) -> None:
672
+ content = example_content.rstrip("\n")
673
+ target_path.write_text(
674
+ f"{content}\n" if content else "",
675
+ encoding="utf-8",
676
+ )
677
+
678
+ await _apply_env_change(
679
+ _writer,
680
+ reload_callback=reload_callback,
681
+ env_path=env_path,
682
+ )
app/admin/routes.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 管理后台路由模块
3
+ """
4
+ from datetime import datetime
5
+
6
+ from fastapi import APIRouter, Depends, Request
7
+ from fastapi.responses import HTMLResponse
8
+ from fastapi.templating import Jinja2Templates
9
+
10
+ from app.admin.auth import require_auth
11
+ from app.admin.config_manager import build_config_page_data
12
+ from app.admin.stats import (
13
+ DEFAULT_TREND_WINDOW,
14
+ TREND_WINDOW_OPTIONS,
15
+ collect_admin_stats,
16
+ get_process_uptime,
17
+ )
18
+
19
+ router = APIRouter(prefix="/admin", tags=["admin"])
20
+ templates = Jinja2Templates(directory="app/templates")
21
+ DEFAULT_TOKEN_NAMESPACE = "zai"
22
+
23
+
24
+ @router.get("/login", response_class=HTMLResponse)
25
+ async def login_page(request: Request):
26
+ """登录页面"""
27
+ return templates.TemplateResponse("login.html", {"request": request})
28
+
29
+
30
+ @router.get("/", response_class=HTMLResponse, dependencies=[Depends(require_auth)])
31
+ async def dashboard(request: Request):
32
+ """仪表盘首页"""
33
+ stats = await collect_admin_stats(
34
+ DEFAULT_TOKEN_NAMESPACE,
35
+ trend_window=DEFAULT_TREND_WINDOW,
36
+ )
37
+ stats["uptime"] = get_process_uptime()
38
+
39
+ context = {
40
+ "request": request,
41
+ "stats": stats,
42
+ "current_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
43
+ "trend_windows": TREND_WINDOW_OPTIONS,
44
+ }
45
+
46
+ return templates.TemplateResponse("index.html", context)
47
+
48
+
49
+ @router.get(
50
+ "/config",
51
+ response_class=HTMLResponse,
52
+ dependencies=[Depends(require_auth)],
53
+ )
54
+ async def config_page(request: Request):
55
+ """配置管理页面"""
56
+ page_data = build_config_page_data()
57
+
58
+ context = {
59
+ "request": request,
60
+ "sections": page_data["sections"],
61
+ "env_content": page_data["env_content"],
62
+ "overview": page_data["overview"],
63
+ }
64
+ return templates.TemplateResponse("config.html", context)
65
+
66
+
67
+ @router.get("/logs", response_class=HTMLResponse, dependencies=[Depends(require_auth)])
68
+ async def logs_page(request: Request):
69
+ """实时日志页面"""
70
+ context = {
71
+ "request": request,
72
+ }
73
+ return templates.TemplateResponse("logs.html", context)
74
+
75
+
76
+ @router.get(
77
+ "/tokens",
78
+ response_class=HTMLResponse,
79
+ dependencies=[Depends(require_auth)],
80
+ )
81
+ async def tokens_page(request: Request):
82
+ """Token 管理页面"""
83
+ from app.core.config import settings
84
+
85
+ maintenance_actions: list[str] = []
86
+ if settings.TOKEN_AUTO_REMOVE_DUPLICATES:
87
+ maintenance_actions.append("删除重复 Token")
88
+ if settings.TOKEN_AUTO_HEALTH_CHECK:
89
+ maintenance_actions.append("批量测活")
90
+ if settings.TOKEN_AUTO_DELETE_INVALID:
91
+ maintenance_actions.append("删除失效 Token")
92
+
93
+ context = {
94
+ "request": request,
95
+ "automation": {
96
+ "config_url": "/admin/config#tokens",
97
+ "import_enabled": settings.TOKEN_AUTO_IMPORT_ENABLED,
98
+ "import_source_dir": settings.TOKEN_AUTO_IMPORT_SOURCE_DIR,
99
+ "import_interval": settings.TOKEN_AUTO_IMPORT_INTERVAL,
100
+ "has_import_source_dir": bool(
101
+ settings.TOKEN_AUTO_IMPORT_SOURCE_DIR.strip()
102
+ ),
103
+ "maintenance_enabled": settings.TOKEN_AUTO_MAINTENANCE_ENABLED,
104
+ "maintenance_interval": settings.TOKEN_AUTO_MAINTENANCE_INTERVAL,
105
+ "maintenance_actions": maintenance_actions,
106
+ "has_maintenance_actions": bool(maintenance_actions),
107
+ },
108
+ }
109
+ return templates.TemplateResponse("tokens.html", context)
app/admin/stats.py ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """管理后台统计聚合辅助函数。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import time
7
+ from typing import Any, Dict, Optional
8
+
9
+ import psutil
10
+
11
+ from app.services.request_log_dao import RequestLogDAO, get_request_log_dao
12
+ from app.services.token_dao import TokenDAO, get_token_dao
13
+ from app.utils.token_pool import TokenPool, get_token_pool
14
+
15
+ _TOKEN_POOL_SENTINEL = object()
16
+ DEFAULT_TREND_WINDOW = "7d"
17
+ TREND_WINDOW_OPTIONS = (
18
+ {"key": "24h", "label": "24 小时"},
19
+ {"key": "7d", "label": "7 天"},
20
+ {"key": "30d", "label": "30 天"},
21
+ )
22
+
23
+
24
+ def _coerce_int(value: Any) -> int:
25
+ """将数据库聚合结果安全转换为整数。"""
26
+ return int(value or 0)
27
+
28
+
29
+ def calculate_success_rate(
30
+ successful_requests: int,
31
+ total_requests: int,
32
+ ) -> float:
33
+ """计算成功率百分比。"""
34
+ if total_requests <= 0:
35
+ return 0.0
36
+ return round(successful_requests / total_requests * 100, 1)
37
+
38
+
39
+ def format_compact_number(value: Any) -> str:
40
+ """格式化大数字,便于仪表盘展示。"""
41
+ number = int(value or 0)
42
+ if number >= 1_000_000:
43
+ return f"{number / 1_000_000:.1f}M"
44
+ if number >= 10_000:
45
+ return f"{number / 10_000:.1f}万"
46
+ if number >= 1_000:
47
+ return f"{number / 1_000:.1f}k"
48
+ return str(number)
49
+
50
+
51
+ def normalize_trend_window(value: Any) -> str:
52
+ """规范化趋势窗口参数,非法值回退到默认值。"""
53
+ normalized = str(value or "").strip().lower()
54
+ if normalized in {"24h", "7d", "30d"}:
55
+ return normalized
56
+ if normalized == "1d":
57
+ return "24h"
58
+ return DEFAULT_TREND_WINDOW
59
+
60
+
61
+ def format_uptime(total_seconds: int) -> str:
62
+ """格式化运行时长。"""
63
+ total_seconds = max(0, int(total_seconds))
64
+ days, remainder = divmod(total_seconds, 86400)
65
+ hours, remainder = divmod(remainder, 3600)
66
+ minutes, seconds = divmod(remainder, 60)
67
+
68
+ parts = []
69
+ if days:
70
+ parts.append(f"{days}天")
71
+ if days or hours:
72
+ parts.append(f"{hours}小时")
73
+ if days or hours or minutes:
74
+ parts.append(f"{minutes}分钟")
75
+ parts.append(f"{seconds}秒")
76
+
77
+ return " ".join(parts)
78
+
79
+
80
+ def get_process_uptime() -> str:
81
+ """获取当前进程运行时长。"""
82
+ created_at = psutil.Process(os.getpid()).create_time()
83
+ return format_uptime(int(time.time() - created_at))
84
+
85
+
86
+ async def collect_admin_stats(
87
+ provider: str,
88
+ *,
89
+ token_dao: Optional[TokenDAO] = None,
90
+ request_log_dao: Optional[RequestLogDAO] = None,
91
+ token_pool: Any = _TOKEN_POOL_SENTINEL,
92
+ trend_window: str = DEFAULT_TREND_WINDOW,
93
+ ) -> Dict[str, Any]:
94
+ """聚合管理后台所需的 Token 与请求统计。"""
95
+ token_dao = token_dao or get_token_dao()
96
+ request_log_dao = request_log_dao or get_request_log_dao()
97
+ if token_pool is _TOKEN_POOL_SENTINEL:
98
+ token_pool = get_token_pool()
99
+ trend_window = normalize_trend_window(trend_window)
100
+
101
+ token_counts = await token_dao.get_provider_token_counts(provider)
102
+ request_stats = await request_log_dao.get_provider_request_stats(provider)
103
+ usage_trend = await request_log_dao.get_provider_usage_trend(
104
+ provider,
105
+ window=trend_window,
106
+ )
107
+
108
+ pool_status: Dict[str, Any] = {}
109
+ if isinstance(token_pool, TokenPool) or hasattr(token_pool, "get_pool_status"):
110
+ pool_status = token_pool.get_pool_status() if token_pool else {}
111
+
112
+ total_tokens = _coerce_int(token_counts.get("total_tokens"))
113
+ enabled_tokens = _coerce_int(token_counts.get("enabled_tokens"))
114
+ user_tokens = _coerce_int(token_counts.get("user_tokens"))
115
+ guest_tokens = _coerce_int(token_counts.get("guest_tokens"))
116
+ unknown_tokens = _coerce_int(token_counts.get("unknown_tokens"))
117
+
118
+ pool_total_tokens = _coerce_int(pool_status.get("total_tokens"))
119
+ if pool_total_tokens == 0 and token_pool is None:
120
+ pool_total_tokens = max(0, enabled_tokens - guest_tokens)
121
+
122
+ available_tokens = _coerce_int(pool_status.get("available_tokens"))
123
+ healthy_tokens = _coerce_int(pool_status.get("healthy_tokens"))
124
+ unhealthy_tokens = _coerce_int(pool_status.get("unhealthy_tokens"))
125
+
126
+ total_requests = _coerce_int(request_stats.get("total_requests"))
127
+ successful_requests = _coerce_int(request_stats.get("successful_requests"))
128
+ failed_requests = _coerce_int(request_stats.get("failed_requests"))
129
+ input_tokens = _coerce_int(request_stats.get("input_tokens"))
130
+ output_tokens = _coerce_int(request_stats.get("output_tokens"))
131
+ total_consumed_tokens = _coerce_int(request_stats.get("total_tokens"))
132
+ cache_creation_tokens = _coerce_int(
133
+ request_stats.get("cache_creation_tokens")
134
+ )
135
+ cache_read_tokens = _coerce_int(request_stats.get("cache_read_tokens"))
136
+ cache_creation_requests = _coerce_int(
137
+ request_stats.get("cache_creation_requests")
138
+ )
139
+ cache_hit_requests = _coerce_int(request_stats.get("cache_hit_requests"))
140
+ average_latency = round(float(request_stats.get("avg_duration") or 0.0), 2)
141
+ average_first_token_latency = round(
142
+ float(request_stats.get("avg_first_token_time") or 0.0),
143
+ 2,
144
+ )
145
+ total_cache_tokens = cache_creation_tokens + cache_read_tokens
146
+
147
+ return {
148
+ "total_tokens": total_tokens,
149
+ "enabled_tokens": enabled_tokens,
150
+ "user_tokens": user_tokens,
151
+ "guest_tokens": guest_tokens,
152
+ "unknown_tokens": unknown_tokens,
153
+ "pool_total_tokens": pool_total_tokens,
154
+ "available_tokens": available_tokens,
155
+ "healthy_tokens": healthy_tokens,
156
+ "unhealthy_tokens": unhealthy_tokens,
157
+ "total_requests": total_requests,
158
+ "successful_requests": successful_requests,
159
+ "failed_requests": failed_requests,
160
+ "input_tokens": input_tokens,
161
+ "output_tokens": output_tokens,
162
+ "total_consumed_tokens": total_consumed_tokens,
163
+ "cache_creation_tokens": cache_creation_tokens,
164
+ "cache_read_tokens": cache_read_tokens,
165
+ "total_cache_tokens": total_cache_tokens,
166
+ "cache_creation_requests": cache_creation_requests,
167
+ "cache_hit_requests": cache_hit_requests,
168
+ "average_latency": average_latency,
169
+ "average_first_token_latency": average_first_token_latency,
170
+ "trend_window": trend_window,
171
+ "usage_trend": usage_trend,
172
+ "total_consumed_tokens_display": format_compact_number(
173
+ total_consumed_tokens
174
+ ),
175
+ "total_cache_tokens_display": format_compact_number(
176
+ total_cache_tokens
177
+ ),
178
+ "input_tokens_display": format_compact_number(input_tokens),
179
+ "output_tokens_display": format_compact_number(output_tokens),
180
+ "success_rate": calculate_success_rate(
181
+ successful_requests,
182
+ total_requests,
183
+ ),
184
+ }
app/core/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ from app.core import claude, config, openai
5
+
6
+ __all__ = ["claude", "config", "openai"]
app/core/claude.py ADDED
@@ -0,0 +1,582 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ import json
5
+ import math
6
+ import time
7
+ import uuid
8
+ from typing import Any, AsyncGenerator, Dict, List, Optional
9
+
10
+ from fastapi import APIRouter, Header, Request
11
+ from fastapi.responses import JSONResponse, StreamingResponse
12
+
13
+ from app.core.claude_compat import (
14
+ build_non_stream_response,
15
+ claude_messages_to_openai,
16
+ claude_tool_choice_to_openai,
17
+ claude_tools_to_openai,
18
+ extract_text,
19
+ make_claude_id,
20
+ sse_content_block_delta,
21
+ sse_content_block_start,
22
+ sse_content_block_stop,
23
+ sse_error,
24
+ sse_message_delta,
25
+ sse_message_start,
26
+ sse_message_stop,
27
+ sse_ping,
28
+ )
29
+ from app.core.config import settings
30
+ from app.core.openai import get_upstream_client
31
+ from app.models.schemas import Message, OpenAIRequest
32
+ from app.utils.logger import get_logger
33
+ from app.utils.request_logging import (
34
+ extract_openai_usage,
35
+ extract_claude_usage,
36
+ wrap_claude_stream_with_logging,
37
+ write_request_log,
38
+ )
39
+ from app.utils.request_source import detect_request_source, format_request_source
40
+
41
+ logger = get_logger()
42
+ router = APIRouter()
43
+
44
+
45
+ def _resolve_claude_model(model: Any) -> str:
46
+ """Map Claude/Claude Code model aliases to local upstream-supported models."""
47
+ if not isinstance(model, str) or not model.strip():
48
+ return settings.GLM5_MODEL
49
+
50
+ raw_model = model.strip()
51
+ normalized = raw_model.casefold()
52
+ if normalized.endswith("[1m]"):
53
+ normalized = normalized[:-4].rstrip()
54
+
55
+ direct_models = {
56
+ settings.GLM45_MODEL.casefold(): settings.GLM45_MODEL,
57
+ settings.GLM45_THINKING_MODEL.casefold(): settings.GLM45_THINKING_MODEL,
58
+ settings.GLM45_SEARCH_MODEL.casefold(): settings.GLM45_SEARCH_MODEL,
59
+ settings.GLM45_AIR_MODEL.casefold(): settings.GLM45_AIR_MODEL,
60
+ settings.GLM46V_MODEL.casefold(): settings.GLM46V_MODEL,
61
+ settings.GLM5_MODEL.casefold(): settings.GLM5_MODEL,
62
+ settings.GLM47_MODEL.casefold(): settings.GLM47_MODEL,
63
+ settings.GLM47_THINKING_MODEL.casefold(): settings.GLM47_THINKING_MODEL,
64
+ settings.GLM47_SEARCH_MODEL.casefold(): settings.GLM47_SEARCH_MODEL,
65
+ settings.GLM47_ADVANCED_SEARCH_MODEL.casefold(): settings.GLM47_ADVANCED_SEARCH_MODEL,
66
+ }
67
+ if normalized in direct_models:
68
+ return direct_models[normalized]
69
+
70
+ alias_map = {
71
+ "default": settings.GLM5_MODEL,
72
+ "sonnet": settings.GLM5_MODEL,
73
+ "haiku": settings.GLM45_AIR_MODEL,
74
+ "opus": settings.GLM5_MODEL,
75
+ "opusplan": settings.GLM47_THINKING_MODEL,
76
+ }
77
+ if normalized in alias_map:
78
+ return alias_map[normalized]
79
+
80
+ if normalized.startswith("claude-sonnet") or normalized.startswith("claude-3-7-sonnet") or normalized.startswith("claude-3-5-sonnet"):
81
+ return settings.GLM5_MODEL
82
+ if normalized.startswith("claude-opus") or normalized.startswith("claude-4-opus"):
83
+ return settings.GLM5_MODEL
84
+ if normalized.startswith("claude-haiku") or normalized.startswith("claude-3-5-haiku"):
85
+ return settings.GLM45_AIR_MODEL
86
+
87
+ return raw_model
88
+
89
+
90
+ def _estimate_tokens(text: str) -> int:
91
+ if not text:
92
+ return 0
93
+ return max(1, math.ceil(len(text) / 2))
94
+
95
+
96
+ def _extract_api_key(
97
+ authorization: Optional[str],
98
+ x_api_key: Optional[str],
99
+ ) -> Optional[str]:
100
+ if x_api_key:
101
+ return x_api_key
102
+ if authorization and authorization.startswith("Bearer "):
103
+ return authorization[7:]
104
+ return None
105
+
106
+
107
+ def _claude_error_response(
108
+ message: str,
109
+ status_code: int,
110
+ error_type: str,
111
+ ) -> JSONResponse:
112
+ return JSONResponse(
113
+ status_code=status_code,
114
+ content={
115
+ "type": "error",
116
+ "error": {"type": error_type, "message": message},
117
+ },
118
+ )
119
+
120
+
121
+ def _build_openai_request(body: Dict[str, Any]) -> OpenAIRequest:
122
+ system = body.get("system")
123
+ claude_messages = body.get("messages", [])
124
+ openai_messages = claude_messages_to_openai(system, claude_messages)
125
+ openai_tools = claude_tools_to_openai(body.get("tools"))
126
+ tool_choice = claude_tool_choice_to_openai(body.get("tool_choice"))
127
+
128
+ thinking = body.get("thinking")
129
+ enable_thinking = None
130
+ if isinstance(thinking, dict):
131
+ thinking_type = thinking.get("type")
132
+ if thinking_type == "enabled":
133
+ enable_thinking = True
134
+ elif thinking_type == "disabled":
135
+ enable_thinking = False
136
+
137
+ messages = [Message.model_validate(message) for message in openai_messages]
138
+ resolved_model = _resolve_claude_model(body.get("model", settings.GLM5_MODEL))
139
+ if resolved_model != body.get("model", settings.GLM5_MODEL):
140
+ logger.info(
141
+ f"🔀 Claude 模型映射: "
142
+ f"{body.get('model', settings.GLM5_MODEL)} -> {resolved_model}"
143
+ )
144
+
145
+ return OpenAIRequest(
146
+ model=resolved_model,
147
+ messages=messages,
148
+ stream=bool(body.get("stream", False)),
149
+ temperature=body.get("temperature"),
150
+ max_tokens=body.get("max_tokens"),
151
+ tools=openai_tools,
152
+ tool_choice=tool_choice,
153
+ enable_thinking=enable_thinking,
154
+ )
155
+
156
+
157
+ def _build_prompt_text(body: Dict[str, Any]) -> str:
158
+ prompt_parts: List[str] = []
159
+ system = body.get("system")
160
+ if system:
161
+ prompt_parts.append(extract_text(system))
162
+
163
+ for message in body.get("messages", []):
164
+ content = message.get("content") if isinstance(message, dict) else None
165
+ text = extract_text(content)
166
+ if text:
167
+ prompt_parts.append(text)
168
+
169
+ return "\n".join(part for part in prompt_parts if part)
170
+
171
+
172
+ def _normalize_tool_calls(tool_calls: Any) -> List[Dict[str, Any]]:
173
+ if not isinstance(tool_calls, list):
174
+ return []
175
+
176
+ normalized: List[Dict[str, Any]] = []
177
+ seen_ids = set()
178
+ for tool_call in tool_calls:
179
+ if not isinstance(tool_call, dict):
180
+ continue
181
+
182
+ tool_call_id = tool_call.get("id") or f"call_{uuid.uuid4().hex[:24]}"
183
+ if tool_call_id in seen_ids:
184
+ continue
185
+ seen_ids.add(tool_call_id)
186
+
187
+ function_data = (
188
+ tool_call.get("function")
189
+ if isinstance(tool_call.get("function"), dict)
190
+ else {}
191
+ )
192
+ arguments = function_data.get("arguments", "{}")
193
+ if not isinstance(arguments, str):
194
+ try:
195
+ arguments = json.dumps(arguments, ensure_ascii=False)
196
+ except Exception:
197
+ arguments = "{}"
198
+
199
+ normalized.append(
200
+ {
201
+ "id": tool_call_id,
202
+ "type": "function",
203
+ "function": {
204
+ "name": function_data.get("name", ""),
205
+ "arguments": arguments,
206
+ },
207
+ }
208
+ )
209
+
210
+ return normalized
211
+
212
+
213
+ def _convert_openai_response_to_claude(response: Dict[str, Any], msg_id: str) -> Dict[str, Any]:
214
+ choice = ((response.get("choices") or [{}])[0]) if isinstance(response, dict) else {}
215
+ message = choice.get("message") or {}
216
+ reasoning = message.get("reasoning_content")
217
+ usage = extract_openai_usage(response)
218
+ return build_non_stream_response(
219
+ msg_id=msg_id,
220
+ model=response.get("model", settings.GLM5_MODEL),
221
+ reasoning_parts=[reasoning] if isinstance(reasoning, str) and reasoning else [],
222
+ answer_text=message.get("content") or "",
223
+ tool_calls=_normalize_tool_calls(message.get("tool_calls")),
224
+ input_tokens=usage["input_tokens"],
225
+ output_tokens=usage["output_tokens"],
226
+ cache_creation_tokens=usage["cache_creation_tokens"],
227
+ cache_read_tokens=usage["cache_read_tokens"],
228
+ )
229
+
230
+
231
+ async def _stream_openai_to_claude(
232
+ openai_stream: AsyncGenerator[str, None],
233
+ msg_id: str,
234
+ model: str,
235
+ input_tokens: int,
236
+ ) -> AsyncGenerator[str, None]:
237
+ reasoning_parts: List[str] = []
238
+ answer_parts: List[str] = []
239
+ tool_calls: List[Dict[str, Any]] = []
240
+ block_index = 0
241
+ thinking_started = False
242
+ final_input_tokens = input_tokens
243
+ final_output_tokens = 0
244
+ cache_creation_tokens = 0
245
+ cache_read_tokens = 0
246
+
247
+ yield sse_message_start(msg_id, model, input_tokens)
248
+ yield sse_ping()
249
+
250
+ try:
251
+ async for chunk in openai_stream:
252
+ if not chunk.startswith("data: "):
253
+ continue
254
+
255
+ payload_text = chunk[6:].strip()
256
+ if not payload_text or payload_text == "[DONE]":
257
+ continue
258
+
259
+ payload = json.loads(payload_text)
260
+ if isinstance(payload, dict) and "error" in payload:
261
+ error = payload.get("error") or {}
262
+ yield sse_error(
263
+ error.get("type", "api_error"),
264
+ error.get("message", "Upstream error"),
265
+ )
266
+ return
267
+
268
+ choice = ((payload.get("choices") or [{}])[0]) if isinstance(payload, dict) else {}
269
+ delta = choice.get("delta") or {}
270
+
271
+ reasoning_delta = delta.get("reasoning_content")
272
+ if reasoning_delta:
273
+ if not thinking_started:
274
+ yield sse_content_block_start(
275
+ block_index,
276
+ {"type": "thinking", "thinking": ""},
277
+ )
278
+ thinking_started = True
279
+
280
+ reasoning_parts.append(reasoning_delta)
281
+ yield sse_content_block_delta(
282
+ block_index,
283
+ {"type": "thinking_delta", "thinking": reasoning_delta},
284
+ )
285
+
286
+ content_delta = delta.get("content")
287
+ if content_delta:
288
+ answer_parts.append(content_delta)
289
+
290
+ if payload.get("usage"):
291
+ usage = extract_openai_usage(payload)
292
+ if usage["input_tokens"] > 0:
293
+ final_input_tokens = usage["input_tokens"]
294
+ if usage["output_tokens"] > 0:
295
+ final_output_tokens = usage["output_tokens"]
296
+ if usage["cache_creation_tokens"] > 0:
297
+ cache_creation_tokens = usage["cache_creation_tokens"]
298
+ if usage["cache_read_tokens"] > 0:
299
+ cache_read_tokens = usage["cache_read_tokens"]
300
+
301
+ tool_calls.extend(_normalize_tool_calls(delta.get("tool_calls")))
302
+
303
+ if thinking_started:
304
+ yield sse_content_block_stop(block_index)
305
+ block_index += 1
306
+
307
+ answer_text = "".join(answer_parts)
308
+ if answer_text:
309
+ yield sse_content_block_start(block_index, {"type": "text", "text": ""})
310
+ yield sse_content_block_delta(
311
+ block_index,
312
+ {"type": "text_delta", "text": answer_text},
313
+ )
314
+ yield sse_content_block_stop(block_index)
315
+ block_index += 1
316
+
317
+ if tool_calls:
318
+ for tool_call in tool_calls:
319
+ function_data = tool_call.get("function") or {}
320
+ tool_id = tool_call.get(
321
+ "id",
322
+ f"toolu_{uuid.uuid4().hex[:20]}",
323
+ ).replace("call_", "toolu_")
324
+ yield sse_content_block_start(
325
+ block_index,
326
+ {
327
+ "type": "tool_use",
328
+ "id": tool_id,
329
+ "name": function_data.get("name", ""),
330
+ "input": {},
331
+ },
332
+ )
333
+ yield sse_content_block_delta(
334
+ block_index,
335
+ {
336
+ "type": "input_json_delta",
337
+ "partial_json": function_data.get("arguments", "{}"),
338
+ },
339
+ )
340
+ yield sse_content_block_stop(block_index)
341
+ block_index += 1
342
+
343
+ if not final_output_tokens:
344
+ final_output_tokens = _estimate_tokens(
345
+ "".join(reasoning_parts) + answer_text
346
+ )
347
+
348
+ yield sse_message_delta(
349
+ "tool_use" if tool_calls else "end_turn",
350
+ final_output_tokens,
351
+ input_tokens=final_input_tokens,
352
+ cache_creation_tokens=cache_creation_tokens,
353
+ cache_read_tokens=cache_read_tokens,
354
+ )
355
+ yield sse_message_stop()
356
+ except Exception as exc:
357
+ logger.error(f"❌ Claude 流式响应转换失败: {exc}")
358
+ yield sse_error("api_error", str(exc))
359
+
360
+
361
+ @router.post("/v1/messages")
362
+ @router.post("/anthropic/v1/messages")
363
+ async def claude_messages(
364
+ request: Request,
365
+ authorization: Optional[str] = Header(None),
366
+ x_api_key: Optional[str] = Header(None, alias="x-api-key"),
367
+ ):
368
+ source_info = detect_request_source(
369
+ request,
370
+ protocol_hint="anthropic",
371
+ )
372
+ source_prefix = format_request_source(source_info)
373
+ started_at = time.perf_counter()
374
+ requested_model = "unknown"
375
+
376
+ try:
377
+ body = await request.json()
378
+ except Exception:
379
+ await write_request_log(
380
+ provider="zai",
381
+ model=requested_model,
382
+ source_info=source_info,
383
+ success=False,
384
+ started_at=started_at,
385
+ status_code=400,
386
+ error_message="Invalid JSON body",
387
+ )
388
+ return _claude_error_response(
389
+ "Invalid JSON body",
390
+ 400,
391
+ "invalid_request_error",
392
+ )
393
+
394
+ requested_model = str(body.get("model") or "unknown")
395
+ source_info = detect_request_source(
396
+ request,
397
+ protocol_hint="anthropic",
398
+ model_hint=body.get("model"),
399
+ )
400
+ source_prefix = format_request_source(source_info)
401
+
402
+ if not settings.SKIP_AUTH_TOKEN:
403
+ api_key = _extract_api_key(authorization, x_api_key)
404
+ if not api_key:
405
+ await write_request_log(
406
+ provider="zai",
407
+ model=requested_model,
408
+ source_info=source_info,
409
+ success=False,
410
+ started_at=started_at,
411
+ status_code=401,
412
+ error_message="Missing API key",
413
+ )
414
+ return _claude_error_response(
415
+ "Missing API key",
416
+ 401,
417
+ "authentication_error",
418
+ )
419
+ if api_key != settings.AUTH_TOKEN:
420
+ await write_request_log(
421
+ provider="zai",
422
+ model=requested_model,
423
+ source_info=source_info,
424
+ success=False,
425
+ started_at=started_at,
426
+ status_code=401,
427
+ error_message="Invalid API key",
428
+ )
429
+ return _claude_error_response(
430
+ "Invalid API key",
431
+ 401,
432
+ "authentication_error",
433
+ )
434
+
435
+ try:
436
+ openai_request = _build_openai_request(body)
437
+ except Exception as exc:
438
+ await write_request_log(
439
+ provider="zai",
440
+ model=requested_model,
441
+ source_info=source_info,
442
+ success=False,
443
+ started_at=started_at,
444
+ status_code=400,
445
+ error_message=f"Invalid request: {exc}",
446
+ )
447
+ return _claude_error_response(
448
+ f"Invalid request: {exc}",
449
+ 400,
450
+ "invalid_request_error",
451
+ )
452
+
453
+ if not openai_request.messages:
454
+ await write_request_log(
455
+ provider="zai",
456
+ model=openai_request.model,
457
+ source_info=source_info,
458
+ success=False,
459
+ started_at=started_at,
460
+ status_code=400,
461
+ error_message="messages is required",
462
+ )
463
+ return _claude_error_response(
464
+ "messages is required",
465
+ 400,
466
+ "invalid_request_error",
467
+ )
468
+ logger.info(
469
+ f"{source_prefix} 🤖 收到 Claude 请求 - 模型: {body.get('model')}, 映射模型: {openai_request.model}, 流式: {openai_request.stream}, 消息数: {len(openai_request.messages)}, 工具数: {len(openai_request.tools) if openai_request.tools else 0}"
470
+ )
471
+
472
+ msg_id = make_claude_id()
473
+ input_tokens = _estimate_tokens(_build_prompt_text(body))
474
+
475
+ try:
476
+ client = get_upstream_client()
477
+ result = await client.chat_completion(openai_request)
478
+ except Exception as exc:
479
+ logger.error(f"{source_prefix} ❌ Claude 请求处理失败: {exc}")
480
+ await write_request_log(
481
+ provider="zai",
482
+ model=openai_request.model,
483
+ source_info=source_info,
484
+ success=False,
485
+ started_at=started_at,
486
+ status_code=500,
487
+ error_message=str(exc),
488
+ )
489
+ return _claude_error_response(str(exc), 500, "api_error")
490
+
491
+ if isinstance(result, dict) and "error" in result:
492
+ error = result.get("error") or {}
493
+ error_code = error.get("code")
494
+ status_code = error_code if isinstance(error_code, int) else 500
495
+ await write_request_log(
496
+ provider="zai",
497
+ model=openai_request.model,
498
+ source_info=source_info,
499
+ success=False,
500
+ started_at=started_at,
501
+ status_code=status_code,
502
+ error_message=error.get("message", "Unknown upstream error"),
503
+ )
504
+ return _claude_error_response(
505
+ error.get("message", "Unknown upstream error"),
506
+ status_code,
507
+ error.get("type", "api_error"),
508
+ )
509
+
510
+ if openai_request.stream:
511
+ if not hasattr(result, "__aiter__"):
512
+ await write_request_log(
513
+ provider="zai",
514
+ model=openai_request.model,
515
+ source_info=source_info,
516
+ success=False,
517
+ started_at=started_at,
518
+ status_code=500,
519
+ error_message="Expected streaming response",
520
+ )
521
+ return _claude_error_response(
522
+ "Expected streaming response",
523
+ 500,
524
+ "api_error",
525
+ )
526
+
527
+ return StreamingResponse(
528
+ wrap_claude_stream_with_logging(
529
+ _stream_openai_to_claude(
530
+ result,
531
+ msg_id,
532
+ openai_request.model,
533
+ input_tokens,
534
+ ),
535
+ provider="zai",
536
+ model=openai_request.model,
537
+ source_info=source_info,
538
+ started_at=started_at,
539
+ input_tokens=input_tokens,
540
+ ),
541
+ media_type="text/event-stream",
542
+ headers={
543
+ "Cache-Control": "no-cache",
544
+ "Connection": "keep-alive",
545
+ "Access-Control-Allow-Origin": "*",
546
+ },
547
+ )
548
+
549
+ if not isinstance(result, dict):
550
+ await write_request_log(
551
+ provider="zai",
552
+ model=openai_request.model,
553
+ source_info=source_info,
554
+ success=False,
555
+ started_at=started_at,
556
+ status_code=500,
557
+ error_message="Expected non-streaming response payload",
558
+ )
559
+ return _claude_error_response(
560
+ "Expected non-streaming response payload",
561
+ 500,
562
+ "api_error",
563
+ )
564
+
565
+ response_data = _convert_openai_response_to_claude(result, msg_id)
566
+ if not response_data.get("usage", {}).get("input_tokens"):
567
+ response_data["usage"]["input_tokens"] = input_tokens
568
+ usage = extract_claude_usage(response_data)
569
+ await write_request_log(
570
+ provider="zai",
571
+ model=openai_request.model,
572
+ source_info=source_info,
573
+ success=True,
574
+ started_at=started_at,
575
+ status_code=200,
576
+ input_tokens=usage["input_tokens"],
577
+ output_tokens=usage["output_tokens"],
578
+ cache_creation_tokens=usage["cache_creation_tokens"],
579
+ cache_read_tokens=usage["cache_read_tokens"],
580
+ total_tokens=usage["total_tokens"],
581
+ )
582
+ return JSONResponse(content=response_data)
app/core/claude_compat.py ADDED
@@ -0,0 +1,352 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """Claude Messages API 兼容辅助函数。"""
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import uuid
10
+ from typing import Any, Optional
11
+
12
+
13
+ def extract_text(content: Any) -> str:
14
+ """Extract plain text from Claude/OpenAI mixed content blocks."""
15
+ if isinstance(content, str):
16
+ return content
17
+
18
+ if isinstance(content, list):
19
+ return " ".join(
20
+ str(block.get("text", ""))
21
+ for block in content
22
+ if isinstance(block, dict) and block.get("type") == "text"
23
+ ).strip()
24
+
25
+ return str(content) if content else ""
26
+
27
+
28
+ def claude_messages_to_openai(system: Any, messages: list[dict]) -> list[dict]:
29
+ """Convert Claude messages payload into OpenAI-style messages."""
30
+ converted: list[dict] = []
31
+
32
+ if system:
33
+ if isinstance(system, str):
34
+ converted.append({"role": "system", "content": system})
35
+ elif isinstance(system, list):
36
+ system_text = [
37
+ block.get("text", "")
38
+ for block in system
39
+ if isinstance(block, dict) and block.get("type") == "text"
40
+ ]
41
+ if system_text:
42
+ converted.append({
43
+ "role": "system",
44
+ "content": "\n".join(system_text),
45
+ })
46
+
47
+ for message in messages:
48
+ role = message.get("role", "user")
49
+ content = message.get("content", "")
50
+
51
+ if role == "assistant" and isinstance(content, list):
52
+ text_parts: list[str] = []
53
+ tool_calls: list[dict] = []
54
+
55
+ for block in content:
56
+ if not isinstance(block, dict):
57
+ continue
58
+
59
+ block_type = block.get("type")
60
+ if block_type == "text":
61
+ text_parts.append(block.get("text", ""))
62
+ elif block_type == "tool_use":
63
+ tool_calls.append(
64
+ {
65
+ "id": block.get(
66
+ "id",
67
+ f"call_{uuid.uuid4().hex[:24]}",
68
+ ),
69
+ "type": "function",
70
+ "function": {
71
+ "name": block.get("name", ""),
72
+ "arguments": json.dumps(
73
+ block.get("input", {}),
74
+ ensure_ascii=False,
75
+ ),
76
+ },
77
+ }
78
+ )
79
+
80
+ openai_message: dict = {
81
+ "role": "assistant",
82
+ "content": " ".join(text_parts).strip() or None,
83
+ }
84
+ if tool_calls:
85
+ openai_message["tool_calls"] = tool_calls
86
+ converted.append(openai_message)
87
+ continue
88
+
89
+ if role == "user" and isinstance(content, list):
90
+ has_tool_result = any(
91
+ isinstance(block, dict) and block.get("type") == "tool_result"
92
+ for block in content
93
+ )
94
+ if has_tool_result:
95
+ for block in content:
96
+ if not isinstance(block, dict):
97
+ continue
98
+
99
+ block_type = block.get("type")
100
+ if block_type == "tool_result":
101
+ result_content = block.get("content", "")
102
+ if isinstance(result_content, str):
103
+ rendered = result_content
104
+ elif isinstance(result_content, list):
105
+ rendered = " ".join(
106
+ item.get("text", "")
107
+ for item in result_content
108
+ if isinstance(item, dict)
109
+ and item.get("type") == "text"
110
+ )
111
+ else:
112
+ rendered = str(result_content)
113
+
114
+ converted.append(
115
+ {
116
+ "role": "tool",
117
+ "tool_call_id": block.get("tool_use_id", ""),
118
+ "content": rendered,
119
+ }
120
+ )
121
+ elif block_type == "text":
122
+ converted.append(
123
+ {"role": "user", "content": block.get("text", "")}
124
+ )
125
+ continue
126
+
127
+ converted.append({"role": role, "content": extract_text(content)})
128
+
129
+ return converted
130
+
131
+
132
+ def claude_tools_to_openai(tools: Optional[list[dict]]) -> Optional[list[dict]]:
133
+ """Convert Claude tool schemas into OpenAI function tools."""
134
+ if not tools:
135
+ return None
136
+
137
+ converted = [
138
+ {
139
+ "type": "function",
140
+ "function": {
141
+ "name": tool.get("name", ""),
142
+ "description": tool.get("description", ""),
143
+ "parameters": tool.get("input_schema", {}),
144
+ },
145
+ }
146
+ for tool in tools
147
+ if isinstance(tool, dict)
148
+ ]
149
+ return converted or None
150
+
151
+
152
+ def claude_tool_choice_to_openai(tool_choice: Any) -> Any:
153
+ """Convert Claude tool_choice payload into OpenAI-compatible form."""
154
+ if not isinstance(tool_choice, dict):
155
+ return tool_choice
156
+
157
+ tool_choice_type = tool_choice.get("type", "auto")
158
+ if tool_choice_type == "auto":
159
+ return "auto"
160
+ if tool_choice_type == "any":
161
+ return "required"
162
+ if tool_choice_type == "none":
163
+ return "none"
164
+ if tool_choice_type == "tool":
165
+ name = tool_choice.get("name", "")
166
+ if name:
167
+ return {"type": "function", "function": {"name": name}}
168
+ return tool_choice
169
+
170
+
171
+ def make_claude_id() -> str:
172
+ """Generate a Claude-style message id."""
173
+ return f"msg_{uuid.uuid4().hex[:24]}"
174
+
175
+
176
+ def build_tool_call_blocks(tool_calls: list[dict]) -> list[dict]:
177
+ """Convert OpenAI tool calls to Claude tool_use blocks."""
178
+ blocks = []
179
+ for tool_call in tool_calls:
180
+ function_data = (
181
+ tool_call.get("function")
182
+ if isinstance(tool_call.get("function"), dict)
183
+ else {}
184
+ )
185
+ arguments = function_data.get("arguments", "{}")
186
+ try:
187
+ input_data = json.loads(arguments) if isinstance(arguments, str) else arguments
188
+ except Exception:
189
+ input_data = {}
190
+
191
+ blocks.append(
192
+ {
193
+ "type": "tool_use",
194
+ "id": tool_call.get(
195
+ "id",
196
+ f"toolu_{uuid.uuid4().hex[:20]}",
197
+ ).replace("call_", "toolu_"),
198
+ "name": function_data.get("name", ""),
199
+ "input": input_data,
200
+ }
201
+ )
202
+ return blocks
203
+
204
+
205
+ def build_non_stream_response(
206
+ msg_id: str,
207
+ model: str,
208
+ reasoning_parts: list[str],
209
+ answer_text: str,
210
+ tool_calls: Optional[list[dict]],
211
+ input_tokens: int,
212
+ output_tokens: int,
213
+ cache_creation_tokens: int = 0,
214
+ cache_read_tokens: int = 0,
215
+ ) -> dict:
216
+ """Build a Claude non-streaming message response."""
217
+ content: list[dict] = []
218
+ if reasoning_parts:
219
+ content.append(
220
+ {"type": "thinking", "thinking": "".join(reasoning_parts)}
221
+ )
222
+ if answer_text:
223
+ content.append({"type": "text", "text": answer_text})
224
+ elif not tool_calls:
225
+ content.append({"type": "text", "text": ""})
226
+ if tool_calls:
227
+ content.extend(build_tool_call_blocks(tool_calls))
228
+
229
+ return {
230
+ "id": msg_id,
231
+ "type": "message",
232
+ "role": "assistant",
233
+ "content": content,
234
+ "model": model,
235
+ "stop_reason": "tool_use" if tool_calls else "end_turn",
236
+ "stop_sequence": None,
237
+ "usage": {
238
+ "input_tokens": input_tokens,
239
+ "output_tokens": output_tokens,
240
+ "cache_creation_input_tokens": cache_creation_tokens,
241
+ "cache_read_input_tokens": cache_read_tokens,
242
+ },
243
+ }
244
+
245
+
246
+ def sse(event: str, data: dict) -> str:
247
+ """Format a Claude SSE event."""
248
+ return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
249
+
250
+
251
+ def sse_message_start(
252
+ msg_id: str,
253
+ model: str,
254
+ input_tokens: int,
255
+ cache_creation_tokens: int = 0,
256
+ cache_read_tokens: int = 0,
257
+ ) -> str:
258
+ """Create Claude message_start SSE event."""
259
+ return sse(
260
+ "message_start",
261
+ {
262
+ "type": "message_start",
263
+ "message": {
264
+ "id": msg_id,
265
+ "type": "message",
266
+ "role": "assistant",
267
+ "content": [],
268
+ "model": model,
269
+ "stop_reason": None,
270
+ "stop_sequence": None,
271
+ "usage": {
272
+ "input_tokens": input_tokens,
273
+ "cache_creation_input_tokens": cache_creation_tokens,
274
+ "cache_read_input_tokens": cache_read_tokens,
275
+ "output_tokens": 0,
276
+ },
277
+ },
278
+ },
279
+ )
280
+
281
+
282
+ def sse_ping() -> str:
283
+ """Create Claude ping SSE event."""
284
+ return sse("ping", {"type": "ping"})
285
+
286
+
287
+ def sse_content_block_start(index: int, block: dict) -> str:
288
+ """Create Claude content_block_start SSE event."""
289
+ return sse(
290
+ "content_block_start",
291
+ {
292
+ "type": "content_block_start",
293
+ "index": index,
294
+ "content_block": block,
295
+ },
296
+ )
297
+
298
+
299
+ def sse_content_block_delta(index: int, delta: dict) -> str:
300
+ """Create Claude content_block_delta SSE event."""
301
+ return sse(
302
+ "content_block_delta",
303
+ {"type": "content_block_delta", "index": index, "delta": delta},
304
+ )
305
+
306
+
307
+ def sse_content_block_stop(index: int) -> str:
308
+ """Create Claude content_block_stop SSE event."""
309
+ return sse(
310
+ "content_block_stop",
311
+ {"type": "content_block_stop", "index": index},
312
+ )
313
+
314
+
315
+ def sse_message_delta(
316
+ stop_reason: str,
317
+ output_tokens: int,
318
+ *,
319
+ input_tokens: int = 0,
320
+ cache_creation_tokens: int = 0,
321
+ cache_read_tokens: int = 0,
322
+ ) -> str:
323
+ """Create Claude message_delta SSE event."""
324
+ return sse(
325
+ "message_delta",
326
+ {
327
+ "type": "message_delta",
328
+ "delta": {"stop_reason": stop_reason, "stop_sequence": None},
329
+ "usage": {
330
+ "input_tokens": input_tokens,
331
+ "output_tokens": output_tokens,
332
+ "cache_creation_input_tokens": cache_creation_tokens,
333
+ "cache_read_input_tokens": cache_read_tokens,
334
+ },
335
+ },
336
+ )
337
+
338
+
339
+ def sse_message_stop() -> str:
340
+ """Create Claude message_stop SSE event."""
341
+ return sse("message_stop", {"type": "message_stop"})
342
+
343
+
344
+ def sse_error(error_type: str, message: str) -> str:
345
+ """Create Claude error SSE event."""
346
+ return sse(
347
+ "error",
348
+ {
349
+ "type": "error",
350
+ "error": {"type": error_type, "message": message},
351
+ },
352
+ )
app/core/config.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ import os
5
+ from typing import Optional
6
+
7
+ from pydantic_settings import BaseSettings, SettingsConfigDict
8
+
9
+
10
+ class Settings(BaseSettings):
11
+ """Application settings"""
12
+
13
+ # API Configuration
14
+ API_ENDPOINT: str = "https://chat.z.ai/api/v2/chat/completions"
15
+
16
+ # Authentication
17
+ AUTH_TOKEN: Optional[str] = os.getenv("AUTH_TOKEN")
18
+
19
+ # Token池配置
20
+ TOKEN_FAILURE_THRESHOLD: int = int(
21
+ os.getenv("TOKEN_FAILURE_THRESHOLD", "3")
22
+ )
23
+ TOKEN_RECOVERY_TIMEOUT: int = int(
24
+ os.getenv("TOKEN_RECOVERY_TIMEOUT", "1800")
25
+ )
26
+ TOKEN_AUTO_IMPORT_ENABLED: bool = (
27
+ os.getenv("TOKEN_AUTO_IMPORT_ENABLED", "false").lower() == "true"
28
+ )
29
+ TOKEN_AUTO_IMPORT_SOURCE_DIR: str = os.getenv("TOKEN_AUTO_IMPORT_SOURCE_DIR", "")
30
+ TOKEN_AUTO_IMPORT_INTERVAL: int = int(
31
+ os.getenv("TOKEN_AUTO_IMPORT_INTERVAL", "300")
32
+ )
33
+ TOKEN_AUTO_MAINTENANCE_ENABLED: bool = (
34
+ os.getenv("TOKEN_AUTO_MAINTENANCE_ENABLED", "false").lower() == "true"
35
+ )
36
+ TOKEN_AUTO_MAINTENANCE_INTERVAL: int = int(
37
+ os.getenv("TOKEN_AUTO_MAINTENANCE_INTERVAL", "1800")
38
+ )
39
+ TOKEN_AUTO_REMOVE_DUPLICATES: bool = (
40
+ os.getenv("TOKEN_AUTO_REMOVE_DUPLICATES", "true").lower() == "true"
41
+ )
42
+ TOKEN_AUTO_HEALTH_CHECK: bool = (
43
+ os.getenv("TOKEN_AUTO_HEALTH_CHECK", "true").lower() == "true"
44
+ )
45
+ TOKEN_AUTO_DELETE_INVALID: bool = (
46
+ os.getenv("TOKEN_AUTO_DELETE_INVALID", "false").lower() == "true"
47
+ )
48
+
49
+ # Model Configuration
50
+ GLM45_MODEL: str = os.getenv("GLM45_MODEL", "GLM-4.5")
51
+ GLM45_THINKING_MODEL: str = os.getenv("GLM45_THINKING_MODEL", "GLM-4.5-Thinking")
52
+ GLM45_SEARCH_MODEL: str = os.getenv("GLM45_SEARCH_MODEL", "GLM-4.5-Search")
53
+ GLM45_AIR_MODEL: str = os.getenv("GLM45_AIR_MODEL", "GLM-4.5-Air")
54
+ GLM46V_MODEL: str = os.getenv("GLM46V_MODEL", "GLM-4.6V")
55
+ GLM5_MODEL: str = os.getenv("GLM5_MODEL", "GLM-5")
56
+ GLM47_MODEL: str = os.getenv("GLM47_MODEL", "GLM-4.7")
57
+ GLM47_THINKING_MODEL: str = os.getenv("GLM47_THINKING_MODEL", "GLM-4.7-Thinking")
58
+ GLM47_SEARCH_MODEL: str = os.getenv("GLM47_SEARCH_MODEL", "GLM-4.7-Search")
59
+ GLM47_ADVANCED_SEARCH_MODEL: str = os.getenv(
60
+ "GLM47_ADVANCED_SEARCH_MODEL",
61
+ "GLM-4.7-advanced-search",
62
+ )
63
+
64
+ # Server Configuration
65
+ LISTEN_PORT: int = int(os.getenv("LISTEN_PORT", "8080"))
66
+ DEBUG_LOGGING: bool = os.getenv("DEBUG_LOGGING", "true").lower() == "true"
67
+ SERVICE_NAME: str = os.getenv("SERVICE_NAME", "api-proxy-server")
68
+ ROOT_PATH: str = os.getenv("ROOT_PATH", "")
69
+
70
+ ANONYMOUS_MODE: bool = os.getenv("ANONYMOUS_MODE", "true").lower() == "true"
71
+ GUEST_POOL_SIZE: int = int(os.getenv("GUEST_POOL_SIZE", "3"))
72
+ TOOL_SUPPORT: bool = os.getenv("TOOL_SUPPORT", "true").lower() == "true"
73
+ SCAN_LIMIT: int = int(os.getenv("SCAN_LIMIT", "200000"))
74
+ SKIP_AUTH_TOKEN: bool = os.getenv("SKIP_AUTH_TOKEN", "false").lower() == "true"
75
+
76
+ # Proxy Configuration
77
+ HTTP_PROXY: Optional[str] = os.getenv("HTTP_PROXY")
78
+ HTTPS_PROXY: Optional[str] = os.getenv("HTTPS_PROXY")
79
+ SOCKS5_PROXY: Optional[str] = os.getenv("SOCKS5_PROXY")
80
+
81
+ # Admin Panel Authentication
82
+ ADMIN_PASSWORD: str = os.getenv("ADMIN_PASSWORD", "admin123")
83
+ SESSION_SECRET_KEY: str = os.getenv(
84
+ "SESSION_SECRET_KEY",
85
+ "your-secret-key-change-in-production",
86
+ )
87
+ DB_PATH: str = os.getenv("DB_PATH", "tokens.db")
88
+
89
+ model_config = SettingsConfigDict(
90
+ env_file=".env",
91
+ extra="ignore", # 忽略额外字段,防止环境变量中的未知字段导致验证错误
92
+ )
93
+
94
+
95
+ settings = Settings()
app/core/openai.py ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ import json
5
+ import time
6
+ from typing import Optional
7
+
8
+ from fastapi import APIRouter, Header, HTTPException, Request
9
+ from fastapi.responses import JSONResponse, StreamingResponse
10
+
11
+ from app.core.config import settings
12
+ from app.models.schemas import (
13
+ Choice,
14
+ Message,
15
+ Model,
16
+ ModelsResponse,
17
+ OpenAIRequest,
18
+ OpenAIResponse,
19
+ Usage,
20
+ )
21
+ from app.core.upstream import UpstreamClient
22
+ from app.utils.logger import get_logger
23
+ from app.utils.request_logging import (
24
+ extract_openai_usage,
25
+ wrap_openai_stream_with_logging,
26
+ write_request_log,
27
+ )
28
+ from app.utils.request_source import detect_request_source, format_request_source
29
+
30
+ logger = get_logger()
31
+ router = APIRouter()
32
+
33
+ _upstream_client: Optional[UpstreamClient] = None
34
+
35
+
36
+ def get_upstream_client() -> UpstreamClient:
37
+ """获取懒加载的上游适配器单例。"""
38
+ global _upstream_client
39
+ if _upstream_client is None:
40
+ _upstream_client = UpstreamClient()
41
+ return _upstream_client
42
+
43
+
44
+ async def handle_non_stream_response(stream_response, request: OpenAIRequest) -> JSONResponse:
45
+ """处理非流式响应。"""
46
+ logger.info("📄 开始处理非流式响应")
47
+
48
+ full_content = []
49
+ async for chunk_data in stream_response():
50
+ if chunk_data.startswith("data: "):
51
+ chunk_str = chunk_data[6:].strip()
52
+ if chunk_str and chunk_str != "[DONE]":
53
+ try:
54
+ chunk = json.loads(chunk_str)
55
+ if "choices" in chunk and chunk["choices"]:
56
+ choice = chunk["choices"][0]
57
+ if "delta" in choice and "content" in choice["delta"]:
58
+ content = choice["delta"]["content"]
59
+ if content:
60
+ full_content.append(content)
61
+ except json.JSONDecodeError:
62
+ continue
63
+
64
+ response_data = OpenAIResponse(
65
+ id=f"chatcmpl-{int(time.time())}",
66
+ object="chat.completion",
67
+ created=int(time.time()),
68
+ model=request.model,
69
+ choices=[
70
+ Choice(
71
+ index=0,
72
+ message=Message(
73
+ role="assistant",
74
+ content="".join(full_content),
75
+ tool_calls=None,
76
+ ),
77
+ finish_reason="stop",
78
+ )
79
+ ],
80
+ usage=Usage(prompt_tokens=0, completion_tokens=0, total_tokens=0),
81
+ )
82
+
83
+ logger.info("✅ 非流式响应处理完成")
84
+ return JSONResponse(content=response_data.model_dump(exclude_none=True))
85
+
86
+
87
+ @router.get("/v1/models")
88
+ async def list_models():
89
+ """返回当前服务支持的模型列表。"""
90
+ try:
91
+ client = get_upstream_client()
92
+ current_time = int(time.time())
93
+ response = ModelsResponse(
94
+ data=[
95
+ Model(id=model_id, created=current_time, owned_by=settings.SERVICE_NAME)
96
+ for model_id in client.get_supported_models()
97
+ ]
98
+ )
99
+ return JSONResponse(content=response.model_dump(exclude_none=True))
100
+ except Exception as exc:
101
+ logger.error(f"❌ 获取模型列表失败: {exc}")
102
+ raise HTTPException(status_code=500, detail=f"Failed to list models: {exc}")
103
+
104
+
105
+ @router.post("/v1/chat/completions")
106
+ async def chat_completions(
107
+ body: OpenAIRequest,
108
+ http_request: Request,
109
+ authorization: Optional[str] = Header(None),
110
+ ):
111
+ """直接调用上游适配器处理请求。"""
112
+ source_info = detect_request_source(
113
+ http_request,
114
+ protocol_hint="openai",
115
+ model_hint=body.model,
116
+ )
117
+ source_prefix = format_request_source(source_info)
118
+ started_at = time.perf_counter()
119
+
120
+ role = body.messages[0].role if body.messages else "unknown"
121
+ logger.info(
122
+ f"{source_prefix} 😶‍🌫️ 收到客户端请求 - 模型: {body.model}, 流式: {body.stream}, 消息数: {len(body.messages)}, 角色: {role}, 工具数: {len(body.tools) if body.tools else 0}"
123
+ )
124
+
125
+ try:
126
+ if not settings.SKIP_AUTH_TOKEN:
127
+ if not authorization or not authorization.startswith("Bearer "):
128
+ raise HTTPException(status_code=401, detail="Missing or invalid Authorization header")
129
+
130
+ api_key = authorization[7:]
131
+ if api_key != settings.AUTH_TOKEN:
132
+ raise HTTPException(status_code=401, detail="Invalid API key")
133
+
134
+ client = get_upstream_client()
135
+ result = await client.chat_completion(body)
136
+
137
+ if isinstance(result, dict) and "error" in result:
138
+ error_info = result["error"]
139
+ error_message = error_info.get("message", "Unknown upstream error")
140
+ error_code = error_info.get("code")
141
+ status_code = 404 if error_code == "model_not_found" else 500
142
+ raise HTTPException(status_code=status_code, detail=error_message)
143
+
144
+ if body.stream:
145
+ if hasattr(result, "__aiter__"):
146
+ return StreamingResponse(
147
+ wrap_openai_stream_with_logging(
148
+ result,
149
+ provider="zai",
150
+ model=body.model,
151
+ source_info=source_info,
152
+ started_at=started_at,
153
+ ),
154
+ media_type="text/event-stream",
155
+ headers={
156
+ "Cache-Control": "no-cache",
157
+ "Connection": "keep-alive",
158
+ "Access-Control-Allow-Origin": "*",
159
+ },
160
+ )
161
+ raise HTTPException(
162
+ status_code=500,
163
+ detail="Expected streaming response but got non-streaming result",
164
+ )
165
+
166
+ if isinstance(result, dict):
167
+ usage = extract_openai_usage(result)
168
+ await write_request_log(
169
+ provider="zai",
170
+ model=body.model,
171
+ source_info=source_info,
172
+ success="error" not in result,
173
+ started_at=started_at,
174
+ status_code=200 if "error" not in result else 500,
175
+ input_tokens=usage["input_tokens"],
176
+ output_tokens=usage["output_tokens"],
177
+ cache_creation_tokens=usage["cache_creation_tokens"],
178
+ cache_read_tokens=usage["cache_read_tokens"],
179
+ total_tokens=usage["total_tokens"],
180
+ error_message=(result.get("error") or {}).get("message") if isinstance(result, dict) else None,
181
+ )
182
+ return JSONResponse(content=result)
183
+
184
+ response = await handle_non_stream_response(result, body)
185
+ response_body = json.loads(response.body)
186
+ usage = extract_openai_usage(response_body)
187
+ await write_request_log(
188
+ provider="zai",
189
+ model=body.model,
190
+ source_info=source_info,
191
+ success=True,
192
+ started_at=started_at,
193
+ status_code=200,
194
+ input_tokens=usage["input_tokens"],
195
+ output_tokens=usage["output_tokens"],
196
+ cache_creation_tokens=usage["cache_creation_tokens"],
197
+ cache_read_tokens=usage["cache_read_tokens"],
198
+ total_tokens=usage["total_tokens"],
199
+ )
200
+ return response
201
+
202
+ except HTTPException as exc:
203
+ await write_request_log(
204
+ provider="zai",
205
+ model=body.model,
206
+ source_info=source_info,
207
+ success=False,
208
+ started_at=started_at,
209
+ status_code=exc.status_code,
210
+ error_message=str(exc.detail),
211
+ )
212
+ raise
213
+ except Exception as exc:
214
+ logger.error(f"{source_prefix} ❌ 请求处理失败: {exc}")
215
+ await write_request_log(
216
+ provider="zai",
217
+ model=body.model,
218
+ source_info=source_info,
219
+ success=False,
220
+ started_at=started_at,
221
+ status_code=500,
222
+ error_message=str(exc),
223
+ )
224
+ raise HTTPException(status_code=500, detail=f"Internal server error: {str(exc)}")
app/core/openai_compat.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """OpenAI 兼容响应辅助函数。"""
5
+
6
+ import json
7
+ import time
8
+ import uuid
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ from app.utils.logger import get_logger
12
+
13
+ logger = get_logger()
14
+ SYSTEM_FINGERPRINT = "fp_api_proxy_001"
15
+
16
+
17
+ def create_chat_id() -> str:
18
+ """生成聊天 ID。"""
19
+ return f"chatcmpl-{uuid.uuid4().hex}"
20
+
21
+
22
+ def create_openai_chunk(
23
+ chat_id: str,
24
+ model: str,
25
+ delta: Dict[str, Any],
26
+ finish_reason: Optional[str] = None,
27
+ ) -> Dict[str, Any]:
28
+ """创建 OpenAI 格式的流式响应块。"""
29
+ return {
30
+ "id": chat_id,
31
+ "object": "chat.completion.chunk",
32
+ "created": int(time.time()),
33
+ "model": model,
34
+ "choices": [
35
+ {
36
+ "index": 0,
37
+ "delta": delta,
38
+ "finish_reason": finish_reason,
39
+ "logprobs": None,
40
+ }
41
+ ],
42
+ "system_fingerprint": SYSTEM_FINGERPRINT,
43
+ }
44
+
45
+
46
+ def create_openai_response(
47
+ chat_id: str,
48
+ model: str,
49
+ content: str,
50
+ usage: Optional[Dict[str, int]] = None,
51
+ ) -> Dict[str, Any]:
52
+ """创建 OpenAI 格式的非流式响应。"""
53
+ return {
54
+ "id": chat_id,
55
+ "object": "chat.completion",
56
+ "created": int(time.time()),
57
+ "model": model,
58
+ "choices": [
59
+ {
60
+ "index": 0,
61
+ "message": {"role": "assistant", "content": content},
62
+ "finish_reason": "stop",
63
+ "logprobs": None,
64
+ }
65
+ ],
66
+ "usage": usage
67
+ or {
68
+ "prompt_tokens": 0,
69
+ "completion_tokens": 0,
70
+ "total_tokens": 0,
71
+ },
72
+ "system_fingerprint": SYSTEM_FINGERPRINT,
73
+ }
74
+
75
+
76
+ def create_openai_response_with_reasoning(
77
+ chat_id: str,
78
+ model: str,
79
+ content: str,
80
+ reasoning_content: Optional[str] = None,
81
+ usage: Optional[Dict[str, int]] = None,
82
+ tool_calls: Optional[List[Dict[str, Any]]] = None,
83
+ ) -> Dict[str, Any]:
84
+ """创建包含 reasoning/tool_calls 的 OpenAI 响应。"""
85
+ message: Dict[str, Any] = {
86
+ "role": "assistant",
87
+ "content": content,
88
+ }
89
+
90
+ if reasoning_content and reasoning_content.strip():
91
+ message["reasoning_content"] = reasoning_content
92
+
93
+ if tool_calls:
94
+ message["tool_calls"] = tool_calls
95
+
96
+ return {
97
+ "id": chat_id,
98
+ "object": "chat.completion",
99
+ "created": int(time.time()),
100
+ "model": model,
101
+ "choices": [
102
+ {
103
+ "index": 0,
104
+ "message": message,
105
+ "finish_reason": "tool_calls" if tool_calls else "stop",
106
+ "logprobs": None,
107
+ }
108
+ ],
109
+ "usage": usage
110
+ or {
111
+ "prompt_tokens": 0,
112
+ "completion_tokens": 0,
113
+ "total_tokens": 0,
114
+ },
115
+ "system_fingerprint": SYSTEM_FINGERPRINT,
116
+ }
117
+
118
+
119
+ async def format_sse_chunk(chunk: Dict[str, Any]) -> str:
120
+ """格式化 SSE 响应块。"""
121
+ return f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n"
122
+
123
+
124
+ async def format_sse_done() -> str:
125
+ """格式化 SSE 结束标记。"""
126
+ return "data: [DONE]\n\n"
127
+
128
+
129
+ def handle_error(error: Exception, context: str = "") -> Dict[str, Any]:
130
+ """统一错误处理。"""
131
+ error_msg = f"上游{context}错误: {str(error)}" if context else f"上游错误: {str(error)}"
132
+ logger.error(error_msg)
133
+ return {
134
+ "error": {
135
+ "message": error_msg,
136
+ "type": "upstream_error",
137
+ "code": "internal_error",
138
+ }
139
+ }
app/core/upstream.py ADDED
@@ -0,0 +1,2245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """上游适配器。"""
5
+
6
+ import asyncio
7
+ import base64
8
+ import json
9
+ import random
10
+ import time
11
+ import uuid
12
+ from datetime import datetime, timezone
13
+ from typing import Any, AsyncGenerator, Dict, List, Optional, Set, Tuple, Union
14
+ from urllib.parse import urlencode
15
+
16
+ import httpx
17
+
18
+ from app.core.config import settings
19
+ from app.core.openai_compat import (
20
+ create_openai_chunk,
21
+ create_openai_response_with_reasoning,
22
+ format_sse_chunk,
23
+ handle_error,
24
+ )
25
+ from app.models.schemas import OpenAIRequest
26
+ from app.utils.fe_version import get_latest_fe_version
27
+ from app.utils.guest_session_pool import get_guest_session_pool
28
+ from app.utils.logger import get_logger
29
+ from app.utils.signature import generate_signature
30
+ from app.utils.token_pool import get_token_pool
31
+ from app.utils.tool_call_handler import (
32
+ parse_and_extract_tool_calls,
33
+ )
34
+ from app.utils.user_agent import get_random_user_agent
35
+
36
+ logger = get_logger()
37
+
38
+ DEFAULT_ZAI_BASE_URL = "https://chat.z.ai"
39
+ CHAT_BOOTSTRAP_MAX_CONTENT_LEN = 500
40
+ DEFAULT_PLATFORM = "web"
41
+ DEFAULT_CLIENT_VERSION = "0.0.1"
42
+ DEFAULT_TIMEZONE = "Asia/Shanghai"
43
+ DEFAULT_LANGUAGE = "zh-CN"
44
+ DEFAULT_SCREEN_WIDTH = "1920"
45
+ DEFAULT_SCREEN_HEIGHT = "1080"
46
+ DEFAULT_VIEWPORT_WIDTH = "944"
47
+ DEFAULT_VIEWPORT_HEIGHT = "919"
48
+ DEFAULT_VIEWPORT_SIZE = f"{DEFAULT_VIEWPORT_WIDTH}x{DEFAULT_VIEWPORT_HEIGHT}"
49
+ DEFAULT_SCREEN_RESOLUTION = f"{DEFAULT_SCREEN_WIDTH}x{DEFAULT_SCREEN_HEIGHT}"
50
+ DEFAULT_COLOR_DEPTH = "24"
51
+ DEFAULT_PIXEL_RATIO = "1.25"
52
+ DEFAULT_MAX_TOUCH_POINTS = "10"
53
+ DEFAULT_TIMEZONE_OFFSET = "-480"
54
+ DEFAULT_PAGE_TITLE = "Z.ai Chat Proxy"
55
+ DEFAULT_COMPLETION_FEATURES = [
56
+ {"type": "mcp", "server": "vibe-coding", "status": "hidden"},
57
+ {"type": "mcp", "server": "ppt-maker", "status": "hidden"},
58
+ {"type": "mcp", "server": "image-search", "status": "hidden"},
59
+ {"type": "mcp", "server": "deep-research", "status": "hidden"},
60
+ {"type": "tool_selector", "server": "tool_selector", "status": "hidden"},
61
+ {"type": "mcp", "server": "advanced-search", "status": "hidden"},
62
+ ]
63
+ GLM46V_MCP_SERVERS = [
64
+ "vlm-image-search",
65
+ "vlm-image-recognition",
66
+ "vlm-image-processing",
67
+ ]
68
+ GLM46V_SELECTED_FEATURES = [
69
+ {"type": "mcp", "server": "vlm-image-search", "status": "selected"},
70
+ {"type": "mcp", "server": "vlm-image-recognition", "status": "selected"},
71
+ {"type": "mcp", "server": "vlm-image-processing", "status": "selected"},
72
+ ]
73
+
74
+ def generate_uuid() -> str:
75
+ """生成UUID v4"""
76
+ return str(uuid.uuid4())
77
+
78
+ def get_dynamic_headers(
79
+ chat_id: str = "",
80
+ browser_type: Optional[str] = None,
81
+ ) -> Dict[str, str]:
82
+ """生成上游请求所需的动态浏览器 headers。"""
83
+ browser_choices = [
84
+ "chrome",
85
+ "chrome",
86
+ "chrome",
87
+ "edge",
88
+ "edge",
89
+ "firefox",
90
+ "safari",
91
+ ]
92
+ selected_browser = browser_type or random.choice(browser_choices)
93
+ user_agent = get_random_user_agent(selected_browser)
94
+ fe_version = get_latest_fe_version()
95
+
96
+ chrome_version = "139"
97
+ edge_version = "139"
98
+
99
+ if "Chrome/" in user_agent:
100
+ try:
101
+ chrome_version = user_agent.split("Chrome/")[1].split(".")[0]
102
+ except Exception:
103
+ pass
104
+
105
+ if "Edg/" in user_agent:
106
+ try:
107
+ edge_version = user_agent.split("Edg/")[1].split(".")[0]
108
+ sec_ch_ua = (
109
+ f'"Microsoft Edge";v="{edge_version}", '
110
+ f'"Chromium";v="{chrome_version}", "Not_A Brand";v="24"'
111
+ )
112
+ except Exception:
113
+ sec_ch_ua = (
114
+ f'"Not_A Brand";v="8", "Chromium";v="{chrome_version}", '
115
+ f'"Google Chrome";v="{chrome_version}"'
116
+ )
117
+ elif "Firefox/" in user_agent:
118
+ sec_ch_ua = None
119
+ else:
120
+ sec_ch_ua = (
121
+ f'"Not_A Brand";v="8", "Chromium";v="{chrome_version}", '
122
+ f'"Google Chrome";v="{chrome_version}"'
123
+ )
124
+
125
+ headers = {
126
+ "Content-Type": "application/json",
127
+ "Accept": "application/json, text/event-stream",
128
+ "Connection": "keep-alive",
129
+ "Cache-Control": "no-cache",
130
+ "User-Agent": user_agent,
131
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
132
+ "X-FE-Version": fe_version,
133
+ "Origin": "https://chat.z.ai",
134
+ }
135
+
136
+ if sec_ch_ua:
137
+ headers["sec-ch-ua"] = sec_ch_ua
138
+ headers["sec-ch-ua-mobile"] = "?0"
139
+ headers["sec-ch-ua-platform"] = '"Windows"'
140
+
141
+ if chat_id:
142
+ headers["Referer"] = f"https://chat.z.ai/c/{chat_id}"
143
+ else:
144
+ headers["Referer"] = "https://chat.z.ai/"
145
+
146
+ return headers
147
+
148
+ def _urlsafe_b64decode(data: str) -> bytes:
149
+ """Decode a URL-safe base64 string with proper padding."""
150
+ if isinstance(data, str):
151
+ data_bytes = data.encode("utf-8")
152
+ else:
153
+ data_bytes = data
154
+ padding = b"=" * (-len(data_bytes) % 4)
155
+ return base64.urlsafe_b64decode(data_bytes + padding)
156
+
157
+
158
+ def _decode_jwt_payload(token: str) -> Dict[str, Any]:
159
+ """Decode JWT payload without verification to extract metadata."""
160
+ try:
161
+ parts = token.split(".")
162
+ if len(parts) < 2:
163
+ return {}
164
+ payload_raw = _urlsafe_b64decode(parts[1])
165
+ return json.loads(payload_raw.decode("utf-8", errors="ignore"))
166
+ except Exception:
167
+ return {}
168
+
169
+
170
+ def _extract_user_id_from_token(token: str) -> str:
171
+ """Extract user_id from a JWT's payload. Fallback to 'guest'."""
172
+ payload = _decode_jwt_payload(token) if token else {}
173
+ for key in ("id", "user_id", "uid", "sub"):
174
+ val = payload.get(key)
175
+ if isinstance(val, (str, int)) and str(val):
176
+ return str(val)
177
+ return "guest"
178
+
179
+
180
+ def _extract_text_from_content(content: Any) -> str:
181
+ """Extract text parts from OpenAI-compatible content payloads."""
182
+ if isinstance(content, str):
183
+ return content
184
+
185
+ if isinstance(content, list):
186
+ parts: List[str] = []
187
+ for item in content:
188
+ if isinstance(item, dict) and item.get("type") == "text":
189
+ parts.append(str(item.get("text", "")))
190
+ return " ".join(part for part in parts if part).strip()
191
+
192
+ if content is None:
193
+ return ""
194
+
195
+ try:
196
+ return json.dumps(content, ensure_ascii=False)
197
+ except Exception:
198
+ return str(content)
199
+
200
+
201
+ def _stringify_tool_arguments(arguments: Any) -> str:
202
+ """Normalize tool-call arguments into a JSON string."""
203
+ if isinstance(arguments, str):
204
+ return arguments
205
+
206
+ try:
207
+ return json.dumps(arguments or {}, ensure_ascii=False)
208
+ except Exception:
209
+ return "{}"
210
+
211
+
212
+ def _build_tool_call_index(
213
+ messages: List[Dict[str, Any]],
214
+ ) -> Dict[str, Dict[str, str]]:
215
+ """Index assistant tool calls by id for later tool-result messages."""
216
+ index: Dict[str, Dict[str, str]] = {}
217
+
218
+ for message in messages:
219
+ if message.get("role") != "assistant":
220
+ continue
221
+
222
+ tool_calls = message.get("tool_calls")
223
+ if not isinstance(tool_calls, list):
224
+ continue
225
+
226
+ for tool_call in tool_calls:
227
+ if not isinstance(tool_call, dict):
228
+ continue
229
+
230
+ tool_call_id = tool_call.get("id")
231
+ function_data = (
232
+ tool_call.get("function")
233
+ if isinstance(tool_call.get("function"), dict)
234
+ else {}
235
+ )
236
+ name = str(function_data.get("name", "")).strip()
237
+ if not isinstance(tool_call_id, str) or not name:
238
+ continue
239
+
240
+ index[tool_call_id] = {
241
+ "name": name,
242
+ "arguments": _stringify_tool_arguments(
243
+ function_data.get("arguments")
244
+ ),
245
+ }
246
+
247
+ return index
248
+
249
+
250
+ def _format_tool_result_message(
251
+ tool_name: str,
252
+ tool_arguments: str,
253
+ result_content: str,
254
+ ) -> str:
255
+ """Serialize a tool result into a text block the upstream can consume."""
256
+ return (
257
+ "<tool_execution_result>\n"
258
+ f"<tool_name>{tool_name}</tool_name>\n"
259
+ f"<tool_arguments>{tool_arguments}</tool_arguments>\n"
260
+ f"<tool_output>{result_content}</tool_output>\n"
261
+ "</tool_execution_result>"
262
+ )
263
+
264
+
265
+ def _format_assistant_tool_calls(tool_calls: List[Dict[str, Any]]) -> str:
266
+ """Serialize historical assistant tool calls into a text block."""
267
+ blocks: List[str] = []
268
+
269
+ for tool_call in tool_calls:
270
+ if not isinstance(tool_call, dict):
271
+ continue
272
+
273
+ function_data = (
274
+ tool_call.get("function")
275
+ if isinstance(tool_call.get("function"), dict)
276
+ else {}
277
+ )
278
+ name = str(function_data.get("name", "")).strip()
279
+ if not name:
280
+ continue
281
+
282
+ arguments = _stringify_tool_arguments(function_data.get("arguments"))
283
+ blocks.append(
284
+ "<function_call>\n"
285
+ f"<name>{name}</name>\n"
286
+ f"<args_json>{arguments}</args_json>\n"
287
+ "</function_call>"
288
+ )
289
+
290
+ if not blocks:
291
+ return ""
292
+
293
+ return "<function_calls>\n" + "\n".join(blocks) + "\n</function_calls>"
294
+
295
+
296
+ def _preprocess_openai_messages(
297
+ messages: List[Dict[str, Any]],
298
+ ) -> List[Dict[str, Any]]:
299
+ """Normalize OpenAI history into shapes accepted by the upstream service."""
300
+ tool_call_index = _build_tool_call_index(messages)
301
+ normalized: List[Dict[str, Any]] = []
302
+
303
+ for message in messages:
304
+ if not isinstance(message, dict):
305
+ continue
306
+
307
+ role = message.get("role")
308
+
309
+ if role == "developer":
310
+ converted = dict(message)
311
+ converted["role"] = "system"
312
+ normalized.append(converted)
313
+ continue
314
+
315
+ if role == "tool":
316
+ tool_call_id = message.get("tool_call_id")
317
+ content = _extract_text_from_content(message.get("content"))
318
+ tool_info = tool_call_index.get(
319
+ tool_call_id,
320
+ {
321
+ "name": str(message.get("name") or "unknown_tool"),
322
+ "arguments": "{}",
323
+ },
324
+ )
325
+ normalized.append(
326
+ {
327
+ "role": "user",
328
+ "content": _format_tool_result_message(
329
+ tool_info["name"],
330
+ tool_info["arguments"],
331
+ content,
332
+ ),
333
+ }
334
+ )
335
+ continue
336
+
337
+ if role == "assistant" and isinstance(message.get("tool_calls"), list):
338
+ content = _extract_text_from_content(message.get("content"))
339
+ tool_calls_text = _format_assistant_tool_calls(message["tool_calls"])
340
+ merged_content = "\n".join(
341
+ part for part in (content, tool_calls_text) if part
342
+ ).strip()
343
+ normalized.append({"role": "assistant", "content": merged_content})
344
+ continue
345
+
346
+ normalized.append(dict(message))
347
+
348
+ return normalized
349
+
350
+
351
+ def _extract_last_user_text(messages: List[Dict[str, Any]]) -> str:
352
+ """Extract the last user text from the original OpenAI message history."""
353
+ for message in reversed(messages):
354
+ if message.get("role") != "user":
355
+ continue
356
+ content = _extract_text_from_content(message.get("content"))
357
+ if content:
358
+ return content
359
+ return ""
360
+
361
+
362
+
363
+ class UpstreamClient:
364
+ """当前服务使用的上游适配器。"""
365
+
366
+ def __init__(self):
367
+ self.name = "upstream"
368
+ self.logger = logger
369
+ self.api_endpoint = settings.API_ENDPOINT
370
+
371
+ # 当前上游特定配置
372
+ self.base_url = DEFAULT_ZAI_BASE_URL
373
+ self.auth_url = f"{self.base_url}/api/v1/auths/"
374
+
375
+ # 模型映射
376
+ self.model_mapping = {
377
+ settings.GLM45_MODEL: "0727-360B-API", # GLM-4.5
378
+ settings.GLM45_THINKING_MODEL: "0727-360B-API", # GLM-4.5-Thinking
379
+ settings.GLM45_SEARCH_MODEL: "0727-360B-API", # GLM-4.5-Search
380
+ settings.GLM45_AIR_MODEL: "0727-106B-API", # GLM-4.5-Air
381
+ settings.GLM46V_MODEL: "glm-4.6v", # GLM-4.6V多模态
382
+ settings.GLM5_MODEL: "glm-5", # GLM-5
383
+ settings.GLM47_MODEL: "glm-4.7", # GLM-4.7
384
+ settings.GLM47_THINKING_MODEL: "glm-4.7", # GLM-4.7-Thinking
385
+ settings.GLM47_SEARCH_MODEL: "glm-4.7", # GLM-4.7-Search
386
+ settings.GLM47_ADVANCED_SEARCH_MODEL: "glm-4.7", # GLM-4.7-advanced-search
387
+ }
388
+
389
+ def _get_guest_retry_limit(self) -> int:
390
+ """匿名号池可提供的最大重试预算。"""
391
+ if not settings.ANONYMOUS_MODE:
392
+ return 0
393
+
394
+ guest_pool = get_guest_session_pool()
395
+ if not guest_pool:
396
+ return max(2, settings.GUEST_POOL_SIZE + 1)
397
+
398
+ pool_status = guest_pool.get_pool_status()
399
+ available_sessions = int(
400
+ pool_status.get("valid_sessions")
401
+ or pool_status.get("available_sessions")
402
+ or 0
403
+ )
404
+ return max(2, available_sessions + 1)
405
+
406
+ def _get_authenticated_retry_limit(self) -> int:
407
+ """认证号池与静态 Token 可提供的最大重试预算。"""
408
+ available_tokens = 0
409
+ token_pool = get_token_pool()
410
+ if token_pool:
411
+ available_tokens = int(
412
+ token_pool.get_pool_status().get("available_tokens", 0) or 0
413
+ )
414
+
415
+ return max(0, available_tokens)
416
+
417
+ def _get_total_retry_limit(self) -> int:
418
+ """综合认证号池与匿名号池的最大尝试次数。"""
419
+ return max(
420
+ 1,
421
+ self._get_authenticated_retry_limit() + self._get_guest_retry_limit(),
422
+ )
423
+
424
+ def _is_guest_auth(self, transformed: Dict[str, Any]) -> bool:
425
+ """判断当前请求是否使用匿名会话。"""
426
+ return str(transformed.get("auth_mode") or "") == "guest"
427
+
428
+ def _should_retry_guest_session(
429
+ self,
430
+ status_code: int,
431
+ is_concurrency_limited: bool,
432
+ attempt: int,
433
+ max_attempts: int,
434
+ transformed: Dict[str, Any],
435
+ ) -> bool:
436
+ """判断匿名号池是否需要刷新会话后重试。"""
437
+ return (
438
+ self._is_guest_auth(transformed)
439
+ and (status_code == 401 or is_concurrency_limited)
440
+ and attempt + 1 < max_attempts
441
+ )
442
+
443
+ def _should_retry_authenticated_session(
444
+ self,
445
+ status_code: int,
446
+ is_concurrency_limited: bool,
447
+ attempt: int,
448
+ max_attempts: int,
449
+ transformed: Dict[str, Any],
450
+ ) -> bool:
451
+ """判断认证号池是否需要切号重试。"""
452
+ current_token = str(transformed.get("token") or "")
453
+ return (
454
+ not self._is_guest_auth(transformed)
455
+ and bool(current_token)
456
+ and (status_code == 401 or is_concurrency_limited)
457
+ and attempt + 1 < max_attempts
458
+ )
459
+
460
+ async def _release_guest_session(self, transformed: Dict[str, Any]):
461
+ """释放当前匿名会话占用。"""
462
+ if not self._is_guest_auth(transformed):
463
+ return
464
+
465
+ guest_pool = get_guest_session_pool()
466
+ guest_user_id = str(
467
+ transformed.get("guest_user_id") or transformed.get("user_id") or ""
468
+ )
469
+ if guest_pool and guest_user_id:
470
+ guest_pool.release(guest_user_id)
471
+
472
+ async def _report_guest_session_failure(
473
+ self,
474
+ transformed: Dict[str, Any],
475
+ *,
476
+ is_concurrency_limited: bool = False,
477
+ ):
478
+ """上报匿名会话失败并补齐新会话。"""
479
+ if not self._is_guest_auth(transformed):
480
+ return
481
+
482
+ guest_pool = get_guest_session_pool()
483
+ guest_user_id = str(
484
+ transformed.get("guest_user_id") or transformed.get("user_id") or ""
485
+ )
486
+ if not guest_pool or not guest_user_id:
487
+ return
488
+
489
+ if is_concurrency_limited:
490
+ await guest_pool.cleanup_idle_chats()
491
+
492
+ await guest_pool.report_failure(guest_user_id)
493
+
494
+ async def _refresh_guest_request(
495
+ self,
496
+ request: OpenAIRequest,
497
+ attempt: int,
498
+ excluded_tokens: Set[str],
499
+ excluded_guest_user_ids: Set[str],
500
+ failed_transformed: Dict[str, Any],
501
+ is_concurrency_limited: bool = False,
502
+ ) -> Dict[str, Any]:
503
+ """匿名会话失效或并发受限后切换会话并重签请求。"""
504
+ retry_number = attempt + 2
505
+ self.logger.warning(
506
+ "🔄 匿名会话不可用,正在切换匿名会话并进行第 "
507
+ f"{retry_number} 次请求"
508
+ )
509
+ await self._report_guest_session_failure(
510
+ failed_transformed,
511
+ is_concurrency_limited=is_concurrency_limited,
512
+ )
513
+ return await self.transform_request(
514
+ request,
515
+ excluded_tokens=excluded_tokens,
516
+ excluded_guest_user_ids=excluded_guest_user_ids,
517
+ )
518
+
519
+ async def _refresh_authenticated_request(
520
+ self,
521
+ request: OpenAIRequest,
522
+ attempt: int,
523
+ excluded_tokens: Set[str],
524
+ excluded_guest_user_ids: Set[str],
525
+ ) -> Dict[str, Any]:
526
+ """认证模式下切换到下一枚 Token,并允许回退匿名池。"""
527
+ retry_number = attempt + 2
528
+ self.logger.warning(
529
+ "🔄 检测到认证会话不可用,正在切换认证 Token/回退匿名池并进行第 "
530
+ f"{retry_number} 次请求"
531
+ )
532
+ return await self.transform_request(
533
+ request,
534
+ excluded_tokens=excluded_tokens,
535
+ excluded_guest_user_ids=excluded_guest_user_ids,
536
+ )
537
+
538
+ def _extract_upstream_error_details(
539
+ self,
540
+ status_code: int,
541
+ error_text: str,
542
+ ) -> Tuple[Optional[int], str]:
543
+ """解析上游错误响应中的 code/message。"""
544
+ parsed_code: Optional[int] = None
545
+ parsed_message = (error_text or "").strip()
546
+
547
+ try:
548
+ payload = json.loads(error_text)
549
+ except Exception:
550
+ return parsed_code, parsed_message
551
+
552
+ if not isinstance(payload, dict):
553
+ return parsed_code, parsed_message
554
+
555
+ candidates = [
556
+ payload,
557
+ payload.get("error") if isinstance(payload.get("error"), dict) else None,
558
+ payload.get("detail") if isinstance(payload.get("detail"), dict) else None,
559
+ payload.get("data") if isinstance(payload.get("data"), dict) else None,
560
+ ]
561
+
562
+ for candidate in candidates:
563
+ if not isinstance(candidate, dict):
564
+ continue
565
+
566
+ code = candidate.get("code")
567
+ if isinstance(code, int):
568
+ parsed_code = code
569
+ elif isinstance(code, str) and code.isdigit():
570
+ parsed_code = int(code)
571
+
572
+ for key in ("message", "msg", "detail", "error"):
573
+ value = candidate.get(key)
574
+ if isinstance(value, str) and value.strip():
575
+ parsed_message = value.strip()
576
+ break
577
+
578
+ if parsed_code is not None or parsed_message:
579
+ break
580
+
581
+ return parsed_code, parsed_message
582
+
583
+ def _is_concurrency_limited(
584
+ self,
585
+ status_code: int,
586
+ error_code: Optional[int],
587
+ error_message: str,
588
+ ) -> bool:
589
+ """判断是否为上游并发限制/429 场景。"""
590
+ message = (error_message or "").casefold()
591
+ return (
592
+ status_code == 429
593
+ or error_code == 429
594
+ or "concurrency" in message
595
+ or "too many requests" in message
596
+ or "并发" in error_message
597
+ )
598
+
599
+ def get_supported_models(self) -> List[str]:
600
+ """获取支持的模型列表"""
601
+ return [
602
+ settings.GLM45_MODEL,
603
+ settings.GLM45_THINKING_MODEL,
604
+ settings.GLM45_SEARCH_MODEL,
605
+ settings.GLM45_AIR_MODEL,
606
+ settings.GLM46V_MODEL,
607
+ settings.GLM5_MODEL,
608
+ settings.GLM47_MODEL,
609
+ settings.GLM47_THINKING_MODEL,
610
+ settings.GLM47_SEARCH_MODEL,
611
+ settings.GLM47_ADVANCED_SEARCH_MODEL,
612
+ ]
613
+
614
+ def _requires_persisted_chat(self, upstream_model_id: str) -> bool:
615
+ """需要挂载真实 chat 会话的上游模型。"""
616
+ return bool(
617
+ self._get_model_request_profile(upstream_model_id)["use_persisted_chat"]
618
+ )
619
+
620
+ def _get_model_request_profile(self, upstream_model_id: str) -> Dict[str, Any]:
621
+ """返回模型专属的请求配置。"""
622
+ if upstream_model_id == "glm-4.6v":
623
+ return {
624
+ "use_persisted_chat": True,
625
+ "preview_mode": False,
626
+ "mcp_servers": list(GLM46V_MCP_SERVERS),
627
+ "feature_entries": [dict(item) for item in GLM46V_SELECTED_FEATURES],
628
+ "default_enable_thinking": True,
629
+ }
630
+
631
+ if upstream_model_id == "glm-5":
632
+ return {
633
+ "use_persisted_chat": False,
634
+ "preview_mode": True,
635
+ "mcp_servers": [],
636
+ "feature_entries": [],
637
+ "default_enable_thinking": True,
638
+ }
639
+
640
+ return {
641
+ "use_persisted_chat": upstream_model_id == "glm-4.7",
642
+ "preview_mode": True,
643
+ "mcp_servers": [],
644
+ "feature_entries": [],
645
+ "default_enable_thinking": None,
646
+ }
647
+
648
+ def _build_request_variables(self) -> Dict[str, str]:
649
+ """构建上游请求需要的运行时变量。"""
650
+ now = datetime.now()
651
+ return {
652
+ "{{USER_NAME}}": "Guest",
653
+ "{{USER_LOCATION}}": "Unknown",
654
+ "{{CURRENT_DATETIME}}": now.strftime("%Y-%m-%d %H:%M:%S"),
655
+ "{{CURRENT_DATE}}": now.strftime("%Y-%m-%d"),
656
+ "{{CURRENT_TIME}}": now.strftime("%H:%M:%S"),
657
+ "{{CURRENT_WEEKDAY}}": now.strftime("%A"),
658
+ "{{CURRENT_TIMEZONE}}": DEFAULT_TIMEZONE,
659
+ "{{USER_LANGUAGE}}": DEFAULT_LANGUAGE,
660
+ }
661
+
662
+ def _build_browser_query_params(
663
+ self,
664
+ *,
665
+ chat_id: str,
666
+ token: str,
667
+ user_id: str,
668
+ user_agent: str,
669
+ timestamp_ms: int,
670
+ ) -> Dict[str, str]:
671
+ """构建 GLM-4.7 所需的浏览器指纹查询参数。"""
672
+ now = datetime.now(timezone.utc)
673
+ browser_name = "Chrome"
674
+ if "Edg/" in user_agent:
675
+ browser_name = "Microsoft Edge"
676
+ elif "Firefox/" in user_agent:
677
+ browser_name = "Firefox"
678
+ elif "Safari/" in user_agent and "Chrome/" not in user_agent:
679
+ browser_name = "Safari"
680
+
681
+ return {
682
+ "version": DEFAULT_CLIENT_VERSION,
683
+ "platform": DEFAULT_PLATFORM,
684
+ "token": token,
685
+ "user_agent": user_agent,
686
+ "language": DEFAULT_LANGUAGE,
687
+ "languages": DEFAULT_LANGUAGE,
688
+ "timezone": DEFAULT_TIMEZONE,
689
+ "cookie_enabled": "true",
690
+ "screen_width": DEFAULT_SCREEN_WIDTH,
691
+ "screen_height": DEFAULT_SCREEN_HEIGHT,
692
+ "screen_resolution": DEFAULT_SCREEN_RESOLUTION,
693
+ "viewport_height": DEFAULT_VIEWPORT_HEIGHT,
694
+ "viewport_width": DEFAULT_VIEWPORT_WIDTH,
695
+ "viewport_size": DEFAULT_VIEWPORT_SIZE,
696
+ "color_depth": DEFAULT_COLOR_DEPTH,
697
+ "pixel_ratio": DEFAULT_PIXEL_RATIO,
698
+ "current_url": f"{self.base_url}/c/{chat_id}",
699
+ "pathname": f"/c/{chat_id}",
700
+ "search": "",
701
+ "hash": "",
702
+ "host": "chat.z.ai",
703
+ "hostname": "chat.z.ai",
704
+ "protocol": "https:",
705
+ "referrer": "",
706
+ "title": DEFAULT_PAGE_TITLE,
707
+ "timezone_offset": DEFAULT_TIMEZONE_OFFSET,
708
+ "local_time": (
709
+ now.strftime("%Y-%m-%dT%H:%M:%S.")
710
+ + f"{now.microsecond // 1000:03d}Z"
711
+ ),
712
+ "utc_time": now.strftime("%a, %d %b %Y %H:%M:%S GMT"),
713
+ "is_mobile": "false",
714
+ "is_touch": "false",
715
+ "max_touch_points": DEFAULT_MAX_TOUCH_POINTS,
716
+ "browser_name": browser_name,
717
+ "os_name": "Windows",
718
+ "signature_timestamp": str(timestamp_ms),
719
+ }
720
+
721
+ def _build_signed_completion_request(
722
+ self,
723
+ *,
724
+ prompt: str,
725
+ chat_id: str,
726
+ token: str,
727
+ user_id: str,
728
+ user_agent: str,
729
+ use_browser_fingerprint: bool,
730
+ ) -> Tuple[str, str, str]:
731
+ """构建上游 completions 的签名 URL 与请求头元数据。"""
732
+ timestamp_ms = int(time.time() * 1000)
733
+ request_id = generate_uuid()
734
+ core_params = {
735
+ "requestId": request_id,
736
+ "timestamp": str(timestamp_ms),
737
+ "user_id": user_id,
738
+ }
739
+ canonical_payload = ",".join(
740
+ f"{key},{value}" for key, value in sorted(core_params.items())
741
+ )
742
+ signature = generate_signature(
743
+ e=canonical_payload,
744
+ t=prompt or "",
745
+ s=timestamp_ms,
746
+ )["signature"]
747
+ query_params = dict(core_params)
748
+ if use_browser_fingerprint:
749
+ query_params.update(
750
+ self._build_browser_query_params(
751
+ chat_id=chat_id,
752
+ token=token,
753
+ user_id=user_id,
754
+ user_agent=user_agent,
755
+ timestamp_ms=timestamp_ms,
756
+ )
757
+ )
758
+ else:
759
+ query_params.update(
760
+ {
761
+ "token": token,
762
+ "version": DEFAULT_CLIENT_VERSION,
763
+ "platform": DEFAULT_PLATFORM,
764
+ "current_url": f"{self.base_url}/c/{chat_id}",
765
+ "pathname": f"/c/{chat_id}",
766
+ "signature_timestamp": str(timestamp_ms),
767
+ }
768
+ )
769
+
770
+ return (
771
+ f"{self.api_endpoint}?{urlencode(query_params)}",
772
+ signature,
773
+ str(timestamp_ms),
774
+ )
775
+
776
+ async def _create_upstream_chat(
777
+ self,
778
+ *,
779
+ prompt: str,
780
+ model: str,
781
+ token: str,
782
+ headers: Dict[str, str],
783
+ enable_thinking: bool,
784
+ web_search: bool,
785
+ user_message_id: Optional[str] = None,
786
+ files: Optional[List[Dict[str, Any]]] = None,
787
+ feature_entries: Optional[List[Dict[str, Any]]] = None,
788
+ mcp_servers: Optional[List[str]] = None,
789
+ ) -> str:
790
+ """为 GLM-4.7 系列创建上游真实 chat 会话。"""
791
+ init_content = prompt[:CHAT_BOOTSTRAP_MAX_CONTENT_LEN]
792
+ if len(prompt) > CHAT_BOOTSTRAP_MAX_CONTENT_LEN:
793
+ init_content = init_content + "..."
794
+
795
+ message_id = user_message_id or generate_uuid()
796
+ timestamp_seconds = int(time.time())
797
+ chat_features = (
798
+ [dict(item) for item in feature_entries]
799
+ if feature_entries
800
+ else [
801
+ {
802
+ "type": "tool_selector",
803
+ "server": "tool_selector_h",
804
+ "status": "hidden",
805
+ }
806
+ ]
807
+ )
808
+ body = {
809
+ "chat": {
810
+ "id": "",
811
+ "title": "新聊天",
812
+ "models": [model],
813
+ "params": {},
814
+ "history": {
815
+ "messages": {
816
+ message_id: {
817
+ "id": message_id,
818
+ "parentId": None,
819
+ "childrenIds": [],
820
+ "role": "user",
821
+ "content": init_content,
822
+ **({"files": [dict(item) for item in files]} if files else {}),
823
+ "timestamp": timestamp_seconds,
824
+ "models": [model],
825
+ }
826
+ },
827
+ "currentId": message_id,
828
+ },
829
+ "tags": [],
830
+ "flags": [],
831
+ "features": chat_features,
832
+ "mcp_servers": list(mcp_servers or []),
833
+ "enable_thinking": enable_thinking,
834
+ "auto_web_search": web_search,
835
+ "message_version": 1,
836
+ "extra": {},
837
+ "timestamp": int(time.time() * 1000),
838
+ }
839
+ }
840
+ request_headers = {
841
+ "Content-Type": "application/json",
842
+ "Accept": "application/json",
843
+ "Authorization": f"Bearer {token}",
844
+ "User-Agent": headers["User-Agent"],
845
+ "Accept-Language": headers.get("Accept-Language", DEFAULT_LANGUAGE),
846
+ "Origin": self.base_url,
847
+ "Referer": f"{self.base_url}/",
848
+ }
849
+ async with httpx.AsyncClient(
850
+ base_url=self.base_url,
851
+ timeout=self._build_timeout(),
852
+ limits=self._build_limits(),
853
+ proxy=self._get_proxy_config(),
854
+ follow_redirects=True,
855
+ ) as client:
856
+ response = await client.post(
857
+ "/api/v1/chats/new",
858
+ headers=request_headers,
859
+ json=body,
860
+ )
861
+
862
+ if response.status_code != 200:
863
+ raise RuntimeError(
864
+ f"上游创建 chat 失败: {response.status_code} {response.text}"
865
+ )
866
+
867
+ payload = response.json()
868
+ chat_id = str(payload.get("id") or payload.get("chat", {}).get("id") or "")
869
+ if not chat_id:
870
+ raise RuntimeError("上游创建 chat 成功但未返回 chat_id")
871
+ return chat_id
872
+
873
+ def _build_glm47_completion_body(
874
+ self,
875
+ *,
876
+ model: str,
877
+ messages: List[Dict[str, Any]],
878
+ prompt: str,
879
+ chat_id: str,
880
+ enable_thinking: bool,
881
+ web_search: bool,
882
+ files: List[Dict[str, Any]],
883
+ tools: Optional[List[Dict[str, Any]]],
884
+ tool_choice: Any,
885
+ temperature: Optional[float],
886
+ max_tokens: Optional[int],
887
+ mcp_servers: List[str],
888
+ preview_mode: bool,
889
+ feature_entries: Optional[List[Dict[str, Any]]],
890
+ message_id: str,
891
+ current_user_message_id: str,
892
+ current_user_message_parent_id: Optional[str],
893
+ ) -> Dict[str, Any]:
894
+ """构建兼容持久化 chat 模型的精简 completions 请求体。"""
895
+ params: Dict[str, Any] = {}
896
+ if temperature is not None:
897
+ params["temperature"] = temperature
898
+ if max_tokens is not None:
899
+ params["max_tokens"] = max_tokens
900
+
901
+ body: Dict[str, Any] = {
902
+ "stream": True,
903
+ "model": model,
904
+ "messages": messages,
905
+ "signature_prompt": prompt,
906
+ "params": params,
907
+ "extra": {},
908
+ "features": {
909
+ "image_generation": False,
910
+ "web_search": web_search,
911
+ "auto_web_search": web_search,
912
+ "preview_mode": preview_mode,
913
+ "flags": [],
914
+ "enable_thinking": enable_thinking,
915
+ },
916
+ "variables": self._build_request_variables(),
917
+ "chat_id": chat_id,
918
+ "id": message_id,
919
+ "current_user_message_id": current_user_message_id,
920
+ "current_user_message_parent_id": current_user_message_parent_id,
921
+ "background_tasks": {
922
+ "title_generation": True,
923
+ "tags_generation": True,
924
+ },
925
+ }
926
+ if files:
927
+ body["files"] = files
928
+ if mcp_servers:
929
+ body["mcp_servers"] = mcp_servers
930
+ if tools:
931
+ body["tools"] = tools
932
+ if tool_choice is not None:
933
+ body["tool_choice"] = tool_choice
934
+ return body
935
+
936
+ def _clean_reasoning_delta(self, delta_content: str) -> str:
937
+ """清理思考阶段的 details 包裹内容。"""
938
+ if not delta_content:
939
+ return ""
940
+
941
+ if delta_content.startswith("<details"):
942
+ if "</summary>\n>" in delta_content:
943
+ return delta_content.split("</summary>\n>")[-1].strip()
944
+ if "</summary>\n" in delta_content:
945
+ return delta_content.split("</summary>\n")[-1].lstrip("> ").strip()
946
+
947
+ return delta_content
948
+
949
+ def _extract_answer_content(self, text: str) -> str:
950
+ """提取思考结束后的答案正文。"""
951
+ if not text:
952
+ return ""
953
+
954
+ if "</details>\n" in text:
955
+ return text.split("</details>\n")[-1]
956
+
957
+ if "</details>" in text:
958
+ return text.split("</details>")[-1].lstrip()
959
+
960
+ return text
961
+
962
+ def _normalize_tool_calls(
963
+ self,
964
+ raw_tool_calls: Any,
965
+ start_index: int = 0,
966
+ ) -> List[Dict[str, Any]]:
967
+ """标准化上游工具调用为 OpenAI 兼容格式。"""
968
+ if not raw_tool_calls:
969
+ return []
970
+
971
+ tool_calls = raw_tool_calls if isinstance(raw_tool_calls, list) else [raw_tool_calls]
972
+ normalized: List[Dict[str, Any]] = []
973
+
974
+ for offset, tool_call in enumerate(tool_calls):
975
+ if not isinstance(tool_call, dict):
976
+ continue
977
+
978
+ function_data = tool_call.get("function") or {}
979
+ normalized.append(
980
+ {
981
+ "index": tool_call.get("index", start_index + offset),
982
+ "id": tool_call.get("id") or f"call_{uuid.uuid4().hex[:24]}",
983
+ "type": "function",
984
+ "function": {
985
+ "name": function_data.get("name", ""),
986
+ "arguments": function_data.get("arguments", ""),
987
+ },
988
+ }
989
+ )
990
+
991
+ return normalized
992
+
993
+ def _format_search_results(self, data: Dict[str, Any]) -> str:
994
+ """将上游搜索结果格式化为可追加的 Markdown 引用。"""
995
+ search_info = data.get("results") or data.get("sources") or data.get("citations")
996
+ if not isinstance(search_info, list) or not search_info:
997
+ return ""
998
+
999
+ citations = []
1000
+ for index, item in enumerate(search_info, 1):
1001
+ if not isinstance(item, dict):
1002
+ continue
1003
+
1004
+ title = item.get("title") or item.get("name") or f"Result {index}"
1005
+ url = item.get("url") or item.get("link")
1006
+ if url:
1007
+ citations.append(f"[{index}] [{title}]({url})")
1008
+
1009
+ if not citations:
1010
+ return ""
1011
+
1012
+ return "\n\n---\n" + "\n".join(citations)
1013
+
1014
+ def _get_proxy_config(self) -> Optional[str]:
1015
+ """Get proxy configuration from settings"""
1016
+ # In httpx 0.28.1, proxy parameter expects a single URL string
1017
+ # Support HTTP_PROXY, HTTPS_PROXY and SOCKS5_PROXY
1018
+
1019
+ if settings.HTTPS_PROXY:
1020
+ self.logger.info(f"🔄 使用HTTPS代理: {settings.HTTPS_PROXY}")
1021
+ return settings.HTTPS_PROXY
1022
+
1023
+ if settings.HTTP_PROXY:
1024
+ self.logger.info(f"🔄 使用HTTP代理: {settings.HTTP_PROXY}")
1025
+ return settings.HTTP_PROXY
1026
+
1027
+ if settings.SOCKS5_PROXY:
1028
+ self.logger.info(f"🔄 使用SOCKS5代理: {settings.SOCKS5_PROXY}")
1029
+ return settings.SOCKS5_PROXY
1030
+
1031
+ return None
1032
+
1033
+ def _build_timeout(self, read_timeout: float = 30.0) -> httpx.Timeout:
1034
+ """Create httpx timeout settings tuned for upstream chat traffic."""
1035
+ return httpx.Timeout(
1036
+ connect=5.0,
1037
+ read=read_timeout,
1038
+ write=10.0,
1039
+ pool=5.0,
1040
+ )
1041
+
1042
+ def _build_limits(self) -> httpx.Limits:
1043
+ """Create conservative connection-pool limits for upstream requests."""
1044
+ return httpx.Limits(
1045
+ max_keepalive_connections=5,
1046
+ max_connections=10,
1047
+ )
1048
+
1049
+ async def _fetch_direct_guest_auth(self) -> Dict[str, Any]:
1050
+ """匿名号池缺席时,兜底直连拉取一个访客令牌。"""
1051
+ max_retries = 3
1052
+
1053
+ for retry_count in range(max_retries):
1054
+ try:
1055
+ headers = get_dynamic_headers()
1056
+ self.logger.debug(
1057
+ f"尝试获取访客令牌 (第{retry_count + 1}次): {self.auth_url}"
1058
+ )
1059
+
1060
+ proxies = self._get_proxy_config()
1061
+ async with httpx.AsyncClient(
1062
+ timeout=self._build_timeout(),
1063
+ follow_redirects=True,
1064
+ limits=self._build_limits(),
1065
+ proxy=proxies,
1066
+ ) as client:
1067
+ response = await client.get(self.auth_url, headers=headers)
1068
+
1069
+ if response.status_code == 200:
1070
+ data = response.json()
1071
+ token = str(data.get("token") or "").strip()
1072
+ if token:
1073
+ user_id = str(
1074
+ data.get("id")
1075
+ or data.get("user_id")
1076
+ or _extract_user_id_from_token(token)
1077
+ )
1078
+ username = str(
1079
+ data.get("name")
1080
+ or str(data.get("email") or "").split("@")[0]
1081
+ or "Guest"
1082
+ )
1083
+ self.logger.info(
1084
+ f"✅ 直连获取匿名令牌成功: {token[:20]}..."
1085
+ )
1086
+ return {
1087
+ "token": token,
1088
+ "user_id": user_id,
1089
+ "username": username or "Guest",
1090
+ "auth_mode": "guest",
1091
+ "token_source": "guest_direct",
1092
+ "guest_user_id": user_id,
1093
+ }
1094
+
1095
+ self.logger.warning(f"响应中未找到 token 字段: {data}")
1096
+ elif response.status_code == 405:
1097
+ self.logger.error(
1098
+ "🚫 请求被 WAF 拦截 (405),无法直连获取匿名令牌"
1099
+ )
1100
+ break
1101
+ else:
1102
+ self.logger.warning(
1103
+ f"直连获取匿名令牌失败,状态码: {response.status_code}"
1104
+ )
1105
+ except httpx.TimeoutException as exc:
1106
+ self.logger.warning(
1107
+ f"直连获取匿名令牌超时 (第{retry_count + 1}次): {exc}"
1108
+ )
1109
+ except httpx.ConnectError as exc:
1110
+ self.logger.warning(
1111
+ f"直连获取匿名令牌连接错误 (第{retry_count + 1}次): {exc}"
1112
+ )
1113
+ except json.JSONDecodeError as exc:
1114
+ self.logger.warning(
1115
+ f"直连获取匿名令牌 JSON 解析错误 (第{retry_count + 1}次): {exc}"
1116
+ )
1117
+ except Exception as exc:
1118
+ self.logger.warning(
1119
+ f"直连获取匿名令牌失败 (第{retry_count + 1}次): {exc}"
1120
+ )
1121
+
1122
+ if retry_count + 1 < max_retries:
1123
+ await asyncio.sleep(2)
1124
+
1125
+ return {
1126
+ "token": "",
1127
+ "user_id": "guest",
1128
+ "username": "Guest",
1129
+ "auth_mode": "guest",
1130
+ "token_source": "guest_direct",
1131
+ "guest_user_id": None,
1132
+ }
1133
+
1134
+ async def get_auth_info(
1135
+ self,
1136
+ excluded_tokens: Optional[Set[str]] = None,
1137
+ excluded_guest_user_ids: Optional[Set[str]] = None,
1138
+ ) -> Dict[str, Any]:
1139
+ """优先获取认证 Token,必要时回退匿名会话池。"""
1140
+ token_pool = get_token_pool()
1141
+ if token_pool:
1142
+ token = token_pool.get_next_token(exclude_tokens=excluded_tokens)
1143
+ if token:
1144
+ user_id = _extract_user_id_from_token(token)
1145
+ self.logger.debug(f"从认证号池获取令牌: {token[:20]}...")
1146
+ return {
1147
+ "token": token,
1148
+ "user_id": user_id,
1149
+ "username": "User",
1150
+ "auth_mode": "authenticated",
1151
+ "token_source": "auth_pool",
1152
+ "guest_user_id": None,
1153
+ }
1154
+
1155
+ if settings.ANONYMOUS_MODE:
1156
+ guest_pool = get_guest_session_pool()
1157
+ if guest_pool:
1158
+ try:
1159
+ session = await guest_pool.acquire(
1160
+ exclude_user_ids=excluded_guest_user_ids
1161
+ )
1162
+ self.logger.info(
1163
+ "🫥 认证池不可用,回退匿名会话池: "
1164
+ f"user_id={session.user_id}"
1165
+ )
1166
+ return {
1167
+ "token": session.token,
1168
+ "user_id": session.user_id,
1169
+ "username": session.username,
1170
+ "auth_mode": "guest",
1171
+ "token_source": "guest_pool",
1172
+ "guest_user_id": session.user_id,
1173
+ }
1174
+ except Exception as exc:
1175
+ self.logger.warning(f"匿名会话池获取失败,转为直连访客鉴权: {exc}")
1176
+
1177
+ return await self._fetch_direct_guest_auth()
1178
+
1179
+ self.logger.error("❌ 无法获取有效的上游令牌")
1180
+ return {
1181
+ "token": "",
1182
+ "user_id": "",
1183
+ "username": "",
1184
+ "auth_mode": "authenticated",
1185
+ "token_source": "none",
1186
+ "guest_user_id": None,
1187
+ }
1188
+
1189
+ async def mark_token_failure(self, token: str, error: Exception = None):
1190
+ """标记token使用失败"""
1191
+ token_pool = get_token_pool()
1192
+ if token_pool:
1193
+ await token_pool.record_token_failure(token, error)
1194
+
1195
+ async def upload_image(
1196
+ self,
1197
+ data_url: str,
1198
+ chat_id: str,
1199
+ token: str,
1200
+ user_id: str,
1201
+ auth_mode: str = "authenticated",
1202
+ ) -> Optional[Dict]:
1203
+ """上传 base64 编码的图片到上游服务器。
1204
+
1205
+ Args:
1206
+ data_url: data:image/xxx;base64,... 格式的图片数据
1207
+ chat_id: 当前对话ID
1208
+ token: 认证令牌
1209
+ user_id: 用户ID
1210
+ auth_mode: 当前鉴权模式,guest 模式下禁止上传
1211
+
1212
+ Returns:
1213
+ 上传成功返回完整的文件信息字典,失败返回 None
1214
+ """
1215
+ if auth_mode == "guest" or not data_url.startswith("data:"):
1216
+ return None
1217
+
1218
+ try:
1219
+ # 解析 data URL
1220
+ header, encoded = data_url.split(",", 1)
1221
+ mime_type = header.split(";")[0].split(":")[1] if ":" in header else "image/jpeg"
1222
+
1223
+ # 解码 base64 数据
1224
+ image_data = base64.b64decode(encoded)
1225
+ filename = str(uuid.uuid4())
1226
+
1227
+ self.logger.debug(f"📤 上传图片: {filename}, 大小: {len(image_data)} bytes")
1228
+
1229
+ # 构建上传请求
1230
+ upload_url = f"{self.base_url}/api/v1/files/"
1231
+ headers = {
1232
+ "Accept": "*/*",
1233
+ "Accept-Language": "zh-CN,zh;q=0.9",
1234
+ "Cache-Control": "no-cache",
1235
+ "Connection": "keep-alive",
1236
+ "Origin": f"{self.base_url}",
1237
+ "Pragma": "no-cache",
1238
+ "Referer": (
1239
+ f"{self.base_url}/c/{chat_id}" if chat_id else f"{self.base_url}/"
1240
+ ),
1241
+ "Sec-Ch-Ua": '"Microsoft Edge";v="141", "Not?A_Brand";v="8", "Chromium";v="141"',
1242
+ "Sec-Ch-Ua-Mobile": "?0",
1243
+ "Sec-Ch-Ua-Platform": '"Windows"',
1244
+ "Sec-Fetch-Dest": "empty",
1245
+ "Sec-Fetch-Mode": "cors",
1246
+ "Sec-Fetch-Site": "same-origin",
1247
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36 Edg/141.0.0.0",
1248
+ "Authorization": f"Bearer {token}",
1249
+ }
1250
+
1251
+ # Get proxy configuration
1252
+ proxies = self._get_proxy_config()
1253
+
1254
+ # 使用 httpx 上传文件
1255
+ async with httpx.AsyncClient(
1256
+ timeout=self._build_timeout(),
1257
+ limits=self._build_limits(),
1258
+ proxy=proxies,
1259
+ ) as client:
1260
+ files = {
1261
+ "file": (filename, image_data, mime_type)
1262
+ }
1263
+ response = await client.post(upload_url, files=files, headers=headers)
1264
+
1265
+ if response.status_code == 200:
1266
+ result = response.json()
1267
+ file_id = result.get("id")
1268
+ file_name = result.get("filename")
1269
+ file_size = len(image_data)
1270
+
1271
+ self.logger.info(f"✅ 图片上传成功: {file_id}_{file_name}")
1272
+
1273
+ # 返回符合上游格式的文件信息
1274
+ current_timestamp = int(time.time())
1275
+ return {
1276
+ "type": "image",
1277
+ "file": {
1278
+ "id": file_id,
1279
+ "user_id": user_id,
1280
+ "hash": None,
1281
+ "filename": file_name,
1282
+ "data": {},
1283
+ "meta": {
1284
+ "name": file_name,
1285
+ "content_type": mime_type,
1286
+ "size": file_size,
1287
+ "data": {},
1288
+ },
1289
+ "created_at": current_timestamp,
1290
+ "updated_at": current_timestamp
1291
+ },
1292
+ "id": file_id,
1293
+ "url": f"/api/v1/files/{file_id}/content",
1294
+ "name": file_name,
1295
+ "status": "uploaded",
1296
+ "size": file_size,
1297
+ "error": "",
1298
+ "itemId": str(uuid.uuid4()),
1299
+ "media": "image"
1300
+ }
1301
+ else:
1302
+ self.logger.error(f"❌ 图片上传失败: {response.status_code} - {response.text}")
1303
+ return None
1304
+
1305
+ except Exception as e:
1306
+ self.logger.error(f"❌ 图片上传异常: {e}")
1307
+ return None
1308
+
1309
+ async def transform_request(
1310
+ self,
1311
+ request: OpenAIRequest,
1312
+ excluded_tokens: Optional[Set[str]] = None,
1313
+ excluded_guest_user_ids: Optional[Set[str]] = None,
1314
+ ) -> Dict[str, Any]:
1315
+ """转换 OpenAI 请求为上游格式。"""
1316
+ self.logger.info(f"🔄 转换 OpenAI 请求到上游格式: {request.model}")
1317
+
1318
+ raw_messages = [
1319
+ message.model_dump(exclude_none=True)
1320
+ for message in request.messages
1321
+ ]
1322
+ normalized_messages = _preprocess_openai_messages(raw_messages)
1323
+
1324
+ auth_info = await self.get_auth_info(
1325
+ excluded_tokens=excluded_tokens,
1326
+ excluded_guest_user_ids=excluded_guest_user_ids,
1327
+ )
1328
+ token = str(auth_info.get("token") or "")
1329
+ if not token:
1330
+ raise RuntimeError("无法获取上游认证令牌")
1331
+
1332
+ user_id = str(auth_info.get("user_id") or _extract_user_id_from_token(token))
1333
+ auth_mode = str(auth_info.get("auth_mode") or "authenticated")
1334
+ token_source = str(auth_info.get("token_source") or "unknown")
1335
+ guest_user_id = auth_info.get("guest_user_id")
1336
+ # 确定请求的模型特性
1337
+ last_user_text = _extract_last_user_text(raw_messages)
1338
+ requested_model = request.model
1339
+ is_thinking_model = "-thinking" in requested_model.casefold()
1340
+ is_search_model = "-search" in requested_model.casefold()
1341
+ is_advanced_search = requested_model == settings.GLM47_ADVANCED_SEARCH_MODEL
1342
+ upstream_model_id = self.model_mapping.get(requested_model, "0727-360B-API")
1343
+ tools = request.tools if settings.TOOL_SUPPORT and request.tools else None
1344
+ tool_choice = getattr(request, "tool_choice", None)
1345
+ model_profile = self._get_model_request_profile(upstream_model_id)
1346
+ enable_thinking = request.enable_thinking
1347
+ if enable_thinking is None:
1348
+ default_enable_thinking = model_profile["default_enable_thinking"]
1349
+ enable_thinking = (
1350
+ default_enable_thinking
1351
+ if default_enable_thinking is not None
1352
+ else is_thinking_model
1353
+ )
1354
+
1355
+ web_search = request.web_search
1356
+ if web_search is None:
1357
+ web_search = is_search_model or is_advanced_search
1358
+
1359
+ use_persisted_chat = bool(model_profile["use_persisted_chat"])
1360
+ preview_mode = bool(model_profile["preview_mode"])
1361
+ feature_entries = list(model_profile["feature_entries"])
1362
+ persisted_user_message_id = generate_uuid() if use_persisted_chat else None
1363
+ persisted_assistant_message_id = generate_uuid() if use_persisted_chat else None
1364
+
1365
+ mcp_servers = list(model_profile["mcp_servers"])
1366
+ if is_advanced_search and "advanced-search" not in mcp_servers:
1367
+ mcp_servers.append("advanced-search")
1368
+ self.logger.info("🔍 检测到高级搜索模型,添加 advanced-search MCP 服务器")
1369
+
1370
+ headers = get_dynamic_headers(
1371
+ browser_type="chrome" if use_persisted_chat else None,
1372
+ )
1373
+ chat_id = generate_uuid()
1374
+
1375
+ # 处理消息格式 - 上游使用单独的 files 字段传递图片
1376
+ messages = []
1377
+ files = []
1378
+ upload_chat_id = "" if use_persisted_chat else chat_id
1379
+
1380
+ for msg in normalized_messages:
1381
+ role = str(msg.get("role", "user"))
1382
+ content = msg.get("content")
1383
+
1384
+ if isinstance(content, str):
1385
+ messages.append({"role": role, "content": content})
1386
+ continue
1387
+
1388
+ if not isinstance(content, list):
1389
+ continue
1390
+
1391
+ text_parts = []
1392
+ image_parts = []
1393
+ for part in content:
1394
+ image_url = None
1395
+ if hasattr(part, "type"):
1396
+ if part.type == "text" and hasattr(part, "text"):
1397
+ text_parts.append(part.text or "")
1398
+ elif part.type == "image_url" and hasattr(part, "image_url"):
1399
+ if hasattr(part.image_url, "url"):
1400
+ image_url = part.image_url.url
1401
+ elif (
1402
+ isinstance(part.image_url, dict)
1403
+ and "url" in part.image_url
1404
+ ):
1405
+ image_url = part.image_url["url"]
1406
+ elif isinstance(part, dict):
1407
+ if part.get("type") == "text":
1408
+ text_parts.append(part.get("text", ""))
1409
+ elif part.get("type") == "image_url":
1410
+ image_url = part.get("image_url", {}).get("url", "")
1411
+ elif isinstance(part, str):
1412
+ text_parts.append(part)
1413
+
1414
+ if not image_url:
1415
+ continue
1416
+
1417
+ self.logger.debug(f"✅ 检测到图片: {image_url[:50]}...")
1418
+ if image_url.startswith("data:") and auth_mode != "guest":
1419
+ self.logger.info("🔄 上传 base64 图片到上游服务")
1420
+ file_info = await self.upload_image(
1421
+ image_url,
1422
+ upload_chat_id,
1423
+ token,
1424
+ user_id,
1425
+ auth_mode=auth_mode,
1426
+ )
1427
+ if not file_info:
1428
+ self.logger.warning("⚠️ 图片上传失败")
1429
+ text_parts.append("[系统提示: 图片上传失败]")
1430
+ continue
1431
+
1432
+ files.append(file_info)
1433
+ self.logger.info("✅ 图片已添加到 files 数组")
1434
+ if persisted_user_message_id:
1435
+ file_info["ref_user_msg_id"] = persisted_user_message_id
1436
+ image_ref = str(file_info["id"])
1437
+ image_parts.append(
1438
+ {
1439
+ "type": "image_url",
1440
+ "image_url": {"url": image_ref},
1441
+ }
1442
+ )
1443
+ self.logger.debug(f"📎 图片引用: {image_ref}")
1444
+ continue
1445
+
1446
+ if auth_mode != "guest":
1447
+ self.logger.warning("⚠️ 非 base64 图片或匿名模式,保留原始URL")
1448
+ image_parts.append(
1449
+ {
1450
+ "type": "image_url",
1451
+ "image_url": {"url": image_url},
1452
+ }
1453
+ )
1454
+
1455
+ message_content = []
1456
+ combined_text = " ".join(text_parts).strip()
1457
+ if combined_text:
1458
+ message_content.append({"type": "text", "text": combined_text})
1459
+ message_content.extend(image_parts)
1460
+ if message_content:
1461
+ messages.append({"role": role, "content": message_content})
1462
+
1463
+ if use_persisted_chat:
1464
+ chat_id = await self._create_upstream_chat(
1465
+ prompt=last_user_text,
1466
+ model=upstream_model_id,
1467
+ token=token,
1468
+ headers=headers,
1469
+ enable_thinking=enable_thinking,
1470
+ web_search=web_search,
1471
+ user_message_id=persisted_user_message_id,
1472
+ files=files or None,
1473
+ feature_entries=feature_entries or None,
1474
+ mcp_servers=mcp_servers or None,
1475
+ )
1476
+ self.logger.info(f"🧩 已为 {requested_model} 创建上游 chat: {chat_id}")
1477
+ headers["Referer"] = f"{self.base_url}/c/{chat_id}"
1478
+
1479
+ if use_persisted_chat:
1480
+ body = self._build_glm47_completion_body(
1481
+ model=upstream_model_id,
1482
+ messages=messages,
1483
+ prompt=last_user_text,
1484
+ chat_id=chat_id,
1485
+ enable_thinking=enable_thinking,
1486
+ web_search=web_search,
1487
+ files=files,
1488
+ tools=tools,
1489
+ tool_choice=tool_choice,
1490
+ temperature=request.temperature,
1491
+ max_tokens=request.max_tokens,
1492
+ mcp_servers=mcp_servers,
1493
+ preview_mode=preview_mode,
1494
+ feature_entries=feature_entries or None,
1495
+ message_id=persisted_assistant_message_id or generate_uuid(),
1496
+ current_user_message_id=persisted_user_message_id or generate_uuid(),
1497
+ current_user_message_parent_id=None,
1498
+ )
1499
+ else:
1500
+ message_id = generate_uuid()
1501
+ session_id = generate_uuid()
1502
+ body = {
1503
+ "stream": True,
1504
+ "model": upstream_model_id,
1505
+ "messages": messages,
1506
+ "signature_prompt": last_user_text,
1507
+ "files": files,
1508
+ "params": {},
1509
+ "extra": {},
1510
+ "features": {
1511
+ "image_generation": False,
1512
+ "web_search": web_search,
1513
+ "auto_web_search": web_search,
1514
+ "preview_mode": preview_mode,
1515
+ "flags": [],
1516
+ "features": [
1517
+ dict(item)
1518
+ for item in (feature_entries or DEFAULT_COMPLETION_FEATURES)
1519
+ ],
1520
+ "enable_thinking": enable_thinking,
1521
+ },
1522
+ "background_tasks": {
1523
+ "title_generation": False,
1524
+ "tags_generation": False,
1525
+ },
1526
+ "mcp_servers": mcp_servers,
1527
+ "variables": self._build_request_variables(),
1528
+ "model_item": {
1529
+ "id": upstream_model_id,
1530
+ "name": requested_model,
1531
+ "owned_by": settings.SERVICE_NAME,
1532
+ },
1533
+ "chat_id": chat_id,
1534
+ "id": message_id,
1535
+ "session_id": session_id,
1536
+ "current_user_message_id": message_id,
1537
+ "current_user_message_parent_id": None,
1538
+ }
1539
+ if tools:
1540
+ body["tools"] = tools
1541
+ if tool_choice is not None:
1542
+ body["tool_choice"] = tool_choice
1543
+ self.logger.info(f"🔧 工具调用将直接透传到上游: {len(tools)} 个工具")
1544
+ else:
1545
+ body["tools"] = None
1546
+ if request.temperature is not None:
1547
+ body["params"]["temperature"] = request.temperature
1548
+ if request.max_tokens is not None:
1549
+ body["params"]["max_tokens"] = request.max_tokens
1550
+
1551
+ try:
1552
+ signed_url, signature, timestamp_ms = self._build_signed_completion_request(
1553
+ prompt=last_user_text,
1554
+ chat_id=chat_id,
1555
+ token=token,
1556
+ user_id=user_id,
1557
+ user_agent=headers["User-Agent"],
1558
+ use_browser_fingerprint=use_persisted_chat,
1559
+ )
1560
+ logger.debug(
1561
+ "[上游] 生成签名成功: %s... (user_id=%s, timestamp=%s)",
1562
+ signature[:16],
1563
+ user_id,
1564
+ timestamp_ms,
1565
+ )
1566
+ except Exception as e:
1567
+ logger.error(f"[上游] 签名生成失败: {e}")
1568
+ signature = ""
1569
+ timestamp_ms = "0"
1570
+ signed_url = self.api_endpoint
1571
+
1572
+ fe_version = headers.get("X-FE-Version") or get_latest_fe_version()
1573
+ headers.update(
1574
+ {
1575
+ "Authorization": f"Bearer {token}",
1576
+ "Content-Type": "application/json",
1577
+ "Accept": "*/*" if use_persisted_chat else "application/json",
1578
+ "X-FE-Version": fe_version,
1579
+ "X-Signature": signature,
1580
+ }
1581
+ )
1582
+
1583
+ logger.debug(
1584
+ "[上游] 请求头: Authorization=Bearer *****, X-Signature=%s...",
1585
+ signature[:16] if signature else "(空)",
1586
+ )
1587
+ logger.debug(
1588
+ "[上游] URL 参数: timestamp=%s, user_id=%s, persisted_chat=%s",
1589
+ timestamp_ms,
1590
+ user_id,
1591
+ use_persisted_chat,
1592
+ )
1593
+
1594
+ # 存储当前token用于错误处理
1595
+ self._current_token = token
1596
+
1597
+ return {
1598
+ "url": signed_url,
1599
+ "headers": headers,
1600
+ "body": body,
1601
+ "token": token,
1602
+ "chat_id": chat_id,
1603
+ "model": requested_model,
1604
+ "user_id": user_id,
1605
+ "auth_mode": auth_mode,
1606
+ "token_source": token_source,
1607
+ "guest_user_id": guest_user_id,
1608
+ }
1609
+
1610
+ async def chat_completion(
1611
+ self,
1612
+ request: OpenAIRequest,
1613
+ **kwargs
1614
+ ) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
1615
+ """聊天完成接口。"""
1616
+ self.logger.info(f"🔄 {self.name} 处理请求: {request.model}")
1617
+ self.logger.debug(f" 消息数量: {len(request.messages)}")
1618
+ self.logger.debug(f" 流式模式: {request.stream}")
1619
+
1620
+ try:
1621
+ transformed = await self.transform_request(request)
1622
+
1623
+ if request.stream:
1624
+ return self._create_stream_response(request, transformed)
1625
+
1626
+ proxies = self._get_proxy_config()
1627
+ max_attempts = self._get_total_retry_limit()
1628
+ excluded_tokens: Set[str] = set()
1629
+ excluded_guest_user_ids: Set[str] = set()
1630
+
1631
+ for attempt in range(max_attempts):
1632
+ async with httpx.AsyncClient(
1633
+ timeout=self._build_timeout(read_timeout=60.0),
1634
+ limits=self._build_limits(),
1635
+ proxy=proxies,
1636
+ ) as client:
1637
+ response = await client.post(
1638
+ transformed["url"],
1639
+ headers=transformed["headers"],
1640
+ json=transformed["body"],
1641
+ )
1642
+
1643
+ error_code, error_message = self._extract_upstream_error_details(
1644
+ response.status_code,
1645
+ response.text,
1646
+ )
1647
+ is_concurrency_limited = self._is_concurrency_limited(
1648
+ response.status_code,
1649
+ error_code,
1650
+ error_message,
1651
+ )
1652
+
1653
+ if self._should_retry_guest_session(
1654
+ response.status_code,
1655
+ is_concurrency_limited,
1656
+ attempt,
1657
+ max_attempts,
1658
+ transformed,
1659
+ ):
1660
+ guest_user_id = str(
1661
+ transformed.get("guest_user_id")
1662
+ or transformed.get("user_id")
1663
+ or ""
1664
+ )
1665
+ if guest_user_id:
1666
+ excluded_guest_user_ids.add(guest_user_id)
1667
+ transformed = await self._refresh_guest_request(
1668
+ request,
1669
+ attempt,
1670
+ excluded_tokens,
1671
+ excluded_guest_user_ids,
1672
+ transformed,
1673
+ is_concurrency_limited=is_concurrency_limited,
1674
+ )
1675
+ continue
1676
+
1677
+ if self._should_retry_authenticated_session(
1678
+ response.status_code,
1679
+ is_concurrency_limited,
1680
+ attempt,
1681
+ max_attempts,
1682
+ transformed,
1683
+ ):
1684
+ current_token = str(transformed.get("token") or "")
1685
+ if current_token:
1686
+ excluded_tokens.add(current_token)
1687
+ await self.mark_token_failure(
1688
+ current_token,
1689
+ Exception(error_message or "上游认证会话不可用"),
1690
+ )
1691
+ self.logger.warning(
1692
+ "⚠️ 认证会话不可用,准备切换认证 Token/回退匿名池: "
1693
+ f"{current_token[:20]}..."
1694
+ )
1695
+ transformed = await self._refresh_authenticated_request(
1696
+ request,
1697
+ attempt,
1698
+ excluded_tokens,
1699
+ excluded_guest_user_ids,
1700
+ )
1701
+ continue
1702
+
1703
+ if not response.is_success:
1704
+ error_msg = f"上游 API 错误: {response.status_code}"
1705
+ if not self._is_guest_auth(transformed):
1706
+ current_token = str(transformed.get("token") or "")
1707
+ if current_token:
1708
+ await self.mark_token_failure(
1709
+ current_token,
1710
+ Exception(error_message or error_msg),
1711
+ )
1712
+ await self._release_guest_session(transformed)
1713
+ self.logger.error(f"❌ {self.name} 响应失败: {error_msg}")
1714
+ return handle_error(Exception(error_message or error_msg))
1715
+
1716
+ try:
1717
+ result = await self.transform_response(response, request, transformed)
1718
+ finally:
1719
+ await self._release_guest_session(transformed)
1720
+
1721
+ if not self._is_guest_auth(transformed):
1722
+ current_token = str(transformed.get("token") or "")
1723
+ if current_token:
1724
+ token_pool = get_token_pool()
1725
+ if token_pool:
1726
+ await token_pool.record_token_success(current_token)
1727
+
1728
+ return result
1729
+
1730
+ except Exception as e:
1731
+ self.logger.error(f"❌ {self.name} 响应失败: {str(e)}")
1732
+ return handle_error(e, "请求处理")
1733
+
1734
+ async def _create_stream_response(
1735
+ self,
1736
+ request: OpenAIRequest,
1737
+ transformed: Dict[str, Any]
1738
+ ) -> AsyncGenerator[str, None]:
1739
+ """创建流式响应,并在首包前支持双池重试。"""
1740
+ max_attempts = self._get_total_retry_limit()
1741
+ excluded_tokens: Set[str] = set()
1742
+ excluded_guest_user_ids: Set[str] = set()
1743
+ current_token = str(transformed.get("token") or "")
1744
+
1745
+ try:
1746
+ proxies = self._get_proxy_config()
1747
+
1748
+ async with httpx.AsyncClient(
1749
+ timeout=self._build_timeout(read_timeout=180.0),
1750
+ http2=True,
1751
+ limits=self._build_limits(),
1752
+ proxy=proxies,
1753
+ ) as client:
1754
+ for attempt in range(max_attempts):
1755
+ self.logger.info(f"�� 发送请求到上游: {transformed['url']}")
1756
+ async with client.stream(
1757
+ "POST",
1758
+ transformed["url"],
1759
+ json=transformed["body"],
1760
+ headers=transformed["headers"],
1761
+ ) as response:
1762
+ error_text = await response.aread() if response.status_code != 200 else b""
1763
+ error_msg = error_text.decode("utf-8", errors="ignore")
1764
+ error_code, parsed_error_message = (
1765
+ self._extract_upstream_error_details(
1766
+ response.status_code,
1767
+ error_msg,
1768
+ )
1769
+ if response.status_code != 200
1770
+ else (None, "")
1771
+ )
1772
+ is_concurrency_limited = self._is_concurrency_limited(
1773
+ response.status_code,
1774
+ error_code,
1775
+ parsed_error_message,
1776
+ )
1777
+
1778
+ if self._should_retry_guest_session(
1779
+ response.status_code,
1780
+ is_concurrency_limited,
1781
+ attempt,
1782
+ max_attempts,
1783
+ transformed,
1784
+ ):
1785
+ guest_user_id = str(
1786
+ transformed.get("guest_user_id")
1787
+ or transformed.get("user_id")
1788
+ or ""
1789
+ )
1790
+ if guest_user_id:
1791
+ excluded_guest_user_ids.add(guest_user_id)
1792
+ transformed = await self._refresh_guest_request(
1793
+ request,
1794
+ attempt,
1795
+ excluded_tokens,
1796
+ excluded_guest_user_ids,
1797
+ transformed,
1798
+ is_concurrency_limited=is_concurrency_limited,
1799
+ )
1800
+ current_token = str(transformed.get("token") or "")
1801
+ continue
1802
+
1803
+ if self._should_retry_authenticated_session(
1804
+ response.status_code,
1805
+ is_concurrency_limited,
1806
+ attempt,
1807
+ max_attempts,
1808
+ transformed,
1809
+ ):
1810
+ if current_token:
1811
+ excluded_tokens.add(current_token)
1812
+ await self.mark_token_failure(
1813
+ current_token,
1814
+ Exception(
1815
+ parsed_error_message or "上游认证会话不可用"
1816
+ ),
1817
+ )
1818
+ self.logger.warning(
1819
+ "⚠️ 流式请求命中认证会话限制,准备切号/回退匿名池: "
1820
+ f"{current_token[:20]}..."
1821
+ )
1822
+ transformed = await self._refresh_authenticated_request(
1823
+ request,
1824
+ attempt,
1825
+ excluded_tokens,
1826
+ excluded_guest_user_ids,
1827
+ )
1828
+ current_token = str(transformed.get("token") or "")
1829
+ continue
1830
+
1831
+ if response.status_code != 200:
1832
+ self.logger.error(f"❌ 上游返回错误: {response.status_code}")
1833
+ if error_msg:
1834
+ self.logger.error(f"❌ 错误详情: {error_msg}")
1835
+
1836
+ if not self._is_guest_auth(transformed) and current_token:
1837
+ await self.mark_token_failure(
1838
+ current_token,
1839
+ Exception(
1840
+ parsed_error_message
1841
+ or f"Upstream error: {response.status_code}"
1842
+ ),
1843
+ )
1844
+ await self._release_guest_session(transformed)
1845
+
1846
+ if response.status_code == 405:
1847
+ self.logger.error(
1848
+ "🚫 请求被上游 WAF 拦截,可能是请求头或签名异常"
1849
+ )
1850
+ error_response = {
1851
+ "error": {
1852
+ "message": (
1853
+ "请求被上游WAF拦截(405 Method Not Allowed),"
1854
+ "可能是请求头或签名异常,请稍后重试..."
1855
+ ),
1856
+ "type": "waf_blocked",
1857
+ "code": 405,
1858
+ }
1859
+ }
1860
+ else:
1861
+ error_response = {
1862
+ "error": {
1863
+ "message": parsed_error_message
1864
+ or f"Upstream error: {response.status_code}",
1865
+ "type": "upstream_error",
1866
+ "code": error_code or response.status_code,
1867
+ }
1868
+ }
1869
+ yield f"data: {json.dumps(error_response)}\n\n"
1870
+ yield "data: [DONE]\n\n"
1871
+ return
1872
+
1873
+ chat_id = transformed["chat_id"]
1874
+ model = transformed["model"]
1875
+ try:
1876
+ async for chunk in self._handle_stream_response(
1877
+ response,
1878
+ chat_id,
1879
+ model,
1880
+ request,
1881
+ transformed,
1882
+ ):
1883
+ yield chunk
1884
+ finally:
1885
+ await self._release_guest_session(transformed)
1886
+
1887
+ if not self._is_guest_auth(transformed) and current_token:
1888
+ token_pool = get_token_pool()
1889
+ if token_pool:
1890
+ await token_pool.record_token_success(current_token)
1891
+ return
1892
+ except Exception as e:
1893
+ self.logger.error(f"❌ 流处理错误: {e}")
1894
+ import traceback
1895
+ self.logger.error(traceback.format_exc())
1896
+ if self._is_guest_auth(transformed):
1897
+ await self._release_guest_session(transformed)
1898
+ elif current_token:
1899
+ await self.mark_token_failure(current_token, e)
1900
+
1901
+ error_response = {
1902
+ "error": {
1903
+ "message": str(e),
1904
+ "type": "stream_error"
1905
+ }
1906
+ }
1907
+ yield f"data: {json.dumps(error_response)}\n\n"
1908
+ yield "data: [DONE]\n\n"
1909
+ return
1910
+
1911
+ async def transform_response(
1912
+ self,
1913
+ response: httpx.Response,
1914
+ request: OpenAIRequest,
1915
+ transformed: Dict[str, Any]
1916
+ ) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
1917
+ """转换上游响应为 OpenAI 格式。"""
1918
+ chat_id = transformed["chat_id"]
1919
+ model = transformed["model"]
1920
+
1921
+ if request.stream:
1922
+ return self._handle_stream_response(response, chat_id, model, request, transformed)
1923
+ else:
1924
+ return await self._handle_non_stream_response(response, chat_id, model)
1925
+
1926
+ async def _handle_stream_response(
1927
+ self,
1928
+ response: httpx.Response,
1929
+ chat_id: str,
1930
+ model: str,
1931
+ request: OpenAIRequest,
1932
+ transformed: Dict[str, Any]
1933
+ ) -> AsyncGenerator[str, None]:
1934
+ """处理上游流式响应"""
1935
+ self.logger.info("✅ 上游响应成功,开始处理 SSE 流")
1936
+
1937
+ has_tools = settings.TOOL_SUPPORT and bool(request.tools)
1938
+ buffered_content = ""
1939
+ usage_info: Dict[str, int] = {
1940
+ "prompt_tokens": 0,
1941
+ "completion_tokens": 0,
1942
+ "total_tokens": 0,
1943
+ }
1944
+ tool_calls_accum: List[Dict[str, Any]] = []
1945
+ has_sent_role = False
1946
+ finished = False
1947
+ line_count = 0
1948
+
1949
+ async def ensure_role_sent() -> Optional[str]:
1950
+ nonlocal has_sent_role
1951
+ if has_sent_role:
1952
+ return None
1953
+
1954
+ has_sent_role = True
1955
+ return await format_sse_chunk(
1956
+ create_openai_chunk(chat_id, model, {"role": "assistant"})
1957
+ )
1958
+
1959
+ async def finalize_stream() -> AsyncGenerator[str, None]:
1960
+ nonlocal finished, tool_calls_accum
1961
+ if finished:
1962
+ return
1963
+
1964
+ if has_tools and not tool_calls_accum:
1965
+ parsed_tool_calls, _ = parse_and_extract_tool_calls(buffered_content)
1966
+ normalized = self._normalize_tool_calls(parsed_tool_calls)
1967
+ if normalized:
1968
+ tool_calls_accum = normalized
1969
+ role_output = await ensure_role_sent()
1970
+ if role_output:
1971
+ yield role_output
1972
+ for tool_call in normalized:
1973
+ yield await format_sse_chunk(
1974
+ create_openai_chunk(
1975
+ chat_id,
1976
+ model,
1977
+ {"tool_calls": [tool_call]},
1978
+ )
1979
+ )
1980
+
1981
+ if not has_sent_role:
1982
+ role_output = await ensure_role_sent()
1983
+ if role_output:
1984
+ yield role_output
1985
+
1986
+ finish_reason = "tool_calls" if tool_calls_accum else "stop"
1987
+ finish_chunk = create_openai_chunk(
1988
+ chat_id,
1989
+ model,
1990
+ {},
1991
+ finish_reason,
1992
+ )
1993
+ finish_chunk["usage"] = usage_info
1994
+ yield await format_sse_chunk(finish_chunk)
1995
+ yield "data: [DONE]\n\n"
1996
+ finished = True
1997
+
1998
+ try:
1999
+ async for line in response.aiter_lines():
2000
+ line_count += 1
2001
+ if not line:
2002
+ continue
2003
+
2004
+ current_line = line.strip()
2005
+ if not current_line.startswith("data:"):
2006
+ continue
2007
+
2008
+ chunk_str = current_line[5:].strip()
2009
+ if not chunk_str:
2010
+ continue
2011
+
2012
+ if chunk_str == "[DONE]":
2013
+ async for final_chunk in finalize_stream():
2014
+ yield final_chunk
2015
+ continue
2016
+
2017
+ try:
2018
+ chunk = json.loads(chunk_str)
2019
+ except json.JSONDecodeError as error:
2020
+ self.logger.debug(f"❌ JSON解析错误: {error}, 内容: {chunk_str[:1000]}")
2021
+ continue
2022
+
2023
+ chunk_type = chunk.get("type")
2024
+ data = chunk.get("data", {}) if chunk_type == "chat:completion" else chunk
2025
+ if not isinstance(data, dict):
2026
+ continue
2027
+
2028
+ phase = data.get("phase")
2029
+ delta_content = data.get("delta_content", "")
2030
+ edit_content = data.get("edit_content", "")
2031
+
2032
+ if phase and phase != getattr(self, "_last_phase", None):
2033
+ self.logger.info(f"📈 SSE 阶段: {phase}")
2034
+ self._last_phase = phase
2035
+
2036
+ if data.get("usage"):
2037
+ usage_info = data["usage"]
2038
+
2039
+ if delta_content:
2040
+ buffered_content += delta_content
2041
+ elif edit_content:
2042
+ buffered_content += edit_content
2043
+
2044
+ direct_tool_calls = self._normalize_tool_calls(
2045
+ data.get("tool_calls"),
2046
+ len(tool_calls_accum),
2047
+ )
2048
+ if direct_tool_calls:
2049
+ role_output = await ensure_role_sent()
2050
+ if role_output:
2051
+ yield role_output
2052
+ tool_calls_accum.extend(direct_tool_calls)
2053
+ for tool_call in direct_tool_calls:
2054
+ yield await format_sse_chunk(
2055
+ create_openai_chunk(
2056
+ chat_id,
2057
+ model,
2058
+ {"tool_calls": [tool_call]},
2059
+ )
2060
+ )
2061
+
2062
+ if phase == "thinking" and delta_content:
2063
+ cleaned = self._clean_reasoning_delta(delta_content)
2064
+ if cleaned:
2065
+ role_output = await ensure_role_sent()
2066
+ if role_output:
2067
+ yield role_output
2068
+ yield await format_sse_chunk(
2069
+ create_openai_chunk(
2070
+ chat_id,
2071
+ model,
2072
+ {"reasoning_content": cleaned},
2073
+ )
2074
+ )
2075
+
2076
+ elif phase == "answer":
2077
+ text = delta_content or self._extract_answer_content(edit_content)
2078
+ if text:
2079
+ role_output = await ensure_role_sent()
2080
+ if role_output:
2081
+ yield role_output
2082
+ yield await format_sse_chunk(
2083
+ create_openai_chunk(
2084
+ chat_id,
2085
+ model,
2086
+ {"content": text},
2087
+ )
2088
+ )
2089
+
2090
+ elif phase == "other":
2091
+ other_text = self._extract_answer_content(edit_content)
2092
+ if other_text:
2093
+ role_output = await ensure_role_sent()
2094
+ if role_output:
2095
+ yield role_output
2096
+ yield await format_sse_chunk(
2097
+ create_openai_chunk(
2098
+ chat_id,
2099
+ model,
2100
+ {"content": other_text},
2101
+ )
2102
+ )
2103
+
2104
+ elif phase == "search" or chunk_type == "web_search":
2105
+ citation_text = self._format_search_results(data)
2106
+ if citation_text:
2107
+ role_output = await ensure_role_sent()
2108
+ if role_output:
2109
+ yield role_output
2110
+ yield await format_sse_chunk(
2111
+ create_openai_chunk(
2112
+ chat_id,
2113
+ model,
2114
+ {"content": citation_text},
2115
+ )
2116
+ )
2117
+
2118
+ if data.get("done"):
2119
+ async for final_chunk in finalize_stream():
2120
+ yield final_chunk
2121
+ return
2122
+
2123
+ self.logger.info(f"✅ SSE 流处理完成,共处理 {line_count} 行数据")
2124
+
2125
+ if not finished:
2126
+ async for final_chunk in finalize_stream():
2127
+ yield final_chunk
2128
+
2129
+ except Exception as e:
2130
+ self.logger.error(f"❌ 流式响应处理错误: {e}")
2131
+ import traceback
2132
+ self.logger.error(traceback.format_exc())
2133
+ yield await format_sse_chunk(
2134
+ create_openai_chunk(chat_id, model, {}, "stop")
2135
+ )
2136
+ yield "data: [DONE]\n\n"
2137
+
2138
+ async def _handle_non_stream_response(
2139
+ self,
2140
+ response: httpx.Response,
2141
+ chat_id: str,
2142
+ model: str
2143
+ ) -> Dict[str, Any]:
2144
+ """处理非流式响应,聚合上游 SSE 为一次性 OpenAI 响应。"""
2145
+ final_content = ""
2146
+ reasoning_content = ""
2147
+ tool_calls_accum: List[Dict[str, Any]] = []
2148
+ usage_info: Dict[str, int] = {
2149
+ "prompt_tokens": 0,
2150
+ "completion_tokens": 0,
2151
+ "total_tokens": 0,
2152
+ }
2153
+
2154
+ try:
2155
+ async for line in response.aiter_lines():
2156
+ if not line:
2157
+ continue
2158
+
2159
+ line = line.strip()
2160
+ if not line.startswith("data:"):
2161
+ try:
2162
+ maybe_err = json.loads(line)
2163
+ if isinstance(maybe_err, dict) and (
2164
+ "error" in maybe_err or "code" in maybe_err or "message" in maybe_err
2165
+ ):
2166
+ msg = (
2167
+ (maybe_err.get("error") or {}).get("message")
2168
+ if isinstance(maybe_err.get("error"), dict)
2169
+ else maybe_err.get("message")
2170
+ ) or "上游返回错误"
2171
+ return handle_error(Exception(msg), "API响应")
2172
+ except Exception:
2173
+ pass
2174
+ continue
2175
+
2176
+ data_str = line[5:].strip()
2177
+ if not data_str or data_str in ("[DONE]", "DONE", "done"):
2178
+ continue
2179
+
2180
+ try:
2181
+ chunk = json.loads(data_str)
2182
+ except json.JSONDecodeError:
2183
+ continue
2184
+
2185
+ chunk_type = chunk.get("type")
2186
+ data = chunk.get("data", {}) if chunk_type == "chat:completion" else chunk
2187
+ if not isinstance(data, dict):
2188
+ continue
2189
+
2190
+ phase = data.get("phase")
2191
+ delta_content = data.get("delta_content", "")
2192
+ edit_content = data.get("edit_content", "")
2193
+
2194
+ if data.get("usage"):
2195
+ usage_info = data["usage"]
2196
+
2197
+ if phase == "thinking" and delta_content:
2198
+ reasoning_content += self._clean_reasoning_delta(delta_content)
2199
+
2200
+ elif phase == "answer":
2201
+ if delta_content:
2202
+ final_content += delta_content
2203
+ elif edit_content:
2204
+ final_content += self._extract_answer_content(edit_content)
2205
+
2206
+ elif phase == "other" and edit_content:
2207
+ final_content += self._extract_answer_content(edit_content)
2208
+
2209
+ elif phase == "search" or chunk_type == "web_search":
2210
+ final_content += self._format_search_results(data)
2211
+
2212
+ tool_calls_accum.extend(
2213
+ self._normalize_tool_calls(
2214
+ data.get("tool_calls"),
2215
+ len(tool_calls_accum),
2216
+ )
2217
+ )
2218
+
2219
+ except Exception as e:
2220
+ self.logger.error(f"❌ 非流式响应处理错误: {e}")
2221
+ import traceback
2222
+ self.logger.error(traceback.format_exc())
2223
+ return handle_error(e, "非流式聚合")
2224
+
2225
+ if not tool_calls_accum:
2226
+ parsed_tool_calls, cleaned_content = parse_and_extract_tool_calls(final_content)
2227
+ normalized = self._normalize_tool_calls(parsed_tool_calls)
2228
+ if normalized:
2229
+ tool_calls_accum = normalized
2230
+ final_content = cleaned_content
2231
+
2232
+ final_content = (final_content or "").strip()
2233
+ reasoning_content = (reasoning_content or "").strip()
2234
+
2235
+ if not final_content and reasoning_content:
2236
+ final_content = reasoning_content
2237
+
2238
+ return create_openai_response_with_reasoning(
2239
+ chat_id,
2240
+ model,
2241
+ final_content,
2242
+ reasoning_content,
2243
+ usage_info,
2244
+ tool_calls_accum or None,
2245
+ )
app/models/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ from app.models import schemas
5
+
6
+ __all__ = ["schemas"]
app/models/request_log.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """请求日志数据库模型。"""
2
+
3
+ from app.core.config import settings
4
+
5
+ DB_PATH = settings.DB_PATH
6
+
7
+ # 创建请求日志表的SQL
8
+ SQL_CREATE_REQUEST_LOGS_TABLE = """
9
+ CREATE TABLE IF NOT EXISTS request_logs (
10
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
11
+ timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
12
+ provider TEXT NOT NULL,
13
+ endpoint TEXT DEFAULT '',
14
+ source TEXT DEFAULT 'unknown',
15
+ protocol TEXT DEFAULT 'unknown',
16
+ client_name TEXT DEFAULT 'Unknown',
17
+ model TEXT NOT NULL,
18
+ status_code INTEGER DEFAULT 200,
19
+ success BOOLEAN NOT NULL,
20
+ duration REAL,
21
+ first_token_time REAL,
22
+ input_tokens INTEGER DEFAULT 0,
23
+ output_tokens INTEGER DEFAULT 0,
24
+ cache_creation_tokens INTEGER DEFAULT 0,
25
+ cache_read_tokens INTEGER DEFAULT 0,
26
+ total_tokens INTEGER DEFAULT 0,
27
+ error_message TEXT,
28
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
29
+ );
30
+
31
+ CREATE INDEX IF NOT EXISTS idx_request_logs_timestamp ON request_logs(timestamp);
32
+ CREATE INDEX IF NOT EXISTS idx_request_logs_model ON request_logs(model);
33
+ CREATE INDEX IF NOT EXISTS idx_request_logs_provider ON request_logs(provider);
34
+ CREATE INDEX IF NOT EXISTS idx_request_logs_source ON request_logs(source);
35
+ """
app/models/schemas.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ from typing import Dict, List, Optional, Any, Union, Literal
5
+ from pydantic import BaseModel
6
+
7
+
8
+ class ImageUrl(BaseModel):
9
+ """Image URL model for vision content"""
10
+ url: str
11
+
12
+
13
+ class ContentPart(BaseModel):
14
+ """Content part model for OpenAI's new content format"""
15
+
16
+ type: str
17
+ text: Optional[str] = None
18
+ image_url: Optional[ImageUrl] = None # 添加 image_url 字段
19
+
20
+
21
+ class Message(BaseModel):
22
+ """Chat message model"""
23
+
24
+ role: str
25
+ content: Optional[Union[str, List[ContentPart]]] = None
26
+ reasoning_content: Optional[str] = None
27
+ tool_calls: Optional[List[Dict[str, Any]]] = None
28
+ tool_call_id: Optional[str] = None
29
+ name: Optional[str] = None
30
+
31
+
32
+ class OpenAIRequest(BaseModel):
33
+ """OpenAI-compatible request model"""
34
+
35
+ model: str
36
+ messages: List[Message]
37
+ stream: Optional[bool] = False
38
+ temperature: Optional[float] = None
39
+ max_tokens: Optional[int] = None
40
+ tools: Optional[List[Dict[str, Any]]] = None
41
+ tool_choice: Optional[Any] = None
42
+ enable_thinking: Optional[bool] = None
43
+ web_search: Optional[bool] = None
44
+
45
+
46
+ class ModelItem(BaseModel):
47
+ """Model information item"""
48
+
49
+ id: str
50
+ name: str
51
+ owned_by: str
52
+
53
+
54
+ class UpstreamRequest(BaseModel):
55
+ """Upstream service request model"""
56
+
57
+ stream: bool
58
+ model: str
59
+ messages: List[Message]
60
+ params: Dict[str, Any] = {}
61
+ features: Dict[str, Any] = {}
62
+ signature_prompt: Optional[str] = None
63
+ files: Optional[List[Dict[str, Any]]] = None
64
+ extra: Optional[Dict[str, Any]] = None
65
+ background_tasks: Optional[Dict[str, bool]] = None
66
+ chat_id: Optional[str] = None
67
+ id: Optional[str] = None
68
+ session_id: Optional[str] = None
69
+ current_user_message_id: Optional[str] = None
70
+ current_user_message_parent_id: Optional[str] = None
71
+ mcp_servers: Optional[List[str]] = None
72
+ model_item: Optional[Dict[str, Any]] = {} # Model item dictionary
73
+ tools: Optional[List[Dict[str, Any]]] = None # Add tools field for OpenAI compatibility
74
+ tool_choice: Optional[Any] = None
75
+ variables: Optional[Dict[str, str]] = None
76
+ model_config = {"protected_namespaces": ()}
77
+
78
+
79
+ class Delta(BaseModel):
80
+ """Stream delta model"""
81
+
82
+ role: Optional[str] = None
83
+ content: Optional[str] = "" or None
84
+ reasoning_content: Optional[str] = None
85
+ tool_calls: Optional[List[Dict[str, Any]]] = None
86
+
87
+
88
+ class Choice(BaseModel):
89
+ """Response choice model"""
90
+
91
+ index: int
92
+ message: Optional[Message] = None
93
+ delta: Optional[Delta] = None
94
+ finish_reason: Optional[str] = None
95
+
96
+
97
+ class Usage(BaseModel):
98
+ """Token usage statistics"""
99
+
100
+ prompt_tokens: int = 0
101
+ completion_tokens: int = 0
102
+ total_tokens: int = 0
103
+
104
+
105
+ class OpenAIResponse(BaseModel):
106
+ """OpenAI-compatible response model"""
107
+
108
+ id: str
109
+ object: str
110
+ created: int
111
+ model: str
112
+ choices: List[Choice]
113
+ usage: Optional[Usage] = None
114
+
115
+
116
+ class UpstreamError(BaseModel):
117
+ """Upstream error model"""
118
+
119
+ detail: str
120
+ code: int
121
+
122
+
123
+ class UpstreamDataInner(BaseModel):
124
+ """Inner upstream data model"""
125
+
126
+ error: Optional[UpstreamError] = None
127
+
128
+
129
+ class UpstreamDataData(BaseModel):
130
+ """Upstream data content model"""
131
+
132
+ delta_content: str = ""
133
+ edit_content: str = ""
134
+ phase: str = ""
135
+ done: bool = False
136
+ results: Optional[List[Dict[str, Any]]] = None
137
+ sources: Optional[List[Dict[str, Any]]] = None
138
+ citations: Optional[List[Dict[str, Any]]] = None
139
+ tool_calls: Optional[List[Dict[str, Any]]] = None
140
+ usage: Optional[Usage] = None
141
+ error: Optional[UpstreamError] = None
142
+ inner: Optional[UpstreamDataInner] = None
143
+
144
+
145
+ class UpstreamData(BaseModel):
146
+ """Upstream data model"""
147
+
148
+ type: str
149
+ data: UpstreamDataData
150
+ error: Optional[UpstreamError] = None
151
+
152
+
153
+ class Model(BaseModel):
154
+ """Model information for listing"""
155
+
156
+ id: str
157
+ object: str = "model"
158
+ created: int
159
+ owned_by: str
160
+
161
+
162
+ class ModelsResponse(BaseModel):
163
+ """Models list response model"""
164
+
165
+ object: str = "list"
166
+ data: List[Model]
app/models/token_db.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Token 数据库模型定义。"""
2
+
3
+ from app.core.config import settings
4
+
5
+ SQL_CREATE_TABLES = """
6
+ -- Token 配置表
7
+ CREATE TABLE IF NOT EXISTS tokens (
8
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
9
+ provider TEXT NOT NULL, -- 提供商: zai
10
+ token TEXT NOT NULL UNIQUE, -- Token 值(唯一)
11
+ token_type TEXT DEFAULT 'user', -- Token 类型: user, guest, unknown
12
+ is_enabled BOOLEAN DEFAULT 1, -- 是否启用
13
+ priority INTEGER DEFAULT 0, -- 优先级(用于排序)
14
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
15
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
16
+ UNIQUE(provider, token) -- 同一提供商内 Token 唯一
17
+ );
18
+
19
+ -- Token 使用统计表
20
+ CREATE TABLE IF NOT EXISTS token_stats (
21
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
22
+ token_id INTEGER NOT NULL,
23
+ total_requests INTEGER DEFAULT 0,
24
+ successful_requests INTEGER DEFAULT 0,
25
+ failed_requests INTEGER DEFAULT 0,
26
+ last_success_time DATETIME,
27
+ last_failure_time DATETIME,
28
+ FOREIGN KEY (token_id) REFERENCES tokens(id) ON DELETE CASCADE
29
+ );
30
+
31
+ -- 创建索引
32
+ CREATE INDEX IF NOT EXISTS idx_tokens_provider ON tokens(provider);
33
+ CREATE INDEX IF NOT EXISTS idx_tokens_enabled ON tokens(is_enabled);
34
+ CREATE INDEX IF NOT EXISTS idx_token_stats_token_id ON token_stats(token_id);
35
+
36
+ -- 触发器:自动更新 updated_at
37
+ CREATE TRIGGER IF NOT EXISTS update_tokens_timestamp
38
+ AFTER UPDATE ON tokens
39
+ BEGIN
40
+ UPDATE tokens SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
41
+ END;
42
+ """
43
+
44
+ DB_PATH = settings.DB_PATH
app/services/request_log_dao.py ADDED
@@ -0,0 +1,630 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 请求日志数据访问层 (DAO)
3
+ 提供请求日志的 CRUD 操作和查询功能
4
+ """
5
+ import os
6
+ import sqlite3
7
+ from contextlib import asynccontextmanager
8
+ from datetime import datetime, timedelta
9
+ from typing import Dict, List, Optional
10
+
11
+ import aiosqlite
12
+
13
+ from app.models.request_log import DB_PATH, SQL_CREATE_REQUEST_LOGS_TABLE
14
+ from app.utils.logger import logger
15
+
16
+
17
+ def _format_sqlite_datetime(value: datetime) -> str:
18
+ """格式化为 SQLite `CURRENT_TIMESTAMP` 兼容的时间字符串。"""
19
+ return value.strftime("%Y-%m-%d %H:%M:%S")
20
+
21
+
22
+ def _normalize_trend_window(window: Optional[str], days: Optional[int]) -> str:
23
+ """统一趋势窗口参数,兼容旧版 `days` 调用。"""
24
+ if window:
25
+ normalized = str(window).strip().lower()
26
+ elif days == 30:
27
+ normalized = "30d"
28
+ elif days == 1:
29
+ normalized = "24h"
30
+ else:
31
+ normalized = "7d"
32
+
33
+ if normalized in {"24h", "7d", "30d"}:
34
+ return normalized
35
+ if normalized == "1d":
36
+ return "24h"
37
+ return "7d"
38
+
39
+
40
+ class RequestLogDAO:
41
+ """请求日志数据访问对象"""
42
+
43
+ def __init__(self, db_path: str = DB_PATH):
44
+ """初始化 DAO"""
45
+ self.db_path = db_path
46
+ self._ensure_db_directory()
47
+ self._init_db()
48
+
49
+ def _ensure_db_directory(self):
50
+ """确保数据库目录存在"""
51
+ db_dir = os.path.dirname(self.db_path)
52
+ if db_dir and not os.path.exists(db_dir):
53
+ os.makedirs(db_dir, exist_ok=True)
54
+
55
+ def _init_db(self):
56
+ """初始化数据库表"""
57
+ try:
58
+ conn = sqlite3.connect(self.db_path)
59
+ conn.executescript(SQL_CREATE_REQUEST_LOGS_TABLE)
60
+ self._ensure_columns(conn)
61
+ conn.commit()
62
+ conn.close()
63
+ logger.debug("请求日志表初始化成功")
64
+ except Exception as e:
65
+ logger.error(f"初始化请求日志表失败: {e}")
66
+
67
+ def _ensure_columns(self, conn: sqlite3.Connection):
68
+ """为旧数据库补齐新增列。"""
69
+ cursor = conn.execute("PRAGMA table_info(request_logs)")
70
+ existing_columns = {row[1] for row in cursor.fetchall()}
71
+ required_columns = {
72
+ "endpoint": "TEXT DEFAULT ''",
73
+ "source": "TEXT DEFAULT 'unknown'",
74
+ "protocol": "TEXT DEFAULT 'unknown'",
75
+ "client_name": "TEXT DEFAULT 'Unknown'",
76
+ "status_code": "INTEGER DEFAULT 200",
77
+ "cache_creation_tokens": "INTEGER DEFAULT 0",
78
+ "cache_read_tokens": "INTEGER DEFAULT 0",
79
+ }
80
+
81
+ for column, definition in required_columns.items():
82
+ if column in existing_columns:
83
+ continue
84
+ conn.execute(
85
+ f"ALTER TABLE request_logs ADD COLUMN {column} {definition}"
86
+ )
87
+
88
+ @asynccontextmanager
89
+ async def get_connection(self):
90
+ """获取异步数据库连接"""
91
+ conn = await aiosqlite.connect(self.db_path)
92
+ conn.row_factory = aiosqlite.Row
93
+ try:
94
+ yield conn
95
+ finally:
96
+ await conn.close()
97
+
98
+ async def add_log(
99
+ self,
100
+ provider: str,
101
+ endpoint: str,
102
+ source: str,
103
+ protocol: str,
104
+ client_name: str,
105
+ model: str,
106
+ status_code: int,
107
+ success: bool,
108
+ duration: float = 0.0,
109
+ first_token_time: float = 0.0,
110
+ input_tokens: int = 0,
111
+ output_tokens: int = 0,
112
+ cache_creation_tokens: int = 0,
113
+ cache_read_tokens: int = 0,
114
+ total_tokens: Optional[int] = None,
115
+ error_message: str = None
116
+ ) -> int:
117
+ """
118
+ 添加请求日志
119
+
120
+ Args:
121
+ provider: 提供商名称
122
+ endpoint: 请求端点
123
+ source: 请求来源标识
124
+ protocol: 协议类型
125
+ client_name: 客户端名称
126
+ model: 模型名称
127
+ status_code: 请求状态码
128
+ success: 是否成功
129
+ duration: 总耗时(秒)
130
+ first_token_time: 首字延迟(秒)
131
+ input_tokens: 输入 token 数
132
+ output_tokens: 输出 token 数
133
+ cache_creation_tokens: 缓存创建 token 数
134
+ cache_read_tokens: 缓存命中 token 数
135
+ total_tokens: 总 token 数
136
+ error_message: 错误信息
137
+
138
+ Returns:
139
+ 日志 ID
140
+ """
141
+ if total_tokens is None:
142
+ total_tokens = input_tokens + output_tokens
143
+
144
+ async with self.get_connection() as conn:
145
+ cursor = await conn.execute(
146
+ """
147
+ INSERT INTO request_logs
148
+ (provider, endpoint, source, protocol, client_name, model,
149
+ status_code, success, duration, first_token_time,
150
+ input_tokens, output_tokens, cache_creation_tokens,
151
+ cache_read_tokens, total_tokens, error_message)
152
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
153
+ """,
154
+ (
155
+ provider,
156
+ endpoint,
157
+ source,
158
+ protocol,
159
+ client_name,
160
+ model,
161
+ status_code,
162
+ success,
163
+ duration,
164
+ first_token_time,
165
+ input_tokens,
166
+ output_tokens,
167
+ cache_creation_tokens,
168
+ cache_read_tokens,
169
+ total_tokens,
170
+ error_message,
171
+ )
172
+ )
173
+ await conn.commit()
174
+ return cursor.lastrowid
175
+
176
+ async def get_recent_logs(
177
+ self,
178
+ limit: int = 100,
179
+ offset: int = 0,
180
+ provider: str = None,
181
+ model: str = None,
182
+ success: bool = None,
183
+ source: str = None,
184
+ ) -> List[Dict]:
185
+ """
186
+ 获取最近的请求日志
187
+
188
+ Args:
189
+ limit: 返回数量限制
190
+ provider: 过滤提供商
191
+ model: 过滤模型
192
+ success: 过滤成功/失败状态
193
+
194
+ Returns:
195
+ 日志列表
196
+ """
197
+ query = "SELECT * FROM request_logs WHERE 1=1"
198
+ params = []
199
+
200
+ if provider:
201
+ query += " AND provider = ?"
202
+ params.append(provider)
203
+
204
+ if model:
205
+ query += " AND model = ?"
206
+ params.append(model)
207
+
208
+ if success is not None:
209
+ query += " AND success = ?"
210
+ params.append(success)
211
+
212
+ if source:
213
+ query += " AND source = ?"
214
+ params.append(source)
215
+
216
+ query += " ORDER BY timestamp DESC, id DESC LIMIT ? OFFSET ?"
217
+ params.extend([limit, max(0, offset)])
218
+
219
+ async with self.get_connection() as conn:
220
+ cursor = await conn.execute(query, params)
221
+ rows = await cursor.fetchall()
222
+ return [dict(row) for row in rows]
223
+
224
+ async def count_logs(
225
+ self,
226
+ provider: str = None,
227
+ model: str = None,
228
+ success: bool = None,
229
+ source: str = None,
230
+ ) -> int:
231
+ """统计日志总数。"""
232
+ query = "SELECT COUNT(*) AS total_count FROM request_logs WHERE 1=1"
233
+ params = []
234
+
235
+ if provider:
236
+ query += " AND provider = ?"
237
+ params.append(provider)
238
+
239
+ if model:
240
+ query += " AND model = ?"
241
+ params.append(model)
242
+
243
+ if success is not None:
244
+ query += " AND success = ?"
245
+ params.append(success)
246
+
247
+ if source:
248
+ query += " AND source = ?"
249
+ params.append(source)
250
+
251
+ async with self.get_connection() as conn:
252
+ cursor = await conn.execute(query, params)
253
+ row = await cursor.fetchone()
254
+ return int(row["total_count"] or 0) if row else 0
255
+
256
+ async def get_logs_by_time_range(
257
+ self,
258
+ start_time: datetime,
259
+ end_time: datetime,
260
+ provider: str = None,
261
+ model: str = None
262
+ ) -> List[Dict]:
263
+ """
264
+ 按时间范围获取日志
265
+
266
+ Args:
267
+ start_time: 开始时间
268
+ end_time: 结束时间
269
+ provider: 过滤提供商
270
+ model: 过滤模型
271
+
272
+ Returns:
273
+ 日志列表
274
+ """
275
+ query = "SELECT * FROM request_logs WHERE timestamp BETWEEN ? AND ?"
276
+ params = [
277
+ _format_sqlite_datetime(start_time),
278
+ _format_sqlite_datetime(end_time),
279
+ ]
280
+
281
+ if provider:
282
+ query += " AND provider = ?"
283
+ params.append(provider)
284
+
285
+ if model:
286
+ query += " AND model = ?"
287
+ params.append(model)
288
+
289
+ query += " ORDER BY timestamp DESC, id DESC"
290
+
291
+ async with self.get_connection() as conn:
292
+ cursor = await conn.execute(query, params)
293
+ rows = await cursor.fetchall()
294
+ return [dict(row) for row in rows]
295
+
296
+ async def get_provider_request_stats(self, provider: Optional[str] = None) -> Dict:
297
+ """聚合请求日志统计,可按提供商过滤。"""
298
+ query = """
299
+ SELECT
300
+ COUNT(*) as total_requests,
301
+ SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as successful_requests,
302
+ SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) as failed_requests,
303
+ SUM(input_tokens) as input_tokens,
304
+ SUM(output_tokens) as output_tokens,
305
+ SUM(total_tokens) as total_tokens,
306
+ SUM(cache_creation_tokens) as cache_creation_tokens,
307
+ SUM(cache_read_tokens) as cache_read_tokens,
308
+ SUM(
309
+ CASE WHEN cache_creation_tokens > 0 THEN 1 ELSE 0 END
310
+ ) as cache_creation_requests,
311
+ SUM(
312
+ CASE WHEN cache_read_tokens > 0 THEN 1 ELSE 0 END
313
+ ) as cache_hit_requests,
314
+ AVG(duration) as avg_duration,
315
+ AVG(
316
+ CASE
317
+ WHEN first_token_time > 0 THEN first_token_time
318
+ ELSE NULL
319
+ END
320
+ ) as avg_first_token_time
321
+ FROM request_logs
322
+ """
323
+ params: List[object] = []
324
+
325
+ if provider:
326
+ query += " WHERE provider = ?"
327
+ params.append(provider)
328
+
329
+ try:
330
+ async with self.get_connection() as conn:
331
+ cursor = await conn.execute(query, params)
332
+ row = await cursor.fetchone()
333
+
334
+ if not row:
335
+ return {
336
+ "total_requests": 0,
337
+ "successful_requests": 0,
338
+ "failed_requests": 0,
339
+ "input_tokens": 0,
340
+ "output_tokens": 0,
341
+ "total_tokens": 0,
342
+ "cache_creation_tokens": 0,
343
+ "cache_read_tokens": 0,
344
+ "cache_creation_requests": 0,
345
+ "cache_hit_requests": 0,
346
+ "avg_duration": 0.0,
347
+ "avg_first_token_time": 0.0,
348
+ }
349
+
350
+ return {
351
+ "total_requests": int(row["total_requests"] or 0),
352
+ "successful_requests": int(row["successful_requests"] or 0),
353
+ "failed_requests": int(row["failed_requests"] or 0),
354
+ "input_tokens": int(row["input_tokens"] or 0),
355
+ "output_tokens": int(row["output_tokens"] or 0),
356
+ "total_tokens": int(row["total_tokens"] or 0),
357
+ "cache_creation_tokens": int(
358
+ row["cache_creation_tokens"] or 0
359
+ ),
360
+ "cache_read_tokens": int(row["cache_read_tokens"] or 0),
361
+ "cache_creation_requests": int(
362
+ row["cache_creation_requests"] or 0
363
+ ),
364
+ "cache_hit_requests": int(row["cache_hit_requests"] or 0),
365
+ "avg_duration": float(row["avg_duration"] or 0.0),
366
+ "avg_first_token_time": float(
367
+ row["avg_first_token_time"] or 0.0
368
+ ),
369
+ }
370
+ except Exception as e:
371
+ logger.error(f"❌ 获取请求统计失败: {e}")
372
+ return {
373
+ "total_requests": 0,
374
+ "successful_requests": 0,
375
+ "failed_requests": 0,
376
+ "input_tokens": 0,
377
+ "output_tokens": 0,
378
+ "total_tokens": 0,
379
+ "cache_creation_tokens": 0,
380
+ "cache_read_tokens": 0,
381
+ "cache_creation_requests": 0,
382
+ "cache_hit_requests": 0,
383
+ "avg_duration": 0.0,
384
+ "avg_first_token_time": 0.0,
385
+ }
386
+
387
+ async def get_provider_usage_trend(
388
+ self,
389
+ provider: Optional[str] = None,
390
+ days: Optional[int] = None,
391
+ *,
392
+ window: Optional[str] = None,
393
+ now: Optional[datetime] = None,
394
+ ) -> List[Dict]:
395
+ """按窗口聚合最近一段时间的请求与 token 趋势。"""
396
+ trend_window = _normalize_trend_window(window, days)
397
+ current_time = now or datetime.utcnow()
398
+
399
+ if trend_window == "24h":
400
+ bucket_count = 24
401
+ current_hour = current_time.replace(
402
+ minute=0,
403
+ second=0,
404
+ microsecond=0,
405
+ )
406
+ start_time = current_hour - timedelta(hours=bucket_count - 1)
407
+ bucket_expression = "strftime('%Y-%m-%d %H:00:00', timestamp)"
408
+ row_key = "trend_bucket"
409
+ label_format = "%H:%M"
410
+ tooltip_format = "%Y-%m-%d %H:00"
411
+ rows = await self._query_usage_trend_rows(
412
+ provider,
413
+ start_time,
414
+ bucket_expression,
415
+ row_key,
416
+ )
417
+ rows_by_bucket = {str(row[row_key]): dict(row) for row in rows}
418
+ trend: List[Dict] = []
419
+
420
+ for offset in range(bucket_count):
421
+ bucket_time = start_time + timedelta(hours=offset)
422
+ bucket_key = bucket_time.strftime("%Y-%m-%d %H:00:00")
423
+ trend.append(
424
+ self._build_usage_trend_point(
425
+ row=rows_by_bucket.get(bucket_key, {}),
426
+ bucket=bucket_key,
427
+ label=bucket_time.strftime(label_format),
428
+ tooltip_label=bucket_time.strftime(tooltip_format),
429
+ )
430
+ )
431
+
432
+ return trend
433
+
434
+ bucket_count = 30 if trend_window == "30d" else 7
435
+ current_date = current_time.date()
436
+ start_date = current_date - timedelta(days=bucket_count - 1)
437
+ start_time = datetime.combine(start_date, datetime.min.time())
438
+ rows = await self._query_usage_trend_rows(
439
+ provider,
440
+ start_time,
441
+ "DATE(timestamp)",
442
+ "trend_bucket",
443
+ )
444
+ rows_by_bucket = {
445
+ str(row["trend_bucket"]): dict(row)
446
+ for row in rows
447
+ }
448
+ trend = []
449
+
450
+ for offset in range(bucket_count):
451
+ bucket_date = start_date + timedelta(days=offset)
452
+ bucket_key = bucket_date.isoformat()
453
+ trend.append(
454
+ self._build_usage_trend_point(
455
+ row=rows_by_bucket.get(bucket_key, {}),
456
+ bucket=bucket_key,
457
+ label=bucket_date.strftime("%m-%d"),
458
+ tooltip_label=bucket_date.strftime("%Y-%m-%d"),
459
+ )
460
+ )
461
+
462
+ return trend
463
+
464
+ async def _query_usage_trend_rows(
465
+ self,
466
+ provider: Optional[str],
467
+ start_time: datetime,
468
+ bucket_expression: str,
469
+ bucket_alias: str,
470
+ ) -> list[aiosqlite.Row]:
471
+ query = f"""
472
+ SELECT
473
+ {bucket_expression} as {bucket_alias},
474
+ COUNT(*) as total_requests,
475
+ SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as successful_requests,
476
+ SUM(input_tokens) as input_tokens,
477
+ SUM(output_tokens) as output_tokens,
478
+ SUM(total_tokens) as total_tokens,
479
+ SUM(cache_creation_tokens) as cache_creation_tokens,
480
+ SUM(cache_read_tokens) as cache_read_tokens
481
+ FROM request_logs
482
+ WHERE timestamp >= ?
483
+ """
484
+ params: List[object] = [_format_sqlite_datetime(start_time)]
485
+
486
+ if provider:
487
+ query += " AND provider = ?"
488
+ params.append(provider)
489
+
490
+ query += f" GROUP BY {bucket_expression} ORDER BY {bucket_alias} ASC"
491
+
492
+ async with self.get_connection() as conn:
493
+ cursor = await conn.execute(query, params)
494
+ return await cursor.fetchall()
495
+
496
+ def _build_usage_trend_point(
497
+ self,
498
+ *,
499
+ row: Dict,
500
+ bucket: str,
501
+ label: str,
502
+ tooltip_label: str,
503
+ ) -> Dict:
504
+ total_requests = int(row.get("total_requests") or 0)
505
+ successful_requests = int(row.get("successful_requests") or 0)
506
+ cache_creation_tokens = int(row.get("cache_creation_tokens") or 0)
507
+ cache_read_tokens = int(row.get("cache_read_tokens") or 0)
508
+
509
+ return {
510
+ "bucket": bucket,
511
+ "label": label,
512
+ "tooltip_label": tooltip_label,
513
+ "total_requests": total_requests,
514
+ "successful_requests": successful_requests,
515
+ "failed_requests": max(0, total_requests - successful_requests),
516
+ "input_tokens": int(row.get("input_tokens") or 0),
517
+ "output_tokens": int(row.get("output_tokens") or 0),
518
+ "total_tokens": int(row.get("total_tokens") or 0),
519
+ "cache_creation_tokens": cache_creation_tokens,
520
+ "cache_read_tokens": cache_read_tokens,
521
+ "cache_total_tokens": (
522
+ cache_creation_tokens + cache_read_tokens
523
+ ),
524
+ "success_rate": round(
525
+ (
526
+ successful_requests / total_requests * 100
527
+ ) if total_requests > 0 else 0,
528
+ 1,
529
+ ),
530
+ }
531
+
532
+ async def get_model_stats_from_db(self, hours: int = 24) -> Dict:
533
+ """
534
+ 从数据库获取模型统计(最近N小时)
535
+
536
+ Args:
537
+ hours: 小时数
538
+
539
+ Returns:
540
+ 模型统计数据
541
+ """
542
+ start_time = datetime.utcnow() - timedelta(hours=hours)
543
+
544
+ async with self.get_connection() as conn:
545
+ cursor = await conn.execute(
546
+ """
547
+ SELECT
548
+ model,
549
+ COUNT(*) as total,
550
+ SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as success,
551
+ SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) as failed,
552
+ SUM(input_tokens) as input_tokens,
553
+ SUM(output_tokens) as output_tokens,
554
+ SUM(total_tokens) as total_tokens,
555
+ AVG(duration) as avg_duration,
556
+ AVG(first_token_time) as avg_first_token_time
557
+ FROM request_logs
558
+ WHERE timestamp >= ?
559
+ GROUP BY model
560
+ ORDER BY total DESC
561
+ """,
562
+ (_format_sqlite_datetime(start_time),)
563
+ )
564
+ rows = await cursor.fetchall()
565
+
566
+ result = {}
567
+ for row in rows:
568
+ model = row['model']
569
+ result[model] = {
570
+ 'total': row['total'],
571
+ 'success': row['success'],
572
+ 'failed': row['failed'],
573
+ 'input_tokens': row['input_tokens'] or 0,
574
+ 'output_tokens': row['output_tokens'] or 0,
575
+ 'total_tokens': row['total_tokens'] or 0,
576
+ 'avg_duration': round(row['avg_duration'] or 0, 2),
577
+ 'avg_first_token_time': round(row['avg_first_token_time'] or 0, 2),
578
+ 'success_rate': round(
579
+ (row['success'] / row['total'] * 100)
580
+ if row['total'] > 0
581
+ else 0,
582
+ 1,
583
+ ),
584
+ }
585
+
586
+ return result
587
+
588
+ async def delete_old_logs(self, days: int = 30) -> int:
589
+ """
590
+ 删除旧日志
591
+
592
+ Args:
593
+ days: 保留天数
594
+
595
+ Returns:
596
+ 删除的记录数
597
+ """
598
+ cutoff_time = datetime.utcnow() - timedelta(days=days)
599
+
600
+ async with self.get_connection() as conn:
601
+ cursor = await conn.execute(
602
+ "DELETE FROM request_logs WHERE timestamp < ?",
603
+ (_format_sqlite_datetime(cutoff_time),)
604
+ )
605
+ await conn.commit()
606
+ return cursor.rowcount
607
+
608
+
609
+ # 全局单例实例
610
+ _request_log_dao: Optional[RequestLogDAO] = None
611
+
612
+
613
+ def get_request_log_dao() -> RequestLogDAO:
614
+ """
615
+ 获取请求日志 DAO 单例
616
+
617
+ Returns:
618
+ RequestLogDAO 实例
619
+ """
620
+ global _request_log_dao
621
+ if _request_log_dao is None:
622
+ _request_log_dao = RequestLogDAO()
623
+ return _request_log_dao
624
+
625
+
626
+ def init_request_log_dao():
627
+ """初始化请求日志 DAO"""
628
+ global _request_log_dao
629
+ _request_log_dao = RequestLogDAO()
630
+ return _request_log_dao
app/services/token_automation.py ADDED
@@ -0,0 +1,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Background automation for token import and maintenance."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from dataclasses import dataclass
7
+ from typing import Optional
8
+
9
+ from app.core.config import settings
10
+ from app.services.token_dao import TokenDAO, get_token_dao
11
+ from app.services.token_importer import TokenImportSummary, import_tokens_from_directory
12
+ from app.utils.logger import logger
13
+ from app.utils.token_pool import TokenPool, get_token_pool
14
+
15
+ DEFAULT_TOKEN_PROVIDER = "zai"
16
+ _AUTO_IMPORT_LOCK = asyncio.Lock()
17
+ _AUTO_MAINTENANCE_LOCK = asyncio.Lock()
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class TokenMaintenanceSummary:
22
+ provider: str
23
+ checked_count: int = 0
24
+ duplicate_removed_count: int = 0
25
+ valid_count: int = 0
26
+ guest_count: int = 0
27
+ invalid_count: int = 0
28
+ deleted_invalid_count: int = 0
29
+
30
+
31
+ async def run_directory_import(
32
+ source_dir: str,
33
+ *,
34
+ provider: str = DEFAULT_TOKEN_PROVIDER,
35
+ validate: bool = True,
36
+ dao: Optional[TokenDAO] = None,
37
+ pool: Optional[TokenPool] = None,
38
+ ) -> TokenImportSummary:
39
+ """Import tokens from a configured directory and refresh the pool if needed."""
40
+ if _AUTO_IMPORT_LOCK.locked():
41
+ raise RuntimeError("目录导入任务正在执行,请稍后再试")
42
+
43
+ async with _AUTO_IMPORT_LOCK:
44
+ summary = await import_tokens_from_directory(
45
+ source_dir,
46
+ provider=provider,
47
+ validate=validate,
48
+ dao=dao,
49
+ )
50
+
51
+ active_pool = pool if pool is not None else get_token_pool()
52
+ if active_pool and summary.imported_count > 0:
53
+ await active_pool.sync_from_database(provider)
54
+ logger.info("✅ 目录导入后已同步 Token 池")
55
+
56
+ return summary
57
+
58
+
59
+ async def run_token_maintenance(
60
+ *,
61
+ provider: str = DEFAULT_TOKEN_PROVIDER,
62
+ remove_duplicates: bool = True,
63
+ run_health_check: bool = True,
64
+ delete_invalid_tokens: bool = False,
65
+ dao: Optional[TokenDAO] = None,
66
+ pool: Optional[TokenPool] = None,
67
+ ) -> TokenMaintenanceSummary:
68
+ """Run dedupe, validation, and invalid-token cleanup as one maintenance cycle."""
69
+ if _AUTO_MAINTENANCE_LOCK.locked():
70
+ raise RuntimeError("Token 自动维护任务正在执行,请稍后再试")
71
+
72
+ token_dao = dao or get_token_dao()
73
+ duplicate_removed_count = 0
74
+ checked_count = 0
75
+ valid_count = 0
76
+ guest_count = 0
77
+ invalid_count = 0
78
+ deleted_invalid_count = 0
79
+
80
+ async with _AUTO_MAINTENANCE_LOCK:
81
+ if remove_duplicates:
82
+ duplicate_removed_count = await token_dao.remove_duplicate_tokens(provider)
83
+
84
+ should_validate = run_health_check or delete_invalid_tokens
85
+ invalid_token_ids: list[int] = []
86
+
87
+ if should_validate:
88
+ validation_result = await token_dao.validate_tokens_detailed(provider)
89
+ checked_count = int(validation_result.get("checked", 0) or 0)
90
+ valid_count = int(validation_result.get("valid", 0) or 0)
91
+ guest_count = int(validation_result.get("guest", 0) or 0)
92
+ invalid_count = int(validation_result.get("invalid", 0) or 0)
93
+ invalid_token_ids = list(
94
+ validation_result.get("invalid_token_ids", []) or []
95
+ )
96
+
97
+ if delete_invalid_tokens and invalid_token_ids:
98
+ deleted_invalid_count = await token_dao.delete_tokens_by_ids(
99
+ invalid_token_ids
100
+ )
101
+
102
+ active_pool = pool if pool is not None else get_token_pool()
103
+ if active_pool:
104
+ await active_pool.sync_from_database(provider)
105
+ logger.info("✅ Token 维护后已同步 Token 池")
106
+
107
+ return TokenMaintenanceSummary(
108
+ provider=provider,
109
+ checked_count=checked_count,
110
+ duplicate_removed_count=duplicate_removed_count,
111
+ valid_count=valid_count,
112
+ guest_count=guest_count,
113
+ invalid_count=invalid_count,
114
+ deleted_invalid_count=deleted_invalid_count,
115
+ )
116
+
117
+
118
+ class TokenAutomationScheduler:
119
+ """Run token import and maintenance loops in the application background."""
120
+
121
+ def __init__(self) -> None:
122
+ self._stop_event = asyncio.Event()
123
+ self._tasks: list[asyncio.Task] = []
124
+ self._import_warning: Optional[str] = None
125
+ self._maintenance_warning: Optional[str] = None
126
+
127
+ async def start(self) -> None:
128
+ if self._tasks:
129
+ return
130
+
131
+ self._stop_event.clear()
132
+ self._tasks = [
133
+ asyncio.create_task(
134
+ self._auto_import_loop(),
135
+ name="token-auto-import",
136
+ ),
137
+ asyncio.create_task(
138
+ self._auto_maintenance_loop(),
139
+ name="token-auto-maintenance",
140
+ ),
141
+ ]
142
+ logger.info("✅ Token 自动任务调度器已启动")
143
+
144
+ async def stop(self) -> None:
145
+ if not self._tasks:
146
+ return
147
+
148
+ self._stop_event.set()
149
+ for task in self._tasks:
150
+ task.cancel()
151
+
152
+ await asyncio.gather(*self._tasks, return_exceptions=True)
153
+ self._tasks.clear()
154
+ self._import_warning = None
155
+ self._maintenance_warning = None
156
+ logger.info("🛑 Token 自动任务调度器已停止")
157
+
158
+ async def _auto_import_loop(self) -> None:
159
+ while not self._stop_event.is_set():
160
+ wait_seconds = 15
161
+ try:
162
+ if settings.TOKEN_AUTO_IMPORT_ENABLED:
163
+ wait_seconds = max(int(settings.TOKEN_AUTO_IMPORT_INTERVAL), 30)
164
+ source_dir = settings.TOKEN_AUTO_IMPORT_SOURCE_DIR.strip()
165
+ if not source_dir:
166
+ self._log_import_warning_once(
167
+ "已启用自动导入,但未配置导入目录"
168
+ )
169
+ else:
170
+ self._import_warning = None
171
+ summary = await run_directory_import(
172
+ source_dir,
173
+ provider=DEFAULT_TOKEN_PROVIDER,
174
+ )
175
+ logger.info(
176
+ "🔄 自动导入完成: scanned={} imported={} duplicate={} invalid={}",
177
+ summary.scanned_files,
178
+ summary.imported_count,
179
+ summary.duplicate_count,
180
+ summary.invalid_json_count + summary.invalid_token_count,
181
+ )
182
+ except asyncio.CancelledError:
183
+ raise
184
+ except RuntimeError as exc:
185
+ logger.info(f"⏭️ 跳过本轮自动导入: {exc}")
186
+ except (FileNotFoundError, NotADirectoryError) as exc:
187
+ self._log_import_warning_once(str(exc))
188
+ except Exception as exc:
189
+ logger.exception(f"❌ 自动导入 Token 失败: {exc}")
190
+
191
+ await self._wait_or_stop(wait_seconds)
192
+
193
+ async def _auto_maintenance_loop(self) -> None:
194
+ while not self._stop_event.is_set():
195
+ wait_seconds = 15
196
+ try:
197
+ if settings.TOKEN_AUTO_MAINTENANCE_ENABLED:
198
+ wait_seconds = max(
199
+ int(settings.TOKEN_AUTO_MAINTENANCE_INTERVAL),
200
+ 30,
201
+ )
202
+ if not self._has_enabled_maintenance_action():
203
+ self._log_maintenance_warning_once(
204
+ "已启用自动维护,但未选择任何维护动作"
205
+ )
206
+ else:
207
+ self._maintenance_warning = None
208
+ summary = await run_token_maintenance(
209
+ provider=DEFAULT_TOKEN_PROVIDER,
210
+ remove_duplicates=settings.TOKEN_AUTO_REMOVE_DUPLICATES,
211
+ run_health_check=settings.TOKEN_AUTO_HEALTH_CHECK,
212
+ delete_invalid_tokens=settings.TOKEN_AUTO_DELETE_INVALID,
213
+ )
214
+ logger.info(
215
+ "🧹 自动维护完成: dedupe={} checked={} valid={} guest={} invalid={} deleted={}",
216
+ summary.duplicate_removed_count,
217
+ summary.checked_count,
218
+ summary.valid_count,
219
+ summary.guest_count,
220
+ summary.invalid_count,
221
+ summary.deleted_invalid_count,
222
+ )
223
+ except asyncio.CancelledError:
224
+ raise
225
+ except RuntimeError as exc:
226
+ logger.info(f"⏭️ 跳过本轮自动维护: {exc}")
227
+ except Exception as exc:
228
+ logger.exception(f"❌ Token 自动维护失败: {exc}")
229
+
230
+ await self._wait_or_stop(wait_seconds)
231
+
232
+ async def _wait_or_stop(self, timeout: int) -> None:
233
+ try:
234
+ await asyncio.wait_for(self._stop_event.wait(), timeout=timeout)
235
+ except asyncio.TimeoutError:
236
+ return
237
+
238
+ def _has_enabled_maintenance_action(self) -> bool:
239
+ return any(
240
+ (
241
+ settings.TOKEN_AUTO_REMOVE_DUPLICATES,
242
+ settings.TOKEN_AUTO_HEALTH_CHECK,
243
+ settings.TOKEN_AUTO_DELETE_INVALID,
244
+ )
245
+ )
246
+
247
+ def _log_import_warning_once(self, message: str) -> None:
248
+ if self._import_warning == message:
249
+ return
250
+ self._import_warning = message
251
+ logger.warning(f"⚠️ {message}")
252
+
253
+ def _log_maintenance_warning_once(self, message: str) -> None:
254
+ if self._maintenance_warning == message:
255
+ return
256
+ self._maintenance_warning = message
257
+ logger.warning(f"⚠️ {message}")
258
+
259
+
260
+ _scheduler: Optional[TokenAutomationScheduler] = None
261
+
262
+
263
+ def get_token_automation_scheduler() -> TokenAutomationScheduler:
264
+ global _scheduler
265
+ if _scheduler is None:
266
+ _scheduler = TokenAutomationScheduler()
267
+ return _scheduler
268
+
269
+
270
+ async def start_token_automation_scheduler() -> None:
271
+ await get_token_automation_scheduler().start()
272
+
273
+
274
+ async def stop_token_automation_scheduler() -> None:
275
+ global _scheduler
276
+ if _scheduler is None:
277
+ return
278
+ await _scheduler.stop()
app/services/token_dao.py ADDED
@@ -0,0 +1,664 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Token 数据访问层 (DAO)
3
+ 提供 Token 的 CRUD 操作和查询功能
4
+ """
5
+ import os
6
+ import sqlite3
7
+ from contextlib import asynccontextmanager
8
+ from typing import Any, Dict, List, Optional, Tuple
9
+
10
+ import aiosqlite
11
+
12
+ from app.models.token_db import DB_PATH, SQL_CREATE_TABLES
13
+ from app.utils.logger import logger
14
+
15
+
16
+ class TokenDAO:
17
+ """Token 数据访问对象"""
18
+
19
+ def __init__(self, db_path: str = DB_PATH):
20
+ """初始化 DAO"""
21
+ self.db_path = db_path
22
+ self._ensure_db_directory()
23
+
24
+ def _ensure_db_directory(self):
25
+ """确保数据库目录存在"""
26
+ db_dir = os.path.dirname(self.db_path)
27
+ if db_dir and not os.path.exists(db_dir):
28
+ os.makedirs(db_dir, exist_ok=True)
29
+
30
+ @asynccontextmanager
31
+ async def get_connection(self):
32
+ """获取异步数据库连接"""
33
+ conn = await aiosqlite.connect(self.db_path)
34
+ conn.row_factory = aiosqlite.Row # 返回字典式结果
35
+
36
+ # 启用外键约束(SQLite 默认关闭)
37
+ await conn.execute("PRAGMA foreign_keys = ON")
38
+
39
+ try:
40
+ yield conn
41
+ finally:
42
+ await conn.close()
43
+
44
+ def get_sync_connection(self):
45
+ """获取同步数据库连接(用于初始化)"""
46
+ conn = sqlite3.connect(self.db_path)
47
+ # 启用外键约束
48
+ conn.execute("PRAGMA foreign_keys = ON")
49
+ return conn
50
+
51
+ async def init_database(self):
52
+ """初始化数据库表结构"""
53
+ try:
54
+ # 使用同步连接创建表(避免异步初始化问题)
55
+ conn = self.get_sync_connection()
56
+ conn.executescript(SQL_CREATE_TABLES)
57
+ conn.commit()
58
+ conn.close()
59
+ except Exception as e:
60
+ logger.error(f"❌ Token 数据库初始化失败: {e}")
61
+ raise
62
+
63
+ # ==================== Token CRUD 操作 ====================
64
+
65
+ async def add_token(
66
+ self,
67
+ provider: str,
68
+ token: str,
69
+ token_type: str = "user",
70
+ priority: int = 0,
71
+ validate: bool = True
72
+ ) -> Optional[int]:
73
+ """
74
+ 添加新 Token(可选验证)
75
+
76
+ Args:
77
+ provider: 提供商名称
78
+ token: Token 值
79
+ token_type: Token 类型(如果 validate=True 将被验证结果覆盖)
80
+ priority: 优先级
81
+ validate: 是否验证 Token(仅针对 zai 提供商)
82
+
83
+ Returns:
84
+ token_id 或 None(验证失败或已存在)
85
+ """
86
+ try:
87
+ # 对于 zai 提供商,强制验证 Token
88
+ if provider == "zai" and validate:
89
+ from app.utils.token_pool import ZAITokenValidator
90
+
91
+ validated_type, is_valid, error_msg = await ZAITokenValidator.validate_token(token)
92
+
93
+ # 拒绝 guest token
94
+ if validated_type == "guest":
95
+ logger.warning(f"🚫 拒绝添加匿名用户 Token: {token[:20]}... - {error_msg}")
96
+ return None
97
+
98
+ # 拒绝无效 token
99
+ if not is_valid:
100
+ logger.warning(f"🚫 Token 验证失败: {token[:20]}... - {error_msg}")
101
+ return None
102
+
103
+ # 使用验证后的类型
104
+ token_type = validated_type
105
+
106
+ async with self.get_connection() as conn:
107
+ cursor = await conn.execute("""
108
+ INSERT OR IGNORE INTO tokens (provider, token, token_type, priority)
109
+ VALUES (?, ?, ?, ?)
110
+ """, (provider, token, token_type, priority))
111
+
112
+ await conn.commit()
113
+
114
+ if cursor.lastrowid > 0:
115
+ # 同时创建统计记录
116
+ await conn.execute("""
117
+ INSERT INTO token_stats (token_id)
118
+ VALUES (?)
119
+ """, (cursor.lastrowid,))
120
+ await conn.commit()
121
+ logger.info(f"✅ 添加 Token: {provider} ({token_type}) - {token[:20]}...")
122
+ return cursor.lastrowid
123
+ else:
124
+ logger.warning(f"⚠️ Token 已存在: {provider} - {token[:20]}...")
125
+ return None
126
+ except Exception as e:
127
+ logger.error(f"❌ 添加 Token 失败: {e}")
128
+ return None
129
+
130
+ async def get_tokens_by_provider(
131
+ self,
132
+ provider: str,
133
+ enabled_only: bool = True,
134
+ limit: Optional[int] = None,
135
+ offset: int = 0,
136
+ ) -> List[Dict]:
137
+ """
138
+ 获取指定提供商的所有 Token
139
+
140
+ Args:
141
+ provider: 提供商名称
142
+ enabled_only: 是否只返回启用的 Token
143
+ """
144
+ try:
145
+ async with self.get_connection() as conn:
146
+ query = """
147
+ SELECT t.*, ts.total_requests, ts.successful_requests, ts.failed_requests,
148
+ ts.last_success_time, ts.last_failure_time
149
+ FROM tokens t
150
+ LEFT JOIN token_stats ts ON t.id = ts.token_id
151
+ WHERE t.provider = ?
152
+ """
153
+ params = [provider]
154
+
155
+ if enabled_only:
156
+ query += " AND t.is_enabled = 1"
157
+
158
+ query += " ORDER BY t.priority DESC, t.id ASC"
159
+
160
+ if limit is not None:
161
+ query += " LIMIT ? OFFSET ?"
162
+ params.extend([limit, max(0, offset)])
163
+
164
+ cursor = await conn.execute(query, params)
165
+ rows = await cursor.fetchall()
166
+
167
+ return [dict(row) for row in rows]
168
+ except Exception as e:
169
+ logger.error(f"❌ 查询 Token 失败: {e}")
170
+ return []
171
+
172
+ async def get_all_tokens(self, enabled_only: bool = False) -> List[Dict]:
173
+ """获取所有 Token"""
174
+ try:
175
+ async with self.get_connection() as conn:
176
+ query = """
177
+ SELECT t.*, ts.total_requests, ts.successful_requests, ts.failed_requests,
178
+ ts.last_success_time, ts.last_failure_time
179
+ FROM tokens t
180
+ LEFT JOIN token_stats ts ON t.id = ts.token_id
181
+ """
182
+
183
+ if enabled_only:
184
+ query += " WHERE t.is_enabled = 1"
185
+
186
+ query += " ORDER BY t.provider, t.priority DESC, t.id ASC"
187
+
188
+ cursor = await conn.execute(query)
189
+ rows = await cursor.fetchall()
190
+
191
+ return [dict(row) for row in rows]
192
+ except Exception as e:
193
+ logger.error(f"❌ 查询所有 Token 失败: {e}")
194
+ return []
195
+
196
+ async def update_token_status(self, token_id: int, is_enabled: bool):
197
+ """更新 Token 启用状态"""
198
+ try:
199
+ async with self.get_connection() as conn:
200
+ await conn.execute("""
201
+ UPDATE tokens SET is_enabled = ? WHERE id = ?
202
+ """, (is_enabled, token_id))
203
+ await conn.commit()
204
+ logger.info(f"✅ 更新 Token 状态: id={token_id}, enabled={is_enabled}")
205
+ except Exception as e:
206
+ logger.error(f"❌ 更新 Token 状态失败: {e}")
207
+
208
+ async def update_token_type(self, token_id: int, token_type: str):
209
+ """更新 Token 类型"""
210
+ try:
211
+ async with self.get_connection() as conn:
212
+ await conn.execute("""
213
+ UPDATE tokens SET token_type = ? WHERE id = ?
214
+ """, (token_type, token_id))
215
+ await conn.commit()
216
+ logger.info(f"✅ 更新 Token 类型: id={token_id}, type={token_type}")
217
+ except Exception as e:
218
+ logger.error(f"❌ 更新 Token 类型失败: {e}")
219
+
220
+ async def delete_token(self, token_id: int):
221
+ """删除 Token(级联删除统计数据)"""
222
+ try:
223
+ async with self.get_connection() as conn:
224
+ await conn.execute("DELETE FROM tokens WHERE id = ?", (token_id,))
225
+ await conn.commit()
226
+ logger.info(f"✅ 删除 Token: id={token_id}")
227
+ except Exception as e:
228
+ logger.error(f"❌ 删除 Token 失败: {e}")
229
+
230
+ async def delete_tokens_by_ids(self, token_ids: List[int]) -> int:
231
+ """批量删除 Token(级联删除统计数据)"""
232
+ if not token_ids:
233
+ return 0
234
+
235
+ try:
236
+ placeholders = ",".join("?" for _ in token_ids)
237
+ async with self.get_connection() as conn:
238
+ await conn.execute(
239
+ f"DELETE FROM tokens WHERE id IN ({placeholders})",
240
+ token_ids,
241
+ )
242
+ cursor = await conn.execute("SELECT changes()")
243
+ row = await cursor.fetchone()
244
+ await conn.commit()
245
+
246
+ deleted_count = int(row[0] if row else 0)
247
+ logger.info(f"✅ 批量删除 Token: {deleted_count} 个")
248
+ return deleted_count
249
+ except Exception as e:
250
+ logger.error(f"❌ 批量删除 Token 失败: {e}")
251
+ return 0
252
+
253
+ async def delete_tokens_by_provider(self, provider: str):
254
+ """删除指定提供商的所有 Token"""
255
+ try:
256
+ async with self.get_connection() as conn:
257
+ await conn.execute("DELETE FROM tokens WHERE provider = ?", (provider,))
258
+ await conn.commit()
259
+ logger.info(f"✅ 删除提供商所有 Token: {provider}")
260
+ except Exception as e:
261
+ logger.error(f"❌ 删除提供商 Token 失败: {e}")
262
+
263
+ # ==================== Token 统计操作 ====================
264
+
265
+ async def record_success(self, token_id: int):
266
+ """记录 Token 使用成功"""
267
+ try:
268
+ async with self.get_connection() as conn:
269
+ await conn.execute("""
270
+ UPDATE token_stats
271
+ SET total_requests = total_requests + 1,
272
+ successful_requests = successful_requests + 1,
273
+ last_success_time = CURRENT_TIMESTAMP
274
+ WHERE token_id = ?
275
+ """, (token_id,))
276
+ await conn.commit()
277
+ except Exception as e:
278
+ logger.error(f"❌ 记录成功失败: {e}")
279
+
280
+ async def record_failure(self, token_id: int):
281
+ """记录 Token 使用失败"""
282
+ try:
283
+ async with self.get_connection() as conn:
284
+ await conn.execute("""
285
+ UPDATE token_stats
286
+ SET total_requests = total_requests + 1,
287
+ failed_requests = failed_requests + 1,
288
+ last_failure_time = CURRENT_TIMESTAMP
289
+ WHERE token_id = ?
290
+ """, (token_id,))
291
+ await conn.commit()
292
+ except Exception as e:
293
+ logger.error(f"❌ 记录失败失败: {e}")
294
+
295
+ async def get_token_stats(self, token_id: int) -> Optional[Dict]:
296
+ """获取 Token 统计信息"""
297
+ try:
298
+ async with self.get_connection() as conn:
299
+ cursor = await conn.execute("""
300
+ SELECT * FROM token_stats WHERE token_id = ?
301
+ """, (token_id,))
302
+ row = await cursor.fetchone()
303
+ return dict(row) if row else None
304
+ except Exception as e:
305
+ logger.error(f"❌ 获取统计信息失败: {e}")
306
+ return None
307
+
308
+ # ==================== 批量操作 ====================
309
+
310
+ async def bulk_add_tokens(
311
+ self,
312
+ provider: str,
313
+ tokens: List[str],
314
+ token_type: str = "user",
315
+ validate: bool = True
316
+ ) -> Tuple[int, int]:
317
+ """
318
+ 批量添加 Token(可选验证)
319
+
320
+ Args:
321
+ provider: 提供商名称
322
+ tokens: Token 列表
323
+ token_type: Token 类型(如果 validate=True 将被覆盖)
324
+ validate: 是否验证 Token(仅针对 zai)
325
+
326
+ Returns:
327
+ (成功添加数量, 失败数量)
328
+ """
329
+ added_count = 0
330
+ failed_count = 0
331
+
332
+ for token in tokens:
333
+ if token.strip(): # 过滤空 token
334
+ token_id = await self.add_token(
335
+ provider,
336
+ token.strip(),
337
+ token_type,
338
+ validate=validate
339
+ )
340
+ if token_id:
341
+ added_count += 1
342
+ else:
343
+ failed_count += 1
344
+
345
+ logger.info(f"✅ 批量添加完成: {provider} - 成功 {added_count}/{len(tokens)},失败 {failed_count}")
346
+ return added_count, failed_count
347
+
348
+ async def replace_tokens(self, provider: str, tokens: List[str],
349
+ token_type: str = "user"):
350
+ """
351
+ 替换指定提供商的所有 Token(先删除后添加)
352
+ """
353
+ # 删除旧 Token
354
+ await self.delete_tokens_by_provider(provider)
355
+
356
+ # 添加新 Token
357
+ added_count = await self.bulk_add_tokens(provider, tokens, token_type)
358
+
359
+ logger.info(f"✅ 替换 Token 完成: {provider} - {added_count} 个")
360
+ return added_count
361
+
362
+ async def remove_duplicate_tokens(self, provider: Optional[str] = None) -> int:
363
+ """
364
+ 删除重复 Token,保留每个 provider/token 组合中排序靠前的一条记录。
365
+
366
+ 正常情况下唯一约束会阻止重复数据,这里主要处理历史数据或手工导入异常。
367
+ """
368
+ try:
369
+ tokens = (
370
+ await self.get_tokens_by_provider(provider, enabled_only=False)
371
+ if provider
372
+ else await self.get_all_tokens(enabled_only=False)
373
+ )
374
+
375
+ seen_keys: set[tuple[str, str]] = set()
376
+ duplicate_ids: list[int] = []
377
+
378
+ for token_record in tokens:
379
+ token_value = str(token_record.get("token") or "").strip()
380
+ token_provider = str(token_record.get("provider") or "")
381
+ key = (token_provider, token_value)
382
+
383
+ if key in seen_keys:
384
+ duplicate_ids.append(int(token_record["id"]))
385
+ continue
386
+
387
+ seen_keys.add(key)
388
+
389
+ deleted_count = await self.delete_tokens_by_ids(duplicate_ids)
390
+ if deleted_count > 0:
391
+ logger.info(f"✅ 已清理重复 Token: {deleted_count} 个")
392
+ return deleted_count
393
+ except Exception as e:
394
+ logger.error(f"❌ 清理重复 Token 失败: {e}")
395
+ return 0
396
+
397
+ # ==================== 实用方法 ====================
398
+
399
+ async def get_token_by_value(self, provider: str, token: str) -> Optional[Dict]:
400
+ """根据 Token 值查询"""
401
+ try:
402
+ async with self.get_connection() as conn:
403
+ cursor = await conn.execute("""
404
+ SELECT t.*, ts.total_requests, ts.successful_requests, ts.failed_requests
405
+ FROM tokens t
406
+ LEFT JOIN token_stats ts ON t.id = ts.token_id
407
+ WHERE t.provider = ? AND t.token = ?
408
+ """, (provider, token))
409
+ row = await cursor.fetchone()
410
+ return dict(row) if row else None
411
+ except Exception as e:
412
+ logger.error(f"❌ 查询 Token 失败: {e}")
413
+ return None
414
+
415
+ async def get_provider_stats(self, provider: str) -> Dict:
416
+ """获取提供商统计信息"""
417
+ try:
418
+ async with self.get_connection() as conn:
419
+ cursor = await conn.execute("""
420
+ SELECT
421
+ COUNT(*) as total_tokens,
422
+ SUM(CASE WHEN is_enabled = 1 THEN 1 ELSE 0 END) as enabled_tokens,
423
+ SUM(ts.total_requests) as total_requests,
424
+ SUM(ts.successful_requests) as successful_requests,
425
+ SUM(ts.failed_requests) as failed_requests
426
+ FROM tokens t
427
+ LEFT JOIN token_stats ts ON t.id = ts.token_id
428
+ WHERE t.provider = ?
429
+ """, (provider,))
430
+ row = await cursor.fetchone()
431
+ return dict(row) if row else {}
432
+ except Exception as e:
433
+ logger.error(f"❌ 获取提供商统计失败: {e}")
434
+ return {}
435
+
436
+ async def get_provider_token_counts(self, provider: str) -> Dict[str, int]:
437
+ """聚合提供商的 Token 数量与类型分布。"""
438
+ try:
439
+ async with self.get_connection() as conn:
440
+ cursor = await conn.execute(
441
+ """
442
+ SELECT
443
+ COUNT(*) as total_tokens,
444
+ SUM(CASE WHEN is_enabled = 1 THEN 1 ELSE 0 END) as enabled_tokens,
445
+ SUM(CASE WHEN token_type = 'user' THEN 1 ELSE 0 END) as user_tokens,
446
+ SUM(CASE WHEN token_type = 'guest' THEN 1 ELSE 0 END) as guest_tokens,
447
+ SUM(CASE WHEN token_type = 'unknown' THEN 1 ELSE 0 END) as unknown_tokens
448
+ FROM tokens
449
+ WHERE provider = ?
450
+ """,
451
+ (provider,),
452
+ )
453
+ row = await cursor.fetchone()
454
+
455
+ if not row:
456
+ return {
457
+ "total_tokens": 0,
458
+ "enabled_tokens": 0,
459
+ "user_tokens": 0,
460
+ "guest_tokens": 0,
461
+ "unknown_tokens": 0,
462
+ }
463
+
464
+ return {
465
+ "total_tokens": int(row["total_tokens"] or 0),
466
+ "enabled_tokens": int(row["enabled_tokens"] or 0),
467
+ "user_tokens": int(row["user_tokens"] or 0),
468
+ "guest_tokens": int(row["guest_tokens"] or 0),
469
+ "unknown_tokens": int(row["unknown_tokens"] or 0),
470
+ }
471
+ except Exception as e:
472
+ logger.error(f"❌ 获取 Token 数量统计失败: {e}")
473
+ return {
474
+ "total_tokens": 0,
475
+ "enabled_tokens": 0,
476
+ "user_tokens": 0,
477
+ "guest_tokens": 0,
478
+ "unknown_tokens": 0,
479
+ }
480
+
481
+ async def count_tokens_by_provider(
482
+ self,
483
+ provider: str,
484
+ enabled_only: bool = False,
485
+ ) -> int:
486
+ """统计提供商下的 Token 总数。"""
487
+ try:
488
+ async with self.get_connection() as conn:
489
+ query = "SELECT COUNT(*) AS total_count FROM tokens WHERE provider = ?"
490
+ params: List[object] = [provider]
491
+ if enabled_only:
492
+ query += " AND is_enabled = 1"
493
+
494
+ cursor = await conn.execute(query, params)
495
+ row = await cursor.fetchone()
496
+
497
+ return int(row["total_count"] or 0) if row else 0
498
+ except Exception as e:
499
+ logger.error(f"❌ 统计 Token 总数失败: {e}")
500
+ return 0
501
+
502
+ # ==================== Token 验证操作 ====================
503
+
504
+ async def validate_and_update_token(self, token_id: int) -> bool:
505
+ """
506
+ 验证单个 Token 并更新其类型
507
+
508
+ Args:
509
+ token_id: Token 数据库 ID
510
+
511
+ Returns:
512
+ 是否为有效的认证用户 Token
513
+ """
514
+ try:
515
+ # 获取 Token 信息
516
+ async with self.get_connection() as conn:
517
+ cursor = await conn.execute("""
518
+ SELECT provider, token FROM tokens WHERE id = ?
519
+ """, (token_id,))
520
+ row = await cursor.fetchone()
521
+
522
+ if not row:
523
+ logger.error(f"❌ Token ID {token_id} 不存在")
524
+ return False
525
+
526
+ provider = row["provider"]
527
+ token = row["token"]
528
+
529
+ if provider != "zai":
530
+ logger.info(f"⏭️ 跳过非 zai 提供商的 Token 验证: {provider}")
531
+ return True
532
+
533
+ # 验证 Token
534
+ from app.utils.token_pool import ZAITokenValidator
535
+
536
+ token_type, is_valid, error_msg = await ZAITokenValidator.validate_token(token)
537
+
538
+ # 更新 Token 类型
539
+ await self.update_token_type(token_id, token_type)
540
+
541
+ if not is_valid:
542
+ logger.warning(f"⚠️ Token 验证失败: id={token_id}, type={token_type}, error={error_msg}")
543
+
544
+ return is_valid
545
+
546
+ except Exception as e:
547
+ logger.error(f"❌ 验证 Token 失败: {e}")
548
+ return False
549
+
550
+ async def validate_tokens_detailed(self, provider: str = "zai") -> Dict[str, Any]:
551
+ """
552
+ 批量验证所有 Token,并返回详细结果。
553
+
554
+ Returns:
555
+ {
556
+ "checked": 数量,
557
+ "valid": 数量,
558
+ "guest": 数量,
559
+ "invalid": 数量,
560
+ "invalid_token_ids": [id, ...],
561
+ }
562
+ """
563
+ try:
564
+ tokens = await self.get_tokens_by_provider(provider, enabled_only=False)
565
+
566
+ if not tokens:
567
+ logger.warning(f"⚠️ 没有需要验证的 {provider} Token")
568
+ return {
569
+ "checked": 0,
570
+ "valid": 0,
571
+ "guest": 0,
572
+ "invalid": 0,
573
+ "invalid_token_ids": [],
574
+ }
575
+
576
+ logger.info(f"🔍 开始批量验证 {len(tokens)} 个 {provider} Token...")
577
+
578
+ from app.utils.token_pool import ZAITokenValidator
579
+
580
+ stats: Dict[str, Any] = {
581
+ "checked": len(tokens),
582
+ "valid": 0,
583
+ "guest": 0,
584
+ "invalid": 0,
585
+ "invalid_token_ids": [],
586
+ }
587
+
588
+ for token_record in tokens:
589
+ token_id = int(token_record["id"])
590
+ token = str(token_record["token"])
591
+
592
+ token_type, is_valid, error_msg = await ZAITokenValidator.validate_token(
593
+ token
594
+ )
595
+ await self.update_token_type(token_id, token_type)
596
+
597
+ if token_type == "user" and is_valid:
598
+ stats["valid"] += 1
599
+ elif token_type == "guest":
600
+ stats["guest"] += 1
601
+ stats["invalid_token_ids"].append(token_id)
602
+ else:
603
+ stats["invalid"] += 1
604
+ stats["invalid_token_ids"].append(token_id)
605
+ if error_msg:
606
+ logger.warning(
607
+ "⚠️ Token 验证失败: id={}, type={}, error={}",
608
+ token_id,
609
+ token_type,
610
+ error_msg,
611
+ )
612
+
613
+ logger.info(
614
+ "✅ 批量验证完成: 有效 {}, 匿名 {}, 无效 {}",
615
+ stats["valid"],
616
+ stats["guest"],
617
+ stats["invalid"],
618
+ )
619
+ return stats
620
+
621
+ except Exception as e:
622
+ logger.error(f"❌ 批量验证失败: {e}")
623
+ return {
624
+ "checked": 0,
625
+ "valid": 0,
626
+ "guest": 0,
627
+ "invalid": 0,
628
+ "invalid_token_ids": [],
629
+ }
630
+
631
+ async def validate_all_tokens(self, provider: str = "zai") -> Dict[str, int]:
632
+ """
633
+ 批量验证所有 Token
634
+
635
+ Args:
636
+ provider: 提供商名称(默认 zai)
637
+
638
+ Returns:
639
+ 统计结果 {"valid": 数量, "guest": 数量, "invalid": 数量}
640
+ """
641
+ stats = await self.validate_tokens_detailed(provider)
642
+ return {
643
+ "valid": int(stats.get("valid", 0) or 0),
644
+ "guest": int(stats.get("guest", 0) or 0),
645
+ "invalid": int(stats.get("invalid", 0) or 0),
646
+ }
647
+
648
+
649
+ # 全局单例
650
+ _token_dao: Optional[TokenDAO] = None
651
+
652
+
653
+ def get_token_dao() -> TokenDAO:
654
+ """获取全局 TokenDAO 实例"""
655
+ global _token_dao
656
+ if _token_dao is None:
657
+ _token_dao = TokenDAO()
658
+ return _token_dao
659
+
660
+
661
+ async def init_token_database():
662
+ """初始化 Token 数据库"""
663
+ dao = get_token_dao()
664
+ await dao.init_database()
app/services/token_importer.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """本地目录 token 导入服务。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ from app.services.token_dao import TokenDAO, get_token_dao
11
+ from app.utils.logger import logger
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class TokenImportSummary:
16
+ source_dir: str
17
+ scanned_files: int
18
+ imported_count: int
19
+ duplicate_count: int
20
+ invalid_json_count: int
21
+ missing_token_count: int
22
+ invalid_token_count: int
23
+
24
+ @property
25
+ def failed_count(self) -> int:
26
+ return (
27
+ self.duplicate_count
28
+ + self.invalid_json_count
29
+ + self.missing_token_count
30
+ + self.invalid_token_count
31
+ )
32
+
33
+
34
+ def _load_token_payload(file_path: Path) -> dict:
35
+ try:
36
+ return json.loads(file_path.read_text(encoding="utf-8"))
37
+ except json.JSONDecodeError as exc:
38
+ raise ValueError(f"JSON 解析失败: {exc}") from exc
39
+
40
+
41
+ async def import_tokens_from_directory(
42
+ source_dir: str | Path,
43
+ *,
44
+ provider: str = "zai",
45
+ validate: bool = True,
46
+ dao: Optional[TokenDAO] = None,
47
+ ) -> TokenImportSummary:
48
+ """
49
+ 从本地目录导入 token。
50
+
51
+ 目录中的每个 JSON 文件应至少包含 `token` 字段。
52
+ """
53
+ source_path = Path(source_dir).expanduser().resolve()
54
+ if not source_path.exists():
55
+ raise FileNotFoundError(f"导入目录不存在: {source_path}")
56
+ if not source_path.is_dir():
57
+ raise NotADirectoryError(f"导入路径不是目录: {source_path}")
58
+
59
+ token_dao = dao or get_token_dao()
60
+ token_files = sorted(source_path.rglob("*.json"))
61
+ seen_tokens: set[str] = set()
62
+ imported_count = 0
63
+ duplicate_count = 0
64
+ invalid_json_count = 0
65
+ missing_token_count = 0
66
+ invalid_token_count = 0
67
+
68
+ for file_path in token_files:
69
+ try:
70
+ payload = _load_token_payload(file_path)
71
+ except ValueError as exc:
72
+ invalid_json_count += 1
73
+ logger.warning(f"⚠️ 跳过无效 JSON 文件: {file_path} - {exc}")
74
+ continue
75
+
76
+ if not isinstance(payload, dict):
77
+ invalid_json_count += 1
78
+ logger.warning(f"⚠️ 跳过非对象 JSON 文件: {file_path}")
79
+ continue
80
+
81
+ token = str(payload.get("token") or "").strip()
82
+ email = str(payload.get("email") or "").strip()
83
+ if not token:
84
+ missing_token_count += 1
85
+ logger.warning(f"⚠️ 文件缺少 token 字段: {file_path}")
86
+ continue
87
+
88
+ if token in seen_tokens:
89
+ duplicate_count += 1
90
+ logger.info(f"↩️ 跳过本批次重复 Token: {file_path.name}")
91
+ continue
92
+ seen_tokens.add(token)
93
+
94
+ existing = await token_dao.get_token_by_value(provider, token)
95
+ if existing is not None:
96
+ duplicate_count += 1
97
+ logger.info(
98
+ "↩️ Token 已存在,跳过导入: {} ({})",
99
+ file_path.name,
100
+ email or "unknown",
101
+ )
102
+ continue
103
+
104
+ token_id = await token_dao.add_token(
105
+ provider=provider,
106
+ token=token,
107
+ token_type="user",
108
+ validate=validate,
109
+ )
110
+ if token_id is None:
111
+ invalid_token_count += 1
112
+ logger.warning(f"⚠️ Token 导入失败: {file_path.name} ({email or 'unknown'})")
113
+ continue
114
+
115
+ imported_count += 1
116
+ logger.info(f"✅ 已导入 Token: {file_path.name} ({email or 'unknown'})")
117
+
118
+ summary = TokenImportSummary(
119
+ source_dir=str(source_path),
120
+ scanned_files=len(token_files),
121
+ imported_count=imported_count,
122
+ duplicate_count=duplicate_count,
123
+ invalid_json_count=invalid_json_count,
124
+ missing_token_count=missing_token_count,
125
+ invalid_token_count=invalid_token_count,
126
+ )
127
+ logger.info(
128
+ "✅ Token 目录导入完成: "
129
+ "scanned={}, imported={}, duplicate={}, invalid_json={}, "
130
+ "missing_token={}, invalid_token={}",
131
+ summary.scanned_files,
132
+ summary.imported_count,
133
+ summary.duplicate_count,
134
+ summary.invalid_json_count,
135
+ summary.missing_token_count,
136
+ summary.invalid_token_count,
137
+ )
138
+ return summary
app/templates/base.html ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN" class="h-full bg-gray-50">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>{% block title %}管理后台{% endblock %} - API 控制台</title>
7
+
8
+ <!-- Tailwind CSS (CDN) -->
9
+ <script src="https://cdn.tailwindcss.com"></script>
10
+
11
+ <!-- Alpine.js (CDN) -->
12
+ <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
13
+
14
+ <!-- htmx (CDN) -->
15
+ <script src="https://unpkg.com/htmx.org@1.9.10"></script>
16
+
17
+ <!-- Chart.js (CDN) -->
18
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1"></script>
19
+
20
+ <!-- 自定义样式 -->
21
+ <style>
22
+ /* 自定义滚动条 */
23
+ ::-webkit-scrollbar {
24
+ width: 8px;
25
+ height: 8px;
26
+ }
27
+ ::-webkit-scrollbar-track {
28
+ background: #f1f1f1;
29
+ }
30
+ ::-webkit-scrollbar-thumb {
31
+ background: #888;
32
+ border-radius: 4px;
33
+ }
34
+ ::-webkit-scrollbar-thumb:hover {
35
+ background: #555;
36
+ }
37
+
38
+ /* htmx 加载指示器 */
39
+ .htmx-indicator {
40
+ display: none;
41
+ }
42
+ .htmx-request .htmx-indicator {
43
+ display: inline-block;
44
+ }
45
+ .htmx-request.htmx-indicator {
46
+ display: inline-block;
47
+ }
48
+
49
+ /* 平滑过渡 */
50
+ .fade-in {
51
+ animation: fadeIn 0.3s ease-in;
52
+ }
53
+ @keyframes fadeIn {
54
+ from { opacity: 0; }
55
+ to { opacity: 1; }
56
+ }
57
+ </style>
58
+
59
+ {% block extra_head %}{% endblock %}
60
+ </head>
61
+ <body class="h-full" x-data="{
62
+ sidebarOpen: true,
63
+ async logout() {
64
+ if (confirm('确定要登出吗?')) {
65
+ try {
66
+ const response = await fetch('/admin/api/logout', {
67
+ method: 'POST'
68
+ });
69
+ if (response.ok) {
70
+ window.location.href = '/admin/login';
71
+ }
72
+ } catch (err) {
73
+ console.error('登出失败:', err);
74
+ alert('登出失败,请稍后重试');
75
+ }
76
+ }
77
+ }
78
+ }">
79
+ <div class="min-h-full">
80
+ <!-- 顶部导航栏 -->
81
+ <nav class="bg-indigo-600 shadow-lg">
82
+ <div class="mx-auto px-4 sm:px-6 lg:px-8">
83
+ <div class="flex h-16 items-center justify-between">
84
+ <!-- Logo 和切换按钮 -->
85
+ <div class="flex items-center">
86
+ <button @click="sidebarOpen = !sidebarOpen" class="text-white hover:bg-indigo-700 p-2 rounded-md">
87
+ <svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
88
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
89
+ </svg>
90
+ </button>
91
+ <div class="ml-4 flex items-center">
92
+ <h1 class="text-2xl font-bold text-white">API 管理后台</h1>
93
+ </div>
94
+ </div>
95
+
96
+ <!-- 右侧信息 -->
97
+ <div class="flex items-center space-x-4">
98
+ <!-- 实时状态指示器 -->
99
+ <div class="flex items-center text-white">
100
+ <span class="relative flex h-3 w-3">
101
+ <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
102
+ <span class="relative inline-flex rounded-full h-3 w-3 bg-green-500"></span>
103
+ </span>
104
+ <span class="ml-2 text-sm">服务运行中</span>
105
+ </div>
106
+
107
+ <!-- 登出按钮 -->
108
+ <button
109
+ @click="logout()"
110
+ class="flex items-center text-white hover:bg-indigo-700 px-3 py-2 rounded-md text-sm font-medium transition-colors">
111
+ <svg class="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
112
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
113
+ </svg>
114
+ 登出
115
+ </button>
116
+ </div>
117
+ </div>
118
+ </div>
119
+ </nav>
120
+
121
+ <div class="flex">
122
+ <!-- 侧边栏 -->
123
+ <aside
124
+ x-show="sidebarOpen"
125
+ x-transition:enter="transition ease-out duration-200"
126
+ x-transition:enter-start="transform -translate-x-full"
127
+ x-transition:enter-end="transform translate-x-0"
128
+ x-transition:leave="transition ease-in duration-200"
129
+ x-transition:leave-start="transform translate-x-0"
130
+ x-transition:leave-end="transform -translate-x-full"
131
+ class="w-64 bg-white shadow-lg min-h-screen">
132
+ <nav class="mt-5 px-2 space-y-1">
133
+ {% set current_path = request.url.path %}
134
+
135
+ <!-- 仪表盘 -->
136
+ <a href="/admin"
137
+ class="{% if current_path == '/admin' or current_path == '/admin/' %}bg-indigo-100 text-indigo-700{% else %}text-gray-700 hover:bg-gray-100{% endif %} group flex items-center px-3 py-2 text-sm font-medium rounded-md">
138
+ <svg class="mr-3 h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
139
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
140
+ </svg>
141
+ 仪表盘
142
+ </a>
143
+
144
+ <!-- 配置管理 -->
145
+ <a href="/admin/config"
146
+ class="{% if '/config' in current_path %}bg-indigo-100 text-indigo-700{% else %}text-gray-700 hover:bg-gray-100{% endif %} group flex items-center px-3 py-2 text-sm font-medium rounded-md">
147
+ <svg class="mr-3 h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
148
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
149
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
150
+ </svg>
151
+ 配置管理
152
+ </a>
153
+
154
+ <!-- 实时日志 -->
155
+ <a href="/admin/logs"
156
+ class="{% if '/logs' in current_path %}bg-indigo-100 text-indigo-700{% else %}text-gray-700 hover:bg-gray-100{% endif %} group flex items-center px-3 py-2 text-sm font-medium rounded-md">
157
+ <svg class="mr-3 h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
158
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
159
+ </svg>
160
+ 实时日志
161
+ </a>
162
+
163
+ <!-- Token 管理 -->
164
+ <a href="/admin/tokens"
165
+ class="{% if '/tokens' in current_path %}bg-indigo-100 text-indigo-700{% else %}text-gray-700 hover:bg-gray-100{% endif %} group flex items-center px-3 py-2 text-sm font-medium rounded-md">
166
+ <svg class="mr-3 h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
167
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
168
+ </svg>
169
+ Token 管理
170
+ </a>
171
+
172
+ <!-- 分隔线 -->
173
+ <div class="border-t border-gray-200 my-4"></div>
174
+
175
+ <!-- API 文档 -->
176
+ <a href="/docs" target="_blank"
177
+ class="text-gray-700 hover:bg-gray-100 group flex items-center px-3 py-2 text-sm font-medium rounded-md">
178
+ <svg class="mr-3 h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
179
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
180
+ </svg>
181
+ API 文档
182
+ </a>
183
+ </nav>
184
+ </aside>
185
+
186
+ <!-- 主内容区 -->
187
+ <main class="flex-1 p-6">
188
+ <!-- 通知区域 -->
189
+ <div id="notification" class="mb-4"></div>
190
+
191
+ <!-- 页面内容 -->
192
+ <div class="fade-in">
193
+ {% block content %}{% endblock %}
194
+ </div>
195
+ </main>
196
+ </div>
197
+ </div>
198
+
199
+ {% block extra_scripts %}{% endblock %}
200
+ </body>
201
+ </html>
app/templates/components/recent_logs.html ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!-- 最近请求日志列表 -->
2
+ <script>
3
+ window.dashboardRecentLogsPage = {{ page.current_page }};
4
+ window.dashboardRecentLogsPageSize = {{ page.page_size }};
5
+ </script>
6
+
7
+ <div class="space-y-4">
8
+ <div class="max-h-[30rem] overflow-auto rounded-2xl border border-slate-200">
9
+ {% if logs %}
10
+ <table class="min-w-[1240px] divide-y divide-slate-200">
11
+ <thead class="bg-slate-50/95 backdrop-blur">
12
+ <tr>
13
+ <th class="sticky top-0 bg-slate-50/95 px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-slate-500">时间</th>
14
+ <th class="sticky top-0 bg-slate-50/95 px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-slate-500">请求</th>
15
+ <th class="sticky top-0 bg-slate-50/95 px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-slate-500">标记</th>
16
+ <th class="sticky top-0 bg-slate-50/95 px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-slate-500">输入 / 输出</th>
17
+ <th class="sticky top-0 bg-slate-50/95 px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-slate-500">缓存创建 / 命中</th>
18
+ <th class="sticky top-0 bg-slate-50/95 px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-slate-500">用时 / 首字</th>
19
+ <th class="sticky top-0 bg-slate-50/95 px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-slate-500">状态</th>
20
+ </tr>
21
+ </thead>
22
+ <tbody class="divide-y divide-slate-100 bg-white">
23
+ {% for log in logs %}
24
+ <tr class="transition-colors hover:bg-slate-50/70">
25
+ <td class="px-4 py-3 whitespace-nowrap text-sm font-medium text-slate-900">
26
+ {{ log.timestamp }}
27
+ </td>
28
+ <td class="px-4 py-3 text-sm text-slate-600">
29
+ <div class="flex max-w-[28rem] items-center gap-2 overflow-hidden whitespace-nowrap">
30
+ <span class="inline-flex items-center rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-700">
31
+ {{ log.model }}
32
+ </span>
33
+ <span class="truncate font-mono text-xs text-slate-500">
34
+ {{ log.endpoint }}
35
+ </span>
36
+ </div>
37
+ {% if log.error_message %}
38
+ <p class="mt-1 max-w-[28rem] truncate text-xs text-red-500">
39
+ {{ log.error_message }}
40
+ </p>
41
+ {% endif %}
42
+ </td>
43
+ <td class="px-4 py-3 text-sm text-slate-600">
44
+ <div class="flex max-w-[20rem] items-center gap-2 overflow-hidden whitespace-nowrap">
45
+ <span class="inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-700">
46
+ {{ log.client_name }}
47
+ </span>
48
+ <span class="inline-flex items-center rounded-full bg-sky-50 px-2 py-0.5 text-xs font-medium text-sky-700">
49
+ {{ log.protocol_display }}
50
+ </span>
51
+ {% if log.source_display %}
52
+ <span class="inline-flex items-center rounded-full bg-violet-50 px-2 py-0.5 text-xs font-medium text-violet-700">
53
+ {{ log.source_display }}
54
+ </span>
55
+ {% endif %}
56
+ {% if log.provider_display %}
57
+ <span class="inline-flex items-center rounded-full bg-indigo-50 px-2 py-0.5 text-xs font-medium text-indigo-700">
58
+ {{ log.provider_display }}
59
+ </span>
60
+ {% endif %}
61
+ </div>
62
+ </td>
63
+ <td class="px-4 py-3 whitespace-nowrap text-sm font-medium tabular-nums text-slate-700">
64
+ <span class="text-violet-700">输入 {{ log.input_tokens }}</span>
65
+ <span class="mx-2 text-slate-300">/</span>
66
+ <span class="text-rose-700">输出 {{ log.output_tokens }}</span>
67
+ </td>
68
+ <td class="px-4 py-3 whitespace-nowrap text-sm font-medium tabular-nums text-slate-700">
69
+ <span class="text-emerald-700">创建 {{ log.cache_creation_tokens }}</span>
70
+ <span class="mx-2 text-slate-300">/</span>
71
+ <span class="text-amber-700">命中 {{ log.cache_read_tokens }}</span>
72
+ </td>
73
+ <td class="px-4 py-3 whitespace-nowrap text-sm font-medium tabular-nums text-slate-700">
74
+ <span>用时 {{ log.duration_display }}</span>
75
+ <span class="mx-2 text-slate-300">/</span>
76
+ <span class="text-sky-700">首字 {{ log.first_token_display }}</span>
77
+ </td>
78
+ <td class="px-4 py-3 whitespace-nowrap text-sm">
79
+ <span class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-semibold {% if log.success %}bg-emerald-100 text-emerald-800{% else %}bg-red-100 text-red-800{% endif %}">
80
+ {{ "成功" if log.success else "失败" }}
81
+ </span>
82
+ <span class="ml-2 text-xs font-medium {% if log.success %}text-emerald-700{% else %}text-red-700{% endif %}">
83
+ HTTP {{ log.status_code }}
84
+ </span>
85
+ </td>
86
+ </tr>
87
+ {% endfor %}
88
+ </tbody>
89
+ </table>
90
+ {% else %}
91
+ <div class="py-8 text-center text-gray-500">
92
+ <svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
93
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
94
+ </svg>
95
+ <p class="mt-2">暂无请求日志</p>
96
+ </div>
97
+ {% endif %}
98
+ </div>
99
+
100
+ <div class="flex flex-col gap-3 border-t border-gray-100 pt-4 text-sm text-gray-600 sm:flex-row sm:items-center sm:justify-between">
101
+ <div>
102
+ {% if page.total_items > 0 %}
103
+ 显示第 {{ page.start_item }} - {{ page.end_item }} 条,共 {{ page.total_items }} 条
104
+ {% else %}
105
+ 暂无日志数据
106
+ {% endif %}
107
+ </div>
108
+ <div class="flex items-center gap-2">
109
+ <button
110
+ type="button"
111
+ onclick="loadRecentLogsPage({{ page.previous_page }})"
112
+ class="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium transition-colors {% if page.has_previous %}bg-white text-gray-700 hover:bg-gray-50{% else %}cursor-not-allowed bg-gray-100 text-gray-400{% endif %}"
113
+ {% if not page.has_previous %}disabled{% endif %}>
114
+ 上一页
115
+ </button>
116
+ <span class="min-w-[88px] text-center text-xs text-gray-500">
117
+ 第 {{ page.current_page }} / {{ page.total_pages }} 页
118
+ </span>
119
+ <button
120
+ type="button"
121
+ onclick="loadRecentLogsPage({{ page.next_page }})"
122
+ class="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium transition-colors {% if page.has_next %}bg-white text-gray-700 hover:bg-gray-50{% else %}cursor-not-allowed bg-gray-100 text-gray-400{% endif %}"
123
+ {% if not page.has_next %}disabled{% endif %}>
124
+ 下一页
125
+ </button>
126
+ </div>
127
+ </div>
128
+ </div>
app/templates/components/token_list.html ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!-- Token 列表表格 -->
2
+ <script>
3
+ window.tokenListPage = {{ page.current_page }};
4
+ window.tokenListPageSize = {{ page.page_size }};
5
+ </script>
6
+
7
+ <div class="space-y-4">
8
+ <div class="max-h-[42rem] overflow-auto rounded-lg border border-gray-100">
9
+ {% if tokens %}
10
+ <table class="min-w-full divide-y divide-gray-200">
11
+ <thead class="bg-gray-50">
12
+ <tr>
13
+ <th class="sticky top-0 bg-gray-50 px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">ID</th>
14
+ <th class="sticky top-0 bg-gray-50 px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">Token</th>
15
+ <th class="sticky top-0 bg-gray-50 px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">类型</th>
16
+ <th class="sticky top-0 bg-gray-50 px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">健康度</th>
17
+ <th class="sticky top-0 bg-gray-50 px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">状态</th>
18
+ <th class="sticky top-0 bg-gray-50 px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">使用统计</th>
19
+ <th class="sticky top-0 bg-gray-50 px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">创建时间</th>
20
+ <th class="sticky top-0 bg-gray-50 px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">操作</th>
21
+ </tr>
22
+ </thead>
23
+ <tbody class="bg-white divide-y divide-gray-200">
24
+ {% for token in tokens %}
25
+ {% include "components/token_row.html" %}
26
+ {% endfor %}
27
+ </tbody>
28
+ </table>
29
+ {% else %}
30
+ <div class="text-center py-12 text-gray-500">
31
+ <svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
32
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
33
+ </svg>
34
+ <p class="mt-2 font-medium">暂无 Token</p>
35
+ <p class="mt-1 text-sm">点击右上角"添加 Token"按钮开始添加</p>
36
+ </div>
37
+ {% endif %}
38
+ </div>
39
+
40
+ <div class="flex flex-col gap-3 border-t border-gray-100 pt-4 text-sm text-gray-600 sm:flex-row sm:items-center sm:justify-between">
41
+ <div>
42
+ {% if page.total_items > 0 %}
43
+ 显示第 {{ page.start_item }} - {{ page.end_item }} 条,共 {{ page.total_items }} 个 Token
44
+ {% else %}
45
+ 暂无 Token 数据
46
+ {% endif %}
47
+ </div>
48
+ <div class="flex items-center gap-2">
49
+ <button
50
+ type="button"
51
+ onclick="loadTokenListPage({{ page.previous_page }})"
52
+ class="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium transition-colors {% if page.has_previous %}bg-white text-gray-700 hover:bg-gray-50{% else %}cursor-not-allowed bg-gray-100 text-gray-400{% endif %}"
53
+ {% if not page.has_previous %}disabled{% endif %}>
54
+ 上一页
55
+ </button>
56
+ <span class="min-w-[88px] text-center text-xs text-gray-500">
57
+ 第 {{ page.current_page }} / {{ page.total_pages }} 页
58
+ </span>
59
+ <button
60
+ type="button"
61
+ onclick="loadTokenListPage({{ page.next_page }})"
62
+ class="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium transition-colors {% if page.has_next %}bg-white text-gray-700 hover:bg-gray-50{% else %}cursor-not-allowed bg-gray-100 text-gray-400{% endif %}"
63
+ {% if not page.has_next %}disabled{% endif %}>
64
+ 下一页
65
+ </button>
66
+ </div>
67
+ </div>
68
+ </div>
69
+
70
+ <!-- Token 数量统计(更新页面标题) -->
71
+ <script>
72
+ (function() {
73
+ const tokenCount = {{ page.total_items }};
74
+ const countElement = document.getElementById('token-count');
75
+ if (countElement) {
76
+ countElement.textContent = `(共 ${tokenCount} 个)`;
77
+ }
78
+ })();
79
+ </script>
80
+
81
+ <!-- 复制到剪贴板函数 -->
82
+ <script>
83
+ function copyToClipboard(text) {
84
+ if (navigator.clipboard && navigator.clipboard.writeText) {
85
+ navigator.clipboard.writeText(text).then(() => {
86
+ const notification = document.createElement('div');
87
+ notification.className = 'fixed bottom-4 right-4 bg-green-500 text-white px-4 py-2 rounded shadow-lg z-50';
88
+ notification.textContent = '✓ Token 已复制到剪贴板';
89
+ document.body.appendChild(notification);
90
+
91
+ setTimeout(() => {
92
+ notification.remove();
93
+ }, 2000);
94
+ }).catch(err => {
95
+ console.error('复制失败:', err);
96
+ alert('复制失败,请手动复制');
97
+ });
98
+ } else {
99
+ const textArea = document.createElement('textarea');
100
+ textArea.value = text;
101
+ textArea.style.position = 'fixed';
102
+ textArea.style.left = '-999999px';
103
+ document.body.appendChild(textArea);
104
+ textArea.select();
105
+ try {
106
+ document.execCommand('copy');
107
+ alert('Token 已复制到剪贴板');
108
+ } catch (err) {
109
+ alert('复制失败,请手动复制');
110
+ }
111
+ document.body.removeChild(textArea);
112
+ }
113
+ }
114
+ </script>
app/templates/components/token_pool.html ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!-- Token 池状态卡片 -->
2
+ <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
3
+ {% for token in tokens %}
4
+ <div class="border rounded-lg p-4 hover:shadow-lg transition-shadow">
5
+ <div class="flex items-center justify-between mb-2">
6
+ <span class="text-sm font-medium text-gray-700">Token #{{ token.index }}</span>
7
+ <span class="px-2 py-1 text-xs font-semibold rounded-full {{ token.status_color }}">
8
+ {{ token.status }}
9
+ </span>
10
+ </div>
11
+ <div class="space-y-1 text-sm text-gray-600">
12
+ <div class="truncate">
13
+ <span class="font-mono text-xs bg-gray-100 px-2 py-1 rounded">{{ token.key }}</span>
14
+ </div>
15
+ <div>类型:
16
+ {% if token.token_type == 'user' %}
17
+ <span class="text-green-600 font-semibold">认证用户</span>
18
+ {% elif token.token_type == 'guest' %}
19
+ <span class="text-yellow-600 font-semibold">匿名用户</span>
20
+ {% else %}
21
+ <span class="text-gray-600">未知</span>
22
+ {% endif %}
23
+ </div>
24
+ <div>成功率: <span class="font-medium">{{ token.success_rate }}</span></div>
25
+ <div>失败次数: <span class="font-medium">{{ token.failure_count }}</span></div>
26
+ <div class="text-xs text-gray-500">最后使用: {{ token.last_used }}</div>
27
+ </div>
28
+ </div>
29
+ {% endfor %}
30
+
31
+ {% if not tokens %}
32
+ <div class="col-span-full text-center py-8 text-gray-500">
33
+ <svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
34
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
35
+ </svg>
36
+ <p class="mt-2">暂无 Token 配置</p>
37
+ <p class="mt-1 text-sm">请在配置管理页面添加 Token</p>
38
+ </div>
39
+ {% endif %}
40
+ </div>
app/templates/components/token_row.html ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!-- 单个 Token 行模板 -->
2
+ {% set success_rate = (token.successful_requests / token.total_requests * 100) if token.total_requests else 0 %}
3
+ {% set is_healthy = (token.token_type == 'user' and token.is_enabled and (success_rate >= 50 or token.total_requests <= 3)) %}
4
+ <tr class="hover:bg-gray-50 transition-colors" id="token-row-{{ token.id }}">
5
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 font-medium">
6
+ {{ token.id }}
7
+ </td>
8
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
9
+ <div class="flex items-center space-x-2">
10
+ <span class="font-mono text-xs bg-gray-100 px-2 py-1 rounded">
11
+ {{ token.token[:30] }}...
12
+ </span>
13
+ <button onclick="copyToClipboard('{{ token.token }}')"
14
+ class="text-gray-400 hover:text-indigo-600 transition-colors"
15
+ title="复制完整 Token">
16
+ <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
17
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
18
+ </svg>
19
+ </button>
20
+ </div>
21
+ </td>
22
+ <td class="px-6 py-4 whitespace-nowrap text-sm">
23
+ {% if token.token_type == 'user' %}
24
+ <span class="inline-flex items-center px-2.5 py-0.5 text-xs font-semibold rounded-full bg-green-100 text-green-800">
25
+ <svg class="h-3 w-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
26
+ <path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clip-rule="evenodd" />
27
+ </svg>
28
+ 认证用户
29
+ </span>
30
+ {% elif token.token_type == 'guest' %}
31
+ <span class="inline-flex items-center px-2.5 py-0.5 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800">
32
+ <svg class="h-3 w-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
33
+ <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
34
+ </svg>
35
+ 匿名用户
36
+ </span>
37
+ {% else %}
38
+ <span class="inline-flex items-center px-2.5 py-0.5 text-xs font-semibold rounded-full bg-gray-100 text-gray-800">
39
+ <svg class="h-3 w-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
40
+ <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
41
+ </svg>
42
+ 未知
43
+ </span>
44
+ {% endif %}
45
+ </td>
46
+ <td class="px-6 py-4 whitespace-nowrap text-sm">
47
+ <!-- 健康度指示器 -->
48
+ <div class="flex items-center space-x-2">
49
+ {% if is_healthy %}
50
+ <div class="flex items-center">
51
+ <svg class="h-5 w-5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
52
+ <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
53
+ </svg>
54
+ <span class="ml-1 text-green-700 font-medium">健康</span>
55
+ </div>
56
+ {% elif token.token_type == 'guest' %}
57
+ <div class="flex items-center">
58
+ <svg class="h-5 w-5 text-yellow-500" fill="currentColor" viewBox="0 0 20 20">
59
+ <path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
60
+ </svg>
61
+ <span class="ml-1 text-yellow-700 font-medium">匿名</span>
62
+ </div>
63
+ {% elif not token.is_enabled %}
64
+ <div class="flex items-center">
65
+ <svg class="h-5 w-5 text-gray-500" fill="currentColor" viewBox="0 0 20 20">
66
+ <path fill-rule="evenodd" d="M13.477 14.89A6 6 0 015.11 6.524l8.367 8.368zm1.414-1.414L6.524 5.11a6 6 0 018.367 8.367zM18 10a8 8 0 11-16 0 8 8 0 0116 0z" clip-rule="evenodd" />
67
+ </svg>
68
+ <span class="ml-1 text-gray-700 font-medium">已禁用</span>
69
+ </div>
70
+ {% else %}
71
+ <div class="flex items-center">
72
+ <svg class="h-5 w-5 text-red-500" fill="currentColor" viewBox="0 0 20 20">
73
+ <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
74
+ </svg>
75
+ <span class="ml-1 text-red-700 font-medium">不健康</span>
76
+ </div>
77
+ {% endif %}
78
+ </div>
79
+ </td>
80
+ <td class="px-6 py-4 whitespace-nowrap text-sm">
81
+ <button hx-post="/admin/api/tokens/toggle/{{ token.id }}?enabled={{ 'false' if token.is_enabled else 'true' }}"
82
+ hx-swap="outerHTML"
83
+ class="inline-flex items-center px-2.5 py-0.5 text-xs font-semibold rounded-full transition-colors {{ 'bg-green-100 text-green-800 hover:bg-green-200' if token.is_enabled else 'bg-red-100 text-red-800 hover:bg-red-200' }}">
84
+ <span class="h-2 w-2 rounded-full mr-1.5 {{ 'bg-green-500' if token.is_enabled else 'bg-red-500' }}"></span>
85
+ {{ '已启用' if token.is_enabled else '已禁用' }}
86
+ </button>
87
+ </td>
88
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
89
+ {% if token.total_requests %}
90
+ <div class="space-y-1">
91
+ <div class="flex items-center justify-between">
92
+ <span class="text-xs text-gray-600">成功:</span>
93
+ <span class="font-medium text-green-600">{{ token.successful_requests }}</span>
94
+ </div>
95
+ <div class="flex items-center justify-between">
96
+ <span class="text-xs text-gray-600">失败:</span>
97
+ <span class="font-medium text-red-600">{{ token.failed_requests }}</span>
98
+ </div>
99
+ <div class="flex items-center justify-between">
100
+ <span class="text-xs text-gray-600">成功率:</span>
101
+ <span class="font-medium {{ 'text-green-600' if success_rate >= 50 else 'text-red-600' }}">
102
+ {{ "%.1f"|format(success_rate) }}%
103
+ </span>
104
+ </div>
105
+ <!-- 成功率进度条 -->
106
+ <div class="w-full bg-gray-200 rounded-full h-1.5 mt-1">
107
+ <div class="h-1.5 rounded-full transition-all {{ 'bg-green-500' if success_rate >= 50 else 'bg-red-500' }}"
108
+ style="width: {{ success_rate }}%"></div>
109
+ </div>
110
+ </div>
111
+ {% else %}
112
+ <span class="text-gray-400 text-xs">未使用</span>
113
+ {% endif %}
114
+ </td>
115
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
116
+ <div class="flex flex-col space-y-1">
117
+ <span class="text-xs">{{ token.created_at[:10] if token.created_at else 'N/A' }}</span>
118
+ <span class="text-xs text-gray-400">{{ token.created_at[11:19] if token.created_at else '' }}</span>
119
+ </div>
120
+ </td>
121
+ <td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
122
+ <div class="flex items-center space-x-3">
123
+ <!-- 验证按钮 -->
124
+ <button hx-post="/admin/api/tokens/validate-single/{{ token.id }}"
125
+ hx-target="#token-row-{{ token.id }}"
126
+ hx-swap="outerHTML"
127
+ hx-indicator="#validate-spinner-{{ token.id }}"
128
+ class="text-blue-600 hover:text-blue-900 transition-colors relative validate-token-btn"
129
+ title="验证 Token"
130
+ data-token-id="{{ token.id }}">
131
+ <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
132
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
133
+ </svg>
134
+ <!-- 加载指示器 -->
135
+ <svg id="validate-spinner-{{ token.id }}" class="htmx-indicator absolute inset-0 h-4 w-4 animate-spin text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
136
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
137
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
138
+ </svg>
139
+ </button>
140
+ <!-- 删除按钮 -->
141
+ <button hx-delete="/admin/api/tokens/delete/{{ token.id }}"
142
+ hx-target="#token-row-{{ token.id }}"
143
+ hx-swap="outerHTML swap:1s"
144
+ hx-on::after-request="if (event.detail.successful) { htmx.trigger(document.body, 'tokenListRefresh'); htmx.trigger(document.body, 'statsRefresh'); }"
145
+ hx-confirm="确定要删除这个 Token 吗?"
146
+ class="text-red-600 hover:text-red-900 transition-colors"
147
+ title="删除 Token">
148
+ <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
149
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
150
+ </svg>
151
+ </button>
152
+ </div>
153
+ </td>
154
+ </tr>
app/templates/components/token_stats.html ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!-- Token 统计面板 -->
2
+ <div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
3
+ <!-- 总数 -->
4
+ <div class="bg-white overflow-hidden shadow rounded-lg">
5
+ <div class="p-5">
6
+ <div class="flex items-center">
7
+ <div class="flex-shrink-0">
8
+ <svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
9
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
10
+ </svg>
11
+ </div>
12
+ <div class="ml-5 w-0 flex-1">
13
+ <dl>
14
+ <dt class="text-sm font-medium text-gray-500 truncate">Token 总数</dt>
15
+ <dd class="text-2xl font-bold text-gray-900">{{ stats.total_tokens }}</dd>
16
+ </dl>
17
+ </div>
18
+ </div>
19
+ </div>
20
+ </div>
21
+
22
+ <!-- 已启用 -->
23
+ <div class="bg-white overflow-hidden shadow rounded-lg">
24
+ <div class="p-5">
25
+ <div class="flex items-center">
26
+ <div class="flex-shrink-0">
27
+ <svg class="h-6 w-6 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
28
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
29
+ </svg>
30
+ </div>
31
+ <div class="ml-5 w-0 flex-1">
32
+ <dl>
33
+ <dt class="text-sm font-medium text-gray-500 truncate">已启用</dt>
34
+ <dd class="flex items-baseline">
35
+ <div class="text-2xl font-bold text-green-600">{{ stats.enabled_tokens }}</div>
36
+ {% if stats.total_tokens > 0 %}
37
+ <div class="ml-2 flex items-baseline text-sm font-semibold text-green-600">
38
+ {{ "%.0f"|format(stats.enabled_tokens / stats.total_tokens * 100) }}%
39
+ </div>
40
+ {% endif %}
41
+ </dd>
42
+ </dl>
43
+ </div>
44
+ </div>
45
+ </div>
46
+ </div>
47
+
48
+ <!-- 认证用户 -->
49
+ <div class="bg-white overflow-hidden shadow rounded-lg">
50
+ <div class="p-5">
51
+ <div class="flex items-center">
52
+ <div class="flex-shrink-0">
53
+ <svg class="h-6 w-6 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
54
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
55
+ </svg>
56
+ </div>
57
+ <div class="ml-5 w-0 flex-1">
58
+ <dl>
59
+ <dt class="text-sm font-medium text-gray-500 truncate">认证用户</dt>
60
+ <dd class="flex items-baseline">
61
+ <div class="text-2xl font-bold text-blue-600">{{ stats.user_tokens }}</div>
62
+ {% if stats.guest_tokens > 0 %}
63
+ <div class="ml-2 flex items-baseline text-sm font-semibold text-yellow-600">
64
+ <svg class="h-4 w-4 mr-0.5" fill="currentColor" viewBox="0 0 20 20">
65
+ <path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
66
+ </svg>
67
+ {{ stats.guest_tokens }} 个匿名
68
+ </div>
69
+ {% endif %}
70
+ </dd>
71
+ </dl>
72
+ </div>
73
+ </div>
74
+ </div>
75
+ </div>
76
+
77
+ <!-- 成功率 -->
78
+ <div class="bg-white overflow-hidden shadow rounded-lg">
79
+ <div class="p-5">
80
+ <div class="flex items-center">
81
+ <div class="flex-shrink-0">
82
+ {% if stats.total_requests > 0 %}
83
+ {% set success_rate = (stats.successful_requests / stats.total_requests * 100) %}
84
+ {% if success_rate >= 80 %}
85
+ <svg class="h-6 w-6 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
86
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
87
+ </svg>
88
+ {% elif success_rate >= 50 %}
89
+ <svg class="h-6 w-6 text-yellow-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
90
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
91
+ </svg>
92
+ {% else %}
93
+ <svg class="h-6 w-6 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
94
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 17h8m0 0v-8m0 8l-8-8-4 4-6-6" />
95
+ </svg>
96
+ {% endif %}
97
+ {% else %}
98
+ <svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
99
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
100
+ </svg>
101
+ {% endif %}
102
+ </div>
103
+ <div class="ml-5 w-0 flex-1">
104
+ <dl>
105
+ <dt class="text-sm font-medium text-gray-500 truncate">总成功率</dt>
106
+ <dd>
107
+ {% if stats.total_requests > 0 %}
108
+ {% set success_rate = (stats.successful_requests / stats.total_requests * 100) %}
109
+ <div class="text-2xl font-bold {{ 'text-green-600' if success_rate >= 80 else ('text-yellow-600' if success_rate >= 50 else 'text-red-600') }}">
110
+ {{ "%.1f"|format(success_rate) }}%
111
+ </div>
112
+ <div class="mt-1 text-xs text-gray-500">
113
+ {{ stats.successful_requests }} / {{ stats.total_requests }} 请求
114
+ </div>
115
+ {% else %}
116
+ <div class="text-2xl font-bold text-gray-400">N/A</div>
117
+ <div class="mt-1 text-xs text-gray-500">暂无请求</div>
118
+ {% endif %}
119
+ </dd>
120
+ </dl>
121
+ </div>
122
+ </div>
123
+ </div>
124
+ </div>
125
+ </div>
app/templates/config.html ADDED
@@ -0,0 +1,344 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}配置管理{% endblock %}
4
+
5
+ {% block extra_head %}
6
+ <style>
7
+ [x-cloak] {
8
+ display: none !important;
9
+ }
10
+ .config-grid {
11
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
12
+ }
13
+ </style>
14
+ {% endblock %}
15
+
16
+ {% macro section_link(section) -%}
17
+ <button
18
+ type="button"
19
+ @click.prevent="document.getElementById('{{ section.id }}')?.scrollIntoView({ behavior: 'smooth', block: 'start' })"
20
+ class="inline-flex items-center rounded-full border border-slate-200 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 transition hover:border-indigo-300 hover:text-indigo-700">
21
+ {{ section.title }}
22
+ </button>
23
+ {%- endmacro %}
24
+
25
+ {% macro render_field(field) -%}
26
+ <div class="{{ 'md:col-span-2' if field.wide else '' }} rounded-2xl border border-slate-200 bg-slate-50/70 p-4 shadow-sm">
27
+ {% if field.value_type == 'bool' %}
28
+ <label class="flex min-h-[44px] cursor-pointer items-start gap-3">
29
+ <input
30
+ type="checkbox"
31
+ name="{{ field.key }}"
32
+ class="mt-1 h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
33
+ {{ 'checked' if field.value else '' }}>
34
+ <span class="flex-1">
35
+ <span class="block text-sm font-semibold text-slate-900">{{ field.label }}</span>
36
+ <span class="mt-1 block text-sm leading-6 text-slate-600">{{ field.description }}</span>
37
+ </span>
38
+ </label>
39
+ {% else %}
40
+ <div {% if field.sensitive %}x-data="{ reveal: false }"{% endif %}>
41
+ <div class="flex items-start justify-between gap-3">
42
+ <label for="{{ field.key }}" class="block text-sm font-semibold text-slate-900">
43
+ {{ field.label }}
44
+ </label>
45
+ {% if field.sensitive %}
46
+ <button
47
+ type="button"
48
+ @click="reveal = !reveal"
49
+ class="inline-flex items-center rounded-full border border-slate-200 bg-white px-2.5 py-1 text-xs font-medium text-slate-600 transition hover:border-indigo-300 hover:text-indigo-700">
50
+ <span x-text="reveal ? '隐藏' : '显示'"></span>
51
+ </button>
52
+ {% endif %}
53
+ </div>
54
+ <p class="mt-1 text-sm leading-6 text-slate-600">{{ field.description }}</p>
55
+ <input
56
+ id="{{ field.key }}"
57
+ name="{{ field.key }}"
58
+ {% if field.sensitive %}
59
+ :type="reveal ? 'text' : '{{ field.input_type }}'"
60
+ {% else %}
61
+ type="{{ field.input_type }}"
62
+ {% endif %}
63
+ value="{{ field.value }}"
64
+ placeholder="{{ field.placeholder }}"
65
+ {% if field.required %}required{% endif %}
66
+ {% if field.min_value is not none %}min="{{ field.min_value }}"{% endif %}
67
+ {% if field.max_value is not none %}max="{{ field.max_value }}"{% endif %}
68
+ class="mt-3 block w-full rounded-xl border-slate-300 bg-white px-3 py-2.5 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
69
+ </div>
70
+ {% endif %}
71
+
72
+ <div class="mt-3 flex flex-wrap gap-2 text-xs">
73
+ <span class="inline-flex items-center rounded-full px-2.5 py-1 font-medium ring-1 ring-inset {{ field.source_badge_class }}">
74
+ {{ field.source_label }}
75
+ </span>
76
+ {% if field.restart_required %}
77
+ <span class="inline-flex items-center rounded-full bg-amber-50 px-2.5 py-1 font-medium text-amber-700 ring-1 ring-inset ring-amber-200">
78
+ 需重启
79
+ </span>
80
+ {% endif %}
81
+ {% if field.sensitive %}
82
+ <span class="inline-flex items-center rounded-full bg-rose-50 px-2.5 py-1 font-medium text-rose-700 ring-1 ring-inset ring-rose-200">
83
+ 敏感字段
84
+ </span>
85
+ {% endif %}
86
+ </div>
87
+ </div>
88
+ {%- endmacro %}
89
+
90
+ {% block content %}
91
+ <div x-data="configPage()" class="space-y-6">
92
+ <div class="overflow-hidden rounded-3xl bg-slate-900 shadow-2xl">
93
+ <div class="bg-[radial-gradient(circle_at_top_right,_rgba(99,102,241,0.35),_transparent_35%),linear-gradient(135deg,_#0f172a,_#1e293b_55%,_#172554)] px-6 py-8 sm:px-8">
94
+ <div class="flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
95
+ <div class="max-w-3xl">
96
+ <p class="text-sm font-semibold uppercase tracking-[0.2em] text-indigo-200">Admin Config Center</p>
97
+ <h2 class="mt-3 text-3xl font-bold text-white sm:text-4xl">集中管理运行参数,并支持直接编辑 `.env` 源文件</h2>
98
+ <p class="mt-3 max-w-2xl text-sm leading-6 text-slate-300 sm:text-base">
99
+ 结构化表单适合日常操作,源文件模式适合批量调整、复制完整配置或保留注释。两种模式都会在保存后立即热重载。
100
+ </p>
101
+ </div>
102
+
103
+ <div class="flex flex-wrap items-center gap-3">
104
+ <button
105
+ type="button"
106
+ @click="setView('form')"
107
+ :class="activeView === 'form'
108
+ ? 'bg-white text-slate-900 shadow-lg'
109
+ : 'bg-white/10 text-white hover:bg-white/20'"
110
+ class="inline-flex min-h-[44px] items-center rounded-2xl px-4 py-2 text-sm font-semibold transition">
111
+ 结构化表单
112
+ </button>
113
+ <button
114
+ type="button"
115
+ @click="setView('source')"
116
+ :class="activeView === 'source'
117
+ ? 'bg-white text-slate-900 shadow-lg'
118
+ : 'bg-white/10 text-white hover:bg-white/20'"
119
+ class="inline-flex min-h-[44px] items-center rounded-2xl px-4 py-2 text-sm font-semibold transition">
120
+ 源文件编辑
121
+ </button>
122
+ <button
123
+ type="button"
124
+ hx-post="/admin/api/config/reset"
125
+ hx-target="#config-feedback"
126
+ hx-swap="innerHTML"
127
+ hx-confirm="确定要用 .env.example 覆盖当前 .env 吗?"
128
+ class="inline-flex min-h-[44px] items-center rounded-2xl border border-white/15 bg-white/5 px-4 py-2 text-sm font-semibold text-white transition hover:bg-white/15">
129
+ 重置为示例配置
130
+ </button>
131
+ </div>
132
+ </div>
133
+
134
+ <div class="config-grid mt-8 grid gap-3">
135
+ <div class="rounded-2xl border border-white/10 bg-white/10 p-4 backdrop-blur">
136
+ <p class="text-xs uppercase tracking-[0.2em] text-indigo-200">受管字段</p>
137
+ <p class="mt-2 text-3xl font-bold text-white">{{ overview.total_fields }}</p>
138
+ <p class="mt-1 text-sm text-slate-300">{{ overview.total_sections }} 个分组</p>
139
+ </div>
140
+ <div class="rounded-2xl border border-white/10 bg-white/10 p-4 backdrop-blur">
141
+ <p class="text-xs uppercase tracking-[0.2em] text-indigo-200">.env 覆写</p>
142
+ <p class="mt-2 text-3xl font-bold text-white">{{ overview.overridden_fields }}</p>
143
+ <p class="mt-1 text-sm text-slate-300">{{ overview.default_fields }} 个字段仍在使用默认值</p>
144
+ </div>
145
+ <div class="rounded-2xl border border-white/10 bg-white/10 p-4 backdrop-blur">
146
+ <p class="text-xs uppercase tracking-[0.2em] text-indigo-200">敏感字段</p>
147
+ <p class="mt-2 text-3xl font-bold text-white">{{ overview.sensitive_fields }}</p>
148
+ <p class="mt-1 text-sm text-slate-300">{{ overview.restart_required_fields }} 个字段修改后建议重启</p>
149
+ </div>
150
+ <div class="rounded-2xl border border-white/10 bg-white/10 p-4 backdrop-blur">
151
+ <p class="text-xs uppercase tracking-[0.2em] text-indigo-200">源文件状态</p>
152
+ <p class="mt-2 text-lg font-bold text-white">{{ '.env 已存在' if overview.env_exists else '.env 尚未创建' }}</p>
153
+ <p class="mt-1 text-sm text-slate-300">{{ overview.env_line_count }} 行,{{ '.env.example 可用' if overview.example_exists else '缺少 .env.example' }}</p>
154
+ </div>
155
+ </div>
156
+ </div>
157
+ </div>
158
+
159
+ <div id="config-feedback"></div>
160
+
161
+ {% if not overview.env_exists %}
162
+ <div class="rounded-2xl border border-amber-200 bg-amber-50 px-5 py-4 text-sm text-amber-900">
163
+ 当前工作目录中尚未找到 `.env` 文件。你可以直接保存表单或源文件,系统会自动创建它。
164
+ </div>
165
+ {% endif %}
166
+
167
+ <div class="grid gap-6 xl:grid-cols-[280px,minmax(0,1fr)]">
168
+ <aside class="space-y-4">
169
+ <div class="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200">
170
+ <div class="flex items-center justify-between">
171
+ <h3 class="text-base font-semibold text-slate-900">工作区</h3>
172
+ </div>
173
+ <div class="mt-3 rounded-2xl bg-slate-100 px-3 py-2 text-[11px] leading-5 text-slate-600 break-all">
174
+ {{ overview.env_path }}
175
+ </div>
176
+ <div class="mt-4 space-y-3 text-sm text-slate-600">
177
+ <p>表单模式会只更新受管字段,并保留 `.env` 中未知配置和注释顺序。</p>
178
+ <p>源文件模式会直接覆盖整个 `.env`,适合批量调整和粘贴完整配置。</p>
179
+ </div>
180
+ </div>
181
+
182
+ <div x-show="activeView === 'form'" x-cloak class="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200">
183
+ <h3 class="text-base font-semibold text-slate-900">分组导航</h3>
184
+ <div class="mt-4 flex flex-wrap gap-2">
185
+ {% for section in sections %}
186
+ {{ section_link(section) }}
187
+ {% endfor %}
188
+ </div>
189
+ </div>
190
+
191
+ <div x-show="activeView === 'source'" x-cloak class="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200">
192
+ <h3 class="text-base font-semibold text-slate-900">源文件提示</h3>
193
+ <div class="mt-4 space-y-3 text-sm leading-6 text-slate-600">
194
+ <p>每一行应遵循 `KEY=VALUE` 格式,空行和以 `#` 开头的注释会被保留。</p>
195
+ <p>直接修改源文件时,结构化表单不会实时同步,保存后页面会自动刷新到当前视图。</p>
196
+ <p>如果你需要保留完整注释、顺序或复制整套配置,优先使用这一模式。</p>
197
+ </div>
198
+ </div>
199
+
200
+ <div class="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200">
201
+ <h3 class="text-base font-semibold text-slate-900">热重载说明</h3>
202
+ <div class="mt-4 space-y-3 text-sm leading-6 text-slate-600">
203
+ <p>大多数字段保存后会立即生效。</p>
204
+ <p>标记为“需重启”的字段涉及监听端口、进程名称或底层持久化路径,建议手动重启服务。</p>
205
+ <p>修改后台密码或会话密钥后,建议重新登录后台验证新配置已生效。</p>
206
+ </div>
207
+ </div>
208
+ </aside>
209
+
210
+ <div class="space-y-6">
211
+ <form
212
+ x-show="activeView === 'form'"
213
+ x-cloak
214
+ hx-post="/admin/api/config/save"
215
+ hx-target="#config-feedback"
216
+ hx-swap="innerHTML"
217
+ class="space-y-6">
218
+ {% for section in sections %}
219
+ <section id="{{ section.id }}" class="overflow-hidden rounded-3xl bg-white shadow-sm ring-1 ring-slate-200">
220
+ <div class="border-b border-slate-200 bg-slate-50/70 px-6 py-5">
221
+ <div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
222
+ <div>
223
+ <h3 class="text-lg font-semibold text-slate-900">{{ section.title }}</h3>
224
+ <p class="mt-1 text-sm leading-6 text-slate-600">{{ section.description }}</p>
225
+ </div>
226
+ <span class="inline-flex items-center rounded-full bg-slate-900 px-3 py-1 text-xs font-semibold text-white">
227
+ {{ section.field_count }} 个字段
228
+ </span>
229
+ </div>
230
+ </div>
231
+ <div class="grid gap-4 p-6 md:grid-cols-2">
232
+ {% for field in section.fields %}
233
+ {{ render_field(field) }}
234
+ {% endfor %}
235
+ </div>
236
+ </section>
237
+ {% endfor %}
238
+
239
+ <div class="sticky bottom-4 z-10 rounded-3xl border border-slate-200 bg-white/95 p-4 shadow-2xl backdrop-blur">
240
+ <div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
241
+ <p class="text-sm text-slate-600">
242
+ 表单模式会保留未知配置,只更新当前页面管理的字段。
243
+ </p>
244
+ <div class="flex flex-wrap gap-3">
245
+ <button
246
+ type="button"
247
+ @click="setView('source')"
248
+ class="inline-flex min-h-[44px] items-center rounded-2xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 transition hover:border-indigo-300 hover:text-indigo-700">
249
+ 切换到源文件编辑
250
+ </button>
251
+ <button
252
+ type="submit"
253
+ class="inline-flex min-h-[44px] items-center rounded-2xl bg-indigo-600 px-5 py-2 text-sm font-semibold text-white shadow-lg shadow-indigo-600/20 transition hover:bg-indigo-700">
254
+ 保存表单并热重载
255
+ </button>
256
+ </div>
257
+ </div>
258
+ </div>
259
+ </form>
260
+
261
+ <form
262
+ x-show="activeView === 'source'"
263
+ x-cloak
264
+ hx-post="/admin/api/config/source"
265
+ hx-target="#config-feedback"
266
+ hx-swap="innerHTML"
267
+ class="space-y-6">
268
+ <section class="overflow-hidden rounded-3xl bg-white shadow-sm ring-1 ring-slate-200">
269
+ <div class="border-b border-slate-200 bg-slate-50/70 px-6 py-5">
270
+ <div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
271
+ <div>
272
+ <h3 class="text-lg font-semibold text-slate-900">`.env` 源文件编辑器</h3>
273
+ <p class="mt-1 text-sm leading-6 text-slate-600">
274
+ 直接编辑源文件内容。该模式会原样覆盖整个 `.env`,适合保留注释、批量调整和整体替换。
275
+ </p>
276
+ </div>
277
+ <span class="inline-flex max-w-full items-center rounded-2xl bg-slate-100 px-3 py-2 text-xs font-medium leading-5 text-slate-700 break-all">
278
+ {{ overview.env_path }}
279
+ </span>
280
+ </div>
281
+ </div>
282
+ <div class="space-y-4 p-6">
283
+ <div class="rounded-2xl border border-indigo-100 bg-indigo-50 px-4 py-3 text-sm leading-6 text-indigo-900">
284
+ 保存前会进行基础语法检查,确保每一行是合法的 `KEY=VALUE` 结构;如果热重载失败,系统会自动回滚到原来的 `.env`。
285
+ </div>
286
+ <textarea
287
+ name="env_content"
288
+ rows="28"
289
+ spellcheck="false"
290
+ class="block min-h-[560px] w-full rounded-2xl border border-slate-300 bg-slate-950 px-4 py-4 font-mono text-sm leading-6 text-slate-100 shadow-inner focus:border-indigo-500 focus:ring-indigo-500">{{ env_content }}</textarea>
291
+ </div>
292
+ </section>
293
+
294
+ <div class="sticky bottom-4 z-10 rounded-3xl border border-slate-200 bg-white/95 p-4 shadow-2xl backdrop-blur">
295
+ <div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
296
+ <p class="text-sm text-slate-600">
297
+ 源文件模式会直接覆盖整个 `.env`。保存后会自动刷新页面并保留当前视图。
298
+ </p>
299
+ <div class="flex flex-wrap gap-3">
300
+ <button
301
+ type="button"
302
+ @click="setView('form')"
303
+ class="inline-flex min-h-[44px] items-center rounded-2xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 transition hover:border-indigo-300 hover:text-indigo-700">
304
+ 返回表单视图
305
+ </button>
306
+ <button
307
+ type="submit"
308
+ class="inline-flex min-h-[44px] items-center rounded-2xl bg-slate-900 px-5 py-2 text-sm font-semibold text-white shadow-lg transition hover:bg-slate-800">
309
+ 保存源文件并热重载
310
+ </button>
311
+ </div>
312
+ </div>
313
+ </div>
314
+ </form>
315
+ </div>
316
+ </div>
317
+ </div>
318
+ {% endblock %}
319
+
320
+ {% block extra_scripts %}
321
+ <script>
322
+ function configPage() {
323
+ return {
324
+ activeView: localStorage.getItem('admin-config-active-view') || 'form',
325
+ setView(view) {
326
+ this.activeView = view;
327
+ localStorage.setItem('admin-config-active-view', view);
328
+ }
329
+ };
330
+ }
331
+
332
+ document.body.addEventListener('admin-config-refresh', () => {
333
+ setTimeout(() => {
334
+ window.location.reload();
335
+ }, 450);
336
+ });
337
+
338
+ document.body.addEventListener('htmx:afterSwap', function(evt) {
339
+ if (evt.detail.target.id === 'config-feedback') {
340
+ window.scrollTo({ top: 0, behavior: 'smooth' });
341
+ }
342
+ });
343
+ </script>
344
+ {% endblock %}
app/templates/index.html ADDED
@@ -0,0 +1,588 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}仪表盘{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="space-y-6">
7
+ <div class="overflow-hidden rounded-3xl bg-slate-900 shadow-2xl">
8
+ <div class="bg-[radial-gradient(circle_at_top_right,_rgba(45,212,191,0.22),_transparent_30%),radial-gradient(circle_at_left,_rgba(59,130,246,0.18),_transparent_28%),linear-gradient(135deg,_#0f172a,_#111827_58%,_#1e293b)] px-6 py-7 sm:px-8">
9
+ <div class="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
10
+ <div class="max-w-3xl">
11
+ <p class="text-sm font-semibold uppercase tracking-[0.2em] text-cyan-200">Usage Dashboard</p>
12
+ <h2 class="mt-3 text-3xl font-bold text-white sm:text-4xl">查看请求消耗、缓存效果、延迟表现和使用趋势</h2>
13
+ <p class="mt-3 text-sm leading-6 text-slate-300 sm:text-base">
14
+ 统计来源于请求日志数据库,覆盖输入输出 Token、缓存创建与命中、成功率、平均延迟,以及最近 24 小时、7 天、30 天的使用趋势。
15
+ </p>
16
+ </div>
17
+ <div class="rounded-2xl border border-white/10 bg-white/10 px-5 py-4 backdrop-blur">
18
+ <p class="text-xs uppercase tracking-[0.2em] text-cyan-200">Last Update</p>
19
+ <p id="last-update" class="mt-2 text-lg font-semibold text-white">{{ current_time }}</p>
20
+ <p class="mt-1 text-sm text-slate-300">运行时间 {{ stats.uptime }}</p>
21
+ </div>
22
+ </div>
23
+ </div>
24
+ </div>
25
+
26
+ <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
27
+ <div class="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200">
28
+ <div class="flex items-start justify-between gap-4">
29
+ <div>
30
+ <p class="text-sm font-medium text-slate-500">总请求数</p>
31
+ <p class="mt-2 text-3xl font-bold text-slate-900">{{ stats.total_requests }}</p>
32
+ </div>
33
+ <span class="rounded-2xl bg-slate-100 p-3 text-slate-600">
34
+ <svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
35
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
36
+ </svg>
37
+ </span>
38
+ </div>
39
+ <p class="mt-3 text-sm text-slate-500">成功 {{ stats.successful_requests }} / 失败 {{ stats.failed_requests }}</p>
40
+ </div>
41
+
42
+ <div class="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200">
43
+ <div class="flex items-start justify-between gap-4">
44
+ <div>
45
+ <p class="text-sm font-medium text-slate-500">总消耗 Token 数</p>
46
+ <p class="mt-2 text-3xl font-bold text-slate-900">{{ stats.total_consumed_tokens_display }}</p>
47
+ </div>
48
+ <span class="rounded-2xl bg-emerald-50 p-3 text-emerald-600">
49
+ <svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
50
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 1.343-3 3v6h6v-6c0-1.657-1.343-3-3-3zm0 0V5m0 3a4 4 0 00-4 4v5h8v-5a4 4 0 00-4-4z" />
51
+ </svg>
52
+ </span>
53
+ </div>
54
+ <p class="mt-3 text-sm text-slate-500">累计 {{ stats.total_consumed_tokens }} Tokens</p>
55
+ </div>
56
+
57
+ <div class="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200">
58
+ <div class="flex items-start justify-between gap-4">
59
+ <div>
60
+ <p class="text-sm font-medium text-slate-500">缓存 Token</p>
61
+ <p class="mt-2 text-3xl font-bold text-slate-900">{{ stats.total_cache_tokens_display }}</p>
62
+ </div>
63
+ <span class="rounded-2xl bg-amber-50 p-3 text-amber-600">
64
+ <svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
65
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7h16M4 12h10m-10 5h16" />
66
+ </svg>
67
+ </span>
68
+ </div>
69
+ <p class="mt-3 text-sm text-slate-500">创建 {{ stats.cache_creation_tokens }} / 命中 {{ stats.cache_read_tokens }}</p>
70
+ </div>
71
+
72
+ <div class="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200">
73
+ <div class="flex items-start justify-between gap-4">
74
+ <div>
75
+ <p class="text-sm font-medium text-slate-500">成功率</p>
76
+ <p class="mt-2 text-3xl font-bold text-slate-900">{{ stats.success_rate }}%</p>
77
+ </div>
78
+ <span class="rounded-2xl bg-sky-50 p-3 text-sky-600">
79
+ <svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
80
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
81
+ </svg>
82
+ </span>
83
+ </div>
84
+ <p class="mt-3 text-sm text-slate-500">图表支持切换 24 小时 / 7 天 / 30 天</p>
85
+ </div>
86
+
87
+ <div class="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200">
88
+ <div class="flex items-start justify-between gap-4">
89
+ <div>
90
+ <p class="text-sm font-medium text-slate-500">输入 Token</p>
91
+ <p class="mt-2 text-3xl font-bold text-slate-900">{{ stats.input_tokens_display }}</p>
92
+ </div>
93
+ <span class="rounded-2xl bg-violet-50 p-3 text-violet-600">
94
+ <svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
95
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 12H4m0 0l6-6m-6 6l6 6" />
96
+ </svg>
97
+ </span>
98
+ </div>
99
+ <p class="mt-3 text-sm text-slate-500">累计 {{ stats.input_tokens }} Tokens</p>
100
+ </div>
101
+
102
+ <div class="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200">
103
+ <div class="flex items-start justify-between gap-4">
104
+ <div>
105
+ <p class="text-sm font-medium text-slate-500">输出 Token</p>
106
+ <p class="mt-2 text-3xl font-bold text-slate-900">{{ stats.output_tokens_display }}</p>
107
+ </div>
108
+ <span class="rounded-2xl bg-rose-50 p-3 text-rose-600">
109
+ <svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
110
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 12h16m0 0l-6-6m6 6l-6 6" />
111
+ </svg>
112
+ </span>
113
+ </div>
114
+ <p class="mt-3 text-sm text-slate-500">累计 {{ stats.output_tokens }} Tokens</p>
115
+ </div>
116
+
117
+ <div class="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200">
118
+ <div class="flex items-start justify-between gap-4">
119
+ <div>
120
+ <p class="text-sm font-medium text-slate-500">平均延迟</p>
121
+ <p class="mt-2 text-3xl font-bold text-slate-900">{{ "%.2f"|format(stats.average_latency) }}s</p>
122
+ </div>
123
+ <span class="rounded-2xl bg-orange-50 p-3 text-orange-600">
124
+ <svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
125
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
126
+ </svg>
127
+ </span>
128
+ </div>
129
+ <p class="mt-3 text-sm text-slate-500">平均首字延迟 {{ "%.2f"|format(stats.average_first_token_latency) }}s</p>
130
+ </div>
131
+
132
+ <div class="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200">
133
+ <div class="flex items-start justify-between gap-4">
134
+ <div>
135
+ <p class="text-sm font-medium text-slate-500">Token 池健康度</p>
136
+ <p class="mt-2 text-3xl font-bold text-slate-900">{{ stats.healthy_tokens }}/{{ stats.pool_total_tokens }}</p>
137
+ </div>
138
+ <span class="rounded-2xl {% if stats.pool_total_tokens == 0 %}bg-slate-100 text-slate-500{% elif stats.healthy_tokens >= stats.pool_total_tokens * 0.8 %}bg-emerald-50 text-emerald-600{% elif stats.healthy_tokens >= stats.pool_total_tokens * 0.5 %}bg-amber-50 text-amber-600{% else %}bg-red-50 text-red-600{% endif %} p-3">
139
+ <svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
140
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5 2a9 9 0 11-18 0 9 9 0 0118 0z" />
141
+ </svg>
142
+ </span>
143
+ </div>
144
+ <p class="mt-3 text-sm text-slate-500">可用 {{ stats.available_tokens }} / 已启用 {{ stats.enabled_tokens }} / 认证 {{ stats.user_tokens }}</p>
145
+ </div>
146
+ </div>
147
+
148
+ <div class="grid grid-cols-1 gap-6 xl:grid-cols-3">
149
+ <section class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-slate-200 xl:col-span-2">
150
+ <div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
151
+ <div>
152
+ <h3 class="text-lg font-semibold text-slate-900">使用趋势图</h3>
153
+ <p
154
+ id="usage-trend-description"
155
+ class="mt-1 text-sm text-slate-600"
156
+ >
157
+ 最近 7 天按天聚合的请求量、输入输出与缓存变化。
158
+ </p>
159
+ </div>
160
+ <div
161
+ id="usage-trend-window-switcher"
162
+ class="flex flex-wrap gap-2"
163
+ >
164
+ {% set trend_window_options = trend_windows if trend_windows is defined and trend_windows else [
165
+ {'key': '24h', 'label': '24 小时'},
166
+ {'key': '7d', 'label': '7 天'},
167
+ {'key': '30d', 'label': '30 天'}
168
+ ] %}
169
+ {% for option in trend_window_options %}
170
+ <button
171
+ type="button"
172
+ data-trend-window="{{ option.key }}"
173
+ class="inline-flex items-center rounded-full border border-slate-200 bg-white px-3 py-1.5 text-sm font-medium text-slate-600 transition-colors hover:border-sky-300 hover:text-sky-700"
174
+ >
175
+ {{ option.label }}
176
+ </button>
177
+ {% endfor %}
178
+ </div>
179
+ </div>
180
+ <div class="mt-4 flex flex-wrap gap-2 text-xs">
181
+ <span class="inline-flex items-center rounded-full bg-slate-100 px-3 py-1 font-medium text-slate-600">蓝柱: 请求量</span>
182
+ <span class="inline-flex items-center rounded-full bg-violet-50 px-3 py-1 font-medium text-violet-700">紫线: 输入</span>
183
+ <span class="inline-flex items-center rounded-full bg-rose-50 px-3 py-1 font-medium text-rose-700">红线: 输出</span>
184
+ <span class="inline-flex items-center rounded-full bg-emerald-50 px-3 py-1 font-medium text-emerald-700">绿线: 缓存创建</span>
185
+ <span class="inline-flex items-center rounded-full bg-amber-50 px-3 py-1 font-medium text-amber-700">黄线: 缓存命中</span>
186
+ </div>
187
+ <div class="mt-6 h-[320px]">
188
+ <canvas id="usage-trend-chart"></canvas>
189
+ </div>
190
+ <p
191
+ id="usage-trend-error"
192
+ class="mt-3 hidden rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"
193
+ ></p>
194
+ </section>
195
+
196
+ <div class="space-y-6">
197
+ <section class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-slate-200">
198
+ <h3 class="text-lg font-semibold text-slate-900">缓存创建 / 命中</h3>
199
+ <p class="mt-1 text-sm text-slate-600">按请求次数和 Token 数量查看缓存是否真的生效。</p>
200
+ <div class="mt-5 grid grid-cols-1 gap-4">
201
+ <div class="rounded-2xl border border-emerald-100 bg-emerald-50 p-4">
202
+ <p class="text-sm font-medium text-emerald-700">缓存创建</p>
203
+ <p class="mt-2 text-2xl font-bold text-emerald-900">{{ stats.cache_creation_requests }}</p>
204
+ <p class="mt-1 text-sm text-emerald-700">共创建 {{ stats.cache_creation_tokens }} Tokens</p>
205
+ </div>
206
+ <div class="rounded-2xl border border-amber-100 bg-amber-50 p-4">
207
+ <p class="text-sm font-medium text-amber-700">缓存命中</p>
208
+ <p class="mt-2 text-2xl font-bold text-amber-900">{{ stats.cache_hit_requests }}</p>
209
+ <p class="mt-1 text-sm text-amber-700">共命中 {{ stats.cache_read_tokens }} Tokens</p>
210
+ </div>
211
+ </div>
212
+ </section>
213
+
214
+ <section class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-slate-200">
215
+ <h3 class="text-lg font-semibold text-slate-900">输入 / 输出画像</h3>
216
+ <p class="mt-1 text-sm text-slate-600">对比 Prompt 与 Completion 的消耗分布。</p>
217
+ {% set usage_total = stats.input_tokens + stats.output_tokens %}
218
+ {% set input_ratio = (stats.input_tokens / usage_total * 100) if usage_total > 0 else 0 %}
219
+ {% set output_ratio = (stats.output_tokens / usage_total * 100) if usage_total > 0 else 0 %}
220
+ <div class="mt-5 space-y-4">
221
+ <div>
222
+ <div class="flex items-center justify-between text-sm">
223
+ <span class="font-medium text-slate-700">输入 Token</span>
224
+ <span class="text-slate-500">{{ "%.1f"|format(input_ratio) }}%</span>
225
+ </div>
226
+ <div class="mt-2 h-2 rounded-full bg-slate-100">
227
+ <div class="h-2 rounded-full bg-violet-500" style="width: {{ input_ratio }}%"></div>
228
+ </div>
229
+ <p class="mt-2 text-sm text-slate-500">{{ stats.input_tokens }} Tokens</p>
230
+ </div>
231
+ <div>
232
+ <div class="flex items-center justify-between text-sm">
233
+ <span class="font-medium text-slate-700">输出 Token</span>
234
+ <span class="text-slate-500">{{ "%.1f"|format(output_ratio) }}%</span>
235
+ </div>
236
+ <div class="mt-2 h-2 rounded-full bg-slate-100">
237
+ <div class="h-2 rounded-full bg-rose-500" style="width: {{ output_ratio }}%"></div>
238
+ </div>
239
+ <p class="mt-2 text-sm text-slate-500">{{ stats.output_tokens }} Tokens</p>
240
+ </div>
241
+ </div>
242
+ </section>
243
+ </div>
244
+ </div>
245
+
246
+ <div class="bg-white shadow rounded-lg">
247
+ <div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
248
+ <h3 class="text-lg font-medium text-gray-900">最近请求日志</h3>
249
+ <div class="flex items-center space-x-2">
250
+ <label class="flex items-center cursor-pointer">
251
+ <input id="recent-logs-auto-refresh" type="checkbox" checked class="form-checkbox h-4 w-4 text-indigo-600">
252
+ <span class="ml-2 text-sm text-gray-600">自动刷新</span>
253
+ </label>
254
+ </div>
255
+ </div>
256
+ <div class="p-6">
257
+ <div
258
+ id="recent-logs"
259
+ hx-get="/admin/api/recent-logs?page=1&page_size=12"
260
+ hx-trigger="load"
261
+ hx-swap="innerHTML">
262
+ <div class="flex justify-center items-center py-12">
263
+ <svg class="animate-spin h-8 w-8 text-indigo-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
264
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
265
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
266
+ </svg>
267
+ <span class="ml-3 text-gray-500">加载中...</span>
268
+ </div>
269
+ </div>
270
+ </div>
271
+ </div>
272
+ </div>
273
+ {% endblock %}
274
+
275
+ {% block extra_scripts %}
276
+ <script>
277
+ window.dashboardRecentLogsPage = 1;
278
+ window.dashboardRecentLogsPageSize = 12;
279
+ window.dashboardTrendWindow = {{ stats.get('trend_window', '7d') | tojson }};
280
+ window.dashboardTrendData = {{ stats.get('usage_trend', []) | tojson }};
281
+ window.dashboardTrendChart = null;
282
+
283
+ const trendWindowDescriptions = {
284
+ '24h': '最近 24 小时按小时聚合的请求量、输入输出与缓存变化。',
285
+ '7d': '最近 7 天按天聚合的请求量、输入输出与缓存变化。',
286
+ '30d': '最近 30 天按天聚合的请求量、输入输出与缓存变化。'
287
+ };
288
+
289
+ function loadRecentLogsPage(page) {
290
+ const nextPage = Math.max(1, Number(page) || 1);
291
+ window.dashboardRecentLogsPage = nextPage;
292
+ const url = `/admin/api/recent-logs?page=${nextPage}&page_size=${window.dashboardRecentLogsPageSize}`;
293
+ htmx.ajax('GET', url, {
294
+ target: '#recent-logs',
295
+ swap: 'innerHTML'
296
+ });
297
+ }
298
+
299
+ function updateTime() {
300
+ const now = new Date();
301
+ document.getElementById('last-update').textContent = now.toLocaleString('zh-CN');
302
+ }
303
+
304
+ function updateTrendDescription() {
305
+ const description = document.getElementById('usage-trend-description');
306
+ if (!description) {
307
+ return;
308
+ }
309
+ description.textContent = (
310
+ trendWindowDescriptions[window.dashboardTrendWindow]
311
+ || trendWindowDescriptions['7d']
312
+ );
313
+ }
314
+
315
+ function updateTrendWindowButtons() {
316
+ document
317
+ .querySelectorAll('[data-trend-window]')
318
+ .forEach((button) => {
319
+ const isActive = (
320
+ button.dataset.trendWindow === window.dashboardTrendWindow
321
+ );
322
+ button.disabled = false;
323
+ button.classList.toggle('bg-sky-600', isActive);
324
+ button.classList.toggle('border-sky-600', isActive);
325
+ button.classList.toggle('text-white', isActive);
326
+ button.classList.toggle('shadow-sm', isActive);
327
+ button.classList.toggle('bg-white', !isActive);
328
+ button.classList.toggle('border-slate-200', !isActive);
329
+ button.classList.toggle('text-slate-600', !isActive);
330
+ });
331
+ }
332
+
333
+ function setTrendLoadingState(isLoading) {
334
+ document
335
+ .querySelectorAll('[data-trend-window]')
336
+ .forEach((button) => {
337
+ button.disabled = isLoading;
338
+ button.classList.toggle('opacity-60', isLoading);
339
+ button.classList.toggle('cursor-not-allowed', isLoading);
340
+ });
341
+ }
342
+
343
+ function showTrendError(message) {
344
+ const errorBox = document.getElementById('usage-trend-error');
345
+ if (!errorBox) {
346
+ return;
347
+ }
348
+ if (!message) {
349
+ errorBox.classList.add('hidden');
350
+ errorBox.textContent = '';
351
+ return;
352
+ }
353
+ errorBox.textContent = message;
354
+ errorBox.classList.remove('hidden');
355
+ }
356
+
357
+ function renderUsageTrendChart() {
358
+ const canvas = document.getElementById('usage-trend-chart');
359
+ if (!canvas) {
360
+ return;
361
+ }
362
+
363
+ const usageTrend = window.dashboardTrendData || [];
364
+ const labels = usageTrend.map((item) => item.label);
365
+ const requestSeries = usageTrend.map((item) => item.total_requests);
366
+ const inputSeries = usageTrend.map((item) => item.input_tokens);
367
+ const outputSeries = usageTrend.map((item) => item.output_tokens);
368
+ const cacheCreationSeries = usageTrend.map(
369
+ (item) => item.cache_creation_tokens
370
+ );
371
+ const cacheReadSeries = usageTrend.map(
372
+ (item) => item.cache_read_tokens
373
+ );
374
+
375
+ if (window.dashboardTrendChart) {
376
+ window.dashboardTrendChart.destroy();
377
+ }
378
+
379
+ window.dashboardTrendChart = new Chart(canvas, {
380
+ type: 'bar',
381
+ data: {
382
+ labels,
383
+ datasets: [
384
+ {
385
+ type: 'bar',
386
+ label: '请求量',
387
+ data: requestSeries,
388
+ yAxisID: 'yRequests',
389
+ backgroundColor: 'rgba(59, 130, 246, 0.18)',
390
+ borderColor: 'rgba(59, 130, 246, 0.85)',
391
+ borderWidth: 1.5,
392
+ borderRadius: 10
393
+ },
394
+ {
395
+ type: 'line',
396
+ label: '输入 Token',
397
+ data: inputSeries,
398
+ yAxisID: 'yTokens',
399
+ borderColor: '#8b5cf6',
400
+ backgroundColor: 'rgba(139, 92, 246, 0.16)',
401
+ borderWidth: 3,
402
+ tension: 0.35,
403
+ pointRadius: 3,
404
+ pointHoverRadius: 5,
405
+ fill: false
406
+ },
407
+ {
408
+ type: 'line',
409
+ label: '输出 Token',
410
+ data: outputSeries,
411
+ yAxisID: 'yTokens',
412
+ borderColor: '#f43f5e',
413
+ backgroundColor: 'rgba(244, 63, 94, 0.16)',
414
+ borderWidth: 3,
415
+ tension: 0.35,
416
+ pointRadius: 3,
417
+ pointHoverRadius: 5,
418
+ fill: false
419
+ },
420
+ {
421
+ type: 'line',
422
+ label: '缓存创建',
423
+ data: cacheCreationSeries,
424
+ yAxisID: 'yTokens',
425
+ borderColor: '#10b981',
426
+ backgroundColor: 'rgba(16, 185, 129, 0.16)',
427
+ borderWidth: 2,
428
+ borderDash: [6, 4],
429
+ tension: 0.35,
430
+ pointRadius: 2,
431
+ pointHoverRadius: 4,
432
+ fill: false
433
+ },
434
+ {
435
+ type: 'line',
436
+ label: '缓存命中',
437
+ data: cacheReadSeries,
438
+ yAxisID: 'yTokens',
439
+ borderColor: '#f59e0b',
440
+ backgroundColor: 'rgba(245, 158, 11, 0.15)',
441
+ borderWidth: 2,
442
+ borderDash: [3, 4],
443
+ tension: 0.35,
444
+ pointRadius: 2,
445
+ pointHoverRadius: 4,
446
+ fill: false
447
+ }
448
+ ]
449
+ },
450
+ options: {
451
+ maintainAspectRatio: false,
452
+ interaction: {
453
+ mode: 'index',
454
+ intersect: false
455
+ },
456
+ plugins: {
457
+ legend: {
458
+ position: 'bottom',
459
+ labels: {
460
+ usePointStyle: true,
461
+ boxWidth: 10,
462
+ color: '#475569'
463
+ }
464
+ },
465
+ tooltip: {
466
+ callbacks: {
467
+ title(context) {
468
+ const index = context[0]?.dataIndex ?? 0;
469
+ return (
470
+ usageTrend[index]?.tooltip_label
471
+ || usageTrend[index]?.label
472
+ || ''
473
+ );
474
+ },
475
+ label(context) {
476
+ const value = Number(context.parsed.y || 0);
477
+ if (context.dataset.label === '请求量') {
478
+ return `${context.dataset.label}: ${value} 次`;
479
+ }
480
+ return (
481
+ `${context.dataset.label}: `
482
+ + `${value.toLocaleString('zh-CN')} Tokens`
483
+ );
484
+ }
485
+ }
486
+ }
487
+ },
488
+ scales: {
489
+ x: {
490
+ grid: {
491
+ display: false
492
+ }
493
+ },
494
+ yRequests: {
495
+ position: 'left',
496
+ beginAtZero: true,
497
+ ticks: {
498
+ precision: 0
499
+ },
500
+ grid: {
501
+ color: 'rgba(148, 163, 184, 0.14)'
502
+ }
503
+ },
504
+ yTokens: {
505
+ position: 'right',
506
+ beginAtZero: true,
507
+ grid: {
508
+ drawOnChartArea: false
509
+ },
510
+ ticks: {
511
+ callback(value) {
512
+ return Number(value).toLocaleString('zh-CN');
513
+ }
514
+ }
515
+ }
516
+ }
517
+ }
518
+ });
519
+
520
+ updateTrendDescription();
521
+ updateTrendWindowButtons();
522
+ }
523
+
524
+ async function loadUsageTrend(windowKey) {
525
+ const nextWindow = String(windowKey || '').trim();
526
+ if (!nextWindow) {
527
+ return;
528
+ }
529
+
530
+ setTrendLoadingState(true);
531
+ showTrendError('');
532
+
533
+ try {
534
+ const response = await fetch(
535
+ `/admin/api/dashboard/usage-trend?window=${encodeURIComponent(nextWindow)}`,
536
+ {
537
+ headers: {
538
+ 'X-Requested-With': 'XMLHttpRequest'
539
+ }
540
+ }
541
+ );
542
+
543
+ if (!response.ok) {
544
+ throw new Error(`HTTP ${response.status}`);
545
+ }
546
+
547
+ const payload = await response.json();
548
+ window.dashboardTrendWindow = payload.window || '7d';
549
+ window.dashboardTrendData = payload.points || [];
550
+ renderUsageTrendChart();
551
+ } catch (error) {
552
+ showTrendError('趋势图加载失败,请稍后重试。');
553
+ console.error('Failed to load dashboard trend:', error);
554
+ } finally {
555
+ setTrendLoadingState(false);
556
+ }
557
+ }
558
+
559
+ function initTrendWindowSwitcher() {
560
+ updateTrendDescription();
561
+ updateTrendWindowButtons();
562
+
563
+ document
564
+ .querySelectorAll('[data-trend-window]')
565
+ .forEach((button) => {
566
+ button.addEventListener('click', () => {
567
+ const nextWindow = button.dataset.trendWindow;
568
+ if (nextWindow === window.dashboardTrendWindow) {
569
+ return;
570
+ }
571
+ loadUsageTrend(nextWindow);
572
+ });
573
+ });
574
+ }
575
+
576
+ updateTime();
577
+ initTrendWindowSwitcher();
578
+ renderUsageTrendChart();
579
+ setInterval(updateTime, 1000);
580
+
581
+ setInterval(() => {
582
+ const checkbox = document.getElementById('recent-logs-auto-refresh');
583
+ if (checkbox && checkbox.checked) {
584
+ loadRecentLogsPage(window.dashboardRecentLogsPage || 1);
585
+ }
586
+ }, 3000);
587
+ </script>
588
+ {% endblock %}
app/templates/login.html ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN" class="h-full bg-gray-50">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>登录 - API 控制台</title>
7
+
8
+ <!-- Tailwind CSS (CDN) -->
9
+ <script src="https://cdn.tailwindcss.com"></script>
10
+
11
+ <!-- Alpine.js (CDN) -->
12
+ <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
13
+
14
+ <style>
15
+ .gradient-bg {
16
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
17
+ }
18
+ </style>
19
+ </head>
20
+ <body class="h-full">
21
+ <div class="min-h-full flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
22
+ <div class="max-w-md w-full space-y-8">
23
+ <!-- Logo 和标题 -->
24
+ <div>
25
+ <div class="mx-auto h-16 w-16 flex items-center justify-center rounded-full gradient-bg">
26
+ <svg class="h-10 w-10 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
27
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
28
+ </svg>
29
+ </div>
30
+ <h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
31
+ API 管理后台
32
+ </h2>
33
+ <p class="mt-2 text-center text-sm text-gray-600">
34
+ 请输入管理密码以继续
35
+ </p>
36
+ </div>
37
+
38
+ <!-- 登录表单 -->
39
+ <div class="mt-8 space-y-6"
40
+ x-data="{
41
+ password: '',
42
+ loading: false,
43
+ error: '',
44
+ async login() {
45
+ if (!this.password) {
46
+ this.error = '请输入密码';
47
+ return;
48
+ }
49
+
50
+ this.loading = true;
51
+ this.error = '';
52
+
53
+ try {
54
+ const response = await fetch('/admin/api/login', {
55
+ method: 'POST',
56
+ headers: {
57
+ 'Content-Type': 'application/json',
58
+ },
59
+ body: JSON.stringify({ password: this.password })
60
+ });
61
+
62
+ const data = await response.json();
63
+
64
+ if (response.ok && data.success) {
65
+ window.location.href = '/admin';
66
+ } else {
67
+ this.error = data.message || '密码错误,请重试';
68
+ }
69
+ } catch (err) {
70
+ this.error = '登录失败,请稍后重试';
71
+ } finally {
72
+ this.loading = false;
73
+ }
74
+ }
75
+ }">
76
+
77
+ <!-- 错误提示 -->
78
+ <div x-show="error"
79
+ x-transition
80
+ class="bg-red-50 border-l-4 border-red-400 p-4">
81
+ <div class="flex">
82
+ <div class="flex-shrink-0">
83
+ <svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
84
+ <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
85
+ </svg>
86
+ </div>
87
+ <div class="ml-3">
88
+ <p class="text-sm text-red-700" x-text="error"></p>
89
+ </div>
90
+ </div>
91
+ </div>
92
+
93
+ <!-- 登录表单 -->
94
+ <form @submit.prevent="login" class="mt-8 space-y-6">
95
+ <div class="rounded-md shadow-sm -space-y-px">
96
+ <div>
97
+ <label for="password" class="sr-only">密码</label>
98
+ <input
99
+ id="password"
100
+ name="password"
101
+ type="password"
102
+ autocomplete="current-password"
103
+ required
104
+ x-model="password"
105
+ class="appearance-none rounded-md relative block w-full px-3 py-3 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
106
+ placeholder="请输入管理密码"
107
+ @keydown.enter="login">
108
+ </div>
109
+ </div>
110
+
111
+ <div>
112
+ <button
113
+ type="submit"
114
+ :disabled="loading"
115
+ class="group relative w-full flex justify-center py-3 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
116
+ <span class="absolute left-0 inset-y-0 flex items-center pl-3">
117
+ <svg class="h-5 w-5 text-indigo-500 group-hover:text-indigo-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
118
+ <path fill-rule="evenodd" d="M10 1a4.5 4.5 0 00-4.5 4.5V9H5a2 2 0 00-2 2v6a2 2 0 002 2h10a2 2 0 002-2v-6a2 2 0 00-2-2h-.5V5.5A4.5 4.5 0 0010 1zm3 8V5.5a3 3 0 10-6 0V9h6z" clip-rule="evenodd" />
119
+ </svg>
120
+ </span>
121
+ <span x-show="!loading">登录</span>
122
+ <span x-show="loading" class="flex items-center">
123
+ <svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
124
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
125
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
126
+ </svg>
127
+ 登录中...
128
+ </span>
129
+ </button>
130
+ </div>
131
+ </form>
132
+
133
+ <!-- 提示信息 -->
134
+ <div class="text-center">
135
+ <p class="text-xs text-gray-500">
136
+ 默认密码:admin123(请在 .env 中修改 ADMIN_PASSWORD)
137
+ </p>
138
+ </div>
139
+ </div>
140
+ </div>
141
+ </div>
142
+ </body>
143
+ </html>
app/templates/logs.html ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}实时日志{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="space-y-6">
7
+ <div class="flex items-center justify-between">
8
+ <div>
9
+ <h2 class="text-3xl font-bold text-gray-900">实时日志</h2>
10
+ <p class="mt-1 text-sm text-gray-600">滚动查看服务当前输出的最新日志</p>
11
+ </div>
12
+ <div class="flex items-center space-x-3">
13
+ <button
14
+ onclick="window.location.reload()"
15
+ class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors">
16
+ 刷新页面
17
+ </button>
18
+ </div>
19
+ </div>
20
+
21
+ <div class="bg-white shadow rounded-lg">
22
+ <div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
23
+ <h3 class="text-lg font-medium text-gray-900">日志流</h3>
24
+ <div class="flex space-x-2">
25
+ <button
26
+ onclick="document.getElementById('live-logs').innerHTML = '<div class=\'text-center text-gray-500 py-4\'>日志已清空</div>'"
27
+ class="px-3 py-1 text-sm bg-gray-200 hover:bg-gray-300 rounded-md transition-colors">
28
+ 清空
29
+ </button>
30
+ </div>
31
+ </div>
32
+ <div class="p-6">
33
+ <div
34
+ id="live-logs"
35
+ hx-get="/admin/api/live-logs"
36
+ hx-trigger="load, every 3s"
37
+ hx-swap="innerHTML scroll:bottom"
38
+ class="bg-gray-900 text-gray-100 p-4 rounded-md font-mono text-sm overflow-y-auto"
39
+ style="max-height: 600px;">
40
+ <div class="flex justify-center items-center py-8">
41
+ <span class="text-gray-500">加载中...</span>
42
+ </div>
43
+ </div>
44
+ </div>
45
+ </div>
46
+ </div>
47
+ {% endblock %}
48
+
49
+ {% block extra_scripts %}
50
+ <script>
51
+ const logsContainer = document.getElementById('live-logs');
52
+
53
+ document.body.addEventListener('htmx:afterSwap', function(event) {
54
+ if (event.detail.target.id === 'live-logs') {
55
+ logsContainer.scrollTop = logsContainer.scrollHeight;
56
+ }
57
+ });
58
+ </script>
59
+ {% endblock %}
app/templates/tokens.html ADDED
@@ -0,0 +1,487 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}Token 管理{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="space-y-6" x-data='{
7
+ showAddModal: false,
8
+ showValidateModal: false,
9
+ newToken: "",
10
+ bulkTokens: "",
11
+ hasImportSourceDir: {{ "true" if automation.has_import_source_dir else "false" }},
12
+ hasMaintenanceActions: {{ "true" if automation.has_maintenance_actions else "false" }},
13
+ isImporting: false,
14
+ isRunningMaintenance: false,
15
+ isValidating: false
16
+ }'>
17
+ <!-- 页面标题 -->
18
+ <div class="flex items-center justify-between">
19
+ <div>
20
+ <h2 class="text-3xl font-bold text-gray-900">Token 管理</h2>
21
+ <p class="mt-1 text-sm text-gray-600">管理和维护当前服务使用的 Token</p>
22
+ </div>
23
+ <div class="flex flex-wrap gap-3">
24
+ <a href="{{ automation.config_url }}"
25
+ class="px-4 py-2 bg-slate-900 text-white rounded-md text-sm font-medium hover:bg-slate-800 flex items-center">
26
+ <svg class="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
27
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
28
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
29
+ </svg>
30
+ 打开配置中心
31
+ </a>
32
+ <button @click="showValidateModal = true"
33
+ class="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 flex items-center">
34
+ <svg class="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
35
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
36
+ </svg>
37
+ 批量验证
38
+ </button>
39
+ <button @click="showAddModal = true"
40
+ class="px-4 py-2 bg-indigo-600 text-white rounded-md text-sm font-medium hover:bg-indigo-700 flex items-center">
41
+ <svg class="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
42
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
43
+ </svg>
44
+ 添加 Token
45
+ </button>
46
+ </div>
47
+ </div>
48
+
49
+ <!-- 统计面板 -->
50
+ <div id="token-stats"
51
+ hx-get="/admin/api/tokens/stats"
52
+ hx-trigger="load, statsRefresh from:body"
53
+ hx-swap="innerHTML">
54
+ <!-- 加载中 -->
55
+ <div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
56
+ <div class="bg-white overflow-hidden shadow rounded-lg animate-pulse">
57
+ <div class="p-5">
58
+ <div class="h-4 bg-gray-200 rounded w-1/2 mb-2"></div>
59
+ <div class="h-8 bg-gray-200 rounded w-1/3"></div>
60
+ </div>
61
+ </div>
62
+ <div class="bg-white overflow-hidden shadow rounded-lg animate-pulse">
63
+ <div class="p-5">
64
+ <div class="h-4 bg-gray-200 rounded w-1/2 mb-2"></div>
65
+ <div class="h-8 bg-gray-200 rounded w-1/3"></div>
66
+ </div>
67
+ </div>
68
+ <div class="bg-white overflow-hidden shadow rounded-lg animate-pulse">
69
+ <div class="p-5">
70
+ <div class="h-4 bg-gray-200 rounded w-1/2 mb-2"></div>
71
+ <div class="h-8 bg-gray-200 rounded w-1/3"></div>
72
+ </div>
73
+ </div>
74
+ <div class="bg-white overflow-hidden shadow rounded-lg animate-pulse">
75
+ <div class="p-5">
76
+ <div class="h-4 bg-gray-200 rounded w-1/2 mb-2"></div>
77
+ <div class="h-8 bg-gray-200 rounded w-1/3"></div>
78
+ </div>
79
+ </div>
80
+ </div>
81
+ </div>
82
+
83
+ <!-- 自动目录导入与维护 -->
84
+ <div id="token-automation" class="grid grid-cols-1 gap-6 xl:grid-cols-2">
85
+ <section class="bg-white shadow rounded-lg p-6 space-y-5">
86
+ <div class="flex items-start justify-between gap-4">
87
+ <div>
88
+ <h3 class="text-lg font-medium text-gray-900">目录导入策略</h3>
89
+ <p class="mt-1 text-sm text-gray-600">
90
+ 配置入口已迁移到配置管理页。这里仅展示当前策略,并允许立即执行一次导入。
91
+ </p>
92
+ </div>
93
+ <span class="inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold {% if automation.import_enabled %}bg-emerald-100 text-emerald-700{% else %}bg-gray-100 text-gray-600{% endif %}">
94
+ {{ '定时已开启' if automation.import_enabled else '定时已关闭' }}
95
+ </span>
96
+ </div>
97
+
98
+ {% if automation.has_import_source_dir %}
99
+ <div class="rounded-lg border border-emerald-100 bg-emerald-50 p-4 text-sm text-emerald-700">
100
+ 手动导入会复用当前配置的目录和验证逻辑,重复 Token 会自动跳过。
101
+ </div>
102
+ {% else %}
103
+ <div class="rounded-lg border border-amber-100 bg-amber-50 p-4 text-sm text-amber-700">
104
+ 还没有配置导入目录,无法执行手动导入。请先到配置管理页设置 `TOKEN_AUTO_IMPORT_SOURCE_DIR`。
105
+ </div>
106
+ {% endif %}
107
+
108
+ <dl class="space-y-4 text-sm">
109
+ <div class="rounded-lg border border-gray-200 p-4">
110
+ <dt class="font-medium text-gray-700">Token 目录</dt>
111
+ <dd class="mt-2 rounded-md bg-gray-50 px-3 py-2 font-mono text-xs text-gray-700 break-all">
112
+ {{ automation.import_source_dir or '未配置' }}
113
+ </dd>
114
+ </div>
115
+ <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
116
+ <div class="rounded-lg border border-gray-200 p-4">
117
+ <dt class="font-medium text-gray-700">扫描间隔</dt>
118
+ <dd class="mt-2 text-gray-900">{{ automation.import_interval }} 秒</dd>
119
+ </div>
120
+ <div class="rounded-lg border border-gray-200 p-4">
121
+ <dt class="font-medium text-gray-700">配置位置</dt>
122
+ <dd class="mt-2 text-gray-900">配置管理 / Token 池策略</dd>
123
+ </div>
124
+ </div>
125
+ </dl>
126
+
127
+ <div class="flex flex-wrap gap-3 pt-2">
128
+ <a href="{{ automation.config_url }}"
129
+ class="px-4 py-2 border border-emerald-200 text-emerald-700 rounded-md text-sm font-medium hover:bg-emerald-50">
130
+ 去配置中心修改
131
+ </a>
132
+ <button type="button"
133
+ hx-post="/admin/api/tokens/import-directory"
134
+ hx-target="#notification"
135
+ @htmx:before-request="isImporting = true"
136
+ @htmx:after-request="isImporting = false; if ($event.detail.successful) { htmx.trigger('body', 'tokenListRefresh'); htmx.trigger('body', 'statsRefresh'); }"
137
+ :disabled="isImporting || !hasImportSourceDir"
138
+ class="px-4 py-2 bg-emerald-600 text-white rounded-md text-sm font-medium hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed">
139
+ 立即导入当前目录
140
+ </button>
141
+ </div>
142
+ </section>
143
+
144
+ <section class="bg-white shadow rounded-lg p-6 space-y-5">
145
+ <div class="flex items-start justify-between gap-4">
146
+ <div>
147
+ <h3 class="text-lg font-medium text-gray-900">自动维护策略</h3>
148
+ <p class="mt-1 text-sm text-gray-600">
149
+ 维护动作和定时间隔统一在配置管理页设置。这里仅执行当前已配置的维护策略。
150
+ </p>
151
+ </div>
152
+ <span class="inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold {% if automation.maintenance_enabled %}bg-blue-100 text-blue-700{% else %}bg-gray-100 text-gray-600{% endif %}">
153
+ {{ '定时已开启' if automation.maintenance_enabled else '定时已关闭' }}
154
+ </span>
155
+ </div>
156
+
157
+ {% if automation.has_maintenance_actions %}
158
+ <div class="rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-700">
159
+ 手动维护会按当前配置顺序执行去重、测活和失效清理,不再在本页单独维护另一套选项。
160
+ </div>
161
+ {% else %}
162
+ <div class="rounded-lg border border-amber-100 bg-amber-50 p-4 text-sm text-amber-700">
163
+ 当前没有配置任何维护动作。请先到配置管理页勾选至少一个维护动作。
164
+ </div>
165
+ {% endif %}
166
+
167
+ <dl class="space-y-4 text-sm">
168
+ <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
169
+ <div class="rounded-lg border border-gray-200 p-4">
170
+ <dt class="font-medium text-gray-700">维护间隔</dt>
171
+ <dd class="mt-2 text-gray-900">{{ automation.maintenance_interval }} 秒</dd>
172
+ </div>
173
+ <div class="rounded-lg border border-gray-200 p-4">
174
+ <dt class="font-medium text-gray-700">配置位置</dt>
175
+ <dd class="mt-2 text-gray-900">配置管理 / Token 池策略</dd>
176
+ </div>
177
+ </div>
178
+ <div class="rounded-lg border border-gray-200 p-4">
179
+ <dt class="font-medium text-gray-700">当前维护动作</dt>
180
+ <dd class="mt-2 flex flex-wrap gap-2">
181
+ {% if automation.maintenance_actions %}
182
+ {% for action in automation.maintenance_actions %}
183
+ <span class="inline-flex items-center rounded-full bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-700">{{ action }}</span>
184
+ {% endfor %}
185
+ {% else %}
186
+ <span class="text-gray-500">未配置</span>
187
+ {% endif %}
188
+ </dd>
189
+ </div>
190
+ </dl>
191
+
192
+ <div class="flex flex-wrap gap-3 pt-2">
193
+ <a href="{{ automation.config_url }}"
194
+ class="px-4 py-2 border border-blue-200 text-blue-700 rounded-md text-sm font-medium hover:bg-blue-50">
195
+ 去配置中心修改
196
+ </a>
197
+ <button type="button"
198
+ hx-post="/admin/api/tokens/maintenance/run"
199
+ hx-target="#notification"
200
+ @htmx:before-request="isRunningMaintenance = true"
201
+ @htmx:after-request="isRunningMaintenance = false; if ($event.detail.successful) { htmx.trigger('body', 'tokenListRefresh'); htmx.trigger('body', 'statsRefresh'); }"
202
+ :disabled="isRunningMaintenance || !hasMaintenanceActions"
203
+ class="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed">
204
+ 执行当前维护策略
205
+ </button>
206
+ </div>
207
+ </section>
208
+ </div>
209
+
210
+ <!-- Token 列表 -->
211
+ <div class="bg-white shadow rounded-lg">
212
+ <div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
213
+ <h3 class="text-lg font-medium text-gray-900 flex items-center">
214
+ Token 列表
215
+ <span class="ml-2 text-sm font-normal text-gray-500" id="token-count"></span>
216
+ </h3>
217
+ <div class="flex items-center space-x-2">
218
+ <button hx-post="/admin/api/tokens/sync-pool"
219
+ hx-target="#notification"
220
+ class="text-sm text-purple-600 hover:text-purple-700 flex items-center">
221
+ <svg class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
222
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
223
+ </svg>
224
+ 同步 Token 池
225
+ </button>
226
+ <button hx-post="/admin/api/tokens/health-check"
227
+ hx-target="#notification"
228
+ class="text-sm text-blue-600 hover:text-blue-700 flex items-center">
229
+ <svg class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
230
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
231
+ </svg>
232
+ 健康检查
233
+ </button>
234
+ </div>
235
+ </div>
236
+ <div id="token-list"
237
+ hx-get="/admin/api/tokens/list?page=1&page_size=20"
238
+ hx-trigger="load"
239
+ hx-swap="innerHTML">
240
+ <!-- Token 列表内容 -->
241
+ <div class="flex justify-center items-center py-12">
242
+ <svg class="animate-spin h-8 w-8 text-indigo-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
243
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
244
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
245
+ </svg>
246
+ </div>
247
+ </div>
248
+ </div>
249
+
250
+ <!-- 添加 Token 弹窗 -->
251
+ <div x-show="showAddModal"
252
+ x-transition:enter="transition ease-out duration-300"
253
+ x-transition:enter-start="opacity-0"
254
+ x-transition:enter-end="opacity-100"
255
+ class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50"
256
+ @click.self="showAddModal = false"
257
+ style="display: none;">
258
+ <div class="relative top-20 mx-auto p-5 border w-full max-w-2xl shadow-lg rounded-md bg-white">
259
+ <div class="flex items-center justify-between pb-3 border-b">
260
+ <h3 class="text-lg font-medium">添加 Token</h3>
261
+ <button @click="showAddModal = false" class="text-gray-400 hover:text-gray-600">
262
+ <svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
263
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
264
+ </svg>
265
+ </button>
266
+ </div>
267
+
268
+ <div class="mt-4 space-y-4">
269
+ <!-- 提示信息 -->
270
+ <div class="bg-blue-50 border-l-4 border-blue-400 p-4">
271
+ <div class="flex">
272
+ <div class="flex-shrink-0">
273
+ <svg class="h-5 w-5 text-blue-400" fill="currentColor" viewBox="0 0 20 20">
274
+ <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
275
+ </svg>
276
+ </div>
277
+ <div class="ml-3">
278
+ <p class="text-sm text-blue-700">
279
+ <strong>Token 验证:</strong>添加时将自动验证 Token 有效性,
280
+ <span class="font-semibold">匿名用户 Token (guest) 将被拒绝</span>。
281
+ </p>
282
+ </div>
283
+ </div>
284
+ </div>
285
+
286
+ <!-- 单个 Token -->
287
+ <div>
288
+ <label class="block text-sm font-medium text-gray-700">单个 Token</label>
289
+ <input type="text"
290
+ x-model="newToken"
291
+ placeholder="输入 Token(以 eyJ 开头的 JWT)"
292
+ class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
293
+ </div>
294
+
295
+ <!-- 批量导入 -->
296
+ <div>
297
+ <label class="block text-sm font-medium text-gray-700">批量导入(每行一个)</label>
298
+ <textarea x-model="bulkTokens"
299
+ rows="6"
300
+ placeholder="每行一个 Token,支持逗号分隔&#10;eyJhbGc...&#10;eyJhbGc...&#10;或: token1, token2, token3"
301
+ class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm font-mono text-xs"></textarea>
302
+ <p class="mt-1 text-sm text-gray-500">支持格式:每行一个 Token,或使用逗号分隔</p>
303
+ </div>
304
+
305
+ <!-- 提交按钮 -->
306
+ <div class="flex justify-end space-x-3 pt-4 border-t">
307
+ <button @click="showAddModal = false"
308
+ class="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50">
309
+ 取消
310
+ </button>
311
+ <button hx-post="/admin/api/tokens/add"
312
+ :hx-vals="JSON.stringify({
313
+ single_token: newToken,
314
+ bulk_tokens: bulkTokens
315
+ })"
316
+ hx-target="#notification"
317
+ @htmx:after-request="showAddModal = false; newToken = ''; bulkTokens = ''; htmx.trigger('body', 'tokenListRefresh'); htmx.trigger('body', 'statsRefresh')"
318
+ class="px-4 py-2 bg-indigo-600 text-white rounded-md text-sm font-medium hover:bg-indigo-700">
319
+ 添加
320
+ </button>
321
+ </div>
322
+ </div>
323
+ </div>
324
+ </div>
325
+
326
+ <!-- 批量验证弹窗 -->
327
+ <div x-show="showValidateModal"
328
+ x-transition:enter="transition ease-out duration-300"
329
+ x-transition:enter-start="opacity-0"
330
+ x-transition:enter-end="opacity-100"
331
+ class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50"
332
+ @click.self="showValidateModal = false"
333
+ style="display: none;">
334
+ <div class="relative top-20 mx-auto p-5 border w-full max-w-lg shadow-lg rounded-md bg-white">
335
+ <div class="flex items-center justify-between pb-3 border-b">
336
+ <h3 class="text-lg font-medium">批量验证 Token</h3>
337
+ <button @click="showValidateModal = false" class="text-gray-400 hover:text-gray-600">
338
+ <svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
339
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
340
+ </svg>
341
+ </button>
342
+ </div>
343
+
344
+ <div class="mt-4 space-y-4">
345
+ <!-- 警告信息 -->
346
+ <div class="bg-yellow-50 border-l-4 border-yellow-400 p-4">
347
+ <div class="flex">
348
+ <div class="flex-shrink-0">
349
+ <svg class="h-5 w-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
350
+ <path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
351
+ </svg>
352
+ </div>
353
+ <div class="ml-3">
354
+ <p class="text-sm text-yellow-700">
355
+ 将验证当前通道的所有 Token 的有效性。
356
+ <br>此操作可能需要较长时间,请耐心等待。
357
+ </p>
358
+ </div>
359
+ </div>
360
+ </div>
361
+
362
+ <!-- 验证说明 -->
363
+ <div class="text-sm text-gray-600 space-y-2">
364
+ <p><strong>验证内容:</strong></p>
365
+ <ul class="list-disc list-inside space-y-1 ml-4">
366
+ <li>检查 Token 是否有效</li>
367
+ <li>识别 Token 类型(认证用户 / 匿名用户)</li>
368
+ <li>更新数据库中的 Token 类型</li>
369
+ <li>匿名用户 Token 将被标记为不健康</li>
370
+ </ul>
371
+ </div>
372
+
373
+ <!-- 进度显示 -->
374
+ <div id="validate-progress" class="hidden">
375
+ <div class="flex items-center justify-center py-4">
376
+ <svg class="animate-spin h-8 w-8 text-indigo-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
377
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
378
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
379
+ </svg>
380
+ <span class="ml-3 text-gray-700">验证中...</span>
381
+ </div>
382
+ </div>
383
+
384
+ <!-- 提交按钮 -->
385
+ <div class="flex justify-end space-x-3 pt-4 border-t">
386
+ <button @click="showValidateModal = false"
387
+ :disabled="isValidating"
388
+ class="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed">
389
+ 取消
390
+ </button>
391
+ <button hx-post="/admin/api/tokens/validate"
392
+ hx-target="#notification"
393
+ @htmx:before-request="isValidating = true; document.getElementById('validate-progress').classList.remove('hidden')"
394
+ @htmx:after-request="isValidating = false; showValidateModal = false; document.getElementById('validate-progress').classList.add('hidden'); htmx.trigger('body', 'tokenListRefresh'); htmx.trigger('body', 'statsRefresh')"
395
+ :disabled="isValidating"
396
+ class="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed">
397
+ 开始验证
398
+ </button>
399
+ </div>
400
+ </div>
401
+ </div>
402
+ </div>
403
+ </div>
404
+ {% endblock %}
405
+
406
+ {% block extra_scripts %}
407
+ <script>
408
+ window.tokenListPage = 1;
409
+ window.tokenListPageSize = 20;
410
+
411
+ function loadTokenListPage(page) {
412
+ const nextPage = Math.max(1, Number(page) || 1);
413
+ window.tokenListPage = nextPage;
414
+ const url = `/admin/api/tokens/list?page=${nextPage}&page_size=${window.tokenListPageSize}`;
415
+ htmx.ajax('GET', url, {
416
+ target: '#token-list',
417
+ swap: 'innerHTML'
418
+ });
419
+ }
420
+
421
+ // 全局通知函数
422
+ function showNotification(message, type = 'success') {
423
+ const notification = document.createElement('div');
424
+ const bgColor = type === 'success' ? 'bg-green-500' : type === 'error' ? 'bg-red-500' : 'bg-blue-500';
425
+ notification.className = `fixed top-4 right-4 ${bgColor} text-white px-6 py-3 rounded-lg shadow-lg z-50 transition-all transform`;
426
+ notification.style.animation = 'slideInRight 0.3s ease-out';
427
+ notification.innerHTML = `
428
+ <div class="flex items-center space-x-2">
429
+ <svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
430
+ <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
431
+ </svg>
432
+ <span class="font-medium">${message}</span>
433
+ </div>
434
+ `;
435
+
436
+ document.body.appendChild(notification);
437
+
438
+ // 3秒后自动消失
439
+ setTimeout(() => {
440
+ notification.style.opacity = '0';
441
+ notification.style.transform = 'translateX(100%)';
442
+ setTimeout(() => notification.remove(), 300);
443
+ }, 3000);
444
+ }
445
+
446
+ // 添加动画样式
447
+ if (!document.getElementById('notification-styles')) {
448
+ const style = document.createElement('style');
449
+ style.id = 'notification-styles';
450
+ style.textContent = `
451
+ @keyframes slideInRight {
452
+ from {
453
+ opacity: 0;
454
+ transform: translateX(100%);
455
+ }
456
+ to {
457
+ opacity: 1;
458
+ transform: translateX(0);
459
+ }
460
+ }
461
+ `;
462
+ document.head.appendChild(style);
463
+ }
464
+
465
+ // 监听验证按钮的完成事件
466
+ document.body.addEventListener('htmx:afterSwap', function(evt) {
467
+ // 检查是否是验证按钮触发的事件
468
+ if (evt.detail.target && evt.detail.target.id && evt.detail.target.id.startsWith('token-row-')) {
469
+ // 从目标元素提取 token ID
470
+ const tokenId = evt.detail.target.id.replace('token-row-', '');
471
+
472
+ // 检查是否是验证操作(通过查看触发元素)
473
+ const triggerElt = evt.detail.requestConfig?.elt;
474
+ if (triggerElt && triggerElt.classList.contains('validate-token-btn')) {
475
+ showNotification(`✓ Token ID ${tokenId} 验证完成`, 'success');
476
+
477
+ // 同时刷新统计数据
478
+ htmx.trigger('body', 'statsRefresh');
479
+ }
480
+ }
481
+ });
482
+
483
+ document.body.addEventListener('tokenListRefresh', function() {
484
+ loadTokenListPage(window.tokenListPage || 1);
485
+ });
486
+ </script>
487
+ {% endblock %}
app/utils/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ from app.utils import reload_config, logger
5
+
6
+ __all__ = ["reload_config", "logger"]
app/utils/env_file.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Helpers for updating .env files without dropping unrelated settings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Mapping
8
+
9
+ _ENV_KEY_PATTERN = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=")
10
+
11
+
12
+ def _serialize_env_value(value: object) -> str:
13
+ if isinstance(value, bool):
14
+ return "true" if value else "false"
15
+
16
+ text = "" if value is None else str(value)
17
+ if not text:
18
+ return ""
19
+
20
+ if any(char.isspace() for char in text) or any(
21
+ char in text for char in ["#", '"', "\\", "'"]
22
+ ):
23
+ if "'" not in text:
24
+ return f"'{text}'"
25
+
26
+ escaped = text.replace("\\", "\\\\").replace('"', '\\"')
27
+ return f'"{escaped}"'
28
+
29
+ return text
30
+
31
+
32
+ def update_env_file(
33
+ updates: Mapping[str, object],
34
+ env_path: str | Path = ".env",
35
+ ) -> None:
36
+ """Update selected keys inside a .env file while preserving other lines."""
37
+ path = Path(env_path)
38
+ lines = path.read_text(encoding="utf-8").splitlines() if path.exists() else []
39
+ remaining_updates = {key: _serialize_env_value(value) for key, value in updates.items()}
40
+
41
+ for index, line in enumerate(lines):
42
+ match = _ENV_KEY_PATTERN.match(line)
43
+ if not match:
44
+ continue
45
+
46
+ key = match.group(1)
47
+ if key not in remaining_updates:
48
+ continue
49
+
50
+ lines[index] = f"{key}={remaining_updates.pop(key)}"
51
+
52
+ if remaining_updates:
53
+ if lines and lines[-1].strip():
54
+ lines.append("")
55
+ for key, value in remaining_updates.items():
56
+ lines.append(f"{key}={value}")
57
+
58
+ content = "\n".join(lines).rstrip()
59
+ path.write_text(f"{content}\n" if content else "", encoding="utf-8")
app/utils/fe_version.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """
5
+ Utility helpers for resolving the latest X-FE-Version value from chat.z.ai.
6
+
7
+ The upstream service embeds the current front-end release identifier inside
8
+ its landing page static asset URLs (e.g. `prod-fe-1.0.107`). The helpers in
9
+ this module fetch the landing page, extract the version string, and cache it
10
+ with a configurable TTL so the expensive network fetch only happens when
11
+ necessary.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ import time
18
+ from typing import Optional
19
+
20
+ import httpx
21
+
22
+ from app.utils.logger import get_logger
23
+ from app.utils.user_agent import get_random_user_agent
24
+
25
+ # Base URL to probe for the version string.
26
+ FE_VERSION_SOURCE_URL = "https://chat.z.ai"
27
+
28
+ # Cache TTL in seconds (default: 30 minutes).
29
+ CACHE_TTL_SECONDS = 1800
30
+
31
+ _logger = get_logger()
32
+ _version_pattern = re.compile(r"prod-fe-\d+\.\d+\.\d+")
33
+
34
+ _cached_version: str = ""
35
+ _cached_at: float = 0.0
36
+
37
+
38
+ def _extract_version(page_content: str) -> Optional[str]:
39
+ """Extract the version string from the page content."""
40
+ if not page_content:
41
+ return None
42
+
43
+ matches = _version_pattern.findall(page_content)
44
+ if not matches:
45
+ return None
46
+
47
+ # Choose the highest lexical value to guard against mixed versions.
48
+ return max(matches)
49
+
50
+
51
+
52
+
53
+ def _should_use_cache(force_refresh: bool) -> bool:
54
+ """Determine whether the cached value can be reused."""
55
+ if force_refresh:
56
+ return False
57
+ if not _cached_version:
58
+ return False
59
+ if _cached_at <= 0:
60
+ return False
61
+ return (time.time() - _cached_at) < CACHE_TTL_SECONDS
62
+
63
+
64
+ def get_latest_fe_version(force_refresh: bool = False) -> str:
65
+ """
66
+ Resolve the latest X-FE-Version value from chat.z.ai.
67
+
68
+ The lookup order is:
69
+ 1. Cached value within TTL.
70
+ 2. Remote fetch from chat.z.ai.
71
+
72
+ Raises:
73
+ Exception: If unable to fetch the version from the remote source.
74
+ """
75
+ global _cached_version, _cached_at
76
+
77
+ if _should_use_cache(force_refresh):
78
+ return _cached_version
79
+
80
+ try:
81
+ headers = {"User-Agent": get_random_user_agent("chrome")}
82
+ except Exception:
83
+ headers = {
84
+ "User-Agent": (
85
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
86
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
87
+ "Chrome/120.0.0.0 Safari/537.36"
88
+ )
89
+ }
90
+
91
+ try:
92
+ with httpx.Client(timeout=10.0, follow_redirects=True) as client:
93
+ response = client.get(FE_VERSION_SOURCE_URL, headers=headers)
94
+ response.raise_for_status()
95
+ version = _extract_version(response.text)
96
+ if version:
97
+ if version != _cached_version:
98
+ _logger.info(f"[Z.AI] Detected X-FE-Version update: {version}")
99
+ _cached_version = version
100
+ _cached_at = time.time()
101
+ return version
102
+
103
+ _logger.error("[Z.AI] Unable to locate X-FE-Version in landing page")
104
+ raise Exception("Unable to locate X-FE-Version in landing page")
105
+ except Exception as exc:
106
+ _logger.error(f"[Z.AI] Failed to fetch X-FE-Version from {FE_VERSION_SOURCE_URL}: {exc}")
107
+ raise Exception(f"Failed to fetch X-FE-Version: {exc}")
108
+
109
+
110
+ def refresh_fe_version() -> str:
111
+ """Force refresh the cached version by bypassing the TTL."""
112
+ return get_latest_fe_version(force_refresh=True)
app/utils/guest_session_pool.py ADDED
@@ -0,0 +1,646 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """匿名访客会话池。"""
5
+
6
+ import asyncio
7
+ import random
8
+ import time
9
+ from dataclasses import dataclass, field
10
+ from threading import Lock
11
+ from typing import Dict, List, Optional, Set
12
+
13
+ import httpx
14
+
15
+ from app.core.config import settings
16
+ from app.utils.fe_version import get_latest_fe_version
17
+ from app.utils.logger import logger
18
+ from app.utils.user_agent import get_random_user_agent
19
+
20
+ AUTH_URL = "https://chat.z.ai/api/v1/auths/"
21
+ CHATS_URL = "https://chat.z.ai/api/v1/chats/"
22
+ AUTH_HTTP_MAX_KEEPALIVE_CONNECTIONS = 20
23
+ AUTH_HTTP_MAX_CONNECTIONS = 50
24
+ GUEST_SESSION_TTL_SECONDS = 480
25
+ GUEST_SESSION_TTL_JITTER_SECONDS = 60
26
+ GUEST_SESSION_MIN_TTL_SECONDS = 180
27
+ GUEST_POOL_MAINTENANCE_INTERVAL_SECONDS = 30
28
+ GUEST_CLEANUP_PARALLELISM = 4
29
+ CAPACITY_FILL_ATTEMPT_MULTIPLIER = 3
30
+ CAPACITY_FILL_MIN_ATTEMPTS = 3
31
+ MAX_DUPLICATE_LOG_USER_IDS = 3
32
+
33
+
34
+ def _get_proxy_config() -> Optional[str]:
35
+ """获取代理配置。"""
36
+ if settings.HTTPS_PROXY:
37
+ return settings.HTTPS_PROXY
38
+ if settings.HTTP_PROXY:
39
+ return settings.HTTP_PROXY
40
+ if settings.SOCKS5_PROXY:
41
+ return settings.SOCKS5_PROXY
42
+ return None
43
+
44
+
45
+ def _build_timeout(read_timeout: float = 30.0) -> httpx.Timeout:
46
+ """构建访客会话相关请求超时。"""
47
+ return httpx.Timeout(
48
+ connect=5.0,
49
+ read=read_timeout,
50
+ write=10.0,
51
+ pool=5.0,
52
+ )
53
+
54
+
55
+ def _build_limits() -> httpx.Limits:
56
+ """构建访客会话相关连接池限制。"""
57
+ return httpx.Limits(
58
+ max_keepalive_connections=AUTH_HTTP_MAX_KEEPALIVE_CONNECTIONS,
59
+ max_connections=AUTH_HTTP_MAX_CONNECTIONS,
60
+ )
61
+
62
+
63
+ def _build_async_client(read_timeout: float = 30.0) -> httpx.AsyncClient:
64
+ """构建访客会话相关 HTTP 客户端。"""
65
+ return httpx.AsyncClient(
66
+ timeout=_build_timeout(read_timeout),
67
+ follow_redirects=True,
68
+ limits=_build_limits(),
69
+ proxy=_get_proxy_config(),
70
+ )
71
+
72
+
73
+ def _build_dynamic_headers(chat_id: str = "") -> Dict[str, str]:
74
+ """生成匿名访客鉴权所需浏览器请求头。"""
75
+ browser_choices = [
76
+ "chrome",
77
+ "chrome",
78
+ "chrome",
79
+ "edge",
80
+ "edge",
81
+ "firefox",
82
+ "safari",
83
+ ]
84
+ browser_type = random.choice(browser_choices)
85
+ user_agent = get_random_user_agent(browser_type)
86
+ fe_version = get_latest_fe_version()
87
+
88
+ chrome_version = "139"
89
+ edge_version = "139"
90
+
91
+ if "Chrome/" in user_agent:
92
+ try:
93
+ chrome_version = user_agent.split("Chrome/")[1].split(".")[0]
94
+ except Exception:
95
+ pass
96
+
97
+ if "Edg/" in user_agent:
98
+ try:
99
+ edge_version = user_agent.split("Edg/")[1].split(".")[0]
100
+ sec_ch_ua = (
101
+ f'"Microsoft Edge";v="{edge_version}", '
102
+ f'"Chromium";v="{chrome_version}", "Not_A Brand";v="24"'
103
+ )
104
+ except Exception:
105
+ sec_ch_ua = (
106
+ f'"Not_A Brand";v="8", "Chromium";v="{chrome_version}", '
107
+ f'"Google Chrome";v="{chrome_version}"'
108
+ )
109
+ elif "Firefox/" in user_agent:
110
+ sec_ch_ua = None
111
+ else:
112
+ sec_ch_ua = (
113
+ f'"Not_A Brand";v="8", "Chromium";v="{chrome_version}", '
114
+ f'"Google Chrome";v="{chrome_version}"'
115
+ )
116
+
117
+ headers = {
118
+ "Content-Type": "application/json",
119
+ "Accept": "application/json, text/event-stream",
120
+ "Connection": "keep-alive",
121
+ "Cache-Control": "no-cache",
122
+ "User-Agent": user_agent,
123
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
124
+ "X-FE-Version": fe_version,
125
+ "Origin": "https://chat.z.ai",
126
+ }
127
+
128
+ if sec_ch_ua:
129
+ headers["sec-ch-ua"] = sec_ch_ua
130
+ headers["sec-ch-ua-mobile"] = "?0"
131
+ headers["sec-ch-ua-platform"] = '"Windows"'
132
+
133
+ if chat_id:
134
+ headers["Referer"] = f"https://chat.z.ai/c/{chat_id}"
135
+ else:
136
+ headers["Referer"] = "https://chat.z.ai/"
137
+
138
+ return headers
139
+
140
+
141
+ def _build_session_expiry() -> float:
142
+ """为新会话分配带抖动的过期时间,避免整池同时失效。"""
143
+ jitter = random.uniform(
144
+ -GUEST_SESSION_TTL_JITTER_SECONDS,
145
+ GUEST_SESSION_TTL_JITTER_SECONDS,
146
+ )
147
+ ttl_seconds = max(
148
+ GUEST_SESSION_MIN_TTL_SECONDS,
149
+ GUEST_SESSION_TTL_SECONDS + jitter,
150
+ )
151
+ return time.time() + ttl_seconds
152
+
153
+
154
+ @dataclass
155
+ class GuestSession:
156
+ """单个匿名访客会话。"""
157
+
158
+ token: str
159
+ user_id: str
160
+ username: str
161
+ created_at: float = field(default_factory=time.time)
162
+ expires_at: float = field(default_factory=_build_session_expiry)
163
+ active_requests: int = 0
164
+ valid: bool = True
165
+ failure_count: int = 0
166
+ last_failure_time: float = 0.0
167
+
168
+ @property
169
+ def age(self) -> float:
170
+ """会话存活时间。"""
171
+ return time.time() - self.created_at
172
+
173
+ @property
174
+ def is_expired(self) -> bool:
175
+ """判断会话是否��过期。"""
176
+ return time.time() >= self.expires_at
177
+
178
+ def snapshot(self) -> Dict[str, str]:
179
+ """获取当前会话快照。"""
180
+ return {
181
+ "token": self.token,
182
+ "user_id": self.user_id,
183
+ "username": self.username,
184
+ }
185
+
186
+
187
+ class GuestSessionPool:
188
+ """匿名访客会话池,支持最小负载获取与失败替换。"""
189
+
190
+ def __init__(self, pool_size: int = 3):
191
+ self.pool_size = max(1, pool_size)
192
+ self._lock = Lock()
193
+ self._sessions: Dict[str, GuestSession] = {}
194
+ self._maintenance_task: Optional[asyncio.Task] = None
195
+ self._http_client: Optional[httpx.AsyncClient] = None
196
+ self._client_lock = asyncio.Lock()
197
+ self._capacity_lock = asyncio.Lock()
198
+ self._background_tasks: Set[asyncio.Task] = set()
199
+ self._cleanup_parallelism = GUEST_CLEANUP_PARALLELISM
200
+ self._maintenance_interval = GUEST_POOL_MAINTENANCE_INTERVAL_SECONDS
201
+
202
+ async def _get_http_client(self) -> httpx.AsyncClient:
203
+ """获取可复用的 HTTP 客户端,减少频繁建连开销。"""
204
+ if self._http_client is not None:
205
+ return self._http_client
206
+
207
+ async with self._client_lock:
208
+ if self._http_client is None:
209
+ self._http_client = _build_async_client()
210
+ return self._http_client
211
+
212
+ async def _close_http_client(self):
213
+ """关闭可复用的 HTTP 客户端。"""
214
+ async with self._client_lock:
215
+ client = self._http_client
216
+ self._http_client = None
217
+
218
+ if client is not None:
219
+ await client.aclose()
220
+
221
+ def _track_background_task(self, coro) -> asyncio.Task:
222
+ """跟踪后台任务,避免清理阻塞前台重试路径。"""
223
+ task = asyncio.create_task(coro)
224
+ self._background_tasks.add(task)
225
+
226
+ def _on_done(done_task: asyncio.Task):
227
+ self._background_tasks.discard(done_task)
228
+ try:
229
+ done_task.result()
230
+ except asyncio.CancelledError:
231
+ pass
232
+ except Exception as exc:
233
+ logger.warning(f"⚠️ 匿名会话后台任务异常: {exc}")
234
+
235
+ task.add_done_callback(_on_done)
236
+ return task
237
+
238
+ async def _wait_background_tasks(self):
239
+ """等待当前已注册的后台任务结束。"""
240
+ pending = list(self._background_tasks)
241
+ if pending:
242
+ await asyncio.gather(*pending, return_exceptions=True)
243
+
244
+ async def _delete_sessions_concurrently(self, sessions: List[GuestSession]):
245
+ """并发清理多枚匿名会话,加快池维护速度。"""
246
+ if not sessions:
247
+ return
248
+
249
+ semaphore = asyncio.Semaphore(self._cleanup_parallelism)
250
+
251
+ async def _cleanup(session: GuestSession):
252
+ async with semaphore:
253
+ await self._delete_all_chats(session)
254
+
255
+ await asyncio.gather(*(_cleanup(session) for session in sessions))
256
+
257
+ async def _create_session(self) -> GuestSession:
258
+ """创建一个新的匿名访客会话。"""
259
+ headers = _build_dynamic_headers()
260
+
261
+ # 访客鉴权会写入 cookie,复用同一个 client 会把“新建会话”粘回旧访客身份。
262
+ async with _build_async_client() as auth_client:
263
+ response = await auth_client.get(AUTH_URL, headers=headers)
264
+
265
+ if response.status_code != 200:
266
+ raise RuntimeError(
267
+ f"匿名会话创建失败: HTTP {response.status_code} {response.text[:200]}"
268
+ )
269
+
270
+ data = response.json()
271
+ token = str(data.get("token") or "").strip()
272
+ user_id = str(
273
+ data.get("id") or data.get("user_id") or data.get("uid") or ""
274
+ ).strip()
275
+ username = str(
276
+ data.get("name")
277
+ or str(data.get("email") or "").split("@")[0]
278
+ or f"guest-{user_id[:8] or 'session'}"
279
+ ).strip()
280
+
281
+ if not token:
282
+ raise RuntimeError(f"匿名会话创建失败: 未返回 token {data}")
283
+ if not user_id:
284
+ user_id = f"guest-{token[:12]}"
285
+
286
+ logger.info(
287
+ f"🫥 创建匿名会话成功: user_id={user_id}, username={username or 'Guest'}"
288
+ )
289
+ return GuestSession(
290
+ token=token,
291
+ user_id=user_id,
292
+ username=username or "Guest",
293
+ )
294
+
295
+ async def _delete_all_chats(self, session: GuestSession) -> bool:
296
+ """删除匿名会话的全部对话,尽量释放并发占用。"""
297
+ headers = _build_dynamic_headers()
298
+ headers.update(
299
+ {
300
+ "Authorization": f"Bearer {session.token}",
301
+ "Accept": "application/json",
302
+ "Content-Type": "application/json",
303
+ }
304
+ )
305
+
306
+ try:
307
+ client = await self._get_http_client()
308
+ response = await client.delete(CHATS_URL, headers=headers)
309
+
310
+ if response.status_code == 200:
311
+ logger.info(f"🧹 已���理匿名会话聊天记录: {session.user_id}")
312
+ return True
313
+
314
+ logger.warning(
315
+ f"⚠️ 清理匿名会话聊天记录失败: {session.user_id}, "
316
+ f"HTTP {response.status_code}, body={response.text[:200]}"
317
+ )
318
+ except Exception as exc:
319
+ logger.warning(f"⚠️ 清理匿名会话聊天记录异常: {session.user_id}, {exc}")
320
+
321
+ return False
322
+
323
+ def _list_valid_sessions(
324
+ self,
325
+ exclude_user_ids: Optional[Set[str]] = None,
326
+ ) -> List[GuestSession]:
327
+ """获取有效匿名会话列表。"""
328
+ excluded = exclude_user_ids or set()
329
+ with self._lock:
330
+ return [
331
+ session
332
+ for session in self._sessions.values()
333
+ if self._is_session_usable(session)
334
+ and session.user_id not in excluded
335
+ ]
336
+
337
+ def _is_session_usable(self, session: GuestSession) -> bool:
338
+ """判断会话当前是否还能继续分配。"""
339
+ return session.valid and not session.is_expired
340
+
341
+ def _should_retire_session(self, session: GuestSession) -> bool:
342
+ """判断会话是否应当从池中回收。"""
343
+ return session.active_requests == 0 and not self._is_session_usable(session)
344
+
345
+ def _can_replace_session(self, session: GuestSession) -> bool:
346
+ """判断当前池内会话是否允许被新的同 user_id 会话替换。"""
347
+ return self._should_retire_session(session)
348
+
349
+ def _store_session(self, session: GuestSession) -> bool:
350
+ """仅在会话唯一或旧会话已过期时写入会话池。"""
351
+ with self._lock:
352
+ existing = self._sessions.get(session.user_id)
353
+ if existing and not self._can_replace_session(existing):
354
+ return False
355
+ self._sessions[session.user_id] = session
356
+ return True
357
+
358
+ def _log_duplicate_sessions(self, action: str, user_ids: List[str]):
359
+ """记录重复会话,避免补池时静默覆盖。"""
360
+ if not user_ids:
361
+ return
362
+
363
+ sample = ", ".join(user_ids[:MAX_DUPLICATE_LOG_USER_IDS])
364
+ logger.warning(
365
+ f"⚠️ 匿名会话池{action}收到重复会话,已忽略: "
366
+ f"count={len(user_ids)}, user_ids={sample}"
367
+ )
368
+
369
+ def _register_create_results(self, action: str, results: List[object]) -> int:
370
+ """写入新创建的会话,并显式忽略重复 user_id。"""
371
+ created = 0
372
+ duplicate_user_ids: List[str] = []
373
+
374
+ for result in results:
375
+ if isinstance(result, GuestSession):
376
+ if self._store_session(result):
377
+ created += 1
378
+ else:
379
+ duplicate_user_ids.append(result.user_id)
380
+ continue
381
+
382
+ if isinstance(result, Exception):
383
+ logger.warning(f"⚠️ 匿名会话池{action}失败: {result}")
384
+
385
+ self._log_duplicate_sessions(action, duplicate_user_ids)
386
+ return created
387
+
388
+ def _get_fill_attempt_budget(self, missing_count: int) -> int:
389
+ """为补池/获取会话计算显式尝试上限,避免重复会话导致死循环。"""
390
+ scaled_budget = max(1, missing_count) * CAPACITY_FILL_ATTEMPT_MULTIPLIER
391
+ minimum_budget = max(1, missing_count) + CAPACITY_FILL_MIN_ATTEMPTS
392
+ return max(scaled_budget, minimum_budget)
393
+
394
+ def _pop_retired_sessions(self) -> List[GuestSession]:
395
+ """移除当前所有可回收的失效会话。"""
396
+ retired_sessions: List[GuestSession] = []
397
+
398
+ with self._lock:
399
+ for user_id, session in list(self._sessions.items()):
400
+ if self._should_retire_session(session):
401
+ retired_sessions.append(self._sessions.pop(user_id))
402
+
403
+ return retired_sessions
404
+
405
+ async def _ensure_capacity(self):
406
+ """补齐匿名会话池容量。"""
407
+ async with self._capacity_lock:
408
+ attempts_left = self._get_fill_attempt_budget(
409
+ self.pool_size - len(self._list_valid_sessions())
410
+ )
411
+
412
+ while attempts_left > 0:
413
+ need = self.pool_size - len(self._list_valid_sessions())
414
+ if need <= 0:
415
+ return
416
+
417
+ batch_size = min(need, attempts_left)
418
+ results = await asyncio.gather(
419
+ *[self._create_session() for _ in range(batch_size)],
420
+ return_exceptions=True,
421
+ )
422
+ attempts_left -= batch_size
423
+
424
+ created = self._register_create_results("补齐", results)
425
+ if created == 0 and attempts_left == 0:
426
+ break
427
+
428
+ remaining = self.pool_size - len(self._list_valid_sessions())
429
+ if remaining > 0:
430
+ logger.warning(
431
+ "⚠️ 匿名会话池补齐未达到目标容量: "
432
+ f"missing={remaining}, current={len(self._list_valid_sessions())}"
433
+ )
434
+
435
+ async def _maintenance_loop(self):
436
+ """后台维护:回收过期/失效会话,并补齐池容量。"""
437
+ while True:
438
+ try:
439
+ await asyncio.sleep(self._maintenance_interval)
440
+ retired_sessions = self._pop_retired_sessions()
441
+ await self._delete_sessions_concurrently(retired_sessions)
442
+
443
+ await self._ensure_capacity()
444
+ except asyncio.CancelledError:
445
+ return
446
+ except Exception as exc:
447
+ logger.warning(f"⚠️ 匿名会话池后台维护异常: {exc}")
448
+
449
+ async def initialize(self):
450
+ """初始化匿名会话池。"""
451
+ if self._maintenance_task:
452
+ return
453
+
454
+ await self._ensure_capacity()
455
+ created = len(self._list_valid_sessions())
456
+
457
+ if created == 0:
458
+ fallback = await self._create_session()
459
+ if not self._store_session(fallback):
460
+ raise RuntimeError(
461
+ "匿名会话池初始化失败: 无法写入唯一匿名会话"
462
+ )
463
+ created = len(self._list_valid_sessions())
464
+
465
+ logger.info(f"✅ 匿名会话池初始化完成: {created} 个会话")
466
+ self._maintenance_task = asyncio.create_task(self._maintenance_loop())
467
+
468
+ async def close(self):
469
+ """关闭匿名会话池。"""
470
+ if self._maintenance_task:
471
+ self._maintenance_task.cancel()
472
+ try:
473
+ await self._maintenance_task
474
+ except asyncio.CancelledError:
475
+ pass
476
+ self._maintenance_task = None
477
+
478
+ with self._lock:
479
+ sessions = list(self._sessions.values())
480
+ self._sessions.clear()
481
+
482
+ await self._wait_background_tasks()
483
+ idle_sessions = [
484
+ session for session in sessions if session.active_requests == 0
485
+ ]
486
+ await self._delete_sessions_concurrently(idle_sessions)
487
+ await self._close_http_client()
488
+
489
+ async def acquire(
490
+ self,
491
+ exclude_user_ids: Optional[Set[str]] = None,
492
+ ) -> GuestSession:
493
+ """按最小忙碌度获取一个可用匿名会话。"""
494
+ excluded = exclude_user_ids or set()
495
+ attempts_left = self._get_fill_attempt_budget(len(excluded) + 1)
496
+
497
+ while attempts_left > 0:
498
+ candidates = self._list_valid_sessions(exclude_user_ids=excluded)
499
+ if candidates:
500
+ session = min(
501
+ candidates,
502
+ key=lambda item: (item.active_requests, item.created_at),
503
+ )
504
+ with self._lock:
505
+ current = self._sessions.get(session.user_id)
506
+ if (
507
+ current
508
+ and self._is_session_usable(current)
509
+ and current.user_id not in excluded
510
+ ):
511
+ current.active_requests += 1
512
+ return current
513
+
514
+ new_session = await self._create_session()
515
+ attempts_left -= 1
516
+ if new_session.user_id in excluded:
517
+ logger.warning(
518
+ "⚠️ 获取匿名会话时命中排除 user_id,已忽略: "
519
+ f"{new_session.user_id}"
520
+ )
521
+ continue
522
+
523
+ if not self._store_session(new_session):
524
+ logger.warning(
525
+ "⚠️ 获取匿名会话时命中重复 user_id,已重试: "
526
+ f"{new_session.user_id}"
527
+ )
528
+ continue
529
+
530
+ with self._lock:
531
+ current = self._sessions.get(new_session.user_id)
532
+ if current and self._is_session_usable(current):
533
+ current.active_requests += 1
534
+ return current
535
+
536
+ raise RuntimeError("匿名会话池获取失败: 未能创建唯一匿名会话")
537
+
538
+ def release(self, user_id: str):
539
+ """释放一个匿名会话占用。"""
540
+ retired_session: Optional[GuestSession] = None
541
+
542
+ with self._lock:
543
+ session = self._sessions.get(user_id)
544
+ if session:
545
+ session.active_requests = max(0, session.active_requests - 1)
546
+ if self._should_retire_session(session):
547
+ retired_session = self._sessions.pop(user_id)
548
+
549
+ if retired_session:
550
+ logger.info(f"🧹 已回收过期匿名会话: {retired_session.user_id}")
551
+ self._track_background_task(self._delete_all_chats(retired_session))
552
+ self._track_background_task(self._ensure_capacity())
553
+
554
+ async def report_failure(self, user_id: Optional[str] = None):
555
+ """标记匿名会话失效,并尝试补一个新会话。"""
556
+ session: Optional[GuestSession] = None
557
+
558
+ if user_id:
559
+ with self._lock:
560
+ session = self._sessions.pop(user_id, None)
561
+ if session:
562
+ session.valid = False
563
+ session.failure_count += 1
564
+ session.last_failure_time = time.time()
565
+ session.active_requests = 0
566
+
567
+ if session:
568
+ self._track_background_task(self._delete_all_chats(session))
569
+ logger.warning(f"⚠️ 已淘汰匿名会话: {session.user_id}")
570
+
571
+ await self._ensure_capacity()
572
+
573
+ async def refresh_auth(self, failed_user_id: Optional[str] = None):
574
+ """兼容 glm-demo 命名:刷新匿名会话。"""
575
+ await self.report_failure(failed_user_id)
576
+
577
+ async def cleanup_idle_chats(self):
578
+ """清理当前空闲匿名会话的聊天记录。"""
579
+ with self._lock:
580
+ idle_sessions = [
581
+ session
582
+ for session in self._sessions.values()
583
+ if self._is_session_usable(session) and session.active_requests == 0
584
+ ]
585
+
586
+ await self._delete_sessions_concurrently(idle_sessions)
587
+
588
+ def get_pool_status(self) -> Dict[str, int]:
589
+ """获取匿名会话池状态。"""
590
+ with self._lock:
591
+ sessions = list(self._sessions.values())
592
+
593
+ valid_sessions = [
594
+ session for session in sessions if self._is_session_usable(session)
595
+ ]
596
+ busy_sessions = [
597
+ session for session in valid_sessions if session.active_requests > 0
598
+ ]
599
+
600
+ return {
601
+ "total_sessions": len(sessions),
602
+ "valid_sessions": len(valid_sessions),
603
+ "available_sessions": len(
604
+ [session for session in valid_sessions if session.active_requests == 0]
605
+ ),
606
+ "busy_sessions": len(busy_sessions),
607
+ "expired_sessions": len(
608
+ [session for session in sessions if session.is_expired]
609
+ ),
610
+ }
611
+
612
+
613
+ _guest_session_pool: Optional[GuestSessionPool] = None
614
+ _guest_pool_lock = Lock()
615
+
616
+
617
+ def get_guest_session_pool() -> Optional[GuestSessionPool]:
618
+ """获取全局匿名会话池。"""
619
+ return _guest_session_pool
620
+
621
+
622
+ async def initialize_guest_session_pool(
623
+ pool_size: int = 3,
624
+ ) -> GuestSessionPool:
625
+ """初始化全局匿名会话池。"""
626
+ global _guest_session_pool
627
+
628
+ with _guest_pool_lock:
629
+ if _guest_session_pool is None:
630
+ _guest_session_pool = GuestSessionPool(pool_size=pool_size)
631
+ pool = _guest_session_pool
632
+
633
+ await pool.initialize()
634
+ return pool
635
+
636
+
637
+ async def close_guest_session_pool():
638
+ """关闭全局匿名会话池。"""
639
+ global _guest_session_pool
640
+
641
+ with _guest_pool_lock:
642
+ pool = _guest_session_pool
643
+ _guest_session_pool = None
644
+
645
+ if pool:
646
+ await pool.close()
app/utils/logger.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ import sys
5
+ from pathlib import Path
6
+ from loguru import logger
7
+
8
+ # Global logger instance
9
+ app_logger = None
10
+
11
+
12
+ def setup_logger(log_dir, log_retention_days=7, log_rotation="1 day", debug_mode=False):
13
+ """
14
+ Create a logger instance
15
+
16
+ Parameters:
17
+ log_dir (str): 日志目录
18
+ log_retention_days (int): 日志保留天数
19
+ log_rotation (str): 日志轮转间隔
20
+ debug_mode (bool): 是否开启调试模式
21
+ """
22
+ global app_logger
23
+
24
+ # 移除所有现有的日志处理器(支持热重载)
25
+ logger.remove()
26
+
27
+ log_level = "DEBUG" if debug_mode else "INFO"
28
+
29
+ console_format = (
30
+ "<green>{time:HH:mm:ss}</green> | <level>{level: <8}</level> | <level>{message}</level>"
31
+ if not debug_mode
32
+ else "<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | "
33
+ "<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> | <level>{message}</level>"
34
+ )
35
+
36
+ # 添加控制台输出(根据 debug_mode 设置级别)
37
+ logger.add(sys.stderr, level=log_level, format=console_format, colorize=True)
38
+
39
+ # 只有在 debug_mode 时才添加文件输出
40
+ if debug_mode:
41
+ try:
42
+ log_path = Path(log_dir)
43
+ log_path.mkdir(parents=True, exist_ok=True)
44
+
45
+ log_file = log_path / "{time:YYYY-MM-DD}.log"
46
+ file_format = "{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {name}:{function}:{line} | {message}"
47
+
48
+ logger.add(
49
+ str(log_file),
50
+ level=log_level,
51
+ format=file_format,
52
+ rotation=log_rotation,
53
+ retention=f"{log_retention_days} days",
54
+ encoding="utf-8",
55
+ compression="zip",
56
+ enqueue=True,
57
+ catch=True,
58
+ )
59
+ except (PermissionError, OSError) as e:
60
+ # 如果无法创建日志目录或文件,降级为仅控制台输出
61
+ logger.warning(f"⚠️ 无法创建日志文件 ({e}),将仅使用控制台输出")
62
+
63
+ app_logger = logger
64
+
65
+ return logger
66
+
67
+
68
+ def get_logger():
69
+ """Get the logger instance"""
70
+ global app_logger
71
+ if app_logger is None:
72
+ # 如果没有设置过logger,使用默认配置
73
+ logger.remove() # 移除所有现有处理器
74
+ logger.add(sys.stderr, level="INFO", format="<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> | <level>{message}</level>")
75
+ app_logger = logger
76
+ return app_logger
77
+
78
+
79
+ if __name__ == "__main__":
80
+ """Test the logger"""
81
+ import tempfile
82
+
83
+ with tempfile.TemporaryDirectory() as temp_dir:
84
+ try:
85
+ setup_logger(temp_dir, debug_mode=True)
86
+
87
+ logger.debug("这是一条调试日志")
88
+ logger.info("这是一条信息日志")
89
+ logger.warning("这是一条警告日志")
90
+ logger.error("这是一条错误日志")
91
+ logger.critical("这是一条严重日志")
92
+
93
+ try:
94
+ 1 / 0
95
+ except ZeroDivisionError:
96
+ logger.exception("发生了除零异常")
97
+
98
+ print("✅ 日志测试完成")
99
+
100
+ logger.remove()
101
+
102
+ except Exception as e:
103
+ print(f"❌ 日志测试失败: {e}")
104
+ logger.remove()
105
+ raise
app/utils/reload_config.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """
5
+ 热重载配置模块
6
+ 定义 Granian 服务器热重载时需要忽略的目录和文件模式
7
+ """
8
+
9
+ # 忽略的目录列表
10
+ RELOAD_IGNORE_DIRS = [
11
+ "logs", # 忽略日志目录
12
+ "storage", # 忽略存储目录
13
+ "__pycache__", # 忽略 Python 缓存
14
+ ".git", # 忽略 git 目录
15
+ ".github", # 忽略 GitHub 相关目录
16
+ ".vscode", # 忽略 VSCode 配置目录
17
+ "deploy", # 忽略部署相关目录
18
+ ".idea", # 忽略 IntelliJ IDEA 配置目录
19
+ "node_modules", # 忽略 node_modules
20
+ "migrations", # 忽略数据库迁移目录
21
+ ".pytest_cache", # 忽略 pytest 缓存
22
+ ".venv", # 忽略虚拟环境
23
+ "venv", # 忽略虚拟环境
24
+ "env", # 忽略环境目录
25
+ ".mypy_cache", # 忽略 mypy 缓存
26
+ ".ruff_cache", # 忽略 ruff 缓存
27
+ "dist", # 忽略构建分发目录
28
+ "build", # 忽略构建目录
29
+ ".coverage", # 忽略测试覆盖率文件
30
+ "htmlcov", # 忽略覆盖率报告目录
31
+ "tests", # 忽略测试目录
32
+ "z-ai2api-server.pid", # 忽略 PID 文件
33
+ "app\\templates" # 忽略模板目录
34
+ ]
35
+
36
+ # 忽略的文件模式(正则表达式)
37
+ RELOAD_IGNORE_PATTERNS = [
38
+ # 日志文件
39
+ r".*\.log$",
40
+ r".*\.log\.\d+$",
41
+ # 数据库文件
42
+ r".*\.sqlite3.*",
43
+ r".*\.db$",
44
+ r".*\.db-.*$",
45
+ # Python 相关
46
+ r".*\.pyc$",
47
+ r".*\.pyo$",
48
+ r".*\.pyd$",
49
+ # 临时文件
50
+ r".*\.tmp$",
51
+ r".*\.temp$",
52
+ r".*\.swp$",
53
+ r".*\.swo$",
54
+ r".*~$",
55
+ # 系统文件
56
+ r".*\.DS_Store$",
57
+ r".*Thumbs\.db$",
58
+ r".*\.directory$",
59
+ # 编辑器文件
60
+ r".*\.vscode.*",
61
+ r".*\.idea.*",
62
+ # 测试和覆盖率
63
+ r".*\.coverage$",
64
+ r".*\.pytest_cache.*",
65
+ # 构建文件
66
+ r".*\.egg-info.*",
67
+ r".*\.wheel$",
68
+ r".*\.whl$",
69
+ # 版本控制
70
+ r".*\.git.*",
71
+ r".*\.gitignore$",
72
+ r".*\.gitkeep$",
73
+ # 配置文件备份
74
+ r".*\.bak$",
75
+ r".*\.backup$",
76
+ r".*\.orig$",
77
+ # 锁文件
78
+ r".*\.lock$",
79
+ r".*\.pid$",
80
+ ]
81
+
82
+ # 监视的路径(只监视应用相关代码)
83
+ RELOAD_WATCH_PATHS = [
84
+ "app", # 应用主目录
85
+ "main.py", # 主入口文件
86
+ ]
87
+
88
+ # 热重载配置
89
+ RELOAD_CONFIG = {
90
+ "reload_ignore_dirs": RELOAD_IGNORE_DIRS,
91
+ "reload_ignore_patterns": RELOAD_IGNORE_PATTERNS,
92
+ "reload_paths": RELOAD_WATCH_PATHS,
93
+ "reload_tick": 500, # 监视频率(毫秒)
94
+ }
app/utils/request_logging.py ADDED
@@ -0,0 +1,337 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """请求日志写库与流式日志包装。"""
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import time
10
+ from typing import Any, AsyncGenerator, Dict, Optional
11
+
12
+ from app.services.request_log_dao import get_request_log_dao
13
+ from app.utils.logger import get_logger
14
+ from app.utils.request_source import RequestSourceInfo
15
+
16
+ logger = get_logger()
17
+
18
+
19
+ def _coerce_int(value: Any) -> int:
20
+ try:
21
+ return int(value or 0)
22
+ except (TypeError, ValueError):
23
+ return 0
24
+
25
+
26
+ def _merge_usage(
27
+ current: Dict[str, int],
28
+ update: Dict[str, int],
29
+ *,
30
+ include_cache_in_total: bool,
31
+ ) -> Dict[str, int]:
32
+ merged = dict(current)
33
+
34
+ for key in (
35
+ "input_tokens",
36
+ "output_tokens",
37
+ "cache_creation_tokens",
38
+ "cache_read_tokens",
39
+ ):
40
+ value = _coerce_int(update.get(key))
41
+ if value > 0:
42
+ merged[key] = value
43
+
44
+ total_tokens = _coerce_int(update.get("total_tokens"))
45
+ if total_tokens > 0:
46
+ merged["total_tokens"] = total_tokens
47
+ return merged
48
+
49
+ merged["total_tokens"] = (
50
+ merged["input_tokens"] + merged["output_tokens"]
51
+ )
52
+ if include_cache_in_total:
53
+ merged["total_tokens"] += (
54
+ merged["cache_creation_tokens"] + merged["cache_read_tokens"]
55
+ )
56
+
57
+ return merged
58
+
59
+
60
+ def extract_openai_usage(response: Dict[str, Any]) -> Dict[str, int]:
61
+ """Extract usage from an OpenAI-compatible response payload."""
62
+ usage = response.get("usage") or {}
63
+ prompt_details = usage.get("prompt_tokens_details") or {}
64
+ input_details = usage.get("input_token_details") or {}
65
+
66
+ input_tokens = _coerce_int(
67
+ usage.get("prompt_tokens") or usage.get("input_tokens")
68
+ )
69
+ output_tokens = _coerce_int(
70
+ usage.get("completion_tokens") or usage.get("output_tokens")
71
+ )
72
+ cache_creation_tokens = _coerce_int(
73
+ usage.get("cache_creation_input_tokens")
74
+ or prompt_details.get("cache_creation_tokens")
75
+ or input_details.get("cache_creation_input_tokens")
76
+ or input_details.get("cache_creation_tokens")
77
+ )
78
+ cache_read_tokens = _coerce_int(
79
+ usage.get("cache_read_input_tokens")
80
+ or prompt_details.get("cached_tokens")
81
+ or prompt_details.get("cache_read_tokens")
82
+ or input_details.get("cached_tokens")
83
+ or input_details.get("cache_read_input_tokens")
84
+ or input_details.get("cache_read_tokens")
85
+ )
86
+ total_tokens = _coerce_int(usage.get("total_tokens"))
87
+ if total_tokens <= 0:
88
+ total_tokens = input_tokens + output_tokens
89
+
90
+ return {
91
+ "input_tokens": input_tokens,
92
+ "output_tokens": output_tokens,
93
+ "cache_creation_tokens": cache_creation_tokens,
94
+ "cache_read_tokens": cache_read_tokens,
95
+ "total_tokens": total_tokens,
96
+ }
97
+
98
+
99
+ def extract_claude_usage(response: Dict[str, Any]) -> Dict[str, int]:
100
+ """Extract usage from a Claude-compatible response payload."""
101
+ usage = response.get("usage") or {}
102
+ input_tokens = _coerce_int(
103
+ usage.get("input_tokens") or usage.get("prompt_tokens")
104
+ )
105
+ output_tokens = _coerce_int(
106
+ usage.get("output_tokens") or usage.get("completion_tokens")
107
+ )
108
+ cache_creation_tokens = _coerce_int(
109
+ usage.get("cache_creation_input_tokens")
110
+ or usage.get("cache_creation_tokens")
111
+ )
112
+ cache_read_tokens = _coerce_int(
113
+ usage.get("cache_read_input_tokens")
114
+ or usage.get("cached_tokens")
115
+ or usage.get("cache_read_tokens")
116
+ )
117
+ total_tokens = _coerce_int(usage.get("total_tokens"))
118
+ if total_tokens <= 0:
119
+ total_tokens = (
120
+ input_tokens
121
+ + output_tokens
122
+ + cache_creation_tokens
123
+ + cache_read_tokens
124
+ )
125
+
126
+ return {
127
+ "input_tokens": input_tokens,
128
+ "output_tokens": output_tokens,
129
+ "cache_creation_tokens": cache_creation_tokens,
130
+ "cache_read_tokens": cache_read_tokens,
131
+ "total_tokens": total_tokens,
132
+ }
133
+
134
+
135
+ async def write_request_log(
136
+ *,
137
+ provider: str,
138
+ model: str,
139
+ source_info: RequestSourceInfo,
140
+ success: bool,
141
+ started_at: float,
142
+ status_code: int = 200,
143
+ first_token_time: float = 0.0,
144
+ input_tokens: int = 0,
145
+ output_tokens: int = 0,
146
+ cache_creation_tokens: int = 0,
147
+ cache_read_tokens: int = 0,
148
+ total_tokens: Optional[int] = None,
149
+ error_message: Optional[str] = None,
150
+ ) -> None:
151
+ """Persist a request log entry without breaking request handling."""
152
+ duration = max(0.0, time.perf_counter() - started_at)
153
+ try:
154
+ dao = get_request_log_dao()
155
+ await dao.add_log(
156
+ provider=provider,
157
+ endpoint=source_info.endpoint,
158
+ source=source_info.source,
159
+ protocol=source_info.protocol,
160
+ client_name=source_info.client_name,
161
+ model=model,
162
+ status_code=status_code,
163
+ success=success,
164
+ duration=duration,
165
+ first_token_time=first_token_time,
166
+ input_tokens=input_tokens,
167
+ output_tokens=output_tokens,
168
+ cache_creation_tokens=cache_creation_tokens,
169
+ cache_read_tokens=cache_read_tokens,
170
+ total_tokens=total_tokens,
171
+ error_message=error_message,
172
+ )
173
+ except Exception as exc:
174
+ logger.error(f"写入请求日志失败: {exc}")
175
+
176
+
177
+ def _openai_payload_has_output(payload: Dict[str, Any]) -> bool:
178
+ choice = ((payload.get("choices") or [{}])[0]) if isinstance(payload, dict) else {}
179
+ delta = choice.get("delta") or {}
180
+ return bool(
181
+ delta.get("content")
182
+ or delta.get("reasoning_content")
183
+ or delta.get("tool_calls")
184
+ )
185
+
186
+
187
+ async def wrap_openai_stream_with_logging(
188
+ stream: AsyncGenerator[str, None],
189
+ *,
190
+ provider: str,
191
+ model: str,
192
+ source_info: RequestSourceInfo,
193
+ started_at: float,
194
+ ) -> AsyncGenerator[str, None]:
195
+ """Wrap OpenAI SSE stream and persist completion metadata."""
196
+ success = True
197
+ status_code = 200
198
+ error_message: Optional[str] = None
199
+ first_token_time = 0.0
200
+ usage = {
201
+ "input_tokens": 0,
202
+ "output_tokens": 0,
203
+ "cache_creation_tokens": 0,
204
+ "cache_read_tokens": 0,
205
+ "total_tokens": 0,
206
+ }
207
+
208
+ try:
209
+ async for chunk in stream:
210
+ if chunk.startswith("data: "):
211
+ payload_text = chunk[6:].strip()
212
+ if payload_text and payload_text != "[DONE]":
213
+ try:
214
+ payload = json.loads(payload_text)
215
+ except json.JSONDecodeError:
216
+ payload = None
217
+
218
+ if isinstance(payload, dict):
219
+ if "error" in payload:
220
+ success = False
221
+ error = payload.get("error") or {}
222
+ error_message = (
223
+ error.get("message")
224
+ or "Unknown stream error"
225
+ )
226
+ status_code = int(error.get("code") or 500)
227
+ else:
228
+ if (
229
+ not first_token_time
230
+ and _openai_payload_has_output(payload)
231
+ ):
232
+ first_token_time = max(
233
+ 0.0,
234
+ time.perf_counter() - started_at,
235
+ )
236
+ if payload.get("usage"):
237
+ usage = _merge_usage(
238
+ usage,
239
+ extract_openai_usage(payload),
240
+ include_cache_in_total=False,
241
+ )
242
+
243
+ yield chunk
244
+ except Exception as exc:
245
+ success = False
246
+ status_code = 500
247
+ error_message = str(exc)
248
+ raise
249
+ finally:
250
+ await write_request_log(
251
+ provider=provider,
252
+ model=model,
253
+ source_info=source_info,
254
+ success=success,
255
+ started_at=started_at,
256
+ status_code=status_code,
257
+ first_token_time=first_token_time,
258
+ input_tokens=usage["input_tokens"],
259
+ output_tokens=usage["output_tokens"],
260
+ cache_creation_tokens=usage["cache_creation_tokens"],
261
+ cache_read_tokens=usage["cache_read_tokens"],
262
+ total_tokens=usage["total_tokens"],
263
+ error_message=error_message,
264
+ )
265
+
266
+
267
+ async def wrap_claude_stream_with_logging(
268
+ stream: AsyncGenerator[str, None],
269
+ *,
270
+ provider: str,
271
+ model: str,
272
+ source_info: RequestSourceInfo,
273
+ started_at: float,
274
+ input_tokens: int,
275
+ ) -> AsyncGenerator[str, None]:
276
+ """Wrap Claude SSE stream and persist completion metadata."""
277
+ success = True
278
+ status_code = 200
279
+ error_message: Optional[str] = None
280
+ first_token_time = 0.0
281
+ usage = {
282
+ "input_tokens": input_tokens,
283
+ "output_tokens": 0,
284
+ "cache_creation_tokens": 0,
285
+ "cache_read_tokens": 0,
286
+ "total_tokens": input_tokens,
287
+ }
288
+ current_event: Optional[str] = None
289
+
290
+ try:
291
+ async for chunk in stream:
292
+ if chunk.startswith("event: "):
293
+ current_event = chunk[7:].strip()
294
+ elif chunk.startswith("data: "):
295
+ payload_text = chunk[6:].strip()
296
+ try:
297
+ payload = json.loads(payload_text)
298
+ except json.JSONDecodeError:
299
+ payload = None
300
+
301
+ if isinstance(payload, dict):
302
+ if current_event == "content_block_delta" and not first_token_time:
303
+ first_token_time = max(0.0, time.perf_counter() - started_at)
304
+ if payload.get("usage"):
305
+ usage = _merge_usage(
306
+ usage,
307
+ extract_claude_usage(payload),
308
+ include_cache_in_total=True,
309
+ )
310
+ elif current_event == "error":
311
+ success = False
312
+ status_code = 500
313
+ error = payload.get("error") or {}
314
+ error_message = error.get("message") or "Claude stream error"
315
+
316
+ yield chunk
317
+ except Exception as exc:
318
+ success = False
319
+ status_code = 500
320
+ error_message = str(exc)
321
+ raise
322
+ finally:
323
+ await write_request_log(
324
+ provider=provider,
325
+ model=model,
326
+ source_info=source_info,
327
+ success=success,
328
+ started_at=started_at,
329
+ status_code=status_code,
330
+ first_token_time=first_token_time,
331
+ input_tokens=usage["input_tokens"],
332
+ output_tokens=usage["output_tokens"],
333
+ cache_creation_tokens=usage["cache_creation_tokens"],
334
+ cache_read_tokens=usage["cache_read_tokens"],
335
+ total_tokens=usage["total_tokens"],
336
+ error_message=error_message,
337
+ )
app/utils/request_source.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """请求来源识别辅助函数。"""
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ from dataclasses import dataclass
10
+ from typing import Any, Optional
11
+
12
+ from fastapi import Request
13
+
14
+
15
+ ANTHROPIC_MODEL_PREFIXES = (
16
+ "claude-",
17
+ "claude.",
18
+ )
19
+ ANTHROPIC_MODEL_ALIASES = {
20
+ "sonnet",
21
+ "opus",
22
+ "haiku",
23
+ "opusplan",
24
+ }
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class RequestSourceInfo:
29
+ """Normalized request-source metadata for logging."""
30
+
31
+ source: str
32
+ protocol: str
33
+ client_name: str
34
+ endpoint: str
35
+ user_agent: str
36
+
37
+
38
+ def _normalize_source_name(value: str) -> str:
39
+ normalized = re.sub(r"[^a-zA-Z0-9._-]+", "_", value.strip().lower())
40
+ return normalized.strip("_") or "unknown"
41
+
42
+
43
+ def _looks_like_anthropic_model(model_hint: Optional[str]) -> bool:
44
+ if not isinstance(model_hint, str):
45
+ return False
46
+
47
+ normalized = model_hint.strip().casefold()
48
+ if normalized in ANTHROPIC_MODEL_ALIASES:
49
+ return True
50
+
51
+ return normalized.startswith(ANTHROPIC_MODEL_PREFIXES)
52
+
53
+
54
+ def detect_request_source(
55
+ request: Request,
56
+ protocol_hint: Optional[str] = None,
57
+ model_hint: Optional[str] = None,
58
+ ) -> RequestSourceInfo:
59
+ """Detect the request source from headers, path, and model hints."""
60
+ headers = request.headers
61
+ endpoint = request.url.path
62
+ user_agent = (headers.get("user-agent") or "").strip()
63
+ user_agent_normalized = user_agent.casefold()
64
+
65
+ protocol = (protocol_hint or "").strip().lower()
66
+ if not protocol:
67
+ if headers.get("anthropic-version") or "/messages" in endpoint:
68
+ protocol = "anthropic"
69
+ elif "/chat/completions" in endpoint:
70
+ protocol = "openai"
71
+ else:
72
+ protocol = "unknown"
73
+
74
+ explicit_source = headers.get("x-request-source") or headers.get("x-client-source")
75
+ if explicit_source:
76
+ source = _normalize_source_name(explicit_source)
77
+ return RequestSourceInfo(
78
+ source=source,
79
+ protocol=protocol,
80
+ client_name=explicit_source.strip(),
81
+ endpoint=endpoint,
82
+ user_agent=user_agent,
83
+ )
84
+
85
+ if any(token in user_agent_normalized for token in ("claude-code", "claude code", "claude-cli", "claude/")):
86
+ source = "claude_code"
87
+ client_name = "Claude Code"
88
+ elif "anthropic" in user_agent_normalized:
89
+ source = "anthropic_sdk"
90
+ client_name = "Anthropic SDK"
91
+ elif "openai" in user_agent_normalized:
92
+ source = "openai_sdk"
93
+ client_name = "OpenAI SDK"
94
+ elif "curl/" in user_agent_normalized:
95
+ source = "curl"
96
+ client_name = "curl"
97
+ elif any(token in user_agent_normalized for token in ("python-httpx", "httpx/", "python-requests", "requests/")):
98
+ source = "custom_http_client"
99
+ client_name = "HTTP Client"
100
+ elif "mozilla/" in user_agent_normalized:
101
+ source = "browser"
102
+ client_name = "Browser"
103
+ elif protocol == "anthropic":
104
+ source = "claude_family" if _looks_like_anthropic_model(model_hint) else "anthropic_compatible"
105
+ client_name = "Claude/Anthropic Compatible"
106
+ elif protocol == "openai":
107
+ source = "openai_compatible"
108
+ client_name = "OpenAI Compatible"
109
+ else:
110
+ source = "unknown"
111
+ client_name = "Unknown"
112
+
113
+ return RequestSourceInfo(
114
+ source=source,
115
+ protocol=protocol,
116
+ client_name=client_name,
117
+ endpoint=endpoint,
118
+ user_agent=user_agent,
119
+ )
120
+
121
+
122
+ def format_request_source(info: RequestSourceInfo) -> str:
123
+ """Render request-source metadata into a compact log prefix."""
124
+ return (
125
+ f"[source={info.source}]"
126
+ f"[protocol={info.protocol}]"
127
+ f"[client={info.client_name}]"
128
+ f"[endpoint={info.endpoint}]"
129
+ )
app/utils/signature.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """
5
+ Z.AI 签名工具模块
6
+ """
7
+
8
+ import hmac
9
+ import hashlib
10
+ import base64
11
+ from typing import Dict
12
+
13
+
14
+ def generate_signature(e: str, t: str, s: int) -> dict:
15
+ """Generate signature matching JavaScript zs function.
16
+
17
+ Args:
18
+ e: canonical metadata string, e.g. "requestId,<uuid>,timestamp,<ms>,user_id,<id>"
19
+ t: latest user message text that feeds into the signature prompt (may be empty)
20
+ s: timestamp in milliseconds
21
+
22
+ Returns:
23
+ Dictionary with signature and timestamp
24
+ """
25
+ # r = Number(s) - convert to number (already a number in Python)
26
+ r = s
27
+ # i = s - timestamp as string
28
+ i = str(s)
29
+
30
+ # n = new TextEncoder
31
+ # a = n.encode(t)
32
+ a = t.encode('utf-8')
33
+
34
+ # w = btoa(String.fromCharCode(...a))
35
+ # This is equivalent to base64 encoding the UTF-8 bytes
36
+ w = base64.b64encode(a).decode('ascii')
37
+
38
+ # c = `${e}|${w}|${i}`
39
+ c = f"{e}|{w}|{i}"
40
+
41
+ # E = Math.floor(r / (5 * 60 * 1e3))
42
+ E = r // (5 * 60 * 1000)
43
+
44
+ # A = CryptoJS.HmacSHA256(`${E}`, "key-@@@@)))()((9))-xxxx&&&%%%%%")
45
+ secret = "key-@@@@)))()((9))-xxxx&&&%%%%%"
46
+ A = hmac.new(secret.encode('utf-8'), str(E).encode('utf-8'), hashlib.sha256).hexdigest()
47
+
48
+ # k = CryptoJS.HmacSHA256(c, A).toString()
49
+ k = hmac.new(A.encode('utf-8'), c.encode('utf-8'), hashlib.sha256).hexdigest()
50
+
51
+ # return n.encode(c), { signature: k, timestamp: i }
52
+ # Note: n.encode(c) is not used in the return value, so we ignore it
53
+ return {
54
+ "signature": k,
55
+ "timestamp": i
56
+ }