diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..f7fc51960e37e2c7621b33b8877313e7167e7ed3 --- /dev/null +++ b/.env.example @@ -0,0 +1,36 @@ +# ===== 鉴权 ===== +# 后台管理密钥(访问 /admin 时用于登录) +XTC_ADMIN_KEY=change-me-admin-key +# 前端长期访问密钥(手表端请求时附带) +XTC_ACCESS_KEY=change-me-access-key +# JWT 签名密钥(临时令牌签发,必须设置) +XTC_JWT_SECRET=change-me-jwt-secret-strong-random + +# ===== 紧急开关 ===== +# 紧急关闭后台与 /admin/api/*(默认 false) +XTC_DISABLE_ADMIN=false + +# ===== 上游初始化(仅做种子,其余厂商在后台配)===== +# 可留空,启动后用 /admin 添加 +GEMINI_API_KEY= +OPENAI_API_KEY= + +# ===== 上游超时 ===== +# 单一超时配置,无 Edge 分支 +UPSTREAM_TIMEOUT_MS=120000 + +# ===== HF Hub 配置备份(可选)===== +# 配置文件通过 HF Hub dataset 仓库做持久化备份 +# 解决 HF Spaces 免费档磁盘 ephemeral(restart/stop 丢数据)问题 +# 未配置时降级为纯本地存储,功能正常但重建后配置会丢失 +# HF_TOKEN:访问 HF Hub 仓库的令牌(read/write 权限) +# HF_CONFIG_REPO:仓库名称,格式 用户名/仓库名(如 myname/xtc-config) +HF_TOKEN= +HF_CONFIG_REPO= + +# ===== 临时令牌 ===== +XTC_ACCESS_TOKEN_TTL_SEC=1800 +XTC_ACCESS_TOKEN_LENGTH=8 + +# ===== 运行 ===== +PORT=7860 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..39845e43bc0a651509cf196879877cb5bd0529c2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +.venv/ +venv/ +env/ +ENV/ + +# 测试与缓存 +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +*.egg-info/ + +# 环境变量(不提交真实密钥) +.env +.env.local +.env.*.local + +# 数据库与运行时 +*.db +*.db-journal +*.db-wal +*.db-shm +/data/ + +# IDE +.vscode/ +.idea/ +*.swp + +# OS +.DS_Store +Thumbs.db diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000000000000000000000000000000000000..8d990de4914c6bc5b0d39105014fc7062c9af9ca --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,377 @@ +# 部署文档(Hugging Face Spaces) + +本文档说明如何把 XTC 后端部署到 Hugging Face Spaces,采用**混合存储方案**:本地 SQLite 持久卷(热存储)+ HF Hub dataset 仓库(配置备份)。 + +> **存储方案说明**:本项目采用混合方案—— +> - **主体数据**(令牌、会话、请求历史、用量、审计、速率限制、Webhook)存在 HF Spaces 本地的 `/data/xtc.db`(SQLite) +> - **配置文件**(providers / 默认厂商)额外同步到 HF Hub dataset 仓库做备份 +> +> **为什么配置要走 Hub?** HF Spaces **免费档磁盘是 ephemeral(临时)**,restart/stop 会清空 `/data`,导致配置丢失。把配置 JSON 备份到 HF Hub dataset 仓库(随账号持久保存),重建后能自动拉取恢复。其余数据(会话/日志等)反正不重启也无所谓,纯本地即可。 +> +> **付费档用户**($5/月 Small 持久存储):`/data` 永久保留,可不配置 Hub 备份(但配了也无妨,多一层保险)。 + +--- + +## 一、整体架构 + +``` +手表端 / 前端 + │ HTTPS + ▼ +Cloudflare Workers(反向代理,可选) + │ 解决:免费域名 / 国内访问 / 48h 休眠保活 + ▼ +Hugging Face Spaces(Docker 类型) + │ 运行 FastAPI + uvicorn + ▼ +/data/xtc.db(SQLite 持久卷) + │ + ├─ config 配置(providers / 默认厂商)──┐ 备份 + ├─ access_tokens 临时令牌 │ + ├─ sessions 会话历史 │ + ├─ session_messages 会话消息 │ + ├─ request_log 请求历史(最近 N 条) │ 仅配置 + ├─ usage_log 用量统计 │ 走 Hub + ├─ audit_log 审计日志 │ + ├─ rate_limit 速率限制 │ + └─ webhook_config Webhook 配置 │ + ▼ + HF Hub dataset 仓库(config/app_config.json) + 解决免费档 ephemeral 问题,重建后自动恢复配置 +``` + +**为什么仅配置走 Hub,其余纯本地?** +- 单实例部署,Hub 的"多实例同步"优势用不上,避免无谓的网络开销 +- 本地 SQLite 读写比网络 API 快几个数量级 +- 配置是唯一"丢了很麻烦"的数据(要重新填所有 API Key),其余数据(日志/会话)重建丢失可接受 +- 图片/文件前端会重传,不需要后端长期存储 +- 复杂度可控:只在 `config_store` 一处接入 Hub,其余模块无感知 + +**持久性保证**: +- HF Spaces 免费档:restart/stop 清空 `/data`,但配置已备份到 Hub,重建后自动恢复 +- HF Spaces 付费档($5/月):`/data` 永久保留,所有数据都不丢 +- 删除整个 Space:`/data` 丢失,但 Hub 仓库里的配置备份仍在 + +--- + +## 二、前置准备 + +### 1. 注册 Hugging Face 账号 +访问 https://huggingface.co/join 注册(免费)。 + +### 2. 准备代码仓库 +本项目代码在 GitHub 仓库 `luckfun233/AI` 的 `xtc-backend-hf/` 目录。HF Spaces 支持两种方式同步代码: +- **方式 A(推荐)**:HF Space 直接从 GitHub 同步(每次 push 自动重建) +- **方式 B**:手动把代码推到 HF Space 的 Git 仓库 + +--- + +## 三、创建 HF Space + +### 1. 新建 Space +1. 访问 https://huggingface.co/new-space +2. 填写: + - **Space name**:`xtc-backend`(或任意名) + - **License**:随意 + - **SDK**:选择 **Docker** + - **Space hardware**:**CPU basic (Free)**(免费档够用) + - **Visibility**:**Private**(强烈建议私有,避免后台暴露) +3. 点击 **Create Space** + +### 2. 绑定持久化存储(可选,付费档) +**免费档磁盘是 ephemeral(临时)**,restart/stop 会清空 `/data`。本项目通过 HF Hub 仓库备份配置来规避此问题(见下一步),所以**免费档也能用**。 + +如需所有数据(会话/日志等)都持久保留,可开通付费持久存储: + +1. 进入刚创建的 Space +2. 点击顶部 **Settings** 标签 +3. 找到 **Persistent storage** 区域 +4. 选择容量(**20 GB Small - $5/month**,最小档够用) +5. 点击 **Attach** 并完成支付 + +> **免费档 vs 付费档**: +> - **免费档**:配置靠 Hub 备份(不丢),其余数据 restart/stop 后清空(可接受) +> - **付费档**:所有数据永久保留,Hub 备份作为额外保险 + +### 3. 创建 HF Hub 配置备份仓库(免费档强烈建议) +用于备份配置文件,解决免费档磁盘 ephemeral 问题: + +1. 访问 https://huggingface.co/new-dataset +2. 填写: + - **Dataset name**:`xtc-config`(或任意名) + - **License**:随意 + - **Visibility**:**Private**(强烈建议私有,配置含 API Key) +3. 点击 **Create dataset** + +记录仓库名称,格式为 `用户名/xtc-config`(如 `luckfun233/xtc-config`),后面配置 `HF_CONFIG_REPO` 要用。 + +### 4. 创建 HF Access Token +用于后端读写配置备份仓库: + +1. 访问 https://huggingface.co/settings/tokens +2. 点击 **New token** +3. 填写 Name(如 `xtc-backend`),Type 选 **Write**(需要写权限推送配置) +4. 复制 token(以 `hf_` 开头),后面配置 `HF_TOKEN` 要用 + +> **权限说明**:token 需要对上一步创建的 dataset 仓库有 write 权限。Space 和 dataset 在同一账号下时,Write token 自动有权限。 + +### 5. 同步代码 +**方式 A:从 GitHub 同步(推荐)** +1. Space Settings → **Repositories** → **Connect a GitHub repository** +2. 选择 `luckfun233/AI` 仓库 +3. **Root directory** 填写:`xtc-backend-hf` +4. 保存后,每次 GitHub push 自动触发 HF 重建 + +**方式 B:手动推送** +```bash +# 在 HF 账号设置里创建 Access Token:https://huggingface.co/settings/tokens +# 权限选 Write,复制 token + +cd xtc-backend-hf +git init +git remote add hf https://<你的HF用户名>:@huggingface.co/spaces/<你的HF用户名>/xtc-backend +git add . +git commit -m "init" +git push hf main +``` + +--- + +## 四、配置环境变量 + +**这是最关键的一步**。在 Space Settings → **Variables and secrets** 中添加以下环境变量: + +### 必填(不填无法正常运行) + +| 变量名 | 说明 | 示例值 | +|---|---|---| +| `XTC_ADMIN_KEY` | 后台管理密钥,访问 `/admin` 登录用 | `my-super-admin-key-2026` | +| `XTC_ACCESS_KEY` | 前端/手表端长期访问密钥 | `my-access-key-for-watch` | +| `XTC_JWT_SECRET` | JWT 签名密钥(临时令牌),**必须随机长字符串** | `a1b2c3d4e5...(32位以上随机)` | + +> **生成随机密钥**:在本地终端运行 `openssl rand -hex 32` 生成 `XTC_JWT_SECRET`。 + +### 选填(有默认值,通常不用改) + +| 变量名 | 默认值 | 说明 | +|---|---|---| +| `PORT` | `7860` | 服务端口(HF Spaces 固定 7860,不要改) | +| `XTC_DB_PATH` | `/data/xtc.db` | SQLite 数据库路径(持久卷路径,不要改) | +| `XTC_DISABLE_ADMIN` | `false` | 紧急关闭后台(设 `true` 则 `/admin` 不可用) | +| `UPSTREAM_TIMEOUT_MS` | `120000` | 上游 API 超时(毫秒) | +| `XTC_ACCESS_TOKEN_TTL_SEC` | `1800` | 临时令牌有效期(秒,60-86400) | +| `XTC_ACCESS_TOKEN_LENGTH` | `8` | 临时令牌长度(6-24) | + +### 选填(种子厂商,也可启动后在后台配) + +| 变量名 | 说明 | +|---|---| +| `GEMINI_API_KEY` | Gemini API 密钥(可填多个,逗号分隔) | +| `OPENAI_API_KEY` | OpenAI 兼容厂商的 API 密钥 | +| `OPENAI_BASE_URL` | OpenAI 兼容厂商的 base URL(默认 `https://api.openai.com`) | + +> **建议**:`GEMINI_API_KEY` / `OPENAI_API_KEY` 留空,启动后用 `/admin` 后台添加厂商,这样密钥存在 SQLite 而非环境变量里,方便随时增删轮询。 + +### 选填(HF Hub 配置备份,免费档强烈建议) + +| 变量名 | 说明 | 示例值 | +|---|---|---| +| `HF_TOKEN` | HF Hub 访问令牌(write 权限,见第三节第 4 步) | `hf_xxxxxxxxxxxxxxxxxxxx` | +| `HF_CONFIG_REPO` | 配置备份 dataset 仓库名(见第三节第 3 步) | `luckfun233/xtc-config` | + +> **配置后才生效**:两个变量都填了才会启用 Hub 配置备份。只填一个不生效。 +> **降级行为**:未配置时,后端纯本地运行(功能正常),但 restart/stop 后配置会丢失,需重新在后台添加厂商。 +> **付费档用户**:`/data` 已持久,可不配(但配了多一层保险,删 Space 重建也能恢复配置)。 + +### 配置方式 + +在 Space Settings → **Variables and secrets**: + +- **Variables**:普通环境变量(明文存储,如 `PORT`、`XTC_DB_PATH`、`HF_CONFIG_REPO`) +- **Secrets**:敏感信息(加密存储,推荐用于 `XTC_ADMIN_KEY`、`XTC_ACCESS_KEY`、`XTC_JWT_SECRET`、`HF_TOKEN`、`GEMINI_API_KEY`) + +--- + +## 五、首次启动验证 + +### 1. 等待构建 +Space 创建后会自动拉取代码、构建 Docker 镜像。在 Space 主页能看到构建日志,看到 `Running on http://0.0.0.0:7860` 即构建成功。 + +### 2. 健康检查 +访问 `https://<你的用户名>-xtc-backend.hf.space/health`,应返回: +```json +{"ok": true, "service": "xtc-backend-hf", "version": "..."} +``` + +### 3. 登录后台 +访问 `https://<你的用户名>-xtc-backend.hf.space/admin`,输入 `XTC_ADMIN_KEY` 登录。 + +### 4. 添加厂商 +在后台 **厂商** Tab: +1. 点击"添加厂商" +2. 填写: + - **ID**:`gemini`(或任意唯一标识) + - **名称**:`Gemini` + - **类型**:`gemini` + - **API Keys**:填入你的 Gemini API Key(每行一个,支持多密钥轮询) +3. 保存 +4. 设为默认厂商 + +### 5. 测试对话 +在后台 **测试** Tab,输入消息测试是否正常返回。 + +### 6. 测试 API +```bash +curl -X POST https://<你的用户名>-xtc-backend.hf.space/v1/chat/completions \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gemini-flash-latest", + "messages": [{"role": "user", "content": "你好"}] + }' +``` + +--- + +## 六、Cloudflare Workers 反向代理(可选但推荐) + +解决三个问题: +1. **免费域名**:HF Spaces 默认域名 `*.hf.space` 在国内可能无法访问 +2. **国内访问**:CF Workers 节点在国内可达 +3. **48h 休眠保活**:HF 免费档 48h 无请求会休眠,CF Workers 定时器保活 + +### 1. 创建 Worker +在 Cloudflare Dashboard → Workers & Pages → Create Worker: +```javascript +export default { + async fetch(request) { + const url = new URL(request.url); + url.hostname = '<你的用户名>-xtc-backend.hf.space'; + return fetch(url, request); + }, + // 每 24 小时保活一次 + async scheduled(event, env, ctx) { + ctx.waitUntil(fetch('https://<你的用户名>-xtc-backend.hf.space/health')); + } +}; +``` + +### 2. 配置定时器 +Worker Settings → **Triggers** → **Cron Triggers**: +``` +0 */12 * * * +``` +(每 12 小时触发一次,保活) + +### 3. 绑定自定义域名(可选) +Worker → Settings → **Triggers** → **Custom Domains**,添加你自己的域名。 + +--- + +## 七、数据备份与恢复 + +### 自动备份(配置文件) +配置了 `HF_TOKEN` + `HF_CONFIG_REPO` 后,每次在后台修改厂商配置都会**自动**把配置 JSON 推送到 HF Hub dataset 仓库的 `config/app_config.json`。无需手动操作。 + +### 手动备份 +后台 → **概览** Tab 有"导出配置"按钮,可下载 `app_config.json`(含所有厂商配置)到本地。 + +如需备份完整数据库(含会话/日志),需通过 SSH/终端方式(HF Spaces 付费档支持)复制 `/data/xtc.db`。 + +### 恢复 +- **免费档 restart/stop 后**:`/data` 被清空,但启动时后端会自动从 Hub 拉取 `config/app_config.json` 恢复配置(需已配置 `HF_TOKEN` + `HF_CONFIG_REPO`)。其余数据(会话/日志)丢失。 +- **付费档重建后**:`/data` 保留,所有数据自动恢复,无需操作。 +- **删除 Space 重建**:先配置环境变量(含 `HF_TOKEN` + `HF_CONFIG_REPO`),启动后配置自动从 Hub 恢复;或登录后台 → 厂商 → 导入手动导出的配置 JSON。 + +--- + +## 八、常见问题 + +### Q1: 重建 Space 后配置丢失? +**原因**:免费档磁盘是 ephemeral,restart/stop 会清空 `/data`;且未配置 HF Hub 配置备份。 +**解决**: +1. 配置 `HF_TOKEN` + `HF_CONFIG_REPO` 环境变量(见第三节第 3-4 步),启用配置自动备份 +2. 启动时后端会自动从 Hub 拉取配置恢复 +3. 或开通付费持久存储($5/月),`/data` 永久保留 + +### Q2: 后台登录提示 "invalid admin key"? +**原因**:`XTC_ADMIN_KEY` 环境变量未设置或拼写错误。 +**解决**:Space Settings → Variables and secrets 检查 `XTC_ADMIN_KEY`,注意 Secrets 修改后需要重启 Space。 + +### Q3: 手表端请求返回 401? +**原因**:`XTC_ACCESS_KEY` 不匹配,或请求头格式错误。 +**解决**: +- 请求头格式:`Authorization: Bearer ` +- 检查环境变量值是否前后有空格 + +### Q4: Space 休眠了怎么办? +**原因**:HF 免费档 48h 无请求自动休眠。 +**解决**: +- 配置 Cloudflare Workers 定时器(见第六节) +- 或用外部监控服务(如 UptimeRobot)每 5 分钟 ping `/health` + +### Q5: 怎么查看请求日志? +后台 → **请求历史** Tab,默认保留最近 500 条完整请求(含标头、body、响应、耗时、错误码)。可在该 Tab 调整保留条数(0-2000)。 + +### Q6: 怎么添加多个 API Key 做轮询? +后台 → **厂商** → 编辑厂商 → **API Keys** 字段,每行填一个 key,自动轮询。 + +### Q7: 国内访问慢/连不上? +1. 用 Cloudflare Workers 反向代理(见第六节) +2. 或绑定自定义域名走 CF CDN + +--- + +## 九、环境变量完整清单 + +```bash +# ===== 必填 ===== +XTC_ADMIN_KEY=<你的后台管理密钥> +XTC_ACCESS_KEY=<前端访问密钥> +XTC_JWT_SECRET=<32位以上随机字符串> + +# ===== 默认值(通常不用改)===== +PORT=7860 +XTC_DB_PATH=/data/xtc.db +XTC_DISABLE_ADMIN=false +UPSTREAM_TIMEOUT_MS=120000 +XTC_ACCESS_TOKEN_TTL_SEC=1800 +XTC_ACCESS_TOKEN_LENGTH=8 + +# ===== 种子厂商(可选,留空用后台配)===== +GEMINI_API_KEY= +OPENAI_API_KEY= +OPENAI_BASE_URL=https://api.openai.com + +# ===== HF Hub 配置备份(免费档强烈建议)===== +HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxx +HF_CONFIG_REPO=你的用户名/xtc-config +``` + +--- + +## 十、文件结构说明 + +部署后 Space 内的关键文件: + +``` +/data/ # 本地存储卷(免费档 ephemeral,付费档持久) +└── xtc.db # SQLite 数据库(所有数据,配置额外备份到 Hub) + +/app/ # 应用代码(每次重建从 Git 拉取) +├── Dockerfile +├── requirements.txt +└── app/ + ├── main.py # FastAPI 入口 + ├── config.py # 环境变量配置 + ├── database.py # SQLite 初始化 + ├── hf_storage.py # HF Hub 配置备份抽象层 + ├── auth.py # 鉴权(admin key + access key + JWT) + ├── middleware.py # 速率限制中间件 + ├── request_log_middleware.py # 请求日志中间件 + ├── errors.py # 统一错误模型 + ├── admin_html.py # 后台 HTML + ├── adapters/ # Gemini/OpenAI 适配器 + ├── services/ # 业务逻辑层(config_store 接入 Hub 备份) + └── api/ # API 路由 +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..ca186bc491565307fb6fcc9a4ef08997defb3249 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM python:3.11-slim + +WORKDIR /app + +# 系统依赖:libheif 用于 HEIC/HEIF 解码(图片修正增强) +RUN apt-get update && apt-get install -y --no-install-recommends \ + libheif1 libheif-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt \ + && pip install --no-cache-dir pillow-heif + +COPY . . + +ENV PORT=7860 +ENV PYTHONUNBUFFERED=1 +ENV XTC_DB_PATH=/data/xtc.db +EXPOSE 7860 + +# 持久化目录(HF Spaces 自动挂载 /data;本地 docker run 时可 -v 挂载) +RUN mkdir -p /data +VOLUME ["/data"] + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"] diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..498b49382fd856835c651ab4119d9f2cb162ccc3 --- /dev/null +++ b/README.md @@ -0,0 +1,256 @@ +--- +title: XTC Backend +emoji: 🚀 +colorFrom: gray +colorTo: indigo +sdk: docker +app_port: 7860 +pinned: false +--- + +# XTC Backend (Hugging Face 版) + +从 Netlify 版迁移而来的 OpenAI / Gemini 兼容网关,部署到 Hugging Face Spaces。 + +> **重要**:本目录是迁移重写版,原 Netlify 后端代码保留在 `../openai-gemini-main-master/` 作为备用,未被覆盖。 + +## 目录结构 + +``` +xtc-backend-hf/ +├── Dockerfile # HF Spaces Docker 镜像 +├── requirements.txt # Python 依赖 +├── .env.example # 环境变量样例 +├── app/ +│ ├── main.py # FastAPI 应用装配 +│ ├── config.py # 环境变量配置(pydantic-settings) +│ ├── errors.py # 统一错误模型(兼容前端契约) +│ ├── auth.py # access_key + JWT 临时令牌 +│ ├── database.py # SQLite 初始化 +│ ├── cache.py # 模型列表 TTL 缓存 +│ ├── http_client.py # httpx 全局连接池 +│ ├── admin_html.py # 后台 HTML(占位版) +│ ├── api/ # FastAPI 路由 +│ │ ├── health.py # /health +│ │ ├── openai_compat.py# /v1/chat/completions /v1/embeddings /v1/models +│ │ ├── xtc.py # /v1/xtc/providers /v1/xtc/models /v1/xtc/chat +│ │ ├── pseudo_stream.py# /v1/xtc/chat/pseudo/start /v1/xtc/chat/pseudo/poll +│ │ ├── image_fix.py # /v1/xtc/image/fix/test +│ │ ├── admin.py # /admin/api/* +│ │ ├── logs.py # /logs/api/*(合并原 xtc-log-receiver) +│ │ └── _common.py # 共享工具 +│ ├── adapters/ # 上游适配器 +│ │ ├── gemini_api.py # Gemini 总入口 +│ │ ├── openai.py # OpenAI 兼容透传 +│ │ └── gemini/ # Gemini→OpenAI 转换子模块 +│ │ ├── messages.py # messages ↔ contents +│ │ ├── config.py # generation params +│ │ ├── tools.py # tools / tool_choice +│ │ ├── response.py # 非流式响应转换 +│ │ └── stream.py # 流式 chunk 转换 +│ ├── providers/ # 厂商解析与策略 +│ │ ├── resolver.py # 解析厂商 +│ │ ├── keypool.py # 多密钥轮询(持久化) +│ │ └── policy.py # 白/黑名单 + glob +│ ├── media/imagefix.py # 图片修正(Pillow) +│ ├── models/config.py # Provider / AppConfig 数据模型 +│ ├── services/ # 业务服务 +│ │ ├── config_store.py # 配置存储(SQLite + HF Hub 同步) +│ │ └── pseudo_store.py # 伪流式会话存储 +│ └── utils/ # 工具 +│ ├── thought.py # 思考链抽取 +│ └── sse.py # SSE 解析 +└── README.md +``` + +## 与原 Netlify 版的差异 + +### 简化(砍掉的复杂度) + +| 简化项 | 原因 | +|---|---| +| 环境变量配厂商(`XTC_PROVIDER_1_*` ... `XTC_PROVIDER_12_*`) | 后端有数据库,Admin API 直接写 | +| 配置加密 `XTC_CONFIG_CRYPT_KEY` / `enc:v1:` | HF Secrets 加密 + 私有 Hub 仓库 + 容器内 SQLite 已够安全 | +| `XTC_CONFIG_ENV_ONLY` / `XTC_ADMIN_REQUIRE_KEY_ON_PAGE` | 改用 JWT 登录态 | +| `XTC_ACCESS_SHORT_KEY` / `XTC_ACCESS_PIN` | JWT 令牌已足够 | +| 多平台入口(6 个) | 只跑 HF Docker | +| Edge 路径 `/edge/*` | 无 Edge 概念 | +| `runtimeEnv` + `process.env` + `Deno.env` 三段式 | 只用 `os.environ` | + +### 保留(兼容契约,前端零改动) + +- OpenAI 兼容三件套:`/v1/chat/completions`、`/v1/embeddings`、`/v1/models` +- XTC 简化接口:`/v1/xtc/providers`、`/v1/xtc/models`、`/v1/xtc/chat`、`/v1/xtc/chat/pseudo/*`、`/v1/xtc/image/fix/test` +- 错误响应格式:`{ ok:false, error:{ code, message, status, retryable, hint, upstream?, details? } }` +- 错误码集合:`bad_request/unauthorized/forbidden/not_found/request_timeout/quota_exceeded/internal_error/service_unavailable/upstream_timeout/model_disabled/server_misconfigured` +- 图片修正三模式:`mirror_h` / `rotate_180` / `mirror_h_rotate_180` +- 流式协议:OpenAI SSE、`stream_mode: simple`、伪流式 +- 响应头:`x-xtc-provider` / `x-xtc-model` / `x-xtc-image-fix-mode` +- Admin API 全部端点 + +### 新增 + +- HF Hub 仓库配置同步(`HF_TOKEN` + `HF_CONFIG_REPO`) +- JWT 临时令牌(替代内存 Map,多实例可用) +- 多密钥 RR 计数器持久化到 SQLite +- 伪流式会话存 SQLite(替代 Netlify Blobs) +- 日志接收合并进主服务(`/logs/api/*`) +- 用量统计表(`usage_log`) +- 审计日志表(`audit_log`) + +## 环境变量 + +参考 [.env.example](file:///workspace/xtc-backend-hf/.env.example)。必填: + +- `XTC_ADMIN_KEY`:后台登录密钥 +- `XTC_ACCESS_KEY`:前端长期密钥 +- `XTC_JWT_SECRET`:JWT 签名密钥(签发临时令牌用) + +可选种子(首次启动初始化): + +- `GEMINI_API_KEY` / `GEMINI_API_KEYS`(逗号分隔多 key) +- `OPENAI_API_KEY` / `OPENAI_API_KEYS` +- `OPENAI_BASE_URL` + +可选 HF Hub 同步: + +- `HF_TOKEN`:访问 Hub 仓库的令牌 +- `HF_CONFIG_REPO`:仓库名(如 `your-username/xtc-config`,建议设为 dataset 私有仓库) + +## 本地运行 + +```bash +cd xtc-backend-hf +python -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt +pip install pillow-heif # 可选,HEIC 解码 + +# 配置环境变量 +cp .env.example .env +# 编辑 .env 设置 XTC_ADMIN_KEY / XTC_ACCESS_KEY / XTC_JWT_SECRET + +# 启动 +uvicorn app.main:app --host 0.0.0.0 --port 7860 --reload +``` + +访问: + +- 健康检查:http://localhost:7860/health +- 后台:http://localhost:7860/admin +- API 文档:http://localhost:7860/docs + +## 部署到 Hugging Face Spaces + +1. 在 HF 创建一个 **Docker SDK** 类型的 Space(私有推荐) +2. 在 Space Settings → Repository secrets 添加环境变量: + - `XTC_ADMIN_KEY`、`XTC_ACCESS_KEY`、`XTC_JWT_SECRET` + - 可选:`GEMINI_API_KEY`、`OPENAI_API_KEY`、`HF_TOKEN`、`HF_CONFIG_REPO` +3. 把本目录所有文件上传到 Space 仓库根目录(或用 GitHub Actions 同步) +4. HF 自动构建并启动,访问 `https://.hf.space` + +## 国内访问与保活(Cloudflare Workers 反代) + +```javascript +// CF Workers 脚本 +addEventListener("fetch", e => e.respondWith(handle(e.request))); +addEventListener("scheduled", e => e.waitUntil(keepalive())); + +const UPSTREAM = "https://your-space.hf.space"; + +async function handle(req) { + const url = new URL(req.url); + const upstream = new URL(UPSTREAM + url.pathname + url.search); + const resp = await fetch(upstream, { + method: req.method, headers: req.headers, body: req.body, + }); + return new Response(resp.body, resp); +} + +async function keepalive() { + try { await fetch(UPSTREAM + "/health"); } catch (e) {} +} +``` + +在 `wrangler.toml` 配置 Cron Triggers(每 24 小时一次): + +```toml +[triggers] +crons = ["0 */24 * * *"] +``` + +## 接口契约速查 + +### OpenAI 兼容 + +``` +POST /v1/chat/completions # 头部 Authorization: Bearer 或 x-xtc-access-key +POST /v1/embeddings +GET /v1/models +``` + +### XTC 简化 + +``` +GET /v1/xtc/providers # { ok, defaultProviderId, providers[], count } +GET /v1/xtc/models?provider=&all=1 # { ok, provider, models[], count } +POST /v1/xtc/chat # 见下方字段 +POST /v1/xtc/chat/pseudo/start # { ok, session_id, provider, model } +GET|POST /v1/xtc/chat/pseudo/poll # { ok, status, deltas[], cursor, expires_in_sec } +POST /v1/xtc/image/fix/test # 返回 PNG 二进制 +``` + +`/v1/xtc/chat` 成功响应: + +```json +{ + "ok": true, + "provider": "gemini", + "model": "gemini-2.5-flash", + "thought": "...", + "text": "...", + "raw": "...", + "usage": {}, + "warning": "(可选)图片修正失败提示", + "image_fix_mode": "mirror_h", + "image_fixed": true, + "image_fix_failed_count": 0, + "image_camera_facing": "front" +} +``` + +### Admin API + +``` +POST /admin/api/login +POST /admin/api/xtc-access-token +GET /admin/api/tokens # 新增 +POST /admin/api/tokens/revoke # 新增 +GET /admin/api/config +PUT /admin/api/config +GET /admin/api/providers +POST /admin/api/providers +DELETE /admin/api/providers?id= +GET /admin/api/provider-models?id= +POST /admin/api/provider-settings +POST /admin/api/default-provider +POST /admin/api/test-chat +``` + +### 日志(合并自 xtc-log-receiver) + +``` +POST /logs/api/log +POST /logs/api/log/batch +GET /logs/api/stats?hours=24 # admin +GET /logs/api/logs?hours=24&level=&limit= # admin +``` + +## 后续阶段 + +本版仅完成 MVP(核心接口迁移),后续阶段: + +1. **阶段 2**:Vue 3 SPA 重写后台(替换占位 HTML),加用量统计仪表盘、模型策略可视化 +2. **阶段 3**:HF Hub 多实例同步增强、Webhook 通知、速率限制 +3. **阶段 4**:会话持久化、文件解析(PDF/Word)、模型路由策略、RAG + +详见 `../openai-gemini-main-master/迁移到HuggingFace分析报告.md`。 diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/adapters/__init__.py b/app/adapters/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/adapters/gemini/__init__.py b/app/adapters/gemini/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e365a599e7eb7b523d5a91c7f71a8e0fce889fe2 --- /dev/null +++ b/app/adapters/gemini/__init__.py @@ -0,0 +1,9 @@ +"""Gemini 适配器:OpenAI messages <-> Gemini contents 转换。 + +包含 5 个子模块: +- messages.py: messages -> contents + system_instruction +- config.py: OpenAI generation params -> Gemini generationConfig +- tools.py: OpenAI tools/tool_choice -> Gemini function_declarations +- response.py: Gemini response -> OpenAI ChatCompletion +- stream.py: Gemini SSE -> OpenAI chunk +""" diff --git a/app/adapters/gemini/config.py b/app/adapters/gemini/config.py new file mode 100644 index 0000000000000000000000000000000000000000..5579ae151f96b74594f202f226a7ed3e6aa9858b --- /dev/null +++ b/app/adapters/gemini/config.py @@ -0,0 +1,126 @@ +"""OpenAI generation params -> Gemini generationConfig + safety_settings + thinking_config。""" +from __future__ import annotations + +import re +from typing import Any, Optional + + +def _pop(d: dict, *keys: str, default: Any = None) -> Any: + for k in keys: + if k in d: + return d.pop(k) + return default + + +def build_generation_config(body: dict, extra_google: Optional[dict] = None) -> tuple[dict, Optional[dict]]: + """构造 generationConfig 与 thinkingConfig。 + + Returns: + (generation_config_with_thinking, safety_settings) + """ + config: dict[str, Any] = {} + + # 标量参数 + if "temperature" in body and body["temperature"] is not None: + try: + config["temperature"] = float(body["temperature"]) + except (TypeError, ValueError): + pass + if "top_p" in body and body["top_p"] is not None: + try: + config["topP"] = float(body["top_p"]) + except (TypeError, ValueError): + pass + if "top_k" in body and body["top_k"] is not None: + try: + config["topK"] = int(body["top_k"]) + except (TypeError, ValueError): + pass + if "max_tokens" in body and body["max_tokens"] is not None: + try: + config["maxOutputTokens"] = int(body["max_tokens"]) + except (TypeError, ValueError): + pass + if "max_completion_tokens" in body and body["max_completion_tokens"] is not None: + try: + config["maxOutputTokens"] = int(body["max_completion_tokens"]) + except (TypeError, ValueError): + pass + if "presence_penalty" in body and body["presence_penalty"] is not None: + try: + config["presencePenalty"] = float(body["presence_penalty"]) + except (TypeError, ValueError): + pass + if "frequency_penalty" in body and body["frequency_penalty"] is not None: + try: + config["frequencyPenalty"] = float(body["frequency_penalty"]) + except (TypeError, ValueError): + pass + if "seed" in body and body["seed"] is not None: + try: + config["seed"] = int(body["seed"]) + except (TypeError, ValueError): + pass + stop = body.get("stop") + if stop: + if isinstance(stop, str): + config["stopSequences"] = [stop] + elif isinstance(stop, list): + config["stopSequences"] = [s for s in stop if isinstance(s, str)] + n = body.get("n") + if n and isinstance(n, int) and n > 1: + # Gemini 不支持 n,记一下不报错 + pass + + # response_format + rf = body.get("response_format") + if isinstance(rf, dict): + rtype = rf.get("type") + if rtype == "json_object": + config["responseMimeType"] = "application/json" + elif rtype == "json_schema": + config["responseMimeType"] = "application/json" + schema = rf.get("json_schema") or {} + if isinstance(schema, dict): + jsd = schema.get("schema") if "schema" in schema else schema + if isinstance(jsd, dict): + config["responseSchema"] = jsd + + # thinking config(reasoning_effort) + thinking: dict[str, Any] = {} + reasoning_effort = body.get("reasoning_effort") + if reasoning_effort: + re_str = str(reasoning_effort).lower() + # v3 thinkingLevel + if re_str in ("low", "medium", "high"): + thinking["thinkingLevel"] = re_str.capitalize() + elif re_str == "minimal": + thinking["thinkingLevel"] = "Minimal" + elif re_str in ("none", "off"): + thinking["thinkingBudget"] = 0 + # v2 thinkingBudget(数字) + if isinstance(reasoning_effort, int): + thinking["thinkingBudget"] = int(reasoning_effort) + + # extra_body.google 覆盖 + extra_google = extra_google or {} + if isinstance(extra_google, dict): + # safety_settings + safety_settings = extra_google.get("safety_settings") or extra_google.get("safetySettings") + # cached_content + cached = extra_google.get("cached_content") or extra_google.get("cachedContent") + if cached: + # 不放进 generationConfig,由上层放 body + body["_cached_content"] = cached + # thinking_config 覆盖 + tc = extra_google.get("thinking_config") or extra_google.get("thinkingConfig") + if isinstance(tc, dict): + thinking.update(tc) + else: + safety_settings = None + + # 合并 thinking 到 generationConfig + if thinking: + config["thinkingConfig"] = thinking + + return config, safety_settings diff --git a/app/adapters/gemini/messages.py b/app/adapters/gemini/messages.py new file mode 100644 index 0000000000000000000000000000000000000000..afcc475096d25f0d242b0f942b5f9167d84077eb --- /dev/null +++ b/app/adapters/gemini/messages.py @@ -0,0 +1,194 @@ +"""messages -> contents + system_instruction 转换。""" +from __future__ import annotations + +import base64 +import re +from typing import Any, Optional + +# data URL 解析 +_DATA_URL_RE = re.compile(r"^data:([^;,]+)?(;base64)?,(.*)$", re.DOTALL) + + +def _parse_data_url(url: str) -> Optional[dict]: + """data URL -> {mimeType, data(b64)},仅 base64 形式。""" + if not isinstance(url, str): + return None + m = _DATA_URL_RE.match(url.strip()) + if not m: + return None + mime = m.group(1) or "application/octet-stream" + is_b64 = bool(m.group(2)) + payload = m.group(3) + if is_b64: + try: + # 校验可解码 + base64.b64decode(payload, validate=False) + return {"mimeType": mime, "data": payload} + except Exception: + return None + # 非 base64 的 data URL,转 base64 + try: + b64 = base64.b64encode(payload.encode("utf-8")).decode("ascii") + return {"mimeType": mime, "data": b64} + except Exception: + return None + + +def _content_to_parts(content: Any) -> list[dict]: + """OpenAI content(str 或 list)-> Gemini parts。""" + if isinstance(content, str): + return [{"text": content}] if content else [] + + parts: list[dict] = [] + if not isinstance(content, list): + return parts + for item in content: + if not isinstance(item, dict): + continue + t = item.get("type") + if t == "text": + text = item.get("text", "") + if text: + parts.append({"text": text}) + elif t == "image_url": + url_obj = item.get("image_url") or {} + url = url_obj.get("url") if isinstance(url_obj, dict) else url_obj + inline = _parse_data_url(url) if isinstance(url, str) else None + if inline: + parts.append({"inlineData": inline}) + elif t == "input_audio": + data_obj = item.get("input_audio") or {} + data_str = data_obj.get("data") if isinstance(data_obj, dict) else None + if data_str: + mime = "audio/wav" + if isinstance(data_obj, dict) and data_obj.get("format") == "mp3": + mime = "audio/mpeg" + parts.append({"inlineData": {"mimeType": mime, "data": data_str}}) + elif t == "input_image": + url = item.get("image_url") if isinstance(item.get("image_url"), str) else None + inline = _parse_data_url(url) if url else None + if inline: + parts.append({"inlineData": inline}) + return parts + + +def _merge_tool_messages(messages: list[dict]) -> list[dict]: + """合并连续的 tool 角色消息为单条 function 响应。 + + OpenAI: 多个 tool 消息对应不同 tool_call_id + Gemini: 单条 functionResponse parts(多个 part) + """ + out: list[dict] = [] + i = 0 + while i < len(messages): + msg = messages[i] + if msg.get("role") != "tool": + out.append(msg) + i += 1 + continue + # 收集连续 tool 消息 + tool_calls_map: dict[str, Any] = {} + # 找到上一条 assistant 的 tool_calls,用于映射 tool_call_id -> function name + prev_assistant = None + for prev in reversed(out): + if prev.get("role") == "assistant": + prev_assistant = prev + break + name_by_id: dict[str, str] = {} + if prev_assistant: + for tc in prev_assistant.get("tool_calls") or []: + if isinstance(tc, dict): + tid = tc.get("id") + fn = (tc.get("function") or {}).get("name") + if tid and fn: + name_by_id[tid] = fn + + parts: list[dict] = [] + while i < len(messages) and messages[i].get("role") == "tool": + tm = messages[i] + tool_call_id = tm.get("tool_call_id", "") + name = tm.get("name") or name_by_id.get(tool_call_id) or "function" + content = tm.get("content") + try: + if isinstance(content, str): + parsed = __import__("json").loads(content) if content.strip() else {} + elif isinstance(content, dict): + parsed = content + else: + parsed = {"result": content} + except Exception: + parsed = {"result": content} + parts.append({"functionResponse": {"name": name, "response": parsed}}) + i += 1 + out.append({"role": "function", "_parts": parts}) + return out + + +def convert_messages_to_contents(messages: list[dict]) -> tuple[list[dict], Optional[str]]: + """OpenAI messages -> Gemini contents + system_instruction。 + + Returns: + (contents, system_instruction_text) + """ + if not messages: + return [], None + + # 提取 system + system_parts: list[str] = [] + chat_messages: list[dict] = [] + for msg in messages: + role = msg.get("role") + if role == "system" or role == "developer": + content = msg.get("content") + if isinstance(content, str) and content: + system_parts.append(content) + elif isinstance(content, list): + for p in content: + if isinstance(p, dict) and p.get("type") == "text" and p.get("text"): + system_parts.append(p["text"]) + else: + chat_messages.append(msg) + + system_text = "\n\n".join(system_parts) if system_parts else None + + # 合并连续 tool 消息 + chat_messages = _merge_tool_messages(chat_messages) + + contents: list[dict] = [] + for msg in chat_messages: + role = msg.get("role", "user") + if role == "assistant": + gemini_role = "model" + elif role == "tool": + gemini_role = "function" # 被 _merge_tool_messages 处理过 + elif role == "function": + gemini_role = "function" + else: + gemini_role = "user" + + parts: list[dict] = [] + # 处理 _parts(合并后的 tool 响应) + if msg.get("_parts"): + parts.extend(msg["_parts"]) + else: + parts.extend(_content_to_parts(msg.get("content"))) + + # assistant 的 tool_calls -> functionCall parts + for tc in msg.get("tool_calls") or []: + if not isinstance(tc, dict): + continue + fn = tc.get("function") or {} + name = fn.get("name") + args_str = fn.get("arguments", "{}") + try: + args = __import__("json").loads(args_str) if isinstance(args_str, str) else args_str + except Exception: + args = {} + if name: + parts.append({"functionCall": {"name": name, "args": args}}) + + if not parts: + parts = [{"text": ""}] + contents.append({"role": gemini_role, "parts": parts}) + + return contents, system_text diff --git a/app/adapters/gemini/response.py b/app/adapters/gemini/response.py new file mode 100644 index 0000000000000000000000000000000000000000..972f0b23e35ffaab61105d545861c54c5cc5d749 --- /dev/null +++ b/app/adapters/gemini/response.py @@ -0,0 +1,162 @@ +"""Gemini response -> OpenAI ChatCompletion 转换。""" +from __future__ import annotations + +import json +import time +from typing import Any, Optional + +from ...utils.thought import wrap_thought + + +_FINISH_MAP = { + "STOP": "stop", + "MAX_TOKENS": "length", + "SAFETY": "content_filter", + "RECITATION": "content_filter", + "OTHER": "stop", + "FINISH_REASON_UNSPECIFIED": "stop", +} + + +def _parts_to_openai(parts: list[dict], *, include_thought: bool = True) -> tuple[str, list[dict], Optional[str]]: + """Gemini parts -> (content_text, tool_calls, thought_text)。 + + thought parts 会被包裹成 ... 嵌入 content。 + """ + text_chunks: list[str] = [] + thought_chunks: list[str] = [] + tool_calls: list[dict] = [] + for i, part in enumerate(parts or []): + if not isinstance(part, dict): + continue + # 思考链(thought=true 标记) + if part.get("thought"): + t = part.get("text") or "" + if t: + thought_chunks.append(t) + continue + if "text" in part: + text_chunks.append(part["text"] or "") + elif "functionCall" in part: + fc = part["functionCall"] or {} + name = fc.get("name") or "" + args = fc.get("args") or {} + try: + args_str = json.dumps(args, ensure_ascii=False) + except Exception: + args_str = "{}" + tool_calls.append( + { + "id": f"call_{i+1}", + "type": "function", + "function": {"name": name, "arguments": args_str}, + } + ) + content = "".join(text_chunks) + thought = "\n\n".join(thought_chunks) + if include_thought and thought: + content = wrap_thought(thought) + content + return content, tool_calls, thought if thought else None + + +def convert_response(gemini_resp: dict, *, model: str) -> dict: + """Gemini generateContent response -> OpenAI ChatCompletion。""" + candidates = gemini_resp.get("candidates") or [] + choices: list[dict] = [] + for cand in candidates: + content_obj = cand.get("content") or {} + parts = content_obj.get("parts") or [] + finish_reason_raw = cand.get("finishReason") or "STOP" + finish_reason = _FINISH_MAP.get(finish_reason_raw, "stop") + # promptFeedback 阻断 + feedback = cand.get("promptFeedback") or {} + if feedback.get("blockReason"): + choices.append( + { + "index": cand.get("index", 0), + "message": {"role": "assistant", "content": ""}, + "finish_reason": "content_filter", + } + ) + continue + content, tool_calls, _ = _parts_to_openai(parts) + msg: dict[str, Any] = {"role": "assistant", "content": content or None} + if tool_calls: + msg["tool_calls"] = tool_calls + choices.append( + { + "index": cand.get("index", 0), + "message": msg, + "finish_reason": finish_reason, + } + ) + + if not choices: + choices.append( + { + "index": 0, + "message": {"role": "assistant", "content": ""}, + "finish_reason": "stop", + } + ) + + # usage + usage_meta = gemini_resp.get("usageMetadata") or {} + usage = { + "prompt_tokens": int(usage_meta.get("promptTokenCount") or 0), + "completion_tokens": int(usage_meta.get("candidatesTokenCount") or 0), + "total_tokens": int(usage_meta.get("totalTokenCount") or 0), + } + if usage_meta.get("thoughtsTokenCount"): + usage["reasoning_tokens"] = int(usage_meta["thoughtsTokenCount"]) + if usage_meta.get("cachedContentTokenCount"): + usage["prompt_tokens_details"] = { + "cached_tokens": int(usage_meta["cachedContentTokenCount"]) + } + if usage_meta.get("audioTokenCount"): + usage["completion_tokens_details"] = { + "audio_tokens": int(usage_meta["audioTokenCount"]) + } + + return { + "id": f"chatcmpl-{int(time.time()*1000)}", + "object": "chat.completion", + "created": int(time.time()), + "model": model, + "choices": choices, + "usage": usage, + } + + +def convert_embeddings_response(gemini_resp: dict, *, model: str) -> dict: + """Gemini batchEmbedContents -> OpenAI embeddings。""" + embeddings = gemini_resp.get("embeddings") or [] + data = [] + for i, emb in enumerate(embeddings): + values = (emb.get("values") or []) if isinstance(emb, dict) else [] + data.append({"object": "embedding", "index": i, "embedding": values}) + return { + "object": "list", + "data": data, + "model": model, + "usage": {"prompt_tokens": 0, "total_tokens": 0}, + } + + +def convert_models_response(gemini_resp: dict) -> dict: + """Gemini /models -> OpenAI /models。""" + models = gemini_resp.get("models") or [] + data = [] + for m in models: + name = (m.get("name") or "").removeprefix("models/") + if not name: + continue + data.append( + { + "id": name, + "object": "model", + "created": 0, + "owned_by": "google", + } + ) + return {"object": "list", "data": data} diff --git a/app/adapters/gemini/stream.py b/app/adapters/gemini/stream.py new file mode 100644 index 0000000000000000000000000000000000000000..8a380ee6a2346f8bd44c3442edcfa759be26bef7 --- /dev/null +++ b/app/adapters/gemini/stream.py @@ -0,0 +1,171 @@ +"""Gemini 流式 SSE -> OpenAI chunk 转换。""" +from __future__ import annotations + +import json +import time +from typing import Any, AsyncIterator, Optional + +from ...utils.thought import wrap_thought +from .response import _FINISH_MAP + + +def _empty_chunk(*, model: str, role: Optional[str] = None) -> dict: + delta: dict[str, Any] = {} + if role: + delta["role"] = role + return { + "id": f"chatcmpl-{int(time.time()*1000)}", + "object": "chat.completion.chunk", + "created": int(time.time()), + "model": model, + "choices": [{"index": 0, "delta": delta, "finish_reason": None}], + } + + +def _text_chunk(text: str, *, model: str) -> dict: + return { + "id": f"chatcmpl-{int(time.time()*1000)}", + "object": "chat.completion.chunk", + "created": int(time.time()), + "model": model, + "choices": [ + {"index": 0, "delta": {"content": text}, "finish_reason": None} + ], + } + + +def _tool_call_chunk(name: str, args: str, *, model: str, index: int = 0) -> dict: + return { + "id": f"chatcmpl-{int(time.time()*1000)}", + "object": "chat.completion.chunk", + "created": int(time.time()), + "model": model, + "choices": [ + { + "index": 0, + "delta": { + "tool_calls": [ + { + "index": index, + "id": f"call_{index+1}", + "type": "function", + "function": {"name": name, "arguments": args}, + } + ] + }, + "finish_reason": None, + } + ], + } + + +def _finish_chunk(*, model: str, finish_reason: str, usage: Optional[dict] = None) -> dict: + return { + "id": f"chatcmpl-{int(time.time()*1000)}", + "object": "chat.completion.chunk", + "created": int(time.time()), + "model": model, + "choices": [ + {"index": 0, "delta": {}, "finish_reason": finish_reason} + ], + **({"usage": usage} if usage else {}), + } + + +async def stream_openai_chunks( + gemini_sse: AsyncIterator[dict], + *, + model: str, + include_usage: bool = False, +) -> AsyncIterator[dict]: + """把 Gemini SSE 流转换为 OpenAI chunk 字典流。""" + sent_role = False + in_thought = False + usage_final: Optional[dict] = None + tool_index = 0 + + async for ev in gemini_sse: + if ev.get("__done__"): + break + if ev.get("__raw__"): + # 非 JSON 行,忽略 + continue + + candidates = ev.get("candidates") or [] + if not candidates: + # 可能是纯 usage chunk + um = ev.get("usageMetadata") + if um: + usage_final = _build_usage(um) + continue + + cand = candidates[0] + content_obj = cand.get("content") or {} + parts = content_obj.get("parts") or [] + + # 首包补 role + if not sent_role: + sent_role = True + yield _empty_chunk(model=model, role="assistant") + + # 拼接 thought 开闭标记 + text + text_buf: list[str] = [] + thought_buf: list[str] = [] + for part in parts: + if not isinstance(part, dict): + continue + if part.get("thought"): + t = part.get("text") or "" + if t: + thought_buf.append(t) + continue + if "text" in part: + text_buf.append(part["text"] or "") + elif "functionCall" in part: + fc = part["functionCall"] or {} + name = fc.get("name") or "" + args = fc.get("args") or {} + try: + args_str = json.dumps(args, ensure_ascii=False) + except Exception: + args_str = "{}" + yield _tool_call_chunk(name, args_str, model=model, index=tool_index) + tool_index += 1 + + # thought 包裹 + if thought_buf: + thought_text = "".join(thought_buf) + yield _text_chunk(wrap_thought(thought_text), model=model) + + if text_buf: + yield _text_chunk("".join(text_buf), model=model) + + finish_raw = cand.get("finishReason") + if finish_raw: + finish_reason = _FINISH_MAP.get(finish_raw, "stop") + um = ev.get("usageMetadata") + if um: + usage_final = _build_usage(um) + yield _finish_chunk( + model=model, finish_reason=finish_reason, + usage=usage_final if include_usage else None, + ) + + # 流结束,若 include_usage 且未发过 usage,发一个空 usage 包 + if include_usage and not usage_final: + yield _finish_chunk(model=model, finish_reason="stop", usage={ + "prompt_tokens": 0, + "completion_tokens": 0, + "total_tokens": 0, + }) + + +def _build_usage(um: dict) -> dict: + usage = { + "prompt_tokens": int(um.get("promptTokenCount") or 0), + "completion_tokens": int(um.get("candidatesTokenCount") or 0), + "total_tokens": int(um.get("totalTokenCount") or 0), + } + if um.get("thoughtsTokenCount"): + usage["reasoning_tokens"] = int(um["thoughtsTokenCount"]) + return usage diff --git a/app/adapters/gemini/tools.py b/app/adapters/gemini/tools.py new file mode 100644 index 0000000000000000000000000000000000000000..901ca0c12b757c9408b877b3c63dc9d79d5ffb4f --- /dev/null +++ b/app/adapters/gemini/tools.py @@ -0,0 +1,61 @@ +"""OpenAI tools / tool_choice -> Gemini tools (function_declarations)。""" +from __future__ import annotations + +from typing import Any, Optional + + +def convert_tools(body: dict) -> Optional[list[dict]]: + """转换 OpenAI tools 数组为 Gemini tools 格式。 + + Returns: + Gemini tools list 或 None + """ + tools = body.get("tools") + if not tools or not isinstance(tools, list): + return None + + func_decls: list[dict] = [] + for tool in tools: + if not isinstance(tool, dict): + continue + if tool.get("type") and tool.get("type") != "function": + continue + func = tool.get("function") or tool + if not isinstance(func, dict): + continue + name = func.get("name") + if not name: + continue + decl: dict[str, Any] = { + "name": name, + "description": func.get("description") or "", + } + params = func.get("parameters") + if isinstance(params, dict): + decl["parameters"] = params + func_decls.append(decl) + + if not func_decls: + return None + + out: list[dict] = [{"function_declarations": func_decls}] + + # tool_choice + tool_choice = body.get("tool_choice") + if tool_choice: + fcc: dict[str, Any] = {} + if tool_choice == "auto": + fcc["mode"] = "AUTO" + elif tool_choice == "none": + fcc["mode"] = "NONE" + elif tool_choice == "required": + fcc["mode"] = "ANY" + elif isinstance(tool_choice, dict): + fn_name = (tool_choice.get("function") or {}).get("name") + if fn_name: + fcc["mode"] = "ANY" + fcc["allowed_function_names"] = [fn_name] + if fcc: + out[0]["function_calling_config"] = fcc + + return out diff --git a/app/adapters/gemini_api.py b/app/adapters/gemini_api.py new file mode 100644 index 0000000000000000000000000000000000000000..a6d909058a4a4e6d150b566537ee0a7d19433394 --- /dev/null +++ b/app/adapters/gemini_api.py @@ -0,0 +1,307 @@ +"""Gemini 适配器入口:拼装上游请求、调用、超时、错误处理。""" +from __future__ import annotations + +import json +import re +from typing import Any, AsyncIterator, Optional + +import httpx + +from ..config import ( + GEMINI_API_CLIENT, + GEMINI_API_VERSION, + GEMINI_BASE_URL, + GEMINI_DEFAULT_EMBEDDINGS_MODEL, + get_settings, +) +from ..errors import HttpError, error_code_for_status, is_retryable_status +from ..http_client import get_http_client +from ..utils.sse import parse_sse_stream +from .gemini.config import build_generation_config +from .gemini.messages import convert_messages_to_contents +from .gemini.response import ( + convert_embeddings_response, + convert_models_response, + convert_response, +) +from .gemini.stream import stream_openai_chunks +from .gemini.tools import convert_tools + + +def _build_headers(api_key: str) -> dict[str, str]: + return { + "x-goog-api-client": GEMINI_API_CLIENT, + "x-goog-api-key": api_key, + "Content-Type": "application/json", + } + + +def _strip_model_suffix(model: str) -> tuple[str, bool, bool]: + """剥离模型名后缀(:search / -search-preview),返回 (clean_model, use_search, _removed)。""" + use_search = False + m = model + if m.endswith(":search"): + use_search = True + m = m[: -len(":search")] + elif m.endswith("-search-preview"): + use_search = True + m = m[: -len("-search-preview")] + return m, use_search, False + + +def _build_body( + *, + messages: list[dict], + body: dict, + model: str, +) -> dict[str, Any]: + """构造 Gemini generateContent body。""" + contents, system_text = convert_messages_to_contents(messages) + gemini_body: dict[str, Any] = {"contents": contents} + if system_text: + gemini_body["system_instruction"] = {"parts": [{"text": system_text}]} + + # 处理 google extra_body + extra_google = None + extra_body = body.get("extra_body") or body.get("google") + if isinstance(extra_body, dict): + extra_google = extra_body.get("google") if "google" in extra_body else extra_body + + gen_config, safety_settings = build_generation_config(body, extra_google) + if gen_config: + gemini_body["generationConfig"] = gen_config + if safety_settings: + gemini_body["safetySettings"] = safety_settings + + # cached content + cached = body.get("_cached_content") or (extra_google or {}).get("cached_content") if extra_google else None + if cached: + gemini_body["cachedContent"] = cached + + # tools + tools = convert_tools(body) + # 模型后缀 :search -> 自动追加 googleSearch 工具 + clean_model, use_search, _ = _strip_model_suffix(model) + if use_search: + search_tool = {"google_search": {}} if False else {"googleSearch": {}} + if tools: + tools.append(search_tool) + else: + tools = [search_tool] + if tools: + gemini_body["tools"] = tools + + return gemini_body, clean_model + + +async def chat_completions( + *, + api_key: str, + model: str, + messages: list[dict], + body: dict, + stream: bool = False, +) -> Any: + """调用 Gemini,返回 OpenAI 格式结果或 chunk 流。""" + gemini_body, clean_model = _build_body(messages=messages, body=body, model=model) + + if stream: + return _stream_chat(api_key, clean_model, gemini_body, body) + + url = ( + f"{GEMINI_BASE_URL}/{GEMINI_API_VERSION}/models/{clean_model}:generateContent" + ) + client = get_http_client() + try: + resp = await client.post( + url, + headers=_build_headers(api_key), + json=gemini_body, + ) + except httpx.TimeoutException as e: + raise HttpError( + f"Gemini upstream timeout: {e}", + status=504, + code="upstream_timeout", + ) from e + except httpx.HTTPError as e: + raise HttpError( + f"Gemini upstream error: {e}", + status=502, + code="bad_gateway", + ) from e + + if resp.status_code >= 400: + await _raise_upstream_error(resp) + + try: + data = resp.json() + except Exception as e: + raise HttpError( + f"Gemini upstream returned non-JSON: {resp.text[:200]}", + status=502, + code="bad_gateway", + ) from e + + return convert_response(data, model=clean_model) + + +async def _stream_chat( + api_key: str, + clean_model: str, + gemini_body: dict, + body: dict, +) -> AsyncIterator[dict]: + """流式调用 Gemini,yield OpenAI chunk dict。""" + url = ( + f"{GEMINI_BASE_URL}/{GEMINI_API_VERSION}/models/{clean_model}:streamGenerateContent" + "?alt=sse" + ) + client = get_http_client() + include_usage = False + so = body.get("stream_options") + if isinstance(so, dict) and so.get("include_usage"): + include_usage = True + + try: + async with client.stream( + "POST", + url, + headers=_build_headers(api_key), + json=gemini_body, + ) as resp: + if resp.status_code >= 400: + text = await resp.aread() + await _raise_upstream_error_from_raw(resp.status_code, text) + sse_iter = parse_sse_stream(resp.aiter_bytes()) + async for chunk in stream_openai_chunks( + sse_iter, model=clean_model, include_usage=include_usage + ): + yield chunk + except httpx.TimeoutException as e: + raise HttpError( + f"Gemini stream timeout: {e}", + status=504, + code="upstream_timeout", + ) from e + except HttpError: + raise + except httpx.HTTPError as e: + raise HttpError( + f"Gemini stream error: {e}", + status=502, + code="bad_gateway", + ) from e + + +async def embeddings( + *, + api_key: str, + model: str, + input_data: Any, +) -> dict: + """调用 Gemini batchEmbedContents,返回 OpenAI embeddings 格式。""" + # 归一化 input 为 list + if isinstance(input_data, str): + inputs = [input_data] + elif isinstance(input_data, list): + inputs = [str(x) for x in input_data] + else: + inputs = [str(input_data)] + + clean_model = model or GEMINI_DEFAULT_EMBEDDINGS_MODEL + url = f"{GEMINI_BASE_URL}/{GEMINI_API_VERSION}/models/{clean_model}:batchEmbedContents" + body = {"requests": [{"model": f"models/{clean_model}", "content": {"parts": [{"text": t}]}} for t in inputs]} + + client = get_http_client() + try: + resp = await client.post(url, headers=_build_headers(api_key), json=body) + except httpx.TimeoutException as e: + raise HttpError(f"Gemini embeddings timeout: {e}", status=504, code="upstream_timeout") from e + except httpx.HTTPError as e: + raise HttpError(f"Gemini embeddings error: {e}", status=502, code="bad_gateway") from e + + if resp.status_code >= 400: + await _raise_upstream_error(resp) + + try: + data = resp.json() + except Exception as e: + raise HttpError("Gemini embeddings non-JSON", status=502, code="bad_gateway") from e + + return convert_embeddings_response(data, model=clean_model) + + +async def list_models(*, api_key: str) -> dict: + url = f"{GEMINI_BASE_URL}/{GEMINI_API_VERSION}/models" + client = get_http_client() + try: + resp = await client.get(url, headers=_build_headers(api_key), params={"pageSize": "200"}) + except httpx.HTTPError as e: + raise HttpError(f"Gemini list models error: {e}", status=502, code="bad_gateway") from e + if resp.status_code >= 400: + await _raise_upstream_error(resp) + try: + data = resp.json() + except Exception as e: + raise HttpError("Gemini list models non-JSON", status=502, code="bad_gateway") from e + return convert_models_response(data) + + +async def list_models_raw(*, api_key: str) -> list[dict]: + """原始模型列表(供后台拉取模型用)。""" + url = f"{GEMINI_BASE_URL}/{GEMINI_API_VERSION}/models" + client = get_http_client() + try: + resp = await client.get(url, headers=_build_headers(api_key), params={"pageSize": "200"}) + except httpx.HTTPError as e: + raise HttpError(f"Gemini list models error: {e}", status=502, code="bad_gateway") from e + if resp.status_code >= 400: + await _raise_upstream_error(resp) + try: + data = resp.json() + except Exception as e: + raise HttpError("Gemini list models non-JSON", status=502, code="bad_gateway") from e + out = [] + for m in data.get("models") or []: + name = (m.get("name") or "").removeprefix("models/") + if name: + out.append({"id": name, "name": m.get("displayName") or name}) + return out + + +async def _raise_upstream_error(resp: httpx.Response) -> None: + text = resp.text + try: + body = resp.json() + except Exception: + body = None + upstream_err = None + if isinstance(body, dict): + upstream_err = body.get("error") if isinstance(body.get("error"), dict) else body + code = (upstream_err or {}).get("code") if isinstance(upstream_err, dict) else None + code = code or error_code_for_status(resp.status_code, "upstream_error") + message = (upstream_err or {}).get("message") if isinstance(upstream_err, dict) else None + message = message or text or f"Upstream error ({resp.status_code})" + raise HttpError( + message, + status=resp.status_code, + code=code, + upstream=upstream_err, + ) + + +async def _raise_upstream_error_from_raw(status: int, raw: bytes) -> None: + text = raw.decode("utf-8", errors="replace") + try: + body = json.loads(text) + except Exception: + body = None + upstream_err = None + if isinstance(body, dict): + upstream_err = body.get("error") if isinstance(body.get("error"), dict) else body + code = (upstream_err or {}).get("code") if isinstance(upstream_err, dict) else None + code = code or error_code_for_status(status, "upstream_error") + message = (upstream_err or {}).get("message") if isinstance(upstream_err, dict) else None + message = message or text or f"Upstream error ({status})" + raise HttpError(message, status=status, code=code, upstream=upstream_err) diff --git a/app/adapters/openai.py b/app/adapters/openai.py new file mode 100644 index 0000000000000000000000000000000000000000..bb338efc8771db4bcb432a874f13231684024d0f --- /dev/null +++ b/app/adapters/openai.py @@ -0,0 +1,171 @@ +"""OpenAI 兼容上游适配器:透传到 {base_url}/v1/{endpoint}。 + +直接转发请求/响应/流式,不做格式转换。 +""" +from __future__ import annotations + +import json +from typing import Any, AsyncIterator, Optional + +import httpx + +from ..errors import HttpError, error_code_for_status +from ..http_client import get_http_client + + +def _normalize_base_url(base_url: str) -> str: + base_url = (base_url or "https://api.openai.com").rstrip("/") + # 兼容用户填了 /v1 的情况 + if base_url.endswith("/v1"): + return base_url[:-3] + return base_url + + +def _build_headers(api_key: str, *, auth_header: str = "Authorization") -> dict[str, str]: + return { + "Content-Type": "application/json", + auth_header: f"Bearer {api_key}", + } + + +async def chat_completions( + *, + base_url: str, + api_key: str, + body: dict, + stream: bool = False, +) -> Any: + """透传到上游 /v1/chat/completions。""" + url = f"{_normalize_base_url(base_url)}/v1/chat/completions" + client = get_http_client() + try: + if stream: + return _stream_passthrough(base_url, api_key, body) + resp = await client.post(url, headers=_build_headers(api_key), json=body) + except httpx.TimeoutException as e: + raise HttpError(f"OpenAI upstream timeout: {e}", status=504, code="upstream_timeout") from e + except httpx.HTTPError as e: + raise HttpError(f"OpenAI upstream error: {e}", status=502, code="bad_gateway") from e + + if resp.status_code >= 400: + await _raise_upstream_error(resp) + + try: + return resp.json() + except Exception as e: + raise HttpError("OpenAI upstream non-JSON", status=502, code="bad_gateway") from e + + +async def _stream_passthrough( + base_url: str, + api_key: str, + body: dict, +) -> AsyncIterator[bytes]: + """透传 SSE 字节流。""" + url = f"{_normalize_base_url(base_url)}/v1/chat/completions" + client = get_http_client() + try: + async with client.stream( + "POST", url, headers=_build_headers(api_key), json=body + ) as resp: + if resp.status_code >= 400: + raw = await resp.aread() + await _raise_upstream_error_from_raw(resp.status_code, raw) + async for chunk in resp.aiter_bytes(): + yield chunk + except httpx.TimeoutException as e: + raise HttpError(f"OpenAI stream timeout: {e}", status=504, code="upstream_timeout") from e + except HttpError: + raise + except httpx.HTTPError as e: + raise HttpError(f"OpenAI stream error: {e}", status=502, code="bad_gateway") from e + + +async def embeddings( + *, + base_url: str, + api_key: str, + body: dict, +) -> dict: + url = f"{_normalize_base_url(base_url)}/v1/embeddings" + client = get_http_client() + try: + resp = await client.post(url, headers=_build_headers(api_key), json=body) + except httpx.TimeoutException as e: + raise HttpError(f"OpenAI embeddings timeout: {e}", status=504, code="upstream_timeout") from e + except httpx.HTTPError as e: + raise HttpError(f"OpenAI embeddings error: {e}", status=502, code="bad_gateway") from e + + if resp.status_code >= 400: + await _raise_upstream_error(resp) + + try: + return resp.json() + except Exception as e: + raise HttpError("OpenAI embeddings non-JSON", status=502, code="bad_gateway") from e + + +async def list_models( + *, + base_url: str, + api_key: str, +) -> dict: + url = f"{_normalize_base_url(base_url)}/v1/models" + client = get_http_client() + try: + resp = await client.get(url, headers=_build_headers(api_key)) + except httpx.HTTPError as e: + raise HttpError(f"OpenAI list models error: {e}", status=502, code="bad_gateway") from e + if resp.status_code >= 400: + await _raise_upstream_error(resp) + try: + return resp.json() + except Exception as e: + raise HttpError("OpenAI list models non-JSON", status=502, code="bad_gateway") from e + + +async def list_models_raw( + *, + base_url: str, + api_key: str, +) -> list[dict]: + """原始模型列表(供后台拉取模型用)。""" + data = await list_models(base_url=base_url, api_key=api_key) + out = [] + for m in data.get("data") or []: + mid = m.get("id") + if mid: + out.append({"id": mid, "name": m.get("id")}) + return out + + +async def _raise_upstream_error(resp: httpx.Response) -> None: + text = resp.text + try: + body = resp.json() + except Exception: + body = None + upstream_err = None + if isinstance(body, dict): + upstream_err = body.get("error") if isinstance(body.get("error"), dict) else body + code = (upstream_err or {}).get("code") if isinstance(upstream_err, dict) else None + code = code or error_code_for_status(resp.status_code, "upstream_error") + message = (upstream_err or {}).get("message") if isinstance(upstream_err, dict) else None + message = message or text or f"Upstream error ({resp.status_code})" + raise HttpError(message, status=resp.status_code, code=code, upstream=upstream_err) + + +async def _raise_upstream_error_from_raw(status: int, raw: bytes) -> None: + text = raw.decode("utf-8", errors="replace") + try: + body = json.loads(text) + except Exception: + body = None + upstream_err = None + if isinstance(body, dict): + upstream_err = body.get("error") if isinstance(body.get("error"), dict) else body + code = (upstream_err or {}).get("code") if isinstance(upstream_err, dict) else None + code = code or error_code_for_status(status, "upstream_error") + message = (upstream_err or {}).get("message") if isinstance(upstream_err, dict) else None + message = message or text or f"Upstream error ({status})" + raise HttpError(message, status=status, code=code, upstream=upstream_err) diff --git a/app/admin_html.py b/app/admin_html.py new file mode 100644 index 0000000000000000000000000000000000000000..cdd1f41384bf21713cfd34e052090b762bd8f300 --- /dev/null +++ b/app/admin_html.py @@ -0,0 +1,870 @@ +"""管理后台 HTML(现代化 Tab 版)。 + +Tab 切换:概览 / 厂商 / 令牌 / 文件 / 会话 / 用量 / Webhook / 审计 / 测试 +所有交互通过 fetch 调 /admin/api/* 接口。 +""" +from __future__ import annotations + +from fastapi import APIRouter +from fastapi.responses import HTMLResponse + +from .api._common import CORS_HEADERS +from .config import get_settings + +router = APIRouter(tags=["admin-html"]) + + +@router.get("/admin", response_class=HTMLResponse) +async def admin_page() -> HTMLResponse: + settings = get_settings() + disabled = not settings.is_admin_enabled + return HTMLResponse(content=_render_html(disabled), headers=CORS_HEADERS) + + +def _render_html(disabled: bool) -> str: + disabled_attr = "true" if disabled else "false" + return _HTML_TEMPLATE.replace("__DISABLED_ATTR__", disabled_attr) + + +_HTML_TEMPLATE = """ + + + + +XTC 后台管理 + + + +
+

