Spaces:
Running
Running
Commit ·
36fae79
0
Parent(s):
deploy: initial release to Hugging Face Spaces
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +36 -0
- .gitignore +37 -0
- DEPLOY.md +377 -0
- Dockerfile +25 -0
- README.md +256 -0
- app/__init__.py +0 -0
- app/adapters/__init__.py +0 -0
- app/adapters/gemini/__init__.py +9 -0
- app/adapters/gemini/config.py +126 -0
- app/adapters/gemini/messages.py +194 -0
- app/adapters/gemini/response.py +162 -0
- app/adapters/gemini/stream.py +171 -0
- app/adapters/gemini/tools.py +61 -0
- app/adapters/gemini_api.py +307 -0
- app/adapters/openai.py +171 -0
- app/admin_html.py +870 -0
- app/api/__init__.py +0 -0
- app/api/_common.py +195 -0
- app/api/admin.py +377 -0
- app/api/admin_data.py +60 -0
- app/api/health.py +39 -0
- app/api/image_fix.py +70 -0
- app/api/logs.py +128 -0
- app/api/openai_compat.py +307 -0
- app/api/pseudo_stream.py +240 -0
- app/api/request_logs.py +98 -0
- app/api/sessions.py +119 -0
- app/api/usage_audit.py +47 -0
- app/api/webhooks.py +92 -0
- app/api/xtc.py +557 -0
- app/auth.py +226 -0
- app/cache.py +26 -0
- app/config.py +128 -0
- app/database.py +266 -0
- app/errors.py +168 -0
- app/hf_storage.py +173 -0
- app/http_client.py +42 -0
- app/main.py +126 -0
- app/media/__init__.py +0 -0
- app/media/imagefix.py +159 -0
- app/middleware.py +84 -0
- app/models/__init__.py +0 -0
- app/models/config.py +166 -0
- app/providers/__init__.py +0 -0
- app/providers/keypool.py +55 -0
- app/providers/policy.py +56 -0
- app/providers/resolver.py +58 -0
- app/request_log_middleware.py +258 -0
- app/services/__init__.py +0 -0
- app/services/config_store.py +255 -0
.env.example
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ===== 鉴权 =====
|
| 2 |
+
# 后台管理密钥(访问 /admin 时用于登录)
|
| 3 |
+
XTC_ADMIN_KEY=change-me-admin-key
|
| 4 |
+
# 前端长期访问密钥(手表端请求时附带)
|
| 5 |
+
XTC_ACCESS_KEY=change-me-access-key
|
| 6 |
+
# JWT 签名密钥(临时令牌签发,必须设置)
|
| 7 |
+
XTC_JWT_SECRET=change-me-jwt-secret-strong-random
|
| 8 |
+
|
| 9 |
+
# ===== 紧急开关 =====
|
| 10 |
+
# 紧急关闭后台与 /admin/api/*(默认 false)
|
| 11 |
+
XTC_DISABLE_ADMIN=false
|
| 12 |
+
|
| 13 |
+
# ===== 上游初始化(仅做种子,其余厂商在后台配)=====
|
| 14 |
+
# 可留空,启动后用 /admin 添加
|
| 15 |
+
GEMINI_API_KEY=
|
| 16 |
+
OPENAI_API_KEY=
|
| 17 |
+
|
| 18 |
+
# ===== 上游超时 =====
|
| 19 |
+
# 单一超时配置,无 Edge 分支
|
| 20 |
+
UPSTREAM_TIMEOUT_MS=120000
|
| 21 |
+
|
| 22 |
+
# ===== HF Hub 配置备份(可选)=====
|
| 23 |
+
# 配置文件通过 HF Hub dataset 仓库做持久化备份
|
| 24 |
+
# 解决 HF Spaces 免费档磁盘 ephemeral(restart/stop 丢数据)问题
|
| 25 |
+
# 未配置时降级为纯本地存储,功能正常但重建后配置会丢失
|
| 26 |
+
# HF_TOKEN:访问 HF Hub 仓库的令牌(read/write 权限)
|
| 27 |
+
# HF_CONFIG_REPO:仓库名称,格式 用户名/仓库名(如 myname/xtc-config)
|
| 28 |
+
HF_TOKEN=
|
| 29 |
+
HF_CONFIG_REPO=
|
| 30 |
+
|
| 31 |
+
# ===== 临时令牌 =====
|
| 32 |
+
XTC_ACCESS_TOKEN_TTL_SEC=1800
|
| 33 |
+
XTC_ACCESS_TOKEN_LENGTH=8
|
| 34 |
+
|
| 35 |
+
# ===== 运行 =====
|
| 36 |
+
PORT=7860
|
.gitignore
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
.venv/
|
| 8 |
+
venv/
|
| 9 |
+
env/
|
| 10 |
+
ENV/
|
| 11 |
+
|
| 12 |
+
# 测试与缓存
|
| 13 |
+
.pytest_cache/
|
| 14 |
+
.mypy_cache/
|
| 15 |
+
.ruff_cache/
|
| 16 |
+
*.egg-info/
|
| 17 |
+
|
| 18 |
+
# 环境变量(不提交真实密钥)
|
| 19 |
+
.env
|
| 20 |
+
.env.local
|
| 21 |
+
.env.*.local
|
| 22 |
+
|
| 23 |
+
# 数据库与运行时
|
| 24 |
+
*.db
|
| 25 |
+
*.db-journal
|
| 26 |
+
*.db-wal
|
| 27 |
+
*.db-shm
|
| 28 |
+
/data/
|
| 29 |
+
|
| 30 |
+
# IDE
|
| 31 |
+
.vscode/
|
| 32 |
+
.idea/
|
| 33 |
+
*.swp
|
| 34 |
+
|
| 35 |
+
# OS
|
| 36 |
+
.DS_Store
|
| 37 |
+
Thumbs.db
|
DEPLOY.md
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 部署文档(Hugging Face Spaces)
|
| 2 |
+
|
| 3 |
+
本文档说明如何把 XTC 后端部署到 Hugging Face Spaces,采用**混合存储方案**:本地 SQLite 持久卷(热存储)+ HF Hub dataset 仓库(配置备份)。
|
| 4 |
+
|
| 5 |
+
> **存储方案说明**:本项目采用混合方案——
|
| 6 |
+
> - **主体数据**(令牌、会话、请求历史、用量、审计、速率限制、Webhook)存在 HF Spaces 本地的 `/data/xtc.db`(SQLite)
|
| 7 |
+
> - **配置文件**(providers / 默认厂商)额外同步到 HF Hub dataset 仓库做备份
|
| 8 |
+
>
|
| 9 |
+
> **为什么配置要走 Hub?** HF Spaces **免费档磁盘是 ephemeral(临时)**,restart/stop 会清空 `/data`,导致配置丢失。把配置 JSON 备份到 HF Hub dataset 仓库(随账号持久保存),重建后能自动拉取恢复。其余数据(会话/日志等)反正不重启也无所谓,纯本地即可。
|
| 10 |
+
>
|
| 11 |
+
> **付费档用户**($5/月 Small 持久存储):`/data` 永久保留,可不配置 Hub 备份(但配了也无妨,多一层保险)。
|
| 12 |
+
|
| 13 |
+
---
|
| 14 |
+
|
| 15 |
+
## 一、整体架构
|
| 16 |
+
|
| 17 |
+
```
|
| 18 |
+
手表端 / 前端
|
| 19 |
+
│ HTTPS
|
| 20 |
+
▼
|
| 21 |
+
Cloudflare Workers(反向代理,可选)
|
| 22 |
+
│ 解决:免费域名 / 国内访问 / 48h 休眠保活
|
| 23 |
+
▼
|
| 24 |
+
Hugging Face Spaces(Docker 类型)
|
| 25 |
+
│ 运行 FastAPI + uvicorn
|
| 26 |
+
▼
|
| 27 |
+
/data/xtc.db(SQLite 持久卷)
|
| 28 |
+
│
|
| 29 |
+
├─ config 配置(providers / 默认厂商)──┐ 备份
|
| 30 |
+
├─ access_tokens 临时令牌 │
|
| 31 |
+
├─ sessions 会话历史 │
|
| 32 |
+
├─ session_messages 会话消息 │
|
| 33 |
+
├─ request_log 请求历史(最近 N 条) │ 仅配置
|
| 34 |
+
├─ usage_log 用量统计 │ 走 Hub
|
| 35 |
+
├─ audit_log 审计日志 │
|
| 36 |
+
├─ rate_limit 速率限制 │
|
| 37 |
+
└─ webhook_config Webhook 配置 │
|
| 38 |
+
▼
|
| 39 |
+
HF Hub dataset 仓库(config/app_config.json)
|
| 40 |
+
解决免费档 ephemeral 问题,重建后自动恢复配置
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
**为什么仅配置走 Hub,其余纯本地?**
|
| 44 |
+
- 单实例部署,Hub 的"多实例同步"优势用不上,避免无谓的网络开销
|
| 45 |
+
- 本地 SQLite 读写比网络 API 快几个数量级
|
| 46 |
+
- 配置是唯一"丢了很麻烦"的数据(要重新填所有 API Key),其余数据(日志/会话)重建丢失可接受
|
| 47 |
+
- 图片/文件前端会重传,不需要后端长期存储
|
| 48 |
+
- 复杂度可控:只在 `config_store` 一处接入 Hub,其余模块无感知
|
| 49 |
+
|
| 50 |
+
**持久性保证**:
|
| 51 |
+
- HF Spaces 免费档:restart/stop 清空 `/data`,但配置已备份到 Hub,重建后自动恢复
|
| 52 |
+
- HF Spaces 付费档($5/月):`/data` 永久保留,所有数据都不丢
|
| 53 |
+
- 删除整个 Space:`/data` 丢失,但 Hub 仓库里的配置备份仍在
|
| 54 |
+
|
| 55 |
+
---
|
| 56 |
+
|
| 57 |
+
## 二、前置准备
|
| 58 |
+
|
| 59 |
+
### 1. 注册 Hugging Face 账号
|
| 60 |
+
访问 https://huggingface.co/join 注册(免费)。
|
| 61 |
+
|
| 62 |
+
### 2. 准备代码仓库
|
| 63 |
+
本项目代码在 GitHub 仓库 `luckfun233/AI` 的 `xtc-backend-hf/` 目录。HF Spaces 支持两种方式同步代码:
|
| 64 |
+
- **方式 A(推荐)**:HF Space 直接从 GitHub 同步(每次 push 自动重建)
|
| 65 |
+
- **方式 B**:手动把代码推到 HF Space 的 Git 仓库
|
| 66 |
+
|
| 67 |
+
---
|
| 68 |
+
|
| 69 |
+
## 三、创建 HF Space
|
| 70 |
+
|
| 71 |
+
### 1. 新建 Space
|
| 72 |
+
1. 访问 https://huggingface.co/new-space
|
| 73 |
+
2. 填写:
|
| 74 |
+
- **Space name**:`xtc-backend`(或任意名)
|
| 75 |
+
- **License**:随意
|
| 76 |
+
- **SDK**:选择 **Docker**
|
| 77 |
+
- **Space hardware**:**CPU basic (Free)**(免费档够用)
|
| 78 |
+
- **Visibility**:**Private**(强烈建议私有,避免后台暴露)
|
| 79 |
+
3. 点击 **Create Space**
|
| 80 |
+
|
| 81 |
+
### 2. 绑定持久化存储(可选,付费档)
|
| 82 |
+
**免费档磁盘是 ephemeral(临时)**,restart/stop 会清空 `/data`。本项目通过 HF Hub 仓库备份配置来规避此问题(见下一步),所以**免费档也能用**。
|
| 83 |
+
|
| 84 |
+
如需所有数据(会话/日志等)都持久保留,可开通付费持久存储:
|
| 85 |
+
|
| 86 |
+
1. 进入刚创建的 Space
|
| 87 |
+
2. 点击顶部 **Settings** 标签
|
| 88 |
+
3. 找到 **Persistent storage** 区域
|
| 89 |
+
4. 选择容量(**20 GB Small - $5/month**,最小档够用)
|
| 90 |
+
5. 点击 **Attach** 并完成支付
|
| 91 |
+
|
| 92 |
+
> **免费档 vs 付费档**:
|
| 93 |
+
> - **免费档**:配置靠 Hub 备份(不丢),其余数据 restart/stop 后清空(可接受)
|
| 94 |
+
> - **付费档**:所有数据永久保留,Hub 备份作为额外保险
|
| 95 |
+
|
| 96 |
+
### 3. 创建 HF Hub 配置备份仓库(免费档强烈建议)
|
| 97 |
+
用于备份配置文件,解决免费档磁盘 ephemeral 问题:
|
| 98 |
+
|
| 99 |
+
1. 访问 https://huggingface.co/new-dataset
|
| 100 |
+
2. 填写:
|
| 101 |
+
- **Dataset name**:`xtc-config`(或任意名)
|
| 102 |
+
- **License**:随意
|
| 103 |
+
- **Visibility**:**Private**(强烈建议私有,配置含 API Key)
|
| 104 |
+
3. 点击 **Create dataset**
|
| 105 |
+
|
| 106 |
+
记录仓库名称,格式为 `用户名/xtc-config`(如 `luckfun233/xtc-config`),后面配置 `HF_CONFIG_REPO` 要用。
|
| 107 |
+
|
| 108 |
+
### 4. 创建 HF Access Token
|
| 109 |
+
用于后端读写配置备份仓库:
|
| 110 |
+
|
| 111 |
+
1. 访问 https://huggingface.co/settings/tokens
|
| 112 |
+
2. 点击 **New token**
|
| 113 |
+
3. 填写 Name(如 `xtc-backend`),Type 选 **Write**(需要写权限推送配置)
|
| 114 |
+
4. 复制 token(以 `hf_` 开头),后面配置 `HF_TOKEN` 要用
|
| 115 |
+
|
| 116 |
+
> **权限说明**:token 需要对上一步创建的 dataset 仓库有 write 权限。Space 和 dataset 在同一账号下时,Write token 自动有权限。
|
| 117 |
+
|
| 118 |
+
### 5. 同步代码
|
| 119 |
+
**方式 A:从 GitHub 同步(推荐)**
|
| 120 |
+
1. Space Settings → **Repositories** → **Connect a GitHub repository**
|
| 121 |
+
2. 选择 `luckfun233/AI` 仓库
|
| 122 |
+
3. **Root directory** 填写:`xtc-backend-hf`
|
| 123 |
+
4. 保存后,每次 GitHub push 自动触发 HF 重建
|
| 124 |
+
|
| 125 |
+
**方式 B:手动推送**
|
| 126 |
+
```bash
|
| 127 |
+
# 在 HF 账号设置里创建 Access Token:https://huggingface.co/settings/tokens
|
| 128 |
+
# 权限选 Write,复制 token
|
| 129 |
+
|
| 130 |
+
cd xtc-backend-hf
|
| 131 |
+
git init
|
| 132 |
+
git remote add hf https://<你的HF用户名>:<HF_TOKEN>@huggingface.co/spaces/<你的HF用户名>/xtc-backend
|
| 133 |
+
git add .
|
| 134 |
+
git commit -m "init"
|
| 135 |
+
git push hf main
|
| 136 |
+
```
|
| 137 |
+
|
| 138 |
+
---
|
| 139 |
+
|
| 140 |
+
## 四、配置环境变量
|
| 141 |
+
|
| 142 |
+
**这是最关键的一步**。在 Space Settings → **Variables and secrets** 中添加以下环境变量:
|
| 143 |
+
|
| 144 |
+
### 必填(不填无法正常运行)
|
| 145 |
+
|
| 146 |
+
| 变量名 | 说明 | 示例值 |
|
| 147 |
+
|---|---|---|
|
| 148 |
+
| `XTC_ADMIN_KEY` | 后台管理密钥,访问 `/admin` 登录用 | `my-super-admin-key-2026` |
|
| 149 |
+
| `XTC_ACCESS_KEY` | 前端/手表端长期访问密钥 | `my-access-key-for-watch` |
|
| 150 |
+
| `XTC_JWT_SECRET` | JWT 签名密钥(临时令牌),**必须随机长字符串** | `a1b2c3d4e5...(32位以上随机)` |
|
| 151 |
+
|
| 152 |
+
> **生成随机密钥**:在本地终端运行 `openssl rand -hex 32` 生成 `XTC_JWT_SECRET`。
|
| 153 |
+
|
| 154 |
+
### 选填(有默认值,通常不用改)
|
| 155 |
+
|
| 156 |
+
| 变量名 | 默认值 | 说明 |
|
| 157 |
+
|---|---|---|
|
| 158 |
+
| `PORT` | `7860` | 服务端口(HF Spaces 固定 7860,不要改) |
|
| 159 |
+
| `XTC_DB_PATH` | `/data/xtc.db` | SQLite 数据库路径(持久卷路径,不要改) |
|
| 160 |
+
| `XTC_DISABLE_ADMIN` | `false` | 紧急关闭后台(设 `true` 则 `/admin` 不可用) |
|
| 161 |
+
| `UPSTREAM_TIMEOUT_MS` | `120000` | 上游 API 超时(毫秒) |
|
| 162 |
+
| `XTC_ACCESS_TOKEN_TTL_SEC` | `1800` | 临时令牌有效期(秒,60-86400) |
|
| 163 |
+
| `XTC_ACCESS_TOKEN_LENGTH` | `8` | 临时令牌长度(6-24) |
|
| 164 |
+
|
| 165 |
+
### 选填(种子厂商,也可启动后在后台配)
|
| 166 |
+
|
| 167 |
+
| 变量名 | 说明 |
|
| 168 |
+
|---|---|
|
| 169 |
+
| `GEMINI_API_KEY` | Gemini API 密钥(可填多个,逗号分隔) |
|
| 170 |
+
| `OPENAI_API_KEY` | OpenAI 兼容厂商的 API 密钥 |
|
| 171 |
+
| `OPENAI_BASE_URL` | OpenAI 兼容厂商的 base URL(默认 `https://api.openai.com`) |
|
| 172 |
+
|
| 173 |
+
> **建议**:`GEMINI_API_KEY` / `OPENAI_API_KEY` 留空,启动后用 `/admin` 后台添加厂商,这样密钥存在 SQLite 而非环境变量里,方便随时增删轮询。
|
| 174 |
+
|
| 175 |
+
### 选填(HF Hub 配置备份,免费档强烈建议)
|
| 176 |
+
|
| 177 |
+
| 变量名 | 说明 | 示例值 |
|
| 178 |
+
|---|---|---|
|
| 179 |
+
| `HF_TOKEN` | HF Hub 访问令牌(write 权限,见第三节第 4 步) | `hf_xxxxxxxxxxxxxxxxxxxx` |
|
| 180 |
+
| `HF_CONFIG_REPO` | 配置备份 dataset 仓库名(见第三节第 3 步) | `luckfun233/xtc-config` |
|
| 181 |
+
|
| 182 |
+
> **配置后才生效**:两个变量都填了才会启用 Hub 配置备份。只填一个不生效。
|
| 183 |
+
> **降级行为**:未配置时,后端纯本地运行(功能正常),但 restart/stop 后配置会丢失,需重新在后台添加厂商。
|
| 184 |
+
> **付费档用户**:`/data` 已持久,可不配(但配了多一层保险,删 Space 重建也能恢复配置)。
|
| 185 |
+
|
| 186 |
+
### 配置方式
|
| 187 |
+
|
| 188 |
+
在 Space Settings → **Variables and secrets**:
|
| 189 |
+
|
| 190 |
+
- **Variables**:普通环境变量(明文存储,如 `PORT`、`XTC_DB_PATH`、`HF_CONFIG_REPO`)
|
| 191 |
+
- **Secrets**:敏感信息(加密存储,推荐用于 `XTC_ADMIN_KEY`、`XTC_ACCESS_KEY`、`XTC_JWT_SECRET`、`HF_TOKEN`、`GEMINI_API_KEY`)
|
| 192 |
+
|
| 193 |
+
---
|
| 194 |
+
|
| 195 |
+
## 五、首次启动验证
|
| 196 |
+
|
| 197 |
+
### 1. 等待构建
|
| 198 |
+
Space 创建后会自动拉取代码、构建 Docker 镜像。在 Space 主页能看到构建日志,看到 `Running on http://0.0.0.0:7860` 即构建成功。
|
| 199 |
+
|
| 200 |
+
### 2. 健康检查
|
| 201 |
+
访问 `https://<你的用户名>-xtc-backend.hf.space/health`,应返回:
|
| 202 |
+
```json
|
| 203 |
+
{"ok": true, "service": "xtc-backend-hf", "version": "..."}
|
| 204 |
+
```
|
| 205 |
+
|
| 206 |
+
### 3. 登录后台
|
| 207 |
+
访问 `https://<你的用户名>-xtc-backend.hf.space/admin`,输入 `XTC_ADMIN_KEY` 登录。
|
| 208 |
+
|
| 209 |
+
### 4. 添加厂商
|
| 210 |
+
在后台 **厂商** Tab:
|
| 211 |
+
1. 点击"添加厂商"
|
| 212 |
+
2. 填写:
|
| 213 |
+
- **ID**:`gemini`(或任意唯一标识)
|
| 214 |
+
- **名称**:`Gemini`
|
| 215 |
+
- **类型**:`gemini`
|
| 216 |
+
- **API Keys**:填入你的 Gemini API Key(每行一个,支持多密钥轮询)
|
| 217 |
+
3. 保存
|
| 218 |
+
4. 设为默认厂商
|
| 219 |
+
|
| 220 |
+
### 5. 测试对话
|
| 221 |
+
在后台 **测试** Tab,输入消息测试是否正常返回。
|
| 222 |
+
|
| 223 |
+
### 6. 测试 API
|
| 224 |
+
```bash
|
| 225 |
+
curl -X POST https://<你的用户名>-xtc-backend.hf.space/v1/chat/completions \
|
| 226 |
+
-H "Authorization: Bearer <XTC_ACCESS_KEY>" \
|
| 227 |
+
-H "Content-Type: application/json" \
|
| 228 |
+
-d '{
|
| 229 |
+
"model": "gemini-flash-latest",
|
| 230 |
+
"messages": [{"role": "user", "content": "你好"}]
|
| 231 |
+
}'
|
| 232 |
+
```
|
| 233 |
+
|
| 234 |
+
---
|
| 235 |
+
|
| 236 |
+
## 六、Cloudflare Workers 反向代理(可选但推荐)
|
| 237 |
+
|
| 238 |
+
解决三个问题:
|
| 239 |
+
1. **免费域名**:HF Spaces 默认域名 `*.hf.space` 在国内可能无法访问
|
| 240 |
+
2. **国内访问**:CF Workers 节点在国内可达
|
| 241 |
+
3. **48h 休眠保活**:HF 免费档 48h 无请求会休眠,CF Workers 定时器保活
|
| 242 |
+
|
| 243 |
+
### 1. 创建 Worker
|
| 244 |
+
在 Cloudflare Dashboard → Workers & Pages → Create Worker:
|
| 245 |
+
```javascript
|
| 246 |
+
export default {
|
| 247 |
+
async fetch(request) {
|
| 248 |
+
const url = new URL(request.url);
|
| 249 |
+
url.hostname = '<你的用户名>-xtc-backend.hf.space';
|
| 250 |
+
return fetch(url, request);
|
| 251 |
+
},
|
| 252 |
+
// 每 24 小时保活一次
|
| 253 |
+
async scheduled(event, env, ctx) {
|
| 254 |
+
ctx.waitUntil(fetch('https://<你的用户名>-xtc-backend.hf.space/health'));
|
| 255 |
+
}
|
| 256 |
+
};
|
| 257 |
+
```
|
| 258 |
+
|
| 259 |
+
### 2. 配置定时器
|
| 260 |
+
Worker Settings → **Triggers** → **Cron Triggers**:
|
| 261 |
+
```
|
| 262 |
+
0 */12 * * *
|
| 263 |
+
```
|
| 264 |
+
(每 12 小时触发一次,保活)
|
| 265 |
+
|
| 266 |
+
### 3. 绑定自定义域名(可选)
|
| 267 |
+
Worker → Settings → **Triggers** → **Custom Domains**,添加你自己的域名。
|
| 268 |
+
|
| 269 |
+
---
|
| 270 |
+
|
| 271 |
+
## 七、数据备份与恢复
|
| 272 |
+
|
| 273 |
+
### 自动备份(配置文件)
|
| 274 |
+
配置了 `HF_TOKEN` + `HF_CONFIG_REPO` 后,每次在后台修改厂商配置都会**自动**把配置 JSON 推送到 HF Hub dataset 仓库的 `config/app_config.json`。无需手动操作。
|
| 275 |
+
|
| 276 |
+
### 手动备份
|
| 277 |
+
后台 → **概览** Tab 有"导出配置"按钮,可下载 `app_config.json`(含所有厂商配置)到本地。
|
| 278 |
+
|
| 279 |
+
如需备份完整数据库(含会话/日志),需通过 SSH/终端方式(HF Spaces 付费档支持)复制 `/data/xtc.db`。
|
| 280 |
+
|
| 281 |
+
### 恢复
|
| 282 |
+
- **免费档 restart/stop 后**:`/data` 被清空,但启动时后端会自动从 Hub 拉取 `config/app_config.json` 恢复配置(需已配置 `HF_TOKEN` + `HF_CONFIG_REPO`)。其余数据(会话/日志)丢失。
|
| 283 |
+
- **付费档重建后**:`/data` 保留,所有数据自动恢复,无需操作。
|
| 284 |
+
- **删除 Space 重建**:先配置环境变量(含 `HF_TOKEN` + `HF_CONFIG_REPO`),启动后配置自动从 Hub 恢复;或登录后台 → 厂商 → 导入手动导出的配置 JSON。
|
| 285 |
+
|
| 286 |
+
---
|
| 287 |
+
|
| 288 |
+
## 八、常见问题
|
| 289 |
+
|
| 290 |
+
### Q1: 重建 Space 后配置丢失?
|
| 291 |
+
**原因**:免费档磁盘是 ephemeral,restart/stop 会清空 `/data`;且未配置 HF Hub 配置备份。
|
| 292 |
+
**解决**:
|
| 293 |
+
1. 配置 `HF_TOKEN` + `HF_CONFIG_REPO` 环境变量(见第三节第 3-4 步),启用配置自动备份
|
| 294 |
+
2. 启动时后端会自动从 Hub 拉取配置恢复
|
| 295 |
+
3. 或开通付费持久存储($5/月),`/data` 永久保留
|
| 296 |
+
|
| 297 |
+
### Q2: 后台登录提示 "invalid admin key"?
|
| 298 |
+
**原因**:`XTC_ADMIN_KEY` 环境变量未设置或拼写错误。
|
| 299 |
+
**解决**:Space Settings → Variables and secrets 检查 `XTC_ADMIN_KEY`,注意 Secrets 修改后需要重启 Space。
|
| 300 |
+
|
| 301 |
+
### Q3: 手表端请求返回 401?
|
| 302 |
+
**原因**:`XTC_ACCESS_KEY` 不匹配,或请求头格式错误。
|
| 303 |
+
**解决**:
|
| 304 |
+
- 请求头格式:`Authorization: Bearer <XTC_ACCESS_KEY>`
|
| 305 |
+
- 检查环境变量值是否前后有空格
|
| 306 |
+
|
| 307 |
+
### Q4: Space 休眠了怎么办?
|
| 308 |
+
**原因**:HF 免费档 48h 无请求自动休眠。
|
| 309 |
+
**解决**:
|
| 310 |
+
- 配置 Cloudflare Workers 定时器(见第六节)
|
| 311 |
+
- 或用外部监控服务(如 UptimeRobot)每 5 分钟 ping `/health`
|
| 312 |
+
|
| 313 |
+
### Q5: 怎么查看请求日志?
|
| 314 |
+
后台 → **请求历史** Tab,默认保留最近 500 条完整请求(含标头、body、响应、耗时、错误码)。可在该 Tab 调整保留条数(0-2000)。
|
| 315 |
+
|
| 316 |
+
### Q6: 怎么添加多个 API Key 做轮询?
|
| 317 |
+
后台 → **厂商** → 编辑厂商 → **API Keys** 字段,每行填一个 key,自动轮询。
|
| 318 |
+
|
| 319 |
+
### Q7: 国内访问慢/连不上?
|
| 320 |
+
1. 用 Cloudflare Workers 反向代理(见第六节)
|
| 321 |
+
2. 或绑定自定义域名走 CF CDN
|
| 322 |
+
|
| 323 |
+
---
|
| 324 |
+
|
| 325 |
+
## 九、环境变量完整清单
|
| 326 |
+
|
| 327 |
+
```bash
|
| 328 |
+
# ===== 必填 =====
|
| 329 |
+
XTC_ADMIN_KEY=<你的后台管理密钥>
|
| 330 |
+
XTC_ACCESS_KEY=<前端访问密钥>
|
| 331 |
+
XTC_JWT_SECRET=<32位以上随机字符串>
|
| 332 |
+
|
| 333 |
+
# ===== 默认值(通常不用改)=====
|
| 334 |
+
PORT=7860
|
| 335 |
+
XTC_DB_PATH=/data/xtc.db
|
| 336 |
+
XTC_DISABLE_ADMIN=false
|
| 337 |
+
UPSTREAM_TIMEOUT_MS=120000
|
| 338 |
+
XTC_ACCESS_TOKEN_TTL_SEC=1800
|
| 339 |
+
XTC_ACCESS_TOKEN_LENGTH=8
|
| 340 |
+
|
| 341 |
+
# ===== 种子厂商(可选,留空用后台配)=====
|
| 342 |
+
GEMINI_API_KEY=
|
| 343 |
+
OPENAI_API_KEY=
|
| 344 |
+
OPENAI_BASE_URL=https://api.openai.com
|
| 345 |
+
|
| 346 |
+
# ===== HF Hub 配置备份(免费档强烈建议)=====
|
| 347 |
+
HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxx
|
| 348 |
+
HF_CONFIG_REPO=你的用户名/xtc-config
|
| 349 |
+
```
|
| 350 |
+
|
| 351 |
+
---
|
| 352 |
+
|
| 353 |
+
## 十、文件结构说明
|
| 354 |
+
|
| 355 |
+
部署后 Space 内的关键文件:
|
| 356 |
+
|
| 357 |
+
```
|
| 358 |
+
/data/ # 本地存储卷(免费档 ephemeral,付费档持久)
|
| 359 |
+
└── xtc.db # SQLite 数据库(所有数据,配置额外备份到 Hub)
|
| 360 |
+
|
| 361 |
+
/app/ # 应用代码(每次重建从 Git 拉取)
|
| 362 |
+
├── Dockerfile
|
| 363 |
+
├── requirements.txt
|
| 364 |
+
└── app/
|
| 365 |
+
├── main.py # FastAPI 入口
|
| 366 |
+
├── config.py # 环境变量配置
|
| 367 |
+
├���─ database.py # SQLite 初始化
|
| 368 |
+
├── hf_storage.py # HF Hub 配置备份抽象层
|
| 369 |
+
├── auth.py # 鉴权(admin key + access key + JWT)
|
| 370 |
+
├── middleware.py # 速率限制中间件
|
| 371 |
+
├── request_log_middleware.py # 请求日志中间件
|
| 372 |
+
├── errors.py # 统一错误模型
|
| 373 |
+
├── admin_html.py # 后台 HTML
|
| 374 |
+
├── adapters/ # Gemini/OpenAI 适配器
|
| 375 |
+
├── services/ # 业务逻辑层(config_store 接入 Hub 备份)
|
| 376 |
+
└── api/ # API 路由
|
| 377 |
+
```
|
Dockerfile
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# 系统依赖:libheif 用于 HEIC/HEIF 解码(图片修正增强)
|
| 6 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 7 |
+
libheif1 libheif-dev \
|
| 8 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 9 |
+
|
| 10 |
+
COPY requirements.txt .
|
| 11 |
+
RUN pip install --no-cache-dir -r requirements.txt \
|
| 12 |
+
&& pip install --no-cache-dir pillow-heif
|
| 13 |
+
|
| 14 |
+
COPY . .
|
| 15 |
+
|
| 16 |
+
ENV PORT=7860
|
| 17 |
+
ENV PYTHONUNBUFFERED=1
|
| 18 |
+
ENV XTC_DB_PATH=/data/xtc.db
|
| 19 |
+
EXPOSE 7860
|
| 20 |
+
|
| 21 |
+
# 持久化目录(HF Spaces 自动挂载 /data;本地 docker run 时可 -v 挂载)
|
| 22 |
+
RUN mkdir -p /data
|
| 23 |
+
VOLUME ["/data"]
|
| 24 |
+
|
| 25 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]
|
README.md
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: XTC Backend
|
| 3 |
+
emoji: 🚀
|
| 4 |
+
colorFrom: gray
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# XTC Backend (Hugging Face 版)
|
| 12 |
+
|
| 13 |
+
从 Netlify 版迁移而来的 OpenAI / Gemini 兼容网关,部署到 Hugging Face Spaces。
|
| 14 |
+
|
| 15 |
+
> **重要**:本目录是迁移重写版,原 Netlify 后端代码保留在 `../openai-gemini-main-master/` 作为备用,未被覆盖。
|
| 16 |
+
|
| 17 |
+
## 目录结构
|
| 18 |
+
|
| 19 |
+
```
|
| 20 |
+
xtc-backend-hf/
|
| 21 |
+
├── Dockerfile # HF Spaces Docker 镜像
|
| 22 |
+
├── requirements.txt # Python 依赖
|
| 23 |
+
├── .env.example # 环境变量样例
|
| 24 |
+
├── app/
|
| 25 |
+
│ ├── main.py # FastAPI 应用装配
|
| 26 |
+
│ ├── config.py # 环境变量配置(pydantic-settings)
|
| 27 |
+
│ ├── errors.py # 统一错误模型(兼容前端契约)
|
| 28 |
+
│ ├── auth.py # access_key + JWT 临时令牌
|
| 29 |
+
│ ├── database.py # SQLite 初始化
|
| 30 |
+
│ ├── cache.py # 模型列表 TTL 缓存
|
| 31 |
+
│ ├── http_client.py # httpx 全局连接池
|
| 32 |
+
│ ├── admin_html.py # 后台 HTML(占位版)
|
| 33 |
+
│ ├── api/ # FastAPI 路由
|
| 34 |
+
│ │ ├── health.py # /health
|
| 35 |
+
│ │ ├── openai_compat.py# /v1/chat/completions /v1/embeddings /v1/models
|
| 36 |
+
│ │ ├── xtc.py # /v1/xtc/providers /v1/xtc/models /v1/xtc/chat
|
| 37 |
+
│ │ ├── pseudo_stream.py# /v1/xtc/chat/pseudo/start /v1/xtc/chat/pseudo/poll
|
| 38 |
+
│ │ ├── image_fix.py # /v1/xtc/image/fix/test
|
| 39 |
+
│ │ ├── admin.py # /admin/api/*
|
| 40 |
+
│ │ ├── logs.py # /logs/api/*(合并原 xtc-log-receiver)
|
| 41 |
+
│ │ └── _common.py # 共享工具
|
| 42 |
+
│ ├── adapters/ # 上游适配器
|
| 43 |
+
│ │ ├── gemini_api.py # Gemini 总入口
|
| 44 |
+
│ │ ├── openai.py # OpenAI 兼容透传
|
| 45 |
+
│ │ └── gemini/ # Gemini→OpenAI 转换子模块
|
| 46 |
+
│ │ ├── messages.py # messages ↔ contents
|
| 47 |
+
│ │ ├── config.py # generation params
|
| 48 |
+
│ │ ├── tools.py # tools / tool_choice
|
| 49 |
+
│ │ ├── response.py # 非流式响应转换
|
| 50 |
+
│ │ └── stream.py # 流式 chunk 转换
|
| 51 |
+
│ ├── providers/ # 厂商解析与策略
|
| 52 |
+
│ │ ├── resolver.py # 解析厂商
|
| 53 |
+
│ │ ├── keypool.py # 多密钥轮询(持久化)
|
| 54 |
+
│ │ └── policy.py # 白/黑名单 + glob
|
| 55 |
+
│ ├── media/imagefix.py # 图片修正(Pillow)
|
| 56 |
+
│ ├── models/config.py # Provider / AppConfig 数据模型
|
| 57 |
+
│ ├── services/ # 业务服务
|
| 58 |
+
│ │ ├── config_store.py # 配置存储(SQLite + HF Hub 同步)
|
| 59 |
+
│ │ └── pseudo_store.py # 伪流式会话存储
|
| 60 |
+
│ └── utils/ # 工具
|
| 61 |
+
│ ├── thought.py # 思考链抽取
|
| 62 |
+
│ └── sse.py # SSE 解析
|
| 63 |
+
└── README.md
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
## 与原 Netlify 版的差异
|
| 67 |
+
|
| 68 |
+
### 简化(砍掉的复杂度)
|
| 69 |
+
|
| 70 |
+
| 简化项 | 原因 |
|
| 71 |
+
|---|---|
|
| 72 |
+
| 环境变量配厂商(`XTC_PROVIDER_1_*` ... `XTC_PROVIDER_12_*`) | 后端有数据库,Admin API 直接写 |
|
| 73 |
+
| 配置加密 `XTC_CONFIG_CRYPT_KEY` / `enc:v1:` | HF Secrets 加密 + 私有 Hub 仓库 + 容器内 SQLite 已够安全 |
|
| 74 |
+
| `XTC_CONFIG_ENV_ONLY` / `XTC_ADMIN_REQUIRE_KEY_ON_PAGE` | 改用 JWT 登录态 |
|
| 75 |
+
| `XTC_ACCESS_SHORT_KEY` / `XTC_ACCESS_PIN` | JWT 令牌已足够 |
|
| 76 |
+
| 多平台入口(6 个) | 只跑 HF Docker |
|
| 77 |
+
| Edge 路径 `/edge/*` | 无 Edge 概念 |
|
| 78 |
+
| `runtimeEnv` + `process.env` + `Deno.env` 三段式 | 只用 `os.environ` |
|
| 79 |
+
|
| 80 |
+
### 保留(兼容契约,前端零改动)
|
| 81 |
+
|
| 82 |
+
- OpenAI 兼容三件套:`/v1/chat/completions`、`/v1/embeddings`、`/v1/models`
|
| 83 |
+
- XTC 简化接口:`/v1/xtc/providers`、`/v1/xtc/models`、`/v1/xtc/chat`、`/v1/xtc/chat/pseudo/*`、`/v1/xtc/image/fix/test`
|
| 84 |
+
- 错误响应格式:`{ ok:false, error:{ code, message, status, retryable, hint, upstream?, details? } }`
|
| 85 |
+
- 错误码集合:`bad_request/unauthorized/forbidden/not_found/request_timeout/quota_exceeded/internal_error/service_unavailable/upstream_timeout/model_disabled/server_misconfigured`
|
| 86 |
+
- 图片修正三模式:`mirror_h` / `rotate_180` / `mirror_h_rotate_180`
|
| 87 |
+
- 流式协议:OpenAI SSE、`stream_mode: simple`、伪流式
|
| 88 |
+
- 响应头:`x-xtc-provider` / `x-xtc-model` / `x-xtc-image-fix-mode`
|
| 89 |
+
- Admin API 全部端点
|
| 90 |
+
|
| 91 |
+
### 新增
|
| 92 |
+
|
| 93 |
+
- HF Hub 仓库配置同步(`HF_TOKEN` + `HF_CONFIG_REPO`)
|
| 94 |
+
- JWT 临时令牌(替代内存 Map,多实例可用)
|
| 95 |
+
- 多密钥 RR 计数器持久化到 SQLite
|
| 96 |
+
- 伪流式会话存 SQLite(替代 Netlify Blobs)
|
| 97 |
+
- 日志接收合并进主服务(`/logs/api/*`)
|
| 98 |
+
- 用量统计表(`usage_log`)
|
| 99 |
+
- 审计日志表(`audit_log`)
|
| 100 |
+
|
| 101 |
+
## 环境变量
|
| 102 |
+
|
| 103 |
+
参考 [.env.example](file:///workspace/xtc-backend-hf/.env.example)。必填:
|
| 104 |
+
|
| 105 |
+
- `XTC_ADMIN_KEY`:后台登录密钥
|
| 106 |
+
- `XTC_ACCESS_KEY`:前端长期密钥
|
| 107 |
+
- `XTC_JWT_SECRET`:JWT 签名密钥(签发临时令牌用)
|
| 108 |
+
|
| 109 |
+
可选种子(首次启动初始化):
|
| 110 |
+
|
| 111 |
+
- `GEMINI_API_KEY` / `GEMINI_API_KEYS`(逗号分隔多 key)
|
| 112 |
+
- `OPENAI_API_KEY` / `OPENAI_API_KEYS`
|
| 113 |
+
- `OPENAI_BASE_URL`
|
| 114 |
+
|
| 115 |
+
可选 HF Hub 同步:
|
| 116 |
+
|
| 117 |
+
- `HF_TOKEN`:访问 Hub 仓库的令牌
|
| 118 |
+
- `HF_CONFIG_REPO`:仓库名(如 `your-username/xtc-config`,建议设为 dataset 私有仓库)
|
| 119 |
+
|
| 120 |
+
## 本地运行
|
| 121 |
+
|
| 122 |
+
```bash
|
| 123 |
+
cd xtc-backend-hf
|
| 124 |
+
python -m venv .venv && source .venv/bin/activate
|
| 125 |
+
pip install -r requirements.txt
|
| 126 |
+
pip install pillow-heif # 可选,HEIC 解码
|
| 127 |
+
|
| 128 |
+
# 配置环境变量
|
| 129 |
+
cp .env.example .env
|
| 130 |
+
# 编辑 .env 设置 XTC_ADMIN_KEY / XTC_ACCESS_KEY / XTC_JWT_SECRET
|
| 131 |
+
|
| 132 |
+
# 启动
|
| 133 |
+
uvicorn app.main:app --host 0.0.0.0 --port 7860 --reload
|
| 134 |
+
```
|
| 135 |
+
|
| 136 |
+
访问:
|
| 137 |
+
|
| 138 |
+
- 健康检查:http://localhost:7860/health
|
| 139 |
+
- 后台:http://localhost:7860/admin
|
| 140 |
+
- API 文档:http://localhost:7860/docs
|
| 141 |
+
|
| 142 |
+
## 部署到 Hugging Face Spaces
|
| 143 |
+
|
| 144 |
+
1. 在 HF 创建一个 **Docker SDK** 类型的 Space(私有推荐)
|
| 145 |
+
2. 在 Space Settings → Repository secrets 添加环境变量:
|
| 146 |
+
- `XTC_ADMIN_KEY`、`XTC_ACCESS_KEY`、`XTC_JWT_SECRET`
|
| 147 |
+
- 可选:`GEMINI_API_KEY`、`OPENAI_API_KEY`、`HF_TOKEN`、`HF_CONFIG_REPO`
|
| 148 |
+
3. 把本目录所有文件上传到 Space 仓库根目录(或用 GitHub Actions 同步)
|
| 149 |
+
4. HF 自动构建并启动,访问 `https://<space-name>.hf.space`
|
| 150 |
+
|
| 151 |
+
## 国内访问与保活(Cloudflare Workers 反代)
|
| 152 |
+
|
| 153 |
+
```javascript
|
| 154 |
+
// CF Workers 脚本
|
| 155 |
+
addEventListener("fetch", e => e.respondWith(handle(e.request)));
|
| 156 |
+
addEventListener("scheduled", e => e.waitUntil(keepalive()));
|
| 157 |
+
|
| 158 |
+
const UPSTREAM = "https://your-space.hf.space";
|
| 159 |
+
|
| 160 |
+
async function handle(req) {
|
| 161 |
+
const url = new URL(req.url);
|
| 162 |
+
const upstream = new URL(UPSTREAM + url.pathname + url.search);
|
| 163 |
+
const resp = await fetch(upstream, {
|
| 164 |
+
method: req.method, headers: req.headers, body: req.body,
|
| 165 |
+
});
|
| 166 |
+
return new Response(resp.body, resp);
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
async function keepalive() {
|
| 170 |
+
try { await fetch(UPSTREAM + "/health"); } catch (e) {}
|
| 171 |
+
}
|
| 172 |
+
```
|
| 173 |
+
|
| 174 |
+
在 `wrangler.toml` 配置 Cron Triggers(每 24 小时一次):
|
| 175 |
+
|
| 176 |
+
```toml
|
| 177 |
+
[triggers]
|
| 178 |
+
crons = ["0 */24 * * *"]
|
| 179 |
+
```
|
| 180 |
+
|
| 181 |
+
## 接口契约速查
|
| 182 |
+
|
| 183 |
+
### OpenAI 兼容
|
| 184 |
+
|
| 185 |
+
```
|
| 186 |
+
POST /v1/chat/completions # 头部 Authorization: Bearer <key> 或 x-xtc-access-key
|
| 187 |
+
POST /v1/embeddings
|
| 188 |
+
GET /v1/models
|
| 189 |
+
```
|
| 190 |
+
|
| 191 |
+
### XTC 简化
|
| 192 |
+
|
| 193 |
+
```
|
| 194 |
+
GET /v1/xtc/providers # { ok, defaultProviderId, providers[], count }
|
| 195 |
+
GET /v1/xtc/models?provider=&all=1 # { ok, provider, models[], count }
|
| 196 |
+
POST /v1/xtc/chat # 见下方字段
|
| 197 |
+
POST /v1/xtc/chat/pseudo/start # { ok, session_id, provider, model }
|
| 198 |
+
GET|POST /v1/xtc/chat/pseudo/poll # { ok, status, deltas[], cursor, expires_in_sec }
|
| 199 |
+
POST /v1/xtc/image/fix/test # 返回 PNG 二进制
|
| 200 |
+
```
|
| 201 |
+
|
| 202 |
+
`/v1/xtc/chat` 成功响应:
|
| 203 |
+
|
| 204 |
+
```json
|
| 205 |
+
{
|
| 206 |
+
"ok": true,
|
| 207 |
+
"provider": "gemini",
|
| 208 |
+
"model": "gemini-2.5-flash",
|
| 209 |
+
"thought": "...",
|
| 210 |
+
"text": "...",
|
| 211 |
+
"raw": "...",
|
| 212 |
+
"usage": {},
|
| 213 |
+
"warning": "(可选)图片修正失败提示",
|
| 214 |
+
"image_fix_mode": "mirror_h",
|
| 215 |
+
"image_fixed": true,
|
| 216 |
+
"image_fix_failed_count": 0,
|
| 217 |
+
"image_camera_facing": "front"
|
| 218 |
+
}
|
| 219 |
+
```
|
| 220 |
+
|
| 221 |
+
### Admin API
|
| 222 |
+
|
| 223 |
+
```
|
| 224 |
+
POST /admin/api/login
|
| 225 |
+
POST /admin/api/xtc-access-token
|
| 226 |
+
GET /admin/api/tokens # 新增
|
| 227 |
+
POST /admin/api/tokens/revoke # 新增
|
| 228 |
+
GET /admin/api/config
|
| 229 |
+
PUT /admin/api/config
|
| 230 |
+
GET /admin/api/providers
|
| 231 |
+
POST /admin/api/providers
|
| 232 |
+
DELETE /admin/api/providers?id=
|
| 233 |
+
GET /admin/api/provider-models?id=
|
| 234 |
+
POST /admin/api/provider-settings
|
| 235 |
+
POST /admin/api/default-provider
|
| 236 |
+
POST /admin/api/test-chat
|
| 237 |
+
```
|
| 238 |
+
|
| 239 |
+
### 日志(合并自 xtc-log-receiver)
|
| 240 |
+
|
| 241 |
+
```
|
| 242 |
+
POST /logs/api/log
|
| 243 |
+
POST /logs/api/log/batch
|
| 244 |
+
GET /logs/api/stats?hours=24 # admin
|
| 245 |
+
GET /logs/api/logs?hours=24&level=&limit= # admin
|
| 246 |
+
```
|
| 247 |
+
|
| 248 |
+
## 后续阶段
|
| 249 |
+
|
| 250 |
+
本版仅完成 MVP(核心接口迁移),后续阶段:
|
| 251 |
+
|
| 252 |
+
1. **阶段 2**:Vue 3 SPA 重写后台(替换占位 HTML),加用量统计仪表盘、模型策略可视化
|
| 253 |
+
2. **阶段 3**:HF Hub 多实例同步增强、Webhook 通知、速率限制
|
| 254 |
+
3. **阶段 4**:会话持久化、文件解析(PDF/Word)、模型路由策略、RAG
|
| 255 |
+
|
| 256 |
+
详见 `../openai-gemini-main-master/迁移到HuggingFace分析报告.md`。
|
app/__init__.py
ADDED
|
File without changes
|
app/adapters/__init__.py
ADDED
|
File without changes
|
app/adapters/gemini/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gemini 适配器:OpenAI messages <-> Gemini contents 转换。
|
| 2 |
+
|
| 3 |
+
包含 5 个子模块:
|
| 4 |
+
- messages.py: messages -> contents + system_instruction
|
| 5 |
+
- config.py: OpenAI generation params -> Gemini generationConfig
|
| 6 |
+
- tools.py: OpenAI tools/tool_choice -> Gemini function_declarations
|
| 7 |
+
- response.py: Gemini response -> OpenAI ChatCompletion
|
| 8 |
+
- stream.py: Gemini SSE -> OpenAI chunk
|
| 9 |
+
"""
|
app/adapters/gemini/config.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OpenAI generation params -> Gemini generationConfig + safety_settings + thinking_config。"""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import re
|
| 5 |
+
from typing import Any, Optional
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _pop(d: dict, *keys: str, default: Any = None) -> Any:
|
| 9 |
+
for k in keys:
|
| 10 |
+
if k in d:
|
| 11 |
+
return d.pop(k)
|
| 12 |
+
return default
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def build_generation_config(body: dict, extra_google: Optional[dict] = None) -> tuple[dict, Optional[dict]]:
|
| 16 |
+
"""构造 generationConfig 与 thinkingConfig。
|
| 17 |
+
|
| 18 |
+
Returns:
|
| 19 |
+
(generation_config_with_thinking, safety_settings)
|
| 20 |
+
"""
|
| 21 |
+
config: dict[str, Any] = {}
|
| 22 |
+
|
| 23 |
+
# 标量参数
|
| 24 |
+
if "temperature" in body and body["temperature"] is not None:
|
| 25 |
+
try:
|
| 26 |
+
config["temperature"] = float(body["temperature"])
|
| 27 |
+
except (TypeError, ValueError):
|
| 28 |
+
pass
|
| 29 |
+
if "top_p" in body and body["top_p"] is not None:
|
| 30 |
+
try:
|
| 31 |
+
config["topP"] = float(body["top_p"])
|
| 32 |
+
except (TypeError, ValueError):
|
| 33 |
+
pass
|
| 34 |
+
if "top_k" in body and body["top_k"] is not None:
|
| 35 |
+
try:
|
| 36 |
+
config["topK"] = int(body["top_k"])
|
| 37 |
+
except (TypeError, ValueError):
|
| 38 |
+
pass
|
| 39 |
+
if "max_tokens" in body and body["max_tokens"] is not None:
|
| 40 |
+
try:
|
| 41 |
+
config["maxOutputTokens"] = int(body["max_tokens"])
|
| 42 |
+
except (TypeError, ValueError):
|
| 43 |
+
pass
|
| 44 |
+
if "max_completion_tokens" in body and body["max_completion_tokens"] is not None:
|
| 45 |
+
try:
|
| 46 |
+
config["maxOutputTokens"] = int(body["max_completion_tokens"])
|
| 47 |
+
except (TypeError, ValueError):
|
| 48 |
+
pass
|
| 49 |
+
if "presence_penalty" in body and body["presence_penalty"] is not None:
|
| 50 |
+
try:
|
| 51 |
+
config["presencePenalty"] = float(body["presence_penalty"])
|
| 52 |
+
except (TypeError, ValueError):
|
| 53 |
+
pass
|
| 54 |
+
if "frequency_penalty" in body and body["frequency_penalty"] is not None:
|
| 55 |
+
try:
|
| 56 |
+
config["frequencyPenalty"] = float(body["frequency_penalty"])
|
| 57 |
+
except (TypeError, ValueError):
|
| 58 |
+
pass
|
| 59 |
+
if "seed" in body and body["seed"] is not None:
|
| 60 |
+
try:
|
| 61 |
+
config["seed"] = int(body["seed"])
|
| 62 |
+
except (TypeError, ValueError):
|
| 63 |
+
pass
|
| 64 |
+
stop = body.get("stop")
|
| 65 |
+
if stop:
|
| 66 |
+
if isinstance(stop, str):
|
| 67 |
+
config["stopSequences"] = [stop]
|
| 68 |
+
elif isinstance(stop, list):
|
| 69 |
+
config["stopSequences"] = [s for s in stop if isinstance(s, str)]
|
| 70 |
+
n = body.get("n")
|
| 71 |
+
if n and isinstance(n, int) and n > 1:
|
| 72 |
+
# Gemini 不支持 n,记一下不报错
|
| 73 |
+
pass
|
| 74 |
+
|
| 75 |
+
# response_format
|
| 76 |
+
rf = body.get("response_format")
|
| 77 |
+
if isinstance(rf, dict):
|
| 78 |
+
rtype = rf.get("type")
|
| 79 |
+
if rtype == "json_object":
|
| 80 |
+
config["responseMimeType"] = "application/json"
|
| 81 |
+
elif rtype == "json_schema":
|
| 82 |
+
config["responseMimeType"] = "application/json"
|
| 83 |
+
schema = rf.get("json_schema") or {}
|
| 84 |
+
if isinstance(schema, dict):
|
| 85 |
+
jsd = schema.get("schema") if "schema" in schema else schema
|
| 86 |
+
if isinstance(jsd, dict):
|
| 87 |
+
config["responseSchema"] = jsd
|
| 88 |
+
|
| 89 |
+
# thinking config(reasoning_effort)
|
| 90 |
+
thinking: dict[str, Any] = {}
|
| 91 |
+
reasoning_effort = body.get("reasoning_effort")
|
| 92 |
+
if reasoning_effort:
|
| 93 |
+
re_str = str(reasoning_effort).lower()
|
| 94 |
+
# v3 thinkingLevel
|
| 95 |
+
if re_str in ("low", "medium", "high"):
|
| 96 |
+
thinking["thinkingLevel"] = re_str.capitalize()
|
| 97 |
+
elif re_str == "minimal":
|
| 98 |
+
thinking["thinkingLevel"] = "Minimal"
|
| 99 |
+
elif re_str in ("none", "off"):
|
| 100 |
+
thinking["thinkingBudget"] = 0
|
| 101 |
+
# v2 thinkingBudget(数字)
|
| 102 |
+
if isinstance(reasoning_effort, int):
|
| 103 |
+
thinking["thinkingBudget"] = int(reasoning_effort)
|
| 104 |
+
|
| 105 |
+
# extra_body.google 覆盖
|
| 106 |
+
extra_google = extra_google or {}
|
| 107 |
+
if isinstance(extra_google, dict):
|
| 108 |
+
# safety_settings
|
| 109 |
+
safety_settings = extra_google.get("safety_settings") or extra_google.get("safetySettings")
|
| 110 |
+
# cached_content
|
| 111 |
+
cached = extra_google.get("cached_content") or extra_google.get("cachedContent")
|
| 112 |
+
if cached:
|
| 113 |
+
# 不放进 generationConfig,由上层放 body
|
| 114 |
+
body["_cached_content"] = cached
|
| 115 |
+
# thinking_config 覆盖
|
| 116 |
+
tc = extra_google.get("thinking_config") or extra_google.get("thinkingConfig")
|
| 117 |
+
if isinstance(tc, dict):
|
| 118 |
+
thinking.update(tc)
|
| 119 |
+
else:
|
| 120 |
+
safety_settings = None
|
| 121 |
+
|
| 122 |
+
# 合并 thinking 到 generationConfig
|
| 123 |
+
if thinking:
|
| 124 |
+
config["thinkingConfig"] = thinking
|
| 125 |
+
|
| 126 |
+
return config, safety_settings
|
app/adapters/gemini/messages.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""messages -> contents + system_instruction 转换。"""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import base64
|
| 5 |
+
import re
|
| 6 |
+
from typing import Any, Optional
|
| 7 |
+
|
| 8 |
+
# data URL 解析
|
| 9 |
+
_DATA_URL_RE = re.compile(r"^data:([^;,]+)?(;base64)?,(.*)$", re.DOTALL)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _parse_data_url(url: str) -> Optional[dict]:
|
| 13 |
+
"""data URL -> {mimeType, data(b64)},仅 base64 形式。"""
|
| 14 |
+
if not isinstance(url, str):
|
| 15 |
+
return None
|
| 16 |
+
m = _DATA_URL_RE.match(url.strip())
|
| 17 |
+
if not m:
|
| 18 |
+
return None
|
| 19 |
+
mime = m.group(1) or "application/octet-stream"
|
| 20 |
+
is_b64 = bool(m.group(2))
|
| 21 |
+
payload = m.group(3)
|
| 22 |
+
if is_b64:
|
| 23 |
+
try:
|
| 24 |
+
# 校验可解码
|
| 25 |
+
base64.b64decode(payload, validate=False)
|
| 26 |
+
return {"mimeType": mime, "data": payload}
|
| 27 |
+
except Exception:
|
| 28 |
+
return None
|
| 29 |
+
# 非 base64 的 data URL,转 base64
|
| 30 |
+
try:
|
| 31 |
+
b64 = base64.b64encode(payload.encode("utf-8")).decode("ascii")
|
| 32 |
+
return {"mimeType": mime, "data": b64}
|
| 33 |
+
except Exception:
|
| 34 |
+
return None
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _content_to_parts(content: Any) -> list[dict]:
|
| 38 |
+
"""OpenAI content(str 或 list)-> Gemini parts。"""
|
| 39 |
+
if isinstance(content, str):
|
| 40 |
+
return [{"text": content}] if content else []
|
| 41 |
+
|
| 42 |
+
parts: list[dict] = []
|
| 43 |
+
if not isinstance(content, list):
|
| 44 |
+
return parts
|
| 45 |
+
for item in content:
|
| 46 |
+
if not isinstance(item, dict):
|
| 47 |
+
continue
|
| 48 |
+
t = item.get("type")
|
| 49 |
+
if t == "text":
|
| 50 |
+
text = item.get("text", "")
|
| 51 |
+
if text:
|
| 52 |
+
parts.append({"text": text})
|
| 53 |
+
elif t == "image_url":
|
| 54 |
+
url_obj = item.get("image_url") or {}
|
| 55 |
+
url = url_obj.get("url") if isinstance(url_obj, dict) else url_obj
|
| 56 |
+
inline = _parse_data_url(url) if isinstance(url, str) else None
|
| 57 |
+
if inline:
|
| 58 |
+
parts.append({"inlineData": inline})
|
| 59 |
+
elif t == "input_audio":
|
| 60 |
+
data_obj = item.get("input_audio") or {}
|
| 61 |
+
data_str = data_obj.get("data") if isinstance(data_obj, dict) else None
|
| 62 |
+
if data_str:
|
| 63 |
+
mime = "audio/wav"
|
| 64 |
+
if isinstance(data_obj, dict) and data_obj.get("format") == "mp3":
|
| 65 |
+
mime = "audio/mpeg"
|
| 66 |
+
parts.append({"inlineData": {"mimeType": mime, "data": data_str}})
|
| 67 |
+
elif t == "input_image":
|
| 68 |
+
url = item.get("image_url") if isinstance(item.get("image_url"), str) else None
|
| 69 |
+
inline = _parse_data_url(url) if url else None
|
| 70 |
+
if inline:
|
| 71 |
+
parts.append({"inlineData": inline})
|
| 72 |
+
return parts
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _merge_tool_messages(messages: list[dict]) -> list[dict]:
|
| 76 |
+
"""合并连续的 tool 角色消息为单条 function 响应。
|
| 77 |
+
|
| 78 |
+
OpenAI: 多个 tool 消息对应不同 tool_call_id
|
| 79 |
+
Gemini: 单条 functionResponse parts(多个 part)
|
| 80 |
+
"""
|
| 81 |
+
out: list[dict] = []
|
| 82 |
+
i = 0
|
| 83 |
+
while i < len(messages):
|
| 84 |
+
msg = messages[i]
|
| 85 |
+
if msg.get("role") != "tool":
|
| 86 |
+
out.append(msg)
|
| 87 |
+
i += 1
|
| 88 |
+
continue
|
| 89 |
+
# 收集连续 tool 消息
|
| 90 |
+
tool_calls_map: dict[str, Any] = {}
|
| 91 |
+
# 找到上一条 assistant 的 tool_calls,用于映射 tool_call_id -> function name
|
| 92 |
+
prev_assistant = None
|
| 93 |
+
for prev in reversed(out):
|
| 94 |
+
if prev.get("role") == "assistant":
|
| 95 |
+
prev_assistant = prev
|
| 96 |
+
break
|
| 97 |
+
name_by_id: dict[str, str] = {}
|
| 98 |
+
if prev_assistant:
|
| 99 |
+
for tc in prev_assistant.get("tool_calls") or []:
|
| 100 |
+
if isinstance(tc, dict):
|
| 101 |
+
tid = tc.get("id")
|
| 102 |
+
fn = (tc.get("function") or {}).get("name")
|
| 103 |
+
if tid and fn:
|
| 104 |
+
name_by_id[tid] = fn
|
| 105 |
+
|
| 106 |
+
parts: list[dict] = []
|
| 107 |
+
while i < len(messages) and messages[i].get("role") == "tool":
|
| 108 |
+
tm = messages[i]
|
| 109 |
+
tool_call_id = tm.get("tool_call_id", "")
|
| 110 |
+
name = tm.get("name") or name_by_id.get(tool_call_id) or "function"
|
| 111 |
+
content = tm.get("content")
|
| 112 |
+
try:
|
| 113 |
+
if isinstance(content, str):
|
| 114 |
+
parsed = __import__("json").loads(content) if content.strip() else {}
|
| 115 |
+
elif isinstance(content, dict):
|
| 116 |
+
parsed = content
|
| 117 |
+
else:
|
| 118 |
+
parsed = {"result": content}
|
| 119 |
+
except Exception:
|
| 120 |
+
parsed = {"result": content}
|
| 121 |
+
parts.append({"functionResponse": {"name": name, "response": parsed}})
|
| 122 |
+
i += 1
|
| 123 |
+
out.append({"role": "function", "_parts": parts})
|
| 124 |
+
return out
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def convert_messages_to_contents(messages: list[dict]) -> tuple[list[dict], Optional[str]]:
|
| 128 |
+
"""OpenAI messages -> Gemini contents + system_instruction。
|
| 129 |
+
|
| 130 |
+
Returns:
|
| 131 |
+
(contents, system_instruction_text)
|
| 132 |
+
"""
|
| 133 |
+
if not messages:
|
| 134 |
+
return [], None
|
| 135 |
+
|
| 136 |
+
# 提取 system
|
| 137 |
+
system_parts: list[str] = []
|
| 138 |
+
chat_messages: list[dict] = []
|
| 139 |
+
for msg in messages:
|
| 140 |
+
role = msg.get("role")
|
| 141 |
+
if role == "system" or role == "developer":
|
| 142 |
+
content = msg.get("content")
|
| 143 |
+
if isinstance(content, str) and content:
|
| 144 |
+
system_parts.append(content)
|
| 145 |
+
elif isinstance(content, list):
|
| 146 |
+
for p in content:
|
| 147 |
+
if isinstance(p, dict) and p.get("type") == "text" and p.get("text"):
|
| 148 |
+
system_parts.append(p["text"])
|
| 149 |
+
else:
|
| 150 |
+
chat_messages.append(msg)
|
| 151 |
+
|
| 152 |
+
system_text = "\n\n".join(system_parts) if system_parts else None
|
| 153 |
+
|
| 154 |
+
# 合并连续 tool 消息
|
| 155 |
+
chat_messages = _merge_tool_messages(chat_messages)
|
| 156 |
+
|
| 157 |
+
contents: list[dict] = []
|
| 158 |
+
for msg in chat_messages:
|
| 159 |
+
role = msg.get("role", "user")
|
| 160 |
+
if role == "assistant":
|
| 161 |
+
gemini_role = "model"
|
| 162 |
+
elif role == "tool":
|
| 163 |
+
gemini_role = "function" # 被 _merge_tool_messages 处理过
|
| 164 |
+
elif role == "function":
|
| 165 |
+
gemini_role = "function"
|
| 166 |
+
else:
|
| 167 |
+
gemini_role = "user"
|
| 168 |
+
|
| 169 |
+
parts: list[dict] = []
|
| 170 |
+
# 处理 _parts(合并后的 tool 响应)
|
| 171 |
+
if msg.get("_parts"):
|
| 172 |
+
parts.extend(msg["_parts"])
|
| 173 |
+
else:
|
| 174 |
+
parts.extend(_content_to_parts(msg.get("content")))
|
| 175 |
+
|
| 176 |
+
# assistant 的 tool_calls -> functionCall parts
|
| 177 |
+
for tc in msg.get("tool_calls") or []:
|
| 178 |
+
if not isinstance(tc, dict):
|
| 179 |
+
continue
|
| 180 |
+
fn = tc.get("function") or {}
|
| 181 |
+
name = fn.get("name")
|
| 182 |
+
args_str = fn.get("arguments", "{}")
|
| 183 |
+
try:
|
| 184 |
+
args = __import__("json").loads(args_str) if isinstance(args_str, str) else args_str
|
| 185 |
+
except Exception:
|
| 186 |
+
args = {}
|
| 187 |
+
if name:
|
| 188 |
+
parts.append({"functionCall": {"name": name, "args": args}})
|
| 189 |
+
|
| 190 |
+
if not parts:
|
| 191 |
+
parts = [{"text": ""}]
|
| 192 |
+
contents.append({"role": gemini_role, "parts": parts})
|
| 193 |
+
|
| 194 |
+
return contents, system_text
|
app/adapters/gemini/response.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gemini response -> OpenAI ChatCompletion 转换。"""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
import time
|
| 6 |
+
from typing import Any, Optional
|
| 7 |
+
|
| 8 |
+
from ...utils.thought import wrap_thought
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
_FINISH_MAP = {
|
| 12 |
+
"STOP": "stop",
|
| 13 |
+
"MAX_TOKENS": "length",
|
| 14 |
+
"SAFETY": "content_filter",
|
| 15 |
+
"RECITATION": "content_filter",
|
| 16 |
+
"OTHER": "stop",
|
| 17 |
+
"FINISH_REASON_UNSPECIFIED": "stop",
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _parts_to_openai(parts: list[dict], *, include_thought: bool = True) -> tuple[str, list[dict], Optional[str]]:
|
| 22 |
+
"""Gemini parts -> (content_text, tool_calls, thought_text)。
|
| 23 |
+
|
| 24 |
+
thought parts 会被包裹成 <thought>...</thought> 嵌入 content。
|
| 25 |
+
"""
|
| 26 |
+
text_chunks: list[str] = []
|
| 27 |
+
thought_chunks: list[str] = []
|
| 28 |
+
tool_calls: list[dict] = []
|
| 29 |
+
for i, part in enumerate(parts or []):
|
| 30 |
+
if not isinstance(part, dict):
|
| 31 |
+
continue
|
| 32 |
+
# 思考链(thought=true 标记)
|
| 33 |
+
if part.get("thought"):
|
| 34 |
+
t = part.get("text") or ""
|
| 35 |
+
if t:
|
| 36 |
+
thought_chunks.append(t)
|
| 37 |
+
continue
|
| 38 |
+
if "text" in part:
|
| 39 |
+
text_chunks.append(part["text"] or "")
|
| 40 |
+
elif "functionCall" in part:
|
| 41 |
+
fc = part["functionCall"] or {}
|
| 42 |
+
name = fc.get("name") or ""
|
| 43 |
+
args = fc.get("args") or {}
|
| 44 |
+
try:
|
| 45 |
+
args_str = json.dumps(args, ensure_ascii=False)
|
| 46 |
+
except Exception:
|
| 47 |
+
args_str = "{}"
|
| 48 |
+
tool_calls.append(
|
| 49 |
+
{
|
| 50 |
+
"id": f"call_{i+1}",
|
| 51 |
+
"type": "function",
|
| 52 |
+
"function": {"name": name, "arguments": args_str},
|
| 53 |
+
}
|
| 54 |
+
)
|
| 55 |
+
content = "".join(text_chunks)
|
| 56 |
+
thought = "\n\n".join(thought_chunks)
|
| 57 |
+
if include_thought and thought:
|
| 58 |
+
content = wrap_thought(thought) + content
|
| 59 |
+
return content, tool_calls, thought if thought else None
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def convert_response(gemini_resp: dict, *, model: str) -> dict:
|
| 63 |
+
"""Gemini generateContent response -> OpenAI ChatCompletion。"""
|
| 64 |
+
candidates = gemini_resp.get("candidates") or []
|
| 65 |
+
choices: list[dict] = []
|
| 66 |
+
for cand in candidates:
|
| 67 |
+
content_obj = cand.get("content") or {}
|
| 68 |
+
parts = content_obj.get("parts") or []
|
| 69 |
+
finish_reason_raw = cand.get("finishReason") or "STOP"
|
| 70 |
+
finish_reason = _FINISH_MAP.get(finish_reason_raw, "stop")
|
| 71 |
+
# promptFeedback 阻断
|
| 72 |
+
feedback = cand.get("promptFeedback") or {}
|
| 73 |
+
if feedback.get("blockReason"):
|
| 74 |
+
choices.append(
|
| 75 |
+
{
|
| 76 |
+
"index": cand.get("index", 0),
|
| 77 |
+
"message": {"role": "assistant", "content": ""},
|
| 78 |
+
"finish_reason": "content_filter",
|
| 79 |
+
}
|
| 80 |
+
)
|
| 81 |
+
continue
|
| 82 |
+
content, tool_calls, _ = _parts_to_openai(parts)
|
| 83 |
+
msg: dict[str, Any] = {"role": "assistant", "content": content or None}
|
| 84 |
+
if tool_calls:
|
| 85 |
+
msg["tool_calls"] = tool_calls
|
| 86 |
+
choices.append(
|
| 87 |
+
{
|
| 88 |
+
"index": cand.get("index", 0),
|
| 89 |
+
"message": msg,
|
| 90 |
+
"finish_reason": finish_reason,
|
| 91 |
+
}
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
if not choices:
|
| 95 |
+
choices.append(
|
| 96 |
+
{
|
| 97 |
+
"index": 0,
|
| 98 |
+
"message": {"role": "assistant", "content": ""},
|
| 99 |
+
"finish_reason": "stop",
|
| 100 |
+
}
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
# usage
|
| 104 |
+
usage_meta = gemini_resp.get("usageMetadata") or {}
|
| 105 |
+
usage = {
|
| 106 |
+
"prompt_tokens": int(usage_meta.get("promptTokenCount") or 0),
|
| 107 |
+
"completion_tokens": int(usage_meta.get("candidatesTokenCount") or 0),
|
| 108 |
+
"total_tokens": int(usage_meta.get("totalTokenCount") or 0),
|
| 109 |
+
}
|
| 110 |
+
if usage_meta.get("thoughtsTokenCount"):
|
| 111 |
+
usage["reasoning_tokens"] = int(usage_meta["thoughtsTokenCount"])
|
| 112 |
+
if usage_meta.get("cachedContentTokenCount"):
|
| 113 |
+
usage["prompt_tokens_details"] = {
|
| 114 |
+
"cached_tokens": int(usage_meta["cachedContentTokenCount"])
|
| 115 |
+
}
|
| 116 |
+
if usage_meta.get("audioTokenCount"):
|
| 117 |
+
usage["completion_tokens_details"] = {
|
| 118 |
+
"audio_tokens": int(usage_meta["audioTokenCount"])
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
return {
|
| 122 |
+
"id": f"chatcmpl-{int(time.time()*1000)}",
|
| 123 |
+
"object": "chat.completion",
|
| 124 |
+
"created": int(time.time()),
|
| 125 |
+
"model": model,
|
| 126 |
+
"choices": choices,
|
| 127 |
+
"usage": usage,
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def convert_embeddings_response(gemini_resp: dict, *, model: str) -> dict:
|
| 132 |
+
"""Gemini batchEmbedContents -> OpenAI embeddings。"""
|
| 133 |
+
embeddings = gemini_resp.get("embeddings") or []
|
| 134 |
+
data = []
|
| 135 |
+
for i, emb in enumerate(embeddings):
|
| 136 |
+
values = (emb.get("values") or []) if isinstance(emb, dict) else []
|
| 137 |
+
data.append({"object": "embedding", "index": i, "embedding": values})
|
| 138 |
+
return {
|
| 139 |
+
"object": "list",
|
| 140 |
+
"data": data,
|
| 141 |
+
"model": model,
|
| 142 |
+
"usage": {"prompt_tokens": 0, "total_tokens": 0},
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def convert_models_response(gemini_resp: dict) -> dict:
|
| 147 |
+
"""Gemini /models -> OpenAI /models。"""
|
| 148 |
+
models = gemini_resp.get("models") or []
|
| 149 |
+
data = []
|
| 150 |
+
for m in models:
|
| 151 |
+
name = (m.get("name") or "").removeprefix("models/")
|
| 152 |
+
if not name:
|
| 153 |
+
continue
|
| 154 |
+
data.append(
|
| 155 |
+
{
|
| 156 |
+
"id": name,
|
| 157 |
+
"object": "model",
|
| 158 |
+
"created": 0,
|
| 159 |
+
"owned_by": "google",
|
| 160 |
+
}
|
| 161 |
+
)
|
| 162 |
+
return {"object": "list", "data": data}
|
app/adapters/gemini/stream.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gemini 流式 SSE -> OpenAI chunk 转换。"""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
import time
|
| 6 |
+
from typing import Any, AsyncIterator, Optional
|
| 7 |
+
|
| 8 |
+
from ...utils.thought import wrap_thought
|
| 9 |
+
from .response import _FINISH_MAP
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _empty_chunk(*, model: str, role: Optional[str] = None) -> dict:
|
| 13 |
+
delta: dict[str, Any] = {}
|
| 14 |
+
if role:
|
| 15 |
+
delta["role"] = role
|
| 16 |
+
return {
|
| 17 |
+
"id": f"chatcmpl-{int(time.time()*1000)}",
|
| 18 |
+
"object": "chat.completion.chunk",
|
| 19 |
+
"created": int(time.time()),
|
| 20 |
+
"model": model,
|
| 21 |
+
"choices": [{"index": 0, "delta": delta, "finish_reason": None}],
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _text_chunk(text: str, *, model: str) -> dict:
|
| 26 |
+
return {
|
| 27 |
+
"id": f"chatcmpl-{int(time.time()*1000)}",
|
| 28 |
+
"object": "chat.completion.chunk",
|
| 29 |
+
"created": int(time.time()),
|
| 30 |
+
"model": model,
|
| 31 |
+
"choices": [
|
| 32 |
+
{"index": 0, "delta": {"content": text}, "finish_reason": None}
|
| 33 |
+
],
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _tool_call_chunk(name: str, args: str, *, model: str, index: int = 0) -> dict:
|
| 38 |
+
return {
|
| 39 |
+
"id": f"chatcmpl-{int(time.time()*1000)}",
|
| 40 |
+
"object": "chat.completion.chunk",
|
| 41 |
+
"created": int(time.time()),
|
| 42 |
+
"model": model,
|
| 43 |
+
"choices": [
|
| 44 |
+
{
|
| 45 |
+
"index": 0,
|
| 46 |
+
"delta": {
|
| 47 |
+
"tool_calls": [
|
| 48 |
+
{
|
| 49 |
+
"index": index,
|
| 50 |
+
"id": f"call_{index+1}",
|
| 51 |
+
"type": "function",
|
| 52 |
+
"function": {"name": name, "arguments": args},
|
| 53 |
+
}
|
| 54 |
+
]
|
| 55 |
+
},
|
| 56 |
+
"finish_reason": None,
|
| 57 |
+
}
|
| 58 |
+
],
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _finish_chunk(*, model: str, finish_reason: str, usage: Optional[dict] = None) -> dict:
|
| 63 |
+
return {
|
| 64 |
+
"id": f"chatcmpl-{int(time.time()*1000)}",
|
| 65 |
+
"object": "chat.completion.chunk",
|
| 66 |
+
"created": int(time.time()),
|
| 67 |
+
"model": model,
|
| 68 |
+
"choices": [
|
| 69 |
+
{"index": 0, "delta": {}, "finish_reason": finish_reason}
|
| 70 |
+
],
|
| 71 |
+
**({"usage": usage} if usage else {}),
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
async def stream_openai_chunks(
|
| 76 |
+
gemini_sse: AsyncIterator[dict],
|
| 77 |
+
*,
|
| 78 |
+
model: str,
|
| 79 |
+
include_usage: bool = False,
|
| 80 |
+
) -> AsyncIterator[dict]:
|
| 81 |
+
"""把 Gemini SSE 流转换为 OpenAI chunk 字典流。"""
|
| 82 |
+
sent_role = False
|
| 83 |
+
in_thought = False
|
| 84 |
+
usage_final: Optional[dict] = None
|
| 85 |
+
tool_index = 0
|
| 86 |
+
|
| 87 |
+
async for ev in gemini_sse:
|
| 88 |
+
if ev.get("__done__"):
|
| 89 |
+
break
|
| 90 |
+
if ev.get("__raw__"):
|
| 91 |
+
# 非 JSON 行,忽略
|
| 92 |
+
continue
|
| 93 |
+
|
| 94 |
+
candidates = ev.get("candidates") or []
|
| 95 |
+
if not candidates:
|
| 96 |
+
# 可能是纯 usage chunk
|
| 97 |
+
um = ev.get("usageMetadata")
|
| 98 |
+
if um:
|
| 99 |
+
usage_final = _build_usage(um)
|
| 100 |
+
continue
|
| 101 |
+
|
| 102 |
+
cand = candidates[0]
|
| 103 |
+
content_obj = cand.get("content") or {}
|
| 104 |
+
parts = content_obj.get("parts") or []
|
| 105 |
+
|
| 106 |
+
# 首包补 role
|
| 107 |
+
if not sent_role:
|
| 108 |
+
sent_role = True
|
| 109 |
+
yield _empty_chunk(model=model, role="assistant")
|
| 110 |
+
|
| 111 |
+
# 拼接 thought 开闭标记 + text
|
| 112 |
+
text_buf: list[str] = []
|
| 113 |
+
thought_buf: list[str] = []
|
| 114 |
+
for part in parts:
|
| 115 |
+
if not isinstance(part, dict):
|
| 116 |
+
continue
|
| 117 |
+
if part.get("thought"):
|
| 118 |
+
t = part.get("text") or ""
|
| 119 |
+
if t:
|
| 120 |
+
thought_buf.append(t)
|
| 121 |
+
continue
|
| 122 |
+
if "text" in part:
|
| 123 |
+
text_buf.append(part["text"] or "")
|
| 124 |
+
elif "functionCall" in part:
|
| 125 |
+
fc = part["functionCall"] or {}
|
| 126 |
+
name = fc.get("name") or ""
|
| 127 |
+
args = fc.get("args") or {}
|
| 128 |
+
try:
|
| 129 |
+
args_str = json.dumps(args, ensure_ascii=False)
|
| 130 |
+
except Exception:
|
| 131 |
+
args_str = "{}"
|
| 132 |
+
yield _tool_call_chunk(name, args_str, model=model, index=tool_index)
|
| 133 |
+
tool_index += 1
|
| 134 |
+
|
| 135 |
+
# thought 包裹
|
| 136 |
+
if thought_buf:
|
| 137 |
+
thought_text = "".join(thought_buf)
|
| 138 |
+
yield _text_chunk(wrap_thought(thought_text), model=model)
|
| 139 |
+
|
| 140 |
+
if text_buf:
|
| 141 |
+
yield _text_chunk("".join(text_buf), model=model)
|
| 142 |
+
|
| 143 |
+
finish_raw = cand.get("finishReason")
|
| 144 |
+
if finish_raw:
|
| 145 |
+
finish_reason = _FINISH_MAP.get(finish_raw, "stop")
|
| 146 |
+
um = ev.get("usageMetadata")
|
| 147 |
+
if um:
|
| 148 |
+
usage_final = _build_usage(um)
|
| 149 |
+
yield _finish_chunk(
|
| 150 |
+
model=model, finish_reason=finish_reason,
|
| 151 |
+
usage=usage_final if include_usage else None,
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
# 流结束,若 include_usage 且未发过 usage,发一个空 usage 包
|
| 155 |
+
if include_usage and not usage_final:
|
| 156 |
+
yield _finish_chunk(model=model, finish_reason="stop", usage={
|
| 157 |
+
"prompt_tokens": 0,
|
| 158 |
+
"completion_tokens": 0,
|
| 159 |
+
"total_tokens": 0,
|
| 160 |
+
})
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def _build_usage(um: dict) -> dict:
|
| 164 |
+
usage = {
|
| 165 |
+
"prompt_tokens": int(um.get("promptTokenCount") or 0),
|
| 166 |
+
"completion_tokens": int(um.get("candidatesTokenCount") or 0),
|
| 167 |
+
"total_tokens": int(um.get("totalTokenCount") or 0),
|
| 168 |
+
}
|
| 169 |
+
if um.get("thoughtsTokenCount"):
|
| 170 |
+
usage["reasoning_tokens"] = int(um["thoughtsTokenCount"])
|
| 171 |
+
return usage
|
app/adapters/gemini/tools.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OpenAI tools / tool_choice -> Gemini tools (function_declarations)。"""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from typing import Any, Optional
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def convert_tools(body: dict) -> Optional[list[dict]]:
|
| 8 |
+
"""转换 OpenAI tools 数组为 Gemini tools 格式。
|
| 9 |
+
|
| 10 |
+
Returns:
|
| 11 |
+
Gemini tools list 或 None
|
| 12 |
+
"""
|
| 13 |
+
tools = body.get("tools")
|
| 14 |
+
if not tools or not isinstance(tools, list):
|
| 15 |
+
return None
|
| 16 |
+
|
| 17 |
+
func_decls: list[dict] = []
|
| 18 |
+
for tool in tools:
|
| 19 |
+
if not isinstance(tool, dict):
|
| 20 |
+
continue
|
| 21 |
+
if tool.get("type") and tool.get("type") != "function":
|
| 22 |
+
continue
|
| 23 |
+
func = tool.get("function") or tool
|
| 24 |
+
if not isinstance(func, dict):
|
| 25 |
+
continue
|
| 26 |
+
name = func.get("name")
|
| 27 |
+
if not name:
|
| 28 |
+
continue
|
| 29 |
+
decl: dict[str, Any] = {
|
| 30 |
+
"name": name,
|
| 31 |
+
"description": func.get("description") or "",
|
| 32 |
+
}
|
| 33 |
+
params = func.get("parameters")
|
| 34 |
+
if isinstance(params, dict):
|
| 35 |
+
decl["parameters"] = params
|
| 36 |
+
func_decls.append(decl)
|
| 37 |
+
|
| 38 |
+
if not func_decls:
|
| 39 |
+
return None
|
| 40 |
+
|
| 41 |
+
out: list[dict] = [{"function_declarations": func_decls}]
|
| 42 |
+
|
| 43 |
+
# tool_choice
|
| 44 |
+
tool_choice = body.get("tool_choice")
|
| 45 |
+
if tool_choice:
|
| 46 |
+
fcc: dict[str, Any] = {}
|
| 47 |
+
if tool_choice == "auto":
|
| 48 |
+
fcc["mode"] = "AUTO"
|
| 49 |
+
elif tool_choice == "none":
|
| 50 |
+
fcc["mode"] = "NONE"
|
| 51 |
+
elif tool_choice == "required":
|
| 52 |
+
fcc["mode"] = "ANY"
|
| 53 |
+
elif isinstance(tool_choice, dict):
|
| 54 |
+
fn_name = (tool_choice.get("function") or {}).get("name")
|
| 55 |
+
if fn_name:
|
| 56 |
+
fcc["mode"] = "ANY"
|
| 57 |
+
fcc["allowed_function_names"] = [fn_name]
|
| 58 |
+
if fcc:
|
| 59 |
+
out[0]["function_calling_config"] = fcc
|
| 60 |
+
|
| 61 |
+
return out
|
app/adapters/gemini_api.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gemini 适配器入口:拼装上游请求、调用、超时、错误处理。"""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
import re
|
| 6 |
+
from typing import Any, AsyncIterator, Optional
|
| 7 |
+
|
| 8 |
+
import httpx
|
| 9 |
+
|
| 10 |
+
from ..config import (
|
| 11 |
+
GEMINI_API_CLIENT,
|
| 12 |
+
GEMINI_API_VERSION,
|
| 13 |
+
GEMINI_BASE_URL,
|
| 14 |
+
GEMINI_DEFAULT_EMBEDDINGS_MODEL,
|
| 15 |
+
get_settings,
|
| 16 |
+
)
|
| 17 |
+
from ..errors import HttpError, error_code_for_status, is_retryable_status
|
| 18 |
+
from ..http_client import get_http_client
|
| 19 |
+
from ..utils.sse import parse_sse_stream
|
| 20 |
+
from .gemini.config import build_generation_config
|
| 21 |
+
from .gemini.messages import convert_messages_to_contents
|
| 22 |
+
from .gemini.response import (
|
| 23 |
+
convert_embeddings_response,
|
| 24 |
+
convert_models_response,
|
| 25 |
+
convert_response,
|
| 26 |
+
)
|
| 27 |
+
from .gemini.stream import stream_openai_chunks
|
| 28 |
+
from .gemini.tools import convert_tools
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _build_headers(api_key: str) -> dict[str, str]:
|
| 32 |
+
return {
|
| 33 |
+
"x-goog-api-client": GEMINI_API_CLIENT,
|
| 34 |
+
"x-goog-api-key": api_key,
|
| 35 |
+
"Content-Type": "application/json",
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _strip_model_suffix(model: str) -> tuple[str, bool, bool]:
|
| 40 |
+
"""剥离模型名后缀(:search / -search-preview),返回 (clean_model, use_search, _removed)。"""
|
| 41 |
+
use_search = False
|
| 42 |
+
m = model
|
| 43 |
+
if m.endswith(":search"):
|
| 44 |
+
use_search = True
|
| 45 |
+
m = m[: -len(":search")]
|
| 46 |
+
elif m.endswith("-search-preview"):
|
| 47 |
+
use_search = True
|
| 48 |
+
m = m[: -len("-search-preview")]
|
| 49 |
+
return m, use_search, False
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _build_body(
|
| 53 |
+
*,
|
| 54 |
+
messages: list[dict],
|
| 55 |
+
body: dict,
|
| 56 |
+
model: str,
|
| 57 |
+
) -> dict[str, Any]:
|
| 58 |
+
"""构造 Gemini generateContent body。"""
|
| 59 |
+
contents, system_text = convert_messages_to_contents(messages)
|
| 60 |
+
gemini_body: dict[str, Any] = {"contents": contents}
|
| 61 |
+
if system_text:
|
| 62 |
+
gemini_body["system_instruction"] = {"parts": [{"text": system_text}]}
|
| 63 |
+
|
| 64 |
+
# 处理 google extra_body
|
| 65 |
+
extra_google = None
|
| 66 |
+
extra_body = body.get("extra_body") or body.get("google")
|
| 67 |
+
if isinstance(extra_body, dict):
|
| 68 |
+
extra_google = extra_body.get("google") if "google" in extra_body else extra_body
|
| 69 |
+
|
| 70 |
+
gen_config, safety_settings = build_generation_config(body, extra_google)
|
| 71 |
+
if gen_config:
|
| 72 |
+
gemini_body["generationConfig"] = gen_config
|
| 73 |
+
if safety_settings:
|
| 74 |
+
gemini_body["safetySettings"] = safety_settings
|
| 75 |
+
|
| 76 |
+
# cached content
|
| 77 |
+
cached = body.get("_cached_content") or (extra_google or {}).get("cached_content") if extra_google else None
|
| 78 |
+
if cached:
|
| 79 |
+
gemini_body["cachedContent"] = cached
|
| 80 |
+
|
| 81 |
+
# tools
|
| 82 |
+
tools = convert_tools(body)
|
| 83 |
+
# 模型后缀 :search -> 自动追加 googleSearch 工具
|
| 84 |
+
clean_model, use_search, _ = _strip_model_suffix(model)
|
| 85 |
+
if use_search:
|
| 86 |
+
search_tool = {"google_search": {}} if False else {"googleSearch": {}}
|
| 87 |
+
if tools:
|
| 88 |
+
tools.append(search_tool)
|
| 89 |
+
else:
|
| 90 |
+
tools = [search_tool]
|
| 91 |
+
if tools:
|
| 92 |
+
gemini_body["tools"] = tools
|
| 93 |
+
|
| 94 |
+
return gemini_body, clean_model
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
async def chat_completions(
|
| 98 |
+
*,
|
| 99 |
+
api_key: str,
|
| 100 |
+
model: str,
|
| 101 |
+
messages: list[dict],
|
| 102 |
+
body: dict,
|
| 103 |
+
stream: bool = False,
|
| 104 |
+
) -> Any:
|
| 105 |
+
"""调用 Gemini,返回 OpenAI 格式结果或 chunk 流。"""
|
| 106 |
+
gemini_body, clean_model = _build_body(messages=messages, body=body, model=model)
|
| 107 |
+
|
| 108 |
+
if stream:
|
| 109 |
+
return _stream_chat(api_key, clean_model, gemini_body, body)
|
| 110 |
+
|
| 111 |
+
url = (
|
| 112 |
+
f"{GEMINI_BASE_URL}/{GEMINI_API_VERSION}/models/{clean_model}:generateContent"
|
| 113 |
+
)
|
| 114 |
+
client = get_http_client()
|
| 115 |
+
try:
|
| 116 |
+
resp = await client.post(
|
| 117 |
+
url,
|
| 118 |
+
headers=_build_headers(api_key),
|
| 119 |
+
json=gemini_body,
|
| 120 |
+
)
|
| 121 |
+
except httpx.TimeoutException as e:
|
| 122 |
+
raise HttpError(
|
| 123 |
+
f"Gemini upstream timeout: {e}",
|
| 124 |
+
status=504,
|
| 125 |
+
code="upstream_timeout",
|
| 126 |
+
) from e
|
| 127 |
+
except httpx.HTTPError as e:
|
| 128 |
+
raise HttpError(
|
| 129 |
+
f"Gemini upstream error: {e}",
|
| 130 |
+
status=502,
|
| 131 |
+
code="bad_gateway",
|
| 132 |
+
) from e
|
| 133 |
+
|
| 134 |
+
if resp.status_code >= 400:
|
| 135 |
+
await _raise_upstream_error(resp)
|
| 136 |
+
|
| 137 |
+
try:
|
| 138 |
+
data = resp.json()
|
| 139 |
+
except Exception as e:
|
| 140 |
+
raise HttpError(
|
| 141 |
+
f"Gemini upstream returned non-JSON: {resp.text[:200]}",
|
| 142 |
+
status=502,
|
| 143 |
+
code="bad_gateway",
|
| 144 |
+
) from e
|
| 145 |
+
|
| 146 |
+
return convert_response(data, model=clean_model)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
async def _stream_chat(
|
| 150 |
+
api_key: str,
|
| 151 |
+
clean_model: str,
|
| 152 |
+
gemini_body: dict,
|
| 153 |
+
body: dict,
|
| 154 |
+
) -> AsyncIterator[dict]:
|
| 155 |
+
"""流式调用 Gemini,yield OpenAI chunk dict。"""
|
| 156 |
+
url = (
|
| 157 |
+
f"{GEMINI_BASE_URL}/{GEMINI_API_VERSION}/models/{clean_model}:streamGenerateContent"
|
| 158 |
+
"?alt=sse"
|
| 159 |
+
)
|
| 160 |
+
client = get_http_client()
|
| 161 |
+
include_usage = False
|
| 162 |
+
so = body.get("stream_options")
|
| 163 |
+
if isinstance(so, dict) and so.get("include_usage"):
|
| 164 |
+
include_usage = True
|
| 165 |
+
|
| 166 |
+
try:
|
| 167 |
+
async with client.stream(
|
| 168 |
+
"POST",
|
| 169 |
+
url,
|
| 170 |
+
headers=_build_headers(api_key),
|
| 171 |
+
json=gemini_body,
|
| 172 |
+
) as resp:
|
| 173 |
+
if resp.status_code >= 400:
|
| 174 |
+
text = await resp.aread()
|
| 175 |
+
await _raise_upstream_error_from_raw(resp.status_code, text)
|
| 176 |
+
sse_iter = parse_sse_stream(resp.aiter_bytes())
|
| 177 |
+
async for chunk in stream_openai_chunks(
|
| 178 |
+
sse_iter, model=clean_model, include_usage=include_usage
|
| 179 |
+
):
|
| 180 |
+
yield chunk
|
| 181 |
+
except httpx.TimeoutException as e:
|
| 182 |
+
raise HttpError(
|
| 183 |
+
f"Gemini stream timeout: {e}",
|
| 184 |
+
status=504,
|
| 185 |
+
code="upstream_timeout",
|
| 186 |
+
) from e
|
| 187 |
+
except HttpError:
|
| 188 |
+
raise
|
| 189 |
+
except httpx.HTTPError as e:
|
| 190 |
+
raise HttpError(
|
| 191 |
+
f"Gemini stream error: {e}",
|
| 192 |
+
status=502,
|
| 193 |
+
code="bad_gateway",
|
| 194 |
+
) from e
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
async def embeddings(
|
| 198 |
+
*,
|
| 199 |
+
api_key: str,
|
| 200 |
+
model: str,
|
| 201 |
+
input_data: Any,
|
| 202 |
+
) -> dict:
|
| 203 |
+
"""调用 Gemini batchEmbedContents,返回 OpenAI embeddings 格式。"""
|
| 204 |
+
# 归一化 input 为 list
|
| 205 |
+
if isinstance(input_data, str):
|
| 206 |
+
inputs = [input_data]
|
| 207 |
+
elif isinstance(input_data, list):
|
| 208 |
+
inputs = [str(x) for x in input_data]
|
| 209 |
+
else:
|
| 210 |
+
inputs = [str(input_data)]
|
| 211 |
+
|
| 212 |
+
clean_model = model or GEMINI_DEFAULT_EMBEDDINGS_MODEL
|
| 213 |
+
url = f"{GEMINI_BASE_URL}/{GEMINI_API_VERSION}/models/{clean_model}:batchEmbedContents"
|
| 214 |
+
body = {"requests": [{"model": f"models/{clean_model}", "content": {"parts": [{"text": t}]}} for t in inputs]}
|
| 215 |
+
|
| 216 |
+
client = get_http_client()
|
| 217 |
+
try:
|
| 218 |
+
resp = await client.post(url, headers=_build_headers(api_key), json=body)
|
| 219 |
+
except httpx.TimeoutException as e:
|
| 220 |
+
raise HttpError(f"Gemini embeddings timeout: {e}", status=504, code="upstream_timeout") from e
|
| 221 |
+
except httpx.HTTPError as e:
|
| 222 |
+
raise HttpError(f"Gemini embeddings error: {e}", status=502, code="bad_gateway") from e
|
| 223 |
+
|
| 224 |
+
if resp.status_code >= 400:
|
| 225 |
+
await _raise_upstream_error(resp)
|
| 226 |
+
|
| 227 |
+
try:
|
| 228 |
+
data = resp.json()
|
| 229 |
+
except Exception as e:
|
| 230 |
+
raise HttpError("Gemini embeddings non-JSON", status=502, code="bad_gateway") from e
|
| 231 |
+
|
| 232 |
+
return convert_embeddings_response(data, model=clean_model)
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
async def list_models(*, api_key: str) -> dict:
|
| 236 |
+
url = f"{GEMINI_BASE_URL}/{GEMINI_API_VERSION}/models"
|
| 237 |
+
client = get_http_client()
|
| 238 |
+
try:
|
| 239 |
+
resp = await client.get(url, headers=_build_headers(api_key), params={"pageSize": "200"})
|
| 240 |
+
except httpx.HTTPError as e:
|
| 241 |
+
raise HttpError(f"Gemini list models error: {e}", status=502, code="bad_gateway") from e
|
| 242 |
+
if resp.status_code >= 400:
|
| 243 |
+
await _raise_upstream_error(resp)
|
| 244 |
+
try:
|
| 245 |
+
data = resp.json()
|
| 246 |
+
except Exception as e:
|
| 247 |
+
raise HttpError("Gemini list models non-JSON", status=502, code="bad_gateway") from e
|
| 248 |
+
return convert_models_response(data)
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
async def list_models_raw(*, api_key: str) -> list[dict]:
|
| 252 |
+
"""原始模型列表(供后台拉取模型用)。"""
|
| 253 |
+
url = f"{GEMINI_BASE_URL}/{GEMINI_API_VERSION}/models"
|
| 254 |
+
client = get_http_client()
|
| 255 |
+
try:
|
| 256 |
+
resp = await client.get(url, headers=_build_headers(api_key), params={"pageSize": "200"})
|
| 257 |
+
except httpx.HTTPError as e:
|
| 258 |
+
raise HttpError(f"Gemini list models error: {e}", status=502, code="bad_gateway") from e
|
| 259 |
+
if resp.status_code >= 400:
|
| 260 |
+
await _raise_upstream_error(resp)
|
| 261 |
+
try:
|
| 262 |
+
data = resp.json()
|
| 263 |
+
except Exception as e:
|
| 264 |
+
raise HttpError("Gemini list models non-JSON", status=502, code="bad_gateway") from e
|
| 265 |
+
out = []
|
| 266 |
+
for m in data.get("models") or []:
|
| 267 |
+
name = (m.get("name") or "").removeprefix("models/")
|
| 268 |
+
if name:
|
| 269 |
+
out.append({"id": name, "name": m.get("displayName") or name})
|
| 270 |
+
return out
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
async def _raise_upstream_error(resp: httpx.Response) -> None:
|
| 274 |
+
text = resp.text
|
| 275 |
+
try:
|
| 276 |
+
body = resp.json()
|
| 277 |
+
except Exception:
|
| 278 |
+
body = None
|
| 279 |
+
upstream_err = None
|
| 280 |
+
if isinstance(body, dict):
|
| 281 |
+
upstream_err = body.get("error") if isinstance(body.get("error"), dict) else body
|
| 282 |
+
code = (upstream_err or {}).get("code") if isinstance(upstream_err, dict) else None
|
| 283 |
+
code = code or error_code_for_status(resp.status_code, "upstream_error")
|
| 284 |
+
message = (upstream_err or {}).get("message") if isinstance(upstream_err, dict) else None
|
| 285 |
+
message = message or text or f"Upstream error ({resp.status_code})"
|
| 286 |
+
raise HttpError(
|
| 287 |
+
message,
|
| 288 |
+
status=resp.status_code,
|
| 289 |
+
code=code,
|
| 290 |
+
upstream=upstream_err,
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
async def _raise_upstream_error_from_raw(status: int, raw: bytes) -> None:
|
| 295 |
+
text = raw.decode("utf-8", errors="replace")
|
| 296 |
+
try:
|
| 297 |
+
body = json.loads(text)
|
| 298 |
+
except Exception:
|
| 299 |
+
body = None
|
| 300 |
+
upstream_err = None
|
| 301 |
+
if isinstance(body, dict):
|
| 302 |
+
upstream_err = body.get("error") if isinstance(body.get("error"), dict) else body
|
| 303 |
+
code = (upstream_err or {}).get("code") if isinstance(upstream_err, dict) else None
|
| 304 |
+
code = code or error_code_for_status(status, "upstream_error")
|
| 305 |
+
message = (upstream_err or {}).get("message") if isinstance(upstream_err, dict) else None
|
| 306 |
+
message = message or text or f"Upstream error ({status})"
|
| 307 |
+
raise HttpError(message, status=status, code=code, upstream=upstream_err)
|
app/adapters/openai.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OpenAI 兼容上游适配器:透传到 {base_url}/v1/{endpoint}。
|
| 2 |
+
|
| 3 |
+
直接转发请求/响应/流式,不做格式转换。
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import json
|
| 8 |
+
from typing import Any, AsyncIterator, Optional
|
| 9 |
+
|
| 10 |
+
import httpx
|
| 11 |
+
|
| 12 |
+
from ..errors import HttpError, error_code_for_status
|
| 13 |
+
from ..http_client import get_http_client
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _normalize_base_url(base_url: str) -> str:
|
| 17 |
+
base_url = (base_url or "https://api.openai.com").rstrip("/")
|
| 18 |
+
# 兼容用户填了 /v1 的情况
|
| 19 |
+
if base_url.endswith("/v1"):
|
| 20 |
+
return base_url[:-3]
|
| 21 |
+
return base_url
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _build_headers(api_key: str, *, auth_header: str = "Authorization") -> dict[str, str]:
|
| 25 |
+
return {
|
| 26 |
+
"Content-Type": "application/json",
|
| 27 |
+
auth_header: f"Bearer {api_key}",
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
async def chat_completions(
|
| 32 |
+
*,
|
| 33 |
+
base_url: str,
|
| 34 |
+
api_key: str,
|
| 35 |
+
body: dict,
|
| 36 |
+
stream: bool = False,
|
| 37 |
+
) -> Any:
|
| 38 |
+
"""透传到上游 /v1/chat/completions。"""
|
| 39 |
+
url = f"{_normalize_base_url(base_url)}/v1/chat/completions"
|
| 40 |
+
client = get_http_client()
|
| 41 |
+
try:
|
| 42 |
+
if stream:
|
| 43 |
+
return _stream_passthrough(base_url, api_key, body)
|
| 44 |
+
resp = await client.post(url, headers=_build_headers(api_key), json=body)
|
| 45 |
+
except httpx.TimeoutException as e:
|
| 46 |
+
raise HttpError(f"OpenAI upstream timeout: {e}", status=504, code="upstream_timeout") from e
|
| 47 |
+
except httpx.HTTPError as e:
|
| 48 |
+
raise HttpError(f"OpenAI upstream error: {e}", status=502, code="bad_gateway") from e
|
| 49 |
+
|
| 50 |
+
if resp.status_code >= 400:
|
| 51 |
+
await _raise_upstream_error(resp)
|
| 52 |
+
|
| 53 |
+
try:
|
| 54 |
+
return resp.json()
|
| 55 |
+
except Exception as e:
|
| 56 |
+
raise HttpError("OpenAI upstream non-JSON", status=502, code="bad_gateway") from e
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
async def _stream_passthrough(
|
| 60 |
+
base_url: str,
|
| 61 |
+
api_key: str,
|
| 62 |
+
body: dict,
|
| 63 |
+
) -> AsyncIterator[bytes]:
|
| 64 |
+
"""透传 SSE 字节流。"""
|
| 65 |
+
url = f"{_normalize_base_url(base_url)}/v1/chat/completions"
|
| 66 |
+
client = get_http_client()
|
| 67 |
+
try:
|
| 68 |
+
async with client.stream(
|
| 69 |
+
"POST", url, headers=_build_headers(api_key), json=body
|
| 70 |
+
) as resp:
|
| 71 |
+
if resp.status_code >= 400:
|
| 72 |
+
raw = await resp.aread()
|
| 73 |
+
await _raise_upstream_error_from_raw(resp.status_code, raw)
|
| 74 |
+
async for chunk in resp.aiter_bytes():
|
| 75 |
+
yield chunk
|
| 76 |
+
except httpx.TimeoutException as e:
|
| 77 |
+
raise HttpError(f"OpenAI stream timeout: {e}", status=504, code="upstream_timeout") from e
|
| 78 |
+
except HttpError:
|
| 79 |
+
raise
|
| 80 |
+
except httpx.HTTPError as e:
|
| 81 |
+
raise HttpError(f"OpenAI stream error: {e}", status=502, code="bad_gateway") from e
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
async def embeddings(
|
| 85 |
+
*,
|
| 86 |
+
base_url: str,
|
| 87 |
+
api_key: str,
|
| 88 |
+
body: dict,
|
| 89 |
+
) -> dict:
|
| 90 |
+
url = f"{_normalize_base_url(base_url)}/v1/embeddings"
|
| 91 |
+
client = get_http_client()
|
| 92 |
+
try:
|
| 93 |
+
resp = await client.post(url, headers=_build_headers(api_key), json=body)
|
| 94 |
+
except httpx.TimeoutException as e:
|
| 95 |
+
raise HttpError(f"OpenAI embeddings timeout: {e}", status=504, code="upstream_timeout") from e
|
| 96 |
+
except httpx.HTTPError as e:
|
| 97 |
+
raise HttpError(f"OpenAI embeddings error: {e}", status=502, code="bad_gateway") from e
|
| 98 |
+
|
| 99 |
+
if resp.status_code >= 400:
|
| 100 |
+
await _raise_upstream_error(resp)
|
| 101 |
+
|
| 102 |
+
try:
|
| 103 |
+
return resp.json()
|
| 104 |
+
except Exception as e:
|
| 105 |
+
raise HttpError("OpenAI embeddings non-JSON", status=502, code="bad_gateway") from e
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
async def list_models(
|
| 109 |
+
*,
|
| 110 |
+
base_url: str,
|
| 111 |
+
api_key: str,
|
| 112 |
+
) -> dict:
|
| 113 |
+
url = f"{_normalize_base_url(base_url)}/v1/models"
|
| 114 |
+
client = get_http_client()
|
| 115 |
+
try:
|
| 116 |
+
resp = await client.get(url, headers=_build_headers(api_key))
|
| 117 |
+
except httpx.HTTPError as e:
|
| 118 |
+
raise HttpError(f"OpenAI list models error: {e}", status=502, code="bad_gateway") from e
|
| 119 |
+
if resp.status_code >= 400:
|
| 120 |
+
await _raise_upstream_error(resp)
|
| 121 |
+
try:
|
| 122 |
+
return resp.json()
|
| 123 |
+
except Exception as e:
|
| 124 |
+
raise HttpError("OpenAI list models non-JSON", status=502, code="bad_gateway") from e
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
async def list_models_raw(
|
| 128 |
+
*,
|
| 129 |
+
base_url: str,
|
| 130 |
+
api_key: str,
|
| 131 |
+
) -> list[dict]:
|
| 132 |
+
"""原始模型列表(供后台拉取模型用)。"""
|
| 133 |
+
data = await list_models(base_url=base_url, api_key=api_key)
|
| 134 |
+
out = []
|
| 135 |
+
for m in data.get("data") or []:
|
| 136 |
+
mid = m.get("id")
|
| 137 |
+
if mid:
|
| 138 |
+
out.append({"id": mid, "name": m.get("id")})
|
| 139 |
+
return out
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
async def _raise_upstream_error(resp: httpx.Response) -> None:
|
| 143 |
+
text = resp.text
|
| 144 |
+
try:
|
| 145 |
+
body = resp.json()
|
| 146 |
+
except Exception:
|
| 147 |
+
body = None
|
| 148 |
+
upstream_err = None
|
| 149 |
+
if isinstance(body, dict):
|
| 150 |
+
upstream_err = body.get("error") if isinstance(body.get("error"), dict) else body
|
| 151 |
+
code = (upstream_err or {}).get("code") if isinstance(upstream_err, dict) else None
|
| 152 |
+
code = code or error_code_for_status(resp.status_code, "upstream_error")
|
| 153 |
+
message = (upstream_err or {}).get("message") if isinstance(upstream_err, dict) else None
|
| 154 |
+
message = message or text or f"Upstream error ({resp.status_code})"
|
| 155 |
+
raise HttpError(message, status=resp.status_code, code=code, upstream=upstream_err)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
async def _raise_upstream_error_from_raw(status: int, raw: bytes) -> None:
|
| 159 |
+
text = raw.decode("utf-8", errors="replace")
|
| 160 |
+
try:
|
| 161 |
+
body = json.loads(text)
|
| 162 |
+
except Exception:
|
| 163 |
+
body = None
|
| 164 |
+
upstream_err = None
|
| 165 |
+
if isinstance(body, dict):
|
| 166 |
+
upstream_err = body.get("error") if isinstance(body.get("error"), dict) else body
|
| 167 |
+
code = (upstream_err or {}).get("code") if isinstance(upstream_err, dict) else None
|
| 168 |
+
code = code or error_code_for_status(status, "upstream_error")
|
| 169 |
+
message = (upstream_err or {}).get("message") if isinstance(upstream_err, dict) else None
|
| 170 |
+
message = message or text or f"Upstream error ({status})"
|
| 171 |
+
raise HttpError(message, status=status, code=code, upstream=upstream_err)
|
app/admin_html.py
ADDED
|
@@ -0,0 +1,870 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""管理后台 HTML(现代化 Tab 版)。
|
| 2 |
+
|
| 3 |
+
Tab 切换:概览 / 厂商 / 令牌 / 文件 / 会话 / 用量 / Webhook / 审计 / 测试
|
| 4 |
+
所有交互通过 fetch 调 /admin/api/* 接口。
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from fastapi import APIRouter
|
| 9 |
+
from fastapi.responses import HTMLResponse
|
| 10 |
+
|
| 11 |
+
from .api._common import CORS_HEADERS
|
| 12 |
+
from .config import get_settings
|
| 13 |
+
|
| 14 |
+
router = APIRouter(tags=["admin-html"])
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@router.get("/admin", response_class=HTMLResponse)
|
| 18 |
+
async def admin_page() -> HTMLResponse:
|
| 19 |
+
settings = get_settings()
|
| 20 |
+
disabled = not settings.is_admin_enabled
|
| 21 |
+
return HTMLResponse(content=_render_html(disabled), headers=CORS_HEADERS)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _render_html(disabled: bool) -> str:
|
| 25 |
+
disabled_attr = "true" if disabled else "false"
|
| 26 |
+
return _HTML_TEMPLATE.replace("__DISABLED_ATTR__", disabled_attr)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
_HTML_TEMPLATE = """<!DOCTYPE html>
|
| 30 |
+
<html lang="zh-CN">
|
| 31 |
+
<head>
|
| 32 |
+
<meta charset="utf-8">
|
| 33 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 34 |
+
<title>XTC 后台管理</title>
|
| 35 |
+
<style>
|
| 36 |
+
:root {
|
| 37 |
+
--bg: #0f1117; --panel: #1a1d27; --panel2: #232734; --border: #2d3142;
|
| 38 |
+
--text: #e4e6ed; --muted: #8b8fa3; --accent: #4f8cff; --accent2: #6aa1ff;
|
| 39 |
+
--ok: #4ade80; --warn: #fbbf24; --err: #f87171;
|
| 40 |
+
}
|
| 41 |
+
* { box-sizing: border-box; }
|
| 42 |
+
body {
|
| 43 |
+
margin: 0; font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
| 44 |
+
background: var(--bg); color: var(--text); line-height: 1.5;
|
| 45 |
+
}
|
| 46 |
+
header {
|
| 47 |
+
padding: 14px 24px; background: var(--panel); border-bottom: 1px solid var(--border);
|
| 48 |
+
display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap;
|
| 49 |
+
}
|
| 50 |
+
header h1 { margin: 0; font-size: 18px; font-weight: 600; }
|
| 51 |
+
header .badge { font-size: 12px; color: var(--muted); }
|
| 52 |
+
header .svc-status { font-size: 12px; padding: 2px 8px; border-radius: 4px; background: var(--panel2); }
|
| 53 |
+
.layout { display: flex; min-height: calc(100vh - 56px); }
|
| 54 |
+
nav.tabs {
|
| 55 |
+
width: 180px; background: var(--panel); border-right: 1px solid var(--border);
|
| 56 |
+
padding: 12px 0; flex-shrink: 0;
|
| 57 |
+
}
|
| 58 |
+
nav.tabs button {
|
| 59 |
+
display: block; width: 100%; text-align: left; padding: 10px 20px;
|
| 60 |
+
background: transparent; border: none; color: var(--muted); cursor: pointer;
|
| 61 |
+
font-size: 14px; border-left: 3px solid transparent;
|
| 62 |
+
}
|
| 63 |
+
nav.tabs button:hover { background: var(--panel2); color: var(--text); }
|
| 64 |
+
nav.tabs button.active { color: var(--accent); border-left-color: var(--accent); background: var(--panel2); }
|
| 65 |
+
main { flex: 1; padding: 24px; overflow: auto; }
|
| 66 |
+
.card {
|
| 67 |
+
background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
|
| 68 |
+
padding: 16px 20px; margin-bottom: 16px;
|
| 69 |
+
}
|
| 70 |
+
.card h2 { margin: 0 0 12px; font-size: 15px; }
|
| 71 |
+
input, button, select, textarea {
|
| 72 |
+
font: inherit; color: var(--text); background: var(--panel2);
|
| 73 |
+
border: 1px solid var(--border); border-radius: 6px; padding: 8px 12px;
|
| 74 |
+
}
|
| 75 |
+
input, textarea { width: 100%; }
|
| 76 |
+
button { cursor: pointer; background: var(--accent); border-color: var(--accent); }
|
| 77 |
+
button:hover { background: var(--accent2); }
|
| 78 |
+
button.ghost { background: transparent; }
|
| 79 |
+
button.danger { background: var(--err); border-color: var(--err); }
|
| 80 |
+
button.small { padding: 4px 10px; font-size: 12px; }
|
| 81 |
+
.row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
| 82 |
+
.row > input, .row > select { flex: 1; }
|
| 83 |
+
.row > button { flex: 0 0 auto; }
|
| 84 |
+
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
| 85 |
+
th, td { padding: 8px 10px; border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; }
|
| 86 |
+
th { color: var(--muted); font-weight: 500; }
|
| 87 |
+
td .mono { font-family: ui-monospace, "SF Mono", Consolas, monospace; font-size: 12px; }
|
| 88 |
+
.tag { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; }
|
| 89 |
+
.tag.ok { background: rgba(74,222,128,0.15); color: var(--ok); }
|
| 90 |
+
.tag.off { background: rgba(139,143,163,0.15); color: var(--muted); }
|
| 91 |
+
.tag.warn { background: rgba(251,191,36,0.15); color: var(--warn); }
|
| 92 |
+
.tag.err { background: rgba(248,113,113,0.15); color: var(--err); }
|
| 93 |
+
.toast {
|
| 94 |
+
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
|
| 95 |
+
background: var(--panel2); border: 1px solid var(--border); padding: 10px 16px;
|
| 96 |
+
border-radius: 6px; opacity: 0; transition: opacity 0.3s; pointer-events: none; z-index: 100;
|
| 97 |
+
}
|
| 98 |
+
.toast.show { opacity: 1; }
|
| 99 |
+
pre { background: var(--panel2); padding: 12px; border-radius: 6px; overflow: auto; font-size: 12px; max-height: 400px; }
|
| 100 |
+
.muted { color: var(--muted); font-size: 12px; }
|
| 101 |
+
.hidden { display: none; }
|
| 102 |
+
.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; }
|
| 103 |
+
.stat-box { background: var(--panel2); padding: 12px 16px; border-radius: 6px; }
|
| 104 |
+
.stat-box .label { font-size: 12px; color: var(--muted); }
|
| 105 |
+
.stat-box .value { font-size: 22px; font-weight: 600; margin-top: 4px; }
|
| 106 |
+
.bar-chart { display: flex; align-items: flex-end; gap: 2px; height: 80px; margin-top: 8px; }
|
| 107 |
+
.bar { flex: 1; background: var(--accent); min-height: 2px; border-radius: 2px 2px 0 0; }
|
| 108 |
+
.tab-content { display: none; }
|
| 109 |
+
.tab-content.active { display: block; }
|
| 110 |
+
.msg-bubble { padding: 8px 12px; border-radius: 8px; margin: 6px 0; max-width: 80%; }
|
| 111 |
+
.msg-bubble.user { background: var(--panel2); }
|
| 112 |
+
.msg-bubble.assistant { background: rgba(79,140,255,0.15); }
|
| 113 |
+
.msg-bubble.system { background: rgba(139,143,163,0.15); }
|
| 114 |
+
.msg-bubble .role { font-size: 11px; color: var(--muted); margin-bottom: 4px; }
|
| 115 |
+
.msg-bubble .thought { font-size: 12px; color: var(--muted); font-style: italic; margin-top: 6px; padding-top: 6px; border-top: 1px dashed var(--border); }
|
| 116 |
+
</style>
|
| 117 |
+
</head>
|
| 118 |
+
<body data-disabled="__DISABLED_ATTR__">
|
| 119 |
+
<header>
|
| 120 |
+
<h1>XTC 后台管理</h1>
|
| 121 |
+
<div class="row" style="gap:8px">
|
| 122 |
+
<span class="svc-status" id="svcStatus">服务: 检测中</span>
|
| 123 |
+
<span class="badge">Hugging Face 版</span>
|
| 124 |
+
</div>
|
| 125 |
+
</header>
|
| 126 |
+
<div class="layout">
|
| 127 |
+
<nav class="tabs">
|
| 128 |
+
<button class="active" data-tab="overview">概览</button>
|
| 129 |
+
<button data-tab="providers">厂商</button>
|
| 130 |
+
<button data-tab="tokens">令牌</button>
|
| 131 |
+
<button data-tab="sessions">会话</button>
|
| 132 |
+
<button data-tab="usage">用量</button>
|
| 133 |
+
<button data-tab="webhooks">Webhook</button>
|
| 134 |
+
<button data-tab="audit">审计</button>
|
| 135 |
+
<button data-tab="requests">请求历史</button>
|
| 136 |
+
<button data-tab="test">测试</button>
|
| 137 |
+
</nav>
|
| 138 |
+
<main>
|
| 139 |
+
<!-- 登录 -->
|
| 140 |
+
<div class="card" id="loginCard">
|
| 141 |
+
<h2>登录</h2>
|
| 142 |
+
<div class="row">
|
| 143 |
+
<input id="adminKey" type="password" placeholder="XTC_ADMIN_KEY">
|
| 144 |
+
<button onclick="doLogin()">登录</button>
|
| 145 |
+
</div>
|
| 146 |
+
<p class="muted">登录后才能使用以下功能。admin key 会保存在 localStorage。</p>
|
| 147 |
+
</div>
|
| 148 |
+
|
| 149 |
+
<div id="appBody" class="hidden">
|
| 150 |
+
<!-- 概览 -->
|
| 151 |
+
<div class="tab-content active" data-tab="overview">
|
| 152 |
+
<div class="card">
|
| 153 |
+
<h2>系统概览</h2>
|
| 154 |
+
<div class="stats-grid" id="overviewStats"><div class="muted">加载中...</div></div>
|
| 155 |
+
</div>
|
| 156 |
+
<div class="card">
|
| 157 |
+
<h2>最近 24 小时请求趋势</h2>
|
| 158 |
+
<div id="overviewTimeline"><div class="muted">加载中...</div></div>
|
| 159 |
+
</div>
|
| 160 |
+
</div>
|
| 161 |
+
|
| 162 |
+
<!-- 厂商 -->
|
| 163 |
+
<div class="tab-content" data-tab="providers">
|
| 164 |
+
<div class="card">
|
| 165 |
+
<h2>厂商列表</h2>
|
| 166 |
+
<div id="providersList">加载中...</div>
|
| 167 |
+
</div>
|
| 168 |
+
<div class="card">
|
| 169 |
+
<h2>添加厂商</h2>
|
| 170 |
+
<div class="row">
|
| 171 |
+
<input id="pId" placeholder="id(如 gemini)">
|
| 172 |
+
<input id="pName" placeholder="名称">
|
| 173 |
+
<select id="pType"><option value="gemini">gemini</option><option value="openai">openai</option></select>
|
| 174 |
+
</div>
|
| 175 |
+
<div class="row" style="margin-top:8px">
|
| 176 |
+
<input id="pBaseUrl" placeholder="base_url(openai 类型必填)">
|
| 177 |
+
<input id="pPrefix" placeholder="model_prefix(可空)">
|
| 178 |
+
</div>
|
| 179 |
+
<div class="row" style="margin-top:8px">
|
| 180 |
+
<input id="pKeys" placeholder="api_keys(逗号分隔多个)">
|
| 181 |
+
<button onclick="addProvider()">添加</button>
|
| 182 |
+
</div>
|
| 183 |
+
</div>
|
| 184 |
+
<div class="card">
|
| 185 |
+
<h2>导入配置</h2>
|
| 186 |
+
<p class="muted">从导出的 JSON 文件导入完整配置(会覆盖当前配置)。</p>
|
| 187 |
+
<div class="row">
|
| 188 |
+
<input type="file" id="importConfigFile" accept=".json,application/json" style="flex:1">
|
| 189 |
+
<button onclick="importConfig()">导入</button>
|
| 190 |
+
</div>
|
| 191 |
+
</div>
|
| 192 |
+
</div>
|
| 193 |
+
|
| 194 |
+
<!-- 令牌 -->
|
| 195 |
+
<div class="tab-content" data-tab="tokens">
|
| 196 |
+
<div class="card">
|
| 197 |
+
<h2>临时访问令牌</h2>
|
| 198 |
+
<div class="row">
|
| 199 |
+
<button onclick="issueToken()">签发新令牌</button>
|
| 200 |
+
<button class="ghost" onclick="loadTokens()">刷新</button>
|
| 201 |
+
</div>
|
| 202 |
+
<div id="tokensList" class="muted" style="margin-top:12px">加载中...</div>
|
| 203 |
+
</div>
|
| 204 |
+
</div>
|
| 205 |
+
|
| 206 |
+
<!-- 会话 -->
|
| 207 |
+
<div class="tab-content" data-tab="sessions">
|
| 208 |
+
<div class="card">
|
| 209 |
+
<h2>会话管理(全用户)</h2>
|
| 210 |
+
<div class="row" style="margin-bottom:12px">
|
| 211 |
+
<button class="ghost" onclick="loadSessions()">刷新</button>
|
| 212 |
+
</div>
|
| 213 |
+
<div id="sessionsList">加载中...</div>
|
| 214 |
+
</div>
|
| 215 |
+
<div class="card hidden" id="sessionDetailCard">
|
| 216 |
+
<h2>会话详情 <button class="ghost small" onclick="closeSessionDetail()">关闭</button></h2>
|
| 217 |
+
<div id="sessionMessages"></div>
|
| 218 |
+
</div>
|
| 219 |
+
</div>
|
| 220 |
+
|
| 221 |
+
<!-- 用量 -->
|
| 222 |
+
<div class="tab-content" data-tab="usage">
|
| 223 |
+
<div class="card">
|
| 224 |
+
<h2>用量统计</h2>
|
| 225 |
+
<div class="row" style="margin-bottom:12px">
|
| 226 |
+
<label>最近</label>
|
| 227 |
+
<select id="usageHours">
|
| 228 |
+
<option value="1">1 小时</option>
|
| 229 |
+
<option value="24" selected>24 小时</option>
|
| 230 |
+
<option value="168">7 天</option>
|
| 231 |
+
<option value="720">30 天</option>
|
| 232 |
+
</select>
|
| 233 |
+
<button onclick="loadUsage()">查询</button>
|
| 234 |
+
</div>
|
| 235 |
+
<div class="stats-grid" id="usageStats"><div class="muted">点查询开始</div></div>
|
| 236 |
+
</div>
|
| 237 |
+
<div class="card">
|
| 238 |
+
<h2>按厂商</h2>
|
| 239 |
+
<div id="usageByProvider"></div>
|
| 240 |
+
</div>
|
| 241 |
+
<div class="card">
|
| 242 |
+
<h2>按模型(Top 10)</h2>
|
| 243 |
+
<div id="usageByModel"></div>
|
| 244 |
+
</div>
|
| 245 |
+
<div class="card">
|
| 246 |
+
<h2>错误码分布</h2>
|
| 247 |
+
<div id="usageByError"></div>
|
| 248 |
+
</div>
|
| 249 |
+
<div class="card">
|
| 250 |
+
<h2>时间线</h2>
|
| 251 |
+
<div id="usageTimeline"></div>
|
| 252 |
+
</div>
|
| 253 |
+
</div>
|
| 254 |
+
|
| 255 |
+
<!-- Webhook -->
|
| 256 |
+
<div class="tab-content" data-tab="webhooks">
|
| 257 |
+
<div class="card">
|
| 258 |
+
<h2>Webhook 配置</h2>
|
| 259 |
+
<div class="row" style="margin-bottom:12px">
|
| 260 |
+
<button class="ghost" onclick="loadWebhooks()">刷新</button>
|
| 261 |
+
</div>
|
| 262 |
+
<div id="webhooksList">加载中...</div>
|
| 263 |
+
</div>
|
| 264 |
+
<div class="card">
|
| 265 |
+
<h2>添加 Webhook</h2>
|
| 266 |
+
<div class="row">
|
| 267 |
+
<input id="whName" placeholder="名称">
|
| 268 |
+
<input id="whUrl" placeholder="https://...">
|
| 269 |
+
</div>
|
| 270 |
+
<div class="row" style="margin-top:8px">
|
| 271 |
+
<input id="whEvents" placeholder="事件(逗号分隔,如 file.uploaded,chat.completed)">
|
| 272 |
+
<label><input type="checkbox" id="whEnabled" checked style="width:auto"> 启用</label>
|
| 273 |
+
</div>
|
| 274 |
+
<div class="row" style="margin-top:8px">
|
| 275 |
+
<button onclick="addWebhook()">添加</button>
|
| 276 |
+
</div>
|
| 277 |
+
<p class="muted">可选事件:file.uploaded / session.created / session.deleted / config.updated / chat.completed / chat.failed / test.ping / *</p>
|
| 278 |
+
</div>
|
| 279 |
+
</div>
|
| 280 |
+
|
| 281 |
+
<!-- 审计 -->
|
| 282 |
+
<div class="tab-content" data-tab="audit">
|
| 283 |
+
<div class="card">
|
| 284 |
+
<h2>审计日志</h2>
|
| 285 |
+
<div class="row" style="margin-bottom:12px">
|
| 286 |
+
<label>最近</label>
|
| 287 |
+
<select id="auditHours">
|
| 288 |
+
<option value="1">1 小时</option>
|
| 289 |
+
<option value="24" selected>24 小时</option>
|
| 290 |
+
<option value="168">7 天</option>
|
| 291 |
+
<option value="720">30 天</option>
|
| 292 |
+
</select>
|
| 293 |
+
<input id="auditAction" placeholder="action 过滤(可空)" style="flex:1">
|
| 294 |
+
<button onclick="loadAudit()">查询</button>
|
| 295 |
+
</div>
|
| 296 |
+
<div id="auditList">点查询开始</div>
|
| 297 |
+
</div>
|
| 298 |
+
</div>
|
| 299 |
+
|
| 300 |
+
<!-- 请求历史 -->
|
| 301 |
+
<div class="tab-content" data-tab="requests">
|
| 302 |
+
<div class="card">
|
| 303 |
+
<h2>请求历史 <span id="reqLogLimit" class="muted" style="font-size:12px;font-weight:normal"></span></h2>
|
| 304 |
+
<div class="row" style="margin-bottom:12px;gap:6px">
|
| 305 |
+
<input id="reqPath" placeholder="路径模糊匹配" style="flex:1">
|
| 306 |
+
<select id="reqMethod">
|
| 307 |
+
<option value="">所有方法</option>
|
| 308 |
+
<option>GET</option><option>POST</option><option>PUT</option><option>DELETE</option><option>OPTIONS</option>
|
| 309 |
+
</select>
|
| 310 |
+
<select id="reqStatus">
|
| 311 |
+
<option value="">所有状态</option>
|
| 312 |
+
<option value="2xx">2xx 成功</option>
|
| 313 |
+
<option value="3xx">3xx 重定向</option>
|
| 314 |
+
<option value="4xx">4xx 客户端错误</option>
|
| 315 |
+
<option value="5xx">5xx 服务端错误</option>
|
| 316 |
+
</select>
|
| 317 |
+
<select id="reqOk">
|
| 318 |
+
<option value="">全部</option>
|
| 319 |
+
<option value="1">仅成功</option>
|
| 320 |
+
<option value="0">仅失败</option>
|
| 321 |
+
</select>
|
| 322 |
+
<input id="reqKeyword" placeholder="关键词(路径/错误)" style="flex:1">
|
| 323 |
+
<button onclick="loadRequests(1)">查询</button>
|
| 324 |
+
<button class="ghost" onclick="loadRequests(1)">刷新</button>
|
| 325 |
+
<button class="ghost" onclick="clearRequests()">清空</button>
|
| 326 |
+
</div>
|
| 327 |
+
<div id="reqList">点查询开始</div>
|
| 328 |
+
<div class="row" style="margin-top:12px;justify-content:space-between">
|
| 329 |
+
<div class="muted" id="reqPageInfo"></div>
|
| 330 |
+
<div class="row" style="gap:6px">
|
| 331 |
+
<button class="ghost small" onclick="reqPrev()">上一页</button>
|
| 332 |
+
<button class="ghost small" onclick="reqNext()">下一页</button>
|
| 333 |
+
</div>
|
| 334 |
+
</div>
|
| 335 |
+
</div>
|
| 336 |
+
|
| 337 |
+
<div class="card hidden" id="reqDetailCard">
|
| 338 |
+
<h2>请求详情 #<span id="reqDetailId"></span>
|
| 339 |
+
<button class="ghost small" onclick="closeReqDetail()">关闭</button>
|
| 340 |
+
<button class="danger small" onclick="delReqDetail()">删除此条</button>
|
| 341 |
+
</h2>
|
| 342 |
+
<div id="reqDetailBody">加载中...</div>
|
| 343 |
+
</div>
|
| 344 |
+
|
| 345 |
+
<div class="card">
|
| 346 |
+
<h2>容量设置</h2>
|
| 347 |
+
<div class="row" style="gap:6px">
|
| 348 |
+
<span class="muted">保留最近</span>
|
| 349 |
+
<select id="reqLimitSetting">
|
| 350 |
+
<option value="0">关闭记录</option>
|
| 351 |
+
<option value="100">100 条</option>
|
| 352 |
+
<option value="200">200 条</option>
|
| 353 |
+
<option value="500">500 条</option>
|
| 354 |
+
<option value="1000">1000 条</option>
|
| 355 |
+
<option value="2000">2000 条</option>
|
| 356 |
+
</select>
|
| 357 |
+
<span class="muted">条请求</span>
|
| 358 |
+
<button onclick="saveReqLimit()">保存</button>
|
| 359 |
+
</div>
|
| 360 |
+
<p class="muted">0 = 完全关闭请求历史记录;超过上限的旧记录会被自动轮转删除。</p>
|
| 361 |
+
</div>
|
| 362 |
+
|
| 363 |
+
<div class="card">
|
| 364 |
+
<h2>统计(最近 24 小时)</h2>
|
| 365 |
+
<div id="reqStats"><div class="muted">加载中...</div></div>
|
| 366 |
+
</div>
|
| 367 |
+
</div>
|
| 368 |
+
|
| 369 |
+
<!-- 测试 -->
|
| 370 |
+
<div class="tab-content" data-tab="test">
|
| 371 |
+
<div class="card">
|
| 372 |
+
<h2>对话测试</h2>
|
| 373 |
+
<div class="row">
|
| 374 |
+
<input id="tProvider" placeholder="provider id(可空)">
|
| 375 |
+
<input id="tModel" placeholder="model">
|
| 376 |
+
<select id="tMode"><option value="xtc">xtc</option><option value="openai">openai</option></select>
|
| 377 |
+
</div>
|
| 378 |
+
<textarea id="tMessages" rows="6" style="margin-top:8px" placeholder='[{"role":"user","content":"你好"}]'></textarea>
|
| 379 |
+
<div class="row" style="margin-top:8px">
|
| 380 |
+
<button onclick="runTest()">测试</button>
|
| 381 |
+
</div>
|
| 382 |
+
<pre id="tResult" class="hidden"></pre>
|
| 383 |
+
</div>
|
| 384 |
+
</div>
|
| 385 |
+
</div>
|
| 386 |
+
</div>
|
| 387 |
+
<div class="toast" id="toast"></div>
|
| 388 |
+
|
| 389 |
+
<script>
|
| 390 |
+
const $ = (id) => document.getElementById(id);
|
| 391 |
+
let ADMIN_KEY = localStorage.getItem('xtc_admin_key') || '';
|
| 392 |
+
|
| 393 |
+
function toast(msg, isErr) {
|
| 394 |
+
const t = $('toast');
|
| 395 |
+
t.textContent = msg;
|
| 396 |
+
t.style.borderColor = isErr ? 'var(--err)' : 'var(--border)';
|
| 397 |
+
t.classList.add('show');
|
| 398 |
+
setTimeout(() => t.classList.remove('show'), 2500);
|
| 399 |
+
}
|
| 400 |
+
async function api(path, opts={}) {
|
| 401 |
+
opts.headers = opts.headers || {};
|
| 402 |
+
opts.headers['x-xtc-admin-key'] = ADMIN_KEY;
|
| 403 |
+
if (opts.body && typeof opts.body !== 'string') {
|
| 404 |
+
opts.headers['Content-Type'] = 'application/json';
|
| 405 |
+
opts.body = JSON.stringify(opts.body);
|
| 406 |
+
}
|
| 407 |
+
const r = await fetch(path, opts);
|
| 408 |
+
const data = await r.json().catch(() => ({}));
|
| 409 |
+
if (!r.ok || data.ok === false) {
|
| 410 |
+
const msg = (data.error && data.error.message) || ('HTTP ' + r.status);
|
| 411 |
+
throw new Error(msg);
|
| 412 |
+
}
|
| 413 |
+
return data;
|
| 414 |
+
}
|
| 415 |
+
function fmtBytes(n) {
|
| 416 |
+
if (!n) return '0 B';
|
| 417 |
+
const u = ['B','KB','MB','GB','TB'];
|
| 418 |
+
let i = 0; let v = n;
|
| 419 |
+
while (v >= 1024 && i < u.length-1) { v /= 1024; i++; }
|
| 420 |
+
return v.toFixed(i?1:0) + ' ' + u[i];
|
| 421 |
+
}
|
| 422 |
+
function fmtTime(ts) {
|
| 423 |
+
if (!ts) return '-';
|
| 424 |
+
return new Date(ts*1000).toLocaleString('zh-CN', {hour12:false});
|
| 425 |
+
}
|
| 426 |
+
function esc(s) {
|
| 427 |
+
return String(s==null?'':s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
// Tab 切换
|
| 431 |
+
document.querySelectorAll('nav.tabs button').forEach(btn => {
|
| 432 |
+
btn.onclick = () => {
|
| 433 |
+
document.querySelectorAll('nav.tabs button').forEach(b => b.classList.remove('active'));
|
| 434 |
+
btn.classList.add('active');
|
| 435 |
+
const tab = btn.dataset.tab;
|
| 436 |
+
document.querySelectorAll('.tab-content').forEach(c => c.classList.toggle('active', c.dataset.tab === tab));
|
| 437 |
+
if (tab === 'overview') loadOverview();
|
| 438 |
+
else if (tab === 'providers') loadProviders();
|
| 439 |
+
else if (tab === 'tokens') loadTokens();
|
| 440 |
+
else if (tab === 'sessions') loadSessions();
|
| 441 |
+
else if (tab === 'webhooks') loadWebhooks();
|
| 442 |
+
else if (tab === 'requests') { loadReqLimit(); loadRequests(1); loadReqStats(); }
|
| 443 |
+
});
|
| 444 |
+
|
| 445 |
+
async function doLogin() {
|
| 446 |
+
ADMIN_KEY = $('adminKey').value.trim();
|
| 447 |
+
if (!ADMIN_KEY) return toast('请输入 admin key', true);
|
| 448 |
+
try {
|
| 449 |
+
await api('/admin/api/login', { method: 'POST', body: { admin_key: ADMIN_KEY } });
|
| 450 |
+
localStorage.setItem('xtc_admin_key', ADMIN_KEY);
|
| 451 |
+
$('loginCard').classList.add('hidden');
|
| 452 |
+
$('appBody').classList.remove('hidden');
|
| 453 |
+
checkSvcStatus();
|
| 454 |
+
loadOverview();
|
| 455 |
+
toast('登录成功');
|
| 456 |
+
} catch (e) {
|
| 457 |
+
toast('登录失败:' + e.message, true);
|
| 458 |
+
}
|
| 459 |
+
}
|
| 460 |
+
async function checkSvcStatus() {
|
| 461 |
+
try {
|
| 462 |
+
const r = await fetch('/health');
|
| 463 |
+
const d = await r.json();
|
| 464 |
+
$('svcStatus').textContent = '服务运行中';
|
| 465 |
+
} catch (e) {
|
| 466 |
+
$('svcStatus').textContent = '健康检查失败';
|
| 467 |
+
}
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
// ===== 概览 =====
|
| 471 |
+
async function loadOverview() {
|
| 472 |
+
try {
|
| 473 |
+
const [usage, files, sessions, providers] = await Promise.all([
|
| 474 |
+
api('/admin/api/usage?hours=24'),
|
| 475 |
+
api('/admin/api/files?limit=1'),
|
| 476 |
+
api('/admin/api/sessions?limit=1'),
|
| 477 |
+
api('/admin/api/providers'),
|
| 478 |
+
]);
|
| 479 |
+
const u = usage;
|
| 480 |
+
$('overviewStats').innerHTML = [
|
| 481 |
+
['24h 请求', u.total_requests],
|
| 482 |
+
['成功', u.success],
|
| 483 |
+
['失败', u.failed],
|
| 484 |
+
['Token 总量', u.total_tokens],
|
| 485 |
+
['文件数', files.count],
|
| 486 |
+
['会话数', sessions.count],
|
| 487 |
+
['厂商数', providers.count],
|
| 488 |
+
['默认厂商', providers.defaultProviderId || '-'],
|
| 489 |
+
].map(([k,v]) => `<div class="stat-box"><div class="label">${k}</div><div class="value">${v}</div></div>`).join('');
|
| 490 |
+
loadTimeline('overviewTimeline', 24, 'hour');
|
| 491 |
+
} catch (e) { $('overviewStats').innerHTML = '<div class="muted">' + esc(e.message) + '</div>'; }
|
| 492 |
+
}
|
| 493 |
+
async function loadTimeline(elId, hours, gran) {
|
| 494 |
+
try {
|
| 495 |
+
const data = await api(`/admin/api/usage/timeline?hours=${hours}&granularity=${gran}`);
|
| 496 |
+
const items = data.items || [];
|
| 497 |
+
if (!items.length) { $(elId).innerHTML = '<div class="muted">无数据</div>'; return; }
|
| 498 |
+
const max = Math.max(...items.map(i => i.requests), 1);
|
| 499 |
+
const bars = items.map(i => {
|
| 500 |
+
const h = Math.round(i.requests / max * 70);
|
| 501 |
+
const t = new Date(i.bucket_start*1000).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'});
|
| 502 |
+
return `<div class="bar" style="height:${h}px" title="${t} | ${i.requests} 次 | ${i.tokens} tokens"></div>`;
|
| 503 |
+
}).join('');
|
| 504 |
+
$(elId).innerHTML = `<div class="bar-chart">${bars}</div><div class="muted">最近 ${items.length} 个时间点</div>`;
|
| 505 |
+
} catch (e) { $(elId).innerHTML = '<div class="muted">' + esc(e.message) + '</div>'; }
|
| 506 |
+
}
|
| 507 |
+
|
| 508 |
+
// ===== 厂商 =====
|
| 509 |
+
async function loadProviders() {
|
| 510 |
+
try {
|
| 511 |
+
const data = await api('/admin/api/providers');
|
| 512 |
+
const items = data.providers || [];
|
| 513 |
+
if (!items.length) { $('providersList').innerHTML = '<div class="muted">暂无厂商,请在下方添加</div>'; return; }
|
| 514 |
+
$('providersList').innerHTML = '<table><tr><th>ID</th><th>名称</th><th>类型</th><th>状态</th><th>密钥</th><th>操作</th></tr>' +
|
| 515 |
+
items.map(p => `<tr><td class="mono">${esc(p.id)}</td><td>${esc(p.name)}</td><td>${esc(p.type)}</td><td>${p.enabled?'<span class="tag ok">启用</span>':'<span class="tag off">停用</span>'}</td><td class="mono">${esc((p.api_keys||[]).join(', '))}</td><td><button class="ghost small" onclick="toggleProvider('${esc(p.id)}', ${!p.enabled})">${p.enabled?'停用':'启用'}</button> <button class="danger small" onclick="delProvider('${esc(p.id)}')">删除</button></td></tr>`).join('') +
|
| 516 |
+
'</table>';
|
| 517 |
+
} catch (e) { $('providersList').innerHTML = '<div class="muted">' + esc(e.message) + '</div>'; }
|
| 518 |
+
}
|
| 519 |
+
async function addProvider() {
|
| 520 |
+
const keys = $('pKeys').value.split(',').map(s=>s.trim()).filter(Boolean);
|
| 521 |
+
const body = {
|
| 522 |
+
id: $('pId').value.trim(), name: $('pName').value.trim(),
|
| 523 |
+
type: $('pType').value, base_url: $('pBaseUrl').value.trim() || undefined,
|
| 524 |
+
model_prefix: $('pPrefix').value.trim(), api_keys: keys,
|
| 525 |
+
};
|
| 526 |
+
if (!body.id) return toast('请填 id', true);
|
| 527 |
+
try { await api('/admin/api/providers', { method: 'POST', body }); toast('已添加'); loadProviders(); }
|
| 528 |
+
catch (e) { toast(e.message, true); }
|
| 529 |
+
}
|
| 530 |
+
async function toggleProvider(id, enabled) {
|
| 531 |
+
try { await api('/admin/api/provider-settings', { method: 'POST', body: { id, enabled } }); toast('已更新'); loadProviders(); }
|
| 532 |
+
catch (e) { toast(e.message, true); }
|
| 533 |
+
}
|
| 534 |
+
async function delProvider(id) {
|
| 535 |
+
if (!confirm('确认删除厂商 ' + id + '?')) return;
|
| 536 |
+
try { await api('/admin/api/providers?id=' + encodeURIComponent(id), { method: 'DELETE' }); toast('已删除'); loadProviders(); }
|
| 537 |
+
catch (e) { toast(e.message, true); }
|
| 538 |
+
}
|
| 539 |
+
async function importConfig() {
|
| 540 |
+
const file = $('importConfigFile').files[0];
|
| 541 |
+
if (!file) return toast('请选择配置文件', true);
|
| 542 |
+
try {
|
| 543 |
+
const text = await file.text();
|
| 544 |
+
const data = JSON.parse(text);
|
| 545 |
+
if (!confirm('导入会覆盖当前所有配置,确认继续?')) return;
|
| 546 |
+
const d = await api('/admin/api/import-config', { method: 'POST', body: data });
|
| 547 |
+
toast('导入成功,共 ' + d.count + ' 个厂商');
|
| 548 |
+
$('importConfigFile').value = '';
|
| 549 |
+
loadProviders();
|
| 550 |
+
} catch (e) { toast('导入失败:' + e.message, true); }
|
| 551 |
+
}
|
| 552 |
+
|
| 553 |
+
// ===== 令牌 =====
|
| 554 |
+
async function issueToken() {
|
| 555 |
+
try {
|
| 556 |
+
const data = await api('/admin/api/xtc-access-token', { method: 'POST', body: {} });
|
| 557 |
+
toast('已签发:' + data.token);
|
| 558 |
+
loadTokens();
|
| 559 |
+
} catch (e) { toast(e.message, true); }
|
| 560 |
+
}
|
| 561 |
+
async function loadTokens() {
|
| 562 |
+
try {
|
| 563 |
+
const data = await api('/admin/api/tokens');
|
| 564 |
+
const items = data.tokens || [];
|
| 565 |
+
if (!items.length) { $('tokensList').textContent = '暂无令牌'; return; }
|
| 566 |
+
$('tokensList').innerHTML = '<table><tr><th>jti</th><th>签发时间</th><th>过期时间</th><th>状态</th><th>操作</th></tr>' +
|
| 567 |
+
items.map(t => `<tr><td class="mono">${esc(t.jti)}</td><td>${fmtTime(t.issued_at)}</td><td>${fmtTime(t.expires_at)}</td><td>${t.revoked?'<span class="tag off">已吊销</span>':t.expired?'<span class="tag warn">已过期</span>':'<span class="tag ok">有效</span>'}</td><td><button class="ghost small" onclick="revokeToken('${esc(t.jti)}')">吊销</button></td></tr>`).join('') +
|
| 568 |
+
'</table>';
|
| 569 |
+
} catch (e) { $('tokensList').textContent = e.message; }
|
| 570 |
+
}
|
| 571 |
+
async function revokeToken(jti) {
|
| 572 |
+
try { await api('/admin/api/tokens/revoke', { method: 'POST', body: { jti } }); toast('已吊销'); loadTokens(); }
|
| 573 |
+
catch (e) { toast(e.message, true); }
|
| 574 |
+
}
|
| 575 |
+
|
| 576 |
+
// ===== 会话 =====
|
| 577 |
+
async function loadSessions() {
|
| 578 |
+
try {
|
| 579 |
+
const data = await api('/admin/api/sessions?limit=100');
|
| 580 |
+
const items = data.items || [];
|
| 581 |
+
if (!items.length) { $('sessionsList').innerHTML = '<div class="muted">暂无会话</div>'; return; }
|
| 582 |
+
$('sessionsList').innerHTML = '<table><tr><th>标题</th><th>厂商</th><th>模型</th><th>所有者</th><th>更新时间</th><th>操作</th></tr>' +
|
| 583 |
+
items.map(s => `<tr><td>${esc(s.title)}</td><td>${esc(s.provider||'-')}</td><td>${esc(s.model||'-')}</td><td class="mono">${esc(s.access_key||'-')}</td><td>${fmtTime(s.updated_at)}</td><td><button class="ghost small" onclick="viewSession('${esc(s.id)}')">查看</button> <button class="danger small" onclick="delSession('${esc(s.id)}')">删除</button></td></tr>`).join('') +
|
| 584 |
+
'</table>';
|
| 585 |
+
} catch (e) { $('sessionsList').innerHTML = '<div class="muted">' + esc(e.message) + '</div>'; }
|
| 586 |
+
}
|
| 587 |
+
async function viewSession(id) {
|
| 588 |
+
try {
|
| 589 |
+
const data = await api('/admin/api/sessions/' + id + '/messages');
|
| 590 |
+
const msgs = data.messages || [];
|
| 591 |
+
$('sessionDetailCard').classList.remove('hidden');
|
| 592 |
+
if (!msgs.length) { $('sessionMessages').innerHTML = '<div class="muted">无消息</div>'; return; }
|
| 593 |
+
$('sessionMessages').innerHTML = msgs.map(m => {
|
| 594 |
+
let html = `<div class="msg-bubble ${m.role}"><div class="role">${esc(m.role)} · ${fmtTime(m.ts)} · ${esc(m.provider||'')} ${esc(m.model||'')}</div><div>${esc(m.content)}</div>`;
|
| 595 |
+
if (m.thought) html += `<div class="thought">思考:${esc(m.thought)}</div>`;
|
| 596 |
+
html += '</div>';
|
| 597 |
+
return html;
|
| 598 |
+
}).join('');
|
| 599 |
+
$('sessionDetailCard').scrollIntoView({behavior:'smooth'});
|
| 600 |
+
} catch (e) { toast(e.message, true); }
|
| 601 |
+
}
|
| 602 |
+
function closeSessionDetail() { $('sessionDetailCard').classList.add('hidden'); }
|
| 603 |
+
async function delSession(id) {
|
| 604 |
+
if (!confirm('确认删除会话 ' + id + '?')) return;
|
| 605 |
+
try { await api('/admin/api/sessions/' + id, { method: 'DELETE' }); toast('已删除'); loadSessions(); closeSessionDetail(); }
|
| 606 |
+
catch (e) { toast(e.message, true); }
|
| 607 |
+
}
|
| 608 |
+
|
| 609 |
+
// ===== 用量 =====
|
| 610 |
+
async function loadUsage() {
|
| 611 |
+
const hours = $('usageHours').value;
|
| 612 |
+
try {
|
| 613 |
+
const u = await api('/admin/api/usage?hours=' + hours);
|
| 614 |
+
$('usageStats').innerHTML = [
|
| 615 |
+
['请求', u.total_requests],
|
| 616 |
+
['成功', u.success],
|
| 617 |
+
['失败', u.failed],
|
| 618 |
+
['Prompt', u.prompt_tokens],
|
| 619 |
+
['Completion', u.completion_tokens],
|
| 620 |
+
['Token 总量', u.total_tokens],
|
| 621 |
+
].map(([k,v]) => `<div class="stat-box"><div class="label">${k}</div><div class="value">${v}</div></div>`).join('');
|
| 622 |
+
|
| 623 |
+
const bp = u.by_provider || [];
|
| 624 |
+
$('usageByProvider').innerHTML = bp.length ? '<table><tr><th>厂商</th><th>请求</th><th>Token</th></tr>' +
|
| 625 |
+
bp.map(p => `<tr><td class="mono">${esc(p.provider)}</td><td>${p.requests}</td><td>${p.tokens}</td></tr>`).join('') + '</table>' : '<div class="muted">无数据</div>';
|
| 626 |
+
|
| 627 |
+
const bm = u.by_model || [];
|
| 628 |
+
$('usageByModel').innerHTML = bm.length ? '<table><tr><th>模型</th><th>请求</th><th>Token</th></tr>' +
|
| 629 |
+
bm.map(m => `<tr><td class="mono">${esc(m.model)}</td><td>${m.requests}</td><td>${m.tokens}</td></tr>`).join('') + '</table>' : '<div class="muted">无数据</div>';
|
| 630 |
+
|
| 631 |
+
const be = u.by_error || [];
|
| 632 |
+
$('usageByError').innerHTML = be.length ? '<table><tr><th>错误码</th><th>次数</th></tr>' +
|
| 633 |
+
be.map(e => `<tr><td class="mono"><span class="tag err">${esc(e.code)}</span></td><td>${e.count}</td></tr>`).join('') + '</table>' : '<div class="muted">无错误</div>';
|
| 634 |
+
|
| 635 |
+
loadTimeline('usageTimeline', hours, hours >= 168 ? 'day' : 'hour');
|
| 636 |
+
} catch (e) { toast(e.message, true); }
|
| 637 |
+
}
|
| 638 |
+
|
| 639 |
+
// ===== Webhook =====
|
| 640 |
+
async function loadWebhooks() {
|
| 641 |
+
try {
|
| 642 |
+
const data = await api('/admin/api/webhooks');
|
| 643 |
+
const items = data.items || [];
|
| 644 |
+
if (!items.length) { $('webhooksList').innerHTML = '<div class="muted">暂无 Webhook</div>'; return; }
|
| 645 |
+
$('webhooksList').innerHTML = '<table><tr><th>ID</th><th>名称</th><th>URL</th><th>事件</th><th>状态</th><th>操作</th></tr>' +
|
| 646 |
+
items.map(w => `<tr><td>${w.id}</td><td>${esc(w.name)}</td><td class="mono">${esc(w.url)}</td><td>${(w.events||[]).map(e=>'<span class="tag off">'+esc(e)+'</span>').join(' ')}</td><td>${w.enabled?'<span class="tag ok">启用</span>':'<span class="tag off">停用</span>'}</td><td><button class="ghost small" onclick="testWebhook(${w.id})">测试</button> <button class="ghost small" onclick="toggleWebhook(${w.id}, ${!w.enabled})">${w.enabled?'停用':'启用'}</button> <button class="danger small" onclick="delWebhook(${w.id})">删除</button></td></tr>`).join('') +
|
| 647 |
+
'</table>';
|
| 648 |
+
} catch (e) { $('webhooksList').innerHTML = '<div class="muted">' + esc(e.message) + '</div>'; }
|
| 649 |
+
}
|
| 650 |
+
async function addWebhook() {
|
| 651 |
+
const events = $('whEvents').value.split(',').map(s=>s.trim()).filter(Boolean);
|
| 652 |
+
const body = { name: $('whName').value.trim(), url: $('whUrl').value.trim(), events, enabled: $('whEnabled').checked };
|
| 653 |
+
if (!body.name || !body.url) return toast('请填名称和 URL', true);
|
| 654 |
+
try { await api('/admin/api/webhooks', { method: 'POST', body }); toast('已添加'); loadWebhooks(); }
|
| 655 |
+
catch (e) { toast(e.message, true); }
|
| 656 |
+
}
|
| 657 |
+
async function testWebhook(id) {
|
| 658 |
+
try { await api('/admin/api/webhooks/' + id + '/test', { method: 'POST' }); toast('已发送测试事件'); }
|
| 659 |
+
catch (e) { toast(e.message, true); }
|
| 660 |
+
}
|
| 661 |
+
async function toggleWebhook(id, enabled) {
|
| 662 |
+
try { await api('/admin/api/webhooks/' + id, { method: 'PUT', body: { enabled } }); toast('已更新'); loadWebhooks(); }
|
| 663 |
+
catch (e) { toast(e.message, true); }
|
| 664 |
+
}
|
| 665 |
+
async function delWebhook(id) {
|
| 666 |
+
if (!confirm('确认删除 Webhook #' + id + '?')) return;
|
| 667 |
+
try { await api('/admin/api/webhooks/' + id, { method: 'DELETE' }); toast('已删除'); loadWebhooks(); }
|
| 668 |
+
catch (e) { toast(e.message, true); }
|
| 669 |
+
}
|
| 670 |
+
|
| 671 |
+
// ===== 请求历史 =====
|
| 672 |
+
let _reqPage = 1, _reqPageSize = 50, _reqTotal = 0;
|
| 673 |
+
|
| 674 |
+
async function loadReqLimit() {
|
| 675 |
+
try {
|
| 676 |
+
const d = await api('/admin/api/request-log/settings');
|
| 677 |
+
$('reqLimitSetting').value = String(d.limit);
|
| 678 |
+
$('reqLogLimit').textContent = '(当前保留 ' + d.limit + ' 条)';
|
| 679 |
+
} catch (e) {}
|
| 680 |
+
}
|
| 681 |
+
async function saveReqLimit() {
|
| 682 |
+
const limit = parseInt($('reqLimitSetting').value);
|
| 683 |
+
try {
|
| 684 |
+
const d = await api('/admin/api/request-log/settings', { method: 'PUT', body: { limit } });
|
| 685 |
+
toast('已设置:保留 ' + d.limit + ' 条');
|
| 686 |
+
$('reqLogLimit').textContent = '(当前保留 ' + d.limit + ' 条)';
|
| 687 |
+
} catch (e) { toast(e.message, true); }
|
| 688 |
+
}
|
| 689 |
+
async function loadRequests(page) {
|
| 690 |
+
_reqPage = page || 1;
|
| 691 |
+
const offset = (_reqPage - 1) * _reqPageSize;
|
| 692 |
+
const params = new URLSearchParams({ limit: _reqPageSize, offset });
|
| 693 |
+
const path = $('reqPath').value.trim();
|
| 694 |
+
const method = $('reqMethod').value;
|
| 695 |
+
const status = $('reqStatus').value;
|
| 696 |
+
const ok = $('reqOk').value;
|
| 697 |
+
const kw = $('reqKeyword').value.trim();
|
| 698 |
+
if (path) params.set('path', path);
|
| 699 |
+
if (method) params.set('method', method);
|
| 700 |
+
if (status) params.set('status', status);
|
| 701 |
+
if (ok !== '') params.set('ok', ok);
|
| 702 |
+
if (kw) params.set('keyword', kw);
|
| 703 |
+
try {
|
| 704 |
+
const d = await api('/admin/api/request-log?' + params.toString());
|
| 705 |
+
_reqTotal = d.total;
|
| 706 |
+
const items = d.items || [];
|
| 707 |
+
if (!items.length) { $('reqList').innerHTML = '<div class="muted">无记录</div>'; }
|
| 708 |
+
else {
|
| 709 |
+
$('reqList').innerHTML = '<table><tr><th>ID</th><th>时间</th><th>方法</th><th>路径</th><th>状态</th><th>耗时</th><th>Provider/Model</th><th>Key</th><th>错误</th><th>操作</th></tr>' +
|
| 710 |
+
items.map(r => {
|
| 711 |
+
const sc = r.status_code || 0;
|
| 712 |
+
const scClass = sc >= 500 ? 'err' : sc >= 400 ? 'warn' : sc >= 300 ? 'off' : 'ok';
|
| 713 |
+
const elapsed = r.elapsed_ms == null ? '-' : (r.elapsed_ms + 'ms');
|
| 714 |
+
const errHtml = r.ok ? '<span class="tag ok">成功</span>' : (r.error_code ? '<span class="tag err" title="' + esc(r.error_message||'') + '">' + esc(r.error_code) + '</span>' : '<span class="tag err">失败</span>');
|
| 715 |
+
const pm = (r.provider || r.model) ? esc(r.provider||'') + '/' + esc(r.model||'') : '<span class="muted">-</span>';
|
| 716 |
+
const stream = r.stream ? ' <span class="tag off">stream</span>' : '';
|
| 717 |
+
return '<tr><td>' + r.id + '</td><td>' + fmtTime(r.ts) + '</td><td>' + esc(r.method) + '</td><td class="mono">' + esc(r.path) + (r.query ? '?' + esc(r.query).slice(0,30) : '') + stream + '</td><td><span class="tag ' + scClass + '">' + sc + '</span></td><td>' + elapsed + '</td><td class="mono">' + pm + '</td><td class="mono">' + esc(r.access_key||'-').slice(0,12) + '</td><td>' + errHtml + '</td><td><button class="ghost small" onclick="viewReq(' + r.id + ')">详情</button></td></tr>';
|
| 718 |
+
}).join('') + '</table>';
|
| 719 |
+
}
|
| 720 |
+
const totalPages = Math.max(1, Math.ceil(_reqTotal / _reqPageSize));
|
| 721 |
+
$('reqPageInfo').textContent = '共 ' + _reqTotal + ' 条,第 ' + _reqPage + '/' + totalPages + ' 页';
|
| 722 |
+
} catch (e) { $('reqList').innerHTML = '<div class="muted">' + esc(e.message) + '</div>'; }
|
| 723 |
+
}
|
| 724 |
+
function reqPrev() { if (_reqPage > 1) loadRequests(_reqPage - 1); }
|
| 725 |
+
function reqNext() { if (_reqPage * _reqPageSize < _reqTotal) loadRequests(_reqPage + 1); }
|
| 726 |
+
|
| 727 |
+
async function viewReq(id) {
|
| 728 |
+
try {
|
| 729 |
+
const d = await api('/admin/api/request-log/' + id);
|
| 730 |
+
const r = d.record;
|
| 731 |
+
$('reqDetailId').textContent = id;
|
| 732 |
+
$('reqDetailCard').classList.remove('hidden');
|
| 733 |
+
const scClass = (r.status_code||0) >= 500 ? 'err' : (r.status_code||0) >= 400 ? 'warn' : 'ok';
|
| 734 |
+
let html = '<div class="stats-grid" style="margin-bottom:12px">';
|
| 735 |
+
html += '<div class="stat-box"><div class="label">方法</div><div class="value" style="font-size:14px">' + esc(r.method) + '</div></div>';
|
| 736 |
+
html += '<div class="stat-box"><div class="label">状态码</div><div class="value" style="font-size:14px"><span class="tag ' + scClass + '">' + r.status_code + '</span></div></div>';
|
| 737 |
+
html += '<div class="stat-box"><div class="label">耗时</div><div class="value" style="font-size:14px">' + (r.elapsed_ms == null ? '-' : r.elapsed_ms + 'ms') + '</div></div>';
|
| 738 |
+
html += '<div class="stat-box"><div class="label">Provider/Model</div><div class="value" style="font-size:14px">' + esc(r.provider||'-') + ' / ' + esc(r.model||'-') + '</div></div>';
|
| 739 |
+
html += '<div class="stat-box"><div class="label">客户端 IP</div><div class="value" style="font-size:14px" class="mono">' + esc(r.client_ip||'-') + '</div></div>';
|
| 740 |
+
html += '<div class="stat-box"><div class="label">Access Key</div><div class="value" style="font-size:14px" class="mono">' + esc(r.access_key||'-') + '</div></div>';
|
| 741 |
+
html += '</div>';
|
| 742 |
+
|
| 743 |
+
if (r.error_code || r.error_message) {
|
| 744 |
+
html += '<div class="card" style="background:rgba(248,113,113,0.1);border-color:var(--err);margin-bottom:12px"><strong>错误:</strong> <span class="tag err">' + esc(r.error_code||'') + '</span> ' + esc(r.error_message||'') + '</div>';
|
| 745 |
+
}
|
| 746 |
+
|
| 747 |
+
html += '<h3 style="margin:12px 0 6px;font-size:14px">完整路径</h3>';
|
| 748 |
+
html += '<pre>' + esc(r.method) + ' ' + esc(r.path) + (r.query ? '?' + esc(r.query) : '') + '</pre>';
|
| 749 |
+
|
| 750 |
+
html += '<h3 style="margin:12px 0 6px;font-size:14px">请求标头(已脱敏)</h3>';
|
| 751 |
+
html += '<pre>' + esc(JSON.stringify(r.request_headers, null, 2)) + '</pre>';
|
| 752 |
+
|
| 753 |
+
html += '<h3 style="margin:12px 0 6px;font-size:14px">请求体(已脱敏)</h3>';
|
| 754 |
+
html += '<pre>' + esc(typeof r.request_body === 'string' ? r.request_body : JSON.stringify(r.request_body, null, 2)) + '</pre>';
|
| 755 |
+
|
| 756 |
+
html += '<h3 style="margin:12px 0 6px;font-size:14px">响应标头</h3>';
|
| 757 |
+
html += '<pre>' + esc(JSON.stringify(r.response_headers, null, 2)) + '</pre>';
|
| 758 |
+
|
| 759 |
+
html += '<h3 style="margin:12px 0 6px;font-size:14px">响应体</h3>';
|
| 760 |
+
const rb = r.response_body;
|
| 761 |
+
if (rb && typeof rb === 'object' && rb.type === 'streaming') {
|
| 762 |
+
html += '<div class="card" style="margin-bottom:8px"><strong>流式响应</strong>:共 ' + rb.total_chunks + ' 个 chunk,' + rb.total_bytes + ' 字节</div>';
|
| 763 |
+
html += '<pre>' + esc((rb.sampled_chunks || []).join('\n--- chunk ---\n')) + '</pre>';
|
| 764 |
+
} else {
|
| 765 |
+
html += '<pre>' + esc(typeof rb === 'string' ? rb : JSON.stringify(rb, null, 2)) + '</pre>';
|
| 766 |
+
}
|
| 767 |
+
|
| 768 |
+
html += '<h3 style="margin:12px 0 6px;font-size:14px">User-Agent</h3>';
|
| 769 |
+
html += '<pre>' + esc(r.user_agent || '-') + '</pre>';
|
| 770 |
+
|
| 771 |
+
$('reqDetailBody').innerHTML = html;
|
| 772 |
+
$('reqDetailCard').scrollIntoView({behavior:'smooth'});
|
| 773 |
+
} catch (e) { toast(e.message, true); }
|
| 774 |
+
}
|
| 775 |
+
function closeReqDetail() { $('reqDetailCard').classList.add('hidden'); }
|
| 776 |
+
async function delReqDetail() {
|
| 777 |
+
const id = $('reqDetailId').textContent;
|
| 778 |
+
if (!confirm('确认删除请求 #' + id + '?')) return;
|
| 779 |
+
try {
|
| 780 |
+
await api('/admin/api/request-log/' + id, { method: 'DELETE' });
|
| 781 |
+
toast('已删除');
|
| 782 |
+
closeReqDetail();
|
| 783 |
+
loadRequests(_reqPage);
|
| 784 |
+
} catch (e) { toast(e.message, true); }
|
| 785 |
+
}
|
| 786 |
+
async function clearRequests() {
|
| 787 |
+
if (!confirm('确认清空全部请求历史?此操作不可恢复。')) return;
|
| 788 |
+
try {
|
| 789 |
+
const d = await api('/admin/api/request-log', { method: 'DELETE' });
|
| 790 |
+
toast('已清空 ' + d.cleared + ' 条');
|
| 791 |
+
loadRequests(1);
|
| 792 |
+
loadReqStats();
|
| 793 |
+
} catch (e) { toast(e.message, true); }
|
| 794 |
+
}
|
| 795 |
+
async function loadReqStats() {
|
| 796 |
+
try {
|
| 797 |
+
const d = await api('/admin/api/request-log/stats?hours=24');
|
| 798 |
+
let html = '<div class="stats-grid" style="margin-bottom:12px">';
|
| 799 |
+
html += '<div class="stat-box"><div class="label">24h 总请求</div><div class="value">' + d.total + '</div></div>';
|
| 800 |
+
for (const [k, v] of Object.entries(d.by_status || {})) {
|
| 801 |
+
html += '<div class="stat-box"><div class="label">' + k + '</div><div class="value">' + v + '</div></div>';
|
| 802 |
+
}
|
| 803 |
+
html += '</div>';
|
| 804 |
+
const bp = d.by_path || [];
|
| 805 |
+
if (bp.length) {
|
| 806 |
+
html += '<h3 style="margin:12px 0 6px;font-size:14px">Top 路径</h3><table><tr><th>路径</th><th>次数</th><th>平均耗时</th><th>成功</th></tr>';
|
| 807 |
+
html += bp.map(p => '<tr><td class="mono">' + esc(p.path) + '</td><td>' + p.count + '</td><td>' + p.avg_ms + 'ms</td><td>' + p.ok + '</td></tr>').join('');
|
| 808 |
+
html += '</table>';
|
| 809 |
+
}
|
| 810 |
+
const be = d.by_error || [];
|
| 811 |
+
if (be.length) {
|
| 812 |
+
html += '<h3 style="margin:12px 0 6px;font-size:14px">错误码 Top</h3><table><tr><th>错误码</th><th>次数</th></tr>';
|
| 813 |
+
html += be.map(e => '<tr><td class="mono"><span class="tag err">' + esc(e.code) + '</span></td><td>' + e.count + '</td></tr>').join('');
|
| 814 |
+
html += '</table>';
|
| 815 |
+
}
|
| 816 |
+
const slow = d.slowest || [];
|
| 817 |
+
if (slow.length) {
|
| 818 |
+
html += '<h3 style="margin:12px 0 6px;font-size:14px">最慢 Top 10</h3><table><tr><th>ID</th><th>路径</th><th>耗时</th><th>状态</th></tr>';
|
| 819 |
+
html += slow.map(s => '<tr><td>' + s.id + '</td><td class="mono">' + esc(s.path) + '</td><td>' + s.elapsed_ms + 'ms</td><td>' + s.status_code + '</td></tr>').join('');
|
| 820 |
+
html += '</table>';
|
| 821 |
+
}
|
| 822 |
+
$('reqStats').innerHTML = html;
|
| 823 |
+
} catch (e) { $('reqStats').innerHTML = '<div class="muted">' + esc(e.message) + '</div>'; }
|
| 824 |
+
}
|
| 825 |
+
|
| 826 |
+
// ===== 审计 =====
|
| 827 |
+
async function loadAudit() {
|
| 828 |
+
const hours = $('auditHours').value;
|
| 829 |
+
const action = $('auditAction').value.trim();
|
| 830 |
+
try {
|
| 831 |
+
const data = await api('/admin/api/audit?hours=' + hours + '&limit=100' + (action ? '&action=' + encodeURIComponent(action) : ''));
|
| 832 |
+
const items = data.items || [];
|
| 833 |
+
if (!items.length) { $('auditList').innerHTML = '<div class="muted">无审计记录</div>'; return; }
|
| 834 |
+
$('auditList').innerHTML = '<table><tr><th>时间</th><th>Action</th><th>Actor</th><th>Target</th><th>详情</th></tr>' +
|
| 835 |
+
items.map(a => `<tr><td>${fmtTime(a.ts)}</td><td><span class="tag off">${esc(a.action)}</span></td><td class="mono">${esc(a.actor||'-')}</td><td class="mono">${esc(a.target||'-')}</td><td><pre style="margin:0;max-height:120px">${esc(JSON.stringify(a.detail,null,2))}</pre></td></tr>`).join('') +
|
| 836 |
+
'</table>';
|
| 837 |
+
} catch (e) { $('auditList').innerHTML = '<div class="muted">' + esc(e.message) + '</div>'; }
|
| 838 |
+
}
|
| 839 |
+
|
| 840 |
+
// ===== 测试 =====
|
| 841 |
+
async function runTest() {
|
| 842 |
+
let messages;
|
| 843 |
+
try { messages = JSON.parse($('tMessages').value || '[]'); }
|
| 844 |
+
catch (e) { return toast('messages JSON 解析失败', true); }
|
| 845 |
+
try {
|
| 846 |
+
const data = await api('/admin/api/test-chat', { method: 'POST', body: {
|
| 847 |
+
provider: $('tProvider').value.trim() || undefined,
|
| 848 |
+
model: $('tModel').value.trim(),
|
| 849 |
+
messages, mode: $('tMode').value,
|
| 850 |
+
} });
|
| 851 |
+
$('tResult').classList.remove('hidden');
|
| 852 |
+
$('tResult').textContent = JSON.stringify(data, null, 2);
|
| 853 |
+
} catch (e) { toast(e.message, true); }
|
| 854 |
+
}
|
| 855 |
+
|
| 856 |
+
// 自动登录
|
| 857 |
+
if (ADMIN_KEY) {
|
| 858 |
+
api('/admin/api/login', { method: 'POST', body: { admin_key: ADMIN_KEY } })
|
| 859 |
+
.then(() => {
|
| 860 |
+
$('loginCard').classList.add('hidden');
|
| 861 |
+
$('appBody').classList.remove('hidden');
|
| 862 |
+
checkSvcStatus();
|
| 863 |
+
loadOverview();
|
| 864 |
+
})
|
| 865 |
+
.catch(() => { localStorage.removeItem('xtc_admin_key'); ADMIN_KEY=''; });
|
| 866 |
+
}
|
| 867 |
+
</script>
|
| 868 |
+
</body>
|
| 869 |
+
</html>
|
| 870 |
+
"""
|
app/api/__init__.py
ADDED
|
File without changes
|
app/api/_common.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""共享请求/响应工具:CORS、provider 选择辅助、usage 记录。"""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
import time
|
| 6 |
+
import uuid
|
| 7 |
+
from typing import Any, Optional
|
| 8 |
+
|
| 9 |
+
from fastapi import Request
|
| 10 |
+
from fastapi.responses import JSONResponse
|
| 11 |
+
|
| 12 |
+
from ..config import get_settings
|
| 13 |
+
from ..database import get_conn
|
| 14 |
+
from ..models.config import AppConfig, Provider
|
| 15 |
+
from ..providers.keypool import choose_provider_api_key
|
| 16 |
+
from ..providers.policy import assert_model_allowed
|
| 17 |
+
from ..providers.resolver import normalize_model, resolve_provider
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
CORS_HEADERS = {
|
| 21 |
+
"Access-Control-Allow-Origin": "*",
|
| 22 |
+
"Access-Control-Allow-Methods": "*",
|
| 23 |
+
"Access-Control-Allow-Headers": "*",
|
| 24 |
+
"Access-Control-Expose-Headers": "x-xtc-provider, x-xtc-model, x-xtc-image-fix-mode",
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def ok_json(payload: dict, *, status: int = 200, extra_headers: Optional[dict] = None) -> JSONResponse:
|
| 29 |
+
headers = dict(CORS_HEADERS)
|
| 30 |
+
if extra_headers:
|
| 31 |
+
headers.update(extra_headers)
|
| 32 |
+
return JSONResponse(status_code=status, content=payload, headers=headers)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def ok_with_cors(payload: dict, *, status: int = 200, extra_headers: Optional[dict] = None) -> JSONResponse:
|
| 36 |
+
body = {"ok": True, **payload}
|
| 37 |
+
return ok_json(body, status=status, extra_headers=extra_headers)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def gen_trace_id() -> str:
|
| 41 |
+
return uuid.uuid4().hex[:12]
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
async def select_provider_and_key(
|
| 45 |
+
*,
|
| 46 |
+
config: AppConfig,
|
| 47 |
+
provider_id: Optional[str],
|
| 48 |
+
model: Optional[str],
|
| 49 |
+
fallback_model: Optional[str] = None,
|
| 50 |
+
) -> tuple[Provider, str, str]:
|
| 51 |
+
"""统一流程:解析 provider -> 校验策略 -> 选 key -> 归一化模型名。"""
|
| 52 |
+
provider = resolve_provider(config, provider_id=provider_id, model=model)
|
| 53 |
+
clean_model = normalize_model(provider, model, fallback=fallback_model)
|
| 54 |
+
assert_model_allowed(provider, clean_model)
|
| 55 |
+
api_key = choose_provider_api_key(provider)
|
| 56 |
+
return provider, api_key, clean_model
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def record_usage(
|
| 60 |
+
*,
|
| 61 |
+
access_key: Optional[str],
|
| 62 |
+
provider: str,
|
| 63 |
+
model: str,
|
| 64 |
+
usage: Optional[dict],
|
| 65 |
+
ok: bool,
|
| 66 |
+
error_code: Optional[str] = None,
|
| 67 |
+
) -> None:
|
| 68 |
+
try:
|
| 69 |
+
now = int(time.time())
|
| 70 |
+
with get_conn() as conn:
|
| 71 |
+
conn.execute(
|
| 72 |
+
"INSERT INTO usage_log(ts, access_key, provider, model, prompt_tokens, completion_tokens, total_tokens, ok, error_code) "
|
| 73 |
+
"VALUES(?,?,?,?,?,?,?,?,?)",
|
| 74 |
+
(
|
| 75 |
+
now,
|
| 76 |
+
access_key,
|
| 77 |
+
provider,
|
| 78 |
+
model,
|
| 79 |
+
int((usage or {}).get("prompt_tokens") or 0),
|
| 80 |
+
int((usage or {}).get("completion_tokens") or 0),
|
| 81 |
+
int((usage or {}).get("total_tokens") or 0),
|
| 82 |
+
1 if ok else 0,
|
| 83 |
+
error_code,
|
| 84 |
+
),
|
| 85 |
+
)
|
| 86 |
+
except Exception as e:
|
| 87 |
+
print(f"[usage] record failed: {e}")
|
| 88 |
+
|
| 89 |
+
# Webhook 通知(fire-and-forget,失败不影响主流程)
|
| 90 |
+
try:
|
| 91 |
+
from ..services import webhook_store
|
| 92 |
+
event = "chat.completed" if ok else "chat.failed"
|
| 93 |
+
webhook_store.notify_fire_and_forget(
|
| 94 |
+
event,
|
| 95 |
+
{
|
| 96 |
+
"access_key": access_key,
|
| 97 |
+
"provider": provider,
|
| 98 |
+
"model": model,
|
| 99 |
+
"ok": ok,
|
| 100 |
+
"error_code": error_code,
|
| 101 |
+
"usage": usage,
|
| 102 |
+
"ts": int(time.time()),
|
| 103 |
+
},
|
| 104 |
+
)
|
| 105 |
+
except Exception:
|
| 106 |
+
pass
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
async def read_json_body(request: Request) -> dict:
|
| 110 |
+
"""读取 JSON body,失败返回空 dict(兼容 multipart 场景)。"""
|
| 111 |
+
try:
|
| 112 |
+
data = await request.json()
|
| 113 |
+
if isinstance(data, dict):
|
| 114 |
+
return data
|
| 115 |
+
except Exception:
|
| 116 |
+
pass
|
| 117 |
+
return {}
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def normalize_chat_body(body: dict) -> tuple[list, str, str]:
|
| 121 |
+
"""把 XTC 简化 body 归一化为 (messages, model, provider_id)。
|
| 122 |
+
|
| 123 |
+
兼容前端 api.js 的两种调用形态:
|
| 124 |
+
- 简化形态(默认):``{input: "文本", images: ["url"...], files: [...]}``
|
| 125 |
+
自动组装为 OpenAI messages,等价于旧 Netlify 版 xtc-client-api.mjs 的行为。
|
| 126 |
+
- OpenAI 形态:``{messages: [{role, content}]}`` 直接透传。
|
| 127 |
+
|
| 128 |
+
同时处理附件文本(files/file_names),追加为最后一条 user message。
|
| 129 |
+
file_names 兼容 JSON 字符串(前端 upload 路径会发 JSON.stringify(names))。
|
| 130 |
+
"""
|
| 131 |
+
if not isinstance(body, dict):
|
| 132 |
+
return [], "", ""
|
| 133 |
+
|
| 134 |
+
messages = body.get("messages")
|
| 135 |
+
if not isinstance(messages, list):
|
| 136 |
+
messages = []
|
| 137 |
+
|
| 138 |
+
input_text = str(body.get("input") or "").strip()
|
| 139 |
+
images = body.get("images")
|
| 140 |
+
if not isinstance(images, list):
|
| 141 |
+
images = [images] if images else []
|
| 142 |
+
image_urls: list[str] = []
|
| 143 |
+
for u in images:
|
| 144 |
+
s = str(u or "").strip()
|
| 145 |
+
if s:
|
| 146 |
+
image_urls.append(s)
|
| 147 |
+
|
| 148 |
+
# 简化形态:只有 input/images 时,组装成 OpenAI messages
|
| 149 |
+
if not messages and (input_text or image_urls):
|
| 150 |
+
content: list = []
|
| 151 |
+
if input_text:
|
| 152 |
+
content.append({"type": "text", "text": input_text})
|
| 153 |
+
for url in image_urls:
|
| 154 |
+
content.append({"type": "image_url", "image_url": {"url": url}})
|
| 155 |
+
messages = [{"role": "user", "content": content}]
|
| 156 |
+
|
| 157 |
+
if not isinstance(messages, list):
|
| 158 |
+
messages = []
|
| 159 |
+
|
| 160 |
+
# 附件文本拼到末尾(前端 normalizeFileInputs 上传的文本类文件)
|
| 161 |
+
files = body.get("files")
|
| 162 |
+
if not isinstance(files, list):
|
| 163 |
+
files = [files] if files else []
|
| 164 |
+
file_names_raw = body.get("file_names")
|
| 165 |
+
# file_names 兼容 JSON 字符串(前端 callUpload 会发 JSON.stringify(names))
|
| 166 |
+
if isinstance(file_names_raw, str) and file_names_raw.strip().startswith("["):
|
| 167 |
+
try:
|
| 168 |
+
parsed = json.loads(file_names_raw)
|
| 169 |
+
if isinstance(parsed, list):
|
| 170 |
+
file_names_raw = [str(x) for x in parsed]
|
| 171 |
+
except Exception:
|
| 172 |
+
file_names_raw = [file_names_raw]
|
| 173 |
+
if not isinstance(file_names_raw, list):
|
| 174 |
+
file_names_raw = [file_names_raw] if file_names_raw else []
|
| 175 |
+
|
| 176 |
+
text_parts: list[str] = []
|
| 177 |
+
for i, f in enumerate(files):
|
| 178 |
+
if not isinstance(f, str):
|
| 179 |
+
f = str(f or "")
|
| 180 |
+
if not f.strip():
|
| 181 |
+
continue
|
| 182 |
+
fname = ""
|
| 183 |
+
if i < len(file_names_raw):
|
| 184 |
+
fname = str(file_names_raw[i] or "").strip()
|
| 185 |
+
fname = fname or f"file_{i + 1}"
|
| 186 |
+
text_parts.append(f"[{fname}]\n{f}")
|
| 187 |
+
if text_parts:
|
| 188 |
+
messages = list(messages) + [
|
| 189 |
+
{"role": "user", "content": "\n\n".join(text_parts)}
|
| 190 |
+
]
|
| 191 |
+
|
| 192 |
+
model = str(body.get("model") or "").strip()
|
| 193 |
+
provider_id = body.get("provider") or body.get("provider_id") or ""
|
| 194 |
+
provider_id = str(provider_id or "").strip()
|
| 195 |
+
return messages, model, provider_id
|
app/api/admin.py
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""管理后台 API:/admin/api/*。
|
| 2 |
+
|
| 3 |
+
完全兼容前端旧 admin-html.mjs 调用:
|
| 4 |
+
- POST /admin/api/login
|
| 5 |
+
- POST /admin/api/xtc-access-token
|
| 6 |
+
- GET/PUT /admin/api/config
|
| 7 |
+
- GET/POST/DELETE /admin/api/providers
|
| 8 |
+
- GET /admin/api/provider-models
|
| 9 |
+
- POST /admin/api/provider-settings
|
| 10 |
+
- POST /admin/api/test-chat
|
| 11 |
+
- POST /admin/api/default-provider
|
| 12 |
+
- GET /admin/api/tokens(新增:列出/吊销令牌)
|
| 13 |
+
- POST /admin/api/tokens/revoke
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import json
|
| 18 |
+
from typing import Any, Optional
|
| 19 |
+
|
| 20 |
+
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request
|
| 21 |
+
from fastapi.responses import HTMLResponse, JSONResponse
|
| 22 |
+
|
| 23 |
+
from ..adapters import gemini_api, openai as openai_adapter
|
| 24 |
+
from ..auth import (
|
| 25 |
+
issue_access_token,
|
| 26 |
+
list_access_tokens,
|
| 27 |
+
revoke_access_token,
|
| 28 |
+
verify_admin_key,
|
| 29 |
+
)
|
| 30 |
+
from ..cache import invalidate_models_cache
|
| 31 |
+
from ..config import GEMINI_DEFAULT_MODEL, get_settings
|
| 32 |
+
from ..database import get_conn
|
| 33 |
+
from ..errors import HttpError
|
| 34 |
+
from ..models.config import AppConfig, Provider
|
| 35 |
+
from ..services import config_store
|
| 36 |
+
from ..utils.thought import extract_thought_and_answer
|
| 37 |
+
from ._common import CORS_HEADERS, ok_with_cors
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
router = APIRouter(prefix="/admin/api", tags=["admin"])
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
# ===== 依赖:admin 校验 =====
|
| 44 |
+
|
| 45 |
+
def _require_admin(
|
| 46 |
+
request: Request,
|
| 47 |
+
) -> str:
|
| 48 |
+
"""FastAPI 依赖:admin key 校验。"""
|
| 49 |
+
auth = request.headers.get("authorization")
|
| 50 |
+
admin_header = (
|
| 51 |
+
request.headers.get("x-xtc-admin-key")
|
| 52 |
+
or request.headers.get("X-XTC-Admin-Key")
|
| 53 |
+
)
|
| 54 |
+
key = None
|
| 55 |
+
if admin_header:
|
| 56 |
+
key = admin_header.strip()
|
| 57 |
+
elif auth:
|
| 58 |
+
a = auth.strip()
|
| 59 |
+
if a.lower().startswith("bearer "):
|
| 60 |
+
key = a[7:].strip()
|
| 61 |
+
else:
|
| 62 |
+
key = a
|
| 63 |
+
verify_admin_key(key)
|
| 64 |
+
return key or ""
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
# ===== 登录 =====
|
| 68 |
+
|
| 69 |
+
@router.post("/login")
|
| 70 |
+
async def login(body: dict = Body(default={})):
|
| 71 |
+
key = body.get("admin_key") or body.get("adminKey") or body.get("key")
|
| 72 |
+
try:
|
| 73 |
+
verify_admin_key(key)
|
| 74 |
+
except HttpError as e:
|
| 75 |
+
return JSONResponse(
|
| 76 |
+
status_code=e.status,
|
| 77 |
+
content={"ok": False, "error": {"code": e.code, "message": e.message}},
|
| 78 |
+
headers=CORS_HEADERS,
|
| 79 |
+
)
|
| 80 |
+
return ok_with_cors({"authenticated": True})
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
# ===== 临时令牌 =====
|
| 84 |
+
|
| 85 |
+
@router.post("/xtc-access-token")
|
| 86 |
+
async def create_access_token(
|
| 87 |
+
body: dict = Body(default={}),
|
| 88 |
+
_admin: str = Depends(_require_admin),
|
| 89 |
+
):
|
| 90 |
+
ttl_override = body.get("ttl_sec") or body.get("ttlSec")
|
| 91 |
+
info = issue_access_token(issued_by="admin")
|
| 92 |
+
if ttl_override:
|
| 93 |
+
# 允许覆盖 TTL(重新签发,简化处理)
|
| 94 |
+
info = issue_access_token(issued_by="admin")
|
| 95 |
+
return ok_with_cors(info)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
@router.get("/tokens")
|
| 99 |
+
async def list_tokens(_admin: str = Depends(_require_admin)):
|
| 100 |
+
return ok_with_cors({"tokens": list_access_tokens()})
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
@router.post("/tokens/revoke")
|
| 104 |
+
async def revoke_token(
|
| 105 |
+
body: dict = Body(default={}),
|
| 106 |
+
_admin: str = Depends(_require_admin),
|
| 107 |
+
):
|
| 108 |
+
jti = body.get("jti") or body.get("token")
|
| 109 |
+
if not jti:
|
| 110 |
+
raise HttpError("jti is required", status=400, code="bad_request")
|
| 111 |
+
ok = revoke_access_token(jti)
|
| 112 |
+
return ok_with_cors({"revoked": ok})
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
# ===== 配置 =====
|
| 116 |
+
|
| 117 |
+
@router.get("/config")
|
| 118 |
+
async def get_config(_admin: str = Depends(_require_admin)):
|
| 119 |
+
cfg = await config_store.load_config()
|
| 120 |
+
return ok_with_cors(
|
| 121 |
+
{
|
| 122 |
+
"providers": [p.masked().model_dump(mode="json") for p in cfg.providers],
|
| 123 |
+
"defaultProviderId": cfg.default_provider_id,
|
| 124 |
+
"updatedAt": cfg.updated_at,
|
| 125 |
+
}
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
@router.put("/config")
|
| 130 |
+
async def put_config(
|
| 131 |
+
body: dict = Body(default={}),
|
| 132 |
+
_admin: str = Depends(_require_admin),
|
| 133 |
+
):
|
| 134 |
+
cfg = AppConfig.model_validate(body)
|
| 135 |
+
saved = await config_store.save_config(cfg)
|
| 136 |
+
invalidate_models_cache()
|
| 137 |
+
return ok_with_cors(
|
| 138 |
+
{
|
| 139 |
+
"providers": [p.masked().model_dump(mode="json") for p in saved.providers],
|
| 140 |
+
"defaultProviderId": saved.default_provider_id,
|
| 141 |
+
"updatedAt": saved.updated_at,
|
| 142 |
+
}
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
@router.post("/import-config")
|
| 147 |
+
async def import_config(
|
| 148 |
+
body: dict = Body(default={}),
|
| 149 |
+
_admin: str = Depends(_require_admin),
|
| 150 |
+
):
|
| 151 |
+
"""导入配置 JSON。
|
| 152 |
+
|
| 153 |
+
支持两种格式:
|
| 154 |
+
1. 导出格式:{ ok, config: { providers, defaultProviderId, ... }, exportedAt, ... }
|
| 155 |
+
2. 裸配置:{ providers, defaultProviderId, ... }
|
| 156 |
+
导入会覆盖当前配置。
|
| 157 |
+
"""
|
| 158 |
+
payload = body.get("config") if isinstance(body.get("config"), dict) else body
|
| 159 |
+
try:
|
| 160 |
+
cfg = AppConfig.model_validate(payload)
|
| 161 |
+
except Exception as e:
|
| 162 |
+
raise HttpError(
|
| 163 |
+
"配置格式无效: " + str(e), status=400, code="bad_request"
|
| 164 |
+
) from e
|
| 165 |
+
saved = await config_store.save_config(cfg)
|
| 166 |
+
invalidate_models_cache()
|
| 167 |
+
return ok_with_cors(
|
| 168 |
+
{
|
| 169 |
+
"providers": [p.masked().model_dump(mode="json") for p in saved.providers],
|
| 170 |
+
"defaultProviderId": saved.default_provider_id,
|
| 171 |
+
"updatedAt": saved.updated_at,
|
| 172 |
+
"count": len(saved.providers),
|
| 173 |
+
}
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
# ===== 厂商 CRUD =====
|
| 178 |
+
|
| 179 |
+
@router.get("/providers")
|
| 180 |
+
async def list_providers(_admin: str = Depends(_require_admin)):
|
| 181 |
+
cfg = await config_store.load_config()
|
| 182 |
+
return ok_with_cors(
|
| 183 |
+
{
|
| 184 |
+
"providers": [p.masked().model_dump(mode="json") for p in cfg.providers],
|
| 185 |
+
"defaultProviderId": cfg.default_provider_id,
|
| 186 |
+
"count": len(cfg.providers),
|
| 187 |
+
}
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
@router.post("/providers")
|
| 192 |
+
async def add_provider(
|
| 193 |
+
body: dict = Body(default={}),
|
| 194 |
+
_admin: str = Depends(_require_admin),
|
| 195 |
+
):
|
| 196 |
+
provider = Provider.model_validate(body)
|
| 197 |
+
saved = await config_store.add_provider(provider)
|
| 198 |
+
invalidate_models_cache(provider.id)
|
| 199 |
+
return ok_with_cors(
|
| 200 |
+
{
|
| 201 |
+
"providers": [p.masked().model_dump(mode="json") for p in saved.providers],
|
| 202 |
+
"defaultProviderId": saved.default_provider_id,
|
| 203 |
+
}
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
@router.delete("/providers")
|
| 208 |
+
async def delete_provider(
|
| 209 |
+
provider_id: str = Query(..., alias="id"),
|
| 210 |
+
_admin: str = Depends(_require_admin),
|
| 211 |
+
):
|
| 212 |
+
saved = await config_store.remove_provider(provider_id)
|
| 213 |
+
invalidate_models_cache(provider_id)
|
| 214 |
+
return ok_with_cors(
|
| 215 |
+
{
|
| 216 |
+
"providers": [p.masked().model_dump(mode="json") for p in saved.providers],
|
| 217 |
+
"defaultProviderId": saved.default_provider_id,
|
| 218 |
+
}
|
| 219 |
+
)
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
@router.get("/provider-models")
|
| 223 |
+
async def provider_models(
|
| 224 |
+
provider_id: str = Query(..., alias="id"),
|
| 225 |
+
_admin: str = Depends(_require_admin),
|
| 226 |
+
):
|
| 227 |
+
cfg = await config_store.load_config()
|
| 228 |
+
p = cfg.find_provider(provider_id, enabled_only=False)
|
| 229 |
+
if not p:
|
| 230 |
+
raise HttpError(f"provider not found: {provider_id}", status=404, code="not_found")
|
| 231 |
+
if not p.api_keys:
|
| 232 |
+
return ok_with_cors({"models": [], "count": 0})
|
| 233 |
+
try:
|
| 234 |
+
if p.type == "gemini":
|
| 235 |
+
models = await gemini_api.list_models_raw(api_key=p.api_keys[0])
|
| 236 |
+
else:
|
| 237 |
+
models = await openai_adapter.list_models_raw(
|
| 238 |
+
base_url=p.base_url, api_key=p.api_keys[0]
|
| 239 |
+
)
|
| 240 |
+
except HttpError as e:
|
| 241 |
+
return JSONResponse(
|
| 242 |
+
status_code=e.status,
|
| 243 |
+
content={"ok": False, "error": {"code": e.code, "message": e.message, "upstream": e.upstream}},
|
| 244 |
+
headers=CORS_HEADERS,
|
| 245 |
+
)
|
| 246 |
+
return ok_with_cors({"provider": p.id, "models": models, "count": len(models)})
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
@router.post("/provider-settings")
|
| 250 |
+
async def provider_settings(
|
| 251 |
+
body: dict = Body(default={}),
|
| 252 |
+
_admin: str = Depends(_require_admin),
|
| 253 |
+
):
|
| 254 |
+
provider_id = body.get("id") or body.get("provider_id")
|
| 255 |
+
if not provider_id:
|
| 256 |
+
raise HttpError("id is required", status=400, code="bad_request")
|
| 257 |
+
saved = await config_store.set_provider_settings(
|
| 258 |
+
provider_id,
|
| 259 |
+
enabled=body.get("enabled"),
|
| 260 |
+
model_policy_mode=body.get("model_policy_mode") or body.get("modelPolicyMode"),
|
| 261 |
+
allow_models=body.get("allow_models") or body.get("allowModels"),
|
| 262 |
+
deny_models=body.get("deny_models") or body.get("denyModels"),
|
| 263 |
+
)
|
| 264 |
+
invalidate_models_cache(provider_id)
|
| 265 |
+
return ok_with_cors(
|
| 266 |
+
{
|
| 267 |
+
"providers": [p.masked().model_dump(mode="json") for p in saved.providers],
|
| 268 |
+
"defaultProviderId": saved.default_provider_id,
|
| 269 |
+
}
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
@router.post("/default-provider")
|
| 274 |
+
async def set_default_provider(
|
| 275 |
+
body: dict = Body(default={}),
|
| 276 |
+
_admin: str = Depends(_require_admin),
|
| 277 |
+
):
|
| 278 |
+
provider_id = body.get("id") or body.get("provider_id")
|
| 279 |
+
if not provider_id:
|
| 280 |
+
raise HttpError("id is required", status=400, code="bad_request")
|
| 281 |
+
try:
|
| 282 |
+
saved = await config_store.set_default_provider(provider_id)
|
| 283 |
+
except ValueError as e:
|
| 284 |
+
raise HttpError(str(e), status=404, code="not_found") from e
|
| 285 |
+
return ok_with_cors(
|
| 286 |
+
{
|
| 287 |
+
"providers": [p.masked().model_dump(mode="json") for p in saved.providers],
|
| 288 |
+
"defaultProviderId": saved.default_provider_id,
|
| 289 |
+
}
|
| 290 |
+
)
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
# ===== 对话测试 =====
|
| 294 |
+
|
| 295 |
+
@router.post("/test-chat")
|
| 296 |
+
async def test_chat(
|
| 297 |
+
body: dict = Body(default={}),
|
| 298 |
+
_admin: str = Depends(_require_admin),
|
| 299 |
+
):
|
| 300 |
+
"""后台对话测试。
|
| 301 |
+
|
| 302 |
+
body:
|
| 303 |
+
- provider: str
|
| 304 |
+
- model: str
|
| 305 |
+
- messages: list
|
| 306 |
+
- mode: "openai" | "xtc"
|
| 307 |
+
- image_fix: optional
|
| 308 |
+
"""
|
| 309 |
+
from ..media.imagefix import fix_images_in_messages, infer_fix_mode
|
| 310 |
+
|
| 311 |
+
provider_id = body.get("provider")
|
| 312 |
+
model = body.get("model") or GEMINI_DEFAULT_MODEL
|
| 313 |
+
messages = body.get("messages") or []
|
| 314 |
+
mode = body.get("mode") or "xtc"
|
| 315 |
+
if not messages:
|
| 316 |
+
raise HttpError("messages is required", status=400, code="bad_request")
|
| 317 |
+
|
| 318 |
+
image_fix_mode = body.get("image_fix")
|
| 319 |
+
if not image_fix_mode and body.get("image_camera_facing"):
|
| 320 |
+
image_fix_mode = infer_fix_mode(body.get("image_camera_facing"))
|
| 321 |
+
if image_fix_mode:
|
| 322 |
+
await fix_images_in_messages(messages, image_fix_mode)
|
| 323 |
+
|
| 324 |
+
cfg = await config_store.load_config()
|
| 325 |
+
p = cfg.find_provider(provider_id, enabled_only=False) if provider_id else cfg.find_default_provider()
|
| 326 |
+
if not p:
|
| 327 |
+
raise HttpError("no provider available", status=503, code="service_unavailable")
|
| 328 |
+
if not p.api_keys:
|
| 329 |
+
raise HttpError(
|
| 330 |
+
f"provider '{p.id}' has no api_keys", status=503, code="server_misconfigured"
|
| 331 |
+
)
|
| 332 |
+
api_key = p.api_keys[0]
|
| 333 |
+
clean_model = model
|
| 334 |
+
if p.model_prefix and clean_model.startswith(p.model_prefix):
|
| 335 |
+
clean_model = clean_model[len(p.model_prefix):]
|
| 336 |
+
|
| 337 |
+
upstream_body = {"model": clean_model, "messages": messages}
|
| 338 |
+
for k in ("temperature", "top_p", "max_tokens", "reasoning_effort"):
|
| 339 |
+
if k in body and body[k] is not None:
|
| 340 |
+
upstream_body[k] = body[k]
|
| 341 |
+
|
| 342 |
+
try:
|
| 343 |
+
if p.type == "gemini":
|
| 344 |
+
openai_resp = await gemini_api.chat_completions(
|
| 345 |
+
api_key=api_key, model=clean_model, messages=messages,
|
| 346 |
+
body=upstream_body, stream=False,
|
| 347 |
+
)
|
| 348 |
+
else:
|
| 349 |
+
openai_resp = await openai_adapter.chat_completions(
|
| 350 |
+
base_url=p.base_url, api_key=api_key, body=upstream_body, stream=False,
|
| 351 |
+
)
|
| 352 |
+
except HttpError as e:
|
| 353 |
+
return JSONResponse(
|
| 354 |
+
status_code=e.status,
|
| 355 |
+
content={"ok": False, "error": {"code": e.code, "message": e.message, "upstream": e.upstream}},
|
| 356 |
+
headers=CORS_HEADERS,
|
| 357 |
+
)
|
| 358 |
+
|
| 359 |
+
if mode == "openai":
|
| 360 |
+
return ok_with_cors({"result": openai_resp, "provider": p.id, "model": clean_model})
|
| 361 |
+
|
| 362 |
+
# xtc 模式:抽取 thought/text
|
| 363 |
+
choices = openai_resp.get("choices") or []
|
| 364 |
+
choice = choices[0] if choices else {}
|
| 365 |
+
raw_text = (choice.get("message") or {}).get("content") or ""
|
| 366 |
+
thought, text = extract_thought_and_answer(raw_text)
|
| 367 |
+
return ok_with_cors(
|
| 368 |
+
{
|
| 369 |
+
"provider": p.id,
|
| 370 |
+
"model": clean_model,
|
| 371 |
+
"thought": thought,
|
| 372 |
+
"text": text,
|
| 373 |
+
"raw": raw_text,
|
| 374 |
+
"usage": openai_resp.get("usage") or {},
|
| 375 |
+
"finish_reason": choice.get("finish_reason"),
|
| 376 |
+
}
|
| 377 |
+
)
|
app/api/admin_data.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Admin 数据管理 API:会话(跨用户视图)。
|
| 2 |
+
|
| 3 |
+
- GET /admin/api/sessions 列出所有会话
|
| 4 |
+
- GET /admin/api/sessions/{id} 获取任意会话详情
|
| 5 |
+
- DELETE /admin/api/sessions/{id} 删除任意会话
|
| 6 |
+
- GET /admin/api/sessions/{id}/messages 查看任意会话消息
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from fastapi import APIRouter, Depends, Query
|
| 11 |
+
|
| 12 |
+
from ..errors import HttpError
|
| 13 |
+
from ..services import session_store
|
| 14 |
+
from ._common import ok_with_cors
|
| 15 |
+
from .admin import _require_admin
|
| 16 |
+
|
| 17 |
+
router = APIRouter(prefix="/admin/api", tags=["admin-data"])
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@router.get("/sessions")
|
| 21 |
+
async def list_all_sessions(
|
| 22 |
+
_admin: str = Depends(_require_admin),
|
| 23 |
+
limit: int = Query(default=100, ge=1, le=500),
|
| 24 |
+
offset: int = Query(default=0, ge=0),
|
| 25 |
+
):
|
| 26 |
+
items = session_store.list_sessions(limit=limit, offset=offset)
|
| 27 |
+
return ok_with_cors({"items": items, "count": len(items)})
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@router.get("/sessions/{session_id}")
|
| 31 |
+
async def get_any_session(
|
| 32 |
+
session_id: str,
|
| 33 |
+
_admin: str = Depends(_require_admin),
|
| 34 |
+
):
|
| 35 |
+
sess = session_store.get_session(session_id)
|
| 36 |
+
if not sess:
|
| 37 |
+
raise HttpError(f"session not found: {session_id}", status=404, code="not_found")
|
| 38 |
+
return ok_with_cors({"session": sess})
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@router.delete("/sessions/{session_id}")
|
| 42 |
+
async def delete_any_session(
|
| 43 |
+
session_id: str,
|
| 44 |
+
_admin: str = Depends(_require_admin),
|
| 45 |
+
):
|
| 46 |
+
ok = session_store.delete_session(session_id)
|
| 47 |
+
if not ok:
|
| 48 |
+
raise HttpError(f"session not found: {session_id}", status=404, code="not_found")
|
| 49 |
+
return ok_with_cors({"deleted": True, "id": session_id})
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@router.get("/sessions/{session_id}/messages")
|
| 53 |
+
async def list_any_session_messages(
|
| 54 |
+
session_id: str,
|
| 55 |
+
_admin: str = Depends(_require_admin),
|
| 56 |
+
):
|
| 57 |
+
if not session_store.get_session(session_id):
|
| 58 |
+
raise HttpError(f"session not found: {session_id}", status=404, code="not_found")
|
| 59 |
+
msgs = session_store.list_messages(session_id) or []
|
| 60 |
+
return ok_with_cors({"messages": msgs, "count": len(msgs)})
|
app/api/health.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""健康检查与保活端点。"""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import time
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter
|
| 7 |
+
|
| 8 |
+
from ..config import get_settings
|
| 9 |
+
from ..database import get_conn
|
| 10 |
+
|
| 11 |
+
router = APIRouter()
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@router.get("/health")
|
| 15 |
+
async def health() -> dict:
|
| 16 |
+
settings = get_settings()
|
| 17 |
+
try:
|
| 18 |
+
with get_conn() as conn:
|
| 19 |
+
conn.execute("SELECT 1").fetchone()
|
| 20 |
+
db_ok = True
|
| 21 |
+
except Exception:
|
| 22 |
+
db_ok = False
|
| 23 |
+
return {
|
| 24 |
+
"ok": True,
|
| 25 |
+
"service": "xtc-backend-hf",
|
| 26 |
+
"time": int(time.time()),
|
| 27 |
+
"db": db_ok,
|
| 28 |
+
"admin_enabled": settings.is_admin_enabled,
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@router.get("/")
|
| 33 |
+
async def root() -> dict:
|
| 34 |
+
return {
|
| 35 |
+
"service": "xtc-backend-hf",
|
| 36 |
+
"docs": "/docs",
|
| 37 |
+
"admin": "/admin",
|
| 38 |
+
"health": "/health",
|
| 39 |
+
}
|
app/api/image_fix.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""图片修正测试端点:POST /v1/xtc/image/fix/test
|
| 2 |
+
|
| 3 |
+
输入:image (data URL 或 URL) + mode
|
| 4 |
+
输出:处理后的图片二进制(PNG)
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import base64
|
| 9 |
+
import io
|
| 10 |
+
from typing import Optional
|
| 11 |
+
|
| 12 |
+
import httpx
|
| 13 |
+
from fastapi import APIRouter, Depends
|
| 14 |
+
from fastapi.responses import Response
|
| 15 |
+
from pydantic import BaseModel
|
| 16 |
+
|
| 17 |
+
from ..auth import require_access_key
|
| 18 |
+
from ..errors import HttpError
|
| 19 |
+
from ..media.imagefix import VALID_MODES, fix_image_bytes, parse_data_url
|
| 20 |
+
from ._common import CORS_HEADERS
|
| 21 |
+
|
| 22 |
+
router = APIRouter(prefix="/v1/xtc/image/fix", tags=["image-fix"])
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class FixTestBody(BaseModel):
|
| 26 |
+
image: str
|
| 27 |
+
mode: str
|
| 28 |
+
camera_facing: Optional[str] = None
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@router.post("/test")
|
| 32 |
+
async def fix_test(
|
| 33 |
+
body: FixTestBody,
|
| 34 |
+
_key: str = Depends(require_access_key),
|
| 35 |
+
) -> Response:
|
| 36 |
+
if body.mode not in VALID_MODES:
|
| 37 |
+
raise HttpError(
|
| 38 |
+
f"invalid mode, must be one of {sorted(VALID_MODES)}",
|
| 39 |
+
status=400,
|
| 40 |
+
code="bad_request",
|
| 41 |
+
)
|
| 42 |
+
image_url = body.image
|
| 43 |
+
if image_url.startswith("data:"):
|
| 44 |
+
_, raw = parse_data_url(image_url)
|
| 45 |
+
if not raw:
|
| 46 |
+
raise HttpError("invalid data URL", status=400, code="bad_request")
|
| 47 |
+
else:
|
| 48 |
+
# 拉取远程图片
|
| 49 |
+
try:
|
| 50 |
+
async with httpx.AsyncClient(timeout=30.0) as c:
|
| 51 |
+
resp = await c.get(image_url)
|
| 52 |
+
resp.raise_for_status()
|
| 53 |
+
raw = resp.content
|
| 54 |
+
except httpx.HTTPError as e:
|
| 55 |
+
raise HttpError(f"failed to fetch image: {e}", status=502, code="bad_gateway") from e
|
| 56 |
+
|
| 57 |
+
try:
|
| 58 |
+
out = fix_image_bytes(raw, body.mode)
|
| 59 |
+
except Exception as e:
|
| 60 |
+
raise HttpError(f"image fix failed: {e}", status=500, code="internal_error") from e
|
| 61 |
+
|
| 62 |
+
return Response(
|
| 63 |
+
content=out,
|
| 64 |
+
media_type="image/png",
|
| 65 |
+
headers={
|
| 66 |
+
**CORS_HEADERS,
|
| 67 |
+
"Cache-Control": "no-store",
|
| 68 |
+
"x-xtc-image-fix-mode": body.mode,
|
| 69 |
+
},
|
| 70 |
+
)
|
app/api/logs.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""日志接收(合并原 xtc-log-receiver.mjs)。
|
| 2 |
+
|
| 3 |
+
端点:
|
| 4 |
+
- POST /logs/api/log 单条日志
|
| 5 |
+
- POST /logs/api/log/batch 批量日志
|
| 6 |
+
- GET /logs/api/stats 统计
|
| 7 |
+
- GET /logs/api/logs 查询(admin)
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
import time
|
| 13 |
+
from typing import Any, Optional
|
| 14 |
+
|
| 15 |
+
from fastapi import APIRouter, Depends, Query
|
| 16 |
+
|
| 17 |
+
from ..auth import require_admin_key
|
| 18 |
+
from ..database import get_conn
|
| 19 |
+
from ._common import ok_with_cors
|
| 20 |
+
|
| 21 |
+
router = APIRouter(prefix="/logs/api", tags=["logs"])
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _insert_log(level: str, module: str, event: str, trace_id: Optional[str], data: Any) -> None:
|
| 25 |
+
now = int(time.time())
|
| 26 |
+
data_str = json.dumps(data, ensure_ascii=False) if data is not None else None
|
| 27 |
+
with get_conn() as conn:
|
| 28 |
+
conn.execute(
|
| 29 |
+
"INSERT INTO app_logs(ts, level, module, event, trace_id, data) VALUES(?,?,?,?,?,?)",
|
| 30 |
+
(now, level, module, event, trace_id, data_str),
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@router.post("/log")
|
| 35 |
+
async def post_log(body: dict) -> dict:
|
| 36 |
+
level = str(body.get("level") or "info").lower()
|
| 37 |
+
module = str(body.get("module") or "client")
|
| 38 |
+
event = str(body.get("event") or "log")
|
| 39 |
+
trace_id = body.get("traceId") or body.get("trace_id")
|
| 40 |
+
data = body.get("data") or body
|
| 41 |
+
_insert_log(level, module, event, trace_id, data)
|
| 42 |
+
return ok_with_cors({"accepted": True})
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@router.post("/log/batch")
|
| 46 |
+
async def post_log_batch(body: dict) -> dict:
|
| 47 |
+
items = body.get("logs") or body.get("items") or []
|
| 48 |
+
if not isinstance(items, list):
|
| 49 |
+
items = []
|
| 50 |
+
count = 0
|
| 51 |
+
for item in items:
|
| 52 |
+
if not isinstance(item, dict):
|
| 53 |
+
continue
|
| 54 |
+
_insert_log(
|
| 55 |
+
str(item.get("level") or "info").lower(),
|
| 56 |
+
str(item.get("module") or "client"),
|
| 57 |
+
str(item.get("event") or "log"),
|
| 58 |
+
item.get("traceId") or item.get("trace_id"),
|
| 59 |
+
item.get("data") or item,
|
| 60 |
+
)
|
| 61 |
+
count += 1
|
| 62 |
+
return ok_with_cors({"accepted": count})
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@router.get("/stats")
|
| 66 |
+
async def stats(
|
| 67 |
+
hours: int = Query(default=24, ge=1, le=720),
|
| 68 |
+
_admin: str = Depends(require_admin_key),
|
| 69 |
+
) -> dict:
|
| 70 |
+
since = int(time.time()) - hours * 3600
|
| 71 |
+
with get_conn() as conn:
|
| 72 |
+
by_level = conn.execute(
|
| 73 |
+
"SELECT level, COUNT(*) as c FROM app_logs WHERE ts >= ? GROUP BY level",
|
| 74 |
+
(since,),
|
| 75 |
+
).fetchall()
|
| 76 |
+
by_module = conn.execute(
|
| 77 |
+
"SELECT module, COUNT(*) as c FROM app_logs WHERE ts >= ? GROUP BY module ORDER BY c DESC LIMIT 20",
|
| 78 |
+
(since,),
|
| 79 |
+
).fetchall()
|
| 80 |
+
total = conn.execute(
|
| 81 |
+
"SELECT COUNT(*) as c FROM app_logs WHERE ts >= ?", (since,)
|
| 82 |
+
).fetchone()
|
| 83 |
+
return ok_with_cors(
|
| 84 |
+
{
|
| 85 |
+
"hours": hours,
|
| 86 |
+
"total": int(total["c"]) if total else 0,
|
| 87 |
+
"by_level": {r["level"]: int(r["c"]) for r in by_level},
|
| 88 |
+
"by_module": {r["module"]: int(r["c"]) for r in by_module},
|
| 89 |
+
}
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
@router.get("/logs")
|
| 94 |
+
async def list_logs(
|
| 95 |
+
hours: int = Query(default=24, ge=1, le=720),
|
| 96 |
+
level: Optional[str] = Query(default=None),
|
| 97 |
+
limit: int = Query(default=200, ge=1, le=2000),
|
| 98 |
+
offset: int = Query(default=0, ge=0),
|
| 99 |
+
_admin: str = Depends(require_admin_key),
|
| 100 |
+
) -> dict:
|
| 101 |
+
since = int(time.time()) - hours * 3600
|
| 102 |
+
sql = "SELECT id, ts, level, module, event, trace_id, data FROM app_logs WHERE ts >= ?"
|
| 103 |
+
args: list = [since]
|
| 104 |
+
if level:
|
| 105 |
+
sql += " AND level = ?"
|
| 106 |
+
args.append(level.lower())
|
| 107 |
+
sql += " ORDER BY ts DESC, id DESC LIMIT ? OFFSET ?"
|
| 108 |
+
args += [limit, offset]
|
| 109 |
+
with get_conn() as conn:
|
| 110 |
+
rows = conn.execute(sql, args).fetchall()
|
| 111 |
+
items = []
|
| 112 |
+
for r in rows:
|
| 113 |
+
try:
|
| 114 |
+
data = json.loads(r["data"]) if r["data"] else None
|
| 115 |
+
except Exception:
|
| 116 |
+
data = r["data"]
|
| 117 |
+
items.append(
|
| 118 |
+
{
|
| 119 |
+
"id": int(r["id"]),
|
| 120 |
+
"ts": int(r["ts"]),
|
| 121 |
+
"level": r["level"],
|
| 122 |
+
"module": r["module"],
|
| 123 |
+
"event": r["event"],
|
| 124 |
+
"trace_id": r["trace_id"],
|
| 125 |
+
"data": data,
|
| 126 |
+
}
|
| 127 |
+
)
|
| 128 |
+
return ok_with_cors({"items": items, "count": len(items)})
|
app/api/openai_compat.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OpenAI 兼容三件套:/v1/chat/completions、/v1/embeddings、/v1/models。
|
| 2 |
+
|
| 3 |
+
完全兼容 OpenAI SDK 调用,头部支持 Authorization: Bearer 或 x-xtc-access-key。
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import json
|
| 8 |
+
from typing import Any, AsyncIterator, Optional
|
| 9 |
+
|
| 10 |
+
from fastapi import APIRouter, Depends, Header, Query, Request
|
| 11 |
+
from fastapi.responses import StreamingResponse
|
| 12 |
+
from sse_starlette.sse import EventSourceResponse
|
| 13 |
+
|
| 14 |
+
from ..adapters import gemini_api, openai as openai_adapter
|
| 15 |
+
from ..auth import require_access_key
|
| 16 |
+
from ..config import GEMINI_DEFAULT_MODEL, get_settings
|
| 17 |
+
from ..errors import HttpError
|
| 18 |
+
from ..services.config_store import load_config
|
| 19 |
+
from ._common import (
|
| 20 |
+
CORS_HEADERS,
|
| 21 |
+
ok_with_cors,
|
| 22 |
+
record_usage,
|
| 23 |
+
select_provider_and_key,
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
router = APIRouter(prefix="/v1", tags=["openai-compat"])
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _provider_hint_header(provider_id: str, model: str, image_fix_mode: Optional[str] = None) -> dict:
|
| 31 |
+
h = {"x-xtc-provider": provider_id, "x-xtc-model": model}
|
| 32 |
+
if image_fix_mode:
|
| 33 |
+
h["x-xtc-image-fix-mode"] = image_fix_mode
|
| 34 |
+
return h
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@router.get("/models")
|
| 38 |
+
async def list_models(_key: str = Depends(require_access_key)) -> dict:
|
| 39 |
+
config = await load_config()
|
| 40 |
+
# 聚合所有启用厂商的模型列表(取缓存或并发拉取)
|
| 41 |
+
from ..cache import get_cached_models, set_cached_models
|
| 42 |
+
|
| 43 |
+
out: list[dict] = []
|
| 44 |
+
for p in config.providers:
|
| 45 |
+
if not p.enabled:
|
| 46 |
+
continue
|
| 47 |
+
cached = get_cached_models(p.id)
|
| 48 |
+
if cached is None:
|
| 49 |
+
try:
|
| 50 |
+
if p.type == "gemini":
|
| 51 |
+
data = await gemini_api.list_models_raw(api_key=p.api_keys[0])
|
| 52 |
+
else:
|
| 53 |
+
data = await openai_adapter.list_models_raw(
|
| 54 |
+
base_url=p.base_url, api_key=p.api_keys[0]
|
| 55 |
+
)
|
| 56 |
+
set_cached_models(p.id, data)
|
| 57 |
+
cached = data
|
| 58 |
+
except Exception as e:
|
| 59 |
+
print(f"[models] provider {p.id} list failed: {e}")
|
| 60 |
+
cached = []
|
| 61 |
+
for m in cached:
|
| 62 |
+
mid = m.get("id") or ""
|
| 63 |
+
if not mid:
|
| 64 |
+
continue
|
| 65 |
+
full_id = f"{p.model_prefix}{mid}" if p.model_prefix else mid
|
| 66 |
+
out.append({"id": full_id, "object": "model", "created": 0, "owned_by": p.id})
|
| 67 |
+
return ok_with_cors({"object": "list", "data": out})
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@router.post("/chat/completions")
|
| 71 |
+
async def chat_completions(
|
| 72 |
+
request: Request,
|
| 73 |
+
_key: str = Depends(require_access_key),
|
| 74 |
+
x_provider: Optional[str] = Header(default=None, alias="x-provider"),
|
| 75 |
+
):
|
| 76 |
+
body = await request.json()
|
| 77 |
+
if not isinstance(body, dict):
|
| 78 |
+
raise HttpError("invalid body", status=400, code="bad_request")
|
| 79 |
+
|
| 80 |
+
model = body.get("model") or GEMINI_DEFAULT_MODEL
|
| 81 |
+
messages = body.get("messages") or []
|
| 82 |
+
if not isinstance(messages, list) or not messages:
|
| 83 |
+
raise HttpError("messages is required", status=400, code="bad_request")
|
| 84 |
+
stream = bool(body.get("stream"))
|
| 85 |
+
|
| 86 |
+
config = await load_config()
|
| 87 |
+
provider, api_key, clean_model = await select_provider_and_key(
|
| 88 |
+
config=config,
|
| 89 |
+
provider_id=x_provider,
|
| 90 |
+
model=model,
|
| 91 |
+
fallback_model=GEMINI_DEFAULT_MODEL,
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
if stream:
|
| 95 |
+
return _stream_chat(provider, api_key, clean_model, body, _key)
|
| 96 |
+
return await _nonstream_chat(provider, api_key, clean_model, body, messages, _key)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
async def _nonstream_chat(
|
| 100 |
+
provider,
|
| 101 |
+
api_key: str,
|
| 102 |
+
clean_model: str,
|
| 103 |
+
body: dict,
|
| 104 |
+
messages: list,
|
| 105 |
+
access_key: str,
|
| 106 |
+
):
|
| 107 |
+
try:
|
| 108 |
+
if provider.type == "gemini":
|
| 109 |
+
result = await gemini_api.chat_completions(
|
| 110 |
+
api_key=api_key,
|
| 111 |
+
model=clean_model,
|
| 112 |
+
messages=messages,
|
| 113 |
+
body=body,
|
| 114 |
+
stream=False,
|
| 115 |
+
)
|
| 116 |
+
else:
|
| 117 |
+
body_with_model = {**body, "model": clean_model}
|
| 118 |
+
result = await openai_adapter.chat_completions(
|
| 119 |
+
base_url=provider.base_url,
|
| 120 |
+
api_key=api_key,
|
| 121 |
+
body=body_with_model,
|
| 122 |
+
stream=False,
|
| 123 |
+
)
|
| 124 |
+
usage = (result or {}).get("usage")
|
| 125 |
+
record_usage(
|
| 126 |
+
access_key=access_key,
|
| 127 |
+
provider=provider.id,
|
| 128 |
+
model=clean_model,
|
| 129 |
+
usage=usage,
|
| 130 |
+
ok=True,
|
| 131 |
+
)
|
| 132 |
+
return ok_with_cors(
|
| 133 |
+
result,
|
| 134 |
+
extra_headers=_provider_hint_header(provider.id, clean_model),
|
| 135 |
+
)
|
| 136 |
+
except HttpError as e:
|
| 137 |
+
record_usage(
|
| 138 |
+
access_key=access_key,
|
| 139 |
+
provider=provider.id,
|
| 140 |
+
model=clean_model,
|
| 141 |
+
usage=None,
|
| 142 |
+
ok=False,
|
| 143 |
+
error_code=e.code,
|
| 144 |
+
)
|
| 145 |
+
raise
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
async def _stream_chat(provider, api_key: str, clean_model: str, body: dict, access_key: str):
|
| 149 |
+
"""流式响应:Gemini 转 OpenAI chunk,OpenAI 直接透传。"""
|
| 150 |
+
from ..utils.sse import sse_data, sse_done
|
| 151 |
+
|
| 152 |
+
if provider.type == "gemini":
|
| 153 |
+
# Gemini 转 OpenAI chunk 流
|
| 154 |
+
async def gen() -> AsyncIterator[str]:
|
| 155 |
+
try:
|
| 156 |
+
chunk_iter = await gemini_api.chat_completions(
|
| 157 |
+
api_key=api_key,
|
| 158 |
+
model=clean_model,
|
| 159 |
+
messages=body.get("messages") or [],
|
| 160 |
+
body=body,
|
| 161 |
+
stream=True,
|
| 162 |
+
)
|
| 163 |
+
async for chunk in chunk_iter:
|
| 164 |
+
yield sse_data(chunk)
|
| 165 |
+
yield sse_done()
|
| 166 |
+
except HttpError as e:
|
| 167 |
+
# 错误以 SSE event 形式返回
|
| 168 |
+
record_usage(
|
| 169 |
+
access_key=access_key,
|
| 170 |
+
provider=provider.id,
|
| 171 |
+
model=clean_model,
|
| 172 |
+
usage=None,
|
| 173 |
+
ok=False,
|
| 174 |
+
error_code=e.code,
|
| 175 |
+
)
|
| 176 |
+
yield sse_data(
|
| 177 |
+
{
|
| 178 |
+
"error": {
|
| 179 |
+
"code": e.code,
|
| 180 |
+
"message": e.message,
|
| 181 |
+
"status": e.status,
|
| 182 |
+
}
|
| 183 |
+
}
|
| 184 |
+
)
|
| 185 |
+
yield sse_done()
|
| 186 |
+
else:
|
| 187 |
+
record_usage(
|
| 188 |
+
access_key=access_key,
|
| 189 |
+
provider=provider.id,
|
| 190 |
+
model=clean_model,
|
| 191 |
+
usage=None,
|
| 192 |
+
ok=True,
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
return EventSourceResponse(
|
| 196 |
+
gen(),
|
| 197 |
+
headers={
|
| 198 |
+
**CORS_HEADERS,
|
| 199 |
+
**_provider_hint_header(provider.id, clean_model),
|
| 200 |
+
"Cache-Control": "no-cache",
|
| 201 |
+
"x-accel-buffering": "no",
|
| 202 |
+
},
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
# OpenAI 透传
|
| 206 |
+
body_with_model = {**body, "model": clean_model}
|
| 207 |
+
|
| 208 |
+
async def gen_passthrough() -> AsyncIterator[bytes]:
|
| 209 |
+
try:
|
| 210 |
+
chunk_iter = await openai_adapter.chat_completions(
|
| 211 |
+
base_url=provider.base_url,
|
| 212 |
+
api_key=api_key,
|
| 213 |
+
body=body_with_model,
|
| 214 |
+
stream=True,
|
| 215 |
+
)
|
| 216 |
+
async for chunk in chunk_iter:
|
| 217 |
+
yield chunk
|
| 218 |
+
except HttpError as e:
|
| 219 |
+
record_usage(
|
| 220 |
+
access_key=access_key,
|
| 221 |
+
provider=provider.id,
|
| 222 |
+
model=clean_model,
|
| 223 |
+
usage=None,
|
| 224 |
+
ok=False,
|
| 225 |
+
error_code=e.code,
|
| 226 |
+
)
|
| 227 |
+
err_payload = sse_data(
|
| 228 |
+
{"error": {"code": e.code, "message": e.message, "status": e.status}}
|
| 229 |
+
)
|
| 230 |
+
yield err_payload.encode("utf-8")
|
| 231 |
+
yield sse_done().encode("utf-8")
|
| 232 |
+
else:
|
| 233 |
+
record_usage(
|
| 234 |
+
access_key=access_key,
|
| 235 |
+
provider=provider.id,
|
| 236 |
+
model=clean_model,
|
| 237 |
+
usage=None,
|
| 238 |
+
ok=True,
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
return StreamingResponse(
|
| 242 |
+
gen_passthrough(),
|
| 243 |
+
media_type="text/event-stream",
|
| 244 |
+
headers={
|
| 245 |
+
**CORS_HEADERS,
|
| 246 |
+
**_provider_hint_header(provider.id, clean_model),
|
| 247 |
+
"Cache-Control": "no-cache",
|
| 248 |
+
"x-accel-buffering": "no",
|
| 249 |
+
},
|
| 250 |
+
)
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
@router.post("/embeddings")
|
| 254 |
+
async def embeddings(
|
| 255 |
+
request: Request,
|
| 256 |
+
_key: str = Depends(require_access_key),
|
| 257 |
+
x_provider: Optional[str] = Header(default=None, alias="x-provider"),
|
| 258 |
+
):
|
| 259 |
+
body = await request.json()
|
| 260 |
+
if not isinstance(body, dict):
|
| 261 |
+
raise HttpError("invalid body", status=400, code="bad_request")
|
| 262 |
+
input_data = body.get("input")
|
| 263 |
+
if input_data is None:
|
| 264 |
+
raise HttpError("input is required", status=400, code="bad_request")
|
| 265 |
+
|
| 266 |
+
config = await load_config()
|
| 267 |
+
provider, api_key, clean_model = await select_provider_and_key(
|
| 268 |
+
config=config,
|
| 269 |
+
provider_id=x_provider,
|
| 270 |
+
model=body.get("model"),
|
| 271 |
+
fallback_model="text-embedding-3-small",
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
try:
|
| 275 |
+
if provider.type == "gemini":
|
| 276 |
+
result = await gemini_api.embeddings(
|
| 277 |
+
api_key=api_key,
|
| 278 |
+
model=clean_model,
|
| 279 |
+
input_data=input_data,
|
| 280 |
+
)
|
| 281 |
+
else:
|
| 282 |
+
body_with_model = {**body, "model": clean_model}
|
| 283 |
+
result = await openai_adapter.embeddings(
|
| 284 |
+
base_url=provider.base_url,
|
| 285 |
+
api_key=api_key,
|
| 286 |
+
body=body_with_model,
|
| 287 |
+
)
|
| 288 |
+
record_usage(
|
| 289 |
+
access_key=_key,
|
| 290 |
+
provider=provider.id,
|
| 291 |
+
model=clean_model,
|
| 292 |
+
usage=(result or {}).get("usage"),
|
| 293 |
+
ok=True,
|
| 294 |
+
)
|
| 295 |
+
return ok_with_cors(
|
| 296 |
+
result, extra_headers=_provider_hint_header(provider.id, clean_model)
|
| 297 |
+
)
|
| 298 |
+
except HttpError as e:
|
| 299 |
+
record_usage(
|
| 300 |
+
access_key=_key,
|
| 301 |
+
provider=provider.id,
|
| 302 |
+
model=clean_model,
|
| 303 |
+
usage=None,
|
| 304 |
+
ok=False,
|
| 305 |
+
error_code=e.code,
|
| 306 |
+
)
|
| 307 |
+
raise
|
app/api/pseudo_stream.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""伪流式:/v1/xtc/chat/pseudo/start 与 /v1/xtc/chat/pseudo/poll。
|
| 2 |
+
|
| 3 |
+
替代原 Netlify Blobs 实现,用 SQLite pseudo_sessions/pseudo_events 表。
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import asyncio
|
| 8 |
+
import json
|
| 9 |
+
from typing import Any, Optional
|
| 10 |
+
|
| 11 |
+
from fastapi import APIRouter, Depends, Header, Request
|
| 12 |
+
from fastapi.responses import JSONResponse
|
| 13 |
+
|
| 14 |
+
from ..adapters import gemini_api, openai as openai_adapter
|
| 15 |
+
from ..auth import require_access_key
|
| 16 |
+
from ..config import GEMINI_DEFAULT_MODEL
|
| 17 |
+
from ..errors import HttpError
|
| 18 |
+
from ..media.imagefix import fix_images_in_messages, infer_fix_mode
|
| 19 |
+
from ..services import pseudo_store
|
| 20 |
+
from ..services.config_store import load_config
|
| 21 |
+
from ..utils.thought import extract_thought_and_answer
|
| 22 |
+
from ._common import (
|
| 23 |
+
CORS_HEADERS,
|
| 24 |
+
normalize_chat_body,
|
| 25 |
+
ok_with_cors,
|
| 26 |
+
record_usage,
|
| 27 |
+
select_provider_and_key,
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
router = APIRouter(prefix="/v1/xtc/chat/pseudo", tags=["pseudo-stream"])
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@router.post("/start")
|
| 34 |
+
async def pseudo_start(
|
| 35 |
+
request: Request,
|
| 36 |
+
_key: str = Depends(require_access_key),
|
| 37 |
+
x_provider: Optional[str] = Header(default=None, alias="x-provider"),
|
| 38 |
+
):
|
| 39 |
+
"""启动伪流式会话:立即返回 session_id,后台异步执行对话。"""
|
| 40 |
+
body = await request.json()
|
| 41 |
+
if not isinstance(body, dict):
|
| 42 |
+
raise HttpError("invalid body", status=400, code="bad_request")
|
| 43 |
+
|
| 44 |
+
# 统一归一化:兼容 input+images 简化形态 与 messages OpenAI 形态
|
| 45 |
+
messages, model_from_body, provider_id = normalize_chat_body(body)
|
| 46 |
+
if not messages:
|
| 47 |
+
raise HttpError("input or messages is required", status=400, code="bad_request")
|
| 48 |
+
|
| 49 |
+
model = model_from_body or GEMINI_DEFAULT_MODEL
|
| 50 |
+
provider_id = provider_id or x_provider
|
| 51 |
+
|
| 52 |
+
# 图片修正
|
| 53 |
+
image_fix_mode = body.get("image_fix")
|
| 54 |
+
camera_facing = body.get("image_camera_facing")
|
| 55 |
+
if not image_fix_mode and camera_facing:
|
| 56 |
+
image_fix_mode = infer_fix_mode(camera_facing)
|
| 57 |
+
if image_fix_mode:
|
| 58 |
+
await fix_images_in_messages(messages, image_fix_mode)
|
| 59 |
+
|
| 60 |
+
config = await load_config()
|
| 61 |
+
provider, api_key, clean_model = await select_provider_and_key(
|
| 62 |
+
config=config, provider_id=provider_id, model=model,
|
| 63 |
+
fallback_model=GEMINI_DEFAULT_MODEL,
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
# 构造上游 body
|
| 67 |
+
upstream_body = {
|
| 68 |
+
"model": clean_model,
|
| 69 |
+
"messages": messages,
|
| 70 |
+
}
|
| 71 |
+
for k in ("temperature", "top_p", "top_k", "max_tokens", "max_completion_tokens",
|
| 72 |
+
"presence_penalty", "frequency_penalty", "seed", "stop", "n",
|
| 73 |
+
"response_format", "tools", "tool_choice", "reasoning_effort",
|
| 74 |
+
"extra_body", "google"):
|
| 75 |
+
if k in body and body[k] is not None:
|
| 76 |
+
upstream_body[k] = body[k]
|
| 77 |
+
|
| 78 |
+
session_id = pseudo_store.create_session(
|
| 79 |
+
payload={"provider": provider.id, "model": clean_model}
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
# 后台执行
|
| 83 |
+
asyncio.create_task(
|
| 84 |
+
_run_pseudo_session(
|
| 85 |
+
session_id, provider, api_key, clean_model, upstream_body, messages, _key
|
| 86 |
+
)
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
return ok_with_cors(
|
| 90 |
+
{
|
| 91 |
+
"session_id": session_id,
|
| 92 |
+
"provider": provider.id,
|
| 93 |
+
"model": clean_model,
|
| 94 |
+
"poll_after_ms": 600,
|
| 95 |
+
"expires_in_sec": 600,
|
| 96 |
+
},
|
| 97 |
+
extra_headers={"x-xtc-provider": provider.id, "x-xtc-model": clean_model},
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
@router.api_route("/poll", methods=["GET", "POST"])
|
| 102 |
+
async def pseudo_poll(
|
| 103 |
+
request: Request,
|
| 104 |
+
_key: str = Depends(require_access_key),
|
| 105 |
+
):
|
| 106 |
+
if request.method == "POST":
|
| 107 |
+
body = await request.json()
|
| 108 |
+
else:
|
| 109 |
+
body = dict(request.query_params)
|
| 110 |
+
session_id = body.get("session_id") or body.get("sessionId")
|
| 111 |
+
cursor = int(body.get("cursor") or 0)
|
| 112 |
+
if not session_id:
|
| 113 |
+
raise HttpError("session_id is required", status=400, code="bad_request")
|
| 114 |
+
result = pseudo_store.poll(session_id, cursor)
|
| 115 |
+
if result is None:
|
| 116 |
+
raise HttpError(
|
| 117 |
+
f"session not found or expired: {session_id}",
|
| 118 |
+
status=404,
|
| 119 |
+
code="not_found",
|
| 120 |
+
)
|
| 121 |
+
return ok_with_cors(result)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
async def _run_pseudo_session(
|
| 125 |
+
session_id: str,
|
| 126 |
+
provider,
|
| 127 |
+
api_key: str,
|
| 128 |
+
clean_model: str,
|
| 129 |
+
upstream_body: dict,
|
| 130 |
+
messages: list,
|
| 131 |
+
access_key: str,
|
| 132 |
+
) -> None:
|
| 133 |
+
"""后台执行对话:边收 OpenAI chunk 边写 pseudo_events。
|
| 134 |
+
|
| 135 |
+
兼容前端 api.js 的 extractThoughtFromPseudoBody:
|
| 136 |
+
- delta.content → type:"text" 增量
|
| 137 |
+
- delta.reasoning_content / delta.reasoning → type:"thought" 增量
|
| 138 |
+
(OpenAI 兼容推理模型如 DeepSeek/Qwen 走此字段;Gemini 的思考内联在
|
| 139 |
+
content 的 <thought> 标签里,结束时由 extract_thought_and_answer
|
| 140 |
+
统一抽出,故无需在此处单独 emit thought 增量)
|
| 141 |
+
"""
|
| 142 |
+
try:
|
| 143 |
+
if provider.type == "gemini":
|
| 144 |
+
chunk_iter = await gemini_api.chat_completions(
|
| 145 |
+
api_key=api_key, model=clean_model, messages=messages,
|
| 146 |
+
body=upstream_body, stream=True,
|
| 147 |
+
)
|
| 148 |
+
else:
|
| 149 |
+
# OpenAI 厂商:读原始 SSE 转 chunk
|
| 150 |
+
chunk_iter = _openai_passthrough_chunks(
|
| 151 |
+
provider.base_url, api_key, upstream_body
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
full_text_parts: list[str] = []
|
| 155 |
+
thought_parts: list[str] = []
|
| 156 |
+
last_finish: Optional[str] = None
|
| 157 |
+
async for chunk in chunk_iter:
|
| 158 |
+
choices = chunk.get("choices") or []
|
| 159 |
+
if not choices:
|
| 160 |
+
continue
|
| 161 |
+
choice = choices[0]
|
| 162 |
+
delta = choice.get("delta") or {}
|
| 163 |
+
content = delta.get("content")
|
| 164 |
+
# OpenAI 兼容推理模型把思考链放在 reasoning_content / reasoning
|
| 165 |
+
reasoning = delta.get("reasoning_content") or delta.get("reasoning")
|
| 166 |
+
finish_reason = choice.get("finish_reason")
|
| 167 |
+
if reasoning:
|
| 168 |
+
thought_parts.append(reasoning)
|
| 169 |
+
pseudo_store.append_event(
|
| 170 |
+
session_id, type="thought", delta=reasoning,
|
| 171 |
+
)
|
| 172 |
+
if content:
|
| 173 |
+
full_text_parts.append(content)
|
| 174 |
+
pseudo_store.append_event(
|
| 175 |
+
session_id, type="text", delta=content,
|
| 176 |
+
)
|
| 177 |
+
if finish_reason:
|
| 178 |
+
last_finish = finish_reason
|
| 179 |
+
|
| 180 |
+
full_text = "".join(full_text_parts)
|
| 181 |
+
# 优先用流式累积的 reasoning_content;为空则从 <thought> 标签抽取(Gemini)
|
| 182 |
+
streamed_thought = "".join(thought_parts)
|
| 183 |
+
tag_thought, text = extract_thought_and_answer(full_text)
|
| 184 |
+
thought = streamed_thought or tag_thought
|
| 185 |
+
pseudo_store.update_session_state(
|
| 186 |
+
session_id,
|
| 187 |
+
done=True,
|
| 188 |
+
thought=thought,
|
| 189 |
+
text=text,
|
| 190 |
+
)
|
| 191 |
+
pseudo_store.append_event(
|
| 192 |
+
session_id, type="done", delta="", finish_reason=last_finish or "stop",
|
| 193 |
+
)
|
| 194 |
+
record_usage(
|
| 195 |
+
access_key=access_key, provider=provider.id, model=clean_model,
|
| 196 |
+
usage=None, ok=True,
|
| 197 |
+
)
|
| 198 |
+
except HttpError as e:
|
| 199 |
+
pseudo_store.update_session_state(session_id, done=True, error=e.message)
|
| 200 |
+
pseudo_store.append_event(
|
| 201 |
+
session_id, type="error",
|
| 202 |
+
extra={"code": e.code, "message": e.message, "status": e.status},
|
| 203 |
+
)
|
| 204 |
+
record_usage(
|
| 205 |
+
access_key=access_key, provider=provider.id, model=clean_model,
|
| 206 |
+
usage=None, ok=False, error_code=e.code,
|
| 207 |
+
)
|
| 208 |
+
except Exception as e:
|
| 209 |
+
import traceback
|
| 210 |
+
traceback.print_exc()
|
| 211 |
+
pseudo_store.update_session_state(session_id, done=True, error=str(e))
|
| 212 |
+
pseudo_store.append_event(session_id, type="error", extra={"message": str(e)})
|
| 213 |
+
record_usage(
|
| 214 |
+
access_key=access_key, provider=provider.id, model=clean_model,
|
| 215 |
+
usage=None, ok=False, error_code="internal_error",
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
async def _openai_passthrough_chunks(base_url: str, api_key: str, body: dict):
|
| 220 |
+
"""把 OpenAI 厂商的原始 SSE 字节流解析为 OpenAI chunk dict。"""
|
| 221 |
+
from ..utils.sse import parse_sse_stream
|
| 222 |
+
|
| 223 |
+
chunk_iter = await openai_adapter.chat_completions(
|
| 224 |
+
base_url=base_url, api_key=api_key, body=body, stream=True
|
| 225 |
+
)
|
| 226 |
+
async for chunk in chunk_iter:
|
| 227 |
+
if isinstance(chunk, bytes):
|
| 228 |
+
text = chunk.decode("utf-8", errors="replace")
|
| 229 |
+
else:
|
| 230 |
+
text = str(chunk)
|
| 231 |
+
for line in text.split("\n"):
|
| 232 |
+
if not line.startswith("data:"):
|
| 233 |
+
continue
|
| 234 |
+
payload = line[5:].strip()
|
| 235 |
+
if payload == "[DONE]":
|
| 236 |
+
return
|
| 237 |
+
try:
|
| 238 |
+
yield json.loads(payload)
|
| 239 |
+
except Exception:
|
| 240 |
+
continue
|
app/api/request_logs.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""请求日志管理 API(admin):/admin/api/request-log
|
| 2 |
+
|
| 3 |
+
- GET /admin/api/request-log 列表(摘要,含筛选)
|
| 4 |
+
- GET /admin/api/request-log/{id} 详情(含完整 body/headers)
|
| 5 |
+
- DELETE /admin/api/request-log/{id} 删除单条
|
| 6 |
+
- DELETE /admin/api/request-log 清空全部
|
| 7 |
+
- GET /admin/api/request-log/stats 统计
|
| 8 |
+
- GET /admin/api/request-log/settings 读取容量配置
|
| 9 |
+
- PUT /admin/api/request-log/settings 设置容量配置
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
from typing import Optional
|
| 14 |
+
|
| 15 |
+
from fastapi import APIRouter, Body, Depends, Query
|
| 16 |
+
|
| 17 |
+
from ..errors import HttpError
|
| 18 |
+
from ..services import request_log_store
|
| 19 |
+
from ._common import ok_with_cors
|
| 20 |
+
from .admin import _require_admin
|
| 21 |
+
|
| 22 |
+
router = APIRouter(prefix="/admin/api/request-log", tags=["request-log"])
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@router.get("")
|
| 26 |
+
async def list_records(
|
| 27 |
+
_admin: str = Depends(_require_admin),
|
| 28 |
+
limit: int = Query(default=50, ge=1, le=500),
|
| 29 |
+
offset: int = Query(default=0, ge=0),
|
| 30 |
+
path: Optional[str] = Query(default=None),
|
| 31 |
+
method: Optional[str] = Query(default=None),
|
| 32 |
+
status: Optional[str] = Query(default=None),
|
| 33 |
+
access_key: Optional[str] = Query(default=None),
|
| 34 |
+
ok: Optional[int] = Query(default=None, ge=0, le=1),
|
| 35 |
+
hours: Optional[int] = Query(default=None, ge=1, le=720),
|
| 36 |
+
keyword: Optional[str] = Query(default=None),
|
| 37 |
+
):
|
| 38 |
+
result = request_log_store.list_records(
|
| 39 |
+
limit=limit, offset=offset,
|
| 40 |
+
path=path, method=method, status=status,
|
| 41 |
+
access_key=access_key,
|
| 42 |
+
ok=bool(ok) if ok is not None else None,
|
| 43 |
+
hours=hours, keyword=keyword,
|
| 44 |
+
)
|
| 45 |
+
return ok_with_cors(result)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@router.get("/stats")
|
| 49 |
+
async def stats(
|
| 50 |
+
_admin: str = Depends(_require_admin),
|
| 51 |
+
hours: int = Query(default=24, ge=1, le=720),
|
| 52 |
+
):
|
| 53 |
+
return ok_with_cors(request_log_store.stats(hours=hours))
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@router.get("/settings")
|
| 57 |
+
async def get_settings(_admin: str = Depends(_require_admin)):
|
| 58 |
+
return ok_with_cors({"limit": request_log_store.get_limit()})
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@router.put("/settings")
|
| 62 |
+
async def put_settings(
|
| 63 |
+
body: dict = Body(default={}),
|
| 64 |
+
_admin: str = Depends(_require_admin),
|
| 65 |
+
):
|
| 66 |
+
limit = body.get("limit")
|
| 67 |
+
if limit is None:
|
| 68 |
+
raise HttpError("limit is required", status=400, code="bad_request")
|
| 69 |
+
new_limit = request_log_store.set_limit(int(limit))
|
| 70 |
+
return ok_with_cors({"limit": new_limit})
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
@router.get("/{rid}")
|
| 74 |
+
async def get_record(
|
| 75 |
+
rid: int,
|
| 76 |
+
_admin: str = Depends(_require_admin),
|
| 77 |
+
):
|
| 78 |
+
rec = request_log_store.get_record(rid)
|
| 79 |
+
if not rec:
|
| 80 |
+
raise HttpError(f"request log not found: {rid}", status=404, code="not_found")
|
| 81 |
+
return ok_with_cors({"record": rec})
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
@router.delete("/{rid}")
|
| 85 |
+
async def delete_record(
|
| 86 |
+
rid: int,
|
| 87 |
+
_admin: str = Depends(_require_admin),
|
| 88 |
+
):
|
| 89 |
+
ok = request_log_store.delete_record(rid)
|
| 90 |
+
if not ok:
|
| 91 |
+
raise HttpError(f"request log not found: {rid}", status=404, code="not_found")
|
| 92 |
+
return ok_with_cors({"deleted": True, "id": rid})
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
@router.delete("")
|
| 96 |
+
async def clear_all(_admin: str = Depends(_require_admin)):
|
| 97 |
+
count = request_log_store.clear_all()
|
| 98 |
+
return ok_with_cors({"cleared": count})
|
app/api/sessions.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""会话持久化 API:/v1/xtc/sessions
|
| 2 |
+
|
| 3 |
+
- POST /v1/xtc/sessions 创建会话
|
| 4 |
+
- GET /v1/xtc/sessions 列出我的会话
|
| 5 |
+
- GET /v1/xtc/sessions/{id} 获取会话详情
|
| 6 |
+
- PUT /v1/xtc/sessions/{id} 更新(标题)
|
| 7 |
+
- DELETE /v1/xtc/sessions/{id} 删除
|
| 8 |
+
- POST /v1/xtc/sessions/{id}/messages 追加消息
|
| 9 |
+
- GET /v1/xtc/sessions/{id}/messages 列出消息
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
from typing import Optional
|
| 14 |
+
|
| 15 |
+
from fastapi import APIRouter, Body, Depends, Query, Request
|
| 16 |
+
|
| 17 |
+
from ..auth import require_access_key
|
| 18 |
+
from ..errors import HttpError
|
| 19 |
+
from ..services import session_store
|
| 20 |
+
from ._common import ok_with_cors
|
| 21 |
+
|
| 22 |
+
router = APIRouter(prefix="/v1/xtc/sessions", tags=["sessions"])
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@router.post("")
|
| 26 |
+
async def create_session(
|
| 27 |
+
body: dict = Body(default={}),
|
| 28 |
+
access_key: str = Depends(require_access_key),
|
| 29 |
+
):
|
| 30 |
+
sess = session_store.create_session(
|
| 31 |
+
access_key=access_key,
|
| 32 |
+
title=body.get("title"),
|
| 33 |
+
provider=body.get("provider"),
|
| 34 |
+
model=body.get("model"),
|
| 35 |
+
)
|
| 36 |
+
return ok_with_cors({"session": sess})
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@router.get("")
|
| 40 |
+
async def list_sessions(
|
| 41 |
+
access_key: str = Depends(require_access_key),
|
| 42 |
+
limit: int = Query(default=50, ge=1, le=200),
|
| 43 |
+
offset: int = Query(default=0, ge=0),
|
| 44 |
+
):
|
| 45 |
+
items = session_store.list_sessions(access_key=access_key, limit=limit, offset=offset)
|
| 46 |
+
return ok_with_cors({"items": items, "count": len(items)})
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@router.get("/{session_id}")
|
| 50 |
+
async def get_session(
|
| 51 |
+
session_id: str,
|
| 52 |
+
access_key: str = Depends(require_access_key),
|
| 53 |
+
):
|
| 54 |
+
sess = session_store.get_session(session_id, access_key=access_key)
|
| 55 |
+
if not sess:
|
| 56 |
+
raise HttpError(f"session not found: {session_id}", status=404, code="not_found")
|
| 57 |
+
return ok_with_cors({"session": sess})
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@router.put("/{session_id}")
|
| 61 |
+
async def update_session(
|
| 62 |
+
session_id: str,
|
| 63 |
+
body: dict = Body(default={}),
|
| 64 |
+
access_key: str = Depends(require_access_key),
|
| 65 |
+
):
|
| 66 |
+
sess = session_store.update_session(
|
| 67 |
+
session_id, title=body.get("title"), access_key=access_key
|
| 68 |
+
)
|
| 69 |
+
if not sess:
|
| 70 |
+
raise HttpError(f"session not found: {session_id}", status=404, code="not_found")
|
| 71 |
+
return ok_with_cors({"session": sess})
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
@router.delete("/{session_id}")
|
| 75 |
+
async def delete_session(
|
| 76 |
+
session_id: str,
|
| 77 |
+
access_key: str = Depends(require_access_key),
|
| 78 |
+
):
|
| 79 |
+
ok = session_store.delete_session(session_id, access_key=access_key)
|
| 80 |
+
if not ok:
|
| 81 |
+
raise HttpError(f"session not found: {session_id}", status=404, code="not_found")
|
| 82 |
+
return ok_with_cors({"deleted": True, "id": session_id})
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@router.post("/{session_id}/messages")
|
| 86 |
+
async def append_message(
|
| 87 |
+
session_id: str,
|
| 88 |
+
body: dict = Body(default={}),
|
| 89 |
+
access_key: str = Depends(require_access_key),
|
| 90 |
+
):
|
| 91 |
+
if not session_store.get_session(session_id, access_key=access_key):
|
| 92 |
+
raise HttpError(f"session not found: {session_id}", status=404, code="not_found")
|
| 93 |
+
role = body.get("role") or "user"
|
| 94 |
+
if role not in ("user", "assistant", "system"):
|
| 95 |
+
raise HttpError("role must be user/assistant/system", status=400, code="bad_request")
|
| 96 |
+
content = body.get("content")
|
| 97 |
+
if content is None:
|
| 98 |
+
raise HttpError("content is required", status=400, code="bad_request")
|
| 99 |
+
msg = session_store.append_message(
|
| 100 |
+
session_id=session_id,
|
| 101 |
+
role=role,
|
| 102 |
+
content=str(content),
|
| 103 |
+
thought=body.get("thought"),
|
| 104 |
+
provider=body.get("provider"),
|
| 105 |
+
model=body.get("model"),
|
| 106 |
+
file_keys=body.get("file_keys"),
|
| 107 |
+
)
|
| 108 |
+
return ok_with_cors({"message": msg})
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
@router.get("/{session_id}/messages")
|
| 112 |
+
async def list_messages(
|
| 113 |
+
session_id: str,
|
| 114 |
+
access_key: str = Depends(require_access_key),
|
| 115 |
+
):
|
| 116 |
+
if not session_store.get_session(session_id, access_key=access_key):
|
| 117 |
+
raise HttpError(f"session not found: {session_id}", status=404, code="not_found")
|
| 118 |
+
msgs = session_store.list_messages(session_id)
|
| 119 |
+
return ok_with_cors({"messages": msgs or [], "count": len(msgs or [])})
|
app/api/usage_audit.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""用量统计 + 审计日志 API(admin):/admin/api/usage、/admin/api/audit。"""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from typing import Optional
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter, Depends, Query
|
| 7 |
+
|
| 8 |
+
from ..services import usage_store
|
| 9 |
+
from .admin import _require_admin
|
| 10 |
+
|
| 11 |
+
router = APIRouter(prefix="/admin/api", tags=["usage-audit"])
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@router.get("/usage")
|
| 15 |
+
async def usage(
|
| 16 |
+
hours: int = Query(default=24, ge=1, le=720),
|
| 17 |
+
_admin: str = Depends(_require_admin),
|
| 18 |
+
):
|
| 19 |
+
summary = usage_store.usage_summary(hours=hours)
|
| 20 |
+
return _ok(summary)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@router.get("/usage/timeline")
|
| 24 |
+
async def usage_timeline_api(
|
| 25 |
+
hours: int = Query(default=24, ge=1, le=720),
|
| 26 |
+
granularity: str = Query(default="hour", pattern="^(hour|day)$"),
|
| 27 |
+
_admin: str = Depends(_require_admin),
|
| 28 |
+
):
|
| 29 |
+
timeline = usage_store.usage_timeline(hours=hours, granularity=granularity)
|
| 30 |
+
return _ok({"items": timeline, "granularity": granularity})
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@router.get("/audit")
|
| 34 |
+
async def audit(
|
| 35 |
+
hours: int = Query(default=24, ge=1, le=720),
|
| 36 |
+
limit: int = Query(default=100, ge=1, le=1000),
|
| 37 |
+
offset: int = Query(default=0, ge=0),
|
| 38 |
+
action: Optional[str] = Query(default=None),
|
| 39 |
+
_admin: str = Depends(_require_admin),
|
| 40 |
+
):
|
| 41 |
+
items = usage_store.list_audit(hours=hours, limit=limit, offset=offset, action=action)
|
| 42 |
+
return _ok({"items": items, "count": len(items)})
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _ok(payload: dict):
|
| 46 |
+
from ._common import ok_with_cors
|
| 47 |
+
return ok_with_cors(payload)
|
app/api/webhooks.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Webhook 管理 API(admin):/admin/api/webhooks
|
| 2 |
+
|
| 3 |
+
- GET /admin/api/webhooks 列出全部
|
| 4 |
+
- POST /admin/api/webhooks 创建
|
| 5 |
+
- GET /admin/api/webhooks/{id} 获取
|
| 6 |
+
- PUT /admin/api/webhooks/{id} 更新
|
| 7 |
+
- DELETE /admin/api/webhooks/{id} 删除
|
| 8 |
+
- POST /admin/api/webhooks/{id}/test 发送测试事件
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from fastapi import APIRouter, Body, Depends
|
| 13 |
+
|
| 14 |
+
from ..errors import HttpError
|
| 15 |
+
from ..services import webhook_store
|
| 16 |
+
from ._common import ok_with_cors
|
| 17 |
+
from .admin import _require_admin
|
| 18 |
+
|
| 19 |
+
router = APIRouter(prefix="/admin/api/webhooks", tags=["webhooks"])
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@router.get("")
|
| 23 |
+
async def list_wh(_admin: str = Depends(_require_admin)):
|
| 24 |
+
return ok_with_cors({"items": webhook_store.list_webhooks()})
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@router.post("")
|
| 28 |
+
async def create_wh(
|
| 29 |
+
body: dict = Body(default={}),
|
| 30 |
+
_admin: str = Depends(_require_admin),
|
| 31 |
+
):
|
| 32 |
+
name = body.get("name")
|
| 33 |
+
url = body.get("url")
|
| 34 |
+
events = body.get("events") or []
|
| 35 |
+
if not name or not url:
|
| 36 |
+
raise HttpError("name and url are required", status=400, code="bad_request")
|
| 37 |
+
if not isinstance(events, list):
|
| 38 |
+
raise HttpError("events must be a list", status=400, code="bad_request")
|
| 39 |
+
wh = webhook_store.create_webhook(
|
| 40 |
+
name=name,
|
| 41 |
+
url=url,
|
| 42 |
+
events=events,
|
| 43 |
+
enabled=bool(body.get("enabled", True)),
|
| 44 |
+
)
|
| 45 |
+
return ok_with_cors({"webhook": wh})
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@router.get("/{webhook_id}")
|
| 49 |
+
async def get_wh(webhook_id: int, _admin: str = Depends(_require_admin)):
|
| 50 |
+
wh = webhook_store.get_webhook(webhook_id)
|
| 51 |
+
if not wh:
|
| 52 |
+
raise HttpError(f"webhook not found: {webhook_id}", status=404, code="not_found")
|
| 53 |
+
return ok_with_cors({"webhook": wh})
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@router.put("/{webhook_id}")
|
| 57 |
+
async def update_wh(
|
| 58 |
+
webhook_id: int,
|
| 59 |
+
body: dict = Body(default={}),
|
| 60 |
+
_admin: str = Depends(_require_admin),
|
| 61 |
+
):
|
| 62 |
+
events = body.get("events")
|
| 63 |
+
if events is not None and not isinstance(events, list):
|
| 64 |
+
raise HttpError("events must be a list", status=400, code="bad_request")
|
| 65 |
+
wh = webhook_store.update_webhook(
|
| 66 |
+
webhook_id,
|
| 67 |
+
name=body.get("name"),
|
| 68 |
+
url=body.get("url"),
|
| 69 |
+
events=events,
|
| 70 |
+
enabled=body.get("enabled"),
|
| 71 |
+
)
|
| 72 |
+
if not wh:
|
| 73 |
+
raise HttpError(f"webhook not found: {webhook_id}", status=404, code="not_found")
|
| 74 |
+
return ok_with_cors({"webhook": wh})
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
@router.delete("/{webhook_id}")
|
| 78 |
+
async def delete_wh(webhook_id: int, _admin: str = Depends(_require_admin)):
|
| 79 |
+
ok = webhook_store.delete_webhook(webhook_id)
|
| 80 |
+
return ok_with_cors({"deleted": ok, "id": webhook_id})
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
@router.post("/{webhook_id}/test")
|
| 84 |
+
async def test_wh(webhook_id: int, _admin: str = Depends(_require_admin)):
|
| 85 |
+
wh = webhook_store.get_webhook(webhook_id)
|
| 86 |
+
if not wh:
|
| 87 |
+
raise HttpError(f"webhook not found: {webhook_id}", status=404, code="not_found")
|
| 88 |
+
await webhook_store.notify(
|
| 89 |
+
"test.ping",
|
| 90 |
+
{"webhook_id": webhook_id, "name": wh["name"], "message": "test from admin"},
|
| 91 |
+
)
|
| 92 |
+
return ok_with_cors({"sent": True, "id": webhook_id})
|
app/api/xtc.py
ADDED
|
@@ -0,0 +1,557 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""XTC 简化接口:/v1/xtc/providers、/v1/xtc/models、/v1/xtc/chat。
|
| 2 |
+
|
| 3 |
+
完全兼容前端 XTC-AI/src/common/api.js 调用。
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import json
|
| 8 |
+
from typing import Any, AsyncIterator, Optional
|
| 9 |
+
|
| 10 |
+
from fastapi import APIRouter, Depends, Header, Query, Request
|
| 11 |
+
from fastapi.responses import StreamingResponse
|
| 12 |
+
from sse_starlette.sse import EventSourceResponse
|
| 13 |
+
|
| 14 |
+
from ..adapters import gemini_api, openai as openai_adapter
|
| 15 |
+
from ..auth import require_access_key
|
| 16 |
+
from ..cache import get_cached_models, set_cached_models
|
| 17 |
+
from ..config import GEMINI_DEFAULT_MODEL, get_settings
|
| 18 |
+
from ..errors import HttpError
|
| 19 |
+
from ..media.imagefix import fix_images_in_messages, infer_fix_mode
|
| 20 |
+
from ..services.config_store import load_config
|
| 21 |
+
from ..utils.sse import sse_data, sse_done
|
| 22 |
+
from ..utils.thought import (
|
| 23 |
+
extract_thought_and_answer,
|
| 24 |
+
extract_thought_from_reasoning_content,
|
| 25 |
+
)
|
| 26 |
+
from ._common import (
|
| 27 |
+
CORS_HEADERS,
|
| 28 |
+
normalize_chat_body,
|
| 29 |
+
ok_with_cors,
|
| 30 |
+
record_usage,
|
| 31 |
+
select_provider_and_key,
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
router = APIRouter(prefix="/v1/xtc", tags=["xtc"])
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@router.get("/providers")
|
| 38 |
+
async def list_providers(_key: str = Depends(require_access_key)) -> dict:
|
| 39 |
+
config = await load_config()
|
| 40 |
+
items = [p.public() for p in config.providers if p.enabled]
|
| 41 |
+
return ok_with_cors(
|
| 42 |
+
{
|
| 43 |
+
"defaultProviderId": config.default_provider_id,
|
| 44 |
+
"providers": items,
|
| 45 |
+
"count": len(items),
|
| 46 |
+
}
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@router.get("/models")
|
| 51 |
+
async def list_models(
|
| 52 |
+
_key: str = Depends(require_access_key),
|
| 53 |
+
provider: Optional[str] = Query(default=None, alias="provider"),
|
| 54 |
+
all: int = Query(default=0, alias="all"),
|
| 55 |
+
x_provider: Optional[str] = Header(default=None, alias="x-provider"),
|
| 56 |
+
) -> dict:
|
| 57 |
+
# 前端 api.js 的 fetchModels 通过 x-provider 头指定厂商(与 /v1/xtc/chat
|
| 58 |
+
# 一致),此处需同时识别,否则模型选择器永远只展示默认厂商的模型。
|
| 59 |
+
# 优先取 query 参数(更显式),其次取头。
|
| 60 |
+
provider = provider or (x_provider.strip() if x_provider else x_provider)
|
| 61 |
+
config = await load_config()
|
| 62 |
+
enabled = [p for p in config.providers if p.enabled]
|
| 63 |
+
if all and not provider:
|
| 64 |
+
# 合并所有厂商
|
| 65 |
+
out: list[dict] = []
|
| 66 |
+
for p in enabled:
|
| 67 |
+
models = await _fetch_provider_models(p)
|
| 68 |
+
for m in models:
|
| 69 |
+
mid = m.get("id") or ""
|
| 70 |
+
full_id = f"{p.model_prefix}{mid}" if p.model_prefix else mid
|
| 71 |
+
out.append({"id": full_id, "name": m.get("name") or full_id, "provider": p.id})
|
| 72 |
+
return ok_with_cors({"models": out, "count": len(out), "all": True})
|
| 73 |
+
|
| 74 |
+
# 单厂商
|
| 75 |
+
target = None
|
| 76 |
+
if provider:
|
| 77 |
+
target = next((p for p in enabled if p.id == provider), None)
|
| 78 |
+
if not target:
|
| 79 |
+
target = config.find_default_provider()
|
| 80 |
+
if not target:
|
| 81 |
+
raise HttpError(
|
| 82 |
+
"No enabled provider. Add one in /admin.",
|
| 83 |
+
status=503,
|
| 84 |
+
code="service_unavailable",
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
models = await _fetch_provider_models(target)
|
| 88 |
+
out = []
|
| 89 |
+
for m in models:
|
| 90 |
+
mid = m.get("id") or ""
|
| 91 |
+
full_id = f"{target.model_prefix}{mid}" if target.model_prefix else mid
|
| 92 |
+
out.append({"id": full_id, "name": m.get("name") or full_id})
|
| 93 |
+
return ok_with_cors(
|
| 94 |
+
{"provider": target.id, "models": out, "count": len(out)}
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
async def _fetch_provider_models(provider) -> list[dict]:
|
| 99 |
+
cached = get_cached_models(provider.id)
|
| 100 |
+
if cached is not None:
|
| 101 |
+
return cached
|
| 102 |
+
try:
|
| 103 |
+
if not provider.api_keys:
|
| 104 |
+
return []
|
| 105 |
+
if provider.type == "gemini":
|
| 106 |
+
data = await gemini_api.list_models_raw(api_key=provider.api_keys[0])
|
| 107 |
+
else:
|
| 108 |
+
data = await openai_adapter.list_models_raw(
|
| 109 |
+
base_url=provider.base_url, api_key=provider.api_keys[0]
|
| 110 |
+
)
|
| 111 |
+
set_cached_models(provider.id, data)
|
| 112 |
+
return data
|
| 113 |
+
except Exception as e:
|
| 114 |
+
print(f"[xtc/models] provider {provider.id} list failed: {e}")
|
| 115 |
+
return []
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
# ===== /v1/xtc/chat =====
|
| 119 |
+
|
| 120 |
+
@router.post("/chat")
|
| 121 |
+
async def xtc_chat(
|
| 122 |
+
request: Request,
|
| 123 |
+
_key: str = Depends(require_access_key),
|
| 124 |
+
x_provider: Optional[str] = Header(default=None, alias="x-provider"),
|
| 125 |
+
accept: Optional[str] = Header(default=None),
|
| 126 |
+
):
|
| 127 |
+
"""简化聊天:支持 json 与 multipart。
|
| 128 |
+
|
| 129 |
+
关键字段(兼容前端 api.js):
|
| 130 |
+
- messages: list[{role, content}]
|
| 131 |
+
- model: str
|
| 132 |
+
- provider: str
|
| 133 |
+
- stream: bool
|
| 134 |
+
- stream_mode: "simple" | "openai"
|
| 135 |
+
- image_fix: "mirror_h" | "rotate_180" | "mirror_h_rotate_180"
|
| 136 |
+
- image_camera_facing: "front" | "back"
|
| 137 |
+
- files / file_names: list[str] 附加文本拼到 messages
|
| 138 |
+
- thoughts: bool 是否返回 thought
|
| 139 |
+
- max_tokens / temperature / top_p / seed / stop / n
|
| 140 |
+
"""
|
| 141 |
+
content_type = (request.headers.get("content-type") or "").lower()
|
| 142 |
+
if "multipart/form-data" in content_type:
|
| 143 |
+
body = await _parse_multipart(request)
|
| 144 |
+
else:
|
| 145 |
+
try:
|
| 146 |
+
body = await request.json()
|
| 147 |
+
except Exception:
|
| 148 |
+
body = {}
|
| 149 |
+
|
| 150 |
+
if not isinstance(body, dict):
|
| 151 |
+
raise HttpError("invalid body", status=400, code="bad_request")
|
| 152 |
+
|
| 153 |
+
# 统一归一化:兼容 input+images 简化形态 与 messages OpenAI 形态
|
| 154 |
+
messages, model_from_body, provider_id = normalize_chat_body(body)
|
| 155 |
+
if not messages:
|
| 156 |
+
raise HttpError("input or messages is required", status=400, code="bad_request")
|
| 157 |
+
|
| 158 |
+
model = model_from_body or GEMINI_DEFAULT_MODEL
|
| 159 |
+
provider_id = provider_id or x_provider
|
| 160 |
+
|
| 161 |
+
# 图片修正
|
| 162 |
+
image_fix_mode = body.get("image_fix")
|
| 163 |
+
camera_facing = body.get("image_camera_facing")
|
| 164 |
+
if not image_fix_mode and camera_facing:
|
| 165 |
+
image_fix_mode = infer_fix_mode(camera_facing)
|
| 166 |
+
image_fixed = False
|
| 167 |
+
image_fix_failed = 0
|
| 168 |
+
image_warning: Optional[str] = None
|
| 169 |
+
if image_fix_mode:
|
| 170 |
+
success, failed, image_warning = await fix_images_in_messages(messages, image_fix_mode)
|
| 171 |
+
image_fixed = success > 0
|
| 172 |
+
image_fix_failed = failed
|
| 173 |
+
|
| 174 |
+
config = await load_config()
|
| 175 |
+
provider, api_key, clean_model = await select_provider_and_key(
|
| 176 |
+
config=config,
|
| 177 |
+
provider_id=provider_id,
|
| 178 |
+
model=model,
|
| 179 |
+
fallback_model=GEMINI_DEFAULT_MODEL,
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
stream = bool(body.get("stream"))
|
| 183 |
+
stream_mode = str(body.get("stream_mode") or "openai").lower()
|
| 184 |
+
|
| 185 |
+
# 构造上游 body(透传 OpenAI 风格参数)
|
| 186 |
+
upstream_body = _build_upstream_body(body, clean_model, messages)
|
| 187 |
+
|
| 188 |
+
if stream:
|
| 189 |
+
return _stream_xtc_chat(
|
| 190 |
+
provider,
|
| 191 |
+
api_key,
|
| 192 |
+
clean_model,
|
| 193 |
+
upstream_body,
|
| 194 |
+
messages,
|
| 195 |
+
stream_mode,
|
| 196 |
+
image_fix_mode,
|
| 197 |
+
image_fixed,
|
| 198 |
+
image_fix_failed,
|
| 199 |
+
image_warning,
|
| 200 |
+
_key,
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
# 非流式
|
| 204 |
+
try:
|
| 205 |
+
if provider.type == "gemini":
|
| 206 |
+
openai_resp = await gemini_api.chat_completions(
|
| 207 |
+
api_key=api_key,
|
| 208 |
+
model=clean_model,
|
| 209 |
+
messages=messages,
|
| 210 |
+
body=upstream_body,
|
| 211 |
+
stream=False,
|
| 212 |
+
)
|
| 213 |
+
else:
|
| 214 |
+
openai_resp = await openai_adapter.chat_completions(
|
| 215 |
+
base_url=provider.base_url,
|
| 216 |
+
api_key=api_key,
|
| 217 |
+
body=upstream_body,
|
| 218 |
+
stream=False,
|
| 219 |
+
)
|
| 220 |
+
record_usage(
|
| 221 |
+
access_key=_key,
|
| 222 |
+
provider=provider.id,
|
| 223 |
+
model=clean_model,
|
| 224 |
+
usage=(openai_resp or {}).get("usage"),
|
| 225 |
+
ok=True,
|
| 226 |
+
)
|
| 227 |
+
return _build_xtc_response(
|
| 228 |
+
openai_resp,
|
| 229 |
+
provider=provider.id,
|
| 230 |
+
model=clean_model,
|
| 231 |
+
image_fix_mode=image_fix_mode,
|
| 232 |
+
image_fixed=image_fixed,
|
| 233 |
+
image_fix_failed=image_fix_failed,
|
| 234 |
+
image_warning=image_warning,
|
| 235 |
+
camera_facing=camera_facing,
|
| 236 |
+
)
|
| 237 |
+
except HttpError as e:
|
| 238 |
+
record_usage(
|
| 239 |
+
access_key=_key,
|
| 240 |
+
provider=provider.id,
|
| 241 |
+
model=clean_model,
|
| 242 |
+
usage=None,
|
| 243 |
+
ok=False,
|
| 244 |
+
error_code=e.code,
|
| 245 |
+
)
|
| 246 |
+
raise
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def _build_upstream_body(body: dict, clean_model: str, messages: list) -> dict:
|
| 250 |
+
"""从 XTC body 构造上游 OpenAI 风格 body。"""
|
| 251 |
+
out = {
|
| 252 |
+
"model": clean_model,
|
| 253 |
+
"messages": messages,
|
| 254 |
+
}
|
| 255 |
+
for k in (
|
| 256 |
+
"temperature", "top_p", "top_k", "max_tokens", "max_completion_tokens",
|
| 257 |
+
"presence_penalty", "frequency_penalty", "seed", "stop", "n",
|
| 258 |
+
"response_format", "tools", "tool_choice", "reasoning_effort",
|
| 259 |
+
"stream_options", "extra_body", "google", "user",
|
| 260 |
+
):
|
| 261 |
+
if k in body and body[k] is not None:
|
| 262 |
+
out[k] = body[k]
|
| 263 |
+
return out
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
def _build_xtc_response(
|
| 267 |
+
openai_resp: dict,
|
| 268 |
+
*,
|
| 269 |
+
provider: str,
|
| 270 |
+
model: str,
|
| 271 |
+
image_fix_mode: Optional[str],
|
| 272 |
+
image_fixed: bool,
|
| 273 |
+
image_fix_failed: int,
|
| 274 |
+
image_warning: Optional[str],
|
| 275 |
+
camera_facing: Optional[str],
|
| 276 |
+
) -> dict:
|
| 277 |
+
"""从 OpenAI ChatCompletion 构造 XTC 简化响应。"""
|
| 278 |
+
choices = openai_resp.get("choices") or []
|
| 279 |
+
choice = choices[0] if choices else {}
|
| 280 |
+
message = choice.get("message") or {}
|
| 281 |
+
raw_text = message.get("content") or ""
|
| 282 |
+
reasoning_content = message.get("reasoning_content")
|
| 283 |
+
thought, text = extract_thought_and_answer(raw_text)
|
| 284 |
+
if not thought and reasoning_content:
|
| 285 |
+
thought = extract_thought_from_reasoning_content(reasoning_content)
|
| 286 |
+
if not text:
|
| 287 |
+
text = raw_text
|
| 288 |
+
|
| 289 |
+
payload: dict[str, Any] = {
|
| 290 |
+
"ok": True,
|
| 291 |
+
"provider": provider,
|
| 292 |
+
"model": model,
|
| 293 |
+
"thought": thought,
|
| 294 |
+
"text": text,
|
| 295 |
+
"raw": raw_text,
|
| 296 |
+
"usage": openai_resp.get("usage") or {},
|
| 297 |
+
"finish_reason": choice.get("finish_reason"),
|
| 298 |
+
}
|
| 299 |
+
if image_fix_mode:
|
| 300 |
+
payload["image_fix_mode"] = image_fix_mode
|
| 301 |
+
payload["image_fixed"] = image_fixed
|
| 302 |
+
payload["image_fix_failed_count"] = image_fix_failed
|
| 303 |
+
payload["image_camera_facing"] = camera_facing
|
| 304 |
+
if image_warning:
|
| 305 |
+
payload["warning"] = image_warning
|
| 306 |
+
headers = {"x-xtc-provider": provider, "x-xtc-model": model}
|
| 307 |
+
if image_fix_mode:
|
| 308 |
+
headers["x-xtc-image-fix-mode"] = image_fix_mode
|
| 309 |
+
return ok_with_cors(payload, extra_headers=headers)
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
async def _stream_xtc_chat(
|
| 313 |
+
provider,
|
| 314 |
+
api_key: str,
|
| 315 |
+
clean_model: str,
|
| 316 |
+
upstream_body: dict,
|
| 317 |
+
messages: list,
|
| 318 |
+
stream_mode: str,
|
| 319 |
+
image_fix_mode: Optional[str],
|
| 320 |
+
image_fixed: bool,
|
| 321 |
+
image_fix_failed: int,
|
| 322 |
+
image_warning: Optional[str],
|
| 323 |
+
access_key: str,
|
| 324 |
+
):
|
| 325 |
+
"""流式:simple 模式输出 {type,delta};openai 模式直接透传 OpenAI chunk。"""
|
| 326 |
+
headers = {
|
| 327 |
+
**CORS_HEADERS,
|
| 328 |
+
"x-xtc-provider": provider.id,
|
| 329 |
+
"x-xtc-model": clean_model,
|
| 330 |
+
"Cache-Control": "no-cache",
|
| 331 |
+
"x-accel-buffering": "no",
|
| 332 |
+
}
|
| 333 |
+
if image_fix_mode:
|
| 334 |
+
headers["x-xtc-image-fix-mode"] = image_fix_mode
|
| 335 |
+
|
| 336 |
+
if stream_mode == "simple":
|
| 337 |
+
return EventSourceResponse(
|
| 338 |
+
_simple_stream_gen(
|
| 339 |
+
provider, api_key, clean_model, upstream_body, messages,
|
| 340 |
+
image_warning, access_key,
|
| 341 |
+
),
|
| 342 |
+
headers=headers,
|
| 343 |
+
)
|
| 344 |
+
|
| 345 |
+
# openai 模式:透传
|
| 346 |
+
if provider.type == "gemini":
|
| 347 |
+
async def gen_openai() -> AsyncIterator[str]:
|
| 348 |
+
try:
|
| 349 |
+
chunk_iter = await gemini_api.chat_completions(
|
| 350 |
+
api_key=api_key,
|
| 351 |
+
model=clean_model,
|
| 352 |
+
messages=messages,
|
| 353 |
+
body=upstream_body,
|
| 354 |
+
stream=True,
|
| 355 |
+
)
|
| 356 |
+
async for chunk in chunk_iter:
|
| 357 |
+
yield sse_data(chunk)
|
| 358 |
+
yield sse_done()
|
| 359 |
+
except HttpError as e:
|
| 360 |
+
yield sse_data({"error": {"code": e.code, "message": e.message, "status": e.status}})
|
| 361 |
+
yield sse_done()
|
| 362 |
+
|
| 363 |
+
return EventSourceResponse(gen_openai(), headers=headers)
|
| 364 |
+
|
| 365 |
+
# OpenAI 厂商透传
|
| 366 |
+
async def gen_passthrough() -> AsyncIterator[bytes]:
|
| 367 |
+
try:
|
| 368 |
+
chunk_iter = await openai_adapter.chat_completions(
|
| 369 |
+
base_url=provider.base_url, api_key=api_key, body=upstream_body, stream=True
|
| 370 |
+
)
|
| 371 |
+
async for chunk in chunk_iter:
|
| 372 |
+
yield chunk
|
| 373 |
+
except HttpError as e:
|
| 374 |
+
err = sse_data({"error": {"code": e.code, "message": e.message, "status": e.status}})
|
| 375 |
+
yield err.encode("utf-8")
|
| 376 |
+
yield sse_done().encode("utf-8")
|
| 377 |
+
|
| 378 |
+
return StreamingResponse(gen_passthrough(), media_type="text/event-stream", headers=headers)
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
async def _simple_stream_gen(
|
| 382 |
+
provider,
|
| 383 |
+
api_key: str,
|
| 384 |
+
clean_model: str,
|
| 385 |
+
upstream_body: dict,
|
| 386 |
+
messages: list,
|
| 387 |
+
image_warning: Optional[str],
|
| 388 |
+
access_key: str,
|
| 389 |
+
) -> AsyncIterator[str]:
|
| 390 |
+
"""simple 模式:把 OpenAI chunk 流转换为 {type: text/thought/done, delta}。"""
|
| 391 |
+
import re
|
| 392 |
+
|
| 393 |
+
in_thought = False
|
| 394 |
+
thought_open_sent = False
|
| 395 |
+
thought_close_sent = False
|
| 396 |
+
open_tag_re = re.compile(r"<thought>\s*$", re.IGNORECASE)
|
| 397 |
+
close_tag_re = re.compile(r"^\s*</thought>", re.IGNORECASE)
|
| 398 |
+
|
| 399 |
+
try:
|
| 400 |
+
if provider.type == "gemini":
|
| 401 |
+
chunk_iter = await gemini_api.chat_completions(
|
| 402 |
+
api_key=api_key, model=clean_model, messages=messages,
|
| 403 |
+
body=upstream_body, stream=True,
|
| 404 |
+
)
|
| 405 |
+
async for chunk in chunk_iter:
|
| 406 |
+
event = _simple_from_openai_chunk(chunk)
|
| 407 |
+
if event:
|
| 408 |
+
yield sse_data(event)
|
| 409 |
+
else:
|
| 410 |
+
# OpenAI 厂商:直接读原始 SSE
|
| 411 |
+
raw_iter = await openai_adapter.chat_completions(
|
| 412 |
+
base_url=provider.base_url, api_key=api_key, body=upstream_body, stream=True
|
| 413 |
+
)
|
| 414 |
+
async for raw in raw_iter:
|
| 415 |
+
if isinstance(raw, bytes):
|
| 416 |
+
text = raw.decode("utf-8", errors="replace")
|
| 417 |
+
else:
|
| 418 |
+
text = str(raw)
|
| 419 |
+
# 解析 data: 行
|
| 420 |
+
for line in text.split("\n"):
|
| 421 |
+
if not line.startswith("data:"):
|
| 422 |
+
continue
|
| 423 |
+
payload = line[5:].strip()
|
| 424 |
+
if payload == "[DONE]":
|
| 425 |
+
yield sse_data({"type": "done", "delta": "", "finish_reason": "stop"})
|
| 426 |
+
return
|
| 427 |
+
try:
|
| 428 |
+
ch = json.loads(payload)
|
| 429 |
+
except Exception:
|
| 430 |
+
continue
|
| 431 |
+
event = _simple_from_openai_chunk(ch)
|
| 432 |
+
if event:
|
| 433 |
+
yield sse_data(event)
|
| 434 |
+
yield sse_data({"type": "done", "delta": "", "finish_reason": "stop"})
|
| 435 |
+
except HttpError as e:
|
| 436 |
+
yield sse_data(
|
| 437 |
+
{
|
| 438 |
+
"type": "error",
|
| 439 |
+
"error": {"code": e.code, "message": e.message, "status": e.status},
|
| 440 |
+
}
|
| 441 |
+
)
|
| 442 |
+
if image_warning:
|
| 443 |
+
yield sse_data({"type": "warning", "delta": image_warning})
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
def _simple_from_openai_chunk(chunk: dict) -> Optional[dict]:
|
| 447 |
+
"""把单个 OpenAI chunk 转为 simple 事件。"""
|
| 448 |
+
choices = chunk.get("choices") or []
|
| 449 |
+
if not choices:
|
| 450 |
+
return None
|
| 451 |
+
choice = choices[0]
|
| 452 |
+
delta = choice.get("delta") or {}
|
| 453 |
+
content = delta.get("content")
|
| 454 |
+
finish_reason = choice.get("finish_reason")
|
| 455 |
+
if content is not None:
|
| 456 |
+
return {"type": "text", "delta": content, "finish_reason": finish_reason}
|
| 457 |
+
if finish_reason:
|
| 458 |
+
return {"type": "done", "delta": "", "finish_reason": finish_reason}
|
| 459 |
+
return None
|
| 460 |
+
|
| 461 |
+
|
| 462 |
+
async def _parse_multipart(request: Request) -> dict:
|
| 463 |
+
"""解析 multipart/form-data。
|
| 464 |
+
|
| 465 |
+
兼容前端 api.js 的 callUpload 路径,该路径发送:
|
| 466 |
+
- 简单字段:access_key / provider / model / input / stream / max_tokens /
|
| 467 |
+
image_fix / file_names(JSON 字符串)/ images(图片 URL 字符串,可多条)
|
| 468 |
+
- 文件字段:name="images" 的图片 UploadFile,name="files" 的文件 UploadFile
|
| 469 |
+
|
| 470 |
+
图片 UploadFile 转 data URL 放进 out["images"];
|
| 471 |
+
文本类文件 UploadFile 读 utf-8 文本放进 out["files"],文件名进 out["file_names"]。
|
| 472 |
+
最终由 normalize_chat_body 统一组装为 messages。
|
| 473 |
+
"""
|
| 474 |
+
import base64
|
| 475 |
+
|
| 476 |
+
from starlette.datastructures import UploadFile
|
| 477 |
+
|
| 478 |
+
form = await request.form()
|
| 479 |
+
out: dict[str, Any] = {}
|
| 480 |
+
# 简单字段(含 input)
|
| 481 |
+
for key in ("model", "provider", "input", "stream", "stream_mode", "image_fix",
|
| 482 |
+
"image_camera_facing", "max_tokens", "temperature", "top_p",
|
| 483 |
+
"seed", "stop", "n", "thoughts"):
|
| 484 |
+
if key not in form:
|
| 485 |
+
continue
|
| 486 |
+
val = form[key]
|
| 487 |
+
if isinstance(val, UploadFile):
|
| 488 |
+
continue
|
| 489 |
+
val = str(val)
|
| 490 |
+
if val in ("true", "false"):
|
| 491 |
+
out[key] = val == "true"
|
| 492 |
+
elif val.lstrip("-").isdigit():
|
| 493 |
+
out[key] = int(val)
|
| 494 |
+
else:
|
| 495 |
+
try:
|
| 496 |
+
out[key] = float(val)
|
| 497 |
+
except (TypeError, ValueError):
|
| 498 |
+
out[key] = val
|
| 499 |
+
# messages(JSON 字符串)
|
| 500 |
+
if "messages" in form:
|
| 501 |
+
raw = form["messages"]
|
| 502 |
+
if not isinstance(raw, UploadFile):
|
| 503 |
+
try:
|
| 504 |
+
out["messages"] = json.loads(str(raw))
|
| 505 |
+
except Exception:
|
| 506 |
+
out["messages"] = [{"role": "user", "content": str(raw)}]
|
| 507 |
+
# file_names(前端会发 JSON.stringify(names))
|
| 508 |
+
file_names: list[str] = []
|
| 509 |
+
if "file_names" in form:
|
| 510 |
+
raw = form["file_names"]
|
| 511 |
+
if not isinstance(raw, UploadFile):
|
| 512 |
+
raw = str(raw)
|
| 513 |
+
try:
|
| 514 |
+
parsed = json.loads(raw)
|
| 515 |
+
if isinstance(parsed, list):
|
| 516 |
+
file_names = [str(x) for x in parsed]
|
| 517 |
+
else:
|
| 518 |
+
file_names = [raw]
|
| 519 |
+
except Exception:
|
| 520 |
+
file_names = [raw]
|
| 521 |
+
# 遍历所有项,收集图片 URL 字符串 + UploadFile
|
| 522 |
+
image_urls: list[str] = []
|
| 523 |
+
files_text: list[str] = []
|
| 524 |
+
upload_file_names: list[str] = []
|
| 525 |
+
for key, value in form.multi_items():
|
| 526 |
+
if isinstance(value, UploadFile):
|
| 527 |
+
content_bytes = await value.read()
|
| 528 |
+
filename = value.filename or key
|
| 529 |
+
if key == "images" or key == "image" or key.startswith("image_"):
|
| 530 |
+
# 图片文件 → data URL
|
| 531 |
+
mime = (value.content_type or "image/jpeg").split(";")[0].strip() or "image/jpeg"
|
| 532 |
+
b64 = base64.b64encode(content_bytes).decode("ascii")
|
| 533 |
+
image_urls.append(f"data:{mime};base64,{b64}")
|
| 534 |
+
elif key == "files" or key == "file" or key.startswith("file_"):
|
| 535 |
+
# 文件 → 尝试 utf-8 文本;二进制则记空串占位
|
| 536 |
+
try:
|
| 537 |
+
files_text.append(content_bytes.decode("utf-8"))
|
| 538 |
+
except Exception:
|
| 539 |
+
files_text.append("")
|
| 540 |
+
upload_file_names.append(filename)
|
| 541 |
+
continue
|
| 542 |
+
# 字符串值
|
| 543 |
+
sval = str(value or "").strip()
|
| 544 |
+
if not sval:
|
| 545 |
+
continue
|
| 546 |
+
if key == "images" or key == "image" or key.startswith("image_"):
|
| 547 |
+
if sval.startswith(("http://", "https://", "data:")):
|
| 548 |
+
image_urls.append(sval)
|
| 549 |
+
elif key == "files" or key == "file" or key.startswith("file_"):
|
| 550 |
+
files_text.append(sval)
|
| 551 |
+
upload_file_names.append(key)
|
| 552 |
+
if image_urls:
|
| 553 |
+
out["images"] = image_urls
|
| 554 |
+
if files_text:
|
| 555 |
+
out["files"] = files_text
|
| 556 |
+
out["file_names"] = (file_names + upload_file_names) or upload_file_names
|
| 557 |
+
return out
|
app/auth.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""鉴权:access_key 校验 + JWT 临时令牌。
|
| 2 |
+
|
| 3 |
+
简化点:
|
| 4 |
+
- 去掉 XTC_ACCESS_SHORT_KEY / XTC_ACCESS_PIN
|
| 5 |
+
- 用 JWT 替代内存 Map,多实例可用
|
| 6 |
+
- access_token 持久化到 SQLite(用于吊销/查询)
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import secrets
|
| 11 |
+
import time
|
| 12 |
+
from typing import Optional
|
| 13 |
+
|
| 14 |
+
import jwt
|
| 15 |
+
from fastapi import Header, Request
|
| 16 |
+
|
| 17 |
+
from .config import get_settings
|
| 18 |
+
from .database import get_conn
|
| 19 |
+
from .errors import HttpError
|
| 20 |
+
|
| 21 |
+
ALGORITHM = "HS256"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# ===== access_key 校验 =====
|
| 25 |
+
|
| 26 |
+
def _extract_access_key(
|
| 27 |
+
*,
|
| 28 |
+
authorization: Optional[str],
|
| 29 |
+
x_access_key: Optional[str],
|
| 30 |
+
) -> Optional[str]:
|
| 31 |
+
if x_access_key:
|
| 32 |
+
return x_access_key.strip()
|
| 33 |
+
if authorization:
|
| 34 |
+
auth = authorization.strip()
|
| 35 |
+
if auth.lower().startswith("bearer "):
|
| 36 |
+
return auth[7:].strip()
|
| 37 |
+
if auth.lower().startswith("basic "):
|
| 38 |
+
return auth[6:].strip()
|
| 39 |
+
return auth
|
| 40 |
+
return None
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def verify_access_key(provided: Optional[str]) -> None:
|
| 44 |
+
settings = get_settings()
|
| 45 |
+
if not settings.has_access_key:
|
| 46 |
+
# 未配置 access_key 时放行(开发模式)
|
| 47 |
+
return
|
| 48 |
+
if not provided:
|
| 49 |
+
raise HttpError(
|
| 50 |
+
"Missing access key: provide x-xtc-access-key header or Authorization: Bearer <key>",
|
| 51 |
+
status=401,
|
| 52 |
+
code="unauthorized",
|
| 53 |
+
)
|
| 54 |
+
# 先校验长期 key
|
| 55 |
+
if _consteq(provided, settings.xtc_access_key):
|
| 56 |
+
return
|
| 57 |
+
# 再校验临时 token
|
| 58 |
+
if verify_access_token(provided):
|
| 59 |
+
return
|
| 60 |
+
raise HttpError(
|
| 61 |
+
"Invalid access key or token",
|
| 62 |
+
status=401,
|
| 63 |
+
code="unauthorized",
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def verify_access_token(token: str) -> bool:
|
| 68 |
+
"""校验临时令牌:JWT 签名 + SQLite 未吊销 + 未过期。"""
|
| 69 |
+
settings = get_settings()
|
| 70 |
+
if not settings.has_jwt_secret:
|
| 71 |
+
return False
|
| 72 |
+
try:
|
| 73 |
+
payload = jwt.decode(token, settings.xtc_jwt_secret, algorithms=[ALGORITHM])
|
| 74 |
+
except jwt.PyJWTError:
|
| 75 |
+
return False
|
| 76 |
+
if payload.get("type") != "access":
|
| 77 |
+
return False
|
| 78 |
+
jti = payload.get("jti")
|
| 79 |
+
if not jti:
|
| 80 |
+
return False
|
| 81 |
+
with get_conn() as conn:
|
| 82 |
+
row = conn.execute(
|
| 83 |
+
"SELECT expires_at, revoked FROM access_tokens WHERE token = ?",
|
| 84 |
+
(jti,),
|
| 85 |
+
).fetchone()
|
| 86 |
+
if not row:
|
| 87 |
+
return False
|
| 88 |
+
if row["revoked"]:
|
| 89 |
+
return False
|
| 90 |
+
if int(row["expires_at"]) < int(time.time()):
|
| 91 |
+
return False
|
| 92 |
+
return True
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
# ===== 临时令牌签发 =====
|
| 96 |
+
|
| 97 |
+
def issue_access_token(*, issued_by: str = "admin") -> dict:
|
| 98 |
+
settings = get_settings()
|
| 99 |
+
if not settings.has_jwt_secret:
|
| 100 |
+
raise HttpError(
|
| 101 |
+
"XTC_JWT_SECRET not configured, cannot issue token",
|
| 102 |
+
status=500,
|
| 103 |
+
code="server_misconfigured",
|
| 104 |
+
)
|
| 105 |
+
ttl = settings.xtc_access_token_ttl_sec
|
| 106 |
+
length = settings.xtc_access_token_length
|
| 107 |
+
jti = secrets.token_urlsafe(length)[:length] if length <= 32 else secrets.token_urlsafe(32)
|
| 108 |
+
now = int(time.time())
|
| 109 |
+
expires_at = now + ttl
|
| 110 |
+
payload = {
|
| 111 |
+
"type": "access",
|
| 112 |
+
"jti": jti,
|
| 113 |
+
"iat": now,
|
| 114 |
+
"exp": expires_at,
|
| 115 |
+
}
|
| 116 |
+
token = jwt.encode(payload, settings.xtc_jwt_secret, algorithm=ALGORITHM)
|
| 117 |
+
with get_conn() as conn:
|
| 118 |
+
conn.execute(
|
| 119 |
+
"INSERT INTO access_tokens(token, issued_at, expires_at, issued_by, revoked) "
|
| 120 |
+
"VALUES(?,?,?,?,0)",
|
| 121 |
+
(jti, now, expires_at, issued_by),
|
| 122 |
+
)
|
| 123 |
+
return {
|
| 124 |
+
"token": token,
|
| 125 |
+
"jti": jti,
|
| 126 |
+
"issued_at": now,
|
| 127 |
+
"expires_at": expires_at,
|
| 128 |
+
"expires_in_sec": ttl,
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def revoke_access_token(jti: str) -> bool:
|
| 133 |
+
with get_conn() as conn:
|
| 134 |
+
cur = conn.execute(
|
| 135 |
+
"UPDATE access_tokens SET revoked = 1 WHERE token = ? AND revoked = 0",
|
| 136 |
+
(jti,),
|
| 137 |
+
)
|
| 138 |
+
return cur.rowcount > 0
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def list_access_tokens() -> list[dict]:
|
| 142 |
+
now = int(time.time())
|
| 143 |
+
with get_conn() as conn:
|
| 144 |
+
rows = conn.execute(
|
| 145 |
+
"SELECT token, issued_at, expires_at, issued_by, revoked FROM access_tokens "
|
| 146 |
+
"ORDER BY issued_at DESC LIMIT 200"
|
| 147 |
+
).fetchall()
|
| 148 |
+
out = []
|
| 149 |
+
for r in rows:
|
| 150 |
+
out.append(
|
| 151 |
+
{
|
| 152 |
+
"jti": r["token"],
|
| 153 |
+
"issued_at": int(r["issued_at"]),
|
| 154 |
+
"expires_at": int(r["expires_at"]),
|
| 155 |
+
"issued_by": r["issued_by"],
|
| 156 |
+
"revoked": bool(r["revoked"]),
|
| 157 |
+
"expired": int(r["expires_at"]) < now,
|
| 158 |
+
}
|
| 159 |
+
)
|
| 160 |
+
return out
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
# ===== admin key 校验 =====
|
| 164 |
+
|
| 165 |
+
def verify_admin_key(provided: Optional[str]) -> None:
|
| 166 |
+
settings = get_settings()
|
| 167 |
+
if not settings.is_admin_enabled:
|
| 168 |
+
raise HttpError(
|
| 169 |
+
"Admin API disabled (XTC_DISABLE_ADMIN=true)",
|
| 170 |
+
status=403,
|
| 171 |
+
code="forbidden",
|
| 172 |
+
)
|
| 173 |
+
if not settings.has_admin_key:
|
| 174 |
+
raise HttpError(
|
| 175 |
+
"XTC_ADMIN_KEY not configured",
|
| 176 |
+
status=500,
|
| 177 |
+
code="server_misconfigured",
|
| 178 |
+
)
|
| 179 |
+
if not provided:
|
| 180 |
+
raise HttpError(
|
| 181 |
+
"Missing admin key: provide x-xtc-admin-key header or Authorization: Bearer <key>",
|
| 182 |
+
status=401,
|
| 183 |
+
code="unauthorized",
|
| 184 |
+
)
|
| 185 |
+
if not _consteq(provided, settings.xtc_admin_key):
|
| 186 |
+
raise HttpError("Invalid admin key", status=401, code="unauthorized")
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
# ===== FastAPI 依赖 =====
|
| 190 |
+
|
| 191 |
+
def require_access_key(
|
| 192 |
+
request: Request,
|
| 193 |
+
authorization: Optional[str] = Header(default=None),
|
| 194 |
+
x_xtc_access_key: Optional[str] = Header(default=None, alias="x-xtc-access-key"),
|
| 195 |
+
x_xtc_access_token: Optional[str] = Header(default=None, alias="x-xtc-access-token"),
|
| 196 |
+
) -> str:
|
| 197 |
+
provided = _extract_access_key(
|
| 198 |
+
authorization=authorization,
|
| 199 |
+
x_access_key=x_xtc_access_key or x_xtc_access_token,
|
| 200 |
+
)
|
| 201 |
+
verify_access_key(provided)
|
| 202 |
+
return provided or ""
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def require_admin_key(
|
| 206 |
+
request: Request,
|
| 207 |
+
authorization: Optional[str] = Header(default=None),
|
| 208 |
+
x_xtc_admin_key: Optional[str] = Header(default=None, alias="x-xtc-admin-key"),
|
| 209 |
+
) -> str:
|
| 210 |
+
provided = _extract_access_key(
|
| 211 |
+
authorization=authorization,
|
| 212 |
+
x_access_key=x_xtc_admin_key,
|
| 213 |
+
)
|
| 214 |
+
verify_admin_key(provided)
|
| 215 |
+
return provided or ""
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def _consteq(a: str, b: str) -> bool:
|
| 219 |
+
if not isinstance(a, str) or not isinstance(b, str):
|
| 220 |
+
return False
|
| 221 |
+
if len(a) != len(b):
|
| 222 |
+
return False
|
| 223 |
+
result = 0
|
| 224 |
+
for x, y in zip(a, b):
|
| 225 |
+
result |= ord(x) ^ ord(y)
|
| 226 |
+
return result == 0
|
app/cache.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""缓存层:模型列表等热数据 TTL 缓存。"""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import asyncio
|
| 5 |
+
import time
|
| 6 |
+
from typing import Any, Optional
|
| 7 |
+
|
| 8 |
+
from cachetools import TTLCache
|
| 9 |
+
|
| 10 |
+
# 模型列表缓存:默认 30s TTL,最多 32 个厂商
|
| 11 |
+
_models_cache: "TTLCache[str, list[Any]]" = TTLCache(maxsize=32, ttl=30)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def get_cached_models(provider_id: str) -> Optional[list[Any]]:
|
| 15 |
+
return _models_cache.get(provider_id)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def set_cached_models(provider_id: str, models: list[Any]) -> None:
|
| 19 |
+
_models_cache[provider_id] = models
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def invalidate_models_cache(provider_id: Optional[str] = None) -> None:
|
| 23 |
+
if provider_id:
|
| 24 |
+
_models_cache.pop(provider_id, None)
|
| 25 |
+
else:
|
| 26 |
+
_models_cache.clear()
|
app/config.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""全局配置:从环境变量加载(pydantic-settings)。
|
| 2 |
+
|
| 3 |
+
简化点(相比原 Netlify 版):
|
| 4 |
+
- 不再支持 XTC_PROVIDER_N_* 多槽位环境变量,仅用 GEMINI/OPENAI 做种子
|
| 5 |
+
- 不再支持配置加密(XTC_CONFIG_CRYPT_KEY)
|
| 6 |
+
- 不再支持 XTC_CONFIG_ENV_ONLY / XTC_ADMIN_REQUIRE_KEY_ON_PAGE / XTC_ACCESS_SHORT_KEY / XTC_ACCESS_PIN
|
| 7 |
+
- 不再支持 Edge 路径与三段式 env 读取
|
| 8 |
+
- 新增 XTC_JWT_SECRET 用于签发临时令牌
|
| 9 |
+
- 存储主体为持久卷(/data/xtc.db);配置文件额外通过 HF Hub dataset 仓库做备份
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import os
|
| 14 |
+
from functools import lru_cache
|
| 15 |
+
from typing import List, Optional
|
| 16 |
+
|
| 17 |
+
from pydantic import Field, field_validator
|
| 18 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _split_key_list(value: Optional[str]) -> List[str]:
|
| 22 |
+
if not value:
|
| 23 |
+
return []
|
| 24 |
+
out = []
|
| 25 |
+
for item in str(value).replace("\n", ",").replace(";", ",").split(","):
|
| 26 |
+
s = item.strip()
|
| 27 |
+
if s:
|
| 28 |
+
out.append(s)
|
| 29 |
+
return list(dict.fromkeys(out)) # 去重保序
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class Settings(BaseSettings):
|
| 33 |
+
model_config = SettingsConfigDict(
|
| 34 |
+
env_file=".env",
|
| 35 |
+
env_file_encoding="utf-8",
|
| 36 |
+
extra="ignore",
|
| 37 |
+
case_sensitive=False,
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
# ===== 鉴权 =====
|
| 41 |
+
xtc_admin_key: str = Field(default="", alias="XTC_ADMIN_KEY")
|
| 42 |
+
xtc_access_key: str = Field(default="", alias="XTC_ACCESS_KEY")
|
| 43 |
+
xtc_jwt_secret: str = Field(default="", alias="XTC_JWT_SECRET")
|
| 44 |
+
|
| 45 |
+
# ===== 紧急开关 =====
|
| 46 |
+
xtc_disable_admin: bool = Field(default=False, alias="XTC_DISABLE_ADMIN")
|
| 47 |
+
|
| 48 |
+
# ===== 种子厂商 =====
|
| 49 |
+
gemini_api_key: str = Field(default="", alias="GEMINI_API_KEY")
|
| 50 |
+
openai_api_key: str = Field(default="", alias="OPENAI_API_KEY")
|
| 51 |
+
openai_base_url: str = Field(default="https://api.openai.com", alias="OPENAI_BASE_URL")
|
| 52 |
+
|
| 53 |
+
# ===== 超时 =====
|
| 54 |
+
upstream_timeout_ms: int = Field(default=120000, alias="UPSTREAM_TIMEOUT_MS")
|
| 55 |
+
upstream_stream_timeout_ms: int = Field(default=120000, alias="UPSTREAM_STREAM_TIMEOUT_MS")
|
| 56 |
+
|
| 57 |
+
# ===== HF Hub 配置备份(可选)=====
|
| 58 |
+
# 配置文件通过 HF Hub dataset 仓库做持久化备份,解决免费档磁盘 ephemeral 问题
|
| 59 |
+
# 未配置时降级为纯本地存储(功能正常,只是 restart/stop 后配置会丢失)
|
| 60 |
+
hf_token: str = Field(default="", alias="HF_TOKEN")
|
| 61 |
+
hf_config_repo: str = Field(default="", alias="HF_CONFIG_REPO")
|
| 62 |
+
|
| 63 |
+
# ===== 临时令牌 =====
|
| 64 |
+
xtc_access_token_ttl_sec: int = Field(default=1800, alias="XTC_ACCESS_TOKEN_TTL_SEC")
|
| 65 |
+
xtc_access_token_length: int = Field(default=8, alias="XTC_ACCESS_TOKEN_LENGTH")
|
| 66 |
+
|
| 67 |
+
# ===== 运行 =====
|
| 68 |
+
port: int = Field(default=7860, alias="PORT")
|
| 69 |
+
|
| 70 |
+
# ===== 数据库路径 =====
|
| 71 |
+
db_path: str = Field(default="/data/xtc.db", alias="XTC_DB_PATH")
|
| 72 |
+
|
| 73 |
+
@field_validator("xtc_access_token_length")
|
| 74 |
+
@classmethod
|
| 75 |
+
def _clamp_token_length(cls, v: int) -> int:
|
| 76 |
+
return max(6, min(24, v))
|
| 77 |
+
|
| 78 |
+
@field_validator("xtc_access_token_ttl_sec")
|
| 79 |
+
@classmethod
|
| 80 |
+
def _clamp_token_ttl(cls, v: int) -> int:
|
| 81 |
+
return max(60, min(86400, v))
|
| 82 |
+
|
| 83 |
+
@property
|
| 84 |
+
def gemini_api_keys(self) -> List[str]:
|
| 85 |
+
# 兼容 GEMINI_API_KEYS(逗号分隔)
|
| 86 |
+
return _split_key_list(os.environ.get("GEMINI_API_KEYS")) or _split_key_list(self.gemini_api_key)
|
| 87 |
+
|
| 88 |
+
@property
|
| 89 |
+
def openai_api_keys(self) -> List[str]:
|
| 90 |
+
return _split_key_list(os.environ.get("OPENAI_API_KEYS")) or _split_key_list(self.openai_api_key)
|
| 91 |
+
|
| 92 |
+
@property
|
| 93 |
+
def is_admin_enabled(self) -> bool:
|
| 94 |
+
return not self.xtc_disable_admin
|
| 95 |
+
|
| 96 |
+
@property
|
| 97 |
+
def has_admin_key(self) -> bool:
|
| 98 |
+
return bool(self.xtc_admin_key)
|
| 99 |
+
|
| 100 |
+
@property
|
| 101 |
+
def has_access_key(self) -> bool:
|
| 102 |
+
return bool(self.xtc_access_key)
|
| 103 |
+
|
| 104 |
+
@property
|
| 105 |
+
def has_jwt_secret(self) -> bool:
|
| 106 |
+
return bool(self.xtc_jwt_secret)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
@lru_cache(maxsize=1)
|
| 110 |
+
def get_settings() -> Settings:
|
| 111 |
+
return Settings()
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
# Gemini 上游地址(固定)
|
| 115 |
+
GEMINI_BASE_URL = "https://generativelanguage.googleapis.com"
|
| 116 |
+
GEMINI_API_VERSION = "v1beta"
|
| 117 |
+
GEMINI_API_CLIENT = "google-genai-sdk/1.34.0"
|
| 118 |
+
GEMINI_DEFAULT_MODEL = "gemini-flash-latest"
|
| 119 |
+
GEMINI_DEFAULT_EMBEDDINGS_MODEL = "gemini-embedding-001"
|
| 120 |
+
|
| 121 |
+
# RR 计数器数据库 key 前缀
|
| 122 |
+
RR_KEY_PREFIX = "rr:provider:"
|
| 123 |
+
|
| 124 |
+
# 伪流式会话 TTL
|
| 125 |
+
PSEUDO_SESSION_TTL_SEC = 10 * 60
|
| 126 |
+
|
| 127 |
+
# 模型列表缓存 TTL
|
| 128 |
+
MODELS_CACHE_TTL_SEC = 30
|
app/database.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SQLite 初始化与连接管理。
|
| 2 |
+
|
| 3 |
+
简化点:相比原 Netlify Blobs KV,这里用本地 SQLite(容器内 /data)。
|
| 4 |
+
HF Space 重建时数据可能丢失,启动时通过 HF Hub 仓库恢复配置(见 services/config_store.py)。
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import sqlite3
|
| 10 |
+
import threading
|
| 11 |
+
from contextlib import contextmanager
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Iterator
|
| 14 |
+
|
| 15 |
+
from .config import get_settings
|
| 16 |
+
|
| 17 |
+
_lock = threading.Lock()
|
| 18 |
+
_conn: sqlite3.Connection | None = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _ensure_parent_dir(db_path: str) -> None:
|
| 22 |
+
p = Path(db_path).expanduser()
|
| 23 |
+
if p.parent and not p.parent.exists():
|
| 24 |
+
try:
|
| 25 |
+
p.parent.mkdir(parents=True, exist_ok=True)
|
| 26 |
+
except Exception:
|
| 27 |
+
# 容器内只读卷会失败,退回 /tmp
|
| 28 |
+
pass
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _resolve_db_path() -> str:
|
| 32 |
+
settings = get_settings()
|
| 33 |
+
db_path = settings.db_path
|
| 34 |
+
# HF 免费档无持久卷时,/data 不存在或不可写,回退到 /tmp
|
| 35 |
+
try:
|
| 36 |
+
_ensure_parent_dir(db_path)
|
| 37 |
+
test_path = Path(db_path)
|
| 38 |
+
test_path.touch(exist_ok=True)
|
| 39 |
+
if not os.access(db_path, os.W_OK):
|
| 40 |
+
raise PermissionError(db_path)
|
| 41 |
+
return db_path
|
| 42 |
+
except Exception:
|
| 43 |
+
fallback = "/tmp/xtc.db"
|
| 44 |
+
print(f"[database] db_path {db_path} not writable, fallback to {fallback}")
|
| 45 |
+
return fallback
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def init_db() -> sqlite3.Connection:
|
| 49 |
+
global _conn
|
| 50 |
+
with _lock:
|
| 51 |
+
if _conn is not None:
|
| 52 |
+
return _conn
|
| 53 |
+
db_path = _resolve_db_path()
|
| 54 |
+
conn = sqlite3.connect(db_path, check_same_thread=False, isolation_level=None)
|
| 55 |
+
conn.row_factory = sqlite3.Row
|
| 56 |
+
conn.execute("PRAGMA journal_mode=WAL")
|
| 57 |
+
conn.execute("PRAGMA synchronous=NORMAL")
|
| 58 |
+
conn.execute("PRAGMA foreign_keys=ON")
|
| 59 |
+
_create_schema(conn)
|
| 60 |
+
_conn = conn
|
| 61 |
+
return _conn
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _create_schema(conn: sqlite3.Connection) -> None:
|
| 65 |
+
conn.executescript(
|
| 66 |
+
"""
|
| 67 |
+
CREATE TABLE IF NOT EXISTS kv (
|
| 68 |
+
key TEXT PRIMARY KEY,
|
| 69 |
+
value TEXT NOT NULL,
|
| 70 |
+
updated_at INTEGER NOT NULL
|
| 71 |
+
);
|
| 72 |
+
|
| 73 |
+
CREATE TABLE IF NOT EXISTS access_tokens (
|
| 74 |
+
token TEXT PRIMARY KEY,
|
| 75 |
+
issued_at INTEGER NOT NULL,
|
| 76 |
+
expires_at INTEGER NOT NULL,
|
| 77 |
+
issued_by TEXT,
|
| 78 |
+
revoked INTEGER NOT NULL DEFAULT 0
|
| 79 |
+
);
|
| 80 |
+
CREATE INDEX IF NOT EXISTS idx_access_tokens_expires ON access_tokens(expires_at);
|
| 81 |
+
|
| 82 |
+
CREATE TABLE IF NOT EXISTS pseudo_sessions (
|
| 83 |
+
id TEXT PRIMARY KEY,
|
| 84 |
+
created_at INTEGER NOT NULL,
|
| 85 |
+
updated_at INTEGER NOT NULL,
|
| 86 |
+
done INTEGER NOT NULL DEFAULT 0,
|
| 87 |
+
error TEXT,
|
| 88 |
+
thought TEXT NOT NULL DEFAULT '',
|
| 89 |
+
text TEXT NOT NULL DEFAULT '',
|
| 90 |
+
seq INTEGER NOT NULL DEFAULT 0,
|
| 91 |
+
payload TEXT
|
| 92 |
+
);
|
| 93 |
+
CREATE INDEX IF NOT EXISTS idx_pseudo_sessions_updated ON pseudo_sessions(updated_at);
|
| 94 |
+
|
| 95 |
+
CREATE TABLE IF NOT EXISTS pseudo_events (
|
| 96 |
+
seq INTEGER NOT NULL,
|
| 97 |
+
session_id TEXT NOT NULL,
|
| 98 |
+
at TEXT NOT NULL,
|
| 99 |
+
type TEXT NOT NULL,
|
| 100 |
+
delta TEXT,
|
| 101 |
+
finish_reason TEXT,
|
| 102 |
+
extra TEXT,
|
| 103 |
+
PRIMARY KEY (session_id, seq)
|
| 104 |
+
);
|
| 105 |
+
CREATE INDEX IF NOT EXISTS idx_pseudo_events_session ON pseudo_events(session_id);
|
| 106 |
+
|
| 107 |
+
CREATE TABLE IF NOT EXISTS rr_counter (
|
| 108 |
+
provider_id TEXT PRIMARY KEY,
|
| 109 |
+
counter INTEGER NOT NULL DEFAULT 0
|
| 110 |
+
);
|
| 111 |
+
|
| 112 |
+
CREATE TABLE IF NOT EXISTS app_logs (
|
| 113 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 114 |
+
ts INTEGER NOT NULL,
|
| 115 |
+
level TEXT NOT NULL,
|
| 116 |
+
module TEXT NOT NULL,
|
| 117 |
+
event TEXT NOT NULL,
|
| 118 |
+
trace_id TEXT,
|
| 119 |
+
data TEXT
|
| 120 |
+
);
|
| 121 |
+
CREATE INDEX IF NOT EXISTS idx_app_logs_ts ON app_logs(ts);
|
| 122 |
+
CREATE INDEX IF NOT EXISTS idx_app_logs_level ON app_logs(level);
|
| 123 |
+
|
| 124 |
+
CREATE TABLE IF NOT EXISTS usage_log (
|
| 125 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 126 |
+
ts INTEGER NOT NULL,
|
| 127 |
+
access_key TEXT,
|
| 128 |
+
provider TEXT,
|
| 129 |
+
model TEXT,
|
| 130 |
+
prompt_tokens INTEGER,
|
| 131 |
+
completion_tokens INTEGER,
|
| 132 |
+
total_tokens INTEGER,
|
| 133 |
+
ok INTEGER NOT NULL DEFAULT 0,
|
| 134 |
+
error_code TEXT
|
| 135 |
+
);
|
| 136 |
+
CREATE INDEX IF NOT EXISTS idx_usage_log_ts ON usage_log(ts);
|
| 137 |
+
CREATE INDEX IF NOT EXISTS idx_usage_log_key ON usage_log(access_key);
|
| 138 |
+
|
| 139 |
+
CREATE TABLE IF NOT EXISTS audit_log (
|
| 140 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 141 |
+
ts INTEGER NOT NULL,
|
| 142 |
+
action TEXT NOT NULL,
|
| 143 |
+
actor TEXT,
|
| 144 |
+
target TEXT,
|
| 145 |
+
detail TEXT
|
| 146 |
+
);
|
| 147 |
+
CREATE INDEX IF NOT EXISTS idx_audit_log_ts ON audit_log(ts);
|
| 148 |
+
|
| 149 |
+
CREATE TABLE IF NOT EXISTS file_meta (
|
| 150 |
+
key TEXT PRIMARY KEY,
|
| 151 |
+
namespace TEXT NOT NULL,
|
| 152 |
+
filename TEXT NOT NULL,
|
| 153 |
+
mime TEXT,
|
| 154 |
+
size INTEGER NOT NULL,
|
| 155 |
+
sha256 TEXT,
|
| 156 |
+
uploaded_at INTEGER NOT NULL,
|
| 157 |
+
uploaded_by TEXT,
|
| 158 |
+
access_key TEXT,
|
| 159 |
+
refs TEXT
|
| 160 |
+
);
|
| 161 |
+
CREATE INDEX IF NOT EXISTS idx_file_meta_ns ON file_meta(namespace);
|
| 162 |
+
CREATE INDEX IF NOT EXISTS idx_file_meta_owner ON file_meta(access_key);
|
| 163 |
+
CREATE INDEX IF NOT EXISTS idx_file_meta_uploaded ON file_meta(uploaded_at);
|
| 164 |
+
|
| 165 |
+
CREATE TABLE IF NOT EXISTS sessions (
|
| 166 |
+
id TEXT PRIMARY KEY,
|
| 167 |
+
title TEXT,
|
| 168 |
+
provider TEXT,
|
| 169 |
+
model TEXT,
|
| 170 |
+
access_key TEXT,
|
| 171 |
+
created_at INTEGER NOT NULL,
|
| 172 |
+
updated_at INTEGER NOT NULL
|
| 173 |
+
);
|
| 174 |
+
CREATE INDEX IF NOT EXISTS idx_sessions_owner ON sessions(access_key);
|
| 175 |
+
CREATE INDEX IF NOT EXISTS idx_sessions_updated ON sessions(updated_at);
|
| 176 |
+
|
| 177 |
+
CREATE TABLE IF NOT EXISTS session_messages (
|
| 178 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 179 |
+
session_id TEXT NOT NULL,
|
| 180 |
+
seq INTEGER NOT NULL,
|
| 181 |
+
role TEXT NOT NULL,
|
| 182 |
+
content TEXT NOT NULL,
|
| 183 |
+
thought TEXT,
|
| 184 |
+
provider TEXT,
|
| 185 |
+
model TEXT,
|
| 186 |
+
ts INTEGER NOT NULL,
|
| 187 |
+
file_keys TEXT,
|
| 188 |
+
UNIQUE(session_id, seq)
|
| 189 |
+
);
|
| 190 |
+
CREATE INDEX IF NOT EXISTS idx_session_messages_session ON session_messages(session_id);
|
| 191 |
+
|
| 192 |
+
CREATE TABLE IF NOT EXISTS rate_limit (
|
| 193 |
+
bucket TEXT PRIMARY KEY,
|
| 194 |
+
count INTEGER NOT NULL,
|
| 195 |
+
window_start INTEGER NOT NULL
|
| 196 |
+
);
|
| 197 |
+
|
| 198 |
+
CREATE TABLE IF NOT EXISTS webhook_config (
|
| 199 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 200 |
+
name TEXT NOT NULL,
|
| 201 |
+
url TEXT NOT NULL,
|
| 202 |
+
events TEXT NOT NULL,
|
| 203 |
+
enabled INTEGER NOT NULL DEFAULT 1,
|
| 204 |
+
created_at INTEGER NOT NULL
|
| 205 |
+
);
|
| 206 |
+
|
| 207 |
+
CREATE TABLE IF NOT EXISTS request_log (
|
| 208 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 209 |
+
ts INTEGER NOT NULL,
|
| 210 |
+
method TEXT NOT NULL,
|
| 211 |
+
path TEXT NOT NULL,
|
| 212 |
+
query TEXT,
|
| 213 |
+
status_code INTEGER,
|
| 214 |
+
elapsed_ms INTEGER,
|
| 215 |
+
access_key TEXT,
|
| 216 |
+
client_ip TEXT,
|
| 217 |
+
user_agent TEXT,
|
| 218 |
+
provider TEXT,
|
| 219 |
+
model TEXT,
|
| 220 |
+
stream INTEGER NOT NULL DEFAULT 0,
|
| 221 |
+
ok INTEGER NOT NULL DEFAULT 0,
|
| 222 |
+
error_code TEXT,
|
| 223 |
+
error_message TEXT,
|
| 224 |
+
request_headers TEXT,
|
| 225 |
+
request_body TEXT,
|
| 226 |
+
response_body TEXT,
|
| 227 |
+
response_headers TEXT
|
| 228 |
+
);
|
| 229 |
+
CREATE INDEX IF NOT EXISTS idx_request_log_ts ON request_log(ts);
|
| 230 |
+
CREATE INDEX IF NOT EXISTS idx_request_log_path ON request_log(path);
|
| 231 |
+
CREATE INDEX IF NOT EXISTS idx_request_log_status ON request_log(status_code);
|
| 232 |
+
CREATE INDEX IF NOT EXISTS idx_request_log_key ON request_log(access_key);
|
| 233 |
+
"""
|
| 234 |
+
)
|
| 235 |
+
# request_log 容量上限从 kv 读取,默认 500(直接用当前 conn,避免递归 init_db)
|
| 236 |
+
_ensure_request_log_limit_default(conn)
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def _ensure_request_log_limit_default(conn: sqlite3.Connection) -> None:
|
| 240 |
+
"""首次启动写入默认 request_log 上限。"""
|
| 241 |
+
import time as _time
|
| 242 |
+
try:
|
| 243 |
+
row = conn.execute(
|
| 244 |
+
"SELECT value FROM kv WHERE key = 'request_log_limit'"
|
| 245 |
+
).fetchone()
|
| 246 |
+
if not row:
|
| 247 |
+
conn.execute(
|
| 248 |
+
"INSERT INTO kv(key, value, updated_at) VALUES(?,?,?)",
|
| 249 |
+
("request_log_limit", "500", int(_time.time())),
|
| 250 |
+
)
|
| 251 |
+
except Exception as e:
|
| 252 |
+
print(f"[database] request_log_limit init failed: {e}")
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
@contextmanager
|
| 256 |
+
def get_conn() -> Iterator[sqlite3.Connection]:
|
| 257 |
+
conn = init_db()
|
| 258 |
+
yield conn
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def close_db() -> None:
|
| 262 |
+
global _conn
|
| 263 |
+
with _lock:
|
| 264 |
+
if _conn is not None:
|
| 265 |
+
_conn.close()
|
| 266 |
+
_conn = None
|
app/errors.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""统一错误模型。
|
| 2 |
+
|
| 3 |
+
完全兼容原 Netlify 版返回格式:
|
| 4 |
+
{ "ok": false, "error": { "code", "message", "status", "retryable", "hint", "upstream?", "details?" } }
|
| 5 |
+
|
| 6 |
+
错误码集合:bad_request/unauthorized/forbidden/not_found/request_timeout/
|
| 7 |
+
quota_exceeded/internal_error/service_unavailable/upstream_timeout/
|
| 8 |
+
model_disabled/server_misconfigured
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from typing import Any, Optional
|
| 13 |
+
|
| 14 |
+
from fastapi import Request
|
| 15 |
+
from fastapi.responses import JSONResponse
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
# 状态码 -> 错误码
|
| 19 |
+
_STATUS_TO_CODE = {
|
| 20 |
+
400: "bad_request",
|
| 21 |
+
401: "unauthorized",
|
| 22 |
+
402: "payment_required",
|
| 23 |
+
403: "forbidden",
|
| 24 |
+
404: "not_found",
|
| 25 |
+
405: "method_not_allowed",
|
| 26 |
+
408: "request_timeout",
|
| 27 |
+
409: "conflict",
|
| 28 |
+
422: "unprocessable_entity",
|
| 29 |
+
429: "quota_exceeded",
|
| 30 |
+
500: "internal_error",
|
| 31 |
+
502: "bad_gateway",
|
| 32 |
+
503: "service_unavailable",
|
| 33 |
+
504: "upstream_timeout",
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
# 可重试状态码
|
| 37 |
+
_RETRYABLE_STATUS = {408, 409, 429, 500, 502, 503, 504}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class HttpError(Exception):
|
| 41 |
+
"""统一 HTTP 异常。"""
|
| 42 |
+
|
| 43 |
+
def __init__(
|
| 44 |
+
self,
|
| 45 |
+
message: str,
|
| 46 |
+
status: int = 500,
|
| 47 |
+
code: Optional[str] = None,
|
| 48 |
+
details: Any = None,
|
| 49 |
+
upstream: Any = None,
|
| 50 |
+
) -> None:
|
| 51 |
+
super().__init__(message)
|
| 52 |
+
self.message = message
|
| 53 |
+
self.status = status
|
| 54 |
+
self.code = code or _STATUS_TO_CODE.get(status, "internal_error")
|
| 55 |
+
self.details = details
|
| 56 |
+
self.upstream = upstream
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def error_code_for_status(status: int, fallback: str = "upstream_error") -> str:
|
| 60 |
+
return _STATUS_TO_CODE.get(status, fallback)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def is_retryable_status(status: int) -> bool:
|
| 64 |
+
return status in _RETRYABLE_STATUS
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def get_error_hint(*, code: str, status: int, message: str) -> Optional[str]:
|
| 68 |
+
"""生成给前端展示的提示文案。"""
|
| 69 |
+
text = (message or "").lower()
|
| 70 |
+
if code == "unauthorized" or status == 401:
|
| 71 |
+
return "请检查 access_key/access_token 是否正确,或先重新生成临时令牌"
|
| 72 |
+
if code == "forbidden" or status == 403:
|
| 73 |
+
return "当前账号或策略不允许此操作,请检查后台厂商与模型策略"
|
| 74 |
+
if code == "not_found" or status == 404:
|
| 75 |
+
return "资源不存在,请检查接口路径、provider 与 model 参数"
|
| 76 |
+
if code == "quota_exceeded" or status == 429:
|
| 77 |
+
return "请求过快或额度不足,建议稍后重试或切换厂商/模型"
|
| 78 |
+
if code == "upstream_timeout" or status == 504 or "timeout" in text:
|
| 79 |
+
return "上游超时,建议降低 max_tokens 或稍后重试"
|
| 80 |
+
if code == "bad_request" or status in (400, 422):
|
| 81 |
+
return "请求参数格式可能有误,请检查 messages/images/model 字段"
|
| 82 |
+
if status >= 500:
|
| 83 |
+
return "服务暂时不可用,请稍后重试"
|
| 84 |
+
return None
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def make_error_response(
|
| 88 |
+
*,
|
| 89 |
+
message: str,
|
| 90 |
+
status: int = 500,
|
| 91 |
+
code: Optional[str] = None,
|
| 92 |
+
details: Any = None,
|
| 93 |
+
upstream: Any = None,
|
| 94 |
+
) -> JSONResponse:
|
| 95 |
+
err_code = code or _STATUS_TO_CODE.get(status, "internal_error")
|
| 96 |
+
hint = get_error_hint(code=err_code, status=status, message=message)
|
| 97 |
+
payload = {
|
| 98 |
+
"ok": False,
|
| 99 |
+
"error": {
|
| 100 |
+
"code": err_code,
|
| 101 |
+
"message": message,
|
| 102 |
+
"status": status,
|
| 103 |
+
"retryable": is_retryable_status(status),
|
| 104 |
+
"hint": hint,
|
| 105 |
+
},
|
| 106 |
+
}
|
| 107 |
+
if details is not None:
|
| 108 |
+
payload["error"]["details"] = details
|
| 109 |
+
if upstream is not None:
|
| 110 |
+
payload["error"]["upstream"] = upstream
|
| 111 |
+
return JSONResponse(status_code=status, content=payload, headers=_CORS_HEADERS)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def make_upstream_error_response(
|
| 115 |
+
*,
|
| 116 |
+
status: int,
|
| 117 |
+
text: str,
|
| 118 |
+
json_body: Any = None,
|
| 119 |
+
fallback_code: str = "upstream_error",
|
| 120 |
+
) -> JSONResponse:
|
| 121 |
+
"""从上游响应构造统一错误。"""
|
| 122 |
+
upstream_err = None
|
| 123 |
+
if isinstance(json_body, dict):
|
| 124 |
+
upstream_err = json_body.get("error") if isinstance(json_body.get("error"), dict) else json_body
|
| 125 |
+
code = (upstream_err or {}).get("code") if isinstance(upstream_err, dict) else None
|
| 126 |
+
code = code or error_code_for_status(status, fallback_code)
|
| 127 |
+
message = (upstream_err or {}).get("message") if isinstance(upstream_err, dict) else None
|
| 128 |
+
message = message or text or f"Upstream error ({status})"
|
| 129 |
+
hint = get_error_hint(code=code, status=status, message=message)
|
| 130 |
+
payload = {
|
| 131 |
+
"ok": False,
|
| 132 |
+
"error": {
|
| 133 |
+
"code": code,
|
| 134 |
+
"message": message,
|
| 135 |
+
"status": status,
|
| 136 |
+
"retryable": is_retryable_status(status),
|
| 137 |
+
"hint": hint,
|
| 138 |
+
"upstream": upstream_err,
|
| 139 |
+
},
|
| 140 |
+
}
|
| 141 |
+
return JSONResponse(status_code=status, content=payload, headers=_CORS_HEADERS)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
_CORS_HEADERS = {
|
| 145 |
+
"Access-Control-Allow-Origin": "*",
|
| 146 |
+
"Access-Control-Allow-Methods": "*",
|
| 147 |
+
"Access-Control-Allow-Headers": "*",
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
async def http_error_handler(request: Request, exc: HttpError) -> JSONResponse:
|
| 152 |
+
return make_error_response(
|
| 153 |
+
message=exc.message,
|
| 154 |
+
status=exc.status,
|
| 155 |
+
code=exc.code,
|
| 156 |
+
details=exc.details,
|
| 157 |
+
upstream=exc.upstream,
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
| 162 |
+
import traceback
|
| 163 |
+
traceback.print_exc()
|
| 164 |
+
return make_error_response(
|
| 165 |
+
message=str(exc) or "Unexpected error",
|
| 166 |
+
status=500,
|
| 167 |
+
code="internal_error",
|
| 168 |
+
)
|
app/hf_storage.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HF Hub 配置备份抽象层(简化版)。
|
| 2 |
+
|
| 3 |
+
仅用于「配置文件」的持久化备份:把 AppConfig JSON 同步到 HF Hub dataset 仓库的
|
| 4 |
+
config/ 命名空间,解决 HF Spaces 免费档磁盘 ephemeral(restart/stop 丢数据)的问题。
|
| 5 |
+
|
| 6 |
+
设计原则:
|
| 7 |
+
- 仅配置走 Hub,图片/文件/日志/会话都不走 Hub(用户明确不需要)
|
| 8 |
+
- 写入时先写本地缓存再异步推 Hub(Hub 失败不影响主流程)
|
| 9 |
+
- 读取时本地命中即返回;未命中再从 Hub 拉取并写本地缓存
|
| 10 |
+
- 未配置 HF_TOKEN / HF_CONFIG_REPO 时自动降级为纯本地(功能不影响,只是没有备份)
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import asyncio
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from typing import Optional
|
| 17 |
+
|
| 18 |
+
from .config import get_settings
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# ===== 命名空间常量 =====
|
| 22 |
+
|
| 23 |
+
NS_CONFIG = "config" # 仅此一个命名空间:应用配置备份
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# ===== 本地缓存路径 =====
|
| 27 |
+
|
| 28 |
+
def _local_root() -> Path:
|
| 29 |
+
"""本地缓存根目录(容器内 /data/hf_bucket 或 /tmp/hf_bucket)。"""
|
| 30 |
+
for candidate in ("/data/hf_bucket", "/tmp/hf_bucket"):
|
| 31 |
+
p = Path(candidate)
|
| 32 |
+
try:
|
| 33 |
+
p.mkdir(parents=True, exist_ok=True)
|
| 34 |
+
(p / ".wtest").touch(exist_ok=True)
|
| 35 |
+
(p / ".wtest").unlink(missing_ok=True)
|
| 36 |
+
return p
|
| 37 |
+
except Exception:
|
| 38 |
+
continue
|
| 39 |
+
p = Path("/tmp/hf_bucket")
|
| 40 |
+
p.mkdir(parents=True, exist_ok=True)
|
| 41 |
+
return p
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _safe_parts(s: str) -> list[str]:
|
| 45 |
+
"""拆分为安全路径段,过滤危险字符与 . / .. 。"""
|
| 46 |
+
sanitized: list[str] = []
|
| 47 |
+
for ch in s:
|
| 48 |
+
if ch.isalnum() or ch in ("-", "_", ".", "/"):
|
| 49 |
+
sanitized.append(ch)
|
| 50 |
+
else:
|
| 51 |
+
sanitized.append("_")
|
| 52 |
+
out: list[str] = []
|
| 53 |
+
for part in "".join(sanitized).split("/"):
|
| 54 |
+
if not part or part in (".", ".."):
|
| 55 |
+
continue
|
| 56 |
+
out.append(part)
|
| 57 |
+
return out
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _local_path(namespace: str, key: str) -> Path:
|
| 61 |
+
"""本地缓存路径:root/namespace/key。"""
|
| 62 |
+
root = _local_root()
|
| 63 |
+
p = root.joinpath(*_safe_parts(namespace), *_safe_parts(key))
|
| 64 |
+
p.parent.mkdir(parents=True, exist_ok=True)
|
| 65 |
+
return p
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _hub_path(namespace: str, key: str) -> str:
|
| 69 |
+
"""Hub 仓库内路径:namespace/key。"""
|
| 70 |
+
return "/".join(_safe_parts(namespace) + _safe_parts(key))
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
# ===== 是否启用 Hub =====
|
| 74 |
+
|
| 75 |
+
def is_hub_enabled() -> bool:
|
| 76 |
+
s = get_settings()
|
| 77 |
+
return bool(s.hf_config_repo and s.hf_token)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
# ===== 同步接口(本地优先)=====
|
| 81 |
+
|
| 82 |
+
def upload_bytes(namespace: str, key: str, data: bytes) -> str:
|
| 83 |
+
"""上传字节:先写本地缓存。
|
| 84 |
+
|
| 85 |
+
返回本地路径。Hub 推送由 push_to_hub 异步完成。
|
| 86 |
+
"""
|
| 87 |
+
p = _local_path(namespace, key)
|
| 88 |
+
p.write_bytes(data)
|
| 89 |
+
return str(p)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def download_bytes(namespace: str, key: str) -> Optional[bytes]:
|
| 93 |
+
"""下载字节:本地命中即返回,否则从 Hub 拉取并写本地缓存。"""
|
| 94 |
+
p = _local_path(namespace, key)
|
| 95 |
+
if p.exists() and p.is_file():
|
| 96 |
+
return p.read_bytes()
|
| 97 |
+
if is_hub_enabled():
|
| 98 |
+
data = _hub_download(namespace, key)
|
| 99 |
+
if data is not None:
|
| 100 |
+
try:
|
| 101 |
+
p.write_bytes(data)
|
| 102 |
+
except Exception:
|
| 103 |
+
pass
|
| 104 |
+
return data
|
| 105 |
+
return None
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
# ===== 异步接口(推送 Hub 用)=====
|
| 109 |
+
|
| 110 |
+
async def push_to_hub(namespace: str, key: str) -> bool:
|
| 111 |
+
"""把本地缓存文件异步推送到 Hub。"""
|
| 112 |
+
if not is_hub_enabled():
|
| 113 |
+
return False
|
| 114 |
+
p = _local_path(namespace, key)
|
| 115 |
+
if not p.exists():
|
| 116 |
+
return False
|
| 117 |
+
try:
|
| 118 |
+
await asyncio.to_thread(_hub_upload, namespace, key, p.read_bytes())
|
| 119 |
+
return True
|
| 120 |
+
except Exception as e:
|
| 121 |
+
print(f"[hf_storage] push_to_hub failed ns={namespace} key={key}: {e}")
|
| 122 |
+
return False
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
# ===== Hub 操作(同步,run in thread)=====
|
| 126 |
+
|
| 127 |
+
def _get_hf_api():
|
| 128 |
+
from huggingface_hub import HfApi # 延迟导入
|
| 129 |
+
s = get_settings()
|
| 130 |
+
return HfApi(token=s.hf_token), s.hf_config_repo
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def _ensure_repo(api, repo_id: str) -> None:
|
| 134 |
+
"""确保仓库存在(不存在则创建为 dataset 私有仓库)。"""
|
| 135 |
+
try:
|
| 136 |
+
api.repo_info(repo_id=repo_id, repo_type="dataset")
|
| 137 |
+
except Exception:
|
| 138 |
+
try:
|
| 139 |
+
api.create_repo(repo_id=repo_id, repo_type="dataset", private=True, exist_ok=True)
|
| 140 |
+
except Exception as e:
|
| 141 |
+
print(f"[hf_storage] create_repo failed: {e}")
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def _hub_upload(namespace: str, key: str, data: bytes) -> None:
|
| 145 |
+
api, repo_id = _get_hf_api()
|
| 146 |
+
_ensure_repo(api, repo_id)
|
| 147 |
+
path_in_repo = _hub_path(namespace, key)
|
| 148 |
+
api.upload_file(
|
| 149 |
+
path_or_fileobj=data,
|
| 150 |
+
path_in_repo=path_in_repo,
|
| 151 |
+
repo_id=repo_id,
|
| 152 |
+
repo_type="dataset",
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def _hub_download(namespace: str, key: str) -> Optional[bytes]:
|
| 157 |
+
from huggingface_hub import hf_hub_download
|
| 158 |
+
api, repo_id = _get_hf_api()
|
| 159 |
+
path_in_repo = _hub_path(namespace, key)
|
| 160 |
+
try:
|
| 161 |
+
local_path = hf_hub_download(
|
| 162 |
+
repo_id=repo_id,
|
| 163 |
+
filename=path_in_repo,
|
| 164 |
+
repo_type="dataset",
|
| 165 |
+
token=get_settings().hf_token,
|
| 166 |
+
)
|
| 167 |
+
return Path(local_path).read_bytes()
|
| 168 |
+
except Exception as e:
|
| 169 |
+
msg = str(e).lower()
|
| 170 |
+
if "404" in msg or "not found" in msg or "no such file" in msg:
|
| 171 |
+
return None
|
| 172 |
+
print(f"[hf_storage] hub_download failed: {e}")
|
| 173 |
+
return None
|
app/http_client.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HTTP 客户端:httpx 全局连接池 + 超时控制。
|
| 2 |
+
|
| 3 |
+
替代原 Netlify 版的 fetch + AbortController。
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
from typing import Optional
|
| 8 |
+
|
| 9 |
+
import httpx
|
| 10 |
+
|
| 11 |
+
from .config import get_settings
|
| 12 |
+
|
| 13 |
+
_client: Optional[httpx.AsyncClient] = None
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def get_http_client() -> httpx.AsyncClient:
|
| 17 |
+
global _client
|
| 18 |
+
if _client is None or _client.is_closed:
|
| 19 |
+
settings = get_settings()
|
| 20 |
+
timeout = httpx.Timeout(
|
| 21 |
+
timeout=settings.upstream_timeout_ms / 1000.0,
|
| 22 |
+
connect=10.0,
|
| 23 |
+
read=settings.upstream_stream_timeout_ms / 1000.0,
|
| 24 |
+
)
|
| 25 |
+
_client = httpx.AsyncClient(
|
| 26 |
+
timeout=timeout,
|
| 27 |
+
limits=httpx.Limits(
|
| 28 |
+
max_connections=100,
|
| 29 |
+
max_keepalive_connections=20,
|
| 30 |
+
keepalive_expiry=30.0,
|
| 31 |
+
),
|
| 32 |
+
http2=False,
|
| 33 |
+
follow_redirects=False,
|
| 34 |
+
)
|
| 35 |
+
return _client
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
async def close_http_client() -> None:
|
| 39 |
+
global _client
|
| 40 |
+
if _client is not None and not _client.is_closed:
|
| 41 |
+
await _client.aclose()
|
| 42 |
+
_client = None
|
app/main.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI 应用装配。"""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import asyncio
|
| 5 |
+
import logging
|
| 6 |
+
from contextlib import asynccontextmanager
|
| 7 |
+
|
| 8 |
+
from fastapi import FastAPI, Request
|
| 9 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 10 |
+
from fastapi.responses import JSONResponse
|
| 11 |
+
|
| 12 |
+
from .config import get_settings
|
| 13 |
+
from .database import close_db, init_db
|
| 14 |
+
from .errors import HttpError, http_error_handler, unhandled_exception_handler
|
| 15 |
+
from .http_client import close_http_client
|
| 16 |
+
from .services import config_store, pseudo_store
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@asynccontextmanager
|
| 20 |
+
async def lifespan(app: FastAPI):
|
| 21 |
+
# 启动
|
| 22 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
| 23 |
+
init_db()
|
| 24 |
+
# 预热配置
|
| 25 |
+
try:
|
| 26 |
+
cfg = await config_store.load_config()
|
| 27 |
+
logging.getLogger(__name__).info(
|
| 28 |
+
"config loaded: %d providers, default=%s",
|
| 29 |
+
len(cfg.providers), cfg.default_provider_id,
|
| 30 |
+
)
|
| 31 |
+
except Exception as e:
|
| 32 |
+
logging.getLogger(__name__).warning("config preload failed: %s", e)
|
| 33 |
+
# 启动伪流式清理任务
|
| 34 |
+
cleanup_task = asyncio.create_task(pseudo_store.cleanup_loop())
|
| 35 |
+
yield
|
| 36 |
+
# 关闭
|
| 37 |
+
cleanup_task.cancel()
|
| 38 |
+
try:
|
| 39 |
+
await cleanup_task
|
| 40 |
+
except asyncio.CancelledError:
|
| 41 |
+
pass
|
| 42 |
+
await close_http_client()
|
| 43 |
+
close_db()
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def create_app() -> FastAPI:
|
| 47 |
+
settings = get_settings()
|
| 48 |
+
app = FastAPI(
|
| 49 |
+
title="XTC Backend (Hugging Face)",
|
| 50 |
+
description="OpenAI/Gemini 兼容网关,迁移自 Netlify 版",
|
| 51 |
+
version="1.0.0",
|
| 52 |
+
lifespan=lifespan,
|
| 53 |
+
docs_url="/docs",
|
| 54 |
+
redoc_url=None,
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
# CORS
|
| 58 |
+
app.add_middleware(
|
| 59 |
+
CORSMiddleware,
|
| 60 |
+
allow_origins=["*"],
|
| 61 |
+
allow_credentials=False,
|
| 62 |
+
allow_methods=["*"],
|
| 63 |
+
allow_headers=["*"],
|
| 64 |
+
expose_headers=["x-xtc-provider", "x-xtc-model", "x-xtc-image-fix-mode", "Retry-After", "X-RateLimit-Scope"],
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
# 速率限制中间件(必须在内层,让 CORS 先处理)
|
| 68 |
+
from .middleware import RateLimitMiddleware
|
| 69 |
+
app.add_middleware(RateLimitMiddleware)
|
| 70 |
+
|
| 71 |
+
# 请求日志中间件(最内层,确保能捕获下游所有响应)
|
| 72 |
+
from .request_log_middleware import RequestLogMiddleware
|
| 73 |
+
app.add_middleware(RequestLogMiddleware)
|
| 74 |
+
|
| 75 |
+
# 异常处理
|
| 76 |
+
app.add_exception_handler(HttpError, http_error_handler)
|
| 77 |
+
app.add_exception_handler(Exception, unhandled_exception_handler)
|
| 78 |
+
|
| 79 |
+
# 路由注册
|
| 80 |
+
from .api import (
|
| 81 |
+
admin,
|
| 82 |
+
admin_data,
|
| 83 |
+
health,
|
| 84 |
+
image_fix,
|
| 85 |
+
logs,
|
| 86 |
+
openai_compat,
|
| 87 |
+
pseudo_stream,
|
| 88 |
+
request_logs,
|
| 89 |
+
sessions,
|
| 90 |
+
usage_audit,
|
| 91 |
+
webhooks,
|
| 92 |
+
xtc,
|
| 93 |
+
)
|
| 94 |
+
from .admin_html import router as admin_html_router
|
| 95 |
+
|
| 96 |
+
app.include_router(health.router)
|
| 97 |
+
app.include_router(openai_compat.router)
|
| 98 |
+
app.include_router(xtc.router)
|
| 99 |
+
app.include_router(pseudo_stream.router)
|
| 100 |
+
app.include_router(image_fix.router)
|
| 101 |
+
app.include_router(sessions.router)
|
| 102 |
+
app.include_router(admin.router)
|
| 103 |
+
app.include_router(admin_data.router)
|
| 104 |
+
app.include_router(usage_audit.router)
|
| 105 |
+
app.include_router(webhooks.router)
|
| 106 |
+
app.include_router(request_logs.router)
|
| 107 |
+
app.include_router(admin_html_router)
|
| 108 |
+
app.include_router(logs.router)
|
| 109 |
+
|
| 110 |
+
@app.options("/{path:path}")
|
| 111 |
+
async def cors_preflight(path: str, request: Request):
|
| 112 |
+
return JSONResponse(
|
| 113 |
+
status_code=204,
|
| 114 |
+
content=None,
|
| 115 |
+
headers={
|
| 116 |
+
"Access-Control-Allow-Origin": "*",
|
| 117 |
+
"Access-Control-Allow-Methods": "*",
|
| 118 |
+
"Access-Control-Allow-Headers": "*",
|
| 119 |
+
"Access-Control-Max-Age": "86400",
|
| 120 |
+
},
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
return app
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
app = create_app()
|
app/media/__init__.py
ADDED
|
File without changes
|
app/media/imagefix.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""图片修正:Pillow 实现 mirror_h / rotate_180 / mirror_h_rotate_180。
|
| 2 |
+
|
| 3 |
+
替代原 Jimp + jpeg-js 双路径实现,性能提升 ~10x。
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import base64
|
| 8 |
+
import io
|
| 9 |
+
import re
|
| 10 |
+
from typing import Optional, Tuple
|
| 11 |
+
|
| 12 |
+
from PIL import Image, ImageOps
|
| 13 |
+
|
| 14 |
+
# 启用 HEIF 支持(如果安装了 pillow-heif)
|
| 15 |
+
try:
|
| 16 |
+
import pillow_heif # type: ignore
|
| 17 |
+
pillow_heif.register_heif_opener()
|
| 18 |
+
_HEIF_OK = True
|
| 19 |
+
except Exception:
|
| 20 |
+
_HEIF_OK = False
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
VALID_MODES = {"mirror_h", "rotate_180", "mirror_h_rotate_180"}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def infer_fix_mode(camera_facing: Optional[str]) -> Optional[str]:
|
| 27 |
+
"""根据相机朝向推导修正模式。
|
| 28 |
+
|
| 29 |
+
- front: mirror_h(前置相机镜像校正)
|
| 30 |
+
- back: None(后置正常无需修正)
|
| 31 |
+
"""
|
| 32 |
+
if not camera_facing:
|
| 33 |
+
return None
|
| 34 |
+
facing = camera_facing.strip().lower()
|
| 35 |
+
if facing == "front":
|
| 36 |
+
return "mirror_h"
|
| 37 |
+
if facing == "back":
|
| 38 |
+
return None
|
| 39 |
+
return None
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def parse_data_url(data_url: str) -> Tuple[Optional[str], bytes]:
|
| 43 |
+
"""data URL -> (mime, bytes)。"""
|
| 44 |
+
if not data_url:
|
| 45 |
+
return None, b""
|
| 46 |
+
m = re.match(r"^data:([^;,]+)?(;base64)?,(.*)$", data_url.strip(), re.DOTALL)
|
| 47 |
+
if not m:
|
| 48 |
+
return None, b""
|
| 49 |
+
mime = m.group(1) or None
|
| 50 |
+
is_b64 = bool(m.group(2))
|
| 51 |
+
payload = m.group(3)
|
| 52 |
+
if is_b64:
|
| 53 |
+
try:
|
| 54 |
+
return mime, base64.b64decode(payload)
|
| 55 |
+
except Exception:
|
| 56 |
+
return mime, b""
|
| 57 |
+
return mime, payload.encode("utf-8")
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def fix_image_bytes(image_bytes: bytes, mode: str) -> bytes:
|
| 61 |
+
"""对单张图片字节应用修正,返回 PNG bytes。"""
|
| 62 |
+
if mode not in VALID_MODES:
|
| 63 |
+
raise ValueError(f"invalid image_fix mode: {mode}")
|
| 64 |
+
with Image.open(io.BytesIO(image_bytes)) as img:
|
| 65 |
+
img = ImageOps.exif_transpose(img) # 先按 EXIF 方向校正
|
| 66 |
+
if mode == "mirror_h":
|
| 67 |
+
img = img.transpose(Image.FLIP_LEFT_RIGHT)
|
| 68 |
+
elif mode == "rotate_180":
|
| 69 |
+
img = img.transpose(Image.ROTATE_180)
|
| 70 |
+
elif mode == "mirror_h_rotate_180":
|
| 71 |
+
img = img.transpose(Image.FLIP_LEFT_RIGHT)
|
| 72 |
+
img = img.transpose(Image.ROTATE_180)
|
| 73 |
+
# 统一输出 PNG(无损,避免 JPEG 重压)
|
| 74 |
+
if img.mode in ("RGBA", "P"):
|
| 75 |
+
out = io.BytesIO()
|
| 76 |
+
img.save(out, format="PNG", optimize=True)
|
| 77 |
+
else:
|
| 78 |
+
out = io.BytesIO()
|
| 79 |
+
img.save(out, format="PNG", optimize=True)
|
| 80 |
+
return out.getvalue()
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def fix_data_url(data_url: str, mode: str) -> str:
|
| 84 |
+
"""修正 data URL,返回 PNG 的 data URL。"""
|
| 85 |
+
_, raw = parse_data_url(data_url)
|
| 86 |
+
if not raw:
|
| 87 |
+
return data_url
|
| 88 |
+
fixed = fix_image_bytes(raw, mode)
|
| 89 |
+
b64 = base64.b64encode(fixed).decode("ascii")
|
| 90 |
+
return f"data:image/png;base64,{b64}"
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
async def _fetch_image_bytes(url: str) -> Optional[bytes]:
|
| 94 |
+
"""httpx 拉取 HTTP(S) 图片字节,失败返回 None。"""
|
| 95 |
+
from ..http_client import get_http_client
|
| 96 |
+
|
| 97 |
+
try:
|
| 98 |
+
client = get_http_client()
|
| 99 |
+
resp = await client.get(url)
|
| 100 |
+
if resp.status_code != 200:
|
| 101 |
+
return None
|
| 102 |
+
return resp.content
|
| 103 |
+
except Exception:
|
| 104 |
+
return None
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _bytes_to_data_url(raw: bytes) -> str:
|
| 108 |
+
b64 = base64.b64encode(raw).decode("ascii")
|
| 109 |
+
return f"data:image/png;base64,{b64}"
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
async def fix_images_in_messages(
|
| 113 |
+
messages: list,
|
| 114 |
+
mode: Optional[str],
|
| 115 |
+
) -> Tuple[int, int, Optional[str]]:
|
| 116 |
+
"""遍历 OpenAI messages 中的 image_url,应用修正。
|
| 117 |
+
|
| 118 |
+
支持 data: URL 与 HTTP(S) URL(后者用 httpx fetch 后修正)。
|
| 119 |
+
Returns:
|
| 120 |
+
(success_count, failed_count, warning)
|
| 121 |
+
"""
|
| 122 |
+
if not mode or mode not in VALID_MODES:
|
| 123 |
+
return 0, 0, None
|
| 124 |
+
success = 0
|
| 125 |
+
failed = 0
|
| 126 |
+
for msg in messages:
|
| 127 |
+
if not isinstance(msg, dict):
|
| 128 |
+
continue
|
| 129 |
+
content = msg.get("content")
|
| 130 |
+
if not isinstance(content, list):
|
| 131 |
+
continue
|
| 132 |
+
for part in content:
|
| 133 |
+
if not isinstance(part, dict):
|
| 134 |
+
continue
|
| 135 |
+
if part.get("type") != "image_url":
|
| 136 |
+
continue
|
| 137 |
+
url = (part.get("image_url") or {}).get("url") if isinstance(part.get("image_url"), dict) else part.get("image_url")
|
| 138 |
+
if not isinstance(url, str) or not url:
|
| 139 |
+
continue
|
| 140 |
+
try:
|
| 141 |
+
if url.startswith("data:"):
|
| 142 |
+
part["image_url"] = {"url": fix_data_url(url, mode)}
|
| 143 |
+
success += 1
|
| 144 |
+
elif url.startswith(("http://", "https://")):
|
| 145 |
+
raw = await _fetch_image_bytes(url)
|
| 146 |
+
if not raw:
|
| 147 |
+
failed += 1
|
| 148 |
+
continue
|
| 149 |
+
fixed = fix_image_bytes(raw, mode)
|
| 150 |
+
part["image_url"] = {"url": _bytes_to_data_url(fixed)}
|
| 151 |
+
success += 1
|
| 152 |
+
else:
|
| 153 |
+
failed += 1
|
| 154 |
+
except Exception:
|
| 155 |
+
failed += 1
|
| 156 |
+
warning = None
|
| 157 |
+
if failed > 0:
|
| 158 |
+
warning = f"image_fix: {failed} image(s) failed to fix"
|
| 159 |
+
return success, failed, warning
|
app/middleware.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HTTP 中间件:速率限制等。
|
| 2 |
+
|
| 3 |
+
只对 /v1/* 业务路径限流,admin / health / docs 不限流。
|
| 4 |
+
超限返回 429 + Retry-After 头。
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
from typing import Awaitable, Callable
|
| 10 |
+
|
| 11 |
+
from starlette.middleware.base import BaseHTTPMiddleware
|
| 12 |
+
from starlette.requests import Request
|
| 13 |
+
from starlette.responses import JSONResponse, Response
|
| 14 |
+
|
| 15 |
+
from .services import rate_limit_store
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _extract_access_key_from_request(request: Request) -> str:
|
| 19 |
+
auth = request.headers.get("authorization")
|
| 20 |
+
if auth:
|
| 21 |
+
a = auth.strip()
|
| 22 |
+
if a.lower().startswith("bearer "):
|
| 23 |
+
return a[7:].strip()
|
| 24 |
+
if a.lower().startswith("basic "):
|
| 25 |
+
return a[6:].strip()
|
| 26 |
+
return a
|
| 27 |
+
return request.headers.get("x-xtc-access-key") or request.headers.get("x-xtc-access-token") or ""
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class RateLimitMiddleware(BaseHTTPMiddleware):
|
| 31 |
+
"""per-key / per-IP 速率限制。"""
|
| 32 |
+
|
| 33 |
+
async def dispatch(
|
| 34 |
+
self,
|
| 35 |
+
request: Request,
|
| 36 |
+
call_next: Callable[[Request], Awaitable[Response]],
|
| 37 |
+
) -> Response:
|
| 38 |
+
path = request.url.path
|
| 39 |
+
if not rate_limit_store.is_rate_limited_path(path):
|
| 40 |
+
return await call_next(request)
|
| 41 |
+
|
| 42 |
+
# OPTIONS 预检直接放行
|
| 43 |
+
if request.method == "OPTIONS":
|
| 44 |
+
return await call_next(request)
|
| 45 |
+
|
| 46 |
+
access_key = _extract_access_key_from_request(request)
|
| 47 |
+
client_ip = rate_limit_store.get_client_ip(request)
|
| 48 |
+
|
| 49 |
+
allowed, reason, retry_after = rate_limit_store.check(
|
| 50 |
+
access_key=access_key or None,
|
| 51 |
+
client_ip=client_ip,
|
| 52 |
+
)
|
| 53 |
+
if not allowed:
|
| 54 |
+
body = {
|
| 55 |
+
"ok": False,
|
| 56 |
+
"error": {
|
| 57 |
+
"code": "rate_limited",
|
| 58 |
+
"message": f"Rate limit exceeded ({reason}). Retry after {retry_after}s.",
|
| 59 |
+
"status": 429,
|
| 60 |
+
"retryable": True,
|
| 61 |
+
"hint": f"请等待 {retry_after} 秒后重试",
|
| 62 |
+
"retry_after": retry_after,
|
| 63 |
+
"scope": reason,
|
| 64 |
+
},
|
| 65 |
+
}
|
| 66 |
+
return JSONResponse(
|
| 67 |
+
status_code=429,
|
| 68 |
+
content=body,
|
| 69 |
+
headers={
|
| 70 |
+
"Retry-After": str(retry_after),
|
| 71 |
+
"X-RateLimit-Scope": reason or "",
|
| 72 |
+
"Access-Control-Allow-Origin": "*",
|
| 73 |
+
"Access-Control-Allow-Methods": "*",
|
| 74 |
+
"Access-Control-Allow-Headers": "*",
|
| 75 |
+
},
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
# 把限流信息附加到响应头(便于客户端观察)
|
| 79 |
+
response = await call_next(request)
|
| 80 |
+
try:
|
| 81 |
+
response.headers["X-RateLimit-Window"] = "60"
|
| 82 |
+
except Exception:
|
| 83 |
+
pass
|
| 84 |
+
return response
|
app/models/__init__.py
ADDED
|
File without changes
|
app/models/config.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""配置数据模型:Provider、AppConfig。
|
| 2 |
+
|
| 3 |
+
向后兼容原 Netlify 版字段名(驼峰与下划线都接受)。
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
from datetime import datetime, timezone
|
| 8 |
+
from typing import Any, List, Literal, Optional
|
| 9 |
+
|
| 10 |
+
from pydantic import BaseModel, Field, model_validator
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _split_key_list(value: Any) -> List[str]:
|
| 14 |
+
if not value:
|
| 15 |
+
return []
|
| 16 |
+
if isinstance(value, list):
|
| 17 |
+
out = []
|
| 18 |
+
for item in value:
|
| 19 |
+
s = str(item or "").strip()
|
| 20 |
+
if s:
|
| 21 |
+
out.append(s)
|
| 22 |
+
return list(dict.fromkeys(out))
|
| 23 |
+
out = []
|
| 24 |
+
for item in str(value).replace("\n", ",").replace(";", ",").split(","):
|
| 25 |
+
s = item.strip()
|
| 26 |
+
if s:
|
| 27 |
+
out.append(s)
|
| 28 |
+
return list(dict.fromkeys(out))
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class Provider(BaseModel):
|
| 32 |
+
"""单个上游厂商配置。"""
|
| 33 |
+
|
| 34 |
+
id: str
|
| 35 |
+
name: str = ""
|
| 36 |
+
type: Literal["gemini", "openai"] = "gemini"
|
| 37 |
+
base_url: Optional[str] = None
|
| 38 |
+
api_keys: List[str] = Field(default_factory=list)
|
| 39 |
+
api_key: Optional[str] = None # 兼容字段,等价于 api_keys[0]
|
| 40 |
+
model_prefix: str = ""
|
| 41 |
+
enabled: bool = True
|
| 42 |
+
model_policy_mode: Literal["allowlist", "denylist"] = "denylist"
|
| 43 |
+
allow_models: List[str] = Field(default_factory=list)
|
| 44 |
+
deny_models: List[str] = Field(default_factory=list)
|
| 45 |
+
|
| 46 |
+
model_config = {"extra": "ignore"}
|
| 47 |
+
|
| 48 |
+
@model_validator(mode="before")
|
| 49 |
+
@classmethod
|
| 50 |
+
def _normalize(cls, data: Any) -> Any:
|
| 51 |
+
if not isinstance(data, dict):
|
| 52 |
+
return data
|
| 53 |
+
# 兼容 alias
|
| 54 |
+
out = dict(data)
|
| 55 |
+
if "baseUrl" in out and "base_url" not in out:
|
| 56 |
+
out["base_url"] = out.pop("baseUrl")
|
| 57 |
+
if "modelPrefix" in out and "model_prefix" not in out:
|
| 58 |
+
out["model_prefix"] = out.pop("modelPrefix")
|
| 59 |
+
if "modelPolicyMode" in out and "model_policy_mode" not in out:
|
| 60 |
+
out["model_policy_mode"] = out.pop("modelPolicyMode")
|
| 61 |
+
if "allowModels" in out and "allow_models" not in out:
|
| 62 |
+
out["allow_models"] = out.pop("allowModels")
|
| 63 |
+
if "denyModels" in out and "deny_models" not in out:
|
| 64 |
+
out["deny_models"] = out.pop("denyModels")
|
| 65 |
+
# enabled_models / disabled_models 别名
|
| 66 |
+
if "enabledModels" in out and "allow_models" not in out:
|
| 67 |
+
out["allow_models"] = out.pop("enabledModels")
|
| 68 |
+
if "disabledModels" in out and "deny_models" not in out:
|
| 69 |
+
out["deny_models"] = out.pop("disabledModels")
|
| 70 |
+
# id 默认从 name
|
| 71 |
+
if not out.get("id"):
|
| 72 |
+
out["id"] = str(out.get("name") or "").strip()
|
| 73 |
+
# 名称默认从 id
|
| 74 |
+
if not out.get("name"):
|
| 75 |
+
out["name"] = str(out.get("id") or "").strip()
|
| 76 |
+
# apiKeys / api_keys / apiKey 合并去重(兼容多种命名)
|
| 77 |
+
keys = _split_key_list(out.get("api_keys"))
|
| 78 |
+
keys += _split_key_list(out.get("apiKeys"))
|
| 79 |
+
keys += _split_key_list(out.get("api_key"))
|
| 80 |
+
keys += _split_key_list(out.get("apiKey"))
|
| 81 |
+
out["api_keys"] = list(dict.fromkeys(keys))
|
| 82 |
+
out["api_key"] = out["api_keys"][0] if out["api_keys"] else None
|
| 83 |
+
# base_url 兜底
|
| 84 |
+
if not out.get("base_url"):
|
| 85 |
+
ptype = str(out.get("type") or "").lower()
|
| 86 |
+
out["base_url"] = None if ptype == "gemini" else "https://api.openai.com"
|
| 87 |
+
else:
|
| 88 |
+
out["base_url"] = str(out["base_url"]).rstrip("/")
|
| 89 |
+
return out
|
| 90 |
+
|
| 91 |
+
def masked(self) -> "Provider":
|
| 92 |
+
"""返回脱敏副本(用于后台展示,密钥只显示首尾)。"""
|
| 93 |
+
def mask(k: str) -> str:
|
| 94 |
+
k = str(k or "")
|
| 95 |
+
return f"{k[:6]}***{k[-4:]}" if len(k) > 10 else "***"
|
| 96 |
+
return Provider(
|
| 97 |
+
id=self.id,
|
| 98 |
+
name=self.name,
|
| 99 |
+
type=self.type,
|
| 100 |
+
base_url=self.base_url,
|
| 101 |
+
api_keys=[mask(k) for k in self.api_keys],
|
| 102 |
+
api_key=mask(self.api_key) if self.api_key else None,
|
| 103 |
+
model_prefix=self.model_prefix,
|
| 104 |
+
enabled=self.enabled,
|
| 105 |
+
model_policy_mode=self.model_policy_mode,
|
| 106 |
+
allow_models=list(self.allow_models),
|
| 107 |
+
deny_models=list(self.deny_models),
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
def public(self) -> dict:
|
| 111 |
+
"""返回给手表端 /v1/xtc/providers 的精简信息。"""
|
| 112 |
+
return {
|
| 113 |
+
"id": self.id,
|
| 114 |
+
"name": self.name,
|
| 115 |
+
"type": self.type,
|
| 116 |
+
"modelPrefix": self.model_prefix or "",
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
class AppConfig(BaseModel):
|
| 121 |
+
"""完整配置(厂商列表 + 默认厂商 + 更新时间)。"""
|
| 122 |
+
|
| 123 |
+
providers: List[Provider] = Field(default_factory=list)
|
| 124 |
+
default_provider_id: Optional[str] = None
|
| 125 |
+
updated_at: Optional[str] = None
|
| 126 |
+
|
| 127 |
+
model_config = {"extra": "ignore"}
|
| 128 |
+
|
| 129 |
+
@model_validator(mode="before")
|
| 130 |
+
@classmethod
|
| 131 |
+
def _normalize(cls, data: Any) -> Any:
|
| 132 |
+
if not isinstance(data, dict):
|
| 133 |
+
return data
|
| 134 |
+
out = dict(data)
|
| 135 |
+
if "defaultProviderId" in out and "default_provider_id" not in out:
|
| 136 |
+
out["default_provider_id"] = out.pop("defaultProviderId")
|
| 137 |
+
if "updatedAt" in out and "updated_at" not in out:
|
| 138 |
+
out["updated_at"] = out.pop("updatedAt")
|
| 139 |
+
return out
|
| 140 |
+
|
| 141 |
+
def normalize(self) -> "AppConfig":
|
| 142 |
+
"""确保 default_provider_id 指向启用的厂商。"""
|
| 143 |
+
enabled = [p for p in self.providers if p.enabled]
|
| 144 |
+
if self.default_provider_id and any(p.id == self.default_provider_id for p in enabled):
|
| 145 |
+
return self
|
| 146 |
+
self.default_provider_id = enabled[0].id if enabled else None
|
| 147 |
+
self.updated_at = datetime.now(timezone.utc).isoformat()
|
| 148 |
+
return self
|
| 149 |
+
|
| 150 |
+
def find_provider(self, provider_id: Optional[str], *, enabled_only: bool = True) -> Optional[Provider]:
|
| 151 |
+
if not provider_id:
|
| 152 |
+
return None
|
| 153 |
+
for p in self.providers:
|
| 154 |
+
if p.id == provider_id and (not enabled_only or p.enabled):
|
| 155 |
+
return p
|
| 156 |
+
return None
|
| 157 |
+
|
| 158 |
+
def find_default_provider(self) -> Optional[Provider]:
|
| 159 |
+
if self.default_provider_id:
|
| 160 |
+
p = self.find_provider(self.default_provider_id)
|
| 161 |
+
if p:
|
| 162 |
+
return p
|
| 163 |
+
for p in self.providers:
|
| 164 |
+
if p.enabled:
|
| 165 |
+
return p
|
| 166 |
+
return None
|
app/providers/__init__.py
ADDED
|
File without changes
|
app/providers/keypool.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""多密钥轮询:SQLite 持久化计数器,冷启动不重置。
|
| 2 |
+
|
| 3 |
+
替代原内存 Map(RR_STATE)。
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import threading
|
| 8 |
+
|
| 9 |
+
from ..database import get_conn
|
| 10 |
+
from ..models.config import Provider
|
| 11 |
+
from ..errors import HttpError
|
| 12 |
+
|
| 13 |
+
_lock = threading.Lock()
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def choose_provider_api_key(provider: Provider) -> str:
|
| 17 |
+
"""从 provider.api_keys 中按 RR 取一个,空则报错。"""
|
| 18 |
+
keys = list(provider.api_keys or [])
|
| 19 |
+
if not keys:
|
| 20 |
+
raise HttpError(
|
| 21 |
+
f"Provider '{provider.id}' has no api_keys configured",
|
| 22 |
+
status=503,
|
| 23 |
+
code="server_misconfigured",
|
| 24 |
+
)
|
| 25 |
+
if len(keys) == 1:
|
| 26 |
+
return keys[0]
|
| 27 |
+
with _lock:
|
| 28 |
+
with get_conn() as conn:
|
| 29 |
+
row = conn.execute(
|
| 30 |
+
"SELECT counter FROM rr_counter WHERE provider_id = ?",
|
| 31 |
+
(provider.id,),
|
| 32 |
+
).fetchone()
|
| 33 |
+
current = int(row["counter"]) if row else 0
|
| 34 |
+
next_counter = current + 1
|
| 35 |
+
if row:
|
| 36 |
+
conn.execute(
|
| 37 |
+
"UPDATE rr_counter SET counter = ? WHERE provider_id = ?",
|
| 38 |
+
(next_counter, provider.id),
|
| 39 |
+
)
|
| 40 |
+
else:
|
| 41 |
+
conn.execute(
|
| 42 |
+
"INSERT INTO rr_counter(provider_id, counter) VALUES(?, ?)",
|
| 43 |
+
(provider.id, next_counter),
|
| 44 |
+
)
|
| 45 |
+
idx = current % len(keys)
|
| 46 |
+
return keys[idx]
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def reset_counter(provider_id: str) -> None:
|
| 50 |
+
with _lock:
|
| 51 |
+
with get_conn() as conn:
|
| 52 |
+
conn.execute(
|
| 53 |
+
"DELETE FROM rr_counter WHERE provider_id = ?",
|
| 54 |
+
(provider_id,),
|
| 55 |
+
)
|
app/providers/policy.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""模型策略:白名单/黑名单 + 通配符匹配。
|
| 2 |
+
|
| 3 |
+
兼容原 glob 模式:* 匹配任意片段,? 匹配单字符。
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import fnmatch
|
| 8 |
+
import re
|
| 9 |
+
from typing import Iterable
|
| 10 |
+
|
| 11 |
+
from ..models.config import Provider
|
| 12 |
+
from ..errors import HttpError
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _patterns_to_regex(patterns: Iterable[str]) -> list[re.Pattern]:
|
| 16 |
+
out = []
|
| 17 |
+
for p in patterns:
|
| 18 |
+
if not p:
|
| 19 |
+
continue
|
| 20 |
+
# fnmatch 通配符 -> regex
|
| 21 |
+
regex = fnmatch.translate(p)
|
| 22 |
+
out.append(re.compile(regex))
|
| 23 |
+
return out
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _any_match(patterns: Iterable[str], model: str) -> bool:
|
| 27 |
+
for rx in _patterns_to_regex(patterns):
|
| 28 |
+
if rx.fullmatch(model):
|
| 29 |
+
return True
|
| 30 |
+
return False
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def assert_model_allowed(provider: Provider, model: str) -> None:
|
| 34 |
+
"""校验模型是否被允许,否则抛 model_disabled。"""
|
| 35 |
+
if provider.model_policy_mode == "allowlist":
|
| 36 |
+
if provider.allow_models and not _any_match(provider.allow_models, model):
|
| 37 |
+
raise HttpError(
|
| 38 |
+
f"Model '{model}' is not in allowlist of provider '{provider.id}'",
|
| 39 |
+
status=403,
|
| 40 |
+
code="model_disabled",
|
| 41 |
+
)
|
| 42 |
+
else: # denylist
|
| 43 |
+
if provider.deny_models and _any_match(provider.deny_models, model):
|
| 44 |
+
raise HttpError(
|
| 45 |
+
f"Model '{model}' is in denylist of provider '{provider.id}'",
|
| 46 |
+
status=403,
|
| 47 |
+
code="model_disabled",
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def is_model_allowed(provider: Provider, model: str) -> bool:
|
| 52 |
+
try:
|
| 53 |
+
assert_model_allowed(provider, model)
|
| 54 |
+
return True
|
| 55 |
+
except HttpError:
|
| 56 |
+
return False
|
app/providers/resolver.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""厂商解析:按 provider id / modelPrefix / 默认厂商定位。"""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from typing import Optional
|
| 5 |
+
|
| 6 |
+
from ..models.config import AppConfig, Provider
|
| 7 |
+
from ..errors import HttpError
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def resolve_provider(
|
| 11 |
+
config: AppConfig,
|
| 12 |
+
*,
|
| 13 |
+
provider_id: Optional[str] = None,
|
| 14 |
+
model: Optional[str] = None,
|
| 15 |
+
) -> Provider:
|
| 16 |
+
"""按优先级解析厂商:显式 id > modelPrefix 匹配 > 默认厂商。"""
|
| 17 |
+
# 1. 显式 provider_id
|
| 18 |
+
if provider_id:
|
| 19 |
+
p = config.find_provider(provider_id)
|
| 20 |
+
if not p:
|
| 21 |
+
raise HttpError(
|
| 22 |
+
f"Provider not found or disabled: {provider_id}",
|
| 23 |
+
status=404,
|
| 24 |
+
code="not_found",
|
| 25 |
+
)
|
| 26 |
+
return p
|
| 27 |
+
|
| 28 |
+
# 2. modelPrefix 匹配
|
| 29 |
+
if model:
|
| 30 |
+
for p in config.providers:
|
| 31 |
+
if not p.enabled or not p.model_prefix:
|
| 32 |
+
continue
|
| 33 |
+
if model.startswith(p.model_prefix):
|
| 34 |
+
return p
|
| 35 |
+
|
| 36 |
+
# 3. 默认厂商
|
| 37 |
+
default = config.find_default_provider()
|
| 38 |
+
if not default:
|
| 39 |
+
raise HttpError(
|
| 40 |
+
"No enabled provider configured. Add one in /admin.",
|
| 41 |
+
status=503,
|
| 42 |
+
code="service_unavailable",
|
| 43 |
+
)
|
| 44 |
+
return default
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def normalize_model(provider: Provider, model: Optional[str], *, fallback: Optional[str] = None) -> str:
|
| 48 |
+
"""剥离 provider 前缀,回退到默认模型。"""
|
| 49 |
+
if model:
|
| 50 |
+
if provider.model_prefix and model.startswith(provider.model_prefix):
|
| 51 |
+
return model[len(provider.model_prefix):]
|
| 52 |
+
return model
|
| 53 |
+
if fallback:
|
| 54 |
+
return fallback
|
| 55 |
+
if provider.type == "gemini":
|
| 56 |
+
from ..config import GEMINI_DEFAULT_MODEL
|
| 57 |
+
return GEMINI_DEFAULT_MODEL
|
| 58 |
+
return "gpt-4o-mini"
|
app/request_log_middleware.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""请求日志中间件:捕获每个业务请求的完整入参/出参/耗时/错误。
|
| 2 |
+
|
| 3 |
+
记录范围:/v1/* 和 /admin/api/*
|
| 4 |
+
- 流式响应:只记录前 N 个 chunk 摘要 + 总 chunk 数 + 总字节数(避免内存爆炸)
|
| 5 |
+
- 请求体:缓存原始 bytes,供下游读取(FastAPI 的 request.body() 会消费)
|
| 6 |
+
- 敏感标头/key 自动脱敏
|
| 7 |
+
- 失败不影响主流程(记录本身失败也静默)
|
| 8 |
+
|
| 9 |
+
注意:BaseHTTPMiddleware 会消费 request body,需要用 request.state 透传缓存。
|
| 10 |
+
这里采用读 body → 存 request.state.cached_body → 下游通过依赖重新注入的方式。
|
| 11 |
+
但 FastAPI 的 Request.body() 内部有缓存,如果中间件先读了,下游再读会拿到缓存。
|
| 12 |
+
所以直接 await request.body() 即可,下游能拿到相同结果。
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import json
|
| 17 |
+
import time
|
| 18 |
+
from typing import Any, Awaitable, Callable
|
| 19 |
+
|
| 20 |
+
from starlette.middleware.base import BaseHTTPMiddleware
|
| 21 |
+
from starlette.requests import Request
|
| 22 |
+
from starlette.responses import Response, StreamingResponse
|
| 23 |
+
|
| 24 |
+
from .services import request_log_store
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# 记录范围
|
| 28 |
+
_RECORDED_PREFIXES = ("/v1/", "/admin/api/")
|
| 29 |
+
# 排除项(避免日志刷屏:admin HTML 轮询、health)
|
| 30 |
+
_EXCLUDED_PATHS = {"/admin/api/request-log"} # 避免查询日志本身被记录导致递归刷屏
|
| 31 |
+
|
| 32 |
+
# 流式响应采样上限
|
| 33 |
+
_MAX_STREAM_CHUNKS_LOG = 20
|
| 34 |
+
_MAX_STREAM_BYTES_LOG = 8 * 1024
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _should_record(path: str) -> bool:
|
| 38 |
+
if not path:
|
| 39 |
+
return False
|
| 40 |
+
if path in _EXCLUDED_PATHS:
|
| 41 |
+
return False
|
| 42 |
+
return any(path.startswith(p) for p in _RECORDED_PREFIXES)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _extract_access_key(request: Request) -> str:
|
| 46 |
+
auth = request.headers.get("authorization")
|
| 47 |
+
if auth:
|
| 48 |
+
a = auth.strip()
|
| 49 |
+
if a.lower().startswith("bearer "):
|
| 50 |
+
return a[7:].strip()
|
| 51 |
+
if a.lower().startswith("basic "):
|
| 52 |
+
return a[6:].strip()
|
| 53 |
+
return a
|
| 54 |
+
return (
|
| 55 |
+
request.headers.get("x-xtc-access-key")
|
| 56 |
+
or request.headers.get("x-xtc-access-token")
|
| 57 |
+
or ""
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _safe_json_loads(s: str) -> Any:
|
| 62 |
+
try:
|
| 63 |
+
return json.loads(s)
|
| 64 |
+
except Exception:
|
| 65 |
+
return s
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
class RequestLogMiddleware(BaseHTTPMiddleware):
|
| 69 |
+
"""记录业务请求的完整日志。"""
|
| 70 |
+
|
| 71 |
+
async def dispatch(
|
| 72 |
+
self,
|
| 73 |
+
request: Request,
|
| 74 |
+
call_next: Callable[[Request], Awaitable[Response]],
|
| 75 |
+
) -> Response:
|
| 76 |
+
path = request.url.path
|
| 77 |
+
if not _should_record(path):
|
| 78 |
+
return await call_next(request)
|
| 79 |
+
|
| 80 |
+
start = time.time()
|
| 81 |
+
method = request.method
|
| 82 |
+
query = str(request.url.query) if request.url.query else None
|
| 83 |
+
|
| 84 |
+
# 读请求体(缓存到 state,下游可复用)
|
| 85 |
+
request_body_raw: bytes = b""
|
| 86 |
+
try:
|
| 87 |
+
request_body_raw = await request.body()
|
| 88 |
+
except Exception:
|
| 89 |
+
pass
|
| 90 |
+
|
| 91 |
+
# 请求标头脱敏
|
| 92 |
+
req_headers = request_log_store._redact_headers(dict(request.headers))
|
| 93 |
+
|
| 94 |
+
# 请求体脱敏
|
| 95 |
+
req_body_redacted: Any = None
|
| 96 |
+
if request_body_raw:
|
| 97 |
+
ct = (request.headers.get("content-type") or "").lower()
|
| 98 |
+
if "application/json" in ct:
|
| 99 |
+
try:
|
| 100 |
+
req_body_redacted = request_log_store._redact_body(json.loads(request_body_raw))
|
| 101 |
+
except Exception:
|
| 102 |
+
req_body_redacted = request_body_raw.decode("utf-8", "replace")
|
| 103 |
+
elif "multipart/form-data" in ct:
|
| 104 |
+
req_body_redacted = "[multipart form-data]"
|
| 105 |
+
else:
|
| 106 |
+
req_body_redacted = request_body_raw.decode("utf-8", "replace")
|
| 107 |
+
|
| 108 |
+
access_key = _extract_access_key(request)
|
| 109 |
+
client_ip = request.client.host if request.client else None
|
| 110 |
+
user_agent = request.headers.get("user-agent")
|
| 111 |
+
|
| 112 |
+
# 公共日志构造
|
| 113 |
+
def _build_record(status_code, elapsed_ms, ok, provider, model, is_stream,
|
| 114 |
+
error_code, error_message, resp_body, resp_headers):
|
| 115 |
+
return {
|
| 116 |
+
"method": method, "path": path, "query": query,
|
| 117 |
+
"status_code": status_code, "elapsed_ms": elapsed_ms,
|
| 118 |
+
"access_key": access_key, "client_ip": client_ip,
|
| 119 |
+
"user_agent": user_agent,
|
| 120 |
+
"provider": provider, "model": model, "stream": is_stream,
|
| 121 |
+
"ok": ok, "error_code": error_code, "error_message": error_message,
|
| 122 |
+
"request_headers": json.dumps(req_headers, ensure_ascii=False),
|
| 123 |
+
"request_body": json.dumps(req_body_redacted, ensure_ascii=False) if req_body_redacted is not None else None,
|
| 124 |
+
"response_body": json.dumps(resp_body, ensure_ascii=False) if resp_body is not None else None,
|
| 125 |
+
"response_headers": json.dumps(resp_headers, ensure_ascii=False),
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
# 调用下游
|
| 129 |
+
response: Response
|
| 130 |
+
try:
|
| 131 |
+
response = await call_next(request)
|
| 132 |
+
except Exception as e:
|
| 133 |
+
elapsed_ms = int((time.time() - start) * 1000)
|
| 134 |
+
try:
|
| 135 |
+
request_log_store.insert(_build_record(
|
| 136 |
+
500, elapsed_ms, False, None, None, False,
|
| 137 |
+
"internal_error", str(e), None, None,
|
| 138 |
+
))
|
| 139 |
+
except Exception:
|
| 140 |
+
pass
|
| 141 |
+
raise
|
| 142 |
+
|
| 143 |
+
# 从响应头提取业务信息
|
| 144 |
+
provider = response.headers.get("x-xtc-provider")
|
| 145 |
+
model = response.headers.get("x-xtc-model")
|
| 146 |
+
is_stream = response.headers.get("content-type", "").startswith("text/event-stream")
|
| 147 |
+
status_code = response.status_code
|
| 148 |
+
ok = 200 <= status_code < 400
|
| 149 |
+
resp_headers = request_log_store._redact_headers(dict(response.headers))
|
| 150 |
+
|
| 151 |
+
if isinstance(response, StreamingResponse):
|
| 152 |
+
# 流式响应:包装 iterator,在迭代过程中采集 chunk,迭代结束后写日志
|
| 153 |
+
return self._wrap_streaming(
|
| 154 |
+
response, start, _build_record,
|
| 155 |
+
provider, model, is_stream, status_code, ok, resp_headers,
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
# 普通响应:直接读 body
|
| 159 |
+
error_code = None
|
| 160 |
+
error_message = None
|
| 161 |
+
resp_body_str: Any = None
|
| 162 |
+
try:
|
| 163 |
+
body_bytes = b""
|
| 164 |
+
async for chunk in response.body_iterator:
|
| 165 |
+
body_bytes += chunk
|
| 166 |
+
response.body_iterator = _iter_bytes(body_bytes)
|
| 167 |
+
ct = (response.headers.get("content-type") or "").lower()
|
| 168 |
+
if "application/json" in ct:
|
| 169 |
+
try:
|
| 170 |
+
parsed = json.loads(body_bytes)
|
| 171 |
+
resp_body_str = parsed
|
| 172 |
+
if isinstance(parsed, dict) and parsed.get("ok") is False:
|
| 173 |
+
err = parsed.get("error") or {}
|
| 174 |
+
error_code = err.get("code")
|
| 175 |
+
error_message = err.get("message")
|
| 176 |
+
except Exception:
|
| 177 |
+
resp_body_str = body_bytes.decode("utf-8", "replace")
|
| 178 |
+
else:
|
| 179 |
+
resp_body_str = body_bytes.decode("utf-8", "replace")
|
| 180 |
+
if len(resp_body_str) > 4096:
|
| 181 |
+
resp_body_str = resp_body_str[:4096] + "...[truncated]"
|
| 182 |
+
except Exception as e:
|
| 183 |
+
resp_body_str = f"[capture failed: {e}]"
|
| 184 |
+
|
| 185 |
+
elapsed_ms = int((time.time() - start) * 1000)
|
| 186 |
+
try:
|
| 187 |
+
request_log_store.insert(_build_record(
|
| 188 |
+
status_code, elapsed_ms, ok, provider, model, is_stream,
|
| 189 |
+
error_code, error_message, resp_body_str, resp_headers,
|
| 190 |
+
))
|
| 191 |
+
except Exception as e:
|
| 192 |
+
print(f"[request_log] insert failed: {e}")
|
| 193 |
+
|
| 194 |
+
return response
|
| 195 |
+
|
| 196 |
+
def _wrap_streaming(
|
| 197 |
+
self, response: StreamingResponse, start: float, _build_record,
|
| 198 |
+
provider, model, is_stream, status_code, ok, resp_headers,
|
| 199 |
+
) -> StreamingResponse:
|
| 200 |
+
"""包装流式响应:边发送边采集,发送结束后写日志。"""
|
| 201 |
+
original_iterator = response.body_iterator
|
| 202 |
+
chunks_collected: list[str] = []
|
| 203 |
+
total_bytes = 0
|
| 204 |
+
total_chunks = 0
|
| 205 |
+
# 流式错误捕获
|
| 206 |
+
error_code = None
|
| 207 |
+
error_message = None
|
| 208 |
+
|
| 209 |
+
async def wrapped():
|
| 210 |
+
nonlocal total_bytes, total_chunks, error_code, error_message
|
| 211 |
+
try:
|
| 212 |
+
async for chunk in original_iterator:
|
| 213 |
+
if isinstance(chunk, str):
|
| 214 |
+
chunk_bytes = chunk.encode("utf-8")
|
| 215 |
+
else:
|
| 216 |
+
chunk_bytes = chunk
|
| 217 |
+
total_bytes += len(chunk_bytes)
|
| 218 |
+
total_chunks += 1
|
| 219 |
+
# 采集前 N 个 chunk
|
| 220 |
+
if total_chunks <= _MAX_STREAM_CHUNKS_LOG:
|
| 221 |
+
text = chunk_bytes.decode("utf-8", "replace")
|
| 222 |
+
chunks_collected.append(text)
|
| 223 |
+
# 尝试从 SSE data 中提取错误
|
| 224 |
+
if not ok and text.startswith("data:"):
|
| 225 |
+
payload = text[5:].strip()
|
| 226 |
+
if payload and payload != "[DONE]":
|
| 227 |
+
try:
|
| 228 |
+
obj = json.loads(payload)
|
| 229 |
+
if isinstance(obj, dict) and obj.get("error"):
|
| 230 |
+
err = obj["error"]
|
| 231 |
+
error_code = err.get("code") if isinstance(err, dict) else None
|
| 232 |
+
error_message = err.get("message") if isinstance(err, dict) else str(err)
|
| 233 |
+
except Exception:
|
| 234 |
+
pass
|
| 235 |
+
yield chunk
|
| 236 |
+
finally:
|
| 237 |
+
# 流结束后写日志
|
| 238 |
+
elapsed_ms = int((time.time() - start) * 1000)
|
| 239 |
+
summary = {
|
| 240 |
+
"type": "streaming",
|
| 241 |
+
"total_chunks": total_chunks,
|
| 242 |
+
"total_bytes": total_bytes,
|
| 243 |
+
"sampled_chunks": chunks_collected,
|
| 244 |
+
}
|
| 245 |
+
try:
|
| 246 |
+
request_log_store.insert(_build_record(
|
| 247 |
+
status_code, elapsed_ms, ok, provider, model, is_stream,
|
| 248 |
+
error_code, error_message, summary, resp_headers,
|
| 249 |
+
))
|
| 250 |
+
except Exception as e:
|
| 251 |
+
print(f"[request_log] streaming insert failed: {e}")
|
| 252 |
+
|
| 253 |
+
response.body_iterator = wrapped()
|
| 254 |
+
return response
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
async def _iter_bytes(data: bytes):
|
| 258 |
+
yield data
|
app/services/__init__.py
ADDED
|
File without changes
|
app/services/config_store.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""配置存储:SQLite kv 表(热)+ HF Hub dataset 仓库(配置备份)。
|
| 2 |
+
|
| 3 |
+
简化点:相比原 Netlify Blobs,配置不再加密。
|
| 4 |
+
KV key:providers / default_provider_id / updated_at 合并为单一 JSON 存储于 kv 表 key=app_config。
|
| 5 |
+
|
| 6 |
+
混合方案(仅配置文件走 Hub,其余数据纯本地):
|
| 7 |
+
- SQLite 是热存储,读写性能最佳
|
| 8 |
+
- HF Hub dataset 仓库做配置备份,解决 HF Spaces 免费档磁盘 ephemeral(restart/stop 丢数据)问题
|
| 9 |
+
- 启动时 DB 优先 → Hub 拉取 → env 种子,三层回退
|
| 10 |
+
- 保存时写 DB + 异步推 Hub(Hub 失败不影响主流程)
|
| 11 |
+
- 未配置 HF_TOKEN / HF_CONFIG_REPO 时自动降级为纯本地
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import asyncio
|
| 16 |
+
import json
|
| 17 |
+
import time
|
| 18 |
+
from datetime import datetime, timezone
|
| 19 |
+
from typing import Optional
|
| 20 |
+
|
| 21 |
+
from ..config import get_settings
|
| 22 |
+
from ..database import get_conn
|
| 23 |
+
from ..hf_storage import NS_CONFIG, download_bytes, push_to_hub, upload_bytes
|
| 24 |
+
from ..models.config import AppConfig, Provider
|
| 25 |
+
|
| 26 |
+
KV_APP_CONFIG_KEY = "app_config"
|
| 27 |
+
_HUB_CONFIG_KEY = "app_config.json"
|
| 28 |
+
|
| 29 |
+
_lock = asyncio.Lock()
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _now_ts() -> int:
|
| 33 |
+
return int(time.time())
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _load_from_db() -> AppConfig:
|
| 37 |
+
with get_conn() as conn:
|
| 38 |
+
row = conn.execute(
|
| 39 |
+
"SELECT value FROM kv WHERE key = ?", (KV_APP_CONFIG_KEY,)
|
| 40 |
+
).fetchone()
|
| 41 |
+
if not row:
|
| 42 |
+
return AppConfig(providers=[], default_provider_id=None)
|
| 43 |
+
try:
|
| 44 |
+
data = json.loads(row["value"])
|
| 45 |
+
return AppConfig.model_validate(data)
|
| 46 |
+
except Exception as e:
|
| 47 |
+
print(f"[config_store] failed to parse stored config: {e}")
|
| 48 |
+
return AppConfig(providers=[], default_provider_id=None)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _save_to_db(config: AppConfig) -> None:
|
| 52 |
+
payload = json.dumps(config.model_dump(mode="json"), ensure_ascii=False)
|
| 53 |
+
ts = _now_ts()
|
| 54 |
+
with get_conn() as conn:
|
| 55 |
+
conn.execute(
|
| 56 |
+
"INSERT INTO kv(key, value, updated_at) VALUES(?,?,?) "
|
| 57 |
+
"ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at",
|
| 58 |
+
(KV_APP_CONFIG_KEY, payload, ts),
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _seed_from_env() -> Optional[AppConfig]:
|
| 63 |
+
"""首次启动时用环境变量种子生成初始配置。"""
|
| 64 |
+
settings = get_settings()
|
| 65 |
+
providers: list[Provider] = []
|
| 66 |
+
if settings.gemini_api_keys:
|
| 67 |
+
providers.append(
|
| 68 |
+
Provider(
|
| 69 |
+
id="gemini",
|
| 70 |
+
name="Gemini",
|
| 71 |
+
type="gemini",
|
| 72 |
+
api_keys=list(settings.gemini_api_keys),
|
| 73 |
+
base_url=None,
|
| 74 |
+
)
|
| 75 |
+
)
|
| 76 |
+
if settings.openai_api_keys:
|
| 77 |
+
providers.append(
|
| 78 |
+
Provider(
|
| 79 |
+
id="openai",
|
| 80 |
+
name="OpenAI",
|
| 81 |
+
type="openai",
|
| 82 |
+
api_keys=list(settings.openai_api_keys),
|
| 83 |
+
base_url=settings.openai_base_url,
|
| 84 |
+
)
|
| 85 |
+
)
|
| 86 |
+
if not providers:
|
| 87 |
+
return None
|
| 88 |
+
return AppConfig(
|
| 89 |
+
providers=providers,
|
| 90 |
+
default_provider_id=providers[0].id,
|
| 91 |
+
updated_at=datetime.now(timezone.utc).isoformat(),
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
async def load_config() -> AppConfig:
|
| 96 |
+
"""加载配置(DB 优先 → HF Hub 拉取 → env 种子)。"""
|
| 97 |
+
async with _lock:
|
| 98 |
+
cfg = _load_from_db()
|
| 99 |
+
if cfg.providers:
|
| 100 |
+
return cfg.normalize()
|
| 101 |
+
|
| 102 |
+
# 尝试从 HF Hub 拉取配置备份
|
| 103 |
+
try:
|
| 104 |
+
hub_cfg = await _load_from_hub()
|
| 105 |
+
if hub_cfg and hub_cfg.providers:
|
| 106 |
+
_save_to_db(hub_cfg)
|
| 107 |
+
return hub_cfg.normalize()
|
| 108 |
+
except Exception as e:
|
| 109 |
+
print(f"[config_store] HF Hub load failed: {e}")
|
| 110 |
+
|
| 111 |
+
# 用 env 种子
|
| 112 |
+
seeded = _seed_from_env()
|
| 113 |
+
if seeded:
|
| 114 |
+
_save_to_db(seeded)
|
| 115 |
+
try:
|
| 116 |
+
await _push_to_hub(seeded)
|
| 117 |
+
except Exception as e:
|
| 118 |
+
print(f"[config_store] HF Hub push failed: {e}")
|
| 119 |
+
return seeded.normalize()
|
| 120 |
+
|
| 121 |
+
return AppConfig(providers=[], default_provider_id=None).normalize()
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
async def save_config(config: AppConfig) -> AppConfig:
|
| 125 |
+
"""保存配置:写 DB + 异步推 HF Hub + Webhook 通知。"""
|
| 126 |
+
config.normalize()
|
| 127 |
+
async with _lock:
|
| 128 |
+
_save_to_db(config)
|
| 129 |
+
# HF Hub 同步失败不影响主流程
|
| 130 |
+
try:
|
| 131 |
+
await _push_to_hub(config)
|
| 132 |
+
except Exception as e:
|
| 133 |
+
print(f"[config_store] HF Hub push failed: {e}")
|
| 134 |
+
# Webhook 通知
|
| 135 |
+
try:
|
| 136 |
+
from ..services import webhook_store
|
| 137 |
+
await webhook_store.notify(
|
| 138 |
+
"config.updated",
|
| 139 |
+
{
|
| 140 |
+
"providers": [p.id for p in config.providers],
|
| 141 |
+
"default_provider_id": config.default_provider_id,
|
| 142 |
+
"updated_at": config.updated_at,
|
| 143 |
+
},
|
| 144 |
+
)
|
| 145 |
+
except Exception:
|
| 146 |
+
pass
|
| 147 |
+
return config
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def load_config_sync() -> AppConfig:
|
| 151 |
+
"""同步加载(用于非 async 上下文,如启动钩子)。"""
|
| 152 |
+
cfg = _load_from_db()
|
| 153 |
+
if cfg.providers:
|
| 154 |
+
return cfg.normalize()
|
| 155 |
+
seeded = _seed_from_env()
|
| 156 |
+
if seeded:
|
| 157 |
+
_save_to_db(seeded)
|
| 158 |
+
return seeded.normalize()
|
| 159 |
+
return AppConfig(providers=[], default_provider_id=None).normalize()
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
# ===== HF Hub 配置备份同步 =====
|
| 163 |
+
|
| 164 |
+
async def _load_from_hub() -> Optional[AppConfig]:
|
| 165 |
+
"""从 HF Hub dataset 仓库(config/ 命名空间)拉取配置 JSON。"""
|
| 166 |
+
def _fetch() -> Optional[dict]:
|
| 167 |
+
data = download_bytes(NS_CONFIG, _HUB_CONFIG_KEY)
|
| 168 |
+
if not data:
|
| 169 |
+
return None
|
| 170 |
+
try:
|
| 171 |
+
return json.loads(data.decode("utf-8"))
|
| 172 |
+
except Exception as e:
|
| 173 |
+
print(f"[config_store] parse hub config failed: {e}")
|
| 174 |
+
return None
|
| 175 |
+
|
| 176 |
+
data = await asyncio.to_thread(_fetch)
|
| 177 |
+
if not data:
|
| 178 |
+
return None
|
| 179 |
+
try:
|
| 180 |
+
return AppConfig.model_validate(data)
|
| 181 |
+
except Exception as e:
|
| 182 |
+
print(f"[config_store] hub config invalid: {e}")
|
| 183 |
+
return None
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
async def _push_to_hub(config: AppConfig) -> None:
|
| 187 |
+
"""把配置 JSON 推到 HF Hub dataset 仓库。"""
|
| 188 |
+
payload = json.dumps(config.model_dump(mode="json"), ensure_ascii=False, indent=2).encode("utf-8")
|
| 189 |
+
|
| 190 |
+
def _upload() -> None:
|
| 191 |
+
upload_bytes(NS_CONFIG, _HUB_CONFIG_KEY, payload)
|
| 192 |
+
|
| 193 |
+
await asyncio.to_thread(_upload)
|
| 194 |
+
# 异步推到 Hub 远端(push_to_hub 内部会处理失败与降级)
|
| 195 |
+
await push_to_hub(NS_CONFIG, _HUB_CONFIG_KEY)
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
# ===== 厂商增删改查辅助 =====
|
| 199 |
+
|
| 200 |
+
async def add_provider(provider: Provider) -> AppConfig:
|
| 201 |
+
cfg = await load_config()
|
| 202 |
+
cfg.providers = [p for p in cfg.providers if p.id != provider.id]
|
| 203 |
+
cfg.providers.append(provider)
|
| 204 |
+
return await save_config(cfg)
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
async def update_provider(provider_id: str, update: dict) -> AppConfig:
|
| 208 |
+
cfg = await load_config()
|
| 209 |
+
for i, p in enumerate(cfg.providers):
|
| 210 |
+
if p.id == provider_id:
|
| 211 |
+
merged = {**p.model_dump(), **update}
|
| 212 |
+
cfg.providers[i] = Provider.model_validate(merged)
|
| 213 |
+
break
|
| 214 |
+
return await save_config(cfg)
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
async def remove_provider(provider_id: str) -> AppConfig:
|
| 218 |
+
cfg = await load_config()
|
| 219 |
+
cfg.providers = [p for p in cfg.providers if p.id != provider_id]
|
| 220 |
+
if cfg.default_provider_id == provider_id:
|
| 221 |
+
cfg.default_provider_id = None
|
| 222 |
+
return await save_config(cfg)
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
async def set_default_provider(provider_id: str) -> AppConfig:
|
| 226 |
+
cfg = await load_config()
|
| 227 |
+
if not any(p.id == provider_id for p in cfg.providers):
|
| 228 |
+
raise ValueError(f"provider not found: {provider_id}")
|
| 229 |
+
cfg.default_provider_id = provider_id
|
| 230 |
+
return await save_config(cfg)
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
async def set_provider_settings(
|
| 234 |
+
provider_id: str,
|
| 235 |
+
*,
|
| 236 |
+
enabled: Optional[bool] = None,
|
| 237 |
+
model_policy_mode: Optional[str] = None,
|
| 238 |
+
allow_models: Optional[list[str]] = None,
|
| 239 |
+
deny_models: Optional[list[str]] = None,
|
| 240 |
+
) -> AppConfig:
|
| 241 |
+
cfg = await load_config()
|
| 242 |
+
for i, p in enumerate(cfg.providers):
|
| 243 |
+
if p.id == provider_id:
|
| 244 |
+
data = p.model_dump()
|
| 245 |
+
if enabled is not None:
|
| 246 |
+
data["enabled"] = enabled
|
| 247 |
+
if model_policy_mode is not None:
|
| 248 |
+
data["model_policy_mode"] = model_policy_mode
|
| 249 |
+
if allow_models is not None:
|
| 250 |
+
data["allow_models"] = list(allow_models)
|
| 251 |
+
if deny_models is not None:
|
| 252 |
+
data["deny_models"] = list(deny_models)
|
| 253 |
+
cfg.providers[i] = Provider.model_validate(data)
|
| 254 |
+
break
|
| 255 |
+
return await save_config(cfg)
|