cjovs commited on
Commit ·
66a73ac
1
Parent(s): 230966f
Deploy chatgpt2api with bucket mounted at /app/data
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +48 -0
- .gitignore +30 -0
- Dockerfile +26 -0
- LICENSE +21 -0
- README.md +351 -6
- VERSION +1 -0
- api/__init__.py +2 -0
- api/accounts.py +426 -0
- api/ai.py +141 -0
- api/app.py +63 -0
- api/errors.py +46 -0
- api/image_inputs.py +294 -0
- api/image_tasks.py +96 -0
- api/register.py +68 -0
- api/support.py +132 -0
- api/system.py +334 -0
- config.json +50 -0
- main.py +9 -0
- pyproject.toml +27 -0
- scripts/migrate_storage.py +163 -0
- scripts/test_storage.py +129 -0
- services/__init__.py +0 -0
- services/account_service.py +946 -0
- services/auth_service.py +253 -0
- services/backup_service.py +673 -0
- services/config.py +390 -0
- services/content_filter.py +196 -0
- services/cpa_service.py +315 -0
- services/image_service.py +356 -0
- services/image_storage_service.py +405 -0
- services/image_tags_service.py +76 -0
- services/image_task_service.py +388 -0
- services/log_service.py +255 -0
- services/openai_backend_api.py +986 -0
- services/protocol/__init__.py +2 -0
- services/protocol/anthropic_v1_messages.py +306 -0
- services/protocol/conversation.py +699 -0
- services/protocol/error_response.py +124 -0
- services/protocol/openai_v1_chat_complete.py +184 -0
- services/protocol/openai_v1_image_edit.py +40 -0
- services/protocol/openai_v1_image_generations.py +33 -0
- services/protocol/openai_v1_models.py +26 -0
- services/protocol/openai_v1_response.py +225 -0
- services/proxy_service.py +63 -0
- services/register/__init__.py +0 -0
- services/register/mail_provider.py +991 -0
- services/register/openai_register.py +766 -0
- services/register_service.py +197 -0
- services/storage/__init__.py +5 -0
- services/storage/base.py +38 -0
.env.example
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================
|
| 2 |
+
# ChatGPT2API 环境变量配置示例
|
| 3 |
+
# ============================================
|
| 4 |
+
|
| 5 |
+
# 认证密钥(必填)
|
| 6 |
+
CHATGPT2API_AUTH_KEY=your_secret_key_here
|
| 7 |
+
|
| 8 |
+
# 基础 URL(可选,用于生成图片 URL)
|
| 9 |
+
# CHATGPT2API_BASE_URL=https://your-domain.com
|
| 10 |
+
|
| 11 |
+
# ============================================
|
| 12 |
+
# 存储后端配置
|
| 13 |
+
# ============================================
|
| 14 |
+
|
| 15 |
+
# 存储后端类型(可选值: json, sqlite, postgres, git)
|
| 16 |
+
# 默认: json
|
| 17 |
+
STORAGE_BACKEND=json
|
| 18 |
+
|
| 19 |
+
# ============================================
|
| 20 |
+
# 数据库配置(当 STORAGE_BACKEND=sqlite/postgres 时使用)
|
| 21 |
+
# ============================================
|
| 22 |
+
|
| 23 |
+
# PostgreSQL 示例
|
| 24 |
+
# DATABASE_URL=postgresql://user:password@localhost:5432/chatgpt2api
|
| 25 |
+
|
| 26 |
+
# Supabase 示例
|
| 27 |
+
# DATABASE_URL=postgresql://postgres.xxx:password@aws-1-us-west-1.pooler.supabase.com:5432/postgres
|
| 28 |
+
|
| 29 |
+
# SQLite 示例(不指定时自动使用 data/accounts.db)
|
| 30 |
+
# DATABASE_URL=sqlite:///app/data/accounts.db
|
| 31 |
+
|
| 32 |
+
# ============================================
|
| 33 |
+
# Git 仓库配置(当 STORAGE_BACKEND=git 时使用)
|
| 34 |
+
# ============================================
|
| 35 |
+
|
| 36 |
+
# Git 仓库地址(必填)
|
| 37 |
+
# GIT_REPO_URL=https://github.com/your-username/your-private-repo.git
|
| 38 |
+
|
| 39 |
+
# Git 访问令牌(必填)
|
| 40 |
+
# GitHub: ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
| 41 |
+
# GitLab: glpat-xxxxxxxxxxxxxxxxxxxx
|
| 42 |
+
# GIT_TOKEN=your_git_token_here
|
| 43 |
+
|
| 44 |
+
# Git 分支(可选,默认: main)
|
| 45 |
+
# GIT_BRANCH=main
|
| 46 |
+
|
| 47 |
+
# Git 仓库中的文件路径(可选,默认: accounts.json)
|
| 48 |
+
# GIT_FILE_PATH=accounts.json
|
.gitignore
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python-generated files
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[oc]
|
| 4 |
+
build/
|
| 5 |
+
dist/
|
| 6 |
+
wheels/
|
| 7 |
+
*.egg-info
|
| 8 |
+
web_dist
|
| 9 |
+
# Virtual environments
|
| 10 |
+
.venv
|
| 11 |
+
.idea
|
| 12 |
+
data
|
| 13 |
+
config.json
|
| 14 |
+
edit
|
| 15 |
+
|
| 16 |
+
docker-compose-local.yml
|
| 17 |
+
|
| 18 |
+
.claude/settings.local.json
|
| 19 |
+
|
| 20 |
+
# Environment variables
|
| 21 |
+
.env
|
| 22 |
+
.env.local
|
| 23 |
+
|
| 24 |
+
# Database files
|
| 25 |
+
*.db
|
| 26 |
+
*.sqlite
|
| 27 |
+
*.sqlite3
|
| 28 |
+
|
| 29 |
+
# Git cache
|
| 30 |
+
git_cache/
|
Dockerfile
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM node:22-alpine AS web-build
|
| 2 |
+
WORKDIR /app/web
|
| 3 |
+
COPY web/package.json web/bun.lock ./
|
| 4 |
+
RUN npm install
|
| 5 |
+
COPY VERSION /app/VERSION
|
| 6 |
+
COPY web ./
|
| 7 |
+
RUN NEXT_PUBLIC_APP_VERSION="$(cat /app/VERSION)" npm run build
|
| 8 |
+
|
| 9 |
+
FROM python:3.13-slim AS app
|
| 10 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 11 |
+
PYTHONUNBUFFERED=1 \
|
| 12 |
+
UV_LINK_MODE=copy
|
| 13 |
+
WORKDIR /app
|
| 14 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 15 |
+
git libpq-dev gcc openssl \
|
| 16 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 17 |
+
RUN pip install --no-cache-dir uv
|
| 18 |
+
COPY pyproject.toml uv.lock main.py config.json VERSION ./
|
| 19 |
+
RUN uv sync --frozen --no-dev --no-install-project
|
| 20 |
+
COPY api ./api
|
| 21 |
+
COPY services ./services
|
| 22 |
+
COPY utils ./utils
|
| 23 |
+
COPY scripts ./scripts
|
| 24 |
+
COPY --from=web-build /app/web/out ./web_dist
|
| 25 |
+
EXPOSE 7860
|
| 26 |
+
CMD ["uv", "run", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860", "--access-log"]
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 kunkun
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
CHANGED
|
@@ -1,10 +1,355 @@
|
|
| 1 |
-
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: ChatGPT2API
|
| 3 |
+
emoji: 🎨
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
+
<h1 align="center">ChatGPT2API</h1>
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
<p align="center">ChatGPT2API 主要是对 ChatGPT 官网相关能力进行逆向整理与封装,提供面向 ChatGPT 图片生成、图片编辑、多图组图编辑场景的 OpenAI 兼容图片 API / 代理,并集成在线画图、号池管理、多种账号导入方式与 Docker 自托管部署能力。</p>
|
| 14 |
+
|
| 15 |
+
> [!WARNING]
|
| 16 |
+
> 免责声明:
|
| 17 |
+
>
|
| 18 |
+
> 本项目涉及对 ChatGPT 官网文本生成、图片生成与图片编辑等相关接口的逆向研究,仅供个人学习、技术研究与非商业性技术交流使用。
|
| 19 |
+
>
|
| 20 |
+
> - 严禁将本项目用于任何商业用途、盈利性使用、批量操作、自动化滥用或规模化调用。
|
| 21 |
+
> - 严禁将本项目用于破坏市场秩序、恶意竞争、套利倒卖、二次售卖相关服务,以及任何违反 OpenAI 服务条款或当地法律法规的行为。
|
| 22 |
+
> - 严禁将本项目用于生成、传播或协助生成违法、暴力、色情、未成年人相关内容,或用于诈骗、欺诈、骚扰等非法或不当用途。
|
| 23 |
+
> - 使用者应自行承担全部风险,包括但不限于账号被限制、临时封禁或永久封禁以及因违规使用等所导致的法律责任。
|
| 24 |
+
> - 使用本项目即视为你已充分理解并同意本免责声明全部内容;如因滥用、违规或违法使用造成任何后果,均由使用者自行承担。
|
| 25 |
+
|
| 26 |
+
> [!IMPORTANT]
|
| 27 |
+
> 本项目基于对 ChatGPT 官网相关能力的逆向研究实现,存在账号受限、临时封禁或永久封禁的风险。请勿使用你自己的重要账号、常用账号或高价值账号进行测试。
|
| 28 |
+
|
| 29 |
+
> [!CAUTION]
|
| 30 |
+
> 旧版本存在已知漏洞,请尽快升级到最新版本。公网部署时请尽量不要放置敏感信息,并自行做好访问控制与隔离。
|
| 31 |
+
|
| 32 |
+
## 快速开始
|
| 33 |
+
|
| 34 |
+
已发布镜像支持 `linux/amd64` 与 `linux/arm64`,在 x86 服务器和 Apple Silicon / ARM Linux 设备上都会自动拉取匹配架构的版本。
|
| 35 |
+
|
| 36 |
+
### Docker 运行
|
| 37 |
+
|
| 38 |
+
```bash
|
| 39 |
+
git clone git@github.com:basketikun/chatgpt2api.git
|
| 40 |
+
cd chatgpt2api
|
| 41 |
+
docker compose up -d
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
启动前请先在 `config.json` 中设置 `auth-key`,也可以在 `docker-compose.yml` 中通过 `CHATGPT2API_AUTH_KEY` 覆盖。
|
| 45 |
+
|
| 46 |
+
- Web 面板:`http://localhost:3000`
|
| 47 |
+
- API 地址:`http://localhost:3000/v1`
|
| 48 |
+
- 数据目录:`./data`
|
| 49 |
+
|
| 50 |
+
### 本地开发
|
| 51 |
+
|
| 52 |
+
启动后端:
|
| 53 |
+
|
| 54 |
+
```bash
|
| 55 |
+
git clone git@github.com:basketikun/chatgpt2api.git
|
| 56 |
+
cd chatgpt2api
|
| 57 |
+
uv sync
|
| 58 |
+
uv run main.py
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
启动前端:
|
| 62 |
+
|
| 63 |
+
```bash
|
| 64 |
+
cd chatgpt2api/web
|
| 65 |
+
bun install
|
| 66 |
+
bun run dev
|
| 67 |
+
```
|
| 68 |
+
|
| 69 |
+
后续更新新版本:
|
| 70 |
+
|
| 71 |
+
```bash
|
| 72 |
+
docker pull ghcr.io/basketikun/chatgpt2api:latest
|
| 73 |
+
docker-compose down
|
| 74 |
+
docker-compose up -d
|
| 75 |
+
|
| 76 |
+
```
|
| 77 |
+
|
| 78 |
+
### 存储后端配置
|
| 79 |
+
|
| 80 |
+
支持通过环境变量 `STORAGE_BACKEND` 切换存储方式:
|
| 81 |
+
|
| 82 |
+
- `json` - 本地 JSON 文件(默认)
|
| 83 |
+
- `sqlite` - 本地 SQLite 数据库
|
| 84 |
+
- `postgres` - 外部 PostgreSQL(需配置 `DATABASE_URL`)
|
| 85 |
+
- `git` - Git 私有仓库(需配置 `GIT_REPO_URL` 和 `GIT_TOKEN`)
|
| 86 |
+
|
| 87 |
+
示例:使用 PostgreSQL
|
| 88 |
+
|
| 89 |
+
```yaml
|
| 90 |
+
environment:
|
| 91 |
+
- STORAGE_BACKEND=postgres
|
| 92 |
+
- DATABASE_URL=postgresql://user:password@host:5432/dbname
|
| 93 |
+
```
|
| 94 |
+
|
| 95 |
+
## 功能
|
| 96 |
+
|
| 97 |
+
### API 兼容能力
|
| 98 |
+
|
| 99 |
+
- 兼容 `POST /v1/images/generations` 图片生成接口
|
| 100 |
+
- 兼容 `POST /v1/images/edits` 图片编辑接口
|
| 101 |
+
- 兼容面向图片场景的 `POST /v1/chat/completions`
|
| 102 |
+
- 兼容面向图片场景的 `POST /v1/responses`
|
| 103 |
+
- `GET /v1/models` 返回 `gpt-image-2`、`codex-gpt-image-2`、`auto`、`gpt-5`、`gpt-5-1`、`gpt-5-2`、`gpt-5-3`、`gpt-5-3-mini`、
|
| 104 |
+
`gpt-5-mini`
|
| 105 |
+
- 支持通过 `n` 返回多张生成结果
|
| 106 |
+
- 支持 Codex 中的画图接口逆向,仅 `Plus` / `Team` / `Pro` 订阅可用,模型别名为 `codex-gpt-image-2`,如有需要可自行在其他场景映射回
|
| 107 |
+
`gpt-image-2`,用于和官网画图区分;也就意味着同一账号会同时有官网和 Codex 两份生图额度
|
| 108 |
+
|
| 109 |
+
### 在线画图功能
|
| 110 |
+
|
| 111 |
+
- 内置在线画图工作台,支持生成、图片编辑与多图组图编辑
|
| 112 |
+
- 支持 `gpt-image-2`、`codex-gpt-image-2`、`auto`、`gpt-5`、`gpt-5-1`、`gpt-5-2`、`gpt-5-3`、`gpt-5-3-mini`、`gpt-5-mini` 模型选择
|
| 113 |
+
- 编辑模式支持参考图上传
|
| 114 |
+
- 前端支持多图生成交互
|
| 115 |
+
- 本地保存图片会话历史,支持回看、删除和清空
|
| 116 |
+
- 支持服务端缓存图片URL
|
| 117 |
+
|
| 118 |
+
### 号池管理功能
|
| 119 |
+
|
| 120 |
+
- 自动刷新账号邮箱、类型、额度和恢复时间
|
| 121 |
+
- 轮询可用账号执行图片生成与图片编辑
|
| 122 |
+
- 遇到 Token 失效类错误时自动剔除无效 Token
|
| 123 |
+
- 定时检查限流账号并自动刷新
|
| 124 |
+
- 支持网页端配置全局 HTTP / HTTPS / SOCKS5 / SOCKS5H 代理
|
| 125 |
+
- 支持搜索、筛选、批量刷新、导出、手动编辑和清理账号
|
| 126 |
+
- 支持四种导入方式:本地 CPA JSON 文件导入、���程 CPA 服务器导入、`sub2api` 服务器导入、`access_token` 导入
|
| 127 |
+
- 支持在设置页配置 `sub2api` 服务器,筛选并批量导入其中的 OpenAI OAuth 账号
|
| 128 |
+
|
| 129 |
+
### 实验性 / 规划中
|
| 130 |
+
|
| 131 |
+
- `/v1/complete` 文本补全与流式输出已实现,但仍在测试,目前会出现对话重复的问题,请谨慎测试使用
|
| 132 |
+
- 详细状态说明见:[功能清单](./docs/feature-status.en.md)
|
| 133 |
+
|
| 134 |
+
## Screenshots
|
| 135 |
+
|
| 136 |
+
文生图界面:
|
| 137 |
+
|
| 138 |
+

|
| 139 |
+
|
| 140 |
+
编辑图:
|
| 141 |
+
|
| 142 |
+

|
| 143 |
+
|
| 144 |
+
Cherry Studio 中使用,支持作为绘图接口接入:
|
| 145 |
+
|
| 146 |
+

|
| 147 |
+
|
| 148 |
+
号池管理:
|
| 149 |
+
|
| 150 |
+

|
| 151 |
+
|
| 152 |
+
New Api 接入:
|
| 153 |
+
|
| 154 |
+

|
| 155 |
+
|
| 156 |
+
## API
|
| 157 |
+
|
| 158 |
+
所有 AI 接口都需要请求头:
|
| 159 |
+
|
| 160 |
+
```http
|
| 161 |
+
Authorization: Bearer <auth-key>
|
| 162 |
+
```
|
| 163 |
+
|
| 164 |
+
<details>
|
| 165 |
+
<summary><code>GET /v1/models</code></summary>
|
| 166 |
+
<br>
|
| 167 |
+
|
| 168 |
+
返回当前暴露的图片模型列表。
|
| 169 |
+
|
| 170 |
+
```bash
|
| 171 |
+
curl http://localhost:8000/v1/models \
|
| 172 |
+
-H "Authorization: Bearer <auth-key>"
|
| 173 |
+
```
|
| 174 |
+
|
| 175 |
+
<details>
|
| 176 |
+
<summary>说明</summary>
|
| 177 |
+
<br>
|
| 178 |
+
|
| 179 |
+
| 字段 | 说明 |
|
| 180 |
+
|:-----|:-----------------------------------------------------------------------------------------------------------|
|
| 181 |
+
| 返回模型 | `gpt-image-2`、`codex-gpt-image-2`、`auto`、`gpt-5`、`gpt-5-1`、`gpt-5-2`、`gpt-5-3`、`gpt-5-3-mini`、`gpt-5-mini` |
|
| 182 |
+
| 接入场景 | 可接入 Cherry Studio、New API 等上游或客户端 |
|
| 183 |
+
|
| 184 |
+
<br>
|
| 185 |
+
</details>
|
| 186 |
+
</details>
|
| 187 |
+
|
| 188 |
+
<details>
|
| 189 |
+
<summary><code>POST /v1/images/generations</code></summary>
|
| 190 |
+
<br>
|
| 191 |
+
|
| 192 |
+
OpenAI 兼容图片生成接口,用于文生图。
|
| 193 |
+
|
| 194 |
+
```bash
|
| 195 |
+
curl http://localhost:8000/v1/images/generations \
|
| 196 |
+
-H "Content-Type: application/json" \
|
| 197 |
+
-H "Authorization: Bearer <auth-key>" \
|
| 198 |
+
-d '{
|
| 199 |
+
"model": "gpt-image-2",
|
| 200 |
+
"prompt": "一只漂浮在太空里的猫",
|
| 201 |
+
"n": 1,
|
| 202 |
+
"response_format": "b64_json"
|
| 203 |
+
}'
|
| 204 |
+
```
|
| 205 |
+
|
| 206 |
+
<details>
|
| 207 |
+
<summary>字段说明</summary>
|
| 208 |
+
<br>
|
| 209 |
+
|
| 210 |
+
| 字段 | 说明 |
|
| 211 |
+
|:------------------|:---------------------------------------------------|
|
| 212 |
+
| `model` | 图片模型,当前可用值以 `/v1/models` 返回结果为准,推荐使用 `gpt-image-2` |
|
| 213 |
+
| `prompt` | 图片生成提示词 |
|
| 214 |
+
| `n` | 生成数量,当前后端限制为 `1-4` |
|
| 215 |
+
| `response_format` | 当前请求模型中包含该字段,默认值为 `b64_json` |
|
| 216 |
+
|
| 217 |
+
<br>
|
| 218 |
+
</details>
|
| 219 |
+
</details>
|
| 220 |
+
|
| 221 |
+
<details>
|
| 222 |
+
<summary><code>POST /v1/images/edits</code></summary>
|
| 223 |
+
<br>
|
| 224 |
+
|
| 225 |
+
OpenAI 兼容图片编辑接口,可上传图片文件,也可按官方 JSON 格式传入图片链接并生成编辑结果。
|
| 226 |
+
|
| 227 |
+
```bash
|
| 228 |
+
curl http://localhost:8000/v1/images/edits \
|
| 229 |
+
-H "Authorization: Bearer <auth-key>" \
|
| 230 |
+
-F "model=gpt-image-2" \
|
| 231 |
+
-F "prompt=把这张图改成赛博朋克夜景风格" \
|
| 232 |
+
-F "n=1" \
|
| 233 |
+
-F "image=@./input.png"
|
| 234 |
+
```
|
| 235 |
+
|
| 236 |
+
也可以直接传图片 URL:
|
| 237 |
+
|
| 238 |
+
```bash
|
| 239 |
+
curl http://localhost:8000/v1/images/edits \
|
| 240 |
+
-H "Authorization: Bearer <auth-key>" \
|
| 241 |
+
-H "Content-Type: application/json" \
|
| 242 |
+
-d '{
|
| 243 |
+
"model": "gpt-image-2",
|
| 244 |
+
"prompt": "把这张图改成赛博朋克夜景风格",
|
| 245 |
+
"images": [
|
| 246 |
+
{"image_url": "https://example.com/input.png"}
|
| 247 |
+
]
|
| 248 |
+
}'
|
| 249 |
+
```
|
| 250 |
+
|
| 251 |
+
<details>
|
| 252 |
+
<summary>字段说明</summary>
|
| 253 |
+
<br>
|
| 254 |
+
|
| 255 |
+
| 字段 | 说明 |
|
| 256 |
+
|:------------|:----------------------------------------------|
|
| 257 |
+
| `model` | 图片模型, `gpt-image-2` |
|
| 258 |
+
| `prompt` | 图片编辑提示词 |
|
| 259 |
+
| `n` | 生成数量,当前后端限制为 `1-4` |
|
| 260 |
+
| `image` | 需要编辑的图片文件,使用 multipart/form-data 上传 |
|
| 261 |
+
| `images` | JSON 图片引用数组,支持 `{"image_url": "https://..."}` |
|
| 262 |
+
| `image_url` | 表单模式下也可直接传图片链接,支持重复字段传多张图 |
|
| 263 |
+
|
| 264 |
+
<br>
|
| 265 |
+
</details>
|
| 266 |
+
</details>
|
| 267 |
+
|
| 268 |
+
<details>
|
| 269 |
+
<summary><code>POST /v1/chat/completions</code></summary>
|
| 270 |
+
<br>
|
| 271 |
+
|
| 272 |
+
面向图片场景的 Chat Completions 兼容接口,不是完整通用聊天代理。
|
| 273 |
+
|
| 274 |
+
```bash
|
| 275 |
+
curl http://localhost:8000/v1/chat/completions \
|
| 276 |
+
-H "Content-Type: application/json" \
|
| 277 |
+
-H "Authorization: Bearer <auth-key>" \
|
| 278 |
+
-d '{
|
| 279 |
+
"model": "gpt-image-2",
|
| 280 |
+
"messages": [
|
| 281 |
+
{
|
| 282 |
+
"role": "user",
|
| 283 |
+
"content": "生成一张雨夜东京街头的赛博朋克猫"
|
| 284 |
+
}
|
| 285 |
+
],
|
| 286 |
+
"n": 1
|
| 287 |
+
}'
|
| 288 |
+
```
|
| 289 |
+
|
| 290 |
+
<details>
|
| 291 |
+
<summary>字段说明</summary>
|
| 292 |
+
<br>
|
| 293 |
+
|
| 294 |
+
| 字段 | 说明 |
|
| 295 |
+
|:-----------|:------------------|
|
| 296 |
+
| `model` | 图片模型,默认按图片生成场景处理 |
|
| 297 |
+
| `messages` | 消息数组,需要是图片相关请求内容 |
|
| 298 |
+
| `n` | 生成数量,按当前实现解析为图片数量 |
|
| 299 |
+
| `stream` | 已实现,但仍在测试 |
|
| 300 |
+
|
| 301 |
+
<br>
|
| 302 |
+
</details>
|
| 303 |
+
</details>
|
| 304 |
+
|
| 305 |
+
<details>
|
| 306 |
+
<summary><code>POST /v1/responses</code></summary>
|
| 307 |
+
<br>
|
| 308 |
+
|
| 309 |
+
面向图片生成工具调用的 Responses API 兼容接口,不是完整通用 Responses API 代理。
|
| 310 |
+
|
| 311 |
+
```bash
|
| 312 |
+
curl http://localhost:8000/v1/responses \
|
| 313 |
+
-H "Content-Type: application/json" \
|
| 314 |
+
-H "Authorization: Bearer <auth-key>" \
|
| 315 |
+
-d '{
|
| 316 |
+
"model": "gpt-5",
|
| 317 |
+
"input": "生成一张未来感城市天际线图片",
|
| 318 |
+
"tools": [
|
| 319 |
+
{
|
| 320 |
+
"type": "image_generation"
|
| 321 |
+
}
|
| 322 |
+
]
|
| 323 |
+
}'
|
| 324 |
+
```
|
| 325 |
+
|
| 326 |
+
<details>
|
| 327 |
+
<summary>字段说明</summary>
|
| 328 |
+
<br>
|
| 329 |
+
|
| 330 |
+
| 字段 | 说明 |
|
| 331 |
+
|:---------|:------------------------------|
|
| 332 |
+
| `model` | 响应中会回显该模型字段,但图片生成当前仍走图片生成兼容逻辑 |
|
| 333 |
+
| `input` | 输入内容,需要能解析出图片生成提示词 |
|
| 334 |
+
| `tools` | 必须包含 `image_generation` 工具请求 |
|
| 335 |
+
| `stream` | 已实现,但仍在测试 |
|
| 336 |
+
|
| 337 |
+
<br>
|
| 338 |
+
</details>
|
| 339 |
+
</details>
|
| 340 |
+
|
| 341 |
+
## 社区支持
|
| 342 |
+
|
| 343 |
+
学 AI , 上 L 站:[LinuxDO](https://linux.do)
|
| 344 |
+
|
| 345 |
+
## Contributors
|
| 346 |
+
|
| 347 |
+
感谢所有为本项目做出贡献的开发者:
|
| 348 |
+
|
| 349 |
+
<a href="https://github.com/basketikun/chatgpt2api/graphs/contributors">
|
| 350 |
+
<img alt="Contributors" src="https://contrib.rocks/image?repo=basketikun/chatgpt2api" />
|
| 351 |
+
</a>
|
| 352 |
+
|
| 353 |
+
## Star History
|
| 354 |
+
|
| 355 |
+
[](https://www.star-history.com/?repos=basketikun%2Fchatgpt2api&type=date&legend=top-left)
|
VERSION
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
1.1.7
|
api/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from api.app import create_app
|
| 2 |
+
|
api/accounts.py
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import io
|
| 4 |
+
import json
|
| 5 |
+
import re
|
| 6 |
+
import zipfile
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
from typing import Any, Literal
|
| 9 |
+
|
| 10 |
+
from fastapi import APIRouter, Header, HTTPException
|
| 11 |
+
from fastapi.concurrency import run_in_threadpool
|
| 12 |
+
from fastapi.responses import Response
|
| 13 |
+
from pydantic import BaseModel, Field
|
| 14 |
+
|
| 15 |
+
from services.auth_service import auth_service
|
| 16 |
+
|
| 17 |
+
from api.support import (
|
| 18 |
+
require_admin,
|
| 19 |
+
sanitize_cpa_pool,
|
| 20 |
+
sanitize_cpa_pools,
|
| 21 |
+
sanitize_sub2api_server,
|
| 22 |
+
sanitize_sub2api_servers,
|
| 23 |
+
)
|
| 24 |
+
from services.account_service import account_service
|
| 25 |
+
from services.cpa_service import cpa_config, cpa_import_service, list_remote_files
|
| 26 |
+
from services.sub2api_service import (
|
| 27 |
+
list_remote_accounts as sub2api_list_remote_accounts,
|
| 28 |
+
list_remote_groups as sub2api_list_remote_groups,
|
| 29 |
+
sub2api_config,
|
| 30 |
+
sub2api_import_service,
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class UserKeyCreateRequest(BaseModel):
|
| 36 |
+
name: str = ""
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class UserKeyUpdateRequest(BaseModel):
|
| 40 |
+
name: str | None = None
|
| 41 |
+
enabled: bool | None = None
|
| 42 |
+
key: str | None = None
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class AccountCreateRequest(BaseModel):
|
| 46 |
+
tokens: list[str] = Field(default_factory=list)
|
| 47 |
+
accounts: list[dict[str, Any]] = Field(default_factory=list)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class AccountDeleteRequest(BaseModel):
|
| 51 |
+
tokens: list[str] = Field(default_factory=list)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class AccountRefreshRequest(BaseModel):
|
| 55 |
+
access_tokens: list[str] = Field(default_factory=list)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class AccountExportRequest(BaseModel):
|
| 59 |
+
access_tokens: list[str] = Field(default_factory=list)
|
| 60 |
+
format: Literal["json", "zip"] = "json"
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class AccountUpdateRequest(BaseModel):
|
| 64 |
+
access_token: str = ""
|
| 65 |
+
type: str | None = None
|
| 66 |
+
status: str | None = None
|
| 67 |
+
quota: int | None = None
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class CPAPoolCreateRequest(BaseModel):
|
| 71 |
+
name: str = ""
|
| 72 |
+
base_url: str = ""
|
| 73 |
+
secret_key: str = ""
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
class CPAPoolUpdateRequest(BaseModel):
|
| 77 |
+
name: str | None = None
|
| 78 |
+
base_url: str | None = None
|
| 79 |
+
secret_key: str | None = None
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class CPAImportRequest(BaseModel):
|
| 83 |
+
names: list[str] = Field(default_factory=list)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
class Sub2APIServerCreateRequest(BaseModel):
|
| 87 |
+
name: str = ""
|
| 88 |
+
base_url: str = ""
|
| 89 |
+
email: str = ""
|
| 90 |
+
password: str = ""
|
| 91 |
+
api_key: str = ""
|
| 92 |
+
group_id: str = ""
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
class Sub2APIServerUpdateRequest(BaseModel):
|
| 96 |
+
name: str | None = None
|
| 97 |
+
base_url: str | None = None
|
| 98 |
+
email: str | None = None
|
| 99 |
+
password: str | None = None
|
| 100 |
+
api_key: str | None = None
|
| 101 |
+
group_id: str | None = None
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
class Sub2APIImportRequest(BaseModel):
|
| 105 |
+
account_ids: list[str] = Field(default_factory=list)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _account_payload_token(item: dict[str, Any]) -> str:
|
| 109 |
+
return str(item.get("access_token") or item.get("accessToken") or "").strip()
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def _unique_tokens(tokens: list[str]) -> list[str]:
|
| 113 |
+
return list(dict.fromkeys(str(token or "").strip() for token in tokens if str(token or "").strip()))
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _download_timestamp() -> str:
|
| 117 |
+
return datetime.now().strftime("%Y%m%d-%H%M%S")
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _safe_export_name(value: str, fallback: str) -> str:
|
| 121 |
+
clean = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip()).strip("-._")
|
| 122 |
+
return (clean or fallback)[:80]
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def _account_zip_bytes(items: list[dict[str, str]]) -> bytes:
|
| 126 |
+
buf = io.BytesIO()
|
| 127 |
+
used_names: set[str] = set()
|
| 128 |
+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as archive:
|
| 129 |
+
for index, item in enumerate(items, start=1):
|
| 130 |
+
raw_name = item.get("email") or item.get("account_id") or f"account-{index:03d}"
|
| 131 |
+
base_name = _safe_export_name(raw_name, f"account-{index:03d}")
|
| 132 |
+
name = base_name
|
| 133 |
+
suffix = 2
|
| 134 |
+
while name in used_names:
|
| 135 |
+
name = f"{base_name}-{suffix}"
|
| 136 |
+
suffix += 1
|
| 137 |
+
used_names.add(name)
|
| 138 |
+
archive.writestr(
|
| 139 |
+
f"{name}.json",
|
| 140 |
+
json.dumps(item, ensure_ascii=False, indent=2) + "\n",
|
| 141 |
+
)
|
| 142 |
+
return buf.getvalue()
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def create_router() -> APIRouter:
|
| 146 |
+
router = APIRouter()
|
| 147 |
+
|
| 148 |
+
@router.get("/api/auth/users")
|
| 149 |
+
async def list_user_keys(authorization: str | None = Header(default=None)):
|
| 150 |
+
require_admin(authorization)
|
| 151 |
+
return {"items": auth_service.list_keys(role="user")}
|
| 152 |
+
|
| 153 |
+
@router.post("/api/auth/users")
|
| 154 |
+
async def create_user_key(body: UserKeyCreateRequest, authorization: str | None = Header(default=None)):
|
| 155 |
+
require_admin(authorization)
|
| 156 |
+
try:
|
| 157 |
+
item, raw_key = auth_service.create_key(role="user", name=body.name)
|
| 158 |
+
except ValueError as exc:
|
| 159 |
+
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
|
| 160 |
+
return {"item": item, "key": raw_key, "items": auth_service.list_keys(role="user")}
|
| 161 |
+
|
| 162 |
+
@router.post("/api/auth/users/{key_id}")
|
| 163 |
+
async def update_user_key(
|
| 164 |
+
key_id: str,
|
| 165 |
+
body: UserKeyUpdateRequest,
|
| 166 |
+
authorization: str | None = Header(default=None),
|
| 167 |
+
):
|
| 168 |
+
require_admin(authorization)
|
| 169 |
+
updates = {
|
| 170 |
+
key: value
|
| 171 |
+
for key, value in {
|
| 172 |
+
"name": body.name,
|
| 173 |
+
"enabled": body.enabled,
|
| 174 |
+
"key": body.key,
|
| 175 |
+
}.items()
|
| 176 |
+
if value is not None
|
| 177 |
+
}
|
| 178 |
+
if not updates:
|
| 179 |
+
raise HTTPException(status_code=400, detail={"error": "还没有检测到改动,请修改后再保存"})
|
| 180 |
+
try:
|
| 181 |
+
item = auth_service.update_key(key_id, updates, role="user")
|
| 182 |
+
except ValueError as exc:
|
| 183 |
+
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
|
| 184 |
+
if item is None:
|
| 185 |
+
raise HTTPException(status_code=404, detail={"error": "这条用户密钥不存在,可能已经被删除"})
|
| 186 |
+
return {"item": item, "items": auth_service.list_keys(role="user")}
|
| 187 |
+
|
| 188 |
+
@router.delete("/api/auth/users/{key_id}")
|
| 189 |
+
async def delete_user_key(key_id: str, authorization: str | None = Header(default=None)):
|
| 190 |
+
require_admin(authorization)
|
| 191 |
+
if not auth_service.delete_key(key_id, role="user"):
|
| 192 |
+
raise HTTPException(status_code=404, detail={"error": "这条用户密钥不存在,可能已经被删除"})
|
| 193 |
+
return {"items": auth_service.list_keys(role="user")}
|
| 194 |
+
|
| 195 |
+
@router.get("/api/accounts")
|
| 196 |
+
async def get_accounts(authorization: str | None = Header(default=None)):
|
| 197 |
+
require_admin(authorization)
|
| 198 |
+
return {"items": account_service.list_accounts()}
|
| 199 |
+
|
| 200 |
+
@router.post("/api/accounts")
|
| 201 |
+
async def create_accounts(body: AccountCreateRequest, authorization: str | None = Header(default=None)):
|
| 202 |
+
require_admin(authorization)
|
| 203 |
+
account_payloads = [item for item in body.accounts if isinstance(item, dict)]
|
| 204 |
+
payload_tokens = [_account_payload_token(item) for item in account_payloads]
|
| 205 |
+
tokens = _unique_tokens([*body.tokens, *payload_tokens])
|
| 206 |
+
if not tokens:
|
| 207 |
+
raise HTTPException(status_code=400, detail={"error": "tokens is required"})
|
| 208 |
+
if account_payloads:
|
| 209 |
+
result = account_service.add_account_items(account_payloads)
|
| 210 |
+
payload_token_set = set(_unique_tokens(payload_tokens))
|
| 211 |
+
extra_tokens = [token for token in tokens if token not in payload_token_set]
|
| 212 |
+
if extra_tokens:
|
| 213 |
+
extra_result = account_service.add_accounts(extra_tokens)
|
| 214 |
+
result["added"] = int(result.get("added") or 0) + int(extra_result.get("added") or 0)
|
| 215 |
+
result["skipped"] = int(result.get("skipped") or 0) + int(extra_result.get("skipped") or 0)
|
| 216 |
+
else:
|
| 217 |
+
result = account_service.add_accounts(tokens)
|
| 218 |
+
refresh_result = account_service.refresh_accounts(tokens)
|
| 219 |
+
return {
|
| 220 |
+
**result,
|
| 221 |
+
"refreshed": refresh_result.get("refreshed", 0),
|
| 222 |
+
"errors": refresh_result.get("errors", []),
|
| 223 |
+
"items": refresh_result.get("items", result.get("items", [])),
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
@router.delete("/api/accounts")
|
| 227 |
+
async def delete_accounts(body: AccountDeleteRequest, authorization: str | None = Header(default=None)):
|
| 228 |
+
require_admin(authorization)
|
| 229 |
+
tokens = [str(token or "").strip() for token in body.tokens if str(token or "").strip()]
|
| 230 |
+
if not tokens:
|
| 231 |
+
raise HTTPException(status_code=400, detail={"error": "tokens is required"})
|
| 232 |
+
return account_service.delete_accounts(tokens)
|
| 233 |
+
|
| 234 |
+
@router.post("/api/accounts/refresh")
|
| 235 |
+
async def refresh_accounts(body: AccountRefreshRequest, authorization: str | None = Header(default=None)):
|
| 236 |
+
require_admin(authorization)
|
| 237 |
+
access_tokens = [str(token or "").strip() for token in body.access_tokens if str(token or "").strip()]
|
| 238 |
+
if not access_tokens:
|
| 239 |
+
access_tokens = account_service.list_tokens()
|
| 240 |
+
if not access_tokens:
|
| 241 |
+
raise HTTPException(status_code=400, detail={"error": "access_tokens is required"})
|
| 242 |
+
return account_service.refresh_accounts(access_tokens)
|
| 243 |
+
|
| 244 |
+
@router.post("/api/accounts/export")
|
| 245 |
+
async def export_accounts(body: AccountExportRequest, authorization: str | None = Header(default=None)):
|
| 246 |
+
require_admin(authorization)
|
| 247 |
+
access_tokens = _unique_tokens(body.access_tokens)
|
| 248 |
+
items = account_service.build_export_items(access_tokens)
|
| 249 |
+
if not items:
|
| 250 |
+
raise HTTPException(
|
| 251 |
+
status_code=400,
|
| 252 |
+
detail={"error": "没有可导出的完整账号,需要同时有 access_token、refresh_token 和 id_token"},
|
| 253 |
+
)
|
| 254 |
+
|
| 255 |
+
timestamp = _download_timestamp()
|
| 256 |
+
if body.format == "zip":
|
| 257 |
+
content = _account_zip_bytes(items)
|
| 258 |
+
return Response(
|
| 259 |
+
content,
|
| 260 |
+
media_type="application/zip",
|
| 261 |
+
headers={"Content-Disposition": f'attachment; filename="codex-accounts-{timestamp}.zip"'},
|
| 262 |
+
)
|
| 263 |
+
|
| 264 |
+
payload: dict[str, str] | list[dict[str, str]] = items[0] if len(items) == 1 else items
|
| 265 |
+
return Response(
|
| 266 |
+
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
|
| 267 |
+
media_type="application/json",
|
| 268 |
+
headers={"Content-Disposition": f'attachment; filename="codex-accounts-{timestamp}.json"'},
|
| 269 |
+
)
|
| 270 |
+
|
| 271 |
+
@router.post("/api/accounts/update")
|
| 272 |
+
async def update_account(body: AccountUpdateRequest, authorization: str | None = Header(default=None)):
|
| 273 |
+
require_admin(authorization)
|
| 274 |
+
access_token = str(body.access_token or "").strip()
|
| 275 |
+
if not access_token:
|
| 276 |
+
raise HTTPException(status_code=400, detail={"error": "access_token is required"})
|
| 277 |
+
updates = {key: value for key, value in {"type": body.type, "status": body.status, "quota": body.quota}.items() if value is not None}
|
| 278 |
+
if not updates:
|
| 279 |
+
raise HTTPException(status_code=400, detail={"error": "还没有检测到改动,请修改后再保存"})
|
| 280 |
+
account = account_service.update_account(access_token, updates)
|
| 281 |
+
if account is None:
|
| 282 |
+
raise HTTPException(status_code=404, detail={"error": "account not found"})
|
| 283 |
+
return {"item": account, "items": account_service.list_accounts()}
|
| 284 |
+
|
| 285 |
+
@router.get("/api/cpa/pools")
|
| 286 |
+
async def list_cpa_pools(authorization: str | None = Header(default=None)):
|
| 287 |
+
require_admin(authorization)
|
| 288 |
+
return {"pools": sanitize_cpa_pools(cpa_config.list_pools())}
|
| 289 |
+
|
| 290 |
+
@router.post("/api/cpa/pools")
|
| 291 |
+
async def create_cpa_pool(body: CPAPoolCreateRequest, authorization: str | None = Header(default=None)):
|
| 292 |
+
require_admin(authorization)
|
| 293 |
+
if not body.base_url.strip():
|
| 294 |
+
raise HTTPException(status_code=400, detail={"error": "base_url is required"})
|
| 295 |
+
if not body.secret_key.strip():
|
| 296 |
+
raise HTTPException(status_code=400, detail={"error": "secret_key is required"})
|
| 297 |
+
pool = cpa_config.add_pool(name=body.name, base_url=body.base_url, secret_key=body.secret_key)
|
| 298 |
+
return {"pool": sanitize_cpa_pool(pool), "pools": sanitize_cpa_pools(cpa_config.list_pools())}
|
| 299 |
+
|
| 300 |
+
@router.post("/api/cpa/pools/{pool_id}")
|
| 301 |
+
async def update_cpa_pool(pool_id: str, body: CPAPoolUpdateRequest, authorization: str | None = Header(default=None)):
|
| 302 |
+
require_admin(authorization)
|
| 303 |
+
pool = cpa_config.update_pool(pool_id, body.model_dump(exclude_none=True))
|
| 304 |
+
if pool is None:
|
| 305 |
+
raise HTTPException(status_code=404, detail={"error": "pool not found"})
|
| 306 |
+
return {"pool": sanitize_cpa_pool(pool), "pools": sanitize_cpa_pools(cpa_config.list_pools())}
|
| 307 |
+
|
| 308 |
+
@router.delete("/api/cpa/pools/{pool_id}")
|
| 309 |
+
async def delete_cpa_pool(pool_id: str, authorization: str | None = Header(default=None)):
|
| 310 |
+
require_admin(authorization)
|
| 311 |
+
if not cpa_config.delete_pool(pool_id):
|
| 312 |
+
raise HTTPException(status_code=404, detail={"error": "pool not found"})
|
| 313 |
+
return {"pools": sanitize_cpa_pools(cpa_config.list_pools())}
|
| 314 |
+
|
| 315 |
+
@router.get("/api/cpa/pools/{pool_id}/files")
|
| 316 |
+
async def cpa_pool_files(pool_id: str, authorization: str | None = Header(default=None)):
|
| 317 |
+
require_admin(authorization)
|
| 318 |
+
pool = cpa_config.get_pool(pool_id)
|
| 319 |
+
if pool is None:
|
| 320 |
+
raise HTTPException(status_code=404, detail={"error": "pool not found"})
|
| 321 |
+
return {"pool_id": pool_id, "files": await run_in_threadpool(list_remote_files, pool)}
|
| 322 |
+
|
| 323 |
+
@router.post("/api/cpa/pools/{pool_id}/import")
|
| 324 |
+
async def cpa_pool_import(pool_id: str, body: CPAImportRequest, authorization: str | None = Header(default=None)):
|
| 325 |
+
require_admin(authorization)
|
| 326 |
+
pool = cpa_config.get_pool(pool_id)
|
| 327 |
+
if pool is None:
|
| 328 |
+
raise HTTPException(status_code=404, detail={"error": "pool not found"})
|
| 329 |
+
try:
|
| 330 |
+
job = cpa_import_service.start_import(pool, body.names)
|
| 331 |
+
except ValueError as exc:
|
| 332 |
+
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
|
| 333 |
+
return {"import_job": job}
|
| 334 |
+
|
| 335 |
+
@router.get("/api/cpa/pools/{pool_id}/import")
|
| 336 |
+
async def cpa_pool_import_progress(pool_id: str, authorization: str | None = Header(default=None)):
|
| 337 |
+
require_admin(authorization)
|
| 338 |
+
pool = cpa_config.get_pool(pool_id)
|
| 339 |
+
if pool is None:
|
| 340 |
+
raise HTTPException(status_code=404, detail={"error": "pool not found"})
|
| 341 |
+
return {"import_job": pool.get("import_job")}
|
| 342 |
+
|
| 343 |
+
@router.get("/api/sub2api/servers")
|
| 344 |
+
async def list_sub2api_servers(authorization: str | None = Header(default=None)):
|
| 345 |
+
require_admin(authorization)
|
| 346 |
+
return {"servers": sanitize_sub2api_servers(sub2api_config.list_servers())}
|
| 347 |
+
|
| 348 |
+
@router.post("/api/sub2api/servers")
|
| 349 |
+
async def create_sub2api_server(body: Sub2APIServerCreateRequest, authorization: str | None = Header(default=None)):
|
| 350 |
+
require_admin(authorization)
|
| 351 |
+
if not body.base_url.strip():
|
| 352 |
+
raise HTTPException(status_code=400, detail={"error": "base_url is required"})
|
| 353 |
+
has_login = body.email.strip() and body.password.strip()
|
| 354 |
+
has_api_key = bool(body.api_key.strip())
|
| 355 |
+
if not has_login and not has_api_key:
|
| 356 |
+
raise HTTPException(status_code=400, detail={"error": "email+password or api_key is required"})
|
| 357 |
+
server = sub2api_config.add_server(
|
| 358 |
+
name=body.name,
|
| 359 |
+
base_url=body.base_url,
|
| 360 |
+
email=body.email,
|
| 361 |
+
password=body.password,
|
| 362 |
+
api_key=body.api_key,
|
| 363 |
+
group_id=body.group_id,
|
| 364 |
+
)
|
| 365 |
+
return {"server": sanitize_sub2api_server(server), "servers": sanitize_sub2api_servers(sub2api_config.list_servers())}
|
| 366 |
+
|
| 367 |
+
@router.post("/api/sub2api/servers/{server_id}")
|
| 368 |
+
async def update_sub2api_server(server_id: str, body: Sub2APIServerUpdateRequest, authorization: str | None = Header(default=None)):
|
| 369 |
+
require_admin(authorization)
|
| 370 |
+
server = sub2api_config.update_server(server_id, body.model_dump(exclude_none=True))
|
| 371 |
+
if server is None:
|
| 372 |
+
raise HTTPException(status_code=404, detail={"error": "server not found"})
|
| 373 |
+
return {"server": sanitize_sub2api_server(server), "servers": sanitize_sub2api_servers(sub2api_config.list_servers())}
|
| 374 |
+
|
| 375 |
+
@router.delete("/api/sub2api/servers/{server_id}")
|
| 376 |
+
async def delete_sub2api_server(server_id: str, authorization: str | None = Header(default=None)):
|
| 377 |
+
require_admin(authorization)
|
| 378 |
+
if not sub2api_config.delete_server(server_id):
|
| 379 |
+
raise HTTPException(status_code=404, detail={"error": "server not found"})
|
| 380 |
+
return {"servers": sanitize_sub2api_servers(sub2api_config.list_servers())}
|
| 381 |
+
|
| 382 |
+
@router.get("/api/sub2api/servers/{server_id}/groups")
|
| 383 |
+
async def sub2api_server_groups(server_id: str, authorization: str | None = Header(default=None)):
|
| 384 |
+
require_admin(authorization)
|
| 385 |
+
server = sub2api_config.get_server(server_id)
|
| 386 |
+
if server is None:
|
| 387 |
+
raise HTTPException(status_code=404, detail={"error": "server not found"})
|
| 388 |
+
try:
|
| 389 |
+
groups = await run_in_threadpool(sub2api_list_remote_groups, server)
|
| 390 |
+
except Exception as exc:
|
| 391 |
+
raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
|
| 392 |
+
return {"server_id": server_id, "groups": groups}
|
| 393 |
+
|
| 394 |
+
@router.get("/api/sub2api/servers/{server_id}/accounts")
|
| 395 |
+
async def sub2api_server_accounts(server_id: str, authorization: str | None = Header(default=None)):
|
| 396 |
+
require_admin(authorization)
|
| 397 |
+
server = sub2api_config.get_server(server_id)
|
| 398 |
+
if server is None:
|
| 399 |
+
raise HTTPException(status_code=404, detail={"error": "server not found"})
|
| 400 |
+
try:
|
| 401 |
+
accounts = await run_in_threadpool(sub2api_list_remote_accounts, server)
|
| 402 |
+
except Exception as exc:
|
| 403 |
+
raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
|
| 404 |
+
return {"server_id": server_id, "accounts": accounts}
|
| 405 |
+
|
| 406 |
+
@router.post("/api/sub2api/servers/{server_id}/import")
|
| 407 |
+
async def sub2api_server_import(server_id: str, body: Sub2APIImportRequest, authorization: str | None = Header(default=None)):
|
| 408 |
+
require_admin(authorization)
|
| 409 |
+
server = sub2api_config.get_server(server_id)
|
| 410 |
+
if server is None:
|
| 411 |
+
raise HTTPException(status_code=404, detail={"error": "server not found"})
|
| 412 |
+
try:
|
| 413 |
+
job = sub2api_import_service.start_import(server, body.account_ids)
|
| 414 |
+
except ValueError as exc:
|
| 415 |
+
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
|
| 416 |
+
return {"import_job": job}
|
| 417 |
+
|
| 418 |
+
@router.get("/api/sub2api/servers/{server_id}/import")
|
| 419 |
+
async def sub2api_server_import_progress(server_id: str, authorization: str | None = Header(default=None)):
|
| 420 |
+
require_admin(authorization)
|
| 421 |
+
server = sub2api_config.get_server(server_id)
|
| 422 |
+
if server is None:
|
| 423 |
+
raise HTTPException(status_code=404, detail={"error": "server not found"})
|
| 424 |
+
return {"import_job": server.get("import_job")}
|
| 425 |
+
|
| 426 |
+
return router
|
api/ai.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, Header, HTTPException, Request
|
| 4 |
+
from fastapi.concurrency import run_in_threadpool
|
| 5 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 6 |
+
|
| 7 |
+
from api.image_inputs import parse_image_edit_request, read_image_sources
|
| 8 |
+
from api.support import require_identity, resolve_image_base_url
|
| 9 |
+
from services.content_filter import check_request, request_text
|
| 10 |
+
from services.log_service import LoggedCall
|
| 11 |
+
from services.protocol import (
|
| 12 |
+
anthropic_v1_messages,
|
| 13 |
+
openai_v1_chat_complete,
|
| 14 |
+
openai_v1_image_edit,
|
| 15 |
+
openai_v1_image_generations,
|
| 16 |
+
openai_v1_models,
|
| 17 |
+
openai_v1_response,
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class ImageGenerationRequest(BaseModel):
|
| 22 |
+
prompt: str = Field(..., min_length=1)
|
| 23 |
+
model: str = "gpt-image-2"
|
| 24 |
+
n: int = Field(default=1, ge=1, le=4)
|
| 25 |
+
size: str | None = None
|
| 26 |
+
quality: str = "auto"
|
| 27 |
+
response_format: str = "b64_json"
|
| 28 |
+
history_disabled: bool = True
|
| 29 |
+
stream: bool | None = None
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class ChatCompletionRequest(BaseModel):
|
| 33 |
+
model_config = ConfigDict(extra="allow")
|
| 34 |
+
model: str | None = None
|
| 35 |
+
prompt: str | None = None
|
| 36 |
+
n: int | None = None
|
| 37 |
+
stream: bool | None = None
|
| 38 |
+
modalities: list[str] | None = None
|
| 39 |
+
messages: list[dict[str, object]] | None = None
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class ResponseCreateRequest(BaseModel):
|
| 43 |
+
model_config = ConfigDict(extra="allow")
|
| 44 |
+
model: str | None = None
|
| 45 |
+
input: object | None = None
|
| 46 |
+
tools: list[dict[str, object]] | None = None
|
| 47 |
+
tool_choice: object | None = None
|
| 48 |
+
stream: bool | None = None
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class AnthropicMessageRequest(BaseModel):
|
| 52 |
+
model_config = ConfigDict(extra="allow")
|
| 53 |
+
model: str | None = None
|
| 54 |
+
messages: list[dict[str, object]] | None = None
|
| 55 |
+
system: object | None = None
|
| 56 |
+
stream: bool | None = None
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
async def filter_or_log(call: LoggedCall, text: str) -> None:
|
| 60 |
+
try:
|
| 61 |
+
await run_in_threadpool(check_request, text)
|
| 62 |
+
except HTTPException as exc:
|
| 63 |
+
call.log("调用失败", status="failed", error=str(exc.detail))
|
| 64 |
+
raise
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def create_router() -> APIRouter:
|
| 68 |
+
router = APIRouter()
|
| 69 |
+
|
| 70 |
+
@router.get("/v1/models")
|
| 71 |
+
async def list_models(authorization: str | None = Header(default=None)):
|
| 72 |
+
require_identity(authorization)
|
| 73 |
+
try:
|
| 74 |
+
return await run_in_threadpool(openai_v1_models.list_models)
|
| 75 |
+
except Exception as exc:
|
| 76 |
+
raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
|
| 77 |
+
|
| 78 |
+
@router.post("/v1/images/generations")
|
| 79 |
+
async def generate_images(
|
| 80 |
+
body: ImageGenerationRequest,
|
| 81 |
+
request: Request,
|
| 82 |
+
authorization: str | None = Header(default=None),
|
| 83 |
+
):
|
| 84 |
+
identity = require_identity(authorization)
|
| 85 |
+
payload = body.model_dump(mode="python")
|
| 86 |
+
payload["base_url"] = resolve_image_base_url(request)
|
| 87 |
+
call = LoggedCall(identity, "/v1/images/generations", body.model, "文生图", request_text=body.prompt)
|
| 88 |
+
await filter_or_log(call, body.prompt)
|
| 89 |
+
return await call.run(openai_v1_image_generations.handle, payload)
|
| 90 |
+
|
| 91 |
+
@router.post("/v1/images/edits")
|
| 92 |
+
async def edit_images(
|
| 93 |
+
request: Request,
|
| 94 |
+
authorization: str | None = Header(default=None),
|
| 95 |
+
):
|
| 96 |
+
identity = require_identity(authorization)
|
| 97 |
+
payload, image_sources = await parse_image_edit_request(request)
|
| 98 |
+
prompt = str(payload["prompt"])
|
| 99 |
+
model = str(payload["model"])
|
| 100 |
+
call = LoggedCall(identity, "/v1/images/edits", model, "图生图", request_text=prompt)
|
| 101 |
+
await filter_or_log(call, prompt)
|
| 102 |
+
payload["images"] = await read_image_sources(image_sources)
|
| 103 |
+
payload["base_url"] = resolve_image_base_url(request)
|
| 104 |
+
return await call.run(openai_v1_image_edit.handle, payload)
|
| 105 |
+
|
| 106 |
+
@router.post("/v1/chat/completions")
|
| 107 |
+
async def create_chat_completion(body: ChatCompletionRequest, authorization: str | None = Header(default=None)):
|
| 108 |
+
identity = require_identity(authorization)
|
| 109 |
+
payload = body.model_dump(mode="python")
|
| 110 |
+
model = str(payload.get("model") or "auto")
|
| 111 |
+
request_preview = request_text(payload.get("prompt"), payload.get("messages"))
|
| 112 |
+
call = LoggedCall(identity, "/v1/chat/completions", model, "文本生成", request_text=request_preview)
|
| 113 |
+
await filter_or_log(call, request_preview)
|
| 114 |
+
return await call.run(openai_v1_chat_complete.handle, payload)
|
| 115 |
+
|
| 116 |
+
@router.post("/v1/responses")
|
| 117 |
+
async def create_response(body: ResponseCreateRequest, authorization: str | None = Header(default=None)):
|
| 118 |
+
identity = require_identity(authorization)
|
| 119 |
+
payload = body.model_dump(mode="python")
|
| 120 |
+
model = str(payload.get("model") or "auto")
|
| 121 |
+
request_preview = request_text(payload.get("input"), payload.get("instructions"))
|
| 122 |
+
call = LoggedCall(identity, "/v1/responses", model, "Responses", request_text=request_preview)
|
| 123 |
+
await filter_or_log(call, request_preview)
|
| 124 |
+
return await call.run(openai_v1_response.handle, payload)
|
| 125 |
+
|
| 126 |
+
@router.post("/v1/messages")
|
| 127 |
+
async def create_message(
|
| 128 |
+
body: AnthropicMessageRequest,
|
| 129 |
+
authorization: str | None = Header(default=None),
|
| 130 |
+
x_api_key: str | None = Header(default=None, alias="x-api-key"),
|
| 131 |
+
anthropic_version: str | None = Header(default=None, alias="anthropic-version"),
|
| 132 |
+
):
|
| 133 |
+
identity = require_identity(authorization or (f"Bearer {x_api_key}" if x_api_key else None))
|
| 134 |
+
payload = body.model_dump(mode="python")
|
| 135 |
+
model = str(payload.get("model") or "auto")
|
| 136 |
+
request_preview = request_text(payload.get("system"), payload.get("messages"), payload.get("tools"))
|
| 137 |
+
call = LoggedCall(identity, "/v1/messages", model, "Messages", request_text=request_preview)
|
| 138 |
+
await filter_or_log(call, request_preview)
|
| 139 |
+
return await call.run(anthropic_v1_messages.handle, payload, sse="anthropic")
|
| 140 |
+
|
| 141 |
+
return router
|
api/app.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from contextlib import asynccontextmanager
|
| 4 |
+
from threading import Event
|
| 5 |
+
|
| 6 |
+
from fastapi import FastAPI, HTTPException
|
| 7 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 8 |
+
from fastapi.responses import FileResponse
|
| 9 |
+
|
| 10 |
+
from api import accounts, ai, image_tasks, register, system
|
| 11 |
+
from api.errors import install_exception_handlers
|
| 12 |
+
from api.support import resolve_web_asset, start_limited_account_watcher
|
| 13 |
+
from services.backup_service import backup_service
|
| 14 |
+
from services.config import config
|
| 15 |
+
from services.image_service import start_image_cleanup_scheduler
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def create_app() -> FastAPI:
|
| 19 |
+
app_version = config.app_version
|
| 20 |
+
|
| 21 |
+
@asynccontextmanager
|
| 22 |
+
async def lifespan(_: FastAPI):
|
| 23 |
+
stop_event = Event()
|
| 24 |
+
thread = start_limited_account_watcher(stop_event)
|
| 25 |
+
cleanup_thread = start_image_cleanup_scheduler(stop_event)
|
| 26 |
+
backup_service.start()
|
| 27 |
+
config.cleanup_old_images()
|
| 28 |
+
try:
|
| 29 |
+
yield
|
| 30 |
+
finally:
|
| 31 |
+
stop_event.set()
|
| 32 |
+
thread.join(timeout=1)
|
| 33 |
+
cleanup_thread.join(timeout=1)
|
| 34 |
+
backup_service.stop()
|
| 35 |
+
|
| 36 |
+
app = FastAPI(title="chatgpt2api", version=app_version, lifespan=lifespan)
|
| 37 |
+
install_exception_handlers(app)
|
| 38 |
+
app.add_middleware(
|
| 39 |
+
CORSMiddleware,
|
| 40 |
+
allow_origins=["*"],
|
| 41 |
+
allow_credentials=False,
|
| 42 |
+
allow_methods=["*"],
|
| 43 |
+
allow_headers=["*"],
|
| 44 |
+
)
|
| 45 |
+
app.include_router(ai.create_router())
|
| 46 |
+
app.include_router(accounts.create_router())
|
| 47 |
+
app.include_router(image_tasks.create_router())
|
| 48 |
+
app.include_router(register.create_router())
|
| 49 |
+
app.include_router(system.create_router(app_version))
|
| 50 |
+
|
| 51 |
+
@app.get("/{full_path:path}", include_in_schema=False)
|
| 52 |
+
async def serve_web(full_path: str):
|
| 53 |
+
asset = resolve_web_asset(full_path)
|
| 54 |
+
if asset is not None:
|
| 55 |
+
return FileResponse(asset)
|
| 56 |
+
if full_path.strip("/").startswith("_next/"):
|
| 57 |
+
raise HTTPException(status_code=404, detail="Not Found")
|
| 58 |
+
fallback = resolve_web_asset("")
|
| 59 |
+
if fallback is None:
|
| 60 |
+
raise HTTPException(status_code=404, detail="Not Found")
|
| 61 |
+
return FileResponse(fallback)
|
| 62 |
+
|
| 63 |
+
return app
|
api/errors.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from fastapi import FastAPI, Request
|
| 4 |
+
from fastapi.encoders import jsonable_encoder
|
| 5 |
+
from fastapi.exceptions import RequestValidationError
|
| 6 |
+
from fastapi.responses import JSONResponse
|
| 7 |
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
| 8 |
+
|
| 9 |
+
from services.protocol.error_response import anthropic_error_response, openai_error_response
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _is_openai_compatible_path(path: str) -> bool:
|
| 13 |
+
return path == "/v1" or path.startswith("/v1/")
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _is_anthropic_messages_path(path: str) -> bool:
|
| 17 |
+
return path == "/v1/messages"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _compatible_error_response(
|
| 21 |
+
request: Request,
|
| 22 |
+
detail: object,
|
| 23 |
+
status_code: int,
|
| 24 |
+
headers: dict[str, str] | None = None,
|
| 25 |
+
) -> JSONResponse:
|
| 26 |
+
if _is_anthropic_messages_path(request.url.path):
|
| 27 |
+
return anthropic_error_response(detail, status_code, headers=headers)
|
| 28 |
+
return openai_error_response(detail, status_code, headers=headers)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def install_exception_handlers(app: FastAPI) -> None:
|
| 32 |
+
@app.exception_handler(StarletteHTTPException)
|
| 33 |
+
async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse:
|
| 34 |
+
if _is_openai_compatible_path(request.url.path):
|
| 35 |
+
return _compatible_error_response(request, exc.detail, exc.status_code, exc.headers)
|
| 36 |
+
return JSONResponse(
|
| 37 |
+
status_code=exc.status_code,
|
| 38 |
+
content={"detail": jsonable_encoder(exc.detail)},
|
| 39 |
+
headers=exc.headers,
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
@app.exception_handler(RequestValidationError)
|
| 43 |
+
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
|
| 44 |
+
if _is_openai_compatible_path(request.url.path):
|
| 45 |
+
return _compatible_error_response(request, exc.errors(), 422)
|
| 46 |
+
return JSONResponse(status_code=422, content={"detail": jsonable_encoder(exc.errors())})
|
api/image_inputs.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
import binascii
|
| 5 |
+
import json
|
| 6 |
+
import mimetypes
|
| 7 |
+
import re
|
| 8 |
+
from pathlib import PurePosixPath
|
| 9 |
+
from typing import Any, TypeGuard
|
| 10 |
+
from urllib.parse import unquote, unquote_to_bytes, urlparse
|
| 11 |
+
|
| 12 |
+
from curl_cffi import requests
|
| 13 |
+
from fastapi import HTTPException, Request
|
| 14 |
+
from fastapi.concurrency import run_in_threadpool
|
| 15 |
+
from starlette.datastructures import UploadFile
|
| 16 |
+
|
| 17 |
+
from services.proxy_service import proxy_settings
|
| 18 |
+
|
| 19 |
+
ImageInput = tuple[bytes, str, str]
|
| 20 |
+
ImageSource = str | UploadFile | ImageInput
|
| 21 |
+
|
| 22 |
+
MAX_IMAGE_REFERENCE_BYTES = 50 * 1024 * 1024
|
| 23 |
+
IMAGE_REFERENCE_FIELDS = {"image", "image[]", "images", "images[]", "image_url", "image_url[]"}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _clean(value: object, default: str = "") -> str:
|
| 27 |
+
"""清理字符串:转换为字符串并去掉首尾空白。"""
|
| 28 |
+
text = str(value if value is not None else default).strip()
|
| 29 |
+
return text or default
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _is_upload(value: object) -> TypeGuard[UploadFile]:
|
| 33 |
+
"""识别上传文件:兼容 Starlette 表单返回的 UploadFile。"""
|
| 34 |
+
return isinstance(value, UploadFile)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _parse_bool(value: object) -> bool | None:
|
| 38 |
+
"""解析布尔字段:兼容 JSON 布尔值和表单字符串。"""
|
| 39 |
+
if value is None or value == "":
|
| 40 |
+
return None
|
| 41 |
+
if isinstance(value, bool):
|
| 42 |
+
return value
|
| 43 |
+
text = _clean(value).lower()
|
| 44 |
+
if text in {"true", "1", "yes", "y", "on"}:
|
| 45 |
+
return True
|
| 46 |
+
if text in {"false", "0", "no", "n", "off"}:
|
| 47 |
+
return False
|
| 48 |
+
raise HTTPException(status_code=400, detail={"error": "stream must be a boolean"})
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _parse_count(value: object) -> int:
|
| 52 |
+
"""解析生成数量:保持图片接口的 1 到 4 限制。"""
|
| 53 |
+
try:
|
| 54 |
+
count = int(value or 1)
|
| 55 |
+
except (TypeError, ValueError) as exc:
|
| 56 |
+
raise HTTPException(status_code=400, detail={"error": "n must be an integer"}) from exc
|
| 57 |
+
if count < 1 or count > 4:
|
| 58 |
+
raise HTTPException(status_code=400, detail={"error": "n must be between 1 and 4"})
|
| 59 |
+
return count
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _payload_from_fields(fields: dict[str, Any]) -> dict[str, Any]:
|
| 63 |
+
"""构造图片编辑载荷:从表单或 JSON 字段提取通用参数。"""
|
| 64 |
+
prompt = _clean(fields.get("prompt"))
|
| 65 |
+
if not prompt:
|
| 66 |
+
raise HTTPException(status_code=400, detail={"error": "prompt is required"})
|
| 67 |
+
payload = {
|
| 68 |
+
"prompt": prompt,
|
| 69 |
+
"model": _clean(fields.get("model"), "gpt-image-2"),
|
| 70 |
+
"n": _parse_count(fields.get("n")),
|
| 71 |
+
"size": _clean(fields.get("size")) or None,
|
| 72 |
+
"quality": _clean(fields.get("quality"), "auto"),
|
| 73 |
+
"response_format": _clean(fields.get("response_format"), "b64_json"),
|
| 74 |
+
"stream": _parse_bool(fields.get("stream")),
|
| 75 |
+
}
|
| 76 |
+
if "client_task_id" in fields:
|
| 77 |
+
payload["client_task_id"] = _clean(fields.get("client_task_id"))
|
| 78 |
+
return payload
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _json_reference_value(value: object) -> object:
|
| 82 |
+
"""解析表单图片引用:支持把 images 字段写成 JSON 字符串。"""
|
| 83 |
+
if not isinstance(value, str):
|
| 84 |
+
return value
|
| 85 |
+
text = value.strip()
|
| 86 |
+
if not text or text[0] not in "[{":
|
| 87 |
+
return value
|
| 88 |
+
try:
|
| 89 |
+
return json.loads(text)
|
| 90 |
+
except json.JSONDecodeError:
|
| 91 |
+
return value
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _decode_base64_image(value: object, filename: str, mime_type: str) -> ImageInput:
|
| 95 |
+
try:
|
| 96 |
+
data = base64.b64decode(str(value).strip(), validate=True)
|
| 97 |
+
except (binascii.Error, ValueError) as exc:
|
| 98 |
+
raise HTTPException(status_code=400, detail={"error": "invalid base64 image data"}) from exc
|
| 99 |
+
if not data:
|
| 100 |
+
raise HTTPException(status_code=400, detail={"error": "image file is empty"})
|
| 101 |
+
if len(data) > MAX_IMAGE_REFERENCE_BYTES:
|
| 102 |
+
raise HTTPException(status_code=400, detail={"error": "image URL exceeds 50MB limit"})
|
| 103 |
+
return data, filename, mime_type
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _source_from_object(value: dict[str, Any]) -> list[ImageSource]:
|
| 107 |
+
"""提取图片引用对象:支持 image_url 或 url,明确拒绝 file_id。"""
|
| 108 |
+
has_url = "image_url" in value or "url" in value
|
| 109 |
+
if value.get("file_id"):
|
| 110 |
+
raise HTTPException(
|
| 111 |
+
status_code=400,
|
| 112 |
+
detail={"error": "file_id image references are not supported; use image_url instead"},
|
| 113 |
+
)
|
| 114 |
+
inline = value.get("b64_json") or value.get("base64")
|
| 115 |
+
if inline:
|
| 116 |
+
filename = _clean(value.get("filename") or value.get("file_name"), "image.png")
|
| 117 |
+
mime_type = _clean(value.get("mime_type") or value.get("mimeType"), "image/png")
|
| 118 |
+
return [_decode_base64_image(inline, filename, mime_type)]
|
| 119 |
+
if not has_url:
|
| 120 |
+
raise HTTPException(status_code=400, detail={"error": "image reference must include image_url"})
|
| 121 |
+
image_url = value.get("image_url", value.get("url"))
|
| 122 |
+
if isinstance(image_url, dict):
|
| 123 |
+
image_url = image_url.get("url")
|
| 124 |
+
return _sources_from_value(image_url)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def _sources_from_value(value: object) -> list[ImageSource]:
|
| 128 |
+
"""展开图片引用:把字符串、数组和对象统一成图片来源列表。"""
|
| 129 |
+
value = _json_reference_value(value)
|
| 130 |
+
if _is_upload(value):
|
| 131 |
+
return [value]
|
| 132 |
+
if isinstance(value, str):
|
| 133 |
+
text = value.strip()
|
| 134 |
+
if not text:
|
| 135 |
+
return []
|
| 136 |
+
if text.lower().startswith(("data:", "http://", "https://")):
|
| 137 |
+
return [text]
|
| 138 |
+
return [_decode_base64_image(text, "image.png", "image/png")]
|
| 139 |
+
if isinstance(value, list):
|
| 140 |
+
sources: list[ImageSource] = []
|
| 141 |
+
for item in value:
|
| 142 |
+
sources.extend(_sources_from_value(item))
|
| 143 |
+
return sources
|
| 144 |
+
if isinstance(value, dict):
|
| 145 |
+
return _source_from_object(value)
|
| 146 |
+
if value is None:
|
| 147 |
+
return []
|
| 148 |
+
raise HTTPException(status_code=400, detail={"error": "invalid image reference"})
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def _json_image_sources(body: dict[str, Any]) -> list[ImageSource]:
|
| 152 |
+
"""读取 JSON 图片引用:优先支持官方 images 数组字段。"""
|
| 153 |
+
sources: list[ImageSource] = []
|
| 154 |
+
for key in ("images", "image", "image_url"):
|
| 155 |
+
if key in body:
|
| 156 |
+
sources.extend(_sources_from_value(body.get(key)))
|
| 157 |
+
return sources
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
async def parse_image_edit_request(request: Request) -> tuple[dict[str, Any], list[ImageSource]]:
|
| 161 |
+
"""解析图片编辑请求:同时支持 multipart 上传和官方 JSON 图片 URL。"""
|
| 162 |
+
content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
|
| 163 |
+
if content_type == "application/json":
|
| 164 |
+
try:
|
| 165 |
+
body = await request.json()
|
| 166 |
+
except json.JSONDecodeError as exc:
|
| 167 |
+
raise HTTPException(status_code=400, detail={"error": "invalid JSON body"}) from exc
|
| 168 |
+
if not isinstance(body, dict):
|
| 169 |
+
raise HTTPException(status_code=400, detail={"error": "JSON body must be an object"})
|
| 170 |
+
return _payload_from_fields(body), _json_image_sources(body)
|
| 171 |
+
|
| 172 |
+
form = await request.form()
|
| 173 |
+
fields: dict[str, Any] = {}
|
| 174 |
+
for key in ("client_task_id", "prompt", "model", "n", "size", "quality", "response_format", "stream"):
|
| 175 |
+
value = form.get(key)
|
| 176 |
+
if isinstance(value, str):
|
| 177 |
+
fields[key] = value
|
| 178 |
+
sources: list[ImageSource] = []
|
| 179 |
+
for key, value in form.multi_items():
|
| 180 |
+
if key in IMAGE_REFERENCE_FIELDS:
|
| 181 |
+
sources.extend(_sources_from_value(value))
|
| 182 |
+
return _payload_from_fields(fields), sources
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def _extension_from_mime(mime_type: str) -> str:
|
| 186 |
+
"""推导图片扩展名:把 MIME 类型转换为常见文件后缀。"""
|
| 187 |
+
subtype = mime_type.split("/", 1)[1].split("+", 1)[0] if "/" in mime_type else "png"
|
| 188 |
+
if subtype == "jpeg":
|
| 189 |
+
return "jpg"
|
| 190 |
+
return re.sub(r"[^a-z0-9]+", "", subtype.lower()) or "png"
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def _safe_filename(name: str, mime_type: str, fallback: str) -> str:
|
| 194 |
+
"""生成安全文件名:清理 URL 文件名并补齐扩展名。"""
|
| 195 |
+
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", name).strip("._")
|
| 196 |
+
if not cleaned:
|
| 197 |
+
cleaned = fallback
|
| 198 |
+
if "." not in cleaned:
|
| 199 |
+
cleaned = f"{cleaned}.{_extension_from_mime(mime_type)}"
|
| 200 |
+
return cleaned
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def _decode_data_url(url: str) -> ImageInput:
|
| 204 |
+
"""解码 data URL:把内联图片转成标准图片输入元组。"""
|
| 205 |
+
header, separator, payload = url.partition(",")
|
| 206 |
+
if not separator:
|
| 207 |
+
raise HTTPException(status_code=400, detail={"error": "invalid data image URL"})
|
| 208 |
+
mime_type = header.split(";", 1)[0].removeprefix("data:") or "image/png"
|
| 209 |
+
if not mime_type.startswith("image/"):
|
| 210 |
+
raise HTTPException(status_code=400, detail={"error": "image_url must point to an image"})
|
| 211 |
+
try:
|
| 212 |
+
data = base64.b64decode(payload, validate=True) if ";base64" in header else unquote_to_bytes(payload)
|
| 213 |
+
except (binascii.Error, ValueError) as exc:
|
| 214 |
+
raise HTTPException(status_code=400, detail={"error": "invalid data image URL"}) from exc
|
| 215 |
+
if not data:
|
| 216 |
+
raise HTTPException(status_code=400, detail={"error": "image URL is empty"})
|
| 217 |
+
if len(data) > MAX_IMAGE_REFERENCE_BYTES:
|
| 218 |
+
raise HTTPException(status_code=400, detail={"error": "image URL exceeds 50MB limit"})
|
| 219 |
+
return data, f"image_url.{_extension_from_mime(mime_type)}", mime_type
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def _response_mime_type(response: requests.Response, parsed_path: str) -> str:
|
| 223 |
+
"""识别下载图片类型:优先响应头,必要时按 URL 后缀推断。"""
|
| 224 |
+
header_type = str(response.headers.get("content-type") or "").split(";", 1)[0].strip().lower()
|
| 225 |
+
guessed_type = mimetypes.guess_type(parsed_path)[0] or ""
|
| 226 |
+
if header_type.startswith("image/"):
|
| 227 |
+
return header_type
|
| 228 |
+
if header_type and header_type not in {"application/octet-stream", "binary/octet-stream"}:
|
| 229 |
+
raise HTTPException(status_code=400, detail={"error": "image_url must point to an image"})
|
| 230 |
+
if guessed_type.startswith("image/"):
|
| 231 |
+
return guessed_type
|
| 232 |
+
if not header_type or header_type in {"application/octet-stream", "binary/octet-stream"}:
|
| 233 |
+
return "image/png"
|
| 234 |
+
raise HTTPException(status_code=400, detail={"error": "image_url must point to an image"})
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def _filename_from_url(parsed_path: str, mime_type: str) -> str:
|
| 238 |
+
"""生成 URL 图片文件名:从链接路径提取名称并做安全化。"""
|
| 239 |
+
raw_name = PurePosixPath(unquote(parsed_path)).name
|
| 240 |
+
return _safe_filename(raw_name, mime_type, "image_url")
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def _download_image_url(url: str) -> ImageInput:
|
| 244 |
+
"""下载远程图片:把 http/https 图片链接转成标准图片输入元组。"""
|
| 245 |
+
source = _clean(url)
|
| 246 |
+
if source.startswith("data:"):
|
| 247 |
+
return _decode_data_url(source)
|
| 248 |
+
parsed = urlparse(source)
|
| 249 |
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
| 250 |
+
raise HTTPException(status_code=400, detail={"error": "image_url must be an http or https URL"})
|
| 251 |
+
try:
|
| 252 |
+
response = requests.get(
|
| 253 |
+
source,
|
| 254 |
+
headers={"Accept": "image/*,*/*;q=0.8", "User-Agent": "chatgpt2api image fetcher"},
|
| 255 |
+
timeout=60,
|
| 256 |
+
allow_redirects=True,
|
| 257 |
+
**proxy_settings.build_session_kwargs(),
|
| 258 |
+
)
|
| 259 |
+
except Exception as exc:
|
| 260 |
+
raise HTTPException(status_code=400, detail={"error": f"image_url fetch failed: {exc}"}) from exc
|
| 261 |
+
if not 200 <= response.status_code < 300:
|
| 262 |
+
raise HTTPException(status_code=400, detail={"error": f"image_url fetch failed: HTTP {response.status_code}"})
|
| 263 |
+
content_length = _clean(response.headers.get("content-length"))
|
| 264 |
+
if content_length and content_length.isdigit() and int(content_length) > MAX_IMAGE_REFERENCE_BYTES:
|
| 265 |
+
raise HTTPException(status_code=400, detail={"error": "image_url exceeds 50MB limit"})
|
| 266 |
+
data = response.content
|
| 267 |
+
if not data:
|
| 268 |
+
raise HTTPException(status_code=400, detail={"error": "image_url returned empty content"})
|
| 269 |
+
if len(data) > MAX_IMAGE_REFERENCE_BYTES:
|
| 270 |
+
raise HTTPException(status_code=400, detail={"error": "image_url exceeds 50MB limit"})
|
| 271 |
+
mime_type = _response_mime_type(response, parsed.path)
|
| 272 |
+
return data, _filename_from_url(parsed.path, mime_type), mime_type
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
async def read_image_sources(sources: list[ImageSource]) -> list[ImageInput]:
|
| 276 |
+
"""读取图片来源:上传文件直接读取,URL 下载后统一返回图片元组。"""
|
| 277 |
+
images: list[ImageInput] = []
|
| 278 |
+
for source in sources:
|
| 279 |
+
if isinstance(source, tuple):
|
| 280 |
+
images.append(source)
|
| 281 |
+
continue
|
| 282 |
+
if _is_upload(source):
|
| 283 |
+
try:
|
| 284 |
+
image_data = await source.read()
|
| 285 |
+
finally:
|
| 286 |
+
await source.close()
|
| 287 |
+
if not image_data:
|
| 288 |
+
raise HTTPException(status_code=400, detail={"error": "image file is empty"})
|
| 289 |
+
images.append((image_data, source.filename or "image.png", source.content_type or "image/png"))
|
| 290 |
+
continue
|
| 291 |
+
images.append(await run_in_threadpool(_download_image_url, source))
|
| 292 |
+
if not images:
|
| 293 |
+
raise HTTPException(status_code=400, detail={"error": "image file or image_url is required"})
|
| 294 |
+
return images
|
api/image_tasks.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, Header, HTTPException, Query, Request
|
| 4 |
+
from fastapi.concurrency import run_in_threadpool
|
| 5 |
+
from pydantic import BaseModel, Field
|
| 6 |
+
|
| 7 |
+
from api.image_inputs import parse_image_edit_request, read_image_sources
|
| 8 |
+
from api.support import require_identity, resolve_image_base_url
|
| 9 |
+
from services.content_filter import check_request
|
| 10 |
+
from services.image_task_service import image_task_service
|
| 11 |
+
from services.log_service import LoggedCall
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class ImageGenerationTaskRequest(BaseModel):
|
| 15 |
+
client_task_id: str = Field(..., min_length=1)
|
| 16 |
+
prompt: str = Field(..., min_length=1)
|
| 17 |
+
model: str = "gpt-image-2"
|
| 18 |
+
size: str | None = None
|
| 19 |
+
quality: str = "auto"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _parse_task_ids(value: str) -> list[str]:
|
| 23 |
+
return [item.strip() for item in value.split(",") if item.strip()]
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
async def filter_or_log(call: LoggedCall, text: str) -> None:
|
| 27 |
+
try:
|
| 28 |
+
await run_in_threadpool(check_request, text)
|
| 29 |
+
except HTTPException as exc:
|
| 30 |
+
call.log("调用失败", status="failed", error=str(exc.detail))
|
| 31 |
+
raise
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def create_router() -> APIRouter:
|
| 35 |
+
router = APIRouter()
|
| 36 |
+
|
| 37 |
+
@router.get("/api/image-tasks")
|
| 38 |
+
async def list_image_tasks(
|
| 39 |
+
ids: str = Query(default=""),
|
| 40 |
+
authorization: str | None = Header(default=None),
|
| 41 |
+
):
|
| 42 |
+
identity = require_identity(authorization)
|
| 43 |
+
return await run_in_threadpool(image_task_service.list_tasks, identity, _parse_task_ids(ids))
|
| 44 |
+
|
| 45 |
+
@router.post("/api/image-tasks/generations")
|
| 46 |
+
async def create_generation_task(
|
| 47 |
+
body: ImageGenerationTaskRequest,
|
| 48 |
+
request: Request,
|
| 49 |
+
authorization: str | None = Header(default=None),
|
| 50 |
+
):
|
| 51 |
+
identity = require_identity(authorization)
|
| 52 |
+
await filter_or_log(LoggedCall(identity, "/api/image-tasks/generations", body.model, "文生图任务", request_text=body.prompt), body.prompt)
|
| 53 |
+
try:
|
| 54 |
+
return await run_in_threadpool(
|
| 55 |
+
image_task_service.submit_generation,
|
| 56 |
+
identity,
|
| 57 |
+
client_task_id=body.client_task_id,
|
| 58 |
+
prompt=body.prompt,
|
| 59 |
+
model=body.model,
|
| 60 |
+
size=body.size,
|
| 61 |
+
quality=body.quality,
|
| 62 |
+
base_url=resolve_image_base_url(request),
|
| 63 |
+
)
|
| 64 |
+
except ValueError as exc:
|
| 65 |
+
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
|
| 66 |
+
|
| 67 |
+
@router.post("/api/image-tasks/edits")
|
| 68 |
+
async def create_edit_task(
|
| 69 |
+
request: Request,
|
| 70 |
+
authorization: str | None = Header(default=None),
|
| 71 |
+
):
|
| 72 |
+
identity = require_identity(authorization)
|
| 73 |
+
payload, image_sources = await parse_image_edit_request(request)
|
| 74 |
+
client_task_id = str(payload.get("client_task_id") or "").strip()
|
| 75 |
+
if not client_task_id:
|
| 76 |
+
raise HTTPException(status_code=400, detail={"error": "client_task_id is required"})
|
| 77 |
+
prompt = str(payload["prompt"])
|
| 78 |
+
model = str(payload["model"])
|
| 79 |
+
await filter_or_log(LoggedCall(identity, "/api/image-tasks/edits", model, "图生图任务", request_text=prompt), prompt)
|
| 80 |
+
images = await read_image_sources(image_sources)
|
| 81 |
+
try:
|
| 82 |
+
return await run_in_threadpool(
|
| 83 |
+
image_task_service.submit_edit,
|
| 84 |
+
identity,
|
| 85 |
+
client_task_id=client_task_id,
|
| 86 |
+
prompt=prompt,
|
| 87 |
+
model=model,
|
| 88 |
+
size=payload["size"],
|
| 89 |
+
quality=payload["quality"],
|
| 90 |
+
base_url=resolve_image_base_url(request),
|
| 91 |
+
images=images,
|
| 92 |
+
)
|
| 93 |
+
except ValueError as exc:
|
| 94 |
+
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
|
| 95 |
+
|
| 96 |
+
return router
|
api/register.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import json
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter, Header
|
| 7 |
+
from fastapi.responses import StreamingResponse
|
| 8 |
+
from pydantic import BaseModel
|
| 9 |
+
|
| 10 |
+
from api.support import require_admin
|
| 11 |
+
from services.register_service import register_service
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class RegisterConfigRequest(BaseModel):
|
| 15 |
+
mail: dict | None = None
|
| 16 |
+
proxy: str | None = None
|
| 17 |
+
total: int | None = None
|
| 18 |
+
threads: int | None = None
|
| 19 |
+
mode: str | None = None
|
| 20 |
+
target_quota: int | None = None
|
| 21 |
+
target_available: int | None = None
|
| 22 |
+
check_interval: int | None = None
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def create_router() -> APIRouter:
|
| 26 |
+
router = APIRouter()
|
| 27 |
+
|
| 28 |
+
@router.get("/api/register")
|
| 29 |
+
async def get_register_config(authorization: str | None = Header(default=None)):
|
| 30 |
+
require_admin(authorization)
|
| 31 |
+
return {"register": register_service.get()}
|
| 32 |
+
|
| 33 |
+
@router.post("/api/register")
|
| 34 |
+
async def update_register_config(body: RegisterConfigRequest, authorization: str | None = Header(default=None)):
|
| 35 |
+
require_admin(authorization)
|
| 36 |
+
return {"register": register_service.update(body.model_dump(exclude_none=True))}
|
| 37 |
+
|
| 38 |
+
@router.post("/api/register/start")
|
| 39 |
+
async def start_register(authorization: str | None = Header(default=None)):
|
| 40 |
+
require_admin(authorization)
|
| 41 |
+
return {"register": register_service.start()}
|
| 42 |
+
|
| 43 |
+
@router.post("/api/register/stop")
|
| 44 |
+
async def stop_register(authorization: str | None = Header(default=None)):
|
| 45 |
+
require_admin(authorization)
|
| 46 |
+
return {"register": register_service.stop()}
|
| 47 |
+
|
| 48 |
+
@router.post("/api/register/reset")
|
| 49 |
+
async def reset_register(authorization: str | None = Header(default=None)):
|
| 50 |
+
require_admin(authorization)
|
| 51 |
+
return {"register": register_service.reset()}
|
| 52 |
+
|
| 53 |
+
@router.get("/api/register/events")
|
| 54 |
+
async def register_events(token: str = ""):
|
| 55 |
+
require_admin(f"Bearer {token}")
|
| 56 |
+
|
| 57 |
+
async def stream():
|
| 58 |
+
last = ""
|
| 59 |
+
while True:
|
| 60 |
+
payload = json.dumps(register_service.get(), ensure_ascii=False)
|
| 61 |
+
if payload != last:
|
| 62 |
+
last = payload
|
| 63 |
+
yield f"data: {payload}\n\n"
|
| 64 |
+
await asyncio.sleep(0.5)
|
| 65 |
+
|
| 66 |
+
return StreamingResponse(stream(), media_type="text/event-stream")
|
| 67 |
+
|
| 68 |
+
return router
|
api/support.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from threading import Event, Thread
|
| 5 |
+
|
| 6 |
+
from fastapi import HTTPException, Request
|
| 7 |
+
|
| 8 |
+
from services.account_service import account_service
|
| 9 |
+
from services.auth_service import auth_service
|
| 10 |
+
from services.config import config
|
| 11 |
+
|
| 12 |
+
BASE_DIR = Path(__file__).resolve().parents[1]
|
| 13 |
+
WEB_DIST_DIR = BASE_DIR / "web_dist"
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def extract_bearer_token(authorization: str | None) -> str:
|
| 17 |
+
scheme, _, value = str(authorization or "").partition(" ")
|
| 18 |
+
if scheme.lower() != "bearer" or not value.strip():
|
| 19 |
+
return ""
|
| 20 |
+
return value.strip()
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _legacy_admin_identity(token: str) -> dict[str, object] | None:
|
| 24 |
+
auth_key = str(config.auth_key or "").strip()
|
| 25 |
+
if auth_key and token == auth_key:
|
| 26 |
+
return {"id": "admin", "name": "管理员", "role": "admin"}
|
| 27 |
+
return None
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def require_identity(authorization: str | None) -> dict[str, object]:
|
| 31 |
+
token = extract_bearer_token(authorization)
|
| 32 |
+
identity = _legacy_admin_identity(token) or auth_service.authenticate(token)
|
| 33 |
+
if identity is None:
|
| 34 |
+
raise HTTPException(status_code=401, detail={"error": "密钥无效或已失效,请重新登录"})
|
| 35 |
+
return identity
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def require_auth_key(authorization: str | None) -> None:
|
| 39 |
+
require_identity(authorization)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def require_admin(authorization: str | None) -> dict[str, object]:
|
| 43 |
+
identity = require_identity(authorization)
|
| 44 |
+
if identity.get("role") != "admin":
|
| 45 |
+
raise HTTPException(status_code=403, detail={"error": "需要管理员权限才能执行这个操作"})
|
| 46 |
+
return identity
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def resolve_image_base_url(request: Request) -> str:
|
| 50 |
+
return config.base_url or f"{request.url.scheme}://{request.headers.get('host', request.url.netloc)}"
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def raise_image_quota_error(exc: Exception) -> None:
|
| 54 |
+
message = str(exc)
|
| 55 |
+
if "no available image quota" in message.lower():
|
| 56 |
+
raise HTTPException(status_code=429, detail={"error": "no available image quota"}) from exc
|
| 57 |
+
raise HTTPException(status_code=502, detail={"error": message}) from exc
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def sanitize_cpa_pool(pool: dict | None) -> dict | None:
|
| 61 |
+
if not isinstance(pool, dict):
|
| 62 |
+
return None
|
| 63 |
+
return {key: value for key, value in pool.items() if key != "secret_key"}
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def sanitize_cpa_pools(pools: list[dict]) -> list[dict]:
|
| 67 |
+
return [sanitized for pool in pools if (sanitized := sanitize_cpa_pool(pool)) is not None]
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def sanitize_sub2api_server(server: dict | None) -> dict | None:
|
| 71 |
+
if not isinstance(server, dict):
|
| 72 |
+
return None
|
| 73 |
+
sanitized = {key: value for key, value in server.items() if key not in {"password", "api_key"}}
|
| 74 |
+
sanitized["has_api_key"] = bool(str(server.get("api_key") or "").strip())
|
| 75 |
+
return sanitized
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def sanitize_sub2api_servers(servers: list[dict]) -> list[dict]:
|
| 79 |
+
return [sanitized for server in servers if (sanitized := sanitize_sub2api_server(server)) is not None]
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def start_limited_account_watcher(stop_event: Event) -> Thread:
|
| 83 |
+
interval_seconds = config.refresh_account_interval_minute * 60
|
| 84 |
+
|
| 85 |
+
def worker() -> None:
|
| 86 |
+
while not stop_event.is_set():
|
| 87 |
+
try:
|
| 88 |
+
limited_tokens = account_service.list_limited_tokens()
|
| 89 |
+
expiring_tokens = account_service.list_expiring_access_tokens()
|
| 90 |
+
keepalive_tokens = account_service.list_refresh_token_keepalive_tokens()
|
| 91 |
+
tokens = list(dict.fromkeys([*limited_tokens, *expiring_tokens]))
|
| 92 |
+
expiring_token_set = set(expiring_tokens)
|
| 93 |
+
keepalive_tokens = [token for token in keepalive_tokens if token not in expiring_token_set]
|
| 94 |
+
if tokens:
|
| 95 |
+
print(
|
| 96 |
+
"[account-watcher] checking "
|
| 97 |
+
f"{len(limited_tokens)} limited accounts, "
|
| 98 |
+
f"{len(expiring_tokens)} expiring access tokens"
|
| 99 |
+
)
|
| 100 |
+
account_service.refresh_accounts(tokens)
|
| 101 |
+
if keepalive_tokens:
|
| 102 |
+
print(f"[account-watcher] keepalive {len(keepalive_tokens)} refresh tokens")
|
| 103 |
+
result = account_service.keepalive_refresh_tokens(keepalive_tokens)
|
| 104 |
+
if result.get("errors"):
|
| 105 |
+
print(f"[account-watcher] keepalive errors: {result['errors']}")
|
| 106 |
+
except Exception as exc:
|
| 107 |
+
print(f"[account-watcher] fail {exc}")
|
| 108 |
+
stop_event.wait(interval_seconds)
|
| 109 |
+
|
| 110 |
+
thread = Thread(target=worker, name="account-watcher", daemon=True)
|
| 111 |
+
thread.start()
|
| 112 |
+
return thread
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def resolve_web_asset(requested_path: str) -> Path | None:
|
| 116 |
+
if not WEB_DIST_DIR.exists():
|
| 117 |
+
return None
|
| 118 |
+
clean_path = requested_path.strip("/")
|
| 119 |
+
base_dir = WEB_DIST_DIR.resolve()
|
| 120 |
+
candidates = [base_dir / "index.html"] if not clean_path else [
|
| 121 |
+
base_dir / Path(clean_path),
|
| 122 |
+
base_dir / clean_path / "index.html",
|
| 123 |
+
base_dir / f"{clean_path}.html",
|
| 124 |
+
]
|
| 125 |
+
for candidate in candidates:
|
| 126 |
+
try:
|
| 127 |
+
candidate.resolve().relative_to(base_dir)
|
| 128 |
+
except ValueError:
|
| 129 |
+
continue
|
| 130 |
+
if candidate.is_file():
|
| 131 |
+
return candidate
|
| 132 |
+
return None
|
api/system.py
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from urllib.parse import quote
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter, Header, HTTPException, Query, Request
|
| 6 |
+
from fastapi.concurrency import run_in_threadpool
|
| 7 |
+
from fastapi.responses import HTMLResponse, Response, StreamingResponse
|
| 8 |
+
from pydantic import BaseModel, ConfigDict
|
| 9 |
+
|
| 10 |
+
from api.support import require_admin, require_identity, resolve_image_base_url
|
| 11 |
+
from services.backup_service import BackupError, backup_service
|
| 12 |
+
from services.config import config
|
| 13 |
+
from services.image_service import (
|
| 14 |
+
compress_images,
|
| 15 |
+
delete_images,
|
| 16 |
+
delete_to_target,
|
| 17 |
+
download_images_zip,
|
| 18 |
+
get_image_download_response,
|
| 19 |
+
get_image_response,
|
| 20 |
+
get_thumbnail_response,
|
| 21 |
+
list_images,
|
| 22 |
+
storage_stats,
|
| 23 |
+
)
|
| 24 |
+
from services.image_storage_service import ImageStorageError, image_storage_service
|
| 25 |
+
from services.image_tags_service import delete_tag, get_all_tags, set_tags
|
| 26 |
+
from services.log_service import log_service
|
| 27 |
+
from services.proxy_service import test_proxy
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class SettingsUpdateRequest(BaseModel):
|
| 31 |
+
model_config = ConfigDict(extra="allow")
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class ProxyTestRequest(BaseModel):
|
| 35 |
+
url: str = ""
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class ImageDeleteRequest(BaseModel):
|
| 39 |
+
paths: list[str] = []
|
| 40 |
+
start_date: str = ""
|
| 41 |
+
end_date: str = ""
|
| 42 |
+
all_matching: bool = False
|
| 43 |
+
|
| 44 |
+
class ImageDownloadRequest(BaseModel):
|
| 45 |
+
paths: list[str]
|
| 46 |
+
|
| 47 |
+
class ImageTagsRequest(BaseModel):
|
| 48 |
+
path: str
|
| 49 |
+
tags: list[str]
|
| 50 |
+
|
| 51 |
+
class LogDeleteRequest(BaseModel):
|
| 52 |
+
ids: list[str] = []
|
| 53 |
+
class BackupDeleteRequest(BaseModel):
|
| 54 |
+
key: str = ""
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def create_router(app_version: str) -> APIRouter:
|
| 58 |
+
router = APIRouter()
|
| 59 |
+
|
| 60 |
+
@router.post("/auth/login")
|
| 61 |
+
async def login(authorization: str | None = Header(default=None)):
|
| 62 |
+
identity = require_identity(authorization)
|
| 63 |
+
return {
|
| 64 |
+
"ok": True,
|
| 65 |
+
"version": app_version,
|
| 66 |
+
"role": identity.get("role"),
|
| 67 |
+
"subject_id": identity.get("id"),
|
| 68 |
+
"name": identity.get("name"),
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
@router.get("/version")
|
| 72 |
+
async def get_version():
|
| 73 |
+
return {"version": app_version}
|
| 74 |
+
|
| 75 |
+
@router.get("/api/settings")
|
| 76 |
+
async def get_settings(authorization: str | None = Header(default=None)):
|
| 77 |
+
require_admin(authorization)
|
| 78 |
+
return {"config": config.get()}
|
| 79 |
+
|
| 80 |
+
@router.post("/api/settings")
|
| 81 |
+
async def save_settings(body: SettingsUpdateRequest, authorization: str | None = Header(default=None)):
|
| 82 |
+
require_admin(authorization)
|
| 83 |
+
try:
|
| 84 |
+
return {"config": config.update(body.model_dump(mode="python"))}
|
| 85 |
+
except ValueError as exc:
|
| 86 |
+
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
|
| 87 |
+
|
| 88 |
+
@router.get("/api/images")
|
| 89 |
+
async def get_images(request: Request, start_date: str = "", end_date: str = "", authorization: str | None = Header(default=None)):
|
| 90 |
+
require_admin(authorization)
|
| 91 |
+
return list_images(resolve_image_base_url(request), start_date=start_date.strip(), end_date=end_date.strip())
|
| 92 |
+
|
| 93 |
+
@router.get("/images/{image_path:path}", include_in_schema=False)
|
| 94 |
+
async def get_image(image_path: str):
|
| 95 |
+
return get_image_response(image_path)
|
| 96 |
+
|
| 97 |
+
@router.get("/image-thumbnails/{image_path:path}", include_in_schema=False)
|
| 98 |
+
async def get_image_thumbnail(image_path: str):
|
| 99 |
+
return get_thumbnail_response(image_path)
|
| 100 |
+
|
| 101 |
+
@router.post("/api/images/delete")
|
| 102 |
+
async def delete_images_endpoint(body: ImageDeleteRequest, authorization: str | None = Header(default=None)):
|
| 103 |
+
require_admin(authorization)
|
| 104 |
+
return delete_images(body.paths, start_date=body.start_date.strip(), end_date=body.end_date.strip(), all_matching=body.all_matching)
|
| 105 |
+
|
| 106 |
+
@router.post("/api/images/download")
|
| 107 |
+
async def download_images_endpoint(body: ImageDownloadRequest, authorization: str | None = Header(default=None)):
|
| 108 |
+
require_admin(authorization)
|
| 109 |
+
buf = download_images_zip(body.paths)
|
| 110 |
+
return StreamingResponse(
|
| 111 |
+
buf,
|
| 112 |
+
media_type="application/zip",
|
| 113 |
+
headers={"Content-Disposition": 'attachment; filename="images.zip"'},
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
@router.get("/api/images/download/{image_path:path}")
|
| 117 |
+
async def download_single_image_endpoint(image_path: str, authorization: str | None = Header(default=None)):
|
| 118 |
+
require_admin(authorization)
|
| 119 |
+
return get_image_download_response(image_path)
|
| 120 |
+
|
| 121 |
+
@router.get("/api/logs")
|
| 122 |
+
async def get_logs(type: str = "", start_date: str = "", end_date: str = "", authorization: str | None = Header(default=None)):
|
| 123 |
+
require_admin(authorization)
|
| 124 |
+
return {"items": log_service.list(type=type.strip(), start_date=start_date.strip(), end_date=end_date.strip())}
|
| 125 |
+
|
| 126 |
+
@router.post("/api/logs/delete")
|
| 127 |
+
async def delete_logs(body: LogDeleteRequest, authorization: str | None = Header(default=None)):
|
| 128 |
+
require_admin(authorization)
|
| 129 |
+
return log_service.delete(body.ids)
|
| 130 |
+
|
| 131 |
+
@router.post("/api/proxy/test")
|
| 132 |
+
async def test_proxy_endpoint(body: ProxyTestRequest, authorization: str | None = Header(default=None)):
|
| 133 |
+
require_admin(authorization)
|
| 134 |
+
candidate = (body.url or "").strip() or config.get_proxy_settings()
|
| 135 |
+
if not candidate:
|
| 136 |
+
raise HTTPException(status_code=400, detail={"error": "proxy url is required"})
|
| 137 |
+
return {"result": await run_in_threadpool(test_proxy, candidate)}
|
| 138 |
+
|
| 139 |
+
@router.get("/api/storage/info")
|
| 140 |
+
async def get_storage_info(authorization: str | None = Header(default=None)):
|
| 141 |
+
require_admin(authorization)
|
| 142 |
+
storage = config.get_storage_backend()
|
| 143 |
+
return {
|
| 144 |
+
"backend": storage.get_backend_info(),
|
| 145 |
+
"health": storage.health_check(),
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
@router.post("/api/backup/test")
|
| 149 |
+
async def test_backup_connection(authorization: str | None = Header(default=None)):
|
| 150 |
+
require_admin(authorization)
|
| 151 |
+
try:
|
| 152 |
+
return {"result": await run_in_threadpool(backup_service.test_connection)}
|
| 153 |
+
except BackupError as exc:
|
| 154 |
+
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
|
| 155 |
+
|
| 156 |
+
@router.post("/api/image-storage/test")
|
| 157 |
+
async def test_image_storage_endpoint(authorization: str | None = Header(default=None)):
|
| 158 |
+
require_admin(authorization)
|
| 159 |
+
return {"result": await run_in_threadpool(image_storage_service.test_webdav)}
|
| 160 |
+
|
| 161 |
+
@router.post("/api/image-storage/sync")
|
| 162 |
+
async def sync_image_storage_endpoint(authorization: str | None = Header(default=None)):
|
| 163 |
+
require_admin(authorization)
|
| 164 |
+
try:
|
| 165 |
+
return {"result": await run_in_threadpool(image_storage_service.sync_all)}
|
| 166 |
+
except ImageStorageError as exc:
|
| 167 |
+
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
|
| 168 |
+
|
| 169 |
+
@router.get("/api/backups")
|
| 170 |
+
async def get_backups(authorization: str | None = Header(default=None)):
|
| 171 |
+
require_admin(authorization)
|
| 172 |
+
try:
|
| 173 |
+
return {
|
| 174 |
+
"items": await run_in_threadpool(backup_service.list_backups),
|
| 175 |
+
"state": backup_service.get_status(),
|
| 176 |
+
"settings": backup_service.get_settings(),
|
| 177 |
+
}
|
| 178 |
+
except BackupError as exc:
|
| 179 |
+
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
|
| 180 |
+
|
| 181 |
+
@router.post("/api/backups/run")
|
| 182 |
+
async def run_backup_endpoint(authorization: str | None = Header(default=None)):
|
| 183 |
+
require_admin(authorization)
|
| 184 |
+
try:
|
| 185 |
+
return {"result": await run_in_threadpool(backup_service.run_backup)}
|
| 186 |
+
except BackupError as exc:
|
| 187 |
+
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
|
| 188 |
+
|
| 189 |
+
@router.post("/api/backups/delete")
|
| 190 |
+
async def delete_backup_endpoint(body: BackupDeleteRequest, authorization: str | None = Header(default=None)):
|
| 191 |
+
require_admin(authorization)
|
| 192 |
+
try:
|
| 193 |
+
await run_in_threadpool(backup_service.delete_backup, body.key)
|
| 194 |
+
return {"ok": True}
|
| 195 |
+
except BackupError as exc:
|
| 196 |
+
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
|
| 197 |
+
|
| 198 |
+
@router.get("/api/backups/detail")
|
| 199 |
+
async def get_backup_detail(key: str = "", authorization: str | None = Header(default=None)):
|
| 200 |
+
require_admin(authorization)
|
| 201 |
+
try:
|
| 202 |
+
return {"item": await run_in_threadpool(backup_service.get_backup_detail, key)}
|
| 203 |
+
except BackupError as exc:
|
| 204 |
+
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
|
| 205 |
+
|
| 206 |
+
@router.get("/api/backups/download")
|
| 207 |
+
async def download_backup_endpoint(key: str = "", authorization: str | None = Header(default=None)):
|
| 208 |
+
require_admin(authorization)
|
| 209 |
+
try:
|
| 210 |
+
item = await run_in_threadpool(backup_service.download_backup, key)
|
| 211 |
+
except BackupError as exc:
|
| 212 |
+
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
|
| 213 |
+
filename = str(item.get("name") or "backup.bin")
|
| 214 |
+
quoted = quote(filename)
|
| 215 |
+
headers = {
|
| 216 |
+
"Content-Disposition": f"attachment; filename*=UTF-8''{quoted}",
|
| 217 |
+
"Content-Length": str(int(item.get("size") or 0)),
|
| 218 |
+
}
|
| 219 |
+
return Response(
|
| 220 |
+
content=bytes(item.get("payload") or b""),
|
| 221 |
+
media_type=str(item.get("content_type") or "application/octet-stream"),
|
| 222 |
+
headers=headers,
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
@router.get("/api/images/tags")
|
| 227 |
+
async def list_image_tags(authorization: str | None = Header(default=None)):
|
| 228 |
+
require_admin(authorization)
|
| 229 |
+
return {"tags": get_all_tags()}
|
| 230 |
+
|
| 231 |
+
@router.post("/api/images/tags")
|
| 232 |
+
async def update_image_tags(body: ImageTagsRequest, authorization: str | None = Header(default=None)):
|
| 233 |
+
require_admin(authorization)
|
| 234 |
+
rel = body.path.strip().lstrip("/")
|
| 235 |
+
if not rel:
|
| 236 |
+
raise HTTPException(status_code=400, detail={"error": "path is required"})
|
| 237 |
+
tags = set_tags(rel, body.tags)
|
| 238 |
+
return {"ok": True, "tags": tags}
|
| 239 |
+
|
| 240 |
+
@router.delete("/api/images/tags/{tag}")
|
| 241 |
+
async def delete_image_tag(tag: str, authorization: str | None = Header(default=None)):
|
| 242 |
+
require_admin(authorization)
|
| 243 |
+
count = delete_tag(tag)
|
| 244 |
+
return {"ok": True, "removed_from": count}
|
| 245 |
+
|
| 246 |
+
@router.get("/api/images/storage")
|
| 247 |
+
async def get_image_storage(authorization: str | None = Header(default=None)):
|
| 248 |
+
require_admin(authorization)
|
| 249 |
+
return storage_stats()
|
| 250 |
+
|
| 251 |
+
@router.post("/api/images/storage/compress")
|
| 252 |
+
async def compress_all_images(authorization: str | None = Header(default=None)):
|
| 253 |
+
require_admin(authorization)
|
| 254 |
+
return await run_in_threadpool(compress_images)
|
| 255 |
+
|
| 256 |
+
@router.post("/api/images/storage/cleanup-to-target")
|
| 257 |
+
async def cleanup_to_target(
|
| 258 |
+
target_free_mb: int = 500,
|
| 259 |
+
dry_run: bool = False,
|
| 260 |
+
authorization: str | None = Header(default=None),
|
| 261 |
+
):
|
| 262 |
+
require_admin(authorization)
|
| 263 |
+
return await run_in_threadpool(delete_to_target, target_free_mb, dry_run)
|
| 264 |
+
|
| 265 |
+
@router.get("/health", response_model=None)
|
| 266 |
+
async def health_dashboard(format: str = Query(default="html")):
|
| 267 |
+
from services.account_service import account_service as acct_svc
|
| 268 |
+
stats = acct_svc.get_stats()
|
| 269 |
+
storage = config.get_storage_backend()
|
| 270 |
+
storage_health = storage.health_check()
|
| 271 |
+
healthy = stats["active"] > 0 or stats["unlimited_quota_count"] > 0
|
| 272 |
+
|
| 273 |
+
stats_json = {
|
| 274 |
+
"status": "ok" if healthy else "degraded",
|
| 275 |
+
"healthy": healthy,
|
| 276 |
+
"version": app_version,
|
| 277 |
+
"storage": {"backend": storage.get_backend_info(), "health": storage_health},
|
| 278 |
+
"accounts": stats,
|
| 279 |
+
}
|
| 280 |
+
if format == "json":
|
| 281 |
+
return stats_json
|
| 282 |
+
return HTMLResponse(f"""<!DOCTYPE html>
|
| 283 |
+
<html lang="zh">
|
| 284 |
+
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
| 285 |
+
<title>号池健康监控 - chatgpt2api</title>
|
| 286 |
+
<style>
|
| 287 |
+
*{{margin:0;padding:0;box-sizing:border-box}}
|
| 288 |
+
body{{font-family:system-ui,-apple-system,sans-serif;background:#0f1117;color:#e2e8f0;min-height:100vh}}
|
| 289 |
+
.header{{background:#1a1d27;border-bottom:1px solid #2a2d3a;padding:16px 24px;display:flex;justify-content:space-between;align-items:center}}
|
| 290 |
+
.header h1{{font-size:20px}}
|
| 291 |
+
.status-dot{{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:8px}}
|
| 292 |
+
.status-ok{{background:#22c55e;box-shadow:0 0 8px #22c55e88}}
|
| 293 |
+
.status-degraded{{background:#f59e0b;box-shadow:0 0 8px #f59e0b88}}
|
| 294 |
+
.container{{max-width:960px;margin:0 auto;padding:24px}}
|
| 295 |
+
.cards{{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:24px}}
|
| 296 |
+
.card{{background:#1a1d27;border:1px solid #2a2d3a;border-radius:10px;padding:16px}}
|
| 297 |
+
.card .value{{font-size:28px;font-weight:700;margin:4px 0}}
|
| 298 |
+
.card .label{{font-size:13px;color:#94a3b8}}
|
| 299 |
+
.green{{color:#22c55e}}.yellow{{color:#f59e0b}}.red{{color:#ef4444}}.blue{{color:#6c63ff}}
|
| 300 |
+
table{{width:100%;border-collapse:collapse;background:#1a1d27;border:1px solid #2a2d3a;border-radius:10px;overflow:hidden}}
|
| 301 |
+
th{{background:#242836;font-weight:600;text-align:left;padding:10px 12px;font-size:12px;color:#94a3b8;text-transform:uppercase}}
|
| 302 |
+
td{{padding:8px 12px;border-top:1px solid #2a2d3a;font-size:14px}}tr:hover td{{background:rgba(108,99,255,.05)}}
|
| 303 |
+
.api-url{{font-family:monospace;font-size:12px;color:#6c63ff}}
|
| 304 |
+
.refresh{{font-size:12px;color:#64748b;text-align:center;margin-top:24px}}
|
| 305 |
+
</style>
|
| 306 |
+
<meta http-equiv="refresh" content="30">
|
| 307 |
+
</head>
|
| 308 |
+
<body>
|
| 309 |
+
<div class="header">
|
| 310 |
+
<h1><span class="status-dot {'status-ok' if healthy else 'status-degraded'}"></span>号池健康监控</h1>
|
| 311 |
+
<div style="font-size:13px;color:#94a3b8">v{app_version} · 30s 自动刷新</div>
|
| 312 |
+
</div>
|
| 313 |
+
<div class="container">
|
| 314 |
+
<div class="cards">
|
| 315 |
+
<div class="card"><div class="label">号池状态</div><div class="value {'green' if healthy else 'yellow'}">{'正常' if healthy else '异常'}</div></div>
|
| 316 |
+
<div class="card"><div class="label">当前账号</div><div class="value blue">{stats['total']}</div></div>
|
| 317 |
+
<div class="card"><div class="label">累计入库</div><div class="value">{stats['cumulative_total']}</div></div>
|
| 318 |
+
<div class="card"><div class="label">可用账号</div><div class="value green">{stats['active']}</div></div>
|
| 319 |
+
<div class="card"><div class="label">无限额</div><div class="value">{stats['unlimited_quota_count']}</div></div>
|
| 320 |
+
<div class="card"><div class="label">剩余额度</div><div class="value">{stats['total_quota']}</div></div>
|
| 321 |
+
<div class="card"><div class="label">限流</div><div class="value yellow">{stats['limited']}</div></div>
|
| 322 |
+
<div class="card"><div class="label">异常</div><div class="value red">{stats['abnormal']}</div></div>
|
| 323 |
+
<div class="card"><div class="label">禁用</div><div class="value">{stats['disabled']}</div></div>
|
| 324 |
+
<div class="card"><div class="label">成功/失败</div><div class="value">{stats['total_success']}<span style="font-size:18px;color:#94a3b8">/</span><span class="red">{stats['total_fail']}</span></div></div>
|
| 325 |
+
</div>
|
| 326 |
+
<h2 style="margin-bottom:12px;font-size:16px">账号类型分布</h2>
|
| 327 |
+
<table>
|
| 328 |
+
<tr><th>类型</th><th>数量</th></tr>
|
| 329 |
+
{''.join(f'<tr><td>{t}</td><td>{c}</td></tr>' for t,c in sorted(stats['by_type'].items()))}
|
| 330 |
+
</table>
|
| 331 |
+
<div class="refresh">JSON: <span class="api-url">/health?format=json</span></div>
|
| 332 |
+
</div></body></html>""")
|
| 333 |
+
|
| 334 |
+
return router
|
config.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"auth-key": "chatgpt2api",
|
| 3 |
+
"refresh_account_interval_minute": 60,
|
| 4 |
+
"image_retention_days": 15,
|
| 5 |
+
"image_poll_timeout_secs": 120,
|
| 6 |
+
"auto_remove_rate_limited_accounts": false,
|
| 7 |
+
"auto_remove_invalid_accounts": true,
|
| 8 |
+
"log_levels": [
|
| 9 |
+
"debug",
|
| 10 |
+
"error",
|
| 11 |
+
"info",
|
| 12 |
+
"warning"
|
| 13 |
+
],
|
| 14 |
+
"proxy": "",
|
| 15 |
+
"base_url": "",
|
| 16 |
+
"sensitive_words": [],
|
| 17 |
+
"global_system_prompt": "",
|
| 18 |
+
"ai_review": {
|
| 19 |
+
"enabled": false,
|
| 20 |
+
"base_url": "",
|
| 21 |
+
"api_key": "",
|
| 22 |
+
"model": "",
|
| 23 |
+
"prompt": ""
|
| 24 |
+
},
|
| 25 |
+
"backup": {
|
| 26 |
+
"enabled": false,
|
| 27 |
+
"provider": "cloudflare_r2",
|
| 28 |
+
"account_id": "",
|
| 29 |
+
"access_key_id": "",
|
| 30 |
+
"secret_access_key": "",
|
| 31 |
+
"bucket": "",
|
| 32 |
+
"prefix": "backups",
|
| 33 |
+
"interval_minutes": 1440,
|
| 34 |
+
"rotation_keep": 10,
|
| 35 |
+
"encrypt": false,
|
| 36 |
+
"passphrase": "",
|
| 37 |
+
"include": {
|
| 38 |
+
"config": true,
|
| 39 |
+
"register": true,
|
| 40 |
+
"cpa": true,
|
| 41 |
+
"sub2api": true,
|
| 42 |
+
"logs": true,
|
| 43 |
+
"image_tasks": true,
|
| 44 |
+
"accounts_snapshot": true,
|
| 45 |
+
"auth_keys_snapshot": true,
|
| 46 |
+
"images": false
|
| 47 |
+
}
|
| 48 |
+
},
|
| 49 |
+
"image_account_concurrency": 3
|
| 50 |
+
}
|
main.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import uvicorn
|
| 4 |
+
from api import create_app
|
| 5 |
+
|
| 6 |
+
app = create_app()
|
| 7 |
+
|
| 8 |
+
if __name__ == "__main__":
|
| 9 |
+
uvicorn.run(app, access_log=False, log_level="info")
|
pyproject.toml
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "chatgpt2api"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "Add your description here"
|
| 5 |
+
readme = "README.md"
|
| 6 |
+
requires-python = ">=3.13"
|
| 7 |
+
dependencies = [
|
| 8 |
+
"curl-cffi>=0.15.0",
|
| 9 |
+
"fastapi>=0.136.0",
|
| 10 |
+
"pillow>=12.2.0",
|
| 11 |
+
"pybase64>=1.4.3",
|
| 12 |
+
"python-multipart>=0.0.26",
|
| 13 |
+
"tiktoken>=0.12.0",
|
| 14 |
+
"uvicorn>=0.44.0",
|
| 15 |
+
"sqlalchemy>=2.0.0",
|
| 16 |
+
"psycopg2-binary>=2.9.0",
|
| 17 |
+
"gitpython>=3.1.0",
|
| 18 |
+
]
|
| 19 |
+
|
| 20 |
+
[dependency-groups]
|
| 21 |
+
dev = [
|
| 22 |
+
"httpx>=0.28.1",
|
| 23 |
+
]
|
| 24 |
+
|
| 25 |
+
[[tool.uv.index]]
|
| 26 |
+
url = "https://mirrors.aliyun.com/pypi/simple"
|
| 27 |
+
default = true
|
scripts/migrate_storage.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
存储后端数据迁移脚本
|
| 4 |
+
|
| 5 |
+
用法:
|
| 6 |
+
python scripts/migrate_storage.py --from json --to postgres
|
| 7 |
+
python scripts/migrate_storage.py --from postgres --to git
|
| 8 |
+
python scripts/migrate_storage.py --export accounts.json
|
| 9 |
+
python scripts/migrate_storage.py --import accounts.json
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import argparse
|
| 13 |
+
import json
|
| 14 |
+
import os
|
| 15 |
+
import sys
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
# 添加项目根目录到 Python 路径
|
| 19 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 20 |
+
|
| 21 |
+
DATA_DIR = Path(__file__).resolve().parents[1] / "data"
|
| 22 |
+
|
| 23 |
+
from services.storage.factory import create_storage_backend
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def export_to_json(output_file: str):
|
| 27 |
+
"""导出当前存储后端的数据到 JSON 文件"""
|
| 28 |
+
print(f"[migrate] Exporting data to {output_file}")
|
| 29 |
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
| 30 |
+
storage = create_storage_backend(DATA_DIR)
|
| 31 |
+
accounts = storage.load_accounts()
|
| 32 |
+
|
| 33 |
+
output_path = Path(output_file)
|
| 34 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 35 |
+
output_path.write_text(
|
| 36 |
+
json.dumps(accounts, ensure_ascii=False, indent=2) + "\n",
|
| 37 |
+
encoding="utf-8",
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
print(f"[migrate] Exported {len(accounts)} accounts to {output_file}")
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def import_from_json(input_file: str):
|
| 44 |
+
"""从 JSON 文件导入数据到当前存储后端"""
|
| 45 |
+
print(f"[migrate] Importing data from {input_file}")
|
| 46 |
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
| 47 |
+
input_path = Path(input_file)
|
| 48 |
+
if not input_path.exists():
|
| 49 |
+
print(f"[migrate] Error: File not found: {input_file}")
|
| 50 |
+
sys.exit(1)
|
| 51 |
+
|
| 52 |
+
try:
|
| 53 |
+
accounts = json.loads(input_path.read_text(encoding="utf-8"))
|
| 54 |
+
if not isinstance(accounts, list):
|
| 55 |
+
print(f"[migrate] Error: Invalid JSON format, expected array")
|
| 56 |
+
sys.exit(1)
|
| 57 |
+
except json.JSONDecodeError as e:
|
| 58 |
+
print(f"[migrate] Error: Invalid JSON: {e}")
|
| 59 |
+
sys.exit(1)
|
| 60 |
+
|
| 61 |
+
storage = create_storage_backend(DATA_DIR)
|
| 62 |
+
storage.save_accounts(accounts)
|
| 63 |
+
|
| 64 |
+
print(f"[migrate] Imported {len(accounts)} accounts")
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def migrate_data(from_backend: str, to_backend: str):
|
| 68 |
+
"""从一个存储后端迁移到另一个"""
|
| 69 |
+
print(f"[migrate] Migrating from {from_backend} to {to_backend}")
|
| 70 |
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
| 71 |
+
# 保存原始环境变量
|
| 72 |
+
original_backend = os.environ.get("STORAGE_BACKEND")
|
| 73 |
+
|
| 74 |
+
try:
|
| 75 |
+
# 从源后端读取数据
|
| 76 |
+
os.environ["STORAGE_BACKEND"] = from_backend
|
| 77 |
+
from_storage = create_storage_backend(DATA_DIR)
|
| 78 |
+
accounts = from_storage.load_accounts()
|
| 79 |
+
print(f"[migrate] Loaded {len(accounts)} accounts from {from_backend}")
|
| 80 |
+
|
| 81 |
+
# 写入目标后端
|
| 82 |
+
os.environ["STORAGE_BACKEND"] = to_backend
|
| 83 |
+
to_storage = create_storage_backend(DATA_DIR)
|
| 84 |
+
to_storage.save_accounts(accounts)
|
| 85 |
+
print(f"[migrate] Saved {len(accounts)} accounts to {to_backend}")
|
| 86 |
+
|
| 87 |
+
print(f"[migrate] Migration completed successfully!")
|
| 88 |
+
|
| 89 |
+
finally:
|
| 90 |
+
# 恢复原始环境变量
|
| 91 |
+
if original_backend:
|
| 92 |
+
os.environ["STORAGE_BACKEND"] = original_backend
|
| 93 |
+
elif "STORAGE_BACKEND" in os.environ:
|
| 94 |
+
del os.environ["STORAGE_BACKEND"]
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def main():
|
| 98 |
+
parser = argparse.ArgumentParser(
|
| 99 |
+
description="ChatGPT2API 存储后端数据迁移工具",
|
| 100 |
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
| 101 |
+
epilog="""
|
| 102 |
+
示例:
|
| 103 |
+
# 从 JSON 迁移到 PostgreSQL
|
| 104 |
+
python scripts/migrate_storage.py --from json --to postgres
|
| 105 |
+
|
| 106 |
+
# 从 PostgreSQL 迁移到 Git
|
| 107 |
+
python scripts/migrate_storage.py --from postgres --to git
|
| 108 |
+
|
| 109 |
+
# 导出当前数据到 JSON 文件
|
| 110 |
+
python scripts/migrate_storage.py --export backup.json
|
| 111 |
+
|
| 112 |
+
# 从 JSON 文件导入数据
|
| 113 |
+
python scripts/migrate_storage.py --import backup.json
|
| 114 |
+
|
| 115 |
+
环境变量:
|
| 116 |
+
STORAGE_BACKEND - 存储后端类型 (json, sqlite, postgres, git)
|
| 117 |
+
DATABASE_URL - 数据库连接字符串
|
| 118 |
+
GIT_REPO_URL - Git 仓库地址
|
| 119 |
+
GIT_TOKEN - Git 访问令牌
|
| 120 |
+
"""
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
parser.add_argument(
|
| 124 |
+
"--from",
|
| 125 |
+
dest="from_backend",
|
| 126 |
+
choices=["json", "sqlite", "postgres", "git"],
|
| 127 |
+
help="源存储后端",
|
| 128 |
+
)
|
| 129 |
+
parser.add_argument(
|
| 130 |
+
"--to",
|
| 131 |
+
dest="to_backend",
|
| 132 |
+
choices=["json", "sqlite", "postgres", "git"],
|
| 133 |
+
help="目标存储后端",
|
| 134 |
+
)
|
| 135 |
+
parser.add_argument(
|
| 136 |
+
"--export",
|
| 137 |
+
dest="export_file",
|
| 138 |
+
metavar="FILE",
|
| 139 |
+
help="导出数据到 JSON 文件",
|
| 140 |
+
)
|
| 141 |
+
parser.add_argument(
|
| 142 |
+
"--import",
|
| 143 |
+
dest="import_file",
|
| 144 |
+
metavar="FILE",
|
| 145 |
+
help="从 JSON 文件导入数据",
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
args = parser.parse_args()
|
| 149 |
+
|
| 150 |
+
# 检查参数
|
| 151 |
+
if args.from_backend and args.to_backend:
|
| 152 |
+
migrate_data(args.from_backend, args.to_backend)
|
| 153 |
+
elif args.export_file:
|
| 154 |
+
export_to_json(args.export_file)
|
| 155 |
+
elif args.import_file:
|
| 156 |
+
import_from_json(args.import_file)
|
| 157 |
+
else:
|
| 158 |
+
parser.print_help()
|
| 159 |
+
sys.exit(1)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
if __name__ == "__main__":
|
| 163 |
+
main()
|
scripts/test_storage.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
存储后端测试脚本
|
| 4 |
+
|
| 5 |
+
用法:
|
| 6 |
+
python scripts/test_storage.py
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
import sys
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
# 添加项目根目录到 Python 路径
|
| 14 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 15 |
+
|
| 16 |
+
DATA_DIR = Path(__file__).resolve().parents[1] / "data"
|
| 17 |
+
|
| 18 |
+
from services.storage.factory import create_storage_backend
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def test_storage():
|
| 22 |
+
"""测试当前配置的存储后端"""
|
| 23 |
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
| 24 |
+
print("=" * 60)
|
| 25 |
+
print("ChatGPT2API 存储后端测试")
|
| 26 |
+
print("=" * 60)
|
| 27 |
+
|
| 28 |
+
# 显示当前配置
|
| 29 |
+
backend_type = os.getenv("STORAGE_BACKEND", "json")
|
| 30 |
+
print(f"\n当前存储后端: {backend_type}")
|
| 31 |
+
|
| 32 |
+
if backend_type in ("sqlite", "postgres", "postgresql", "mysql", "database"):
|
| 33 |
+
database_url = os.getenv("DATABASE_URL", "")
|
| 34 |
+
if database_url:
|
| 35 |
+
# 隐藏密码
|
| 36 |
+
if "://" in database_url and "@" in database_url:
|
| 37 |
+
protocol, rest = database_url.split("://", 1)
|
| 38 |
+
if "@" in rest:
|
| 39 |
+
credentials, host = rest.split("@", 1)
|
| 40 |
+
if ":" in credentials:
|
| 41 |
+
username, _ = credentials.split(":", 1)
|
| 42 |
+
database_url = f"{protocol}://{username}:****@{host}"
|
| 43 |
+
print(f"数据库连接: {database_url}")
|
| 44 |
+
else:
|
| 45 |
+
print(f"数据库连接: 本地 SQLite (data/accounts.db)")
|
| 46 |
+
|
| 47 |
+
elif backend_type == "git":
|
| 48 |
+
repo_url = os.getenv("GIT_REPO_URL", "")
|
| 49 |
+
branch = os.getenv("GIT_BRANCH", "main")
|
| 50 |
+
file_path = os.getenv("GIT_FILE_PATH", "accounts.json")
|
| 51 |
+
print(f"Git 仓库: {repo_url}")
|
| 52 |
+
print(f"Git 分支: {branch}")
|
| 53 |
+
print(f"文件路径: {file_path}")
|
| 54 |
+
|
| 55 |
+
print("\n" + "=" * 60)
|
| 56 |
+
|
| 57 |
+
try:
|
| 58 |
+
# 创建存储后端
|
| 59 |
+
print("\n[1/5] 创建存储后端...")
|
| 60 |
+
storage = create_storage_backend(DATA_DIR)
|
| 61 |
+
print("✅ 存储后端创建成功")
|
| 62 |
+
|
| 63 |
+
# 获取后端信息
|
| 64 |
+
print("\n[2/5] 获取后端信息...")
|
| 65 |
+
info = storage.get_backend_info()
|
| 66 |
+
print(f"✅ 后端类型: {info.get('type')}")
|
| 67 |
+
print(f" 描述: {info.get('description')}")
|
| 68 |
+
for key, value in info.items():
|
| 69 |
+
if key not in ('type', 'description'):
|
| 70 |
+
print(f" {key}: {value}")
|
| 71 |
+
|
| 72 |
+
# 健康检查
|
| 73 |
+
print("\n[3/5] 执行健康检查...")
|
| 74 |
+
health = storage.health_check()
|
| 75 |
+
status = health.get("status")
|
| 76 |
+
if status == "healthy":
|
| 77 |
+
print(f"✅ 健康状态: {status}")
|
| 78 |
+
else:
|
| 79 |
+
print(f"❌ 健康状态: {status}")
|
| 80 |
+
print(f" 错误: {health.get('error')}")
|
| 81 |
+
return False
|
| 82 |
+
|
| 83 |
+
# 读取数据
|
| 84 |
+
print("\n[4/5] 读取账号数据...")
|
| 85 |
+
accounts = storage.load_accounts()
|
| 86 |
+
print(f"✅ 成功读取 {len(accounts)} 个账号")
|
| 87 |
+
|
| 88 |
+
# 写入测试(可选)
|
| 89 |
+
print("\n[5/5] 测试写入功能...")
|
| 90 |
+
test_account = {
|
| 91 |
+
"access_token": "test_token_" + str(os.getpid()),
|
| 92 |
+
"type": "Free",
|
| 93 |
+
"status": "测试",
|
| 94 |
+
"quota": 0,
|
| 95 |
+
"email": "test@example.com",
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
# 添加测试账号
|
| 99 |
+
test_accounts = accounts + [test_account]
|
| 100 |
+
storage.save_accounts(test_accounts)
|
| 101 |
+
print("✅ 写入测试账号成功")
|
| 102 |
+
|
| 103 |
+
# 验证写入
|
| 104 |
+
reloaded = storage.load_accounts()
|
| 105 |
+
if len(reloaded) == len(test_accounts):
|
| 106 |
+
print("✅ 验证写入成功")
|
| 107 |
+
else:
|
| 108 |
+
print(f"❌ 验证失败: 期望 {len(test_accounts)} 个账号,实际 {len(reloaded)} 个")
|
| 109 |
+
return False
|
| 110 |
+
|
| 111 |
+
# 恢复原始数据
|
| 112 |
+
storage.save_accounts(accounts)
|
| 113 |
+
print("✅ 恢复原始数据")
|
| 114 |
+
|
| 115 |
+
print("\n" + "=" * 60)
|
| 116 |
+
print("✅ 所有测试通过!")
|
| 117 |
+
print("=" * 60)
|
| 118 |
+
return True
|
| 119 |
+
|
| 120 |
+
except Exception as e:
|
| 121 |
+
print(f"\n❌ 测试失败: {e}")
|
| 122 |
+
import traceback
|
| 123 |
+
traceback.print_exc()
|
| 124 |
+
return False
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
if __name__ == "__main__":
|
| 128 |
+
success = test_storage()
|
| 129 |
+
sys.exit(0 if success else 1)
|
services/__init__.py
ADDED
|
File without changes
|
services/account_service.py
ADDED
|
@@ -0,0 +1,946 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import time
|
| 4 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 5 |
+
from datetime import datetime, timedelta, timezone
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from threading import Condition, Lock
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
from services.config import config
|
| 11 |
+
from services.log_service import (
|
| 12 |
+
LOG_TYPE_ACCOUNT,
|
| 13 |
+
log_service,
|
| 14 |
+
)
|
| 15 |
+
from services.storage.base import StorageBackend
|
| 16 |
+
from utils.helper import anonymize_token
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class AccountService:
|
| 20 |
+
"""账号池服务,使用 token -> account 的 dict 保存账号。"""
|
| 21 |
+
|
| 22 |
+
_NEW_ACCOUNT_INVALID_GRACE_SECONDS = 10 * 60
|
| 23 |
+
_INVALID_CONFIRM_SECONDS = 30
|
| 24 |
+
_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 24 * 60 * 60
|
| 25 |
+
_REFRESH_TOKEN_KEEPALIVE_SECONDS = 3 * 24 * 60 * 60
|
| 26 |
+
_REFRESH_TOKEN_KEEPALIVE_ERROR_BACKOFF_SECONDS = 6 * 60 * 60
|
| 27 |
+
_REFRESH_TOKEN_KEEPALIVE_BATCH_SIZE = 3
|
| 28 |
+
_TOKEN_REFRESH_ERROR_BACKOFF_SECONDS = 5 * 60
|
| 29 |
+
_OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token"
|
| 30 |
+
_OAUTH_CLIENT_ID = "app_2SKx67EdpoN0G6j64rFvigXD"
|
| 31 |
+
_OAUTH_USER_AGENT = (
|
| 32 |
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
| 33 |
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
| 34 |
+
"Chrome/145.0.0.0 Safari/537.36"
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
def __init__(self, storage_backend: StorageBackend):
|
| 38 |
+
self.storage = storage_backend
|
| 39 |
+
self._lock = Lock()
|
| 40 |
+
self._token_refresh_lock = Lock()
|
| 41 |
+
self._image_slot_condition = Condition(self._lock)
|
| 42 |
+
self._index = 0
|
| 43 |
+
self._accounts = self._load_accounts()
|
| 44 |
+
self._image_inflight: dict[str, int] = {}
|
| 45 |
+
self._token_aliases: dict[str, str] = {}
|
| 46 |
+
self._cumulative_total = self._load_cumulative_total()
|
| 47 |
+
|
| 48 |
+
def _get_cumulative_file(self) -> Path:
|
| 49 |
+
from services.config import DATA_DIR
|
| 50 |
+
return DATA_DIR / ".cumulative_total"
|
| 51 |
+
|
| 52 |
+
def _load_cumulative_total(self) -> int:
|
| 53 |
+
try:
|
| 54 |
+
f = self._get_cumulative_file()
|
| 55 |
+
if f.exists():
|
| 56 |
+
return int(f.read_text().strip())
|
| 57 |
+
except Exception:
|
| 58 |
+
pass
|
| 59 |
+
return len(self._accounts)
|
| 60 |
+
|
| 61 |
+
def _save_cumulative_total(self) -> None:
|
| 62 |
+
try:
|
| 63 |
+
self._get_cumulative_file().write_text(str(self._cumulative_total))
|
| 64 |
+
except Exception:
|
| 65 |
+
pass
|
| 66 |
+
|
| 67 |
+
@staticmethod
|
| 68 |
+
def _now() -> str:
|
| 69 |
+
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
| 70 |
+
|
| 71 |
+
@staticmethod
|
| 72 |
+
def _decode_jwt_payload(token: str) -> dict:
|
| 73 |
+
try:
|
| 74 |
+
payload = str(token or "").split(".")[1]
|
| 75 |
+
payload += "=" * ((4 - len(payload) % 4) % 4)
|
| 76 |
+
import base64
|
| 77 |
+
import json
|
| 78 |
+
data = json.loads(base64.urlsafe_b64decode(payload.encode("ascii")))
|
| 79 |
+
return data if isinstance(data, dict) else {}
|
| 80 |
+
except Exception:
|
| 81 |
+
return {}
|
| 82 |
+
|
| 83 |
+
@staticmethod
|
| 84 |
+
def _parse_time(value: object) -> datetime | None:
|
| 85 |
+
raw = str(value or "").strip()
|
| 86 |
+
if not raw:
|
| 87 |
+
return None
|
| 88 |
+
try:
|
| 89 |
+
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
| 90 |
+
except Exception:
|
| 91 |
+
try:
|
| 92 |
+
parsed = datetime.strptime(raw, "%Y-%m-%d %H:%M:%S")
|
| 93 |
+
except Exception:
|
| 94 |
+
return None
|
| 95 |
+
if parsed.tzinfo is None:
|
| 96 |
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
| 97 |
+
return parsed.astimezone(timezone.utc)
|
| 98 |
+
|
| 99 |
+
@staticmethod
|
| 100 |
+
def _timestamp_to_iso(value: object) -> str:
|
| 101 |
+
try:
|
| 102 |
+
ts = int(value)
|
| 103 |
+
except (TypeError, ValueError):
|
| 104 |
+
return ""
|
| 105 |
+
tz = timezone(timedelta(hours=8))
|
| 106 |
+
return datetime.fromtimestamp(ts, tz=timezone.utc).astimezone(tz).isoformat()
|
| 107 |
+
|
| 108 |
+
def _load_accounts(self) -> dict[str, dict]:
|
| 109 |
+
accounts = self.storage.load_accounts()
|
| 110 |
+
return {
|
| 111 |
+
normalized["access_token"]: normalized
|
| 112 |
+
for item in accounts
|
| 113 |
+
if (normalized := self._normalize_account(item)) is not None
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
def _save_accounts(self) -> None:
|
| 117 |
+
self.storage.save_accounts(list(self._accounts.values()))
|
| 118 |
+
|
| 119 |
+
@staticmethod
|
| 120 |
+
def _is_image_account_available(account: dict) -> bool:
|
| 121 |
+
if not isinstance(account, dict):
|
| 122 |
+
return False
|
| 123 |
+
if account.get("status") in {"禁用", "限流", "异常"}:
|
| 124 |
+
return False
|
| 125 |
+
if bool(account.get("image_quota_unknown")):
|
| 126 |
+
return True
|
| 127 |
+
return int(account.get("quota") or 0) > 0
|
| 128 |
+
|
| 129 |
+
@staticmethod
|
| 130 |
+
def _normalize_account_type(value: object) -> str | None:
|
| 131 |
+
raw = str(value or "").strip()
|
| 132 |
+
if not raw:
|
| 133 |
+
return None
|
| 134 |
+
key = raw.lower().replace("-", "_").replace(" ", "_")
|
| 135 |
+
compact = key.replace("_", "")
|
| 136 |
+
aliases = {
|
| 137 |
+
"free": "free",
|
| 138 |
+
"plus": "Plus",
|
| 139 |
+
"pro": "Pro",
|
| 140 |
+
"prolite": "ProLite",
|
| 141 |
+
"team": "Team",
|
| 142 |
+
"business": "Team",
|
| 143 |
+
"enterprise": "Enterprise",
|
| 144 |
+
}
|
| 145 |
+
return aliases.get(compact) or aliases.get(key) or raw
|
| 146 |
+
|
| 147 |
+
def _search_account_type(self, payload: object) -> str | None:
|
| 148 |
+
if isinstance(payload, dict):
|
| 149 |
+
for key in ("plan_type", "account_plan", "account_type", "subscription_type", "type"):
|
| 150 |
+
plan = self._normalize_account_type(payload.get(key))
|
| 151 |
+
if plan:
|
| 152 |
+
return plan
|
| 153 |
+
for value in payload.values():
|
| 154 |
+
plan = self._search_account_type(value)
|
| 155 |
+
if plan:
|
| 156 |
+
return plan
|
| 157 |
+
elif isinstance(payload, list):
|
| 158 |
+
for value in payload:
|
| 159 |
+
plan = self._search_account_type(value)
|
| 160 |
+
if plan:
|
| 161 |
+
return plan
|
| 162 |
+
return None
|
| 163 |
+
|
| 164 |
+
def _normalize_account(self, item: dict) -> dict | None:
|
| 165 |
+
if not isinstance(item, dict):
|
| 166 |
+
return None
|
| 167 |
+
access_token = item.get("access_token") or item.get("accessToken") or ""
|
| 168 |
+
if not access_token:
|
| 169 |
+
return None
|
| 170 |
+
normalized = dict(item)
|
| 171 |
+
normalized.pop("accessToken", None)
|
| 172 |
+
normalized["access_token"] = access_token
|
| 173 |
+
normalized["type"] = normalized.get("type") or "free"
|
| 174 |
+
normalized["status"] = normalized.get("status") or "正常"
|
| 175 |
+
normalized["quota"] = max(0, int(normalized.get("quota") if normalized.get("quota") is not None else 0))
|
| 176 |
+
normalized["image_quota_unknown"] = bool(normalized.get("image_quota_unknown"))
|
| 177 |
+
normalized["email"] = normalized.get("email") or None
|
| 178 |
+
normalized["user_id"] = normalized.get("user_id") or None
|
| 179 |
+
limits_progress = normalized.get("limits_progress")
|
| 180 |
+
normalized["limits_progress"] = limits_progress if isinstance(limits_progress, list) else []
|
| 181 |
+
normalized["default_model_slug"] = normalized.get("default_model_slug") or None
|
| 182 |
+
normalized["restore_at"] = normalized.get("restore_at") or None
|
| 183 |
+
normalized["success"] = int(normalized.get("success") or 0)
|
| 184 |
+
normalized["fail"] = int(normalized.get("fail") or 0)
|
| 185 |
+
normalized["invalid_count"] = int(normalized.get("invalid_count") or 0)
|
| 186 |
+
normalized["last_used_at"] = normalized.get("last_used_at")
|
| 187 |
+
normalized["last_invalid_at"] = normalized.get("last_invalid_at") or None
|
| 188 |
+
normalized["last_refresh_error"] = normalized.get("last_refresh_error") or None
|
| 189 |
+
normalized["last_refresh_error_at"] = normalized.get("last_refresh_error_at") or None
|
| 190 |
+
normalized["last_token_refresh_at"] = normalized.get("last_token_refresh_at") or None
|
| 191 |
+
normalized["last_token_refresh_error"] = normalized.get("last_token_refresh_error") or None
|
| 192 |
+
normalized["last_token_refresh_error_at"] = normalized.get("last_token_refresh_error_at") or None
|
| 193 |
+
normalized["created_at"] = normalized.get("created_at") or AccountService._now()
|
| 194 |
+
return normalized
|
| 195 |
+
|
| 196 |
+
@staticmethod
|
| 197 |
+
def _jwt_exp(access_token: str) -> int:
|
| 198 |
+
try:
|
| 199 |
+
return int(AccountService._decode_jwt_payload(access_token).get("exp") or 0)
|
| 200 |
+
except (TypeError, ValueError):
|
| 201 |
+
return 0
|
| 202 |
+
|
| 203 |
+
@classmethod
|
| 204 |
+
def _token_expires_in(cls, access_token: str) -> int | None:
|
| 205 |
+
exp = cls._jwt_exp(access_token)
|
| 206 |
+
if exp <= 0:
|
| 207 |
+
return None
|
| 208 |
+
return exp - int(time.time())
|
| 209 |
+
|
| 210 |
+
@classmethod
|
| 211 |
+
def _token_needs_refresh(cls, access_token: str, *, force: bool = False) -> bool:
|
| 212 |
+
if force:
|
| 213 |
+
return True
|
| 214 |
+
remaining = cls._token_expires_in(access_token)
|
| 215 |
+
return remaining is not None and remaining <= cls._ACCESS_TOKEN_REFRESH_SKEW_SECONDS
|
| 216 |
+
|
| 217 |
+
@classmethod
|
| 218 |
+
def _token_issued_at(cls, access_token: str) -> datetime | None:
|
| 219 |
+
try:
|
| 220 |
+
iat = int(cls._decode_jwt_payload(access_token).get("iat") or 0)
|
| 221 |
+
except (TypeError, ValueError):
|
| 222 |
+
return None
|
| 223 |
+
if iat <= 0:
|
| 224 |
+
return None
|
| 225 |
+
return datetime.fromtimestamp(iat, tz=timezone.utc)
|
| 226 |
+
|
| 227 |
+
@staticmethod
|
| 228 |
+
def _safe_response_text(response: object, limit: int = 300) -> str:
|
| 229 |
+
try:
|
| 230 |
+
return str(getattr(response, "text", "") or "")[:limit]
|
| 231 |
+
except Exception:
|
| 232 |
+
return ""
|
| 233 |
+
|
| 234 |
+
def _resolve_access_token_locked(self, access_token: str) -> str:
|
| 235 |
+
token = str(access_token or "").strip()
|
| 236 |
+
seen: set[str] = set()
|
| 237 |
+
while token and token not in self._accounts and token in self._token_aliases and token not in seen:
|
| 238 |
+
seen.add(token)
|
| 239 |
+
token = self._token_aliases.get(token, token)
|
| 240 |
+
return token
|
| 241 |
+
|
| 242 |
+
def resolve_access_token(self, access_token: str) -> str:
|
| 243 |
+
if not access_token:
|
| 244 |
+
return ""
|
| 245 |
+
with self._lock:
|
| 246 |
+
return self._resolve_access_token_locked(access_token)
|
| 247 |
+
|
| 248 |
+
def _get_account_for_token(self, access_token: str) -> tuple[str, dict | None]:
|
| 249 |
+
with self._lock:
|
| 250 |
+
resolved = self._resolve_access_token_locked(access_token)
|
| 251 |
+
account = self._accounts.get(resolved)
|
| 252 |
+
return resolved, dict(account) if account else None
|
| 253 |
+
|
| 254 |
+
def _record_token_refresh_error(self, access_token: str, event: str, error: str) -> None:
|
| 255 |
+
now = datetime.now(timezone.utc).isoformat()
|
| 256 |
+
with self._lock:
|
| 257 |
+
resolved = self._resolve_access_token_locked(access_token)
|
| 258 |
+
current = self._accounts.get(resolved)
|
| 259 |
+
if current is None:
|
| 260 |
+
return
|
| 261 |
+
next_item = dict(current)
|
| 262 |
+
next_item["last_token_refresh_error"] = str(error or "refresh token failed")
|
| 263 |
+
next_item["last_token_refresh_error_at"] = now
|
| 264 |
+
account = self._normalize_account(next_item)
|
| 265 |
+
if account is not None:
|
| 266 |
+
self._accounts[resolved] = account
|
| 267 |
+
self._save_accounts()
|
| 268 |
+
log_service.add(
|
| 269 |
+
LOG_TYPE_ACCOUNT,
|
| 270 |
+
"refresh_token 刷新 access_token 失败",
|
| 271 |
+
{"source": event, "token": anonymize_token(access_token), "error": str(error or "")},
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
def _recent_token_refresh_error(self, account: dict) -> bool:
|
| 275 |
+
last_error_at = self._parse_time(account.get("last_token_refresh_error_at"))
|
| 276 |
+
if last_error_at is None:
|
| 277 |
+
return False
|
| 278 |
+
return (datetime.now(timezone.utc) - last_error_at).total_seconds() < self._TOKEN_REFRESH_ERROR_BACKOFF_SECONDS
|
| 279 |
+
|
| 280 |
+
def _recent_refresh_token_keepalive_error(self, account: dict, now: datetime) -> bool:
|
| 281 |
+
last_error_at = self._parse_time(account.get("last_token_refresh_error_at"))
|
| 282 |
+
if last_error_at is None:
|
| 283 |
+
return False
|
| 284 |
+
return (now - last_error_at).total_seconds() < self._REFRESH_TOKEN_KEEPALIVE_ERROR_BACKOFF_SECONDS
|
| 285 |
+
|
| 286 |
+
def _refresh_token_keepalive_anchor(self, account: dict) -> datetime | None:
|
| 287 |
+
return (
|
| 288 |
+
self._parse_time(account.get("last_token_refresh_at"))
|
| 289 |
+
or self._token_issued_at(str(account.get("access_token") or ""))
|
| 290 |
+
or self._parse_time(account.get("created_at"))
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
def _refresh_token_keepalive_due_at(self, account: dict, now: datetime) -> datetime | None:
|
| 294 |
+
if not str(account.get("refresh_token") or "").strip():
|
| 295 |
+
return None
|
| 296 |
+
if account.get("status") == "禁用":
|
| 297 |
+
return None
|
| 298 |
+
if self._recent_refresh_token_keepalive_error(account, now):
|
| 299 |
+
return None
|
| 300 |
+
anchor = self._refresh_token_keepalive_anchor(account)
|
| 301 |
+
if anchor is None:
|
| 302 |
+
return now
|
| 303 |
+
due_at = anchor + timedelta(seconds=self._REFRESH_TOKEN_KEEPALIVE_SECONDS)
|
| 304 |
+
return due_at if due_at <= now else None
|
| 305 |
+
|
| 306 |
+
def _request_access_token_refresh(self, refresh_token: str) -> dict[str, str]:
|
| 307 |
+
from curl_cffi import requests
|
| 308 |
+
from services.proxy_service import proxy_settings
|
| 309 |
+
|
| 310 |
+
session = requests.Session(**proxy_settings.build_session_kwargs(impersonate="chrome", verify=True))
|
| 311 |
+
try:
|
| 312 |
+
response = session.post(
|
| 313 |
+
self._OAUTH_TOKEN_URL,
|
| 314 |
+
headers={
|
| 315 |
+
"Accept": "application/json",
|
| 316 |
+
"Content-Type": "application/x-www-form-urlencoded",
|
| 317 |
+
"User-Agent": self._OAUTH_USER_AGENT,
|
| 318 |
+
},
|
| 319 |
+
data={
|
| 320 |
+
"grant_type": "refresh_token",
|
| 321 |
+
"refresh_token": refresh_token,
|
| 322 |
+
"client_id": self._OAUTH_CLIENT_ID,
|
| 323 |
+
},
|
| 324 |
+
timeout=60,
|
| 325 |
+
)
|
| 326 |
+
data = response.json() if response.text else {}
|
| 327 |
+
if response.status_code != 200 or not isinstance(data, dict) or not data.get("access_token"):
|
| 328 |
+
detail = ""
|
| 329 |
+
if isinstance(data, dict):
|
| 330 |
+
detail = str(data.get("error_description") or data.get("error") or data.get("message") or "")
|
| 331 |
+
detail = detail or self._safe_response_text(response)
|
| 332 |
+
raise RuntimeError(f"oauth_refresh_http_{response.status_code}{': ' + detail if detail else ''}")
|
| 333 |
+
return {
|
| 334 |
+
"access_token": str(data.get("access_token") or "").strip(),
|
| 335 |
+
"refresh_token": str(data.get("refresh_token") or refresh_token).strip(),
|
| 336 |
+
"id_token": str(data.get("id_token") or "").strip(),
|
| 337 |
+
}
|
| 338 |
+
finally:
|
| 339 |
+
session.close()
|
| 340 |
+
|
| 341 |
+
def _apply_refreshed_tokens(self, old_access_token: str, token_data: dict, event: str) -> str:
|
| 342 |
+
now = datetime.now(timezone.utc).isoformat()
|
| 343 |
+
with self._image_slot_condition:
|
| 344 |
+
old_token = self._resolve_access_token_locked(old_access_token)
|
| 345 |
+
current = self._accounts.get(old_token)
|
| 346 |
+
if current is None:
|
| 347 |
+
return old_token
|
| 348 |
+
new_token = str(token_data.get("access_token") or old_token).strip()
|
| 349 |
+
if not new_token:
|
| 350 |
+
return old_token
|
| 351 |
+
|
| 352 |
+
next_item = dict(current)
|
| 353 |
+
next_item["access_token"] = new_token
|
| 354 |
+
if token_data.get("refresh_token"):
|
| 355 |
+
next_item["refresh_token"] = str(token_data.get("refresh_token") or "").strip()
|
| 356 |
+
if token_data.get("id_token"):
|
| 357 |
+
next_item["id_token"] = str(token_data.get("id_token") or "").strip()
|
| 358 |
+
next_item["last_token_refresh_at"] = now
|
| 359 |
+
next_item["last_token_refresh_error"] = None
|
| 360 |
+
next_item["last_token_refresh_error_at"] = None
|
| 361 |
+
next_item["invalid_count"] = 0
|
| 362 |
+
next_item["last_invalid_at"] = None
|
| 363 |
+
next_item["last_refresh_error"] = None
|
| 364 |
+
next_item["last_refresh_error_at"] = None
|
| 365 |
+
|
| 366 |
+
account = self._normalize_account(next_item)
|
| 367 |
+
if account is None:
|
| 368 |
+
return old_token
|
| 369 |
+
|
| 370 |
+
rotated = new_token != old_token
|
| 371 |
+
if rotated:
|
| 372 |
+
self._accounts.pop(old_token, None)
|
| 373 |
+
self._token_aliases[old_token] = new_token
|
| 374 |
+
old_inflight = int(self._image_inflight.pop(old_token, 0))
|
| 375 |
+
if old_inflight:
|
| 376 |
+
self._image_inflight[new_token] = int(self._image_inflight.get(new_token, 0)) + old_inflight
|
| 377 |
+
self._accounts[new_token] = account
|
| 378 |
+
self._save_accounts()
|
| 379 |
+
self._image_slot_condition.notify_all()
|
| 380 |
+
|
| 381 |
+
log_service.add(
|
| 382 |
+
LOG_TYPE_ACCOUNT,
|
| 383 |
+
"refresh_token 已刷新 access_token",
|
| 384 |
+
{"source": event, "token": anonymize_token(new_token), "rotated": rotated},
|
| 385 |
+
)
|
| 386 |
+
return new_token
|
| 387 |
+
|
| 388 |
+
def refresh_access_token(self, access_token: str, *, force: bool = False, event: str = "refresh_access_token") -> str:
|
| 389 |
+
if not access_token:
|
| 390 |
+
return ""
|
| 391 |
+
with self._token_refresh_lock:
|
| 392 |
+
resolved_token, account = self._get_account_for_token(access_token)
|
| 393 |
+
if not account:
|
| 394 |
+
return access_token
|
| 395 |
+
active_token = str(account.get("access_token") or resolved_token or access_token)
|
| 396 |
+
if not self._token_needs_refresh(active_token, force=force):
|
| 397 |
+
return active_token
|
| 398 |
+
refresh_token = str(account.get("refresh_token") or "").strip()
|
| 399 |
+
if not refresh_token:
|
| 400 |
+
return active_token
|
| 401 |
+
if not force and self._recent_token_refresh_error(account):
|
| 402 |
+
return active_token
|
| 403 |
+
try:
|
| 404 |
+
token_data = self._request_access_token_refresh(refresh_token)
|
| 405 |
+
except Exception as exc:
|
| 406 |
+
self._record_token_refresh_error(active_token, event, str(exc))
|
| 407 |
+
return active_token
|
| 408 |
+
return self._apply_refreshed_tokens(active_token, token_data, event)
|
| 409 |
+
|
| 410 |
+
def list_expiring_access_tokens(self) -> list[str]:
|
| 411 |
+
with self._lock:
|
| 412 |
+
return [
|
| 413 |
+
token
|
| 414 |
+
for account in self._accounts.values()
|
| 415 |
+
if str(account.get("refresh_token") or "").strip()
|
| 416 |
+
and (token := str(account.get("access_token") or "").strip())
|
| 417 |
+
and self._token_needs_refresh(token)
|
| 418 |
+
]
|
| 419 |
+
|
| 420 |
+
def list_refresh_token_keepalive_tokens(self) -> list[str]:
|
| 421 |
+
now = datetime.now(timezone.utc)
|
| 422 |
+
due_items: list[tuple[datetime, str]] = []
|
| 423 |
+
with self._lock:
|
| 424 |
+
for account in self._accounts.values():
|
| 425 |
+
due_at = self._refresh_token_keepalive_due_at(account, now)
|
| 426 |
+
token = str(account.get("access_token") or "").strip()
|
| 427 |
+
if due_at is not None and token:
|
| 428 |
+
due_items.append((due_at, token))
|
| 429 |
+
due_items.sort(key=lambda item: item[0])
|
| 430 |
+
return [token for _, token in due_items[: self._REFRESH_TOKEN_KEEPALIVE_BATCH_SIZE]]
|
| 431 |
+
|
| 432 |
+
def keepalive_refresh_tokens(self, access_tokens: list[str]) -> dict[str, Any]:
|
| 433 |
+
access_tokens = list(dict.fromkeys(token for token in access_tokens if token))
|
| 434 |
+
if not access_tokens:
|
| 435 |
+
return {"refreshed": 0, "errors": [], "items": self.list_accounts()}
|
| 436 |
+
|
| 437 |
+
refreshed = 0
|
| 438 |
+
errors = []
|
| 439 |
+
for access_token in access_tokens:
|
| 440 |
+
before = self.resolve_access_token(access_token)
|
| 441 |
+
after = self.refresh_access_token(before, force=True, event="refresh_token_keepalive")
|
| 442 |
+
account = self.get_account(after)
|
| 443 |
+
if account and str(account.get("last_token_refresh_error") or "").strip():
|
| 444 |
+
errors.append({
|
| 445 |
+
"token": anonymize_token(before),
|
| 446 |
+
"error": str(account.get("last_token_refresh_error") or "refresh token failed"),
|
| 447 |
+
})
|
| 448 |
+
continue
|
| 449 |
+
if account:
|
| 450 |
+
refreshed += 1
|
| 451 |
+
|
| 452 |
+
return {
|
| 453 |
+
"refreshed": refreshed,
|
| 454 |
+
"errors": errors,
|
| 455 |
+
"items": self.list_accounts(),
|
| 456 |
+
}
|
| 457 |
+
|
| 458 |
+
def list_tokens(self) -> list[str]:
|
| 459 |
+
with self._lock:
|
| 460 |
+
return list(self._accounts)
|
| 461 |
+
|
| 462 |
+
def _list_ready_candidate_tokens(self, excluded_tokens: set[str] | None = None) -> list[str]:
|
| 463 |
+
excluded = set(excluded_tokens or set())
|
| 464 |
+
return [
|
| 465 |
+
token
|
| 466 |
+
for item in self._accounts.values()
|
| 467 |
+
if self._is_image_account_available(item)
|
| 468 |
+
and (token := item.get("access_token") or "")
|
| 469 |
+
and token not in excluded
|
| 470 |
+
]
|
| 471 |
+
|
| 472 |
+
def _list_available_candidate_tokens(self, excluded_tokens: set[str] | None = None) -> list[str]:
|
| 473 |
+
max_concurrency = max(1, int(config.image_account_concurrency or 1))
|
| 474 |
+
return [
|
| 475 |
+
token
|
| 476 |
+
for token in self._list_ready_candidate_tokens(excluded_tokens)
|
| 477 |
+
if int(self._image_inflight.get(token, 0)) < max_concurrency
|
| 478 |
+
]
|
| 479 |
+
|
| 480 |
+
def _acquire_next_candidate_token(self, excluded_tokens: set[str] | None = None) -> str:
|
| 481 |
+
with self._image_slot_condition:
|
| 482 |
+
while True:
|
| 483 |
+
if not self._list_ready_candidate_tokens(excluded_tokens):
|
| 484 |
+
raise RuntimeError("no available image quota")
|
| 485 |
+
tokens = self._list_available_candidate_tokens(excluded_tokens)
|
| 486 |
+
if tokens:
|
| 487 |
+
access_token = tokens[self._index % len(tokens)]
|
| 488 |
+
self._index += 1
|
| 489 |
+
self._image_inflight[access_token] = int(self._image_inflight.get(access_token, 0)) + 1
|
| 490 |
+
return access_token
|
| 491 |
+
self._image_slot_condition.wait(timeout=1.0)
|
| 492 |
+
|
| 493 |
+
def release_image_slot(self, access_token: str) -> None:
|
| 494 |
+
if not access_token:
|
| 495 |
+
return
|
| 496 |
+
with self._image_slot_condition:
|
| 497 |
+
access_token = self._resolve_access_token_locked(access_token)
|
| 498 |
+
current_inflight = int(self._image_inflight.get(access_token, 0))
|
| 499 |
+
if current_inflight <= 1:
|
| 500 |
+
self._image_inflight.pop(access_token, None)
|
| 501 |
+
else:
|
| 502 |
+
self._image_inflight[access_token] = current_inflight - 1
|
| 503 |
+
self._image_slot_condition.notify_all()
|
| 504 |
+
|
| 505 |
+
def get_available_access_token(self) -> str:
|
| 506 |
+
attempted_tokens: set[str] = set()
|
| 507 |
+
while True:
|
| 508 |
+
access_token = self._acquire_next_candidate_token(excluded_tokens=attempted_tokens)
|
| 509 |
+
attempted_tokens.add(access_token)
|
| 510 |
+
try:
|
| 511 |
+
account = self.fetch_remote_info(access_token, "get_available_access_token")
|
| 512 |
+
except Exception:
|
| 513 |
+
self.release_image_slot(access_token)
|
| 514 |
+
continue
|
| 515 |
+
if self._is_image_account_available(account or {}):
|
| 516 |
+
return str((account or {}).get("access_token") or access_token)
|
| 517 |
+
self.release_image_slot(access_token)
|
| 518 |
+
|
| 519 |
+
def get_text_access_token(self, excluded_tokens: set[str] | None = None) -> str:
|
| 520 |
+
excluded = set(excluded_tokens or set())
|
| 521 |
+
with self._lock:
|
| 522 |
+
candidates = [
|
| 523 |
+
token
|
| 524 |
+
for account in self._accounts.values()
|
| 525 |
+
if account.get("status") not in {"禁用", "异常"}
|
| 526 |
+
and (token := account.get("access_token") or "")
|
| 527 |
+
and token not in excluded
|
| 528 |
+
]
|
| 529 |
+
if not candidates:
|
| 530 |
+
return ""
|
| 531 |
+
access_token = candidates[self._index % len(candidates)]
|
| 532 |
+
self._index += 1
|
| 533 |
+
return self.refresh_access_token(access_token, event="get_text_access_token") or access_token
|
| 534 |
+
|
| 535 |
+
def mark_text_used(self, access_token: str) -> None:
|
| 536 |
+
if not access_token:
|
| 537 |
+
return
|
| 538 |
+
with self._lock:
|
| 539 |
+
access_token = self._resolve_access_token_locked(access_token)
|
| 540 |
+
current = self._accounts.get(access_token)
|
| 541 |
+
if current is None:
|
| 542 |
+
return
|
| 543 |
+
next_item = dict(current)
|
| 544 |
+
next_item["last_used_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
| 545 |
+
account = self._normalize_account(next_item)
|
| 546 |
+
if account is None:
|
| 547 |
+
return
|
| 548 |
+
self._accounts[access_token] = account
|
| 549 |
+
self._save_accounts()
|
| 550 |
+
|
| 551 |
+
def remove_invalid_token(self, access_token: str, event: str) -> bool:
|
| 552 |
+
if not config.auto_remove_invalid_accounts:
|
| 553 |
+
self.update_account(access_token, {"status": "异常", "quota": 0})
|
| 554 |
+
return False
|
| 555 |
+
removed = bool(self.delete_accounts([access_token])["removed"])
|
| 556 |
+
if removed:
|
| 557 |
+
log_service.add(LOG_TYPE_ACCOUNT, "自动移除异常账号",
|
| 558 |
+
{"source": event, "token": anonymize_token(access_token)})
|
| 559 |
+
elif access_token:
|
| 560 |
+
self.update_account(access_token, {"status": "异常", "quota": 0})
|
| 561 |
+
return removed
|
| 562 |
+
|
| 563 |
+
def get_account(self, access_token: str) -> dict | None:
|
| 564 |
+
if not access_token:
|
| 565 |
+
return None
|
| 566 |
+
with self._lock:
|
| 567 |
+
access_token = self._resolve_access_token_locked(access_token)
|
| 568 |
+
account = self._accounts.get(access_token)
|
| 569 |
+
return dict(account) if account else None
|
| 570 |
+
|
| 571 |
+
def list_accounts(self) -> list[dict]:
|
| 572 |
+
with self._lock:
|
| 573 |
+
return [dict(item) for item in self._accounts.values()]
|
| 574 |
+
|
| 575 |
+
def list_limited_tokens(self) -> list[str]:
|
| 576 |
+
with self._lock:
|
| 577 |
+
return [
|
| 578 |
+
token
|
| 579 |
+
for item in self._accounts.values()
|
| 580 |
+
if item.get("status") == "限流"
|
| 581 |
+
and (token := item.get("access_token") or "")
|
| 582 |
+
]
|
| 583 |
+
|
| 584 |
+
@staticmethod
|
| 585 |
+
def _account_payload_token(item: dict) -> str:
|
| 586 |
+
return str(item.get("access_token") or item.get("accessToken") or "").strip()
|
| 587 |
+
|
| 588 |
+
@staticmethod
|
| 589 |
+
def _prepare_account_payload(item: dict) -> dict | None:
|
| 590 |
+
if not isinstance(item, dict):
|
| 591 |
+
return None
|
| 592 |
+
access_token = AccountService._account_payload_token(item)
|
| 593 |
+
if not access_token:
|
| 594 |
+
return None
|
| 595 |
+
payload = dict(item)
|
| 596 |
+
payload.pop("accessToken", None)
|
| 597 |
+
payload["access_token"] = access_token
|
| 598 |
+
# CPA/Codex 导出文件里的 `type=codex` 是导出格式,不是号池套餐类型。
|
| 599 |
+
if str(payload.get("type") or "").strip().lower() == "codex":
|
| 600 |
+
payload["export_type"] = "codex"
|
| 601 |
+
payload.pop("type", None)
|
| 602 |
+
if payload.get("plan_type") and not payload.get("type"):
|
| 603 |
+
payload["type"] = str(payload.get("plan_type") or "").strip()
|
| 604 |
+
return payload
|
| 605 |
+
|
| 606 |
+
def add_account_items(self, items: list[dict]) -> dict:
|
| 607 |
+
payloads = [
|
| 608 |
+
payload
|
| 609 |
+
for item in items
|
| 610 |
+
if (payload := self._prepare_account_payload(item)) is not None
|
| 611 |
+
]
|
| 612 |
+
return self._add_account_payloads(payloads)
|
| 613 |
+
|
| 614 |
+
def add_accounts(self, tokens: list[str]) -> dict:
|
| 615 |
+
tokens = list(dict.fromkeys(token for token in tokens if token))
|
| 616 |
+
if not tokens:
|
| 617 |
+
return {"added": 0, "skipped": 0, "items": self.list_accounts()}
|
| 618 |
+
return self._add_account_payloads([{"access_token": token} for token in tokens])
|
| 619 |
+
|
| 620 |
+
def _add_account_payloads(self, payloads: list[dict]) -> dict:
|
| 621 |
+
deduped: dict[str, dict] = {}
|
| 622 |
+
for payload in payloads:
|
| 623 |
+
if not isinstance(payload, dict):
|
| 624 |
+
continue
|
| 625 |
+
access_token = self._account_payload_token(payload)
|
| 626 |
+
if not access_token:
|
| 627 |
+
continue
|
| 628 |
+
current = deduped.get(access_token, {})
|
| 629 |
+
deduped[access_token] = {**current, **payload, "access_token": access_token}
|
| 630 |
+
|
| 631 |
+
if not deduped:
|
| 632 |
+
return {"added": 0, "skipped": 0, "items": self.list_accounts()}
|
| 633 |
+
|
| 634 |
+
with self._lock:
|
| 635 |
+
added = 0
|
| 636 |
+
skipped = 0
|
| 637 |
+
for access_token, payload in deduped.items():
|
| 638 |
+
current = self._accounts.get(access_token)
|
| 639 |
+
if current is None:
|
| 640 |
+
added += 1
|
| 641 |
+
self._cumulative_total += 1
|
| 642 |
+
self._save_cumulative_total()
|
| 643 |
+
current = {"created_at": self._now()}
|
| 644 |
+
else:
|
| 645 |
+
skipped += 1
|
| 646 |
+
incoming = dict(payload)
|
| 647 |
+
if not incoming.get("created_at"):
|
| 648 |
+
incoming.pop("created_at", None)
|
| 649 |
+
account = self._normalize_account(
|
| 650 |
+
{
|
| 651 |
+
**current,
|
| 652 |
+
**incoming,
|
| 653 |
+
"access_token": access_token,
|
| 654 |
+
"type": str(incoming.get("type") or current.get("type") or "free"),
|
| 655 |
+
}
|
| 656 |
+
)
|
| 657 |
+
if account is not None:
|
| 658 |
+
self._accounts[access_token] = account
|
| 659 |
+
self._save_accounts()
|
| 660 |
+
items = [dict(item) for item in self._accounts.values()]
|
| 661 |
+
log_service.add(LOG_TYPE_ACCOUNT, f"新增 {added} 个账号,跳过 {skipped} 个",
|
| 662 |
+
{"added": added, "skipped": skipped})
|
| 663 |
+
return {"added": added, "skipped": skipped, "items": items}
|
| 664 |
+
|
| 665 |
+
def delete_accounts(self, tokens: list[str]) -> dict:
|
| 666 |
+
target_set = set(token for token in tokens if token)
|
| 667 |
+
if not target_set:
|
| 668 |
+
return {"removed": 0, "items": self.list_accounts()}
|
| 669 |
+
with self._lock:
|
| 670 |
+
target_set = {self._resolve_access_token_locked(token) for token in target_set if token}
|
| 671 |
+
removed = sum(self._accounts.pop(token, None) is not None for token in target_set)
|
| 672 |
+
for token in target_set:
|
| 673 |
+
self._image_inflight.pop(token, None)
|
| 674 |
+
self._token_aliases = {
|
| 675 |
+
old: new
|
| 676 |
+
for old, new in self._token_aliases.items()
|
| 677 |
+
if old not in target_set and new not in target_set
|
| 678 |
+
}
|
| 679 |
+
if removed:
|
| 680 |
+
if self._accounts:
|
| 681 |
+
self._index %= len(self._accounts)
|
| 682 |
+
else:
|
| 683 |
+
self._index = 0
|
| 684 |
+
self._save_accounts()
|
| 685 |
+
log_service.add(LOG_TYPE_ACCOUNT, f"删除 {removed} 个账号", {"removed": removed})
|
| 686 |
+
items = [dict(item) for item in self._accounts.values()]
|
| 687 |
+
return {"removed": removed, "items": items}
|
| 688 |
+
|
| 689 |
+
def update_account(self, access_token: str, updates: dict) -> dict | None:
|
| 690 |
+
if not access_token:
|
| 691 |
+
return None
|
| 692 |
+
with self._lock:
|
| 693 |
+
access_token = self._resolve_access_token_locked(access_token)
|
| 694 |
+
current = self._accounts.get(access_token)
|
| 695 |
+
if current is None:
|
| 696 |
+
return None
|
| 697 |
+
account = self._normalize_account({**current, **updates, "access_token": access_token})
|
| 698 |
+
if account is None:
|
| 699 |
+
return None
|
| 700 |
+
if account.get("status") == "限流" and config.auto_remove_rate_limited_accounts:
|
| 701 |
+
self._accounts.pop(access_token, None)
|
| 702 |
+
self._save_accounts()
|
| 703 |
+
log_service.add(LOG_TYPE_ACCOUNT, "自动移除限流账号", {"token": anonymize_token(access_token)})
|
| 704 |
+
return None
|
| 705 |
+
self._accounts[access_token] = account
|
| 706 |
+
self._save_accounts()
|
| 707 |
+
log_service.add(LOG_TYPE_ACCOUNT, "更新账号",
|
| 708 |
+
{"token": anonymize_token(access_token), "status": account.get("status")})
|
| 709 |
+
return dict(account)
|
| 710 |
+
return None
|
| 711 |
+
|
| 712 |
+
def _record_refresh_success(self, access_token: str) -> None:
|
| 713 |
+
with self._lock:
|
| 714 |
+
access_token = self._resolve_access_token_locked(access_token)
|
| 715 |
+
current = self._accounts.get(access_token)
|
| 716 |
+
if current is None:
|
| 717 |
+
return
|
| 718 |
+
next_item = dict(current)
|
| 719 |
+
next_item["invalid_count"] = 0
|
| 720 |
+
next_item["last_invalid_at"] = None
|
| 721 |
+
next_item["last_refresh_error"] = None
|
| 722 |
+
next_item["last_refresh_error_at"] = None
|
| 723 |
+
account = self._normalize_account(next_item)
|
| 724 |
+
if account is not None:
|
| 725 |
+
self._accounts[access_token] = account
|
| 726 |
+
|
| 727 |
+
def _should_defer_invalid_token(self, account: dict | None, now: datetime) -> bool:
|
| 728 |
+
if not isinstance(account, dict):
|
| 729 |
+
return False
|
| 730 |
+
created_at = self._parse_time(account.get("created_at"))
|
| 731 |
+
if created_at is not None and (now - created_at).total_seconds() < self._NEW_ACCOUNT_INVALID_GRACE_SECONDS:
|
| 732 |
+
return True
|
| 733 |
+
last_invalid_at = self._parse_time(account.get("last_invalid_at"))
|
| 734 |
+
invalid_count = int(account.get("invalid_count") or 0)
|
| 735 |
+
if invalid_count <= 1:
|
| 736 |
+
return True
|
| 737 |
+
if last_invalid_at is not None and (now - last_invalid_at).total_seconds() < self._INVALID_CONFIRM_SECONDS:
|
| 738 |
+
return True
|
| 739 |
+
return False
|
| 740 |
+
|
| 741 |
+
def _record_invalid_token_seen(self, access_token: str, event: str, error: str) -> bool:
|
| 742 |
+
now = datetime.now(timezone.utc)
|
| 743 |
+
with self._lock:
|
| 744 |
+
access_token = self._resolve_access_token_locked(access_token)
|
| 745 |
+
current = self._accounts.get(access_token)
|
| 746 |
+
if current is None:
|
| 747 |
+
return True
|
| 748 |
+
should_defer = self._should_defer_invalid_token(current, now)
|
| 749 |
+
next_item = dict(current)
|
| 750 |
+
next_item["invalid_count"] = int(next_item.get("invalid_count") or 0) + 1
|
| 751 |
+
next_item["last_invalid_at"] = now.isoformat()
|
| 752 |
+
next_item["last_refresh_error"] = str(error or "invalid access token")
|
| 753 |
+
next_item["last_refresh_error_at"] = now.isoformat()
|
| 754 |
+
account = self._normalize_account(next_item)
|
| 755 |
+
if account is not None:
|
| 756 |
+
self._accounts[access_token] = account
|
| 757 |
+
self._save_accounts()
|
| 758 |
+
if should_defer:
|
| 759 |
+
log_service.add(
|
| 760 |
+
LOG_TYPE_ACCOUNT,
|
| 761 |
+
"暂缓标记异常账号",
|
| 762 |
+
{"source": event, "token": anonymize_token(access_token), "error": str(error or "")},
|
| 763 |
+
)
|
| 764 |
+
return False
|
| 765 |
+
return True
|
| 766 |
+
|
| 767 |
+
def mark_image_result(self, access_token: str, success: bool) -> dict | None:
|
| 768 |
+
if not access_token:
|
| 769 |
+
return None
|
| 770 |
+
self.release_image_slot(access_token)
|
| 771 |
+
with self._lock:
|
| 772 |
+
access_token = self._resolve_access_token_locked(access_token)
|
| 773 |
+
current = self._accounts.get(access_token)
|
| 774 |
+
if current is None:
|
| 775 |
+
return None
|
| 776 |
+
next_item = dict(current)
|
| 777 |
+
next_item["last_used_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
| 778 |
+
image_quota_unknown = bool(next_item.get("image_quota_unknown"))
|
| 779 |
+
if success:
|
| 780 |
+
next_item["success"] = int(next_item.get("success") or 0) + 1
|
| 781 |
+
if not image_quota_unknown:
|
| 782 |
+
next_item["quota"] = max(0, int(next_item.get("quota") or 0) - 1)
|
| 783 |
+
if not image_quota_unknown and next_item["quota"] == 0:
|
| 784 |
+
next_item["status"] = "限流"
|
| 785 |
+
next_item["restore_at"] = next_item.get("restore_at") or None
|
| 786 |
+
elif next_item.get("status") == "限流":
|
| 787 |
+
next_item["status"] = "正常"
|
| 788 |
+
else:
|
| 789 |
+
next_item["fail"] = int(next_item.get("fail") or 0) + 1
|
| 790 |
+
account = self._normalize_account(next_item)
|
| 791 |
+
if account is None:
|
| 792 |
+
return None
|
| 793 |
+
if account.get("status") == "限流" and config.auto_remove_rate_limited_accounts:
|
| 794 |
+
self._accounts.pop(access_token, None)
|
| 795 |
+
self._save_accounts()
|
| 796 |
+
log_service.add(LOG_TYPE_ACCOUNT, "自动移除限流账号", {"token": anonymize_token(access_token)})
|
| 797 |
+
return None
|
| 798 |
+
self._accounts[access_token] = account
|
| 799 |
+
self._save_accounts()
|
| 800 |
+
return dict(account)
|
| 801 |
+
return None
|
| 802 |
+
|
| 803 |
+
def fetch_remote_info(self, access_token: str, event: str = "fetch_remote_info") -> dict[str, Any] | None:
|
| 804 |
+
if not access_token:
|
| 805 |
+
raise ValueError("access_token is required")
|
| 806 |
+
|
| 807 |
+
active_token = self.refresh_access_token(access_token, event=f"{event}:preflight") or access_token
|
| 808 |
+
try:
|
| 809 |
+
from services.openai_backend_api import InvalidAccessTokenError, OpenAIBackendAPI
|
| 810 |
+
result = OpenAIBackendAPI(active_token).get_user_info()
|
| 811 |
+
except InvalidAccessTokenError as exc:
|
| 812 |
+
refreshed_token = self.refresh_access_token(active_token, force=True, event=f"{event}:invalid_access_token")
|
| 813 |
+
if refreshed_token and refreshed_token != active_token:
|
| 814 |
+
try:
|
| 815 |
+
result = OpenAIBackendAPI(refreshed_token).get_user_info()
|
| 816 |
+
except InvalidAccessTokenError as retry_exc:
|
| 817 |
+
if self._record_invalid_token_seen(refreshed_token, event, str(retry_exc)):
|
| 818 |
+
self.remove_invalid_token(refreshed_token, event)
|
| 819 |
+
raise
|
| 820 |
+
active_token = refreshed_token
|
| 821 |
+
else:
|
| 822 |
+
if self._record_invalid_token_seen(active_token, event, str(exc)):
|
| 823 |
+
self.remove_invalid_token(active_token, event)
|
| 824 |
+
raise
|
| 825 |
+
self._record_refresh_success(active_token)
|
| 826 |
+
return self.update_account(active_token, result)
|
| 827 |
+
|
| 828 |
+
def refresh_accounts(self, access_tokens: list[str]) -> dict[str, Any]:
|
| 829 |
+
access_tokens = list(dict.fromkeys(token for token in access_tokens if token))
|
| 830 |
+
if not access_tokens:
|
| 831 |
+
return {"refreshed": 0, "errors": [], "items": self.list_accounts()}
|
| 832 |
+
|
| 833 |
+
refreshed = 0
|
| 834 |
+
errors = []
|
| 835 |
+
max_workers = min(10, len(access_tokens))
|
| 836 |
+
|
| 837 |
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
| 838 |
+
futures = {
|
| 839 |
+
executor.submit(self.fetch_remote_info, token, "refresh_accounts"): token
|
| 840 |
+
for token in access_tokens
|
| 841 |
+
}
|
| 842 |
+
for future in as_completed(futures):
|
| 843 |
+
try:
|
| 844 |
+
account = future.result()
|
| 845 |
+
except Exception as exc:
|
| 846 |
+
errors.append({"token": anonymize_token(futures[future]), "error": str(exc)})
|
| 847 |
+
continue
|
| 848 |
+
if account is not None:
|
| 849 |
+
refreshed += 1
|
| 850 |
+
|
| 851 |
+
return {
|
| 852 |
+
"refreshed": refreshed,
|
| 853 |
+
"errors": errors,
|
| 854 |
+
"items": self.list_accounts(),
|
| 855 |
+
}
|
| 856 |
+
|
| 857 |
+
def build_export_items(self, access_tokens: list[str] | None = None) -> list[dict[str, str]]:
|
| 858 |
+
target_tokens = set(token for token in (access_tokens or []) if token)
|
| 859 |
+
with self._lock:
|
| 860 |
+
accounts = [
|
| 861 |
+
dict(item)
|
| 862 |
+
for item in self._accounts.values()
|
| 863 |
+
if not target_tokens or str(item.get("access_token") or "") in target_tokens
|
| 864 |
+
]
|
| 865 |
+
|
| 866 |
+
items: list[dict[str, str]] = []
|
| 867 |
+
for account in accounts:
|
| 868 |
+
access_token = str(account.get("access_token") or "").strip()
|
| 869 |
+
refresh_token = str(account.get("refresh_token") or "").strip()
|
| 870 |
+
id_token = str(account.get("id_token") or "").strip()
|
| 871 |
+
if not access_token or not refresh_token or not id_token:
|
| 872 |
+
continue
|
| 873 |
+
|
| 874 |
+
access_payload = self._decode_jwt_payload(access_token)
|
| 875 |
+
id_payload = self._decode_jwt_payload(id_token)
|
| 876 |
+
auth_claim = access_payload.get("https://api.openai.com/auth")
|
| 877 |
+
auth_claim = auth_claim if isinstance(auth_claim, dict) else {}
|
| 878 |
+
profile_claim = access_payload.get("https://api.openai.com/profile")
|
| 879 |
+
profile_claim = profile_claim if isinstance(profile_claim, dict) else {}
|
| 880 |
+
|
| 881 |
+
email = (
|
| 882 |
+
str(account.get("email") or "").strip()
|
| 883 |
+
or str(profile_claim.get("email") or "").strip()
|
| 884 |
+
or str(id_payload.get("email") or "").strip()
|
| 885 |
+
)
|
| 886 |
+
account_id = (
|
| 887 |
+
str(account.get("account_id") or "").strip()
|
| 888 |
+
or str(auth_claim.get("chatgpt_account_id") or "").strip()
|
| 889 |
+
or str(account.get("user_id") or "").strip()
|
| 890 |
+
)
|
| 891 |
+
item = {
|
| 892 |
+
"type": str(account.get("export_type") or "codex"),
|
| 893 |
+
"email": email,
|
| 894 |
+
"account_id": account_id,
|
| 895 |
+
"access_token": access_token,
|
| 896 |
+
"refresh_token": refresh_token,
|
| 897 |
+
"id_token": id_token,
|
| 898 |
+
"expired": self._timestamp_to_iso(access_payload.get("exp")),
|
| 899 |
+
"last_refresh": self._timestamp_to_iso(access_payload.get("iat")),
|
| 900 |
+
}
|
| 901 |
+
password = str(account.get("password") or "").strip()
|
| 902 |
+
if password:
|
| 903 |
+
item["password"] = password
|
| 904 |
+
items.append(item)
|
| 905 |
+
return items
|
| 906 |
+
|
| 907 |
+
def get_stats(self) -> dict:
|
| 908 |
+
with self._lock:
|
| 909 |
+
items = list(self._accounts.values())
|
| 910 |
+
total = len(items)
|
| 911 |
+
active = sum(1 for a in items if a.get("status") == "正常")
|
| 912 |
+
limited = sum(1 for a in items if a.get("status") == "限流")
|
| 913 |
+
abnormal = sum(1 for a in items if a.get("status") == "异常")
|
| 914 |
+
disabled = sum(1 for a in items if a.get("status") == "禁用")
|
| 915 |
+
total_quota = sum(max(0, int(a.get("quota") or 0)) for a in items if a.get("status") == "正常")
|
| 916 |
+
unlimited = sum(1 for a in items if a.get("status") == "正常" and bool(a.get("image_quota_unknown")))
|
| 917 |
+
total_success = sum(int(a.get("success") or 0) for a in items)
|
| 918 |
+
total_fail = sum(int(a.get("fail") or 0) for a in items)
|
| 919 |
+
by_type = {}
|
| 920 |
+
for a in items:
|
| 921 |
+
t = a.get("type", "unknown")
|
| 922 |
+
by_type[t] = by_type.get(t, 0) + 1
|
| 923 |
+
return {
|
| 924 |
+
"total": total,
|
| 925 |
+
"cumulative_total": self._cumulative_total,
|
| 926 |
+
"active": active,
|
| 927 |
+
"limited": limited,
|
| 928 |
+
"abnormal": abnormal,
|
| 929 |
+
"disabled": disabled,
|
| 930 |
+
"total_quota": total_quota,
|
| 931 |
+
"unlimited_quota_count": unlimited,
|
| 932 |
+
"total_success": total_success,
|
| 933 |
+
"total_fail": total_fail,
|
| 934 |
+
"by_type": by_type,
|
| 935 |
+
}
|
| 936 |
+
|
| 937 |
+
def account_health(self) -> dict:
|
| 938 |
+
stats = self.get_stats()
|
| 939 |
+
return {
|
| 940 |
+
"healthy": stats["active"] > 0 or stats["unlimited_quota_count"] > 0,
|
| 941 |
+
"status": "ok" if stats["active"] > 0 else "degraded",
|
| 942 |
+
**stats,
|
| 943 |
+
}
|
| 944 |
+
|
| 945 |
+
|
| 946 |
+
account_service = AccountService(config.get_storage_backend())
|
services/auth_service.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
import hmac
|
| 5 |
+
import secrets
|
| 6 |
+
import uuid
|
| 7 |
+
from datetime import datetime, timezone
|
| 8 |
+
from threading import Lock
|
| 9 |
+
from typing import Literal
|
| 10 |
+
|
| 11 |
+
from services.config import config
|
| 12 |
+
from services.storage.base import StorageBackend
|
| 13 |
+
|
| 14 |
+
AuthRole = Literal["admin", "user"]
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _now_iso() -> str:
|
| 18 |
+
return datetime.now(timezone.utc).isoformat()
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _hash_key(value: str) -> str:
|
| 22 |
+
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class AuthService:
|
| 26 |
+
def __init__(self, storage: StorageBackend):
|
| 27 |
+
self.storage = storage
|
| 28 |
+
self._lock = Lock()
|
| 29 |
+
self._items = self._load()
|
| 30 |
+
self._last_used_flush_at: dict[str, datetime] = {}
|
| 31 |
+
|
| 32 |
+
@staticmethod
|
| 33 |
+
def _clean(value: object) -> str:
|
| 34 |
+
return str(value or "").strip()
|
| 35 |
+
|
| 36 |
+
@staticmethod
|
| 37 |
+
def _default_name(role: object) -> str:
|
| 38 |
+
return "管理员密钥" if str(role or "").strip().lower() == "admin" else "普通用户"
|
| 39 |
+
|
| 40 |
+
def _normalize_item(self, raw: object) -> dict[str, object] | None:
|
| 41 |
+
if not isinstance(raw, dict):
|
| 42 |
+
return None
|
| 43 |
+
role = self._clean(raw.get("role")).lower()
|
| 44 |
+
if role not in {"admin", "user"}:
|
| 45 |
+
return None
|
| 46 |
+
key_hash = self._clean(raw.get("key_hash"))
|
| 47 |
+
if not key_hash:
|
| 48 |
+
return None
|
| 49 |
+
item_id = self._clean(raw.get("id")) or uuid.uuid4().hex[:12]
|
| 50 |
+
name = self._clean(raw.get("name")) or self._default_name(role)
|
| 51 |
+
created_at = self._clean(raw.get("created_at")) or _now_iso()
|
| 52 |
+
last_used_at = self._clean(raw.get("last_used_at")) or None
|
| 53 |
+
return {
|
| 54 |
+
"id": item_id,
|
| 55 |
+
"name": name,
|
| 56 |
+
"role": role,
|
| 57 |
+
"key_hash": key_hash,
|
| 58 |
+
"enabled": bool(raw.get("enabled", True)),
|
| 59 |
+
"created_at": created_at,
|
| 60 |
+
"last_used_at": last_used_at,
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
def _load(self) -> list[dict[str, object]]:
|
| 64 |
+
try:
|
| 65 |
+
items = self.storage.load_auth_keys()
|
| 66 |
+
except Exception:
|
| 67 |
+
return []
|
| 68 |
+
if not isinstance(items, list):
|
| 69 |
+
return []
|
| 70 |
+
return [normalized for item in items if (normalized := self._normalize_item(item)) is not None]
|
| 71 |
+
|
| 72 |
+
def _save(self) -> None:
|
| 73 |
+
self.storage.save_auth_keys(self._items)
|
| 74 |
+
|
| 75 |
+
def _reload_locked(self) -> None:
|
| 76 |
+
self._items = self._load()
|
| 77 |
+
|
| 78 |
+
@staticmethod
|
| 79 |
+
def _public_item(item: dict[str, object]) -> dict[str, object]:
|
| 80 |
+
return {
|
| 81 |
+
"id": item.get("id"),
|
| 82 |
+
"name": item.get("name"),
|
| 83 |
+
"role": item.get("role"),
|
| 84 |
+
"enabled": bool(item.get("enabled", True)),
|
| 85 |
+
"created_at": item.get("created_at"),
|
| 86 |
+
"last_used_at": item.get("last_used_at"),
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
def list_keys(self, role: AuthRole | None = None) -> list[dict[str, object]]:
|
| 90 |
+
with self._lock:
|
| 91 |
+
self._reload_locked()
|
| 92 |
+
items = [item for item in self._items if role is None or item.get("role") == role]
|
| 93 |
+
return [self._public_item(item) for item in items]
|
| 94 |
+
|
| 95 |
+
def _has_key_hash_locked(self, key_hash: str, *, exclude_id: str = "") -> bool:
|
| 96 |
+
for item in self._items:
|
| 97 |
+
item_id = self._clean(item.get("id"))
|
| 98 |
+
if exclude_id and item_id == exclude_id:
|
| 99 |
+
continue
|
| 100 |
+
stored_hash = self._clean(item.get("key_hash"))
|
| 101 |
+
if stored_hash and hmac.compare_digest(stored_hash, key_hash):
|
| 102 |
+
return True
|
| 103 |
+
return False
|
| 104 |
+
|
| 105 |
+
def _build_key_hash_locked(self, raw_key: str, *, exclude_id: str = "") -> str:
|
| 106 |
+
candidate = self._clean(raw_key)
|
| 107 |
+
if not candidate:
|
| 108 |
+
raise ValueError("请输入新的专用密钥")
|
| 109 |
+
admin_key = self._clean(config.auth_key)
|
| 110 |
+
if admin_key and hmac.compare_digest(candidate, admin_key):
|
| 111 |
+
raise ValueError("这个密钥和管理员密钥冲突了,请换一个新的密钥")
|
| 112 |
+
key_hash = _hash_key(candidate)
|
| 113 |
+
if self._has_key_hash_locked(key_hash, exclude_id=exclude_id):
|
| 114 |
+
raise ValueError("这个专用密钥已经存在,请换一个新的密钥")
|
| 115 |
+
return key_hash
|
| 116 |
+
|
| 117 |
+
def _has_name_locked(self, name: str, *, role: AuthRole | None = None, exclude_id: str = "") -> bool:
|
| 118 |
+
candidate = self._clean(name)
|
| 119 |
+
if not candidate:
|
| 120 |
+
return False
|
| 121 |
+
for item in self._items:
|
| 122 |
+
item_id = self._clean(item.get("id"))
|
| 123 |
+
if exclude_id and item_id == exclude_id:
|
| 124 |
+
continue
|
| 125 |
+
if role is not None and item.get("role") != role:
|
| 126 |
+
continue
|
| 127 |
+
if self._clean(item.get("name")) == candidate:
|
| 128 |
+
return True
|
| 129 |
+
return False
|
| 130 |
+
|
| 131 |
+
def _build_default_name_locked(self, role: AuthRole, *, exclude_id: str = "") -> str:
|
| 132 |
+
base_name = self._default_name(role)
|
| 133 |
+
if not self._has_name_locked(base_name, role=role, exclude_id=exclude_id):
|
| 134 |
+
return base_name
|
| 135 |
+
suffix = 2
|
| 136 |
+
while True:
|
| 137 |
+
candidate = f"{base_name} {suffix}"
|
| 138 |
+
if not self._has_name_locked(candidate, role=role, exclude_id=exclude_id):
|
| 139 |
+
return candidate
|
| 140 |
+
suffix += 1
|
| 141 |
+
|
| 142 |
+
def _build_name_locked(self, name: str, *, role: AuthRole, exclude_id: str = "") -> str:
|
| 143 |
+
candidate = self._clean(name)
|
| 144 |
+
if not candidate:
|
| 145 |
+
return self._build_default_name_locked(role, exclude_id=exclude_id)
|
| 146 |
+
if self._has_name_locked(candidate, role=role, exclude_id=exclude_id):
|
| 147 |
+
raise ValueError("这个名称已经在使用中了,换一个更容易区分的名称吧")
|
| 148 |
+
return candidate
|
| 149 |
+
|
| 150 |
+
def create_key(self, *, role: AuthRole, name: str = "") -> tuple[dict[str, object], str]:
|
| 151 |
+
with self._lock:
|
| 152 |
+
self._reload_locked()
|
| 153 |
+
normalized_name = self._build_name_locked(name, role=role)
|
| 154 |
+
while True:
|
| 155 |
+
raw_key = f"sk-{secrets.token_urlsafe(24)}"
|
| 156 |
+
try:
|
| 157 |
+
key_hash = self._build_key_hash_locked(raw_key)
|
| 158 |
+
break
|
| 159 |
+
except ValueError:
|
| 160 |
+
continue
|
| 161 |
+
item = {
|
| 162 |
+
"id": uuid.uuid4().hex[:12],
|
| 163 |
+
"name": normalized_name,
|
| 164 |
+
"role": role,
|
| 165 |
+
"key_hash": key_hash,
|
| 166 |
+
"enabled": True,
|
| 167 |
+
"created_at": _now_iso(),
|
| 168 |
+
"last_used_at": None,
|
| 169 |
+
}
|
| 170 |
+
self._items.append(item)
|
| 171 |
+
self._save()
|
| 172 |
+
return self._public_item(item), raw_key
|
| 173 |
+
|
| 174 |
+
def update_key(
|
| 175 |
+
self,
|
| 176 |
+
key_id: str,
|
| 177 |
+
updates: dict[str, object],
|
| 178 |
+
*,
|
| 179 |
+
role: AuthRole | None = None,
|
| 180 |
+
) -> dict[str, object] | None:
|
| 181 |
+
normalized_id = self._clean(key_id)
|
| 182 |
+
if not normalized_id:
|
| 183 |
+
return None
|
| 184 |
+
with self._lock:
|
| 185 |
+
self._reload_locked()
|
| 186 |
+
for index, item in enumerate(self._items):
|
| 187 |
+
if item.get("id") != normalized_id:
|
| 188 |
+
continue
|
| 189 |
+
if role is not None and item.get("role") != role:
|
| 190 |
+
return None
|
| 191 |
+
next_item = dict(item)
|
| 192 |
+
next_role = "admin" if str(next_item.get("role") or "").strip().lower() == "admin" else "user"
|
| 193 |
+
if "name" in updates and updates.get("name") is not None:
|
| 194 |
+
next_item["name"] = self._build_name_locked(
|
| 195 |
+
str(updates.get("name") or ""),
|
| 196 |
+
role=next_role,
|
| 197 |
+
exclude_id=normalized_id,
|
| 198 |
+
)
|
| 199 |
+
if "enabled" in updates and updates.get("enabled") is not None:
|
| 200 |
+
next_item["enabled"] = bool(updates.get("enabled"))
|
| 201 |
+
if "key" in updates and updates.get("key") is not None:
|
| 202 |
+
next_item["key_hash"] = self._build_key_hash_locked(str(updates.get("key") or ""), exclude_id=normalized_id)
|
| 203 |
+
self._items[index] = next_item
|
| 204 |
+
self._save()
|
| 205 |
+
return self._public_item(next_item)
|
| 206 |
+
return None
|
| 207 |
+
|
| 208 |
+
def delete_key(self, key_id: str, *, role: AuthRole | None = None) -> bool:
|
| 209 |
+
normalized_id = self._clean(key_id)
|
| 210 |
+
if not normalized_id:
|
| 211 |
+
return False
|
| 212 |
+
with self._lock:
|
| 213 |
+
self._reload_locked()
|
| 214 |
+
before = len(self._items)
|
| 215 |
+
self._items = [
|
| 216 |
+
item
|
| 217 |
+
for item in self._items
|
| 218 |
+
if not (item.get("id") == normalized_id and (role is None or item.get("role") == role))
|
| 219 |
+
]
|
| 220 |
+
if len(self._items) == before:
|
| 221 |
+
return False
|
| 222 |
+
self._save()
|
| 223 |
+
return True
|
| 224 |
+
|
| 225 |
+
def authenticate(self, raw_key: str) -> dict[str, object] | None:
|
| 226 |
+
candidate = self._clean(raw_key)
|
| 227 |
+
if not candidate:
|
| 228 |
+
return None
|
| 229 |
+
candidate_hash = _hash_key(candidate)
|
| 230 |
+
with self._lock:
|
| 231 |
+
for index, item in enumerate(self._items):
|
| 232 |
+
if not bool(item.get("enabled", True)):
|
| 233 |
+
continue
|
| 234 |
+
stored_hash = self._clean(item.get("key_hash"))
|
| 235 |
+
if not stored_hash or not hmac.compare_digest(stored_hash, candidate_hash):
|
| 236 |
+
continue
|
| 237 |
+
next_item = dict(item)
|
| 238 |
+
now = datetime.now(timezone.utc)
|
| 239 |
+
next_item["last_used_at"] = now.isoformat()
|
| 240 |
+
self._items[index] = next_item
|
| 241 |
+
item_id = self._clean(next_item.get("id"))
|
| 242 |
+
last_flush_at = self._last_used_flush_at.get(item_id)
|
| 243 |
+
if last_flush_at is None or (now - last_flush_at).total_seconds() >= 60:
|
| 244 |
+
try:
|
| 245 |
+
self._save()
|
| 246 |
+
self._last_used_flush_at[item_id] = now
|
| 247 |
+
except Exception:
|
| 248 |
+
pass
|
| 249 |
+
return self._public_item(next_item)
|
| 250 |
+
return None
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
auth_service = AuthService(config.get_storage_backend())
|
services/backup_service.py
ADDED
|
@@ -0,0 +1,673 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
import hmac
|
| 5 |
+
import io
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
import random
|
| 9 |
+
import subprocess
|
| 10 |
+
import tarfile
|
| 11 |
+
import threading
|
| 12 |
+
from datetime import UTC, datetime
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from urllib.parse import quote, urlencode
|
| 15 |
+
|
| 16 |
+
from curl_cffi import requests
|
| 17 |
+
|
| 18 |
+
from services.config import BASE_DIR, CONFIG_FILE, DATA_DIR, config, load_backup_state, save_backup_state
|
| 19 |
+
from services.image_storage_service import IMAGE_INDEX_FILE
|
| 20 |
+
from services.image_tags_service import TAGS_FILE
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _utc_now() -> datetime:
|
| 24 |
+
return datetime.now(UTC)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _iso_now() -> str:
|
| 28 |
+
return _utc_now().replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _clean(value: object) -> str:
|
| 32 |
+
return str(value or "").strip()
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _sha256_hex(value: bytes) -> str:
|
| 36 |
+
return hashlib.sha256(value).hexdigest()
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _is_backup_object(key: object) -> bool:
|
| 40 |
+
name = _clean(key).rsplit("/", 1)[-1]
|
| 41 |
+
return name.startswith("backup-") and (name.endswith(".tar.gz") or name.endswith(".tar.gz.enc"))
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _hmac_sha256(key: bytes, message: str) -> bytes:
|
| 45 |
+
return hmac.new(key, message.encode("utf-8"), hashlib.sha256).digest()
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _openssl_encrypt(data: bytes, passphrase: str) -> bytes:
|
| 49 |
+
env = dict(os.environ)
|
| 50 |
+
env["CHATGPT2API_BACKUP_PASSPHRASE"] = passphrase
|
| 51 |
+
try:
|
| 52 |
+
result = subprocess.run(
|
| 53 |
+
[
|
| 54 |
+
"openssl",
|
| 55 |
+
"enc",
|
| 56 |
+
"-aes-256-cbc",
|
| 57 |
+
"-pbkdf2",
|
| 58 |
+
"-salt",
|
| 59 |
+
"-md",
|
| 60 |
+
"sha256",
|
| 61 |
+
"-pass",
|
| 62 |
+
"env:CHATGPT2API_BACKUP_PASSPHRASE",
|
| 63 |
+
],
|
| 64 |
+
input=data,
|
| 65 |
+
stdout=subprocess.PIPE,
|
| 66 |
+
stderr=subprocess.PIPE,
|
| 67 |
+
check=True,
|
| 68 |
+
env=env,
|
| 69 |
+
)
|
| 70 |
+
except FileNotFoundError as exc:
|
| 71 |
+
raise BackupError("当前环境缺少 openssl,无法执行加密备份") from exc
|
| 72 |
+
except subprocess.CalledProcessError as exc:
|
| 73 |
+
detail = (exc.stderr or b"").decode("utf-8", errors="replace").strip()
|
| 74 |
+
raise BackupError(f"加密备份失败:{detail or 'openssl 执行失败'}") from exc
|
| 75 |
+
return result.stdout
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _openssl_decrypt(data: bytes, passphrase: str) -> bytes:
|
| 79 |
+
env = dict(os.environ)
|
| 80 |
+
env["CHATGPT2API_BACKUP_PASSPHRASE"] = passphrase
|
| 81 |
+
try:
|
| 82 |
+
result = subprocess.run(
|
| 83 |
+
[
|
| 84 |
+
"openssl",
|
| 85 |
+
"enc",
|
| 86 |
+
"-d",
|
| 87 |
+
"-aes-256-cbc",
|
| 88 |
+
"-pbkdf2",
|
| 89 |
+
"-md",
|
| 90 |
+
"sha256",
|
| 91 |
+
"-pass",
|
| 92 |
+
"env:CHATGPT2API_BACKUP_PASSPHRASE",
|
| 93 |
+
],
|
| 94 |
+
input=data,
|
| 95 |
+
stdout=subprocess.PIPE,
|
| 96 |
+
stderr=subprocess.PIPE,
|
| 97 |
+
check=True,
|
| 98 |
+
env=env,
|
| 99 |
+
)
|
| 100 |
+
except FileNotFoundError as exc:
|
| 101 |
+
raise BackupError("当前环境缺少 openssl,无法解密备份内容") from exc
|
| 102 |
+
except subprocess.CalledProcessError as exc:
|
| 103 |
+
detail = (exc.stderr or b"").decode("utf-8", errors="replace").strip()
|
| 104 |
+
raise BackupError(f"解密备份失败:{detail or 'openssl 执行失败'}") from exc
|
| 105 |
+
return result.stdout
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _guess_content_type(name: str) -> str:
|
| 109 |
+
if name.endswith(".json"):
|
| 110 |
+
return "application/json"
|
| 111 |
+
if name.endswith(".jsonl"):
|
| 112 |
+
return "application/x-ndjson"
|
| 113 |
+
if name.endswith(".tar.gz"):
|
| 114 |
+
return "application/gzip"
|
| 115 |
+
if name.endswith(".gz"):
|
| 116 |
+
return "application/gzip"
|
| 117 |
+
return "application/octet-stream"
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _json_bytes(value: object) -> bytes:
|
| 121 |
+
return json.dumps(value, ensure_ascii=False, indent=2).encode("utf-8")
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _count_items(value: object) -> int:
|
| 125 |
+
if isinstance(value, list):
|
| 126 |
+
return len(value)
|
| 127 |
+
if isinstance(value, dict):
|
| 128 |
+
return len(value)
|
| 129 |
+
return 0
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
class BackupError(RuntimeError):
|
| 133 |
+
pass
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
class CloudflareR2Client:
|
| 137 |
+
def __init__(self, settings: dict[str, object]) -> None:
|
| 138 |
+
self.account_id = _clean(settings.get("account_id"))
|
| 139 |
+
self.access_key_id = _clean(settings.get("access_key_id"))
|
| 140 |
+
self.secret_access_key = _clean(settings.get("secret_access_key"))
|
| 141 |
+
self.bucket = _clean(settings.get("bucket"))
|
| 142 |
+
self.prefix = _clean(settings.get("prefix")) or "backups"
|
| 143 |
+
self.session = requests.Session(impersonate="chrome", verify=True)
|
| 144 |
+
|
| 145 |
+
def validate(self) -> None:
|
| 146 |
+
missing = []
|
| 147 |
+
if not self.account_id:
|
| 148 |
+
missing.append("Account ID")
|
| 149 |
+
if not self.access_key_id:
|
| 150 |
+
missing.append("Access Key ID")
|
| 151 |
+
if not self.secret_access_key:
|
| 152 |
+
missing.append("Secret Access Key")
|
| 153 |
+
if not self.bucket:
|
| 154 |
+
missing.append("Bucket")
|
| 155 |
+
if missing:
|
| 156 |
+
raise BackupError(f"R2 配置不完整:缺少 {'、'.join(missing)}")
|
| 157 |
+
|
| 158 |
+
@property
|
| 159 |
+
def endpoint(self) -> str:
|
| 160 |
+
return f"https://{self.account_id}.r2.cloudflarestorage.com"
|
| 161 |
+
|
| 162 |
+
def _aws_v4_headers(
|
| 163 |
+
self,
|
| 164 |
+
method: str,
|
| 165 |
+
path: str,
|
| 166 |
+
*,
|
| 167 |
+
query: dict[str, str] | None = None,
|
| 168 |
+
body: bytes = b"",
|
| 169 |
+
extra_headers: dict[str, str] | None = None,
|
| 170 |
+
) -> tuple[str, dict[str, str]]:
|
| 171 |
+
now = _utc_now()
|
| 172 |
+
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
|
| 173 |
+
date_stamp = now.strftime("%Y%m%d")
|
| 174 |
+
encoded_query = urlencode(sorted((query or {}).items()))
|
| 175 |
+
payload_hash = _sha256_hex(body)
|
| 176 |
+
host = f"{self.account_id}.r2.cloudflarestorage.com"
|
| 177 |
+
headers = {
|
| 178 |
+
"host": host,
|
| 179 |
+
"x-amz-content-sha256": payload_hash,
|
| 180 |
+
"x-amz-date": amz_date,
|
| 181 |
+
}
|
| 182 |
+
if extra_headers:
|
| 183 |
+
for key, value in extra_headers.items():
|
| 184 |
+
headers[key.lower()] = value.strip()
|
| 185 |
+
sorted_items = sorted((key.lower(), " ".join(str(value).strip().split())) for key, value in headers.items())
|
| 186 |
+
canonical_headers = "".join(f"{key}:{value}\n" for key, value in sorted_items)
|
| 187 |
+
signed_headers = ";".join(key for key, _ in sorted_items)
|
| 188 |
+
canonical_request = "\n".join([
|
| 189 |
+
method.upper(),
|
| 190 |
+
path,
|
| 191 |
+
encoded_query,
|
| 192 |
+
canonical_headers,
|
| 193 |
+
signed_headers,
|
| 194 |
+
payload_hash,
|
| 195 |
+
])
|
| 196 |
+
credential_scope = f"{date_stamp}/auto/s3/aws4_request"
|
| 197 |
+
string_to_sign = "\n".join([
|
| 198 |
+
"AWS4-HMAC-SHA256",
|
| 199 |
+
amz_date,
|
| 200 |
+
credential_scope,
|
| 201 |
+
_sha256_hex(canonical_request.encode("utf-8")),
|
| 202 |
+
])
|
| 203 |
+
k_date = _hmac_sha256(("AWS4" + self.secret_access_key).encode("utf-8"), date_stamp)
|
| 204 |
+
k_region = hmac.new(k_date, b"auto", hashlib.sha256).digest()
|
| 205 |
+
k_service = hmac.new(k_region, b"s3", hashlib.sha256).digest()
|
| 206 |
+
k_signing = hmac.new(k_service, b"aws4_request", hashlib.sha256).digest()
|
| 207 |
+
signature = hmac.new(k_signing, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
|
| 208 |
+
authorization = (
|
| 209 |
+
"AWS4-HMAC-SHA256 "
|
| 210 |
+
f"Credential={self.access_key_id}/{credential_scope}, "
|
| 211 |
+
f"SignedHeaders={signed_headers}, "
|
| 212 |
+
f"Signature={signature}"
|
| 213 |
+
)
|
| 214 |
+
request_headers = {key: value for key, value in headers.items()}
|
| 215 |
+
request_headers["authorization"] = authorization
|
| 216 |
+
return encoded_query, request_headers
|
| 217 |
+
|
| 218 |
+
def _request(
|
| 219 |
+
self,
|
| 220 |
+
method: str,
|
| 221 |
+
key: str = "",
|
| 222 |
+
*,
|
| 223 |
+
query: dict[str, str] | None = None,
|
| 224 |
+
body: bytes = b"",
|
| 225 |
+
extra_headers: dict[str, str] | None = None,
|
| 226 |
+
timeout: float = 60.0,
|
| 227 |
+
):
|
| 228 |
+
object_path = f"/{self.bucket}"
|
| 229 |
+
if key:
|
| 230 |
+
object_path += f"/{quote(key.lstrip('/'), safe='/')}"
|
| 231 |
+
encoded_query, headers = self._aws_v4_headers(method, object_path, query=query, body=body, extra_headers=extra_headers)
|
| 232 |
+
url = f"{self.endpoint}{object_path}"
|
| 233 |
+
if encoded_query:
|
| 234 |
+
url += f"?{encoded_query}"
|
| 235 |
+
response = self.session.request(method.upper(), url, headers=headers, data=body, timeout=timeout)
|
| 236 |
+
return response
|
| 237 |
+
|
| 238 |
+
def test_connection(self) -> dict[str, object]:
|
| 239 |
+
self.validate()
|
| 240 |
+
response = self._request("GET", query={"list-type": "2", "max-keys": "1"}, timeout=30.0)
|
| 241 |
+
if response.status_code >= 400:
|
| 242 |
+
raise BackupError(f"连接 R2 失败:HTTP {response.status_code}")
|
| 243 |
+
return {"ok": True, "status": int(response.status_code)}
|
| 244 |
+
|
| 245 |
+
def upload_bytes(self, key: str, payload: bytes, *, content_type: str, metadata: dict[str, str] | None = None) -> dict[str, object]:
|
| 246 |
+
headers = {"content-type": content_type}
|
| 247 |
+
if metadata:
|
| 248 |
+
for item_key, item_value in metadata.items():
|
| 249 |
+
headers[f"x-amz-meta-{item_key}"] = str(item_value)
|
| 250 |
+
response = self._request("PUT", key, body=payload, extra_headers=headers)
|
| 251 |
+
if response.status_code >= 400:
|
| 252 |
+
raise BackupError(f"上传备份失败:HTTP {response.status_code}")
|
| 253 |
+
return {"key": key, "etag": str(response.headers.get("etag") or "").strip('"')}
|
| 254 |
+
|
| 255 |
+
def delete_object(self, key: str) -> None:
|
| 256 |
+
response = self._request("DELETE", key, timeout=30.0)
|
| 257 |
+
if response.status_code >= 400 and response.status_code != 404:
|
| 258 |
+
raise BackupError(f"删除备份失败:HTTP {response.status_code}")
|
| 259 |
+
|
| 260 |
+
def download_bytes(self, key: str) -> bytes:
|
| 261 |
+
response = self._request("GET", key, timeout=60.0)
|
| 262 |
+
if response.status_code >= 400:
|
| 263 |
+
raise BackupError(f"读取备份失败:HTTP {response.status_code}")
|
| 264 |
+
return bytes(response.content or b"")
|
| 265 |
+
|
| 266 |
+
def list_objects(self) -> list[dict[str, object]]:
|
| 267 |
+
items: list[dict[str, object]] = []
|
| 268 |
+
continuation = ""
|
| 269 |
+
while True:
|
| 270 |
+
query = {"list-type": "2", "prefix": f"{self.prefix.rstrip('/')}/", "max-keys": "1000"}
|
| 271 |
+
if continuation:
|
| 272 |
+
query["continuation-token"] = continuation
|
| 273 |
+
response = self._request("GET", query=query, timeout=30.0)
|
| 274 |
+
if response.status_code >= 400:
|
| 275 |
+
raise BackupError(f"获取备份列表失败:HTTP {response.status_code}")
|
| 276 |
+
text = response.text
|
| 277 |
+
for block in text.split("<Contents>")[1:]:
|
| 278 |
+
key = _clean(block.split("<Key>", 1)[1].split("</Key>", 1)[0]) if "<Key>" in block else ""
|
| 279 |
+
if not key:
|
| 280 |
+
continue
|
| 281 |
+
size_text = _clean(block.split("<Size>", 1)[1].split("</Size>", 1)[0]) if "<Size>" in block else "0"
|
| 282 |
+
updated = _clean(block.split("<LastModified>", 1)[1].split("</LastModified>", 1)[0]) if "<LastModified>" in block else ""
|
| 283 |
+
items.append({
|
| 284 |
+
"key": key,
|
| 285 |
+
"size": int(size_text or 0),
|
| 286 |
+
"updated_at": updated,
|
| 287 |
+
})
|
| 288 |
+
truncated = "<IsTruncated>true</IsTruncated>" in text
|
| 289 |
+
if not truncated:
|
| 290 |
+
break
|
| 291 |
+
if "<NextContinuationToken>" not in text:
|
| 292 |
+
break
|
| 293 |
+
continuation = _clean(text.split("<NextContinuationToken>", 1)[1].split("</NextContinuationToken>", 1)[0])
|
| 294 |
+
if not continuation:
|
| 295 |
+
break
|
| 296 |
+
items.sort(key=lambda item: str(item.get("updated_at") or ""), reverse=True)
|
| 297 |
+
return items
|
| 298 |
+
|
| 299 |
+
def close(self) -> None:
|
| 300 |
+
self.session.close()
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
class BackupService:
|
| 304 |
+
def __init__(self) -> None:
|
| 305 |
+
self._lock = threading.RLock()
|
| 306 |
+
self._stop_event = threading.Event()
|
| 307 |
+
self._thread: threading.Thread | None = None
|
| 308 |
+
self._running = False
|
| 309 |
+
|
| 310 |
+
def start(self) -> None:
|
| 311 |
+
with self._lock:
|
| 312 |
+
if self._thread and self._thread.is_alive():
|
| 313 |
+
return
|
| 314 |
+
self._stop_event.clear()
|
| 315 |
+
self._thread = threading.Thread(target=self._run, daemon=True, name="r2-backup-scheduler")
|
| 316 |
+
self._thread.start()
|
| 317 |
+
|
| 318 |
+
def stop(self) -> None:
|
| 319 |
+
with self._lock:
|
| 320 |
+
self._stop_event.set()
|
| 321 |
+
thread = self._thread
|
| 322 |
+
self._thread = None
|
| 323 |
+
if thread and thread.is_alive():
|
| 324 |
+
thread.join(timeout=2)
|
| 325 |
+
|
| 326 |
+
def _run(self) -> None:
|
| 327 |
+
while not self._stop_event.is_set():
|
| 328 |
+
try:
|
| 329 |
+
self.run_scheduled_backup_if_needed()
|
| 330 |
+
except Exception:
|
| 331 |
+
pass
|
| 332 |
+
self._stop_event.wait(30)
|
| 333 |
+
|
| 334 |
+
def run_scheduled_backup_if_needed(self) -> None:
|
| 335 |
+
settings = config.get_backup_settings()
|
| 336 |
+
if not settings.get("enabled"):
|
| 337 |
+
return
|
| 338 |
+
state = self.get_status()
|
| 339 |
+
if state.get("running"):
|
| 340 |
+
return
|
| 341 |
+
interval_minutes = int(settings.get("interval_minutes") or 360)
|
| 342 |
+
last_finished_raw = _clean(state.get("last_finished_at"))
|
| 343 |
+
if last_finished_raw:
|
| 344 |
+
try:
|
| 345 |
+
last_finished = datetime.fromisoformat(last_finished_raw.replace("Z", "+00:00"))
|
| 346 |
+
elapsed = (_utc_now() - last_finished.astimezone(UTC)).total_seconds()
|
| 347 |
+
if elapsed < interval_minutes * 60:
|
| 348 |
+
return
|
| 349 |
+
except Exception:
|
| 350 |
+
pass
|
| 351 |
+
self.run_backup(trigger="schedule")
|
| 352 |
+
|
| 353 |
+
def get_status(self) -> dict[str, object]:
|
| 354 |
+
return {
|
| 355 |
+
**load_backup_state(),
|
| 356 |
+
"running": self._running,
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
def is_configured(self) -> bool:
|
| 360 |
+
settings = config.get_backup_settings()
|
| 361 |
+
return all([
|
| 362 |
+
_clean(settings.get("account_id")),
|
| 363 |
+
_clean(settings.get("access_key_id")),
|
| 364 |
+
_clean(settings.get("secret_access_key")),
|
| 365 |
+
_clean(settings.get("bucket")),
|
| 366 |
+
])
|
| 367 |
+
|
| 368 |
+
def get_settings(self) -> dict[str, object]:
|
| 369 |
+
settings = dict(config.get_backup_settings())
|
| 370 |
+
settings["secret_access_key"] = "********" if _clean(settings.get("secret_access_key")) else ""
|
| 371 |
+
settings["passphrase"] = "********" if _clean(settings.get("passphrase")) else ""
|
| 372 |
+
return settings
|
| 373 |
+
|
| 374 |
+
def update_settings(self, payload: dict[str, object]) -> dict[str, object]:
|
| 375 |
+
current = config.get_backup_settings()
|
| 376 |
+
merged = dict(current)
|
| 377 |
+
merged.update(dict(payload or {}))
|
| 378 |
+
if "include" in payload and isinstance(payload.get("include"), dict):
|
| 379 |
+
include = dict(current.get("include") or {})
|
| 380 |
+
include.update(payload.get("include") or {})
|
| 381 |
+
merged["include"] = include
|
| 382 |
+
if payload.get("secret_access_key") == "********":
|
| 383 |
+
merged["secret_access_key"] = current.get("secret_access_key")
|
| 384 |
+
if payload.get("passphrase") == "********":
|
| 385 |
+
merged["passphrase"] = current.get("passphrase")
|
| 386 |
+
updated = config.update({"backup": merged})
|
| 387 |
+
return dict(updated.get("backup") or {})
|
| 388 |
+
|
| 389 |
+
def test_connection(self) -> dict[str, object]:
|
| 390 |
+
client = CloudflareR2Client(config.get_backup_settings())
|
| 391 |
+
try:
|
| 392 |
+
return client.test_connection()
|
| 393 |
+
finally:
|
| 394 |
+
client.close()
|
| 395 |
+
|
| 396 |
+
def list_backups(self) -> list[dict[str, object]]:
|
| 397 |
+
if not self.is_configured():
|
| 398 |
+
return []
|
| 399 |
+
client = CloudflareR2Client(config.get_backup_settings())
|
| 400 |
+
try:
|
| 401 |
+
items = client.list_objects()
|
| 402 |
+
finally:
|
| 403 |
+
client.close()
|
| 404 |
+
parsed: list[dict[str, object]] = []
|
| 405 |
+
for item in items:
|
| 406 |
+
key = _clean(item.get("key"))
|
| 407 |
+
name = key.rsplit("/", 1)[-1]
|
| 408 |
+
encrypted = name.endswith(".enc")
|
| 409 |
+
parsed.append({
|
| 410 |
+
"key": key,
|
| 411 |
+
"name": name,
|
| 412 |
+
"size": int(item.get("size") or 0),
|
| 413 |
+
"updated_at": item.get("updated_at"),
|
| 414 |
+
"encrypted": encrypted,
|
| 415 |
+
})
|
| 416 |
+
return parsed
|
| 417 |
+
|
| 418 |
+
def delete_backup(self, key: str) -> None:
|
| 419 |
+
candidate = _clean(key)
|
| 420 |
+
if not candidate:
|
| 421 |
+
raise BackupError("备份对象 key 不能为空")
|
| 422 |
+
client = CloudflareR2Client(config.get_backup_settings())
|
| 423 |
+
try:
|
| 424 |
+
client.delete_object(candidate)
|
| 425 |
+
finally:
|
| 426 |
+
client.close()
|
| 427 |
+
|
| 428 |
+
def download_backup(self, key: str) -> dict[str, object]:
|
| 429 |
+
candidate = _clean(key)
|
| 430 |
+
if not candidate:
|
| 431 |
+
raise BackupError("备份对象 key 不能为空")
|
| 432 |
+
client = CloudflareR2Client(config.get_backup_settings())
|
| 433 |
+
try:
|
| 434 |
+
payload = client.download_bytes(candidate)
|
| 435 |
+
finally:
|
| 436 |
+
client.close()
|
| 437 |
+
name = candidate.rsplit("/", 1)[-1] or "backup.bin"
|
| 438 |
+
if candidate.endswith(".enc"):
|
| 439 |
+
passphrase = _clean(config.get_backup_settings().get("passphrase"))
|
| 440 |
+
if not passphrase:
|
| 441 |
+
raise BackupError("当前未配置加密口令,无法下载并解密已加密备份")
|
| 442 |
+
payload = _openssl_decrypt(payload, passphrase)
|
| 443 |
+
if name.endswith(".enc"):
|
| 444 |
+
name = name[:-4] or "backup.tar.gz"
|
| 445 |
+
return {
|
| 446 |
+
"key": candidate,
|
| 447 |
+
"name": name,
|
| 448 |
+
"content_type": _guess_content_type(name),
|
| 449 |
+
"payload": payload,
|
| 450 |
+
"size": len(payload),
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
def get_backup_detail(self, key: str) -> dict[str, object]:
|
| 454 |
+
candidate = _clean(key)
|
| 455 |
+
if not candidate:
|
| 456 |
+
raise BackupError("备份对象 key 不能为空")
|
| 457 |
+
client = CloudflareR2Client(config.get_backup_settings())
|
| 458 |
+
try:
|
| 459 |
+
payload = client.download_bytes(candidate)
|
| 460 |
+
finally:
|
| 461 |
+
client.close()
|
| 462 |
+
detail = self._decode_backup_payload(candidate, payload)
|
| 463 |
+
detail["key"] = candidate
|
| 464 |
+
detail["name"] = candidate.rsplit("/", 1)[-1]
|
| 465 |
+
detail["encrypted"] = candidate.endswith(".enc")
|
| 466 |
+
return detail
|
| 467 |
+
|
| 468 |
+
def run_backup(self, *, trigger: str = "manual") -> dict[str, object]:
|
| 469 |
+
with self._lock:
|
| 470 |
+
current = self.get_status()
|
| 471 |
+
if self._running:
|
| 472 |
+
raise BackupError("当前已有备份任务正在执行")
|
| 473 |
+
started_at = _iso_now()
|
| 474 |
+
self._running = True
|
| 475 |
+
save_backup_state({
|
| 476 |
+
"last_started_at": started_at,
|
| 477 |
+
"last_finished_at": current.get("last_finished_at"),
|
| 478 |
+
"last_status": "idle",
|
| 479 |
+
"last_error": None,
|
| 480 |
+
"last_object_key": current.get("last_object_key"),
|
| 481 |
+
})
|
| 482 |
+
try:
|
| 483 |
+
result = self._run_backup_once(trigger=trigger)
|
| 484 |
+
save_backup_state({
|
| 485 |
+
"last_started_at": started_at,
|
| 486 |
+
"last_finished_at": _iso_now(),
|
| 487 |
+
"last_status": "success",
|
| 488 |
+
"last_error": None,
|
| 489 |
+
"last_object_key": result["key"],
|
| 490 |
+
})
|
| 491 |
+
return result
|
| 492 |
+
except Exception as exc:
|
| 493 |
+
save_backup_state({
|
| 494 |
+
"last_started_at": started_at,
|
| 495 |
+
"last_finished_at": _iso_now(),
|
| 496 |
+
"last_status": "error",
|
| 497 |
+
"last_error": str(exc) or exc.__class__.__name__,
|
| 498 |
+
"last_object_key": current.get("last_object_key"),
|
| 499 |
+
})
|
| 500 |
+
raise
|
| 501 |
+
finally:
|
| 502 |
+
self._running = False
|
| 503 |
+
|
| 504 |
+
def _run_backup_once(self, *, trigger: str) -> dict[str, object]:
|
| 505 |
+
settings = config.get_backup_settings()
|
| 506 |
+
client = CloudflareR2Client(settings)
|
| 507 |
+
client.validate()
|
| 508 |
+
payload_raw = self._build_backup_archive(settings, trigger=trigger)
|
| 509 |
+
encrypted = bool(settings.get("encrypt"))
|
| 510 |
+
if encrypted:
|
| 511 |
+
passphrase = _clean(settings.get("passphrase"))
|
| 512 |
+
if not passphrase:
|
| 513 |
+
raise BackupError("已启用备份加密,但未设置加密口令")
|
| 514 |
+
payload = _openssl_encrypt(payload_raw, passphrase)
|
| 515 |
+
suffix = ".tar.gz.enc"
|
| 516 |
+
else:
|
| 517 |
+
payload = payload_raw
|
| 518 |
+
suffix = ".tar.gz"
|
| 519 |
+
timestamp = _utc_now().strftime("%Y%m%dT%H%M%SZ")
|
| 520 |
+
random_tag = f"{random.randint(0, 0xFFFF):04x}"
|
| 521 |
+
object_key = f"{client.prefix.rstrip('/')}/backup-{timestamp}-{random_tag}{suffix}"
|
| 522 |
+
metadata = {
|
| 523 |
+
"created-at": _iso_now(),
|
| 524 |
+
"encrypted": "true" if encrypted else "false",
|
| 525 |
+
"trigger": trigger,
|
| 526 |
+
}
|
| 527 |
+
try:
|
| 528 |
+
result = client.upload_bytes(object_key, payload, content_type="application/octet-stream", metadata=metadata)
|
| 529 |
+
self._apply_rotation(client, int(settings.get("rotation_keep") or 0))
|
| 530 |
+
return {
|
| 531 |
+
"key": result["key"],
|
| 532 |
+
"size": len(payload),
|
| 533 |
+
"encrypted": encrypted,
|
| 534 |
+
}
|
| 535 |
+
finally:
|
| 536 |
+
client.close()
|
| 537 |
+
|
| 538 |
+
def _decode_backup_payload(self, key: str, payload: bytes) -> dict[str, object]:
|
| 539 |
+
decoded = payload
|
| 540 |
+
if key.endswith(".enc"):
|
| 541 |
+
passphrase = _clean(config.get_backup_settings().get("passphrase"))
|
| 542 |
+
if not passphrase:
|
| 543 |
+
raise BackupError("当前未配置加密口令,无法查看已加密备份")
|
| 544 |
+
decoded = _openssl_decrypt(decoded, passphrase)
|
| 545 |
+
return self._decode_archive_detail(decoded)
|
| 546 |
+
|
| 547 |
+
def _apply_rotation(self, client: CloudflareR2Client, keep: int) -> None:
|
| 548 |
+
if keep <= 0:
|
| 549 |
+
return
|
| 550 |
+
items = [item for item in client.list_objects() if _is_backup_object(item.get("key"))]
|
| 551 |
+
if len(items) <= keep:
|
| 552 |
+
return
|
| 553 |
+
for item in items[keep:]:
|
| 554 |
+
key = _clean(item.get("key"))
|
| 555 |
+
if key:
|
| 556 |
+
client.delete_object(key)
|
| 557 |
+
|
| 558 |
+
def _decode_archive_detail(self, payload: bytes) -> dict[str, object]:
|
| 559 |
+
files: list[dict[str, object]] = []
|
| 560 |
+
snapshots: list[dict[str, object]] = []
|
| 561 |
+
metadata: dict[str, object] = {}
|
| 562 |
+
try:
|
| 563 |
+
with tarfile.open(fileobj=io.BytesIO(payload), mode="r:gz") as archive:
|
| 564 |
+
members = [member for member in archive.getmembers() if member.isfile()]
|
| 565 |
+
for member in members:
|
| 566 |
+
extracted = archive.extractfile(member)
|
| 567 |
+
if extracted is None:
|
| 568 |
+
continue
|
| 569 |
+
raw = extracted.read()
|
| 570 |
+
name = member.name
|
| 571 |
+
if name == "backup-metadata.json":
|
| 572 |
+
try:
|
| 573 |
+
parsed = json.loads(raw.decode("utf-8"))
|
| 574 |
+
if isinstance(parsed, dict):
|
| 575 |
+
metadata = parsed
|
| 576 |
+
except Exception:
|
| 577 |
+
metadata = {}
|
| 578 |
+
continue
|
| 579 |
+
if name.startswith("snapshots/") and name.endswith(".json"):
|
| 580 |
+
count = 0
|
| 581 |
+
try:
|
| 582 |
+
parsed_snapshot = json.loads(raw.decode("utf-8"))
|
| 583 |
+
count = _count_items(parsed_snapshot)
|
| 584 |
+
except Exception:
|
| 585 |
+
count = 0
|
| 586 |
+
snapshots.append({
|
| 587 |
+
"name": name.removeprefix("snapshots/").removesuffix(".json"),
|
| 588 |
+
"count": count,
|
| 589 |
+
})
|
| 590 |
+
continue
|
| 591 |
+
files.append({
|
| 592 |
+
"name": name,
|
| 593 |
+
"exists": True,
|
| 594 |
+
"content_type": _guess_content_type(name),
|
| 595 |
+
"size": len(raw),
|
| 596 |
+
"sha256": _sha256_hex(raw),
|
| 597 |
+
})
|
| 598 |
+
except tarfile.TarError as exc:
|
| 599 |
+
raise BackupError("解析备份压缩包失败,备份可能已损坏") from exc
|
| 600 |
+
files.sort(key=lambda item: str(item.get("name") or ""))
|
| 601 |
+
snapshots.sort(key=lambda item: str(item.get("name") or ""))
|
| 602 |
+
return {
|
| 603 |
+
"created_at": metadata.get("created_at"),
|
| 604 |
+
"trigger": metadata.get("trigger"),
|
| 605 |
+
"app_version": metadata.get("app_version"),
|
| 606 |
+
"storage_backend": metadata.get("storage_backend"),
|
| 607 |
+
"files": files,
|
| 608 |
+
"snapshots": snapshots,
|
| 609 |
+
}
|
| 610 |
+
|
| 611 |
+
def _build_backup_archive(self, settings: dict[str, object], *, trigger: str) -> bytes:
|
| 612 |
+
include = settings.get("include") if isinstance(settings.get("include"), dict) else {}
|
| 613 |
+
metadata = {
|
| 614 |
+
"version": 2,
|
| 615 |
+
"created_at": _iso_now(),
|
| 616 |
+
"trigger": trigger,
|
| 617 |
+
"app_version": config.app_version,
|
| 618 |
+
"storage_backend": config.get_storage_backend().get_backend_info(),
|
| 619 |
+
}
|
| 620 |
+
buffer = io.BytesIO()
|
| 621 |
+
with tarfile.open(fileobj=buffer, mode="w:gz") as archive:
|
| 622 |
+
self._add_bytes_to_archive(archive, "backup-metadata.json", _json_bytes(metadata))
|
| 623 |
+
if include.get("config"):
|
| 624 |
+
self._add_file_to_archive(archive, CONFIG_FILE, "config.json")
|
| 625 |
+
if include.get("register"):
|
| 626 |
+
self._add_file_to_archive(archive, DATA_DIR / "register.json", "data/register.json")
|
| 627 |
+
if include.get("cpa"):
|
| 628 |
+
self._add_file_to_archive(archive, DATA_DIR / "cpa_config.json", "data/cpa_config.json")
|
| 629 |
+
if include.get("sub2api"):
|
| 630 |
+
self._add_file_to_archive(archive, DATA_DIR / "sub2api_config.json", "data/sub2api_config.json")
|
| 631 |
+
if include.get("logs"):
|
| 632 |
+
self._add_file_to_archive(archive, DATA_DIR / "logs.jsonl", "data/logs.jsonl")
|
| 633 |
+
if include.get("image_tasks"):
|
| 634 |
+
self._add_file_to_archive(archive, DATA_DIR / "image_tasks.json", "data/image_tasks.json")
|
| 635 |
+
self._add_file_to_archive(archive, IMAGE_INDEX_FILE, "data/image_index.json")
|
| 636 |
+
if include.get("accounts_snapshot"):
|
| 637 |
+
self._add_bytes_to_archive(
|
| 638 |
+
archive,
|
| 639 |
+
"snapshots/accounts.json",
|
| 640 |
+
_json_bytes(config.get_storage_backend().load_accounts()),
|
| 641 |
+
)
|
| 642 |
+
if include.get("auth_keys_snapshot"):
|
| 643 |
+
self._add_bytes_to_archive(
|
| 644 |
+
archive,
|
| 645 |
+
"snapshots/auth_keys.json",
|
| 646 |
+
_json_bytes(config.get_storage_backend().load_auth_keys()),
|
| 647 |
+
)
|
| 648 |
+
if include.get("images"):
|
| 649 |
+
self._add_file_to_archive(archive, TAGS_FILE, "data/image_tags.json")
|
| 650 |
+
self._add_directory_to_archive(archive, config.images_dir, "data/images")
|
| 651 |
+
return buffer.getvalue()
|
| 652 |
+
|
| 653 |
+
def _add_bytes_to_archive(self, archive: tarfile.TarFile, name: str, payload: bytes) -> None:
|
| 654 |
+
info = tarfile.TarInfo(name=name)
|
| 655 |
+
info.size = len(payload)
|
| 656 |
+
info.mtime = int(_utc_now().timestamp())
|
| 657 |
+
archive.addfile(info, io.BytesIO(payload))
|
| 658 |
+
|
| 659 |
+
def _add_file_to_archive(self, archive: tarfile.TarFile, source: Path, arcname: str) -> None:
|
| 660 |
+
if not source.exists() or not source.is_file():
|
| 661 |
+
return
|
| 662 |
+
archive.add(source, arcname=arcname)
|
| 663 |
+
|
| 664 |
+
def _add_directory_to_archive(self, archive: tarfile.TarFile, source_dir: Path, arcname_root: str) -> None:
|
| 665 |
+
if not source_dir.exists() or not source_dir.is_dir():
|
| 666 |
+
return
|
| 667 |
+
for path in sorted(source_dir.rglob("*")):
|
| 668 |
+
if path.is_file():
|
| 669 |
+
relative = path.relative_to(source_dir).as_posix()
|
| 670 |
+
archive.add(path, arcname=f"{arcname_root}/{relative}")
|
| 671 |
+
|
| 672 |
+
|
| 673 |
+
backup_service = BackupService()
|
services/config.py
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
import sys
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
import time
|
| 9 |
+
|
| 10 |
+
from services.storage.base import StorageBackend
|
| 11 |
+
|
| 12 |
+
BASE_DIR = Path(__file__).resolve().parents[1]
|
| 13 |
+
DATA_DIR = BASE_DIR / "data"
|
| 14 |
+
CONFIG_FILE = BASE_DIR / "config.json"
|
| 15 |
+
VERSION_FILE = BASE_DIR / "VERSION"
|
| 16 |
+
BACKUP_STATE_FILE = DATA_DIR / "backup_state.json"
|
| 17 |
+
|
| 18 |
+
DEFAULT_BACKUP_INCLUDE = {
|
| 19 |
+
"config": True,
|
| 20 |
+
"register": True,
|
| 21 |
+
"cpa": True,
|
| 22 |
+
"sub2api": True,
|
| 23 |
+
"logs": True,
|
| 24 |
+
"image_tasks": True,
|
| 25 |
+
"accounts_snapshot": True,
|
| 26 |
+
"auth_keys_snapshot": True,
|
| 27 |
+
"images": False,
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
DEFAULT_IMAGE_STORAGE = {
|
| 31 |
+
"enabled": False,
|
| 32 |
+
"mode": "local",
|
| 33 |
+
"webdav_url": "",
|
| 34 |
+
"webdav_username": "",
|
| 35 |
+
"webdav_password": "",
|
| 36 |
+
"webdav_root_path": "chatgpt2api/images",
|
| 37 |
+
"public_base_url": "",
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _normalize_bool(value: object, default: bool = False) -> bool:
|
| 42 |
+
if isinstance(value, str):
|
| 43 |
+
lowered = value.strip().lower()
|
| 44 |
+
if lowered in {"1", "true", "yes", "on"}:
|
| 45 |
+
return True
|
| 46 |
+
if lowered in {"0", "false", "no", "off"}:
|
| 47 |
+
return False
|
| 48 |
+
if value is None:
|
| 49 |
+
return default
|
| 50 |
+
return bool(value)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _normalize_positive_int(value: object, default: int, minimum: int = 0) -> int:
|
| 54 |
+
try:
|
| 55 |
+
normalized = int(value)
|
| 56 |
+
except (TypeError, ValueError):
|
| 57 |
+
normalized = default
|
| 58 |
+
return max(minimum, normalized)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _normalize_backup_include(value: object) -> dict[str, bool]:
|
| 62 |
+
source = value if isinstance(value, dict) else {}
|
| 63 |
+
normalized = dict(DEFAULT_BACKUP_INCLUDE)
|
| 64 |
+
for key in normalized:
|
| 65 |
+
normalized[key] = _normalize_bool(source.get(key), normalized[key])
|
| 66 |
+
return normalized
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _normalize_backup_settings(value: object) -> dict[str, object]:
|
| 70 |
+
source = value if isinstance(value, dict) else {}
|
| 71 |
+
return {
|
| 72 |
+
"enabled": _normalize_bool(source.get("enabled"), False),
|
| 73 |
+
"provider": "cloudflare_r2",
|
| 74 |
+
"account_id": str(source.get("account_id") or "").strip(),
|
| 75 |
+
"access_key_id": str(source.get("access_key_id") or "").strip(),
|
| 76 |
+
"secret_access_key": str(source.get("secret_access_key") or "").strip(),
|
| 77 |
+
"bucket": str(source.get("bucket") or "").strip(),
|
| 78 |
+
"prefix": str(source.get("prefix") or "backups").strip().strip("/") or "backups",
|
| 79 |
+
"interval_minutes": _normalize_positive_int(source.get("interval_minutes"), 360, 1),
|
| 80 |
+
"rotation_keep": _normalize_positive_int(source.get("rotation_keep"), 10, 0),
|
| 81 |
+
"encrypt": _normalize_bool(source.get("encrypt"), False),
|
| 82 |
+
"passphrase": str(source.get("passphrase") or "").strip(),
|
| 83 |
+
"include": _normalize_backup_include(source.get("include")),
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _normalize_backup_state(value: object) -> dict[str, object]:
|
| 88 |
+
source = value if isinstance(value, dict) else {}
|
| 89 |
+
return {
|
| 90 |
+
"last_started_at": str(source.get("last_started_at") or "").strip() or None,
|
| 91 |
+
"last_finished_at": str(source.get("last_finished_at") or "").strip() or None,
|
| 92 |
+
"last_status": str(source.get("last_status") or "idle").strip() or "idle",
|
| 93 |
+
"last_error": str(source.get("last_error") or "").strip() or None,
|
| 94 |
+
"last_object_key": str(source.get("last_object_key") or "").strip() or None,
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _normalize_image_storage_settings(value: object) -> dict[str, object]:
|
| 99 |
+
source = value if isinstance(value, dict) else {}
|
| 100 |
+
mode = str(source.get("mode") or "local").strip().lower()
|
| 101 |
+
if mode not in {"local", "webdav", "both"}:
|
| 102 |
+
mode = "local"
|
| 103 |
+
enabled = _normalize_bool(source.get("enabled"), False)
|
| 104 |
+
if not enabled:
|
| 105 |
+
mode = "local"
|
| 106 |
+
root_path = str(source.get("webdav_root_path") or DEFAULT_IMAGE_STORAGE["webdav_root_path"]).strip().strip("/")
|
| 107 |
+
return {
|
| 108 |
+
"enabled": enabled,
|
| 109 |
+
"mode": mode,
|
| 110 |
+
"webdav_url": str(source.get("webdav_url") or "").strip().rstrip("/"),
|
| 111 |
+
"webdav_username": str(source.get("webdav_username") or "").strip(),
|
| 112 |
+
"webdav_password": str(source.get("webdav_password") or "").strip(),
|
| 113 |
+
"webdav_root_path": root_path or str(DEFAULT_IMAGE_STORAGE["webdav_root_path"]),
|
| 114 |
+
"public_base_url": str(source.get("public_base_url") or "").strip().rstrip("/"),
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def _validate_image_storage_settings(settings: dict[str, object]) -> None:
|
| 119 |
+
if not _normalize_bool(settings.get("enabled"), False):
|
| 120 |
+
return
|
| 121 |
+
if not str(settings.get("webdav_url") or "").strip():
|
| 122 |
+
raise ValueError("启用 WebDAV 图片存储后必须填写 WebDAV URL")
|
| 123 |
+
if not str(settings.get("webdav_password") or "").strip():
|
| 124 |
+
raise ValueError("启用 WebDAV 图片存储后必须填写 WebDAV 密码")
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
@dataclass(frozen=True)
|
| 128 |
+
class LoadedSettings:
|
| 129 |
+
auth_key: str
|
| 130 |
+
refresh_account_interval_minute: int
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def _normalize_auth_key(value: object) -> str:
|
| 134 |
+
return str(value or "").strip()
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def _is_invalid_auth_key(value: object) -> bool:
|
| 138 |
+
return _normalize_auth_key(value) == ""
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def _read_json_object(path: Path, *, name: str) -> dict[str, object]:
|
| 142 |
+
if not path.exists():
|
| 143 |
+
return {}
|
| 144 |
+
if path.is_dir():
|
| 145 |
+
print(
|
| 146 |
+
f"Warning: {name} at '{path}' is a directory, ignoring it and falling back to other configuration sources.",
|
| 147 |
+
file=sys.stderr,
|
| 148 |
+
)
|
| 149 |
+
return {}
|
| 150 |
+
try:
|
| 151 |
+
data = json.loads(path.read_text(encoding="utf-8"))
|
| 152 |
+
except Exception:
|
| 153 |
+
return {}
|
| 154 |
+
return data if isinstance(data, dict) else {}
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def _load_settings() -> LoadedSettings:
|
| 158 |
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
| 159 |
+
raw_config = _read_json_object(CONFIG_FILE, name="config.json")
|
| 160 |
+
auth_key = _normalize_auth_key(os.getenv("CHATGPT2API_AUTH_KEY") or raw_config.get("auth-key"))
|
| 161 |
+
if _is_invalid_auth_key(auth_key):
|
| 162 |
+
raise ValueError(
|
| 163 |
+
"❌ auth-key 未设置!\n"
|
| 164 |
+
"请在环境变量 CHATGPT2API_AUTH_KEY 中设置,或者在 config.json 中填写 auth-key。"
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
try:
|
| 168 |
+
refresh_interval = int(raw_config.get("refresh_account_interval_minute", 5))
|
| 169 |
+
except (TypeError, ValueError):
|
| 170 |
+
refresh_interval = 5
|
| 171 |
+
|
| 172 |
+
return LoadedSettings(
|
| 173 |
+
auth_key=auth_key,
|
| 174 |
+
refresh_account_interval_minute=refresh_interval,
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
class ConfigStore:
|
| 179 |
+
def __init__(self, path: Path):
|
| 180 |
+
self.path = path
|
| 181 |
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
| 182 |
+
self.data = self._load()
|
| 183 |
+
self._storage_backend: StorageBackend | None = None
|
| 184 |
+
if _is_invalid_auth_key(self.auth_key):
|
| 185 |
+
raise ValueError(
|
| 186 |
+
"❌ auth-key 未设置!\n"
|
| 187 |
+
"请按以下任意一种方式解决:\n"
|
| 188 |
+
"1. 在 Render 的 Environment 变量中添加:\n"
|
| 189 |
+
" CHATGPT2API_AUTH_KEY = your_real_auth_key\n"
|
| 190 |
+
"2. 或者在 config.json 中填写:\n"
|
| 191 |
+
' "auth-key": "your_real_auth_key"'
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
def _load(self) -> dict[str, object]:
|
| 195 |
+
return _read_json_object(self.path, name="config.json")
|
| 196 |
+
|
| 197 |
+
def _save(self) -> None:
|
| 198 |
+
self.path.write_text(json.dumps(self.data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
| 199 |
+
|
| 200 |
+
@property
|
| 201 |
+
def auth_key(self) -> str:
|
| 202 |
+
return _normalize_auth_key(os.getenv("CHATGPT2API_AUTH_KEY") or self.data.get("auth-key"))
|
| 203 |
+
|
| 204 |
+
@property
|
| 205 |
+
def accounts_file(self) -> Path:
|
| 206 |
+
return DATA_DIR / "accounts.json"
|
| 207 |
+
|
| 208 |
+
@property
|
| 209 |
+
def refresh_account_interval_minute(self) -> int:
|
| 210 |
+
try:
|
| 211 |
+
return int(self.data.get("refresh_account_interval_minute", 5))
|
| 212 |
+
except (TypeError, ValueError):
|
| 213 |
+
return 5
|
| 214 |
+
|
| 215 |
+
@property
|
| 216 |
+
def image_retention_days(self) -> int:
|
| 217 |
+
try:
|
| 218 |
+
return max(1, int(self.data.get("image_retention_days", 30)))
|
| 219 |
+
except (TypeError, ValueError):
|
| 220 |
+
return 30
|
| 221 |
+
|
| 222 |
+
@property
|
| 223 |
+
def image_poll_timeout_secs(self) -> int:
|
| 224 |
+
try:
|
| 225 |
+
return max(1, int(self.data.get("image_poll_timeout_secs", 120)))
|
| 226 |
+
except (TypeError, ValueError):
|
| 227 |
+
return 120
|
| 228 |
+
|
| 229 |
+
@property
|
| 230 |
+
def image_poll_interval_secs(self) -> float:
|
| 231 |
+
try:
|
| 232 |
+
return max(0.5, float(self.data.get("image_poll_interval_secs", 10.0)))
|
| 233 |
+
except (TypeError, ValueError):
|
| 234 |
+
return 10.0
|
| 235 |
+
|
| 236 |
+
@property
|
| 237 |
+
def image_poll_initial_wait_secs(self) -> float:
|
| 238 |
+
"""Image generation upstream takes ~30s; polling immediately wastes requests
|
| 239 |
+
and trips a transient 429. Default 10s gives the conversation document time
|
| 240 |
+
to commit before the first poll."""
|
| 241 |
+
try:
|
| 242 |
+
return max(0.0, float(self.data.get("image_poll_initial_wait_secs", 10.0)))
|
| 243 |
+
except (TypeError, ValueError):
|
| 244 |
+
return 10.0
|
| 245 |
+
|
| 246 |
+
@property
|
| 247 |
+
def image_account_concurrency(self) -> int:
|
| 248 |
+
try:
|
| 249 |
+
return max(1, int(self.data.get("image_account_concurrency", 3)))
|
| 250 |
+
except (TypeError, ValueError):
|
| 251 |
+
return 3
|
| 252 |
+
|
| 253 |
+
@property
|
| 254 |
+
def auto_remove_invalid_accounts(self) -> bool:
|
| 255 |
+
value = self.data.get("auto_remove_invalid_accounts", False)
|
| 256 |
+
if isinstance(value, str):
|
| 257 |
+
return value.strip().lower() in {"1", "true", "yes", "on"}
|
| 258 |
+
return bool(value)
|
| 259 |
+
|
| 260 |
+
@property
|
| 261 |
+
def auto_remove_rate_limited_accounts(self) -> bool:
|
| 262 |
+
value = self.data.get("auto_remove_rate_limited_accounts", False)
|
| 263 |
+
if isinstance(value, str):
|
| 264 |
+
return value.strip().lower() in {"1", "true", "yes", "on"}
|
| 265 |
+
return bool(value)
|
| 266 |
+
|
| 267 |
+
@property
|
| 268 |
+
def log_levels(self) -> list[str]:
|
| 269 |
+
levels = self.data.get("log_levels")
|
| 270 |
+
if not isinstance(levels, list):
|
| 271 |
+
return []
|
| 272 |
+
allowed = {"debug", "info", "warning", "error"}
|
| 273 |
+
return [level for item in levels if (level := str(item or "").strip().lower()) in allowed]
|
| 274 |
+
|
| 275 |
+
@property
|
| 276 |
+
def sensitive_words(self) -> list[str]:
|
| 277 |
+
words = self.data.get("sensitive_words")
|
| 278 |
+
return [word for item in words if (word := str(item or "").strip())] if isinstance(words, list) else []
|
| 279 |
+
|
| 280 |
+
@property
|
| 281 |
+
def ai_review(self) -> dict[str, object]:
|
| 282 |
+
value = self.data.get("ai_review")
|
| 283 |
+
return value if isinstance(value, dict) else {}
|
| 284 |
+
|
| 285 |
+
@property
|
| 286 |
+
def global_system_prompt(self) -> str:
|
| 287 |
+
return str(self.data.get("global_system_prompt") or "").strip()
|
| 288 |
+
|
| 289 |
+
@property
|
| 290 |
+
def images_dir(self) -> Path:
|
| 291 |
+
path = DATA_DIR / "images"
|
| 292 |
+
path.mkdir(parents=True, exist_ok=True)
|
| 293 |
+
return path
|
| 294 |
+
|
| 295 |
+
@property
|
| 296 |
+
def image_thumbnails_dir(self) -> Path:
|
| 297 |
+
path = DATA_DIR / "image_thumbnails"
|
| 298 |
+
path.mkdir(parents=True, exist_ok=True)
|
| 299 |
+
return path
|
| 300 |
+
|
| 301 |
+
def cleanup_old_images(self) -> int:
|
| 302 |
+
cutoff = time.time() - self.image_retention_days * 86400
|
| 303 |
+
removed = 0
|
| 304 |
+
for path in self.images_dir.rglob("*"):
|
| 305 |
+
if path.is_file() and path.stat().st_mtime < cutoff:
|
| 306 |
+
path.unlink()
|
| 307 |
+
removed += 1
|
| 308 |
+
for path in sorted((p for p in self.images_dir.rglob("*") if p.is_dir()), key=lambda p: len(p.parts), reverse=True):
|
| 309 |
+
try:
|
| 310 |
+
path.rmdir()
|
| 311 |
+
except OSError:
|
| 312 |
+
pass
|
| 313 |
+
return removed
|
| 314 |
+
|
| 315 |
+
@property
|
| 316 |
+
def base_url(self) -> str:
|
| 317 |
+
return str(
|
| 318 |
+
os.getenv("CHATGPT2API_BASE_URL")
|
| 319 |
+
or self.data.get("base_url")
|
| 320 |
+
or ""
|
| 321 |
+
).strip().rstrip("/")
|
| 322 |
+
|
| 323 |
+
@property
|
| 324 |
+
def app_version(self) -> str:
|
| 325 |
+
try:
|
| 326 |
+
value = VERSION_FILE.read_text(encoding="utf-8").strip()
|
| 327 |
+
except FileNotFoundError:
|
| 328 |
+
return "0.0.0"
|
| 329 |
+
return value or "0.0.0"
|
| 330 |
+
|
| 331 |
+
def get(self) -> dict[str, object]:
|
| 332 |
+
data = dict(self.data)
|
| 333 |
+
data["refresh_account_interval_minute"] = self.refresh_account_interval_minute
|
| 334 |
+
data["image_retention_days"] = self.image_retention_days
|
| 335 |
+
data["image_poll_timeout_secs"] = self.image_poll_timeout_secs
|
| 336 |
+
data["image_poll_interval_secs"] = self.image_poll_interval_secs
|
| 337 |
+
data["image_poll_initial_wait_secs"] = self.image_poll_initial_wait_secs
|
| 338 |
+
data["image_account_concurrency"] = self.image_account_concurrency
|
| 339 |
+
data["auto_remove_invalid_accounts"] = self.auto_remove_invalid_accounts
|
| 340 |
+
data["auto_remove_rate_limited_accounts"] = self.auto_remove_rate_limited_accounts
|
| 341 |
+
data["log_levels"] = self.log_levels
|
| 342 |
+
data["sensitive_words"] = self.sensitive_words
|
| 343 |
+
data["ai_review"] = self.ai_review
|
| 344 |
+
data["global_system_prompt"] = self.global_system_prompt
|
| 345 |
+
data["backup"] = self.get_backup_settings()
|
| 346 |
+
data["image_storage"] = self.get_image_storage_settings()
|
| 347 |
+
data.pop("auth-key", None)
|
| 348 |
+
return data
|
| 349 |
+
|
| 350 |
+
def get_proxy_settings(self) -> str:
|
| 351 |
+
return str(self.data.get("proxy") or "").strip()
|
| 352 |
+
|
| 353 |
+
def update(self, data: dict[str, object]) -> dict[str, object]:
|
| 354 |
+
next_data = dict(self.data)
|
| 355 |
+
next_data.update(dict(data or {}))
|
| 356 |
+
if "backup" in next_data:
|
| 357 |
+
next_data["backup"] = _normalize_backup_settings(next_data.get("backup"))
|
| 358 |
+
if "image_storage" in next_data:
|
| 359 |
+
next_data["image_storage"] = _normalize_image_storage_settings(next_data.get("image_storage"))
|
| 360 |
+
_validate_image_storage_settings(next_data["image_storage"])
|
| 361 |
+
next_data.pop("backup_state", None)
|
| 362 |
+
self.data = next_data
|
| 363 |
+
self._save()
|
| 364 |
+
return self.get()
|
| 365 |
+
|
| 366 |
+
def get_backup_settings(self) -> dict[str, object]:
|
| 367 |
+
return _normalize_backup_settings(self.data.get("backup"))
|
| 368 |
+
|
| 369 |
+
def get_image_storage_settings(self) -> dict[str, object]:
|
| 370 |
+
return _normalize_image_storage_settings(self.data.get("image_storage"))
|
| 371 |
+
|
| 372 |
+
def get_storage_backend(self) -> StorageBackend:
|
| 373 |
+
"""获取存储后端实例(单例)"""
|
| 374 |
+
if self._storage_backend is None:
|
| 375 |
+
from services.storage.factory import create_storage_backend
|
| 376 |
+
self._storage_backend = create_storage_backend(DATA_DIR)
|
| 377 |
+
return self._storage_backend
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
def load_backup_state() -> dict[str, object]:
|
| 381 |
+
return _normalize_backup_state(_read_json_object(BACKUP_STATE_FILE, name="backup_state.json"))
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
def save_backup_state(state: dict[str, object]) -> dict[str, object]:
|
| 385 |
+
normalized = _normalize_backup_state(state)
|
| 386 |
+
BACKUP_STATE_FILE.write_text(json.dumps(normalized, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
| 387 |
+
return normalized
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
config = ConfigStore(CONFIG_FILE)
|
services/content_filter.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
|
| 5 |
+
from curl_cffi import requests
|
| 6 |
+
from fastapi import HTTPException
|
| 7 |
+
|
| 8 |
+
from services.config import config
|
| 9 |
+
from services.proxy_service import proxy_settings
|
| 10 |
+
from utils.log import logger
|
| 11 |
+
|
| 12 |
+
DEFAULT_REVIEW_PROMPT = "判断用户请求是否允许。只回答 ALLOW 或 REJECT。"
|
| 13 |
+
|
| 14 |
+
# Strip base64 image data URIs before review: a text-only review model can't
|
| 15 |
+
# analyze image bytes, and a single inlined image easily blows past the token
|
| 16 |
+
# budget of the upstream review service.
|
| 17 |
+
_BASE64_DATA_URI = re.compile(r"data:[\w/.+;-]+;base64,[A-Za-z0-9+/=]+")
|
| 18 |
+
|
| 19 |
+
# Cap aligned to the upstream review service's max context. If text still
|
| 20 |
+
# exceeds the cap after base64 stripping, keep equal head/tail halves so both
|
| 21 |
+
# the system prompt and the most recent user message survive.
|
| 22 |
+
_MAX_REVIEW_TEXT_LEN = 100_000
|
| 23 |
+
_TRUNCATION_MARKER = "\n…[truncated]…\n"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _text(value: object) -> str:
|
| 27 |
+
if isinstance(value, str):
|
| 28 |
+
return value
|
| 29 |
+
if isinstance(value, list):
|
| 30 |
+
return "\n".join(_text(item) for item in value)
|
| 31 |
+
if isinstance(value, dict):
|
| 32 |
+
return "\n".join(_text(value.get(key)) for key in ("text", "input_text", "content", "input", "instructions", "system", "prompt"))
|
| 33 |
+
return ""
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def request_text(*values: object) -> str:
|
| 37 |
+
return "\n".join(part for value in values if (part := _text(value).strip()))
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _sanitize_for_review(text: str) -> tuple[str, dict[str, int]]:
|
| 41 |
+
"""Strip base64 data URIs and truncate to the review-service context limit.
|
| 42 |
+
|
| 43 |
+
Returns (sanitized_text, stats) where stats carries base64_blocks_stripped
|
| 44 |
+
and truncated_chars so callers can emit structured logs.
|
| 45 |
+
"""
|
| 46 |
+
sanitized, base64_blocks_stripped = _BASE64_DATA_URI.subn("[image]", text)
|
| 47 |
+
truncated_chars = 0
|
| 48 |
+
if len(sanitized) > _MAX_REVIEW_TEXT_LEN:
|
| 49 |
+
# Reserve marker space so the result stays within the cap.
|
| 50 |
+
half = (_MAX_REVIEW_TEXT_LEN - len(_TRUNCATION_MARKER)) // 2
|
| 51 |
+
truncated_chars = len(sanitized) - 2 * half
|
| 52 |
+
sanitized = sanitized[:half] + _TRUNCATION_MARKER + sanitized[-half:]
|
| 53 |
+
stats = {
|
| 54 |
+
"base64_blocks_stripped": base64_blocks_stripped,
|
| 55 |
+
"truncated_chars": truncated_chars,
|
| 56 |
+
}
|
| 57 |
+
return sanitized, stats
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _extract_review_decision(data: object) -> str | None:
|
| 61 |
+
"""Defensively pull the decision text out of the review service response.
|
| 62 |
+
|
| 63 |
+
Returns None when the response shape doesn't match the OpenAI chat-completion
|
| 64 |
+
contract (e.g. {"error": ...} with no choices). The caller treats None as
|
| 65 |
+
"undecided" and applies the configured fail-open policy.
|
| 66 |
+
"""
|
| 67 |
+
if not isinstance(data, dict):
|
| 68 |
+
return None
|
| 69 |
+
choices = data.get("choices")
|
| 70 |
+
if not isinstance(choices, list) or not choices:
|
| 71 |
+
return None
|
| 72 |
+
first = choices[0]
|
| 73 |
+
if not isinstance(first, dict):
|
| 74 |
+
return None
|
| 75 |
+
message = first.get("message")
|
| 76 |
+
if not isinstance(message, dict):
|
| 77 |
+
return None
|
| 78 |
+
content = message.get("content")
|
| 79 |
+
if content is None:
|
| 80 |
+
return None
|
| 81 |
+
return str(content).strip().lower()
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _is_allow_decision(decision: str) -> bool:
|
| 85 |
+
return decision.startswith(("allow", "pass", "true", "yes", "通过", "允许", "安全"))
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _is_reject_decision(decision: str) -> bool:
|
| 89 |
+
return decision.startswith(("reject", "deny", "block", "false", "no", "拒绝", "不允许", "违规", "禁止"))
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _resolve_fail_open(review: dict) -> bool:
|
| 93 |
+
"""Resolve fail_open from review config. Defaults to True."""
|
| 94 |
+
value = review.get("fail_open")
|
| 95 |
+
if value is None:
|
| 96 |
+
return True
|
| 97 |
+
if isinstance(value, bool):
|
| 98 |
+
return value
|
| 99 |
+
if isinstance(value, str):
|
| 100 |
+
return value.strip().lower() in {"1", "true", "yes", "on"}
|
| 101 |
+
return bool(value)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def check_request(text: str) -> None:
|
| 105 |
+
text = str(text or "")
|
| 106 |
+
if not text.strip():
|
| 107 |
+
return
|
| 108 |
+
# Local sensitive-word match runs on the raw text (cheap, no network).
|
| 109 |
+
for word in config.sensitive_words:
|
| 110 |
+
if word in text:
|
| 111 |
+
raise HTTPException(status_code=400, detail={"error": "检测到敏感词,拒绝本次任务"})
|
| 112 |
+
review = config.ai_review
|
| 113 |
+
if not review.get("enabled"):
|
| 114 |
+
return
|
| 115 |
+
base_url = str(review.get("base_url") or "").strip().rstrip("/")
|
| 116 |
+
api_key = str(review.get("api_key") or "").strip()
|
| 117 |
+
model = str(review.get("model") or "").strip()
|
| 118 |
+
if not base_url or not api_key or not model:
|
| 119 |
+
raise HTTPException(status_code=400, detail={"error": "ai review config is incomplete"})
|
| 120 |
+
|
| 121 |
+
fail_open = _resolve_fail_open(review)
|
| 122 |
+
|
| 123 |
+
review_text, sanitize_stats = _sanitize_for_review(text)
|
| 124 |
+
if sanitize_stats["base64_blocks_stripped"] or sanitize_stats["truncated_chars"]:
|
| 125 |
+
logger.info({
|
| 126 |
+
"event": "ai_review_text_sanitized",
|
| 127 |
+
"original_text_len": len(text),
|
| 128 |
+
"review_text_len": len(review_text),
|
| 129 |
+
**sanitize_stats,
|
| 130 |
+
})
|
| 131 |
+
prompt = str(review.get("prompt") or DEFAULT_REVIEW_PROMPT).strip()
|
| 132 |
+
content = f"{prompt}\n\n用户请求:\n{review_text}\n\n只回答 ALLOW 或 REJECT。"
|
| 133 |
+
|
| 134 |
+
# fail_open=True (default): on upstream failure or ambiguous reply, let the
|
| 135 |
+
# request through. The review is a soft safety net; one missed review is
|
| 136 |
+
# preferable to a 5xx storm when the review service is flaky. Set
|
| 137 |
+
# config.ai_review.fail_open=false for strict-compliance deployments.
|
| 138 |
+
def _on_failure(event_payload: dict) -> None:
|
| 139 |
+
logger.warning(event_payload)
|
| 140 |
+
if not fail_open:
|
| 141 |
+
raise HTTPException(
|
| 142 |
+
status_code=503,
|
| 143 |
+
detail={"error": "AI 审核服务暂时不可用,请稍后重试"},
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
try:
|
| 147 |
+
response = requests.post(
|
| 148 |
+
f"{base_url}/v1/chat/completions",
|
| 149 |
+
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
| 150 |
+
json={"model": model, "messages": [{"role": "user", "content": content}], "temperature": 0},
|
| 151 |
+
timeout=60,
|
| 152 |
+
**proxy_settings.build_session_kwargs(),
|
| 153 |
+
)
|
| 154 |
+
except Exception as exc:
|
| 155 |
+
_on_failure({
|
| 156 |
+
"event": "ai_review_request_failed",
|
| 157 |
+
"error": str(exc),
|
| 158 |
+
"error_type": exc.__class__.__name__,
|
| 159 |
+
"review_text_len": len(review_text),
|
| 160 |
+
"original_text_len": len(text),
|
| 161 |
+
})
|
| 162 |
+
return
|
| 163 |
+
|
| 164 |
+
try:
|
| 165 |
+
data = response.json()
|
| 166 |
+
except Exception as exc:
|
| 167 |
+
_on_failure({
|
| 168 |
+
"event": "ai_review_response_not_json",
|
| 169 |
+
"status_code": response.status_code,
|
| 170 |
+
"body_preview": str(response.text or "")[:200],
|
| 171 |
+
"error": str(exc),
|
| 172 |
+
})
|
| 173 |
+
return
|
| 174 |
+
|
| 175 |
+
decision = _extract_review_decision(data)
|
| 176 |
+
if decision is None:
|
| 177 |
+
_on_failure({
|
| 178 |
+
"event": "ai_review_malformed_response",
|
| 179 |
+
"status_code": response.status_code,
|
| 180 |
+
"body_preview": str(data)[:300],
|
| 181 |
+
"review_text_len": len(review_text),
|
| 182 |
+
"original_text_len": len(text),
|
| 183 |
+
})
|
| 184 |
+
return
|
| 185 |
+
|
| 186 |
+
if _is_allow_decision(decision):
|
| 187 |
+
return
|
| 188 |
+
if _is_reject_decision(decision):
|
| 189 |
+
raise HTTPException(status_code=400, detail={"error": "AI 审核未通过,拒绝本次任务"})
|
| 190 |
+
# Ambiguous decisions (e.g. "MAYBE", empty content) fall back to fail-open policy.
|
| 191 |
+
_on_failure({
|
| 192 |
+
"event": "ai_review_ambiguous_decision",
|
| 193 |
+
"decision": decision[:100],
|
| 194 |
+
"review_text_len": len(review_text),
|
| 195 |
+
})
|
| 196 |
+
return
|
services/cpa_service.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CLIProxyAPI integration for browsing remote auth files and importing selected tokens."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import threading
|
| 7 |
+
import uuid
|
| 8 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 9 |
+
from datetime import datetime, timezone
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from threading import Lock
|
| 12 |
+
|
| 13 |
+
from curl_cffi.requests import Session
|
| 14 |
+
|
| 15 |
+
from services.account_service import account_service
|
| 16 |
+
from services.config import DATA_DIR
|
| 17 |
+
from services.proxy_service import proxy_settings
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
CPA_CONFIG_FILE = DATA_DIR / "cpa_config.json"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _new_id() -> str:
|
| 24 |
+
return uuid.uuid4().hex[:12]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _now_iso() -> str:
|
| 28 |
+
return datetime.now(timezone.utc).isoformat()
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _normalize_import_job(raw: object, *, fail_unfinished: bool) -> dict | None:
|
| 32 |
+
if not isinstance(raw, dict):
|
| 33 |
+
return None
|
| 34 |
+
status = str(raw.get("status") or "failed").strip() or "failed"
|
| 35 |
+
if fail_unfinished and status in {"pending", "running"}:
|
| 36 |
+
status = "failed"
|
| 37 |
+
return {
|
| 38 |
+
"job_id": str(raw.get("job_id") or uuid.uuid4().hex).strip(),
|
| 39 |
+
"status": status,
|
| 40 |
+
"created_at": str(raw.get("created_at") or _now_iso()).strip() or _now_iso(),
|
| 41 |
+
"updated_at": str(raw.get("updated_at") or raw.get("created_at") or _now_iso()).strip() or _now_iso(),
|
| 42 |
+
"total": int(raw.get("total") or 0),
|
| 43 |
+
"completed": int(raw.get("completed") or 0),
|
| 44 |
+
"added": int(raw.get("added") or 0),
|
| 45 |
+
"skipped": int(raw.get("skipped") or 0),
|
| 46 |
+
"refreshed": int(raw.get("refreshed") or 0),
|
| 47 |
+
"failed": int(raw.get("failed") or 0),
|
| 48 |
+
"errors": raw.get("errors") if isinstance(raw.get("errors"), list) else [],
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _normalize_pool(raw: dict) -> dict:
|
| 53 |
+
return {
|
| 54 |
+
"id": str(raw.get("id") or _new_id()).strip(),
|
| 55 |
+
"name": str(raw.get("name") or "").strip(),
|
| 56 |
+
"base_url": str(raw.get("base_url") or "").strip(),
|
| 57 |
+
"secret_key": str(raw.get("secret_key") or "").strip(),
|
| 58 |
+
"import_job": _normalize_import_job(raw.get("import_job"), fail_unfinished=True),
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _management_headers(secret_key: str) -> dict[str, str]:
|
| 63 |
+
return {
|
| 64 |
+
"Authorization": f"Bearer {secret_key}",
|
| 65 |
+
"Accept": "application/json",
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class CPAConfig:
|
| 70 |
+
def __init__(self, store_file: Path):
|
| 71 |
+
self._store_file = store_file
|
| 72 |
+
self._lock = Lock()
|
| 73 |
+
self._pools: list[dict] = self._load()
|
| 74 |
+
|
| 75 |
+
def _load(self) -> list[dict]:
|
| 76 |
+
if not self._store_file.exists():
|
| 77 |
+
return []
|
| 78 |
+
try:
|
| 79 |
+
raw = json.loads(self._store_file.read_text(encoding="utf-8"))
|
| 80 |
+
if isinstance(raw, dict) and "base_url" in raw:
|
| 81 |
+
pool = _normalize_pool(raw)
|
| 82 |
+
return [pool] if pool["base_url"] else []
|
| 83 |
+
if isinstance(raw, list):
|
| 84 |
+
return [_normalize_pool(item) for item in raw if isinstance(item, dict)]
|
| 85 |
+
except Exception:
|
| 86 |
+
pass
|
| 87 |
+
return []
|
| 88 |
+
|
| 89 |
+
def _save(self) -> None:
|
| 90 |
+
self._store_file.parent.mkdir(parents=True, exist_ok=True)
|
| 91 |
+
self._store_file.write_text(json.dumps(self._pools, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
| 92 |
+
|
| 93 |
+
def list_pools(self) -> list[dict]:
|
| 94 |
+
with self._lock:
|
| 95 |
+
return [dict(pool) for pool in self._pools]
|
| 96 |
+
|
| 97 |
+
def get_pool(self, pool_id: str) -> dict | None:
|
| 98 |
+
with self._lock:
|
| 99 |
+
for pool in self._pools:
|
| 100 |
+
if pool["id"] == pool_id:
|
| 101 |
+
return dict(pool)
|
| 102 |
+
return None
|
| 103 |
+
|
| 104 |
+
def add_pool(self, name: str, base_url: str, secret_key: str) -> dict:
|
| 105 |
+
pool = _normalize_pool({"id": _new_id(), "name": name, "base_url": base_url, "secret_key": secret_key})
|
| 106 |
+
with self._lock:
|
| 107 |
+
self._pools.append(pool)
|
| 108 |
+
self._save()
|
| 109 |
+
return dict(pool)
|
| 110 |
+
|
| 111 |
+
def update_pool(self, pool_id: str, updates: dict) -> dict | None:
|
| 112 |
+
with self._lock:
|
| 113 |
+
for index, pool in enumerate(self._pools):
|
| 114 |
+
if pool["id"] != pool_id:
|
| 115 |
+
continue
|
| 116 |
+
merged = {**pool, **{key: value for key, value in updates.items() if value is not None}, "id": pool_id}
|
| 117 |
+
self._pools[index] = _normalize_pool(merged)
|
| 118 |
+
self._save()
|
| 119 |
+
return dict(self._pools[index])
|
| 120 |
+
return None
|
| 121 |
+
|
| 122 |
+
def delete_pool(self, pool_id: str) -> bool:
|
| 123 |
+
with self._lock:
|
| 124 |
+
before = len(self._pools)
|
| 125 |
+
self._pools = [pool for pool in self._pools if pool["id"] != pool_id]
|
| 126 |
+
if len(self._pools) < before:
|
| 127 |
+
self._save()
|
| 128 |
+
return True
|
| 129 |
+
return False
|
| 130 |
+
|
| 131 |
+
def set_import_job(self, pool_id: str, import_job: dict | None) -> dict | None:
|
| 132 |
+
with self._lock:
|
| 133 |
+
for index, pool in enumerate(self._pools):
|
| 134 |
+
if pool["id"] != pool_id:
|
| 135 |
+
continue
|
| 136 |
+
next_pool = dict(pool)
|
| 137 |
+
next_pool["import_job"] = _normalize_import_job(import_job, fail_unfinished=False)
|
| 138 |
+
self._pools[index] = next_pool
|
| 139 |
+
self._save()
|
| 140 |
+
return dict(next_pool)
|
| 141 |
+
return None
|
| 142 |
+
|
| 143 |
+
def get_import_job(self, pool_id: str) -> dict | None:
|
| 144 |
+
with self._lock:
|
| 145 |
+
for pool in self._pools:
|
| 146 |
+
if pool["id"] == pool_id:
|
| 147 |
+
job = pool.get("import_job")
|
| 148 |
+
return dict(job) if isinstance(job, dict) else None
|
| 149 |
+
return None
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def list_remote_files(pool: dict) -> list[dict]:
|
| 153 |
+
base_url = str(pool.get("base_url") or "").strip()
|
| 154 |
+
secret_key = str(pool.get("secret_key") or "").strip()
|
| 155 |
+
if not base_url or not secret_key:
|
| 156 |
+
return []
|
| 157 |
+
|
| 158 |
+
url = f"{base_url.rstrip('/')}/v0/management/auth-files"
|
| 159 |
+
session = Session(**proxy_settings.build_session_kwargs(verify=True))
|
| 160 |
+
try:
|
| 161 |
+
response = session.get(url, headers=_management_headers(secret_key), timeout=30)
|
| 162 |
+
if not response.ok:
|
| 163 |
+
raise RuntimeError(f"remote list failed: HTTP {response.status_code}")
|
| 164 |
+
payload = response.json()
|
| 165 |
+
finally:
|
| 166 |
+
session.close()
|
| 167 |
+
|
| 168 |
+
files = payload.get("files") if isinstance(payload, dict) else None
|
| 169 |
+
if not isinstance(files, list):
|
| 170 |
+
raise RuntimeError("remote list payload is invalid")
|
| 171 |
+
|
| 172 |
+
items: list[dict] = []
|
| 173 |
+
for item in files:
|
| 174 |
+
if not isinstance(item, dict):
|
| 175 |
+
continue
|
| 176 |
+
name = str(item.get("name") or "").strip()
|
| 177 |
+
email = str(item.get("email") or item.get("account") or "").strip()
|
| 178 |
+
if not name:
|
| 179 |
+
continue
|
| 180 |
+
items.append({"name": name, "email": email})
|
| 181 |
+
return items
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def fetch_remote_access_token(pool: dict, file_name: str) -> tuple[str | None, str | None]:
|
| 185 |
+
base_url = str(pool.get("base_url") or "").strip()
|
| 186 |
+
secret_key = str(pool.get("secret_key") or "").strip()
|
| 187 |
+
file_name = str(file_name or "").strip()
|
| 188 |
+
if not base_url or not secret_key or not file_name:
|
| 189 |
+
return None, "invalid request"
|
| 190 |
+
|
| 191 |
+
url = f"{base_url.rstrip('/')}/v0/management/auth-files/download"
|
| 192 |
+
session = Session(**proxy_settings.build_session_kwargs(verify=True))
|
| 193 |
+
try:
|
| 194 |
+
response = session.get(url, headers=_management_headers(secret_key), params={"name": file_name}, timeout=30)
|
| 195 |
+
if not response.ok:
|
| 196 |
+
return None, f"HTTP {response.status_code}"
|
| 197 |
+
payload = response.json()
|
| 198 |
+
except Exception as exc:
|
| 199 |
+
return None, str(exc)
|
| 200 |
+
finally:
|
| 201 |
+
session.close()
|
| 202 |
+
|
| 203 |
+
if not isinstance(payload, dict):
|
| 204 |
+
return None, "invalid payload"
|
| 205 |
+
|
| 206 |
+
access_token = str(payload.get("access_token") or "").strip()
|
| 207 |
+
if not access_token:
|
| 208 |
+
return None, "missing access_token"
|
| 209 |
+
return access_token, None
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
class CPAImportService:
|
| 213 |
+
def __init__(self, cpa_config: CPAConfig):
|
| 214 |
+
self._config = cpa_config
|
| 215 |
+
|
| 216 |
+
def start_import(self, pool: dict, selected_files: list[str]) -> dict:
|
| 217 |
+
names = [str(name or "").strip() for name in selected_files if str(name or "").strip()]
|
| 218 |
+
if not names:
|
| 219 |
+
raise ValueError("selected files is required")
|
| 220 |
+
|
| 221 |
+
pool_id = str(pool.get("id") or "").strip()
|
| 222 |
+
job = {
|
| 223 |
+
"job_id": uuid.uuid4().hex,
|
| 224 |
+
"status": "pending",
|
| 225 |
+
"created_at": _now_iso(),
|
| 226 |
+
"updated_at": _now_iso(),
|
| 227 |
+
"total": len(names),
|
| 228 |
+
"completed": 0,
|
| 229 |
+
"added": 0,
|
| 230 |
+
"skipped": 0,
|
| 231 |
+
"refreshed": 0,
|
| 232 |
+
"failed": 0,
|
| 233 |
+
"errors": [],
|
| 234 |
+
}
|
| 235 |
+
saved_pool = self._config.set_import_job(pool_id, job)
|
| 236 |
+
if saved_pool is None:
|
| 237 |
+
raise ValueError("pool not found")
|
| 238 |
+
|
| 239 |
+
thread = threading.Thread(
|
| 240 |
+
target=self._run_import,
|
| 241 |
+
args=(pool_id, pool, names),
|
| 242 |
+
name=f"cpa-import-{pool_id}",
|
| 243 |
+
daemon=True,
|
| 244 |
+
)
|
| 245 |
+
thread.start()
|
| 246 |
+
return dict(saved_pool.get("import_job") or job)
|
| 247 |
+
|
| 248 |
+
def _update_job(self, pool_id: str, **updates) -> dict | None:
|
| 249 |
+
current = self._config.get_import_job(pool_id)
|
| 250 |
+
if current is None:
|
| 251 |
+
return None
|
| 252 |
+
next_job = {**current, **updates, "updated_at": _now_iso()}
|
| 253 |
+
pool = self._config.set_import_job(pool_id, next_job)
|
| 254 |
+
if pool is None:
|
| 255 |
+
return None
|
| 256 |
+
job = pool.get("import_job")
|
| 257 |
+
return dict(job) if isinstance(job, dict) else None
|
| 258 |
+
|
| 259 |
+
def _append_error(self, pool_id: str, file_name: str, message: str) -> None:
|
| 260 |
+
current = self._config.get_import_job(pool_id)
|
| 261 |
+
if current is None:
|
| 262 |
+
return
|
| 263 |
+
errors = list(current.get("errors") or [])
|
| 264 |
+
errors.append({"name": file_name, "error": message})
|
| 265 |
+
self._update_job(pool_id, errors=errors, failed=len(errors))
|
| 266 |
+
|
| 267 |
+
def _run_import(self, pool_id: str, pool: dict, names: list[str]) -> None:
|
| 268 |
+
self._update_job(pool_id, status="running")
|
| 269 |
+
|
| 270 |
+
tokens: list[str] = []
|
| 271 |
+
max_workers = min(16, max(1, len(names)))
|
| 272 |
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
| 273 |
+
future_map = {executor.submit(fetch_remote_access_token, pool, name): name for name in names}
|
| 274 |
+
for future in as_completed(future_map):
|
| 275 |
+
file_name = future_map[future]
|
| 276 |
+
try:
|
| 277 |
+
token, error = future.result()
|
| 278 |
+
except Exception as exc:
|
| 279 |
+
token, error = None, str(exc)
|
| 280 |
+
|
| 281 |
+
if token:
|
| 282 |
+
tokens.append(token)
|
| 283 |
+
else:
|
| 284 |
+
self._append_error(pool_id, file_name, error or "unknown error")
|
| 285 |
+
|
| 286 |
+
current = self._config.get_import_job(pool_id) or {}
|
| 287 |
+
failed = len(current.get("errors") or [])
|
| 288 |
+
self._update_job(pool_id, completed=int(current.get("completed") or 0) + 1, failed=failed)
|
| 289 |
+
|
| 290 |
+
if not tokens:
|
| 291 |
+
current = self._config.get_import_job(pool_id) or {}
|
| 292 |
+
self._update_job(
|
| 293 |
+
pool_id,
|
| 294 |
+
status="failed",
|
| 295 |
+
completed=int(current.get("total") or 0),
|
| 296 |
+
failed=len(current.get("errors") or []),
|
| 297 |
+
)
|
| 298 |
+
return
|
| 299 |
+
|
| 300 |
+
add_result = account_service.add_accounts(tokens)
|
| 301 |
+
refresh_result = account_service.refresh_accounts(tokens)
|
| 302 |
+
current = self._config.get_import_job(pool_id) or {}
|
| 303 |
+
self._update_job(
|
| 304 |
+
pool_id,
|
| 305 |
+
status="completed",
|
| 306 |
+
completed=len(names),
|
| 307 |
+
added=int(add_result.get("added") or 0),
|
| 308 |
+
skipped=int(add_result.get("skipped") or 0),
|
| 309 |
+
refreshed=int(refresh_result.get("refreshed") or 0),
|
| 310 |
+
failed=len(current.get("errors") or []),
|
| 311 |
+
)
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
cpa_config = CPAConfig(CPA_CONFIG_FILE)
|
| 315 |
+
cpa_import_service = CPAImportService(cpa_config)
|
services/image_service.py
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import io
|
| 4 |
+
import shutil
|
| 5 |
+
import threading
|
| 6 |
+
import time
|
| 7 |
+
import zipfile
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
from fastapi import HTTPException
|
| 11 |
+
from fastapi.responses import FileResponse, Response
|
| 12 |
+
from PIL import Image, ImageOps
|
| 13 |
+
|
| 14 |
+
from services.config import config
|
| 15 |
+
from services.image_storage_service import image_storage_service
|
| 16 |
+
from services.image_tags_service import load_tags, remove_tags
|
| 17 |
+
from utils.log import logger
|
| 18 |
+
|
| 19 |
+
THUMBNAIL_SIZE = (320, 320)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _cleanup_empty_dirs(root: Path) -> None:
|
| 23 |
+
for path in sorted((p for p in root.rglob("*") if p.is_dir()), key=lambda p: len(p.parts), reverse=True):
|
| 24 |
+
try:
|
| 25 |
+
path.rmdir()
|
| 26 |
+
except OSError:
|
| 27 |
+
pass
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _safe_relative_path(path: str) -> str:
|
| 31 |
+
value = str(path or "").strip().replace("\\", "/").lstrip("/")
|
| 32 |
+
if not value:
|
| 33 |
+
raise HTTPException(status_code=404, detail="image not found")
|
| 34 |
+
parts = Path(value).parts
|
| 35 |
+
if any(part in {"", ".", ".."} for part in parts):
|
| 36 |
+
raise HTTPException(status_code=404, detail="image not found")
|
| 37 |
+
return Path(*parts).as_posix()
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _safe_image_path(relative_path: str) -> Path:
|
| 41 |
+
rel = _safe_relative_path(relative_path)
|
| 42 |
+
root = config.images_dir.resolve()
|
| 43 |
+
path = (root / rel).resolve()
|
| 44 |
+
try:
|
| 45 |
+
path.relative_to(root)
|
| 46 |
+
except ValueError as exc:
|
| 47 |
+
raise HTTPException(status_code=404, detail="image not found") from exc
|
| 48 |
+
if not path.is_file():
|
| 49 |
+
raise HTTPException(status_code=404, detail="image not found")
|
| 50 |
+
return path
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def get_image_response(relative_path: str) -> FileResponse | Response:
|
| 54 |
+
if image_storage_service.has_local(relative_path):
|
| 55 |
+
return FileResponse(_safe_image_path(relative_path))
|
| 56 |
+
return Response(content=image_storage_service.get_bytes(relative_path), media_type="image/png")
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _thumbnail_path(relative_path: str) -> Path:
|
| 60 |
+
rel = _safe_relative_path(relative_path)
|
| 61 |
+
return config.image_thumbnails_dir / f"{rel}.png"
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def thumbnail_url(base_url: str, relative_path: str) -> str:
|
| 65 |
+
return f"{base_url.rstrip('/')}/image-thumbnails/{_safe_relative_path(relative_path)}"
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _image_dimensions(path: Path) -> tuple[int, int] | None:
|
| 69 |
+
try:
|
| 70 |
+
with Image.open(path) as image:
|
| 71 |
+
return image.size
|
| 72 |
+
except Exception:
|
| 73 |
+
return None
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def ensure_thumbnail(relative_path: str) -> Path:
|
| 77 |
+
target = _thumbnail_path(relative_path)
|
| 78 |
+
source_mtime = 0.0
|
| 79 |
+
source: Path | None = None
|
| 80 |
+
if image_storage_service.has_local(relative_path):
|
| 81 |
+
source = _safe_image_path(relative_path)
|
| 82 |
+
source_mtime = source.stat().st_mtime
|
| 83 |
+
if target.exists() and (not source_mtime or target.stat().st_mtime >= source_mtime):
|
| 84 |
+
return target
|
| 85 |
+
|
| 86 |
+
target.parent.mkdir(parents=True, exist_ok=True)
|
| 87 |
+
try:
|
| 88 |
+
image_source = source if source is not None else io.BytesIO(image_storage_service.get_bytes(relative_path))
|
| 89 |
+
with Image.open(image_source) as image:
|
| 90 |
+
image = ImageOps.exif_transpose(image)
|
| 91 |
+
if image.mode not in {"RGB", "RGBA"}:
|
| 92 |
+
image = image.convert("RGBA" if "A" in image.getbands() else "RGB")
|
| 93 |
+
image.thumbnail(THUMBNAIL_SIZE, Image.Resampling.LANCZOS)
|
| 94 |
+
image.save(target, format="PNG", optimize=True)
|
| 95 |
+
except HTTPException:
|
| 96 |
+
raise
|
| 97 |
+
except Exception as exc:
|
| 98 |
+
raise HTTPException(status_code=422, detail="failed to create thumbnail") from exc
|
| 99 |
+
return target
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def get_thumbnail_response(relative_path: str) -> FileResponse:
|
| 103 |
+
return FileResponse(ensure_thumbnail(relative_path))
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def get_image_download_response(relative_path: str) -> FileResponse:
|
| 107 |
+
if image_storage_service.has_local(relative_path):
|
| 108 |
+
path = _safe_image_path(relative_path)
|
| 109 |
+
return FileResponse(path, filename=path.name)
|
| 110 |
+
rel = _safe_relative_path(relative_path)
|
| 111 |
+
return Response(
|
| 112 |
+
content=image_storage_service.get_bytes(rel),
|
| 113 |
+
media_type="image/png",
|
| 114 |
+
headers={"Content-Disposition": f'attachment; filename="{Path(rel).name}"'},
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def cleanup_image_thumbnails() -> int:
|
| 119 |
+
thumbnails_root = config.image_thumbnails_dir
|
| 120 |
+
removed = 0
|
| 121 |
+
for path in thumbnails_root.rglob("*"):
|
| 122 |
+
if not path.is_file():
|
| 123 |
+
continue
|
| 124 |
+
rel = path.relative_to(thumbnails_root).as_posix()
|
| 125 |
+
if not rel.endswith(".png") or not image_storage_service.exists(rel[:-4]):
|
| 126 |
+
path.unlink()
|
| 127 |
+
removed += 1
|
| 128 |
+
_cleanup_empty_dirs(thumbnails_root)
|
| 129 |
+
return removed
|
| 130 |
+
|
| 131 |
+
def list_images(base_url: str, start_date: str = "", end_date: str = "") -> dict[str, object]:
|
| 132 |
+
config.cleanup_old_images()
|
| 133 |
+
cleanup_image_thumbnails()
|
| 134 |
+
all_tags = load_tags()
|
| 135 |
+
items = [
|
| 136 |
+
{
|
| 137 |
+
**item,
|
| 138 |
+
"url": str(item.get("url") or f"{base_url.rstrip('/')}/images/{item['path']}"),
|
| 139 |
+
"thumbnail_url": thumbnail_url(base_url, str(item["path"])),
|
| 140 |
+
"tags": all_tags.get(str(item["path"]), []),
|
| 141 |
+
}
|
| 142 |
+
for item in image_storage_service.list_items(base_url, start_date, end_date)
|
| 143 |
+
]
|
| 144 |
+
groups: dict[str, list[dict[str, object]]] = {}
|
| 145 |
+
for item in items:
|
| 146 |
+
groups.setdefault(str(item["date"]), []).append(item)
|
| 147 |
+
return {"items": items, "groups": [{"date": key, "items": value} for key, value in groups.items()]}
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def delete_images(paths: list[str] | None = None, start_date: str = "", end_date: str = "", all_matching: bool = False) -> dict[str, int]:
|
| 151 |
+
root = config.images_dir.resolve()
|
| 152 |
+
targets = [
|
| 153 |
+
str(item["path"])
|
| 154 |
+
for item in image_storage_service.list_items("", start_date=start_date, end_date=end_date)
|
| 155 |
+
] if all_matching else (paths or [])
|
| 156 |
+
removed = 0
|
| 157 |
+
for item in targets:
|
| 158 |
+
path = (root / item).resolve()
|
| 159 |
+
try:
|
| 160 |
+
path.relative_to(root)
|
| 161 |
+
except ValueError:
|
| 162 |
+
continue
|
| 163 |
+
if image_storage_service.delete(item):
|
| 164 |
+
removed += 1
|
| 165 |
+
for thumbnail in (_thumbnail_path(item), config.image_thumbnails_dir / _safe_relative_path(item)):
|
| 166 |
+
if thumbnail.is_file():
|
| 167 |
+
thumbnail.unlink()
|
| 168 |
+
remove_tags(item)
|
| 169 |
+
_cleanup_empty_dirs(root)
|
| 170 |
+
_cleanup_empty_dirs(config.image_thumbnails_dir)
|
| 171 |
+
return {"removed": removed}
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def download_images_zip(paths: list[str]) -> io.BytesIO:
|
| 175 |
+
root = config.images_dir.resolve()
|
| 176 |
+
buf = io.BytesIO()
|
| 177 |
+
added = 0
|
| 178 |
+
used_names: set[str] = set()
|
| 179 |
+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
| 180 |
+
for item in paths:
|
| 181 |
+
rel = _safe_relative_path(item)
|
| 182 |
+
path = (root / rel).resolve()
|
| 183 |
+
payload: bytes | None = None
|
| 184 |
+
try:
|
| 185 |
+
path.relative_to(root)
|
| 186 |
+
except ValueError:
|
| 187 |
+
continue
|
| 188 |
+
if path.is_file():
|
| 189 |
+
payload = path.read_bytes()
|
| 190 |
+
else:
|
| 191 |
+
try:
|
| 192 |
+
payload = image_storage_service.get_bytes(rel)
|
| 193 |
+
except Exception:
|
| 194 |
+
continue
|
| 195 |
+
name = path.name
|
| 196 |
+
if name in used_names:
|
| 197 |
+
stem = path.stem
|
| 198 |
+
suffix = path.suffix
|
| 199 |
+
counter = 2
|
| 200 |
+
while f"{stem}_{counter}{suffix}" in used_names:
|
| 201 |
+
counter += 1
|
| 202 |
+
name = f"{stem}_{counter}{suffix}"
|
| 203 |
+
used_names.add(name)
|
| 204 |
+
zf.writestr(name, payload)
|
| 205 |
+
added += 1
|
| 206 |
+
if added == 0:
|
| 207 |
+
raise HTTPException(status_code=404, detail="no images found")
|
| 208 |
+
buf.seek(0)
|
| 209 |
+
return buf
|
| 210 |
+
def storage_stats() -> dict:
|
| 211 |
+
import shutil
|
| 212 |
+
usage = shutil.disk_usage(config.images_dir)
|
| 213 |
+
total_mb = usage.total // (1024 * 1024)
|
| 214 |
+
used_mb = usage.used // (1024 * 1024)
|
| 215 |
+
free_mb = usage.free // (1024 * 1024)
|
| 216 |
+
|
| 217 |
+
image_count = 0
|
| 218 |
+
image_size = 0
|
| 219 |
+
for p in config.images_dir.rglob("*"):
|
| 220 |
+
if p.is_file():
|
| 221 |
+
image_count += 1
|
| 222 |
+
image_size += p.stat().st_size
|
| 223 |
+
|
| 224 |
+
return {
|
| 225 |
+
"disk_total_mb": total_mb,
|
| 226 |
+
"disk_used_mb": used_mb,
|
| 227 |
+
"disk_free_mb": free_mb,
|
| 228 |
+
"image_count": image_count,
|
| 229 |
+
"image_size_mb": image_size // (1024 * 1024),
|
| 230 |
+
"image_size_bytes": image_size,
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def compress_images(quality: int = 60) -> dict:
|
| 235 |
+
"""重新压缩所有图片,返回节省的空间"""
|
| 236 |
+
saved = 0
|
| 237 |
+
count = 0
|
| 238 |
+
for p in sorted(config.images_dir.rglob("*.png")):
|
| 239 |
+
if not p.is_file():
|
| 240 |
+
continue
|
| 241 |
+
try:
|
| 242 |
+
orig = p.stat().st_size
|
| 243 |
+
with Image.open(p) as img:
|
| 244 |
+
img = ImageOps.exif_transpose(img)
|
| 245 |
+
img.save(str(p) + ".tmp", format="PNG", optimize=True)
|
| 246 |
+
new_size = Path(str(p) + ".tmp").stat().st_size
|
| 247 |
+
if new_size < orig:
|
| 248 |
+
Path(str(p) + ".tmp").replace(p)
|
| 249 |
+
saved += orig - new_size
|
| 250 |
+
count += 1
|
| 251 |
+
else:
|
| 252 |
+
Path(str(p) + ".tmp").unlink()
|
| 253 |
+
except Exception:
|
| 254 |
+
pass
|
| 255 |
+
return {"compressed": count, "saved_bytes": saved, "saved_mb": saved // (1024 * 1024)}
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def delete_to_target(target_free_mb: int, dry_run: bool = False) -> dict:
|
| 259 |
+
"""删除最旧的图片直到剩余空间达到 target_free_mb"""
|
| 260 |
+
import shutil
|
| 261 |
+
usage = shutil.disk_usage(config.images_dir)
|
| 262 |
+
current_free = usage.free // (1024 * 1024)
|
| 263 |
+
if current_free >= target_free_mb and not dry_run:
|
| 264 |
+
return {"removed": 0, "current_free_mb": current_free, "target_free_mb": target_free_mb, "done": True}
|
| 265 |
+
|
| 266 |
+
files = sorted(
|
| 267 |
+
(p for p in config.images_dir.rglob("*.png") if p.is_file()),
|
| 268 |
+
key=lambda p: p.stat().st_mtime,
|
| 269 |
+
)
|
| 270 |
+
removed = 0
|
| 271 |
+
freed = 0
|
| 272 |
+
for p in files:
|
| 273 |
+
if current_free + freed // (1024 * 1024) >= target_free_mb:
|
| 274 |
+
break
|
| 275 |
+
size = p.stat().st_size
|
| 276 |
+
if not dry_run:
|
| 277 |
+
rel = p.relative_to(config.images_dir).as_posix()
|
| 278 |
+
for tp in (_thumbnail_path(rel), config.image_thumbnails_dir / _safe_relative_path(rel)):
|
| 279 |
+
if tp.is_file():
|
| 280 |
+
tp.unlink()
|
| 281 |
+
remove_tags(rel)
|
| 282 |
+
p.unlink()
|
| 283 |
+
freed += size
|
| 284 |
+
removed += 1
|
| 285 |
+
|
| 286 |
+
if not dry_run:
|
| 287 |
+
_cleanup_empty_dirs(config.images_dir)
|
| 288 |
+
_cleanup_empty_dirs(config.image_thumbnails_dir)
|
| 289 |
+
|
| 290 |
+
return {
|
| 291 |
+
"removed": removed,
|
| 292 |
+
"freed_mb": freed // (1024 * 1024),
|
| 293 |
+
"target_free_mb": target_free_mb,
|
| 294 |
+
"current_free_mb": current_free + (freed // (1024 * 1024)),
|
| 295 |
+
"done": (current_free + freed // (1024 * 1024)) >= target_free_mb,
|
| 296 |
+
"dry_run": dry_run,
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
def download_images_zip(paths: list[str]) -> io.BytesIO:
|
| 301 |
+
root = config.images_dir.resolve()
|
| 302 |
+
buf = io.BytesIO()
|
| 303 |
+
added = 0
|
| 304 |
+
used_names: set[str] = set()
|
| 305 |
+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
| 306 |
+
for item in paths:
|
| 307 |
+
rel = _safe_relative_path(item)
|
| 308 |
+
path = (root / rel).resolve()
|
| 309 |
+
try:
|
| 310 |
+
path.relative_to(root)
|
| 311 |
+
except ValueError:
|
| 312 |
+
continue
|
| 313 |
+
if not path.is_file():
|
| 314 |
+
continue
|
| 315 |
+
name = path.name
|
| 316 |
+
if name in used_names:
|
| 317 |
+
stem = path.stem
|
| 318 |
+
suffix = path.suffix
|
| 319 |
+
counter = 2
|
| 320 |
+
while f"{stem}_{counter}{suffix}" in used_names:
|
| 321 |
+
counter += 1
|
| 322 |
+
name = f"{stem}_{counter}{suffix}"
|
| 323 |
+
used_names.add(name)
|
| 324 |
+
zf.write(path, name)
|
| 325 |
+
added += 1
|
| 326 |
+
if added == 0:
|
| 327 |
+
raise HTTPException(status_code=404, detail="no images found")
|
| 328 |
+
buf.seek(0)
|
| 329 |
+
return buf
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
def _auto_cleanup_worker(stop_event: threading.Event) -> None:
|
| 333 |
+
"""后台线程:每30分钟检查存储,空间低于阈值自动清理最旧图片"""
|
| 334 |
+
import shutil
|
| 335 |
+
min_free_mb = getattr(config, "image_min_free_mb", None)
|
| 336 |
+
if min_free_mb is None:
|
| 337 |
+
min_free_mb = 500
|
| 338 |
+
|
| 339 |
+
while not stop_event.wait(1800): # 每30分钟
|
| 340 |
+
try:
|
| 341 |
+
config.cleanup_old_images()
|
| 342 |
+
cleanup_image_thumbnails()
|
| 343 |
+
usage = shutil.disk_usage(config.images_dir)
|
| 344 |
+
free_mb = usage.free // (1024 * 1024)
|
| 345 |
+
if free_mb < min_free_mb:
|
| 346 |
+
logger.info({"event": "image_auto_cleanup", "free_mb": free_mb, "min_free_mb": min_free_mb})
|
| 347 |
+
result = delete_to_target(min_free_mb)
|
| 348 |
+
logger.info({"event": "image_auto_cleanup_done", **result})
|
| 349 |
+
except Exception:
|
| 350 |
+
pass
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
def start_image_cleanup_scheduler(stop_event: threading.Event) -> threading.Thread:
|
| 354 |
+
t = threading.Thread(target=_auto_cleanup_worker, args=(stop_event,), daemon=True, name="image-cleanup")
|
| 355 |
+
t.start()
|
| 356 |
+
return t
|
services/image_storage_service.py
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
import io
|
| 5 |
+
import json
|
| 6 |
+
import time
|
| 7 |
+
from dataclasses import dataclass
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from threading import Lock
|
| 11 |
+
from urllib.parse import quote, urlparse
|
| 12 |
+
|
| 13 |
+
from curl_cffi import requests
|
| 14 |
+
from fastapi import HTTPException
|
| 15 |
+
from PIL import Image
|
| 16 |
+
|
| 17 |
+
from services.config import DATA_DIR, config
|
| 18 |
+
|
| 19 |
+
IMAGE_INDEX_FILE = DATA_DIR / "image_index.json"
|
| 20 |
+
IMAGE_INDEX_LOCK = Lock()
|
| 21 |
+
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class ImageStorageError(RuntimeError):
|
| 25 |
+
pass
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@dataclass(frozen=True)
|
| 29 |
+
class StoredImage:
|
| 30 |
+
rel: str
|
| 31 |
+
url: str
|
| 32 |
+
storage: str
|
| 33 |
+
size: int
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _clean(value: object) -> str:
|
| 37 |
+
return str(value or "").strip()
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _now_iso() -> str:
|
| 41 |
+
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _safe_relative_path(path: str) -> str:
|
| 45 |
+
value = str(path or "").strip().replace("\\", "/").lstrip("/")
|
| 46 |
+
if not value:
|
| 47 |
+
raise HTTPException(status_code=404, detail="image not found")
|
| 48 |
+
parts = Path(value).parts
|
| 49 |
+
if any(part in {"", ".", ".."} for part in parts):
|
| 50 |
+
raise HTTPException(status_code=404, detail="image not found")
|
| 51 |
+
return Path(*parts).as_posix()
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _image_dimensions(payload: bytes) -> tuple[int, int] | None:
|
| 55 |
+
try:
|
| 56 |
+
with Image.open(io.BytesIO(payload)) as image:
|
| 57 |
+
return image.size
|
| 58 |
+
except Exception:
|
| 59 |
+
return None
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _is_image_rel(path: str) -> bool:
|
| 63 |
+
try:
|
| 64 |
+
safe_rel = _safe_relative_path(path)
|
| 65 |
+
except HTTPException:
|
| 66 |
+
return False
|
| 67 |
+
return Path(safe_rel).suffix.lower() in IMAGE_EXTENSIONS
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _local_image_path(relative_path: str) -> Path:
|
| 71 |
+
rel = _safe_relative_path(relative_path)
|
| 72 |
+
root = config.images_dir.resolve()
|
| 73 |
+
path = (root / rel).resolve()
|
| 74 |
+
try:
|
| 75 |
+
path.relative_to(root)
|
| 76 |
+
except ValueError as exc:
|
| 77 |
+
raise HTTPException(status_code=404, detail="image not found") from exc
|
| 78 |
+
return path
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _read_json_object(path: Path) -> dict[str, object]:
|
| 82 |
+
if not path.exists():
|
| 83 |
+
return {}
|
| 84 |
+
try:
|
| 85 |
+
data = json.loads(path.read_text(encoding="utf-8"))
|
| 86 |
+
except Exception:
|
| 87 |
+
return {}
|
| 88 |
+
return data if isinstance(data, dict) else {}
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _write_json_object(path: Path, data: dict[str, object]) -> None:
|
| 92 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 93 |
+
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
| 94 |
+
tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
| 95 |
+
tmp_path.replace(path)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
class WebDAVClient:
|
| 99 |
+
def __init__(self, settings: dict[str, object]):
|
| 100 |
+
self.url = _clean(settings.get("webdav_url")).rstrip("/")
|
| 101 |
+
self.username = _clean(settings.get("webdav_username"))
|
| 102 |
+
self.password = _clean(settings.get("webdav_password"))
|
| 103 |
+
self.root_path = _clean(settings.get("webdav_root_path")).strip("/")
|
| 104 |
+
self.session = requests.Session()
|
| 105 |
+
|
| 106 |
+
def _auth_kwargs(self) -> dict[str, object]:
|
| 107 |
+
return {"auth": (self.username, self.password)} if self.username or self.password else {}
|
| 108 |
+
|
| 109 |
+
def _request(self, method: str, url: str, **kwargs):
|
| 110 |
+
response = self.session.request(method, url, timeout=30, **self._auth_kwargs(), **kwargs)
|
| 111 |
+
if response.status_code >= 400 and not (method == "MKCOL" and response.status_code in {405}):
|
| 112 |
+
raise ImageStorageError(f"WebDAV {method} failed: HTTP {response.status_code}")
|
| 113 |
+
return response
|
| 114 |
+
|
| 115 |
+
def remote_url(self, rel: str = "") -> str:
|
| 116 |
+
parts = [part for part in [self.root_path, _safe_relative_path(rel) if rel else ""] if part]
|
| 117 |
+
encoded = "/".join(quote(part, safe="") for item in parts for part in item.split("/") if part)
|
| 118 |
+
return f"{self.url}/{encoded}" if encoded else self.url
|
| 119 |
+
|
| 120 |
+
def ensure_dirs(self, rel: str) -> None:
|
| 121 |
+
parts = [part for part in [self.root_path, Path(_safe_relative_path(rel)).parent.as_posix()] if part and part != "."]
|
| 122 |
+
current = self.url
|
| 123 |
+
for item in "/".join(parts).split("/"):
|
| 124 |
+
if not item:
|
| 125 |
+
continue
|
| 126 |
+
current = f"{current}/{quote(item, safe='')}"
|
| 127 |
+
response = self.session.request("MKCOL", current, timeout=30, **self._auth_kwargs())
|
| 128 |
+
if response.status_code in {201, 405}:
|
| 129 |
+
continue
|
| 130 |
+
if response.status_code >= 400:
|
| 131 |
+
raise ImageStorageError(f"WebDAV MKCOL failed: HTTP {response.status_code}")
|
| 132 |
+
|
| 133 |
+
def put(self, rel: str, payload: bytes, content_type: str = "image/png") -> str:
|
| 134 |
+
self.ensure_dirs(rel)
|
| 135 |
+
url = self.remote_url(rel)
|
| 136 |
+
self._request("PUT", url, data=payload, headers={"Content-Type": content_type})
|
| 137 |
+
return url
|
| 138 |
+
|
| 139 |
+
def get(self, rel: str) -> bytes:
|
| 140 |
+
response = self._request("GET", self.remote_url(rel))
|
| 141 |
+
return bytes(response.content)
|
| 142 |
+
|
| 143 |
+
def delete(self, rel: str) -> bool:
|
| 144 |
+
response = self.session.request("DELETE", self.remote_url(rel), timeout=30, **self._auth_kwargs())
|
| 145 |
+
if response.status_code in {200, 202, 204, 404}:
|
| 146 |
+
return response.status_code != 404
|
| 147 |
+
raise ImageStorageError(f"WebDAV DELETE failed: HTTP {response.status_code}")
|
| 148 |
+
|
| 149 |
+
def test(self) -> dict[str, object]:
|
| 150 |
+
if not self.url:
|
| 151 |
+
return {"ok": False, "status": 0, "error": "WebDAV URL is required"}
|
| 152 |
+
if urlparse(self.url).scheme not in {"http", "https"}:
|
| 153 |
+
return {"ok": False, "status": 0, "error": "invalid WebDAV URL"}
|
| 154 |
+
test_rel = ".chatgpt2api_webdav_test.txt"
|
| 155 |
+
try:
|
| 156 |
+
self.put(test_rel, b"chatgpt2api webdav test\n", content_type="text/plain")
|
| 157 |
+
self.delete(test_rel)
|
| 158 |
+
return {"ok": True, "status": 200, "error": None}
|
| 159 |
+
except ImageStorageError as exc:
|
| 160 |
+
return {"ok": False, "status": 0, "error": str(exc)}
|
| 161 |
+
except Exception as exc:
|
| 162 |
+
return {"ok": False, "status": 0, "error": str(exc) or exc.__class__.__name__}
|
| 163 |
+
finally:
|
| 164 |
+
self.session.close()
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
class ImageStorageService:
|
| 168 |
+
def __init__(self, index_file: Path = IMAGE_INDEX_FILE):
|
| 169 |
+
self.index_file = index_file
|
| 170 |
+
self._index_lock = IMAGE_INDEX_LOCK
|
| 171 |
+
|
| 172 |
+
def settings(self) -> dict[str, object]:
|
| 173 |
+
return config.get_image_storage_settings()
|
| 174 |
+
|
| 175 |
+
def mode(self) -> str:
|
| 176 |
+
return _clean(self.settings().get("mode")) or "local"
|
| 177 |
+
|
| 178 |
+
def _load_index(self) -> dict[str, dict[str, object]]:
|
| 179 |
+
raw = _read_json_object(self.index_file)
|
| 180 |
+
items = raw.get("items")
|
| 181 |
+
if not isinstance(items, dict):
|
| 182 |
+
return {}
|
| 183 |
+
return {str(key): value for key, value in items.items() if isinstance(value, dict)}
|
| 184 |
+
|
| 185 |
+
def _load_clean_index(self) -> dict[str, dict[str, object]]:
|
| 186 |
+
items = self._load_index()
|
| 187 |
+
return {rel: item for rel, item in items.items() if _is_image_rel(rel)}
|
| 188 |
+
|
| 189 |
+
def _save_index(self, items: dict[str, dict[str, object]]) -> None:
|
| 190 |
+
_write_json_object(self.index_file, {"items": items})
|
| 191 |
+
|
| 192 |
+
def _public_url(self, rel: str, base_url: str | None = None) -> str:
|
| 193 |
+
settings = self.settings()
|
| 194 |
+
public_base_url = _clean(settings.get("public_base_url"))
|
| 195 |
+
if public_base_url:
|
| 196 |
+
return f"{public_base_url.rstrip('/')}/{_safe_relative_path(rel)}"
|
| 197 |
+
return f"{(base_url or config.base_url).rstrip('/')}/images/{_safe_relative_path(rel)}"
|
| 198 |
+
|
| 199 |
+
def make_relative_path(self, image_data: bytes) -> str:
|
| 200 |
+
file_hash = hashlib.md5(image_data).hexdigest()
|
| 201 |
+
filename = f"{int(time.time())}_{file_hash}.png"
|
| 202 |
+
relative_dir = Path(time.strftime("%Y"), time.strftime("%m"), time.strftime("%d"))
|
| 203 |
+
return f"{relative_dir.as_posix()}/{filename}"
|
| 204 |
+
|
| 205 |
+
def save(self, image_data: bytes, base_url: str | None = None) -> StoredImage:
|
| 206 |
+
config.cleanup_old_images()
|
| 207 |
+
rel = self.make_relative_path(image_data)
|
| 208 |
+
mode = self.mode()
|
| 209 |
+
if mode not in {"local", "webdav", "both"}:
|
| 210 |
+
mode = "local"
|
| 211 |
+
stored_local = False
|
| 212 |
+
stored_webdav = False
|
| 213 |
+
remote_url = ""
|
| 214 |
+
|
| 215 |
+
if mode in {"local", "both"}:
|
| 216 |
+
path = _local_image_path(rel)
|
| 217 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 218 |
+
path.write_bytes(image_data)
|
| 219 |
+
stored_local = True
|
| 220 |
+
|
| 221 |
+
if mode in {"webdav", "both"}:
|
| 222 |
+
remote_url = WebDAVClient(self.settings()).put(rel, image_data)
|
| 223 |
+
stored_webdav = True
|
| 224 |
+
|
| 225 |
+
dimensions = _image_dimensions(image_data)
|
| 226 |
+
item = {
|
| 227 |
+
"rel": rel,
|
| 228 |
+
"path": rel,
|
| 229 |
+
"name": Path(rel).name,
|
| 230 |
+
"date": "-".join(rel.split("/")[:3]),
|
| 231 |
+
"size": len(image_data),
|
| 232 |
+
"created_at": _now_iso(),
|
| 233 |
+
"storage": "both" if stored_local and stored_webdav else ("webdav" if stored_webdav else "local"),
|
| 234 |
+
"local": stored_local,
|
| 235 |
+
"webdav": stored_webdav,
|
| 236 |
+
"remote_url": remote_url,
|
| 237 |
+
}
|
| 238 |
+
if dimensions:
|
| 239 |
+
item["width"], item["height"] = dimensions
|
| 240 |
+
with self._index_lock:
|
| 241 |
+
items = self._load_clean_index()
|
| 242 |
+
items[rel] = item
|
| 243 |
+
self._save_index(items)
|
| 244 |
+
return StoredImage(rel=rel, url=self._public_url(rel, base_url), storage=str(item["storage"]), size=len(image_data))
|
| 245 |
+
|
| 246 |
+
def get_bytes(self, rel: str) -> bytes:
|
| 247 |
+
safe_rel = _safe_relative_path(rel)
|
| 248 |
+
if not _is_image_rel(safe_rel):
|
| 249 |
+
raise HTTPException(status_code=404, detail="image not found")
|
| 250 |
+
path = _local_image_path(safe_rel)
|
| 251 |
+
if path.is_file():
|
| 252 |
+
return path.read_bytes()
|
| 253 |
+
item = self._load_clean_index().get(safe_rel, {})
|
| 254 |
+
if item.get("webdav"):
|
| 255 |
+
return WebDAVClient(self.settings()).get(safe_rel)
|
| 256 |
+
raise HTTPException(status_code=404, detail="image not found")
|
| 257 |
+
|
| 258 |
+
def exists(self, rel: str) -> bool:
|
| 259 |
+
safe_rel = _safe_relative_path(rel)
|
| 260 |
+
if not _is_image_rel(safe_rel):
|
| 261 |
+
return False
|
| 262 |
+
if _local_image_path(safe_rel).is_file():
|
| 263 |
+
return True
|
| 264 |
+
item = self._load_clean_index().get(safe_rel, {})
|
| 265 |
+
return bool(item.get("webdav"))
|
| 266 |
+
|
| 267 |
+
def has_local(self, rel: str) -> bool:
|
| 268 |
+
safe_rel = _safe_relative_path(rel)
|
| 269 |
+
return _is_image_rel(safe_rel) and _local_image_path(safe_rel).is_file()
|
| 270 |
+
|
| 271 |
+
def list_items(self, base_url: str, start_date: str = "", end_date: str = "") -> list[dict[str, object]]:
|
| 272 |
+
with self._index_lock:
|
| 273 |
+
indexed = self._load_clean_index()
|
| 274 |
+
root = config.images_dir
|
| 275 |
+
changed = False
|
| 276 |
+
for path in root.rglob("*"):
|
| 277 |
+
if not path.is_file() or not _is_image_rel(path.name):
|
| 278 |
+
continue
|
| 279 |
+
rel = path.relative_to(root).as_posix()
|
| 280 |
+
if rel in indexed:
|
| 281 |
+
continue
|
| 282 |
+
dimensions = None
|
| 283 |
+
try:
|
| 284 |
+
dimensions = _image_dimensions(path.read_bytes())
|
| 285 |
+
except Exception:
|
| 286 |
+
dimensions = None
|
| 287 |
+
indexed[rel] = {
|
| 288 |
+
"rel": rel,
|
| 289 |
+
"path": rel,
|
| 290 |
+
"name": path.name,
|
| 291 |
+
"date": "-".join(rel.split("/")[:3]) if len(rel.split("/")) >= 4 else datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d"),
|
| 292 |
+
"size": path.stat().st_size,
|
| 293 |
+
"created_at": datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
|
| 294 |
+
"storage": "local",
|
| 295 |
+
"local": True,
|
| 296 |
+
"webdav": False,
|
| 297 |
+
**({"width": dimensions[0], "height": dimensions[1]} if dimensions else {}),
|
| 298 |
+
}
|
| 299 |
+
changed = True
|
| 300 |
+
|
| 301 |
+
items: list[dict[str, object]] = []
|
| 302 |
+
for rel, item in list(indexed.items()):
|
| 303 |
+
if not _is_image_rel(rel):
|
| 304 |
+
indexed.pop(rel, None)
|
| 305 |
+
changed = True
|
| 306 |
+
continue
|
| 307 |
+
local = _local_image_path(rel).is_file()
|
| 308 |
+
webdav = bool(item.get("webdav"))
|
| 309 |
+
if not local and not webdav:
|
| 310 |
+
indexed.pop(rel, None)
|
| 311 |
+
changed = True
|
| 312 |
+
continue
|
| 313 |
+
storage = "both" if local and webdav else ("webdav" if webdav else "local")
|
| 314 |
+
if item.get("local") != local or item.get("storage") != storage:
|
| 315 |
+
item = {
|
| 316 |
+
**item,
|
| 317 |
+
"local": local,
|
| 318 |
+
"storage": storage,
|
| 319 |
+
}
|
| 320 |
+
indexed[rel] = item
|
| 321 |
+
changed = True
|
| 322 |
+
day = str(item.get("date") or "")
|
| 323 |
+
if start_date and day < start_date:
|
| 324 |
+
continue
|
| 325 |
+
if end_date and day > end_date:
|
| 326 |
+
continue
|
| 327 |
+
items.append({
|
| 328 |
+
**item,
|
| 329 |
+
"rel": rel,
|
| 330 |
+
"path": rel,
|
| 331 |
+
"url": self._public_url(rel, base_url),
|
| 332 |
+
})
|
| 333 |
+
if changed:
|
| 334 |
+
self._save_index(indexed)
|
| 335 |
+
items.sort(key=lambda item: str(item.get("created_at") or ""), reverse=True)
|
| 336 |
+
return items
|
| 337 |
+
|
| 338 |
+
def delete(self, rel: str) -> bool:
|
| 339 |
+
safe_rel = _safe_relative_path(rel)
|
| 340 |
+
removed = False
|
| 341 |
+
path = _local_image_path(safe_rel)
|
| 342 |
+
if path.is_file():
|
| 343 |
+
path.unlink()
|
| 344 |
+
removed = True
|
| 345 |
+
with self._index_lock:
|
| 346 |
+
items = self._load_clean_index()
|
| 347 |
+
item = items.get(safe_rel, {})
|
| 348 |
+
if item.get("webdav"):
|
| 349 |
+
try:
|
| 350 |
+
removed = WebDAVClient(self.settings()).delete(safe_rel) or removed
|
| 351 |
+
except ImageStorageError:
|
| 352 |
+
if not removed:
|
| 353 |
+
raise
|
| 354 |
+
if safe_rel in items:
|
| 355 |
+
items.pop(safe_rel, None)
|
| 356 |
+
self._save_index(items)
|
| 357 |
+
return removed
|
| 358 |
+
|
| 359 |
+
def sync_all(self) -> dict[str, int]:
|
| 360 |
+
settings = self.settings()
|
| 361 |
+
if self.mode() not in {"webdav", "both"}:
|
| 362 |
+
raise ImageStorageError("WebDAV 图片存储未启用")
|
| 363 |
+
uploaded = 0
|
| 364 |
+
skipped = 0
|
| 365 |
+
failed = 0
|
| 366 |
+
with self._index_lock:
|
| 367 |
+
items = self._load_clean_index()
|
| 368 |
+
client = WebDAVClient(settings)
|
| 369 |
+
for path in sorted(config.images_dir.rglob("*")):
|
| 370 |
+
if not path.is_file() or not _is_image_rel(path.name):
|
| 371 |
+
continue
|
| 372 |
+
rel = path.relative_to(config.images_dir).as_posix()
|
| 373 |
+
item = items.get(rel, {})
|
| 374 |
+
if item.get("webdav"):
|
| 375 |
+
skipped += 1
|
| 376 |
+
continue
|
| 377 |
+
try:
|
| 378 |
+
payload = path.read_bytes()
|
| 379 |
+
remote_url = client.put(rel, payload)
|
| 380 |
+
dimensions = _image_dimensions(payload)
|
| 381 |
+
items[rel] = {
|
| 382 |
+
**item,
|
| 383 |
+
"rel": rel,
|
| 384 |
+
"path": rel,
|
| 385 |
+
"name": path.name,
|
| 386 |
+
"date": "-".join(rel.split("/")[:3]) if len(rel.split("/")) >= 4 else datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d"),
|
| 387 |
+
"size": len(payload),
|
| 388 |
+
"created_at": str(item.get("created_at") or datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")),
|
| 389 |
+
"storage": "both",
|
| 390 |
+
"local": True,
|
| 391 |
+
"webdav": True,
|
| 392 |
+
"remote_url": remote_url,
|
| 393 |
+
**({"width": dimensions[0], "height": dimensions[1]} if dimensions else {}),
|
| 394 |
+
}
|
| 395 |
+
uploaded += 1
|
| 396 |
+
except Exception:
|
| 397 |
+
failed += 1
|
| 398 |
+
self._save_index(items)
|
| 399 |
+
return {"uploaded": uploaded, "skipped": skipped, "failed": failed}
|
| 400 |
+
|
| 401 |
+
def test_webdav(self) -> dict[str, object]:
|
| 402 |
+
return WebDAVClient(self.settings()).test()
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
image_storage_service = ImageStorageService()
|
services/image_tags_service.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
from services.config import DATA_DIR
|
| 7 |
+
|
| 8 |
+
TAGS_FILE = DATA_DIR / "image_tags.json"
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _ensure_file() -> None:
|
| 12 |
+
TAGS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
| 13 |
+
if not TAGS_FILE.exists():
|
| 14 |
+
TAGS_FILE.write_text("{}", encoding="utf-8")
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def load_tags() -> dict[str, list[str]]:
|
| 18 |
+
_ensure_file()
|
| 19 |
+
try:
|
| 20 |
+
data = json.loads(TAGS_FILE.read_text(encoding="utf-8"))
|
| 21 |
+
except Exception:
|
| 22 |
+
return {}
|
| 23 |
+
return data if isinstance(data, dict) else {}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def save_tags(data: dict[str, list[str]]) -> None:
|
| 27 |
+
_ensure_file()
|
| 28 |
+
TAGS_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def get_tags(image_rel: str) -> list[str]:
|
| 32 |
+
return load_tags().get(image_rel, [])
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def set_tags(image_rel: str, tags: list[str]) -> list[str]:
|
| 36 |
+
data = load_tags()
|
| 37 |
+
cleaned = list(dict.fromkeys(t.strip() for t in tags if t.strip()))
|
| 38 |
+
if cleaned:
|
| 39 |
+
data[image_rel] = cleaned
|
| 40 |
+
else:
|
| 41 |
+
data.pop(image_rel, None)
|
| 42 |
+
save_tags(data)
|
| 43 |
+
return cleaned
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def remove_tags(image_rel: str) -> None:
|
| 47 |
+
data = load_tags()
|
| 48 |
+
if data.pop(image_rel, None) is not None:
|
| 49 |
+
save_tags(data)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def delete_tag(tag: str) -> int:
|
| 53 |
+
"""从所有图片中删除指定标签,返回受影响的图片数。"""
|
| 54 |
+
data = load_tags()
|
| 55 |
+
count = 0
|
| 56 |
+
for rel in list(data):
|
| 57 |
+
if tag in data[rel]:
|
| 58 |
+
data[rel] = [t for t in data[rel] if t != tag]
|
| 59 |
+
if not data[rel]:
|
| 60 |
+
del data[rel]
|
| 61 |
+
count += 1
|
| 62 |
+
if count > 0:
|
| 63 |
+
save_tags(data)
|
| 64 |
+
return count
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def get_all_tags() -> list[str]:
|
| 68 |
+
data = load_tags()
|
| 69 |
+
seen: set[str] = set()
|
| 70 |
+
result: list[str] = []
|
| 71 |
+
for tags in data.values():
|
| 72 |
+
for t in tags:
|
| 73 |
+
if t not in seen:
|
| 74 |
+
seen.add(t)
|
| 75 |
+
result.append(t)
|
| 76 |
+
return result
|
services/image_task_service.py
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import threading
|
| 5 |
+
import time
|
| 6 |
+
from collections.abc import Callable
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
from services.config import DATA_DIR, config
|
| 12 |
+
from services.content_filter import request_text
|
| 13 |
+
from services.log_service import LOG_TYPE_CALL, log_service
|
| 14 |
+
from services.protocol import openai_v1_image_edit, openai_v1_image_generations
|
| 15 |
+
|
| 16 |
+
TASK_STATUS_QUEUED = "queued"
|
| 17 |
+
TASK_STATUS_RUNNING = "running"
|
| 18 |
+
TASK_STATUS_SUCCESS = "success"
|
| 19 |
+
TASK_STATUS_ERROR = "error"
|
| 20 |
+
TERMINAL_STATUSES = {TASK_STATUS_SUCCESS, TASK_STATUS_ERROR}
|
| 21 |
+
UNFINISHED_STATUSES = {TASK_STATUS_QUEUED, TASK_STATUS_RUNNING}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _now_iso() -> str:
|
| 25 |
+
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _timestamp(value: object) -> float:
|
| 29 |
+
if not isinstance(value, str) or not value.strip():
|
| 30 |
+
return 0.0
|
| 31 |
+
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S"):
|
| 32 |
+
try:
|
| 33 |
+
return datetime.strptime(value[:26], fmt).timestamp()
|
| 34 |
+
except ValueError:
|
| 35 |
+
continue
|
| 36 |
+
try:
|
| 37 |
+
return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
|
| 38 |
+
except Exception:
|
| 39 |
+
return 0.0
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _clean(value: object, default: str = "") -> str:
|
| 43 |
+
return str(value or default).strip()
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _owner_id(identity: dict[str, object]) -> str:
|
| 47 |
+
return _clean(identity.get("id")) or "anonymous"
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _task_key(owner_id: str, task_id: str) -> str:
|
| 51 |
+
return f"{owner_id}:{task_id}"
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _collect_image_urls(data: list[Any]) -> list[str]:
|
| 55 |
+
urls: list[str] = []
|
| 56 |
+
for item in data:
|
| 57 |
+
if isinstance(item, dict):
|
| 58 |
+
url = item.get("url")
|
| 59 |
+
if isinstance(url, str) and url:
|
| 60 |
+
urls.append(url)
|
| 61 |
+
return urls
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _public_task(task: dict[str, Any]) -> dict[str, Any]:
|
| 65 |
+
item = {
|
| 66 |
+
"id": task.get("id"),
|
| 67 |
+
"status": task.get("status"),
|
| 68 |
+
"mode": task.get("mode"),
|
| 69 |
+
"model": task.get("model"),
|
| 70 |
+
"size": task.get("size"),
|
| 71 |
+
"quality": task.get("quality"),
|
| 72 |
+
"created_at": task.get("created_at"),
|
| 73 |
+
"updated_at": task.get("updated_at"),
|
| 74 |
+
}
|
| 75 |
+
if task.get("data") is not None:
|
| 76 |
+
item["data"] = task.get("data")
|
| 77 |
+
if task.get("error"):
|
| 78 |
+
item["error"] = task.get("error")
|
| 79 |
+
return item
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class ImageTaskService:
|
| 83 |
+
def __init__(
|
| 84 |
+
self,
|
| 85 |
+
path: Path,
|
| 86 |
+
*,
|
| 87 |
+
generation_handler: Callable[[dict[str, Any]], dict[str, Any]] = openai_v1_image_generations.handle,
|
| 88 |
+
edit_handler: Callable[[dict[str, Any]], dict[str, Any]] = openai_v1_image_edit.handle,
|
| 89 |
+
retention_days_getter: Callable[[], int] | None = None,
|
| 90 |
+
):
|
| 91 |
+
self.path = path
|
| 92 |
+
self.generation_handler = generation_handler
|
| 93 |
+
self.edit_handler = edit_handler
|
| 94 |
+
self.retention_days_getter = retention_days_getter or (lambda: config.image_retention_days)
|
| 95 |
+
self._lock = threading.RLock()
|
| 96 |
+
self._tasks: dict[str, dict[str, Any]] = {}
|
| 97 |
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
| 98 |
+
with self._lock:
|
| 99 |
+
self._tasks = self._load_locked()
|
| 100 |
+
changed = self._recover_unfinished_locked()
|
| 101 |
+
changed = self._cleanup_locked() or changed
|
| 102 |
+
if changed:
|
| 103 |
+
self._save_locked()
|
| 104 |
+
|
| 105 |
+
def submit_generation(
|
| 106 |
+
self,
|
| 107 |
+
identity: dict[str, object],
|
| 108 |
+
*,
|
| 109 |
+
client_task_id: str,
|
| 110 |
+
prompt: str,
|
| 111 |
+
model: str,
|
| 112 |
+
size: str | None,
|
| 113 |
+
quality: str = "auto",
|
| 114 |
+
base_url: str = "",
|
| 115 |
+
) -> dict[str, Any]:
|
| 116 |
+
payload = {
|
| 117 |
+
"prompt": prompt,
|
| 118 |
+
"model": model,
|
| 119 |
+
"n": 1,
|
| 120 |
+
"size": size,
|
| 121 |
+
"quality": quality,
|
| 122 |
+
"response_format": "url",
|
| 123 |
+
"base_url": base_url,
|
| 124 |
+
}
|
| 125 |
+
return self._submit(identity, client_task_id=client_task_id, mode="generate", payload=payload)
|
| 126 |
+
|
| 127 |
+
def submit_edit(
|
| 128 |
+
self,
|
| 129 |
+
identity: dict[str, object],
|
| 130 |
+
*,
|
| 131 |
+
client_task_id: str,
|
| 132 |
+
prompt: str,
|
| 133 |
+
model: str,
|
| 134 |
+
size: str | None,
|
| 135 |
+
quality: str = "auto",
|
| 136 |
+
base_url: str = "",
|
| 137 |
+
images: list[tuple[bytes, str, str]] | None = None,
|
| 138 |
+
) -> dict[str, Any]:
|
| 139 |
+
payload = {
|
| 140 |
+
"prompt": prompt,
|
| 141 |
+
"images": images or [],
|
| 142 |
+
"model": model,
|
| 143 |
+
"n": 1,
|
| 144 |
+
"size": size,
|
| 145 |
+
"quality": quality,
|
| 146 |
+
"response_format": "url",
|
| 147 |
+
"base_url": base_url,
|
| 148 |
+
}
|
| 149 |
+
return self._submit(identity, client_task_id=client_task_id, mode="edit", payload=payload)
|
| 150 |
+
|
| 151 |
+
def list_tasks(self, identity: dict[str, object], task_ids: list[str]) -> dict[str, Any]:
|
| 152 |
+
owner = _owner_id(identity)
|
| 153 |
+
requested_ids = [_clean(task_id) for task_id in task_ids if _clean(task_id)]
|
| 154 |
+
with self._lock:
|
| 155 |
+
if self._cleanup_locked():
|
| 156 |
+
self._save_locked()
|
| 157 |
+
items = []
|
| 158 |
+
missing_ids = []
|
| 159 |
+
for task_id in requested_ids:
|
| 160 |
+
task = self._tasks.get(_task_key(owner, task_id))
|
| 161 |
+
if task is None:
|
| 162 |
+
missing_ids.append(task_id)
|
| 163 |
+
else:
|
| 164 |
+
items.append(_public_task(task))
|
| 165 |
+
if not requested_ids:
|
| 166 |
+
items = [
|
| 167 |
+
_public_task(task)
|
| 168 |
+
for task in self._tasks.values()
|
| 169 |
+
if task.get("owner_id") == owner
|
| 170 |
+
]
|
| 171 |
+
items.sort(key=lambda item: str(item.get("updated_at") or ""), reverse=True)
|
| 172 |
+
missing_ids = []
|
| 173 |
+
return {"items": items, "missing_ids": missing_ids}
|
| 174 |
+
|
| 175 |
+
def _submit(
|
| 176 |
+
self,
|
| 177 |
+
identity: dict[str, object],
|
| 178 |
+
*,
|
| 179 |
+
client_task_id: str,
|
| 180 |
+
mode: str,
|
| 181 |
+
payload: dict[str, Any],
|
| 182 |
+
) -> dict[str, Any]:
|
| 183 |
+
task_id = _clean(client_task_id)
|
| 184 |
+
if not task_id:
|
| 185 |
+
raise ValueError("client_task_id is required")
|
| 186 |
+
owner = _owner_id(identity)
|
| 187 |
+
key = _task_key(owner, task_id)
|
| 188 |
+
now = _now_iso()
|
| 189 |
+
should_start = False
|
| 190 |
+
with self._lock:
|
| 191 |
+
cleaned = self._cleanup_locked()
|
| 192 |
+
task = self._tasks.get(key)
|
| 193 |
+
if task is not None:
|
| 194 |
+
if cleaned:
|
| 195 |
+
self._save_locked()
|
| 196 |
+
return _public_task(task)
|
| 197 |
+
task = {
|
| 198 |
+
"id": task_id,
|
| 199 |
+
"owner_id": owner,
|
| 200 |
+
"status": TASK_STATUS_QUEUED,
|
| 201 |
+
"mode": mode,
|
| 202 |
+
"model": _clean(payload.get("model"), "gpt-image-2"),
|
| 203 |
+
"size": _clean(payload.get("size")),
|
| 204 |
+
"quality": _clean(payload.get("quality"), "auto"),
|
| 205 |
+
"created_at": now,
|
| 206 |
+
"updated_at": now,
|
| 207 |
+
}
|
| 208 |
+
self._tasks[key] = task
|
| 209 |
+
self._save_locked()
|
| 210 |
+
should_start = True
|
| 211 |
+
|
| 212 |
+
if should_start:
|
| 213 |
+
thread = threading.Thread(
|
| 214 |
+
target=self._run_task,
|
| 215 |
+
args=(key, mode, payload, dict(identity), _clean(payload.get("model"), "gpt-image-2")),
|
| 216 |
+
name=f"image-task-{task_id[:16]}",
|
| 217 |
+
daemon=True,
|
| 218 |
+
)
|
| 219 |
+
thread.start()
|
| 220 |
+
return _public_task(task)
|
| 221 |
+
|
| 222 |
+
def _run_task(
|
| 223 |
+
self,
|
| 224 |
+
key: str,
|
| 225 |
+
mode: str,
|
| 226 |
+
payload: dict[str, Any],
|
| 227 |
+
identity: dict[str, object],
|
| 228 |
+
model: str,
|
| 229 |
+
) -> None:
|
| 230 |
+
started = time.time()
|
| 231 |
+
self._update_task(key, status=TASK_STATUS_RUNNING, error="")
|
| 232 |
+
try:
|
| 233 |
+
handler = self.edit_handler if mode == "edit" else self.generation_handler
|
| 234 |
+
result = handler(payload)
|
| 235 |
+
if not isinstance(result, dict):
|
| 236 |
+
raise RuntimeError("image task returned streaming result unexpectedly")
|
| 237 |
+
data = result.get("data")
|
| 238 |
+
if not isinstance(data, list) or not data:
|
| 239 |
+
upstream = _clean(result.get("message"))
|
| 240 |
+
if upstream:
|
| 241 |
+
message = upstream
|
| 242 |
+
else:
|
| 243 |
+
message = "号池中没有可用账号或所有账号均被限流,请检查号池状态(账号额度、是否被封禁、是否到达生图上限)"
|
| 244 |
+
raise RuntimeError(message)
|
| 245 |
+
self._update_task(key, status=TASK_STATUS_SUCCESS, data=data, error="")
|
| 246 |
+
self._log_call(
|
| 247 |
+
identity,
|
| 248 |
+
mode,
|
| 249 |
+
model,
|
| 250 |
+
started,
|
| 251 |
+
"调用完成",
|
| 252 |
+
request_preview=request_text(payload.get("prompt")),
|
| 253 |
+
urls=_collect_image_urls(data),
|
| 254 |
+
)
|
| 255 |
+
except Exception as exc:
|
| 256 |
+
error_message = str(exc) or "image task failed"
|
| 257 |
+
self._update_task(key, status=TASK_STATUS_ERROR, error=error_message, data=[])
|
| 258 |
+
self._log_call(
|
| 259 |
+
identity,
|
| 260 |
+
mode,
|
| 261 |
+
model,
|
| 262 |
+
started,
|
| 263 |
+
"调用失败",
|
| 264 |
+
request_preview=request_text(payload.get("prompt")),
|
| 265 |
+
status="failed",
|
| 266 |
+
error=error_message,
|
| 267 |
+
)
|
| 268 |
+
|
| 269 |
+
def _log_call(
|
| 270 |
+
self,
|
| 271 |
+
identity: dict[str, object],
|
| 272 |
+
mode: str,
|
| 273 |
+
model: str,
|
| 274 |
+
started: float,
|
| 275 |
+
suffix: str,
|
| 276 |
+
*,
|
| 277 |
+
request_preview: str = "",
|
| 278 |
+
status: str = "success",
|
| 279 |
+
error: str = "",
|
| 280 |
+
urls: list[str] | None = None,
|
| 281 |
+
) -> None:
|
| 282 |
+
endpoint = "/v1/images/edits" if mode == "edit" else "/v1/images/generations"
|
| 283 |
+
summary_prefix = "图生图" if mode == "edit" else "文生图"
|
| 284 |
+
detail = {
|
| 285 |
+
"key_id": identity.get("id"),
|
| 286 |
+
"key_name": identity.get("name"),
|
| 287 |
+
"role": identity.get("role"),
|
| 288 |
+
"endpoint": endpoint,
|
| 289 |
+
"model": model,
|
| 290 |
+
"started_at": datetime.fromtimestamp(started).strftime("%Y-%m-%d %H:%M:%S"),
|
| 291 |
+
"ended_at": _now_iso(),
|
| 292 |
+
"duration_ms": int((time.time() - started) * 1000),
|
| 293 |
+
"status": status,
|
| 294 |
+
}
|
| 295 |
+
if request_preview:
|
| 296 |
+
detail["request_text"] = request_preview
|
| 297 |
+
if error:
|
| 298 |
+
detail["error"] = error
|
| 299 |
+
if urls:
|
| 300 |
+
detail["urls"] = list(dict.fromkeys(urls))
|
| 301 |
+
try:
|
| 302 |
+
log_service.add(LOG_TYPE_CALL, f"{summary_prefix}{suffix}", detail)
|
| 303 |
+
except Exception:
|
| 304 |
+
pass
|
| 305 |
+
|
| 306 |
+
def _update_task(self, key: str, **updates: Any) -> None:
|
| 307 |
+
with self._lock:
|
| 308 |
+
task = self._tasks.get(key)
|
| 309 |
+
if task is None:
|
| 310 |
+
return
|
| 311 |
+
task.update(updates)
|
| 312 |
+
task["updated_at"] = _now_iso()
|
| 313 |
+
self._save_locked()
|
| 314 |
+
|
| 315 |
+
def _load_locked(self) -> dict[str, dict[str, Any]]:
|
| 316 |
+
if not self.path.exists():
|
| 317 |
+
return {}
|
| 318 |
+
try:
|
| 319 |
+
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
| 320 |
+
except Exception:
|
| 321 |
+
return {}
|
| 322 |
+
raw_items = raw.get("tasks") if isinstance(raw, dict) else raw
|
| 323 |
+
if not isinstance(raw_items, list):
|
| 324 |
+
return {}
|
| 325 |
+
tasks: dict[str, dict[str, Any]] = {}
|
| 326 |
+
for item in raw_items:
|
| 327 |
+
if not isinstance(item, dict):
|
| 328 |
+
continue
|
| 329 |
+
task_id = _clean(item.get("id"))
|
| 330 |
+
owner = _clean(item.get("owner_id"))
|
| 331 |
+
if not task_id or not owner:
|
| 332 |
+
continue
|
| 333 |
+
status = _clean(item.get("status"))
|
| 334 |
+
if status not in {TASK_STATUS_QUEUED, TASK_STATUS_RUNNING, TASK_STATUS_SUCCESS, TASK_STATUS_ERROR}:
|
| 335 |
+
status = TASK_STATUS_ERROR
|
| 336 |
+
task = {
|
| 337 |
+
"id": task_id,
|
| 338 |
+
"owner_id": owner,
|
| 339 |
+
"status": status,
|
| 340 |
+
"mode": "edit" if item.get("mode") == "edit" else "generate",
|
| 341 |
+
"model": _clean(item.get("model"), "gpt-image-2"),
|
| 342 |
+
"size": _clean(item.get("size")),
|
| 343 |
+
"quality": _clean(item.get("quality"), "auto"),
|
| 344 |
+
"created_at": _clean(item.get("created_at"), _now_iso()),
|
| 345 |
+
"updated_at": _clean(item.get("updated_at"), _clean(item.get("created_at"), _now_iso())),
|
| 346 |
+
}
|
| 347 |
+
data = item.get("data")
|
| 348 |
+
if isinstance(data, list):
|
| 349 |
+
task["data"] = data
|
| 350 |
+
error = _clean(item.get("error"))
|
| 351 |
+
if error:
|
| 352 |
+
task["error"] = error
|
| 353 |
+
tasks[_task_key(owner, task_id)] = task
|
| 354 |
+
return tasks
|
| 355 |
+
|
| 356 |
+
def _save_locked(self) -> None:
|
| 357 |
+
items = sorted(self._tasks.values(), key=lambda item: str(item.get("updated_at") or ""), reverse=True)
|
| 358 |
+
tmp_path = self.path.with_suffix(self.path.suffix + ".tmp")
|
| 359 |
+
tmp_path.write_text(json.dumps({"tasks": items}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
| 360 |
+
tmp_path.replace(self.path)
|
| 361 |
+
|
| 362 |
+
def _recover_unfinished_locked(self) -> bool:
|
| 363 |
+
changed = False
|
| 364 |
+
for task in self._tasks.values():
|
| 365 |
+
if task.get("status") in UNFINISHED_STATUSES:
|
| 366 |
+
task["status"] = TASK_STATUS_ERROR
|
| 367 |
+
task["error"] = "服务已重启,未完成的图片任务已中断"
|
| 368 |
+
task["updated_at"] = _now_iso()
|
| 369 |
+
changed = True
|
| 370 |
+
return changed
|
| 371 |
+
|
| 372 |
+
def _cleanup_locked(self) -> bool:
|
| 373 |
+
try:
|
| 374 |
+
retention_days = max(1, int(self.retention_days_getter()))
|
| 375 |
+
except Exception:
|
| 376 |
+
retention_days = 30
|
| 377 |
+
cutoff = time.time() - retention_days * 86400
|
| 378 |
+
removed_keys = [
|
| 379 |
+
key
|
| 380 |
+
for key, task in self._tasks.items()
|
| 381 |
+
if task.get("status") in TERMINAL_STATUSES and _timestamp(task.get("updated_at")) < cutoff
|
| 382 |
+
]
|
| 383 |
+
for key in removed_keys:
|
| 384 |
+
self._tasks.pop(key, None)
|
| 385 |
+
return bool(removed_keys)
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
image_task_service = ImageTaskService(DATA_DIR / "image_tasks.json")
|
services/log_service.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
import json
|
| 5 |
+
import itertools
|
| 6 |
+
import time
|
| 7 |
+
from dataclasses import dataclass, field
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Any
|
| 11 |
+
from uuid import uuid4
|
| 12 |
+
|
| 13 |
+
from fastapi import HTTPException
|
| 14 |
+
from fastapi.concurrency import run_in_threadpool
|
| 15 |
+
from fastapi.responses import JSONResponse, StreamingResponse
|
| 16 |
+
|
| 17 |
+
from services.config import DATA_DIR
|
| 18 |
+
from services.protocol.error_response import anthropic_error_response, openai_error_response
|
| 19 |
+
from utils.helper import anthropic_sse_stream, sse_json_stream
|
| 20 |
+
|
| 21 |
+
LOG_TYPE_CALL = "call"
|
| 22 |
+
LOG_TYPE_ACCOUNT = "account"
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class LogService:
|
| 26 |
+
def __init__(self, path: Path):
|
| 27 |
+
self.path = path
|
| 28 |
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
| 29 |
+
|
| 30 |
+
@staticmethod
|
| 31 |
+
def _legacy_id(raw_line: str, line_number: int) -> str:
|
| 32 |
+
payload = f"{line_number}:{raw_line}".encode("utf-8", errors="ignore")
|
| 33 |
+
return hashlib.sha1(payload).hexdigest()[:24]
|
| 34 |
+
|
| 35 |
+
def _parse_line(self, raw_line: str, line_number: int) -> dict[str, Any] | None:
|
| 36 |
+
try:
|
| 37 |
+
item = json.loads(raw_line)
|
| 38 |
+
except Exception:
|
| 39 |
+
return None
|
| 40 |
+
if not isinstance(item, dict):
|
| 41 |
+
return None
|
| 42 |
+
parsed = dict(item)
|
| 43 |
+
parsed["id"] = str(parsed.get("id") or self._legacy_id(raw_line, line_number))
|
| 44 |
+
return parsed
|
| 45 |
+
|
| 46 |
+
@staticmethod
|
| 47 |
+
def _serialize_item(item: dict[str, Any]) -> str:
|
| 48 |
+
return json.dumps(item, ensure_ascii=False, separators=(",", ":"))
|
| 49 |
+
|
| 50 |
+
@staticmethod
|
| 51 |
+
def _matches_filters(item: dict[str, Any], *, type: str = "", start_date: str = "", end_date: str = "") -> bool:
|
| 52 |
+
t = str(item.get("time") or "")
|
| 53 |
+
day = t[:10]
|
| 54 |
+
if type and item.get("type") != type:
|
| 55 |
+
return False
|
| 56 |
+
if start_date and day < start_date:
|
| 57 |
+
return False
|
| 58 |
+
if end_date and day > end_date:
|
| 59 |
+
return False
|
| 60 |
+
return True
|
| 61 |
+
|
| 62 |
+
def add(self, type: str, summary: str = "", detail: dict[str, Any] | None = None, **data: Any) -> None:
|
| 63 |
+
item = {
|
| 64 |
+
"id": uuid4().hex,
|
| 65 |
+
"time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
| 66 |
+
"type": type,
|
| 67 |
+
"summary": summary,
|
| 68 |
+
"detail": detail or data,
|
| 69 |
+
}
|
| 70 |
+
with self.path.open("a", encoding="utf-8") as file:
|
| 71 |
+
file.write(self._serialize_item(item) + "\n")
|
| 72 |
+
|
| 73 |
+
def list(self, type: str = "", start_date: str = "", end_date: str = "", limit: int = 200) -> list[dict[str, Any]]:
|
| 74 |
+
if not self.path.exists():
|
| 75 |
+
return []
|
| 76 |
+
items: list[dict[str, Any]] = []
|
| 77 |
+
lines = self.path.read_text(encoding="utf-8").splitlines()
|
| 78 |
+
for line_number in range(len(lines) - 1, -1, -1):
|
| 79 |
+
item = self._parse_line(lines[line_number], line_number)
|
| 80 |
+
if item is None:
|
| 81 |
+
continue
|
| 82 |
+
if not self._matches_filters(item, type=type, start_date=start_date, end_date=end_date):
|
| 83 |
+
continue
|
| 84 |
+
items.append(item)
|
| 85 |
+
if len(items) >= limit:
|
| 86 |
+
break
|
| 87 |
+
return items
|
| 88 |
+
|
| 89 |
+
def delete(self, ids: list[str]) -> dict[str, int]:
|
| 90 |
+
target_ids = {str(item or "").strip() for item in ids if str(item or "").strip()}
|
| 91 |
+
if not self.path.exists() or not target_ids:
|
| 92 |
+
return {"removed": 0}
|
| 93 |
+
lines = self.path.read_text(encoding="utf-8").splitlines()
|
| 94 |
+
kept_lines: list[str] = []
|
| 95 |
+
removed = 0
|
| 96 |
+
for line_number, raw_line in enumerate(lines):
|
| 97 |
+
item = self._parse_line(raw_line, line_number)
|
| 98 |
+
if item is None:
|
| 99 |
+
kept_lines.append(raw_line)
|
| 100 |
+
continue
|
| 101 |
+
if str(item.get("id") or "") in target_ids:
|
| 102 |
+
removed += 1
|
| 103 |
+
continue
|
| 104 |
+
kept_lines.append(self._serialize_item(item))
|
| 105 |
+
content = "\n".join(kept_lines)
|
| 106 |
+
if content:
|
| 107 |
+
content += "\n"
|
| 108 |
+
self.path.write_text(content, encoding="utf-8")
|
| 109 |
+
return {"removed": removed}
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
log_service = LogService(DATA_DIR / "logs.jsonl")
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _collect_urls(value: object) -> list[str]:
|
| 116 |
+
urls: list[str] = []
|
| 117 |
+
if isinstance(value, dict):
|
| 118 |
+
for key, item in value.items():
|
| 119 |
+
if key == "url" and isinstance(item, str):
|
| 120 |
+
urls.append(item)
|
| 121 |
+
elif key == "urls" and isinstance(item, list):
|
| 122 |
+
urls.extend(str(url) for url in item if isinstance(url, str))
|
| 123 |
+
else:
|
| 124 |
+
urls.extend(_collect_urls(item))
|
| 125 |
+
elif isinstance(value, list):
|
| 126 |
+
for item in value:
|
| 127 |
+
urls.extend(_collect_urls(item))
|
| 128 |
+
return urls
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def _request_excerpt(text: object, limit: int = 1000) -> str:
|
| 132 |
+
value = str(text or "").strip()
|
| 133 |
+
if not value:
|
| 134 |
+
return ""
|
| 135 |
+
normalized = " ".join(value.split())
|
| 136 |
+
if len(normalized) <= limit:
|
| 137 |
+
return normalized
|
| 138 |
+
return normalized[: limit - 1].rstrip() + "…"
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def _image_error_response(exc: Exception) -> JSONResponse:
|
| 142 |
+
message = str(exc)
|
| 143 |
+
if "no available image quota" in message.lower():
|
| 144 |
+
return openai_error_response(
|
| 145 |
+
{
|
| 146 |
+
"error": {
|
| 147 |
+
"message": "no available image quota",
|
| 148 |
+
"type": "insufficient_quota",
|
| 149 |
+
"param": None,
|
| 150 |
+
"code": "insufficient_quota",
|
| 151 |
+
}
|
| 152 |
+
},
|
| 153 |
+
429,
|
| 154 |
+
)
|
| 155 |
+
if hasattr(exc, "to_openai_error") and hasattr(exc, "status_code"):
|
| 156 |
+
return JSONResponse(status_code=int(exc.status_code), content=exc.to_openai_error())
|
| 157 |
+
return openai_error_response(message, 502)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def _protocol_error_response(exc: Exception, status_code: int, sse: str) -> JSONResponse:
|
| 161 |
+
message = str(exc)
|
| 162 |
+
if sse == "anthropic":
|
| 163 |
+
return anthropic_error_response(message, status_code)
|
| 164 |
+
return openai_error_response(message, status_code)
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def _next_item(items):
|
| 168 |
+
try:
|
| 169 |
+
return True, next(items)
|
| 170 |
+
except StopIteration:
|
| 171 |
+
return False, None
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
@dataclass
|
| 175 |
+
class LoggedCall:
|
| 176 |
+
identity: dict[str, object]
|
| 177 |
+
endpoint: str
|
| 178 |
+
model: str
|
| 179 |
+
summary: str
|
| 180 |
+
started: float = field(default_factory=time.time)
|
| 181 |
+
request_text: str = ""
|
| 182 |
+
|
| 183 |
+
async def run(self, handler, *args, sse: str = "openai"):
|
| 184 |
+
from services.protocol.conversation import ImageGenerationError
|
| 185 |
+
|
| 186 |
+
try:
|
| 187 |
+
result = await run_in_threadpool(handler, *args)
|
| 188 |
+
except ImageGenerationError as exc:
|
| 189 |
+
self.log("调用失败", status="failed", error=str(exc))
|
| 190 |
+
return _image_error_response(exc)
|
| 191 |
+
except HTTPException as exc:
|
| 192 |
+
self.log("调用失败", status="failed", error=str(exc.detail))
|
| 193 |
+
raise
|
| 194 |
+
except Exception as exc:
|
| 195 |
+
self.log("调用失败", status="failed", error=str(exc))
|
| 196 |
+
return _protocol_error_response(exc, 502, sse)
|
| 197 |
+
|
| 198 |
+
if isinstance(result, dict):
|
| 199 |
+
self.log("调用完成", result)
|
| 200 |
+
return result
|
| 201 |
+
|
| 202 |
+
sender = anthropic_sse_stream if sse == "anthropic" else sse_json_stream
|
| 203 |
+
try:
|
| 204 |
+
has_first, first = await run_in_threadpool(_next_item, result)
|
| 205 |
+
except ImageGenerationError as exc:
|
| 206 |
+
self.log("调用失败", status="failed", error=str(exc))
|
| 207 |
+
return _image_error_response(exc)
|
| 208 |
+
except HTTPException as exc:
|
| 209 |
+
self.log("调用失败", status="failed", error=str(exc.detail))
|
| 210 |
+
raise
|
| 211 |
+
except Exception as exc:
|
| 212 |
+
self.log("调用失败", status="failed", error=str(exc))
|
| 213 |
+
return _protocol_error_response(exc, 502, sse)
|
| 214 |
+
if not has_first:
|
| 215 |
+
self.log("流式调用结束")
|
| 216 |
+
return StreamingResponse(sender(()), media_type="text/event-stream")
|
| 217 |
+
return StreamingResponse(sender(self.stream(itertools.chain([first], result))), media_type="text/event-stream")
|
| 218 |
+
|
| 219 |
+
def stream(self, items):
|
| 220 |
+
urls: list[str] = []
|
| 221 |
+
failed = False
|
| 222 |
+
try:
|
| 223 |
+
for item in items:
|
| 224 |
+
urls.extend(_collect_urls(item))
|
| 225 |
+
yield item
|
| 226 |
+
except Exception as exc:
|
| 227 |
+
failed = True
|
| 228 |
+
self.log("流式调用失败", status="failed", error=str(exc), urls=urls)
|
| 229 |
+
raise
|
| 230 |
+
finally:
|
| 231 |
+
if not failed:
|
| 232 |
+
self.log("流式调用结束", urls=urls)
|
| 233 |
+
|
| 234 |
+
def log(self, suffix: str, result: object = None, status: str = "success", error: str = "",
|
| 235 |
+
urls: list[str] | None = None) -> None:
|
| 236 |
+
detail = {
|
| 237 |
+
"key_id": self.identity.get("id"),
|
| 238 |
+
"key_name": self.identity.get("name"),
|
| 239 |
+
"role": self.identity.get("role"),
|
| 240 |
+
"endpoint": self.endpoint,
|
| 241 |
+
"model": self.model,
|
| 242 |
+
"started_at": datetime.fromtimestamp(self.started).strftime("%Y-%m-%d %H:%M:%S"),
|
| 243 |
+
"ended_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
| 244 |
+
"duration_ms": int((time.time() - self.started) * 1000),
|
| 245 |
+
"status": status,
|
| 246 |
+
}
|
| 247 |
+
request_excerpt = _request_excerpt(self.request_text)
|
| 248 |
+
if request_excerpt:
|
| 249 |
+
detail["request_text"] = request_excerpt
|
| 250 |
+
if error:
|
| 251 |
+
detail["error"] = error
|
| 252 |
+
collected_urls = [*(urls or []), *_collect_urls(result)]
|
| 253 |
+
if collected_urls:
|
| 254 |
+
detail["urls"] = list(dict.fromkeys(collected_urls))
|
| 255 |
+
log_service.add(LOG_TYPE_CALL, f"{self.summary}{suffix}", detail)
|
services/openai_backend_api.py
ADDED
|
@@ -0,0 +1,986 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import base64
|
| 2 |
+
import os
|
| 3 |
+
import random
|
| 4 |
+
import re
|
| 5 |
+
import time
|
| 6 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 7 |
+
from dataclasses import dataclass
|
| 8 |
+
from io import BytesIO
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Any, Dict, Iterator, Optional
|
| 11 |
+
|
| 12 |
+
from curl_cffi import requests
|
| 13 |
+
from PIL import Image
|
| 14 |
+
|
| 15 |
+
from services.account_service import account_service
|
| 16 |
+
from services.config import config
|
| 17 |
+
from services.proxy_service import proxy_settings
|
| 18 |
+
from utils.helper import UpstreamHTTPError, ensure_ok, iter_sse_payloads, new_uuid
|
| 19 |
+
from utils.log import logger
|
| 20 |
+
from utils.pow import build_legacy_requirements_token, build_proof_token, parse_pow_resources
|
| 21 |
+
from utils.turnstile import solve_turnstile_token
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class InvalidAccessTokenError(RuntimeError):
|
| 25 |
+
pass
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class ImagePollTimeoutError(RuntimeError):
|
| 29 |
+
pass
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@dataclass
|
| 33 |
+
class ChatRequirements:
|
| 34 |
+
"""保存一次对话请求所需的 sentinel token。"""
|
| 35 |
+
token: str
|
| 36 |
+
proof_token: str = ""
|
| 37 |
+
turnstile_token: str = ""
|
| 38 |
+
so_token: str = ""
|
| 39 |
+
raw_finalize: Optional[Dict[str, Any]] = None
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
DEFAULT_CLIENT_VERSION = "prod-be885abbfcfe7b1f511e88b3003d9ee44757fbad"
|
| 43 |
+
DEFAULT_CLIENT_BUILD_NUMBER = "5955942"
|
| 44 |
+
DEFAULT_POW_SCRIPT = "https://chatgpt.com/backend-api/sentinel/sdk.js"
|
| 45 |
+
CODEX_IMAGE_MODEL = "codex-gpt-image-2"
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class OpenAIBackendAPI:
|
| 49 |
+
"""ChatGPT Web 后端封装。
|
| 50 |
+
|
| 51 |
+
说明:
|
| 52 |
+
- 传入 `access_token` 时,聊天和模型列表都会走已登录链路
|
| 53 |
+
例如 `/backend-api/sentinel/chat-requirements`、`/backend-api/conversation`
|
| 54 |
+
- 不传 `access_token` 时,会走未登录链路
|
| 55 |
+
例如 `/backend-anon/sentinel/chat-requirements`、`/backend-anon/conversation`
|
| 56 |
+
- `stream_conversation()` 是底层统一流式入口
|
| 57 |
+
- 协议兼容转换放在 `services.protocol`
|
| 58 |
+
"""
|
| 59 |
+
|
| 60 |
+
def __init__(self, access_token: str = "") -> None:
|
| 61 |
+
"""初始化后端客户端。
|
| 62 |
+
|
| 63 |
+
参数:
|
| 64 |
+
- `access_token`:可选。传入后表示使用已登录链路;不传则使用未登录链路。
|
| 65 |
+
"""
|
| 66 |
+
self.base_url = "https://chatgpt.com"
|
| 67 |
+
self.client_version = DEFAULT_CLIENT_VERSION
|
| 68 |
+
self.client_build_number = DEFAULT_CLIENT_BUILD_NUMBER
|
| 69 |
+
self.access_token = access_token
|
| 70 |
+
self.fp = self._build_fp()
|
| 71 |
+
self.user_agent = self.fp["user-agent"]
|
| 72 |
+
self.device_id = self.fp["oai-device-id"]
|
| 73 |
+
self.session_id = self.fp["oai-session-id"]
|
| 74 |
+
self.pow_script_sources: list[str] = []
|
| 75 |
+
self.pow_data_build = ""
|
| 76 |
+
self.session = requests.Session(**proxy_settings.build_session_kwargs(
|
| 77 |
+
impersonate=self.fp["impersonate"],
|
| 78 |
+
verify=True,
|
| 79 |
+
))
|
| 80 |
+
self.session.headers.update({
|
| 81 |
+
"User-Agent": self.user_agent,
|
| 82 |
+
"Origin": self.base_url,
|
| 83 |
+
"Referer": self.base_url + "/",
|
| 84 |
+
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8,en-US;q=0.7",
|
| 85 |
+
"Cache-Control": "no-cache",
|
| 86 |
+
"Pragma": "no-cache",
|
| 87 |
+
"Priority": "u=1, i",
|
| 88 |
+
"Sec-Ch-Ua": self.fp["sec-ch-ua"],
|
| 89 |
+
"Sec-Ch-Ua-Arch": '"x86"',
|
| 90 |
+
"Sec-Ch-Ua-Bitness": '"64"',
|
| 91 |
+
"Sec-Ch-Ua-Full-Version": '"143.0.3650.96"',
|
| 92 |
+
"Sec-Ch-Ua-Full-Version-List": '"Microsoft Edge";v="143.0.3650.96", "Chromium";v="143.0.7499.147", "Not A(Brand";v="24.0.0.0"',
|
| 93 |
+
"Sec-Ch-Ua-Mobile": self.fp["sec-ch-ua-mobile"],
|
| 94 |
+
"Sec-Ch-Ua-Model": '""',
|
| 95 |
+
"Sec-Ch-Ua-Platform": self.fp["sec-ch-ua-platform"],
|
| 96 |
+
"Sec-Ch-Ua-Platform-Version": '"19.0.0"',
|
| 97 |
+
"Sec-Fetch-Dest": "empty",
|
| 98 |
+
"Sec-Fetch-Mode": "cors",
|
| 99 |
+
"Sec-Fetch-Site": "same-origin",
|
| 100 |
+
"OAI-Device-Id": self.device_id,
|
| 101 |
+
"OAI-Session-Id": self.session_id,
|
| 102 |
+
"OAI-Language": "zh-CN",
|
| 103 |
+
"OAI-Client-Version": self.client_version,
|
| 104 |
+
"OAI-Client-Build-Number": self.client_build_number,
|
| 105 |
+
})
|
| 106 |
+
if self.access_token:
|
| 107 |
+
self.session.headers["Authorization"] = f"Bearer {self.access_token}"
|
| 108 |
+
|
| 109 |
+
def _build_fp(self) -> Dict[str, str]:
|
| 110 |
+
account = account_service.get_account(self.access_token) if self.access_token else {}
|
| 111 |
+
account = account if isinstance(account, dict) else {}
|
| 112 |
+
raw_fp = account.get("fp")
|
| 113 |
+
fp = {str(k).lower(): str(v) for k, v in raw_fp.items()} if isinstance(raw_fp, dict) else {}
|
| 114 |
+
for key in (
|
| 115 |
+
"user-agent",
|
| 116 |
+
"impersonate",
|
| 117 |
+
"oai-device-id",
|
| 118 |
+
"oai-session-id",
|
| 119 |
+
"sec-ch-ua",
|
| 120 |
+
"sec-ch-ua-mobile",
|
| 121 |
+
"sec-ch-ua-platform",
|
| 122 |
+
):
|
| 123 |
+
value = str(account.get(key) or "").strip()
|
| 124 |
+
if value:
|
| 125 |
+
fp[key] = value
|
| 126 |
+
fp.setdefault(
|
| 127 |
+
"user-agent",
|
| 128 |
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
| 129 |
+
"(KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0",
|
| 130 |
+
)
|
| 131 |
+
fp.setdefault("impersonate", "edge101")
|
| 132 |
+
fp.setdefault("oai-device-id", new_uuid())
|
| 133 |
+
fp.setdefault("oai-session-id", new_uuid())
|
| 134 |
+
fp.setdefault("sec-ch-ua", '"Microsoft Edge";v="143", "Chromium";v="143", "Not A(Brand";v="24"')
|
| 135 |
+
fp.setdefault("sec-ch-ua-mobile", "?0")
|
| 136 |
+
fp.setdefault("sec-ch-ua-platform", '"Windows"')
|
| 137 |
+
return fp
|
| 138 |
+
|
| 139 |
+
def _headers(self, path: str, extra: Optional[Dict[str, str]] = None) -> Dict[str, str]:
|
| 140 |
+
"""构造请求头,并补上 web 端要求的 target path/route。"""
|
| 141 |
+
headers = dict(self.session.headers)
|
| 142 |
+
headers["X-OpenAI-Target-Path"] = path
|
| 143 |
+
headers["X-OpenAI-Target-Route"] = path
|
| 144 |
+
if extra:
|
| 145 |
+
headers.update(extra)
|
| 146 |
+
return headers
|
| 147 |
+
|
| 148 |
+
@staticmethod
|
| 149 |
+
def _extract_quota_and_restore_at(limits_progress: list[Any]) -> tuple[int, str | None, bool]:
|
| 150 |
+
for item in limits_progress:
|
| 151 |
+
if isinstance(item, dict) and item.get("feature_name") == "image_gen":
|
| 152 |
+
return int(item.get("remaining") or 0), str(item.get("reset_after") or "") or None, False
|
| 153 |
+
return 0, None, True
|
| 154 |
+
|
| 155 |
+
def _get_me(self) -> Dict[str, Any]:
|
| 156 |
+
path = "/backend-api/me"
|
| 157 |
+
response = self.session.get(self.base_url + path, headers=self._headers(path), timeout=20)
|
| 158 |
+
if response.status_code != 200:
|
| 159 |
+
if response.status_code == 401:
|
| 160 |
+
raise InvalidAccessTokenError(f"{path} failed: HTTP {response.status_code}")
|
| 161 |
+
raise RuntimeError(f"{path} failed: HTTP {response.status_code}")
|
| 162 |
+
return response.json()
|
| 163 |
+
|
| 164 |
+
def _get_conversation_init(self) -> Dict[str, Any]:
|
| 165 |
+
path = "/backend-api/conversation/init"
|
| 166 |
+
response = self.session.post(
|
| 167 |
+
self.base_url + path,
|
| 168 |
+
headers=self._headers(path, {"Content-Type": "application/json"}),
|
| 169 |
+
json={
|
| 170 |
+
"gizmo_id": None,
|
| 171 |
+
"requested_default_model": None,
|
| 172 |
+
"conversation_id": None,
|
| 173 |
+
"timezone_offset_min": -480,
|
| 174 |
+
},
|
| 175 |
+
timeout=20,
|
| 176 |
+
)
|
| 177 |
+
if response.status_code != 200:
|
| 178 |
+
if response.status_code == 401:
|
| 179 |
+
raise InvalidAccessTokenError(f"{path} failed: HTTP {response.status_code}")
|
| 180 |
+
raise RuntimeError(f"{path} failed: HTTP {response.status_code}")
|
| 181 |
+
return response.json()
|
| 182 |
+
|
| 183 |
+
def _get_default_account(self) -> Dict[str, Any]:
|
| 184 |
+
route = "/backend-api/accounts/check/v4-2023-04-27"
|
| 185 |
+
response = self.session.get(self.base_url + route + "?timezone_offset_min=-480", headers=self._headers(route),
|
| 186 |
+
timeout=20)
|
| 187 |
+
if response.status_code != 200:
|
| 188 |
+
if response.status_code == 401:
|
| 189 |
+
raise InvalidAccessTokenError(f"{route} failed: HTTP {response.status_code}")
|
| 190 |
+
raise RuntimeError(f"/backend-api/accounts/check failed: HTTP {response.status_code}")
|
| 191 |
+
payload = response.json()
|
| 192 |
+
logger.debug({"event": "backend_user_info_account_payload", "account_payload": payload})
|
| 193 |
+
return ((payload.get("accounts") or {}).get("default") or {}).get("account") or {}
|
| 194 |
+
|
| 195 |
+
def get_user_info(self) -> Dict[str, Any]:
|
| 196 |
+
"""获取当前 token 的账号信息。"""
|
| 197 |
+
if not self.access_token:
|
| 198 |
+
raise RuntimeError("access_token is required")
|
| 199 |
+
logger.debug({"event": "backend_user_info_start"})
|
| 200 |
+
with ThreadPoolExecutor(max_workers=3) as executor:
|
| 201 |
+
me_future = executor.submit(self._get_me)
|
| 202 |
+
init_future = executor.submit(self._get_conversation_init)
|
| 203 |
+
account_future = executor.submit(self._get_default_account)
|
| 204 |
+
me_payload, init_payload, default_account = me_future.result(), init_future.result(), account_future.result()
|
| 205 |
+
|
| 206 |
+
plan_type = str(default_account.get("plan_type") or "free")
|
| 207 |
+
|
| 208 |
+
limits_progress = init_payload.get("limits_progress")
|
| 209 |
+
limits_progress = limits_progress if isinstance(limits_progress, list) else []
|
| 210 |
+
quota, restore_at, image_quota_unknown = self._extract_quota_and_restore_at(limits_progress)
|
| 211 |
+
result = {
|
| 212 |
+
"email": me_payload.get("email"),
|
| 213 |
+
"user_id": me_payload.get("id"),
|
| 214 |
+
"type": plan_type,
|
| 215 |
+
"quota": quota,
|
| 216 |
+
"image_quota_unknown": image_quota_unknown,
|
| 217 |
+
"limits_progress": limits_progress,
|
| 218 |
+
"default_model_slug": init_payload.get("default_model_slug"),
|
| 219 |
+
"restore_at": restore_at,
|
| 220 |
+
"status": "正常" if image_quota_unknown and plan_type.lower() != "free" else ("限流" if quota == 0 else "正常"),
|
| 221 |
+
}
|
| 222 |
+
logger.debug({
|
| 223 |
+
"event": "backend_user_info_result",
|
| 224 |
+
"email": result.get("email"),
|
| 225 |
+
"user_id": result.get("user_id"),
|
| 226 |
+
"type": result.get("type"),
|
| 227 |
+
"quota": result.get("quota"),
|
| 228 |
+
"image_quota_unknown": result.get("image_quota_unknown"),
|
| 229 |
+
"default_model_slug": result.get("default_model_slug"),
|
| 230 |
+
"restore_at": result.get("restore_at"),
|
| 231 |
+
"status": result.get("status"),
|
| 232 |
+
})
|
| 233 |
+
return result
|
| 234 |
+
|
| 235 |
+
def _bootstrap_headers(self) -> Dict[str, str]:
|
| 236 |
+
"""构造首页预热请求头。"""
|
| 237 |
+
return {
|
| 238 |
+
"User-Agent": self.user_agent,
|
| 239 |
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
| 240 |
+
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
| 241 |
+
"Sec-Ch-Ua": self.session.headers["Sec-Ch-Ua"],
|
| 242 |
+
"Sec-Ch-Ua-Mobile": self.session.headers["Sec-Ch-Ua-Mobile"],
|
| 243 |
+
"Sec-Ch-Ua-Platform": self.session.headers["Sec-Ch-Ua-Platform"],
|
| 244 |
+
"Sec-Fetch-Dest": "document",
|
| 245 |
+
"Sec-Fetch-Mode": "navigate",
|
| 246 |
+
"Sec-Fetch-Site": "none",
|
| 247 |
+
"Sec-Fetch-User": "?1",
|
| 248 |
+
"Upgrade-Insecure-Requests": "1",
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
def _build_requirements(self, data: Dict[str, Any], source_p: str = "") -> ChatRequirements:
|
| 252 |
+
"""把 sentinel 响应整理成后续对话需要的 token 集合。"""
|
| 253 |
+
if (data.get("arkose") or {}).get("required"):
|
| 254 |
+
raise RuntimeError("chat requirements requires arkose token, which is not implemented")
|
| 255 |
+
|
| 256 |
+
proof_token = ""
|
| 257 |
+
proof_info = data.get("proofofwork") or {}
|
| 258 |
+
if proof_info.get("required"):
|
| 259 |
+
proof_token = build_proof_token(
|
| 260 |
+
proof_info.get("seed", ""),
|
| 261 |
+
proof_info.get("difficulty", ""),
|
| 262 |
+
self.user_agent,
|
| 263 |
+
script_sources=self.pow_script_sources,
|
| 264 |
+
data_build=self.pow_data_build,
|
| 265 |
+
)
|
| 266 |
+
|
| 267 |
+
turnstile_token = ""
|
| 268 |
+
turnstile_info = data.get("turnstile") or {}
|
| 269 |
+
if turnstile_info.get("required") and turnstile_info.get("dx"):
|
| 270 |
+
turnstile_token = solve_turnstile_token(turnstile_info["dx"], source_p) or ""
|
| 271 |
+
|
| 272 |
+
return ChatRequirements(
|
| 273 |
+
token=data.get("token", ""),
|
| 274 |
+
proof_token=proof_token,
|
| 275 |
+
turnstile_token=turnstile_token,
|
| 276 |
+
so_token=data.get("so_token", ""),
|
| 277 |
+
raw_finalize=data,
|
| 278 |
+
)
|
| 279 |
+
|
| 280 |
+
def _conversation_headers(self, path: str, requirements: ChatRequirements) -> Dict[str, str]:
|
| 281 |
+
"""根据当前 requirements 构造对话 SSE 请求头。"""
|
| 282 |
+
headers = {
|
| 283 |
+
"Accept": "text/event-stream",
|
| 284 |
+
"Content-Type": "application/json",
|
| 285 |
+
"OpenAI-Sentinel-Chat-Requirements-Token": requirements.token,
|
| 286 |
+
}
|
| 287 |
+
if requirements.proof_token:
|
| 288 |
+
headers["OpenAI-Sentinel-Proof-Token"] = requirements.proof_token
|
| 289 |
+
if requirements.turnstile_token:
|
| 290 |
+
headers["OpenAI-Sentinel-Turnstile-Token"] = requirements.turnstile_token
|
| 291 |
+
if requirements.so_token:
|
| 292 |
+
headers["OpenAI-Sentinel-SO-Token"] = requirements.so_token
|
| 293 |
+
return self._headers(path, headers)
|
| 294 |
+
|
| 295 |
+
def _api_messages_to_conversation_messages(self, messages: list[Dict[str, Any]]) -> list[Dict[str, Any]]:
|
| 296 |
+
"""把标准 chat messages 转成 web conversation 所需的 messages。"""
|
| 297 |
+
conversation_messages = []
|
| 298 |
+
for item in messages:
|
| 299 |
+
role = item.get("role", "user")
|
| 300 |
+
content = item.get("content", "")
|
| 301 |
+
if isinstance(content, str):
|
| 302 |
+
conversation_messages.append({
|
| 303 |
+
"id": new_uuid(),
|
| 304 |
+
"author": {"role": role},
|
| 305 |
+
"content": {"content_type": "text", "parts": [content]},
|
| 306 |
+
})
|
| 307 |
+
continue
|
| 308 |
+
if not isinstance(content, list):
|
| 309 |
+
raise RuntimeError("only string or list message content is supported")
|
| 310 |
+
text_parts: list[str] = []
|
| 311 |
+
image_inputs: list[tuple[bytes, str]] = []
|
| 312 |
+
for part in content:
|
| 313 |
+
if not isinstance(part, dict):
|
| 314 |
+
continue
|
| 315 |
+
part_type = str(part.get("type") or "")
|
| 316 |
+
if part_type == "text":
|
| 317 |
+
text_parts.append(str(part.get("text") or ""))
|
| 318 |
+
elif part_type == "image":
|
| 319 |
+
data = part.get("data")
|
| 320 |
+
mime = str(part.get("mime") or "image/png")
|
| 321 |
+
if isinstance(data, (bytes, bytearray)):
|
| 322 |
+
image_inputs.append((bytes(data), mime))
|
| 323 |
+
if not image_inputs:
|
| 324 |
+
conversation_messages.append({
|
| 325 |
+
"id": new_uuid(),
|
| 326 |
+
"author": {"role": role},
|
| 327 |
+
"content": {"content_type": "text", "parts": ["".join(text_parts)]},
|
| 328 |
+
})
|
| 329 |
+
continue
|
| 330 |
+
if not self.access_token:
|
| 331 |
+
raise RuntimeError("authenticated upstream account required for image input")
|
| 332 |
+
uploaded: list[Dict[str, Any]] = []
|
| 333 |
+
for idx, (data, mime) in enumerate(image_inputs, start=1):
|
| 334 |
+
ext_part = mime.split("/", 1)[1].split("+")[0] if "/" in mime else "png"
|
| 335 |
+
extension = "jpg" if ext_part == "jpeg" else (ext_part or "png")
|
| 336 |
+
b64 = base64.b64encode(data).decode("ascii")
|
| 337 |
+
uploaded.append(self._upload_image(f"data:{mime};base64,{b64}", f"image_{idx}.{extension}"))
|
| 338 |
+
parts: list[Any] = []
|
| 339 |
+
for ref in uploaded:
|
| 340 |
+
parts.append({
|
| 341 |
+
"content_type": "image_asset_pointer",
|
| 342 |
+
"asset_pointer": f"file-service://{ref['file_id']}",
|
| 343 |
+
"width": ref["width"],
|
| 344 |
+
"height": ref["height"],
|
| 345 |
+
"size_bytes": ref["file_size"],
|
| 346 |
+
})
|
| 347 |
+
text = "".join(text_parts)
|
| 348 |
+
if text:
|
| 349 |
+
parts.append(text)
|
| 350 |
+
conversation_messages.append({
|
| 351 |
+
"id": new_uuid(),
|
| 352 |
+
"author": {"role": role},
|
| 353 |
+
"content": {"content_type": "multimodal_text", "parts": parts},
|
| 354 |
+
"metadata": {
|
| 355 |
+
"attachments": [{
|
| 356 |
+
"id": ref["file_id"],
|
| 357 |
+
"mimeType": ref["mime_type"],
|
| 358 |
+
"name": ref["file_name"],
|
| 359 |
+
"size": ref["file_size"],
|
| 360 |
+
"width": ref["width"],
|
| 361 |
+
"height": ref["height"],
|
| 362 |
+
} for ref in uploaded],
|
| 363 |
+
},
|
| 364 |
+
})
|
| 365 |
+
return conversation_messages
|
| 366 |
+
|
| 367 |
+
def _conversation_payload(self, messages: list[Dict[str, Any]], model: str, timezone: str) -> Dict[str, Any]:
|
| 368 |
+
"""把标准 messages 构造成 web 对话请求体。"""
|
| 369 |
+
return {
|
| 370 |
+
"action": "next",
|
| 371 |
+
"messages": self._api_messages_to_conversation_messages(messages),
|
| 372 |
+
"model": model,
|
| 373 |
+
"parent_message_id": new_uuid(),
|
| 374 |
+
"conversation_mode": {"kind": "primary_assistant"},
|
| 375 |
+
"conversation_origin": None,
|
| 376 |
+
"force_paragen": False,
|
| 377 |
+
"force_paragen_model_slug": "",
|
| 378 |
+
"force_rate_limit": False,
|
| 379 |
+
"force_use_sse": True,
|
| 380 |
+
"history_and_training_disabled": True,
|
| 381 |
+
"reset_rate_limits": False,
|
| 382 |
+
"suggestions": [],
|
| 383 |
+
"supported_encodings": [],
|
| 384 |
+
"system_hints": [],
|
| 385 |
+
"timezone": timezone,
|
| 386 |
+
"timezone_offset_min": -480,
|
| 387 |
+
"variant_purpose": "comparison_implicit",
|
| 388 |
+
"websocket_request_id": new_uuid(),
|
| 389 |
+
"client_contextual_info": {
|
| 390 |
+
"is_dark_mode": False,
|
| 391 |
+
"time_since_loaded": 120,
|
| 392 |
+
"page_height": 900,
|
| 393 |
+
"page_width": 1400,
|
| 394 |
+
"pixel_ratio": 2,
|
| 395 |
+
"screen_height": 1440,
|
| 396 |
+
"screen_width": 2560,
|
| 397 |
+
},
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
def _image_model_slug(self, model: str) -> str:
|
| 401 |
+
"""把标准图片模型名映射到底层 model slug。"""
|
| 402 |
+
model = str(model or "").strip()
|
| 403 |
+
if not model:
|
| 404 |
+
return "auto"
|
| 405 |
+
if model == "gpt-image-2":
|
| 406 |
+
return "gpt-5-3"
|
| 407 |
+
if model == CODEX_IMAGE_MODEL:
|
| 408 |
+
return model
|
| 409 |
+
return "auto"
|
| 410 |
+
|
| 411 |
+
def _image_headers(self, path: str, requirements: ChatRequirements, conduit_token: str = "", accept: str = "*/*") -> \
|
| 412 |
+
Dict[str, str]:
|
| 413 |
+
"""构造图片链路请求头。"""
|
| 414 |
+
headers = {
|
| 415 |
+
"Content-Type": "application/json",
|
| 416 |
+
"Accept": accept,
|
| 417 |
+
"OpenAI-Sentinel-Chat-Requirements-Token": requirements.token,
|
| 418 |
+
}
|
| 419 |
+
if requirements.proof_token:
|
| 420 |
+
headers["OpenAI-Sentinel-Proof-Token"] = requirements.proof_token
|
| 421 |
+
if conduit_token:
|
| 422 |
+
headers["X-Conduit-Token"] = conduit_token
|
| 423 |
+
if accept == "text/event-stream":
|
| 424 |
+
headers["X-Oai-Turn-Trace-Id"] = new_uuid()
|
| 425 |
+
return self._headers(path, headers)
|
| 426 |
+
|
| 427 |
+
def _prepare_image_conversation(self, prompt: str, requirements: ChatRequirements, model: str) -> str:
|
| 428 |
+
"""为图片生成准备 conduit token。"""
|
| 429 |
+
path = "/backend-api/f/conversation/prepare"
|
| 430 |
+
payload = {
|
| 431 |
+
"action": "next",
|
| 432 |
+
"fork_from_shared_post": False,
|
| 433 |
+
"parent_message_id": new_uuid(),
|
| 434 |
+
"model": self._image_model_slug(model),
|
| 435 |
+
"client_prepare_state": "success",
|
| 436 |
+
"timezone_offset_min": -480,
|
| 437 |
+
"timezone": "Asia/Shanghai",
|
| 438 |
+
"conversation_mode": {"kind": "primary_assistant"},
|
| 439 |
+
"system_hints": ["picture_v2"],
|
| 440 |
+
"partial_query": {
|
| 441 |
+
"id": new_uuid(),
|
| 442 |
+
"author": {"role": "user"},
|
| 443 |
+
"content": {"content_type": "text", "parts": [prompt]},
|
| 444 |
+
},
|
| 445 |
+
"supports_buffering": True,
|
| 446 |
+
"supported_encodings": ["v1"],
|
| 447 |
+
"client_contextual_info": {"app_name": "chatgpt.com"},
|
| 448 |
+
}
|
| 449 |
+
response = self.session.post(
|
| 450 |
+
self.base_url + path,
|
| 451 |
+
headers=self._image_headers(path, requirements),
|
| 452 |
+
json=payload,
|
| 453 |
+
timeout=60,
|
| 454 |
+
)
|
| 455 |
+
ensure_ok(response, path)
|
| 456 |
+
return response.json().get("conduit_token", "")
|
| 457 |
+
|
| 458 |
+
def _decode_image_base64(self, image: str) -> bytes:
|
| 459 |
+
"""把 base64 图片字符串或本地路径解码成二进制。"""
|
| 460 |
+
if (
|
| 461 |
+
image
|
| 462 |
+
and len(image) < 512
|
| 463 |
+
and not image.startswith("data:")
|
| 464 |
+
and "\n" not in image
|
| 465 |
+
and "\r" not in image
|
| 466 |
+
):
|
| 467 |
+
file_path = Path(os.path.expanduser(image))
|
| 468 |
+
if file_path.exists() and file_path.is_file():
|
| 469 |
+
return file_path.read_bytes()
|
| 470 |
+
payload = image.split(",", 1)[1] if image.startswith("data:") and "," in image else image
|
| 471 |
+
return base64.b64decode(payload)
|
| 472 |
+
|
| 473 |
+
def _upload_image(self, image: str, file_name: str = "image.png") -> Dict[str, Any]:
|
| 474 |
+
"""上传一张 base64 图片,返回底层文件元数据。"""
|
| 475 |
+
data = self._decode_image_base64(image)
|
| 476 |
+
if (
|
| 477 |
+
image
|
| 478 |
+
and len(image) < 512
|
| 479 |
+
and not image.startswith("data:")
|
| 480 |
+
and "\n" not in image
|
| 481 |
+
and "\r" not in image
|
| 482 |
+
):
|
| 483 |
+
candidate_path = Path(os.path.expanduser(image))
|
| 484 |
+
if candidate_path.exists() and candidate_path.is_file():
|
| 485 |
+
file_name = candidate_path.name
|
| 486 |
+
image = Image.open(BytesIO(data))
|
| 487 |
+
width, height = image.size
|
| 488 |
+
mime_type = Image.MIME.get(image.format, "image/png")
|
| 489 |
+
path = "/backend-api/files"
|
| 490 |
+
response = self.session.post(
|
| 491 |
+
self.base_url + path,
|
| 492 |
+
headers=self._headers(path, {"Content-Type": "application/json", "Accept": "application/json"}),
|
| 493 |
+
json={"file_name": file_name, "file_size": len(data), "use_case": "multimodal", "width": width,
|
| 494 |
+
"height": height},
|
| 495 |
+
timeout=60,
|
| 496 |
+
)
|
| 497 |
+
ensure_ok(response, path)
|
| 498 |
+
upload_meta = response.json()
|
| 499 |
+
time.sleep(0.5)
|
| 500 |
+
response = self.session.put(
|
| 501 |
+
upload_meta["upload_url"],
|
| 502 |
+
headers={
|
| 503 |
+
"Content-Type": mime_type,
|
| 504 |
+
"x-ms-blob-type": "BlockBlob",
|
| 505 |
+
"x-ms-version": "2020-04-08",
|
| 506 |
+
"Origin": self.base_url,
|
| 507 |
+
"Referer": self.base_url + "/",
|
| 508 |
+
"User-Agent": self.user_agent,
|
| 509 |
+
"Accept": "application/json, text/plain, */*",
|
| 510 |
+
"Accept-Language": "en-US,en;q=0.8",
|
| 511 |
+
},
|
| 512 |
+
data=data,
|
| 513 |
+
timeout=120,
|
| 514 |
+
)
|
| 515 |
+
ensure_ok(response, "image_upload")
|
| 516 |
+
path = f"/backend-api/files/{upload_meta['file_id']}/uploaded"
|
| 517 |
+
response = self.session.post(
|
| 518 |
+
self.base_url + path,
|
| 519 |
+
headers=self._headers(path, {"Content-Type": "application/json", "Accept": "application/json"}),
|
| 520 |
+
data="{}",
|
| 521 |
+
timeout=60,
|
| 522 |
+
)
|
| 523 |
+
ensure_ok(response, path)
|
| 524 |
+
return {
|
| 525 |
+
"file_id": upload_meta["file_id"],
|
| 526 |
+
"file_name": file_name,
|
| 527 |
+
"file_size": len(data),
|
| 528 |
+
"mime_type": mime_type,
|
| 529 |
+
"width": width,
|
| 530 |
+
"height": height,
|
| 531 |
+
}
|
| 532 |
+
|
| 533 |
+
def _start_image_generation(self, prompt: str, requirements: ChatRequirements, conduit_token: str, model: str,
|
| 534 |
+
references: Optional[list[Dict[str, Any]]] = None) -> requests.Response:
|
| 535 |
+
"""启动图片生成或编辑的 SSE 请求。"""
|
| 536 |
+
references = references or []
|
| 537 |
+
parts = [{
|
| 538 |
+
"content_type": "image_asset_pointer",
|
| 539 |
+
"asset_pointer": f"file-service://{item['file_id']}",
|
| 540 |
+
"width": item["width"],
|
| 541 |
+
"height": item["height"],
|
| 542 |
+
"size_bytes": item["file_size"],
|
| 543 |
+
} for item in references]
|
| 544 |
+
parts.append(prompt)
|
| 545 |
+
content = {"content_type": "multimodal_text", "parts": parts} if references else {"content_type": "text",
|
| 546 |
+
"parts": [prompt]}
|
| 547 |
+
metadata = {
|
| 548 |
+
"developer_mode_connector_ids": [],
|
| 549 |
+
"selected_github_repos": [],
|
| 550 |
+
"selected_all_github_repos": False,
|
| 551 |
+
"system_hints": ["picture_v2"],
|
| 552 |
+
"serialization_metadata": {"custom_symbol_offsets": []},
|
| 553 |
+
}
|
| 554 |
+
if references:
|
| 555 |
+
metadata["attachments"] = [{
|
| 556 |
+
"id": item["file_id"],
|
| 557 |
+
"mimeType": item["mime_type"],
|
| 558 |
+
"name": item["file_name"],
|
| 559 |
+
"size": item["file_size"],
|
| 560 |
+
"width": item["width"],
|
| 561 |
+
"height": item["height"],
|
| 562 |
+
} for item in references]
|
| 563 |
+
payload = {
|
| 564 |
+
"action": "next",
|
| 565 |
+
"messages": [{
|
| 566 |
+
"id": new_uuid(),
|
| 567 |
+
"author": {"role": "user"},
|
| 568 |
+
"create_time": time.time(),
|
| 569 |
+
"content": content,
|
| 570 |
+
"metadata": metadata,
|
| 571 |
+
}],
|
| 572 |
+
"parent_message_id": new_uuid(),
|
| 573 |
+
"model": self._image_model_slug(model),
|
| 574 |
+
"client_prepare_state": "sent",
|
| 575 |
+
"timezone_offset_min": -480,
|
| 576 |
+
"timezone": "Asia/Shanghai",
|
| 577 |
+
"conversation_mode": {"kind": "primary_assistant"},
|
| 578 |
+
"enable_message_followups": True,
|
| 579 |
+
"system_hints": ["picture_v2"],
|
| 580 |
+
"supports_buffering": True,
|
| 581 |
+
"supported_encodings": ["v1"],
|
| 582 |
+
"client_contextual_info": {
|
| 583 |
+
"is_dark_mode": False,
|
| 584 |
+
"time_since_loaded": 1200,
|
| 585 |
+
"page_height": 1072,
|
| 586 |
+
"page_width": 1724,
|
| 587 |
+
"pixel_ratio": 1.2,
|
| 588 |
+
"screen_height": 1440,
|
| 589 |
+
"screen_width": 2560,
|
| 590 |
+
"app_name": "chatgpt.com",
|
| 591 |
+
},
|
| 592 |
+
"paragen_cot_summary_display_override": "allow",
|
| 593 |
+
"force_parallel_switch": "auto",
|
| 594 |
+
}
|
| 595 |
+
path = "/backend-api/f/conversation"
|
| 596 |
+
response = self.session.post(
|
| 597 |
+
self.base_url + path,
|
| 598 |
+
headers=self._image_headers(path, requirements, conduit_token, "text/event-stream"),
|
| 599 |
+
json=payload,
|
| 600 |
+
timeout=300,
|
| 601 |
+
stream=True,
|
| 602 |
+
)
|
| 603 |
+
ensure_ok(response, path)
|
| 604 |
+
return response
|
| 605 |
+
|
| 606 |
+
def _get_conversation(self, conversation_id: str) -> Dict[str, Any]:
|
| 607 |
+
"""获取完整 conversation 详情。"""
|
| 608 |
+
path = f"/backend-api/conversation/{conversation_id}"
|
| 609 |
+
response = self.session.get(self.base_url + path, headers=self._headers(path, {"Accept": "application/json"}),
|
| 610 |
+
timeout=60)
|
| 611 |
+
ensure_ok(response, path)
|
| 612 |
+
return response.json()
|
| 613 |
+
|
| 614 |
+
def _extract_image_tool_records(self, data: Dict[str, Any]) -> list[Dict[str, Any]]:
|
| 615 |
+
"""从 conversation 明细里提取图片工具输出记录。"""
|
| 616 |
+
mapping = data.get("mapping") or {}
|
| 617 |
+
file_pat = re.compile(r"file-service://([A-Za-z0-9_-]+)")
|
| 618 |
+
sed_pat = re.compile(r"sediment://([A-Za-z0-9_-]+)")
|
| 619 |
+
records = []
|
| 620 |
+
for message_id, node in mapping.items():
|
| 621 |
+
message = (node or {}).get("message") or {}
|
| 622 |
+
author = message.get("author") or {}
|
| 623 |
+
metadata = message.get("metadata") or {}
|
| 624 |
+
content = message.get("content") or {}
|
| 625 |
+
if author.get("role") != "tool":
|
| 626 |
+
continue
|
| 627 |
+
if content.get("content_type") != "multimodal_text":
|
| 628 |
+
continue
|
| 629 |
+
file_ids, sediment_ids = [], []
|
| 630 |
+
for part in content.get("parts") or []:
|
| 631 |
+
text = (part.get("asset_pointer") or "") if isinstance(part, dict) else (
|
| 632 |
+
part if isinstance(part, str) else "")
|
| 633 |
+
for hit in file_pat.findall(text):
|
| 634 |
+
if hit not in file_ids:
|
| 635 |
+
file_ids.append(hit)
|
| 636 |
+
for hit in sed_pat.findall(text):
|
| 637 |
+
if hit not in sediment_ids:
|
| 638 |
+
sediment_ids.append(hit)
|
| 639 |
+
if metadata.get("async_task_type") != "image_gen" and not file_ids and not sediment_ids:
|
| 640 |
+
continue
|
| 641 |
+
records.append(
|
| 642 |
+
{"message_id": message_id, "create_time": message.get("create_time") or 0, "file_ids": file_ids,
|
| 643 |
+
"sediment_ids": sediment_ids})
|
| 644 |
+
return sorted(records, key=lambda item: item["create_time"])
|
| 645 |
+
|
| 646 |
+
def _poll_image_results(self, conversation_id: str, timeout_secs: float = 120.0) -> tuple[list[str], list[str]]:
|
| 647 |
+
"""Poll the conversation document until image file ids appear or budget runs out.
|
| 648 |
+
|
| 649 |
+
- Sleeps image_poll_initial_wait_secs first (default 10s, +jitter). ChatGPT
|
| 650 |
+
image generation takes ~30s; polling immediately wastes requests and trips
|
| 651 |
+
a transient 429 the upstream returns within ~200ms of the SSE stream
|
| 652 |
+
closing (the conversation document is not yet committed).
|
| 653 |
+
- Subsequent polls are image_poll_interval_secs apart (default 10s).
|
| 654 |
+
- On upstream 429 / 5xx or network errors, backs off exponentially
|
| 655 |
+
(capped at 16s, +jitter) honoring Retry-After when present.
|
| 656 |
+
- All sleeps stay within timeout_secs; on exhaustion raises ImagePollTimeoutError.
|
| 657 |
+
"""
|
| 658 |
+
start = time.time()
|
| 659 |
+
attempt = 0
|
| 660 |
+
interval = float(config.image_poll_interval_secs)
|
| 661 |
+
initial_wait = float(config.image_poll_initial_wait_secs)
|
| 662 |
+
logger.info({
|
| 663 |
+
"event": "image_poll_start",
|
| 664 |
+
"conversation_id": conversation_id,
|
| 665 |
+
"timeout_secs": timeout_secs,
|
| 666 |
+
"initial_wait_secs": initial_wait,
|
| 667 |
+
"interval_secs": interval,
|
| 668 |
+
})
|
| 669 |
+
|
| 670 |
+
def _remaining() -> float:
|
| 671 |
+
return timeout_secs - (time.time() - start)
|
| 672 |
+
|
| 673 |
+
if initial_wait > 0:
|
| 674 |
+
jitter = random.uniform(0, min(2.0, initial_wait * 0.2))
|
| 675 |
+
sleep_for = min(initial_wait + jitter, max(0.0, _remaining()))
|
| 676 |
+
if sleep_for > 0:
|
| 677 |
+
time.sleep(sleep_for)
|
| 678 |
+
|
| 679 |
+
def _retry_sleep(reason: str, status_code: int | None, error: str | None, retry_after: int | None) -> bool:
|
| 680 |
+
# retry_after=0 means "retry immediately" ��� must not be coerced via falsy check.
|
| 681 |
+
base = retry_after if retry_after is not None else min(2 ** min(attempt, 4), 16)
|
| 682 |
+
backoff = base + random.uniform(0, 0.5)
|
| 683 |
+
remaining = _remaining()
|
| 684 |
+
if remaining <= 0:
|
| 685 |
+
return False
|
| 686 |
+
sleep_for = min(backoff, remaining)
|
| 687 |
+
log_payload: Dict[str, Any] = {
|
| 688 |
+
"event": "image_poll_retry",
|
| 689 |
+
"conversation_id": conversation_id,
|
| 690 |
+
"attempt": attempt,
|
| 691 |
+
"reason": reason,
|
| 692 |
+
"sleep_secs": round(sleep_for, 2),
|
| 693 |
+
}
|
| 694 |
+
if status_code is not None:
|
| 695 |
+
log_payload["status_code"] = status_code
|
| 696 |
+
if error is not None:
|
| 697 |
+
log_payload["error"] = error
|
| 698 |
+
logger.warning(log_payload)
|
| 699 |
+
time.sleep(sleep_for)
|
| 700 |
+
return True
|
| 701 |
+
|
| 702 |
+
while _remaining() > 0:
|
| 703 |
+
attempt += 1
|
| 704 |
+
try:
|
| 705 |
+
conversation = self._get_conversation(conversation_id)
|
| 706 |
+
except UpstreamHTTPError as exc:
|
| 707 |
+
if exc.status_code in (429, 500, 502, 503, 504):
|
| 708 |
+
if _retry_sleep("upstream_status", exc.status_code, None, exc.retry_after):
|
| 709 |
+
continue
|
| 710 |
+
break
|
| 711 |
+
raise
|
| 712 |
+
except requests.exceptions.RequestException as exc:
|
| 713 |
+
if _retry_sleep("network", None, str(exc), None):
|
| 714 |
+
continue
|
| 715 |
+
break
|
| 716 |
+
|
| 717 |
+
file_ids, sediment_ids = [], []
|
| 718 |
+
for record in self._extract_image_tool_records(conversation):
|
| 719 |
+
for file_id in record["file_ids"]:
|
| 720 |
+
if file_id not in file_ids:
|
| 721 |
+
file_ids.append(file_id)
|
| 722 |
+
for sediment_id in record["sediment_ids"]:
|
| 723 |
+
if sediment_id not in sediment_ids:
|
| 724 |
+
sediment_ids.append(sediment_id)
|
| 725 |
+
logger.debug({"event": "image_poll_check", "conversation_id": conversation_id, "attempt": attempt,
|
| 726 |
+
"file_ids": file_ids, "sediment_ids": sediment_ids})
|
| 727 |
+
if file_ids:
|
| 728 |
+
logger.info({"event": "image_poll_hit", "conversation_id": conversation_id, "file_ids": file_ids,
|
| 729 |
+
"sediment_ids": sediment_ids})
|
| 730 |
+
return file_ids, sediment_ids
|
| 731 |
+
if sediment_ids:
|
| 732 |
+
logger.info({"event": "image_poll_hit", "conversation_id": conversation_id, "file_ids": [],
|
| 733 |
+
"sediment_ids": sediment_ids})
|
| 734 |
+
return [], sediment_ids
|
| 735 |
+
logger.debug({"event": "image_poll_wait", "conversation_id": conversation_id,
|
| 736 |
+
"elapsed_secs": round(time.time() - start, 1)})
|
| 737 |
+
wait = min(interval, max(0.0, _remaining()))
|
| 738 |
+
if wait > 0:
|
| 739 |
+
time.sleep(wait)
|
| 740 |
+
logger.info({
|
| 741 |
+
"event": "image_poll_timeout",
|
| 742 |
+
"conversation_id": conversation_id,
|
| 743 |
+
"timeout_secs": timeout_secs,
|
| 744 |
+
"attempts_made": attempt,
|
| 745 |
+
# attempts_made == 0 means the initial_wait consumed the entire budget — no HTTP attempted.
|
| 746 |
+
"initial_wait_exhausted_budget": attempt == 0,
|
| 747 |
+
})
|
| 748 |
+
raise ImagePollTimeoutError(
|
| 749 |
+
f"ChatGPT 生图超时(已等待 {timeout_secs} 秒)。"
|
| 750 |
+
f"当前超时阈值可在 config.json 中调大 image_poll_timeout_secs,"
|
| 751 |
+
f"也可能是账号被限流或生图队列拥堵导致。"
|
| 752 |
+
)
|
| 753 |
+
|
| 754 |
+
def _get_file_download_url(self, file_id: str) -> str:
|
| 755 |
+
"""获取文件下载地址。"""
|
| 756 |
+
path = f"/backend-api/files/{file_id}/download"
|
| 757 |
+
response = self.session.get(self.base_url + path, headers=self._headers(path, {"Accept": "application/json"}),
|
| 758 |
+
timeout=60)
|
| 759 |
+
ensure_ok(response, path)
|
| 760 |
+
data = response.json()
|
| 761 |
+
return data.get("download_url") or data.get("url") or ""
|
| 762 |
+
|
| 763 |
+
def _get_attachment_download_url(self, conversation_id: str, attachment_id: str) -> str:
|
| 764 |
+
"""通过 conversation 附件接口获取下载地址。"""
|
| 765 |
+
path = f"/backend-api/conversation/{conversation_id}/attachment/{attachment_id}/download"
|
| 766 |
+
response = self.session.get(self.base_url + path, headers=self._headers(path, {"Accept": "application/json"}),
|
| 767 |
+
timeout=60)
|
| 768 |
+
ensure_ok(response, path)
|
| 769 |
+
data = response.json()
|
| 770 |
+
return data.get("download_url") or data.get("url") or ""
|
| 771 |
+
|
| 772 |
+
def _resolve_image_urls(self, conversation_id: str, file_ids: list[str], sediment_ids: list[str]) -> list[str]:
|
| 773 |
+
"""把图片结果 id 解析成可下载 URL。"""
|
| 774 |
+
urls = []
|
| 775 |
+
skip_patterns = {"file_upload"}
|
| 776 |
+
for file_id in file_ids:
|
| 777 |
+
if file_id in skip_patterns:
|
| 778 |
+
logger.debug({
|
| 779 |
+
"event": "image_file_id_skipped",
|
| 780 |
+
"source": "file",
|
| 781 |
+
"conversation_id": conversation_id,
|
| 782 |
+
"id": file_id,
|
| 783 |
+
})
|
| 784 |
+
continue
|
| 785 |
+
try:
|
| 786 |
+
url = self._get_file_download_url(file_id)
|
| 787 |
+
except Exception as exc:
|
| 788 |
+
logger.debug({
|
| 789 |
+
"event": "image_download_url_failed",
|
| 790 |
+
"source": "file",
|
| 791 |
+
"conversation_id": conversation_id,
|
| 792 |
+
"id": file_id,
|
| 793 |
+
"error": repr(exc),
|
| 794 |
+
})
|
| 795 |
+
continue
|
| 796 |
+
if url:
|
| 797 |
+
urls.append(url)
|
| 798 |
+
else:
|
| 799 |
+
logger.debug({
|
| 800 |
+
"event": "image_download_url_empty",
|
| 801 |
+
"source": "file",
|
| 802 |
+
"conversation_id": conversation_id,
|
| 803 |
+
"id": file_id,
|
| 804 |
+
})
|
| 805 |
+
if urls or not conversation_id:
|
| 806 |
+
logger.debug({
|
| 807 |
+
"event": "image_urls_resolved",
|
| 808 |
+
"conversation_id": conversation_id,
|
| 809 |
+
"file_ids": file_ids,
|
| 810 |
+
"sediment_ids": sediment_ids,
|
| 811 |
+
"urls": urls,
|
| 812 |
+
})
|
| 813 |
+
return urls
|
| 814 |
+
for sediment_id in sediment_ids:
|
| 815 |
+
try:
|
| 816 |
+
url = self._get_attachment_download_url(conversation_id, sediment_id)
|
| 817 |
+
except Exception as exc:
|
| 818 |
+
logger.debug({
|
| 819 |
+
"event": "image_download_url_failed",
|
| 820 |
+
"source": "sediment",
|
| 821 |
+
"conversation_id": conversation_id,
|
| 822 |
+
"id": sediment_id,
|
| 823 |
+
"error": repr(exc),
|
| 824 |
+
})
|
| 825 |
+
continue
|
| 826 |
+
if url:
|
| 827 |
+
urls.append(url)
|
| 828 |
+
else:
|
| 829 |
+
logger.debug({
|
| 830 |
+
"event": "image_download_url_empty",
|
| 831 |
+
"source": "sediment",
|
| 832 |
+
"conversation_id": conversation_id,
|
| 833 |
+
"id": sediment_id,
|
| 834 |
+
})
|
| 835 |
+
logger.debug({
|
| 836 |
+
"event": "image_urls_resolved",
|
| 837 |
+
"conversation_id": conversation_id,
|
| 838 |
+
"file_ids": file_ids,
|
| 839 |
+
"sediment_ids": sediment_ids,
|
| 840 |
+
"urls": urls,
|
| 841 |
+
})
|
| 842 |
+
return urls
|
| 843 |
+
|
| 844 |
+
def resolve_conversation_image_urls(
|
| 845 |
+
self,
|
| 846 |
+
conversation_id: str,
|
| 847 |
+
file_ids: list[str],
|
| 848 |
+
sediment_ids: list[str],
|
| 849 |
+
poll: bool = True,
|
| 850 |
+
) -> list[str]:
|
| 851 |
+
file_ids = [item for item in file_ids if item != "file_upload"]
|
| 852 |
+
sediment_ids = list(sediment_ids)
|
| 853 |
+
if poll and conversation_id and not file_ids and not sediment_ids:
|
| 854 |
+
logger.info({"event": "image_resolve_poll_needed", "conversation_id": conversation_id})
|
| 855 |
+
polled_file_ids, polled_sediment_ids = self._poll_image_results(conversation_id,
|
| 856 |
+
config.image_poll_timeout_secs)
|
| 857 |
+
file_ids.extend(item for item in polled_file_ids if item and item not in file_ids)
|
| 858 |
+
sediment_ids.extend(item for item in polled_sediment_ids if item and item not in sediment_ids)
|
| 859 |
+
return self._resolve_image_urls(conversation_id, file_ids, sediment_ids)
|
| 860 |
+
|
| 861 |
+
def download_image_bytes(self, urls: list[str]) -> list[bytes]:
|
| 862 |
+
images = []
|
| 863 |
+
for url in urls:
|
| 864 |
+
response = self.session.get(url, timeout=120)
|
| 865 |
+
ensure_ok(response, "image_download")
|
| 866 |
+
images.append(response.content)
|
| 867 |
+
return images
|
| 868 |
+
|
| 869 |
+
def stream_conversation(
|
| 870 |
+
self,
|
| 871 |
+
messages: Optional[list[Dict[str, Any]]] = None,
|
| 872 |
+
model: str = "auto",
|
| 873 |
+
prompt: str = "",
|
| 874 |
+
images: Optional[list[str]] = None,
|
| 875 |
+
system_hints: Optional[list[str]] = None,
|
| 876 |
+
) -> Iterator[str]:
|
| 877 |
+
system_hints = system_hints or []
|
| 878 |
+
if "picture_v2" in system_hints:
|
| 879 |
+
yield from self._stream_picture_conversation(prompt, model, images or [])
|
| 880 |
+
return
|
| 881 |
+
|
| 882 |
+
normalized = messages or [{"role": "user", "content": prompt}]
|
| 883 |
+
self._bootstrap()
|
| 884 |
+
requirements = self._get_chat_requirements()
|
| 885 |
+
path, timezone = self._chat_target()
|
| 886 |
+
payload = self._conversation_payload(normalized, model, timezone)
|
| 887 |
+
response = self.session.post(
|
| 888 |
+
self.base_url + path,
|
| 889 |
+
headers=self._conversation_headers(path, requirements),
|
| 890 |
+
json=payload,
|
| 891 |
+
timeout=300,
|
| 892 |
+
stream=True,
|
| 893 |
+
)
|
| 894 |
+
ensure_ok(response, path)
|
| 895 |
+
try:
|
| 896 |
+
yield from iter_sse_payloads(response)
|
| 897 |
+
finally:
|
| 898 |
+
response.close()
|
| 899 |
+
|
| 900 |
+
def _stream_picture_conversation(
|
| 901 |
+
self,
|
| 902 |
+
prompt: str,
|
| 903 |
+
model: str,
|
| 904 |
+
images: list[str],
|
| 905 |
+
) -> Iterator[str]:
|
| 906 |
+
if not self.access_token:
|
| 907 |
+
raise RuntimeError("access_token is required for image endpoints")
|
| 908 |
+
references = [self._upload_image(image, f"image_{idx}.png") for idx, image in enumerate(images, start=1)]
|
| 909 |
+
self._bootstrap()
|
| 910 |
+
requirements = self._get_chat_requirements()
|
| 911 |
+
conduit_token = self._prepare_image_conversation(prompt, requirements, model)
|
| 912 |
+
response = self._start_image_generation(prompt, requirements, conduit_token, model, references)
|
| 913 |
+
try:
|
| 914 |
+
yield from iter_sse_payloads(response)
|
| 915 |
+
finally:
|
| 916 |
+
response.close()
|
| 917 |
+
|
| 918 |
+
def _bootstrap(self) -> None:
|
| 919 |
+
"""预热首页,并提取 PoW 相关脚本引用。"""
|
| 920 |
+
response = self.session.get(
|
| 921 |
+
self.base_url + "/",
|
| 922 |
+
headers=self._bootstrap_headers(),
|
| 923 |
+
timeout=30,
|
| 924 |
+
)
|
| 925 |
+
ensure_ok(response, "bootstrap")
|
| 926 |
+
self.pow_script_sources, self.pow_data_build = parse_pow_resources(response.text)
|
| 927 |
+
if not self.pow_script_sources:
|
| 928 |
+
self.pow_script_sources = [DEFAULT_POW_SCRIPT]
|
| 929 |
+
|
| 930 |
+
def _get_chat_requirements(self) -> ChatRequirements:
|
| 931 |
+
"""获取当前模式对话所需的 sentinel token。"""
|
| 932 |
+
path = "/backend-api/sentinel/chat-requirements" if self.access_token else "/backend-anon/sentinel/chat-requirements"
|
| 933 |
+
context = "auth_chat_requirements" if self.access_token else "noauth_chat_requirements"
|
| 934 |
+
body = {"p": build_legacy_requirements_token(self.user_agent, self.pow_script_sources, self.pow_data_build)}
|
| 935 |
+
response = self.session.post(
|
| 936 |
+
self.base_url + path,
|
| 937 |
+
headers=self._headers(path, {"Content-Type": "application/json"}),
|
| 938 |
+
json=body,
|
| 939 |
+
timeout=30,
|
| 940 |
+
)
|
| 941 |
+
ensure_ok(response, context)
|
| 942 |
+
requirements = self._build_requirements(response.json(), "" if self.access_token else body["p"])
|
| 943 |
+
if not requirements.token:
|
| 944 |
+
message = "missing auth chat requirements token" if self.access_token else "missing chat requirements token"
|
| 945 |
+
raise RuntimeError(f"{message}: {requirements.raw_finalize}")
|
| 946 |
+
return requirements
|
| 947 |
+
|
| 948 |
+
def _chat_target(self) -> tuple[str, str]:
|
| 949 |
+
if self.access_token:
|
| 950 |
+
return "/backend-api/conversation", "Asia/Shanghai"
|
| 951 |
+
return "/backend-anon/conversation", "America/Los_Angeles"
|
| 952 |
+
|
| 953 |
+
def list_models(self) -> Dict[str, Any]:
|
| 954 |
+
"""返回当前模式下可用模型,格式对齐 OpenAI `/v1/models`。"""
|
| 955 |
+
self._bootstrap()
|
| 956 |
+
path = "/backend-api/models?history_and_training_disabled=false" if self.access_token else (
|
| 957 |
+
"/backend-anon/models?iim=false&is_gizmo=false"
|
| 958 |
+
)
|
| 959 |
+
route = "/backend-api/models" if self.access_token else "/backend-anon/models"
|
| 960 |
+
context = "auth_models" if self.access_token else "anon_models"
|
| 961 |
+
response = self.session.get(
|
| 962 |
+
self.base_url + path,
|
| 963 |
+
headers=self._headers(route),
|
| 964 |
+
timeout=30,
|
| 965 |
+
)
|
| 966 |
+
ensure_ok(response, context)
|
| 967 |
+
data = []
|
| 968 |
+
seen = set()
|
| 969 |
+
for item in response.json().get("models", []):
|
| 970 |
+
if not isinstance(item, dict):
|
| 971 |
+
continue
|
| 972 |
+
slug = str(item.get("slug", "")).strip()
|
| 973 |
+
if not slug or slug in seen:
|
| 974 |
+
continue
|
| 975 |
+
seen.add(slug)
|
| 976 |
+
data.append({
|
| 977 |
+
"id": slug,
|
| 978 |
+
"object": "model",
|
| 979 |
+
"created": int(item.get("created") or 0),
|
| 980 |
+
"owned_by": str(item.get("owned_by") or "chatgpt"),
|
| 981 |
+
"permission": [],
|
| 982 |
+
"root": slug,
|
| 983 |
+
"parent": None,
|
| 984 |
+
})
|
| 985 |
+
data.sort(key=lambda item: item["id"])
|
| 986 |
+
return {"object": "list", "data": data}
|
services/protocol/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Protocol converters for OpenAI-compatible endpoints."""
|
| 2 |
+
|
services/protocol/anthropic_v1_messages.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import html
|
| 4 |
+
import json
|
| 5 |
+
import re
|
| 6 |
+
import time
|
| 7 |
+
import uuid
|
| 8 |
+
from collections.abc import Callable, Iterable, Iterator
|
| 9 |
+
from dataclasses import dataclass
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
from services.account_service import account_service
|
| 13 |
+
from services.openai_backend_api import OpenAIBackendAPI
|
| 14 |
+
from services.protocol.conversation import count_message_tokens, count_text_tokens, normalize_messages
|
| 15 |
+
from services.protocol.openai_v1_chat_complete import collect_chat_content, stream_text_chat_completion
|
| 16 |
+
|
| 17 |
+
XML_TOOL_RULE = """Tool output adapter: when calling tools, output ONLY this XML and no prose/markdown:
|
| 18 |
+
<tool_calls><tool_call><tool_name>TOOL_NAME</tool_name><parameters><PARAM><![CDATA[value]]></PARAM></parameters></tool_call></tool_calls>"""
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@dataclass
|
| 22 |
+
class MessageRequest:
|
| 23 |
+
backend: OpenAIBackendAPI
|
| 24 |
+
messages: list[dict[str, Any]]
|
| 25 |
+
model: str
|
| 26 |
+
tools: Any = None
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _tool_meta(tool: dict[str, object]) -> tuple[str, str, object]:
|
| 30 |
+
fn = tool.get("function") if isinstance(tool.get("function"), dict) else {}
|
| 31 |
+
name = str(tool.get("name") or fn.get("name") or "").strip()
|
| 32 |
+
desc = str(tool.get("description") or fn.get("description") or "").strip()
|
| 33 |
+
schema = tool.get("input_schema") or tool.get("parameters") or fn.get("input_schema") or fn.get("parameters") or {}
|
| 34 |
+
return name, desc, schema
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def build_tool_prompt(tools: object) -> str:
|
| 38 |
+
if not isinstance(tools, list):
|
| 39 |
+
return ""
|
| 40 |
+
blocks = []
|
| 41 |
+
for tool in tools:
|
| 42 |
+
if not isinstance(tool, dict):
|
| 43 |
+
continue
|
| 44 |
+
name, desc, schema = _tool_meta(tool)
|
| 45 |
+
if name:
|
| 46 |
+
blocks.append(f"Tool: {name}\nDescription: {desc}\nParameters: {json.dumps(schema, ensure_ascii=False)}")
|
| 47 |
+
if not blocks:
|
| 48 |
+
return ""
|
| 49 |
+
return "Available tools:\n" + "\n".join(blocks) + """
|
| 50 |
+
|
| 51 |
+
Tool use rules:
|
| 52 |
+
- If the user asks to list/read/search files, inspect project state, run a command, or answer from local code, you MUST call a suitable tool first. Do not say you cannot access files.
|
| 53 |
+
- To call tools, output ONLY XML and no prose/markdown:
|
| 54 |
+
<tool_calls><tool_call><tool_name>TOOL_NAME</tool_name><parameters><PARAM><![CDATA[value]]></PARAM></parameters></tool_call></tool_calls>
|
| 55 |
+
- Put parameters under <parameters> using the exact schema names.
|
| 56 |
+
""".strip()
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def merge_system(system: object, extra: str) -> object:
|
| 60 |
+
system = compact_system(system)
|
| 61 |
+
if _has_claude_code_system(system):
|
| 62 |
+
extra = XML_TOOL_RULE
|
| 63 |
+
if not extra:
|
| 64 |
+
return system
|
| 65 |
+
if isinstance(system, str) and system.strip():
|
| 66 |
+
return f"{system.strip()}\n\n{extra}"
|
| 67 |
+
if isinstance(system, list):
|
| 68 |
+
return [*system, {"type": "text", "text": extra}]
|
| 69 |
+
return extra
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _has_claude_code_system(system: object) -> bool:
|
| 73 |
+
if isinstance(system, str):
|
| 74 |
+
return "You are Claude Code" in system
|
| 75 |
+
if isinstance(system, list):
|
| 76 |
+
return any(isinstance(item, dict) and "You are Claude Code" in str(item.get("text") or "") for item in system)
|
| 77 |
+
return False
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def compact_system(system: object) -> object:
|
| 81 |
+
if isinstance(system, str):
|
| 82 |
+
return _compact_system_text(system)
|
| 83 |
+
if isinstance(system, list):
|
| 84 |
+
result = []
|
| 85 |
+
for item in system:
|
| 86 |
+
if isinstance(item, dict) and str(item.get("type") or "") == "text":
|
| 87 |
+
copied = dict(item)
|
| 88 |
+
copied["text"] = _compact_system_text(str(item.get("text") or ""))
|
| 89 |
+
result.append(copied)
|
| 90 |
+
else:
|
| 91 |
+
result.append(item)
|
| 92 |
+
return result
|
| 93 |
+
return system
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _compact_system_text(text: str) -> str:
|
| 97 |
+
return text or ""
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _compact_message_text(text: str) -> str:
|
| 101 |
+
return text or ""
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def preprocess_payload(payload: dict[str, object], text_mapper: Callable[[str], str] | None = None) -> dict[str, object]:
|
| 105 |
+
payload["messages"] = preprocess_messages(payload.get("messages"), text_mapper)
|
| 106 |
+
payload["system"] = merge_system(payload.get("system"), build_tool_prompt(payload.get("tools")))
|
| 107 |
+
return payload
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def message_request(body: dict[str, Any]) -> MessageRequest:
|
| 111 |
+
payload = preprocess_payload(dict(body))
|
| 112 |
+
return MessageRequest(
|
| 113 |
+
backend=OpenAIBackendAPI(access_token=account_service.get_text_access_token()),
|
| 114 |
+
messages=normalize_messages(payload.get("messages"), payload.get("system")),
|
| 115 |
+
model=str(payload.get("model") or "auto").strip() or "auto",
|
| 116 |
+
tools=payload.get("tools"),
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def preprocess_messages(messages: object, text_mapper: Callable[[str], str] | None = None) -> object:
|
| 121 |
+
if not isinstance(messages, list):
|
| 122 |
+
return messages
|
| 123 |
+
mapper = text_mapper or (lambda text: text)
|
| 124 |
+
result = []
|
| 125 |
+
for message in messages:
|
| 126 |
+
if not isinstance(message, dict):
|
| 127 |
+
continue
|
| 128 |
+
item = dict(message)
|
| 129 |
+
content = item.get("content")
|
| 130 |
+
if isinstance(content, str):
|
| 131 |
+
item["content"] = _compact_message_text(mapper(content))
|
| 132 |
+
elif isinstance(content, list):
|
| 133 |
+
item["content"] = [_preprocess_block(block, mapper) for block in content]
|
| 134 |
+
result.append(item)
|
| 135 |
+
return result
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _preprocess_block(block: object, text_mapper: Callable[[str], str]) -> object:
|
| 139 |
+
if not isinstance(block, dict):
|
| 140 |
+
return block
|
| 141 |
+
block_type = str(block.get("type") or "")
|
| 142 |
+
if block_type == "text":
|
| 143 |
+
item = dict(block)
|
| 144 |
+
item["text"] = _compact_message_text(text_mapper(str(block.get("text") or "")))
|
| 145 |
+
return item
|
| 146 |
+
if block_type == "tool_use":
|
| 147 |
+
return {"type": "text", "text": f"<tool_calls><tool_call><tool_name>{block.get('name') or ''}</tool_name><parameters>{json.dumps(block.get('input') or {}, ensure_ascii=False)}</parameters></tool_call></tool_calls>"}
|
| 148 |
+
if block_type == "tool_result":
|
| 149 |
+
return {"type": "text", "text": f"Tool result {block.get('tool_use_id') or ''}: {block.get('content') or ''}"}
|
| 150 |
+
return block
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def message_response(model: str, text: str, input_tokens: int, output_tokens: int, tools: object = None) -> dict[str, object]:
|
| 154 |
+
content, stop_reason = content_blocks(text, tools)
|
| 155 |
+
return {
|
| 156 |
+
"id": f"msg_{uuid.uuid4()}",
|
| 157 |
+
"type": "message",
|
| 158 |
+
"role": "assistant",
|
| 159 |
+
"model": model,
|
| 160 |
+
"content": content,
|
| 161 |
+
"stop_reason": stop_reason,
|
| 162 |
+
"stop_sequence": None,
|
| 163 |
+
"usage": {"input_tokens": input_tokens, "output_tokens": output_tokens},
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def content_blocks(text: str, tools: object = None) -> tuple[list[dict[str, object]], str]:
|
| 168 |
+
calls = parse_tool_calls(text) if isinstance(tools, list) and tools else []
|
| 169 |
+
text = strip_tool_markup(text)
|
| 170 |
+
if calls:
|
| 171 |
+
content = ([{"type": "text", "text": text}] if text else []) + [{"type": "tool_use", "id": f"toolu_{uuid.uuid4()}", "name": name, "input": args} for name, args in calls]
|
| 172 |
+
return content, "tool_use"
|
| 173 |
+
return [{"type": "text", "text": text}], "end_turn"
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def strip_tool_markup(text: str) -> str:
|
| 177 |
+
return re.sub(r"(?is)<tool_calls\b[^>]*>.*?</tool_calls>|<tool_call\b[^>]*>.*?</tool_call>|<function_call\b[^>]*>.*?</function_call>|<invoke\b[^>]*>.*?</invoke>", "", text or "").strip()
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def streamable_text(text: str) -> str:
|
| 181 |
+
text = text or ""
|
| 182 |
+
match = re.search(r"(?is)<tool_calls\b|<tool_call\b|<function_call\b|<invoke\b", text)
|
| 183 |
+
return text[:match.start()].rstrip() if match else text
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def parse_tool_calls(text: str) -> list[tuple[str, dict[str, object]]]:
|
| 187 |
+
text = re.sub(r"(?is)```.*?```", "", text or "").strip()
|
| 188 |
+
blocks = re.findall(r"(?is)<tool_call\b[^>]*>(.*?)</tool_call>|<function_call\b[^>]*>(.*?)</function_call>|<invoke\b[^>]*>(.*?)</invoke>", text)
|
| 189 |
+
result = []
|
| 190 |
+
for block in (next((part for part in match if part), "") for match in blocks):
|
| 191 |
+
name = xml_value(block, "tool_name") or xml_value(block, "name") or xml_value(block, "function")
|
| 192 |
+
params = xml_value(block, "parameters") or xml_value(block, "input") or xml_value(block, "arguments") or "{}"
|
| 193 |
+
if name:
|
| 194 |
+
result.append((name, parse_tool_params(params)))
|
| 195 |
+
return result
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def xml_value(text: str, tag: str) -> str:
|
| 199 |
+
match = re.search(rf"(?is)<{tag}\b[^>]*>(.*?)</{tag}>", text)
|
| 200 |
+
if not match:
|
| 201 |
+
return ""
|
| 202 |
+
value = match.group(1).strip()
|
| 203 |
+
cdata = re.fullmatch(r"(?is)<!\[CDATA\[(.*?)]]>", value)
|
| 204 |
+
return html.unescape(cdata.group(1) if cdata else value).strip()
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def parse_tool_params(raw: str) -> dict[str, object]:
|
| 208 |
+
raw = raw.strip()
|
| 209 |
+
try:
|
| 210 |
+
parsed = json.loads(raw)
|
| 211 |
+
return parsed if isinstance(parsed, dict) else {}
|
| 212 |
+
except Exception:
|
| 213 |
+
return {m.group(1): parse_tool_value(m.group(2)) for m in re.finditer(r"(?is)<([\w.-]+)\b[^>]*>(.*?)</\1>", raw)}
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def parse_tool_value(raw: str) -> object:
|
| 217 |
+
value = xml_value(f"<x>{raw}</x>", "x")
|
| 218 |
+
try:
|
| 219 |
+
return json.loads(value)
|
| 220 |
+
except Exception:
|
| 221 |
+
return value
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def stream_events(chunks: Iterable[dict[str, object]], model: str, input_tokens: int, output_tokens: Callable[[str], int], tools: object = None) -> Iterator[dict[str, object]]:
|
| 225 |
+
message_id = f"msg_{uuid.uuid4()}"
|
| 226 |
+
created = int(time.time())
|
| 227 |
+
current_text = ""
|
| 228 |
+
streamed_text = ""
|
| 229 |
+
tool_mode = isinstance(tools, list) and bool(tools)
|
| 230 |
+
tool_started = False
|
| 231 |
+
text_open = False
|
| 232 |
+
yield {"type": "message_start", "message": {"id": message_id, "type": "message", "role": "assistant", "model": model, "content": [], "stop_reason": None, "stop_sequence": None, "usage": {"input_tokens": input_tokens, "output_tokens": 0}}}
|
| 233 |
+
if not tool_mode:
|
| 234 |
+
text_open = True
|
| 235 |
+
yield {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}
|
| 236 |
+
for chunk in chunks:
|
| 237 |
+
choice = (chunk.get("choices") or [{}])[0]
|
| 238 |
+
delta = choice.get("delta") or {}
|
| 239 |
+
text_delta = delta.get("content", "") if isinstance(delta, dict) else ""
|
| 240 |
+
if text_delta:
|
| 241 |
+
current_text += text_delta
|
| 242 |
+
if not tool_started:
|
| 243 |
+
visible_text = current_text if not tool_mode else streamable_text(current_text)
|
| 244 |
+
if visible_text.startswith(streamed_text):
|
| 245 |
+
text_delta = visible_text[len(streamed_text):]
|
| 246 |
+
if text_delta:
|
| 247 |
+
if not text_open:
|
| 248 |
+
text_open = True
|
| 249 |
+
yield {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}
|
| 250 |
+
streamed_text = visible_text
|
| 251 |
+
yield {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": text_delta}}
|
| 252 |
+
tool_started = tool_mode and visible_text != current_text
|
| 253 |
+
if choice.get("finish_reason"):
|
| 254 |
+
content, stop_reason = content_blocks(current_text, tools)
|
| 255 |
+
if text_open:
|
| 256 |
+
yield {"type": "content_block_stop", "index": 0}
|
| 257 |
+
if stop_reason == "tool_use":
|
| 258 |
+
start_index = 1 if text_open else 0
|
| 259 |
+
if content and content[0]["type"] == "text":
|
| 260 |
+
remaining = str(content[0].get("text") or "")[len(streamed_text):]
|
| 261 |
+
if remaining:
|
| 262 |
+
if not text_open:
|
| 263 |
+
yield {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}
|
| 264 |
+
yield {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": remaining}}
|
| 265 |
+
if not text_open:
|
| 266 |
+
yield {"type": "content_block_stop", "index": 0}
|
| 267 |
+
start_index = 1
|
| 268 |
+
content = content[1:]
|
| 269 |
+
yield from _stream_buffered_blocks(content, start_index)
|
| 270 |
+
yield {"type": "message_delta", "delta": {"stop_reason": stop_reason, "stop_sequence": None}, "usage": {"output_tokens": output_tokens(current_text)}}
|
| 271 |
+
break
|
| 272 |
+
yield {"type": "message_stop", "created": created}
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def _stream_buffered_blocks(content: list[dict[str, object]], start_index: int = 0) -> Iterator[dict[str, object]]:
|
| 276 |
+
for offset, block in enumerate(content):
|
| 277 |
+
index = start_index + offset
|
| 278 |
+
if block["type"] == "tool_use":
|
| 279 |
+
start = {"type": "tool_use", "id": block["id"], "name": block["name"], "input": {}}
|
| 280 |
+
delta = {"type": "input_json_delta", "partial_json": json.dumps(block.get("input") or {}, ensure_ascii=False)}
|
| 281 |
+
else:
|
| 282 |
+
start = {"type": "text", "text": ""}
|
| 283 |
+
delta = {"type": "text_delta", "text": block.get("text") or ""}
|
| 284 |
+
yield {"type": "content_block_start", "index": index, "content_block": start}
|
| 285 |
+
yield {"type": "content_block_delta", "index": index, "delta": delta}
|
| 286 |
+
yield {"type": "content_block_stop", "index": index}
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]:
|
| 290 |
+
request = message_request(body)
|
| 291 |
+
if body.get("stream"):
|
| 292 |
+
return stream_events(
|
| 293 |
+
stream_text_chat_completion(request.backend, request.messages, request.model),
|
| 294 |
+
request.model,
|
| 295 |
+
count_message_tokens(request.messages, request.model),
|
| 296 |
+
lambda text: count_text_tokens(text, request.model),
|
| 297 |
+
request.tools,
|
| 298 |
+
)
|
| 299 |
+
text = collect_chat_content(stream_text_chat_completion(request.backend, request.messages, request.model))
|
| 300 |
+
return message_response(
|
| 301 |
+
request.model,
|
| 302 |
+
text,
|
| 303 |
+
count_message_tokens(request.messages, request.model),
|
| 304 |
+
count_text_tokens(text, request.model),
|
| 305 |
+
request.tools,
|
| 306 |
+
)
|
services/protocol/conversation.py
ADDED
|
@@ -0,0 +1,699 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
import json
|
| 5 |
+
import re
|
| 6 |
+
import time
|
| 7 |
+
from dataclasses import dataclass, field
|
| 8 |
+
from typing import Any, Iterable, Iterator
|
| 9 |
+
|
| 10 |
+
import tiktoken
|
| 11 |
+
|
| 12 |
+
from services.account_service import account_service
|
| 13 |
+
from services.config import config
|
| 14 |
+
from services.image_storage_service import image_storage_service
|
| 15 |
+
from services.openai_backend_api import ImagePollTimeoutError, OpenAIBackendAPI
|
| 16 |
+
from utils.helper import IMAGE_MODELS, extract_image_from_message_content
|
| 17 |
+
from utils.log import logger
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class ImageGenerationError(Exception):
|
| 21 |
+
def __init__(
|
| 22 |
+
self,
|
| 23 |
+
message: str,
|
| 24 |
+
status_code: int = 502,
|
| 25 |
+
error_type: str = "server_error",
|
| 26 |
+
code: str | None = "upstream_error",
|
| 27 |
+
param: str | None = None,
|
| 28 |
+
) -> None:
|
| 29 |
+
super().__init__(message)
|
| 30 |
+
self.status_code = status_code
|
| 31 |
+
self.error_type = error_type
|
| 32 |
+
self.code = code
|
| 33 |
+
self.param = param
|
| 34 |
+
|
| 35 |
+
def to_openai_error(self) -> dict[str, Any]:
|
| 36 |
+
return {
|
| 37 |
+
"error": {
|
| 38 |
+
"message": str(self),
|
| 39 |
+
"type": self.error_type,
|
| 40 |
+
"param": self.param,
|
| 41 |
+
"code": self.code,
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def is_token_invalid_error(message: str) -> bool:
|
| 47 |
+
text = str(message or "").lower()
|
| 48 |
+
return (
|
| 49 |
+
"token_invalidated" in text
|
| 50 |
+
or "token_revoked" in text
|
| 51 |
+
or "authentication token has been invalidated" in text
|
| 52 |
+
or "invalidated oauth token" in text
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def image_stream_error_message(message: str) -> str:
|
| 57 |
+
text = str(message or "")
|
| 58 |
+
lower = text.lower()
|
| 59 |
+
if is_token_invalid_error(text):
|
| 60 |
+
return "image generation failed"
|
| 61 |
+
if "curl: (35)" in lower or "tls connect error" in lower or "openssl_internal" in lower:
|
| 62 |
+
return "upstream image connection failed, please retry later"
|
| 63 |
+
return text or "image generation failed"
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def encode_images(images: Iterable[tuple[bytes, str, str]]) -> list[str]:
|
| 67 |
+
return [base64.b64encode(data).decode("ascii") for data, _, _ in images if data]
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def save_image_bytes(image_data: bytes, base_url: str | None = None) -> str:
|
| 71 |
+
return image_storage_service.save(image_data, base_url).url
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def message_text(content: Any) -> str:
|
| 75 |
+
if isinstance(content, str):
|
| 76 |
+
return content
|
| 77 |
+
if isinstance(content, list):
|
| 78 |
+
parts = []
|
| 79 |
+
for item in content:
|
| 80 |
+
if isinstance(item, str):
|
| 81 |
+
parts.append(item)
|
| 82 |
+
elif isinstance(item, dict) and str(item.get("type") or "") in {"text", "input_text", "output_text"}:
|
| 83 |
+
parts.append(str(item.get("text") or ""))
|
| 84 |
+
return "".join(parts)
|
| 85 |
+
return ""
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def normalize_messages(messages: object, system: Any = None) -> list[dict[str, Any]]:
|
| 89 |
+
normalized = []
|
| 90 |
+
if config.global_system_prompt:
|
| 91 |
+
normalized.append({"role": "system", "content": config.global_system_prompt})
|
| 92 |
+
system_text = message_text(system)
|
| 93 |
+
if system_text:
|
| 94 |
+
normalized.append({"role": "system", "content": system_text})
|
| 95 |
+
if isinstance(messages, list):
|
| 96 |
+
for message in messages:
|
| 97 |
+
if not isinstance(message, dict):
|
| 98 |
+
continue
|
| 99 |
+
role = message.get("role", "user")
|
| 100 |
+
content = message.get("content", "")
|
| 101 |
+
text = message_text(content)
|
| 102 |
+
images: list[tuple[bytes, str]] = []
|
| 103 |
+
if role == "user":
|
| 104 |
+
images.extend(extract_image_from_message_content(content))
|
| 105 |
+
if isinstance(content, list):
|
| 106 |
+
for part in content:
|
| 107 |
+
if not isinstance(part, dict) or part.get("type") != "image":
|
| 108 |
+
continue
|
| 109 |
+
data = part.get("data")
|
| 110 |
+
if isinstance(data, (bytes, bytearray)):
|
| 111 |
+
images.append((bytes(data), str(part.get("mime") or "image/png")))
|
| 112 |
+
if images:
|
| 113 |
+
parts: list[Any] = []
|
| 114 |
+
if text:
|
| 115 |
+
parts.append({"type": "text", "text": text})
|
| 116 |
+
for data, mime in images:
|
| 117 |
+
parts.append({"type": "image", "data": data, "mime": mime})
|
| 118 |
+
normalized.append({"role": role, "content": parts})
|
| 119 |
+
else:
|
| 120 |
+
normalized.append({"role": role, "content": text})
|
| 121 |
+
return normalized
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def prompt_with_global_system(prompt: str) -> str:
|
| 125 |
+
return f"{config.global_system_prompt}\n\n{prompt}" if config.global_system_prompt else prompt
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def assistant_history_text(messages: list[dict[str, Any]]) -> str:
|
| 129 |
+
return "".join(str(item.get("content") or "") for item in messages if item.get("role") == "assistant")
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def assistant_history_messages(messages: list[dict[str, Any]]) -> list[str]:
|
| 133 |
+
return [str(item.get("content") or "") for item in messages if item.get("role") == "assistant" and item.get("content")]
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def build_image_prompt(prompt: str, size: str | None, quality: str = "auto") -> str:
|
| 137 |
+
hints = []
|
| 138 |
+
if size:
|
| 139 |
+
hints.append(f"输出图片尺寸为 {size}。")
|
| 140 |
+
if quality:
|
| 141 |
+
hints.append(f"输出图片质量为 {quality}。")
|
| 142 |
+
return f"{prompt.strip()}\n\n{''.join(hints)}" if hints else prompt
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def encoding_for_model(model: str):
|
| 146 |
+
try:
|
| 147 |
+
return tiktoken.encoding_for_model(model)
|
| 148 |
+
except KeyError:
|
| 149 |
+
try:
|
| 150 |
+
return tiktoken.get_encoding("o200k_base")
|
| 151 |
+
except KeyError:
|
| 152 |
+
return tiktoken.get_encoding("cl100k_base")
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def count_message_tokens(messages: list[dict[str, Any]], model: str) -> int:
|
| 156 |
+
encoding = encoding_for_model(model)
|
| 157 |
+
total = 0
|
| 158 |
+
for message in messages:
|
| 159 |
+
total += 3
|
| 160 |
+
for key, value in message.items():
|
| 161 |
+
if not isinstance(value, str):
|
| 162 |
+
continue
|
| 163 |
+
total += len(encoding.encode(value))
|
| 164 |
+
if key == "name":
|
| 165 |
+
total += 1
|
| 166 |
+
return total + 3
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def count_text_tokens(text: str, model: str) -> int:
|
| 170 |
+
return len(encoding_for_model(model).encode(text))
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def format_image_result(
|
| 174 |
+
items: list[dict[str, Any]],
|
| 175 |
+
prompt: str,
|
| 176 |
+
response_format: str,
|
| 177 |
+
base_url: str | None = None,
|
| 178 |
+
created: int | None = None,
|
| 179 |
+
message: str = "",
|
| 180 |
+
) -> dict[str, Any]:
|
| 181 |
+
data: list[dict[str, Any]] = []
|
| 182 |
+
for item in items:
|
| 183 |
+
b64_json = str(item.get("b64_json") or "").strip()
|
| 184 |
+
if not b64_json:
|
| 185 |
+
continue
|
| 186 |
+
revised_prompt = str(item.get("revised_prompt") or prompt).strip() or prompt
|
| 187 |
+
if response_format == "b64_json":
|
| 188 |
+
data.append({
|
| 189 |
+
"b64_json": b64_json,
|
| 190 |
+
"url": save_image_bytes(base64.b64decode(b64_json), base_url),
|
| 191 |
+
"revised_prompt": revised_prompt,
|
| 192 |
+
})
|
| 193 |
+
else:
|
| 194 |
+
data.append({
|
| 195 |
+
"url": save_image_bytes(base64.b64decode(b64_json), base_url),
|
| 196 |
+
"revised_prompt": revised_prompt,
|
| 197 |
+
})
|
| 198 |
+
result: dict[str, Any] = {"created": created or int(time.time()), "data": data}
|
| 199 |
+
if message and not data:
|
| 200 |
+
result["message"] = message
|
| 201 |
+
return result
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
@dataclass
|
| 205 |
+
class ConversationRequest:
|
| 206 |
+
model: str = "auto"
|
| 207 |
+
prompt: str = ""
|
| 208 |
+
messages: list[dict[str, Any]] | None = None
|
| 209 |
+
images: list[str] | None = None
|
| 210 |
+
n: int = 1
|
| 211 |
+
size: str | None = None
|
| 212 |
+
quality: str = "auto"
|
| 213 |
+
response_format: str = "b64_json"
|
| 214 |
+
base_url: str | None = None
|
| 215 |
+
message_as_error: bool = False
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
@dataclass
|
| 219 |
+
class ConversationState:
|
| 220 |
+
text: str = ""
|
| 221 |
+
conversation_id: str = ""
|
| 222 |
+
file_ids: list[str] = field(default_factory=list)
|
| 223 |
+
sediment_ids: list[str] = field(default_factory=list)
|
| 224 |
+
blocked: bool = False
|
| 225 |
+
tool_invoked: bool | None = None
|
| 226 |
+
turn_use_case: str = ""
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
@dataclass
|
| 230 |
+
class ImageOutput:
|
| 231 |
+
kind: str
|
| 232 |
+
model: str
|
| 233 |
+
index: int
|
| 234 |
+
total: int
|
| 235 |
+
created: int = field(default_factory=lambda: int(time.time()))
|
| 236 |
+
text: str = ""
|
| 237 |
+
upstream_event_type: str = ""
|
| 238 |
+
data: list[dict[str, Any]] = field(default_factory=list)
|
| 239 |
+
|
| 240 |
+
def to_chunk(self) -> dict[str, Any]:
|
| 241 |
+
chunk: dict[str, Any] = {
|
| 242 |
+
"object": "image.generation.chunk",
|
| 243 |
+
"created": self.created,
|
| 244 |
+
"model": self.model,
|
| 245 |
+
"index": self.index,
|
| 246 |
+
"total": self.total,
|
| 247 |
+
"progress_text": self.text,
|
| 248 |
+
"upstream_event_type": self.upstream_event_type,
|
| 249 |
+
"data": [],
|
| 250 |
+
}
|
| 251 |
+
if self.kind == "message":
|
| 252 |
+
chunk.update({
|
| 253 |
+
"object": "image.generation.message",
|
| 254 |
+
"message": self.text,
|
| 255 |
+
})
|
| 256 |
+
chunk.pop("progress_text", None)
|
| 257 |
+
chunk.pop("upstream_event_type", None)
|
| 258 |
+
elif self.kind == "result":
|
| 259 |
+
chunk.update({
|
| 260 |
+
"object": "image.generation.result",
|
| 261 |
+
"data": self.data,
|
| 262 |
+
})
|
| 263 |
+
chunk.pop("progress_text", None)
|
| 264 |
+
chunk.pop("upstream_event_type", None)
|
| 265 |
+
return chunk
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
def assistant_message_text(message: dict[str, Any]) -> str:
|
| 269 |
+
content = message.get("content") or {}
|
| 270 |
+
parts = content.get("parts") or []
|
| 271 |
+
if not isinstance(parts, list):
|
| 272 |
+
return ""
|
| 273 |
+
return "".join(part for part in parts if isinstance(part, str))
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
def strip_history(text: str, history_text: str = "") -> str:
|
| 277 |
+
text = str(text or "")
|
| 278 |
+
history_text = str(history_text or "")
|
| 279 |
+
while history_text and text.startswith(history_text):
|
| 280 |
+
text = text[len(history_text):]
|
| 281 |
+
return text
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
def assistant_text(event: dict[str, Any], current_text: str = "", history_text: str = "") -> str:
|
| 285 |
+
for candidate in (event, event.get("v")):
|
| 286 |
+
if not isinstance(candidate, dict):
|
| 287 |
+
continue
|
| 288 |
+
message = candidate.get("message")
|
| 289 |
+
if not isinstance(message, dict):
|
| 290 |
+
continue
|
| 291 |
+
role = str((message.get("author") or {}).get("role") or "").strip().lower()
|
| 292 |
+
if role != "assistant":
|
| 293 |
+
continue
|
| 294 |
+
text = assistant_message_text(message)
|
| 295 |
+
if text:
|
| 296 |
+
return strip_history(text, history_text)
|
| 297 |
+
return apply_text_patch(event, current_text, history_text)
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
def event_assistant_text(event: dict[str, Any], history_text: str = "") -> str:
|
| 301 |
+
for candidate in (event, event.get("v")):
|
| 302 |
+
if not isinstance(candidate, dict):
|
| 303 |
+
continue
|
| 304 |
+
message = candidate.get("message")
|
| 305 |
+
if isinstance(message, dict) and (message.get("author") or {}).get("role") == "assistant":
|
| 306 |
+
return strip_history(assistant_message_text(message), history_text)
|
| 307 |
+
return ""
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def apply_text_patch(event: dict[str, Any], current_text: str = "", history_text: str = "") -> str:
|
| 311 |
+
if event.get("p") == "/message/content/parts/0":
|
| 312 |
+
return apply_patch_op(event, current_text, history_text)
|
| 313 |
+
|
| 314 |
+
operations = event.get("v")
|
| 315 |
+
if isinstance(operations, str) and current_text and not event.get("p") and not event.get("o"):
|
| 316 |
+
return current_text + operations
|
| 317 |
+
|
| 318 |
+
if event.get("o") == "patch" and isinstance(operations, list):
|
| 319 |
+
text = current_text
|
| 320 |
+
for item in operations:
|
| 321 |
+
if isinstance(item, dict):
|
| 322 |
+
text = apply_text_patch(item, text, history_text)
|
| 323 |
+
return text
|
| 324 |
+
|
| 325 |
+
if not isinstance(operations, list):
|
| 326 |
+
return current_text
|
| 327 |
+
|
| 328 |
+
text = current_text
|
| 329 |
+
for item in operations:
|
| 330 |
+
if isinstance(item, dict):
|
| 331 |
+
text = apply_text_patch(item, text, history_text)
|
| 332 |
+
return text
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
def apply_patch_op(operation: dict[str, Any], current_text: str, history_text: str = "") -> str:
|
| 336 |
+
op = operation.get("o")
|
| 337 |
+
value = str(operation.get("v") or "")
|
| 338 |
+
if op == "append":
|
| 339 |
+
return current_text + value
|
| 340 |
+
if op == "replace":
|
| 341 |
+
return strip_history(value, history_text)
|
| 342 |
+
return current_text
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
def add_unique(values: list[str], candidates: list[str]) -> None:
|
| 346 |
+
for candidate in candidates:
|
| 347 |
+
if candidate and candidate not in values:
|
| 348 |
+
values.append(candidate)
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
def extract_conversation_ids(payload: str) -> tuple[str, list[str], list[str]]:
|
| 352 |
+
conversation_match = re.search(r'"conversation_id"\s*:\s*"([^"]+)"', payload)
|
| 353 |
+
conversation_id = conversation_match.group(1) if conversation_match else ""
|
| 354 |
+
# Negative lookahead excludes "file-service" (URI prefix, not a real id).
|
| 355 |
+
file_ids = re.findall(r"(file[-_](?!service\b)[A-Za-z0-9]+)", payload)
|
| 356 |
+
sediment_ids = re.findall(r"sediment://([A-Za-z0-9_-]+)", payload)
|
| 357 |
+
return conversation_id, file_ids, sediment_ids
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
def is_image_tool_event(event: dict[str, Any]) -> bool:
|
| 361 |
+
value = event.get("v")
|
| 362 |
+
message = event.get("message") or (value.get("message") if isinstance(value, dict) else None)
|
| 363 |
+
if not isinstance(message, dict):
|
| 364 |
+
return False
|
| 365 |
+
metadata = message.get("metadata") or {}
|
| 366 |
+
author = message.get("author") or {}
|
| 367 |
+
content = message.get("content") or {}
|
| 368 |
+
if author.get("role") != "tool":
|
| 369 |
+
return False
|
| 370 |
+
if metadata.get("async_task_type") == "image_gen":
|
| 371 |
+
return True
|
| 372 |
+
if content.get("content_type") != "multimodal_text":
|
| 373 |
+
return False
|
| 374 |
+
return any(
|
| 375 |
+
isinstance(part, dict) and (
|
| 376 |
+
part.get("content_type") == "image_asset_pointer"
|
| 377 |
+
or str(part.get("asset_pointer") or "").startswith(("file-service://", "sediment://"))
|
| 378 |
+
)
|
| 379 |
+
for part in content.get("parts") or []
|
| 380 |
+
)
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
def update_conversation_state(state: ConversationState, payload: str, event: dict[str, Any] | None = None) -> None:
|
| 384 |
+
conversation_id, file_ids, sediment_ids = extract_conversation_ids(payload)
|
| 385 |
+
if conversation_id and not state.conversation_id:
|
| 386 |
+
state.conversation_id = conversation_id
|
| 387 |
+
# Accept file_id / sediment_id when any of:
|
| 388 |
+
# 1) event is a complete image_gen tool message
|
| 389 |
+
# 2) prior server_ste_metadata already flipped tool_invoked True (in an image_gen turn)
|
| 390 |
+
# 3) patch event whose payload references asset_pointer / file-service://
|
| 391 |
+
# User messages (type=conversation.message) never satisfy these, so attacker-controlled
|
| 392 |
+
# substrings in user input cannot inject file ids into state.
|
| 393 |
+
is_patch_event = isinstance(event, dict) and event.get("o") == "patch"
|
| 394 |
+
image_context = (
|
| 395 |
+
(isinstance(event, dict) and is_image_tool_event(event))
|
| 396 |
+
or state.tool_invoked is True
|
| 397 |
+
or (is_patch_event and ("asset_pointer" in payload or "file-service://" in payload))
|
| 398 |
+
)
|
| 399 |
+
if image_context:
|
| 400 |
+
add_unique(state.file_ids, file_ids)
|
| 401 |
+
add_unique(state.sediment_ids, sediment_ids)
|
| 402 |
+
if not isinstance(event, dict):
|
| 403 |
+
return
|
| 404 |
+
state.conversation_id = str(event.get("conversation_id") or state.conversation_id)
|
| 405 |
+
value = event.get("v")
|
| 406 |
+
if isinstance(value, dict):
|
| 407 |
+
state.conversation_id = str(value.get("conversation_id") or state.conversation_id)
|
| 408 |
+
if event.get("type") == "moderation":
|
| 409 |
+
moderation = event.get("moderation_response")
|
| 410 |
+
if isinstance(moderation, dict) and moderation.get("blocked") is True:
|
| 411 |
+
state.blocked = True
|
| 412 |
+
if event.get("type") == "server_ste_metadata":
|
| 413 |
+
metadata = event.get("metadata")
|
| 414 |
+
if isinstance(metadata, dict):
|
| 415 |
+
if isinstance(metadata.get("tool_invoked"), bool):
|
| 416 |
+
state.tool_invoked = metadata["tool_invoked"]
|
| 417 |
+
state.turn_use_case = str(metadata.get("turn_use_case") or state.turn_use_case)
|
| 418 |
+
|
| 419 |
+
|
| 420 |
+
def conversation_base_event(event_type: str, state: ConversationState, **extra: Any) -> dict[str, Any]:
|
| 421 |
+
return {
|
| 422 |
+
"type": event_type,
|
| 423 |
+
"text": state.text,
|
| 424 |
+
"conversation_id": state.conversation_id,
|
| 425 |
+
"file_ids": list(state.file_ids),
|
| 426 |
+
"sediment_ids": list(state.sediment_ids),
|
| 427 |
+
"blocked": state.blocked,
|
| 428 |
+
"tool_invoked": state.tool_invoked,
|
| 429 |
+
"turn_use_case": state.turn_use_case,
|
| 430 |
+
**extra,
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
def iter_conversation_payloads(payloads: Iterator[str], history_text: str = "",
|
| 435 |
+
history_messages: list[str] | None = None) -> Iterator[dict[str, Any]]:
|
| 436 |
+
state = ConversationState()
|
| 437 |
+
history_messages = history_messages or []
|
| 438 |
+
history_index = 0
|
| 439 |
+
for payload in payloads:
|
| 440 |
+
# print(f"[upstream_sse] {payload}", flush=True)
|
| 441 |
+
if not payload:
|
| 442 |
+
continue
|
| 443 |
+
if payload == "[DONE]":
|
| 444 |
+
yield conversation_base_event("conversation.done", state, done=True)
|
| 445 |
+
break
|
| 446 |
+
try:
|
| 447 |
+
event = json.loads(payload)
|
| 448 |
+
except json.JSONDecodeError:
|
| 449 |
+
update_conversation_state(state, payload)
|
| 450 |
+
yield conversation_base_event("conversation.raw", state, payload=payload)
|
| 451 |
+
continue
|
| 452 |
+
if not isinstance(event, dict):
|
| 453 |
+
yield conversation_base_event("conversation.event", state, raw=event)
|
| 454 |
+
continue
|
| 455 |
+
update_conversation_state(state, payload, event)
|
| 456 |
+
if history_index < len(history_messages) and event_assistant_text(event, history_text) == history_messages[history_index]:
|
| 457 |
+
history_index += 1
|
| 458 |
+
state.text = ""
|
| 459 |
+
continue
|
| 460 |
+
next_text = assistant_text(event, state.text, history_text)
|
| 461 |
+
if next_text != state.text:
|
| 462 |
+
delta = next_text[len(state.text):] if next_text.startswith(state.text) else next_text
|
| 463 |
+
state.text = next_text
|
| 464 |
+
yield conversation_base_event("conversation.delta", state, raw=event, delta=delta)
|
| 465 |
+
continue
|
| 466 |
+
yield conversation_base_event("conversation.event", state, raw=event)
|
| 467 |
+
|
| 468 |
+
|
| 469 |
+
def conversation_events(
|
| 470 |
+
backend: OpenAIBackendAPI,
|
| 471 |
+
messages: list[dict[str, Any]] | None = None,
|
| 472 |
+
model: str = "auto",
|
| 473 |
+
prompt: str = "",
|
| 474 |
+
images: list[str] | None = None,
|
| 475 |
+
size: str | None = None,
|
| 476 |
+
quality: str = "auto",
|
| 477 |
+
) -> Iterator[dict[str, Any]]:
|
| 478 |
+
normalized = normalize_messages(messages or ([{"role": "user", "content": prompt}] if prompt else []))
|
| 479 |
+
image_model = str(model or "").strip() in IMAGE_MODELS
|
| 480 |
+
history_text = "" if image_model else assistant_history_text(normalized)
|
| 481 |
+
history_messages = [] if image_model else assistant_history_messages(normalized)
|
| 482 |
+
final_prompt = prompt_with_global_system(build_image_prompt(prompt, size, quality)) if image_model else prompt
|
| 483 |
+
payloads = backend.stream_conversation(
|
| 484 |
+
messages=normalized,
|
| 485 |
+
model=model,
|
| 486 |
+
prompt=final_prompt,
|
| 487 |
+
images=images if image_model else None,
|
| 488 |
+
system_hints=["picture_v2"] if image_model else None,
|
| 489 |
+
)
|
| 490 |
+
yield from iter_conversation_payloads(payloads, history_text, history_messages)
|
| 491 |
+
|
| 492 |
+
|
| 493 |
+
def text_backend() -> OpenAIBackendAPI:
|
| 494 |
+
return OpenAIBackendAPI(access_token=account_service.get_text_access_token())
|
| 495 |
+
|
| 496 |
+
|
| 497 |
+
def stream_text_deltas(backend: OpenAIBackendAPI, request: ConversationRequest) -> Iterator[str]:
|
| 498 |
+
attempted_tokens: set[str] = set()
|
| 499 |
+
token = getattr(backend, "access_token", "")
|
| 500 |
+
emitted = False
|
| 501 |
+
while True:
|
| 502 |
+
if token and token in attempted_tokens:
|
| 503 |
+
raise RuntimeError("no available text account")
|
| 504 |
+
if token:
|
| 505 |
+
attempted_tokens.add(token)
|
| 506 |
+
try:
|
| 507 |
+
active_backend = OpenAIBackendAPI(access_token=token)
|
| 508 |
+
for event in conversation_events(active_backend, messages=request.messages, model=request.model, prompt=request.prompt):
|
| 509 |
+
if event.get("type") != "conversation.delta":
|
| 510 |
+
continue
|
| 511 |
+
delta = str(event.get("delta") or "")
|
| 512 |
+
if delta:
|
| 513 |
+
emitted = True
|
| 514 |
+
yield delta
|
| 515 |
+
account_service.mark_text_used(token)
|
| 516 |
+
return
|
| 517 |
+
except Exception as exc:
|
| 518 |
+
error_message = str(exc)
|
| 519 |
+
if token and not emitted and is_token_invalid_error(error_message):
|
| 520 |
+
refreshed_token = account_service.refresh_access_token(token, force=True, event="text_stream")
|
| 521 |
+
if refreshed_token and refreshed_token != token and refreshed_token not in attempted_tokens:
|
| 522 |
+
token = refreshed_token
|
| 523 |
+
else:
|
| 524 |
+
account_service.remove_invalid_token(token, "text_stream")
|
| 525 |
+
token = account_service.get_text_access_token(attempted_tokens)
|
| 526 |
+
if token:
|
| 527 |
+
continue
|
| 528 |
+
raise
|
| 529 |
+
|
| 530 |
+
|
| 531 |
+
def collect_text(backend: OpenAIBackendAPI, request: ConversationRequest) -> str:
|
| 532 |
+
return "".join(stream_text_deltas(backend, request))
|
| 533 |
+
|
| 534 |
+
|
| 535 |
+
def stream_image_outputs(
|
| 536 |
+
backend: OpenAIBackendAPI,
|
| 537 |
+
request: ConversationRequest,
|
| 538 |
+
index: int = 1,
|
| 539 |
+
total: int = 1,
|
| 540 |
+
) -> Iterator[ImageOutput]:
|
| 541 |
+
last: dict[str, Any] = {}
|
| 542 |
+
for event in conversation_events(
|
| 543 |
+
backend,
|
| 544 |
+
prompt=request.prompt,
|
| 545 |
+
model=request.model,
|
| 546 |
+
images=request.images or [],
|
| 547 |
+
size=request.size,
|
| 548 |
+
quality=request.quality,
|
| 549 |
+
):
|
| 550 |
+
last = event
|
| 551 |
+
if event.get("type") == "conversation.delta":
|
| 552 |
+
yield ImageOutput(
|
| 553 |
+
kind="progress",
|
| 554 |
+
model=request.model,
|
| 555 |
+
index=index,
|
| 556 |
+
total=total,
|
| 557 |
+
text=str(event.get("delta") or ""),
|
| 558 |
+
upstream_event_type="conversation.delta",
|
| 559 |
+
)
|
| 560 |
+
continue
|
| 561 |
+
if event.get("type") == "conversation.event":
|
| 562 |
+
raw = event.get("raw")
|
| 563 |
+
raw_type = str(raw.get("type") or "") if isinstance(raw, dict) else ""
|
| 564 |
+
yield ImageOutput(
|
| 565 |
+
kind="progress",
|
| 566 |
+
model=request.model,
|
| 567 |
+
index=index,
|
| 568 |
+
total=total,
|
| 569 |
+
upstream_event_type=raw_type,
|
| 570 |
+
)
|
| 571 |
+
|
| 572 |
+
conversation_id = str(last.get("conversation_id") or "")
|
| 573 |
+
file_ids = [str(item) for item in last.get("file_ids") or []]
|
| 574 |
+
sediment_ids = [str(item) for item in last.get("sediment_ids") or []]
|
| 575 |
+
message = str(last.get("text") or "").strip()
|
| 576 |
+
logger.info({
|
| 577 |
+
"event": "image_stream_resolve_start",
|
| 578 |
+
"conversation_id": conversation_id,
|
| 579 |
+
"file_ids": file_ids,
|
| 580 |
+
"sediment_ids": sediment_ids,
|
| 581 |
+
"tool_invoked": last.get("tool_invoked"),
|
| 582 |
+
"turn_use_case": last.get("turn_use_case"),
|
| 583 |
+
})
|
| 584 |
+
if message and not file_ids and not sediment_ids and last.get("blocked"):
|
| 585 |
+
yield ImageOutput(kind="message", model=request.model, index=index, total=total, text=message)
|
| 586 |
+
return
|
| 587 |
+
should_poll_for_image = bool(request.images) or last.get("turn_use_case") == "image gen"
|
| 588 |
+
if message and not file_ids and not sediment_ids and not should_poll_for_image:
|
| 589 |
+
yield ImageOutput(kind="message", model=request.model, index=index, total=total, text=message)
|
| 590 |
+
return
|
| 591 |
+
|
| 592 |
+
image_urls = backend.resolve_conversation_image_urls(conversation_id, file_ids, sediment_ids)
|
| 593 |
+
if image_urls:
|
| 594 |
+
image_items = [
|
| 595 |
+
{"b64_json": base64.b64encode(image_data).decode("ascii")}
|
| 596 |
+
for image_data in backend.download_image_bytes(image_urls)
|
| 597 |
+
]
|
| 598 |
+
data = format_image_result(
|
| 599 |
+
image_items,
|
| 600 |
+
request.prompt,
|
| 601 |
+
request.response_format,
|
| 602 |
+
request.base_url,
|
| 603 |
+
int(time.time()),
|
| 604 |
+
)["data"]
|
| 605 |
+
if data:
|
| 606 |
+
yield ImageOutput(kind="result", model=request.model, index=index, total=total, data=data)
|
| 607 |
+
return
|
| 608 |
+
|
| 609 |
+
if message:
|
| 610 |
+
yield ImageOutput(kind="message", model=request.model, index=index, total=total, text=message)
|
| 611 |
+
|
| 612 |
+
|
| 613 |
+
def stream_image_outputs_with_pool(request: ConversationRequest) -> Iterator[ImageOutput]:
|
| 614 |
+
if str(request.model or "").strip() not in IMAGE_MODELS:
|
| 615 |
+
raise ImageGenerationError("unsupported image model,supported models: " + ", ".join(IMAGE_MODELS))
|
| 616 |
+
|
| 617 |
+
emitted = False
|
| 618 |
+
last_error = ""
|
| 619 |
+
for index in range(1, request.n + 1):
|
| 620 |
+
while True:
|
| 621 |
+
try:
|
| 622 |
+
token = account_service.get_available_access_token()
|
| 623 |
+
except RuntimeError as exc:
|
| 624 |
+
if emitted:
|
| 625 |
+
return
|
| 626 |
+
raise ImageGenerationError(str(exc) or "image generation failed") from exc
|
| 627 |
+
|
| 628 |
+
emitted_for_token = False
|
| 629 |
+
returned_message = False
|
| 630 |
+
returned_result = False
|
| 631 |
+
try:
|
| 632 |
+
backend = OpenAIBackendAPI(access_token=token)
|
| 633 |
+
for output in stream_image_outputs(backend, request, index, request.n):
|
| 634 |
+
if output.kind == "message" and request.message_as_error:
|
| 635 |
+
raise ImageGenerationError(
|
| 636 |
+
output.text or "Image generation was rejected by upstream policy.",
|
| 637 |
+
status_code=400,
|
| 638 |
+
error_type="invalid_request_error",
|
| 639 |
+
code="content_policy_violation",
|
| 640 |
+
)
|
| 641 |
+
emitted = True
|
| 642 |
+
emitted_for_token = True
|
| 643 |
+
returned_message = output.kind == "message"
|
| 644 |
+
returned_result = returned_result or output.kind == "result"
|
| 645 |
+
yield output
|
| 646 |
+
if returned_message or not returned_result:
|
| 647 |
+
account_service.mark_image_result(token, False)
|
| 648 |
+
return
|
| 649 |
+
account_service.mark_image_result(token, True)
|
| 650 |
+
break
|
| 651 |
+
except ImagePollTimeoutError:
|
| 652 |
+
raise
|
| 653 |
+
except ImageGenerationError:
|
| 654 |
+
account_service.mark_image_result(token, False)
|
| 655 |
+
raise
|
| 656 |
+
except Exception as exc:
|
| 657 |
+
account_service.mark_image_result(token, False)
|
| 658 |
+
last_error = str(exc)
|
| 659 |
+
logger.warning({"event": "image_stream_fail", "request_token": token, "error": last_error})
|
| 660 |
+
if not emitted_for_token and is_token_invalid_error(last_error):
|
| 661 |
+
refreshed_token = account_service.refresh_access_token(token, force=True, event="image_stream")
|
| 662 |
+
if refreshed_token and refreshed_token != token:
|
| 663 |
+
token = refreshed_token
|
| 664 |
+
continue
|
| 665 |
+
account_service.remove_invalid_token(token, "image_stream")
|
| 666 |
+
continue
|
| 667 |
+
raise ImageGenerationError(image_stream_error_message(last_error)) from exc
|
| 668 |
+
|
| 669 |
+
if not emitted:
|
| 670 |
+
if not last_error:
|
| 671 |
+
last_error = "no account in the pool could generate images — check account quota and rate-limit status"
|
| 672 |
+
raise ImageGenerationError(image_stream_error_message(last_error))
|
| 673 |
+
|
| 674 |
+
|
| 675 |
+
def stream_image_chunks(outputs: Iterable[ImageOutput]) -> Iterator[dict[str, Any]]:
|
| 676 |
+
for output in outputs:
|
| 677 |
+
yield output.to_chunk()
|
| 678 |
+
|
| 679 |
+
|
| 680 |
+
def collect_image_outputs(outputs: Iterable[ImageOutput]) -> dict[str, Any]:
|
| 681 |
+
created = None
|
| 682 |
+
data: list[dict[str, Any]] = []
|
| 683 |
+
message = ""
|
| 684 |
+
progress_parts: list[str] = []
|
| 685 |
+
for output in outputs:
|
| 686 |
+
created = created or output.created
|
| 687 |
+
if output.kind == "progress" and output.text:
|
| 688 |
+
progress_parts.append(output.text)
|
| 689 |
+
elif output.kind == "message":
|
| 690 |
+
message = output.text
|
| 691 |
+
elif output.kind == "result":
|
| 692 |
+
data.extend(output.data)
|
| 693 |
+
|
| 694 |
+
result: dict[str, Any] = {"created": created or int(time.time()), "data": data}
|
| 695 |
+
if not data:
|
| 696 |
+
text = message or "".join(progress_parts).strip()
|
| 697 |
+
if text:
|
| 698 |
+
result["message"] = text
|
| 699 |
+
return result
|
services/protocol/error_response.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from fastapi.responses import JSONResponse
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _message_from_value(value: object) -> str:
|
| 9 |
+
if isinstance(value, str):
|
| 10 |
+
return value
|
| 11 |
+
if not isinstance(value, dict):
|
| 12 |
+
return ""
|
| 13 |
+
message = value.get("message")
|
| 14 |
+
if isinstance(message, str) and message:
|
| 15 |
+
return message
|
| 16 |
+
return _message_from_value(value.get("error"))
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def error_message_from_detail(detail: object) -> str:
|
| 20 |
+
if isinstance(detail, list):
|
| 21 |
+
messages = []
|
| 22 |
+
for item in detail:
|
| 23 |
+
if not isinstance(item, dict):
|
| 24 |
+
continue
|
| 25 |
+
location = ".".join(str(part) for part in item.get("loc", []) if part != "body")
|
| 26 |
+
message = str(item.get("msg") or "").strip()
|
| 27 |
+
if location and message:
|
| 28 |
+
messages.append(f"{location}: {message}")
|
| 29 |
+
elif message:
|
| 30 |
+
messages.append(message)
|
| 31 |
+
return "; ".join(messages)
|
| 32 |
+
if isinstance(detail, dict):
|
| 33 |
+
message = _message_from_value(detail.get("error")) or _message_from_value(detail)
|
| 34 |
+
if message:
|
| 35 |
+
return message
|
| 36 |
+
return str(detail or "").strip()
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _default_error_type(status_code: int) -> str:
|
| 40 |
+
if status_code == 401:
|
| 41 |
+
return "authentication_error"
|
| 42 |
+
if status_code == 403:
|
| 43 |
+
return "permission_error"
|
| 44 |
+
if status_code == 429:
|
| 45 |
+
return "rate_limit_error"
|
| 46 |
+
if 400 <= status_code < 500:
|
| 47 |
+
return "invalid_request_error"
|
| 48 |
+
return "server_error"
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _default_error_code(status_code: int) -> str:
|
| 52 |
+
if status_code == 401:
|
| 53 |
+
return "invalid_api_key"
|
| 54 |
+
if status_code == 403:
|
| 55 |
+
return "permission_denied"
|
| 56 |
+
if status_code == 429:
|
| 57 |
+
return "rate_limit_exceeded"
|
| 58 |
+
if 400 <= status_code < 500:
|
| 59 |
+
return "bad_request"
|
| 60 |
+
return "upstream_error"
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def openai_error_payload(
|
| 64 |
+
detail: object,
|
| 65 |
+
status_code: int,
|
| 66 |
+
*,
|
| 67 |
+
error_type: str | None = None,
|
| 68 |
+
code: object | None = None,
|
| 69 |
+
param: object | None = None,
|
| 70 |
+
) -> dict[str, Any]:
|
| 71 |
+
error_detail = detail.get("error") if isinstance(detail, dict) else None
|
| 72 |
+
if isinstance(error_detail, dict):
|
| 73 |
+
return {
|
| 74 |
+
"error": {
|
| 75 |
+
"message": error_message_from_detail(error_detail) or "request failed",
|
| 76 |
+
"type": str(error_detail.get("type") or error_type or _default_error_type(status_code)),
|
| 77 |
+
"param": error_detail.get("param", param),
|
| 78 |
+
"code": error_detail.get("code", code if code is not None else _default_error_code(status_code)),
|
| 79 |
+
}
|
| 80 |
+
}
|
| 81 |
+
return {
|
| 82 |
+
"error": {
|
| 83 |
+
"message": error_message_from_detail(detail) or "request failed",
|
| 84 |
+
"type": error_type or _default_error_type(status_code),
|
| 85 |
+
"param": param,
|
| 86 |
+
"code": code if code is not None else _default_error_code(status_code),
|
| 87 |
+
}
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def openai_error_response(
|
| 92 |
+
detail: object,
|
| 93 |
+
status_code: int,
|
| 94 |
+
*,
|
| 95 |
+
headers: dict[str, str] | None = None,
|
| 96 |
+
error_type: str | None = None,
|
| 97 |
+
code: object | None = None,
|
| 98 |
+
param: object | None = None,
|
| 99 |
+
) -> JSONResponse:
|
| 100 |
+
return JSONResponse(
|
| 101 |
+
status_code=status_code,
|
| 102 |
+
content=openai_error_payload(detail, status_code, error_type=error_type, code=code, param=param),
|
| 103 |
+
headers=headers,
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def anthropic_error_response(
|
| 108 |
+
detail: object,
|
| 109 |
+
status_code: int,
|
| 110 |
+
*,
|
| 111 |
+
headers: dict[str, str] | None = None,
|
| 112 |
+
) -> JSONResponse:
|
| 113 |
+
error_type = "api_error" if status_code >= 500 else _default_error_type(status_code)
|
| 114 |
+
return JSONResponse(
|
| 115 |
+
status_code=status_code,
|
| 116 |
+
content={
|
| 117 |
+
"type": "error",
|
| 118 |
+
"error": {
|
| 119 |
+
"type": error_type,
|
| 120 |
+
"message": error_message_from_detail(detail) or "request failed",
|
| 121 |
+
},
|
| 122 |
+
},
|
| 123 |
+
headers=headers,
|
| 124 |
+
)
|
services/protocol/openai_v1_chat_complete.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import time
|
| 4 |
+
import uuid
|
| 5 |
+
from typing import Any, Iterable, Iterator
|
| 6 |
+
|
| 7 |
+
from fastapi import HTTPException
|
| 8 |
+
|
| 9 |
+
from services.protocol.conversation import (
|
| 10 |
+
ConversationRequest,
|
| 11 |
+
ImageOutput,
|
| 12 |
+
collect_image_outputs,
|
| 13 |
+
collect_text,
|
| 14 |
+
count_message_tokens,
|
| 15 |
+
count_text_tokens,
|
| 16 |
+
encode_images,
|
| 17 |
+
normalize_messages,
|
| 18 |
+
stream_image_outputs_with_pool,
|
| 19 |
+
stream_text_deltas,
|
| 20 |
+
text_backend,
|
| 21 |
+
)
|
| 22 |
+
from utils.helper import build_chat_image_markdown_content, extract_chat_image, extract_chat_prompt, is_image_chat_request, parse_image_count
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def completion_chunk(model: str, delta: dict[str, Any], finish_reason: str | None = None, completion_id: str = "", created: int | None = None) -> dict[str, Any]:
|
| 26 |
+
return {
|
| 27 |
+
"id": completion_id or f"chatcmpl-{uuid.uuid4().hex}",
|
| 28 |
+
"object": "chat.completion.chunk",
|
| 29 |
+
"created": created or int(time.time()),
|
| 30 |
+
"model": model,
|
| 31 |
+
"choices": [{"index": 0, "delta": delta, "finish_reason": finish_reason}],
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def completion_response(
|
| 36 |
+
model: str,
|
| 37 |
+
content: str,
|
| 38 |
+
created: int | None = None,
|
| 39 |
+
messages: list[dict[str, Any]] | None = None,
|
| 40 |
+
) -> dict[str, Any]:
|
| 41 |
+
prompt_tokens = count_message_tokens(messages, model) if messages else 0
|
| 42 |
+
completion_tokens = count_text_tokens(content, model) if messages else 0
|
| 43 |
+
return {
|
| 44 |
+
"id": f"chatcmpl-{uuid.uuid4().hex}",
|
| 45 |
+
"object": "chat.completion",
|
| 46 |
+
"created": created or int(time.time()),
|
| 47 |
+
"model": model,
|
| 48 |
+
"choices": [{
|
| 49 |
+
"index": 0,
|
| 50 |
+
"message": {"role": "assistant", "content": content},
|
| 51 |
+
"finish_reason": "stop",
|
| 52 |
+
}],
|
| 53 |
+
"usage": {
|
| 54 |
+
"prompt_tokens": prompt_tokens,
|
| 55 |
+
"completion_tokens": completion_tokens,
|
| 56 |
+
"total_tokens": prompt_tokens + completion_tokens,
|
| 57 |
+
},
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def stream_text_chat_completion(backend, messages: list[dict[str, Any]], model: str) -> Iterator[dict[str, Any]]:
|
| 62 |
+
completion_id = f"chatcmpl-{uuid.uuid4().hex}"
|
| 63 |
+
created = int(time.time())
|
| 64 |
+
sent_role = False
|
| 65 |
+
request = ConversationRequest(model=model, messages=messages)
|
| 66 |
+
for delta_text in stream_text_deltas(backend, request):
|
| 67 |
+
if not sent_role:
|
| 68 |
+
sent_role = True
|
| 69 |
+
yield completion_chunk(model, {"role": "assistant", "content": delta_text}, None, completion_id, created)
|
| 70 |
+
else:
|
| 71 |
+
yield completion_chunk(model, {"content": delta_text}, None, completion_id, created)
|
| 72 |
+
if not sent_role:
|
| 73 |
+
yield completion_chunk(model, {"role": "assistant", "content": ""}, None, completion_id, created)
|
| 74 |
+
yield completion_chunk(model, {}, "stop", completion_id, created)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def collect_chat_content(chunks: Iterable[dict[str, Any]]) -> str:
|
| 78 |
+
parts: list[str] = []
|
| 79 |
+
for chunk in chunks:
|
| 80 |
+
choices = chunk.get("choices")
|
| 81 |
+
first = choices[0] if isinstance(choices, list) and choices and isinstance(choices[0], dict) else {}
|
| 82 |
+
delta = first.get("delta") if isinstance(first.get("delta"), dict) else {}
|
| 83 |
+
content = str(delta.get("content") or "")
|
| 84 |
+
if content:
|
| 85 |
+
parts.append(content)
|
| 86 |
+
return "".join(parts)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def chat_messages_from_body(body: dict[str, Any]) -> list[dict[str, Any]]:
|
| 90 |
+
messages = body.get("messages")
|
| 91 |
+
if isinstance(messages, list) and messages:
|
| 92 |
+
return [message for message in messages if isinstance(message, dict)]
|
| 93 |
+
prompt = str(body.get("prompt") or "").strip()
|
| 94 |
+
if prompt:
|
| 95 |
+
return [{"role": "user", "content": prompt}]
|
| 96 |
+
raise HTTPException(status_code=400, detail={"error": "messages or prompt is required"})
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def chat_image_args(body: dict[str, Any]) -> tuple[str, str, int, list[tuple[bytes, str, str]]]:
|
| 100 |
+
model = str(body.get("model") or "gpt-image-2").strip() or "gpt-image-2"
|
| 101 |
+
prompt = extract_chat_prompt(body)
|
| 102 |
+
if not prompt:
|
| 103 |
+
raise HTTPException(status_code=400, detail={"error": "prompt is required"})
|
| 104 |
+
images = [
|
| 105 |
+
(data, f"image_{idx}.png", mime)
|
| 106 |
+
for idx, (data, mime) in enumerate(extract_chat_image(body), start=1)
|
| 107 |
+
]
|
| 108 |
+
return model, prompt, parse_image_count(body.get("n")), images
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def text_chat_parts(body: dict[str, Any]) -> tuple[str, list[dict[str, Any]]]:
|
| 112 |
+
model = str(body.get("model") or "auto").strip() or "auto"
|
| 113 |
+
messages = normalize_messages(chat_messages_from_body(body))
|
| 114 |
+
return model, messages
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def image_result_content(result: dict[str, Any]) -> str:
|
| 118 |
+
data = result.get("data")
|
| 119 |
+
if isinstance(data, list) and data:
|
| 120 |
+
return build_chat_image_markdown_content(result)
|
| 121 |
+
return str(result.get("message") or "Image generation completed.")
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def image_chat_response(body: dict[str, Any]) -> dict[str, Any]:
|
| 125 |
+
model, prompt, n, images = chat_image_args(body)
|
| 126 |
+
result = collect_image_outputs(stream_image_outputs_with_pool(ConversationRequest(
|
| 127 |
+
prompt=prompt,
|
| 128 |
+
model=model,
|
| 129 |
+
n=n,
|
| 130 |
+
response_format="b64_json",
|
| 131 |
+
images=encode_images(images) or None,
|
| 132 |
+
)))
|
| 133 |
+
return completion_response(model, image_result_content(result), int(result.get("created") or 0) or None)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def image_chat_events(body: dict[str, Any]) -> Iterator[dict[str, Any]]:
|
| 137 |
+
model, prompt, n, images = chat_image_args(body)
|
| 138 |
+
image_outputs = stream_image_outputs_with_pool(ConversationRequest(
|
| 139 |
+
prompt=prompt,
|
| 140 |
+
model=model,
|
| 141 |
+
n=n,
|
| 142 |
+
response_format="b64_json",
|
| 143 |
+
images=encode_images(images) or None,
|
| 144 |
+
))
|
| 145 |
+
yield from stream_image_chat_completion(image_outputs, model)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def stream_image_chat_completion(image_outputs: Iterable[ImageOutput], model: str) -> Iterator[dict[str, Any]]:
|
| 149 |
+
completion_id = f"chatcmpl-{uuid.uuid4().hex}"
|
| 150 |
+
created = int(time.time())
|
| 151 |
+
sent_role = False
|
| 152 |
+
sent_text = ""
|
| 153 |
+
for output in image_outputs:
|
| 154 |
+
content = ""
|
| 155 |
+
if output.kind == "progress":
|
| 156 |
+
content = output.text
|
| 157 |
+
sent_text += content
|
| 158 |
+
elif output.kind == "result":
|
| 159 |
+
content = build_chat_image_markdown_content({"data": output.data})
|
| 160 |
+
elif output.kind == "message":
|
| 161 |
+
content = output.text[len(sent_text):] if output.text.startswith(sent_text) else output.text
|
| 162 |
+
if not content:
|
| 163 |
+
continue
|
| 164 |
+
if not sent_role:
|
| 165 |
+
sent_role = True
|
| 166 |
+
yield completion_chunk(model, {"role": "assistant", "content": content}, None, completion_id, created)
|
| 167 |
+
else:
|
| 168 |
+
yield completion_chunk(model, {"content": content}, None, completion_id, created)
|
| 169 |
+
if not sent_role:
|
| 170 |
+
yield completion_chunk(model, {"role": "assistant", "content": ""}, None, completion_id, created)
|
| 171 |
+
yield completion_chunk(model, {}, "stop", completion_id, created)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]:
|
| 175 |
+
if body.get("stream"):
|
| 176 |
+
if is_image_chat_request(body):
|
| 177 |
+
return image_chat_events(body)
|
| 178 |
+
model, messages = text_chat_parts(body)
|
| 179 |
+
return stream_text_chat_completion(text_backend(), messages, model)
|
| 180 |
+
if is_image_chat_request(body):
|
| 181 |
+
return image_chat_response(body)
|
| 182 |
+
model, messages = text_chat_parts(body)
|
| 183 |
+
request = ConversationRequest(model=model, messages=messages)
|
| 184 |
+
return completion_response(model, collect_text(text_backend(), request), messages=messages)
|
services/protocol/openai_v1_image_edit.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any, Iterator
|
| 4 |
+
|
| 5 |
+
from services.protocol.conversation import (
|
| 6 |
+
ConversationRequest,
|
| 7 |
+
ImageGenerationError,
|
| 8 |
+
collect_image_outputs,
|
| 9 |
+
encode_images,
|
| 10 |
+
stream_image_chunks,
|
| 11 |
+
stream_image_outputs_with_pool,
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]:
|
| 16 |
+
prompt = str(body.get("prompt") or "")
|
| 17 |
+
images = body.get("images") or []
|
| 18 |
+
model = str(body.get("model") or "gpt-image-2")
|
| 19 |
+
n = int(body.get("n") or 1)
|
| 20 |
+
size = body.get("size")
|
| 21 |
+
quality = str(body.get("quality") or "auto")
|
| 22 |
+
response_format = str(body.get("response_format") or "b64_json")
|
| 23 |
+
base_url = str(body.get("base_url") or "") or None
|
| 24 |
+
encoded_images = encode_images(images)
|
| 25 |
+
if not encoded_images:
|
| 26 |
+
raise ImageGenerationError("image is required")
|
| 27 |
+
outputs = stream_image_outputs_with_pool(ConversationRequest(
|
| 28 |
+
prompt=prompt,
|
| 29 |
+
model=model,
|
| 30 |
+
n=n,
|
| 31 |
+
size=size,
|
| 32 |
+
quality=quality,
|
| 33 |
+
response_format=response_format,
|
| 34 |
+
base_url=base_url,
|
| 35 |
+
images=encoded_images,
|
| 36 |
+
message_as_error=True,
|
| 37 |
+
))
|
| 38 |
+
if body.get("stream"):
|
| 39 |
+
return stream_image_chunks(outputs)
|
| 40 |
+
return collect_image_outputs(outputs)
|
services/protocol/openai_v1_image_generations.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any, Iterator
|
| 4 |
+
|
| 5 |
+
from services.protocol.conversation import (
|
| 6 |
+
ConversationRequest,
|
| 7 |
+
collect_image_outputs,
|
| 8 |
+
stream_image_chunks,
|
| 9 |
+
stream_image_outputs_with_pool,
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]:
|
| 14 |
+
prompt = str(body.get("prompt") or "")
|
| 15 |
+
model = str(body.get("model") or "gpt-image-2")
|
| 16 |
+
n = int(body.get("n") or 1)
|
| 17 |
+
size = body.get("size")
|
| 18 |
+
quality = str(body.get("quality") or "auto")
|
| 19 |
+
response_format = str(body.get("response_format") or "b64_json")
|
| 20 |
+
base_url = str(body.get("base_url") or "") or None
|
| 21 |
+
outputs = stream_image_outputs_with_pool(ConversationRequest(
|
| 22 |
+
prompt=prompt,
|
| 23 |
+
model=model,
|
| 24 |
+
n=n,
|
| 25 |
+
size=size,
|
| 26 |
+
quality=quality,
|
| 27 |
+
response_format=response_format,
|
| 28 |
+
base_url=base_url,
|
| 29 |
+
message_as_error=True,
|
| 30 |
+
))
|
| 31 |
+
if body.get("stream"):
|
| 32 |
+
return stream_image_chunks(outputs)
|
| 33 |
+
return collect_image_outputs(outputs)
|
services/protocol/openai_v1_models.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from services.openai_backend_api import OpenAIBackendAPI
|
| 6 |
+
from utils.helper import IMAGE_MODELS
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def list_models() -> dict[str, Any]:
|
| 10 |
+
result = OpenAIBackendAPI().list_models()
|
| 11 |
+
data = result.get("data")
|
| 12 |
+
if not isinstance(data, list):
|
| 13 |
+
return result
|
| 14 |
+
seen = {str(item.get("id") or "").strip() for item in data if isinstance(item, dict)}
|
| 15 |
+
for model in sorted(IMAGE_MODELS):
|
| 16 |
+
if model not in seen:
|
| 17 |
+
data.append({
|
| 18 |
+
"id": model,
|
| 19 |
+
"object": "model",
|
| 20 |
+
"created": 0,
|
| 21 |
+
"owned_by": "chatgpt2api",
|
| 22 |
+
"permission": [],
|
| 23 |
+
"root": model,
|
| 24 |
+
"parent": None,
|
| 25 |
+
})
|
| 26 |
+
return result
|
services/protocol/openai_v1_response.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
import time
|
| 5 |
+
import uuid
|
| 6 |
+
from typing import Any, Iterable, Iterator
|
| 7 |
+
|
| 8 |
+
from fastapi import HTTPException
|
| 9 |
+
|
| 10 |
+
from services.protocol.conversation import (
|
| 11 |
+
ConversationRequest,
|
| 12 |
+
ImageOutput,
|
| 13 |
+
encode_images,
|
| 14 |
+
stream_image_outputs_with_pool,
|
| 15 |
+
stream_text_deltas,
|
| 16 |
+
text_backend,
|
| 17 |
+
)
|
| 18 |
+
from utils.helper import extract_image_from_message_content, extract_response_prompt, has_response_image_generation_tool
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def is_text_response_request(body: dict[str, Any]) -> bool:
|
| 22 |
+
return not has_response_image_generation_tool(body)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def response_image_tool(body: dict[str, Any]) -> dict[str, object]:
|
| 26 |
+
for tool in body.get("tools") or []:
|
| 27 |
+
if isinstance(tool, dict) and tool.get("type") == "image_generation":
|
| 28 |
+
return tool
|
| 29 |
+
return {}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def extract_response_image(input_value: object) -> tuple[bytes, str] | None:
|
| 33 |
+
if isinstance(input_value, dict):
|
| 34 |
+
images = extract_image_from_message_content(input_value.get("content"))
|
| 35 |
+
return images[0] if images else None
|
| 36 |
+
if not isinstance(input_value, list):
|
| 37 |
+
return None
|
| 38 |
+
for item in reversed(input_value):
|
| 39 |
+
if isinstance(item, dict) and str(item.get("type") or "").strip() == "input_image":
|
| 40 |
+
image_url = str(item.get("image_url") or "")
|
| 41 |
+
if image_url.startswith("data:"):
|
| 42 |
+
header, _, data = image_url.partition(",")
|
| 43 |
+
mime = header.split(";")[0].removeprefix("data:")
|
| 44 |
+
return base64.b64decode(data), mime or "image/png"
|
| 45 |
+
if isinstance(item, dict):
|
| 46 |
+
images = extract_image_from_message_content(item.get("content"))
|
| 47 |
+
if images:
|
| 48 |
+
return images[0]
|
| 49 |
+
return None
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def messages_from_input(input_value: object, instructions: object = None) -> list[dict[str, Any]]:
|
| 53 |
+
messages: list[dict[str, Any]] = []
|
| 54 |
+
system_text = str(instructions or "").strip()
|
| 55 |
+
if system_text:
|
| 56 |
+
messages.append({"role": "system", "content": system_text})
|
| 57 |
+
if isinstance(input_value, str):
|
| 58 |
+
if input_value.strip():
|
| 59 |
+
messages.append({"role": "user", "content": input_value.strip()})
|
| 60 |
+
return messages
|
| 61 |
+
if isinstance(input_value, dict):
|
| 62 |
+
messages.append({
|
| 63 |
+
"role": str(input_value.get("role") or "user"),
|
| 64 |
+
"content": extract_response_prompt([input_value]) or input_value.get("content") or "",
|
| 65 |
+
})
|
| 66 |
+
return messages
|
| 67 |
+
if isinstance(input_value, list):
|
| 68 |
+
if all(isinstance(item, dict) and item.get("type") for item in input_value):
|
| 69 |
+
text = extract_response_prompt(input_value)
|
| 70 |
+
if text:
|
| 71 |
+
messages.append({"role": "user", "content": text})
|
| 72 |
+
return messages
|
| 73 |
+
for item in input_value:
|
| 74 |
+
if isinstance(item, dict):
|
| 75 |
+
messages.append({
|
| 76 |
+
"role": str(item.get("role") or "user"),
|
| 77 |
+
"content": extract_response_prompt([item]) or item.get("content") or "",
|
| 78 |
+
})
|
| 79 |
+
return messages
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def text_output_item(text: str, item_id: str | None = None, status: str = "completed") -> dict[str, Any]:
|
| 83 |
+
return {
|
| 84 |
+
"id": item_id or f"msg_{uuid.uuid4().hex}",
|
| 85 |
+
"type": "message",
|
| 86 |
+
"status": status,
|
| 87 |
+
"role": "assistant",
|
| 88 |
+
"content": [{"type": "output_text", "text": text, "annotations": []}],
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def image_output_items(prompt: str, data: list[dict[str, Any]], item_id: str | None = None) -> list[dict[str, Any]]:
|
| 93 |
+
output = []
|
| 94 |
+
for item in data:
|
| 95 |
+
b64_json = str(item.get("b64_json") or "").strip()
|
| 96 |
+
if b64_json:
|
| 97 |
+
output.append({
|
| 98 |
+
"id": item_id or f"ig_{len(output) + 1}",
|
| 99 |
+
"type": "image_generation_call",
|
| 100 |
+
"status": "completed",
|
| 101 |
+
"result": b64_json,
|
| 102 |
+
"revised_prompt": str(item.get("revised_prompt") or prompt).strip() or prompt,
|
| 103 |
+
})
|
| 104 |
+
return output
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def response_created(response_id: str, model: str, created: int) -> dict[str, Any]:
|
| 108 |
+
return {
|
| 109 |
+
"type": "response.created",
|
| 110 |
+
"response": {
|
| 111 |
+
"id": response_id,
|
| 112 |
+
"object": "response",
|
| 113 |
+
"created_at": created,
|
| 114 |
+
"status": "in_progress",
|
| 115 |
+
"error": None,
|
| 116 |
+
"incomplete_details": None,
|
| 117 |
+
"model": model,
|
| 118 |
+
"output": [],
|
| 119 |
+
"parallel_tool_calls": False,
|
| 120 |
+
},
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def response_completed(response_id: str, model: str, created: int, output: list[dict[str, Any]]) -> dict[str, Any]:
|
| 125 |
+
return {
|
| 126 |
+
"type": "response.completed",
|
| 127 |
+
"response": {
|
| 128 |
+
"id": response_id,
|
| 129 |
+
"object": "response",
|
| 130 |
+
"created_at": created,
|
| 131 |
+
"status": "completed",
|
| 132 |
+
"error": None,
|
| 133 |
+
"incomplete_details": None,
|
| 134 |
+
"model": model,
|
| 135 |
+
"output": output,
|
| 136 |
+
"parallel_tool_calls": False,
|
| 137 |
+
},
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def stream_text_response(backend, body: dict[str, Any]) -> Iterator[dict[str, Any]]:
|
| 142 |
+
model = str(body.get("model") or "auto").strip() or "auto"
|
| 143 |
+
messages = messages_from_input(body.get("input"), body.get("instructions"))
|
| 144 |
+
response_id = f"resp_{uuid.uuid4().hex}"
|
| 145 |
+
item_id = f"msg_{uuid.uuid4().hex}"
|
| 146 |
+
created = int(time.time())
|
| 147 |
+
full_text = ""
|
| 148 |
+
yield response_created(response_id, model, created)
|
| 149 |
+
yield {"type": "response.output_item.added", "output_index": 0, "item": text_output_item("", item_id, "in_progress")}
|
| 150 |
+
request = ConversationRequest(model=model, messages=messages)
|
| 151 |
+
for delta in stream_text_deltas(backend, request):
|
| 152 |
+
full_text += delta
|
| 153 |
+
yield {"type": "response.output_text.delta", "item_id": item_id, "output_index": 0, "content_index": 0, "delta": delta}
|
| 154 |
+
yield {"type": "response.output_text.done", "item_id": item_id, "output_index": 0, "content_index": 0, "text": full_text}
|
| 155 |
+
item = text_output_item(full_text, item_id, "completed")
|
| 156 |
+
yield {"type": "response.output_item.done", "output_index": 0, "item": item}
|
| 157 |
+
yield response_completed(response_id, model, created, [item])
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def stream_image_response(image_outputs: Iterable[ImageOutput], prompt: str, model: str) -> Iterator[dict[str, Any]]:
|
| 161 |
+
response_id = f"resp_{uuid.uuid4().hex}"
|
| 162 |
+
created = int(time.time())
|
| 163 |
+
yield response_created(response_id, model, created)
|
| 164 |
+
for output in image_outputs:
|
| 165 |
+
if output.kind == "message":
|
| 166 |
+
text = output.text
|
| 167 |
+
item = text_output_item(text)
|
| 168 |
+
yield {"type": "response.output_text.delta", "item_id": item["id"], "output_index": 0, "content_index": 0, "delta": text}
|
| 169 |
+
yield {"type": "response.output_text.done", "item_id": item["id"], "output_index": 0, "content_index": 0, "text": text}
|
| 170 |
+
yield {"type": "response.output_item.done", "output_index": 0, "item": item}
|
| 171 |
+
yield response_completed(response_id, model, created, [item])
|
| 172 |
+
return
|
| 173 |
+
if output.kind != "result":
|
| 174 |
+
continue
|
| 175 |
+
items = image_output_items(prompt, output.data)
|
| 176 |
+
if items:
|
| 177 |
+
item = items[0]
|
| 178 |
+
yield {"type": "response.output_item.done", "output_index": 0, "item": item}
|
| 179 |
+
yield response_completed(response_id, model, created, [item])
|
| 180 |
+
return
|
| 181 |
+
raise RuntimeError("image generation failed")
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def collect_response(events: Iterable[dict[str, Any]]) -> dict[str, Any]:
|
| 185 |
+
completed = {}
|
| 186 |
+
for event in events:
|
| 187 |
+
if event.get("type") == "response.completed":
|
| 188 |
+
completed = event.get("response") if isinstance(event.get("response"), dict) else {}
|
| 189 |
+
if not completed:
|
| 190 |
+
raise RuntimeError("response generation failed")
|
| 191 |
+
return completed
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def response_events(body: dict[str, Any]) -> Iterator[dict[str, Any]]:
|
| 195 |
+
if is_text_response_request(body):
|
| 196 |
+
yield from stream_text_response(text_backend(), body)
|
| 197 |
+
return
|
| 198 |
+
|
| 199 |
+
prompt = extract_response_prompt(body.get("input"))
|
| 200 |
+
if not prompt:
|
| 201 |
+
raise HTTPException(status_code=400, detail={"error": "input text is required"})
|
| 202 |
+
model = str(body.get("model") or "gpt-image-2").strip() or "gpt-image-2"
|
| 203 |
+
image_info = extract_response_image(body.get("input"))
|
| 204 |
+
if image_info:
|
| 205 |
+
image_data, mime_type = image_info
|
| 206 |
+
images = encode_images([(image_data, "image.png", mime_type)])
|
| 207 |
+
else:
|
| 208 |
+
images = None
|
| 209 |
+
tool = response_image_tool(body)
|
| 210 |
+
image_outputs = stream_image_outputs_with_pool(ConversationRequest(
|
| 211 |
+
prompt=prompt,
|
| 212 |
+
model=model,
|
| 213 |
+
size=tool.get("size"),
|
| 214 |
+
quality=str(tool.get("quality") or "auto"),
|
| 215 |
+
response_format="b64_json",
|
| 216 |
+
images=images,
|
| 217 |
+
))
|
| 218 |
+
yield from stream_image_response(image_outputs, prompt, model)
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]:
|
| 222 |
+
events = response_events(body)
|
| 223 |
+
if body.get("stream"):
|
| 224 |
+
return events
|
| 225 |
+
return collect_response(events)
|
services/proxy_service.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Global outbound proxy helpers for upstream ChatGPT and CPA requests."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import time
|
| 6 |
+
from urllib.parse import urlparse
|
| 7 |
+
|
| 8 |
+
from curl_cffi.requests import Session
|
| 9 |
+
|
| 10 |
+
from services.config import config
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class ProxySettingsStore:
|
| 14 |
+
def build_session_kwargs(self, **session_kwargs) -> dict[str, object]:
|
| 15 |
+
proxy = config.get_proxy_settings()
|
| 16 |
+
if proxy:
|
| 17 |
+
session_kwargs["proxy"] = proxy
|
| 18 |
+
return session_kwargs
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _clean(value: object) -> str:
|
| 22 |
+
return str(value or "").strip()
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _is_valid_proxy_url(url: str) -> bool:
|
| 26 |
+
parsed = urlparse(url)
|
| 27 |
+
return parsed.scheme in {"http", "https", "socks5", "socks5h"} and bool(parsed.netloc)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def test_proxy(url: str, *, timeout: float = 15.0) -> dict:
|
| 31 |
+
candidate = _clean(url)
|
| 32 |
+
if not candidate:
|
| 33 |
+
return {"ok": False, "status": 0, "latency_ms": 0, "error": "proxy url is required"}
|
| 34 |
+
if not _is_valid_proxy_url(candidate):
|
| 35 |
+
return {"ok": False, "status": 0, "latency_ms": 0, "error": "invalid proxy url"}
|
| 36 |
+
session = Session(impersonate="edge101", verify=True, proxy=candidate)
|
| 37 |
+
started = time.perf_counter()
|
| 38 |
+
try:
|
| 39 |
+
response = session.get(
|
| 40 |
+
"https://chatgpt.com/api/auth/csrf",
|
| 41 |
+
headers={"user-agent": "Mozilla/5.0 (chatgpt2api proxy test)"},
|
| 42 |
+
timeout=timeout,
|
| 43 |
+
)
|
| 44 |
+
latency_ms = int((time.perf_counter() - started) * 1000)
|
| 45 |
+
return {
|
| 46 |
+
"ok": response.status_code < 500,
|
| 47 |
+
"status": int(response.status_code),
|
| 48 |
+
"latency_ms": latency_ms,
|
| 49 |
+
"error": None if response.status_code < 500 else f"HTTP {response.status_code}",
|
| 50 |
+
}
|
| 51 |
+
except Exception as exc:
|
| 52 |
+
latency_ms = int((time.perf_counter() - started) * 1000)
|
| 53 |
+
return {
|
| 54 |
+
"ok": False,
|
| 55 |
+
"status": 0,
|
| 56 |
+
"latency_ms": latency_ms,
|
| 57 |
+
"error": str(exc) or exc.__class__.__name__,
|
| 58 |
+
}
|
| 59 |
+
finally:
|
| 60 |
+
session.close()
|
| 61 |
+
|
| 62 |
+
proxy_settings = ProxySettingsStore()
|
| 63 |
+
|
services/register/__init__.py
ADDED
|
File without changes
|
services/register/mail_provider.py
ADDED
|
@@ -0,0 +1,991 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
import json
|
| 5 |
+
import random
|
| 6 |
+
import re
|
| 7 |
+
import string
|
| 8 |
+
import time
|
| 9 |
+
from datetime import datetime, timezone
|
| 10 |
+
from email import message_from_string, policy
|
| 11 |
+
from email.utils import parsedate_to_datetime
|
| 12 |
+
from threading import Lock
|
| 13 |
+
from typing import Any, Callable, TypeVar
|
| 14 |
+
|
| 15 |
+
import requests
|
| 16 |
+
from curl_cffi import requests as curl_requests
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
from services.config import DATA_DIR
|
| 20 |
+
|
| 21 |
+
DDG_ALIASES_FILE = DATA_DIR / "ddg_aliases.json"
|
| 22 |
+
_ddg_aliases_lock = Lock()
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _load_ddg_aliases() -> set[str]:
|
| 26 |
+
try:
|
| 27 |
+
if DDG_ALIASES_FILE.exists():
|
| 28 |
+
data = json.loads(DDG_ALIASES_FILE.read_text(encoding="utf-8"))
|
| 29 |
+
if isinstance(data, list):
|
| 30 |
+
return {str(item).strip().lower() for item in data if str(item).strip()}
|
| 31 |
+
except Exception:
|
| 32 |
+
pass
|
| 33 |
+
return set()
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _save_ddg_aliases(aliases: set[str]) -> None:
|
| 37 |
+
DDG_ALIASES_FILE.parent.mkdir(parents=True, exist_ok=True)
|
| 38 |
+
DDG_ALIASES_FILE.write_text(json.dumps(sorted(aliases), ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _is_ddg_alias_duplicate(address: str) -> bool:
|
| 42 |
+
target = str(address or "").strip().lower()
|
| 43 |
+
if not target:
|
| 44 |
+
return False
|
| 45 |
+
with _ddg_aliases_lock:
|
| 46 |
+
used = _load_ddg_aliases()
|
| 47 |
+
return target in used
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _record_ddg_alias(address: str) -> None:
|
| 51 |
+
target = str(address or "").strip().lower()
|
| 52 |
+
if not target:
|
| 53 |
+
return
|
| 54 |
+
with _ddg_aliases_lock:
|
| 55 |
+
used = _load_ddg_aliases()
|
| 56 |
+
used.add(target)
|
| 57 |
+
_save_ddg_aliases(used)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
ResultT = TypeVar("ResultT")
|
| 61 |
+
domain_lock = Lock()
|
| 62 |
+
provider_lock = Lock()
|
| 63 |
+
domain_index = 0
|
| 64 |
+
provider_index = 0
|
| 65 |
+
cloudmail_token_lock = Lock()
|
| 66 |
+
cloudmail_token_cache: dict[str, tuple[str, float]] = {}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _config(mail_config: dict) -> dict:
|
| 70 |
+
return {
|
| 71 |
+
"request_timeout": float(mail_config.get("request_timeout") or 30),
|
| 72 |
+
"wait_timeout": float(mail_config.get("wait_timeout") or 30),
|
| 73 |
+
"wait_interval": float(mail_config.get("wait_interval") or 2),
|
| 74 |
+
"user_agent": str(mail_config.get("user_agent") or "Mozilla/5.0"),
|
| 75 |
+
"proxy": str(mail_config.get("proxy") or "").strip(),
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _random_mailbox_name() -> str:
|
| 80 |
+
return f"{''.join(random.choices(string.ascii_lowercase, k=5))}{''.join(random.choices(string.digits, k=random.randint(1, 3)))}{''.join(random.choices(string.ascii_lowercase, k=random.randint(1, 3)))}"
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _random_subdomain_label() -> str:
|
| 84 |
+
return "".join(random.choices(string.ascii_lowercase + string.digits, k=random.randint(4, 10)))
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _next_domain(domains: list[str]) -> str:
|
| 88 |
+
global domain_index
|
| 89 |
+
domains = [str(item).strip() for item in domains if str(item).strip()]
|
| 90 |
+
if not domains:
|
| 91 |
+
raise RuntimeError("mail.domain 不能为空")
|
| 92 |
+
if len(domains) == 1:
|
| 93 |
+
return domains[0]
|
| 94 |
+
with domain_lock:
|
| 95 |
+
value = domains[domain_index % len(domains)]
|
| 96 |
+
domain_index = (domain_index + 1) % len(domains)
|
| 97 |
+
return value
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _normalize_string_list(value: Any) -> list[str]:
|
| 101 |
+
if isinstance(value, list):
|
| 102 |
+
return [str(item).strip() for item in value if str(item).strip()]
|
| 103 |
+
text = str(value or "").strip()
|
| 104 |
+
return [text] if text else []
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _parse_received_at(value: Any) -> datetime | None:
|
| 108 |
+
if isinstance(value, (int, float)):
|
| 109 |
+
try:
|
| 110 |
+
return datetime.fromtimestamp(float(value), tz=timezone.utc)
|
| 111 |
+
except Exception:
|
| 112 |
+
return None
|
| 113 |
+
text = str(value or "").strip()
|
| 114 |
+
if not text:
|
| 115 |
+
return None
|
| 116 |
+
try:
|
| 117 |
+
date = datetime.fromisoformat(text[:-1] + "+00:00" if text.endswith("Z") else text)
|
| 118 |
+
return date if date.tzinfo else date.replace(tzinfo=timezone.utc)
|
| 119 |
+
except Exception:
|
| 120 |
+
pass
|
| 121 |
+
try:
|
| 122 |
+
date = parsedate_to_datetime(text)
|
| 123 |
+
return date if date.tzinfo else date.replace(tzinfo=timezone.utc)
|
| 124 |
+
except Exception:
|
| 125 |
+
return None
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def _extract_content(data: dict[str, Any]) -> tuple[str, str]:
|
| 129 |
+
text_content = str(data.get("text_content") or data.get("text") or data.get("body") or data.get("content") or "")
|
| 130 |
+
html_content = str(data.get("html_content") or data.get("html") or data.get("html_body") or data.get("body_html") or "")
|
| 131 |
+
if text_content or html_content:
|
| 132 |
+
return text_content, html_content
|
| 133 |
+
raw = data.get("raw")
|
| 134 |
+
if not isinstance(raw, str) or not raw.strip():
|
| 135 |
+
return "", ""
|
| 136 |
+
try:
|
| 137 |
+
parsed = message_from_string(raw, policy=policy.default)
|
| 138 |
+
except Exception:
|
| 139 |
+
return raw, ""
|
| 140 |
+
plain: list[str] = []
|
| 141 |
+
html: list[str] = []
|
| 142 |
+
for part in parsed.walk() if parsed.is_multipart() else [parsed]:
|
| 143 |
+
if part.get_content_maintype() == "multipart":
|
| 144 |
+
continue
|
| 145 |
+
try:
|
| 146 |
+
payload = part.get_content()
|
| 147 |
+
except Exception:
|
| 148 |
+
payload = ""
|
| 149 |
+
if not payload:
|
| 150 |
+
continue
|
| 151 |
+
if part.get_content_type() == "text/html":
|
| 152 |
+
html.append(str(payload))
|
| 153 |
+
else:
|
| 154 |
+
plain.append(str(payload))
|
| 155 |
+
return "\n".join(plain).strip(), "\n".join(html).strip()
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def _extract_text_candidates(value: Any) -> list[str]:
|
| 159 |
+
if isinstance(value, str):
|
| 160 |
+
return [value]
|
| 161 |
+
if isinstance(value, dict):
|
| 162 |
+
out: list[str] = []
|
| 163 |
+
for key in ("address", "email", "name", "value"):
|
| 164 |
+
if value.get(key):
|
| 165 |
+
out.extend(_extract_text_candidates(value.get(key)))
|
| 166 |
+
return out
|
| 167 |
+
if isinstance(value, list):
|
| 168 |
+
out: list[str] = []
|
| 169 |
+
for item in value:
|
| 170 |
+
out.extend(_extract_text_candidates(item))
|
| 171 |
+
return out
|
| 172 |
+
return []
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def _message_matches_email(data: dict[str, Any], email: str) -> bool:
|
| 176 |
+
target = str(email or "").strip().lower()
|
| 177 |
+
candidates: list[str] = []
|
| 178 |
+
for key in ("to", "mailTo", "receiver", "receivers", "address", "email", "envelope_to"):
|
| 179 |
+
if key in data:
|
| 180 |
+
candidates.extend(_extract_text_candidates(data.get(key)))
|
| 181 |
+
return not target or not candidates or any(target in str(item).strip().lower() for item in candidates if str(item).strip())
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def _extract_code(message: dict[str, Any]) -> str | None:
|
| 185 |
+
content = f"{message.get('subject', '')}\n{message.get('text_content', '')}\n{message.get('html_content', '')}".strip()
|
| 186 |
+
if not content:
|
| 187 |
+
return None
|
| 188 |
+
match = re.search(r"background-color:\s*#F3F3F3[^>]*>[\s\S]*?(\d{6})[\s\S]*?</p>", content, re.I)
|
| 189 |
+
if match:
|
| 190 |
+
return match.group(1)
|
| 191 |
+
match = re.search(r"(?:Verification code|code is|代码为|验证码)[:\s]*(\d{6})", content, re.I)
|
| 192 |
+
if match and match.group(1) != "177010":
|
| 193 |
+
return match.group(1)
|
| 194 |
+
for code in re.findall(r">\s*(\d{6})\s*<|(?<![#&])\b(\d{6})\b", content):
|
| 195 |
+
value = code[0] or code[1]
|
| 196 |
+
if value and value != "177010":
|
| 197 |
+
return value
|
| 198 |
+
return None
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def _message_tracking_ref(message: dict[str, Any]) -> str:
|
| 202 |
+
provider = str(message.get("provider") or "").strip()
|
| 203 |
+
mailbox = str(message.get("mailbox") or "").strip()
|
| 204 |
+
message_id = str(message.get("message_id") or "").strip()
|
| 205 |
+
if message_id:
|
| 206 |
+
return f"id:{provider}:{mailbox}:{message_id}"
|
| 207 |
+
received_at = message.get("received_at")
|
| 208 |
+
received_value = received_at.isoformat() if isinstance(received_at, datetime) else str(received_at or "")
|
| 209 |
+
content = "\n".join(str(message.get(key) or "") for key in ("subject", "sender", "text_content", "html_content"))
|
| 210 |
+
digest = hashlib.sha256(content.encode("utf-8", errors="replace")).hexdigest()
|
| 211 |
+
return f"content:{provider}:{mailbox}:{received_value}:{digest}"
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
class BaseMailProvider:
|
| 215 |
+
name = "unknown"
|
| 216 |
+
|
| 217 |
+
def __init__(self, conf: dict, provider_ref: str = ""):
|
| 218 |
+
self.conf = conf
|
| 219 |
+
self.provider_ref = provider_ref
|
| 220 |
+
|
| 221 |
+
def wait_for(self, mailbox: dict[str, Any], on_message: Callable[[dict[str, Any]], ResultT | None]) -> ResultT | None:
|
| 222 |
+
deadline = time.monotonic() + self.conf["wait_timeout"]
|
| 223 |
+
while time.monotonic() < deadline:
|
| 224 |
+
message = self.fetch_latest_message(mailbox)
|
| 225 |
+
if message:
|
| 226 |
+
result = on_message(message)
|
| 227 |
+
if result is not None:
|
| 228 |
+
return result
|
| 229 |
+
time.sleep(max(0.2, self.conf["wait_interval"]))
|
| 230 |
+
return None
|
| 231 |
+
|
| 232 |
+
def wait_for_code(self, mailbox: dict[str, Any]) -> str | None:
|
| 233 |
+
seen_value = mailbox.setdefault("_seen_code_message_refs", [])
|
| 234 |
+
if not isinstance(seen_value, list):
|
| 235 |
+
seen_value = []
|
| 236 |
+
mailbox["_seen_code_message_refs"] = seen_value
|
| 237 |
+
seen_refs = {str(item) for item in seen_value}
|
| 238 |
+
|
| 239 |
+
def extract_unseen_code(message: dict[str, Any]) -> str | None:
|
| 240 |
+
ref = _message_tracking_ref(message)
|
| 241 |
+
if ref in seen_refs:
|
| 242 |
+
return None
|
| 243 |
+
code = _extract_code(message)
|
| 244 |
+
if code:
|
| 245 |
+
seen_value.append(ref)
|
| 246 |
+
seen_refs.add(ref)
|
| 247 |
+
return code
|
| 248 |
+
|
| 249 |
+
return self.wait_for(mailbox, extract_unseen_code)
|
| 250 |
+
|
| 251 |
+
def close(self) -> None:
|
| 252 |
+
pass
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
class CloudflareTempMailProvider(BaseMailProvider):
|
| 256 |
+
name = "cloudflare_temp_email"
|
| 257 |
+
|
| 258 |
+
def __init__(self, entry: dict, conf: dict):
|
| 259 |
+
super().__init__(conf, str(entry.get("provider_ref") or ""))
|
| 260 |
+
self.api_base = str(entry["api_base"]).rstrip("/")
|
| 261 |
+
self.admin_password = str(entry["admin_password"]).strip()
|
| 262 |
+
self.domain = entry.get("domain") or []
|
| 263 |
+
self.session = curl_requests.Session(impersonate="chrome")
|
| 264 |
+
|
| 265 |
+
def _request(self, method: str, path: str, headers: dict | None = None, params: dict | None = None, payload: dict | None = None, expected: tuple[int, ...] = (200,)):
|
| 266 |
+
resp = self.session.request(method.upper(), f"{self.api_base}{path}", headers={"Content-Type": "application/json", "User-Agent": self.conf["user_agent"], **(headers or {})}, params=params, json=payload, timeout=self.conf["request_timeout"], verify=False)
|
| 267 |
+
if resp.status_code not in expected:
|
| 268 |
+
raise RuntimeError(f"CloudflareTempMail 请求失败: {method} {path}, HTTP {resp.status_code}, body={resp.text[:300]}")
|
| 269 |
+
return {} if resp.status_code == 204 else resp.json()
|
| 270 |
+
|
| 271 |
+
def create_mailbox(self, username: str | None = None) -> dict[str, Any]:
|
| 272 |
+
data = self._request("POST", "/admin/new_address", headers={"x-admin-auth": self.admin_password}, payload={"enablePrefix": True, "name": username or _random_mailbox_name(), "domain": _next_domain(self.domain)})
|
| 273 |
+
address = str(data.get("address") or "").strip()
|
| 274 |
+
token = str(data.get("jwt") or "").strip()
|
| 275 |
+
if not address or not token:
|
| 276 |
+
raise RuntimeError("CloudflareTempMail 缺少 address 或 jwt")
|
| 277 |
+
return {"provider": self.name, "provider_ref": self.provider_ref, "address": address, "token": token}
|
| 278 |
+
|
| 279 |
+
def fetch_latest_message(self, mailbox: dict[str, Any]) -> dict[str, Any] | None:
|
| 280 |
+
data = self._request("GET", "/api/mails", headers={"Authorization": f"Bearer {mailbox['token']}"}, params={"limit": 10, "offset": 0})
|
| 281 |
+
raw = list(data.get("results") or []) if isinstance(data, dict) else data if isinstance(data, list) else []
|
| 282 |
+
messages = [item for item in raw if isinstance(item, dict) and _message_matches_email(item, str(mailbox.get("address") or ""))]
|
| 283 |
+
if not messages:
|
| 284 |
+
return None
|
| 285 |
+
item = messages[0]
|
| 286 |
+
text_content, html_content = _extract_content(item)
|
| 287 |
+
sender = item.get("from") or item.get("sender") or ""
|
| 288 |
+
if isinstance(sender, dict):
|
| 289 |
+
sender = sender.get("address") or sender.get("email") or sender.get("name") or ""
|
| 290 |
+
return {"provider": self.name, "mailbox": mailbox["address"], "message_id": str(item.get("id") or item.get("_id") or ""), "subject": str(item.get("subject") or ""), "sender": str(sender), "text_content": text_content, "html_content": html_content, "received_at": _parse_received_at(item.get("createdAt") or item.get("created_at") or item.get("receivedAt") or item.get("date") or item.get("timestamp")), "raw": item}
|
| 291 |
+
|
| 292 |
+
def close(self) -> None:
|
| 293 |
+
self.session.close()
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
class DDGMailProvider(BaseMailProvider):
|
| 297 |
+
name = "ddg_mail"
|
| 298 |
+
|
| 299 |
+
def __init__(self, entry: dict, conf: dict):
|
| 300 |
+
super().__init__(conf, str(entry.get("provider_ref") or ""))
|
| 301 |
+
self.label = str(entry.get("label") or self.provider_ref)
|
| 302 |
+
self.ddg_token = str(entry["ddg_token"]).strip()
|
| 303 |
+
self.cf_api_base = str(entry.get("api_base") or entry.get("cf_api_base") or "").rstrip("/")
|
| 304 |
+
self.cf_inbox_jwt = str(entry.get("cf_inbox_jwt") or "").strip()
|
| 305 |
+
self.cf_admin_password = str(entry.get("admin_password") or "").strip()
|
| 306 |
+
self.cf_api_key = str(entry.get("cf_api_key") or "").strip()
|
| 307 |
+
self.cf_auth_mode = str(entry.get("cf_auth_mode") or "none").strip().lower()
|
| 308 |
+
self.cf_domain = entry.get("cf_domain") or []
|
| 309 |
+
self.cf_create_path = str(entry.get("cf_create_path") or "/api/new_address").strip()
|
| 310 |
+
self.cf_messages_path = str(entry.get("cf_messages_path") or "/api/mails").strip()
|
| 311 |
+
self.proxy = str(conf.get("proxy") or "").strip()
|
| 312 |
+
self.session = curl_requests.Session(impersonate="chrome")
|
| 313 |
+
if self.proxy:
|
| 314 |
+
self.session.proxies = {"http": self.proxy, "https": self.proxy}
|
| 315 |
+
|
| 316 |
+
def _cf_build_headers(self, content_type: bool = False) -> dict:
|
| 317 |
+
headers = {"Content-Type": "application/json"} if content_type else {}
|
| 318 |
+
if self.cf_api_key:
|
| 319 |
+
if self.cf_auth_mode == "x-api-key":
|
| 320 |
+
headers["X-API-Key"] = self.cf_api_key
|
| 321 |
+
elif self.cf_auth_mode != "none":
|
| 322 |
+
headers["Authorization"] = f"Bearer {self.cf_api_key}"
|
| 323 |
+
return headers
|
| 324 |
+
|
| 325 |
+
def _cf_request(self, method: str, path: str, headers: dict | None = None, params: dict | None = None, payload: dict | None = None, expected: tuple[int, ...] = (200,)) -> dict:
|
| 326 |
+
merged_headers = {**self._cf_build_headers(True), **(headers or {}), "User-Agent": self.conf["user_agent"]}
|
| 327 |
+
if self.cf_admin_password and method.upper() in ("POST",):
|
| 328 |
+
merged_headers["x-admin-auth"] = self.cf_admin_password
|
| 329 |
+
if self.cf_api_key and self.cf_auth_mode == "query-key":
|
| 330 |
+
params = {**(params or {}), "key": self.cf_api_key}
|
| 331 |
+
resp = self.session.request(method.upper(), f"{self.cf_api_base}{path}", headers=merged_headers, params=params, json=payload, timeout=self.conf["request_timeout"], verify=False)
|
| 332 |
+
if resp.status_code not in expected:
|
| 333 |
+
raise RuntimeError(f"DDGMail CF请求失败: {method} {path}, HTTP {resp.status_code}, body={resp.text[:300]}")
|
| 334 |
+
return {} if resp.status_code == 204 else resp.json()
|
| 335 |
+
|
| 336 |
+
def _ddg_request(self, method: str, path: str, payload: dict | None = None) -> dict:
|
| 337 |
+
resp = self.session.request(method.upper(), f"https://quack.duckduckgo.com{path}", headers={"Authorization": f"Bearer {self.ddg_token}", "Content-Type": "application/json", "User-Agent": self.conf["user_agent"]}, json=payload, timeout=self.conf["request_timeout"], verify=False)
|
| 338 |
+
if resp.status_code not in (200, 201):
|
| 339 |
+
raise RuntimeError(f"DDG API请求失败: {method} {path}, HTTP {resp.status_code}, body={resp.text[:300]}")
|
| 340 |
+
return resp.json()
|
| 341 |
+
|
| 342 |
+
def _cf_list_payload(self, data: Any) -> list:
|
| 343 |
+
if isinstance(data, list):
|
| 344 |
+
return data
|
| 345 |
+
if isinstance(data, dict):
|
| 346 |
+
for key in ("results", "hydra:member", "data", "messages"):
|
| 347 |
+
value = data.get(key)
|
| 348 |
+
if isinstance(value, list):
|
| 349 |
+
return value
|
| 350 |
+
if isinstance(value, dict) and isinstance(value.get("messages"), list):
|
| 351 |
+
return value["messages"]
|
| 352 |
+
return []
|
| 353 |
+
|
| 354 |
+
def create_mailbox(self, username: str | None = None) -> dict[str, Any]:
|
| 355 |
+
ddg_data = self._ddg_request("POST", "/api/email/addresses", payload={})
|
| 356 |
+
ddg_address_part = str(ddg_data.get("address") or "").strip()
|
| 357 |
+
if not ddg_address_part:
|
| 358 |
+
raise RuntimeError("DDG API 返回无 address 字段")
|
| 359 |
+
ddg_address = f"{ddg_address_part}@duck.com"
|
| 360 |
+
|
| 361 |
+
if _is_ddg_alias_duplicate(ddg_address):
|
| 362 |
+
raise RuntimeError(f"[{self.label}] DDG日上限已达,别名 {ddg_address} 已存在,自动切换邮箱提供商")
|
| 363 |
+
|
| 364 |
+
_record_ddg_alias(ddg_address)
|
| 365 |
+
|
| 366 |
+
if not self.cf_inbox_jwt:
|
| 367 |
+
raise RuntimeError("DDGMail 需要 cf_inbox_jwt(DDG 转发目标的固定收件箱 JWT),请在邮箱配置中填写 CF Inbox JWT")
|
| 368 |
+
|
| 369 |
+
return {"provider": self.name, "provider_ref": self.provider_ref, "address": ddg_address, "token": self.cf_inbox_jwt, "label": self.label}
|
| 370 |
+
|
| 371 |
+
def _parse_raw_recipient(self, raw_text: str) -> str:
|
| 372 |
+
if not raw_text:
|
| 373 |
+
return ""
|
| 374 |
+
match = re.search(r"^To:\s*(.+?)$", raw_text, re.MULTILINE | re.IGNORECASE)
|
| 375 |
+
if match:
|
| 376 |
+
addr = match.group(1).strip()
|
| 377 |
+
addr = re.sub(r"\s*<[^>]*>", "", addr)
|
| 378 |
+
return addr.strip().lower()
|
| 379 |
+
try:
|
| 380 |
+
parsed = message_from_string(raw_text, policy=policy.default)
|
| 381 |
+
return str(parsed.get("To") or "").strip().lower()
|
| 382 |
+
except Exception:
|
| 383 |
+
return ""
|
| 384 |
+
|
| 385 |
+
def fetch_latest_message(self, mailbox: dict[str, Any]) -> dict[str, Any] | None:
|
| 386 |
+
target_address = str(mailbox.get("address") or "").strip().lower()
|
| 387 |
+
data = self._cf_request("GET", self.cf_messages_path, headers={"Authorization": f"Bearer {mailbox['token']}"}, params={"limit": 30, "offset": 0})
|
| 388 |
+
raw_list = self._cf_list_payload(data)
|
| 389 |
+
messages = [item for item in raw_list if isinstance(item, dict)]
|
| 390 |
+
if not messages:
|
| 391 |
+
return None
|
| 392 |
+
|
| 393 |
+
for item in messages:
|
| 394 |
+
message_id = str(item.get("id") or item.get("msgid") or item.get("_id") or "")
|
| 395 |
+
raw_text = str(item.get("raw") or "")
|
| 396 |
+
raw_recipient = self._parse_raw_recipient(raw_text)
|
| 397 |
+
if target_address and raw_recipient and target_address not in raw_recipient:
|
| 398 |
+
continue
|
| 399 |
+
text_content, html_content = _extract_content(item)
|
| 400 |
+
subject = str(item.get("subject") or "")
|
| 401 |
+
sender = item.get("from") or item.get("sender") or item.get("source") or ""
|
| 402 |
+
if isinstance(sender, dict):
|
| 403 |
+
sender = sender.get("address") or sender.get("email") or sender.get("name") or ""
|
| 404 |
+
if raw_text and (not subject or not sender or subject == sender == ""):
|
| 405 |
+
try:
|
| 406 |
+
parsed = message_from_string(raw_text, policy=policy.default)
|
| 407 |
+
if not subject:
|
| 408 |
+
subject = str(parsed.get("Subject") or "")
|
| 409 |
+
if not sender:
|
| 410 |
+
sender = str(parsed.get("From") or "")
|
| 411 |
+
except Exception:
|
| 412 |
+
pass
|
| 413 |
+
return {"provider": self.name, "mailbox": mailbox["address"], "message_id": message_id, "subject": subject, "sender": str(sender), "text_content": text_content, "html_content": html_content, "received_at": _parse_received_at(item.get("createdAt") or item.get("created_at") or item.get("receivedAt") or item.get("date") or item.get("timestamp")), "raw": item}
|
| 414 |
+
|
| 415 |
+
return None
|
| 416 |
+
|
| 417 |
+
def close(self) -> None:
|
| 418 |
+
self.session.close()
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
class CloudMailGenProvider(BaseMailProvider):
|
| 422 |
+
name = "cloudmail_gen"
|
| 423 |
+
|
| 424 |
+
def __init__(self, entry: dict, conf: dict):
|
| 425 |
+
super().__init__(conf, str(entry.get("provider_ref") or ""))
|
| 426 |
+
self.api_base = str(entry["api_base"]).rstrip("/")
|
| 427 |
+
self.admin_email = str(entry.get("admin_email") or "").strip()
|
| 428 |
+
self.admin_password = str(entry.get("admin_password") or "").strip()
|
| 429 |
+
self.domain = _normalize_string_list(entry.get("domain"))
|
| 430 |
+
self.subdomain = _normalize_string_list(entry.get("subdomain"))
|
| 431 |
+
self.email_prefix = str(entry.get("email_prefix") or "").strip()
|
| 432 |
+
self.session = curl_requests.Session(impersonate="chrome")
|
| 433 |
+
|
| 434 |
+
def _request(
|
| 435 |
+
self,
|
| 436 |
+
method: str,
|
| 437 |
+
path: str,
|
| 438 |
+
headers: dict | None = None,
|
| 439 |
+
params: dict | None = None,
|
| 440 |
+
payload: dict | None = None,
|
| 441 |
+
expected: tuple[int, ...] = (200,),
|
| 442 |
+
):
|
| 443 |
+
resp = self.session.request(
|
| 444 |
+
method.upper(),
|
| 445 |
+
f"{self.api_base}{path}",
|
| 446 |
+
headers={
|
| 447 |
+
"Content-Type": "application/json",
|
| 448 |
+
"User-Agent": self.conf["user_agent"],
|
| 449 |
+
**(headers or {}),
|
| 450 |
+
},
|
| 451 |
+
params=params,
|
| 452 |
+
json=payload,
|
| 453 |
+
timeout=self.conf["request_timeout"],
|
| 454 |
+
verify=False,
|
| 455 |
+
)
|
| 456 |
+
if resp.status_code not in expected:
|
| 457 |
+
raise RuntimeError(f"CloudMailGen 请求失败: {method} {path}, HTTP {resp.status_code}, body={resp.text[:300]}")
|
| 458 |
+
return {} if resp.status_code == 204 else resp.json()
|
| 459 |
+
|
| 460 |
+
def _cache_key(self) -> str:
|
| 461 |
+
return f"{self.api_base}|{self.admin_email}"
|
| 462 |
+
|
| 463 |
+
def _get_token(self) -> str:
|
| 464 |
+
if not self.admin_email or not self.admin_password:
|
| 465 |
+
raise RuntimeError("CloudMailGen 缺少 admin_email 或 admin_password")
|
| 466 |
+
cache_key = self._cache_key()
|
| 467 |
+
now = time.time()
|
| 468 |
+
with cloudmail_token_lock:
|
| 469 |
+
cached = cloudmail_token_cache.get(cache_key)
|
| 470 |
+
if cached and now < cached[1] - 300:
|
| 471 |
+
return cached[0]
|
| 472 |
+
data = self._request(
|
| 473 |
+
"POST",
|
| 474 |
+
"/api/public/genToken",
|
| 475 |
+
payload={"email": self.admin_email, "password": self.admin_password},
|
| 476 |
+
)
|
| 477 |
+
token = ""
|
| 478 |
+
if isinstance(data, dict) and data.get("code") == 200:
|
| 479 |
+
token = str((data.get("data") or {}).get("token") or "").strip()
|
| 480 |
+
if not token:
|
| 481 |
+
raise RuntimeError(f"CloudMailGen genToken 返回异常: {data}")
|
| 482 |
+
with cloudmail_token_lock:
|
| 483 |
+
cloudmail_token_cache[cache_key] = (token, now + 24 * 3600)
|
| 484 |
+
return token
|
| 485 |
+
|
| 486 |
+
def _resolve_address(self, username: str | None = None) -> str:
|
| 487 |
+
domain = _next_domain(self.domain)
|
| 488 |
+
if self.subdomain:
|
| 489 |
+
domain = f"{random.choice(self.subdomain)}.{domain}"
|
| 490 |
+
if username:
|
| 491 |
+
local_part = username
|
| 492 |
+
elif self.email_prefix:
|
| 493 |
+
local_part = f"{self.email_prefix}_{''.join(random.choices(string.ascii_lowercase + string.digits, k=6))}"
|
| 494 |
+
else:
|
| 495 |
+
local_part = _random_mailbox_name()
|
| 496 |
+
return f"{local_part}@{domain}"
|
| 497 |
+
|
| 498 |
+
def create_mailbox(self, username: str | None = None) -> dict[str, Any]:
|
| 499 |
+
if not self.domain:
|
| 500 |
+
raise RuntimeError("CloudMailGen 需要至少配置一个 domain")
|
| 501 |
+
address = self._resolve_address(username)
|
| 502 |
+
return {"provider": self.name, "provider_ref": self.provider_ref, "address": address}
|
| 503 |
+
|
| 504 |
+
def fetch_latest_message(self, mailbox: dict[str, Any]) -> dict[str, Any] | None:
|
| 505 |
+
address = str(mailbox.get("address") or "").strip()
|
| 506 |
+
if not address:
|
| 507 |
+
raise RuntimeError("CloudMailGen 缺少 address")
|
| 508 |
+
token = self._get_token()
|
| 509 |
+
data = self._request(
|
| 510 |
+
"POST",
|
| 511 |
+
"/api/public/emailList",
|
| 512 |
+
headers={"Authorization": token},
|
| 513 |
+
payload={"toEmail": address, "size": 20, "timeSort": "desc"},
|
| 514 |
+
)
|
| 515 |
+
items = (data.get("data") or []) if isinstance(data, dict) and data.get("code") == 200 else []
|
| 516 |
+
messages = [item for item in items if isinstance(item, dict) and _message_matches_email(item, address)]
|
| 517 |
+
if not messages:
|
| 518 |
+
return None
|
| 519 |
+
item = messages[0]
|
| 520 |
+
text_content, html_content = _extract_content(item)
|
| 521 |
+
return {
|
| 522 |
+
"provider": self.name,
|
| 523 |
+
"mailbox": address,
|
| 524 |
+
"message_id": str(item.get("id") or item.get("_id") or item.get("messageId") or ""),
|
| 525 |
+
"subject": str(item.get("subject") or ""),
|
| 526 |
+
"sender": str(item.get("from") or item.get("sender") or ""),
|
| 527 |
+
"text_content": text_content,
|
| 528 |
+
"html_content": html_content,
|
| 529 |
+
"received_at": _parse_received_at(
|
| 530 |
+
item.get("createdAt") or item.get("created_at") or item.get("receivedAt") or item.get("date") or item.get("timestamp")
|
| 531 |
+
),
|
| 532 |
+
"to": item.get("to") or item.get("toEmail") or item.get("mailTo"),
|
| 533 |
+
"raw": item,
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
def close(self) -> None:
|
| 537 |
+
self.session.close()
|
| 538 |
+
|
| 539 |
+
|
| 540 |
+
class TempMailLolProvider(BaseMailProvider):
|
| 541 |
+
name = "tempmail_lol"
|
| 542 |
+
|
| 543 |
+
def __init__(self, entry: dict, conf: dict):
|
| 544 |
+
super().__init__(conf, str(entry.get("provider_ref") or ""))
|
| 545 |
+
self.api_key = str(entry.get("api_key") or "").strip()
|
| 546 |
+
self.domain = [str(item).strip() for item in (entry.get("domain") or []) if str(item).strip()]
|
| 547 |
+
self.session = requests.Session()
|
| 548 |
+
self.session.trust_env = False
|
| 549 |
+
self.session.headers.update({"User-Agent": conf["user_agent"], "Accept": "application/json", "Content-Type": "application/json"})
|
| 550 |
+
if self.api_key:
|
| 551 |
+
self.session.headers["Authorization"] = f"Bearer {self.api_key}"
|
| 552 |
+
|
| 553 |
+
@staticmethod
|
| 554 |
+
def _resolve_domain(domain: str) -> tuple[str, bool]:
|
| 555 |
+
text = str(domain or "").strip().lower()
|
| 556 |
+
if text.startswith("*.") and len(text) > 2:
|
| 557 |
+
return f"{_random_subdomain_label()}.{text[2:]}", True
|
| 558 |
+
return text, False
|
| 559 |
+
|
| 560 |
+
def _request(self, method: str, path: str, params: dict | None = None, payload: dict | None = None, expected: tuple[int, ...] = (200,)):
|
| 561 |
+
resp = self.session.request(method.upper(), f"https://api.tempmail.lol/v2{path}", params=params, json=payload, timeout=self.conf["request_timeout"], verify=False)
|
| 562 |
+
if resp.status_code not in expected:
|
| 563 |
+
raise RuntimeError(f"TempMail.lol 请求失败: {method} {path}, HTTP {resp.status_code}, body={resp.text[:300]}")
|
| 564 |
+
data = resp.json()
|
| 565 |
+
if not isinstance(data, dict):
|
| 566 |
+
raise RuntimeError(f"TempMail.lol {method} {path} 返回结构不是对象")
|
| 567 |
+
return data
|
| 568 |
+
|
| 569 |
+
def create_mailbox(self, username: str | None = None) -> dict[str, Any]:
|
| 570 |
+
payload: dict[str, Any] = {}
|
| 571 |
+
if self.domain:
|
| 572 |
+
domain, force_random_prefix = self._resolve_domain(random.choice(self.domain))
|
| 573 |
+
payload["domain"] = domain
|
| 574 |
+
if force_random_prefix:
|
| 575 |
+
payload["prefix"] = _random_mailbox_name()
|
| 576 |
+
if username and "prefix" not in payload:
|
| 577 |
+
payload["prefix"] = username
|
| 578 |
+
data = self._request("POST", "/inbox/create", payload=payload, expected=(200, 201))
|
| 579 |
+
address = str(data.get("address") or "").strip()
|
| 580 |
+
token = str(data.get("token") or "").strip()
|
| 581 |
+
if not address or not token:
|
| 582 |
+
raise RuntimeError("TempMail.lol 缺少 address 或 token")
|
| 583 |
+
return {"provider": self.name, "provider_ref": self.provider_ref, "address": address, "token": token}
|
| 584 |
+
|
| 585 |
+
def fetch_latest_message(self, mailbox: dict[str, Any]) -> dict[str, Any] | None:
|
| 586 |
+
data = self._request("GET", "/inbox", params={"token": mailbox["token"]})
|
| 587 |
+
items = data.get("emails") or data.get("messages") or []
|
| 588 |
+
messages = [item for item in items if isinstance(item, dict)] if isinstance(items, list) else []
|
| 589 |
+
if not messages:
|
| 590 |
+
return None
|
| 591 |
+
item = max(messages, key=lambda value: ((_parse_received_at(value.get("created_at") or value.get("createdAt") or value.get("date") or value.get("received_at") or value.get("timestamp")) or datetime.fromtimestamp(0, tz=timezone.utc)).timestamp(), str(value.get("id") or value.get("token") or "")))
|
| 592 |
+
text_content, html_content = _extract_content(item)
|
| 593 |
+
return {"provider": self.name, "mailbox": mailbox["address"], "message_id": str(item.get("id") or item.get("token") or ""), "subject": str(item.get("subject") or ""), "sender": str(item.get("from") or item.get("from_address") or ""), "text_content": text_content, "html_content": html_content, "received_at": _parse_received_at(item.get("created_at") or item.get("createdAt") or item.get("date") or item.get("received_at") or item.get("timestamp")), "raw": item}
|
| 594 |
+
|
| 595 |
+
def close(self) -> None:
|
| 596 |
+
self.session.close()
|
| 597 |
+
|
| 598 |
+
|
| 599 |
+
class DuckMailProvider(BaseMailProvider):
|
| 600 |
+
name = "duckmail"
|
| 601 |
+
|
| 602 |
+
def __init__(self, entry: dict, conf: dict):
|
| 603 |
+
super().__init__(conf, str(entry.get("provider_ref") or ""))
|
| 604 |
+
self.api_key = str(entry["api_key"]).strip()
|
| 605 |
+
self.default_domain = str(entry.get("default_domain") or "duckmail.sbs").strip() or "duckmail.sbs"
|
| 606 |
+
self.session = requests.Session()
|
| 607 |
+
self.session.trust_env = False
|
| 608 |
+
self.session.headers.update({"User-Agent": conf["user_agent"], "Accept": "application/json", "Content-Type": "application/json"})
|
| 609 |
+
|
| 610 |
+
def _request(self, method: str, path: str, token: str = "", use_api_key: bool = False, params: dict | None = None, payload: dict | None = None, expected: tuple[int, ...] = (200, 201, 204)):
|
| 611 |
+
headers = {"Authorization": f"Bearer {self.api_key if use_api_key else token}"} if use_api_key or token else {}
|
| 612 |
+
resp = self.session.request(method.upper(), f"https://api.duckmail.sbs{path}", headers=headers, params=params, json=payload, timeout=self.conf["request_timeout"], verify=False)
|
| 613 |
+
if resp.status_code not in expected:
|
| 614 |
+
raise RuntimeError(f"DuckMail 请求失败: {method} {path}, HTTP {resp.status_code}, body={resp.text[:300]}")
|
| 615 |
+
return {} if resp.status_code == 204 else resp.json()
|
| 616 |
+
|
| 617 |
+
@staticmethod
|
| 618 |
+
def _items(data):
|
| 619 |
+
return data if isinstance(data, list) else data.get("hydra:member") or data.get("member") or data.get("data") or []
|
| 620 |
+
|
| 621 |
+
def create_mailbox(self, username: str | None = None) -> dict[str, Any]:
|
| 622 |
+
password = "".join(random.choices(string.ascii_letters + string.digits, k=12))
|
| 623 |
+
address = f"{username or _random_mailbox_name()}@{self.default_domain}"
|
| 624 |
+
payload = {"address": address, "password": password}
|
| 625 |
+
account = self._request("POST", "/accounts", use_api_key=True, payload=payload)
|
| 626 |
+
token_data = self._request("POST", "/token", use_api_key=True, payload=payload)
|
| 627 |
+
return {"provider": self.name, "provider_ref": self.provider_ref, "address": address, "token": str(token_data.get("token") or ""), "password": password, "account_id": str(account.get("id") or "")}
|
| 628 |
+
|
| 629 |
+
def fetch_latest_message(self, mailbox: dict[str, Any]) -> dict[str, Any] | None:
|
| 630 |
+
data = self._request("GET", "/messages", token=str(mailbox.get("token") or ""), params={"page": 1})
|
| 631 |
+
items = self._items(data)
|
| 632 |
+
if not items:
|
| 633 |
+
return None
|
| 634 |
+
item = items[0]
|
| 635 |
+
message_id = str(item.get("id") or item.get("@id") or "").replace("/messages/", "")
|
| 636 |
+
if message_id:
|
| 637 |
+
item = self._request("GET", f"/messages/{message_id}", token=str(mailbox.get("token") or ""))
|
| 638 |
+
sender = item.get("from") or ""
|
| 639 |
+
if isinstance(sender, dict):
|
| 640 |
+
sender = sender.get("address") or sender.get("name") or ""
|
| 641 |
+
html_content = item.get("html") or ""
|
| 642 |
+
if isinstance(html_content, list):
|
| 643 |
+
html_content = "".join(str(value) for value in html_content)
|
| 644 |
+
return {"provider": self.name, "mailbox": mailbox["address"], "message_id": message_id, "subject": str(item.get("subject") or ""), "sender": str(sender), "text_content": str(item.get("text") or item.get("text_content") or ""), "html_content": str(html_content), "received_at": _parse_received_at(item.get("createdAt") or item.get("created_at") or item.get("receivedAt") or item.get("date")), "raw": item}
|
| 645 |
+
|
| 646 |
+
def close(self) -> None:
|
| 647 |
+
self.session.close()
|
| 648 |
+
|
| 649 |
+
|
| 650 |
+
class GptMailProvider(BaseMailProvider):
|
| 651 |
+
name = "gptmail"
|
| 652 |
+
|
| 653 |
+
def __init__(self, entry: dict, conf: dict):
|
| 654 |
+
super().__init__(conf, str(entry.get("provider_ref") or ""))
|
| 655 |
+
self.api_key = str(entry["api_key"]).strip()
|
| 656 |
+
self.default_domain = str(entry.get("default_domain") or "").strip()
|
| 657 |
+
self.session = requests.Session()
|
| 658 |
+
self.session.trust_env = False
|
| 659 |
+
self.session.headers.update({"User-Agent": conf["user_agent"], "Accept": "application/json", "Content-Type": "application/json", "X-API-Key": self.api_key})
|
| 660 |
+
|
| 661 |
+
def _request(self, method: str, path: str, params: dict | None = None, payload: dict | None = None):
|
| 662 |
+
query = dict(params or {})
|
| 663 |
+
resp = self.session.request(method.upper(), f"https://mail.chatgpt.org.uk{path}", params=query, json=payload, timeout=self.conf["request_timeout"], verify=False)
|
| 664 |
+
if resp.status_code != 200:
|
| 665 |
+
raise RuntimeError(f"GPTMail 请求失败: {method} {path}, HTTP {resp.status_code}, body={resp.text[:300]}")
|
| 666 |
+
data = resp.json()
|
| 667 |
+
return data["data"] if isinstance(data, dict) and "data" in data else data
|
| 668 |
+
|
| 669 |
+
def create_mailbox(self, username: str | None = None) -> dict[str, Any]:
|
| 670 |
+
payload = {key: value for key, value in {"prefix": username, "domain": self.default_domain}.items() if value}
|
| 671 |
+
data = self._request("POST" if payload else "GET", "/api/generate-email", payload=payload or None)
|
| 672 |
+
return {"provider": self.name, "provider_ref": self.provider_ref, "address": str(data["email"])}
|
| 673 |
+
|
| 674 |
+
def fetch_latest_message(self, mailbox: dict[str, Any]) -> dict[str, Any] | None:
|
| 675 |
+
data = self._request("GET", "/api/emails", params={"email": mailbox["address"]})
|
| 676 |
+
emails = data if isinstance(data, list) else data.get("emails") or []
|
| 677 |
+
if not emails:
|
| 678 |
+
return None
|
| 679 |
+
item = max(emails, key=lambda value: (float(value.get("timestamp") or 0), str(value.get("id") or "")))
|
| 680 |
+
if item.get("id"):
|
| 681 |
+
item = self._request("GET", f"/api/email/{item['id']}")
|
| 682 |
+
return {"provider": self.name, "mailbox": mailbox["address"], "message_id": str(item.get("id") or ""), "subject": str(item.get("subject") or ""), "sender": str(item.get("from_address") or ""), "text_content": str(item.get("content") or ""), "html_content": str(item.get("html_content") or ""), "received_at": _parse_received_at(item.get("timestamp") or item.get("created_at")), "raw": item}
|
| 683 |
+
|
| 684 |
+
def close(self) -> None:
|
| 685 |
+
self.session.close()
|
| 686 |
+
|
| 687 |
+
|
| 688 |
+
class MoEmailProvider(BaseMailProvider):
|
| 689 |
+
name = "moemail"
|
| 690 |
+
|
| 691 |
+
def __init__(self, entry: dict, conf: dict):
|
| 692 |
+
super().__init__(conf, str(entry.get("provider_ref") or ""))
|
| 693 |
+
self.api_base = str(entry["api_base"]).rstrip("/")
|
| 694 |
+
self.api_key = str(entry["api_key"]).strip()
|
| 695 |
+
raw_domains = entry.get("domain") or []
|
| 696 |
+
if isinstance(raw_domains, list):
|
| 697 |
+
self.domain = [str(item).strip() for item in raw_domains if str(item).strip()]
|
| 698 |
+
else:
|
| 699 |
+
self.domain = [str(raw_domains).strip()] if str(raw_domains).strip() else []
|
| 700 |
+
self.expiry_time = int(entry.get("expiry_time") or 0)
|
| 701 |
+
self.session = curl_requests.Session(impersonate="chrome")
|
| 702 |
+
|
| 703 |
+
def _request(self, method: str, path: str, params: dict | None = None, payload: dict | None = None, expected: tuple[int, ...] = (200,)):
|
| 704 |
+
resp = self.session.request(method.upper(), f"{self.api_base}{path}", headers={"X-API-Key": self.api_key, "Content-Type": "application/json", "User-Agent": self.conf["user_agent"]}, params=params, json=payload, timeout=self.conf["request_timeout"], verify=False)
|
| 705 |
+
if resp.status_code not in expected:
|
| 706 |
+
raise RuntimeError(f"MoEmail 请求失败: {method} {path}, HTTP {resp.status_code}, body={resp.text[:300]}")
|
| 707 |
+
data = resp.json()
|
| 708 |
+
if not isinstance(data, dict):
|
| 709 |
+
raise RuntimeError(f"MoEmail {method} {path} 返回结构不是对象")
|
| 710 |
+
return data
|
| 711 |
+
|
| 712 |
+
def create_mailbox(self, username: str | None = None) -> dict[str, Any]:
|
| 713 |
+
data = self._request("POST", "/api/emails/generate", payload={"name": username or _random_mailbox_name(), "expiryTime": self.expiry_time, "domain": _next_domain(self.domain)}, expected=(200, 201))
|
| 714 |
+
address = str(data.get("email") or "").strip()
|
| 715 |
+
email_id = str(data.get("id") or data.get("email_id") or "").strip()
|
| 716 |
+
if not address or not email_id:
|
| 717 |
+
raise RuntimeError("MoEmail 缺少 email 或 id")
|
| 718 |
+
return {"provider": self.name, "provider_ref": self.provider_ref, "address": address, "email_id": email_id}
|
| 719 |
+
|
| 720 |
+
def fetch_latest_message(self, mailbox: dict[str, Any]) -> dict[str, Any] | None:
|
| 721 |
+
email_id = str(mailbox.get("email_id") or "").strip()
|
| 722 |
+
if not email_id:
|
| 723 |
+
raise RuntimeError("MoEmail 缺少 email_id")
|
| 724 |
+
data = self._request("GET", f"/api/emails/{email_id}")
|
| 725 |
+
items = data.get("messages") or []
|
| 726 |
+
messages = [item for item in items if isinstance(item, dict)] if isinstance(items, list) else []
|
| 727 |
+
if not messages:
|
| 728 |
+
return None
|
| 729 |
+
_, item = max(enumerate(messages), key=lambda pair: (((_parse_received_at(pair[1].get("createdAt") or pair[1].get("created_at") or pair[1].get("receivedAt") or pair[1].get("date") or pair[1].get("timestamp")) or datetime.fromtimestamp(0, tz=timezone.utc)).timestamp()), pair[0]))
|
| 730 |
+
message_id = str(item.get("id") or item.get("message_id") or item.get("_id") or "").strip()
|
| 731 |
+
detail = self._request("GET", f"/api/emails/{email_id}/{message_id}") if message_id else {"message": item}
|
| 732 |
+
message = detail.get("message") if isinstance(detail.get("message"), dict) else detail
|
| 733 |
+
text_content, html_content = _extract_content(message)
|
| 734 |
+
sender = message.get("from") or message.get("sender") or ""
|
| 735 |
+
if isinstance(sender, dict):
|
| 736 |
+
sender = sender.get("address") or sender.get("email") or sender.get("name") or ""
|
| 737 |
+
return {"provider": self.name, "mailbox": mailbox["address"], "message_id": message_id, "subject": str(message.get("subject") or item.get("subject") or ""), "sender": str(sender), "text_content": text_content, "html_content": html_content, "received_at": _parse_received_at(message.get("createdAt") or message.get("created_at") or message.get("receivedAt") or message.get("date") or message.get("timestamp") or item.get("createdAt") or item.get("created_at") or item.get("receivedAt") or item.get("date") or item.get("timestamp")), "raw": detail}
|
| 738 |
+
|
| 739 |
+
def close(self) -> None:
|
| 740 |
+
self.session.close()
|
| 741 |
+
|
| 742 |
+
|
| 743 |
+
class InbucketMailProvider(BaseMailProvider):
|
| 744 |
+
name = "inbucket"
|
| 745 |
+
|
| 746 |
+
def __init__(self, entry: dict, conf: dict):
|
| 747 |
+
super().__init__(conf, str(entry.get("provider_ref") or ""))
|
| 748 |
+
self.api_base = str(entry["api_base"]).rstrip("/")
|
| 749 |
+
raw_domains = entry.get("domain") or []
|
| 750 |
+
if isinstance(raw_domains, list):
|
| 751 |
+
self.domain = [str(item).strip() for item in raw_domains if str(item).strip()]
|
| 752 |
+
else:
|
| 753 |
+
self.domain = [str(raw_domains).strip()] if str(raw_domains).strip() else []
|
| 754 |
+
self.random_subdomain = bool(entry.get("random_subdomain", True))
|
| 755 |
+
self.session = requests.Session()
|
| 756 |
+
self.session.trust_env = False
|
| 757 |
+
self.session.headers.update({
|
| 758 |
+
"User-Agent": conf["user_agent"],
|
| 759 |
+
"Accept": "application/json",
|
| 760 |
+
})
|
| 761 |
+
|
| 762 |
+
def _request(self, method: str, path: str, expected: tuple[int, ...] = (200,)):
|
| 763 |
+
resp = self.session.request(
|
| 764 |
+
method.upper(),
|
| 765 |
+
f"{self.api_base}{path}",
|
| 766 |
+
timeout=self.conf["request_timeout"],
|
| 767 |
+
verify=False,
|
| 768 |
+
)
|
| 769 |
+
if resp.status_code not in expected:
|
| 770 |
+
raise RuntimeError(f"Inbucket 请求失败: {method} {path}, HTTP {resp.status_code}, body={resp.text[:300]}")
|
| 771 |
+
if resp.status_code == 204:
|
| 772 |
+
return {}
|
| 773 |
+
content_type = str(resp.headers.get("content-type") or "").lower()
|
| 774 |
+
if "application/json" in content_type:
|
| 775 |
+
return resp.json()
|
| 776 |
+
return resp.text
|
| 777 |
+
|
| 778 |
+
def _resolve_domain(self) -> str:
|
| 779 |
+
if self.domain:
|
| 780 |
+
return _next_domain(self.domain)
|
| 781 |
+
raise RuntimeError("Inbucket 需要至少配置一个 domain")
|
| 782 |
+
|
| 783 |
+
def _mailbox_name(self, address: str) -> str:
|
| 784 |
+
local_part, _, _ = str(address or "").partition("@")
|
| 785 |
+
return local_part.strip()
|
| 786 |
+
|
| 787 |
+
def create_mailbox(self, username: str | None = None) -> dict[str, Any]:
|
| 788 |
+
local_part = username or _random_mailbox_name()
|
| 789 |
+
base_domain = self._resolve_domain()
|
| 790 |
+
domain = f"{_random_subdomain_label()}.{base_domain}" if self.random_subdomain else base_domain
|
| 791 |
+
address = f"{local_part}@{domain}"
|
| 792 |
+
mailbox_name = self._mailbox_name(address)
|
| 793 |
+
return {
|
| 794 |
+
"provider": self.name,
|
| 795 |
+
"provider_ref": self.provider_ref,
|
| 796 |
+
"address": address,
|
| 797 |
+
"base_domain": base_domain,
|
| 798 |
+
"mailbox_name": mailbox_name,
|
| 799 |
+
}
|
| 800 |
+
|
| 801 |
+
def fetch_latest_message(self, mailbox: dict[str, Any]) -> dict[str, Any] | None:
|
| 802 |
+
mailbox_name = str(mailbox.get("mailbox_name") or self._mailbox_name(str(mailbox.get("address") or ""))).strip()
|
| 803 |
+
if not mailbox_name:
|
| 804 |
+
raise RuntimeError("Inbucket 缺少 mailbox_name")
|
| 805 |
+
data = self._request("GET", f"/api/v1/mailbox/{mailbox_name}")
|
| 806 |
+
items = [item for item in data if isinstance(item, dict)] if isinstance(data, list) else []
|
| 807 |
+
if not items:
|
| 808 |
+
return None
|
| 809 |
+
items.sort(
|
| 810 |
+
key=lambda value: (
|
| 811 |
+
(_parse_received_at(value.get("date")) or datetime.fromtimestamp(0, tz=timezone.utc)).timestamp(),
|
| 812 |
+
str(value.get("id") or ""),
|
| 813 |
+
),
|
| 814 |
+
reverse=True,
|
| 815 |
+
)
|
| 816 |
+
address = str(mailbox.get("address") or "").strip()
|
| 817 |
+
for item in items:
|
| 818 |
+
message_id = str(item.get("id") or "").strip()
|
| 819 |
+
if not message_id:
|
| 820 |
+
continue
|
| 821 |
+
detail = self._request("GET", f"/api/v1/mailbox/{mailbox_name}/{message_id}")
|
| 822 |
+
if not isinstance(detail, dict):
|
| 823 |
+
continue
|
| 824 |
+
header = detail.get("header") if isinstance(detail.get("header"), dict) else {}
|
| 825 |
+
body = detail.get("body") if isinstance(detail.get("body"), dict) else {}
|
| 826 |
+
normalized = {
|
| 827 |
+
"provider": self.name,
|
| 828 |
+
"mailbox": mailbox_name,
|
| 829 |
+
"message_id": message_id,
|
| 830 |
+
"subject": str(detail.get("subject") or item.get("subject") or ""),
|
| 831 |
+
"sender": str(detail.get("from") or item.get("from") or ""),
|
| 832 |
+
"text_content": str(body.get("text") or ""),
|
| 833 |
+
"html_content": str(body.get("html") or ""),
|
| 834 |
+
"received_at": _parse_received_at(detail.get("date") or item.get("date")),
|
| 835 |
+
"to": header.get("To") if isinstance(header, dict) else None,
|
| 836 |
+
"raw": detail,
|
| 837 |
+
}
|
| 838 |
+
if _message_matches_email(normalized, address):
|
| 839 |
+
return normalized
|
| 840 |
+
return None
|
| 841 |
+
|
| 842 |
+
def close(self) -> None:
|
| 843 |
+
self.session.close()
|
| 844 |
+
|
| 845 |
+
|
| 846 |
+
class YydsMailProvider(BaseMailProvider):
|
| 847 |
+
name = "yyds_mail"
|
| 848 |
+
|
| 849 |
+
def __init__(self, entry: dict, conf: dict):
|
| 850 |
+
super().__init__(conf, str(entry.get("provider_ref") or ""))
|
| 851 |
+
self.api_base = str(entry.get("api_base") or "https://maliapi.215.im/v1").rstrip("/")
|
| 852 |
+
self.api_key = str(entry["api_key"]).strip()
|
| 853 |
+
self.domain = [str(item).strip() for item in (entry.get("domain") or []) if str(item).strip()]
|
| 854 |
+
self.subdomain = str(entry.get("subdomain") or "").strip()
|
| 855 |
+
self.wildcard = bool(entry.get("wildcard"))
|
| 856 |
+
self.session = requests.Session()
|
| 857 |
+
self.session.trust_env = False
|
| 858 |
+
self.session.headers.update({"User-Agent": conf["user_agent"], "Accept": "application/json", "Content-Type": "application/json"})
|
| 859 |
+
|
| 860 |
+
def _request(self, method: str, path: str, token: str = "", params: dict | None = None, payload: dict | None = None, expected: tuple[int, ...] = (200, 201, 204)):
|
| 861 |
+
headers = {"Authorization": f"Bearer {token}"} if token else {"X-API-Key": self.api_key}
|
| 862 |
+
resp = self.session.request(method.upper(), f"{self.api_base}{path}", headers=headers, params=params, json=payload, timeout=self.conf["request_timeout"], verify=False)
|
| 863 |
+
if resp.status_code not in expected:
|
| 864 |
+
raise RuntimeError(f"YYDSMail 请求失败: {method} {path}, HTTP {resp.status_code}, body={resp.text[:300]}")
|
| 865 |
+
if resp.status_code == 204:
|
| 866 |
+
return {}
|
| 867 |
+
data = resp.json()
|
| 868 |
+
if isinstance(data, dict) and data.get("success") is False:
|
| 869 |
+
raise RuntimeError(f"YYDSMail 请求失败: {data.get('errorCode') or data.get('error')}")
|
| 870 |
+
return data.get("data") if isinstance(data, dict) and isinstance(data.get("data"), (dict, list)) else data
|
| 871 |
+
|
| 872 |
+
@staticmethod
|
| 873 |
+
def _items(data):
|
| 874 |
+
return data if isinstance(data, list) else data.get("items") or data.get("messages") or data.get("data") or []
|
| 875 |
+
|
| 876 |
+
def create_mailbox(self, username: str | None = None) -> dict[str, Any]:
|
| 877 |
+
payload = {"localPart": username or _random_mailbox_name()}
|
| 878 |
+
if self.domain:
|
| 879 |
+
payload["domain"] = _next_domain(self.domain)
|
| 880 |
+
if self.subdomain:
|
| 881 |
+
payload["subdomain"] = self.subdomain
|
| 882 |
+
data = self._request("POST", "/accounts/wildcard" if self.wildcard else "/accounts", payload=payload)
|
| 883 |
+
address = str(data.get("address") or data.get("email") or "").strip()
|
| 884 |
+
token = str(data.get("token") or data.get("temp_token") or data.get("tempToken") or data.get("access_token") or "").strip()
|
| 885 |
+
if not address or not token:
|
| 886 |
+
raise RuntimeError("YYDSMail 缺少 address 或 token")
|
| 887 |
+
return {"provider": self.name, "provider_ref": self.provider_ref, "address": address, "token": token, "account_id": str(data.get("id") or "")}
|
| 888 |
+
|
| 889 |
+
def fetch_latest_message(self, mailbox: dict[str, Any]) -> dict[str, Any] | None:
|
| 890 |
+
data = self._request("GET", "/messages", token=str(mailbox.get("token") or ""), params={"address": mailbox["address"]})
|
| 891 |
+
messages = [item for item in self._items(data) if isinstance(item, dict)]
|
| 892 |
+
if not messages:
|
| 893 |
+
return None
|
| 894 |
+
item = max(messages, key=lambda value: ((_parse_received_at(value.get("createdAt") or value.get("created_at") or value.get("receivedAt") or value.get("date") or value.get("timestamp")) or datetime.fromtimestamp(0, tz=timezone.utc)).timestamp(), str(value.get("id") or "")))
|
| 895 |
+
message_id = str(item.get("id") or item.get("message_id") or "").strip()
|
| 896 |
+
if message_id:
|
| 897 |
+
item = self._request("GET", f"/messages/{message_id}", token=str(mailbox.get("token") or ""), params={"address": mailbox["address"]})
|
| 898 |
+
text_content, html_content = _extract_content(item)
|
| 899 |
+
sender = item.get("from") or item.get("sender") or ""
|
| 900 |
+
if isinstance(sender, dict):
|
| 901 |
+
sender = sender.get("address") or sender.get("email") or sender.get("name") or ""
|
| 902 |
+
return {"provider": self.name, "mailbox": mailbox["address"], "message_id": message_id, "subject": str(item.get("subject") or ""), "sender": str(sender), "text_content": text_content, "html_content": html_content, "received_at": _parse_received_at(item.get("createdAt") or item.get("created_at") or item.get("receivedAt") or item.get("date") or item.get("timestamp")), "raw": item}
|
| 903 |
+
|
| 904 |
+
def close(self) -> None:
|
| 905 |
+
self.session.close()
|
| 906 |
+
|
| 907 |
+
|
| 908 |
+
def _entries(mail_config: dict) -> list[dict]:
|
| 909 |
+
result: list[dict] = []
|
| 910 |
+
counters: dict[str, int] = {}
|
| 911 |
+
for item in mail_config["providers"]:
|
| 912 |
+
idx = len(result) + 1
|
| 913 |
+
t = item.get("type", "")
|
| 914 |
+
cnt = counters.get(t, 0) + 1
|
| 915 |
+
counters[t] = cnt
|
| 916 |
+
label = f"DDG-{cnt}" if t == "ddg_mail" else f"{t}#{idx}"
|
| 917 |
+
result.append({**item, "provider_ref": f"{item['type']}#{idx}", "label": label})
|
| 918 |
+
return result
|
| 919 |
+
|
| 920 |
+
|
| 921 |
+
def _enabled_entries(mail_config: dict) -> list[dict]:
|
| 922 |
+
items = [item for item in _entries(mail_config) if item.get("enable")]
|
| 923 |
+
if not items:
|
| 924 |
+
raise RuntimeError("mail.providers 没有启用的 provider")
|
| 925 |
+
return items
|
| 926 |
+
|
| 927 |
+
|
| 928 |
+
def _next_entry(mail_config: dict) -> dict:
|
| 929 |
+
global provider_index
|
| 930 |
+
items = _enabled_entries(mail_config)
|
| 931 |
+
if len(items) == 1:
|
| 932 |
+
return dict(items[0])
|
| 933 |
+
with provider_lock:
|
| 934 |
+
value = dict(items[provider_index % len(items)])
|
| 935 |
+
provider_index = (provider_index + 1) % len(items)
|
| 936 |
+
return value
|
| 937 |
+
|
| 938 |
+
|
| 939 |
+
def _create_provider(mail_config: dict, provider: str = "", provider_ref: str = "") -> BaseMailProvider:
|
| 940 |
+
entry = next((dict(item) for item in _entries(mail_config) if provider_ref and item["provider_ref"] == provider_ref), None)
|
| 941 |
+
entry = entry or next((dict(item) for item in _enabled_entries(mail_config) if provider and item["type"] == provider), None) or _next_entry(mail_config)
|
| 942 |
+
conf = _config(mail_config)
|
| 943 |
+
if entry["type"] == "cloudmail_gen":
|
| 944 |
+
return CloudMailGenProvider(entry, conf)
|
| 945 |
+
if entry["type"] == "cloudflare_temp_email":
|
| 946 |
+
return CloudflareTempMailProvider(entry, conf)
|
| 947 |
+
if entry["type"] == "ddg_mail":
|
| 948 |
+
return DDGMailProvider(entry, conf)
|
| 949 |
+
if entry["type"] == "tempmail_lol":
|
| 950 |
+
return TempMailLolProvider(entry, conf)
|
| 951 |
+
if entry["type"] == "duckmail":
|
| 952 |
+
return DuckMailProvider(entry, conf)
|
| 953 |
+
if entry["type"] == "gptmail":
|
| 954 |
+
return GptMailProvider(entry, conf)
|
| 955 |
+
if entry["type"] == "moemail":
|
| 956 |
+
return MoEmailProvider(entry, conf)
|
| 957 |
+
if entry["type"] == "inbucket":
|
| 958 |
+
return InbucketMailProvider(entry, conf)
|
| 959 |
+
if entry["type"] == "yyds_mail":
|
| 960 |
+
return YydsMailProvider(entry, conf)
|
| 961 |
+
raise RuntimeError(f"不支持的 mail.provider: {entry['type']}")
|
| 962 |
+
|
| 963 |
+
|
| 964 |
+
def create_mailbox(mail_config: dict, username: str | None = None) -> dict:
|
| 965 |
+
enabled = _enabled_entries(mail_config)
|
| 966 |
+
tried: set[str] = set()
|
| 967 |
+
last_error = ""
|
| 968 |
+
for _ in range(len(enabled)):
|
| 969 |
+
provider = _create_provider(mail_config)
|
| 970 |
+
provider_key = f"{provider.name}#{provider.provider_ref}"
|
| 971 |
+
try:
|
| 972 |
+
if provider_key in tried:
|
| 973 |
+
continue
|
| 974 |
+
tried.add(provider_key)
|
| 975 |
+
mailbox = provider.create_mailbox(username)
|
| 976 |
+
return mailbox
|
| 977 |
+
except RuntimeError as error:
|
| 978 |
+
last_error = str(error)
|
| 979 |
+
if "DDG日上限已达" not in last_error:
|
| 980 |
+
raise
|
| 981 |
+
finally:
|
| 982 |
+
provider.close()
|
| 983 |
+
raise RuntimeError(last_error or "所有启用的邮箱提供商均无法创建邮箱")
|
| 984 |
+
|
| 985 |
+
|
| 986 |
+
def wait_for_code(mail_config: dict, mailbox: dict) -> str | None:
|
| 987 |
+
provider = _create_provider(mail_config, str(mailbox.get("provider") or ""), str(mailbox.get("provider_ref") or ""))
|
| 988 |
+
try:
|
| 989 |
+
return provider.wait_for_code(mailbox)
|
| 990 |
+
finally:
|
| 991 |
+
provider.close()
|
services/register/openai_register.py
ADDED
|
@@ -0,0 +1,766 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
import hashlib
|
| 5 |
+
import json
|
| 6 |
+
import random
|
| 7 |
+
import secrets
|
| 8 |
+
import string
|
| 9 |
+
import threading
|
| 10 |
+
import time
|
| 11 |
+
import uuid
|
| 12 |
+
from datetime import datetime, timezone
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from typing import Any
|
| 15 |
+
from urllib.parse import parse_qs, urlencode, urlparse
|
| 16 |
+
|
| 17 |
+
import requests
|
| 18 |
+
import urllib3
|
| 19 |
+
from curl_cffi import requests as curl_requests
|
| 20 |
+
from requests.adapters import HTTPAdapter
|
| 21 |
+
from urllib3.util.retry import Retry
|
| 22 |
+
|
| 23 |
+
from services.account_service import account_service
|
| 24 |
+
from services.register import mail_provider
|
| 25 |
+
|
| 26 |
+
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
| 27 |
+
base_dir = Path(__file__).resolve().parent
|
| 28 |
+
config = {
|
| 29 |
+
"mail": {
|
| 30 |
+
"request_timeout": 30,
|
| 31 |
+
"wait_timeout": 30,
|
| 32 |
+
"wait_interval": 2,
|
| 33 |
+
"providers": [],
|
| 34 |
+
},
|
| 35 |
+
"proxy": "",
|
| 36 |
+
"total": 10,
|
| 37 |
+
"threads": 3,
|
| 38 |
+
}
|
| 39 |
+
register_config_file = base_dir.parents[1] / "data" / "register.json"
|
| 40 |
+
try:
|
| 41 |
+
saved_config = json.loads(register_config_file.read_text(encoding="utf-8"))
|
| 42 |
+
config.update({key: saved_config[key] for key in ("mail", "proxy", "total", "threads") if key in saved_config})
|
| 43 |
+
except Exception:
|
| 44 |
+
pass
|
| 45 |
+
|
| 46 |
+
auth_base = "https://auth.openai.com"
|
| 47 |
+
platform_base = "https://platform.openai.com"
|
| 48 |
+
platform_oauth_client_id = "app_2SKx67EdpoN0G6j64rFvigXD"
|
| 49 |
+
platform_oauth_redirect_uri = f"{platform_base}/auth/callback"
|
| 50 |
+
platform_oauth_audience = "https://api.openai.com/v1"
|
| 51 |
+
platform_auth0_client = "eyJuYW1lIjoiYXV0aDAtc3BhLWpzIiwidmVyc2lvbiI6IjEuMjEuMCJ9"
|
| 52 |
+
user_agent = (
|
| 53 |
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
| 54 |
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
| 55 |
+
"Chrome/145.0.0.0 Safari/537.36"
|
| 56 |
+
)
|
| 57 |
+
sec_ch_ua = '"Google Chrome";v="145", "Not?A_Brand";v="8", "Chromium";v="145"'
|
| 58 |
+
sec_ch_ua_full_version_list = '"Chromium";v="145.0.0.0", "Not:A-Brand";v="99.0.0.0", "Google Chrome";v="145.0.0.0"'
|
| 59 |
+
default_timeout = 30
|
| 60 |
+
print_lock = threading.Lock()
|
| 61 |
+
stats_lock = threading.Lock()
|
| 62 |
+
stats = {"done": 0, "success": 0, "fail": 0, "start_time": 0.0}
|
| 63 |
+
register_log_sink = None
|
| 64 |
+
|
| 65 |
+
common_headers = {
|
| 66 |
+
"accept": "application/json",
|
| 67 |
+
"accept-language": "en-US,en;q=0.9",
|
| 68 |
+
"content-type": "application/json",
|
| 69 |
+
"origin": auth_base,
|
| 70 |
+
"priority": "u=1, i",
|
| 71 |
+
"user-agent": user_agent,
|
| 72 |
+
"sec-ch-ua": sec_ch_ua,
|
| 73 |
+
"sec-ch-ua-arch": '"x86_64"',
|
| 74 |
+
"sec-ch-ua-bitness": '"64"',
|
| 75 |
+
"sec-ch-ua-full-version-list": sec_ch_ua_full_version_list,
|
| 76 |
+
"sec-ch-ua-mobile": "?0",
|
| 77 |
+
"sec-ch-ua-model": '""',
|
| 78 |
+
"sec-ch-ua-platform": '"Windows"',
|
| 79 |
+
"sec-ch-ua-platform-version": '"10.0.0"',
|
| 80 |
+
"sec-fetch-dest": "empty",
|
| 81 |
+
"sec-fetch-mode": "cors",
|
| 82 |
+
"sec-fetch-site": "same-origin",
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
navigate_headers = {
|
| 86 |
+
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
| 87 |
+
"accept-language": "en-US,en;q=0.9",
|
| 88 |
+
"user-agent": user_agent,
|
| 89 |
+
"sec-ch-ua": sec_ch_ua,
|
| 90 |
+
"sec-ch-ua-arch": '"x86_64"',
|
| 91 |
+
"sec-ch-ua-bitness": '"64"',
|
| 92 |
+
"sec-ch-ua-full-version-list": sec_ch_ua_full_version_list,
|
| 93 |
+
"sec-ch-ua-mobile": "?0",
|
| 94 |
+
"sec-ch-ua-model": '""',
|
| 95 |
+
"sec-ch-ua-platform": '"Windows"',
|
| 96 |
+
"sec-ch-ua-platform-version": '"10.0.0"',
|
| 97 |
+
"sec-fetch-dest": "document",
|
| 98 |
+
"sec-fetch-mode": "navigate",
|
| 99 |
+
"sec-fetch-site": "same-origin",
|
| 100 |
+
"sec-fetch-user": "?1",
|
| 101 |
+
"upgrade-insecure-requests": "1",
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def log(text: str, color: str = "") -> None:
|
| 106 |
+
colors = {"red": "\033[31m", "green": "\033[32m", "yellow": "\033[33m"}
|
| 107 |
+
if register_log_sink:
|
| 108 |
+
try:
|
| 109 |
+
register_log_sink(text, color)
|
| 110 |
+
except Exception:
|
| 111 |
+
pass
|
| 112 |
+
with print_lock:
|
| 113 |
+
prefix = colors.get(color, "")
|
| 114 |
+
suffix = "\033[0m" if prefix else ""
|
| 115 |
+
print(f"{prefix}{datetime.now().strftime('%H:%M:%S')} {text}{suffix}")
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def step(index: int, text: str, color: str = "") -> None:
|
| 119 |
+
log(f"[任务{index}] {text}", color)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def _make_trace_headers() -> dict[str, str]:
|
| 123 |
+
trace_id = str(random.getrandbits(64))
|
| 124 |
+
parent_id = str(random.getrandbits(64))
|
| 125 |
+
return {
|
| 126 |
+
"traceparent": f"00-{uuid.uuid4().hex}-{format(int(parent_id), '016x')}-01",
|
| 127 |
+
"tracestate": "dd=s:1;o:rum",
|
| 128 |
+
"x-datadog-origin": "rum",
|
| 129 |
+
"x-datadog-parent-id": parent_id,
|
| 130 |
+
"x-datadog-sampling-priority": "1",
|
| 131 |
+
"x-datadog-trace-id": trace_id,
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def _generate_pkce() -> tuple[str, str]:
|
| 136 |
+
code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(64)).rstrip(b"=").decode("ascii")
|
| 137 |
+
code_challenge = base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode("ascii")).digest()).rstrip(b"=").decode("ascii")
|
| 138 |
+
return code_verifier, code_challenge
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def _random_password(length: int = 16) -> str:
|
| 142 |
+
chars = string.ascii_letters + string.digits + "!@#$%"
|
| 143 |
+
value = list(
|
| 144 |
+
secrets.choice(string.ascii_uppercase)
|
| 145 |
+
+ secrets.choice(string.ascii_lowercase)
|
| 146 |
+
+ secrets.choice(string.digits)
|
| 147 |
+
+ secrets.choice("!@#$%")
|
| 148 |
+
+ "".join(secrets.choice(chars) for _ in range(max(0, length - 4)))
|
| 149 |
+
)
|
| 150 |
+
random.shuffle(value)
|
| 151 |
+
return "".join(value)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def _random_name() -> tuple[str, str]:
|
| 155 |
+
return random.choice(["James", "Robert", "John", "Michael", "David", "Mary", "Emma", "Olivia"]), random.choice(
|
| 156 |
+
["Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller"]
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def _random_birthdate() -> str:
|
| 161 |
+
return f"{random.randint(1996, 2006):04d}-{random.randint(1, 12):02d}-{random.randint(1, 28):02d}"
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def _response_json(resp) -> dict:
|
| 165 |
+
try:
|
| 166 |
+
data = resp.json()
|
| 167 |
+
return data if isinstance(data, dict) else {}
|
| 168 |
+
except Exception:
|
| 169 |
+
return {}
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def _decode_jwt_payload(token: str) -> dict:
|
| 173 |
+
try:
|
| 174 |
+
payload = token.split(".")[1]
|
| 175 |
+
padding = 4 - len(payload) % 4
|
| 176 |
+
if padding != 4:
|
| 177 |
+
payload += "=" * padding
|
| 178 |
+
return json.loads(base64.urlsafe_b64decode(payload))
|
| 179 |
+
except Exception:
|
| 180 |
+
return {}
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def create_mailbox(username: str | None = None) -> dict:
|
| 184 |
+
return mail_provider.create_mailbox(config["mail"], username)
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def wait_for_code(mailbox: dict) -> str | None:
|
| 188 |
+
return mail_provider.wait_for_code(config["mail"], mailbox)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
class SentinelTokenGenerator:
|
| 192 |
+
MAX_ATTEMPTS = 500000
|
| 193 |
+
ERROR_PREFIX = "wQ8Lk5FbGpA2NcR9dShT6gYjU7VxZ4D"
|
| 194 |
+
|
| 195 |
+
def __init__(self, device_id: str, ua: str):
|
| 196 |
+
self.device_id = device_id
|
| 197 |
+
self.user_agent = ua
|
| 198 |
+
self.sid = str(uuid.uuid4())
|
| 199 |
+
|
| 200 |
+
@staticmethod
|
| 201 |
+
def _fnv1a_32(text: str) -> str:
|
| 202 |
+
h = 2166136261
|
| 203 |
+
for ch in text:
|
| 204 |
+
h ^= ord(ch)
|
| 205 |
+
h = (h * 16777619) & 0xFFFFFFFF
|
| 206 |
+
h ^= h >> 16
|
| 207 |
+
h = (h * 2246822507) & 0xFFFFFFFF
|
| 208 |
+
h ^= h >> 13
|
| 209 |
+
h = (h * 3266489909) & 0xFFFFFFFF
|
| 210 |
+
h ^= h >> 16
|
| 211 |
+
return format(h & 0xFFFFFFFF, "08x")
|
| 212 |
+
|
| 213 |
+
def _get_config(self) -> list:
|
| 214 |
+
perf_now = random.uniform(1000, 50000)
|
| 215 |
+
return [
|
| 216 |
+
"1920x1080",
|
| 217 |
+
time.strftime("%a %b %d %Y %H:%M:%S GMT+0000 (Coordinated Universal Time)", time.gmtime()),
|
| 218 |
+
4294705152,
|
| 219 |
+
random.random(),
|
| 220 |
+
self.user_agent,
|
| 221 |
+
"https://sentinel.openai.com/sentinel/20260124ceb8/sdk.js",
|
| 222 |
+
None,
|
| 223 |
+
None,
|
| 224 |
+
"en-US",
|
| 225 |
+
random.random(),
|
| 226 |
+
random.choice(["vendorSub-undefined", "plugins-undefined", "mimeTypes-undefined", "hardwareConcurrency-undefined"]),
|
| 227 |
+
random.choice(["location", "implementation", "URL", "documentURI", "compatMode"]),
|
| 228 |
+
random.choice(["Object", "Function", "Array", "Number", "parseFloat", "undefined"]),
|
| 229 |
+
perf_now,
|
| 230 |
+
self.sid,
|
| 231 |
+
"",
|
| 232 |
+
random.choice([4, 8, 12, 16]),
|
| 233 |
+
time.time() * 1000 - perf_now,
|
| 234 |
+
]
|
| 235 |
+
|
| 236 |
+
@staticmethod
|
| 237 |
+
def _b64(data) -> str:
|
| 238 |
+
return base64.b64encode(json.dumps(data, separators=(",", ":"), ensure_ascii=False).encode("utf-8")).decode("ascii")
|
| 239 |
+
|
| 240 |
+
def generate_requirements_token(self) -> str:
|
| 241 |
+
data = self._get_config()
|
| 242 |
+
data[3] = 1
|
| 243 |
+
data[9] = round(random.uniform(5, 50))
|
| 244 |
+
return "gAAAAAC" + self._b64(data)
|
| 245 |
+
|
| 246 |
+
def generate_token(self, seed: str, difficulty: str) -> str:
|
| 247 |
+
start = time.time()
|
| 248 |
+
data = self._get_config()
|
| 249 |
+
difficulty = str(difficulty or "0")
|
| 250 |
+
for i in range(self.MAX_ATTEMPTS):
|
| 251 |
+
data[3] = i
|
| 252 |
+
data[9] = round((time.time() - start) * 1000)
|
| 253 |
+
payload = self._b64(data)
|
| 254 |
+
if self._fnv1a_32(seed + payload)[: len(difficulty)] <= difficulty:
|
| 255 |
+
return "gAAAAAB" + payload + "~S"
|
| 256 |
+
return "gAAAAAB" + self.ERROR_PREFIX + self._b64(str(None))
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def build_sentinel_token(session: requests.Session, device_id: str, flow: str) -> str:
|
| 260 |
+
generator = SentinelTokenGenerator(device_id, user_agent)
|
| 261 |
+
resp = session.post(
|
| 262 |
+
"https://sentinel.openai.com/backend-api/sentinel/req",
|
| 263 |
+
data=json.dumps({"p": generator.generate_requirements_token(), "id": device_id, "flow": flow}),
|
| 264 |
+
headers={
|
| 265 |
+
"Content-Type": "text/plain;charset=UTF-8",
|
| 266 |
+
"Referer": "https://sentinel.openai.com/backend-api/sentinel/frame.html",
|
| 267 |
+
"Origin": "https://sentinel.openai.com",
|
| 268 |
+
"User-Agent": user_agent,
|
| 269 |
+
"sec-ch-ua": sec_ch_ua,
|
| 270 |
+
"sec-ch-ua-mobile": "?0",
|
| 271 |
+
"sec-ch-ua-platform": '"Windows"',
|
| 272 |
+
},
|
| 273 |
+
timeout=20,
|
| 274 |
+
verify=False,
|
| 275 |
+
)
|
| 276 |
+
data = _response_json(resp)
|
| 277 |
+
token = str(data.get("token") or "").strip()
|
| 278 |
+
if resp.status_code != 200 or not token:
|
| 279 |
+
raise RuntimeError(f"sentinel_req_failed_{resp.status_code}")
|
| 280 |
+
pow_data = data.get("proofofwork") or {}
|
| 281 |
+
p_value = (
|
| 282 |
+
generator.generate_token(str(pow_data.get("seed") or ""), str(pow_data.get("difficulty") or "0"))
|
| 283 |
+
if pow_data.get("required") and pow_data.get("seed")
|
| 284 |
+
else generator.generate_requirements_token()
|
| 285 |
+
)
|
| 286 |
+
return json.dumps({"p": p_value, "t": "", "c": token, "id": device_id, "flow": flow}, separators=(",", ":"))
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def _is_socks_proxy(proxy: str) -> bool:
|
| 290 |
+
candidate = str(proxy or "").strip().lower()
|
| 291 |
+
return candidate.startswith("socks5://") or candidate.startswith("socks5h://")
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def create_session(proxy: str = "") -> Any:
|
| 295 |
+
if _is_socks_proxy(proxy):
|
| 296 |
+
return curl_requests.Session(impersonate="chrome", verify=False, proxy=proxy)
|
| 297 |
+
session = requests.Session()
|
| 298 |
+
retry = Retry(total=2, connect=2, read=2, backoff_factor=0.5, status_forcelist=(429, 500, 502, 503, 504))
|
| 299 |
+
adapter = HTTPAdapter(max_retries=retry, pool_connections=50, pool_maxsize=50)
|
| 300 |
+
session.mount("http://", adapter)
|
| 301 |
+
session.mount("https://", adapter)
|
| 302 |
+
session.verify = False
|
| 303 |
+
if proxy:
|
| 304 |
+
session.proxies.update({"http": proxy, "https": proxy})
|
| 305 |
+
return session
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
def request_with_local_retry(session: requests.Session, method: str, url: str, retry_attempts: int = 3, **kwargs):
|
| 309 |
+
last_error = ""
|
| 310 |
+
for _ in range(max(1, retry_attempts)):
|
| 311 |
+
try:
|
| 312 |
+
return session.request(method.upper(), url, timeout=default_timeout, **kwargs), ""
|
| 313 |
+
except Exception as error:
|
| 314 |
+
last_error = str(error)
|
| 315 |
+
time.sleep(1)
|
| 316 |
+
return None, last_error
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
def validate_otp(session: requests.Session, device_id: str, code: str):
|
| 320 |
+
headers = dict(common_headers)
|
| 321 |
+
headers["referer"] = f"{auth_base}/email-verification"
|
| 322 |
+
headers["oai-device-id"] = device_id
|
| 323 |
+
headers.update(_make_trace_headers())
|
| 324 |
+
resp, error = request_with_local_retry(session, "post", f"{auth_base}/api/accounts/email-otp/validate", json={"code": code}, headers=headers, verify=False)
|
| 325 |
+
if resp is not None and resp.status_code == 200:
|
| 326 |
+
return resp, ""
|
| 327 |
+
headers["openai-sentinel-token"] = build_sentinel_token(session, device_id, "authorize_continue")
|
| 328 |
+
resp, error = request_with_local_retry(session, "post", f"{auth_base}/api/accounts/email-otp/validate", json={"code": code}, headers=headers, verify=False)
|
| 329 |
+
return resp, error
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
def extract_oauth_callback_params_from_url(url: str) -> dict[str, str] | None:
|
| 333 |
+
if not url:
|
| 334 |
+
return None
|
| 335 |
+
try:
|
| 336 |
+
params = parse_qs(urlparse(url).query)
|
| 337 |
+
except Exception:
|
| 338 |
+
return None
|
| 339 |
+
code = str((params.get("code") or [""])[0]).strip()
|
| 340 |
+
if not code:
|
| 341 |
+
return None
|
| 342 |
+
return {"code": code, "state": str((params.get("state") or [""])[0]).strip(), "scope": str((params.get("scope") or [""])[0]).strip()}
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
def extract_oauth_callback_params_from_consent_session(session: requests.Session, consent_url: str, device_id: str) -> dict[str, str] | None:
|
| 346 |
+
if consent_url.startswith("/"):
|
| 347 |
+
consent_url = f"{auth_base}{consent_url}"
|
| 348 |
+
current_url = consent_url
|
| 349 |
+
for _ in range(10):
|
| 350 |
+
response = session.get(current_url, headers=navigate_headers, verify=False, timeout=30, allow_redirects=False)
|
| 351 |
+
callback_params = extract_oauth_callback_params_from_url(str(response.url)) or extract_oauth_callback_params_from_url(str(response.headers.get("Location") or "").strip())
|
| 352 |
+
if callback_params:
|
| 353 |
+
return callback_params
|
| 354 |
+
location = str(response.headers.get("Location") or "").strip()
|
| 355 |
+
if response.status_code not in (301, 302, 303, 307, 308) or not location:
|
| 356 |
+
break
|
| 357 |
+
current_url = f"{auth_base}{location}" if location.startswith("/") else location
|
| 358 |
+
raw = session.cookies.get("oai-client-auth-session", domain=".auth.openai.com") or session.cookies.get("oai-client-auth-session")
|
| 359 |
+
if not raw:
|
| 360 |
+
return None
|
| 361 |
+
try:
|
| 362 |
+
first_part = raw.split(".")[0]
|
| 363 |
+
padding = 4 - len(first_part) % 4
|
| 364 |
+
if padding != 4:
|
| 365 |
+
first_part += "=" * padding
|
| 366 |
+
payload = json.loads(base64.urlsafe_b64decode(first_part))
|
| 367 |
+
workspace_id = payload["workspaces"][0]["id"]
|
| 368 |
+
except Exception:
|
| 369 |
+
return None
|
| 370 |
+
headers = dict(common_headers)
|
| 371 |
+
headers["referer"] = consent_url
|
| 372 |
+
headers["oai-device-id"] = device_id
|
| 373 |
+
headers.update(_make_trace_headers())
|
| 374 |
+
ws_resp = session.post(f"{auth_base}/api/accounts/workspace/select", json={"workspace_id": workspace_id}, headers=headers, verify=False, timeout=30, allow_redirects=False)
|
| 375 |
+
callback_params = extract_oauth_callback_params_from_url(str(ws_resp.headers.get("Location") or "").strip())
|
| 376 |
+
if callback_params:
|
| 377 |
+
return callback_params
|
| 378 |
+
ws_data = _response_json(ws_resp)
|
| 379 |
+
orgs = ((ws_data.get("data") or {}).get("orgs") or []) if isinstance(ws_data, dict) else []
|
| 380 |
+
if not orgs:
|
| 381 |
+
return None
|
| 382 |
+
org_id = str((orgs[0] or {}).get("id") or "").strip()
|
| 383 |
+
project_id = str(((orgs[0] or {}).get("projects") or [{}])[0].get("id") or "").strip()
|
| 384 |
+
if not org_id:
|
| 385 |
+
return None
|
| 386 |
+
org_headers = dict(common_headers)
|
| 387 |
+
org_headers["referer"] = str(ws_data.get("continue_url") or consent_url)
|
| 388 |
+
org_headers["oai-device-id"] = device_id
|
| 389 |
+
org_headers.update(_make_trace_headers())
|
| 390 |
+
body = {"org_id": org_id}
|
| 391 |
+
if project_id:
|
| 392 |
+
body["project_id"] = project_id
|
| 393 |
+
org_resp = session.post(f"{auth_base}/api/accounts/organization/select", json=body, headers=org_headers, verify=False, timeout=30, allow_redirects=False)
|
| 394 |
+
return extract_oauth_callback_params_from_url(str(org_resp.headers.get("Location") or "").strip())
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
def exchange_platform_tokens(session: requests.Session, device_id: str, code_verifier: str, consent_url: str) -> dict | None:
|
| 398 |
+
callback_params = extract_oauth_callback_params_from_consent_session(session, consent_url, device_id)
|
| 399 |
+
if not callback_params:
|
| 400 |
+
# 回退方案:直接导航 consent URL(allow_redirects=True),从最终 URL 提取 code
|
| 401 |
+
print(f"[exchange_platform_tokens] 主方案失败,尝试回退方案, continue_url={consent_url[:120]}")
|
| 402 |
+
try:
|
| 403 |
+
r = session.get(consent_url, headers=navigate_headers, allow_redirects=True, verify=False, timeout=30)
|
| 404 |
+
final_url = str(r.url)
|
| 405 |
+
print(f"[exchange_platform_tokens] 回退 final_url={final_url[:120]}")
|
| 406 |
+
callback_params = extract_oauth_callback_params_from_url(final_url)
|
| 407 |
+
if not callback_params:
|
| 408 |
+
for hist in getattr(r, "history", []) or []:
|
| 409 |
+
loc = str(hist.headers.get("Location") or "")
|
| 410 |
+
callback_params = extract_oauth_callback_params_from_url(loc)
|
| 411 |
+
if callback_params:
|
| 412 |
+
break
|
| 413 |
+
except Exception as e:
|
| 414 |
+
print(f"[exchange_platform_tokens] 回退方案异常: {e}")
|
| 415 |
+
if not callback_params:
|
| 416 |
+
print("[exchange_platform_tokens] 所有方案均无法提取 OAuth code")
|
| 417 |
+
return None
|
| 418 |
+
code = str(callback_params.get("code") or "").strip()
|
| 419 |
+
if not code:
|
| 420 |
+
return None
|
| 421 |
+
resp = create_session(config["proxy"]).post(
|
| 422 |
+
f"{auth_base}/oauth/token",
|
| 423 |
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
| 424 |
+
data={
|
| 425 |
+
"grant_type": "authorization_code",
|
| 426 |
+
"code": code,
|
| 427 |
+
"redirect_uri": platform_oauth_redirect_uri,
|
| 428 |
+
"client_id": platform_oauth_client_id,
|
| 429 |
+
"code_verifier": code_verifier,
|
| 430 |
+
},
|
| 431 |
+
verify=False,
|
| 432 |
+
timeout=60,
|
| 433 |
+
)
|
| 434 |
+
data = _response_json(resp)
|
| 435 |
+
if resp.status_code != 200 or not data.get("access_token") or not data.get("refresh_token") or not data.get("id_token"):
|
| 436 |
+
return None
|
| 437 |
+
payload = _decode_jwt_payload(str(data.get("id_token") or "")) or _decode_jwt_payload(str(data.get("access_token") or ""))
|
| 438 |
+
return {
|
| 439 |
+
"email": str(payload.get("email") or "").strip(),
|
| 440 |
+
"access_token": str(data.get("access_token") or "").strip(),
|
| 441 |
+
"refresh_token": str(data.get("refresh_token") or "").strip(),
|
| 442 |
+
"id_token": str(data.get("id_token") or "").strip(),
|
| 443 |
+
}
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
class PlatformRegistrar:
|
| 447 |
+
def __init__(self, proxy: str = "") -> None:
|
| 448 |
+
self.session = create_session(proxy)
|
| 449 |
+
self.device_id = str(uuid.uuid4())
|
| 450 |
+
|
| 451 |
+
def close(self) -> None:
|
| 452 |
+
self.session.close()
|
| 453 |
+
|
| 454 |
+
def _navigate_headers(self, referer: str = "") -> dict[str, str]:
|
| 455 |
+
headers = dict(navigate_headers)
|
| 456 |
+
if referer:
|
| 457 |
+
headers["referer"] = referer
|
| 458 |
+
return headers
|
| 459 |
+
|
| 460 |
+
def _json_headers(self, referer: str) -> dict[str, str]:
|
| 461 |
+
headers = dict(common_headers)
|
| 462 |
+
headers["referer"] = referer
|
| 463 |
+
headers["oai-device-id"] = self.device_id
|
| 464 |
+
headers.update(_make_trace_headers())
|
| 465 |
+
return headers
|
| 466 |
+
|
| 467 |
+
def _platform_authorize(self, email: str, index: int) -> None:
|
| 468 |
+
step(index, "开始 platform authorize")
|
| 469 |
+
self.session.cookies.set("oai-did", self.device_id, domain=".auth.openai.com")
|
| 470 |
+
self.session.cookies.set("oai-did", self.device_id, domain="auth.openai.com")
|
| 471 |
+
_, code_challenge = _generate_pkce()
|
| 472 |
+
params = {
|
| 473 |
+
"issuer": auth_base,
|
| 474 |
+
"client_id": platform_oauth_client_id,
|
| 475 |
+
"audience": platform_oauth_audience,
|
| 476 |
+
"redirect_uri": platform_oauth_redirect_uri,
|
| 477 |
+
"device_id": self.device_id,
|
| 478 |
+
"screen_hint": "login_or_signup",
|
| 479 |
+
"max_age": "0",
|
| 480 |
+
"login_hint": email,
|
| 481 |
+
"scope": "openid profile email offline_access",
|
| 482 |
+
"response_type": "code",
|
| 483 |
+
"response_mode": "query",
|
| 484 |
+
"state": secrets.token_urlsafe(32),
|
| 485 |
+
"nonce": secrets.token_urlsafe(32),
|
| 486 |
+
"code_challenge": code_challenge,
|
| 487 |
+
"code_challenge_method": "S256",
|
| 488 |
+
"auth0Client": platform_auth0_client,
|
| 489 |
+
}
|
| 490 |
+
resp, error = request_with_local_retry(self.session, "get", f"{auth_base}/api/accounts/authorize?{urlencode(params)}", headers=self._navigate_headers(f"{platform_base}/"), allow_redirects=True, verify=False)
|
| 491 |
+
if resp is None or resp.status_code != 200:
|
| 492 |
+
err = _response_json(resp).get("error", {}) if resp is not None else {}
|
| 493 |
+
detail = f": {err.get('code', '')} - {err.get('message', '')}".strip(" -") if err else ""
|
| 494 |
+
raise RuntimeError(error or f"platform_authorize_http_{getattr(resp, 'status_code', 'unknown')}{detail}")
|
| 495 |
+
step(index, "platform authorize 完成")
|
| 496 |
+
|
| 497 |
+
def _register_user(self, email: str, password: str, index: int) -> None:
|
| 498 |
+
step(index, "开始提交注册密码")
|
| 499 |
+
headers = self._json_headers(f"{auth_base}/create-account/password")
|
| 500 |
+
headers["openai-sentinel-token"] = build_sentinel_token(self.session, self.device_id, "username_password_create")
|
| 501 |
+
resp, error = request_with_local_retry(self.session, "post", f"{auth_base}/api/accounts/user/register", json={"username": email, "password": password}, headers=headers, verify=False)
|
| 502 |
+
if resp is None or resp.status_code != 200:
|
| 503 |
+
data = _response_json(resp) if resp is not None else {}
|
| 504 |
+
if data.get("message") == "Failed to create account. Please try again.":
|
| 505 |
+
step(index, "注册失败提示: 邮箱域名很可能因滥用被封禁,请更换邮箱域名", "yellow")
|
| 506 |
+
detail = f", detail={json.dumps(data, ensure_ascii=False)}" if data else ""
|
| 507 |
+
raise RuntimeError(error or f"user_register_http_{getattr(resp, 'status_code', 'unknown')}{detail}")
|
| 508 |
+
step(index, "提交注册密码完成")
|
| 509 |
+
|
| 510 |
+
def _send_otp(self, index: int) -> None:
|
| 511 |
+
step(index, "开始发送验证码")
|
| 512 |
+
resp, error = request_with_local_retry(self.session, "get", f"{auth_base}/api/accounts/email-otp/send", headers=self._navigate_headers(f"{auth_base}/create-account/password"), allow_redirects=True, verify=False)
|
| 513 |
+
if resp is None or resp.status_code not in (200, 302):
|
| 514 |
+
raise RuntimeError(error or f"send_otp_http_{getattr(resp, 'status_code', 'unknown')}")
|
| 515 |
+
step(index, "发送验证码完成")
|
| 516 |
+
|
| 517 |
+
def _validate_otp(self, code: str, index: int) -> None:
|
| 518 |
+
step(index, f"开始校验验证码 {code}")
|
| 519 |
+
resp, error = validate_otp(self.session, self.device_id, code)
|
| 520 |
+
if resp is None or resp.status_code != 200:
|
| 521 |
+
body = ""
|
| 522 |
+
try:
|
| 523 |
+
body = (resp.text or "")[:500] if resp is not None else ""
|
| 524 |
+
except Exception:
|
| 525 |
+
pass
|
| 526 |
+
raise RuntimeError(error or f"validate_otp_http_{getattr(resp, 'status_code', 'unknown')}_body={body}")
|
| 527 |
+
step(index, "验证码校验完成")
|
| 528 |
+
|
| 529 |
+
def _create_account(self, name: str, birthdate: str, index: int) -> None:
|
| 530 |
+
step(index, "开始创建账号资料")
|
| 531 |
+
headers = self._json_headers(f"{auth_base}/about-you")
|
| 532 |
+
headers["openai-sentinel-token"] = build_sentinel_token(self.session, self.device_id, "oauth_create_account")
|
| 533 |
+
resp, error = request_with_local_retry(self.session, "post", f"{auth_base}/api/accounts/create_account", json={"name": name, "birthdate": birthdate}, headers=headers, verify=False)
|
| 534 |
+
if resp is None or resp.status_code not in (200, 302):
|
| 535 |
+
data = _response_json(resp) if resp is not None else {}
|
| 536 |
+
if data.get("message") == "Failed to create account. Please try again.":
|
| 537 |
+
step(index, "创建账号失败提示: 邮箱域名很可能因滥用被封禁,请更换邮箱域名", "yellow")
|
| 538 |
+
detail = f", detail={json.dumps(data, ensure_ascii=False)}" if data else ""
|
| 539 |
+
raise RuntimeError(error or f"create_account_http_{getattr(resp, 'status_code', 'unknown')}{detail}")
|
| 540 |
+
step(index, "创建账号资料完成")
|
| 541 |
+
|
| 542 |
+
def _login_and_exchange_tokens(self, email: str, password: str, mailbox: dict, index: int) -> dict:
|
| 543 |
+
step(index, "开始独立登录换 token")
|
| 544 |
+
login_session = create_session(config["proxy"])
|
| 545 |
+
login_device_id = str(uuid.uuid4())
|
| 546 |
+
login_session.cookies.set("oai-did", login_device_id, domain=".auth.openai.com")
|
| 547 |
+
login_session.cookies.set("oai-did", login_device_id, domain="auth.openai.com")
|
| 548 |
+
code_verifier, code_challenge = _generate_pkce()
|
| 549 |
+
params = {
|
| 550 |
+
"issuer": auth_base,
|
| 551 |
+
"client_id": platform_oauth_client_id,
|
| 552 |
+
"audience": platform_oauth_audience,
|
| 553 |
+
"redirect_uri": platform_oauth_redirect_uri,
|
| 554 |
+
"device_id": login_device_id,
|
| 555 |
+
"screen_hint": "login_or_signup",
|
| 556 |
+
"max_age": "0",
|
| 557 |
+
"login_hint": email,
|
| 558 |
+
"scope": "openid profile email offline_access",
|
| 559 |
+
"response_type": "code",
|
| 560 |
+
"response_mode": "query",
|
| 561 |
+
"state": secrets.token_urlsafe(32),
|
| 562 |
+
"nonce": secrets.token_urlsafe(32),
|
| 563 |
+
"code_challenge": code_challenge,
|
| 564 |
+
"code_challenge_method": "S256",
|
| 565 |
+
"auth0Client": platform_auth0_client,
|
| 566 |
+
}
|
| 567 |
+
|
| 568 |
+
def _login_nav_headers(referer: str = "") -> dict[str, str]:
|
| 569 |
+
h = dict(navigate_headers)
|
| 570 |
+
if referer:
|
| 571 |
+
h["referer"] = referer
|
| 572 |
+
return h
|
| 573 |
+
|
| 574 |
+
def _login_json_headers(referer: str) -> dict[str, str]:
|
| 575 |
+
h = dict(common_headers)
|
| 576 |
+
h["referer"] = referer
|
| 577 |
+
h["oai-device-id"] = login_device_id
|
| 578 |
+
h.update(_make_trace_headers())
|
| 579 |
+
return h
|
| 580 |
+
|
| 581 |
+
def _clear_login_auth_cookies() -> None:
|
| 582 |
+
for cookie in list(login_session.cookies):
|
| 583 |
+
if "auth.openai.com" in str(cookie.domain):
|
| 584 |
+
login_session.cookies.clear(domain=cookie.domain, path=cookie.path, name=cookie.name)
|
| 585 |
+
login_session.cookies.set("oai-did", login_device_id, domain=".auth.openai.com")
|
| 586 |
+
login_session.cookies.set("oai-did", login_device_id, domain="auth.openai.com")
|
| 587 |
+
|
| 588 |
+
def _do_login_authorize(label: str) -> None:
|
| 589 |
+
nonlocal resp, error
|
| 590 |
+
resp, error = request_with_local_retry(
|
| 591 |
+
login_session, "get",
|
| 592 |
+
f"{auth_base}/api/accounts/authorize?{urlencode(params)}",
|
| 593 |
+
headers=_login_nav_headers(f"{platform_base}/"),
|
| 594 |
+
allow_redirects=True, verify=False
|
| 595 |
+
)
|
| 596 |
+
if resp is None:
|
| 597 |
+
raise RuntimeError(error or f"platform_login_authorize_{label}_failed")
|
| 598 |
+
if resp.status_code not in (200, 302):
|
| 599 |
+
raise RuntimeError(error or f"platform_login_authorize_{label}_http_{getattr(resp, 'status_code', 'unknown')}")
|
| 600 |
+
step(index, "登录 authorize 完成" if label == "initial" else f"登录 authorize 重试完成[{label}]")
|
| 601 |
+
|
| 602 |
+
# 提交邮箱(原样,不带 state)
|
| 603 |
+
def _do_authorize_continue():
|
| 604 |
+
h = _login_json_headers(f"{auth_base}/log-in?usernameKind=email")
|
| 605 |
+
h["openai-sentinel-token"] = build_sentinel_token(login_session, login_device_id, "authorize_continue")
|
| 606 |
+
return request_with_local_retry(
|
| 607 |
+
login_session, "post",
|
| 608 |
+
f"{auth_base}/api/accounts/authorize/continue",
|
| 609 |
+
json={"username": {"kind": "email", "value": email}},
|
| 610 |
+
headers=h,
|
| 611 |
+
allow_redirects=False,
|
| 612 |
+
verify=False
|
| 613 |
+
)
|
| 614 |
+
|
| 615 |
+
def _submit_email_with_reauth() -> None:
|
| 616 |
+
nonlocal resp, error
|
| 617 |
+
step(index, "开始提交邮箱")
|
| 618 |
+
for attempt in range(3):
|
| 619 |
+
if attempt:
|
| 620 |
+
step(index, f"邮箱提交 409/状态失效,清理 cookie 后重新 authorize 重试({attempt + 1}/3)", "yellow")
|
| 621 |
+
_clear_login_auth_cookies()
|
| 622 |
+
_do_login_authorize(f"email-{attempt + 1}")
|
| 623 |
+
resp, error = _do_authorize_continue()
|
| 624 |
+
if resp is not None and resp.status_code == 409:
|
| 625 |
+
continue
|
| 626 |
+
break
|
| 627 |
+
if resp is None or resp.status_code != 200:
|
| 628 |
+
data = _response_json(resp) if resp is not None else {}
|
| 629 |
+
detail = json.dumps(data, ensure_ascii=False) if data else ""
|
| 630 |
+
raise RuntimeError(
|
| 631 |
+
error or f"email_submit_http_{getattr(resp, 'status_code', 'unknown')}"
|
| 632 |
+
+ (f": {detail}" if detail else "")
|
| 633 |
+
)
|
| 634 |
+
step(index, "邮箱提交完成")
|
| 635 |
+
|
| 636 |
+
def _verify_password_once():
|
| 637 |
+
headers = _login_json_headers(f"{auth_base}/log-in/password")
|
| 638 |
+
headers["openai-sentinel-token"] = build_sentinel_token(
|
| 639 |
+
login_session, login_device_id, "password_verify"
|
| 640 |
+
)
|
| 641 |
+
return request_with_local_retry(
|
| 642 |
+
login_session, "post",
|
| 643 |
+
f"{auth_base}/api/accounts/password/verify",
|
| 644 |
+
json={"password": password},
|
| 645 |
+
headers=headers,
|
| 646 |
+
allow_redirects=False,
|
| 647 |
+
verify=False
|
| 648 |
+
)
|
| 649 |
+
|
| 650 |
+
def _verify_password_with_reauth() -> None:
|
| 651 |
+
nonlocal resp, error
|
| 652 |
+
step(index, "开始密码校验")
|
| 653 |
+
for attempt in range(3):
|
| 654 |
+
if attempt:
|
| 655 |
+
step(index, f"密码校验 HTTP 409,重新登录状态后重试({attempt + 1}/3)", "yellow")
|
| 656 |
+
_clear_login_auth_cookies()
|
| 657 |
+
_do_login_authorize(f"password-{attempt + 1}")
|
| 658 |
+
_submit_email_with_reauth()
|
| 659 |
+
resp, error = _verify_password_once()
|
| 660 |
+
if resp is not None and resp.status_code == 409:
|
| 661 |
+
continue
|
| 662 |
+
break
|
| 663 |
+
if resp is None or resp.status_code != 200:
|
| 664 |
+
body = ""
|
| 665 |
+
try:
|
| 666 |
+
body = (resp.text or "")[:500] if resp is not None else ""
|
| 667 |
+
except Exception:
|
| 668 |
+
pass
|
| 669 |
+
raise RuntimeError(error or f"password_verify_http_{getattr(resp, 'status_code', '')}_body={body}")
|
| 670 |
+
step(index, "密码校验完成")
|
| 671 |
+
|
| 672 |
+
resp = None
|
| 673 |
+
error = ""
|
| 674 |
+
_do_login_authorize("initial")
|
| 675 |
+
_submit_email_with_reauth()
|
| 676 |
+
_verify_password_with_reauth()
|
| 677 |
+
|
| 678 |
+
payload = _response_json(resp)
|
| 679 |
+
continue_url = str(payload.get("continue_url") or "").strip()
|
| 680 |
+
page_type = str(((payload.get("page") or {}).get("type")) or "")
|
| 681 |
+
|
| 682 |
+
if page_type == "email_otp_verification" or "email-verification" in continue_url or "email-otp" in continue_url:
|
| 683 |
+
step(index, "独立登录需要邮箱验证码")
|
| 684 |
+
code = wait_for_code(mailbox)
|
| 685 |
+
if not code:
|
| 686 |
+
login_session.close()
|
| 687 |
+
raise RuntimeError("独立登录等待验证码超时")
|
| 688 |
+
step(index, f"收到登录验证码: {code}")
|
| 689 |
+
resp, reason = validate_otp(login_session, login_device_id, code)
|
| 690 |
+
if resp is None or resp.status_code != 200:
|
| 691 |
+
print("独立登录验证码校验失败响应:", resp.text if resp is not None else "None")
|
| 692 |
+
data = _response_json(resp) if resp is not None else {}
|
| 693 |
+
message = str((data.get("error") or {}).get("message") or data.get("message") or "").strip()
|
| 694 |
+
login_session.close()
|
| 695 |
+
raise RuntimeError(reason or f"独立登录验证码校验失败{': ' + message if message else ''}")
|
| 696 |
+
otp_payload = _response_json(resp)
|
| 697 |
+
continue_url = str(otp_payload.get("continue_url") or continue_url).strip()
|
| 698 |
+
step(index, "独立登录验证码校验完成")
|
| 699 |
+
|
| 700 |
+
if not continue_url:
|
| 701 |
+
continue_url = f"{auth_base}/sign-in-with-chatgpt/codex/consent"
|
| 702 |
+
tokens = exchange_platform_tokens(login_session, login_device_id, code_verifier, continue_url)
|
| 703 |
+
login_session.close()
|
| 704 |
+
if not tokens:
|
| 705 |
+
raise RuntimeError("token换取失败")
|
| 706 |
+
step(index, "token 换取完成")
|
| 707 |
+
return tokens
|
| 708 |
+
|
| 709 |
+
def register(self, index: int) -> dict:
|
| 710 |
+
step(index, "开始创建邮箱")
|
| 711 |
+
mailbox = create_mailbox()
|
| 712 |
+
email = str(mailbox.get("address") or "").strip()
|
| 713 |
+
if not email:
|
| 714 |
+
raise RuntimeError("邮箱服务未返回 address")
|
| 715 |
+
label = str(mailbox.get("label") or "")
|
| 716 |
+
step(index, f"邮箱创建完成[{label}]: {email}")
|
| 717 |
+
password = _random_password()
|
| 718 |
+
first_name, last_name = _random_name()
|
| 719 |
+
self._platform_authorize(email, index)
|
| 720 |
+
self._register_user(email, password, index)
|
| 721 |
+
self._send_otp(index)
|
| 722 |
+
step(index, "开始等待注册验证码")
|
| 723 |
+
code = wait_for_code(mailbox)
|
| 724 |
+
if not code:
|
| 725 |
+
raise RuntimeError("等待注册验证码超时")
|
| 726 |
+
step(index, f"收到注册验证码: {code}")
|
| 727 |
+
self._validate_otp(code, index)
|
| 728 |
+
self._create_account(f"{first_name} {last_name}", _random_birthdate(), index)
|
| 729 |
+
tokens = self._login_and_exchange_tokens(email, password, mailbox, index)
|
| 730 |
+
return {
|
| 731 |
+
"email": email,
|
| 732 |
+
"password": password,
|
| 733 |
+
"access_token": str(tokens.get("access_token") or "").strip(),
|
| 734 |
+
"refresh_token": str(tokens.get("refresh_token") or "").strip(),
|
| 735 |
+
"id_token": str(tokens.get("id_token") or "").strip(),
|
| 736 |
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
| 737 |
+
}
|
| 738 |
+
|
| 739 |
+
|
| 740 |
+
def worker(index: int) -> dict:
|
| 741 |
+
start = time.time()
|
| 742 |
+
registrar = PlatformRegistrar(config["proxy"])
|
| 743 |
+
try:
|
| 744 |
+
step(index, "任务启动")
|
| 745 |
+
result = registrar.register(index)
|
| 746 |
+
cost = time.time() - start
|
| 747 |
+
access_token = str(result["access_token"])
|
| 748 |
+
account_service.add_account_items([result])
|
| 749 |
+
refresh_result = account_service.refresh_accounts([access_token])
|
| 750 |
+
if refresh_result.get("errors"):
|
| 751 |
+
step(index, f"账号已保存,刷新状态暂未成功,稍后可重试: {refresh_result['errors']}", "yellow")
|
| 752 |
+
with stats_lock:
|
| 753 |
+
stats["done"] += 1
|
| 754 |
+
stats["success"] += 1
|
| 755 |
+
avg = (time.time() - stats["start_time"]) / stats["success"]
|
| 756 |
+
log(f'{result["email"]} 注册成功,本次耗时{cost:.1f}s,全局平均每个号注册耗时{avg:.1f}s', "green")
|
| 757 |
+
return {"ok": True, "index": index, "result": result}
|
| 758 |
+
except Exception as e:
|
| 759 |
+
cost = time.time() - start
|
| 760 |
+
with stats_lock:
|
| 761 |
+
stats["done"] += 1
|
| 762 |
+
stats["fail"] += 1
|
| 763 |
+
log(f"任务{index} 注册失败,本次耗时{cost:.1f}s,原因: {e}", "red")
|
| 764 |
+
return {"ok": False, "index": index, "error": str(e)}
|
| 765 |
+
finally:
|
| 766 |
+
registrar.close()
|
services/register_service.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import threading
|
| 5 |
+
import time
|
| 6 |
+
import uuid
|
| 7 |
+
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
|
| 8 |
+
from datetime import datetime, timezone
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
from services.account_service import account_service
|
| 12 |
+
from services.config import DATA_DIR
|
| 13 |
+
from services.register import openai_register
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
REGISTER_FILE = DATA_DIR / "register.json"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _now() -> str:
|
| 20 |
+
return datetime.now(timezone.utc).isoformat()
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _default_config() -> dict:
|
| 24 |
+
return {**openai_register.config, "mode": "total", "target_quota": 100, "target_available": 10, "check_interval": 5, "enabled": False, "stats": {"success": 0, "fail": 0, "done": 0, "running": 0, "threads": openai_register.config["threads"], "elapsed_seconds": 0, "avg_seconds": 0, "success_rate": 0, "current_quota": 0, "current_available": 0}}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _normalize(raw: dict) -> dict:
|
| 28 |
+
cfg = _default_config()
|
| 29 |
+
cfg.update({k: v for k, v in raw.items() if k not in {"stats", "logs"}})
|
| 30 |
+
cfg["total"] = max(1, int(cfg.get("total") or 1))
|
| 31 |
+
cfg["threads"] = max(1, int(cfg.get("threads") or 1))
|
| 32 |
+
cfg["mode"] = str(cfg.get("mode") or "total").strip() if str(cfg.get("mode") or "total").strip() in {"total", "quota", "available"} else "total"
|
| 33 |
+
cfg["target_quota"] = max(1, int(cfg.get("target_quota") or 1))
|
| 34 |
+
cfg["target_available"] = max(1, int(cfg.get("target_available") or 1))
|
| 35 |
+
cfg["check_interval"] = max(1, int(cfg.get("check_interval") or 5))
|
| 36 |
+
cfg["proxy"] = str(cfg.get("proxy") or "").strip()
|
| 37 |
+
cfg["enabled"] = bool(cfg.get("enabled"))
|
| 38 |
+
stats = {**_default_config()["stats"], **(raw.get("stats") if isinstance(raw.get("stats"), dict) else {}),
|
| 39 |
+
"threads": cfg["threads"]}
|
| 40 |
+
cfg["stats"] = stats
|
| 41 |
+
return cfg
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class RegisterService:
|
| 45 |
+
def __init__(self, store_file: Path):
|
| 46 |
+
self._store_file = store_file
|
| 47 |
+
self._lock = threading.RLock()
|
| 48 |
+
self._runner: threading.Thread | None = None
|
| 49 |
+
self._logs: list[dict] = []
|
| 50 |
+
openai_register.register_log_sink = self._append_log
|
| 51 |
+
self._config = self._load()
|
| 52 |
+
if self._config["enabled"]:
|
| 53 |
+
self.start()
|
| 54 |
+
|
| 55 |
+
def _load(self) -> dict:
|
| 56 |
+
try:
|
| 57 |
+
return _normalize(json.loads(self._store_file.read_text(encoding="utf-8")))
|
| 58 |
+
except Exception:
|
| 59 |
+
return _normalize({})
|
| 60 |
+
|
| 61 |
+
def _save(self) -> None:
|
| 62 |
+
self._store_file.parent.mkdir(parents=True, exist_ok=True)
|
| 63 |
+
self._store_file.write_text(json.dumps(self._config, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
| 64 |
+
|
| 65 |
+
def get(self) -> dict:
|
| 66 |
+
with self._lock:
|
| 67 |
+
return json.loads(json.dumps({**self._config, "logs": self._logs[-300:]}, ensure_ascii=False))
|
| 68 |
+
|
| 69 |
+
def _inject_proxy_to_mail(self) -> None:
|
| 70 |
+
proxy = str(self._config.get("proxy") or "").strip()
|
| 71 |
+
if proxy and isinstance(self._config.get("mail"), dict):
|
| 72 |
+
self._config["mail"]["proxy"] = proxy
|
| 73 |
+
|
| 74 |
+
def update(self, updates: dict) -> dict:
|
| 75 |
+
with self._lock:
|
| 76 |
+
self._config = _normalize({**self._config, **updates})
|
| 77 |
+
self._inject_proxy_to_mail()
|
| 78 |
+
openai_register.config.update({k: self._config[k] for k in ("mail", "proxy", "total", "threads")})
|
| 79 |
+
self._save()
|
| 80 |
+
return self.get()
|
| 81 |
+
|
| 82 |
+
def start(self) -> dict:
|
| 83 |
+
with self._lock:
|
| 84 |
+
if self._runner and self._runner.is_alive():
|
| 85 |
+
self._config["enabled"] = True
|
| 86 |
+
self._save()
|
| 87 |
+
return self.get()
|
| 88 |
+
self._config["enabled"] = True
|
| 89 |
+
self._inject_proxy_to_mail()
|
| 90 |
+
self._logs = []
|
| 91 |
+
metrics = self._pool_metrics()
|
| 92 |
+
self._config["stats"] = {"job_id": uuid.uuid4().hex, "success": 0, "fail": 0, "done": 0, "running": 0, "threads": self._config["threads"], **metrics, "started_at": _now(), "updated_at": _now()}
|
| 93 |
+
openai_register.config.update({k: self._config[k] for k in ("mail", "proxy", "total", "threads")})
|
| 94 |
+
with openai_register.stats_lock:
|
| 95 |
+
openai_register.stats.update({"done": 0, "success": 0, "fail": 0, "start_time": time.time()})
|
| 96 |
+
self._save()
|
| 97 |
+
self._runner = threading.Thread(target=self._run, daemon=True, name="openai-register")
|
| 98 |
+
self._runner.start()
|
| 99 |
+
self._append_log(f"注册任务启动,模式={self._config['mode']},线程数={self._config['threads']}", "yellow")
|
| 100 |
+
return self.get()
|
| 101 |
+
|
| 102 |
+
def stop(self) -> dict:
|
| 103 |
+
with self._lock:
|
| 104 |
+
self._config["enabled"] = False
|
| 105 |
+
self._config["stats"]["updated_at"] = _now()
|
| 106 |
+
self._save()
|
| 107 |
+
self._append_log("已请求停止注册任务,正在等待当前运行任务结束", "yellow")
|
| 108 |
+
return self.get()
|
| 109 |
+
|
| 110 |
+
def reset(self) -> dict:
|
| 111 |
+
with self._lock:
|
| 112 |
+
self._logs = []
|
| 113 |
+
self._config["stats"] = {"success": 0, "fail": 0, "done": 0, "running": 0, "threads": self._config["threads"], "elapsed_seconds": 0, "avg_seconds": 0, "success_rate": 0, **self._pool_metrics(), "updated_at": _now()}
|
| 114 |
+
with openai_register.stats_lock:
|
| 115 |
+
openai_register.stats.update({"done": 0, "success": 0, "fail": 0, "start_time": 0.0})
|
| 116 |
+
self._save()
|
| 117 |
+
return self.get()
|
| 118 |
+
|
| 119 |
+
def _append_log(self, text: str, color: str = "") -> None:
|
| 120 |
+
with self._lock:
|
| 121 |
+
self._logs.append({"time": _now(), "text": str(text), "level": str(color or "info")})
|
| 122 |
+
self._logs = self._logs[-300:]
|
| 123 |
+
|
| 124 |
+
def _pool_metrics(self) -> dict:
|
| 125 |
+
items = account_service.list_accounts()
|
| 126 |
+
normal = [item for item in items if item.get("status") == "正常"]
|
| 127 |
+
return {
|
| 128 |
+
"current_quota": sum(int(item.get("quota") or 0) for item in normal if not item.get("image_quota_unknown")),
|
| 129 |
+
"current_available": len(normal),
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
def _target_reached(self, cfg: dict, submitted: int) -> bool:
|
| 133 |
+
mode = str(cfg.get("mode") or "total")
|
| 134 |
+
metrics = self._pool_metrics()
|
| 135 |
+
self._bump(**metrics)
|
| 136 |
+
if mode == "quota":
|
| 137 |
+
reached = metrics["current_quota"] >= int(cfg.get("target_quota") or 1)
|
| 138 |
+
self._append_log(f"检查号池:当前正常账号={metrics['current_available']},当前剩余额度={metrics['current_quota']},目标额度={cfg.get('target_quota')},{'跳过注册' if reached else '继续注册'}", "yellow")
|
| 139 |
+
return reached
|
| 140 |
+
if mode == "available":
|
| 141 |
+
reached = metrics["current_available"] >= int(cfg.get("target_available") or 1)
|
| 142 |
+
self._append_log(f"检查号池:当前正常账号={metrics['current_available']},目标账号={cfg.get('target_available')},当前剩余额度={metrics['current_quota']},{'跳过注册' if reached else '继续注册'}", "yellow")
|
| 143 |
+
return reached
|
| 144 |
+
return submitted >= int(cfg.get("total") or 1)
|
| 145 |
+
|
| 146 |
+
def _bump(self, **updates) -> None:
|
| 147 |
+
with self._lock:
|
| 148 |
+
self._config["stats"].update(updates)
|
| 149 |
+
stats = self._config["stats"]
|
| 150 |
+
started_at = str(stats.get("started_at") or "")
|
| 151 |
+
if started_at:
|
| 152 |
+
try:
|
| 153 |
+
elapsed = max(0.0, (datetime.now(timezone.utc) - datetime.fromisoformat(started_at)).total_seconds())
|
| 154 |
+
except Exception:
|
| 155 |
+
elapsed = 0.0
|
| 156 |
+
done = int(stats.get("done") or 0)
|
| 157 |
+
success = int(stats.get("success") or 0)
|
| 158 |
+
fail = int(stats.get("fail") or 0)
|
| 159 |
+
stats["elapsed_seconds"] = round(elapsed, 1)
|
| 160 |
+
stats["avg_seconds"] = round(elapsed / success, 1) if success else 0
|
| 161 |
+
stats["success_rate"] = round(success * 100 / max(1, success + fail), 1)
|
| 162 |
+
self._config["stats"]["updated_at"] = _now()
|
| 163 |
+
self._save()
|
| 164 |
+
|
| 165 |
+
def _run(self) -> None:
|
| 166 |
+
threads = int(self.get()["threads"])
|
| 167 |
+
submitted, done, success, fail = 0, 0, 0, 0
|
| 168 |
+
with ThreadPoolExecutor(max_workers=threads) as executor:
|
| 169 |
+
futures = set()
|
| 170 |
+
while True:
|
| 171 |
+
cfg = self.get()
|
| 172 |
+
while self.get()["enabled"] and not self._target_reached(cfg, submitted) and len(futures) < threads:
|
| 173 |
+
submitted += 1
|
| 174 |
+
futures.add(executor.submit(openai_register.worker, submitted))
|
| 175 |
+
self._bump(running=len(futures), done=done, success=success, fail=fail)
|
| 176 |
+
if not futures and (not self.get()["enabled"] or str(cfg.get("mode") or "total") == "total"):
|
| 177 |
+
break
|
| 178 |
+
if not futures:
|
| 179 |
+
time.sleep(max(1, int(cfg.get("check_interval") or 5)))
|
| 180 |
+
continue
|
| 181 |
+
finished, futures = wait(futures, return_when=FIRST_COMPLETED)
|
| 182 |
+
for future in finished:
|
| 183 |
+
done += 1
|
| 184 |
+
try:
|
| 185 |
+
result = future.result()
|
| 186 |
+
success += 1 if result.get("ok") else 0
|
| 187 |
+
fail += 0 if result.get("ok") else 1
|
| 188 |
+
except Exception:
|
| 189 |
+
fail += 1
|
| 190 |
+
self._bump(running=0, done=done, success=success, fail=fail, finished_at=_now())
|
| 191 |
+
with self._lock:
|
| 192 |
+
self._config["enabled"] = False
|
| 193 |
+
self._save()
|
| 194 |
+
self._append_log(f"注册任务结束,成功{success},失败{fail}", "yellow")
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
register_service = RegisterService(REGISTER_FILE)
|
services/storage/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from services.storage.factory import create_storage_backend
|
| 4 |
+
|
| 5 |
+
__all__ = ["create_storage_backend"]
|
services/storage/base.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from abc import ABC, abstractmethod
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class StorageBackend(ABC):
|
| 8 |
+
"""抽象存储后端基类"""
|
| 9 |
+
|
| 10 |
+
@abstractmethod
|
| 11 |
+
def load_accounts(self) -> list[dict[str, Any]]:
|
| 12 |
+
"""加载所有账号数据"""
|
| 13 |
+
pass
|
| 14 |
+
|
| 15 |
+
@abstractmethod
|
| 16 |
+
def save_accounts(self, accounts: list[dict[str, Any]]) -> None:
|
| 17 |
+
"""保存所有账号数据"""
|
| 18 |
+
pass
|
| 19 |
+
|
| 20 |
+
@abstractmethod
|
| 21 |
+
def load_auth_keys(self) -> list[dict[str, Any]]:
|
| 22 |
+
"""加载所有鉴权密钥数据"""
|
| 23 |
+
pass
|
| 24 |
+
|
| 25 |
+
@abstractmethod
|
| 26 |
+
def save_auth_keys(self, auth_keys: list[dict[str, Any]]) -> None:
|
| 27 |
+
"""保存所有鉴权密钥数据"""
|
| 28 |
+
pass
|
| 29 |
+
|
| 30 |
+
@abstractmethod
|
| 31 |
+
def health_check(self) -> dict[str, Any]:
|
| 32 |
+
"""健康检查,返回存储后端状态"""
|
| 33 |
+
pass
|
| 34 |
+
|
| 35 |
+
@abstractmethod
|
| 36 |
+
def get_backend_info(self) -> dict[str, Any]:
|
| 37 |
+
"""获取存储后端信息"""
|
| 38 |
+
pass
|