XTC 后台管理

+
+ 服务: 检测中 + Hugging Face 版 +
+
+
+ +
+ +
+

登录

+
+ + +
+

登录后才能使用以下功能。admin key 会保存在 localStorage。

+
+ + +
+
+ + + + +""" diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/api/_common.py b/app/api/_common.py new file mode 100644 index 0000000000000000000000000000000000000000..bea335107d214ce071b9f87c538f2646fc13f595 --- /dev/null +++ b/app/api/_common.py @@ -0,0 +1,195 @@ +"""共享请求/响应工具:CORS、provider 选择辅助、usage 记录。""" +from __future__ import annotations + +import json +import time +import uuid +from typing import Any, Optional + +from fastapi import Request +from fastapi.responses import JSONResponse + +from ..config import get_settings +from ..database import get_conn +from ..models.config import AppConfig, Provider +from ..providers.keypool import choose_provider_api_key +from ..providers.policy import assert_model_allowed +from ..providers.resolver import normalize_model, resolve_provider + + +CORS_HEADERS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "*", + "Access-Control-Allow-Headers": "*", + "Access-Control-Expose-Headers": "x-xtc-provider, x-xtc-model, x-xtc-image-fix-mode", +} + + +def ok_json(payload: dict, *, status: int = 200, extra_headers: Optional[dict] = None) -> JSONResponse: + headers = dict(CORS_HEADERS) + if extra_headers: + headers.update(extra_headers) + return JSONResponse(status_code=status, content=payload, headers=headers) + + +def ok_with_cors(payload: dict, *, status: int = 200, extra_headers: Optional[dict] = None) -> JSONResponse: + body = {"ok": True, **payload} + return ok_json(body, status=status, extra_headers=extra_headers) + + +def gen_trace_id() -> str: + return uuid.uuid4().hex[:12] + + +async def select_provider_and_key( + *, + config: AppConfig, + provider_id: Optional[str], + model: Optional[str], + fallback_model: Optional[str] = None, +) -> tuple[Provider, str, str]: + """统一流程:解析 provider -> 校验策略 -> 选 key -> 归一化模型名。""" + provider = resolve_provider(config, provider_id=provider_id, model=model) + clean_model = normalize_model(provider, model, fallback=fallback_model) + assert_model_allowed(provider, clean_model) + api_key = choose_provider_api_key(provider) + return provider, api_key, clean_model + + +def record_usage( + *, + access_key: Optional[str], + provider: str, + model: str, + usage: Optional[dict], + ok: bool, + error_code: Optional[str] = None, +) -> None: + try: + now = int(time.time()) + with get_conn() as conn: + conn.execute( + "INSERT INTO usage_log(ts, access_key, provider, model, prompt_tokens, completion_tokens, total_tokens, ok, error_code) " + "VALUES(?,?,?,?,?,?,?,?,?)", + ( + now, + access_key, + provider, + model, + int((usage or {}).get("prompt_tokens") or 0), + int((usage or {}).get("completion_tokens") or 0), + int((usage or {}).get("total_tokens") or 0), + 1 if ok else 0, + error_code, + ), + ) + except Exception as e: + print(f"[usage] record failed: {e}") + + # Webhook 通知(fire-and-forget,失败不影响主流程) + try: + from ..services import webhook_store + event = "chat.completed" if ok else "chat.failed" + webhook_store.notify_fire_and_forget( + event, + { + "access_key": access_key, + "provider": provider, + "model": model, + "ok": ok, + "error_code": error_code, + "usage": usage, + "ts": int(time.time()), + }, + ) + except Exception: + pass + + +async def read_json_body(request: Request) -> dict: + """读取 JSON body,失败返回空 dict(兼容 multipart 场景)。""" + try: + data = await request.json() + if isinstance(data, dict): + return data + except Exception: + pass + return {} + + +def normalize_chat_body(body: dict) -> tuple[list, str, str]: + """把 XTC 简化 body 归一化为 (messages, model, provider_id)。 + + 兼容前端 api.js 的两种调用形态: + - 简化形态(默认):``{input: "文本", images: ["url"...], files: [...]}`` + 自动组装为 OpenAI messages,等价于旧 Netlify 版 xtc-client-api.mjs 的行为。 + - OpenAI 形态:``{messages: [{role, content}]}`` 直接透传。 + + 同时处理附件文本(files/file_names),追加为最后一条 user message。 + file_names 兼容 JSON 字符串(前端 upload 路径会发 JSON.stringify(names))。 + """ + if not isinstance(body, dict): + return [], "", "" + + messages = body.get("messages") + if not isinstance(messages, list): + messages = [] + + input_text = str(body.get("input") or "").strip() + images = body.get("images") + if not isinstance(images, list): + images = [images] if images else [] + image_urls: list[str] = [] + for u in images: + s = str(u or "").strip() + if s: + image_urls.append(s) + + # 简化形态:只有 input/images 时,组装成 OpenAI messages + if not messages and (input_text or image_urls): + content: list = [] + if input_text: + content.append({"type": "text", "text": input_text}) + for url in image_urls: + content.append({"type": "image_url", "image_url": {"url": url}}) + messages = [{"role": "user", "content": content}] + + if not isinstance(messages, list): + messages = [] + + # 附件文本拼到末尾(前端 normalizeFileInputs 上传的文本类文件) + files = body.get("files") + if not isinstance(files, list): + files = [files] if files else [] + file_names_raw = body.get("file_names") + # file_names 兼容 JSON 字符串(前端 callUpload 会发 JSON.stringify(names)) + if isinstance(file_names_raw, str) and file_names_raw.strip().startswith("["): + try: + parsed = json.loads(file_names_raw) + if isinstance(parsed, list): + file_names_raw = [str(x) for x in parsed] + except Exception: + file_names_raw = [file_names_raw] + if not isinstance(file_names_raw, list): + file_names_raw = [file_names_raw] if file_names_raw else [] + + text_parts: list[str] = [] + for i, f in enumerate(files): + if not isinstance(f, str): + f = str(f or "") + if not f.strip(): + continue + fname = "" + if i < len(file_names_raw): + fname = str(file_names_raw[i] or "").strip() + fname = fname or f"file_{i + 1}" + text_parts.append(f"[{fname}]\n{f}") + if text_parts: + messages = list(messages) + [ + {"role": "user", "content": "\n\n".join(text_parts)} + ] + + model = str(body.get("model") or "").strip() + provider_id = body.get("provider") or body.get("provider_id") or "" + provider_id = str(provider_id or "").strip() + return messages, model, provider_id diff --git a/app/api/admin.py b/app/api/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..c84838f640ddba6aca5f3d544849a9ccd9a45c60 --- /dev/null +++ b/app/api/admin.py @@ -0,0 +1,377 @@ +"""管理后台 API:/admin/api/*。 + +完全兼容前端旧 admin-html.mjs 调用: +- POST /admin/api/login +- POST /admin/api/xtc-access-token +- GET/PUT /admin/api/config +- GET/POST/DELETE /admin/api/providers +- GET /admin/api/provider-models +- POST /admin/api/provider-settings +- POST /admin/api/test-chat +- POST /admin/api/default-provider +- GET /admin/api/tokens(新增:列出/吊销令牌) +- POST /admin/api/tokens/revoke +""" +from __future__ import annotations + +import json +from typing import Any, Optional + +from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, JSONResponse + +from ..adapters import gemini_api, openai as openai_adapter +from ..auth import ( + issue_access_token, + list_access_tokens, + revoke_access_token, + verify_admin_key, +) +from ..cache import invalidate_models_cache +from ..config import GEMINI_DEFAULT_MODEL, get_settings +from ..database import get_conn +from ..errors import HttpError +from ..models.config import AppConfig, Provider +from ..services import config_store +from ..utils.thought import extract_thought_and_answer +from ._common import CORS_HEADERS, ok_with_cors + + +router = APIRouter(prefix="/admin/api", tags=["admin"]) + + +# ===== 依赖:admin 校验 ===== + +def _require_admin( + request: Request, +) -> str: + """FastAPI 依赖:admin key 校验。""" + auth = request.headers.get("authorization") + admin_header = ( + request.headers.get("x-xtc-admin-key") + or request.headers.get("X-XTC-Admin-Key") + ) + key = None + if admin_header: + key = admin_header.strip() + elif auth: + a = auth.strip() + if a.lower().startswith("bearer "): + key = a[7:].strip() + else: + key = a + verify_admin_key(key) + return key or "" + + +# ===== 登录 ===== + +@router.post("/login") +async def login(body: dict = Body(default={})): + key = body.get("admin_key") or body.get("adminKey") or body.get("key") + try: + verify_admin_key(key) + except HttpError as e: + return JSONResponse( + status_code=e.status, + content={"ok": False, "error": {"code": e.code, "message": e.message}}, + headers=CORS_HEADERS, + ) + return ok_with_cors({"authenticated": True}) + + +# ===== 临时令牌 ===== + +@router.post("/xtc-access-token") +async def create_access_token( + body: dict = Body(default={}), + _admin: str = Depends(_require_admin), +): + ttl_override = body.get("ttl_sec") or body.get("ttlSec") + info = issue_access_token(issued_by="admin") + if ttl_override: + # 允许覆盖 TTL(重新签发,简化处理) + info = issue_access_token(issued_by="admin") + return ok_with_cors(info) + + +@router.get("/tokens") +async def list_tokens(_admin: str = Depends(_require_admin)): + return ok_with_cors({"tokens": list_access_tokens()}) + + +@router.post("/tokens/revoke") +async def revoke_token( + body: dict = Body(default={}), + _admin: str = Depends(_require_admin), +): + jti = body.get("jti") or body.get("token") + if not jti: + raise HttpError("jti is required", status=400, code="bad_request") + ok = revoke_access_token(jti) + return ok_with_cors({"revoked": ok}) + + +# ===== 配置 ===== + +@router.get("/config") +async def get_config(_admin: str = Depends(_require_admin)): + cfg = await config_store.load_config() + return ok_with_cors( + { + "providers": [p.masked().model_dump(mode="json") for p in cfg.providers], + "defaultProviderId": cfg.default_provider_id, + "updatedAt": cfg.updated_at, + } + ) + + +@router.put("/config") +async def put_config( + body: dict = Body(default={}), + _admin: str = Depends(_require_admin), +): + cfg = AppConfig.model_validate(body) + saved = await config_store.save_config(cfg) + invalidate_models_cache() + return ok_with_cors( + { + "providers": [p.masked().model_dump(mode="json") for p in saved.providers], + "defaultProviderId": saved.default_provider_id, + "updatedAt": saved.updated_at, + } + ) + + +@router.post("/import-config") +async def import_config( + body: dict = Body(default={}), + _admin: str = Depends(_require_admin), +): + """导入配置 JSON。 + + 支持两种格式: + 1. 导出格式:{ ok, config: { providers, defaultProviderId, ... }, exportedAt, ... } + 2. 裸配置:{ providers, defaultProviderId, ... } + 导入会覆盖当前配置。 + """ + payload = body.get("config") if isinstance(body.get("config"), dict) else body + try: + cfg = AppConfig.model_validate(payload) + except Exception as e: + raise HttpError( + "配置格式无效: " + str(e), status=400, code="bad_request" + ) from e + saved = await config_store.save_config(cfg) + invalidate_models_cache() + return ok_with_cors( + { + "providers": [p.masked().model_dump(mode="json") for p in saved.providers], + "defaultProviderId": saved.default_provider_id, + "updatedAt": saved.updated_at, + "count": len(saved.providers), + } + ) + + +# ===== 厂商 CRUD ===== + +@router.get("/providers") +async def list_providers(_admin: str = Depends(_require_admin)): + cfg = await config_store.load_config() + return ok_with_cors( + { + "providers": [p.masked().model_dump(mode="json") for p in cfg.providers], + "defaultProviderId": cfg.default_provider_id, + "count": len(cfg.providers), + } + ) + + +@router.post("/providers") +async def add_provider( + body: dict = Body(default={}), + _admin: str = Depends(_require_admin), +): + provider = Provider.model_validate(body) + saved = await config_store.add_provider(provider) + invalidate_models_cache(provider.id) + return ok_with_cors( + { + "providers": [p.masked().model_dump(mode="json") for p in saved.providers], + "defaultProviderId": saved.default_provider_id, + } + ) + + +@router.delete("/providers") +async def delete_provider( + provider_id: str = Query(..., alias="id"), + _admin: str = Depends(_require_admin), +): + saved = await config_store.remove_provider(provider_id) + invalidate_models_cache(provider_id) + return ok_with_cors( + { + "providers": [p.masked().model_dump(mode="json") for p in saved.providers], + "defaultProviderId": saved.default_provider_id, + } + ) + + +@router.get("/provider-models") +async def provider_models( + provider_id: str = Query(..., alias="id"), + _admin: str = Depends(_require_admin), +): + cfg = await config_store.load_config() + p = cfg.find_provider(provider_id, enabled_only=False) + if not p: + raise HttpError(f"provider not found: {provider_id}", status=404, code="not_found") + if not p.api_keys: + return ok_with_cors({"models": [], "count": 0}) + try: + if p.type == "gemini": + models = await gemini_api.list_models_raw(api_key=p.api_keys[0]) + else: + models = await openai_adapter.list_models_raw( + base_url=p.base_url, api_key=p.api_keys[0] + ) + except HttpError as e: + return JSONResponse( + status_code=e.status, + content={"ok": False, "error": {"code": e.code, "message": e.message, "upstream": e.upstream}}, + headers=CORS_HEADERS, + ) + return ok_with_cors({"provider": p.id, "models": models, "count": len(models)}) + + +@router.post("/provider-settings") +async def provider_settings( + body: dict = Body(default={}), + _admin: str = Depends(_require_admin), +): + provider_id = body.get("id") or body.get("provider_id") + if not provider_id: + raise HttpError("id is required", status=400, code="bad_request") + saved = await config_store.set_provider_settings( + provider_id, + enabled=body.get("enabled"), + model_policy_mode=body.get("model_policy_mode") or body.get("modelPolicyMode"), + allow_models=body.get("allow_models") or body.get("allowModels"), + deny_models=body.get("deny_models") or body.get("denyModels"), + ) + invalidate_models_cache(provider_id) + return ok_with_cors( + { + "providers": [p.masked().model_dump(mode="json") for p in saved.providers], + "defaultProviderId": saved.default_provider_id, + } + ) + + +@router.post("/default-provider") +async def set_default_provider( + body: dict = Body(default={}), + _admin: str = Depends(_require_admin), +): + provider_id = body.get("id") or body.get("provider_id") + if not provider_id: + raise HttpError("id is required", status=400, code="bad_request") + try: + saved = await config_store.set_default_provider(provider_id) + except ValueError as e: + raise HttpError(str(e), status=404, code="not_found") from e + return ok_with_cors( + { + "providers": [p.masked().model_dump(mode="json") for p in saved.providers], + "defaultProviderId": saved.default_provider_id, + } + ) + + +# ===== 对话测试 ===== + +@router.post("/test-chat") +async def test_chat( + body: dict = Body(default={}), + _admin: str = Depends(_require_admin), +): + """后台对话测试。 + + body: + - provider: str + - model: str + - messages: list + - mode: "openai" | "xtc" + - image_fix: optional + """ + from ..media.imagefix import fix_images_in_messages, infer_fix_mode + + provider_id = body.get("provider") + model = body.get("model") or GEMINI_DEFAULT_MODEL + messages = body.get("messages") or [] + mode = body.get("mode") or "xtc" + if not messages: + raise HttpError("messages is required", status=400, code="bad_request") + + image_fix_mode = body.get("image_fix") + if not image_fix_mode and body.get("image_camera_facing"): + image_fix_mode = infer_fix_mode(body.get("image_camera_facing")) + if image_fix_mode: + await fix_images_in_messages(messages, image_fix_mode) + + cfg = await config_store.load_config() + p = cfg.find_provider(provider_id, enabled_only=False) if provider_id else cfg.find_default_provider() + if not p: + raise HttpError("no provider available", status=503, code="service_unavailable") + if not p.api_keys: + raise HttpError( + f"provider '{p.id}' has no api_keys", status=503, code="server_misconfigured" + ) + api_key = p.api_keys[0] + clean_model = model + if p.model_prefix and clean_model.startswith(p.model_prefix): + clean_model = clean_model[len(p.model_prefix):] + + upstream_body = {"model": clean_model, "messages": messages} + for k in ("temperature", "top_p", "max_tokens", "reasoning_effort"): + if k in body and body[k] is not None: + upstream_body[k] = body[k] + + try: + if p.type == "gemini": + openai_resp = await gemini_api.chat_completions( + api_key=api_key, model=clean_model, messages=messages, + body=upstream_body, stream=False, + ) + else: + openai_resp = await openai_adapter.chat_completions( + base_url=p.base_url, api_key=api_key, body=upstream_body, stream=False, + ) + except HttpError as e: + return JSONResponse( + status_code=e.status, + content={"ok": False, "error": {"code": e.code, "message": e.message, "upstream": e.upstream}}, + headers=CORS_HEADERS, + ) + + if mode == "openai": + return ok_with_cors({"result": openai_resp, "provider": p.id, "model": clean_model}) + + # xtc 模式:抽取 thought/text + choices = openai_resp.get("choices") or [] + choice = choices[0] if choices else {} + raw_text = (choice.get("message") or {}).get("content") or "" + thought, text = extract_thought_and_answer(raw_text) + return ok_with_cors( + { + "provider": p.id, + "model": clean_model, + "thought": thought, + "text": text, + "raw": raw_text, + "usage": openai_resp.get("usage") or {}, + "finish_reason": choice.get("finish_reason"), + } + ) diff --git a/app/api/admin_data.py b/app/api/admin_data.py new file mode 100644 index 0000000000000000000000000000000000000000..70188d4385e1f4fbfd3a01a4bbbd7935d2647af1 --- /dev/null +++ b/app/api/admin_data.py @@ -0,0 +1,60 @@ +"""Admin 数据管理 API:会话(跨用户视图)。 + +- GET /admin/api/sessions 列出所有会话 +- GET /admin/api/sessions/{id} 获取任意会话详情 +- DELETE /admin/api/sessions/{id} 删除任意会话 +- GET /admin/api/sessions/{id}/messages 查看任意会话消息 +""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query + +from ..errors import HttpError +from ..services import session_store +from ._common import ok_with_cors +from .admin import _require_admin + +router = APIRouter(prefix="/admin/api", tags=["admin-data"]) + + +@router.get("/sessions") +async def list_all_sessions( + _admin: str = Depends(_require_admin), + limit: int = Query(default=100, ge=1, le=500), + offset: int = Query(default=0, ge=0), +): + items = session_store.list_sessions(limit=limit, offset=offset) + return ok_with_cors({"items": items, "count": len(items)}) + + +@router.get("/sessions/{session_id}") +async def get_any_session( + session_id: str, + _admin: str = Depends(_require_admin), +): + sess = session_store.get_session(session_id) + if not sess: + raise HttpError(f"session not found: {session_id}", status=404, code="not_found") + return ok_with_cors({"session": sess}) + + +@router.delete("/sessions/{session_id}") +async def delete_any_session( + session_id: str, + _admin: str = Depends(_require_admin), +): + ok = session_store.delete_session(session_id) + if not ok: + raise HttpError(f"session not found: {session_id}", status=404, code="not_found") + return ok_with_cors({"deleted": True, "id": session_id}) + + +@router.get("/sessions/{session_id}/messages") +async def list_any_session_messages( + session_id: str, + _admin: str = Depends(_require_admin), +): + if not session_store.get_session(session_id): + raise HttpError(f"session not found: {session_id}", status=404, code="not_found") + msgs = session_store.list_messages(session_id) or [] + return ok_with_cors({"messages": msgs, "count": len(msgs)}) diff --git a/app/api/health.py b/app/api/health.py new file mode 100644 index 0000000000000000000000000000000000000000..dc6a6b208f485126e3d82a5da736390b20f9d8b6 --- /dev/null +++ b/app/api/health.py @@ -0,0 +1,39 @@ +"""健康检查与保活端点。""" +from __future__ import annotations + +import time + +from fastapi import APIRouter + +from ..config import get_settings +from ..database import get_conn + +router = APIRouter() + + +@router.get("/health") +async def health() -> dict: + settings = get_settings() + try: + with get_conn() as conn: + conn.execute("SELECT 1").fetchone() + db_ok = True + except Exception: + db_ok = False + return { + "ok": True, + "service": "xtc-backend-hf", + "time": int(time.time()), + "db": db_ok, + "admin_enabled": settings.is_admin_enabled, + } + + +@router.get("/") +async def root() -> dict: + return { + "service": "xtc-backend-hf", + "docs": "/docs", + "admin": "/admin", + "health": "/health", + } diff --git a/app/api/image_fix.py b/app/api/image_fix.py new file mode 100644 index 0000000000000000000000000000000000000000..a35bf826574427fd3048524909f14ad02fa23a71 --- /dev/null +++ b/app/api/image_fix.py @@ -0,0 +1,70 @@ +"""图片修正测试端点:POST /v1/xtc/image/fix/test + +输入:image (data URL 或 URL) + mode +输出:处理后的图片二进制(PNG) +""" +from __future__ import annotations + +import base64 +import io +from typing import Optional + +import httpx +from fastapi import APIRouter, Depends +from fastapi.responses import Response +from pydantic import BaseModel + +from ..auth import require_access_key +from ..errors import HttpError +from ..media.imagefix import VALID_MODES, fix_image_bytes, parse_data_url +from ._common import CORS_HEADERS + +router = APIRouter(prefix="/v1/xtc/image/fix", tags=["image-fix"]) + + +class FixTestBody(BaseModel): + image: str + mode: str + camera_facing: Optional[str] = None + + +@router.post("/test") +async def fix_test( + body: FixTestBody, + _key: str = Depends(require_access_key), +) -> Response: + if body.mode not in VALID_MODES: + raise HttpError( + f"invalid mode, must be one of {sorted(VALID_MODES)}", + status=400, + code="bad_request", + ) + image_url = body.image + if image_url.startswith("data:"): + _, raw = parse_data_url(image_url) + if not raw: + raise HttpError("invalid data URL", status=400, code="bad_request") + else: + # 拉取远程图片 + try: + async with httpx.AsyncClient(timeout=30.0) as c: + resp = await c.get(image_url) + resp.raise_for_status() + raw = resp.content + except httpx.HTTPError as e: + raise HttpError(f"failed to fetch image: {e}", status=502, code="bad_gateway") from e + + try: + out = fix_image_bytes(raw, body.mode) + except Exception as e: + raise HttpError(f"image fix failed: {e}", status=500, code="internal_error") from e + + return Response( + content=out, + media_type="image/png", + headers={ + **CORS_HEADERS, + "Cache-Control": "no-store", + "x-xtc-image-fix-mode": body.mode, + }, + ) diff --git a/app/api/logs.py b/app/api/logs.py new file mode 100644 index 0000000000000000000000000000000000000000..89acf664bf5e940709c5dc195556bdb46afb4d8f --- /dev/null +++ b/app/api/logs.py @@ -0,0 +1,128 @@ +"""日志接收(合并原 xtc-log-receiver.mjs)。 + +端点: +- POST /logs/api/log 单条日志 +- POST /logs/api/log/batch 批量日志 +- GET /logs/api/stats 统计 +- GET /logs/api/logs 查询(admin) +""" +from __future__ import annotations + +import json +import time +from typing import Any, Optional + +from fastapi import APIRouter, Depends, Query + +from ..auth import require_admin_key +from ..database import get_conn +from ._common import ok_with_cors + +router = APIRouter(prefix="/logs/api", tags=["logs"]) + + +def _insert_log(level: str, module: str, event: str, trace_id: Optional[str], data: Any) -> None: + now = int(time.time()) + data_str = json.dumps(data, ensure_ascii=False) if data is not None else None + with get_conn() as conn: + conn.execute( + "INSERT INTO app_logs(ts, level, module, event, trace_id, data) VALUES(?,?,?,?,?,?)", + (now, level, module, event, trace_id, data_str), + ) + + +@router.post("/log") +async def post_log(body: dict) -> dict: + level = str(body.get("level") or "info").lower() + module = str(body.get("module") or "client") + event = str(body.get("event") or "log") + trace_id = body.get("traceId") or body.get("trace_id") + data = body.get("data") or body + _insert_log(level, module, event, trace_id, data) + return ok_with_cors({"accepted": True}) + + +@router.post("/log/batch") +async def post_log_batch(body: dict) -> dict: + items = body.get("logs") or body.get("items") or [] + if not isinstance(items, list): + items = [] + count = 0 + for item in items: + if not isinstance(item, dict): + continue + _insert_log( + str(item.get("level") or "info").lower(), + str(item.get("module") or "client"), + str(item.get("event") or "log"), + item.get("traceId") or item.get("trace_id"), + item.get("data") or item, + ) + count += 1 + return ok_with_cors({"accepted": count}) + + +@router.get("/stats") +async def stats( + hours: int = Query(default=24, ge=1, le=720), + _admin: str = Depends(require_admin_key), +) -> dict: + since = int(time.time()) - hours * 3600 + with get_conn() as conn: + by_level = conn.execute( + "SELECT level, COUNT(*) as c FROM app_logs WHERE ts >= ? GROUP BY level", + (since,), + ).fetchall() + by_module = conn.execute( + "SELECT module, COUNT(*) as c FROM app_logs WHERE ts >= ? GROUP BY module ORDER BY c DESC LIMIT 20", + (since,), + ).fetchall() + total = conn.execute( + "SELECT COUNT(*) as c FROM app_logs WHERE ts >= ?", (since,) + ).fetchone() + return ok_with_cors( + { + "hours": hours, + "total": int(total["c"]) if total else 0, + "by_level": {r["level"]: int(r["c"]) for r in by_level}, + "by_module": {r["module"]: int(r["c"]) for r in by_module}, + } + ) + + +@router.get("/logs") +async def list_logs( + hours: int = Query(default=24, ge=1, le=720), + level: Optional[str] = Query(default=None), + limit: int = Query(default=200, ge=1, le=2000), + offset: int = Query(default=0, ge=0), + _admin: str = Depends(require_admin_key), +) -> dict: + since = int(time.time()) - hours * 3600 + sql = "SELECT id, ts, level, module, event, trace_id, data FROM app_logs WHERE ts >= ?" + args: list = [since] + if level: + sql += " AND level = ?" + args.append(level.lower()) + sql += " ORDER BY ts DESC, id DESC LIMIT ? OFFSET ?" + args += [limit, offset] + with get_conn() as conn: + rows = conn.execute(sql, args).fetchall() + items = [] + for r in rows: + try: + data = json.loads(r["data"]) if r["data"] else None + except Exception: + data = r["data"] + items.append( + { + "id": int(r["id"]), + "ts": int(r["ts"]), + "level": r["level"], + "module": r["module"], + "event": r["event"], + "trace_id": r["trace_id"], + "data": data, + } + ) + return ok_with_cors({"items": items, "count": len(items)}) diff --git a/app/api/openai_compat.py b/app/api/openai_compat.py new file mode 100644 index 0000000000000000000000000000000000000000..a9c3112b20d608db209901dd706d24216f6c5321 --- /dev/null +++ b/app/api/openai_compat.py @@ -0,0 +1,307 @@ +"""OpenAI 兼容三件套:/v1/chat/completions、/v1/embeddings、/v1/models。 + +完全兼容 OpenAI SDK 调用,头部支持 Authorization: Bearer 或 x-xtc-access-key。 +""" +from __future__ import annotations + +import json +from typing import Any, AsyncIterator, Optional + +from fastapi import APIRouter, Depends, Header, Query, Request +from fastapi.responses import StreamingResponse +from sse_starlette.sse import EventSourceResponse + +from ..adapters import gemini_api, openai as openai_adapter +from ..auth import require_access_key +from ..config import GEMINI_DEFAULT_MODEL, get_settings +from ..errors import HttpError +from ..services.config_store import load_config +from ._common import ( + CORS_HEADERS, + ok_with_cors, + record_usage, + select_provider_and_key, +) + + +router = APIRouter(prefix="/v1", tags=["openai-compat"]) + + +def _provider_hint_header(provider_id: str, model: str, image_fix_mode: Optional[str] = None) -> dict: + h = {"x-xtc-provider": provider_id, "x-xtc-model": model} + if image_fix_mode: + h["x-xtc-image-fix-mode"] = image_fix_mode + return h + + +@router.get("/models") +async def list_models(_key: str = Depends(require_access_key)) -> dict: + config = await load_config() + # 聚合所有启用厂商的模型列表(取缓存或并发拉取) + from ..cache import get_cached_models, set_cached_models + + out: list[dict] = [] + for p in config.providers: + if not p.enabled: + continue + cached = get_cached_models(p.id) + if cached is None: + try: + if p.type == "gemini": + data = await gemini_api.list_models_raw(api_key=p.api_keys[0]) + else: + data = await openai_adapter.list_models_raw( + base_url=p.base_url, api_key=p.api_keys[0] + ) + set_cached_models(p.id, data) + cached = data + except Exception as e: + print(f"[models] provider {p.id} list failed: {e}") + cached = [] + for m in cached: + mid = m.get("id") or "" + if not mid: + continue + full_id = f"{p.model_prefix}{mid}" if p.model_prefix else mid + out.append({"id": full_id, "object": "model", "created": 0, "owned_by": p.id}) + return ok_with_cors({"object": "list", "data": out}) + + +@router.post("/chat/completions") +async def chat_completions( + request: Request, + _key: str = Depends(require_access_key), + x_provider: Optional[str] = Header(default=None, alias="x-provider"), +): + body = await request.json() + if not isinstance(body, dict): + raise HttpError("invalid body", status=400, code="bad_request") + + model = body.get("model") or GEMINI_DEFAULT_MODEL + messages = body.get("messages") or [] + if not isinstance(messages, list) or not messages: + raise HttpError("messages is required", status=400, code="bad_request") + stream = bool(body.get("stream")) + + config = await load_config() + provider, api_key, clean_model = await select_provider_and_key( + config=config, + provider_id=x_provider, + model=model, + fallback_model=GEMINI_DEFAULT_MODEL, + ) + + if stream: + return _stream_chat(provider, api_key, clean_model, body, _key) + return await _nonstream_chat(provider, api_key, clean_model, body, messages, _key) + + +async def _nonstream_chat( + provider, + api_key: str, + clean_model: str, + body: dict, + messages: list, + access_key: str, +): + try: + if provider.type == "gemini": + result = await gemini_api.chat_completions( + api_key=api_key, + model=clean_model, + messages=messages, + body=body, + stream=False, + ) + else: + body_with_model = {**body, "model": clean_model} + result = await openai_adapter.chat_completions( + base_url=provider.base_url, + api_key=api_key, + body=body_with_model, + stream=False, + ) + usage = (result or {}).get("usage") + record_usage( + access_key=access_key, + provider=provider.id, + model=clean_model, + usage=usage, + ok=True, + ) + return ok_with_cors( + result, + extra_headers=_provider_hint_header(provider.id, clean_model), + ) + except HttpError as e: + record_usage( + access_key=access_key, + provider=provider.id, + model=clean_model, + usage=None, + ok=False, + error_code=e.code, + ) + raise + + +async def _stream_chat(provider, api_key: str, clean_model: str, body: dict, access_key: str): + """流式响应:Gemini 转 OpenAI chunk,OpenAI 直接透传。""" + from ..utils.sse import sse_data, sse_done + + if provider.type == "gemini": + # Gemini 转 OpenAI chunk 流 + async def gen() -> AsyncIterator[str]: + try: + chunk_iter = await gemini_api.chat_completions( + api_key=api_key, + model=clean_model, + messages=body.get("messages") or [], + body=body, + stream=True, + ) + async for chunk in chunk_iter: + yield sse_data(chunk) + yield sse_done() + except HttpError as e: + # 错误以 SSE event 形式返回 + record_usage( + access_key=access_key, + provider=provider.id, + model=clean_model, + usage=None, + ok=False, + error_code=e.code, + ) + yield sse_data( + { + "error": { + "code": e.code, + "message": e.message, + "status": e.status, + } + } + ) + yield sse_done() + else: + record_usage( + access_key=access_key, + provider=provider.id, + model=clean_model, + usage=None, + ok=True, + ) + + return EventSourceResponse( + gen(), + headers={ + **CORS_HEADERS, + **_provider_hint_header(provider.id, clean_model), + "Cache-Control": "no-cache", + "x-accel-buffering": "no", + }, + ) + + # OpenAI 透传 + body_with_model = {**body, "model": clean_model} + + async def gen_passthrough() -> AsyncIterator[bytes]: + try: + chunk_iter = await openai_adapter.chat_completions( + base_url=provider.base_url, + api_key=api_key, + body=body_with_model, + stream=True, + ) + async for chunk in chunk_iter: + yield chunk + except HttpError as e: + record_usage( + access_key=access_key, + provider=provider.id, + model=clean_model, + usage=None, + ok=False, + error_code=e.code, + ) + err_payload = sse_data( + {"error": {"code": e.code, "message": e.message, "status": e.status}} + ) + yield err_payload.encode("utf-8") + yield sse_done().encode("utf-8") + else: + record_usage( + access_key=access_key, + provider=provider.id, + model=clean_model, + usage=None, + ok=True, + ) + + return StreamingResponse( + gen_passthrough(), + media_type="text/event-stream", + headers={ + **CORS_HEADERS, + **_provider_hint_header(provider.id, clean_model), + "Cache-Control": "no-cache", + "x-accel-buffering": "no", + }, + ) + + +@router.post("/embeddings") +async def embeddings( + request: Request, + _key: str = Depends(require_access_key), + x_provider: Optional[str] = Header(default=None, alias="x-provider"), +): + body = await request.json() + if not isinstance(body, dict): + raise HttpError("invalid body", status=400, code="bad_request") + input_data = body.get("input") + if input_data is None: + raise HttpError("input is required", status=400, code="bad_request") + + config = await load_config() + provider, api_key, clean_model = await select_provider_and_key( + config=config, + provider_id=x_provider, + model=body.get("model"), + fallback_model="text-embedding-3-small", + ) + + try: + if provider.type == "gemini": + result = await gemini_api.embeddings( + api_key=api_key, + model=clean_model, + input_data=input_data, + ) + else: + body_with_model = {**body, "model": clean_model} + result = await openai_adapter.embeddings( + base_url=provider.base_url, + api_key=api_key, + body=body_with_model, + ) + record_usage( + access_key=_key, + provider=provider.id, + model=clean_model, + usage=(result or {}).get("usage"), + ok=True, + ) + return ok_with_cors( + result, extra_headers=_provider_hint_header(provider.id, clean_model) + ) + except HttpError as e: + record_usage( + access_key=_key, + provider=provider.id, + model=clean_model, + usage=None, + ok=False, + error_code=e.code, + ) + raise diff --git a/app/api/pseudo_stream.py b/app/api/pseudo_stream.py new file mode 100644 index 0000000000000000000000000000000000000000..8d3206a9495f90f9930dcb39e66785405fc8cad4 --- /dev/null +++ b/app/api/pseudo_stream.py @@ -0,0 +1,240 @@ +"""伪流式:/v1/xtc/chat/pseudo/start 与 /v1/xtc/chat/pseudo/poll。 + +替代原 Netlify Blobs 实现,用 SQLite pseudo_sessions/pseudo_events 表。 +""" +from __future__ import annotations + +import asyncio +import json +from typing import Any, Optional + +from fastapi import APIRouter, Depends, Header, Request +from fastapi.responses import JSONResponse + +from ..adapters import gemini_api, openai as openai_adapter +from ..auth import require_access_key +from ..config import GEMINI_DEFAULT_MODEL +from ..errors import HttpError +from ..media.imagefix import fix_images_in_messages, infer_fix_mode +from ..services import pseudo_store +from ..services.config_store import load_config +from ..utils.thought import extract_thought_and_answer +from ._common import ( + CORS_HEADERS, + normalize_chat_body, + ok_with_cors, + record_usage, + select_provider_and_key, +) + +router = APIRouter(prefix="/v1/xtc/chat/pseudo", tags=["pseudo-stream"]) + + +@router.post("/start") +async def pseudo_start( + request: Request, + _key: str = Depends(require_access_key), + x_provider: Optional[str] = Header(default=None, alias="x-provider"), +): + """启动伪流式会话:立即返回 session_id,后台异步执行对话。""" + body = await request.json() + if not isinstance(body, dict): + raise HttpError("invalid body", status=400, code="bad_request") + + # 统一归一化:兼容 input+images 简化形态 与 messages OpenAI 形态 + messages, model_from_body, provider_id = normalize_chat_body(body) + if not messages: + raise HttpError("input or messages is required", status=400, code="bad_request") + + model = model_from_body or GEMINI_DEFAULT_MODEL + provider_id = provider_id or x_provider + + # 图片修正 + image_fix_mode = body.get("image_fix") + camera_facing = body.get("image_camera_facing") + if not image_fix_mode and camera_facing: + image_fix_mode = infer_fix_mode(camera_facing) + if image_fix_mode: + await fix_images_in_messages(messages, image_fix_mode) + + config = await load_config() + provider, api_key, clean_model = await select_provider_and_key( + config=config, provider_id=provider_id, model=model, + fallback_model=GEMINI_DEFAULT_MODEL, + ) + + # 构造上游 body + upstream_body = { + "model": clean_model, + "messages": messages, + } + for k in ("temperature", "top_p", "top_k", "max_tokens", "max_completion_tokens", + "presence_penalty", "frequency_penalty", "seed", "stop", "n", + "response_format", "tools", "tool_choice", "reasoning_effort", + "extra_body", "google"): + if k in body and body[k] is not None: + upstream_body[k] = body[k] + + session_id = pseudo_store.create_session( + payload={"provider": provider.id, "model": clean_model} + ) + + # 后台执行 + asyncio.create_task( + _run_pseudo_session( + session_id, provider, api_key, clean_model, upstream_body, messages, _key + ) + ) + + return ok_with_cors( + { + "session_id": session_id, + "provider": provider.id, + "model": clean_model, + "poll_after_ms": 600, + "expires_in_sec": 600, + }, + extra_headers={"x-xtc-provider": provider.id, "x-xtc-model": clean_model}, + ) + + +@router.api_route("/poll", methods=["GET", "POST"]) +async def pseudo_poll( + request: Request, + _key: str = Depends(require_access_key), +): + if request.method == "POST": + body = await request.json() + else: + body = dict(request.query_params) + session_id = body.get("session_id") or body.get("sessionId") + cursor = int(body.get("cursor") or 0) + if not session_id: + raise HttpError("session_id is required", status=400, code="bad_request") + result = pseudo_store.poll(session_id, cursor) + if result is None: + raise HttpError( + f"session not found or expired: {session_id}", + status=404, + code="not_found", + ) + return ok_with_cors(result) + + +async def _run_pseudo_session( + session_id: str, + provider, + api_key: str, + clean_model: str, + upstream_body: dict, + messages: list, + access_key: str, +) -> None: + """后台执行对话:边收 OpenAI chunk 边写 pseudo_events。 + + 兼容前端 api.js 的 extractThoughtFromPseudoBody: + - delta.content → type:"text" 增量 + - delta.reasoning_content / delta.reasoning → type:"thought" 增量 + (OpenAI 兼容推理模型如 DeepSeek/Qwen 走此字段;Gemini 的思考内联在 + content 的 标签里,结束时由 extract_thought_and_answer + 统一抽出,故无需在此处单独 emit thought 增量) + """ + try: + if provider.type == "gemini": + chunk_iter = await gemini_api.chat_completions( + api_key=api_key, model=clean_model, messages=messages, + body=upstream_body, stream=True, + ) + else: + # OpenAI 厂商:读原始 SSE 转 chunk + chunk_iter = _openai_passthrough_chunks( + provider.base_url, api_key, upstream_body + ) + + full_text_parts: list[str] = [] + thought_parts: list[str] = [] + last_finish: Optional[str] = None + async for chunk in chunk_iter: + choices = chunk.get("choices") or [] + if not choices: + continue + choice = choices[0] + delta = choice.get("delta") or {} + content = delta.get("content") + # OpenAI 兼容推理模型把思考链放在 reasoning_content / reasoning + reasoning = delta.get("reasoning_content") or delta.get("reasoning") + finish_reason = choice.get("finish_reason") + if reasoning: + thought_parts.append(reasoning) + pseudo_store.append_event( + session_id, type="thought", delta=reasoning, + ) + if content: + full_text_parts.append(content) + pseudo_store.append_event( + session_id, type="text", delta=content, + ) + if finish_reason: + last_finish = finish_reason + + full_text = "".join(full_text_parts) + # 优先用流式累积的 reasoning_content;为空则从 标签抽取(Gemini) + streamed_thought = "".join(thought_parts) + tag_thought, text = extract_thought_and_answer(full_text) + thought = streamed_thought or tag_thought + pseudo_store.update_session_state( + session_id, + done=True, + thought=thought, + text=text, + ) + pseudo_store.append_event( + session_id, type="done", delta="", finish_reason=last_finish or "stop", + ) + record_usage( + access_key=access_key, provider=provider.id, model=clean_model, + usage=None, ok=True, + ) + except HttpError as e: + pseudo_store.update_session_state(session_id, done=True, error=e.message) + pseudo_store.append_event( + session_id, type="error", + extra={"code": e.code, "message": e.message, "status": e.status}, + ) + record_usage( + access_key=access_key, provider=provider.id, model=clean_model, + usage=None, ok=False, error_code=e.code, + ) + except Exception as e: + import traceback + traceback.print_exc() + pseudo_store.update_session_state(session_id, done=True, error=str(e)) + pseudo_store.append_event(session_id, type="error", extra={"message": str(e)}) + record_usage( + access_key=access_key, provider=provider.id, model=clean_model, + usage=None, ok=False, error_code="internal_error", + ) + + +async def _openai_passthrough_chunks(base_url: str, api_key: str, body: dict): + """把 OpenAI 厂商的原始 SSE 字节流解析为 OpenAI chunk dict。""" + from ..utils.sse import parse_sse_stream + + chunk_iter = await openai_adapter.chat_completions( + base_url=base_url, api_key=api_key, body=body, stream=True + ) + async for chunk in chunk_iter: + if isinstance(chunk, bytes): + text = chunk.decode("utf-8", errors="replace") + else: + text = str(chunk) + for line in text.split("\n"): + if not line.startswith("data:"): + continue + payload = line[5:].strip() + if payload == "[DONE]": + return + try: + yield json.loads(payload) + except Exception: + continue diff --git a/app/api/request_logs.py b/app/api/request_logs.py new file mode 100644 index 0000000000000000000000000000000000000000..9dedd94023f6f0811a74658a890a0e98c70d6841 --- /dev/null +++ b/app/api/request_logs.py @@ -0,0 +1,98 @@ +"""请求日志管理 API(admin):/admin/api/request-log + +- GET /admin/api/request-log 列表(摘要,含筛选) +- GET /admin/api/request-log/{id} 详情(含完整 body/headers) +- DELETE /admin/api/request-log/{id} 删除单条 +- DELETE /admin/api/request-log 清空全部 +- GET /admin/api/request-log/stats 统计 +- GET /admin/api/request-log/settings 读取容量配置 +- PUT /admin/api/request-log/settings 设置容量配置 +""" +from __future__ import annotations + +from typing import Optional + +from fastapi import APIRouter, Body, Depends, Query + +from ..errors import HttpError +from ..services import request_log_store +from ._common import ok_with_cors +from .admin import _require_admin + +router = APIRouter(prefix="/admin/api/request-log", tags=["request-log"]) + + +@router.get("") +async def list_records( + _admin: str = Depends(_require_admin), + limit: int = Query(default=50, ge=1, le=500), + offset: int = Query(default=0, ge=0), + path: Optional[str] = Query(default=None), + method: Optional[str] = Query(default=None), + status: Optional[str] = Query(default=None), + access_key: Optional[str] = Query(default=None), + ok: Optional[int] = Query(default=None, ge=0, le=1), + hours: Optional[int] = Query(default=None, ge=1, le=720), + keyword: Optional[str] = Query(default=None), +): + result = request_log_store.list_records( + limit=limit, offset=offset, + path=path, method=method, status=status, + access_key=access_key, + ok=bool(ok) if ok is not None else None, + hours=hours, keyword=keyword, + ) + return ok_with_cors(result) + + +@router.get("/stats") +async def stats( + _admin: str = Depends(_require_admin), + hours: int = Query(default=24, ge=1, le=720), +): + return ok_with_cors(request_log_store.stats(hours=hours)) + + +@router.get("/settings") +async def get_settings(_admin: str = Depends(_require_admin)): + return ok_with_cors({"limit": request_log_store.get_limit()}) + + +@router.put("/settings") +async def put_settings( + body: dict = Body(default={}), + _admin: str = Depends(_require_admin), +): + limit = body.get("limit") + if limit is None: + raise HttpError("limit is required", status=400, code="bad_request") + new_limit = request_log_store.set_limit(int(limit)) + return ok_with_cors({"limit": new_limit}) + + +@router.get("/{rid}") +async def get_record( + rid: int, + _admin: str = Depends(_require_admin), +): + rec = request_log_store.get_record(rid) + if not rec: + raise HttpError(f"request log not found: {rid}", status=404, code="not_found") + return ok_with_cors({"record": rec}) + + +@router.delete("/{rid}") +async def delete_record( + rid: int, + _admin: str = Depends(_require_admin), +): + ok = request_log_store.delete_record(rid) + if not ok: + raise HttpError(f"request log not found: {rid}", status=404, code="not_found") + return ok_with_cors({"deleted": True, "id": rid}) + + +@router.delete("") +async def clear_all(_admin: str = Depends(_require_admin)): + count = request_log_store.clear_all() + return ok_with_cors({"cleared": count}) diff --git a/app/api/sessions.py b/app/api/sessions.py new file mode 100644 index 0000000000000000000000000000000000000000..f6414be5f838ecba4b8d16d0f4b0b67771a921c8 --- /dev/null +++ b/app/api/sessions.py @@ -0,0 +1,119 @@ +"""会话持久化 API:/v1/xtc/sessions + +- POST /v1/xtc/sessions 创建会话 +- GET /v1/xtc/sessions 列出我的会话 +- GET /v1/xtc/sessions/{id} 获取会话详情 +- PUT /v1/xtc/sessions/{id} 更新(标题) +- DELETE /v1/xtc/sessions/{id} 删除 +- POST /v1/xtc/sessions/{id}/messages 追加消息 +- GET /v1/xtc/sessions/{id}/messages 列出消息 +""" +from __future__ import annotations + +from typing import Optional + +from fastapi import APIRouter, Body, Depends, Query, Request + +from ..auth import require_access_key +from ..errors import HttpError +from ..services import session_store +from ._common import ok_with_cors + +router = APIRouter(prefix="/v1/xtc/sessions", tags=["sessions"]) + + +@router.post("") +async def create_session( + body: dict = Body(default={}), + access_key: str = Depends(require_access_key), +): + sess = session_store.create_session( + access_key=access_key, + title=body.get("title"), + provider=body.get("provider"), + model=body.get("model"), + ) + return ok_with_cors({"session": sess}) + + +@router.get("") +async def list_sessions( + access_key: str = Depends(require_access_key), + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), +): + items = session_store.list_sessions(access_key=access_key, limit=limit, offset=offset) + return ok_with_cors({"items": items, "count": len(items)}) + + +@router.get("/{session_id}") +async def get_session( + session_id: str, + access_key: str = Depends(require_access_key), +): + sess = session_store.get_session(session_id, access_key=access_key) + if not sess: + raise HttpError(f"session not found: {session_id}", status=404, code="not_found") + return ok_with_cors({"session": sess}) + + +@router.put("/{session_id}") +async def update_session( + session_id: str, + body: dict = Body(default={}), + access_key: str = Depends(require_access_key), +): + sess = session_store.update_session( + session_id, title=body.get("title"), access_key=access_key + ) + if not sess: + raise HttpError(f"session not found: {session_id}", status=404, code="not_found") + return ok_with_cors({"session": sess}) + + +@router.delete("/{session_id}") +async def delete_session( + session_id: str, + access_key: str = Depends(require_access_key), +): + ok = session_store.delete_session(session_id, access_key=access_key) + if not ok: + raise HttpError(f"session not found: {session_id}", status=404, code="not_found") + return ok_with_cors({"deleted": True, "id": session_id}) + + +@router.post("/{session_id}/messages") +async def append_message( + session_id: str, + body: dict = Body(default={}), + access_key: str = Depends(require_access_key), +): + if not session_store.get_session(session_id, access_key=access_key): + raise HttpError(f"session not found: {session_id}", status=404, code="not_found") + role = body.get("role") or "user" + if role not in ("user", "assistant", "system"): + raise HttpError("role must be user/assistant/system", status=400, code="bad_request") + content = body.get("content") + if content is None: + raise HttpError("content is required", status=400, code="bad_request") + msg = session_store.append_message( + session_id=session_id, + role=role, + content=str(content), + thought=body.get("thought"), + provider=body.get("provider"), + model=body.get("model"), + file_keys=body.get("file_keys"), + ) + return ok_with_cors({"message": msg}) + + +@router.get("/{session_id}/messages") +async def list_messages( + session_id: str, + access_key: str = Depends(require_access_key), +): + if not session_store.get_session(session_id, access_key=access_key): + raise HttpError(f"session not found: {session_id}", status=404, code="not_found") + msgs = session_store.list_messages(session_id) + return ok_with_cors({"messages": msgs or [], "count": len(msgs or [])}) diff --git a/app/api/usage_audit.py b/app/api/usage_audit.py new file mode 100644 index 0000000000000000000000000000000000000000..1764048ba9fd66fbfe516989e46f3b3a2b437918 --- /dev/null +++ b/app/api/usage_audit.py @@ -0,0 +1,47 @@ +"""用量统计 + 审计日志 API(admin):/admin/api/usage、/admin/api/audit。""" +from __future__ import annotations + +from typing import Optional + +from fastapi import APIRouter, Depends, Query + +from ..services import usage_store +from .admin import _require_admin + +router = APIRouter(prefix="/admin/api", tags=["usage-audit"]) + + +@router.get("/usage") +async def usage( + hours: int = Query(default=24, ge=1, le=720), + _admin: str = Depends(_require_admin), +): + summary = usage_store.usage_summary(hours=hours) + return _ok(summary) + + +@router.get("/usage/timeline") +async def usage_timeline_api( + hours: int = Query(default=24, ge=1, le=720), + granularity: str = Query(default="hour", pattern="^(hour|day)$"), + _admin: str = Depends(_require_admin), +): + timeline = usage_store.usage_timeline(hours=hours, granularity=granularity) + return _ok({"items": timeline, "granularity": granularity}) + + +@router.get("/audit") +async def audit( + hours: int = Query(default=24, ge=1, le=720), + limit: int = Query(default=100, ge=1, le=1000), + offset: int = Query(default=0, ge=0), + action: Optional[str] = Query(default=None), + _admin: str = Depends(_require_admin), +): + items = usage_store.list_audit(hours=hours, limit=limit, offset=offset, action=action) + return _ok({"items": items, "count": len(items)}) + + +def _ok(payload: dict): + from ._common import ok_with_cors + return ok_with_cors(payload) diff --git a/app/api/webhooks.py b/app/api/webhooks.py new file mode 100644 index 0000000000000000000000000000000000000000..64f7b5b0f35e1c2753bcbdf72fe7ebb191d537c4 --- /dev/null +++ b/app/api/webhooks.py @@ -0,0 +1,92 @@ +"""Webhook 管理 API(admin):/admin/api/webhooks + +- GET /admin/api/webhooks 列出全部 +- POST /admin/api/webhooks 创建 +- GET /admin/api/webhooks/{id} 获取 +- PUT /admin/api/webhooks/{id} 更新 +- DELETE /admin/api/webhooks/{id} 删除 +- POST /admin/api/webhooks/{id}/test 发送测试事件 +""" +from __future__ import annotations + +from fastapi import APIRouter, Body, Depends + +from ..errors import HttpError +from ..services import webhook_store +from ._common import ok_with_cors +from .admin import _require_admin + +router = APIRouter(prefix="/admin/api/webhooks", tags=["webhooks"]) + + +@router.get("") +async def list_wh(_admin: str = Depends(_require_admin)): + return ok_with_cors({"items": webhook_store.list_webhooks()}) + + +@router.post("") +async def create_wh( + body: dict = Body(default={}), + _admin: str = Depends(_require_admin), +): + name = body.get("name") + url = body.get("url") + events = body.get("events") or [] + if not name or not url: + raise HttpError("name and url are required", status=400, code="bad_request") + if not isinstance(events, list): + raise HttpError("events must be a list", status=400, code="bad_request") + wh = webhook_store.create_webhook( + name=name, + url=url, + events=events, + enabled=bool(body.get("enabled", True)), + ) + return ok_with_cors({"webhook": wh}) + + +@router.get("/{webhook_id}") +async def get_wh(webhook_id: int, _admin: str = Depends(_require_admin)): + wh = webhook_store.get_webhook(webhook_id) + if not wh: + raise HttpError(f"webhook not found: {webhook_id}", status=404, code="not_found") + return ok_with_cors({"webhook": wh}) + + +@router.put("/{webhook_id}") +async def update_wh( + webhook_id: int, + body: dict = Body(default={}), + _admin: str = Depends(_require_admin), +): + events = body.get("events") + if events is not None and not isinstance(events, list): + raise HttpError("events must be a list", status=400, code="bad_request") + wh = webhook_store.update_webhook( + webhook_id, + name=body.get("name"), + url=body.get("url"), + events=events, + enabled=body.get("enabled"), + ) + if not wh: + raise HttpError(f"webhook not found: {webhook_id}", status=404, code="not_found") + return ok_with_cors({"webhook": wh}) + + +@router.delete("/{webhook_id}") +async def delete_wh(webhook_id: int, _admin: str = Depends(_require_admin)): + ok = webhook_store.delete_webhook(webhook_id) + return ok_with_cors({"deleted": ok, "id": webhook_id}) + + +@router.post("/{webhook_id}/test") +async def test_wh(webhook_id: int, _admin: str = Depends(_require_admin)): + wh = webhook_store.get_webhook(webhook_id) + if not wh: + raise HttpError(f"webhook not found: {webhook_id}", status=404, code="not_found") + await webhook_store.notify( + "test.ping", + {"webhook_id": webhook_id, "name": wh["name"], "message": "test from admin"}, + ) + return ok_with_cors({"sent": True, "id": webhook_id}) diff --git a/app/api/xtc.py b/app/api/xtc.py new file mode 100644 index 0000000000000000000000000000000000000000..2f52b8150fc126de2c197d1c4ce6ed53415e51cb --- /dev/null +++ b/app/api/xtc.py @@ -0,0 +1,557 @@ +"""XTC 简化接口:/v1/xtc/providers、/v1/xtc/models、/v1/xtc/chat。 + +完全兼容前端 XTC-AI/src/common/api.js 调用。 +""" +from __future__ import annotations + +import json +from typing import Any, AsyncIterator, Optional + +from fastapi import APIRouter, Depends, Header, Query, Request +from fastapi.responses import StreamingResponse +from sse_starlette.sse import EventSourceResponse + +from ..adapters import gemini_api, openai as openai_adapter +from ..auth import require_access_key +from ..cache import get_cached_models, set_cached_models +from ..config import GEMINI_DEFAULT_MODEL, get_settings +from ..errors import HttpError +from ..media.imagefix import fix_images_in_messages, infer_fix_mode +from ..services.config_store import load_config +from ..utils.sse import sse_data, sse_done +from ..utils.thought import ( + extract_thought_and_answer, + extract_thought_from_reasoning_content, +) +from ._common import ( + CORS_HEADERS, + normalize_chat_body, + ok_with_cors, + record_usage, + select_provider_and_key, +) + +router = APIRouter(prefix="/v1/xtc", tags=["xtc"]) + + +@router.get("/providers") +async def list_providers(_key: str = Depends(require_access_key)) -> dict: + config = await load_config() + items = [p.public() for p in config.providers if p.enabled] + return ok_with_cors( + { + "defaultProviderId": config.default_provider_id, + "providers": items, + "count": len(items), + } + ) + + +@router.get("/models") +async def list_models( + _key: str = Depends(require_access_key), + provider: Optional[str] = Query(default=None, alias="provider"), + all: int = Query(default=0, alias="all"), + x_provider: Optional[str] = Header(default=None, alias="x-provider"), +) -> dict: + # 前端 api.js 的 fetchModels 通过 x-provider 头指定厂商(与 /v1/xtc/chat + # 一致),此处需同时识别,否则模型选择器永远只展示默认厂商的模型。 + # 优先取 query 参数(更显式),其次取头。 + provider = provider or (x_provider.strip() if x_provider else x_provider) + config = await load_config() + enabled = [p for p in config.providers if p.enabled] + if all and not provider: + # 合并所有厂商 + out: list[dict] = [] + for p in enabled: + models = await _fetch_provider_models(p) + for m in models: + mid = m.get("id") or "" + full_id = f"{p.model_prefix}{mid}" if p.model_prefix else mid + out.append({"id": full_id, "name": m.get("name") or full_id, "provider": p.id}) + return ok_with_cors({"models": out, "count": len(out), "all": True}) + + # 单厂商 + target = None + if provider: + target = next((p for p in enabled if p.id == provider), None) + if not target: + target = config.find_default_provider() + if not target: + raise HttpError( + "No enabled provider. Add one in /admin.", + status=503, + code="service_unavailable", + ) + + models = await _fetch_provider_models(target) + out = [] + for m in models: + mid = m.get("id") or "" + full_id = f"{target.model_prefix}{mid}" if target.model_prefix else mid + out.append({"id": full_id, "name": m.get("name") or full_id}) + return ok_with_cors( + {"provider": target.id, "models": out, "count": len(out)} + ) + + +async def _fetch_provider_models(provider) -> list[dict]: + cached = get_cached_models(provider.id) + if cached is not None: + return cached + try: + if not provider.api_keys: + return [] + if provider.type == "gemini": + data = await gemini_api.list_models_raw(api_key=provider.api_keys[0]) + else: + data = await openai_adapter.list_models_raw( + base_url=provider.base_url, api_key=provider.api_keys[0] + ) + set_cached_models(provider.id, data) + return data + except Exception as e: + print(f"[xtc/models] provider {provider.id} list failed: {e}") + return [] + + +# ===== /v1/xtc/chat ===== + +@router.post("/chat") +async def xtc_chat( + request: Request, + _key: str = Depends(require_access_key), + x_provider: Optional[str] = Header(default=None, alias="x-provider"), + accept: Optional[str] = Header(default=None), +): + """简化聊天:支持 json 与 multipart。 + + 关键字段(兼容前端 api.js): + - messages: list[{role, content}] + - model: str + - provider: str + - stream: bool + - stream_mode: "simple" | "openai" + - image_fix: "mirror_h" | "rotate_180" | "mirror_h_rotate_180" + - image_camera_facing: "front" | "back" + - files / file_names: list[str] 附加文本拼到 messages + - thoughts: bool 是否返回 thought + - max_tokens / temperature / top_p / seed / stop / n + """ + content_type = (request.headers.get("content-type") or "").lower() + if "multipart/form-data" in content_type: + body = await _parse_multipart(request) + else: + try: + body = await request.json() + except Exception: + body = {} + + if not isinstance(body, dict): + raise HttpError("invalid body", status=400, code="bad_request") + + # 统一归一化:兼容 input+images 简化形态 与 messages OpenAI 形态 + messages, model_from_body, provider_id = normalize_chat_body(body) + if not messages: + raise HttpError("input or messages is required", status=400, code="bad_request") + + model = model_from_body or GEMINI_DEFAULT_MODEL + provider_id = provider_id or x_provider + + # 图片修正 + image_fix_mode = body.get("image_fix") + camera_facing = body.get("image_camera_facing") + if not image_fix_mode and camera_facing: + image_fix_mode = infer_fix_mode(camera_facing) + image_fixed = False + image_fix_failed = 0 + image_warning: Optional[str] = None + if image_fix_mode: + success, failed, image_warning = await fix_images_in_messages(messages, image_fix_mode) + image_fixed = success > 0 + image_fix_failed = failed + + config = await load_config() + provider, api_key, clean_model = await select_provider_and_key( + config=config, + provider_id=provider_id, + model=model, + fallback_model=GEMINI_DEFAULT_MODEL, + ) + + stream = bool(body.get("stream")) + stream_mode = str(body.get("stream_mode") or "openai").lower() + + # 构造上游 body(透传 OpenAI 风格参数) + upstream_body = _build_upstream_body(body, clean_model, messages) + + if stream: + return _stream_xtc_chat( + provider, + api_key, + clean_model, + upstream_body, + messages, + stream_mode, + image_fix_mode, + image_fixed, + image_fix_failed, + image_warning, + _key, + ) + + # 非流式 + try: + if provider.type == "gemini": + openai_resp = await gemini_api.chat_completions( + api_key=api_key, + model=clean_model, + messages=messages, + body=upstream_body, + stream=False, + ) + else: + openai_resp = await openai_adapter.chat_completions( + base_url=provider.base_url, + api_key=api_key, + body=upstream_body, + stream=False, + ) + record_usage( + access_key=_key, + provider=provider.id, + model=clean_model, + usage=(openai_resp or {}).get("usage"), + ok=True, + ) + return _build_xtc_response( + openai_resp, + provider=provider.id, + model=clean_model, + image_fix_mode=image_fix_mode, + image_fixed=image_fixed, + image_fix_failed=image_fix_failed, + image_warning=image_warning, + camera_facing=camera_facing, + ) + except HttpError as e: + record_usage( + access_key=_key, + provider=provider.id, + model=clean_model, + usage=None, + ok=False, + error_code=e.code, + ) + raise + + +def _build_upstream_body(body: dict, clean_model: str, messages: list) -> dict: + """从 XTC body 构造上游 OpenAI 风格 body。""" + out = { + "model": clean_model, + "messages": messages, + } + for k in ( + "temperature", "top_p", "top_k", "max_tokens", "max_completion_tokens", + "presence_penalty", "frequency_penalty", "seed", "stop", "n", + "response_format", "tools", "tool_choice", "reasoning_effort", + "stream_options", "extra_body", "google", "user", + ): + if k in body and body[k] is not None: + out[k] = body[k] + return out + + +def _build_xtc_response( + openai_resp: dict, + *, + provider: str, + model: str, + image_fix_mode: Optional[str], + image_fixed: bool, + image_fix_failed: int, + image_warning: Optional[str], + camera_facing: Optional[str], +) -> dict: + """从 OpenAI ChatCompletion 构造 XTC 简化响应。""" + choices = openai_resp.get("choices") or [] + choice = choices[0] if choices else {} + message = choice.get("message") or {} + raw_text = message.get("content") or "" + reasoning_content = message.get("reasoning_content") + thought, text = extract_thought_and_answer(raw_text) + if not thought and reasoning_content: + thought = extract_thought_from_reasoning_content(reasoning_content) + if not text: + text = raw_text + + payload: dict[str, Any] = { + "ok": True, + "provider": provider, + "model": model, + "thought": thought, + "text": text, + "raw": raw_text, + "usage": openai_resp.get("usage") or {}, + "finish_reason": choice.get("finish_reason"), + } + if image_fix_mode: + payload["image_fix_mode"] = image_fix_mode + payload["image_fixed"] = image_fixed + payload["image_fix_failed_count"] = image_fix_failed + payload["image_camera_facing"] = camera_facing + if image_warning: + payload["warning"] = image_warning + headers = {"x-xtc-provider": provider, "x-xtc-model": model} + if image_fix_mode: + headers["x-xtc-image-fix-mode"] = image_fix_mode + return ok_with_cors(payload, extra_headers=headers) + + +async def _stream_xtc_chat( + provider, + api_key: str, + clean_model: str, + upstream_body: dict, + messages: list, + stream_mode: str, + image_fix_mode: Optional[str], + image_fixed: bool, + image_fix_failed: int, + image_warning: Optional[str], + access_key: str, +): + """流式:simple 模式输出 {type,delta};openai 模式直接透传 OpenAI chunk。""" + headers = { + **CORS_HEADERS, + "x-xtc-provider": provider.id, + "x-xtc-model": clean_model, + "Cache-Control": "no-cache", + "x-accel-buffering": "no", + } + if image_fix_mode: + headers["x-xtc-image-fix-mode"] = image_fix_mode + + if stream_mode == "simple": + return EventSourceResponse( + _simple_stream_gen( + provider, api_key, clean_model, upstream_body, messages, + image_warning, access_key, + ), + headers=headers, + ) + + # openai 模式:透传 + if provider.type == "gemini": + async def gen_openai() -> AsyncIterator[str]: + try: + chunk_iter = await gemini_api.chat_completions( + api_key=api_key, + model=clean_model, + messages=messages, + body=upstream_body, + stream=True, + ) + async for chunk in chunk_iter: + yield sse_data(chunk) + yield sse_done() + except HttpError as e: + yield sse_data({"error": {"code": e.code, "message": e.message, "status": e.status}}) + yield sse_done() + + return EventSourceResponse(gen_openai(), headers=headers) + + # OpenAI 厂商透传 + async def gen_passthrough() -> AsyncIterator[bytes]: + try: + chunk_iter = await openai_adapter.chat_completions( + base_url=provider.base_url, api_key=api_key, body=upstream_body, stream=True + ) + async for chunk in chunk_iter: + yield chunk + except HttpError as e: + err = sse_data({"error": {"code": e.code, "message": e.message, "status": e.status}}) + yield err.encode("utf-8") + yield sse_done().encode("utf-8") + + return StreamingResponse(gen_passthrough(), media_type="text/event-stream", headers=headers) + + +async def _simple_stream_gen( + provider, + api_key: str, + clean_model: str, + upstream_body: dict, + messages: list, + image_warning: Optional[str], + access_key: str, +) -> AsyncIterator[str]: + """simple 模式:把 OpenAI chunk 流转换为 {type: text/thought/done, delta}。""" + import re + + in_thought = False + thought_open_sent = False + thought_close_sent = False + open_tag_re = re.compile(r"\s*$", re.IGNORECASE) + close_tag_re = re.compile(r"^\s*", re.IGNORECASE) + + try: + if provider.type == "gemini": + chunk_iter = await gemini_api.chat_completions( + api_key=api_key, model=clean_model, messages=messages, + body=upstream_body, stream=True, + ) + async for chunk in chunk_iter: + event = _simple_from_openai_chunk(chunk) + if event: + yield sse_data(event) + else: + # OpenAI 厂商:直接读原始 SSE + raw_iter = await openai_adapter.chat_completions( + base_url=provider.base_url, api_key=api_key, body=upstream_body, stream=True + ) + async for raw in raw_iter: + if isinstance(raw, bytes): + text = raw.decode("utf-8", errors="replace") + else: + text = str(raw) + # 解析 data: 行 + for line in text.split("\n"): + if not line.startswith("data:"): + continue + payload = line[5:].strip() + if payload == "[DONE]": + yield sse_data({"type": "done", "delta": "", "finish_reason": "stop"}) + return + try: + ch = json.loads(payload) + except Exception: + continue + event = _simple_from_openai_chunk(ch) + if event: + yield sse_data(event) + yield sse_data({"type": "done", "delta": "", "finish_reason": "stop"}) + except HttpError as e: + yield sse_data( + { + "type": "error", + "error": {"code": e.code, "message": e.message, "status": e.status}, + } + ) + if image_warning: + yield sse_data({"type": "warning", "delta": image_warning}) + + +def _simple_from_openai_chunk(chunk: dict) -> Optional[dict]: + """把单个 OpenAI chunk 转为 simple 事件。""" + choices = chunk.get("choices") or [] + if not choices: + return None + choice = choices[0] + delta = choice.get("delta") or {} + content = delta.get("content") + finish_reason = choice.get("finish_reason") + if content is not None: + return {"type": "text", "delta": content, "finish_reason": finish_reason} + if finish_reason: + return {"type": "done", "delta": "", "finish_reason": finish_reason} + return None + + +async def _parse_multipart(request: Request) -> dict: + """解析 multipart/form-data。 + + 兼容前端 api.js 的 callUpload 路径,该路径发送: + - 简单字段:access_key / provider / model / input / stream / max_tokens / + image_fix / file_names(JSON 字符串)/ images(图片 URL 字符串,可多条) + - 文件字段:name="images" 的图片 UploadFile,name="files" 的文件 UploadFile + + 图片 UploadFile 转 data URL 放进 out["images"]; + 文本类文件 UploadFile 读 utf-8 文本放进 out["files"],文件名进 out["file_names"]。 + 最终由 normalize_chat_body 统一组装为 messages。 + """ + import base64 + + from starlette.datastructures import UploadFile + + form = await request.form() + out: dict[str, Any] = {} + # 简单字段(含 input) + for key in ("model", "provider", "input", "stream", "stream_mode", "image_fix", + "image_camera_facing", "max_tokens", "temperature", "top_p", + "seed", "stop", "n", "thoughts"): + if key not in form: + continue + val = form[key] + if isinstance(val, UploadFile): + continue + val = str(val) + if val in ("true", "false"): + out[key] = val == "true" + elif val.lstrip("-").isdigit(): + out[key] = int(val) + else: + try: + out[key] = float(val) + except (TypeError, ValueError): + out[key] = val + # messages(JSON 字符串) + if "messages" in form: + raw = form["messages"] + if not isinstance(raw, UploadFile): + try: + out["messages"] = json.loads(str(raw)) + except Exception: + out["messages"] = [{"role": "user", "content": str(raw)}] + # file_names(前端会发 JSON.stringify(names)) + file_names: list[str] = [] + if "file_names" in form: + raw = form["file_names"] + if not isinstance(raw, UploadFile): + raw = str(raw) + try: + parsed = json.loads(raw) + if isinstance(parsed, list): + file_names = [str(x) for x in parsed] + else: + file_names = [raw] + except Exception: + file_names = [raw] + # 遍历所有项,收集图片 URL 字符串 + UploadFile + image_urls: list[str] = [] + files_text: list[str] = [] + upload_file_names: list[str] = [] + for key, value in form.multi_items(): + if isinstance(value, UploadFile): + content_bytes = await value.read() + filename = value.filename or key + if key == "images" or key == "image" or key.startswith("image_"): + # 图片文件 → data URL + mime = (value.content_type or "image/jpeg").split(";")[0].strip() or "image/jpeg" + b64 = base64.b64encode(content_bytes).decode("ascii") + image_urls.append(f"data:{mime};base64,{b64}") + elif key == "files" or key == "file" or key.startswith("file_"): + # 文件 → 尝试 utf-8 文本;二进制则记空串占位 + try: + files_text.append(content_bytes.decode("utf-8")) + except Exception: + files_text.append("") + upload_file_names.append(filename) + continue + # 字符串值 + sval = str(value or "").strip() + if not sval: + continue + if key == "images" or key == "image" or key.startswith("image_"): + if sval.startswith(("http://", "https://", "data:")): + image_urls.append(sval) + elif key == "files" or key == "file" or key.startswith("file_"): + files_text.append(sval) + upload_file_names.append(key) + if image_urls: + out["images"] = image_urls + if files_text: + out["files"] = files_text + out["file_names"] = (file_names + upload_file_names) or upload_file_names + return out diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..b4bd30e959a928fdf2b714f5c8bff8a83f9d57df --- /dev/null +++ b/app/auth.py @@ -0,0 +1,226 @@ +"""鉴权:access_key 校验 + JWT 临时令牌。 + +简化点: +- 去掉 XTC_ACCESS_SHORT_KEY / XTC_ACCESS_PIN +- 用 JWT 替代内存 Map,多实例可用 +- access_token 持久化到 SQLite(用于吊销/查询) +""" +from __future__ import annotations + +import secrets +import time +from typing import Optional + +import jwt +from fastapi import Header, Request + +from .config import get_settings +from .database import get_conn +from .errors import HttpError + +ALGORITHM = "HS256" + + +# ===== access_key 校验 ===== + +def _extract_access_key( + *, + authorization: Optional[str], + x_access_key: Optional[str], +) -> Optional[str]: + if x_access_key: + return x_access_key.strip() + if authorization: + auth = authorization.strip() + if auth.lower().startswith("bearer "): + return auth[7:].strip() + if auth.lower().startswith("basic "): + return auth[6:].strip() + return auth + return None + + +def verify_access_key(provided: Optional[str]) -> None: + settings = get_settings() + if not settings.has_access_key: + # 未配置 access_key 时放行(开发模式) + return + if not provided: + raise HttpError( + "Missing access key: provide x-xtc-access-key header or Authorization: Bearer ", + status=401, + code="unauthorized", + ) + # 先校验长期 key + if _consteq(provided, settings.xtc_access_key): + return + # 再校验临时 token + if verify_access_token(provided): + return + raise HttpError( + "Invalid access key or token", + status=401, + code="unauthorized", + ) + + +def verify_access_token(token: str) -> bool: + """校验临时令牌:JWT 签名 + SQLite 未吊销 + 未过期。""" + settings = get_settings() + if not settings.has_jwt_secret: + return False + try: + payload = jwt.decode(token, settings.xtc_jwt_secret, algorithms=[ALGORITHM]) + except jwt.PyJWTError: + return False + if payload.get("type") != "access": + return False + jti = payload.get("jti") + if not jti: + return False + with get_conn() as conn: + row = conn.execute( + "SELECT expires_at, revoked FROM access_tokens WHERE token = ?", + (jti,), + ).fetchone() + if not row: + return False + if row["revoked"]: + return False + if int(row["expires_at"]) < int(time.time()): + return False + return True + + +# ===== 临时令牌签发 ===== + +def issue_access_token(*, issued_by: str = "admin") -> dict: + settings = get_settings() + if not settings.has_jwt_secret: + raise HttpError( + "XTC_JWT_SECRET not configured, cannot issue token", + status=500, + code="server_misconfigured", + ) + ttl = settings.xtc_access_token_ttl_sec + length = settings.xtc_access_token_length + jti = secrets.token_urlsafe(length)[:length] if length <= 32 else secrets.token_urlsafe(32) + now = int(time.time()) + expires_at = now + ttl + payload = { + "type": "access", + "jti": jti, + "iat": now, + "exp": expires_at, + } + token = jwt.encode(payload, settings.xtc_jwt_secret, algorithm=ALGORITHM) + with get_conn() as conn: + conn.execute( + "INSERT INTO access_tokens(token, issued_at, expires_at, issued_by, revoked) " + "VALUES(?,?,?,?,0)", + (jti, now, expires_at, issued_by), + ) + return { + "token": token, + "jti": jti, + "issued_at": now, + "expires_at": expires_at, + "expires_in_sec": ttl, + } + + +def revoke_access_token(jti: str) -> bool: + with get_conn() as conn: + cur = conn.execute( + "UPDATE access_tokens SET revoked = 1 WHERE token = ? AND revoked = 0", + (jti,), + ) + return cur.rowcount > 0 + + +def list_access_tokens() -> list[dict]: + now = int(time.time()) + with get_conn() as conn: + rows = conn.execute( + "SELECT token, issued_at, expires_at, issued_by, revoked FROM access_tokens " + "ORDER BY issued_at DESC LIMIT 200" + ).fetchall() + out = [] + for r in rows: + out.append( + { + "jti": r["token"], + "issued_at": int(r["issued_at"]), + "expires_at": int(r["expires_at"]), + "issued_by": r["issued_by"], + "revoked": bool(r["revoked"]), + "expired": int(r["expires_at"]) < now, + } + ) + return out + + +# ===== admin key 校验 ===== + +def verify_admin_key(provided: Optional[str]) -> None: + settings = get_settings() + if not settings.is_admin_enabled: + raise HttpError( + "Admin API disabled (XTC_DISABLE_ADMIN=true)", + status=403, + code="forbidden", + ) + if not settings.has_admin_key: + raise HttpError( + "XTC_ADMIN_KEY not configured", + status=500, + code="server_misconfigured", + ) + if not provided: + raise HttpError( + "Missing admin key: provide x-xtc-admin-key header or Authorization: Bearer ", + status=401, + code="unauthorized", + ) + if not _consteq(provided, settings.xtc_admin_key): + raise HttpError("Invalid admin key", status=401, code="unauthorized") + + +# ===== FastAPI 依赖 ===== + +def require_access_key( + request: Request, + authorization: Optional[str] = Header(default=None), + x_xtc_access_key: Optional[str] = Header(default=None, alias="x-xtc-access-key"), + x_xtc_access_token: Optional[str] = Header(default=None, alias="x-xtc-access-token"), +) -> str: + provided = _extract_access_key( + authorization=authorization, + x_access_key=x_xtc_access_key or x_xtc_access_token, + ) + verify_access_key(provided) + return provided or "" + + +def require_admin_key( + request: Request, + authorization: Optional[str] = Header(default=None), + x_xtc_admin_key: Optional[str] = Header(default=None, alias="x-xtc-admin-key"), +) -> str: + provided = _extract_access_key( + authorization=authorization, + x_access_key=x_xtc_admin_key, + ) + verify_admin_key(provided) + return provided or "" + + +def _consteq(a: str, b: str) -> bool: + if not isinstance(a, str) or not isinstance(b, str): + return False + if len(a) != len(b): + return False + result = 0 + for x, y in zip(a, b): + result |= ord(x) ^ ord(y) + return result == 0 diff --git a/app/cache.py b/app/cache.py new file mode 100644 index 0000000000000000000000000000000000000000..fa651e2efbfab152157e7de6e3e5817f9baa6453 --- /dev/null +++ b/app/cache.py @@ -0,0 +1,26 @@ +"""缓存层:模型列表等热数据 TTL 缓存。""" +from __future__ import annotations + +import asyncio +import time +from typing import Any, Optional + +from cachetools import TTLCache + +# 模型列表缓存:默认 30s TTL,最多 32 个厂商 +_models_cache: "TTLCache[str, list[Any]]" = TTLCache(maxsize=32, ttl=30) + + +def get_cached_models(provider_id: str) -> Optional[list[Any]]: + return _models_cache.get(provider_id) + + +def set_cached_models(provider_id: str, models: list[Any]) -> None: + _models_cache[provider_id] = models + + +def invalidate_models_cache(provider_id: Optional[str] = None) -> None: + if provider_id: + _models_cache.pop(provider_id, None) + else: + _models_cache.clear() diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000000000000000000000000000000000000..2264663d0774a17601ec8c2f4cc33c21f47e827a --- /dev/null +++ b/app/config.py @@ -0,0 +1,128 @@ +"""全局配置:从环境变量加载(pydantic-settings)。 + +简化点(相比原 Netlify 版): +- 不再支持 XTC_PROVIDER_N_* 多槽位环境变量,仅用 GEMINI/OPENAI 做种子 +- 不再支持配置加密(XTC_CONFIG_CRYPT_KEY) +- 不再支持 XTC_CONFIG_ENV_ONLY / XTC_ADMIN_REQUIRE_KEY_ON_PAGE / XTC_ACCESS_SHORT_KEY / XTC_ACCESS_PIN +- 不再支持 Edge 路径与三段式 env 读取 +- 新增 XTC_JWT_SECRET 用于签发临时令牌 +- 存储主体为持久卷(/data/xtc.db);配置文件额外通过 HF Hub dataset 仓库做备份 +""" +from __future__ import annotations + +import os +from functools import lru_cache +from typing import List, Optional + +from pydantic import Field, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +def _split_key_list(value: Optional[str]) -> List[str]: + if not value: + return [] + out = [] + for item in str(value).replace("\n", ",").replace(";", ",").split(","): + s = item.strip() + if s: + out.append(s) + return list(dict.fromkeys(out)) # 去重保序 + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + case_sensitive=False, + ) + + # ===== 鉴权 ===== + xtc_admin_key: str = Field(default="", alias="XTC_ADMIN_KEY") + xtc_access_key: str = Field(default="", alias="XTC_ACCESS_KEY") + xtc_jwt_secret: str = Field(default="", alias="XTC_JWT_SECRET") + + # ===== 紧急开关 ===== + xtc_disable_admin: bool = Field(default=False, alias="XTC_DISABLE_ADMIN") + + # ===== 种子厂商 ===== + gemini_api_key: str = Field(default="", alias="GEMINI_API_KEY") + openai_api_key: str = Field(default="", alias="OPENAI_API_KEY") + openai_base_url: str = Field(default="https://api.openai.com", alias="OPENAI_BASE_URL") + + # ===== 超时 ===== + upstream_timeout_ms: int = Field(default=120000, alias="UPSTREAM_TIMEOUT_MS") + upstream_stream_timeout_ms: int = Field(default=120000, alias="UPSTREAM_STREAM_TIMEOUT_MS") + + # ===== HF Hub 配置备份(可选)===== + # 配置文件通过 HF Hub dataset 仓库做持久化备份,解决免费档磁盘 ephemeral 问题 + # 未配置时降级为纯本地存储(功能正常,只是 restart/stop 后配置会丢失) + hf_token: str = Field(default="", alias="HF_TOKEN") + hf_config_repo: str = Field(default="", alias="HF_CONFIG_REPO") + + # ===== 临时令牌 ===== + xtc_access_token_ttl_sec: int = Field(default=1800, alias="XTC_ACCESS_TOKEN_TTL_SEC") + xtc_access_token_length: int = Field(default=8, alias="XTC_ACCESS_TOKEN_LENGTH") + + # ===== 运行 ===== + port: int = Field(default=7860, alias="PORT") + + # ===== 数据库路径 ===== + db_path: str = Field(default="/data/xtc.db", alias="XTC_DB_PATH") + + @field_validator("xtc_access_token_length") + @classmethod + def _clamp_token_length(cls, v: int) -> int: + return max(6, min(24, v)) + + @field_validator("xtc_access_token_ttl_sec") + @classmethod + def _clamp_token_ttl(cls, v: int) -> int: + return max(60, min(86400, v)) + + @property + def gemini_api_keys(self) -> List[str]: + # 兼容 GEMINI_API_KEYS(逗号分隔) + return _split_key_list(os.environ.get("GEMINI_API_KEYS")) or _split_key_list(self.gemini_api_key) + + @property + def openai_api_keys(self) -> List[str]: + return _split_key_list(os.environ.get("OPENAI_API_KEYS")) or _split_key_list(self.openai_api_key) + + @property + def is_admin_enabled(self) -> bool: + return not self.xtc_disable_admin + + @property + def has_admin_key(self) -> bool: + return bool(self.xtc_admin_key) + + @property + def has_access_key(self) -> bool: + return bool(self.xtc_access_key) + + @property + def has_jwt_secret(self) -> bool: + return bool(self.xtc_jwt_secret) + + +@lru_cache(maxsize=1) +def get_settings() -> Settings: + return Settings() + + +# Gemini 上游地址(固定) +GEMINI_BASE_URL = "https://generativelanguage.googleapis.com" +GEMINI_API_VERSION = "v1beta" +GEMINI_API_CLIENT = "google-genai-sdk/1.34.0" +GEMINI_DEFAULT_MODEL = "gemini-flash-latest" +GEMINI_DEFAULT_EMBEDDINGS_MODEL = "gemini-embedding-001" + +# RR 计数器数据库 key 前缀 +RR_KEY_PREFIX = "rr:provider:" + +# 伪流式会话 TTL +PSEUDO_SESSION_TTL_SEC = 10 * 60 + +# 模型列表缓存 TTL +MODELS_CACHE_TTL_SEC = 30 diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000000000000000000000000000000000000..8e9bcf0c28554813b97e357c3ab520d196bfa26f --- /dev/null +++ b/app/database.py @@ -0,0 +1,266 @@ +"""SQLite 初始化与连接管理。 + +简化点:相比原 Netlify Blobs KV,这里用本地 SQLite(容器内 /data)。 +HF Space 重建时数据可能丢失,启动时通过 HF Hub 仓库恢复配置(见 services/config_store.py)。 +""" +from __future__ import annotations + +import os +import sqlite3 +import threading +from contextlib import contextmanager +from pathlib import Path +from typing import Iterator + +from .config import get_settings + +_lock = threading.Lock() +_conn: sqlite3.Connection | None = None + + +def _ensure_parent_dir(db_path: str) -> None: + p = Path(db_path).expanduser() + if p.parent and not p.parent.exists(): + try: + p.parent.mkdir(parents=True, exist_ok=True) + except Exception: + # 容器内只读卷会失败,退回 /tmp + pass + + +def _resolve_db_path() -> str: + settings = get_settings() + db_path = settings.db_path + # HF 免费档无持久卷时,/data 不存在或不可写,回退到 /tmp + try: + _ensure_parent_dir(db_path) + test_path = Path(db_path) + test_path.touch(exist_ok=True) + if not os.access(db_path, os.W_OK): + raise PermissionError(db_path) + return db_path + except Exception: + fallback = "/tmp/xtc.db" + print(f"[database] db_path {db_path} not writable, fallback to {fallback}") + return fallback + + +def init_db() -> sqlite3.Connection: + global _conn + with _lock: + if _conn is not None: + return _conn + db_path = _resolve_db_path() + conn = sqlite3.connect(db_path, check_same_thread=False, isolation_level=None) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA synchronous=NORMAL") + conn.execute("PRAGMA foreign_keys=ON") + _create_schema(conn) + _conn = conn + return _conn + + +def _create_schema(conn: sqlite3.Connection) -> None: + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS kv ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS access_tokens ( + token TEXT PRIMARY KEY, + issued_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + issued_by TEXT, + revoked INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_access_tokens_expires ON access_tokens(expires_at); + + CREATE TABLE IF NOT EXISTS pseudo_sessions ( + id TEXT PRIMARY KEY, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + done INTEGER NOT NULL DEFAULT 0, + error TEXT, + thought TEXT NOT NULL DEFAULT '', + text TEXT NOT NULL DEFAULT '', + seq INTEGER NOT NULL DEFAULT 0, + payload TEXT + ); + CREATE INDEX IF NOT EXISTS idx_pseudo_sessions_updated ON pseudo_sessions(updated_at); + + CREATE TABLE IF NOT EXISTS pseudo_events ( + seq INTEGER NOT NULL, + session_id TEXT NOT NULL, + at TEXT NOT NULL, + type TEXT NOT NULL, + delta TEXT, + finish_reason TEXT, + extra TEXT, + PRIMARY KEY (session_id, seq) + ); + CREATE INDEX IF NOT EXISTS idx_pseudo_events_session ON pseudo_events(session_id); + + CREATE TABLE IF NOT EXISTS rr_counter ( + provider_id TEXT PRIMARY KEY, + counter INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS app_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + level TEXT NOT NULL, + module TEXT NOT NULL, + event TEXT NOT NULL, + trace_id TEXT, + data TEXT + ); + CREATE INDEX IF NOT EXISTS idx_app_logs_ts ON app_logs(ts); + CREATE INDEX IF NOT EXISTS idx_app_logs_level ON app_logs(level); + + CREATE TABLE IF NOT EXISTS usage_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + access_key TEXT, + provider TEXT, + model TEXT, + prompt_tokens INTEGER, + completion_tokens INTEGER, + total_tokens INTEGER, + ok INTEGER NOT NULL DEFAULT 0, + error_code TEXT + ); + CREATE INDEX IF NOT EXISTS idx_usage_log_ts ON usage_log(ts); + CREATE INDEX IF NOT EXISTS idx_usage_log_key ON usage_log(access_key); + + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + action TEXT NOT NULL, + actor TEXT, + target TEXT, + detail TEXT + ); + CREATE INDEX IF NOT EXISTS idx_audit_log_ts ON audit_log(ts); + + CREATE TABLE IF NOT EXISTS file_meta ( + key TEXT PRIMARY KEY, + namespace TEXT NOT NULL, + filename TEXT NOT NULL, + mime TEXT, + size INTEGER NOT NULL, + sha256 TEXT, + uploaded_at INTEGER NOT NULL, + uploaded_by TEXT, + access_key TEXT, + refs TEXT + ); + CREATE INDEX IF NOT EXISTS idx_file_meta_ns ON file_meta(namespace); + CREATE INDEX IF NOT EXISTS idx_file_meta_owner ON file_meta(access_key); + CREATE INDEX IF NOT EXISTS idx_file_meta_uploaded ON file_meta(uploaded_at); + + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + title TEXT, + provider TEXT, + model TEXT, + access_key TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_sessions_owner ON sessions(access_key); + CREATE INDEX IF NOT EXISTS idx_sessions_updated ON sessions(updated_at); + + CREATE TABLE IF NOT EXISTS session_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + seq INTEGER NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + thought TEXT, + provider TEXT, + model TEXT, + ts INTEGER NOT NULL, + file_keys TEXT, + UNIQUE(session_id, seq) + ); + CREATE INDEX IF NOT EXISTS idx_session_messages_session ON session_messages(session_id); + + CREATE TABLE IF NOT EXISTS rate_limit ( + bucket TEXT PRIMARY KEY, + count INTEGER NOT NULL, + window_start INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS webhook_config ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + url TEXT NOT NULL, + events TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS request_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + method TEXT NOT NULL, + path TEXT NOT NULL, + query TEXT, + status_code INTEGER, + elapsed_ms INTEGER, + access_key TEXT, + client_ip TEXT, + user_agent TEXT, + provider TEXT, + model TEXT, + stream INTEGER NOT NULL DEFAULT 0, + ok INTEGER NOT NULL DEFAULT 0, + error_code TEXT, + error_message TEXT, + request_headers TEXT, + request_body TEXT, + response_body TEXT, + response_headers TEXT + ); + CREATE INDEX IF NOT EXISTS idx_request_log_ts ON request_log(ts); + CREATE INDEX IF NOT EXISTS idx_request_log_path ON request_log(path); + CREATE INDEX IF NOT EXISTS idx_request_log_status ON request_log(status_code); + CREATE INDEX IF NOT EXISTS idx_request_log_key ON request_log(access_key); + """ + ) + # request_log 容量上限从 kv 读取,默认 500(直接用当前 conn,避免递归 init_db) + _ensure_request_log_limit_default(conn) + + +def _ensure_request_log_limit_default(conn: sqlite3.Connection) -> None: + """首次启动写入默认 request_log 上限。""" + import time as _time + try: + row = conn.execute( + "SELECT value FROM kv WHERE key = 'request_log_limit'" + ).fetchone() + if not row: + conn.execute( + "INSERT INTO kv(key, value, updated_at) VALUES(?,?,?)", + ("request_log_limit", "500", int(_time.time())), + ) + except Exception as e: + print(f"[database] request_log_limit init failed: {e}") + + +@contextmanager +def get_conn() -> Iterator[sqlite3.Connection]: + conn = init_db() + yield conn + + +def close_db() -> None: + global _conn + with _lock: + if _conn is not None: + _conn.close() + _conn = None diff --git a/app/errors.py b/app/errors.py new file mode 100644 index 0000000000000000000000000000000000000000..c47c54b6cad8b1015e003bbb9ada828414700da3 --- /dev/null +++ b/app/errors.py @@ -0,0 +1,168 @@ +"""统一错误模型。 + +完全兼容原 Netlify 版返回格式: + { "ok": false, "error": { "code", "message", "status", "retryable", "hint", "upstream?", "details?" } } + +错误码集合:bad_request/unauthorized/forbidden/not_found/request_timeout/ +quota_exceeded/internal_error/service_unavailable/upstream_timeout/ +model_disabled/server_misconfigured +""" +from __future__ import annotations + +from typing import Any, Optional + +from fastapi import Request +from fastapi.responses import JSONResponse + + +# 状态码 -> 错误码 +_STATUS_TO_CODE = { + 400: "bad_request", + 401: "unauthorized", + 402: "payment_required", + 403: "forbidden", + 404: "not_found", + 405: "method_not_allowed", + 408: "request_timeout", + 409: "conflict", + 422: "unprocessable_entity", + 429: "quota_exceeded", + 500: "internal_error", + 502: "bad_gateway", + 503: "service_unavailable", + 504: "upstream_timeout", +} + +# 可重试状态码 +_RETRYABLE_STATUS = {408, 409, 429, 500, 502, 503, 504} + + +class HttpError(Exception): + """统一 HTTP 异常。""" + + def __init__( + self, + message: str, + status: int = 500, + code: Optional[str] = None, + details: Any = None, + upstream: Any = None, + ) -> None: + super().__init__(message) + self.message = message + self.status = status + self.code = code or _STATUS_TO_CODE.get(status, "internal_error") + self.details = details + self.upstream = upstream + + +def error_code_for_status(status: int, fallback: str = "upstream_error") -> str: + return _STATUS_TO_CODE.get(status, fallback) + + +def is_retryable_status(status: int) -> bool: + return status in _RETRYABLE_STATUS + + +def get_error_hint(*, code: str, status: int, message: str) -> Optional[str]: + """生成给前端展示的提示文案。""" + text = (message or "").lower() + if code == "unauthorized" or status == 401: + return "请检查 access_key/access_token 是否正确,或先重新生成临时令牌" + if code == "forbidden" or status == 403: + return "当前账号或策略不允许此操作,请检查后台厂商与模型策略" + if code == "not_found" or status == 404: + return "资源不存在,请检查接口路径、provider 与 model 参数" + if code == "quota_exceeded" or status == 429: + return "请求过快或额度不足,建议稍后重试或切换厂商/模型" + if code == "upstream_timeout" or status == 504 or "timeout" in text: + return "上游超时,建议降低 max_tokens 或稍后重试" + if code == "bad_request" or status in (400, 422): + return "请求参数格式可能有误,请检查 messages/images/model 字段" + if status >= 500: + return "服务暂时不可用,请稍后重试" + return None + + +def make_error_response( + *, + message: str, + status: int = 500, + code: Optional[str] = None, + details: Any = None, + upstream: Any = None, +) -> JSONResponse: + err_code = code or _STATUS_TO_CODE.get(status, "internal_error") + hint = get_error_hint(code=err_code, status=status, message=message) + payload = { + "ok": False, + "error": { + "code": err_code, + "message": message, + "status": status, + "retryable": is_retryable_status(status), + "hint": hint, + }, + } + if details is not None: + payload["error"]["details"] = details + if upstream is not None: + payload["error"]["upstream"] = upstream + return JSONResponse(status_code=status, content=payload, headers=_CORS_HEADERS) + + +def make_upstream_error_response( + *, + status: int, + text: str, + json_body: Any = None, + fallback_code: str = "upstream_error", +) -> JSONResponse: + """从上游响应构造统一错误。""" + upstream_err = None + if isinstance(json_body, dict): + upstream_err = json_body.get("error") if isinstance(json_body.get("error"), dict) else json_body + code = (upstream_err or {}).get("code") if isinstance(upstream_err, dict) else None + code = code or error_code_for_status(status, fallback_code) + message = (upstream_err or {}).get("message") if isinstance(upstream_err, dict) else None + message = message or text or f"Upstream error ({status})" + hint = get_error_hint(code=code, status=status, message=message) + payload = { + "ok": False, + "error": { + "code": code, + "message": message, + "status": status, + "retryable": is_retryable_status(status), + "hint": hint, + "upstream": upstream_err, + }, + } + return JSONResponse(status_code=status, content=payload, headers=_CORS_HEADERS) + + +_CORS_HEADERS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "*", + "Access-Control-Allow-Headers": "*", +} + + +async def http_error_handler(request: Request, exc: HttpError) -> JSONResponse: + return make_error_response( + message=exc.message, + status=exc.status, + code=exc.code, + details=exc.details, + upstream=exc.upstream, + ) + + +async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse: + import traceback + traceback.print_exc() + return make_error_response( + message=str(exc) or "Unexpected error", + status=500, + code="internal_error", + ) diff --git a/app/hf_storage.py b/app/hf_storage.py new file mode 100644 index 0000000000000000000000000000000000000000..d00c3ac29c62b21e4a47c0e0c5561ce063569de8 --- /dev/null +++ b/app/hf_storage.py @@ -0,0 +1,173 @@ +"""HF Hub 配置备份抽象层(简化版)。 + +仅用于「配置文件」的持久化备份:把 AppConfig JSON 同步到 HF Hub dataset 仓库的 +config/ 命名空间,解决 HF Spaces 免费档磁盘 ephemeral(restart/stop 丢数据)的问题。 + +设计原则: +- 仅配置走 Hub,图片/文件/日志/会话都不走 Hub(用户明确不需要) +- 写入时先写本地缓存再异步推 Hub(Hub 失败不影响主流程) +- 读取时本地命中即返回;未命中再从 Hub 拉取并写本地缓存 +- 未配置 HF_TOKEN / HF_CONFIG_REPO 时自动降级为纯本地(功能不影响,只是没有备份) +""" +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Optional + +from .config import get_settings + + +# ===== 命名空间常量 ===== + +NS_CONFIG = "config" # 仅此一个命名空间:应用配置备份 + + +# ===== 本地缓存路径 ===== + +def _local_root() -> Path: + """本地缓存根目录(容器内 /data/hf_bucket 或 /tmp/hf_bucket)。""" + for candidate in ("/data/hf_bucket", "/tmp/hf_bucket"): + p = Path(candidate) + try: + p.mkdir(parents=True, exist_ok=True) + (p / ".wtest").touch(exist_ok=True) + (p / ".wtest").unlink(missing_ok=True) + return p + except Exception: + continue + p = Path("/tmp/hf_bucket") + p.mkdir(parents=True, exist_ok=True) + return p + + +def _safe_parts(s: str) -> list[str]: + """拆分为安全路径段,过滤危险字符与 . / .. 。""" + sanitized: list[str] = [] + for ch in s: + if ch.isalnum() or ch in ("-", "_", ".", "/"): + sanitized.append(ch) + else: + sanitized.append("_") + out: list[str] = [] + for part in "".join(sanitized).split("/"): + if not part or part in (".", ".."): + continue + out.append(part) + return out + + +def _local_path(namespace: str, key: str) -> Path: + """本地缓存路径:root/namespace/key。""" + root = _local_root() + p = root.joinpath(*_safe_parts(namespace), *_safe_parts(key)) + p.parent.mkdir(parents=True, exist_ok=True) + return p + + +def _hub_path(namespace: str, key: str) -> str: + """Hub 仓库内路径:namespace/key。""" + return "/".join(_safe_parts(namespace) + _safe_parts(key)) + + +# ===== 是否启用 Hub ===== + +def is_hub_enabled() -> bool: + s = get_settings() + return bool(s.hf_config_repo and s.hf_token) + + +# ===== 同步接口(本地优先)===== + +def upload_bytes(namespace: str, key: str, data: bytes) -> str: + """上传字节:先写本地缓存。 + + 返回本地路径。Hub 推送由 push_to_hub 异步完成。 + """ + p = _local_path(namespace, key) + p.write_bytes(data) + return str(p) + + +def download_bytes(namespace: str, key: str) -> Optional[bytes]: + """下载字节:本地命中即返回,否则从 Hub 拉取并写本地缓存。""" + p = _local_path(namespace, key) + if p.exists() and p.is_file(): + return p.read_bytes() + if is_hub_enabled(): + data = _hub_download(namespace, key) + if data is not None: + try: + p.write_bytes(data) + except Exception: + pass + return data + return None + + +# ===== 异步接口(推送 Hub 用)===== + +async def push_to_hub(namespace: str, key: str) -> bool: + """把本地缓存文件异步推送到 Hub。""" + if not is_hub_enabled(): + return False + p = _local_path(namespace, key) + if not p.exists(): + return False + try: + await asyncio.to_thread(_hub_upload, namespace, key, p.read_bytes()) + return True + except Exception as e: + print(f"[hf_storage] push_to_hub failed ns={namespace} key={key}: {e}") + return False + + +# ===== Hub 操作(同步,run in thread)===== + +def _get_hf_api(): + from huggingface_hub import HfApi # 延迟导入 + s = get_settings() + return HfApi(token=s.hf_token), s.hf_config_repo + + +def _ensure_repo(api, repo_id: str) -> None: + """确保仓库存在(不存在则创建为 dataset 私有仓库)。""" + try: + api.repo_info(repo_id=repo_id, repo_type="dataset") + except Exception: + try: + api.create_repo(repo_id=repo_id, repo_type="dataset", private=True, exist_ok=True) + except Exception as e: + print(f"[hf_storage] create_repo failed: {e}") + + +def _hub_upload(namespace: str, key: str, data: bytes) -> None: + api, repo_id = _get_hf_api() + _ensure_repo(api, repo_id) + path_in_repo = _hub_path(namespace, key) + api.upload_file( + path_or_fileobj=data, + path_in_repo=path_in_repo, + repo_id=repo_id, + repo_type="dataset", + ) + + +def _hub_download(namespace: str, key: str) -> Optional[bytes]: + from huggingface_hub import hf_hub_download + api, repo_id = _get_hf_api() + path_in_repo = _hub_path(namespace, key) + try: + local_path = hf_hub_download( + repo_id=repo_id, + filename=path_in_repo, + repo_type="dataset", + token=get_settings().hf_token, + ) + return Path(local_path).read_bytes() + except Exception as e: + msg = str(e).lower() + if "404" in msg or "not found" in msg or "no such file" in msg: + return None + print(f"[hf_storage] hub_download failed: {e}") + return None diff --git a/app/http_client.py b/app/http_client.py new file mode 100644 index 0000000000000000000000000000000000000000..2210a5fcd69317c7825c17b7c8c829b70e000b5d --- /dev/null +++ b/app/http_client.py @@ -0,0 +1,42 @@ +"""HTTP 客户端:httpx 全局连接池 + 超时控制。 + +替代原 Netlify 版的 fetch + AbortController。 +""" +from __future__ import annotations + +from typing import Optional + +import httpx + +from .config import get_settings + +_client: Optional[httpx.AsyncClient] = None + + +def get_http_client() -> httpx.AsyncClient: + global _client + if _client is None or _client.is_closed: + settings = get_settings() + timeout = httpx.Timeout( + timeout=settings.upstream_timeout_ms / 1000.0, + connect=10.0, + read=settings.upstream_stream_timeout_ms / 1000.0, + ) + _client = httpx.AsyncClient( + timeout=timeout, + limits=httpx.Limits( + max_connections=100, + max_keepalive_connections=20, + keepalive_expiry=30.0, + ), + http2=False, + follow_redirects=False, + ) + return _client + + +async def close_http_client() -> None: + global _client + if _client is not None and not _client.is_closed: + await _client.aclose() + _client = None diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000000000000000000000000000000000000..573fc05dcbb1608cf665f658b5a143080e164b6e --- /dev/null +++ b/app/main.py @@ -0,0 +1,126 @@ +"""FastAPI 应用装配。""" +from __future__ import annotations + +import asyncio +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse + +from .config import get_settings +from .database import close_db, init_db +from .errors import HttpError, http_error_handler, unhandled_exception_handler +from .http_client import close_http_client +from .services import config_store, pseudo_store + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # 启动 + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") + init_db() + # 预热配置 + try: + cfg = await config_store.load_config() + logging.getLogger(__name__).info( + "config loaded: %d providers, default=%s", + len(cfg.providers), cfg.default_provider_id, + ) + except Exception as e: + logging.getLogger(__name__).warning("config preload failed: %s", e) + # 启动伪流式清理任务 + cleanup_task = asyncio.create_task(pseudo_store.cleanup_loop()) + yield + # 关闭 + cleanup_task.cancel() + try: + await cleanup_task + except asyncio.CancelledError: + pass + await close_http_client() + close_db() + + +def create_app() -> FastAPI: + settings = get_settings() + app = FastAPI( + title="XTC Backend (Hugging Face)", + description="OpenAI/Gemini 兼容网关,迁移自 Netlify 版", + version="1.0.0", + lifespan=lifespan, + docs_url="/docs", + redoc_url=None, + ) + + # CORS + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=False, + allow_methods=["*"], + allow_headers=["*"], + expose_headers=["x-xtc-provider", "x-xtc-model", "x-xtc-image-fix-mode", "Retry-After", "X-RateLimit-Scope"], + ) + + # 速率限制中间件(必须在内层,让 CORS 先处理) + from .middleware import RateLimitMiddleware + app.add_middleware(RateLimitMiddleware) + + # 请求日志中间件(最内层,确保能捕获下游所有响应) + from .request_log_middleware import RequestLogMiddleware + app.add_middleware(RequestLogMiddleware) + + # 异常处理 + app.add_exception_handler(HttpError, http_error_handler) + app.add_exception_handler(Exception, unhandled_exception_handler) + + # 路由注册 + from .api import ( + admin, + admin_data, + health, + image_fix, + logs, + openai_compat, + pseudo_stream, + request_logs, + sessions, + usage_audit, + webhooks, + xtc, + ) + from .admin_html import router as admin_html_router + + app.include_router(health.router) + app.include_router(openai_compat.router) + app.include_router(xtc.router) + app.include_router(pseudo_stream.router) + app.include_router(image_fix.router) + app.include_router(sessions.router) + app.include_router(admin.router) + app.include_router(admin_data.router) + app.include_router(usage_audit.router) + app.include_router(webhooks.router) + app.include_router(request_logs.router) + app.include_router(admin_html_router) + app.include_router(logs.router) + + @app.options("/{path:path}") + async def cors_preflight(path: str, request: Request): + return JSONResponse( + status_code=204, + content=None, + headers={ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "*", + "Access-Control-Allow-Headers": "*", + "Access-Control-Max-Age": "86400", + }, + ) + + return app + + +app = create_app() diff --git a/app/media/__init__.py b/app/media/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/media/imagefix.py b/app/media/imagefix.py new file mode 100644 index 0000000000000000000000000000000000000000..4ff954ad5e0d79a65edc5cae495303e56e98a1bc --- /dev/null +++ b/app/media/imagefix.py @@ -0,0 +1,159 @@ +"""图片修正:Pillow 实现 mirror_h / rotate_180 / mirror_h_rotate_180。 + +替代原 Jimp + jpeg-js 双路径实现,性能提升 ~10x。 +""" +from __future__ import annotations + +import base64 +import io +import re +from typing import Optional, Tuple + +from PIL import Image, ImageOps + +# 启用 HEIF 支持(如果安装了 pillow-heif) +try: + import pillow_heif # type: ignore + pillow_heif.register_heif_opener() + _HEIF_OK = True +except Exception: + _HEIF_OK = False + + +VALID_MODES = {"mirror_h", "rotate_180", "mirror_h_rotate_180"} + + +def infer_fix_mode(camera_facing: Optional[str]) -> Optional[str]: + """根据相机朝向推导修正模式。 + + - front: mirror_h(前置相机镜像校正) + - back: None(后置正常无需修正) + """ + if not camera_facing: + return None + facing = camera_facing.strip().lower() + if facing == "front": + return "mirror_h" + if facing == "back": + return None + return None + + +def parse_data_url(data_url: str) -> Tuple[Optional[str], bytes]: + """data URL -> (mime, bytes)。""" + if not data_url: + return None, b"" + m = re.match(r"^data:([^;,]+)?(;base64)?,(.*)$", data_url.strip(), re.DOTALL) + if not m: + return None, b"" + mime = m.group(1) or None + is_b64 = bool(m.group(2)) + payload = m.group(3) + if is_b64: + try: + return mime, base64.b64decode(payload) + except Exception: + return mime, b"" + return mime, payload.encode("utf-8") + + +def fix_image_bytes(image_bytes: bytes, mode: str) -> bytes: + """对单张图片字节应用修正,返回 PNG bytes。""" + if mode not in VALID_MODES: + raise ValueError(f"invalid image_fix mode: {mode}") + with Image.open(io.BytesIO(image_bytes)) as img: + img = ImageOps.exif_transpose(img) # 先按 EXIF 方向校正 + if mode == "mirror_h": + img = img.transpose(Image.FLIP_LEFT_RIGHT) + elif mode == "rotate_180": + img = img.transpose(Image.ROTATE_180) + elif mode == "mirror_h_rotate_180": + img = img.transpose(Image.FLIP_LEFT_RIGHT) + img = img.transpose(Image.ROTATE_180) + # 统一输出 PNG(无损,避免 JPEG 重压) + if img.mode in ("RGBA", "P"): + out = io.BytesIO() + img.save(out, format="PNG", optimize=True) + else: + out = io.BytesIO() + img.save(out, format="PNG", optimize=True) + return out.getvalue() + + +def fix_data_url(data_url: str, mode: str) -> str: + """修正 data URL,返回 PNG 的 data URL。""" + _, raw = parse_data_url(data_url) + if not raw: + return data_url + fixed = fix_image_bytes(raw, mode) + b64 = base64.b64encode(fixed).decode("ascii") + return f"data:image/png;base64,{b64}" + + +async def _fetch_image_bytes(url: str) -> Optional[bytes]: + """httpx 拉取 HTTP(S) 图片字节,失败返回 None。""" + from ..http_client import get_http_client + + try: + client = get_http_client() + resp = await client.get(url) + if resp.status_code != 200: + return None + return resp.content + except Exception: + return None + + +def _bytes_to_data_url(raw: bytes) -> str: + b64 = base64.b64encode(raw).decode("ascii") + return f"data:image/png;base64,{b64}" + + +async def fix_images_in_messages( + messages: list, + mode: Optional[str], +) -> Tuple[int, int, Optional[str]]: + """遍历 OpenAI messages 中的 image_url,应用修正。 + + 支持 data: URL 与 HTTP(S) URL(后者用 httpx fetch 后修正)。 + Returns: + (success_count, failed_count, warning) + """ + if not mode or mode not in VALID_MODES: + return 0, 0, None + success = 0 + failed = 0 + for msg in messages: + if not isinstance(msg, dict): + continue + content = msg.get("content") + if not isinstance(content, list): + continue + for part in content: + if not isinstance(part, dict): + continue + if part.get("type") != "image_url": + continue + url = (part.get("image_url") or {}).get("url") if isinstance(part.get("image_url"), dict) else part.get("image_url") + if not isinstance(url, str) or not url: + continue + try: + if url.startswith("data:"): + part["image_url"] = {"url": fix_data_url(url, mode)} + success += 1 + elif url.startswith(("http://", "https://")): + raw = await _fetch_image_bytes(url) + if not raw: + failed += 1 + continue + fixed = fix_image_bytes(raw, mode) + part["image_url"] = {"url": _bytes_to_data_url(fixed)} + success += 1 + else: + failed += 1 + except Exception: + failed += 1 + warning = None + if failed > 0: + warning = f"image_fix: {failed} image(s) failed to fix" + return success, failed, warning diff --git a/app/middleware.py b/app/middleware.py new file mode 100644 index 0000000000000000000000000000000000000000..a28731a50d590bd0e9b1c9b54a35fb674e94ec22 --- /dev/null +++ b/app/middleware.py @@ -0,0 +1,84 @@ +"""HTTP 中间件:速率限制等。 + +只对 /v1/* 业务路径限流,admin / health / docs 不限流。 +超限返回 429 + Retry-After 头。 +""" +from __future__ import annotations + +import json +from typing import Awaitable, Callable + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + +from .services import rate_limit_store + + +def _extract_access_key_from_request(request: Request) -> str: + auth = request.headers.get("authorization") + if auth: + a = auth.strip() + if a.lower().startswith("bearer "): + return a[7:].strip() + if a.lower().startswith("basic "): + return a[6:].strip() + return a + return request.headers.get("x-xtc-access-key") or request.headers.get("x-xtc-access-token") or "" + + +class RateLimitMiddleware(BaseHTTPMiddleware): + """per-key / per-IP 速率限制。""" + + async def dispatch( + self, + request: Request, + call_next: Callable[[Request], Awaitable[Response]], + ) -> Response: + path = request.url.path + if not rate_limit_store.is_rate_limited_path(path): + return await call_next(request) + + # OPTIONS 预检直接放行 + if request.method == "OPTIONS": + return await call_next(request) + + access_key = _extract_access_key_from_request(request) + client_ip = rate_limit_store.get_client_ip(request) + + allowed, reason, retry_after = rate_limit_store.check( + access_key=access_key or None, + client_ip=client_ip, + ) + if not allowed: + body = { + "ok": False, + "error": { + "code": "rate_limited", + "message": f"Rate limit exceeded ({reason}). Retry after {retry_after}s.", + "status": 429, + "retryable": True, + "hint": f"请等待 {retry_after} 秒后重试", + "retry_after": retry_after, + "scope": reason, + }, + } + return JSONResponse( + status_code=429, + content=body, + headers={ + "Retry-After": str(retry_after), + "X-RateLimit-Scope": reason or "", + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "*", + "Access-Control-Allow-Headers": "*", + }, + ) + + # 把限流信息附加到响应头(便于客户端观察) + response = await call_next(request) + try: + response.headers["X-RateLimit-Window"] = "60" + except Exception: + pass + return response diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/models/config.py b/app/models/config.py new file mode 100644 index 0000000000000000000000000000000000000000..5745d9d1112a337dc22788a2cc518b18950df1e3 --- /dev/null +++ b/app/models/config.py @@ -0,0 +1,166 @@ +"""配置数据模型:Provider、AppConfig。 + +向后兼容原 Netlify 版字段名(驼峰与下划线都接受)。 +""" +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, List, Literal, Optional + +from pydantic import BaseModel, Field, model_validator + + +def _split_key_list(value: Any) -> List[str]: + if not value: + return [] + if isinstance(value, list): + out = [] + for item in value: + s = str(item or "").strip() + if s: + out.append(s) + return list(dict.fromkeys(out)) + out = [] + for item in str(value).replace("\n", ",").replace(";", ",").split(","): + s = item.strip() + if s: + out.append(s) + return list(dict.fromkeys(out)) + + +class Provider(BaseModel): + """单个上游厂商配置。""" + + id: str + name: str = "" + type: Literal["gemini", "openai"] = "gemini" + base_url: Optional[str] = None + api_keys: List[str] = Field(default_factory=list) + api_key: Optional[str] = None # 兼容字段,等价于 api_keys[0] + model_prefix: str = "" + enabled: bool = True + model_policy_mode: Literal["allowlist", "denylist"] = "denylist" + allow_models: List[str] = Field(default_factory=list) + deny_models: List[str] = Field(default_factory=list) + + model_config = {"extra": "ignore"} + + @model_validator(mode="before") + @classmethod + def _normalize(cls, data: Any) -> Any: + if not isinstance(data, dict): + return data + # 兼容 alias + out = dict(data) + if "baseUrl" in out and "base_url" not in out: + out["base_url"] = out.pop("baseUrl") + if "modelPrefix" in out and "model_prefix" not in out: + out["model_prefix"] = out.pop("modelPrefix") + if "modelPolicyMode" in out and "model_policy_mode" not in out: + out["model_policy_mode"] = out.pop("modelPolicyMode") + if "allowModels" in out and "allow_models" not in out: + out["allow_models"] = out.pop("allowModels") + if "denyModels" in out and "deny_models" not in out: + out["deny_models"] = out.pop("denyModels") + # enabled_models / disabled_models 别名 + if "enabledModels" in out and "allow_models" not in out: + out["allow_models"] = out.pop("enabledModels") + if "disabledModels" in out and "deny_models" not in out: + out["deny_models"] = out.pop("disabledModels") + # id 默认从 name + if not out.get("id"): + out["id"] = str(out.get("name") or "").strip() + # 名称默认从 id + if not out.get("name"): + out["name"] = str(out.get("id") or "").strip() + # apiKeys / api_keys / apiKey 合并去重(兼容多种命名) + keys = _split_key_list(out.get("api_keys")) + keys += _split_key_list(out.get("apiKeys")) + keys += _split_key_list(out.get("api_key")) + keys += _split_key_list(out.get("apiKey")) + out["api_keys"] = list(dict.fromkeys(keys)) + out["api_key"] = out["api_keys"][0] if out["api_keys"] else None + # base_url 兜底 + if not out.get("base_url"): + ptype = str(out.get("type") or "").lower() + out["base_url"] = None if ptype == "gemini" else "https://api.openai.com" + else: + out["base_url"] = str(out["base_url"]).rstrip("/") + return out + + def masked(self) -> "Provider": + """返回脱敏副本(用于后台展示,密钥只显示首尾)。""" + def mask(k: str) -> str: + k = str(k or "") + return f"{k[:6]}***{k[-4:]}" if len(k) > 10 else "***" + return Provider( + id=self.id, + name=self.name, + type=self.type, + base_url=self.base_url, + api_keys=[mask(k) for k in self.api_keys], + api_key=mask(self.api_key) if self.api_key else None, + model_prefix=self.model_prefix, + enabled=self.enabled, + model_policy_mode=self.model_policy_mode, + allow_models=list(self.allow_models), + deny_models=list(self.deny_models), + ) + + def public(self) -> dict: + """返回给手表端 /v1/xtc/providers 的精简信息。""" + return { + "id": self.id, + "name": self.name, + "type": self.type, + "modelPrefix": self.model_prefix or "", + } + + +class AppConfig(BaseModel): + """完整配置(厂商列表 + 默认厂商 + 更新时间)。""" + + providers: List[Provider] = Field(default_factory=list) + default_provider_id: Optional[str] = None + updated_at: Optional[str] = None + + model_config = {"extra": "ignore"} + + @model_validator(mode="before") + @classmethod + def _normalize(cls, data: Any) -> Any: + if not isinstance(data, dict): + return data + out = dict(data) + if "defaultProviderId" in out and "default_provider_id" not in out: + out["default_provider_id"] = out.pop("defaultProviderId") + if "updatedAt" in out and "updated_at" not in out: + out["updated_at"] = out.pop("updatedAt") + return out + + def normalize(self) -> "AppConfig": + """确保 default_provider_id 指向启用的厂商。""" + enabled = [p for p in self.providers if p.enabled] + if self.default_provider_id and any(p.id == self.default_provider_id for p in enabled): + return self + self.default_provider_id = enabled[0].id if enabled else None + self.updated_at = datetime.now(timezone.utc).isoformat() + return self + + def find_provider(self, provider_id: Optional[str], *, enabled_only: bool = True) -> Optional[Provider]: + if not provider_id: + return None + for p in self.providers: + if p.id == provider_id and (not enabled_only or p.enabled): + return p + return None + + def find_default_provider(self) -> Optional[Provider]: + if self.default_provider_id: + p = self.find_provider(self.default_provider_id) + if p: + return p + for p in self.providers: + if p.enabled: + return p + return None diff --git a/app/providers/__init__.py b/app/providers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/providers/keypool.py b/app/providers/keypool.py new file mode 100644 index 0000000000000000000000000000000000000000..e4b491eb77eff313126bcd36edf052cebf7edf49 --- /dev/null +++ b/app/providers/keypool.py @@ -0,0 +1,55 @@ +"""多密钥轮询:SQLite 持久化计数器,冷启动不重置。 + +替代原内存 Map(RR_STATE)。 +""" +from __future__ import annotations + +import threading + +from ..database import get_conn +from ..models.config import Provider +from ..errors import HttpError + +_lock = threading.Lock() + + +def choose_provider_api_key(provider: Provider) -> str: + """从 provider.api_keys 中按 RR 取一个,空则报错。""" + keys = list(provider.api_keys or []) + if not keys: + raise HttpError( + f"Provider '{provider.id}' has no api_keys configured", + status=503, + code="server_misconfigured", + ) + if len(keys) == 1: + return keys[0] + with _lock: + with get_conn() as conn: + row = conn.execute( + "SELECT counter FROM rr_counter WHERE provider_id = ?", + (provider.id,), + ).fetchone() + current = int(row["counter"]) if row else 0 + next_counter = current + 1 + if row: + conn.execute( + "UPDATE rr_counter SET counter = ? WHERE provider_id = ?", + (next_counter, provider.id), + ) + else: + conn.execute( + "INSERT INTO rr_counter(provider_id, counter) VALUES(?, ?)", + (provider.id, next_counter), + ) + idx = current % len(keys) + return keys[idx] + + +def reset_counter(provider_id: str) -> None: + with _lock: + with get_conn() as conn: + conn.execute( + "DELETE FROM rr_counter WHERE provider_id = ?", + (provider_id,), + ) diff --git a/app/providers/policy.py b/app/providers/policy.py new file mode 100644 index 0000000000000000000000000000000000000000..b45ca74483497a4f7aa8a4ed6b17f3c449866f42 --- /dev/null +++ b/app/providers/policy.py @@ -0,0 +1,56 @@ +"""模型策略:白名单/黑名单 + 通配符匹配。 + +兼容原 glob 模式:* 匹配任意片段,? 匹配单字符。 +""" +from __future__ import annotations + +import fnmatch +import re +from typing import Iterable + +from ..models.config import Provider +from ..errors import HttpError + + +def _patterns_to_regex(patterns: Iterable[str]) -> list[re.Pattern]: + out = [] + for p in patterns: + if not p: + continue + # fnmatch 通配符 -> regex + regex = fnmatch.translate(p) + out.append(re.compile(regex)) + return out + + +def _any_match(patterns: Iterable[str], model: str) -> bool: + for rx in _patterns_to_regex(patterns): + if rx.fullmatch(model): + return True + return False + + +def assert_model_allowed(provider: Provider, model: str) -> None: + """校验模型是否被允许,否则抛 model_disabled。""" + if provider.model_policy_mode == "allowlist": + if provider.allow_models and not _any_match(provider.allow_models, model): + raise HttpError( + f"Model '{model}' is not in allowlist of provider '{provider.id}'", + status=403, + code="model_disabled", + ) + else: # denylist + if provider.deny_models and _any_match(provider.deny_models, model): + raise HttpError( + f"Model '{model}' is in denylist of provider '{provider.id}'", + status=403, + code="model_disabled", + ) + + +def is_model_allowed(provider: Provider, model: str) -> bool: + try: + assert_model_allowed(provider, model) + return True + except HttpError: + return False diff --git a/app/providers/resolver.py b/app/providers/resolver.py new file mode 100644 index 0000000000000000000000000000000000000000..fffd5211a09a92ef681be7851b3eca6f57eefb8e --- /dev/null +++ b/app/providers/resolver.py @@ -0,0 +1,58 @@ +"""厂商解析:按 provider id / modelPrefix / 默认厂商定位。""" +from __future__ import annotations + +from typing import Optional + +from ..models.config import AppConfig, Provider +from ..errors import HttpError + + +def resolve_provider( + config: AppConfig, + *, + provider_id: Optional[str] = None, + model: Optional[str] = None, +) -> Provider: + """按优先级解析厂商:显式 id > modelPrefix 匹配 > 默认厂商。""" + # 1. 显式 provider_id + if provider_id: + p = config.find_provider(provider_id) + if not p: + raise HttpError( + f"Provider not found or disabled: {provider_id}", + status=404, + code="not_found", + ) + return p + + # 2. modelPrefix 匹配 + if model: + for p in config.providers: + if not p.enabled or not p.model_prefix: + continue + if model.startswith(p.model_prefix): + return p + + # 3. 默认厂商 + default = config.find_default_provider() + if not default: + raise HttpError( + "No enabled provider configured. Add one in /admin.", + status=503, + code="service_unavailable", + ) + return default + + +def normalize_model(provider: Provider, model: Optional[str], *, fallback: Optional[str] = None) -> str: + """剥离 provider 前缀,回退到默认模型。""" + if model: + if provider.model_prefix and model.startswith(provider.model_prefix): + return model[len(provider.model_prefix):] + return model + if fallback: + return fallback + if provider.type == "gemini": + from ..config import GEMINI_DEFAULT_MODEL + return GEMINI_DEFAULT_MODEL + return "gpt-4o-mini" diff --git a/app/request_log_middleware.py b/app/request_log_middleware.py new file mode 100644 index 0000000000000000000000000000000000000000..126a58d4a17364d1c56e8844411a955bb462e609 --- /dev/null +++ b/app/request_log_middleware.py @@ -0,0 +1,258 @@ +"""请求日志中间件:捕获每个业务请求的完整入参/出参/耗时/错误。 + +记录范围:/v1/* 和 /admin/api/* +- 流式响应:只记录前 N 个 chunk 摘要 + 总 chunk 数 + 总字节数(避免内存爆炸) +- 请求体:缓存原始 bytes,供下游读取(FastAPI 的 request.body() 会消费) +- 敏感标头/key 自动脱敏 +- 失败不影响主流程(记录本身失败也静默) + +注意:BaseHTTPMiddleware 会消费 request body,需要用 request.state 透传缓存。 +这里采用读 body → 存 request.state.cached_body → 下游通过依赖重新注入的方式。 +但 FastAPI 的 Request.body() 内部有缓存,如果中间件先读了,下游再读会拿到缓存。 +所以直接 await request.body() 即可,下游能拿到相同结果。 +""" +from __future__ import annotations + +import json +import time +from typing import Any, Awaitable, Callable + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response, StreamingResponse + +from .services import request_log_store + + +# 记录范围 +_RECORDED_PREFIXES = ("/v1/", "/admin/api/") +# 排除项(避免日志刷屏:admin HTML 轮询、health) +_EXCLUDED_PATHS = {"/admin/api/request-log"} # 避免查询日志本身被记录导致递归刷屏 + +# 流式响应采样上限 +_MAX_STREAM_CHUNKS_LOG = 20 +_MAX_STREAM_BYTES_LOG = 8 * 1024 + + +def _should_record(path: str) -> bool: + if not path: + return False + if path in _EXCLUDED_PATHS: + return False + return any(path.startswith(p) for p in _RECORDED_PREFIXES) + + +def _extract_access_key(request: Request) -> str: + auth = request.headers.get("authorization") + if auth: + a = auth.strip() + if a.lower().startswith("bearer "): + return a[7:].strip() + if a.lower().startswith("basic "): + return a[6:].strip() + return a + return ( + request.headers.get("x-xtc-access-key") + or request.headers.get("x-xtc-access-token") + or "" + ) + + +def _safe_json_loads(s: str) -> Any: + try: + return json.loads(s) + except Exception: + return s + + +class RequestLogMiddleware(BaseHTTPMiddleware): + """记录业务请求的完整日志。""" + + async def dispatch( + self, + request: Request, + call_next: Callable[[Request], Awaitable[Response]], + ) -> Response: + path = request.url.path + if not _should_record(path): + return await call_next(request) + + start = time.time() + method = request.method + query = str(request.url.query) if request.url.query else None + + # 读请求体(缓存到 state,下游可复用) + request_body_raw: bytes = b"" + try: + request_body_raw = await request.body() + except Exception: + pass + + # 请求标头脱敏 + req_headers = request_log_store._redact_headers(dict(request.headers)) + + # 请求体脱敏 + req_body_redacted: Any = None + if request_body_raw: + ct = (request.headers.get("content-type") or "").lower() + if "application/json" in ct: + try: + req_body_redacted = request_log_store._redact_body(json.loads(request_body_raw)) + except Exception: + req_body_redacted = request_body_raw.decode("utf-8", "replace") + elif "multipart/form-data" in ct: + req_body_redacted = "[multipart form-data]" + else: + req_body_redacted = request_body_raw.decode("utf-8", "replace") + + access_key = _extract_access_key(request) + client_ip = request.client.host if request.client else None + user_agent = request.headers.get("user-agent") + + # 公共日志构造 + def _build_record(status_code, elapsed_ms, ok, provider, model, is_stream, + error_code, error_message, resp_body, resp_headers): + return { + "method": method, "path": path, "query": query, + "status_code": status_code, "elapsed_ms": elapsed_ms, + "access_key": access_key, "client_ip": client_ip, + "user_agent": user_agent, + "provider": provider, "model": model, "stream": is_stream, + "ok": ok, "error_code": error_code, "error_message": error_message, + "request_headers": json.dumps(req_headers, ensure_ascii=False), + "request_body": json.dumps(req_body_redacted, ensure_ascii=False) if req_body_redacted is not None else None, + "response_body": json.dumps(resp_body, ensure_ascii=False) if resp_body is not None else None, + "response_headers": json.dumps(resp_headers, ensure_ascii=False), + } + + # 调用下游 + response: Response + try: + response = await call_next(request) + except Exception as e: + elapsed_ms = int((time.time() - start) * 1000) + try: + request_log_store.insert(_build_record( + 500, elapsed_ms, False, None, None, False, + "internal_error", str(e), None, None, + )) + except Exception: + pass + raise + + # 从响应头提取业务信息 + provider = response.headers.get("x-xtc-provider") + model = response.headers.get("x-xtc-model") + is_stream = response.headers.get("content-type", "").startswith("text/event-stream") + status_code = response.status_code + ok = 200 <= status_code < 400 + resp_headers = request_log_store._redact_headers(dict(response.headers)) + + if isinstance(response, StreamingResponse): + # 流式响应:包装 iterator,在迭代过程中采集 chunk,迭代结束后写日志 + return self._wrap_streaming( + response, start, _build_record, + provider, model, is_stream, status_code, ok, resp_headers, + ) + + # 普通响应:直接读 body + error_code = None + error_message = None + resp_body_str: Any = None + try: + body_bytes = b"" + async for chunk in response.body_iterator: + body_bytes += chunk + response.body_iterator = _iter_bytes(body_bytes) + ct = (response.headers.get("content-type") or "").lower() + if "application/json" in ct: + try: + parsed = json.loads(body_bytes) + resp_body_str = parsed + if isinstance(parsed, dict) and parsed.get("ok") is False: + err = parsed.get("error") or {} + error_code = err.get("code") + error_message = err.get("message") + except Exception: + resp_body_str = body_bytes.decode("utf-8", "replace") + else: + resp_body_str = body_bytes.decode("utf-8", "replace") + if len(resp_body_str) > 4096: + resp_body_str = resp_body_str[:4096] + "...[truncated]" + except Exception as e: + resp_body_str = f"[capture failed: {e}]" + + elapsed_ms = int((time.time() - start) * 1000) + try: + request_log_store.insert(_build_record( + status_code, elapsed_ms, ok, provider, model, is_stream, + error_code, error_message, resp_body_str, resp_headers, + )) + except Exception as e: + print(f"[request_log] insert failed: {e}") + + return response + + def _wrap_streaming( + self, response: StreamingResponse, start: float, _build_record, + provider, model, is_stream, status_code, ok, resp_headers, + ) -> StreamingResponse: + """包装流式响应:边发送边采集,发送结束后写日志。""" + original_iterator = response.body_iterator + chunks_collected: list[str] = [] + total_bytes = 0 + total_chunks = 0 + # 流式错误捕获 + error_code = None + error_message = None + + async def wrapped(): + nonlocal total_bytes, total_chunks, error_code, error_message + try: + async for chunk in original_iterator: + if isinstance(chunk, str): + chunk_bytes = chunk.encode("utf-8") + else: + chunk_bytes = chunk + total_bytes += len(chunk_bytes) + total_chunks += 1 + # 采集前 N 个 chunk + if total_chunks <= _MAX_STREAM_CHUNKS_LOG: + text = chunk_bytes.decode("utf-8", "replace") + chunks_collected.append(text) + # 尝试从 SSE data 中提取错误 + if not ok and text.startswith("data:"): + payload = text[5:].strip() + if payload and payload != "[DONE]": + try: + obj = json.loads(payload) + if isinstance(obj, dict) and obj.get("error"): + err = obj["error"] + error_code = err.get("code") if isinstance(err, dict) else None + error_message = err.get("message") if isinstance(err, dict) else str(err) + except Exception: + pass + yield chunk + finally: + # 流结束后写日志 + elapsed_ms = int((time.time() - start) * 1000) + summary = { + "type": "streaming", + "total_chunks": total_chunks, + "total_bytes": total_bytes, + "sampled_chunks": chunks_collected, + } + try: + request_log_store.insert(_build_record( + status_code, elapsed_ms, ok, provider, model, is_stream, + error_code, error_message, summary, resp_headers, + )) + except Exception as e: + print(f"[request_log] streaming insert failed: {e}") + + response.body_iterator = wrapped() + return response + + +async def _iter_bytes(data: bytes): + yield data diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/services/config_store.py b/app/services/config_store.py new file mode 100644 index 0000000000000000000000000000000000000000..2030803b4eb1d683a59a6841f508454fd18812d7 --- /dev/null +++ b/app/services/config_store.py @@ -0,0 +1,255 @@ +"""配置存储:SQLite kv 表(热)+ HF Hub dataset 仓库(配置备份)。 + +简化点:相比原 Netlify Blobs,配置不再加密。 +KV key:providers / default_provider_id / updated_at 合并为单一 JSON 存储于 kv 表 key=app_config。 + +混合方案(仅配置文件走 Hub,其余数据纯本地): +- SQLite 是热存储,读写性能最佳 +- HF Hub dataset 仓库做配置备份,解决 HF Spaces 免费档磁盘 ephemeral(restart/stop 丢数据)问题 +- 启动时 DB 优先 → Hub 拉取 → env 种子,三层回退 +- 保存时写 DB + 异步推 Hub(Hub 失败不影响主流程) +- 未配置 HF_TOKEN / HF_CONFIG_REPO 时自动降级为纯本地 +""" +from __future__ import annotations + +import asyncio +import json +import time +from datetime import datetime, timezone +from typing import Optional + +from ..config import get_settings +from ..database import get_conn +from ..hf_storage import NS_CONFIG, download_bytes, push_to_hub, upload_bytes +from ..models.config import AppConfig, Provider + +KV_APP_CONFIG_KEY = "app_config" +_HUB_CONFIG_KEY = "app_config.json" + +_lock = asyncio.Lock() + + +def _now_ts() -> int: + return int(time.time()) + + +def _load_from_db() -> AppConfig: + with get_conn() as conn: + row = conn.execute( + "SELECT value FROM kv WHERE key = ?", (KV_APP_CONFIG_KEY,) + ).fetchone() + if not row: + return AppConfig(providers=[], default_provider_id=None) + try: + data = json.loads(row["value"]) + return AppConfig.model_validate(data) + except Exception as e: + print(f"[config_store] failed to parse stored config: {e}") + return AppConfig(providers=[], default_provider_id=None) + + +def _save_to_db(config: AppConfig) -> None: + payload = json.dumps(config.model_dump(mode="json"), ensure_ascii=False) + ts = _now_ts() + with get_conn() as conn: + conn.execute( + "INSERT INTO kv(key, value, updated_at) VALUES(?,?,?) " + "ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at", + (KV_APP_CONFIG_KEY, payload, ts), + ) + + +def _seed_from_env() -> Optional[AppConfig]: + """首次启动时用环境变量种子生成初始配置。""" + settings = get_settings() + providers: list[Provider] = [] + if settings.gemini_api_keys: + providers.append( + Provider( + id="gemini", + name="Gemini", + type="gemini", + api_keys=list(settings.gemini_api_keys), + base_url=None, + ) + ) + if settings.openai_api_keys: + providers.append( + Provider( + id="openai", + name="OpenAI", + type="openai", + api_keys=list(settings.openai_api_keys), + base_url=settings.openai_base_url, + ) + ) + if not providers: + return None + return AppConfig( + providers=providers, + default_provider_id=providers[0].id, + updated_at=datetime.now(timezone.utc).isoformat(), + ) + + +async def load_config() -> AppConfig: + """加载配置(DB 优先 → HF Hub 拉取 → env 种子)。""" + async with _lock: + cfg = _load_from_db() + if cfg.providers: + return cfg.normalize() + + # 尝试从 HF Hub 拉取配置备份 + try: + hub_cfg = await _load_from_hub() + if hub_cfg and hub_cfg.providers: + _save_to_db(hub_cfg) + return hub_cfg.normalize() + except Exception as e: + print(f"[config_store] HF Hub load failed: {e}") + + # 用 env 种子 + seeded = _seed_from_env() + if seeded: + _save_to_db(seeded) + try: + await _push_to_hub(seeded) + except Exception as e: + print(f"[config_store] HF Hub push failed: {e}") + return seeded.normalize() + + return AppConfig(providers=[], default_provider_id=None).normalize() + + +async def save_config(config: AppConfig) -> AppConfig: + """保存配置:写 DB + 异步推 HF Hub + Webhook 通知。""" + config.normalize() + async with _lock: + _save_to_db(config) + # HF Hub 同步失败不影响主流程 + try: + await _push_to_hub(config) + except Exception as e: + print(f"[config_store] HF Hub push failed: {e}") + # Webhook 通知 + try: + from ..services import webhook_store + await webhook_store.notify( + "config.updated", + { + "providers": [p.id for p in config.providers], + "default_provider_id": config.default_provider_id, + "updated_at": config.updated_at, + }, + ) + except Exception: + pass + return config + + +def load_config_sync() -> AppConfig: + """同步加载(用于非 async 上下文,如启动钩子)。""" + cfg = _load_from_db() + if cfg.providers: + return cfg.normalize() + seeded = _seed_from_env() + if seeded: + _save_to_db(seeded) + return seeded.normalize() + return AppConfig(providers=[], default_provider_id=None).normalize() + + +# ===== HF Hub 配置备份同步 ===== + +async def _load_from_hub() -> Optional[AppConfig]: + """从 HF Hub dataset 仓库(config/ 命名空间)拉取配置 JSON。""" + def _fetch() -> Optional[dict]: + data = download_bytes(NS_CONFIG, _HUB_CONFIG_KEY) + if not data: + return None + try: + return json.loads(data.decode("utf-8")) + except Exception as e: + print(f"[config_store] parse hub config failed: {e}") + return None + + data = await asyncio.to_thread(_fetch) + if not data: + return None + try: + return AppConfig.model_validate(data) + except Exception as e: + print(f"[config_store] hub config invalid: {e}") + return None + + +async def _push_to_hub(config: AppConfig) -> None: + """把配置 JSON 推到 HF Hub dataset 仓库。""" + payload = json.dumps(config.model_dump(mode="json"), ensure_ascii=False, indent=2).encode("utf-8") + + def _upload() -> None: + upload_bytes(NS_CONFIG, _HUB_CONFIG_KEY, payload) + + await asyncio.to_thread(_upload) + # 异步推到 Hub 远端(push_to_hub 内部会处理失败与降级) + await push_to_hub(NS_CONFIG, _HUB_CONFIG_KEY) + + +# ===== 厂商增删改查辅助 ===== + +async def add_provider(provider: Provider) -> AppConfig: + cfg = await load_config() + cfg.providers = [p for p in cfg.providers if p.id != provider.id] + cfg.providers.append(provider) + return await save_config(cfg) + + +async def update_provider(provider_id: str, update: dict) -> AppConfig: + cfg = await load_config() + for i, p in enumerate(cfg.providers): + if p.id == provider_id: + merged = {**p.model_dump(), **update} + cfg.providers[i] = Provider.model_validate(merged) + break + return await save_config(cfg) + + +async def remove_provider(provider_id: str) -> AppConfig: + cfg = await load_config() + cfg.providers = [p for p in cfg.providers if p.id != provider_id] + if cfg.default_provider_id == provider_id: + cfg.default_provider_id = None + return await save_config(cfg) + + +async def set_default_provider(provider_id: str) -> AppConfig: + cfg = await load_config() + if not any(p.id == provider_id for p in cfg.providers): + raise ValueError(f"provider not found: {provider_id}") + cfg.default_provider_id = provider_id + return await save_config(cfg) + + +async def set_provider_settings( + provider_id: str, + *, + enabled: Optional[bool] = None, + model_policy_mode: Optional[str] = None, + allow_models: Optional[list[str]] = None, + deny_models: Optional[list[str]] = None, +) -> AppConfig: + cfg = await load_config() + for i, p in enumerate(cfg.providers): + if p.id == provider_id: + data = p.model_dump() + if enabled is not None: + data["enabled"] = enabled + if model_policy_mode is not None: + data["model_policy_mode"] = model_policy_mode + if allow_models is not None: + data["allow_models"] = list(allow_models) + if deny_models is not None: + data["deny_models"] = list(deny_models) + cfg.providers[i] = Provider.model_validate(data) + break + return await save_config(cfg) diff --git a/app/services/pseudo_store.py b/app/services/pseudo_store.py new file mode 100644 index 0000000000000000000000000000000000000000..2e6a5a7160af22ea346407f0e162f24d1c8b8943 --- /dev/null +++ b/app/services/pseudo_store.py @@ -0,0 +1,183 @@ +"""伪流式会话存储:SQLite pseudo_sessions + pseudo_events 表。 + +替代原 Netlify Blobs 的 xtc-pseudo-chat store。 +TTL 10 分钟,定期清理过期会话。 +""" +from __future__ import annotations + +import asyncio +import json +import time +import uuid +from typing import Any, Optional + +from ..config import PSEUDO_SESSION_TTL_SEC +from ..database import get_conn + + +def _now_ts() -> int: + return int(time.time()) + + +def create_session(payload: Optional[dict] = None) -> str: + session_id = uuid.uuid4().hex + now = _now_ts() + with get_conn() as conn: + conn.execute( + "INSERT INTO pseudo_sessions(id, created_at, updated_at, done, error, thought, text, seq, payload) " + "VALUES(?,?,?,?,?,?,?,?,?)", + (session_id, now, now, 0, None, "", "", 0, json.dumps(payload or {}, ensure_ascii=False)), + ) + return session_id + + +def append_event( + session_id: str, + *, + type: str, + delta: Optional[str] = None, + finish_reason: Optional[str] = None, + extra: Optional[dict] = None, +) -> int: + """追加一条事件,返回该事件的 seq。""" + now = _now_ts() + with get_conn() as conn: + row = conn.execute( + "SELECT seq FROM pseudo_sessions WHERE id = ?", (session_id,) + ).fetchone() + if not row: + raise KeyError(f"session not found: {session_id}") + next_seq = int(row["seq"]) + 1 + conn.execute( + "UPDATE pseudo_sessions SET seq = ?, updated_at = ? WHERE id = ?", + (next_seq, now, session_id), + ) + conn.execute( + "INSERT INTO pseudo_events(seq, session_id, at, type, delta, finish_reason, extra) " + "VALUES(?,?,?,?,?,?,?)", + ( + next_seq, + session_id, + now, + type, + delta, + finish_reason, + json.dumps(extra, ensure_ascii=False) if extra else None, + ), + ) + return next_seq + + +def update_session_state( + session_id: str, + *, + done: Optional[bool] = None, + error: Optional[str] = None, + thought: Optional[str] = None, + text: Optional[str] = None, +) -> None: + sets: list[str] = [] + args: list[Any] = [] + if done is not None: + sets.append("done = ?") + args.append(1 if done else 0) + if error is not None: + sets.append("error = ?") + args.append(error) + if thought is not None: + sets.append("thought = ?") + args.append(thought) + if text is not None: + sets.append("text = ?") + args.append(text) + if not sets: + return + sets.append("updated_at = ?") + args.append(_now_ts()) + args.append(session_id) + with get_conn() as conn: + conn.execute( + f"UPDATE pseudo_sessions SET {', '.join(sets)} WHERE id = ?", + tuple(args), + ) + + +def get_session(session_id: str) -> Optional[dict]: + with get_conn() as conn: + row = conn.execute( + "SELECT * FROM pseudo_sessions WHERE id = ?", (session_id,) + ).fetchone() + if not row: + return None + return dict(row) + + +def list_events_after(session_id: str, cursor: int = 0) -> list[dict]: + with get_conn() as conn: + rows = conn.execute( + "SELECT * FROM pseudo_events WHERE session_id = ? AND seq > ? ORDER BY seq ASC", + (session_id, cursor), + ).fetchall() + return [dict(r) for r in rows] + + +def poll(session_id: str, cursor: int = 0) -> Optional[dict]: + """轮询:返回状态 + 增量事件 + 新游标。""" + sess = get_session(session_id) + if not sess: + return None + events = list_events_after(session_id, cursor) + new_cursor = max([e["seq"] for e in events], default=cursor) + status = "error" if sess["error"] else ("done" if sess["done"] else "running") + return { + "session_id": session_id, + "status": status, + "error": sess["error"], + "thought": sess["thought"], + "text": sess["text"], + "done": bool(sess["done"]), + "deltas": [ + { + "seq": e["seq"], + "type": e["type"], + "delta": e["delta"], + "finish_reason": e["finish_reason"], + "extra": json.loads(e["extra"]) if e["extra"] else None, + } + for e in events + ], + "cursor": new_cursor, + "expires_in_sec": max( + 0, + PSEUDO_SESSION_TTL_SEC - (_now_ts() - int(sess["updated_at"])), + ), + } + + +def cleanup_expired() -> int: + """清理过期会话,返回清理数量。""" + threshold = _now_ts() - PSEUDO_SESSION_TTL_SEC + with get_conn() as conn: + cur = conn.execute( + "DELETE FROM pseudo_events WHERE session_id IN (" + " SELECT id FROM pseudo_sessions WHERE updated_at < ?" + ")", + (threshold,), + ) + cur2 = conn.execute( + "DELETE FROM pseudo_sessions WHERE updated_at < ?", + (threshold,), + ) + return cur2.rowcount or 0 + + +async def cleanup_loop() -> None: + """后台定期清理。""" + while True: + try: + n = cleanup_expired() + if n: + print(f"[pseudo_store] cleaned {n} expired sessions") + except Exception as e: + print(f"[pseudo_store] cleanup error: {e}") + await asyncio.sleep(60) diff --git a/app/services/rate_limit_store.py b/app/services/rate_limit_store.py new file mode 100644 index 0000000000000000000000000000000000000000..b41ee85588168bc859c83241ba3678b1f70d137a --- /dev/null +++ b/app/services/rate_limit_store.py @@ -0,0 +1,110 @@ +"""速率限制:内存滑动窗口(cachetools.TTLCache)。 + +策略: +- per-key:按 access_key 限流(已鉴权用户) +- per-IP:按客户端 IP 限流(未鉴权或叠加) +- 多实例下各自计数(HF Space 通常单实例,CF Workers 反代也是单实例) + +限流对象:/v1/* 路径(chat / files / sessions 等业务接口) +不限流:/admin/* /health /docs /openapi.json +""" +from __future__ import annotations + +import threading +import time +from collections import deque +from typing import Optional + +from cachetools import TTLCache + +from ..config import get_settings + + +# 默认策略:60 秒窗口内每 key 60 次,每 IP 120 次 +_DEFAULT_WINDOW_SEC = 60 +_DEFAULT_PER_KEY = 60 +_DEFAULT_PER_IP = 120 + +# bucket -> deque[timestamps] +_key_cache: TTLCache = TTLCache(maxsize=10000, ttl=_DEFAULT_WINDOW_SEC * 2) +_ip_cache: TTLCache = TTLCache(maxsize=10000, ttl=_DEFAULT_WINDOW_SEC * 2) +_lock = threading.Lock() + + +def _settings_limits() -> tuple[int, int, int]: + """从环境变量读取限流参数(可选)。""" + s = get_settings() + window = int(getattr(s, "rate_limit_window_sec", _DEFAULT_WINDOW_SEC) or _DEFAULT_WINDOW_SEC) + per_key = int(getattr(s, "rate_limit_per_key", _DEFAULT_PER_KEY) or _DEFAULT_PER_KEY) + per_ip = int(getattr(s, "rate_limit_per_ip", _DEFAULT_PER_IP) or _DEFAULT_PER_IP) + return window, per_key, per_ip + + +def _check_bucket(cache: TTLCache, bucket: str, limit: int, window: int) -> tuple[bool, int, int]: + """检查并推进一个桶。返回 (allowed, current_count, retry_after_sec)。""" + now = time.time() + cutoff = now - window + with _lock: + dq = cache.get(bucket) + if dq is None: + dq = deque() + cache[bucket] = dq + # 淘汰过期时间戳 + while dq and dq[0] < cutoff: + dq.popleft() + if len(dq) >= limit: + # 计算最早一条何时过期 + retry_after = int(dq[0] + window - now) + 1 + return False, len(dq), max(1, retry_after) + dq.append(now) + return True, len(dq), 0 + + +def check(*, access_key: Optional[str], client_ip: Optional[str]) -> tuple[bool, Optional[str], int]: + """检查速率限制。 + + Returns: + (allowed, reason, retry_after_sec) + reason 为 None 表示放行;否则给出限流维度 "key" 或 "ip" + """ + window, per_key, per_ip = _settings_limits() + + if access_key: + ok, _, retry = _check_bucket(_key_cache, f"k:{access_key}", per_key, window) + if not ok: + return False, "key", retry + + if client_ip: + ok, _, retry = _check_bucket(_ip_cache, f"ip:{client_ip}", per_ip, window) + if not ok: + return False, "ip", retry + + return True, None, 0 + + +def is_rate_limited_path(path: str) -> bool: + """是否对该路径启用限流。""" + if not path: + return False + # 伪流式轮询频率高且需 session_id 鉴权,不计入限流 + if path == "/v1/xtc/chat/pseudo/poll": + return False + # 仅对业务 API 限流 + return path.startswith("/v1/") + + +def get_client_ip(request) -> Optional[str]: + """提取客户端 IP(支持 CF Workers / 代理转发的 X-Forwarded-For)。""" + # CF Workers 反代后会带 X-Forwarded-For + xff = request.headers.get("x-forwarded-for") + if xff: + # 取第一个(最原始的客户端) + return xff.split(",")[0].strip() + x_real_ip = request.headers.get("x-real-ip") + if x_real_ip: + return x_real_ip.strip() + # 兜底用 request.client + client = getattr(request, "client", None) + if client and getattr(client, "host", None): + return client.host + return None diff --git a/app/services/request_log_store.py b/app/services/request_log_store.py new file mode 100644 index 0000000000000000000000000000000000000000..02b21da20efd640dc1c0ba252ff959622e62e80a --- /dev/null +++ b/app/services/request_log_store.py @@ -0,0 +1,352 @@ +"""请求日志存储:完整记录每个业务请求的入参/出参/耗时/错误。 + +借鉴 ds2api 的设计: +- 完整记录请求标头、请求体、响应体、响应标头、耗时、错误码 +- 容量轮转:保留最近 N 条(默认 500,可配 0/100/200/500/1000) +- 列表查询返回摘要(不带大 body),详情查询返回完整 +- 敏感信息脱敏(authorization / api_key / x-xtc-admin-key 等) + +记录范围:/v1/* 和 /admin/api/* 路径 +不记录:/health /docs /openapi.json /redoc /admin(HTML 页面)静态资源 +""" +from __future__ import annotations + +import json +import re +import time +from typing import Any, Optional + +from ..database import get_conn + + +# ===== 常量 ===== + +# 敏感标头/key 脱敏 +_SENSITIVE_HEADERS = { + "authorization", "x-xtc-admin-key", "x-xtc-access-key", "x-xtc-access-token", + "cookie", "x-api-key", "x-hf-token", "hf-token", +} +_SENSITIVE_BODY_KEYS = { + "api_key", "apikey", "api_keys", "apikey", "key", "token", "access_token", + "access_key", "admin_key", "password", "secret", "authorization", +} +# body 最大记录长度(超过截断) +_MAX_BODY_LEN = 64 * 1024 # 64KB +# 标头最大记录长度 +_MAX_HEADERS_LEN = 16 * 1024 # 16KB + + +# ===== 脱敏 ===== + +def _redact_headers(headers: dict) -> dict: + """脱敏敏感标头:保留前 4 位 + ***。""" + out = {} + for k, v in headers.items(): + kl = k.lower() + if kl in _SENSITIVE_HEADERS: + sv = str(v) + if len(sv) <= 8: + out[k] = "***" + else: + out[k] = sv[:4] + "***" + sv[-2:] + else: + out[k] = v + return out + + +def _redact_body(body: Any) -> Any: + """递归脱敏 body 中的敏感字段。""" + if isinstance(body, dict): + out = {} + for k, v in body.items(): + if k.lower() in _SENSITIVE_BODY_KEYS: + out[k] = "***REDACTED***" + else: + out[k] = _redact_body(v) + return out + if isinstance(body, list): + return [_redact_body(item) for item in body] + return body + + +def _truncate(s: Optional[str], max_len: int) -> Optional[str]: + if s is None: + return None + if len(s) <= max_len: + return s + return s[:max_len] + f"\n...[truncated, total {len(s)} bytes]" + + +# ===== 容量配置 ===== + +def get_limit() -> int: + """读取容量上限。0 = 禁用记录。""" + try: + with get_conn() as conn: + row = conn.execute( + "SELECT value FROM kv WHERE key = 'request_log_limit'" + ).fetchone() + return int(row["value"]) if row else 500 + except Exception: + return 500 + + +def set_limit(limit: int) -> int: + """设置容量上限,并立即触发轮转。""" + limit = max(0, min(5000, int(limit))) + now = int(time.time()) + with get_conn() as conn: + conn.execute( + "INSERT INTO kv(key, value, updated_at) VALUES(?,?,?) " + "ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at", + ("request_log_limit", str(limit), now), + ) + if limit > 0: + _rotate(limit) + return limit + + +def _rotate(limit: int) -> int: + """删除超限的旧记录。返回删除条数。""" + with get_conn() as conn: + # 找到要保留的最小 id + row = conn.execute( + "SELECT id FROM request_log ORDER BY id DESC LIMIT 1 OFFSET ?", + (limit,), + ).fetchone() + if not row: + return 0 + threshold = int(row["id"]) + cur = conn.execute("DELETE FROM request_log WHERE id < ?", (threshold,)) + return cur.rowcount + + +# ===== 写入 ===== + +def insert(record: dict) -> Optional[int]: + """插入一条请求日志。返回 id(如果 limit=0 则不插入返回 None)。""" + limit = get_limit() + if limit <= 0: + return None + now = int(time.time()) + with get_conn() as conn: + cur = conn.execute( + """INSERT INTO request_log( + ts, method, path, query, status_code, elapsed_ms, + access_key, client_ip, user_agent, provider, model, stream, + ok, error_code, error_message, + request_headers, request_body, response_body, response_headers + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + now, + record.get("method", ""), + record.get("path", ""), + record.get("query"), + record.get("status_code"), + record.get("elapsed_ms"), + record.get("access_key"), + record.get("client_ip"), + record.get("user_agent"), + record.get("provider"), + record.get("model"), + 1 if record.get("stream") else 0, + 1 if record.get("ok") else 0, + record.get("error_code"), + record.get("error_message"), + _truncate(record.get("request_headers"), _MAX_HEADERS_LEN), + _truncate(record.get("request_body"), _MAX_BODY_LEN), + _truncate(record.get("response_body"), _MAX_BODY_LEN), + _truncate(record.get("response_headers"), _MAX_HEADERS_LEN), + ), + ) + rid = int(cur.lastrowid) + # 轮转(异步感觉没必要,每次插入后检查一次成本低) + # 用阈值触发,避免每次都查 count + if rid % 50 == 0: + _rotate(limit) + return rid + + +# ===== 查询 ===== + +def list_records( + *, + limit: int = 50, + offset: int = 0, + path: Optional[str] = None, + method: Optional[str] = None, + status: Optional[str] = None, + access_key: Optional[str] = None, + ok: Optional[bool] = None, + hours: Optional[int] = None, + keyword: Optional[str] = None, +) -> dict: + """列表查询:返回摘要(不含 request_body / response_body / headers)。""" + sql = "SELECT id, ts, method, path, query, status_code, elapsed_ms, access_key, client_ip, user_agent, provider, model, stream, ok, error_code, error_message FROM request_log WHERE 1=1" + args: list = [] + if path: + sql += " AND path LIKE ?" + args.append(f"%{path}%") + if method: + sql += " AND method = ?" + args.append(method.upper()) + if status: + # 支持 2xx/3xx/4xx/5xx 通配 + s = str(status).strip() + if s.endswith("xx"): + prefix = s[0] + sql += " AND CAST(status_code/100 AS INTEGER) = ?" + args.append(int(prefix)) + else: + sql += " AND status_code = ?" + args.append(int(s)) + if access_key: + sql += " AND access_key = ?" + args.append(access_key) + if ok is not None: + sql += " AND ok = ?" + args.append(1 if ok else 0) + if hours: + since = int(time.time()) - hours * 3600 + sql += " AND ts >= ?" + args.append(since) + if keyword: + sql += " AND (path LIKE ? OR error_message LIKE ? OR error_code LIKE ?)" + kw = f"%{keyword}%" + args += [kw, kw, kw] + # 总数 + count_sql = f"SELECT COUNT(*) AS c FROM ({sql})" + sql += " ORDER BY ts DESC, id DESC LIMIT ? OFFSET ?" + args += [limit, offset] + with get_conn() as conn: + total = int(conn.execute(count_sql, args[:-2]).fetchone()["c"]) + rows = conn.execute(sql, args).fetchall() + items = [_row_to_summary(r) for r in rows] + return {"items": items, "count": len(items), "total": total, "limit": limit, "offset": offset} + + +def get_record(rid: int) -> Optional[dict]: + """详情查询:返回完整记录(含 body / headers)。""" + with get_conn() as conn: + row = conn.execute( + "SELECT * FROM request_log WHERE id = ?", + (rid,), + ).fetchone() + if not row: + return None + return _row_to_detail(row) + + +def clear_all() -> int: + """清空全部请求日志。返回删除条数。""" + with get_conn() as conn: + cur = conn.execute("DELETE FROM request_log") + return cur.rowcount + + +def delete_record(rid: int) -> bool: + with get_conn() as conn: + cur = conn.execute("DELETE FROM request_log WHERE id = ?", (rid,)) + return cur.rowcount > 0 + + +# ===== 行转换 ===== + +def _row_to_summary(row) -> dict: + """摘要:不含大字段。""" + return { + "id": int(row["id"]), + "ts": int(row["ts"]), + "method": row["method"], + "path": row["path"], + "query": row["query"], + "status_code": row["status_code"], + "elapsed_ms": int(row["elapsed_ms"]) if row["elapsed_ms"] is not None else None, + "access_key": row["access_key"], + "client_ip": row["client_ip"], + "user_agent": row["user_agent"], + "provider": row["provider"], + "model": row["model"], + "stream": bool(row["stream"]), + "ok": bool(row["ok"]), + "error_code": row["error_code"], + "error_message": row["error_message"], + } + + +def _row_to_detail(row) -> dict: + """详情:含全部字段。""" + d = _row_to_summary(row) + # 解析 JSON 字段 + for k in ("request_headers", "request_body", "response_body", "response_headers"): + v = row[k] + if v is None: + d[k] = None + continue + # 尝试 JSON 解析 + try: + d[k] = json.loads(v) + except Exception: + d[k] = v + return d + + +# ===== 统计 ===== + +def stats(hours: int = 24) -> dict: + """请求日志统计。""" + since = int(time.time()) - hours * 3600 + with get_conn() as conn: + total = conn.execute( + "SELECT COUNT(*) AS c FROM request_log WHERE ts >= ?", (since,) + ).fetchone() + by_status = conn.execute( + "SELECT CAST(status_code/100 AS INTEGER) AS bucket, COUNT(*) AS c " + "FROM request_log WHERE ts >= ? GROUP BY bucket ORDER BY bucket", + (since,), + ).fetchall() + by_path = conn.execute( + "SELECT path, COUNT(*) AS c, AVG(elapsed_ms) AS avg_ms, " + "SUM(CASE WHEN ok=1 THEN 1 ELSE 0 END) AS ok_c " + "FROM request_log WHERE ts >= ? GROUP BY path ORDER BY c DESC LIMIT 20", + (since,), + ).fetchall() + by_error = conn.execute( + "SELECT error_code, COUNT(*) AS c FROM request_log " + "WHERE ts >= ? AND ok = 0 AND error_code IS NOT NULL " + "GROUP BY error_code ORDER BY c DESC LIMIT 20", + (since,), + ).fetchall() + slow = conn.execute( + "SELECT id, ts, method, path, elapsed_ms, status_code, ok " + "FROM request_log WHERE ts >= ? AND elapsed_ms IS NOT NULL " + "ORDER BY elapsed_ms DESC LIMIT 10", + (since,), + ).fetchall() + return { + "hours": hours, + "total": int(total["c"]) if total else 0, + "by_status": {str(r["bucket"]) + "xx": int(r["c"]) for r in by_status}, + "by_path": [ + { + "path": r["path"], + "count": int(r["c"]), + "avg_ms": round(float(r["avg_ms"]), 1) if r["avg_ms"] else 0, + "ok": int(r["ok_c"]), + } + for r in by_path + ], + "by_error": [{"code": r["error_code"], "count": int(r["c"])} for r in by_error], + "slowest": [ + { + "id": int(r["id"]), + "ts": int(r["ts"]), + "method": r["method"], + "path": r["path"], + "elapsed_ms": int(r["elapsed_ms"]), + "status_code": r["status_code"], + "ok": bool(r["ok"]), + } + for r in slow + ], + } diff --git a/app/services/session_store.py b/app/services/session_store.py new file mode 100644 index 0000000000000000000000000000000000000000..1d325c72a496683af783d26c0a9b1ee9c50caa16 --- /dev/null +++ b/app/services/session_store.py @@ -0,0 +1,195 @@ +"""会话持久化服务:sessions + session_messages 表(持久卷 /data/xtc.db)。 + +手表端可保存对话历史,重装不丢(持久卷保证)。 +""" +from __future__ import annotations + +import json +import time +import uuid +from typing import Optional + +from ..database import get_conn + + +def _now_ts() -> int: + return int(time.time()) + + +def create_session( + *, + access_key: str, + title: Optional[str] = None, + provider: Optional[str] = None, + model: Optional[str] = None, +) -> dict: + sid = uuid.uuid4().hex + now = _now_ts() + with get_conn() as conn: + conn.execute( + "INSERT INTO sessions(id, title, provider, model, access_key, created_at, updated_at) " + "VALUES(?,?,?,?,?,?,?)", + (sid, title or "新会话", provider, model, access_key, now, now), + ) + sess = {"id": sid, "title": title or "新会话", "provider": provider, "model": model, "created_at": now, "updated_at": now} + # Webhook 通知 + try: + from ..services import webhook_store + webhook_store.notify_fire_and_forget("session.created", {"session": sess, "access_key": access_key}) + except Exception: + pass + return sess + + +def list_sessions( + *, + access_key: Optional[str] = None, + limit: int = 50, + offset: int = 0, +) -> list[dict]: + sql = "SELECT id, title, provider, model, access_key, created_at, updated_at FROM sessions" + args: list = [] + if access_key: + sql += " WHERE access_key = ?" + args.append(access_key) + sql += " ORDER BY updated_at DESC LIMIT ? OFFSET ?" + args += [limit, offset] + with get_conn() as conn: + rows = conn.execute(sql, args).fetchall() + return [dict(r) for r in rows] + + +def get_session(session_id: str, *, access_key: Optional[str] = None) -> Optional[dict]: + with get_conn() as conn: + row = conn.execute( + "SELECT * FROM sessions WHERE id = ?" + (" AND access_key = ?" if access_key else ""), + (session_id,) if not access_key else (session_id, access_key), + ).fetchone() + return dict(row) if row else None + + +def append_message( + *, + session_id: str, + role: str, + content: str, + thought: Optional[str] = None, + provider: Optional[str] = None, + model: Optional[str] = None, + file_keys: Optional[list[str]] = None, +) -> dict: + now = _now_ts() + with get_conn() as conn: + # 取下一个 seq + row = conn.execute( + "SELECT COALESCE(MAX(seq), 0) AS max_seq FROM session_messages WHERE session_id = ?", + (session_id,), + ).fetchone() + next_seq = int(row["max_seq"]) + 1 + conn.execute( + "INSERT INTO session_messages(session_id, seq, role, content, thought, provider, model, ts, file_keys) " + "VALUES(?,?,?,?,?,?,?,?,?)", + ( + session_id, + next_seq, + role, + content, + thought, + provider, + model, + now, + json.dumps(file_keys) if file_keys else None, + ), + ) + # 更新 session 时间 + new_title = None + if role == "user" and next_seq == 1: + # 第一条 user 消息自动作为标题 + new_title = content[:40] + ("..." if len(content) > 40 else "") + conn.execute( + "UPDATE sessions SET updated_at = ?, title = ? WHERE id = ?", + (now, new_title, session_id), + ) + else: + conn.execute( + "UPDATE sessions SET updated_at = ? WHERE id = ?", + (now, session_id), + ) + return { + "session_id": session_id, + "seq": next_seq, + "role": role, + "content": content, + "thought": thought, + "provider": provider, + "model": model, + "ts": now, + "file_keys": file_keys, + } + + +def list_messages(session_id: str, *, access_key: Optional[str] = None) -> Optional[list[dict]]: + sess = get_session(session_id, access_key=access_key) + if not sess: + return None + with get_conn() as conn: + rows = conn.execute( + "SELECT seq, role, content, thought, provider, model, ts, file_keys FROM session_messages " + "WHERE session_id = ? ORDER BY seq ASC", + (session_id,), + ).fetchall() + out = [] + for r in rows: + item = dict(r) + if r["file_keys"]: + try: + item["file_keys"] = json.loads(r["file_keys"]) + except Exception: + item["file_keys"] = [] + else: + item["file_keys"] = [] + out.append(item) + return out + + +def delete_session(session_id: str, *, access_key: Optional[str] = None) -> bool: + sess = get_session(session_id, access_key=access_key) + if not sess: + return False + with get_conn() as conn: + conn.execute("DELETE FROM session_messages WHERE session_id = ?", (session_id,)) + conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,)) + # Webhook 通知 + try: + from ..services import webhook_store + webhook_store.notify_fire_and_forget("session.deleted", {"session_id": session_id, "access_key": access_key}) + except Exception: + pass + return True + + +def update_session( + session_id: str, + *, + title: Optional[str] = None, + access_key: Optional[str] = None, +) -> Optional[dict]: + sess = get_session(session_id, access_key=access_key) + if not sess: + return None + sets = [] + args: list = [] + if title is not None: + sets.append("title = ?") + args.append(title) + if not sets: + return sess + sets.append("updated_at = ?") + args.append(_now_ts()) + args.append(session_id) + with get_conn() as conn: + conn.execute( + f"UPDATE sessions SET {', '.join(sets)} WHERE id = ?", + tuple(args), + ) + return get_session(session_id) diff --git a/app/services/usage_store.py b/app/services/usage_store.py new file mode 100644 index 0000000000000000000000000000000000000000..5e000e208e6ee07248bb11ff1d5be65a0c3c6653 --- /dev/null +++ b/app/services/usage_store.py @@ -0,0 +1,132 @@ +"""用量统计 + 审计日志服务。""" +from __future__ import annotations + +import json +import time +from typing import Any, Optional + +from ..database import get_conn + + +def _now_ts() -> int: + return int(time.time()) + + +# ===== 用量统计 ===== + +def usage_summary(*, hours: int = 24, access_key: Optional[str] = None) -> dict: + """统计最近 N 小时的用量。""" + since = _now_ts() - hours * 3600 + sql = "SELECT COUNT(*) AS c, COALESCE(SUM(prompt_tokens),0) AS p, COALESCE(SUM(completion_tokens),0) AS q, COALESCE(SUM(total_tokens),0) AS t, COALESCE(SUM(CASE WHEN ok=1 THEN 1 ELSE 0 END),0) AS ok FROM usage_log WHERE ts >= ?" + args: list = [since] + if access_key: + sql += " AND access_key = ?" + args.append(access_key) + with get_conn() as conn: + row = conn.execute(sql, args).fetchone() + # 按 provider 分组 + sql2 = "SELECT provider, COUNT(*) AS c, COALESCE(SUM(total_tokens),0) AS t FROM usage_log WHERE ts >= ?" + args2: list = [since] + if access_key: + sql2 += " AND access_key = ?" + args2.append(access_key) + sql2 += " GROUP BY provider ORDER BY c DESC" + by_provider = conn.execute(sql2, args2).fetchall() + # 按 model 分组(top 10) + sql3 = "SELECT model, COUNT(*) AS c, COALESCE(SUM(total_tokens),0) AS t FROM usage_log WHERE ts >= ?" + args3: list = [since] + if access_key: + sql3 += " AND access_key = ?" + args3.append(access_key) + sql3 += " GROUP BY model ORDER BY c DESC LIMIT 10" + by_model = conn.execute(sql3, args3).fetchall() + # 错误码分布 + sql4 = "SELECT error_code, COUNT(*) AS c FROM usage_log WHERE ts >= ? AND ok = 0" + args4: list = [since] + if access_key: + sql4 += " AND access_key = ?" + args4.append(access_key) + sql4 += " GROUP BY error_code ORDER BY c DESC" + by_error = conn.execute(sql4, args4).fetchall() + return { + "hours": hours, + "total_requests": int(row["c"]) if row else 0, + "success": int(row["ok"]) if row else 0, + "failed": (int(row["c"]) - int(row["ok"])) if row else 0, + "prompt_tokens": int(row["p"]) if row else 0, + "completion_tokens": int(row["q"]) if row else 0, + "total_tokens": int(row["t"]) if row else 0, + "by_provider": [{"provider": r["provider"], "requests": int(r["c"]), "tokens": int(r["t"])} for r in by_provider], + "by_model": [{"model": r["model"], "requests": int(r["c"]), "tokens": int(r["t"])} for r in by_model], + "by_error": [{"code": r["error_code"], "count": int(r["c"])} for r in by_error], + } + + +def usage_timeline(*, hours: int = 24, granularity: str = "hour", access_key: Optional[str] = None) -> list[dict]: + """时间线(按小时/天分组)。""" + since = _now_ts() - hours * 3600 + if granularity == "day": + group_expr = "(ts / 86400)" + else: + group_expr = "(ts / 3600)" + sql = f"SELECT {group_expr} AS bucket, COUNT(*) AS c, COALESCE(SUM(total_tokens),0) AS t, COALESCE(SUM(CASE WHEN ok=1 THEN 1 ELSE 0 END),0) AS ok FROM usage_log WHERE ts >= ?" + args: list = [since] + if access_key: + sql += " AND access_key = ?" + args.append(access_key) + sql += f" GROUP BY bucket ORDER BY bucket ASC" + with get_conn() as conn: + rows = conn.execute(sql, args).fetchall() + return [ + { + "bucket": int(r["bucket"]), + "bucket_start": int(r["bucket"]) * (86400 if granularity == "day" else 3600), + "requests": int(r["c"]), + "success": int(r["ok"]), + "tokens": int(r["t"]), + } + for r in rows + ] + + +# ===== 审计日志 ===== + +def audit( + *, + action: str, + actor: Optional[str] = None, + target: Optional[str] = None, + detail: Any = None, +) -> None: + try: + detail_str = json.dumps(detail, ensure_ascii=False) if detail is not None else None + with get_conn() as conn: + conn.execute( + "INSERT INTO audit_log(ts, action, actor, target, detail) VALUES(?,?,?,?,?)", + (_now_ts(), action, actor, target, detail_str), + ) + except Exception as e: + print(f"[audit] record failed: {e}") + + +def list_audit(*, hours: int = 24, limit: int = 100, offset: int = 0, action: Optional[str] = None) -> list[dict]: + since = _now_ts() - hours * 3600 + sql = "SELECT id, ts, action, actor, target, detail FROM audit_log WHERE ts >= ?" + args: list = [since] + if action: + sql += " AND action = ?" + args.append(action) + sql += " ORDER BY ts DESC LIMIT ? OFFSET ?" + args += [limit, offset] + with get_conn() as conn: + rows = conn.execute(sql, args).fetchall() + out = [] + for r in rows: + item = dict(r) + if r["detail"]: + try: + item["detail"] = json.loads(r["detail"]) + except Exception: + pass + out.append(item) + return out diff --git a/app/services/webhook_store.py b/app/services/webhook_store.py new file mode 100644 index 0000000000000000000000000000000000000000..3ad1eddbe3d6454d67a8a054e0b02c3cfb393af6 --- /dev/null +++ b/app/services/webhook_store.py @@ -0,0 +1,173 @@ +"""Webhook 通知服务。 + +事件类型: +- chat.completed 聊天成功 +- chat.failed 聊天失败 +- file.uploaded 文件上传 +- config.updated 配置更新 +- session.created 会话创建 +- session.deleted 会话删除 + +配置存 SQLite webhook_config 表;触发时异步 httpx POST, +失败不影响主流程。事件用 events 字段(逗号分隔)过滤。 +""" +from __future__ import annotations + +import asyncio +import json +import time +from typing import Any, Optional + +from ..database import get_conn +from ..http_client import get_http_client + + +# ===== 配置 CRUD ===== + +def _now_ts() -> int: + return int(time.time()) + + +def list_webhooks() -> list[dict]: + with get_conn() as conn: + rows = conn.execute( + "SELECT id, name, url, events, enabled, created_at FROM webhook_config " + "ORDER BY id ASC" + ).fetchall() + out = [] + for r in rows: + out.append({ + "id": int(r["id"]), + "name": r["name"], + "url": r["url"], + "events": [e for e in (r["events"] or "").split(",") if e], + "enabled": bool(r["enabled"]), + "created_at": int(r["created_at"]), + }) + return out + + +def create_webhook(*, name: str, url: str, events: list[str], enabled: bool = True) -> dict: + events_str = ",".join([e for e in (events or []) if e]) + now = _now_ts() + with get_conn() as conn: + cur = conn.execute( + "INSERT INTO webhook_config(name, url, events, enabled, created_at) " + "VALUES(?,?,?,?,?)", + (name, url, events_str, 1 if enabled else 0, now), + ) + wid = int(cur.lastrowid) + return {"id": wid, "name": name, "url": url, "events": events or [], "enabled": enabled, "created_at": now} + + +def update_webhook( + webhook_id: int, + *, + name: Optional[str] = None, + url: Optional[str] = None, + events: Optional[list[str]] = None, + enabled: Optional[bool] = None, +) -> Optional[dict]: + sets: list[str] = [] + args: list = [] + if name is not None: + sets.append("name = ?") + args.append(name) + if url is not None: + sets.append("url = ?") + args.append(url) + if events is not None: + sets.append("events = ?") + args.append(",".join([e for e in events if e])) + if enabled is not None: + sets.append("enabled = ?") + args.append(1 if enabled else 0) + if not sets: + return get_webhook(webhook_id) + args.append(webhook_id) + with get_conn() as conn: + cur = conn.execute( + f"UPDATE webhook_config SET {', '.join(sets)} WHERE id = ?", + tuple(args), + ) + if cur.rowcount == 0: + return None + return get_webhook(webhook_id) + + +def get_webhook(webhook_id: int) -> Optional[dict]: + with get_conn() as conn: + row = conn.execute( + "SELECT id, name, url, events, enabled, created_at FROM webhook_config WHERE id = ?", + (webhook_id,), + ).fetchone() + if not row: + return None + return { + "id": int(row["id"]), + "name": row["name"], + "url": row["url"], + "events": [e for e in (row["events"] or "").split(",") if e], + "enabled": bool(row["enabled"]), + "created_at": int(row["created_at"]), + } + + +def delete_webhook(webhook_id: int) -> bool: + with get_conn() as conn: + cur = conn.execute("DELETE FROM webhook_config WHERE id = ?", (webhook_id,)) + return cur.rowcount > 0 + + +# ===== 事件触发 ===== + +def _matching_webhooks(event: str) -> list[dict]: + """返回订阅了该事件且启用的 webhook 列表。""" + with get_conn() as conn: + rows = conn.execute( + "SELECT id, name, url, events FROM webhook_config WHERE enabled = 1" + ).fetchall() + out = [] + for r in rows: + events = [e for e in (r["events"] or "").split(",") if e] + if event in events or "*" in events: + out.append({"id": int(r["id"]), "name": r["name"], "url": r["url"]}) + return out + + +async def notify(event: str, payload: Any) -> None: + """异步通知所有订阅了 event 的 webhook。失败静默。""" + targets = _matching_webhooks(event) + if not targets: + return + body = json.dumps( + {"event": event, "ts": _now_ts(), "data": payload}, + ensure_ascii=False, + ).encode("utf-8") + client = get_http_client() + for t in targets: + try: + resp = await client.post( + t["url"], + content=body, + headers={ + "Content-Type": "application/json", + "X-XTC-Event": event, + "X-XTC-Webhook-Id": str(t["id"]), + "User-Agent": "xtc-backend-hf-webhook/1.0", + }, + timeout=10.0, + ) + if resp.status_code >= 400: + print(f"[webhook] {t['name']} ({t['url']}) returned {resp.status_code}") + except Exception as e: + print(f"[webhook] {t['name']} ({t['url']}) failed: {e}") + + +def notify_fire_and_forget(event: str, payload: Any) -> None: + """同步上下文用:把通知丢到事件循环后台跑,不阻塞主流程。""" + try: + loop = asyncio.get_event_loop() + except RuntimeError: + return + loop.create_task(notify(event, payload)) diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/utils/sse.py b/app/utils/sse.py new file mode 100644 index 0000000000000000000000000000000000000000..6854ed3d7c1af407d374d1b0c9eadc574902095f --- /dev/null +++ b/app/utils/sse.py @@ -0,0 +1,45 @@ +"""SSE 流式工具。""" +from __future__ import annotations + +import json +from typing import Any, AsyncIterator, Iterable, Optional + + +def sse_data(obj: Any) -> str: + return f"data: {json.dumps(obj, ensure_ascii=False)}\n\n" + + +def sse_done() -> str: + return "data: [DONE]\n\n" + + +def sse_event(event: str, obj: Any) -> str: + return f"event: {event}\ndata: {json.dumps(obj, ensure_ascii=False)}\n\n" + + +async def parse_sse_stream(byte_stream: AsyncIterator[bytes]) -> AsyncIterator[dict]: + """从字节流解析 SSE,yield 每个 data JSON。""" + buffer = "" + async for chunk in byte_stream: + if isinstance(chunk, (bytes, bytearray)): + text = chunk.decode("utf-8", errors="replace") + else: + text = str(chunk) + buffer += text + # SSE 以双换行分隔 + while "\n\n" in buffer: + block, buffer = buffer.split("\n\n", 1) + data_lines = [] + for line in block.split("\n"): + if line.startswith("data:"): + data_lines.append(line[5:].strip()) + if not data_lines: + continue + data_str = "\n".join(data_lines) + if data_str == "[DONE]": + yield {"__done__": True} + return + try: + yield json.loads(data_str) + except json.JSONDecodeError: + yield {"__raw__": data_str} diff --git a/app/utils/thought.py b/app/utils/thought.py new file mode 100644 index 0000000000000000000000000000000000000000..8d9f84825129ed3a7408b847b3ee2cfe8e119522 --- /dev/null +++ b/app/utils/thought.py @@ -0,0 +1,43 @@ +"""思考链抽取:从 ... 标签或 reasoning_content 字段提取。""" +from __future__ import annotations + +import re +from typing import Optional + +_THOUGHT_TAG_RE = re.compile( + r"<(?:thought|think)>\s*([\s\S]*?)\s*", + re.IGNORECASE, +) +_THOUGHT_TAG_FULL_RE = re.compile( + r"<(?:thought|think)>[\s\S]*?\s*", + re.IGNORECASE, +) + + +def extract_thought_and_answer(raw: str) -> tuple[str, str]: + """从原始输出中抽出 thought 与 answer。 + + Returns: + (thought, answer) + """ + if not raw: + return "", "" + thoughts = [] + for m in _THOUGHT_TAG_RE.finditer(raw): + if m.group(1): + thoughts.append(m.group(1).strip()) + answer = _THOUGHT_TAG_FULL_RE.sub("", raw).strip() + return "\n\n".join(thoughts), answer + + +def extract_thought_from_reasoning_content(reasoning_content: Optional[str]) -> str: + if not reasoning_content: + return "" + return reasoning_content.strip() + + +def wrap_thought(thought: str) -> str: + """把 thought 包裹成 ... 嵌入 content。""" + if not thought: + return "" + return f"\n{thought}\n\n\n" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..6aa24c44c645d701b534d006247b65f8a5932451 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,16 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +httpx==0.28.1 +pydantic==2.10.4 +pydantic-settings==2.7.1 +python-multipart==0.0.20 +pillow==11.0.0 +numpy==2.2.1 +sse-starlette==2.2.1 +pyjwt==2.10.1 +bcrypt==4.2.1 +huggingface-hub==0.27.0 +cachetools==5.5.0 +python-dotenv==1.0.1 +# 可选:启用 HTTP/2(默认关闭,HTTP/1.1 已足够) +# h2==4.1.0