a3216 commited on
Commit
36fae79
·
0 Parent(s):

deploy: initial release to Hugging Face Spaces

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
.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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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)