huanx commited on
Commit
bf53440
·
verified ·
1 Parent(s): af64474

Deploy chatgpt2api to Hugging Face Space

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .codex/skills/chatgpt2api-search/SKILL.md +27 -0
  2. .dockerignore +14 -0
  3. .env.example +48 -0
  4. .github/workflows/docker-publish.yml +61 -0
  5. .gitignore +35 -0
  6. .python-version +1 -0
  7. CHANGELOG.md +52 -0
  8. Dockerfile +56 -0
  9. LICENSE +21 -0
  10. README.md +343 -5
  11. VERSION +1 -0
  12. api/__init__.py +2 -0
  13. api/accounts.py +537 -0
  14. api/ai.py +215 -0
  15. api/app.py +63 -0
  16. api/errors.py +46 -0
  17. api/image_inputs.py +294 -0
  18. api/image_tasks.py +118 -0
  19. api/register.py +68 -0
  20. api/support.py +132 -0
  21. api/system.py +334 -0
  22. docker-compose.local.yml +34 -0
  23. docker-compose.yml +29 -0
  24. docs/feature-status.en.md +44 -0
  25. docs/review.md +9 -0
  26. docs/upstream-sse-conversation.md +271 -0
  27. main.py +9 -0
  28. pyproject.toml +27 -0
  29. scripts/migrate_storage.py +163 -0
  30. scripts/test_storage.py +129 -0
  31. scripts/verify_oauth_refresh.py +92 -0
  32. services/__init__.py +0 -0
  33. services/account_service.py +1651 -0
  34. services/auth_service.py +253 -0
  35. services/backup_service.py +673 -0
  36. services/config.py +486 -0
  37. services/content_filter.py +242 -0
  38. services/cpa_service.py +315 -0
  39. services/editable_file_task_service.py +256 -0
  40. services/image_service.py +376 -0
  41. services/image_storage_service.py +405 -0
  42. services/image_tags_service.py +76 -0
  43. services/image_task_service.py +544 -0
  44. services/log_service.py +337 -0
  45. services/oauth_login_service.py +267 -0
  46. services/openai_backend_api.py +0 -0
  47. services/protocol/__init__.py +2 -0
  48. services/protocol/anthropic_v1_messages.py +306 -0
  49. services/protocol/chat_completion_cache.py +236 -0
  50. services/protocol/conversation.py +1556 -0
.codex/skills/chatgpt2api-search/SKILL.md ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: chatgpt2api-search
3
+ description: Use when current web search is needed through this chatgpt2api server. Call the configured HTTP search endpoint with a prompt and return the answer with source URLs.
4
+ ---
5
+
6
+ # ChatGPT2API Search
7
+
8
+ Use this skill when the user asks for current web search, online lookup, recent information, or source-backed answers.
9
+
10
+ ## Endpoint
11
+
12
+ POST http://127.0.0.1:8000/v1/search
13
+
14
+ Headers:
15
+
16
+ Authorization: Bearer chatgpt2api
17
+ Content-Type: application/json
18
+
19
+ Body:
20
+
21
+ {
22
+ "prompt": "<search question>"
23
+ }
24
+
25
+ ## Response
26
+
27
+ Use `answer` as the main response. Include URLs from `sources` when available.
.dockerignore ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .git
2
+ .gitignore
3
+ .idea
4
+ .venv
5
+ __pycache__
6
+ *.pyc
7
+ *.pyo
8
+ *.pyd
9
+
10
+ web/.next
11
+ web/node_modules
12
+
13
+ assets
14
+ README.md
.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
.github/workflows/docker-publish.yml ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Publish Docker Image
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+ workflow_dispatch:
8
+
9
+ env:
10
+ IMAGE_NAME: chatgpt2api
11
+
12
+ jobs:
13
+ docker:
14
+ runs-on: ubuntu-latest
15
+ permissions:
16
+ contents: read
17
+ packages: write
18
+
19
+ steps:
20
+ - name: Checkout
21
+ uses: actions/checkout@v4
22
+
23
+ - name: Set up QEMU
24
+ uses: docker/setup-qemu-action@v3
25
+ with:
26
+ platforms: arm64
27
+
28
+ - name: Set up Docker Buildx
29
+ uses: docker/setup-buildx-action@v3
30
+
31
+ - name: Log in to GHCR
32
+ uses: docker/login-action@v3
33
+ with:
34
+ registry: ghcr.io
35
+ username: ${{ github.actor }}
36
+ password: ${{ secrets.GITHUB_TOKEN }}
37
+
38
+ - name: Extract Docker metadata
39
+ id: meta
40
+ uses: docker/metadata-action@v5
41
+ with:
42
+ images: ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}
43
+ tags: |
44
+ type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
45
+ type=ref,event=tag
46
+ type=sha
47
+ type=semver,pattern={{version}}
48
+ type=semver,pattern={{major}}.{{minor}}
49
+
50
+ - name: Build and push image
51
+ uses: docker/build-push-action@v6
52
+ with:
53
+ context: .
54
+ file: ./Dockerfile
55
+ target: app
56
+ platforms: linux/amd64,linux/arm64
57
+ push: true
58
+ tags: ${{ steps.meta.outputs.tags }}
59
+ labels: ${{ steps.meta.outputs.labels }}
60
+ cache-from: type=gha
61
+ cache-to: type=gha,mode=max
.gitignore ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/
31
+
32
+ # 调试/测试输出目录
33
+ loginwithpassword_*/
34
+ logwithemailverifycode_*/
35
+ test_batch_output/
.python-version ADDED
@@ -0,0 +1 @@
 
 
1
+ 3.13
CHANGELOG.md ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ + [新增] 账号刷新改为异步模式,支持前端轮询刷新/重新登录进度。
6
+ + [新增] 号池管理页面新增重新登录功能,支持密码登录恢复异常账号。
7
+ + [新增] 刷新后自动重新登录异常账号(可在设置页开启)。
8
+ + [新增] 图片生成支持并行模式,多张图片使用独立线程和账号同时生成。
9
+ + [新增] 图片轮询超时自动换账号重试(最多4次),连接超时同账号递增等待重试。
10
+ + [新增] 图片二次确认机制与先check再hit可配置化,关闭后可跳过等待直接返回结果。
11
+ + [新增] 图片任务进度追踪,显示当前生成步骤(上传/预热/获取token/生成中等)。
12
+ + [新增] 图片超时后续轮询功能,前端显示"继续等待"按钮。
13
+ + [新增] 设置页新增图片二次确认、超时等待时间、自动重新登录等配置项。
14
+ + [优化] 优化生图页面滚动加载性能,图片懒加载、会话切换滚动位置保存与恢复。
15
+ + [优化] 图片结果页 LazyImage 懒加载,减少首屏渲染压力。
16
+ + [修复] 图片服务响应添加 CORS 头,修复跨域访问问题。
17
+ + [修复] Checkbox 图标颜色修复,确保可见性。
18
+
19
+ ## 1.4.0 - 2026-05-31
20
+
21
+ + [新增] 新增AI生成可编辑PSD文件逆向。
22
+ + [新增] 新增AI生成可编辑PPT文件逆向。
23
+
24
+ ## 1.3.1 - 2026-05-30
25
+
26
+ + [新增] 新增ChatGPT搜索调试、Skills。
27
+
28
+ ## 1.3.0 - 2026-05-30
29
+
30
+ + [新增] 新增ChatGPT搜索接口逆向。
31
+
32
+ ## 1.2.4 - 2026-05-30
33
+
34
+ + [新增] 添加聊天补全缓存与重复请求合并。
35
+ + [新增] 新增无限画布一键跳转功能
36
+
37
+ ## 1.2.3 - 2026-05-29
38
+
39
+ + [新增] 新增账号级代理。
40
+ + [修复] 修复503异常信息、前端邮箱换行问题。
41
+
42
+ ## 1.2.2 - 2026-05-29
43
+
44
+ + [新增] 新增Codex链路生图、支持2k,4k。
45
+ + [新增] 支持RT刷新账号信息。
46
+
47
+ ## 1.2.0 - 2026-05-28
48
+
49
+ + [新增] 当前版本基线,包含 Web 面板、画图、号池管理、注册机、图片管理、日志管理和设置能力。
50
+ + [新增] 前端版本号支持点击查看版本更新弹窗,展示当前版本、最新版本和更新日志。
51
+ + [优化] 优化注册机效率,成功率大幅提高。
52
+ + [优化] 优化生图页面配置选项。
Dockerfile ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ARG BUILDPLATFORM
2
+ ARG TARGETPLATFORM
3
+ ARG TARGETARCH
4
+
5
+ FROM --platform=$BUILDPLATFORM node:22-alpine AS web-build
6
+
7
+ WORKDIR /app/web
8
+
9
+ COPY web/package.json web/bun.lock ./
10
+ RUN npm install
11
+
12
+ COPY VERSION /app/VERSION
13
+ COPY CHANGELOG.md /app/CHANGELOG.md
14
+ COPY web ./
15
+ RUN NEXT_PUBLIC_APP_VERSION="$(cat /app/VERSION)" npm run build
16
+
17
+
18
+ FROM --platform=$TARGETPLATFORM python:3.13-slim AS app
19
+
20
+ ARG TARGETPLATFORM
21
+ ARG TARGETARCH
22
+
23
+ ENV PYTHONDONTWRITEBYTECODE=1 \
24
+ PYTHONUNBUFFERED=1 \
25
+ UV_LINK_MODE=copy
26
+
27
+ WORKDIR /app
28
+
29
+ # 安装系统依赖
30
+ # - git: Git 存储后端需要
31
+ # - libpq-dev: PostgreSQL 客户端库
32
+ # - gcc: 编译 psycopg2-binary 需要
33
+ RUN apt-get update && apt-get install -y --no-install-recommends \
34
+ git \
35
+ libpq-dev \
36
+ gcc \
37
+ openssl \
38
+ && rm -rf /var/lib/apt/lists/*
39
+
40
+ RUN pip install --no-cache-dir uv
41
+
42
+ COPY pyproject.toml uv.lock ./
43
+ RUN uv sync --frozen --no-dev --no-install-project
44
+
45
+ COPY main.py ./
46
+ COPY config.json ./
47
+ COPY VERSION ./
48
+ COPY api ./api
49
+ COPY services ./services
50
+ COPY utils ./utils
51
+ COPY scripts ./scripts
52
+ COPY --from=web-build /app/web/out ./web_dist
53
+
54
+ EXPOSE 7860
55
+
56
+ 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,348 @@
1
  ---
2
- title: Chatgpt2api
3
- emoji: 😻
4
- colorFrom: red
5
- colorTo: purple
6
  sdk: docker
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: ChatGPT2API
 
 
 
3
  sdk: docker
4
+ app_port: 7860
5
  pinned: false
6
  ---
7
 
8
+ <h1 align="center">ChatGPT2API</h1>
9
+
10
+
11
+ <p align="center">ChatGPT2API 主要是对 ChatGPT 官网相关能力进行逆向整理与封装,提供面向 ChatGPT 图片生成、图片编辑、多图组图编辑场景的 OpenAI 兼容图片 API / 代理,并集成在线画图、号池管理、多种账号导入方式与 Docker 自托管部署能力。</p>
12
+
13
+ > [!WARNING]
14
+ > 免责声明:
15
+ >
16
+ > 本项目涉及对 ChatGPT 官网文本生成、图片生成与图片编辑等相关接口的逆向研究,仅供个人学习、技术研究与非商业性技术交流使用。
17
+ >
18
+ > - 严禁将本项目用于任何商业用途、盈利性使用、批量操作、自动化滥用或规模化调用。
19
+ > - 严禁将本项目用于破坏市场秩序、恶意竞争、套利倒卖、二次售卖相关服务,以及任何违反 OpenAI 服务条款或当地法律法规的行为。
20
+ > - 严禁将本项目用于生成、传播或协助生成违法、暴力、色情、未成年人相关内容,或用于诈骗、欺诈、骚扰等非法或不当用途。
21
+ > - 使用者应自行承担全部风险,包括但不限于账号被限制、临时封禁或永久封禁以及因违规使用等所导致的法律责任。
22
+ > - 使用本项目即视为你已充分理解并同意本免责声明全部内容;如因滥用、违规或违法使用造成任何后果,均由使用者自行承担。
23
+ > - 本项目基于对 ChatGPT 官网相关能力的逆向研究实现,存在账号受限、临时封禁或永久封禁的风险。请勿使用你自己的重要账号、常用账号或高价值账号进行测试。
24
+
25
+ ## 快速开始
26
+
27
+ 已发布镜像支持 `linux/amd64` 与 `linux/arm64`,在 x86 服务器和 Apple Silicon / ARM Linux 设备上都会自动拉取匹配架构的版本。
28
+
29
+ ### Docker 运行
30
+
31
+ ```bash
32
+ git clone git@github.com:basketikun/chatgpt2api.git
33
+ cd chatgpt2api
34
+ docker compose up -d
35
+ ```
36
+
37
+ 启动前请先在 `config.json` 中设置 `auth-key`,也可以在 `docker-compose.yml` 中通过 `CHATGPT2API_AUTH_KEY` 覆盖。
38
+
39
+ - Web 面板:`http://localhost:3000`
40
+ - API 地址:`http://localhost:3000/v1`
41
+ - 数据目录:`./data`
42
+
43
+ ### 本地开发
44
+
45
+ 启动后端:
46
+
47
+ ```bash
48
+ git clone git@github.com:basketikun/chatgpt2api.git
49
+ cd chatgpt2api
50
+ uv sync
51
+ uv run main.py
52
+ ```
53
+
54
+ 启动前端:
55
+
56
+ ```bash
57
+ cd chatgpt2api/web
58
+ bun install
59
+ bun run dev
60
+ ```
61
+
62
+ 后续更新新版本:
63
+
64
+ ```bash
65
+ docker pull ghcr.io/basketikun/chatgpt2api:latest
66
+ docker-compose down
67
+ docker-compose up -d
68
+
69
+ ```
70
+
71
+ ### 存储后端配置
72
+
73
+ 支持通过环境变量 `STORAGE_BACKEND` 切换存储方式:
74
+
75
+ - `json` - 本地 JSON 文件(默认)
76
+ - `sqlite` - 本地 SQLite 数据库
77
+ - `postgres` - 外部 PostgreSQL(需配置 `DATABASE_URL`)
78
+ - `git` - Git 私有仓库(需配置 `GIT_REPO_URL` 和 `GIT_TOKEN`)
79
+
80
+ 示例:使用 PostgreSQL
81
+
82
+ ```yaml
83
+ environment:
84
+ - STORAGE_BACKEND=postgres
85
+ - DATABASE_URL=postgresql://user:password@host:5432/dbname
86
+ ```
87
+
88
+ ## 功能
89
+
90
+ ### API 兼容能力
91
+
92
+ - 兼容 `POST /v1/images/generations` 图片生成接口
93
+ - 兼容 `POST /v1/images/edits` 图片编辑接口
94
+ - 兼容面向图片场景的 `POST /v1/chat/completions`
95
+ - 兼容面向图片场景的 `POST /v1/responses`
96
+ - `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`、
97
+ `gpt-5-mini`
98
+ - 支持通过 `n` 返回多张生成结果
99
+ - 支持生成可编辑 PPT 文件
100
+ - 支持生成可编辑 PSD 文件
101
+ - 支持 Codex 中的画图接口逆向,仅 `Plus` / `Team` / `Pro` 订阅可用,模型别名为 `codex-gpt-image-2`,如有需要可自行在其他场景映射回
102
+ `gpt-image-2`,用于和官网画图区分;也就意味着同一账号会同时有官网和 Codex 两份生图额度
103
+
104
+ ### 在线画图功能
105
+
106
+ - 内置在线画图工作台,支持生成、图片编辑与多图组图编辑
107
+ - 支持 `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` 模型选择
108
+ - 编辑模式支持参考图上传
109
+ - 前端支持多图生成交互
110
+ - 本地保存图片会话历史,支持回看、删除和清空
111
+ - 支持服务端缓存图片URL
112
+ - 图片生成进度追踪,超时后可继续等待
113
+ - 图片懒加载与滚动位置记忆,优化大量图片场景性能
114
+
115
+ ### 号池管理功能
116
+
117
+ - 自动刷新账号邮箱、类型、额度和恢复时间(异步进度追踪)
118
+ - 轮询可用账号执行图片生成与图片编辑
119
+ - 遇到 Token 失效类错误时自动剔除无效 Token
120
+ - 定时检查限流账号并自动刷新
121
+ - 支持密码重新登录恢复异常账号,刷新后可自动重登
122
+ - 支持网页端配置全局 HTTP / HTTPS / SOCKS5 / SOCKS5H 代理
123
+ - 支持搜索、筛选、批量刷新、导出、手动编辑和清��账号
124
+ - 支持四种导入方式:本地 CPA JSON 文件导入、远程 CPA 服务器导入、`sub2api` 服务器导入、`access_token` 导入
125
+ - 支持在设置页配置 `sub2api` 服务器,筛选并批量导入其中的 OpenAI OAuth 账号
126
+
127
+ ### 实验性 / 规划中
128
+
129
+ - `/v1/complete` 文本补全与流式输出已实现,但仍在测试,目前会出现对话重复的问题,请谨慎测试使用
130
+ - `/v1/chat/completions` 文本链路支持短 TTL 缓存、重复请求合并与相邻重复消息清理,可通过 `chat_completion_cache` 配置调整
131
+ - 详细状态说明见:[功能清单](./docs/feature-status.en.md)
132
+
133
+ ## 效果展示
134
+
135
+ <table width="100%">
136
+ <tr>
137
+ <td width="50%"><img src="https://i.ibb.co/Jj8nfwwP/image.png" alt="image" border="0"></td>
138
+ <td width="50%"><img src="https://i.ibb.co/pqf235v/image-edit.png" alt="image edit" border="0"></td>
139
+ </tr>
140
+ <tr>
141
+ <td width="50%"><img src="https://i.ibb.co/tPcqtVfd/chery-studio.png" alt="chery studio" border="0"></td>
142
+ <td width="50%"><img src="https://i.ibb.co/PsT9YHBV/account-pool.png" alt="account pool" border="0"></td>
143
+ </tr>
144
+ <tr>
145
+ <td width="50%"><img src="https://i.ibb.co/rRWLG08q/new-api.png" alt="new api" border="0"></td>
146
+ </tr>
147
+ </table>
148
+
149
+ ## API
150
+
151
+ 所有 AI 接口都需要请求头:
152
+
153
+ ```http
154
+ Authorization: Bearer <auth-key>
155
+ ```
156
+
157
+ <details>
158
+ <summary><code>GET /v1/models</code></summary>
159
+ <br>
160
+
161
+ 返回当前暴露的图片模型列表。
162
+
163
+ ```bash
164
+ curl http://localhost:8000/v1/models \
165
+ -H "Authorization: Bearer <auth-key>"
166
+ ```
167
+
168
+ <details>
169
+ <summary>说明</summary>
170
+ <br>
171
+
172
+ | 字段 | 说明 |
173
+ |:-----|:-----------------------------------------------------------------------------------------------------------|
174
+ | 返回模型 | `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` |
175
+ | 接入场景 | 可接入 Cherry Studio、New API 等上游或客户端 |
176
+
177
+ <br>
178
+ </details>
179
+ </details>
180
+
181
+ <details>
182
+ <summary><code>POST /v1/images/generations</code></summary>
183
+ <br>
184
+
185
+ OpenAI 兼容图片生成接口,用于文生图。
186
+
187
+ ```bash
188
+ curl http://localhost:8000/v1/images/generations \
189
+ -H "Content-Type: application/json" \
190
+ -H "Authorization: Bearer <auth-key>" \
191
+ -d '{
192
+ "model": "gpt-image-2",
193
+ "prompt": "一只漂浮在太空里的猫",
194
+ "n": 1,
195
+ "response_format": "b64_json"
196
+ }'
197
+ ```
198
+
199
+ <details>
200
+ <summary>字段说明</summary>
201
+ <br>
202
+
203
+ | 字段 | 说明 |
204
+ |:------------------|:---------------------------------------------------|
205
+ | `model` | 图片模型,当前可用值以 `/v1/models` 返回结果为准,推荐使用 `gpt-image-2` |
206
+ | `prompt` | 图片生成提示词 |
207
+ | `n` | 生成数量,当前后端限制为 `1-4` |
208
+ | `response_format` | 当前请求模型中包含该字段,默认值为 `b64_json` |
209
+
210
+ <br>
211
+ </details>
212
+ </details>
213
+
214
+ <details>
215
+ <summary><code>POST /v1/images/edits</code></summary>
216
+ <br>
217
+
218
+ OpenAI 兼容图片编辑接口,可上传图片文件,也可按官方 JSON 格式传入图片链接并生成编辑结果。
219
+
220
+ ```bash
221
+ curl http://localhost:8000/v1/images/edits \
222
+ -H "Authorization: Bearer <auth-key>" \
223
+ -F "model=gpt-image-2" \
224
+ -F "prompt=把这张图改成赛博朋克夜景风格" \
225
+ -F "n=1" \
226
+ -F "image=@./input.png"
227
+ ```
228
+
229
+ 也可以直接传图片 URL:
230
+
231
+ ```bash
232
+ curl http://localhost:8000/v1/images/edits \
233
+ -H "Authorization: Bearer <auth-key>" \
234
+ -H "Content-Type: application/json" \
235
+ -d '{
236
+ "model": "gpt-image-2",
237
+ "prompt": "把这张图改成赛博朋克夜景风格",
238
+ "images": [
239
+ {"image_url": "https://example.com/input.png"}
240
+ ]
241
+ }'
242
+ ```
243
+
244
+ <details>
245
+ <summary>字段说明</summary>
246
+ <br>
247
+
248
+ | 字段 | 说明 |
249
+ |:------------|:----------------------------------------------|
250
+ | `model` | 图片模型, `gpt-image-2` |
251
+ | `prompt` | 图片编辑提示词 |
252
+ | `n` | 生成数量,当前后端限制为 `1-4` |
253
+ | `image` | 需要编辑的图片文件,使用 multipart/form-data 上传 |
254
+ | `images` | JSON 图片引用数组,支持 `{"image_url": "https://..."}` |
255
+ | `image_url` | 表单模式下也可直接传图片链接,支持重复字段传多张图 |
256
+
257
+ <br>
258
+ </details>
259
+ </details>
260
+
261
+ <details>
262
+ <summary><code>POST /v1/chat/completions</code></summary>
263
+ <br>
264
+
265
+ 面向图片场景的 Chat Completions 兼容接口,不是完整通用聊天代理。
266
+
267
+ ```bash
268
+ curl http://localhost:8000/v1/chat/completions \
269
+ -H "Content-Type: application/json" \
270
+ -H "Authorization: Bearer <auth-key>" \
271
+ -d '{
272
+ "model": "gpt-image-2",
273
+ "messages": [
274
+ {
275
+ "role": "user",
276
+ "content": "生成一张雨夜东京街头的赛博朋克猫"
277
+ }
278
+ ],
279
+ "n": 1
280
+ }'
281
+ ```
282
+
283
+ <details>
284
+ <summary>字段说明</summary>
285
+ <br>
286
+
287
+ | 字段 | 说明 |
288
+ |:-----------|:------------------|
289
+ | `model` | 图片模型,默认按图片生成场景处理 |
290
+ | `messages` | 消息数组,需要是图片相关请求内容 |
291
+ | `n` | 生成数量,按当前实现解析为图片数量 |
292
+ | `stream` | 已实现,但仍在测试 |
293
+
294
+ <br>
295
+ </details>
296
+ </details>
297
+
298
+ <details>
299
+ <summary><code>POST /v1/responses</code></summary>
300
+ <br>
301
+
302
+ 面向图片生成工具调用的 Responses API 兼容接口,不是完整通用 Responses API 代理。
303
+
304
+ ```bash
305
+ curl http://localhost:8000/v1/responses \
306
+ -H "Content-Type: application/json" \
307
+ -H "Authorization: Bearer <auth-key>" \
308
+ -d '{
309
+ "model": "gpt-5",
310
+ "input": "生成一张未来感城市天际线图片",
311
+ "tools": [
312
+ {
313
+ "type": "image_generation"
314
+ }
315
+ ]
316
+ }'
317
+ ```
318
+
319
+ <details>
320
+ <summary>字段说明</summary>
321
+ <br>
322
+
323
+ | 字段 | 说明 |
324
+ |:---------|:------------------------------|
325
+ | `model` | 响应中会回显该模型字段,但图片生成当前仍走图片生成兼容逻辑 |
326
+ | `input` | 输入内容,需要能解析出图片生成提示词 |
327
+ | `tools` | 必须包含 `image_generation` 工具请求 |
328
+ | `stream` | 已实现,但仍在测试 |
329
+
330
+ <br>
331
+ </details>
332
+ </details>
333
+
334
+ ## 社区支持
335
+
336
+ 学 AI , 上 L 站:[LinuxDO](https://linux.do)
337
+
338
+ ## Contributors
339
+
340
+ 感谢所有为本项目做出贡献的开发者:
341
+
342
+ <a href="https://github.com/basketikun/chatgpt2api/graphs/contributors">
343
+ <img alt="Contributors" src="https://contrib.rocks/image?repo=basketikun/chatgpt2api" />
344
+ </a>
345
+
346
+ ## Star History
347
+
348
+ [![Star History Chart](https://api.star-history.com/chart?repos=basketikun/chatgpt2api&type=date&legend=top-left)](https://www.star-history.com/?repos=basketikun%2Fchatgpt2api&type=date&legend=top-left)
VERSION ADDED
@@ -0,0 +1 @@
 
 
1
+ 1.4.0
api/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ from api.app import create_app
2
+
api/accounts.py ADDED
@@ -0,0 +1,537 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import io
5
+ import json
6
+ import re
7
+ import uuid
8
+ import zipfile
9
+ from datetime import datetime
10
+ from typing import Any, Literal
11
+
12
+ from fastapi import APIRouter, Header, HTTPException
13
+ from fastapi.concurrency import run_in_threadpool
14
+ from fastapi.responses import Response
15
+ from pydantic import BaseModel, Field
16
+
17
+ from services.auth_service import auth_service
18
+
19
+ from api.support import (
20
+ require_admin,
21
+ sanitize_cpa_pool,
22
+ sanitize_cpa_pools,
23
+ sanitize_sub2api_server,
24
+ sanitize_sub2api_servers,
25
+ )
26
+ from services.account_service import account_service
27
+ from services.cpa_service import cpa_config, cpa_import_service, list_remote_files
28
+ from services.oauth_login_service import OAuthLoginError, oauth_login_service
29
+ from services.sub2api_service import (
30
+ list_remote_accounts as sub2api_list_remote_accounts,
31
+ list_remote_groups as sub2api_list_remote_groups,
32
+ sub2api_config,
33
+ sub2api_import_service,
34
+ )
35
+
36
+
37
+
38
+ class UserKeyCreateRequest(BaseModel):
39
+ name: str = ""
40
+
41
+
42
+ class UserKeyUpdateRequest(BaseModel):
43
+ name: str | None = None
44
+ enabled: bool | None = None
45
+ key: str | None = None
46
+
47
+
48
+ class AccountCreateRequest(BaseModel):
49
+ tokens: list[str] = Field(default_factory=list)
50
+ accounts: list[dict[str, Any]] = Field(default_factory=list)
51
+
52
+
53
+ class AccountDeleteRequest(BaseModel):
54
+ tokens: list[str] = Field(default_factory=list)
55
+
56
+
57
+ class AccountRefreshRequest(BaseModel):
58
+ access_tokens: list[str] = Field(default_factory=list)
59
+
60
+
61
+ class AccountExportRequest(BaseModel):
62
+ access_tokens: list[str] = Field(default_factory=list)
63
+ format: Literal["json", "zip"] = "json"
64
+
65
+
66
+ class AccountUpdateRequest(BaseModel):
67
+ access_token: str = ""
68
+ type: str | None = None
69
+ status: str | None = None
70
+ quota: int | None = None
71
+ proxy: str | None = None
72
+
73
+
74
+ class CPAPoolCreateRequest(BaseModel):
75
+ name: str = ""
76
+ base_url: str = ""
77
+ secret_key: str = ""
78
+
79
+
80
+ class CPAPoolUpdateRequest(BaseModel):
81
+ name: str | None = None
82
+ base_url: str | None = None
83
+ secret_key: str | None = None
84
+
85
+
86
+ class CPAImportRequest(BaseModel):
87
+ names: list[str] = Field(default_factory=list)
88
+
89
+
90
+ class Sub2APIServerCreateRequest(BaseModel):
91
+ name: str = ""
92
+ base_url: str = ""
93
+ email: str = ""
94
+ password: str = ""
95
+ api_key: str = ""
96
+ group_id: str = ""
97
+
98
+
99
+ class Sub2APIServerUpdateRequest(BaseModel):
100
+ name: str | None = None
101
+ base_url: str | None = None
102
+ email: str | None = None
103
+ password: str | None = None
104
+ api_key: str | None = None
105
+ group_id: str | None = None
106
+
107
+
108
+ class Sub2APIImportRequest(BaseModel):
109
+ account_ids: list[str] = Field(default_factory=list)
110
+
111
+
112
+ class OAuthLoginStartRequest(BaseModel):
113
+ """起始 OAuth 桥。email_hint 可选,仅用于让 OpenAI 登录页预填邮箱。"""
114
+ email_hint: str = ""
115
+
116
+
117
+ class OAuthLoginFinishRequest(BaseModel):
118
+ """提交 callback。callback 既可以是完整 URL 也可以只填 code。"""
119
+ session_id: str = ""
120
+ callback: str = ""
121
+
122
+
123
+ def _account_payload_token(item: dict[str, Any]) -> str:
124
+ return str(item.get("access_token") or item.get("accessToken") or "").strip()
125
+
126
+
127
+ def _unique_tokens(tokens: list[str]) -> list[str]:
128
+ return list(dict.fromkeys(str(token or "").strip() for token in tokens if str(token or "").strip()))
129
+
130
+
131
+ def _download_timestamp() -> str:
132
+ return datetime.now().strftime("%Y%m%d-%H%M%S")
133
+
134
+
135
+ def _safe_export_name(value: str, fallback: str) -> str:
136
+ clean = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip()).strip("-._")
137
+ return (clean or fallback)[:80]
138
+
139
+
140
+ def _account_zip_bytes(items: list[dict[str, str]]) -> bytes:
141
+ buf = io.BytesIO()
142
+ used_names: set[str] = set()
143
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as archive:
144
+ for index, item in enumerate(items, start=1):
145
+ raw_name = item.get("email") or item.get("account_id") or f"account-{index:03d}"
146
+ base_name = _safe_export_name(raw_name, f"account-{index:03d}")
147
+ name = base_name
148
+ suffix = 2
149
+ while name in used_names:
150
+ name = f"{base_name}-{suffix}"
151
+ suffix += 1
152
+ used_names.add(name)
153
+ archive.writestr(
154
+ f"{name}.json",
155
+ json.dumps(item, ensure_ascii=False, indent=2) + "\n",
156
+ )
157
+ return buf.getvalue()
158
+
159
+
160
+ def create_router() -> APIRouter:
161
+ router = APIRouter()
162
+
163
+ @router.get("/api/auth/users")
164
+ async def list_user_keys(authorization: str | None = Header(default=None)):
165
+ require_admin(authorization)
166
+ return {"items": auth_service.list_keys(role="user")}
167
+
168
+ @router.post("/api/auth/users")
169
+ async def create_user_key(body: UserKeyCreateRequest, authorization: str | None = Header(default=None)):
170
+ require_admin(authorization)
171
+ try:
172
+ item, raw_key = auth_service.create_key(role="user", name=body.name)
173
+ except ValueError as exc:
174
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
175
+ return {"item": item, "key": raw_key, "items": auth_service.list_keys(role="user")}
176
+
177
+ @router.post("/api/auth/users/{key_id}")
178
+ async def update_user_key(
179
+ key_id: str,
180
+ body: UserKeyUpdateRequest,
181
+ authorization: str | None = Header(default=None),
182
+ ):
183
+ require_admin(authorization)
184
+ updates = {
185
+ key: value
186
+ for key, value in {
187
+ "name": body.name,
188
+ "enabled": body.enabled,
189
+ "key": body.key,
190
+ }.items()
191
+ if value is not None
192
+ }
193
+ if not updates:
194
+ raise HTTPException(status_code=400, detail={"error": "还没有检测到改动,请修改后再保存"})
195
+ try:
196
+ item = auth_service.update_key(key_id, updates, role="user")
197
+ except ValueError as exc:
198
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
199
+ if item is None:
200
+ raise HTTPException(status_code=404, detail={"error": "这条用户密钥不存在,可能已经被删除"})
201
+ return {"item": item, "items": auth_service.list_keys(role="user")}
202
+
203
+ @router.delete("/api/auth/users/{key_id}")
204
+ async def delete_user_key(key_id: str, authorization: str | None = Header(default=None)):
205
+ require_admin(authorization)
206
+ if not auth_service.delete_key(key_id, role="user"):
207
+ raise HTTPException(status_code=404, detail={"error": "这条用户密钥不存在,可能已经被删除"})
208
+ return {"items": auth_service.list_keys(role="user")}
209
+
210
+ @router.get("/api/accounts")
211
+ async def get_accounts(authorization: str | None = Header(default=None)):
212
+ require_admin(authorization)
213
+ return {"items": account_service.list_accounts()}
214
+
215
+ @router.post("/api/accounts")
216
+ async def create_accounts(body: AccountCreateRequest, authorization: str | None = Header(default=None)):
217
+ require_admin(authorization)
218
+ account_payloads = [item for item in body.accounts if isinstance(item, dict)]
219
+ payload_tokens = [_account_payload_token(item) for item in account_payloads]
220
+ tokens = _unique_tokens([*body.tokens, *payload_tokens])
221
+ if not tokens:
222
+ raise HTTPException(status_code=400, detail={"error": "tokens is required"})
223
+ if account_payloads:
224
+ result = account_service.add_account_items(account_payloads)
225
+ payload_token_set = set(_unique_tokens(payload_tokens))
226
+ extra_tokens = [token for token in tokens if token not in payload_token_set]
227
+ if extra_tokens:
228
+ extra_result = account_service.add_accounts(extra_tokens)
229
+ result["added"] = int(result.get("added") or 0) + int(extra_result.get("added") or 0)
230
+ result["skipped"] = int(result.get("skipped") or 0) + int(extra_result.get("skipped") or 0)
231
+ else:
232
+ result = account_service.add_accounts(tokens)
233
+ refresh_result = account_service.refresh_accounts(tokens)
234
+ return {
235
+ **result,
236
+ "refreshed": refresh_result.get("refreshed", 0),
237
+ "errors": refresh_result.get("errors", []),
238
+ "items": refresh_result.get("items", result.get("items", [])),
239
+ }
240
+
241
+ @router.delete("/api/accounts")
242
+ async def delete_accounts(body: AccountDeleteRequest, authorization: str | None = Header(default=None)):
243
+ require_admin(authorization)
244
+ tokens = [str(token or "").strip() for token in body.tokens if str(token or "").strip()]
245
+ if not tokens:
246
+ raise HTTPException(status_code=400, detail={"error": "tokens is required"})
247
+ return account_service.delete_accounts(tokens)
248
+
249
+ @router.post("/api/accounts/refresh")
250
+ async def refresh_accounts(body: AccountRefreshRequest, authorization: str | None = Header(default=None)):
251
+ require_admin(authorization)
252
+ access_tokens = [str(token or "").strip() for token in body.access_tokens if str(token or "").strip()]
253
+ if not access_tokens:
254
+ access_tokens = account_service.list_tokens()
255
+ if not access_tokens:
256
+ raise HTTPException(status_code=400, detail={"error": "access_tokens is required"})
257
+
258
+ progress_id = str(uuid.uuid4())
259
+
260
+ async def _do_refresh():
261
+ try:
262
+ await run_in_threadpool(account_service.refresh_accounts, access_tokens, progress_id)
263
+ except Exception as e:
264
+ account_service.finish_refresh_progress(progress_id, error=str(e))
265
+
266
+ asyncio.create_task(_do_refresh())
267
+
268
+ return {"progress_id": progress_id}
269
+
270
+ @router.get("/api/accounts/refresh/progress/{progress_id}")
271
+ async def get_refresh_progress(progress_id: str, authorization: str | None = Header(default=None)):
272
+ require_admin(authorization)
273
+ progress = account_service.get_refresh_progress(progress_id)
274
+ if progress is None:
275
+ raise HTTPException(status_code=404, detail={"error": "progress not found"})
276
+ return progress
277
+
278
+ @router.post("/api/accounts/re-login")
279
+ async def re_login_accounts(body: AccountRefreshRequest, authorization: str | None = Header(default=None)):
280
+ """对选中账号执行密码重新登录流程(密码登录→验证码登录→刷新token)。"""
281
+ require_admin(authorization)
282
+ access_tokens = [str(token or "").strip() for token in body.access_tokens if str(token or "").strip()]
283
+ if not access_tokens:
284
+ raise HTTPException(status_code=400, detail={"error": "access_tokens is required"})
285
+
286
+ progress_id = str(uuid.uuid4())
287
+
288
+ async def _do_relogin():
289
+ try:
290
+ await run_in_threadpool(account_service.re_login_accounts, access_tokens, progress_id)
291
+ except Exception as e:
292
+ account_service.finish_relogin_progress(progress_id, error=str(e))
293
+
294
+ asyncio.create_task(_do_relogin())
295
+
296
+ return {"progress_id": progress_id}
297
+
298
+ @router.get("/api/accounts/re-login/progress/{progress_id}")
299
+ async def get_relogin_progress(progress_id: str, authorization: str | None = Header(default=None)):
300
+ require_admin(authorization)
301
+ progress = account_service.get_relogin_progress(progress_id)
302
+ if progress is None:
303
+ raise HTTPException(status_code=404, detail={"error": "progress not found"})
304
+ return progress
305
+
306
+ @router.post("/api/accounts/export")
307
+ async def export_accounts(body: AccountExportRequest, authorization: str | None = Header(default=None)):
308
+ require_admin(authorization)
309
+ access_tokens = _unique_tokens(body.access_tokens)
310
+ items = account_service.build_export_items(access_tokens)
311
+ if not items:
312
+ raise HTTPException(
313
+ status_code=400,
314
+ detail={"error": "没有可导出的完整账号,需要同时有 access_token、refresh_token 和 id_token"},
315
+ )
316
+
317
+ timestamp = _download_timestamp()
318
+ if body.format == "zip":
319
+ content = _account_zip_bytes(items)
320
+ return Response(
321
+ content,
322
+ media_type="application/zip",
323
+ headers={"Content-Disposition": f'attachment; filename="codex-accounts-{timestamp}.zip"'},
324
+ )
325
+
326
+ payload: dict[str, str] | list[dict[str, str]] = items[0] if len(items) == 1 else items
327
+ return Response(
328
+ json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
329
+ media_type="application/json",
330
+ headers={"Content-Disposition": f'attachment; filename="codex-accounts-{timestamp}.json"'},
331
+ )
332
+
333
+ @router.post("/api/accounts/update")
334
+ async def update_account(body: AccountUpdateRequest, authorization: str | None = Header(default=None)):
335
+ require_admin(authorization)
336
+ access_token = str(body.access_token or "").strip()
337
+ if not access_token:
338
+ raise HTTPException(status_code=400, detail={"error": "access_token is required"})
339
+ updates = {key: value for key, value in {"type": body.type, "status": body.status, "quota": body.quota, "proxy": body.proxy}.items() if value is not None}
340
+ if not updates:
341
+ raise HTTPException(status_code=400, detail={"error": "还没有检测到改动,请修改后再保存"})
342
+ account = account_service.update_account(access_token, updates)
343
+ if account is None:
344
+ raise HTTPException(status_code=404, detail={"error": "account not found"})
345
+ return {"item": account, "items": account_service.list_accounts()}
346
+
347
+ @router.post("/api/accounts/oauth/start")
348
+ async def start_oauth_login(
349
+ body: OAuthLoginStartRequest,
350
+ authorization: str | None = Header(default=None),
351
+ ):
352
+ """登记一次 PKCE 会话,返回可让用户浏览器打开的 authorize URL。"""
353
+ require_admin(authorization)
354
+ try:
355
+ return await run_in_threadpool(oauth_login_service.start, body.email_hint)
356
+ except OAuthLoginError as exc:
357
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
358
+
359
+ @router.post("/api/accounts/oauth/finish")
360
+ async def finish_oauth_login(
361
+ body: OAuthLoginFinishRequest,
362
+ authorization: str | None = Header(default=None),
363
+ ):
364
+ """收用户从浏览器抓回的 callback URL / code,换出 token 三件套并落盘。"""
365
+ require_admin(authorization)
366
+ # 入参日志:截断敏感字段,仅保留前几位,方便排错而不泄密
367
+ cb_preview = (body.callback or "")[:80]
368
+ sid_preview = (body.session_id or "")[:8]
369
+ print(
370
+ f"[oauth-login] finish called: session_id={sid_preview}..., callback_preview={cb_preview!r}",
371
+ flush=True,
372
+ )
373
+ try:
374
+ tokens = await run_in_threadpool(oauth_login_service.finish, body.session_id, body.callback)
375
+ except OAuthLoginError as exc:
376
+ print(f"[oauth-login] finish rejected: {exc}", flush=True)
377
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
378
+
379
+ payload = {
380
+ "access_token": tokens["access_token"],
381
+ "refresh_token": tokens["refresh_token"],
382
+ "id_token": tokens["id_token"],
383
+ "source_type": "oauth_login",
384
+ }
385
+ add_result = await run_in_threadpool(account_service.add_account_items, [payload])
386
+ refresh_result = await run_in_threadpool(
387
+ account_service.refresh_accounts, [tokens["access_token"]]
388
+ )
389
+ return {
390
+ **add_result,
391
+ "refreshed": refresh_result.get("refreshed", 0),
392
+ "errors": refresh_result.get("errors", []),
393
+ "items": refresh_result.get("items", add_result.get("items", [])),
394
+ }
395
+
396
+ @router.get("/api/cpa/pools")
397
+ async def list_cpa_pools(authorization: str | None = Header(default=None)):
398
+ require_admin(authorization)
399
+ return {"pools": sanitize_cpa_pools(cpa_config.list_pools())}
400
+
401
+ @router.post("/api/cpa/pools")
402
+ async def create_cpa_pool(body: CPAPoolCreateRequest, authorization: str | None = Header(default=None)):
403
+ require_admin(authorization)
404
+ if not body.base_url.strip():
405
+ raise HTTPException(status_code=400, detail={"error": "base_url is required"})
406
+ if not body.secret_key.strip():
407
+ raise HTTPException(status_code=400, detail={"error": "secret_key is required"})
408
+ pool = cpa_config.add_pool(name=body.name, base_url=body.base_url, secret_key=body.secret_key)
409
+ return {"pool": sanitize_cpa_pool(pool), "pools": sanitize_cpa_pools(cpa_config.list_pools())}
410
+
411
+ @router.post("/api/cpa/pools/{pool_id}")
412
+ async def update_cpa_pool(pool_id: str, body: CPAPoolUpdateRequest, authorization: str | None = Header(default=None)):
413
+ require_admin(authorization)
414
+ pool = cpa_config.update_pool(pool_id, body.model_dump(exclude_none=True))
415
+ if pool is None:
416
+ raise HTTPException(status_code=404, detail={"error": "pool not found"})
417
+ return {"pool": sanitize_cpa_pool(pool), "pools": sanitize_cpa_pools(cpa_config.list_pools())}
418
+
419
+ @router.delete("/api/cpa/pools/{pool_id}")
420
+ async def delete_cpa_pool(pool_id: str, authorization: str | None = Header(default=None)):
421
+ require_admin(authorization)
422
+ if not cpa_config.delete_pool(pool_id):
423
+ raise HTTPException(status_code=404, detail={"error": "pool not found"})
424
+ return {"pools": sanitize_cpa_pools(cpa_config.list_pools())}
425
+
426
+ @router.get("/api/cpa/pools/{pool_id}/files")
427
+ async def cpa_pool_files(pool_id: str, authorization: str | None = Header(default=None)):
428
+ require_admin(authorization)
429
+ pool = cpa_config.get_pool(pool_id)
430
+ if pool is None:
431
+ raise HTTPException(status_code=404, detail={"error": "pool not found"})
432
+ return {"pool_id": pool_id, "files": await run_in_threadpool(list_remote_files, pool)}
433
+
434
+ @router.post("/api/cpa/pools/{pool_id}/import")
435
+ async def cpa_pool_import(pool_id: str, body: CPAImportRequest, authorization: str | None = Header(default=None)):
436
+ require_admin(authorization)
437
+ pool = cpa_config.get_pool(pool_id)
438
+ if pool is None:
439
+ raise HTTPException(status_code=404, detail={"error": "pool not found"})
440
+ try:
441
+ job = cpa_import_service.start_import(pool, body.names)
442
+ except ValueError as exc:
443
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
444
+ return {"import_job": job}
445
+
446
+ @router.get("/api/cpa/pools/{pool_id}/import")
447
+ async def cpa_pool_import_progress(pool_id: str, authorization: str | None = Header(default=None)):
448
+ require_admin(authorization)
449
+ pool = cpa_config.get_pool(pool_id)
450
+ if pool is None:
451
+ raise HTTPException(status_code=404, detail={"error": "pool not found"})
452
+ return {"import_job": pool.get("import_job")}
453
+
454
+ @router.get("/api/sub2api/servers")
455
+ async def list_sub2api_servers(authorization: str | None = Header(default=None)):
456
+ require_admin(authorization)
457
+ return {"servers": sanitize_sub2api_servers(sub2api_config.list_servers())}
458
+
459
+ @router.post("/api/sub2api/servers")
460
+ async def create_sub2api_server(body: Sub2APIServerCreateRequest, authorization: str | None = Header(default=None)):
461
+ require_admin(authorization)
462
+ if not body.base_url.strip():
463
+ raise HTTPException(status_code=400, detail={"error": "base_url is required"})
464
+ has_login = body.email.strip() and body.password.strip()
465
+ has_api_key = bool(body.api_key.strip())
466
+ if not has_login and not has_api_key:
467
+ raise HTTPException(status_code=400, detail={"error": "email+password or api_key is required"})
468
+ server = sub2api_config.add_server(
469
+ name=body.name,
470
+ base_url=body.base_url,
471
+ email=body.email,
472
+ password=body.password,
473
+ api_key=body.api_key,
474
+ group_id=body.group_id,
475
+ )
476
+ return {"server": sanitize_sub2api_server(server), "servers": sanitize_sub2api_servers(sub2api_config.list_servers())}
477
+
478
+ @router.post("/api/sub2api/servers/{server_id}")
479
+ async def update_sub2api_server(server_id: str, body: Sub2APIServerUpdateRequest, authorization: str | None = Header(default=None)):
480
+ require_admin(authorization)
481
+ server = sub2api_config.update_server(server_id, body.model_dump(exclude_none=True))
482
+ if server is None:
483
+ raise HTTPException(status_code=404, detail={"error": "server not found"})
484
+ return {"server": sanitize_sub2api_server(server), "servers": sanitize_sub2api_servers(sub2api_config.list_servers())}
485
+
486
+ @router.delete("/api/sub2api/servers/{server_id}")
487
+ async def delete_sub2api_server(server_id: str, authorization: str | None = Header(default=None)):
488
+ require_admin(authorization)
489
+ if not sub2api_config.delete_server(server_id):
490
+ raise HTTPException(status_code=404, detail={"error": "server not found"})
491
+ return {"servers": sanitize_sub2api_servers(sub2api_config.list_servers())}
492
+
493
+ @router.get("/api/sub2api/servers/{server_id}/groups")
494
+ async def sub2api_server_groups(server_id: str, authorization: str | None = Header(default=None)):
495
+ require_admin(authorization)
496
+ server = sub2api_config.get_server(server_id)
497
+ if server is None:
498
+ raise HTTPException(status_code=404, detail={"error": "server not found"})
499
+ try:
500
+ groups = await run_in_threadpool(sub2api_list_remote_groups, server)
501
+ except Exception as exc:
502
+ raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
503
+ return {"server_id": server_id, "groups": groups}
504
+
505
+ @router.get("/api/sub2api/servers/{server_id}/accounts")
506
+ async def sub2api_server_accounts(server_id: str, authorization: str | None = Header(default=None)):
507
+ require_admin(authorization)
508
+ server = sub2api_config.get_server(server_id)
509
+ if server is None:
510
+ raise HTTPException(status_code=404, detail={"error": "server not found"})
511
+ try:
512
+ accounts = await run_in_threadpool(sub2api_list_remote_accounts, server)
513
+ except Exception as exc:
514
+ raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
515
+ return {"server_id": server_id, "accounts": accounts}
516
+
517
+ @router.post("/api/sub2api/servers/{server_id}/import")
518
+ async def sub2api_server_import(server_id: str, body: Sub2APIImportRequest, authorization: str | None = Header(default=None)):
519
+ require_admin(authorization)
520
+ server = sub2api_config.get_server(server_id)
521
+ if server is None:
522
+ raise HTTPException(status_code=404, detail={"error": "server not found"})
523
+ try:
524
+ job = sub2api_import_service.start_import(server, body.account_ids)
525
+ except ValueError as exc:
526
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
527
+ return {"import_job": job}
528
+
529
+ @router.get("/api/sub2api/servers/{server_id}/import")
530
+ async def sub2api_server_import_progress(server_id: str, authorization: str | None = Header(default=None)):
531
+ require_admin(authorization)
532
+ server = sub2api_config.get_server(server_id)
533
+ if server is None:
534
+ raise HTTPException(status_code=404, detail={"error": "server not found"})
535
+ return {"import_job": server.get("import_job")}
536
+
537
+ return router
api/ai.py ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter, Header, HTTPException, Request
4
+ from fastapi.concurrency import run_in_threadpool
5
+ from fastapi.responses import FileResponse
6
+ from pydantic import BaseModel, ConfigDict, Field
7
+
8
+ from api.image_inputs import parse_image_edit_request, read_image_sources
9
+ from api.support import require_identity, resolve_image_base_url
10
+ from services.content_filter import check_request, request_shape, request_text
11
+ from services.editable_file_task_service import editable_file_task_service
12
+ from services.log_service import LoggedCall
13
+ from services.protocol import (
14
+ anthropic_v1_messages,
15
+ openai_v1_chat_complete,
16
+ openai_v1_image_edit,
17
+ openai_v1_image_generations,
18
+ openai_v1_models,
19
+ openai_v1_response,
20
+ openai_search,
21
+ )
22
+
23
+
24
+ class ImageGenerationRequest(BaseModel):
25
+ prompt: str = Field(..., min_length=1)
26
+ model: str = "gpt-image-2"
27
+ n: int = Field(default=1, ge=1, le=4)
28
+ size: str | None = None
29
+ quality: str = "auto"
30
+ response_format: str = "b64_json"
31
+ history_disabled: bool = True
32
+ stream: bool | None = None
33
+
34
+
35
+ class ChatCompletionRequest(BaseModel):
36
+ model_config = ConfigDict(extra="allow")
37
+ model: str | None = None
38
+ prompt: str | None = None
39
+ n: int | None = None
40
+ stream: bool | None = None
41
+ modalities: list[str] | None = None
42
+ messages: list[dict[str, object]] | None = None
43
+
44
+
45
+ class ResponseCreateRequest(BaseModel):
46
+ model_config = ConfigDict(extra="allow")
47
+ model: str | None = None
48
+ input: object | None = None
49
+ tools: list[dict[str, object]] | None = None
50
+ tool_choice: object | None = None
51
+ stream: bool | None = None
52
+
53
+
54
+ class AnthropicMessageRequest(BaseModel):
55
+ model_config = ConfigDict(extra="allow")
56
+ model: str | None = None
57
+ messages: list[dict[str, object]] | None = None
58
+ system: object | None = None
59
+ stream: bool | None = None
60
+
61
+
62
+ class SearchRequest(BaseModel):
63
+ prompt: str = Field(..., min_length=1)
64
+
65
+
66
+ class EditableFileTaskRequest(BaseModel):
67
+ prompt: str = ""
68
+ base64_images: list[str] = Field(default_factory=list)
69
+ client_task_id: str | None = None
70
+
71
+
72
+ async def filter_or_log(call: LoggedCall, text: str) -> None:
73
+ try:
74
+ await run_in_threadpool(check_request, text)
75
+ except HTTPException as exc:
76
+ call.log("调用失败", status="failed", error=str(exc.detail))
77
+ raise
78
+
79
+
80
+ def create_router() -> APIRouter:
81
+ router = APIRouter()
82
+
83
+ @router.get("/v1/models")
84
+ async def list_models(authorization: str | None = Header(default=None)):
85
+ require_identity(authorization)
86
+ try:
87
+ return await run_in_threadpool(openai_v1_models.list_models)
88
+ except Exception as exc:
89
+ raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
90
+
91
+ @router.post("/v1/images/generations")
92
+ async def generate_images(
93
+ body: ImageGenerationRequest,
94
+ request: Request,
95
+ authorization: str | None = Header(default=None),
96
+ ):
97
+ identity = require_identity(authorization)
98
+ payload = body.model_dump(mode="python")
99
+ payload["base_url"] = resolve_image_base_url(request)
100
+ call = LoggedCall(identity, "/v1/images/generations", body.model, "文生图", request_text=body.prompt)
101
+ await filter_or_log(call, body.prompt)
102
+ return await call.run(openai_v1_image_generations.handle, payload)
103
+
104
+ @router.post("/v1/images/edits")
105
+ async def edit_images(
106
+ request: Request,
107
+ authorization: str | None = Header(default=None),
108
+ ):
109
+ identity = require_identity(authorization)
110
+ payload, image_sources = await parse_image_edit_request(request)
111
+ prompt = str(payload["prompt"])
112
+ model = str(payload["model"])
113
+ call = LoggedCall(identity, "/v1/images/edits", model, "图生图", request_text=prompt)
114
+ await filter_or_log(call, prompt)
115
+ payload["images"] = await read_image_sources(image_sources)
116
+ payload["base_url"] = resolve_image_base_url(request)
117
+ return await call.run(openai_v1_image_edit.handle, payload)
118
+
119
+ @router.post("/v1/chat/completions")
120
+ async def create_chat_completion(body: ChatCompletionRequest, authorization: str | None = Header(default=None)):
121
+ identity = require_identity(authorization)
122
+ payload = body.model_dump(mode="python")
123
+ model = str(payload.get("model") or "auto")
124
+ request_preview = request_text(payload.get("prompt"), payload.get("messages"))
125
+ call = LoggedCall(
126
+ identity,
127
+ "/v1/chat/completions",
128
+ model,
129
+ "文本生成",
130
+ request_text=request_preview,
131
+ request_shape=request_shape(payload.get("messages")),
132
+ )
133
+ await filter_or_log(call, request_preview)
134
+ return await call.run(openai_v1_chat_complete.handle, payload)
135
+
136
+ @router.post("/v1/responses")
137
+ async def create_response(body: ResponseCreateRequest, authorization: str | None = Header(default=None)):
138
+ identity = require_identity(authorization)
139
+ payload = body.model_dump(mode="python")
140
+ model = str(payload.get("model") or "auto")
141
+ request_preview = request_text(payload.get("input"), payload.get("instructions"))
142
+ call = LoggedCall(
143
+ identity,
144
+ "/v1/responses",
145
+ model,
146
+ "Responses",
147
+ request_text=request_preview,
148
+ request_shape=request_shape(payload.get("input")),
149
+ )
150
+ await filter_or_log(call, request_preview)
151
+ return await call.run(openai_v1_response.handle, payload)
152
+
153
+ @router.post("/v1/messages")
154
+ async def create_message(
155
+ body: AnthropicMessageRequest,
156
+ authorization: str | None = Header(default=None),
157
+ x_api_key: str | None = Header(default=None, alias="x-api-key"),
158
+ anthropic_version: str | None = Header(default=None, alias="anthropic-version"),
159
+ ):
160
+ identity = require_identity(authorization or (f"Bearer {x_api_key}" if x_api_key else None))
161
+ payload = body.model_dump(mode="python")
162
+ model = str(payload.get("model") or "auto")
163
+ request_preview = request_text(payload.get("system"), payload.get("messages"), payload.get("tools"))
164
+ call = LoggedCall(identity, "/v1/messages", model, "Messages", request_text=request_preview)
165
+ await filter_or_log(call, request_preview)
166
+ return await call.run(anthropic_v1_messages.handle, payload, sse="anthropic")
167
+
168
+ @router.post("/v1/search")
169
+ async def search(body: SearchRequest, authorization: str | None = Header(default=None)):
170
+ identity = require_identity(authorization)
171
+ call = LoggedCall(identity, "/v1/search", openai_search.MODEL, "搜索", request_text=body.prompt)
172
+ await filter_or_log(call, body.prompt)
173
+ return await call.run(openai_search.handle, body.model_dump(mode="python"))
174
+
175
+ @router.get("/v1/editable-file-tasks")
176
+ async def list_editable_file_tasks(ids: str = "", authorization: str | None = Header(default=None)):
177
+ identity = require_identity(authorization)
178
+ task_ids = [item.strip() for item in ids.split(",") if item.strip()]
179
+ return await run_in_threadpool(editable_file_task_service.list_tasks, identity, task_ids)
180
+
181
+ @router.get("/files/{file_path:path}")
182
+ async def download_editable_file(file_path: str):
183
+ try:
184
+ path = await run_in_threadpool(editable_file_task_service.public_file_path, file_path)
185
+ except Exception as exc:
186
+ raise HTTPException(status_code=404, detail={"error": "file not found"}) from exc
187
+ return FileResponse(path, filename=path.name)
188
+
189
+ @router.post("/v1/ppt/generations")
190
+ async def create_ppt_task(body: EditableFileTaskRequest, request: Request, authorization: str | None = Header(default=None)):
191
+ identity = require_identity(authorization)
192
+ await filter_or_log(LoggedCall(identity, "/v1/ppt/generations", "gpt-5-5-thinking", "PPT生成任务", request_text=body.prompt), body.prompt)
193
+ return await run_in_threadpool(
194
+ editable_file_task_service.submit_ppt,
195
+ identity,
196
+ client_task_id=body.client_task_id or "",
197
+ prompt=body.prompt,
198
+ base64_images=body.base64_images,
199
+ base_url=resolve_image_base_url(request),
200
+ )
201
+
202
+ @router.post("/v1/psd/generations")
203
+ async def create_psd_task(body: EditableFileTaskRequest, request: Request, authorization: str | None = Header(default=None)):
204
+ identity = require_identity(authorization)
205
+ await filter_or_log(LoggedCall(identity, "/v1/psd/generations", "gpt-5-5-thinking", "PSD生成任务", request_text=body.prompt), body.prompt)
206
+ return await run_in_threadpool(
207
+ editable_file_task_service.submit_psd,
208
+ identity,
209
+ client_task_id=body.client_task_id or "",
210
+ prompt=body.prompt,
211
+ base64_images=body.base64_images,
212
+ base_url=resolve_image_base_url(request),
213
+ )
214
+
215
+ 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,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ class ResumePollRequest(BaseModel):
23
+ extra_timeout_secs: float = Field(default=30.0, ge=5.0, le=120.0)
24
+
25
+
26
+ def _parse_task_ids(value: str) -> list[str]:
27
+ return [item.strip() for item in value.split(",") if item.strip()]
28
+
29
+
30
+ async def filter_or_log(call: LoggedCall, text: str) -> None:
31
+ try:
32
+ await run_in_threadpool(check_request, text)
33
+ except HTTPException as exc:
34
+ call.log("调用失败", status="failed", error=str(exc.detail))
35
+ raise
36
+
37
+
38
+ def create_router() -> APIRouter:
39
+ router = APIRouter()
40
+
41
+ @router.get("/api/image-tasks")
42
+ async def list_image_tasks(
43
+ ids: str = Query(default=""),
44
+ authorization: str | None = Header(default=None),
45
+ ):
46
+ identity = require_identity(authorization)
47
+ return await run_in_threadpool(image_task_service.list_tasks, identity, _parse_task_ids(ids))
48
+
49
+ @router.post("/api/image-tasks/generations")
50
+ async def create_generation_task(
51
+ body: ImageGenerationTaskRequest,
52
+ request: Request,
53
+ authorization: str | None = Header(default=None),
54
+ ):
55
+ identity = require_identity(authorization)
56
+ await filter_or_log(LoggedCall(identity, "/api/image-tasks/generations", body.model, "文生图任务", request_text=body.prompt), body.prompt)
57
+ try:
58
+ return await run_in_threadpool(
59
+ image_task_service.submit_generation,
60
+ identity,
61
+ client_task_id=body.client_task_id,
62
+ prompt=body.prompt,
63
+ model=body.model,
64
+ size=body.size,
65
+ quality=body.quality,
66
+ base_url=resolve_image_base_url(request),
67
+ )
68
+ except ValueError as exc:
69
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
70
+
71
+ @router.post("/api/image-tasks/edits")
72
+ async def create_edit_task(
73
+ request: Request,
74
+ authorization: str | None = Header(default=None),
75
+ ):
76
+ identity = require_identity(authorization)
77
+ payload, image_sources = await parse_image_edit_request(request)
78
+ client_task_id = str(payload.get("client_task_id") or "").strip()
79
+ if not client_task_id:
80
+ raise HTTPException(status_code=400, detail={"error": "client_task_id is required"})
81
+ prompt = str(payload["prompt"])
82
+ model = str(payload["model"])
83
+ await filter_or_log(LoggedCall(identity, "/api/image-tasks/edits", model, "图生图任务", request_text=prompt), prompt)
84
+ images = await read_image_sources(image_sources)
85
+ try:
86
+ return await run_in_threadpool(
87
+ image_task_service.submit_edit,
88
+ identity,
89
+ client_task_id=client_task_id,
90
+ prompt=prompt,
91
+ model=model,
92
+ size=payload["size"],
93
+ quality=payload["quality"],
94
+ base_url=resolve_image_base_url(request),
95
+ images=images,
96
+ )
97
+ except ValueError as exc:
98
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
99
+
100
+ @router.post("/api/image-tasks/{task_id}/resume-poll")
101
+ async def resume_image_poll(
102
+ task_id: str,
103
+ body: ResumePollRequest,
104
+ request: Request,
105
+ authorization: str | None = Header(default=None),
106
+ ):
107
+ identity = require_identity(authorization)
108
+ try:
109
+ return await run_in_threadpool(
110
+ image_task_service.resume_poll,
111
+ identity,
112
+ task_id,
113
+ body.extra_timeout_secs,
114
+ )
115
+ except ValueError as exc:
116
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
117
+
118
+ 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
docker-compose.local.yml ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ app:
3
+ build:
4
+ context: .
5
+ dockerfile: Dockerfile
6
+ image: chatgpt2api:local
7
+ container_name: chatgpt2api-local
8
+ ports:
9
+ - "8000:80"
10
+ volumes:
11
+ - ./data:/app/data
12
+ - ./config.json:/app/config.json
13
+ environment:
14
+ STORAGE_BACKEND: sqlite
15
+ DATABASE_URL: sqlite:////app/data/accounts.db
16
+ # 存储后端配置 (可选值: json, sqlite, postgres, git)
17
+ # environment:
18
+ # STORAGE_BACKEND: json
19
+
20
+ # 数据库配置 (当 STORAGE_BACKEND=sqlite/postgres 时使用)
21
+ # DATABASE_URL: postgresql://user:password@host:5432/dbname
22
+ # DATABASE_URL: sqlite:////app/data/accounts.db
23
+
24
+ # Git 仓库配置 (当 STORAGE_BACKEND=git 时使用)
25
+ # GIT_REPO_URL: https://github.com/user/repo.git
26
+ # GIT_TOKEN: ghp_xxxxxxxxxxxx
27
+ # GIT_BRANCH: main
28
+ # GIT_FILE_PATH: accounts.json
29
+
30
+ # 认证密钥 (可选,覆盖 config.json)
31
+ # CHATGPT2API_AUTH_KEY: your_secret_key
32
+
33
+ # 基础 URL (可选)
34
+ # CHATGPT2API_BASE_URL: https://your-domain.com
docker-compose.yml ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ app:
3
+ image: ghcr.io/basketikun/chatgpt2api:latest
4
+ container_name: chatgpt2api
5
+ restart: unless-stopped
6
+ ports:
7
+ - "3000:80"
8
+ volumes:
9
+ - ./data:/app/data
10
+ - ./config.json:/app/config.json
11
+ environment:
12
+ # 存储后端配置 (可选值: json, sqlite, postgres, git)
13
+ - STORAGE_BACKEND=json
14
+
15
+ # 数据库配置 (当 STORAGE_BACKEND=sqlite/postgres 时使用)
16
+ # - DATABASE_URL=postgresql://user:password@host:5432/dbname
17
+ # - DATABASE_URL=sqlite:///app/data/accounts.db
18
+
19
+ # Git 仓库配置 (当 STORAGE_BACKEND=git 时使用)
20
+ # - GIT_REPO_URL=https://github.com/user/repo.git
21
+ # - GIT_TOKEN=ghp_xxxxxxxxxxxx
22
+ # - GIT_BRANCH=main
23
+ # - GIT_FILE_PATH=accounts.json
24
+
25
+ # 认证密钥 (可选,覆盖 config.json)
26
+ # - CHATGPT2API_AUTH_KEY=your_secret_key
27
+
28
+ # 基础 URL (可选)
29
+ # - CHATGPT2API_BASE_URL=https://your-domain.com
docs/feature-status.en.md ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 功能状态
2
+
3
+ 本文基于当前仓库当前实现整理,用于帮助用户快速了解哪些功能已经可用、哪些仍在完善、哪些待实现。
4
+
5
+ | 功能 | 状态 | 说明 |
6
+ |:----------------------------------------|:--:|:--------------------------------------------------------------|
7
+ | OpenAI 兼容 `POST /v1/images/generations` | ✅ | 已支持,用于图片生成,并可通过 `n` 返回多张图片。 |
8
+ | OpenAI 兼容 `POST /v1/images/edits` | ✅ | 已支持,可上传图片进行编辑。 |
9
+ | 面向图片工作流的 `POST /v1/chat/completions` | ✅ | 已支持图片相关请求。 |
10
+ | 面向图片工作流的 `POST /v1/responses` | ✅ | 已支持图片生成工具调用。 |
11
+ | `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`、`gpt-5-mini`。 |
12
+ | 同时生成多张图片 | ✅ | 已支持,后端与前端都可进行多图生成。 |
13
+ | 图片并行生成 | ✅ | 多张图片使用独立线程和账号同时生成,可通过 `image_parallel_generation` 配置关闭。 |
14
+ | 图片生成进度追踪 | ✅ | 任务显示当前步骤(上传/预热/获取token/生成中等),支持耗时统计。 |
15
+ | 图片超时续轮询 | ✅ | 超时任务可继续等待,前端显示"继续等待"按钮,后端 resume-poll API。 |
16
+ | 图片二次确认与先check再hit | ✅ | 可通过 `image_settle_enabled` 和 `image_check_before_hit_enabled` 配置,关闭后跳过等待直接返回。 |
17
+ | 前端图片工作台 | ✅ | 已支持图片生成、图片编辑、模型选择、历史记录与查看大图。 |
18
+ | 前端图片懒加载与滚动优化 | ✅ | LazyImage 懒加载、会话切换滚动位置保存与恢复、bfcache 页面恢复同步。 |
19
+ | 前端图片输入 / 参考图交互 | ✅ | 已支持参考图上传、预览、移除和编辑模式工作流。 |
20
+ | Codex 画图接口逆向 | ✅ | 已支持,仅 `Plus` / `Team` / `Pro` 订阅可用,模型别名为 `codex-gpt-image-2`;如有需要可自行在其他场景映射回 `gpt-image-2`。这是 Codex 逆向链路,用于和官网画图区分,同一账号通常会同时支持官网和 Codex 两份生图额度。 |
21
+ | Cherry Studio 接入 | ✅ | 已支持作为绘图接口接入 Cherry Studio。 |
22
+ | New API 接入 | ✅ | 已支持接入 New API。 |
23
+ | 账号池管理 | ✅ | 已支持列表、筛选、批量操作、导出、手动编辑、刷新和删除。 |
24
+ | 账号刷新异步进度追踪 | ✅ | 刷新和重新登录改为异步模式,前端轮询显示进度。 |
25
+ | 密码重新登录恢复异常账号 | ✅ | 号池管理页面支持重新登录,刷新后可自动重登异常账号。 |
26
+ | 账号额度刷新与恢复时间同步 | ✅ | 已支持账号信息刷新,限流账号也会自动继续检查。 |
27
+ | 失效 Token 自动清理 | ✅ | 已支持自动移除失效 Token。 |
28
+ | CPA 连接管理 | ✅ | 已支持 CPA 连接的新增、修改、查询和删除。 |
29
+ | CPA 文件浏览与按需导入 | ✅ | 已支持读取远程文件列表、筛选、勾选并导入到本地号池。 |
30
+ | CPA 导入进度跟踪 | ✅ | 已支持导入进度展示与轮询更新。 |
31
+ | `sub2api` 连接管理与账号浏览 | ✅ | 已支持 `sub2api` 服务器的新增、修改、删除、分组查询和 OpenAI OAuth 账号列表读取。 |
32
+ | `sub2api` 导入 | ✅ | 已支持勾选 `sub2api` 中的 OpenAI OAuth 账号,批量拉取 `access_token` 导入本地号池,并展示导入进度。 |
33
+ | Docker 自托管部署 | ✅ | 已支持 Docker Compose 部署,并提供多架构镜像。 |
34
+ | 兼容接口中的多参考图能力 | ✅ | 已实现,支持在兼容接口中传入多参考图。 |
35
+ | 更高级的 Token 调度策略 | ⚠️ | 当前已有基础轮询与限流刷新机制,更复杂的调度策略仍在完善中。 |
36
+ | Render / Vercel 等部署表述 | ⚠️ | 当前主要以 Docker 部署为主,其他平台部署方式暂未重点说明。 |
37
+ | `/v1/complete` 文本补全与流式输出 | ✅ | 已实现。 |
38
+ | 流式输出支持 | ✅ | 已实现。 |
39
+ | 文本补全缓存与重复请求合并 | ✅ | `/v1/chat/completions` 文本链路默认启用 60 秒短缓存、流式结果回放、in-flight 请求合并和相邻重复消息清理;可通过 `chat_completion_cache` 配置关闭或调整。 |
40
+ | 图片尺寸参数 | ❌ | 待实现。 |
41
+ | 服务端图片 URL 缓存 | ✅ | 已实现。 |
42
+ | `rt_token` 刷新 | ❌ | 待实现。 |
43
+ | 代理配置功能 | ✅ | 已支持网页端配置全局 HTTP / HTTPS / SOCKS5 / SOCKS5H 代理,并应用到出站请求。 |
44
+ | Anthropic 协议支持 | ❌ | 待实现。 |
docs/review.md ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ # Review
2
+
3
+ - [P2] Poll when the image tool was invoked
4
+ `services/protocol/conversation.py:586`
5
+ 对于没有输入图片的图像生成任务,延迟结果仍可能在 SSE 返回 `tool_invoked: true` 之后到达,但这里新的判断条件忽略了 `tool_invoked`。这样会直接返回中间文本,而不会继续轮询会话来拿图片 ID。
6
+
7
+ - [P2] Align CloudMail domain validation with the UI
8
+ `web/src/app/register/components/register-card.tsx:266`
9
+ `cloudmail_gen` 的 placeholder 写的是留空会使用服务默认域名,但后端 `create_mailbox` 不接受空域名。用户按 UI 提示保存后,这个 provider 会在真正创建地址前失败。
docs/upstream-sse-conversation.md ADDED
@@ -0,0 +1,271 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 上游 Conversation SSE 协议说明
2
+
3
+ Conversation SSE 是上游对话链路的流式返回协议。每条 SSE `data:` 通常是一段 JSON payload,也可能是协议标记或结束标记。客户端需要按顺序消费这些 payload,维护当前会话状态、文本内容、工具调用状态和图片结果指针。
4
+
5
+ ## 基本形态
6
+
7
+ 常见 payload 示例:
8
+
9
+ ```text
10
+ "v1"
11
+ {"type":"resume_conversation_token",...}
12
+ {"p":"","o":"add","v":{...}}
13
+ {"v":{...}}
14
+ {"p":"/message/content/parts/0","o":"append","v":"..."}
15
+ {"type":"server_ste_metadata","metadata":{...}}
16
+ [DONE]
17
+ ```
18
+
19
+ 处理建议:
20
+
21
+ | payload | 含义 | 处理方式 |
22
+ |:--|:--|:--|
23
+ | `"v1"` | 协议版本标记 | 可记录,通常不影响业务 |
24
+ | `[DONE]` | 当前 SSE 流结束 | 停止继续读取 |
25
+ | JSON object | 事件、消息或 patch | 按字段更新会话状态 |
26
+ | JSON string | 短文本 patch 或协议标记 | 结合上下文处理 |
27
+ | 非 JSON 内容 | 原始内容 | 保留为 raw 事件,避免中断流 |
28
+
29
+ ## 常用字段
30
+
31
+ | 字段 | 说明 |
32
+ |:--|:--|
33
+ | `type` | 上游事件类型,如 `resume_conversation_token`、`input_message`、`message_marker`、`title_generation`、`server_ste_metadata` |
34
+ | `conversation_id` | 当前会话 ID,可从多个事件中获得 |
35
+ | `p` | patch 路径,例如 `/message/content/parts/0` |
36
+ | `o` | patch 操作,例如 `add`、`append`、`replace`、`patch` |
37
+ | `v` | patch 值,可能是字符串、数组,也可能包含完整 message |
38
+ | `c` | 消息序号或游标,常见于 add 类事件 |
39
+ | `message.id` | 消息 ID |
40
+ | `message.author.role` | 消息角色,常见 `system`、`user`、`assistant`、`tool` |
41
+ | `message.content.content_type` | 内容类型,如 `text`、`multimodal_text`、`model_editable_context` |
42
+ | `message.content.parts` | 内容片段,可能包含文本、图片指针或多模态对象 |
43
+ | `message.status` | 消息状态,如 `in_progress`、`finished_successfully` |
44
+ | `message.end_turn` | 是否结束当前轮次 |
45
+ | `metadata.tool_invoked` | 本轮是否调用工具 |
46
+ | `metadata.turn_use_case` | 本轮用途,如 `text`、`multimodal` |
47
+ | `metadata.async_task_type` | 异步工具任务类型,图片生成通常为 `image_gen` |
48
+
49
+ ## 会话启动事件
50
+
51
+ 上游通常会先返回恢复令牌或会话令牌:
52
+
53
+ ```json
54
+ {
55
+ "type": "resume_conversation_token",
56
+ "kind": "topic",
57
+ "token": "...",
58
+ "conversation_id": "..."
59
+ }
60
+ ```
61
+
62
+ 这个事件主要用于标识会话和恢复上下文。业务层通常只需要保存 `conversation_id`,`token` 不应该暴露给下游用户。
63
+
64
+ ## 消息 add 场景
65
+
66
+ 完整消息可能通过 `add` 或带 `v.message` 的事件出现:
67
+
68
+ ```json
69
+ {
70
+ "p": "",
71
+ "o": "add",
72
+ "v": {
73
+ "message": {
74
+ "author": {"role": "assistant"},
75
+ "content": {"content_type": "text", "parts": [""]},
76
+ "status": "in_progress"
77
+ },
78
+ "conversation_id": "..."
79
+ },
80
+ "c": 3
81
+ }
82
+ ```
83
+
84
+ 此类事件常用于创建一条新消息。若消息角色为 `assistant`,后续文本通常会通过 patch 继续追加。
85
+
86
+ ## 文本增量场景
87
+
88
+ 文本输出通常由多条 patch 组成:
89
+
90
+ ```json
91
+ {"p":"/message/content/parts/0","o":"append","v":"Hello"}
92
+ {"v":" world"}
93
+ {"p":"","o":"patch","v":[
94
+ {"p":"/message/content/parts/0","o":"append","v":"!"},
95
+ {"p":"/message/status","o":"replace","v":"finished_successfully"},
96
+ {"p":"/message/end_turn","o":"replace","v":true}
97
+ ]}
98
+ ```
99
+
100
+ 处理要点:
101
+
102
+ | 形态 | 含义 |
103
+ |:--|:--|
104
+ | `p == "/message/content/parts/0"` 且 `o == "append"` | 向当前文本追加内容 |
105
+ | `o == "replace"` | 用新值替换目标字段 |
106
+ | `o == "patch"` 且 `v` 是数组 | 批量 patch,需要按数组顺序处理 |
107
+ | 只有 `v` 且 `v` 是字符串 | 可能是省略路径的文本增量,应结合当前文本流处理 |
108
+
109
+ ## 输入消息场景
110
+
111
+ 用户输入会以 `input_message` 或普通 `user` message 出现。图片编辑请求会包含用户上传的参考图:
112
+
113
+ ```json
114
+ {
115
+ "type": "input_message",
116
+ "input_message": {
117
+ "author": {"role": "user"},
118
+ "content": {
119
+ "content_type": "multimodal_text",
120
+ "parts": [
121
+ {"asset_pointer": "sediment://file_input"},
122
+ "编辑提示词"
123
+ ]
124
+ }
125
+ },
126
+ "conversation_id": "..."
127
+ }
128
+ ```
129
+
130
+ 这类 `sediment://...` 表示输入附件,不是生成结果。即使它可以被下载,也不能当作输出图片返回。
131
+
132
+ ## 图片工具成功场景
133
+
134
+ 图片生成或图片编辑成功时,上游一般会出现工具消息:
135
+
136
+ ```json
137
+ {
138
+ "v": {
139
+ "message": {
140
+ "author": {"role": "tool"},
141
+ "content": {
142
+ "content_type": "multimodal_text",
143
+ "parts": [
144
+ {"asset_pointer": "file-service://file_result"},
145
+ {"asset_pointer": "sediment://file_result"}
146
+ ]
147
+ },
148
+ "metadata": {"async_task_type": "image_gen"}
149
+ }
150
+ },
151
+ "conversation_id": "..."
152
+ }
153
+ ```
154
+
155
+ 只有同时满足以下条件的图片指针,才应该视为输出结果:
156
+
157
+ | 条件 | 说明 |
158
+ |:--|:--|
159
+ | `message.author.role == "tool"` | 来源是工具消息 |
160
+ | `metadata.async_task_type == "image_gen"` | 工具任务是图片生成 |
161
+ | `asset_pointer` 为 `file-service://...` 或 `sediment://...` | 指向可解析图片资源 |
162
+
163
+ ## 图片指针类型
164
+
165
+ | 指针 | 常见来源 | 说明 |
166
+ |:--|:--|:--|
167
+ | `file-service://file_xxx` | 图片工具输出 | 可通过文件下载接口解析 |
168
+ | `sediment://file_xxx` | 输入附件或图片工具输出 | 需要结合消息角色判断来源 |
169
+ | `file_upload` | 上传过程占位 | 通常不应作为输出 |
170
+
171
+ 不要只凭字符串里出现 `file_` 或 `sediment://` 就判定为输出图。必须结合消息角色和任务类型。
172
+
173
+ ## 策略拒绝场景
174
+
175
+ 当上游拒绝请求时,通常不会产生图片工具消息,而是返回普通 assistant 文本:
176
+
177
+ ```text
178
+ I can't assist with that request. If you have another type of modification...
179
+ ```
180
+
181
+ 常见伴随事件:
182
+
183
+ ```json
184
+ {"type":"title_generation","title":"Request Denied","conversation_id":"..."}
185
+ ```
186
+
187
+ ```json
188
+ {
189
+ "type": "server_ste_metadata",
190
+ "metadata": {
191
+ "tool_invoked": false,
192
+ "turn_use_case": "multimodal",
193
+ "did_prompt_contain_image": true
194
+ },
195
+ "conversation_id": "..."
196
+ }
197
+ ```
198
+
199
+ 处理要点:
200
+
201
+ | 条件 | 行为 |
202
+ |:--|:--|
203
+ | 有 assistant 拒绝文本 | 应返回文本消息 |
204
+ | `tool_invoked == false` | 说明没有实际工具结果 |
205
+ | 没有 `role=tool` 且 `async_task_type=image_gen` 的消息 | 不应收集输出图片 |
206
+ | 用户输入消息里有图片指针 | 仍然只视为输入附件 |
207
+
208
+ ## moderation 场景
209
+
210
+ 部分请求可能返回 moderation 事件:
211
+
212
+ ```json
213
+ {
214
+ "type": "moderation",
215
+ "moderation_response": {
216
+ "blocked": true
217
+ },
218
+ "conversation_id": "..."
219
+ }
220
+ ```
221
+
222
+ 若 `blocked == true`,应认为本轮被策略拦截。后续如有 assistant 文本,应优先返回该文本;若没有文本,可返回合适的错误信息。
223
+
224
+ ## marker 和 title 事件
225
+
226
+ 上游会返回一些辅助事件:
227
+
228
+ ```json
229
+ {"type":"message_marker","marker":"user_visible_token","event":"first"}
230
+ {"type":"message_marker","marker":"last_token","event":"last"}
231
+ {"type":"title_generation","title":"...","conversation_id":"..."}
232
+ ```
233
+
234
+ 这些事件通常用于前端展示、标题生成或流式状态标记,不代表实际文本内容或图片结果。
235
+
236
+ ## metadata 事件
237
+
238
+ `server_ste_metadata` 用于描述本轮调度和工具状态:
239
+
240
+ ```json
241
+ {
242
+ "type": "server_ste_metadata",
243
+ "metadata": {
244
+ "tool_invoked": true,
245
+ "turn_use_case": "multimodal",
246
+ "model_slug": "i-mini-m",
247
+ "did_prompt_contain_image": true
248
+ }
249
+ }
250
+ ```
251
+
252
+ 常用判断:
253
+
254
+ | 字段 | 说明 |
255
+ |:--|:--|
256
+ | `tool_invoked == true` | 上游认为本轮调用过工具 |
257
+ | `tool_invoked == false` | 上游未调用工具,常见于拒绝或纯文本响应 |
258
+ | `turn_use_case == "text"` | 按文本响应处理 |
259
+ | `turn_use_case == "multimodal"` | 多模态请求,不代表一定有图片输出 |
260
+ | `did_prompt_contain_image == true` | 输入包含图片,不代表输出包含图片 |
261
+
262
+ ## 结束后的结果判断
263
+
264
+ SSE 结束后可按以下顺序判断结果:
265
+
266
+ 1. 如果已经收集到图片工具输出指针,解析并下载输出图片。
267
+ 2. 如果没有输出图片指针,但有 assistant 文本,并且本轮被拦截或未调用工具,返回文本消息。
268
+ 3. 如果没有输出图片指针,但有 `conversation_id`,可查询完整会话明细,继续寻找图片工具输出。
269
+ 4. 查询完整会话时,仍然只读取 `role=tool` 且 `async_task_type=image_gen` 的消息。
270
+ 5. 如果没有图片结果也没有文本,返回上游异常或空结果错误。
271
+
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)
scripts/verify_oauth_refresh.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """验证 OAuth 账号的自动刷新是否生效。
2
+
3
+ 用法(在容器内,工作目录 /app):
4
+ uv run python scripts/verify_oauth_refresh.py # 只读诊断,安全
5
+ uv run python scripts/verify_oauth_refresh.py --force # 真正触发一次刷新
6
+
7
+ 只读模式:列出每个账号的 access_token 剩余有效期、是否带 refresh_token、
8
+ 上次刷新时间与上次刷新错误,判断"有没有料可刷"。
9
+ --force :对每个带 refresh_token 的账号强制走一次 refresh_access_token(force=True),
10
+ 直接验证 refresh_token + 本项目 client_id 能否从 OpenAI 换出新 access_token。
11
+ 这会真实轮换 access_token(新 token 有效,不损坏账号)。
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import sys
16
+
17
+ from services.account_service import account_service
18
+
19
+
20
+ def _fmt_remaining(seconds: int | None) -> str:
21
+ if seconds is None:
22
+ return "无法解析 exp"
23
+ if seconds <= 0:
24
+ return f"已过期 {(-seconds) // 3600}h"
25
+ return f"{seconds // 3600}h{(seconds % 3600) // 60}m 后过期"
26
+
27
+
28
+ def diagnose() -> list[str]:
29
+ """只读:打印每个账号的刷新就绪状态,返回带 refresh_token 的 token 列表。"""
30
+ tokens = account_service.list_tokens()
31
+ print(f"账号总数: {len(tokens)}\n")
32
+ refreshable: list[str] = []
33
+ for token in tokens:
34
+ account = account_service.get_account(token) or {}
35
+ remaining = account_service._token_expires_in(token)
36
+ has_rt = bool(str(account.get("refresh_token") or "").strip())
37
+ if has_rt:
38
+ refreshable.append(token)
39
+ print(f"- email={account.get('email') or '(未知)'}")
40
+ print(f" access_token[:20] = {token[:20]}...")
41
+ print(f" 距过期 = {_fmt_remaining(remaining)}")
42
+ print(f" refresh_token = {'有 ✅' if has_rt else '无 ❌(无法自动刷新)'}")
43
+ print(f" last_token_refresh_at = {account.get('last_token_refresh_at')}")
44
+ print(f" last_token_refresh_error = {account.get('last_token_refresh_error')}")
45
+ print()
46
+ return refreshable
47
+
48
+
49
+ def force_refresh(tokens: list[str]) -> None:
50
+ """对每个账号 force 刷新一次,并对比前后状态判断成败。"""
51
+ if not tokens:
52
+ print("没有带 refresh_token 的账号,无法验证刷新。")
53
+ return
54
+ print("=" * 60)
55
+ print(f"开始对 {len(tokens)} 个账号 force 刷新(真实调用 OpenAI)...\n")
56
+ ok = 0
57
+ for token in tokens:
58
+ before = account_service.get_account(token) or {}
59
+ new_token = account_service.refresh_access_token(token, force=True, event="manual_verify")
60
+ after = account_service.get_account(new_token) or {}
61
+ err = str(after.get("last_token_refresh_error") or "").strip()
62
+ rotated = new_token != token
63
+ success = bool(new_token) and not err
64
+ if success:
65
+ ok += 1
66
+ print(f"- email={before.get('email') or '(未知)'}")
67
+ print(f" 旧 access_token[:20] = {token[:20]}...")
68
+ print(f" 新 access_token[:20] = {new_token[:20]}...")
69
+ print(f" token 是否轮换 = {'是' if rotated else '否(exp 未到刷新窗口时可能返回原值)'}")
70
+ print(f" last_token_refresh_at = {after.get('last_token_refresh_at')}")
71
+ print(f" last_token_refresh_error = {after.get('last_token_refresh_error') or '无'}")
72
+ print(f" >>> 刷新结果 = {'成功 ✅' if success else '失败 ❌'}")
73
+ print()
74
+ print("=" * 60)
75
+ print(f"汇总: {ok}/{len(tokens)} 个账号刷新成功")
76
+ if ok == len(tokens):
77
+ print("✅ 自动刷新机制对这些账号完全可用——refresh_token 与 client_id 匹配。")
78
+ else:
79
+ print("❌ 有账号刷新失败,看上面的 last_token_refresh_error,或 docker logs 里的 [oauth-login]/refresh 日志。")
80
+
81
+
82
+ def main() -> None:
83
+ do_force = "--force" in sys.argv[1:]
84
+ refreshable = diagnose()
85
+ if do_force:
86
+ force_refresh(refreshable)
87
+ else:
88
+ print("提示: 加 --force 参数可真正触发一次刷新以验证能否成功。")
89
+
90
+
91
+ if __name__ == "__main__":
92
+ main()
services/__init__.py ADDED
File without changes
services/account_service.py ADDED
@@ -0,0 +1,1651 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import secrets
6
+ import time
7
+ import uuid
8
+ from concurrent.futures import ThreadPoolExecutor, as_completed
9
+ from datetime import datetime, timedelta, timezone
10
+ from pathlib import Path
11
+ from threading import Condition, Lock, Thread
12
+ from typing import Any
13
+ from urllib.parse import urlencode
14
+
15
+ from services.config import config
16
+ from services.log_service import (
17
+ LOG_TYPE_ACCOUNT,
18
+ log_service,
19
+ )
20
+ from services.storage.base import StorageBackend
21
+ from utils.helper import anonymize_token
22
+
23
+
24
+ class AccountService:
25
+ """账号池服务,使用 token -> account 的 dict 保存账号。"""
26
+
27
+ _NEW_ACCOUNT_INVALID_GRACE_SECONDS = 10 * 60
28
+ _INVALID_CONFIRM_SECONDS = 30
29
+ _ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 24 * 60 * 60
30
+ _REFRESH_TOKEN_KEEPALIVE_SECONDS = 3 * 24 * 60 * 60
31
+ _REFRESH_TOKEN_KEEPALIVE_ERROR_BACKOFF_SECONDS = 6 * 60 * 60
32
+ _REFRESH_TOKEN_KEEPALIVE_BATCH_SIZE = 3
33
+ _TOKEN_REFRESH_ERROR_BACKOFF_SECONDS = 5 * 60
34
+ _OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token"
35
+ _OAUTH_CLIENT_ID = "app_2SKx67EdpoN0G6j64rFvigXD"
36
+ _OAUTH_USER_AGENT = (
37
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
38
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
39
+ "Chrome/145.0.0.0 Safari/537.36"
40
+ )
41
+
42
+ # 刷新进度追踪
43
+ _refresh_progress: dict[str, dict] = {}
44
+ _refresh_progress_lock = Lock()
45
+ # 重新登录进度追踪
46
+ _relogin_progress: dict[str, dict] = {}
47
+ _relogin_progress_lock = Lock()
48
+
49
+ def __init__(self, storage_backend: StorageBackend):
50
+ self.storage = storage_backend
51
+ self._lock = Lock()
52
+ self._token_refresh_lock = Lock()
53
+ self._image_slot_condition = Condition(self._lock)
54
+ self._index = 0
55
+ self._accounts = self._load_accounts()
56
+ self._image_inflight: dict[str, int] = {}
57
+ self._token_aliases: dict[str, str] = {}
58
+ self._cumulative_total = self._load_cumulative_total()
59
+
60
+ def _get_cumulative_file(self) -> Path:
61
+ from services.config import DATA_DIR
62
+ return DATA_DIR / ".cumulative_total"
63
+
64
+ def _load_cumulative_total(self) -> int:
65
+ try:
66
+ f = self._get_cumulative_file()
67
+ if f.exists():
68
+ return int(f.read_text().strip())
69
+ except Exception:
70
+ pass
71
+ return len(self._accounts)
72
+
73
+ def _save_cumulative_total(self) -> None:
74
+ try:
75
+ self._get_cumulative_file().write_text(str(self._cumulative_total))
76
+ except Exception:
77
+ pass
78
+
79
+ @staticmethod
80
+ def _now() -> str:
81
+ return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
82
+
83
+ @staticmethod
84
+ def _decode_jwt_payload(token: str) -> dict:
85
+ try:
86
+ payload = str(token or "").split(".")[1]
87
+ payload += "=" * ((4 - len(payload) % 4) % 4)
88
+ import base64
89
+ import json
90
+ data = json.loads(base64.urlsafe_b64decode(payload.encode("ascii")))
91
+ return data if isinstance(data, dict) else {}
92
+ except Exception:
93
+ return {}
94
+
95
+ @staticmethod
96
+ def _parse_time(value: object) -> datetime | None:
97
+ raw = str(value or "").strip()
98
+ if not raw:
99
+ return None
100
+ try:
101
+ parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
102
+ except Exception:
103
+ try:
104
+ parsed = datetime.strptime(raw, "%Y-%m-%d %H:%M:%S")
105
+ except Exception:
106
+ return None
107
+ if parsed.tzinfo is None:
108
+ parsed = parsed.replace(tzinfo=timezone.utc)
109
+ return parsed.astimezone(timezone.utc)
110
+
111
+ @staticmethod
112
+ def _timestamp_to_iso(value: object) -> str:
113
+ try:
114
+ ts = int(value)
115
+ except (TypeError, ValueError):
116
+ return ""
117
+ tz = timezone(timedelta(hours=8))
118
+ return datetime.fromtimestamp(ts, tz=timezone.utc).astimezone(tz).isoformat()
119
+
120
+ def _load_accounts(self) -> dict[str, dict]:
121
+ accounts = self.storage.load_accounts()
122
+ return {
123
+ normalized["access_token"]: normalized
124
+ for item in accounts
125
+ if (normalized := self._normalize_account(item)) is not None
126
+ }
127
+
128
+ def _save_accounts(self) -> None:
129
+ self.storage.save_accounts(list(self._accounts.values()))
130
+
131
+ @staticmethod
132
+ def _is_image_account_available(account: dict) -> bool:
133
+ if not isinstance(account, dict):
134
+ return False
135
+ if account.get("status") in {"禁用", "限流", "异常"}:
136
+ return False
137
+ if bool(account.get("image_quota_unknown")):
138
+ return True
139
+ return int(account.get("quota") or 0) > 0
140
+
141
+ @classmethod
142
+ def _account_matches_plan_type(cls, account: dict, plan_type: str | None = None) -> bool:
143
+ if not plan_type:
144
+ return True
145
+ normalized_plan = cls._normalize_account_type(plan_type)
146
+ normalized_account = cls._normalize_account_type(account.get("type"))
147
+ if not normalized_plan or not normalized_account:
148
+ return False
149
+ return normalized_plan.lower() == normalized_account.lower()
150
+
151
+ @classmethod
152
+ def _account_matches_source_type(cls, account: dict, source_type: str | None = None) -> bool:
153
+ if not source_type:
154
+ return True
155
+ return cls._normalize_source_type(account.get("source_type")) == cls._normalize_source_type(source_type)
156
+
157
+ @classmethod
158
+ def _account_matches_any_plan_type(cls, account: dict, plan_types: set[str] | tuple[str, ...] | None = None) -> bool:
159
+ if not plan_types:
160
+ return True
161
+ normalized_account = cls._normalize_account_type(account.get("type"))
162
+ normalized_plans = {
163
+ normalized
164
+ for plan_type in plan_types
165
+ if (normalized := cls._normalize_account_type(plan_type))
166
+ }
167
+ return bool(normalized_account and normalized_account in normalized_plans)
168
+
169
+ @staticmethod
170
+ def _normalize_source_type(value: object) -> str:
171
+ return str(value or "web").strip().lower() or "web"
172
+
173
+ @staticmethod
174
+ def _normalize_account_type(value: object) -> str | None:
175
+ raw = str(value or "").strip()
176
+ if not raw:
177
+ return None
178
+ key = raw.lower().replace("-", "_").replace(" ", "_")
179
+ compact = key.replace("_", "")
180
+ aliases = {
181
+ "free": "free",
182
+ "plus": "Plus",
183
+ "pro": "Pro",
184
+ "prolite": "ProLite",
185
+ "team": "Team",
186
+ "business": "Team",
187
+ "enterprise": "Enterprise",
188
+ }
189
+ return aliases.get(compact) or aliases.get(key) or raw
190
+
191
+ def _search_account_type(self, payload: object) -> str | None:
192
+ if isinstance(payload, dict):
193
+ for key in ("plan_type", "account_plan", "account_type", "subscription_type", "type"):
194
+ plan = self._normalize_account_type(payload.get(key))
195
+ if plan:
196
+ return plan
197
+ for value in payload.values():
198
+ plan = self._search_account_type(value)
199
+ if plan:
200
+ return plan
201
+ elif isinstance(payload, list):
202
+ for value in payload:
203
+ plan = self._search_account_type(value)
204
+ if plan:
205
+ return plan
206
+ return None
207
+
208
+ def _normalize_account(self, item: dict) -> dict | None:
209
+ if not isinstance(item, dict):
210
+ return None
211
+ access_token = item.get("access_token") or item.get("accessToken") or ""
212
+ if not access_token:
213
+ return None
214
+ normalized = dict(item)
215
+ normalized.pop("accessToken", None)
216
+ normalized["access_token"] = access_token
217
+ if str(normalized.get("type") or "").strip().lower() == "codex":
218
+ normalized["export_type"] = "codex"
219
+ normalized.pop("type", None)
220
+ normalized["type"] = normalized.get("type") or "free"
221
+ normalized["status"] = normalized.get("status") or "正常"
222
+ normalized["quota"] = max(0, int(normalized.get("quota") if normalized.get("quota") is not None else 0))
223
+ normalized["image_quota_unknown"] = bool(normalized.get("image_quota_unknown"))
224
+ normalized["email"] = normalized.get("email") or None
225
+ normalized["user_id"] = normalized.get("user_id") or None
226
+ normalized["proxy"] = str(normalized.get("proxy") or "").strip()
227
+ source_type = normalized.get("source_type")
228
+ if not source_type and str(normalized.get("export_type") or "").strip().lower() == "codex":
229
+ source_type = "codex"
230
+ normalized["source_type"] = self._normalize_source_type(source_type)
231
+ limits_progress = normalized.get("limits_progress")
232
+ normalized["limits_progress"] = limits_progress if isinstance(limits_progress, list) else []
233
+ normalized["default_model_slug"] = normalized.get("default_model_slug") or None
234
+ normalized["restore_at"] = normalized.get("restore_at") or None
235
+ normalized["success"] = int(normalized.get("success") or 0)
236
+ normalized["fail"] = int(normalized.get("fail") or 0)
237
+ normalized["invalid_count"] = int(normalized.get("invalid_count") or 0)
238
+ normalized["last_used_at"] = normalized.get("last_used_at")
239
+ normalized["last_invalid_at"] = normalized.get("last_invalid_at") or None
240
+ normalized["last_refresh_error"] = normalized.get("last_refresh_error") or None
241
+ normalized["last_refresh_error_at"] = normalized.get("last_refresh_error_at") or None
242
+ normalized["last_token_refresh_at"] = normalized.get("last_token_refresh_at") or None
243
+ normalized["last_token_refresh_error"] = normalized.get("last_token_refresh_error") or None
244
+ normalized["last_token_refresh_error_at"] = normalized.get("last_token_refresh_error_at") or None
245
+ normalized["created_at"] = normalized.get("created_at") or AccountService._now()
246
+ return normalized
247
+
248
+ @staticmethod
249
+ def _jwt_exp(access_token: str) -> int:
250
+ try:
251
+ return int(AccountService._decode_jwt_payload(access_token).get("exp") or 0)
252
+ except (TypeError, ValueError):
253
+ return 0
254
+
255
+ @classmethod
256
+ def _token_expires_in(cls, access_token: str) -> int | None:
257
+ exp = cls._jwt_exp(access_token)
258
+ if exp <= 0:
259
+ return None
260
+ return exp - int(time.time())
261
+
262
+ @classmethod
263
+ def _token_needs_refresh(cls, access_token: str, *, force: bool = False) -> bool:
264
+ if force:
265
+ return True
266
+ remaining = cls._token_expires_in(access_token)
267
+ return remaining is not None and remaining <= cls._ACCESS_TOKEN_REFRESH_SKEW_SECONDS
268
+
269
+ @classmethod
270
+ def _token_issued_at(cls, access_token: str) -> datetime | None:
271
+ try:
272
+ iat = int(cls._decode_jwt_payload(access_token).get("iat") or 0)
273
+ except (TypeError, ValueError):
274
+ return None
275
+ if iat <= 0:
276
+ return None
277
+ return datetime.fromtimestamp(iat, tz=timezone.utc)
278
+
279
+ @staticmethod
280
+ def _safe_response_text(response: object, limit: int = 300) -> str:
281
+ try:
282
+ return str(getattr(response, "text", "") or "")[:limit]
283
+ except Exception:
284
+ return ""
285
+
286
+ def _resolve_access_token_locked(self, access_token: str) -> str:
287
+ token = str(access_token or "").strip()
288
+ seen: set[str] = set()
289
+ while token and token not in self._accounts and token in self._token_aliases and token not in seen:
290
+ seen.add(token)
291
+ token = self._token_aliases.get(token, token)
292
+ return token
293
+
294
+ def resolve_access_token(self, access_token: str) -> str:
295
+ if not access_token:
296
+ return ""
297
+ with self._lock:
298
+ return self._resolve_access_token_locked(access_token)
299
+
300
+ def _get_account_for_token(self, access_token: str) -> tuple[str, dict | None]:
301
+ with self._lock:
302
+ resolved = self._resolve_access_token_locked(access_token)
303
+ account = self._accounts.get(resolved)
304
+ return resolved, dict(account) if account else None
305
+
306
+ def _record_token_refresh_error(self, access_token: str, event: str, error: str) -> None:
307
+ now = datetime.now(timezone.utc).isoformat()
308
+ with self._lock:
309
+ resolved = self._resolve_access_token_locked(access_token)
310
+ current = self._accounts.get(resolved)
311
+ if current is None:
312
+ return
313
+ next_item = dict(current)
314
+ next_item["last_token_refresh_error"] = str(error or "refresh token failed")
315
+ next_item["last_token_refresh_error_at"] = now
316
+ account = self._normalize_account(next_item)
317
+ if account is not None:
318
+ self._accounts[resolved] = account
319
+ self._save_accounts()
320
+ log_service.add(
321
+ LOG_TYPE_ACCOUNT,
322
+ "refresh_token 刷新 access_token 失败",
323
+ {"source": event, "token": anonymize_token(access_token), "error": str(error or "")},
324
+ )
325
+
326
+ def _recent_token_refresh_error(self, account: dict) -> bool:
327
+ last_error_at = self._parse_time(account.get("last_token_refresh_error_at"))
328
+ if last_error_at is None:
329
+ return False
330
+ return (datetime.now(timezone.utc) - last_error_at).total_seconds() < self._TOKEN_REFRESH_ERROR_BACKOFF_SECONDS
331
+
332
+ def _recent_refresh_token_keepalive_error(self, account: dict, now: datetime) -> bool:
333
+ last_error_at = self._parse_time(account.get("last_token_refresh_error_at"))
334
+ if last_error_at is None:
335
+ return False
336
+ return (now - last_error_at).total_seconds() < self._REFRESH_TOKEN_KEEPALIVE_ERROR_BACKOFF_SECONDS
337
+
338
+ def _refresh_token_keepalive_anchor(self, account: dict) -> datetime | None:
339
+ return (
340
+ self._parse_time(account.get("last_token_refresh_at"))
341
+ or self._token_issued_at(str(account.get("access_token") or ""))
342
+ or self._parse_time(account.get("created_at"))
343
+ )
344
+
345
+ def _refresh_token_keepalive_due_at(self, account: dict, now: datetime) -> datetime | None:
346
+ if not str(account.get("refresh_token") or "").strip():
347
+ return None
348
+ if account.get("status") == "禁用":
349
+ return None
350
+ if self._recent_refresh_token_keepalive_error(account, now):
351
+ return None
352
+ anchor = self._refresh_token_keepalive_anchor(account)
353
+ if anchor is None:
354
+ return now
355
+ due_at = anchor + timedelta(seconds=self._REFRESH_TOKEN_KEEPALIVE_SECONDS)
356
+ return due_at if due_at <= now else None
357
+
358
+ def _request_access_token_refresh(self, refresh_token: str, account: dict | None = None) -> dict[str, str]:
359
+ from curl_cffi import requests
360
+ from services.proxy_service import proxy_settings
361
+
362
+ session = requests.Session(**proxy_settings.build_session_kwargs(account=account, impersonate="chrome", verify=True))
363
+ try:
364
+ response = session.post(
365
+ self._OAUTH_TOKEN_URL,
366
+ headers={
367
+ "Accept": "application/json",
368
+ "Content-Type": "application/x-www-form-urlencoded",
369
+ "User-Agent": self._OAUTH_USER_AGENT,
370
+ },
371
+ data={
372
+ "grant_type": "refresh_token",
373
+ "refresh_token": refresh_token,
374
+ "client_id": self._OAUTH_CLIENT_ID,
375
+ },
376
+ timeout=60,
377
+ )
378
+ data = response.json() if response.text else {}
379
+ if response.status_code != 200 or not isinstance(data, dict) or not data.get("access_token"):
380
+ detail = ""
381
+ if isinstance(data, dict):
382
+ detail = str(data.get("error_description") or data.get("error") or data.get("message") or "")
383
+ detail = detail or self._safe_response_text(response)
384
+ raise RuntimeError(f"oauth_refresh_http_{response.status_code}{': ' + detail if detail else ''}")
385
+ return {
386
+ "access_token": str(data.get("access_token") or "").strip(),
387
+ "refresh_token": str(data.get("refresh_token") or refresh_token).strip(),
388
+ "id_token": str(data.get("id_token") or "").strip(),
389
+ }
390
+ finally:
391
+ session.close()
392
+
393
+ def _apply_refreshed_tokens(self, old_access_token: str, token_data: dict, event: str) -> str:
394
+ now = datetime.now(timezone.utc).isoformat()
395
+ with self._image_slot_condition:
396
+ old_token = self._resolve_access_token_locked(old_access_token)
397
+ current = self._accounts.get(old_token)
398
+ if current is None:
399
+ return old_token
400
+ new_token = str(token_data.get("access_token") or old_token).strip()
401
+ if not new_token:
402
+ return old_token
403
+
404
+ next_item = dict(current)
405
+ next_item["access_token"] = new_token
406
+ if token_data.get("refresh_token"):
407
+ next_item["refresh_token"] = str(token_data.get("refresh_token") or "").strip()
408
+ if token_data.get("id_token"):
409
+ next_item["id_token"] = str(token_data.get("id_token") or "").strip()
410
+ next_item["last_token_refresh_at"] = now
411
+ next_item["last_token_refresh_error"] = None
412
+ next_item["last_token_refresh_error_at"] = None
413
+ next_item["invalid_count"] = 0
414
+ next_item["last_invalid_at"] = None
415
+ next_item["last_refresh_error"] = None
416
+ next_item["last_refresh_error_at"] = None
417
+
418
+ account = self._normalize_account(next_item)
419
+ if account is None:
420
+ return old_token
421
+
422
+ rotated = new_token != old_token
423
+ if rotated:
424
+ self._accounts.pop(old_token, None)
425
+ self._token_aliases[old_token] = new_token
426
+ old_inflight = int(self._image_inflight.pop(old_token, 0))
427
+ if old_inflight:
428
+ self._image_inflight[new_token] = int(self._image_inflight.get(new_token, 0)) + old_inflight
429
+ self._accounts[new_token] = account
430
+ self._save_accounts()
431
+ self._image_slot_condition.notify_all()
432
+
433
+ log_service.add(
434
+ LOG_TYPE_ACCOUNT,
435
+ "refresh_token 已刷新 access_token",
436
+ {"source": event, "token": anonymize_token(new_token), "rotated": rotated},
437
+ )
438
+ return new_token
439
+
440
+ def refresh_access_token(self, access_token: str, *, force: bool = False, event: str = "refresh_access_token") -> str:
441
+ if not access_token:
442
+ return ""
443
+ with self._token_refresh_lock:
444
+ resolved_token, account = self._get_account_for_token(access_token)
445
+ if not account:
446
+ return access_token
447
+ active_token = str(account.get("access_token") or resolved_token or access_token)
448
+ if not self._token_needs_refresh(active_token, force=force):
449
+ return active_token
450
+ refresh_token = str(account.get("refresh_token") or "").strip()
451
+ if not refresh_token:
452
+ return active_token
453
+ if not force and self._recent_token_refresh_error(account):
454
+ return active_token
455
+ try:
456
+ token_data = self._request_access_token_refresh(refresh_token, account)
457
+ except Exception as exc:
458
+ error_str = str(exc or "")
459
+ self._record_token_refresh_error(active_token, event, error_str)
460
+ # 如果是 app_session_terminated 错误,尝试密码重新登录
461
+ if "app_session_terminated" in error_str.lower():
462
+ # 获取账号信息(email, password)
463
+ email = str(account.get("email") or "").strip()
464
+ password = str(account.get("password") or "").strip()
465
+ if email and password:
466
+ # 创建新线程执行密码重新登录
467
+ t = Thread(
468
+ target=self._password_re_login_thread,
469
+ args=(active_token, email, password, event),
470
+ daemon=True,
471
+ )
472
+ t.start()
473
+ return active_token
474
+ return self._apply_refreshed_tokens(active_token, token_data, event)
475
+
476
+ def _password_re_login_thread(self, access_token: str, email: str, password: str, event: str, progress_id: str | None = None) -> None:
477
+ """密码重新登录线程入口"""
478
+ try:
479
+ result = self._login_with_password(email, password)
480
+ if result.get("ok"):
481
+ # 登录成功,更新账号
482
+ new_access_token = result.get("access_token", "")
483
+ new_refresh_token = result.get("refresh_token", "")
484
+ new_id_token = result.get("id_token", "")
485
+ new_expires_at = result.get("expires_at")
486
+
487
+ # 构建 token_data 供 _apply_refreshed_tokens 使用
488
+ token_data = {
489
+ "access_token": new_access_token,
490
+ "refresh_token": new_refresh_token,
491
+ "id_token": new_id_token,
492
+ }
493
+
494
+ # 使用 _apply_refreshed_tokens 更新账号(处理 token 别名)
495
+ new_token = self._apply_refreshed_tokens(access_token, token_data, f"{event}:password_relogin")
496
+
497
+ # 额外更新 source_type 和 status(静默,避免重复日志)
498
+ self.update_account(new_token, {
499
+ "source_type": result.get("source_type", "password"),
500
+ "status": "正常",
501
+ }, quiet=True)
502
+
503
+ log_service.add(
504
+ LOG_TYPE_ACCOUNT,
505
+ "更新账号",
506
+ {
507
+ "source": event,
508
+ "old_token": anonymize_token(access_token),
509
+ "new_token": anonymize_token(new_access_token),
510
+ "email": email,
511
+ "status": "成功",
512
+ },
513
+ )
514
+ if progress_id:
515
+ self.update_relogin_progress(progress_id, access_token, "成功")
516
+ else:
517
+ # 登录失败
518
+ error_type = result.get("error", "")
519
+ if error_type == "password_verify_failed_403" and isinstance(result.get("detail"), dict):
520
+ log_service.add(
521
+ LOG_TYPE_ACCOUNT,
522
+ "更新账号",
523
+ {
524
+ "source": event,
525
+ "token": anonymize_token(access_token),
526
+ "email": email,
527
+ "status": "失败",
528
+ "error": error_type,
529
+ "detail": result.get("detail", {}),
530
+ },
531
+ )
532
+ detail_error = result["detail"].get("error", {})
533
+ if isinstance(detail_error, dict) and detail_error.get("code") == "account_deactivated":
534
+ # 账号已删除/停用 → 标记为禁用
535
+ self.update_account(access_token, {"status": "禁用", "quota": 0}, quiet=True)
536
+ account = self.get_account(access_token) or {}
537
+ log_service.add(
538
+ LOG_TYPE_ACCOUNT,
539
+ "账号已停用-标记禁用",
540
+ {
541
+ "source": event,
542
+ "token": anonymize_token(access_token),
543
+ "email": email,
544
+ "detail": result.get("detail", {}),
545
+ },
546
+ )
547
+ if progress_id:
548
+ self.update_relogin_progress(progress_id, access_token, "禁用")
549
+ else:
550
+ # 永久故障:将账号标记为异常(或自动移除)
551
+ self.remove_invalid_token(access_token, f"{event}:password_relogin_failed", quiet=True)
552
+ if progress_id:
553
+ self.update_relogin_progress(progress_id, access_token, "异常", error_type)
554
+ else:
555
+ log_service.add(
556
+ LOG_TYPE_ACCOUNT,
557
+ "更新账号",
558
+ {
559
+ "source": event,
560
+ "token": anonymize_token(access_token),
561
+ "email": email,
562
+ "status": "失败",
563
+ "error": error_type,
564
+ "detail": result.get("detail", {}),
565
+ },
566
+ )
567
+ # 永久故障:将账号标记为异常(或自动移除)
568
+ self.remove_invalid_token(access_token, f"{event}:password_relogin_failed", quiet=True)
569
+ if progress_id:
570
+ self.update_relogin_progress(progress_id, access_token, "异常", error_type)
571
+ except Exception as exc:
572
+ log_service.add(
573
+ LOG_TYPE_ACCOUNT,
574
+ "更新账号",
575
+ {
576
+ "source": event,
577
+ "token": anonymize_token(access_token),
578
+ "email": email,
579
+ "status": "异常",
580
+ "error": str(exc),
581
+ },
582
+ )
583
+ # 将账号标记为异常(或自动移除)
584
+ self.remove_invalid_token(access_token, f"{event}:password_relogin_exception", quiet=True)
585
+ if progress_id:
586
+ self.update_relogin_progress(progress_id, access_token, "异常", str(exc))
587
+
588
+ def _login_with_password(self, email: str, password: str) -> dict:
589
+ """通过邮箱+密码登录,返回 {access_token, refresh_token, id_token, ...}"""
590
+ from curl_cffi import requests
591
+
592
+ # 常量
593
+ auth_base = "https://auth.openai.com"
594
+ platform_oauth_audience = "https://api.openai.com/v1"
595
+ platform_auth0_client = "eyJuYW1lIjoiYXV0aDAtc3BhLWpzIiwidmVyc2lvbiI6IjEuMjEuMCJ9"
596
+ platform_oauth_client_id = self._OAUTH_CLIENT_ID
597
+ platform_oauth_redirect_uri = "https://platform.openai.com/auth/callback"
598
+ user_agent = self._OAUTH_USER_AGENT
599
+
600
+ # 创建 session
601
+ session_kwargs = {"impersonate": "chrome110", "verify": False}
602
+ proxy = config.get_proxy_settings()
603
+ if proxy:
604
+ session_kwargs["proxy"] = proxy
605
+ session = requests.Session(**session_kwargs)
606
+
607
+ try:
608
+ device_id = str(uuid.uuid4())
609
+
610
+ # ─── 方式2: OAuth authorize 流程 ──────────────────────────
611
+ # 使用 Platform Client + PKCE(与注册流程相同)
612
+
613
+ from utils.pkce import generate_pkce
614
+ code_verifier, code_challenge = generate_pkce()
615
+
616
+ # ② 发起 OAuth authorize 请求 (使用 Platform Client + PKCE)
617
+ session.cookies.set("oai-did", device_id, domain=".auth.openai.com")
618
+ session.cookies.set("oai-did", device_id, domain="auth.openai.com")
619
+ params = {
620
+ "issuer": auth_base,
621
+ "client_id": platform_oauth_client_id,
622
+ "audience": platform_oauth_audience,
623
+ "redirect_uri": platform_oauth_redirect_uri,
624
+ "device_id": device_id,
625
+ "screen_hint": "login_or_signup",
626
+ "max_age": "0",
627
+ "login_hint": email,
628
+ "scope": "openid profile email offline_access",
629
+ "response_type": "code",
630
+ "response_mode": "query",
631
+ "state": secrets.token_urlsafe(32),
632
+ "nonce": secrets.token_urlsafe(32),
633
+ "code_challenge": code_challenge,
634
+ "code_challenge_method": "S256",
635
+ "auth0Client": platform_auth0_client,
636
+ }
637
+ authorize_url = f"{auth_base}/api/accounts/authorize?{urlencode(params)}"
638
+ resp = session.get(
639
+ authorize_url,
640
+ headers={
641
+ "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
642
+ "accept-language": "zh-CN,zh;q=0.9,en;q=0.8",
643
+ "user-agent": user_agent,
644
+ "sec-ch-ua": '"Chromium";v="145", "Google Chrome";v="145", "Not/A)Brand";v="99"',
645
+ "sec-ch-ua-mobile": "?0",
646
+ "sec-ch-ua-platform": '"Windows"',
647
+ "sec-fetch-dest": "document",
648
+ "sec-fetch-mode": "navigate",
649
+ "sec-fetch-site": "cross-site",
650
+ "sec-fetch-user": "?1",
651
+ "upgrade-insecure-requests": "1",
652
+ "referer": "https://platform.openai.com/",
653
+ },
654
+ allow_redirects=True,
655
+ timeout=30,
656
+ )
657
+
658
+ if resp.status_code not in (200, 302):
659
+ return {"ok": False, "error": f"authorize_failed_{resp.status_code}", "detail": {"url": resp.url, "text": resp.text[:500]}}
660
+
661
+ # 检测最终 URL 是否指向错误页面
662
+ final_url = str(resp.url)
663
+ if "/error" in final_url and "payload=" in final_url:
664
+ from urllib.parse import parse_qs, urlparse
665
+ try:
666
+ parsed_query = parse_qs(urlparse(final_url).query)
667
+ error_payload_b64 = parsed_query.get("payload", [""])[0]
668
+ error_payload_b64 += "=" * ((4 - len(error_payload_b64) % 4) % 4)
669
+ error_payload = json.loads(base64.b64decode(error_payload_b64))
670
+ error_code = error_payload.get("errorCode", "")
671
+ if error_code == "rate_limit_exceeded":
672
+ return {"ok": False, "error": "rate_limit_exceeded", "detail": error_payload}
673
+ else:
674
+ return {"ok": False, "error": f"authorize_error_{error_code}", "detail": error_payload}
675
+ except Exception as e:
676
+ return {"ok": False, "error": "authorize_redirect_error", "detail": {"url": final_url, "parse_error": str(e)}}
677
+
678
+ # ③ 提交密码验证
679
+ login_headers = {
680
+ "accept": "application/json",
681
+ "accept-language": "zh-CN,zh;q=0.9",
682
+ "content-type": "application/json",
683
+ "origin": auth_base,
684
+ "priority": "u=1, i",
685
+ "user-agent": user_agent,
686
+ "sec-ch-ua": '"Chromium";v="145", "Google Chrome";v="145", "Not/A)Brand";v="99"',
687
+ "sec-ch-ua-mobile": "?0",
688
+ "sec-ch-ua-platform": '"Windows"',
689
+ "sec-fetch-dest": "empty",
690
+ "sec-fetch-mode": "cors",
691
+ "sec-fetch-site": "same-origin",
692
+ "referer": f"{auth_base}/email-verification",
693
+ "oai-device-id": device_id,
694
+ }
695
+
696
+ # 添加 sentinel token
697
+ try:
698
+ from utils.sentinel import build_sentinel_token
699
+ sentinel_val, oai_sc_val = build_sentinel_token(session, device_id, "password_verify")
700
+ login_headers["openai-sentinel-token"] = sentinel_val
701
+ if oai_sc_val:
702
+ session.cookies.set("oai-sc", oai_sc_val, domain=".openai.com")
703
+ except Exception:
704
+ pass
705
+
706
+ login_resp = session.post(
707
+ f"{auth_base}/api/accounts/password/verify",
708
+ headers=login_headers,
709
+ json={"password": password},
710
+ timeout=30,
711
+ )
712
+
713
+ login_data = {}
714
+ try:
715
+ login_data = login_resp.json() if login_resp.text else {}
716
+ except Exception:
717
+ pass
718
+
719
+ if login_resp.status_code != 200:
720
+ error_code = login_data.get("error", {}).get("code", "")
721
+ error_msg = login_data.get("error", {}).get("message", "")
722
+ if error_code == "unsupported_country_region_territory":
723
+ return {"ok": False, "error": "unsupported_country_region_territory", "detail": login_data}
724
+ elif error_code == "invalid_state":
725
+ return {"ok": False, "error": "invalid_state", "detail": login_data}
726
+ elif "Invalid credentials" in error_msg or "wrong password" in error_msg.lower():
727
+ return {"ok": False, "error": "invalid_password", "detail": login_data}
728
+ return {"ok": False, "error": f"password_verify_failed_{login_resp.status_code}", "detail": login_data}
729
+
730
+ # 获取 authorization code
731
+ continue_url = str(login_data.get("continue_url") or "").strip()
732
+ auth_code = ""
733
+ if continue_url:
734
+ from urllib.parse import parse_qs, urlparse
735
+ parsed_params = parse_qs(urlparse(continue_url).query)
736
+ auth_code = str((parsed_params.get("code") or [""])[0]).strip()
737
+
738
+ # ─── 处理邮箱 OTP 验证 ──────────────────────────
739
+ if not auth_code:
740
+ page_type = ""
741
+ page_info = login_data.get("page")
742
+ if isinstance(page_info, dict):
743
+ page_type = str(page_info.get("type") or "")
744
+
745
+ if page_type == "email_otp_verification":
746
+ # 需要验证码才能登录,直接标记为账号异常
747
+ return {"ok": False, "error": "need_verification_code", "detail": login_data}
748
+ else:
749
+ return {"ok": False, "error": "no_auth_code", "detail": login_data}
750
+
751
+ # ④ 用 code 换 token (使用 Platform Client + code_verifier,与注册流程相同)
752
+ platform_base = "https://platform.openai.com"
753
+ token_resp = session.post(
754
+ f"{auth_base}/api/accounts/oauth/token",
755
+ headers={
756
+ "accept": "*/*",
757
+ "accept-language": "zh-CN,zh;q=0.9",
758
+ "auth0-client": platform_auth0_client,
759
+ "cache-control": "no-cache",
760
+ "content-type": "application/json",
761
+ "origin": platform_base,
762
+ "pragma": "no-cache",
763
+ "priority": "u=1, i",
764
+ "referer": f"{platform_base}/",
765
+ "sec-ch-ua": '"Chromium";v="145", "Google Chrome";v="145", "Not/A)Brand";v="99"',
766
+ "sec-ch-ua-mobile": "?0",
767
+ "sec-ch-ua-platform": '"Windows"',
768
+ "sec-fetch-dest": "empty",
769
+ "sec-fetch-mode": "cors",
770
+ "sec-fetch-site": "same-site",
771
+ "user-agent": user_agent,
772
+ },
773
+ json={
774
+ "client_id": platform_oauth_client_id,
775
+ "code_verifier": code_verifier,
776
+ "grant_type": "authorization_code",
777
+ "code": auth_code,
778
+ "redirect_uri": platform_oauth_redirect_uri,
779
+ },
780
+ verify=False,
781
+ timeout=60,
782
+ )
783
+
784
+ token_data = {}
785
+ try:
786
+ token_data = token_resp.json() if token_resp.text else {}
787
+ except Exception:
788
+ pass
789
+
790
+ if token_resp.status_code != 200 or not token_data.get("access_token"):
791
+ return {"ok": False, "error": "token_exchange_failed", "detail": token_data}
792
+
793
+ access_token = str(token_data.get("access_token") or "").strip()
794
+ refresh_token = str(token_data.get("refresh_token") or "").strip()
795
+ id_token = str(token_data.get("id_token") or "").strip()
796
+
797
+ # ⑤ 用 access_token 获取用户信息
798
+ user_info = {}
799
+ try:
800
+ me_resp = session.get(
801
+ "https://chatgpt.com/backend-api/me",
802
+ headers={
803
+ "accept": "application/json",
804
+ "authorization": f"Bearer {access_token}",
805
+ "user-agent": user_agent,
806
+ },
807
+ timeout=30,
808
+ )
809
+ if me_resp.status_code == 200:
810
+ user_info = me_resp.json() if me_resp.text else {}
811
+ except Exception:
812
+ pass
813
+
814
+ # 解析 JWT payload
815
+ jwt_payload = self._decode_jwt_payload(access_token)
816
+
817
+ email_from_jwt = str(jwt_payload.get("https://api.openai.com/profile", {}).get("email") or "").strip()
818
+ account_id_from_jwt = str(
819
+ jwt_payload.get("https://api.openai.com/auth", {}).get("chatgpt_account_id") or ""
820
+ ).strip()
821
+
822
+ account_info = user_info.get("account") if isinstance(user_info.get("account"), dict) else {}
823
+ result = {
824
+ "ok": True,
825
+ "email": email_from_jwt or email,
826
+ "account_id": account_id_from_jwt or account_info.get("account_id", ""),
827
+ "access_token": access_token,
828
+ "refresh_token": refresh_token,
829
+ "id_token": id_token,
830
+ "expires_at": jwt_payload.get("exp"),
831
+ "source_type": "password",
832
+ }
833
+
834
+ return result
835
+
836
+ finally:
837
+ session.close()
838
+
839
+ def list_expiring_access_tokens(self) -> list[str]:
840
+ with self._lock:
841
+ return [
842
+ token
843
+ for account in self._accounts.values()
844
+ if str(account.get("refresh_token") or "").strip()
845
+ and (token := str(account.get("access_token") or "").strip())
846
+ and self._token_needs_refresh(token)
847
+ ]
848
+
849
+ def list_refresh_token_keepalive_tokens(self) -> list[str]:
850
+ now = datetime.now(timezone.utc)
851
+ due_items: list[tuple[datetime, str]] = []
852
+ with self._lock:
853
+ for account in self._accounts.values():
854
+ due_at = self._refresh_token_keepalive_due_at(account, now)
855
+ token = str(account.get("access_token") or "").strip()
856
+ if due_at is not None and token:
857
+ due_items.append((due_at, token))
858
+ due_items.sort(key=lambda item: item[0])
859
+ return [token for _, token in due_items[: self._REFRESH_TOKEN_KEEPALIVE_BATCH_SIZE]]
860
+
861
+ def keepalive_refresh_tokens(self, access_tokens: list[str]) -> dict[str, Any]:
862
+ access_tokens = list(dict.fromkeys(token for token in access_tokens if token))
863
+ if not access_tokens:
864
+ return {"refreshed": 0, "errors": [], "items": self.list_accounts()}
865
+
866
+ refreshed = 0
867
+ errors = []
868
+ for access_token in access_tokens:
869
+ before = self.resolve_access_token(access_token)
870
+ after = self.refresh_access_token(before, force=True, event="refresh_token_keepalive")
871
+ account = self.get_account(after)
872
+ if account and str(account.get("last_token_refresh_error") or "").strip():
873
+ errors.append({
874
+ "token": anonymize_token(before),
875
+ "error": str(account.get("last_token_refresh_error") or "refresh token failed"),
876
+ })
877
+ continue
878
+ if account:
879
+ refreshed += 1
880
+
881
+ return {
882
+ "refreshed": refreshed,
883
+ "errors": errors,
884
+ "items": self.list_accounts(),
885
+ "relogined": 0,
886
+ }
887
+
888
+ def list_tokens(self) -> list[str]:
889
+ with self._lock:
890
+ return list(self._accounts)
891
+
892
+ def _list_ready_candidate_tokens(
893
+ self,
894
+ excluded_tokens: set[str] | None = None,
895
+ plan_type: str | None = None,
896
+ source_type: str | None = None,
897
+ plan_types: set[str] | tuple[str, ...] | None = None,
898
+ ) -> list[str]:
899
+ excluded = set(excluded_tokens or set())
900
+ return [
901
+ token
902
+ for item in self._accounts.values()
903
+ if self._is_image_account_available(item)
904
+ and self._account_matches_plan_type(item, plan_type)
905
+ and self._account_matches_any_plan_type(item, plan_types)
906
+ and self._account_matches_source_type(item, source_type)
907
+ and (token := item.get("access_token") or "")
908
+ and token not in excluded
909
+ ]
910
+
911
+ def _list_available_candidate_tokens(
912
+ self,
913
+ excluded_tokens: set[str] | None = None,
914
+ plan_type: str | None = None,
915
+ source_type: str | None = None,
916
+ plan_types: set[str] | tuple[str, ...] | None = None,
917
+ ) -> list[str]:
918
+ max_concurrency = max(1, int(config.image_account_concurrency or 1))
919
+ return [
920
+ token
921
+ for token in self._list_ready_candidate_tokens(excluded_tokens, plan_type, source_type, plan_types)
922
+ if int(self._image_inflight.get(token, 0)) < max_concurrency
923
+ ]
924
+
925
+ def _acquire_next_candidate_token(
926
+ self,
927
+ excluded_tokens: set[str] | None = None,
928
+ plan_type: str | None = None,
929
+ source_type: str | None = None,
930
+ plan_types: set[str] | tuple[str, ...] | None = None,
931
+ ) -> str:
932
+ with self._image_slot_condition:
933
+ while True:
934
+ if not self._list_ready_candidate_tokens(excluded_tokens, plan_type, source_type, plan_types):
935
+ raise RuntimeError(
936
+ f"no available {plan_type or source_type or ''} image quota".replace(" ", " ").strip()
937
+ if plan_type or source_type else "no available image quota"
938
+ )
939
+ tokens = self._list_available_candidate_tokens(excluded_tokens, plan_type, source_type, plan_types)
940
+ if tokens:
941
+ access_token = tokens[self._index % len(tokens)]
942
+ self._index += 1
943
+ self._image_inflight[access_token] = int(self._image_inflight.get(access_token, 0)) + 1
944
+ return access_token
945
+ self._image_slot_condition.wait(timeout=1.0)
946
+
947
+ def release_image_slot(self, access_token: str) -> None:
948
+ if not access_token:
949
+ return
950
+ with self._image_slot_condition:
951
+ access_token = self._resolve_access_token_locked(access_token)
952
+ current_inflight = int(self._image_inflight.get(access_token, 0))
953
+ if current_inflight <= 1:
954
+ self._image_inflight.pop(access_token, None)
955
+ else:
956
+ self._image_inflight[access_token] = current_inflight - 1
957
+ self._image_slot_condition.notify_all()
958
+
959
+ def get_available_access_token(
960
+ self,
961
+ plan_type: str | None = None,
962
+ source_type: str | None = None,
963
+ plan_types: set[str] | tuple[str, ...] | None = None,
964
+ ) -> str:
965
+ """从候选池中获取一个可用的图片生图 token。
966
+
967
+ 基于本地缓存做初筛,然后通过 fetch_remote_info 做远程验证(token 有效性、配额等)。
968
+ 限制最大尝试次数防止 token rotation 导致无限循环。
969
+ """
970
+ max_attempts = 20 # 防止无限循环
971
+ attempted_tokens: set[str] = set()
972
+ for _attempt in range(max_attempts):
973
+ access_token = self._acquire_next_candidate_token(
974
+ excluded_tokens=attempted_tokens,
975
+ plan_type=plan_type,
976
+ source_type=source_type,
977
+ plan_types=plan_types,
978
+ )
979
+ attempted_tokens.add(access_token)
980
+ try:
981
+ account = self.fetch_remote_info(access_token, "get_available_access_token")
982
+ except Exception:
983
+ self.release_image_slot(access_token)
984
+ continue
985
+ # fetch_remote_info 内部可能因 token rotation 导致 access_token 变化,
986
+ # 把新 token 也加入排除列表,防止重复尝试
987
+ resolved = str((account or {}).get("access_token") or "")
988
+ if resolved and resolved != access_token:
989
+ attempted_tokens.add(resolved)
990
+ if (
991
+ self._is_image_account_available(account or {})
992
+ and self._account_matches_plan_type(account or {}, plan_type)
993
+ and self._account_matches_any_plan_type(account or {}, plan_types)
994
+ and self._account_matches_source_type(account or {}, source_type)
995
+ ):
996
+ return str((account or {}).get("access_token") or access_token)
997
+ self.release_image_slot(access_token)
998
+ raise RuntimeError(
999
+ f"no available {plan_type or source_type or ''} image quota (tried {len(attempted_tokens)} tokens)".replace(" ", " ").strip()
1000
+ if plan_type or source_type else f"no available image quota (tried {len(attempted_tokens)} tokens)"
1001
+ )
1002
+
1003
+ def get_text_access_token(self, excluded_tokens: set[str] | None = None) -> str:
1004
+ excluded = set(excluded_tokens or set())
1005
+ with self._lock:
1006
+ candidates = [
1007
+ token
1008
+ for account in self._accounts.values()
1009
+ if account.get("status") not in {"禁用", "异常"}
1010
+ and (token := account.get("access_token") or "")
1011
+ and token not in excluded
1012
+ ]
1013
+ if not candidates:
1014
+ return ""
1015
+ access_token = candidates[self._index % len(candidates)]
1016
+ self._index += 1
1017
+ return self.refresh_access_token(access_token, event="get_text_access_token") or access_token
1018
+
1019
+ def mark_text_used(self, access_token: str) -> None:
1020
+ if not access_token:
1021
+ return
1022
+ with self._lock:
1023
+ access_token = self._resolve_access_token_locked(access_token)
1024
+ current = self._accounts.get(access_token)
1025
+ if current is None:
1026
+ return
1027
+ next_item = dict(current)
1028
+ next_item["last_used_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
1029
+ account = self._normalize_account(next_item)
1030
+ if account is None:
1031
+ return
1032
+ self._accounts[access_token] = account
1033
+ self._save_accounts()
1034
+
1035
+ def remove_invalid_token(self, access_token: str, event: str, quiet: bool = False) -> bool:
1036
+ if not config.auto_remove_invalid_accounts:
1037
+ self.update_account(access_token, {"status": "异常", "quota": 0}, quiet=quiet)
1038
+ return False
1039
+ removed = bool(self.delete_accounts([access_token])["removed"])
1040
+ if removed:
1041
+ log_service.add(LOG_TYPE_ACCOUNT, "自动移除异常账号",
1042
+ {"source": event, "token": anonymize_token(access_token)})
1043
+ elif access_token:
1044
+ self.update_account(access_token, {"status": "异常", "quota": 0}, quiet=quiet)
1045
+ return removed
1046
+
1047
+ def get_account(self, access_token: str) -> dict | None:
1048
+ if not access_token:
1049
+ return None
1050
+ with self._lock:
1051
+ access_token = self._resolve_access_token_locked(access_token)
1052
+ account = self._accounts.get(access_token)
1053
+ return dict(account) if account else None
1054
+
1055
+ def list_accounts(self) -> list[dict]:
1056
+ with self._lock:
1057
+ return [dict(item) for item in self._accounts.values()]
1058
+
1059
+ def list_limited_tokens(self) -> list[str]:
1060
+ with self._lock:
1061
+ return [
1062
+ token
1063
+ for item in self._accounts.values()
1064
+ if item.get("status") == "限流"
1065
+ and (token := item.get("access_token") or "")
1066
+ ]
1067
+
1068
+ @staticmethod
1069
+ def _account_payload_token(item: dict) -> str:
1070
+ return str(item.get("access_token") or item.get("accessToken") or "").strip()
1071
+
1072
+ @staticmethod
1073
+ def _prepare_account_payload(item: dict) -> dict | None:
1074
+ if not isinstance(item, dict):
1075
+ return None
1076
+ access_token = AccountService._account_payload_token(item)
1077
+ if not access_token:
1078
+ return None
1079
+ payload = dict(item)
1080
+ payload.pop("accessToken", None)
1081
+ payload["access_token"] = access_token
1082
+ # CPA/Codex 导出文件里的 `type=codex` 是导出格式,不是号池套餐类型。
1083
+ if str(payload.get("type") or "").strip().lower() == "codex":
1084
+ payload["export_type"] = "codex"
1085
+ payload["source_type"] = "codex"
1086
+ payload.pop("type", None)
1087
+ if str(payload.get("export_type") or "").strip().lower() == "codex":
1088
+ payload["source_type"] = "codex"
1089
+ if payload.get("plan_type") and not payload.get("type"):
1090
+ payload["type"] = str(payload.get("plan_type") or "").strip()
1091
+ return payload
1092
+
1093
+ def add_account_items(self, items: list[dict]) -> dict:
1094
+ payloads = [
1095
+ payload
1096
+ for item in items
1097
+ if (payload := self._prepare_account_payload(item)) is not None
1098
+ ]
1099
+ return self._add_account_payloads(payloads)
1100
+
1101
+ def add_accounts(self, tokens: list[str], source_type: str = "web") -> dict:
1102
+ tokens = list(dict.fromkeys(token for token in tokens if token))
1103
+ if not tokens:
1104
+ return {"added": 0, "skipped": 0, "items": self.list_accounts()}
1105
+ return self._add_account_payloads([
1106
+ {"access_token": token, "source_type": self._normalize_source_type(source_type)}
1107
+ for token in tokens
1108
+ ])
1109
+
1110
+ def _add_account_payloads(self, payloads: list[dict]) -> dict:
1111
+ deduped: dict[str, dict] = {}
1112
+ for payload in payloads:
1113
+ if not isinstance(payload, dict):
1114
+ continue
1115
+ access_token = self._account_payload_token(payload)
1116
+ if not access_token:
1117
+ continue
1118
+ current = deduped.get(access_token, {})
1119
+ deduped[access_token] = {**current, **payload, "access_token": access_token}
1120
+
1121
+ if not deduped:
1122
+ return {"added": 0, "skipped": 0, "items": self.list_accounts()}
1123
+
1124
+ with self._lock:
1125
+ added = 0
1126
+ skipped = 0
1127
+ for access_token, payload in deduped.items():
1128
+ current = self._accounts.get(access_token)
1129
+ if current is None:
1130
+ added += 1
1131
+ self._cumulative_total += 1
1132
+ self._save_cumulative_total()
1133
+ current = {"created_at": self._now()}
1134
+ else:
1135
+ skipped += 1
1136
+ incoming = dict(payload)
1137
+ if not incoming.get("created_at"):
1138
+ incoming.pop("created_at", None)
1139
+ account = self._normalize_account(
1140
+ {
1141
+ **current,
1142
+ **incoming,
1143
+ "access_token": access_token,
1144
+ "type": str(incoming.get("type") or current.get("type") or "free"),
1145
+ }
1146
+ )
1147
+ if account is not None:
1148
+ self._accounts[access_token] = account
1149
+ self._save_accounts()
1150
+ items = [dict(item) for item in self._accounts.values()]
1151
+ log_service.add(LOG_TYPE_ACCOUNT, f"新增 {added} 个账号,跳过 {skipped} 个",
1152
+ {"added": added, "skipped": skipped})
1153
+ return {"added": added, "skipped": skipped, "items": items}
1154
+
1155
+ def delete_accounts(self, tokens: list[str]) -> dict:
1156
+ target_set = set(token for token in tokens if token)
1157
+ if not target_set:
1158
+ return {"removed": 0, "items": self.list_accounts()}
1159
+ with self._lock:
1160
+ target_set = {self._resolve_access_token_locked(token) for token in target_set if token}
1161
+ removed = sum(self._accounts.pop(token, None) is not None for token in target_set)
1162
+ for token in target_set:
1163
+ self._image_inflight.pop(token, None)
1164
+ self._token_aliases = {
1165
+ old: new
1166
+ for old, new in self._token_aliases.items()
1167
+ if old not in target_set and new not in target_set
1168
+ }
1169
+ if removed:
1170
+ if self._accounts:
1171
+ self._index %= len(self._accounts)
1172
+ else:
1173
+ self._index = 0
1174
+ self._save_accounts()
1175
+ log_service.add(LOG_TYPE_ACCOUNT, f"删除 {removed} 个账号", {"removed": removed})
1176
+ items = [dict(item) for item in self._accounts.values()]
1177
+ return {"removed": removed, "items": items}
1178
+
1179
+ def update_account(self, access_token: str, updates: dict, quiet: bool = False) -> dict | None:
1180
+ if not access_token:
1181
+ return None
1182
+ with self._lock:
1183
+ access_token = self._resolve_access_token_locked(access_token)
1184
+ current = self._accounts.get(access_token)
1185
+ if current is None:
1186
+ return None
1187
+ account = self._normalize_account({**current, **updates, "access_token": access_token})
1188
+ if account is None:
1189
+ return None
1190
+ if account.get("status") == "限流" and config.auto_remove_rate_limited_accounts:
1191
+ self._accounts.pop(access_token, None)
1192
+ self._save_accounts()
1193
+ log_service.add(LOG_TYPE_ACCOUNT, "自动移除限流账号", {"token": anonymize_token(access_token)})
1194
+ return None
1195
+ self._accounts[access_token] = account
1196
+ self._save_accounts()
1197
+ if not quiet:
1198
+ log_service.add(LOG_TYPE_ACCOUNT, "更新账号",
1199
+ {"token": anonymize_token(access_token), "status": account.get("status")})
1200
+ return dict(account)
1201
+ return None
1202
+
1203
+ def _record_refresh_success(self, access_token: str) -> None:
1204
+ with self._lock:
1205
+ access_token = self._resolve_access_token_locked(access_token)
1206
+ current = self._accounts.get(access_token)
1207
+ if current is None:
1208
+ return
1209
+ next_item = dict(current)
1210
+ next_item["invalid_count"] = 0
1211
+ next_item["last_invalid_at"] = None
1212
+ next_item["last_refresh_error"] = None
1213
+ next_item["last_refresh_error_at"] = None
1214
+ account = self._normalize_account(next_item)
1215
+ if account is not None:
1216
+ self._accounts[access_token] = account
1217
+
1218
+ def _should_defer_invalid_token(self, account: dict | None, now: datetime) -> bool:
1219
+ if not isinstance(account, dict):
1220
+ return False
1221
+ created_at = self._parse_time(account.get("created_at"))
1222
+ if created_at is not None and (now - created_at).total_seconds() < self._NEW_ACCOUNT_INVALID_GRACE_SECONDS:
1223
+ return True
1224
+ last_invalid_at = self._parse_time(account.get("last_invalid_at"))
1225
+ invalid_count = int(account.get("invalid_count") or 0)
1226
+ if invalid_count <= 1:
1227
+ return True
1228
+ if last_invalid_at is not None and (now - last_invalid_at).total_seconds() < self._INVALID_CONFIRM_SECONDS:
1229
+ return True
1230
+ return False
1231
+
1232
+ def _record_invalid_token_seen(self, access_token: str, event: str, error: str) -> bool:
1233
+ now = datetime.now(timezone.utc)
1234
+ with self._lock:
1235
+ access_token = self._resolve_access_token_locked(access_token)
1236
+ current = self._accounts.get(access_token)
1237
+ if current is None:
1238
+ return True
1239
+ should_defer = self._should_defer_invalid_token(current, now)
1240
+ next_item = dict(current)
1241
+ next_item["invalid_count"] = int(next_item.get("invalid_count") or 0) + 1
1242
+ next_item["last_invalid_at"] = now.isoformat()
1243
+ next_item["last_refresh_error"] = str(error or "invalid access token")
1244
+ next_item["last_refresh_error_at"] = now.isoformat()
1245
+ account = self._normalize_account(next_item)
1246
+ if account is not None:
1247
+ self._accounts[access_token] = account
1248
+ self._save_accounts()
1249
+ if should_defer:
1250
+ log_service.add(
1251
+ LOG_TYPE_ACCOUNT,
1252
+ "暂缓标记异常账号",
1253
+ {"source": event, "token": anonymize_token(access_token), "error": str(error or "")},
1254
+ )
1255
+ return False
1256
+ return True
1257
+
1258
+ def mark_image_result(self, access_token: str, success: bool) -> dict | None:
1259
+ if not access_token:
1260
+ return None
1261
+ self.release_image_slot(access_token)
1262
+ with self._lock:
1263
+ access_token = self._resolve_access_token_locked(access_token)
1264
+ current = self._accounts.get(access_token)
1265
+ if current is None:
1266
+ return None
1267
+ next_item = dict(current)
1268
+ next_item["last_used_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
1269
+ image_quota_unknown = bool(next_item.get("image_quota_unknown"))
1270
+ if success:
1271
+ next_item["success"] = int(next_item.get("success") or 0) + 1
1272
+ if not image_quota_unknown:
1273
+ next_item["quota"] = max(0, int(next_item.get("quota") or 0) - 1)
1274
+ if not image_quota_unknown and next_item["quota"] == 0:
1275
+ next_item["status"] = "限流"
1276
+ next_item["restore_at"] = next_item.get("restore_at") or None
1277
+ elif next_item.get("status") == "限流":
1278
+ next_item["status"] = "正常"
1279
+ else:
1280
+ next_item["fail"] = int(next_item.get("fail") or 0) + 1
1281
+ account = self._normalize_account(next_item)
1282
+ if account is None:
1283
+ return None
1284
+ if account.get("status") == "限流" and config.auto_remove_rate_limited_accounts:
1285
+ self._accounts.pop(access_token, None)
1286
+ self._save_accounts()
1287
+ log_service.add(LOG_TYPE_ACCOUNT, "自动移除限流账号", {"token": anonymize_token(access_token)})
1288
+ return None
1289
+ self._accounts[access_token] = account
1290
+ self._save_accounts()
1291
+ return dict(account)
1292
+ return None
1293
+
1294
+ def fetch_remote_info(self, access_token: str, event: str = "fetch_remote_info") -> dict[str, Any] | None:
1295
+ if not access_token:
1296
+ raise ValueError("access_token is required")
1297
+
1298
+ active_token = self.refresh_access_token(access_token, event=f"{event}:preflight") or access_token
1299
+ try:
1300
+ from services.openai_backend_api import InvalidAccessTokenError, OpenAIBackendAPI
1301
+ result = OpenAIBackendAPI(active_token).get_user_info()
1302
+ except InvalidAccessTokenError as exc:
1303
+ refreshed_token = self.refresh_access_token(active_token, force=True, event=f"{event}:invalid_access_token")
1304
+ if refreshed_token and refreshed_token != active_token:
1305
+ try:
1306
+ result = OpenAIBackendAPI(refreshed_token).get_user_info()
1307
+ except InvalidAccessTokenError as retry_exc:
1308
+ if self._record_invalid_token_seen(refreshed_token, event, str(retry_exc)):
1309
+ self.remove_invalid_token(refreshed_token, event)
1310
+ raise
1311
+ active_token = refreshed_token
1312
+ else:
1313
+ if self._record_invalid_token_seen(active_token, event, str(exc)):
1314
+ self.remove_invalid_token(active_token, event)
1315
+ raise
1316
+ self._record_refresh_success(active_token)
1317
+ return self.update_account(active_token, result)
1318
+
1319
+ # ---- 刷新进度追踪 ----
1320
+
1321
+ def init_refresh_progress(self, progress_id: str, total: int) -> None:
1322
+ """初始化刷新进度记录。"""
1323
+ with self._refresh_progress_lock:
1324
+ self._refresh_progress[progress_id] = {
1325
+ "total": total,
1326
+ "processed": 0,
1327
+ "done": False,
1328
+ "error": None,
1329
+ "status_counts": {"正常": 0, "限流": 0, "异常": 0, "禁用": 0},
1330
+ "total_quota": 0,
1331
+ }
1332
+
1333
+ def update_refresh_progress(self, progress_id: str, token: str) -> None:
1334
+ """刷新单个账号后,更新进度计数。"""
1335
+ account = self.get_account(token)
1336
+ status = str(account.get("status") or "正常").strip() if account else "正常"
1337
+ quota = max(0, int(account.get("quota") or 0)) if account else 0
1338
+
1339
+ with self._refresh_progress_lock:
1340
+ progress = self._refresh_progress.get(progress_id)
1341
+ if progress is None:
1342
+ return
1343
+ progress["processed"] += 1
1344
+ progress["status_counts"][status] = progress["status_counts"].get(status, 0) + 1
1345
+ progress["total_quota"] += quota
1346
+
1347
+ def finish_refresh_progress(self, progress_id: str, result: dict | None = None, error: str | None = None) -> None:
1348
+ """标记刷新完成。"""
1349
+ with self._refresh_progress_lock:
1350
+ progress = self._refresh_progress.get(progress_id)
1351
+ if progress is None:
1352
+ return
1353
+ progress["done"] = True
1354
+ progress["result"] = result
1355
+ if error:
1356
+ progress["error"] = error
1357
+
1358
+ def get_refresh_progress(self, progress_id: str) -> dict | None:
1359
+ """查询刷新进度。"""
1360
+ with self._refresh_progress_lock:
1361
+ progress = self._refresh_progress.get(progress_id)
1362
+ return dict(progress) if progress else None
1363
+
1364
+ def clean_refresh_progress(self, progress_id: str) -> None:
1365
+ """清理过期进度记录。"""
1366
+ with self._refresh_progress_lock:
1367
+ self._refresh_progress.pop(progress_id, None)
1368
+
1369
+ # ---- 重新登录进度追踪 ----
1370
+
1371
+ def init_relogin_progress(self, progress_id: str, total: int) -> None:
1372
+ """初始化重新登录进度记录。"""
1373
+ with self._relogin_progress_lock:
1374
+ self._relogin_progress[progress_id] = {
1375
+ "total": total,
1376
+ "processed": 0,
1377
+ "done": False,
1378
+ "error": None,
1379
+ "results": [],
1380
+ }
1381
+
1382
+ def update_relogin_progress(self, progress_id: str, token: str, status: str, error: str | None = None) -> None:
1383
+ """更新单个重新登录进度。当所有账号处理完毕时自动标记完成。"""
1384
+ with self._relogin_progress_lock:
1385
+ progress = self._relogin_progress.get(progress_id)
1386
+ if progress is None:
1387
+ return
1388
+ progress["processed"] += 1
1389
+ progress["results"].append({
1390
+ "token": anonymize_token(token),
1391
+ "status": status,
1392
+ "error": error,
1393
+ })
1394
+ if progress["processed"] >= progress["total"]:
1395
+ progress["done"] = True
1396
+
1397
+ def finish_relogin_progress(self, progress_id: str, result: dict | None = None, error: str | None = None) -> None:
1398
+ """标记重新登录完成。"""
1399
+ with self._relogin_progress_lock:
1400
+ progress = self._relogin_progress.get(progress_id)
1401
+ if progress is None:
1402
+ return
1403
+ progress["done"] = True
1404
+ progress["result"] = result
1405
+ if error:
1406
+ progress["error"] = error
1407
+
1408
+ def get_relogin_progress(self, progress_id: str) -> dict | None:
1409
+ """查询重新登录进度。"""
1410
+ with self._relogin_progress_lock:
1411
+ progress = self._relogin_progress.get(progress_id)
1412
+ return dict(progress) if progress else None
1413
+
1414
+ def clean_relogin_progress(self, progress_id: str) -> None:
1415
+ """清理过期进度记录。"""
1416
+ with self._relogin_progress_lock:
1417
+ self._relogin_progress.pop(progress_id, None)
1418
+
1419
+ def refresh_accounts(self, access_tokens: list[str], progress_id: str | None = None) -> dict[str, Any]:
1420
+ access_tokens = list(dict.fromkeys(token for token in access_tokens if token))
1421
+ if not access_tokens:
1422
+ items = self.list_accounts()
1423
+ result = {"refreshed": 0, "errors": [], "items": items, "relogined": 0}
1424
+ if progress_id:
1425
+ self.finish_refresh_progress(progress_id, result)
1426
+ return result
1427
+
1428
+ refreshed = 0
1429
+ errors = []
1430
+ max_workers = min(10, len(access_tokens))
1431
+
1432
+ if progress_id:
1433
+ self.init_refresh_progress(progress_id, len(access_tokens))
1434
+
1435
+ executor = ThreadPoolExecutor(max_workers=max_workers)
1436
+ try:
1437
+ futures = {
1438
+ executor.submit(self.fetch_remote_info, token, "refresh_accounts"): token
1439
+ for token in access_tokens
1440
+ }
1441
+ for future in as_completed(futures):
1442
+ token = futures[future]
1443
+ try:
1444
+ account = future.result()
1445
+ except (KeyboardInterrupt, SystemExit):
1446
+ executor.shutdown(wait=False, cancel_futures=True)
1447
+ raise
1448
+ except Exception as exc:
1449
+ error_str = str(exc)
1450
+ # TLS/代理连接错误是网络问题,不计入账号失败
1451
+ from services.protocol.conversation import is_tls_connection_error
1452
+ if not is_tls_connection_error(error_str):
1453
+ errors.append({"token": anonymize_token(token), "error": error_str})
1454
+ else:
1455
+ if account is not None:
1456
+ refreshed += 1
1457
+
1458
+ if progress_id:
1459
+ self.update_refresh_progress(progress_id, token)
1460
+ except (KeyboardInterrupt, SystemExit):
1461
+ if progress_id:
1462
+ self.finish_refresh_progress(progress_id, error="cancelled")
1463
+ executor.shutdown(wait=False, cancel_futures=True)
1464
+ raise
1465
+ else:
1466
+ executor.shutdown(wait=True, cancel_futures=True)
1467
+
1468
+ # 自动重新登录异常账号(仅当配置开启时)
1469
+ relogined = 0
1470
+ if config.auto_relogin_after_refresh:
1471
+ for token in access_tokens:
1472
+ account = self.get_account(token)
1473
+ if not account:
1474
+ continue
1475
+ status = str(account.get("status") or "").strip()
1476
+ if status != "异常":
1477
+ continue
1478
+ email = str(account.get("email") or "").strip()
1479
+ password = str(account.get("password") or "").strip()
1480
+ if not email or not password:
1481
+ continue
1482
+ t = Thread(
1483
+ target=self._password_re_login_thread,
1484
+ args=(token, email, password, "auto_relogin_after_refresh"),
1485
+ daemon=True,
1486
+ )
1487
+ t.start()
1488
+ relogined += 1
1489
+
1490
+ result = {
1491
+ "refreshed": refreshed,
1492
+ "errors": errors,
1493
+ "items": self.list_accounts(),
1494
+ "relogined": relogined,
1495
+ }
1496
+
1497
+ if progress_id:
1498
+ self.finish_refresh_progress(progress_id, result)
1499
+
1500
+ return result
1501
+
1502
+ def re_login_accounts(self, access_tokens: list[str], progress_id: str | None = None) -> dict[str, Any]:
1503
+ """对选中账号执行密码重新登录流程。
1504
+
1505
+ 仅对包含 email + password 的账号有效。
1506
+ 登录成功后自动将状态设为"正常"。
1507
+ """
1508
+ access_tokens = list(dict.fromkeys(token for token in access_tokens if token))
1509
+ if not access_tokens:
1510
+ result = {"relogined": 0, "skipped": 0, "errors": [], "items": self.list_accounts()}
1511
+ if progress_id:
1512
+ self.finish_relogin_progress(progress_id, result)
1513
+ return result
1514
+
1515
+ if progress_id:
1516
+ self.init_relogin_progress(progress_id, len(access_tokens))
1517
+
1518
+ relogined = 0
1519
+ skipped = 0
1520
+ errors = []
1521
+
1522
+ for token in access_tokens:
1523
+ account = self.get_account(token)
1524
+ if not account:
1525
+ errors.append({"token": anonymize_token(token), "error": "账号不存在"})
1526
+ if progress_id:
1527
+ self.update_relogin_progress(progress_id, token, "跳过", "账号不存在")
1528
+ continue
1529
+
1530
+ email = str(account.get("email") or "").strip()
1531
+ password = str(account.get("password") or "").strip()
1532
+ if not email or not password:
1533
+ skipped += 1
1534
+ if progress_id:
1535
+ self.update_relogin_progress(progress_id, token, "跳过", "无邮箱密码")
1536
+ continue
1537
+
1538
+ # 在新线程中执行密码重新登录
1539
+ t = Thread(
1540
+ target=self._password_re_login_thread,
1541
+ args=(token, email, password, "manual_relogin", progress_id),
1542
+ daemon=True,
1543
+ )
1544
+ t.start()
1545
+ relogined += 1
1546
+
1547
+ result = {
1548
+ "relogined": relogined,
1549
+ "skipped": skipped,
1550
+ "errors": errors,
1551
+ "items": self.list_accounts(),
1552
+ }
1553
+ if progress_id:
1554
+ # 如果所有账号都已同步处理完毕(没有启动线程),直接标记完成
1555
+ if relogined == 0:
1556
+ self.finish_relogin_progress(progress_id, result)
1557
+ else:
1558
+ # 有线程在运行,等线程结束后再完成
1559
+ pass
1560
+ return result
1561
+
1562
+ def build_export_items(self, access_tokens: list[str] | None = None) -> list[dict[str, str]]:
1563
+ target_tokens = set(token for token in (access_tokens or []) if token)
1564
+ with self._lock:
1565
+ accounts = [
1566
+ dict(item)
1567
+ for item in self._accounts.values()
1568
+ if not target_tokens or str(item.get("access_token") or "") in target_tokens
1569
+ ]
1570
+
1571
+ items: list[dict[str, str]] = []
1572
+ for account in accounts:
1573
+ access_token = str(account.get("access_token") or "").strip()
1574
+ refresh_token = str(account.get("refresh_token") or "").strip()
1575
+ id_token = str(account.get("id_token") or "").strip()
1576
+ if not access_token or not refresh_token or not id_token:
1577
+ continue
1578
+
1579
+ access_payload = self._decode_jwt_payload(access_token)
1580
+ id_payload = self._decode_jwt_payload(id_token)
1581
+ auth_claim = access_payload.get("https://api.openai.com/auth")
1582
+ auth_claim = auth_claim if isinstance(auth_claim, dict) else {}
1583
+ profile_claim = access_payload.get("https://api.openai.com/profile")
1584
+ profile_claim = profile_claim if isinstance(profile_claim, dict) else {}
1585
+
1586
+ email = (
1587
+ str(account.get("email") or "").strip()
1588
+ or str(profile_claim.get("email") or "").strip()
1589
+ or str(id_payload.get("email") or "").strip()
1590
+ )
1591
+ account_id = (
1592
+ str(account.get("account_id") or "").strip()
1593
+ or str(auth_claim.get("chatgpt_account_id") or "").strip()
1594
+ or str(account.get("user_id") or "").strip()
1595
+ )
1596
+ item = {
1597
+ "type": str(account.get("export_type") or "codex"),
1598
+ "email": email,
1599
+ "account_id": account_id,
1600
+ "access_token": access_token,
1601
+ "refresh_token": refresh_token,
1602
+ "id_token": id_token,
1603
+ "expired": self._timestamp_to_iso(access_payload.get("exp")),
1604
+ "last_refresh": self._timestamp_to_iso(access_payload.get("iat")),
1605
+ }
1606
+ password = str(account.get("password") or "").strip()
1607
+ if password:
1608
+ item["password"] = password
1609
+ items.append(item)
1610
+ return items
1611
+
1612
+ def get_stats(self) -> dict:
1613
+ with self._lock:
1614
+ items = list(self._accounts.values())
1615
+ total = len(items)
1616
+ active = sum(1 for a in items if a.get("status") == "正常")
1617
+ limited = sum(1 for a in items if a.get("status") == "限流")
1618
+ abnormal = sum(1 for a in items if a.get("status") == "异常")
1619
+ disabled = sum(1 for a in items if a.get("status") == "禁用")
1620
+ total_quota = sum(max(0, int(a.get("quota") or 0)) for a in items if a.get("status") == "正常")
1621
+ unlimited = sum(1 for a in items if a.get("status") == "正常" and bool(a.get("image_quota_unknown")))
1622
+ total_success = sum(int(a.get("success") or 0) for a in items)
1623
+ total_fail = sum(int(a.get("fail") or 0) for a in items)
1624
+ by_type = {}
1625
+ for a in items:
1626
+ t = a.get("type", "unknown")
1627
+ by_type[t] = by_type.get(t, 0) + 1
1628
+ return {
1629
+ "total": total,
1630
+ "cumulative_total": self._cumulative_total,
1631
+ "active": active,
1632
+ "limited": limited,
1633
+ "abnormal": abnormal,
1634
+ "disabled": disabled,
1635
+ "total_quota": total_quota,
1636
+ "unlimited_quota_count": unlimited,
1637
+ "total_success": total_success,
1638
+ "total_fail": total_fail,
1639
+ "by_type": by_type,
1640
+ }
1641
+
1642
+ def account_health(self) -> dict:
1643
+ stats = self.get_stats()
1644
+ return {
1645
+ "healthy": stats["active"] > 0 or stats["unlimited_quota_count"] > 0,
1646
+ "status": "ok" if stats["active"] > 0 else "degraded",
1647
+ **stats,
1648
+ }
1649
+
1650
+
1651
+ 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,486 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ DEFAULT_CHAT_COMPLETION_CACHE = {
41
+ "enabled": True,
42
+ "ttl_seconds": 60,
43
+ "max_entries": 256,
44
+ "dedupe_inflight": True,
45
+ "stream_cache": True,
46
+ "normalize_messages": True,
47
+ "drop_adjacent_duplicates": True,
48
+ "drop_assistant_history": False,
49
+ }
50
+
51
+
52
+ def _normalize_bool(value: object, default: bool = False) -> bool:
53
+ if isinstance(value, str):
54
+ lowered = value.strip().lower()
55
+ if lowered in {"1", "true", "yes", "on"}:
56
+ return True
57
+ if lowered in {"0", "false", "no", "off"}:
58
+ return False
59
+ if value is None:
60
+ return default
61
+ return bool(value)
62
+
63
+
64
+ def _normalize_positive_int(value: object, default: int, minimum: int = 0) -> int:
65
+ try:
66
+ normalized = int(value)
67
+ except (TypeError, ValueError):
68
+ normalized = default
69
+ return max(minimum, normalized)
70
+
71
+
72
+ def _normalize_backup_include(value: object) -> dict[str, bool]:
73
+ source = value if isinstance(value, dict) else {}
74
+ normalized = dict(DEFAULT_BACKUP_INCLUDE)
75
+ for key in normalized:
76
+ normalized[key] = _normalize_bool(source.get(key), normalized[key])
77
+ return normalized
78
+
79
+
80
+ def _normalize_backup_settings(value: object) -> dict[str, object]:
81
+ source = value if isinstance(value, dict) else {}
82
+ return {
83
+ "enabled": _normalize_bool(source.get("enabled"), False),
84
+ "provider": "cloudflare_r2",
85
+ "account_id": str(source.get("account_id") or "").strip(),
86
+ "access_key_id": str(source.get("access_key_id") or "").strip(),
87
+ "secret_access_key": str(source.get("secret_access_key") or "").strip(),
88
+ "bucket": str(source.get("bucket") or "").strip(),
89
+ "prefix": str(source.get("prefix") or "backups").strip().strip("/") or "backups",
90
+ "interval_minutes": _normalize_positive_int(source.get("interval_minutes"), 360, 1),
91
+ "rotation_keep": _normalize_positive_int(source.get("rotation_keep"), 10, 0),
92
+ "encrypt": _normalize_bool(source.get("encrypt"), False),
93
+ "passphrase": str(source.get("passphrase") or "").strip(),
94
+ "include": _normalize_backup_include(source.get("include")),
95
+ }
96
+
97
+
98
+ def _normalize_backup_state(value: object) -> dict[str, object]:
99
+ source = value if isinstance(value, dict) else {}
100
+ return {
101
+ "last_started_at": str(source.get("last_started_at") or "").strip() or None,
102
+ "last_finished_at": str(source.get("last_finished_at") or "").strip() or None,
103
+ "last_status": str(source.get("last_status") or "idle").strip() or "idle",
104
+ "last_error": str(source.get("last_error") or "").strip() or None,
105
+ "last_object_key": str(source.get("last_object_key") or "").strip() or None,
106
+ }
107
+
108
+
109
+ def _normalize_image_storage_settings(value: object) -> dict[str, object]:
110
+ source = value if isinstance(value, dict) else {}
111
+ mode = str(source.get("mode") or "local").strip().lower()
112
+ if mode not in {"local", "webdav", "both"}:
113
+ mode = "local"
114
+ enabled = _normalize_bool(source.get("enabled"), False)
115
+ if not enabled:
116
+ mode = "local"
117
+ root_path = str(source.get("webdav_root_path") or DEFAULT_IMAGE_STORAGE["webdav_root_path"]).strip().strip("/")
118
+ return {
119
+ "enabled": enabled,
120
+ "mode": mode,
121
+ "webdav_url": str(source.get("webdav_url") or "").strip().rstrip("/"),
122
+ "webdav_username": str(source.get("webdav_username") or "").strip(),
123
+ "webdav_password": str(source.get("webdav_password") or "").strip(),
124
+ "webdav_root_path": root_path or str(DEFAULT_IMAGE_STORAGE["webdav_root_path"]),
125
+ "public_base_url": str(source.get("public_base_url") or "").strip().rstrip("/"),
126
+ }
127
+
128
+
129
+ def _normalize_chat_completion_cache_settings(value: object) -> dict[str, object]:
130
+ source = value if isinstance(value, dict) else {}
131
+ return {
132
+ "enabled": _normalize_bool(source.get("enabled"), DEFAULT_CHAT_COMPLETION_CACHE["enabled"]),
133
+ "ttl_seconds": _normalize_positive_int(
134
+ source.get("ttl_seconds"),
135
+ int(DEFAULT_CHAT_COMPLETION_CACHE["ttl_seconds"]),
136
+ 0,
137
+ ),
138
+ "max_entries": _normalize_positive_int(
139
+ source.get("max_entries"),
140
+ int(DEFAULT_CHAT_COMPLETION_CACHE["max_entries"]),
141
+ 1,
142
+ ),
143
+ "dedupe_inflight": _normalize_bool(
144
+ source.get("dedupe_inflight"),
145
+ bool(DEFAULT_CHAT_COMPLETION_CACHE["dedupe_inflight"]),
146
+ ),
147
+ "stream_cache": _normalize_bool(
148
+ source.get("stream_cache"),
149
+ bool(DEFAULT_CHAT_COMPLETION_CACHE["stream_cache"]),
150
+ ),
151
+ "normalize_messages": _normalize_bool(
152
+ source.get("normalize_messages"),
153
+ bool(DEFAULT_CHAT_COMPLETION_CACHE["normalize_messages"]),
154
+ ),
155
+ "drop_adjacent_duplicates": _normalize_bool(
156
+ source.get("drop_adjacent_duplicates"),
157
+ bool(DEFAULT_CHAT_COMPLETION_CACHE["drop_adjacent_duplicates"]),
158
+ ),
159
+ "drop_assistant_history": _normalize_bool(
160
+ source.get("drop_assistant_history"),
161
+ bool(DEFAULT_CHAT_COMPLETION_CACHE["drop_assistant_history"]),
162
+ ),
163
+ }
164
+
165
+
166
+ def _validate_image_storage_settings(settings: dict[str, object]) -> None:
167
+ if not _normalize_bool(settings.get("enabled"), False):
168
+ return
169
+ if not str(settings.get("webdav_url") or "").strip():
170
+ raise ValueError("启用 WebDAV 图片存储后必须填写 WebDAV URL")
171
+ if not str(settings.get("webdav_password") or "").strip():
172
+ raise ValueError("启用 WebDAV 图片存储后必须填写 WebDAV 密码")
173
+
174
+
175
+ @dataclass(frozen=True)
176
+ class LoadedSettings:
177
+ auth_key: str
178
+ refresh_account_interval_minute: int
179
+
180
+
181
+ def _normalize_auth_key(value: object) -> str:
182
+ return str(value or "").strip()
183
+
184
+
185
+ def _is_invalid_auth_key(value: object) -> bool:
186
+ return _normalize_auth_key(value) == ""
187
+
188
+
189
+ def _read_json_object(path: Path, *, name: str) -> dict[str, object]:
190
+ if not path.exists():
191
+ return {}
192
+ if path.is_dir():
193
+ print(
194
+ f"Warning: {name} at '{path}' is a directory, ignoring it and falling back to other configuration sources.",
195
+ file=sys.stderr,
196
+ )
197
+ return {}
198
+ try:
199
+ data = json.loads(path.read_text(encoding="utf-8"))
200
+ except Exception:
201
+ return {}
202
+ return data if isinstance(data, dict) else {}
203
+
204
+
205
+ def _load_settings() -> LoadedSettings:
206
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
207
+ raw_config = _read_json_object(CONFIG_FILE, name="config.json")
208
+ auth_key = _normalize_auth_key(os.getenv("CHATGPT2API_AUTH_KEY") or raw_config.get("auth-key"))
209
+ if _is_invalid_auth_key(auth_key):
210
+ raise ValueError(
211
+ "❌ auth-key 未设置!\n"
212
+ "请在环境变量 CHATGPT2API_AUTH_KEY 中设置,或者在 config.json 中填写 auth-key。"
213
+ )
214
+
215
+ try:
216
+ refresh_interval = int(raw_config.get("refresh_account_interval_minute", 5))
217
+ except (TypeError, ValueError):
218
+ refresh_interval = 5
219
+
220
+ return LoadedSettings(
221
+ auth_key=auth_key,
222
+ refresh_account_interval_minute=refresh_interval,
223
+ )
224
+
225
+
226
+ class ConfigStore:
227
+ def __init__(self, path: Path):
228
+ self.path = path
229
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
230
+ self.data = self._load()
231
+ self._storage_backend: StorageBackend | None = None
232
+ if _is_invalid_auth_key(self.auth_key):
233
+ raise ValueError(
234
+ "❌ auth-key 未设置!\n"
235
+ "请按以下任意一种方式解决:\n"
236
+ "1. 在 Render 的 Environment 变量中添加:\n"
237
+ " CHATGPT2API_AUTH_KEY = your_real_auth_key\n"
238
+ "2. 或者在 config.json 中填写:\n"
239
+ ' "auth-key": "your_real_auth_key"'
240
+ )
241
+
242
+ def _load(self) -> dict[str, object]:
243
+ return _read_json_object(self.path, name="config.json")
244
+
245
+ def _save(self) -> None:
246
+ self.path.write_text(json.dumps(self.data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
247
+
248
+ @property
249
+ def auth_key(self) -> str:
250
+ return _normalize_auth_key(os.getenv("CHATGPT2API_AUTH_KEY") or self.data.get("auth-key"))
251
+
252
+ @property
253
+ def accounts_file(self) -> Path:
254
+ return DATA_DIR / "accounts.json"
255
+
256
+ @property
257
+ def refresh_account_interval_minute(self) -> int:
258
+ try:
259
+ return int(self.data.get("refresh_account_interval_minute", 5))
260
+ except (TypeError, ValueError):
261
+ return 5
262
+
263
+ @property
264
+ def image_retention_days(self) -> int:
265
+ try:
266
+ return max(1, int(self.data.get("image_retention_days", 30)))
267
+ except (TypeError, ValueError):
268
+ return 30
269
+
270
+ @property
271
+ def image_poll_timeout_secs(self) -> int:
272
+ try:
273
+ return max(1, int(self.data.get("image_poll_timeout_secs", 120)))
274
+ except (TypeError, ValueError):
275
+ return 120
276
+
277
+ @property
278
+ def image_poll_interval_secs(self) -> float:
279
+ try:
280
+ return max(0.5, float(self.data.get("image_poll_interval_secs", 10.0)))
281
+ except (TypeError, ValueError):
282
+ return 10.0
283
+
284
+ @property
285
+ def image_poll_initial_wait_secs(self) -> float:
286
+ """Image generation upstream takes ~30s; polling immediately wastes requests
287
+ and trips a transient 429. Default 10s gives the conversation document time
288
+ to commit before the first poll."""
289
+ try:
290
+ return max(0.0, float(self.data.get("image_poll_initial_wait_secs", 10.0)))
291
+ except (TypeError, ValueError):
292
+ return 10.0
293
+
294
+ @property
295
+ def image_account_concurrency(self) -> int:
296
+ try:
297
+ return max(1, int(self.data.get("image_account_concurrency", 3)))
298
+ except (TypeError, ValueError):
299
+ return 3
300
+
301
+ @property
302
+ def image_parallel_generation(self) -> bool:
303
+ value = self.data.get("image_parallel_generation", True)
304
+ if isinstance(value, str):
305
+ return value.strip().lower() in {"1", "true", "yes", "on"}
306
+ return bool(value)
307
+
308
+ @property
309
+ def image_settle_enabled(self) -> bool:
310
+ """图片二次确认机制:找到 file_ids 后等待一段时间再次确认。"""
311
+ value = self.data.get("image_settle_enabled", True)
312
+ if isinstance(value, str):
313
+ return value.strip().lower() in {"1", "true", "yes", "on"}
314
+ return bool(value)
315
+
316
+ @property
317
+ def image_check_before_hit_enabled(self) -> bool:
318
+ """先check再hit:通过轮询确认 file_ids 存在后再返回,而非仅依赖 SSE 事件。"""
319
+ value = self.data.get("image_check_before_hit_enabled", True)
320
+ if isinstance(value, str):
321
+ return value.strip().lower() in {"1", "true", "yes", "on"}
322
+ return bool(value)
323
+
324
+ @property
325
+ def image_settle_secs(self) -> float:
326
+ """二次确认等待时间(秒)。"""
327
+ try:
328
+ return max(0.5, float(self.data.get("image_settle_secs", 2.0)))
329
+ except (TypeError, ValueError):
330
+ return 2.0
331
+
332
+ @property
333
+ def auto_remove_invalid_accounts(self) -> bool:
334
+ value = self.data.get("auto_remove_invalid_accounts", False)
335
+ if isinstance(value, str):
336
+ return value.strip().lower() in {"1", "true", "yes", "on"}
337
+ return bool(value)
338
+
339
+ @property
340
+ def auto_remove_rate_limited_accounts(self) -> bool:
341
+ value = self.data.get("auto_remove_rate_limited_accounts", False)
342
+ if isinstance(value, str):
343
+ return value.strip().lower() in {"1", "true", "yes", "on"}
344
+ return bool(value)
345
+
346
+ @property
347
+ def auto_relogin_after_refresh(self) -> bool:
348
+ value = self.data.get("auto_relogin_after_refresh", False)
349
+ if isinstance(value, str):
350
+ return value.strip().lower() in {"1", "true", "yes", "on"}
351
+ return bool(value)
352
+
353
+ @property
354
+ def log_levels(self) -> list[str]:
355
+ levels = self.data.get("log_levels")
356
+ if not isinstance(levels, list):
357
+ return []
358
+ allowed = {"debug", "info", "warning", "error"}
359
+ return [level for item in levels if (level := str(item or "").strip().lower()) in allowed]
360
+
361
+ @property
362
+ def sensitive_words(self) -> list[str]:
363
+ words = self.data.get("sensitive_words")
364
+ return [word for item in words if (word := str(item or "").strip())] if isinstance(words, list) else []
365
+
366
+ @property
367
+ def ai_review(self) -> dict[str, object]:
368
+ value = self.data.get("ai_review")
369
+ return value if isinstance(value, dict) else {}
370
+
371
+ @property
372
+ def global_system_prompt(self) -> str:
373
+ return str(self.data.get("global_system_prompt") or "").strip()
374
+
375
+ @property
376
+ def images_dir(self) -> Path:
377
+ path = DATA_DIR / "images"
378
+ path.mkdir(parents=True, exist_ok=True)
379
+ return path
380
+
381
+ @property
382
+ def image_thumbnails_dir(self) -> Path:
383
+ path = DATA_DIR / "image_thumbnails"
384
+ path.mkdir(parents=True, exist_ok=True)
385
+ return path
386
+
387
+ def cleanup_old_images(self) -> int:
388
+ cutoff = time.time() - self.image_retention_days * 86400
389
+ removed = 0
390
+ for path in self.images_dir.rglob("*"):
391
+ if path.is_file() and path.stat().st_mtime < cutoff:
392
+ path.unlink()
393
+ removed += 1
394
+ for path in sorted((p for p in self.images_dir.rglob("*") if p.is_dir()), key=lambda p: len(p.parts), reverse=True):
395
+ try:
396
+ path.rmdir()
397
+ except OSError:
398
+ pass
399
+ return removed
400
+
401
+ @property
402
+ def base_url(self) -> str:
403
+ return str(
404
+ os.getenv("CHATGPT2API_BASE_URL")
405
+ or self.data.get("base_url")
406
+ or ""
407
+ ).strip().rstrip("/")
408
+
409
+ @property
410
+ def app_version(self) -> str:
411
+ try:
412
+ value = VERSION_FILE.read_text(encoding="utf-8").strip()
413
+ except FileNotFoundError:
414
+ return "0.0.0"
415
+ return value or "0.0.0"
416
+
417
+ def get(self) -> dict[str, object]:
418
+ data = dict(self.data)
419
+ data["refresh_account_interval_minute"] = self.refresh_account_interval_minute
420
+ data["image_retention_days"] = self.image_retention_days
421
+ data["image_poll_timeout_secs"] = self.image_poll_timeout_secs
422
+ data["image_poll_interval_secs"] = self.image_poll_interval_secs
423
+ data["image_poll_initial_wait_secs"] = self.image_poll_initial_wait_secs
424
+ data["image_account_concurrency"] = self.image_account_concurrency
425
+ data["image_parallel_generation"] = self.image_parallel_generation
426
+ data["auto_remove_invalid_accounts"] = self.auto_remove_invalid_accounts
427
+ data["auto_remove_rate_limited_accounts"] = self.auto_remove_rate_limited_accounts
428
+ data["auto_relogin_after_refresh"] = self.auto_relogin_after_refresh
429
+ data["log_levels"] = self.log_levels
430
+ data["sensitive_words"] = self.sensitive_words
431
+ data["ai_review"] = self.ai_review
432
+ data["global_system_prompt"] = self.global_system_prompt
433
+ data["backup"] = self.get_backup_settings()
434
+ data["image_storage"] = self.get_image_storage_settings()
435
+ data["chat_completion_cache"] = self.get_chat_completion_cache_settings()
436
+ data.pop("auth-key", None)
437
+ return data
438
+
439
+ def get_proxy_settings(self) -> str:
440
+ return str(self.data.get("proxy") or "").strip()
441
+
442
+ def update(self, data: dict[str, object]) -> dict[str, object]:
443
+ next_data = dict(self.data)
444
+ next_data.update(dict(data or {}))
445
+ if "backup" in next_data:
446
+ next_data["backup"] = _normalize_backup_settings(next_data.get("backup"))
447
+ if "image_storage" in next_data:
448
+ next_data["image_storage"] = _normalize_image_storage_settings(next_data.get("image_storage"))
449
+ _validate_image_storage_settings(next_data["image_storage"])
450
+ if "chat_completion_cache" in next_data:
451
+ next_data["chat_completion_cache"] = _normalize_chat_completion_cache_settings(
452
+ next_data.get("chat_completion_cache")
453
+ )
454
+ next_data.pop("backup_state", None)
455
+ self.data = next_data
456
+ self._save()
457
+ return self.get()
458
+
459
+ def get_backup_settings(self) -> dict[str, object]:
460
+ return _normalize_backup_settings(self.data.get("backup"))
461
+
462
+ def get_image_storage_settings(self) -> dict[str, object]:
463
+ return _normalize_image_storage_settings(self.data.get("image_storage"))
464
+
465
+ def get_chat_completion_cache_settings(self) -> dict[str, object]:
466
+ return _normalize_chat_completion_cache_settings(self.data.get("chat_completion_cache"))
467
+
468
+ def get_storage_backend(self) -> StorageBackend:
469
+ """获取存储后端实例(单例)"""
470
+ if self._storage_backend is None:
471
+ from services.storage.factory import create_storage_backend
472
+ self._storage_backend = create_storage_backend(DATA_DIR)
473
+ return self._storage_backend
474
+
475
+
476
+ def load_backup_state() -> dict[str, object]:
477
+ return _normalize_backup_state(_read_json_object(BACKUP_STATE_FILE, name="backup_state.json"))
478
+
479
+
480
+ def save_backup_state(state: dict[str, object]) -> dict[str, object]:
481
+ normalized = _normalize_backup_state(state)
482
+ BACKUP_STATE_FILE.write_text(json.dumps(normalized, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
483
+ return normalized
484
+
485
+
486
+ config = ConfigStore(CONFIG_FILE)
services/content_filter.py ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 request_shape(*values: object) -> dict[str, int]:
41
+ """Return a safe structural summary without logging prompts or image bytes."""
42
+ stats = {
43
+ "response_message_items": 0,
44
+ "input_image_parts": 0,
45
+ "image_url_parts": 0,
46
+ "image_parts": 0,
47
+ "data_url_images": 0,
48
+ "remote_image_urls": 0,
49
+ "literal_image_placeholders": 0,
50
+ }
51
+
52
+ def walk(value: object, key: str = "") -> None:
53
+ if isinstance(value, str):
54
+ text = value.strip()
55
+ lower = text.lower()
56
+ if "<image>" in lower:
57
+ stats["literal_image_placeholders"] += 1
58
+ if lower.startswith("data:image/"):
59
+ stats["data_url_images"] += 1
60
+ elif key in {"image_url", "url"} and lower.startswith(("http://", "https://")):
61
+ stats["remote_image_urls"] += 1
62
+ return
63
+ if isinstance(value, list):
64
+ for item in value:
65
+ walk(item, key)
66
+ return
67
+ if not isinstance(value, dict):
68
+ return
69
+ item_type = str(value.get("type") or "").strip()
70
+ if item_type == "message":
71
+ stats["response_message_items"] += 1
72
+ elif item_type == "input_image":
73
+ stats["input_image_parts"] += 1
74
+ elif item_type == "image_url":
75
+ stats["image_url_parts"] += 1
76
+ elif item_type == "image":
77
+ stats["image_parts"] += 1
78
+ for child_key, child in value.items():
79
+ walk(child, str(child_key))
80
+
81
+ for value in values:
82
+ walk(value)
83
+ return {key: value for key, value in stats.items() if value}
84
+
85
+
86
+ def _sanitize_for_review(text: str) -> tuple[str, dict[str, int]]:
87
+ """Strip base64 data URIs and truncate to the review-service context limit.
88
+
89
+ Returns (sanitized_text, stats) where stats carries base64_blocks_stripped
90
+ and truncated_chars so callers can emit structured logs.
91
+ """
92
+ sanitized, base64_blocks_stripped = _BASE64_DATA_URI.subn("[image]", text)
93
+ truncated_chars = 0
94
+ if len(sanitized) > _MAX_REVIEW_TEXT_LEN:
95
+ # Reserve marker space so the result stays within the cap.
96
+ half = (_MAX_REVIEW_TEXT_LEN - len(_TRUNCATION_MARKER)) // 2
97
+ truncated_chars = len(sanitized) - 2 * half
98
+ sanitized = sanitized[:half] + _TRUNCATION_MARKER + sanitized[-half:]
99
+ stats = {
100
+ "base64_blocks_stripped": base64_blocks_stripped,
101
+ "truncated_chars": truncated_chars,
102
+ }
103
+ return sanitized, stats
104
+
105
+
106
+ def _extract_review_decision(data: object) -> str | None:
107
+ """Defensively pull the decision text out of the review service response.
108
+
109
+ Returns None when the response shape doesn't match the OpenAI chat-completion
110
+ contract (e.g. {"error": ...} with no choices). The caller treats None as
111
+ "undecided" and applies the configured fail-open policy.
112
+ """
113
+ if not isinstance(data, dict):
114
+ return None
115
+ choices = data.get("choices")
116
+ if not isinstance(choices, list) or not choices:
117
+ return None
118
+ first = choices[0]
119
+ if not isinstance(first, dict):
120
+ return None
121
+ message = first.get("message")
122
+ if not isinstance(message, dict):
123
+ return None
124
+ content = message.get("content")
125
+ if content is None:
126
+ return None
127
+ return str(content).strip().lower()
128
+
129
+
130
+ def _is_allow_decision(decision: str) -> bool:
131
+ return decision.startswith(("allow", "pass", "true", "yes", "通过", "允许", "安全"))
132
+
133
+
134
+ def _is_reject_decision(decision: str) -> bool:
135
+ return decision.startswith(("reject", "deny", "block", "false", "no", "拒绝", "不允许", "违规", "禁止"))
136
+
137
+
138
+ def _resolve_fail_open(review: dict) -> bool:
139
+ """Resolve fail_open from review config. Defaults to True."""
140
+ value = review.get("fail_open")
141
+ if value is None:
142
+ return True
143
+ if isinstance(value, bool):
144
+ return value
145
+ if isinstance(value, str):
146
+ return value.strip().lower() in {"1", "true", "yes", "on"}
147
+ return bool(value)
148
+
149
+
150
+ def check_request(text: str) -> None:
151
+ text = str(text or "")
152
+ if not text.strip():
153
+ return
154
+ # Local sensitive-word match runs on the raw text (cheap, no network).
155
+ for word in config.sensitive_words:
156
+ if word in text:
157
+ raise HTTPException(status_code=400, detail={"error": "检测到敏感词,拒绝本次任务"})
158
+ review = config.ai_review
159
+ if not review.get("enabled"):
160
+ return
161
+ base_url = str(review.get("base_url") or "").strip().rstrip("/")
162
+ api_key = str(review.get("api_key") or "").strip()
163
+ model = str(review.get("model") or "").strip()
164
+ if not base_url or not api_key or not model:
165
+ raise HTTPException(status_code=400, detail={"error": "ai review config is incomplete"})
166
+
167
+ fail_open = _resolve_fail_open(review)
168
+
169
+ review_text, sanitize_stats = _sanitize_for_review(text)
170
+ if sanitize_stats["base64_blocks_stripped"] or sanitize_stats["truncated_chars"]:
171
+ logger.info({
172
+ "event": "ai_review_text_sanitized",
173
+ "original_text_len": len(text),
174
+ "review_text_len": len(review_text),
175
+ **sanitize_stats,
176
+ })
177
+ prompt = str(review.get("prompt") or DEFAULT_REVIEW_PROMPT).strip()
178
+ content = f"{prompt}\n\n用户请求:\n{review_text}\n\n只回答 ALLOW 或 REJECT。"
179
+
180
+ # fail_open=True (default): on upstream failure or ambiguous reply, let the
181
+ # request through. The review is a soft safety net; one missed review is
182
+ # preferable to a 5xx storm when the review service is flaky. Set
183
+ # config.ai_review.fail_open=false for strict-compliance deployments.
184
+ def _on_failure(event_payload: dict) -> None:
185
+ logger.warning(event_payload)
186
+ if not fail_open:
187
+ raise HTTPException(
188
+ status_code=503,
189
+ detail={"error": "AI 审核服务暂时不可用,请稍后重试"},
190
+ )
191
+
192
+ try:
193
+ response = requests.post(
194
+ f"{base_url}/v1/chat/completions",
195
+ headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
196
+ json={"model": model, "messages": [{"role": "user", "content": content}], "temperature": 0},
197
+ timeout=60,
198
+ **proxy_settings.build_session_kwargs(),
199
+ )
200
+ except Exception as exc:
201
+ _on_failure({
202
+ "event": "ai_review_request_failed",
203
+ "error": str(exc),
204
+ "error_type": exc.__class__.__name__,
205
+ "review_text_len": len(review_text),
206
+ "original_text_len": len(text),
207
+ })
208
+ return
209
+
210
+ try:
211
+ data = response.json()
212
+ except Exception as exc:
213
+ _on_failure({
214
+ "event": "ai_review_response_not_json",
215
+ "status_code": response.status_code,
216
+ "body_preview": str(response.text or "")[:200],
217
+ "error": str(exc),
218
+ })
219
+ return
220
+
221
+ decision = _extract_review_decision(data)
222
+ if decision is None:
223
+ _on_failure({
224
+ "event": "ai_review_malformed_response",
225
+ "status_code": response.status_code,
226
+ "body_preview": str(data)[:300],
227
+ "review_text_len": len(review_text),
228
+ "original_text_len": len(text),
229
+ })
230
+ return
231
+
232
+ if _is_allow_decision(decision):
233
+ return
234
+ if _is_reject_decision(decision):
235
+ raise HTTPException(status_code=400, detail={"error": "AI 审核未通过,拒绝本次任务"})
236
+ # Ambiguous decisions (e.g. "MAYBE", empty content) fall back to fail-open policy.
237
+ _on_failure({
238
+ "event": "ai_review_ambiguous_decision",
239
+ "decision": decision[:100],
240
+ "review_text_len": len(review_text),
241
+ })
242
+ 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, source_type="codex")
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/editable_file_task_service.py ADDED
@@ -0,0 +1,256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import threading
5
+ import time
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Any
9
+ from urllib.parse import quote
10
+
11
+ from services.account_service import account_service
12
+ from services.config import DATA_DIR
13
+ from services.content_filter import request_text
14
+ from services.log_service import LOG_TYPE_CALL, log_service
15
+ from services.openai_backend_api import EDITABLE_FILE_MODEL, OpenAIBackendAPI
16
+ from utils.helper import new_uuid
17
+
18
+ TASK_STATUS_QUEUED = "queued"
19
+ TASK_STATUS_RUNNING = "running"
20
+ TASK_STATUS_SUCCESS = "success"
21
+ TASK_STATUS_ERROR = "error"
22
+ UNFINISHED_STATUSES = {TASK_STATUS_QUEUED, TASK_STATUS_RUNNING}
23
+ EDITABLE_FILE_PLAN_TYPES = ("Plus", "Team", "Pro", "Enterprise")
24
+ EDITABLE_FILE_ROOT = DATA_DIR / "files"
25
+ EDITABLE_FILE_TASKS_PATH = DATA_DIR / "editable_file_tasks.json"
26
+
27
+
28
+ def _now_iso() -> str:
29
+ return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
30
+
31
+
32
+ def _clean(value: object, default: str = "") -> str:
33
+ return str(value or default).strip()
34
+
35
+
36
+ def _owner_id(identity: dict[str, object]) -> str:
37
+ return _clean(identity.get("id")) or "anonymous"
38
+
39
+
40
+ def _task_key(owner_id: str, task_id: str) -> str:
41
+ return f"{owner_id}:{task_id}"
42
+
43
+
44
+ def _elapsed_seconds(task: dict[str, Any]) -> int:
45
+ start = float(task.get("started_ts") or task.get("created_ts") or 0)
46
+ end = float(task.get("ended_ts") or time.time())
47
+ return max(0, int(end - start)) if start else 0
48
+
49
+
50
+ def _file_url(path: Path, base_url: str) -> str:
51
+ rel = path.resolve().relative_to(EDITABLE_FILE_ROOT.resolve()).as_posix()
52
+ prefix = str(base_url or "").strip().rstrip("/")
53
+ return f"{prefix}/files/{quote(rel, safe='/')}" if prefix else f"/files/{quote(rel, safe='/')}"
54
+
55
+
56
+ def _editable_access_token() -> str:
57
+ accounts = [
58
+ item for item in account_service.list_accounts()
59
+ if _clean(item.get("access_token"))
60
+ and item.get("status") not in {"禁用", "异常"}
61
+ and account_service._account_matches_any_plan_type(item, EDITABLE_FILE_PLAN_TYPES)
62
+ ]
63
+ if not accounts:
64
+ raise RuntimeError("no available plus/team/pro account")
65
+ accounts.sort(key=lambda item: _clean(item.get("last_used_at")))
66
+ token = _clean(accounts[0].get("access_token"))
67
+ return account_service.refresh_access_token(token, event="editable_file_task") or token
68
+
69
+
70
+ def _public_task(task: dict[str, Any]) -> dict[str, Any]:
71
+ item = {
72
+ "id": task.get("id"),
73
+ "taskId": task.get("id"),
74
+ "status": task.get("status"),
75
+ "kind": task.get("kind"),
76
+ "created_at": task.get("created_at"),
77
+ "updated_at": task.get("updated_at"),
78
+ "elapsed_seconds": _elapsed_seconds(task),
79
+ }
80
+ for key in ("result", "error"):
81
+ if task.get(key):
82
+ item[key] = task[key]
83
+ return item
84
+
85
+
86
+ class EditableFileTaskService:
87
+ def __init__(self, path: Path = EDITABLE_FILE_TASKS_PATH) -> None:
88
+ self.path = path
89
+ self._lock = threading.RLock()
90
+ self._tasks: dict[str, dict[str, Any]] = {}
91
+ self.path.parent.mkdir(parents=True, exist_ok=True)
92
+ with self._lock:
93
+ self._tasks = self._load_locked()
94
+ if self._recover_unfinished_locked():
95
+ self._save_locked()
96
+
97
+ def submit_ppt(self, identity: dict[str, object], *, client_task_id: str = "", prompt: str = "", base64_images: list[str] | None = None, base_url: str = "") -> dict[str, Any]:
98
+ return self._submit(identity, client_task_id=client_task_id, kind="ppt", prompt=prompt, base64_images=base64_images or [], base_url=base_url)
99
+
100
+ def submit_psd(self, identity: dict[str, object], *, client_task_id: str = "", prompt: str = "", base64_images: list[str] | None = None, base_url: str = "") -> dict[str, Any]:
101
+ return self._submit(identity, client_task_id=client_task_id, kind="psd", prompt=prompt, base64_images=base64_images or [], base_url=base_url)
102
+
103
+ def list_tasks(self, identity: dict[str, object], task_ids: list[str]) -> dict[str, Any]:
104
+ owner = _owner_id(identity)
105
+ requested = [_clean(item) for item in task_ids if _clean(item)]
106
+ with self._lock:
107
+ if requested:
108
+ items = [task for task_id in requested if (task := self._tasks.get(_task_key(owner, task_id)))]
109
+ return {"items": [_public_task(item) for item in items], "missing_ids": [task_id for task_id in requested if _task_key(owner, task_id) not in self._tasks]}
110
+ items = [task for task in self._tasks.values() if task.get("owner_id") == owner]
111
+ items.sort(key=lambda item: str(item.get("updated_at") or ""), reverse=True)
112
+ return {"items": [_public_task(item) for item in items], "missing_ids": []}
113
+
114
+ def _submit(self, identity: dict[str, object], *, client_task_id: str, kind: str, prompt: str, base64_images: list[str], base_url: str) -> dict[str, Any]:
115
+ task_id = _clean(client_task_id) or new_uuid()
116
+ owner = _owner_id(identity)
117
+ key = _task_key(owner, task_id)
118
+ now = _now_iso()
119
+ with self._lock:
120
+ if key in self._tasks:
121
+ return _public_task(self._tasks[key])
122
+ ts = time.time()
123
+ self._tasks[key] = {"id": task_id, "owner_id": owner, "status": TASK_STATUS_QUEUED, "kind": kind, "model": EDITABLE_FILE_MODEL, "created_at": now, "updated_at": now, "created_ts": ts, "updated_ts": ts}
124
+ task = dict(self._tasks[key])
125
+ self._save_locked()
126
+ threading.Thread(target=self._run_task, args=(key, kind, prompt, base64_images, dict(identity), base_url), name=f"{kind}-file-task-{task_id[:16]}", daemon=True).start()
127
+ return _public_task(task)
128
+
129
+ def _run_task(self, key: str, kind: str, prompt: str, base64_images: list[str], identity: dict[str, object], base_url: str) -> None:
130
+ started = time.time()
131
+ token = ""
132
+ account_email = ""
133
+ self._update_task(key, status=TASK_STATUS_RUNNING, error="", started_ts=started)
134
+ try:
135
+ if kind == "psd" and not base64_images:
136
+ raise ValueError("base64_images is empty")
137
+ token = _editable_access_token()
138
+ account = account_service.get_account(token) or {}
139
+ account_email = _clean(account.get("email"))
140
+ backend = OpenAIBackendAPI(token)
141
+ output_dir = EDITABLE_FILE_ROOT / kind / key.rsplit(":", 1)[-1]
142
+ result = backend.export_psd_zip(base64_images, prompt, output_dir) if kind == "psd" else backend.export_ppt_zip(base64_images, prompt, output_dir)
143
+ account_service.mark_text_used(token)
144
+ data = {"conversation_id": result.conversation_id, "primary_url": _file_url(result.primary_path, base_url), "zip_url": _file_url(result.zip_path, base_url)}
145
+ self._update_task(key, status=TASK_STATUS_SUCCESS, result=data, account_email=account_email, error="", ended_ts=time.time())
146
+ self._log_call(identity, kind, started, request_text(prompt), account_email=account_email, result=data)
147
+ except Exception as exc:
148
+ error = str(exc) or "editable file task failed"
149
+ self._update_task(key, status=TASK_STATUS_ERROR, error=error, account_email=account_email, ended_ts=time.time())
150
+ self._log_call(identity, kind, started, request_text(prompt), status="failed", error=error, account_email=account_email)
151
+
152
+ def public_file_path(self, relative_path: str) -> Path:
153
+ raw = str(relative_path or "").replace("\\", "/").lstrip("/")
154
+ path = (EDITABLE_FILE_ROOT / raw).resolve()
155
+ path.relative_to(EDITABLE_FILE_ROOT.resolve())
156
+ if not path.is_file():
157
+ raise FileNotFoundError(raw)
158
+ return path
159
+
160
+ def _update_task(self, key: str, **updates: Any) -> None:
161
+ with self._lock:
162
+ task = self._tasks.get(key)
163
+ if task is None:
164
+ return
165
+ task.update(updates)
166
+ task["updated_at"] = _now_iso()
167
+ task["updated_ts"] = time.time()
168
+ self._save_locked()
169
+
170
+ def _load_locked(self) -> dict[str, dict[str, Any]]:
171
+ if not self.path.exists():
172
+ return {}
173
+ try:
174
+ raw = json.loads(self.path.read_text(encoding="utf-8"))
175
+ except Exception:
176
+ return {}
177
+ tasks: dict[str, dict[str, Any]] = {}
178
+ for item in (raw.get("tasks") if isinstance(raw, dict) else raw) or []:
179
+ if not isinstance(item, dict):
180
+ continue
181
+ task_id = _clean(item.get("id"))
182
+ owner = _clean(item.get("owner_id"))
183
+ if not task_id or not owner:
184
+ continue
185
+ task = {
186
+ "id": task_id,
187
+ "owner_id": owner,
188
+ "status": _clean(item.get("status"), TASK_STATUS_ERROR),
189
+ "kind": "psd" if item.get("kind") == "psd" else "ppt",
190
+ "created_at": _clean(item.get("created_at"), _now_iso()),
191
+ "updated_at": _clean(item.get("updated_at"), _clean(item.get("created_at"), _now_iso())),
192
+ "created_ts": float(item.get("created_ts") or 0),
193
+ "updated_ts": float(item.get("updated_ts") or 0),
194
+ }
195
+ for field in ("result", "error", "started_ts", "ended_ts"):
196
+ if item.get(field):
197
+ task[field] = item[field]
198
+ tasks[_task_key(owner, task_id)] = task
199
+ return tasks
200
+
201
+ def _save_locked(self) -> None:
202
+ items = sorted(self._tasks.values(), key=lambda item: str(item.get("updated_at") or ""), reverse=True)
203
+ tmp_path = self.path.with_suffix(self.path.suffix + ".tmp")
204
+ tmp_path.write_text(json.dumps({"tasks": items}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
205
+ tmp_path.replace(self.path)
206
+
207
+ def _recover_unfinished_locked(self) -> bool:
208
+ changed = False
209
+ for task in self._tasks.values():
210
+ if task.get("status") in UNFINISHED_STATUSES:
211
+ task["status"] = TASK_STATUS_ERROR
212
+ task["error"] = "服务已重启,未完成的任务已中断"
213
+ task["ended_ts"] = time.time()
214
+ task["updated_at"] = _now_iso()
215
+ task["updated_ts"] = time.time()
216
+ changed = True
217
+ return changed
218
+
219
+ def _log_call(
220
+ self,
221
+ identity: dict[str, object],
222
+ kind: str,
223
+ started: float,
224
+ request_preview: str,
225
+ *,
226
+ status: str = "success",
227
+ error: str = "",
228
+ account_email: str = "",
229
+ result: dict[str, str] | None = None,
230
+ ) -> None:
231
+ detail = {
232
+ "key_id": identity.get("id"),
233
+ "key_name": identity.get("name"),
234
+ "role": identity.get("role"),
235
+ "endpoint": f"/v1/{kind}/generations",
236
+ "model": EDITABLE_FILE_MODEL,
237
+ "started_at": datetime.fromtimestamp(started).strftime("%Y-%m-%d %H:%M:%S"),
238
+ "ended_at": _now_iso(),
239
+ "duration_ms": int((time.time() - started) * 1000),
240
+ "status": status,
241
+ }
242
+ if request_preview:
243
+ detail["request_text"] = request_preview
244
+ if account_email:
245
+ detail["account_email"] = account_email
246
+ if error:
247
+ detail["error"] = error
248
+ if result:
249
+ detail["result"] = result
250
+ try:
251
+ log_service.add(LOG_TYPE_CALL, f"{kind.upper()}生成任务{'失败' if status == 'failed' else '完成'}", detail)
252
+ except Exception:
253
+ pass
254
+
255
+
256
+ editable_file_task_service = EditableFileTaskService()
services/image_service.py ADDED
@@ -0,0 +1,376 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ headers = {
55
+ "Access-Control-Allow-Origin": "*",
56
+ "Access-Control-Allow-Methods": "GET, OPTIONS",
57
+ "Access-Control-Allow-Headers": "*",
58
+ }
59
+ if image_storage_service.has_local(relative_path):
60
+ return FileResponse(_safe_image_path(relative_path), headers=headers)
61
+ return Response(content=image_storage_service.get_bytes(relative_path), media_type="image/png", headers=headers)
62
+
63
+
64
+ def _thumbnail_path(relative_path: str) -> Path:
65
+ rel = _safe_relative_path(relative_path)
66
+ return config.image_thumbnails_dir / f"{rel}.png"
67
+
68
+
69
+ def thumbnail_url(base_url: str, relative_path: str) -> str:
70
+ return f"{base_url.rstrip('/')}/image-thumbnails/{_safe_relative_path(relative_path)}"
71
+
72
+
73
+ def _image_dimensions(path: Path) -> tuple[int, int] | None:
74
+ try:
75
+ with Image.open(path) as image:
76
+ return image.size
77
+ except Exception:
78
+ return None
79
+
80
+
81
+ def ensure_thumbnail(relative_path: str) -> Path:
82
+ target = _thumbnail_path(relative_path)
83
+ source_mtime = 0.0
84
+ source: Path | None = None
85
+ if image_storage_service.has_local(relative_path):
86
+ source = _safe_image_path(relative_path)
87
+ source_mtime = source.stat().st_mtime
88
+ if target.exists() and (not source_mtime or target.stat().st_mtime >= source_mtime):
89
+ return target
90
+
91
+ target.parent.mkdir(parents=True, exist_ok=True)
92
+ try:
93
+ image_source = source if source is not None else io.BytesIO(image_storage_service.get_bytes(relative_path))
94
+ with Image.open(image_source) as image:
95
+ image = ImageOps.exif_transpose(image)
96
+ if image.mode not in {"RGB", "RGBA"}:
97
+ image = image.convert("RGBA" if "A" in image.getbands() else "RGB")
98
+ image.thumbnail(THUMBNAIL_SIZE, Image.Resampling.LANCZOS)
99
+ image.save(target, format="PNG", optimize=True)
100
+ except HTTPException:
101
+ raise
102
+ except Exception as exc:
103
+ raise HTTPException(status_code=422, detail="failed to create thumbnail") from exc
104
+ return target
105
+
106
+
107
+ def get_thumbnail_response(relative_path: str) -> FileResponse:
108
+ headers = {
109
+ "Access-Control-Allow-Origin": "*",
110
+ "Access-Control-Allow-Methods": "GET, OPTIONS",
111
+ "Access-Control-Allow-Headers": "*",
112
+ }
113
+ return FileResponse(ensure_thumbnail(relative_path), headers=headers)
114
+
115
+
116
+ def get_image_download_response(relative_path: str) -> FileResponse:
117
+ cors_headers = {
118
+ "Access-Control-Allow-Origin": "*",
119
+ "Access-Control-Allow-Methods": "GET, OPTIONS",
120
+ "Access-Control-Allow-Headers": "*",
121
+ }
122
+ if image_storage_service.has_local(relative_path):
123
+ path = _safe_image_path(relative_path)
124
+ headers = {**cors_headers, "Content-Disposition": f'attachment; filename="{path.name}"'}
125
+ return FileResponse(path, filename=path.name, headers=headers)
126
+ rel = _safe_relative_path(relative_path)
127
+ headers = {
128
+ **cors_headers,
129
+ "Content-Disposition": f'attachment; filename="{Path(rel).name}"',
130
+ }
131
+ return Response(
132
+ content=image_storage_service.get_bytes(rel),
133
+ media_type="image/png",
134
+ headers=headers,
135
+ )
136
+
137
+
138
+ def cleanup_image_thumbnails() -> int:
139
+ thumbnails_root = config.image_thumbnails_dir
140
+ removed = 0
141
+ for path in thumbnails_root.rglob("*"):
142
+ if not path.is_file():
143
+ continue
144
+ rel = path.relative_to(thumbnails_root).as_posix()
145
+ if not rel.endswith(".png") or not image_storage_service.exists(rel[:-4]):
146
+ path.unlink()
147
+ removed += 1
148
+ _cleanup_empty_dirs(thumbnails_root)
149
+ return removed
150
+
151
+ def list_images(base_url: str, start_date: str = "", end_date: str = "") -> dict[str, object]:
152
+ config.cleanup_old_images()
153
+ cleanup_image_thumbnails()
154
+ all_tags = load_tags()
155
+ items = [
156
+ {
157
+ **item,
158
+ "url": str(item.get("url") or f"{base_url.rstrip('/')}/images/{item['path']}"),
159
+ "thumbnail_url": thumbnail_url(base_url, str(item["path"])),
160
+ "tags": all_tags.get(str(item["path"]), []),
161
+ }
162
+ for item in image_storage_service.list_items(base_url, start_date, end_date)
163
+ ]
164
+ groups: dict[str, list[dict[str, object]]] = {}
165
+ for item in items:
166
+ groups.setdefault(str(item["date"]), []).append(item)
167
+ return {"items": items, "groups": [{"date": key, "items": value} for key, value in groups.items()]}
168
+
169
+
170
+ def delete_images(paths: list[str] | None = None, start_date: str = "", end_date: str = "", all_matching: bool = False) -> dict[str, int]:
171
+ root = config.images_dir.resolve()
172
+ targets = [
173
+ str(item["path"])
174
+ for item in image_storage_service.list_items("", start_date=start_date, end_date=end_date)
175
+ ] if all_matching else (paths or [])
176
+ removed = 0
177
+ for item in targets:
178
+ path = (root / item).resolve()
179
+ try:
180
+ path.relative_to(root)
181
+ except ValueError:
182
+ continue
183
+ if image_storage_service.delete(item):
184
+ removed += 1
185
+ for thumbnail in (_thumbnail_path(item), config.image_thumbnails_dir / _safe_relative_path(item)):
186
+ if thumbnail.is_file():
187
+ thumbnail.unlink()
188
+ remove_tags(item)
189
+ _cleanup_empty_dirs(root)
190
+ _cleanup_empty_dirs(config.image_thumbnails_dir)
191
+ return {"removed": removed}
192
+
193
+
194
+ def download_images_zip(paths: list[str]) -> io.BytesIO:
195
+ root = config.images_dir.resolve()
196
+ buf = io.BytesIO()
197
+ added = 0
198
+ used_names: set[str] = set()
199
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
200
+ for item in paths:
201
+ rel = _safe_relative_path(item)
202
+ path = (root / rel).resolve()
203
+ payload: bytes | None = None
204
+ try:
205
+ path.relative_to(root)
206
+ except ValueError:
207
+ continue
208
+ if path.is_file():
209
+ payload = path.read_bytes()
210
+ else:
211
+ try:
212
+ payload = image_storage_service.get_bytes(rel)
213
+ except Exception:
214
+ continue
215
+ name = path.name
216
+ if name in used_names:
217
+ stem = path.stem
218
+ suffix = path.suffix
219
+ counter = 2
220
+ while f"{stem}_{counter}{suffix}" in used_names:
221
+ counter += 1
222
+ name = f"{stem}_{counter}{suffix}"
223
+ used_names.add(name)
224
+ zf.writestr(name, payload)
225
+ added += 1
226
+ if added == 0:
227
+ raise HTTPException(status_code=404, detail="no images found")
228
+ buf.seek(0)
229
+ return buf
230
+ def storage_stats() -> dict:
231
+ import shutil
232
+ usage = shutil.disk_usage(config.images_dir)
233
+ total_mb = usage.total // (1024 * 1024)
234
+ used_mb = usage.used // (1024 * 1024)
235
+ free_mb = usage.free // (1024 * 1024)
236
+
237
+ image_count = 0
238
+ image_size = 0
239
+ for p in config.images_dir.rglob("*"):
240
+ if p.is_file():
241
+ image_count += 1
242
+ image_size += p.stat().st_size
243
+
244
+ return {
245
+ "disk_total_mb": total_mb,
246
+ "disk_used_mb": used_mb,
247
+ "disk_free_mb": free_mb,
248
+ "image_count": image_count,
249
+ "image_size_mb": image_size // (1024 * 1024),
250
+ "image_size_bytes": image_size,
251
+ }
252
+
253
+
254
+ def compress_images(quality: int = 60) -> dict:
255
+ """重新压缩所有图片,返回节省的空间"""
256
+ saved = 0
257
+ count = 0
258
+ for p in sorted(config.images_dir.rglob("*.png")):
259
+ if not p.is_file():
260
+ continue
261
+ try:
262
+ orig = p.stat().st_size
263
+ with Image.open(p) as img:
264
+ img = ImageOps.exif_transpose(img)
265
+ img.save(str(p) + ".tmp", format="PNG", optimize=True)
266
+ new_size = Path(str(p) + ".tmp").stat().st_size
267
+ if new_size < orig:
268
+ Path(str(p) + ".tmp").replace(p)
269
+ saved += orig - new_size
270
+ count += 1
271
+ else:
272
+ Path(str(p) + ".tmp").unlink()
273
+ except Exception:
274
+ pass
275
+ return {"compressed": count, "saved_bytes": saved, "saved_mb": saved // (1024 * 1024)}
276
+
277
+
278
+ def delete_to_target(target_free_mb: int, dry_run: bool = False) -> dict:
279
+ """删除最旧的图片直到剩余空间达到 target_free_mb"""
280
+ import shutil
281
+ usage = shutil.disk_usage(config.images_dir)
282
+ current_free = usage.free // (1024 * 1024)
283
+ if current_free >= target_free_mb and not dry_run:
284
+ return {"removed": 0, "current_free_mb": current_free, "target_free_mb": target_free_mb, "done": True}
285
+
286
+ files = sorted(
287
+ (p for p in config.images_dir.rglob("*.png") if p.is_file()),
288
+ key=lambda p: p.stat().st_mtime,
289
+ )
290
+ removed = 0
291
+ freed = 0
292
+ for p in files:
293
+ if current_free + freed // (1024 * 1024) >= target_free_mb:
294
+ break
295
+ size = p.stat().st_size
296
+ if not dry_run:
297
+ rel = p.relative_to(config.images_dir).as_posix()
298
+ for tp in (_thumbnail_path(rel), config.image_thumbnails_dir / _safe_relative_path(rel)):
299
+ if tp.is_file():
300
+ tp.unlink()
301
+ remove_tags(rel)
302
+ p.unlink()
303
+ freed += size
304
+ removed += 1
305
+
306
+ if not dry_run:
307
+ _cleanup_empty_dirs(config.images_dir)
308
+ _cleanup_empty_dirs(config.image_thumbnails_dir)
309
+
310
+ return {
311
+ "removed": removed,
312
+ "freed_mb": freed // (1024 * 1024),
313
+ "target_free_mb": target_free_mb,
314
+ "current_free_mb": current_free + (freed // (1024 * 1024)),
315
+ "done": (current_free + freed // (1024 * 1024)) >= target_free_mb,
316
+ "dry_run": dry_run,
317
+ }
318
+
319
+
320
+ def download_images_zip(paths: list[str]) -> io.BytesIO:
321
+ root = config.images_dir.resolve()
322
+ buf = io.BytesIO()
323
+ added = 0
324
+ used_names: set[str] = set()
325
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
326
+ for item in paths:
327
+ rel = _safe_relative_path(item)
328
+ path = (root / rel).resolve()
329
+ try:
330
+ path.relative_to(root)
331
+ except ValueError:
332
+ continue
333
+ if not path.is_file():
334
+ continue
335
+ name = path.name
336
+ if name in used_names:
337
+ stem = path.stem
338
+ suffix = path.suffix
339
+ counter = 2
340
+ while f"{stem}_{counter}{suffix}" in used_names:
341
+ counter += 1
342
+ name = f"{stem}_{counter}{suffix}"
343
+ used_names.add(name)
344
+ zf.write(path, name)
345
+ added += 1
346
+ if added == 0:
347
+ raise HTTPException(status_code=404, detail="no images found")
348
+ buf.seek(0)
349
+ return buf
350
+
351
+
352
+ def _auto_cleanup_worker(stop_event: threading.Event) -> None:
353
+ """后台线程:每30分钟检查存储,空间低于阈值自动清理最旧图片"""
354
+ import shutil
355
+ min_free_mb = getattr(config, "image_min_free_mb", None)
356
+ if min_free_mb is None:
357
+ min_free_mb = 500
358
+
359
+ while not stop_event.wait(1800): # 每30分钟
360
+ try:
361
+ config.cleanup_old_images()
362
+ cleanup_image_thumbnails()
363
+ usage = shutil.disk_usage(config.images_dir)
364
+ free_mb = usage.free // (1024 * 1024)
365
+ if free_mb < min_free_mb:
366
+ logger.info({"event": "image_auto_cleanup", "free_mb": free_mb, "min_free_mb": min_free_mb})
367
+ result = delete_to_target(min_free_mb)
368
+ logger.info({"event": "image_auto_cleanup_done", **result})
369
+ except Exception:
370
+ pass
371
+
372
+
373
+ def start_image_cleanup_scheduler(stop_event: threading.Event) -> threading.Thread:
374
+ t = threading.Thread(target=_auto_cleanup_worker, args=(stop_event,), daemon=True, name="image-cleanup")
375
+ t.start()
376
+ 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,544 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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("conversation_id"):
76
+ item["conversation_id"] = task.get("conversation_id")
77
+ if task.get("data") is not None:
78
+ item["data"] = task.get("data")
79
+ if task.get("usage") is not None:
80
+ item["usage"] = task.get("usage")
81
+ if task.get("error"):
82
+ item["error"] = task.get("error")
83
+ if task.get("progress"):
84
+ item["progress"] = task.get("progress")
85
+ if task.get("duration_ms") is not None:
86
+ item["duration_ms"] = task.get("duration_ms")
87
+ if task.get("status") in (TASK_STATUS_RUNNING, TASK_STATUS_QUEUED):
88
+ if task.get("status") == TASK_STATUS_RUNNING:
89
+ # RUNNING 状态仅在 started_ts 被设置后(image_stream_resolve_start)才计时
90
+ base_ts = task.get("started_ts")
91
+ else:
92
+ # QUEUED 状态从 created_ts 开始计时(排队等待中)
93
+ base_ts = task.get("created_ts") or task.get("updated_ts")
94
+ if base_ts:
95
+ item["elapsed_secs"] = round(time.time() - base_ts, 1)
96
+ return item
97
+
98
+
99
+ class ImageTaskService:
100
+ def __init__(
101
+ self,
102
+ path: Path,
103
+ *,
104
+ generation_handler: Callable[[dict[str, Any]], dict[str, Any]] = openai_v1_image_generations.handle,
105
+ edit_handler: Callable[[dict[str, Any]], dict[str, Any]] = openai_v1_image_edit.handle,
106
+ retention_days_getter: Callable[[], int] | None = None,
107
+ ):
108
+ self.path = path
109
+ self.generation_handler = generation_handler
110
+ self.edit_handler = edit_handler
111
+ self.retention_days_getter = retention_days_getter or (lambda: config.image_retention_days)
112
+ self._lock = threading.RLock()
113
+ self._tasks: dict[str, dict[str, Any]] = {}
114
+ self.path.parent.mkdir(parents=True, exist_ok=True)
115
+ with self._lock:
116
+ self._tasks = self._load_locked()
117
+ changed = self._recover_unfinished_locked()
118
+ changed = self._cleanup_locked() or changed
119
+ if changed:
120
+ self._save_locked()
121
+
122
+ def submit_generation(
123
+ self,
124
+ identity: dict[str, object],
125
+ *,
126
+ client_task_id: str,
127
+ prompt: str,
128
+ model: str,
129
+ size: str | None,
130
+ quality: str = "auto",
131
+ base_url: str = "",
132
+ ) -> dict[str, Any]:
133
+ payload = {
134
+ "prompt": prompt,
135
+ "model": model,
136
+ "n": 1,
137
+ "size": size,
138
+ "quality": quality,
139
+ "response_format": "url",
140
+ "base_url": base_url,
141
+ }
142
+ return self._submit(identity, client_task_id=client_task_id, mode="generate", payload=payload)
143
+
144
+ def submit_edit(
145
+ self,
146
+ identity: dict[str, object],
147
+ *,
148
+ client_task_id: str,
149
+ prompt: str,
150
+ model: str,
151
+ size: str | None,
152
+ quality: str = "auto",
153
+ base_url: str = "",
154
+ images: list[tuple[bytes, str, str]] | None = None,
155
+ ) -> dict[str, Any]:
156
+ payload = {
157
+ "prompt": prompt,
158
+ "images": images or [],
159
+ "model": model,
160
+ "n": 1,
161
+ "size": size,
162
+ "quality": quality,
163
+ "response_format": "url",
164
+ "base_url": base_url,
165
+ }
166
+ return self._submit(identity, client_task_id=client_task_id, mode="edit", payload=payload)
167
+
168
+ def list_tasks(self, identity: dict[str, object], task_ids: list[str]) -> dict[str, Any]:
169
+ owner = _owner_id(identity)
170
+ requested_ids = [_clean(task_id) for task_id in task_ids if _clean(task_id)]
171
+ with self._lock:
172
+ if self._cleanup_locked():
173
+ self._save_locked()
174
+ items = []
175
+ missing_ids = []
176
+ for task_id in requested_ids:
177
+ task = self._tasks.get(_task_key(owner, task_id))
178
+ if task is None:
179
+ missing_ids.append(task_id)
180
+ else:
181
+ items.append(_public_task(task))
182
+ if not requested_ids:
183
+ items = [
184
+ _public_task(task)
185
+ for task in self._tasks.values()
186
+ if task.get("owner_id") == owner
187
+ ]
188
+ items.sort(key=lambda item: str(item.get("updated_at") or ""), reverse=True)
189
+ missing_ids = []
190
+ return {"items": items, "missing_ids": missing_ids}
191
+
192
+ def _submit(
193
+ self,
194
+ identity: dict[str, object],
195
+ *,
196
+ client_task_id: str,
197
+ mode: str,
198
+ payload: dict[str, Any],
199
+ ) -> dict[str, Any]:
200
+ task_id = _clean(client_task_id)
201
+ if not task_id:
202
+ raise ValueError("client_task_id is required")
203
+ owner = _owner_id(identity)
204
+ key = _task_key(owner, task_id)
205
+ now = _now_iso()
206
+ should_start = False
207
+ with self._lock:
208
+ cleaned = self._cleanup_locked()
209
+ task = self._tasks.get(key)
210
+ if task is not None:
211
+ if cleaned:
212
+ self._save_locked()
213
+ return _public_task(task)
214
+ task = {
215
+ "id": task_id,
216
+ "owner_id": owner,
217
+ "status": TASK_STATUS_QUEUED,
218
+ "mode": mode,
219
+ "model": _clean(payload.get("model"), "gpt-image-2"),
220
+ "size": _clean(payload.get("size")),
221
+ "quality": _clean(payload.get("quality"), "auto"),
222
+ "created_at": now,
223
+ "updated_at": now,
224
+ "created_ts": time.time(),
225
+ }
226
+ self._tasks[key] = task
227
+ self._save_locked()
228
+ should_start = True
229
+
230
+ if should_start:
231
+ thread = threading.Thread(
232
+ target=self._run_task,
233
+ args=(key, mode, payload, dict(identity), _clean(payload.get("model"), "gpt-image-2")),
234
+ name=f"image-task-{task_id[:16]}",
235
+ daemon=True,
236
+ )
237
+ thread.start()
238
+ return _public_task(task)
239
+
240
+ def _run_task(
241
+ self,
242
+ key: str,
243
+ mode: str,
244
+ payload: dict[str, Any],
245
+ identity: dict[str, object],
246
+ model: str,
247
+ ) -> None:
248
+ started = time.time()
249
+ self._update_task(key, status=TASK_STATUS_RUNNING, error="")
250
+ # 创建进度回调,每个步骤完成后更新任务状态
251
+ def progress_callback(step: str) -> None:
252
+ if step == "image_stream_resolve_start":
253
+ self._update_task(key, started_ts=time.time())
254
+ self._update_task(key, progress=step)
255
+ # 将进度回调添加到 payload 中(handler 会提取并传递给 ConversationRequest)
256
+ payload_with_progress = {**payload, "progress_callback": progress_callback}
257
+ try:
258
+ handler = self.edit_handler if mode == "edit" else self.generation_handler
259
+ result = handler(payload_with_progress)
260
+ if not isinstance(result, dict):
261
+ raise RuntimeError("image task returned streaming result unexpectedly")
262
+ data = result.get("data")
263
+ account_email = _clean(result.get("_account_email") or result.get("account_email"))
264
+ if not isinstance(data, list) or not data:
265
+ upstream = _clean(result.get("message"))
266
+ if upstream:
267
+ message = upstream
268
+ else:
269
+ message = "号池中没有可用账号或所有账号均被限流,请检查号池状态(账号额度、是否被封禁、是否到达生图上限)"
270
+ error = RuntimeError(message)
271
+ if account_email:
272
+ setattr(error, "account_email", account_email)
273
+ raise error
274
+ usage = result.get("usage")
275
+ duration_ms = int((time.time() - started) * 1000)
276
+ self._update_task(key, status=TASK_STATUS_SUCCESS, data=data, usage=usage, error="", duration_ms=duration_ms)
277
+ self._log_call(
278
+ identity,
279
+ mode,
280
+ model,
281
+ started,
282
+ "调用完成",
283
+ request_preview=request_text(payload.get("prompt")),
284
+ urls=_collect_image_urls(data),
285
+ account_email=account_email,
286
+ )
287
+ except Exception as exc:
288
+ error_message = str(exc) or "image task failed"
289
+ account_email = _clean(getattr(exc, "account_email", ""))
290
+ conversation_id = _clean(getattr(exc, "conversation_id", ""))
291
+ duration_ms = int((time.time() - started) * 1000)
292
+ self._update_task(key, status=TASK_STATUS_ERROR, error=error_message, data=[],
293
+ duration_ms=duration_ms,
294
+ **({"conversation_id": conversation_id} if conversation_id else {}))
295
+ self._log_call(
296
+ identity,
297
+ mode,
298
+ model,
299
+ started,
300
+ "调用失败",
301
+ request_preview=request_text(payload.get("prompt")),
302
+ status="failed",
303
+ error=error_message,
304
+ account_email=account_email,
305
+ )
306
+
307
+ def _log_call(
308
+ self,
309
+ identity: dict[str, object],
310
+ mode: str,
311
+ model: str,
312
+ started: float,
313
+ suffix: str,
314
+ *,
315
+ request_preview: str = "",
316
+ status: str = "success",
317
+ error: str = "",
318
+ urls: list[str] | None = None,
319
+ account_email: str = "",
320
+ ) -> None:
321
+ endpoint = "/v1/images/edits" if mode == "edit" else "/v1/images/generations"
322
+ summary_prefix = "图生图" if mode == "edit" else "文生图"
323
+ detail = {
324
+ "key_id": identity.get("id"),
325
+ "key_name": identity.get("name"),
326
+ "role": identity.get("role"),
327
+ "endpoint": endpoint,
328
+ "model": model,
329
+ "started_at": datetime.fromtimestamp(started).strftime("%Y-%m-%d %H:%M:%S"),
330
+ "ended_at": _now_iso(),
331
+ "duration_ms": int((time.time() - started) * 1000),
332
+ "status": status,
333
+ }
334
+ if request_preview:
335
+ detail["request_text"] = request_preview
336
+ if error:
337
+ detail["error"] = error
338
+ if account_email:
339
+ detail["account_email"] = account_email
340
+ if urls:
341
+ detail["urls"] = list(dict.fromkeys(urls))
342
+ try:
343
+ log_service.add(LOG_TYPE_CALL, f"{summary_prefix}{suffix}", detail)
344
+ except Exception:
345
+ pass
346
+
347
+ def _update_task(self, key: str, **updates: Any) -> None:
348
+ with self._lock:
349
+ task = self._tasks.get(key)
350
+ if task is None:
351
+ return
352
+ task.update(updates)
353
+ task["updated_at"] = _now_iso()
354
+ task["updated_ts"] = time.time()
355
+ self._save_locked()
356
+
357
+ def _load_locked(self) -> dict[str, dict[str, Any]]:
358
+ if not self.path.exists():
359
+ return {}
360
+ try:
361
+ raw = json.loads(self.path.read_text(encoding="utf-8"))
362
+ except Exception:
363
+ return {}
364
+ raw_items = raw.get("tasks") if isinstance(raw, dict) else raw
365
+ if not isinstance(raw_items, list):
366
+ return {}
367
+ tasks: dict[str, dict[str, Any]] = {}
368
+ for item in raw_items:
369
+ if not isinstance(item, dict):
370
+ continue
371
+ task_id = _clean(item.get("id"))
372
+ owner = _clean(item.get("owner_id"))
373
+ if not task_id or not owner:
374
+ continue
375
+ status = _clean(item.get("status"))
376
+ if status not in {TASK_STATUS_QUEUED, TASK_STATUS_RUNNING, TASK_STATUS_SUCCESS, TASK_STATUS_ERROR}:
377
+ status = TASK_STATUS_ERROR
378
+ task = {
379
+ "id": task_id,
380
+ "owner_id": owner,
381
+ "status": status,
382
+ "mode": "edit" if item.get("mode") == "edit" else "generate",
383
+ "model": _clean(item.get("model"), "gpt-image-2"),
384
+ "size": _clean(item.get("size")),
385
+ "quality": _clean(item.get("quality"), "auto"),
386
+ "created_at": _clean(item.get("created_at"), _now_iso()),
387
+ "updated_at": _clean(item.get("updated_at"), _clean(item.get("created_at"), _now_iso())),
388
+ "created_ts": item.get("created_ts"),
389
+ "updated_ts": item.get("updated_ts"),
390
+ "started_ts": item.get("started_ts"),
391
+ "duration_ms": item.get("duration_ms"),
392
+ }
393
+ data = item.get("data")
394
+ if isinstance(data, list):
395
+ task["data"] = data
396
+ usage = item.get("usage")
397
+ if isinstance(usage, dict):
398
+ task["usage"] = usage
399
+ error = _clean(item.get("error"))
400
+ if error:
401
+ task["error"] = error
402
+ tasks[_task_key(owner, task_id)] = task
403
+ return tasks
404
+
405
+ def _save_locked(self) -> None:
406
+ items = sorted(self._tasks.values(), key=lambda item: str(item.get("updated_at") or ""), reverse=True)
407
+ tmp_path = self.path.with_suffix(self.path.suffix + ".tmp")
408
+ tmp_path.write_text(json.dumps({"tasks": items}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
409
+ tmp_path.replace(self.path)
410
+
411
+ def _recover_unfinished_locked(self) -> bool:
412
+ changed = False
413
+ for task in self._tasks.values():
414
+ if task.get("status") in UNFINISHED_STATUSES:
415
+ task["status"] = TASK_STATUS_ERROR
416
+ task["error"] = "服务已重启,未完成的图片任务已中断"
417
+ task["updated_at"] = _now_iso()
418
+ changed = True
419
+ return changed
420
+
421
+ def _cleanup_locked(self) -> bool:
422
+ try:
423
+ retention_days = max(1, int(self.retention_days_getter()))
424
+ except Exception:
425
+ retention_days = 30
426
+ cutoff = time.time() - retention_days * 86400
427
+ removed_keys = [
428
+ key
429
+ for key, task in self._tasks.items()
430
+ if task.get("status") in TERMINAL_STATUSES and _timestamp(task.get("updated_at")) < cutoff
431
+ ]
432
+ for key in removed_keys:
433
+ self._tasks.pop(key, None)
434
+ return bool(removed_keys)
435
+
436
+ def resume_poll(
437
+ self,
438
+ identity: dict[str, object],
439
+ task_id: str,
440
+ extra_timeout_secs: float = 30.0,
441
+ ) -> dict[str, Any]:
442
+ """恢复对已超时任务的轮询,额外等待 extra_timeout_secs 秒。"""
443
+ owner = _owner_id(identity)
444
+ key = _task_key(owner, _clean(task_id))
445
+ with self._lock:
446
+ task = self._tasks.get(key)
447
+ if task is None:
448
+ raise ValueError("task not found")
449
+ if task.get("status") != TASK_STATUS_ERROR:
450
+ raise ValueError("task is not in error state")
451
+ error_msg = _clean(task.get("error"))
452
+ if "超时" not in error_msg:
453
+ raise ValueError("task error is not a timeout error")
454
+ conversation_id = _clean(task.get("conversation_id"))
455
+ if not conversation_id:
456
+ raise ValueError("task has no conversation_id")
457
+ mode = task.get("mode", "generate")
458
+ model = task.get("model", "gpt-image-2")
459
+ # 将任务状态重置为 running
460
+ self._update_task(key, status=TASK_STATUS_RUNNING, error="")
461
+
462
+ # 启动新线程继续轮询
463
+ thread = threading.Thread(
464
+ target=self._run_resume_poll,
465
+ args=(key, conversation_id, extra_timeout_secs, dict(identity), mode, model),
466
+ name=f"image-resume-{_clean(task_id)[:16]}",
467
+ daemon=True,
468
+ )
469
+ thread.start()
470
+ return _public_task(task)
471
+
472
+ def _run_resume_poll(
473
+ self,
474
+ key: str,
475
+ conversation_id: str,
476
+ extra_timeout_secs: float,
477
+ identity: dict[str, object],
478
+ mode: str,
479
+ model: str,
480
+ ) -> None:
481
+ """后台线程:继续轮询已有 conversation_id 的图片结果。"""
482
+ started = time.time()
483
+ try:
484
+ from services.openai_backend_api import OpenAIBackendAPI
485
+ from services.protocol.conversation import format_image_result
486
+
487
+ backend = OpenAIBackendAPI(proxy_url=config.proxy_url or None)
488
+ file_ids, sediment_ids = backend._poll_image_results(
489
+ conversation_id,
490
+ extra_timeout_secs,
491
+ )
492
+ if not file_ids and not sediment_ids:
493
+ raise RuntimeError(
494
+ f"继续等待 {extra_timeout_secs} 秒后仍未找到图片结果。"
495
+ )
496
+
497
+ image_urls = backend.resolve_conversation_image_urls(
498
+ conversation_id, file_ids, sediment_ids, poll=False,
499
+ )
500
+ if not image_urls:
501
+ raise RuntimeError("图片 URL 解析失败")
502
+
503
+ image_items = [
504
+ {"b64_json": __import__("base64").b64encode(image_data).decode("ascii")}
505
+ for image_data in backend.download_image_bytes(image_urls)
506
+ ]
507
+ # 获取 task 的原始 prompt(从 _public_task 的 mode 判断)
508
+ with self._lock:
509
+ task = self._tasks.get(key)
510
+ quality = _clean(task.get("quality"), "auto") if task else "auto"
511
+ size = _clean(task.get("size")) if task else None
512
+ data = format_image_result(
513
+ image_items,
514
+ "", # prompt 已不重要,结果已经拿到了
515
+ "b64_json",
516
+ "",
517
+ int(time.time()),
518
+ )["data"]
519
+ self._update_task(key, status=TASK_STATUS_SUCCESS, data=data, error="", duration_ms=int((time.time() - started) * 1000))
520
+ self._log_call(
521
+ identity,
522
+ mode,
523
+ model,
524
+ started,
525
+ "调用完成(续轮询)",
526
+ status="success",
527
+ urls=_collect_image_urls(data),
528
+ )
529
+ except Exception as exc:
530
+ error_message = str(exc) or "resume poll failed"
531
+ duration_ms = int((time.time() - started) * 1000)
532
+ self._update_task(key, status=TASK_STATUS_ERROR, error=error_message, data=[], duration_ms=duration_ms)
533
+ self._log_call(
534
+ identity,
535
+ mode,
536
+ model,
537
+ started,
538
+ "调用失败(续轮询)",
539
+ status="failed",
540
+ error=error_message,
541
+ )
542
+
543
+
544
+ image_task_service = ImageTaskService(DATA_DIR / "image_tasks.json")
services/log_service.py ADDED
@@ -0,0 +1,337 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ INTERNAL_RESPONSE_KEYS = {"_account_email", "_conversation_id"}
24
+
25
+
26
+ class LogService:
27
+ def __init__(self, path: Path):
28
+ self.path = path
29
+ self.path.parent.mkdir(parents=True, exist_ok=True)
30
+
31
+ @staticmethod
32
+ def _legacy_id(raw_line: str, line_number: int) -> str:
33
+ payload = f"{line_number}:{raw_line}".encode("utf-8", errors="ignore")
34
+ return hashlib.sha1(payload).hexdigest()[:24]
35
+
36
+ def _parse_line(self, raw_line: str, line_number: int) -> dict[str, Any] | None:
37
+ try:
38
+ item = json.loads(raw_line)
39
+ except Exception:
40
+ return None
41
+ if not isinstance(item, dict):
42
+ return None
43
+ parsed = dict(item)
44
+ parsed["id"] = str(parsed.get("id") or self._legacy_id(raw_line, line_number))
45
+ return parsed
46
+
47
+ @staticmethod
48
+ def _serialize_item(item: dict[str, Any]) -> str:
49
+ return json.dumps(item, ensure_ascii=False, separators=(",", ":"))
50
+
51
+ @staticmethod
52
+ def _matches_filters(item: dict[str, Any], *, type: str = "", start_date: str = "", end_date: str = "") -> bool:
53
+ t = str(item.get("time") or "")
54
+ day = t[:10]
55
+ if type and item.get("type") != type:
56
+ return False
57
+ if start_date and day < start_date:
58
+ return False
59
+ if end_date and day > end_date:
60
+ return False
61
+ return True
62
+
63
+ def add(self, type: str, summary: str = "", detail: dict[str, Any] | None = None, **data: Any) -> None:
64
+ item = {
65
+ "id": uuid4().hex,
66
+ "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
67
+ "type": type,
68
+ "summary": summary,
69
+ "detail": detail or data,
70
+ }
71
+ with self.path.open("a", encoding="utf-8") as file:
72
+ file.write(self._serialize_item(item) + "\n")
73
+
74
+ def list(self, type: str = "", start_date: str = "", end_date: str = "", limit: int = 200) -> list[dict[str, Any]]:
75
+ if not self.path.exists():
76
+ return []
77
+ items: list[dict[str, Any]] = []
78
+ lines = self.path.read_text(encoding="utf-8").splitlines()
79
+ for line_number in range(len(lines) - 1, -1, -1):
80
+ item = self._parse_line(lines[line_number], line_number)
81
+ if item is None:
82
+ continue
83
+ if not self._matches_filters(item, type=type, start_date=start_date, end_date=end_date):
84
+ continue
85
+ items.append(item)
86
+ if len(items) >= limit:
87
+ break
88
+ return items
89
+
90
+ def delete(self, ids: list[str]) -> dict[str, int]:
91
+ target_ids = {str(item or "").strip() for item in ids if str(item or "").strip()}
92
+ if not self.path.exists() or not target_ids:
93
+ return {"removed": 0}
94
+ lines = self.path.read_text(encoding="utf-8").splitlines()
95
+ kept_lines: list[str] = []
96
+ removed = 0
97
+ for line_number, raw_line in enumerate(lines):
98
+ item = self._parse_line(raw_line, line_number)
99
+ if item is None:
100
+ kept_lines.append(raw_line)
101
+ continue
102
+ if str(item.get("id") or "") in target_ids:
103
+ removed += 1
104
+ continue
105
+ kept_lines.append(self._serialize_item(item))
106
+ content = "\n".join(kept_lines)
107
+ if content:
108
+ content += "\n"
109
+ self.path.write_text(content, encoding="utf-8")
110
+ return {"removed": removed}
111
+
112
+
113
+ log_service = LogService(DATA_DIR / "logs.jsonl")
114
+
115
+
116
+ def _collect_urls(value: object) -> list[str]:
117
+ urls: list[str] = []
118
+ if isinstance(value, dict):
119
+ for key, item in value.items():
120
+ if key == "url" and isinstance(item, str):
121
+ urls.append(item)
122
+ elif key == "urls" and isinstance(item, list):
123
+ urls.extend(str(url) for url in item if isinstance(url, str))
124
+ else:
125
+ urls.extend(_collect_urls(item))
126
+ elif isinstance(value, list):
127
+ for item in value:
128
+ urls.extend(_collect_urls(item))
129
+ return urls
130
+
131
+
132
+ def _collect_account_emails(value: object) -> list[str]:
133
+ emails: list[str] = []
134
+ if isinstance(value, dict):
135
+ for key, item in value.items():
136
+ if key in {"_account_email", "account_email"} and isinstance(item, str) and item.strip():
137
+ emails.append(item.strip())
138
+ else:
139
+ emails.extend(_collect_account_emails(item))
140
+ elif isinstance(value, list):
141
+ for item in value:
142
+ emails.extend(_collect_account_emails(item))
143
+ return emails
144
+
145
+
146
+ def _collect_conversation_ids(value: object) -> list[str]:
147
+ ids: list[str] = []
148
+ if isinstance(value, dict):
149
+ for key, item in value.items():
150
+ if key == "_conversation_id" and isinstance(item, str) and item.strip():
151
+ ids.append(item.strip())
152
+ else:
153
+ ids.extend(_collect_conversation_ids(item))
154
+ elif isinstance(value, list):
155
+ for item in value:
156
+ ids.extend(_collect_conversation_ids(item))
157
+ return ids
158
+
159
+
160
+ def _strip_internal_response_fields(value: object) -> object:
161
+ if isinstance(value, dict):
162
+ return {
163
+ key: _strip_internal_response_fields(item)
164
+ for key, item in value.items()
165
+ if key not in INTERNAL_RESPONSE_KEYS
166
+ }
167
+ if isinstance(value, list):
168
+ return [_strip_internal_response_fields(item) for item in value]
169
+ return value
170
+
171
+
172
+ def _request_excerpt(text: object, limit: int = 1000) -> str:
173
+ value = str(text or "").strip()
174
+ if not value:
175
+ return ""
176
+ normalized = " ".join(value.split())
177
+ if len(normalized) <= limit:
178
+ return normalized
179
+ return normalized[: limit - 1].rstrip() + "…"
180
+
181
+
182
+ def _image_error_response(exc: Exception) -> JSONResponse:
183
+ from services.protocol.conversation import public_image_error_message
184
+
185
+ message = public_image_error_message(str(exc))
186
+ if "no available image quota" in message.lower():
187
+ return openai_error_response(
188
+ {
189
+ "error": {
190
+ "message": "no available image quota",
191
+ "type": "insufficient_quota",
192
+ "param": None,
193
+ "code": "insufficient_quota",
194
+ }
195
+ },
196
+ 429,
197
+ )
198
+ if hasattr(exc, "to_openai_error") and hasattr(exc, "status_code"):
199
+ return JSONResponse(status_code=int(exc.status_code), content=exc.to_openai_error())
200
+ return openai_error_response(message, 502)
201
+
202
+
203
+ def _protocol_error_response(exc: Exception, status_code: int, sse: str) -> JSONResponse:
204
+ message = str(exc)
205
+ if sse == "anthropic":
206
+ return anthropic_error_response(message, status_code)
207
+ return openai_error_response(message, status_code)
208
+
209
+
210
+ def _next_item(items):
211
+ try:
212
+ return True, next(items)
213
+ except StopIteration:
214
+ return False, None
215
+
216
+
217
+ @dataclass
218
+ class LoggedCall:
219
+ identity: dict[str, object]
220
+ endpoint: str
221
+ model: str
222
+ summary: str
223
+ started: float = field(default_factory=time.time)
224
+ request_text: str = ""
225
+ request_shape: dict[str, int] | None = None
226
+
227
+ async def run(self, handler, *args, sse: str = "openai"):
228
+ from services.protocol.conversation import ImageGenerationError
229
+
230
+ try:
231
+ result = await run_in_threadpool(handler, *args)
232
+ except ImageGenerationError as exc:
233
+ self.log("调用失败", status="failed", error=str(exc), account_email=getattr(exc, "account_email", ""),
234
+ conversation_id=getattr(exc, "conversation_id", ""))
235
+ return _image_error_response(exc)
236
+ except HTTPException as exc:
237
+ self.log("调用失败", status="failed", error=str(exc.detail))
238
+ raise
239
+ except Exception as exc:
240
+ self.log("调用失败", status="failed", error=str(exc), account_email=getattr(exc, "account_email", ""))
241
+ if self.endpoint.startswith("/v1/images"):
242
+ return _image_error_response(exc)
243
+ return _protocol_error_response(exc, 502, sse)
244
+
245
+ if isinstance(result, dict):
246
+ self.log("调用完成", result)
247
+ response = dict(result)
248
+ response.pop("_account_email", None)
249
+ return response
250
+
251
+ sender = anthropic_sse_stream if sse == "anthropic" else sse_json_stream
252
+ try:
253
+ has_first, first = await run_in_threadpool(_next_item, result)
254
+ except ImageGenerationError as exc:
255
+ self.log("调用失败", status="failed", error=str(exc), account_email=getattr(exc, "account_email", ""),
256
+ conversation_id=getattr(exc, "conversation_id", ""))
257
+ return _image_error_response(exc)
258
+ except HTTPException as exc:
259
+ self.log("调用失败", status="failed", error=str(exc.detail))
260
+ raise
261
+ except Exception as exc:
262
+ self.log("调用失败", status="failed", error=str(exc), account_email=getattr(exc, "account_email", ""))
263
+ if self.endpoint.startswith("/v1/images"):
264
+ return _image_error_response(exc)
265
+ return _protocol_error_response(exc, 502, sse)
266
+ if not has_first:
267
+ self.log("流式调用结束")
268
+ return StreamingResponse(sender(()), media_type="text/event-stream")
269
+ return StreamingResponse(sender(self.stream(itertools.chain([first], result))), media_type="text/event-stream")
270
+
271
+ def stream(self, items):
272
+ urls: list[str] = []
273
+ account_emails: list[str] = []
274
+ conversation_ids: list[str] = []
275
+ failed = False
276
+ try:
277
+ for item in items:
278
+ urls.extend(_collect_urls(item))
279
+ account_emails.extend(_collect_account_emails(item))
280
+ conversation_ids.extend(_collect_conversation_ids(item))
281
+ yield _strip_internal_response_fields(item)
282
+ except Exception as exc:
283
+ failed = True
284
+ self.log(
285
+ "流式调用失败",
286
+ status="failed",
287
+ error=str(exc),
288
+ urls=urls,
289
+ account_email=(account_emails[0] if account_emails else getattr(exc, "account_email", "")),
290
+ conversation_id=(conversation_ids[0] if conversation_ids else getattr(exc, "conversation_id", "")),
291
+ )
292
+ if self.endpoint.startswith("/v1/images") and not hasattr(exc, "to_openai_error"):
293
+ from services.protocol.conversation import ImageGenerationError, public_image_error_message
294
+
295
+ raise ImageGenerationError(public_image_error_message(str(exc))) from exc
296
+ raise
297
+ finally:
298
+ if not failed:
299
+ self.log("流式调用结束", urls=urls, account_email=account_emails[0] if account_emails else "",
300
+ conversation_id=conversation_ids[0] if conversation_ids else "")
301
+
302
+ def log(self, suffix: str, result: object = None, status: str = "success", error: str = "",
303
+ urls: list[str] | None = None, account_email: str = "", conversation_id: str = "") -> None:
304
+ detail = {
305
+ "key_id": self.identity.get("id"),
306
+ "key_name": self.identity.get("name"),
307
+ "role": self.identity.get("role"),
308
+ "endpoint": self.endpoint,
309
+ "model": self.model,
310
+ "started_at": datetime.fromtimestamp(self.started).strftime("%Y-%m-%d %H:%M:%S"),
311
+ "ended_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
312
+ "duration_ms": int((time.time() - self.started) * 1000),
313
+ "status": status,
314
+ }
315
+ request_excerpt = _request_excerpt(self.request_text)
316
+ if request_excerpt:
317
+ detail["request_text"] = request_excerpt
318
+ if self.request_shape:
319
+ detail["request_shape"] = self.request_shape
320
+ if error:
321
+ detail["error"] = error
322
+ email = str(account_email or "").strip()
323
+ if not email:
324
+ emails = _collect_account_emails(result)
325
+ email = emails[0] if emails else ""
326
+ if email:
327
+ detail["account_email"] = email
328
+ conv_id = str(conversation_id or "").strip()
329
+ if not conv_id:
330
+ conv_ids = _collect_conversation_ids(result)
331
+ conv_id = conv_ids[0] if conv_ids else ""
332
+ if conv_id:
333
+ detail["conversation_id"] = conv_id
334
+ collected_urls = [*(urls or []), *_collect_urls(result)]
335
+ if collected_urls and not self.endpoint.startswith("/v1/search"):
336
+ detail["urls"] = list(dict.fromkeys(collected_urls))
337
+ log_service.add(LOG_TYPE_CALL, f"{self.summary}{suffix}", detail)
services/oauth_login_service.py ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """手动 OAuth 桥服务
2
+
3
+ 让用户用自己浏览器走一遍 OpenAI 的标准 OAuth + PKCE 授权码流程:
4
+ 1. 后端生成 code_verifier / code_challenge / state,构造 authorize URL。
5
+ 2. 用户在浏览器登录,浏览器最终被 OpenAI 重定向到 platform.openai.com 的
6
+ callback 地址;用户从地址栏或 devtools 抓出 code,回填到前端。
7
+ 3. 后端拿之前存好的 code_verifier + 回填的 code 调用 /api/accounts/oauth/token
8
+ 得到 {access_token, refresh_token, id_token}。
9
+
10
+ 得到的 refresh_token 跟 account_service 自动刷新机制用的 client_id 是同一个
11
+ (app_2SKx67EdpoN0G6j64rFvigXD),所以落盘后能直接进入 keepalive 周期。
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import secrets
16
+ import threading
17
+ import time
18
+ import uuid
19
+ from typing import Any
20
+ from urllib.parse import parse_qs, urlencode, urlparse
21
+
22
+ from curl_cffi import requests
23
+
24
+ from services.proxy_service import proxy_settings
25
+ from services.register.openai_register import (
26
+ auth_base,
27
+ common_headers,
28
+ platform_auth0_client,
29
+ platform_base,
30
+ platform_oauth_audience,
31
+ platform_oauth_client_id,
32
+ platform_oauth_redirect_uri,
33
+ sec_ch_ua,
34
+ user_agent,
35
+ )
36
+
37
+
38
+ class OAuthLoginError(Exception):
39
+ """OAuth 桥流程中的可预期错误,会被 API 层翻译成 400。"""
40
+
41
+
42
+ class OAuthLoginService:
43
+ """维护 PKCE 临时会话,并完成 code → token 的兑换。"""
44
+
45
+ _SESSION_TTL_SECONDS = 10 * 60 # 用户点开浏览器 + 拿 code 给的时间上限
46
+ _MAX_SESSIONS = 64 # 防止异常累积;超过容量时清理最老的
47
+
48
+ def __init__(self) -> None:
49
+ self._lock = threading.Lock()
50
+ self._sessions: dict[str, dict[str, Any]] = {}
51
+
52
+ @staticmethod
53
+ def _generate_pkce() -> tuple[str, str]:
54
+ """生成 PKCE code_verifier 与对应的 code_challenge(S256)。"""
55
+ from utils.pkce import generate_pkce
56
+ return generate_pkce()
57
+
58
+ def _purge_expired_locked(self) -> None:
59
+ """清理过期或溢出容量的会话,必须在持锁状态下调用。"""
60
+ now = time.time()
61
+ expired = [sid for sid, item in self._sessions.items() if now - item["created_at"] > self._SESSION_TTL_SECONDS]
62
+ for sid in expired:
63
+ self._sessions.pop(sid, None)
64
+ if len(self._sessions) > self._MAX_SESSIONS:
65
+ ordered = sorted(self._sessions.items(), key=lambda kv: kv[1]["created_at"])
66
+ for sid, _ in ordered[: len(self._sessions) - self._MAX_SESSIONS]:
67
+ self._sessions.pop(sid, None)
68
+
69
+ def start(self, email_hint: str = "") -> dict[str, str]:
70
+ """登记一个新的 PKCE 会话,返回 session_id 与可让用户打开的 authorize_url。
71
+
72
+ state 形如 "<session_id>.<nonce>",让 callback URL 自带 session_id,
73
+ finish 时即便前端 React 状态被覆盖也能从 URL 恢复正确的 verifier。
74
+ """
75
+ verifier, challenge = self._generate_pkce()
76
+ nonce = secrets.token_urlsafe(32)
77
+ device_id = str(uuid.uuid4())
78
+ session_id = uuid.uuid4().hex
79
+ state = f"{session_id}.{secrets.token_urlsafe(16)}"
80
+
81
+ params = {
82
+ "issuer": auth_base,
83
+ "client_id": platform_oauth_client_id,
84
+ "audience": platform_oauth_audience,
85
+ "redirect_uri": platform_oauth_redirect_uri,
86
+ "device_id": device_id,
87
+ "screen_hint": "login_or_signup",
88
+ "max_age": "0",
89
+ "scope": "openid profile email offline_access",
90
+ "response_type": "code",
91
+ "response_mode": "query",
92
+ "state": state,
93
+ "nonce": nonce,
94
+ "code_challenge": challenge,
95
+ "code_challenge_method": "S256",
96
+ "auth0Client": platform_auth0_client,
97
+ }
98
+ email_hint = str(email_hint or "").strip()
99
+ if email_hint:
100
+ params["login_hint"] = email_hint
101
+
102
+ authorize_url = f"{auth_base}/api/accounts/authorize?{urlencode(params)}"
103
+
104
+ with self._lock:
105
+ self._purge_expired_locked()
106
+ self._sessions[session_id] = {
107
+ "code_verifier": verifier,
108
+ "state": state,
109
+ "created_at": time.time(),
110
+ "redirect_uri": platform_oauth_redirect_uri,
111
+ }
112
+
113
+ return {
114
+ "session_id": session_id,
115
+ "authorize_url": authorize_url,
116
+ "expires_in": str(self._SESSION_TTL_SECONDS),
117
+ "redirect_uri_prefix": platform_oauth_redirect_uri,
118
+ }
119
+
120
+ @staticmethod
121
+ def _extract_code_from_callback(value: str) -> tuple[str, str]:
122
+ """从 callback URL 或 raw code 中提取 (code, state)。
123
+
124
+ 既允许用户粘贴整段 platform.openai.com/auth/callback?code=...&state=... 的 URL,
125
+ 也允许只粘 code 本身。
126
+ """
127
+ raw = str(value or "").strip()
128
+ if not raw:
129
+ return "", ""
130
+ if raw.startswith("http://") or raw.startswith("https://"):
131
+ try:
132
+ parsed = parse_qs(urlparse(raw).query)
133
+ except Exception as exc:
134
+ raise OAuthLoginError(f"无法解析 callback URL: {exc}") from exc
135
+ code = str((parsed.get("code") or [""])[0]).strip()
136
+ state = str((parsed.get("state") or [""])[0]).strip()
137
+ if not code:
138
+ err = str((parsed.get("error_description") or parsed.get("error") or [""])[0]).strip()
139
+ raise OAuthLoginError(err or "callback URL 中没有 code 参数")
140
+ return code, state
141
+ # 用户可能直接粘了 code 字符串
142
+ return raw, ""
143
+
144
+ def finish(self, session_id: str, callback: str) -> dict[str, str]:
145
+ """用 session_id 配对的 code_verifier 把 callback 里的 code 换成 token 三件套。
146
+
147
+ - 优先用 callback URL 自带 state 里的 session_id(更可靠),
148
+ 找不到才用前端传来的 session_id;
149
+ - 失败时不立刻销毁 session(OAuth code 错配换 token 失败通常不会消耗 code),
150
+ 只有成功兑换才 pop,便于用户用同一 verifier 重试。
151
+ """
152
+ body_sid = str(session_id or "").strip()
153
+ code, state = self._extract_code_from_callback(callback)
154
+ if not code:
155
+ raise OAuthLoginError("缺少 code 或 callback URL")
156
+
157
+ # state 里嵌的 session_id 优先级最高
158
+ state_sid = state.split(".", 1)[0] if state else ""
159
+ candidate_sids = [sid for sid in (state_sid, body_sid) if sid]
160
+ if not candidate_sids:
161
+ raise OAuthLoginError("既未提供 session_id,callback URL 中也未携带 state")
162
+
163
+ with self._lock:
164
+ self._purge_expired_locked()
165
+ session = None
166
+ picked_sid = ""
167
+ for sid in candidate_sids:
168
+ cur = self._sessions.get(sid)
169
+ if cur is not None:
170
+ session = cur
171
+ picked_sid = sid
172
+ break
173
+ if session is None:
174
+ raise OAuthLoginError(
175
+ "OAuth 会话已过期或不存在,请回到导入对话框点\"重新生成\"再走一次"
176
+ )
177
+
178
+ if state and session.get("state") and state != session["state"]:
179
+ raise OAuthLoginError(
180
+ "state 不匹配。常见原因:你点过两次\"打开授权页面\",但浏览器里登录的还是前一次的窗口。请点\"重新生成\"重来。"
181
+ )
182
+
183
+ tokens = self._exchange_code(
184
+ code,
185
+ session["code_verifier"],
186
+ session.get("redirect_uri") or platform_oauth_redirect_uri,
187
+ )
188
+ # 仅在成功兑换之后才消耗 session
189
+ with self._lock:
190
+ self._sessions.pop(picked_sid, None)
191
+ return tokens
192
+
193
+ @staticmethod
194
+ def _exchange_code(code: str, code_verifier: str, redirect_uri: str) -> dict[str, str]:
195
+ """调用 /api/accounts/oauth/token 用 code+verifier 换 token 三件套。"""
196
+ kwargs = proxy_settings.build_session_kwargs(impersonate="chrome", verify=False)
197
+ session = requests.Session(**kwargs)
198
+ try:
199
+ response = session.post(
200
+ f"{auth_base}/api/accounts/oauth/token",
201
+ headers={
202
+ **common_headers,
203
+ "referer": f"{platform_base}/",
204
+ "origin": platform_base,
205
+ "auth0-client": platform_auth0_client,
206
+ "sec-ch-ua": sec_ch_ua,
207
+ "user-agent": user_agent,
208
+ },
209
+ json={
210
+ "client_id": platform_oauth_client_id,
211
+ "code_verifier": code_verifier,
212
+ "grant_type": "authorization_code",
213
+ "code": code,
214
+ "redirect_uri": redirect_uri,
215
+ },
216
+ timeout=60,
217
+ )
218
+ except Exception as exc:
219
+ raise OAuthLoginError(f"换 token 网络异常: {exc}") from exc
220
+ finally:
221
+ session.close()
222
+
223
+ try:
224
+ data = response.json() if response.text else {}
225
+ except Exception:
226
+ data = {}
227
+
228
+ if response.status_code != 200 or not isinstance(data, dict) or not data.get("access_token"):
229
+ detail = ""
230
+ if isinstance(data, dict):
231
+ detail = str(data.get("error_description") or data.get("error") or data.get("message") or "")
232
+ if not detail:
233
+ try:
234
+ detail = str(response.text or "")[:300]
235
+ except Exception:
236
+ detail = ""
237
+ # 打到 docker logs 方便排错——OAuth 换 token 的失败原因往往只有这里能看到
238
+ print(
239
+ f"[oauth-login] /api/accounts/oauth/token rejected: "
240
+ f"status={response.status_code} detail={detail!r} "
241
+ f"raw_body={(getattr(response, 'text', '') or '')[:500]!r}",
242
+ flush=True,
243
+ )
244
+ raise OAuthLoginError(
245
+ f"OpenAI 拒绝换 token (HTTP {response.status_code}){': ' + detail if detail else ''}"
246
+ )
247
+
248
+ access_token = str(data.get("access_token") or "").strip()
249
+ refresh_token = str(data.get("refresh_token") or "").strip()
250
+ id_token = str(data.get("id_token") or "").strip()
251
+
252
+ if not access_token:
253
+ raise OAuthLoginError("OpenAI 返回的 access_token 为空")
254
+ if not refresh_token:
255
+ # scope 含 offline_access 时正常会下发 refresh_token;这里给出明确提示
256
+ raise OAuthLoginError(
257
+ "OpenAI 没有返回 refresh_token(可能 scope 未包含 offline_access 或 code 已使用过)"
258
+ )
259
+
260
+ return {
261
+ "access_token": access_token,
262
+ "refresh_token": refresh_token,
263
+ "id_token": id_token,
264
+ }
265
+
266
+
267
+ oauth_login_service = OAuthLoginService()
services/openai_backend_api.py ADDED
The diff for this file is too large to render. See raw diff
 
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/chat_completion_cache.py ADDED
@@ -0,0 +1,236 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import hashlib
5
+ import json
6
+ import threading
7
+ import time
8
+ from dataclasses import dataclass, field
9
+ from typing import Any, Callable, Iterable, Iterator
10
+
11
+ from services.config import config
12
+
13
+ CACHEABLE_TEXT_KEYS = {
14
+ "frequency_penalty",
15
+ "max_completion_tokens",
16
+ "max_tokens",
17
+ "metadata",
18
+ "model",
19
+ "presence_penalty",
20
+ "reasoning_effort",
21
+ "response_format",
22
+ "seed",
23
+ "stop",
24
+ "temperature",
25
+ "tool_choice",
26
+ "tools",
27
+ "top_p",
28
+ "user",
29
+ }
30
+
31
+
32
+ @dataclass
33
+ class CacheEntry:
34
+ expires_at: float
35
+ value: Any
36
+
37
+
38
+ @dataclass
39
+ class InflightCall:
40
+ condition: threading.Condition = field(default_factory=lambda: threading.Condition(threading.RLock()))
41
+ done: bool = False
42
+ value: Any = None
43
+ error: BaseException | None = None
44
+
45
+
46
+ def _json_safe(value: Any) -> Any:
47
+ if isinstance(value, bytes):
48
+ return {"__bytes_sha256__": hashlib.sha256(value).hexdigest(), "length": len(value)}
49
+ if isinstance(value, bytearray):
50
+ data = bytes(value)
51
+ return {"__bytes_sha256__": hashlib.sha256(data).hexdigest(), "length": len(data)}
52
+ if isinstance(value, dict):
53
+ return {str(key): _json_safe(item) for key, item in value.items()}
54
+ if isinstance(value, (list, tuple)):
55
+ return [_json_safe(item) for item in value]
56
+ return value
57
+
58
+
59
+ def canonical_body(body: dict[str, Any], messages: list[dict[str, Any]], *, stream: bool) -> dict[str, Any]:
60
+ payload = {key: body.get(key) for key in CACHEABLE_TEXT_KEYS if key in body}
61
+ payload["messages"] = messages
62
+ payload["stream"] = bool(stream)
63
+ return payload
64
+
65
+
66
+ def cache_key(body: dict[str, Any], messages: list[dict[str, Any]], *, stream: bool) -> str:
67
+ encoded = json.dumps(
68
+ _json_safe(canonical_body(body, messages, stream=stream)),
69
+ ensure_ascii=False,
70
+ sort_keys=True,
71
+ separators=(",", ":"),
72
+ ).encode("utf-8")
73
+ return hashlib.sha256(encoded).hexdigest()
74
+
75
+
76
+ def _message_signature(message: dict[str, Any]) -> str:
77
+ return json.dumps(_json_safe(message), ensure_ascii=False, sort_keys=True, separators=(",", ":"))
78
+
79
+
80
+ def normalize_text_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
81
+ settings = config.get_chat_completion_cache_settings()
82
+ if not settings.get("normalize_messages"):
83
+ return messages
84
+
85
+ normalized: list[dict[str, Any]] = []
86
+ previous_signature = ""
87
+ for message in messages:
88
+ if settings.get("drop_assistant_history") and str(message.get("role") or "") == "assistant":
89
+ continue
90
+ signature = _message_signature(message)
91
+ if settings.get("drop_adjacent_duplicates") and signature == previous_signature:
92
+ continue
93
+ normalized.append(message)
94
+ previous_signature = signature
95
+ return normalized
96
+
97
+
98
+ class ChatCompletionCache:
99
+ def __init__(self) -> None:
100
+ self._lock = threading.RLock()
101
+ self._entries: dict[str, CacheEntry] = {}
102
+ self._inflight: dict[str, InflightCall] = {}
103
+
104
+ def clear(self) -> None:
105
+ with self._lock:
106
+ self._entries.clear()
107
+ self._inflight.clear()
108
+
109
+ def _settings(self) -> dict[str, object]:
110
+ return config.get_chat_completion_cache_settings()
111
+
112
+ def _prune_locked(self, now: float, max_entries: int) -> None:
113
+ expired = [key for key, item in self._entries.items() if item.expires_at <= now]
114
+ for key in expired:
115
+ self._entries.pop(key, None)
116
+ while len(self._entries) > max_entries:
117
+ oldest_key = min(self._entries, key=lambda key: self._entries[key].expires_at)
118
+ self._entries.pop(oldest_key, None)
119
+
120
+ @staticmethod
121
+ def _copy(value: Any) -> Any:
122
+ return copy.deepcopy(value)
123
+
124
+ def get_or_compute_response(self, key: str, compute: Callable[[], dict[str, Any]]) -> dict[str, Any]:
125
+ settings = self._settings()
126
+ if not settings.get("enabled") or int(settings.get("ttl_seconds") or 0) <= 0:
127
+ return compute()
128
+
129
+ now = time.time()
130
+ max_entries = int(settings.get("max_entries") or 1)
131
+ with self._lock:
132
+ self._prune_locked(now, max_entries)
133
+ entry = self._entries.get(key)
134
+ if entry and entry.expires_at > now:
135
+ return self._copy(entry.value)
136
+ inflight = self._inflight.get(key) if settings.get("dedupe_inflight") else None
137
+ if inflight is None:
138
+ inflight = InflightCall()
139
+ if settings.get("dedupe_inflight"):
140
+ self._inflight[key] = inflight
141
+ owner = True
142
+ else:
143
+ owner = False
144
+
145
+ if not owner:
146
+ with inflight.condition:
147
+ while not inflight.done:
148
+ inflight.condition.wait()
149
+ if inflight.error:
150
+ raise inflight.error
151
+ return self._copy(inflight.value)
152
+
153
+ try:
154
+ value = compute()
155
+ except BaseException as exc:
156
+ with self._lock:
157
+ self._inflight.pop(key, None)
158
+ with inflight.condition:
159
+ inflight.error = exc
160
+ inflight.done = True
161
+ inflight.condition.notify_all()
162
+ raise
163
+
164
+ expires_at = time.time() + int(settings.get("ttl_seconds") or 0)
165
+ with self._lock:
166
+ self._entries[key] = CacheEntry(expires_at=expires_at, value=self._copy(value))
167
+ self._prune_locked(time.time(), max_entries)
168
+ self._inflight.pop(key, None)
169
+ with inflight.condition:
170
+ inflight.value = self._copy(value)
171
+ inflight.done = True
172
+ inflight.condition.notify_all()
173
+ return value
174
+
175
+ def get_or_compute_stream(self, key: str, compute: Callable[[], Iterable[dict[str, Any]]]) -> Iterator[dict[str, Any]]:
176
+ settings = self._settings()
177
+ if (
178
+ not settings.get("enabled")
179
+ or not settings.get("stream_cache")
180
+ or int(settings.get("ttl_seconds") or 0) <= 0
181
+ ):
182
+ yield from compute()
183
+ return
184
+
185
+ now = time.time()
186
+ max_entries = int(settings.get("max_entries") or 1)
187
+ with self._lock:
188
+ self._prune_locked(now, max_entries)
189
+ entry = self._entries.get(key)
190
+ if entry and entry.expires_at > now:
191
+ yield from self._copy(entry.value)
192
+ return
193
+ inflight = self._inflight.get(key) if settings.get("dedupe_inflight") else None
194
+ if inflight is None:
195
+ inflight = InflightCall()
196
+ if settings.get("dedupe_inflight"):
197
+ self._inflight[key] = inflight
198
+ owner = True
199
+ else:
200
+ owner = False
201
+
202
+ if not owner:
203
+ with inflight.condition:
204
+ while not inflight.done:
205
+ inflight.condition.wait()
206
+ if inflight.error:
207
+ raise inflight.error
208
+ yield from self._copy(inflight.value)
209
+ return
210
+
211
+ chunks: list[dict[str, Any]] = []
212
+ try:
213
+ for chunk in compute():
214
+ chunks.append(self._copy(chunk))
215
+ yield chunk
216
+ except BaseException as exc:
217
+ with self._lock:
218
+ self._inflight.pop(key, None)
219
+ with inflight.condition:
220
+ inflight.error = exc
221
+ inflight.done = True
222
+ inflight.condition.notify_all()
223
+ raise
224
+
225
+ expires_at = time.time() + int(settings.get("ttl_seconds") or 0)
226
+ with self._lock:
227
+ self._entries[key] = CacheEntry(expires_at=expires_at, value=self._copy(chunks))
228
+ self._prune_locked(time.time(), max_entries)
229
+ self._inflight.pop(key, None)
230
+ with inflight.condition:
231
+ inflight.value = self._copy(chunks)
232
+ inflight.done = True
233
+ inflight.condition.notify_all()
234
+
235
+
236
+ chat_completion_cache = ChatCompletionCache()
services/protocol/conversation.py ADDED
@@ -0,0 +1,1556 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import re
6
+ import time
7
+ from concurrent.futures import ThreadPoolExecutor, as_completed
8
+ from dataclasses import dataclass, field
9
+ from typing import Any, Iterable, Iterator
10
+
11
+ import tiktoken
12
+
13
+ from services.account_service import account_service
14
+ from services.config import config
15
+ from services.image_storage_service import image_storage_service
16
+ from services.openai_backend_api import ImageContentPolicyError, ImagePollTimeoutError, OpenAIBackendAPI
17
+ from utils.helper import (
18
+ IMAGE_MODELS,
19
+ extract_image_from_message_content,
20
+ is_codex_image_model,
21
+ is_supported_image_model,
22
+ split_image_model,
23
+ )
24
+ from utils.image_tokens import count_image_content_tokens
25
+ from utils.log import logger
26
+
27
+
28
+ class ImageGenerationError(Exception):
29
+ def __init__(
30
+ self,
31
+ message: str,
32
+ status_code: int = 502,
33
+ error_type: str = "server_error",
34
+ code: str | None = "upstream_error",
35
+ param: str | None = None,
36
+ account_email: str = "",
37
+ conversation_id: str = "",
38
+ ) -> None:
39
+ super().__init__(message)
40
+ self.status_code = status_code
41
+ self.error_type = error_type
42
+ self.code = code
43
+ self.param = param
44
+ self.account_email = account_email
45
+ self.conversation_id = conversation_id
46
+
47
+ def to_openai_error(self) -> dict[str, Any]:
48
+ error_dict = {
49
+ "error": {
50
+ "message": public_image_error_message(str(self)),
51
+ "type": self.error_type,
52
+ "param": self.param,
53
+ "code": self.code,
54
+ }
55
+ }
56
+ if self.account_email:
57
+ error_dict["error"]["account_email"] = self.account_email
58
+ return error_dict
59
+
60
+
61
+ def public_image_error_message(message: str) -> str:
62
+ text = str(message or "").strip()
63
+ lower = text.lower()
64
+ if any(item in lower for item in ("backend-api/", "status=", "body=", "chatgpt.com", "upstreamhttperror")):
65
+ return "The image generation request failed. Please try again later."
66
+ return text or "The image generation request failed. Please try again later."
67
+
68
+
69
+ def is_token_invalid_error(message: str) -> bool:
70
+ text = str(message or "").lower()
71
+ return (
72
+ "token_invalidated" in text
73
+ or "token_revoked" in text
74
+ or "authentication token has been invalidated" in text
75
+ or "invalidated oauth token" in text
76
+ )
77
+
78
+
79
+ def is_tls_connection_error(message: str) -> bool:
80
+ """检测 TLS/SSL 连接错误,这类错误通常可以通过重试解决。"""
81
+ text = str(message or "").lower()
82
+ return (
83
+ "curl: (35)" in text
84
+ or "tls connect error" in text
85
+ or "openssl_internal" in text
86
+ or "ssl: wrong_version_number" in text
87
+ or "ssl: certificate_verify_failed" in text
88
+ or "connection aborted" in text
89
+ or "remote disconnected" in text
90
+ or "connection reset by peer" in text
91
+ )
92
+
93
+
94
+ def is_connection_timeout_error(message: str) -> bool:
95
+ """检测连接超时错误(如 curl 28),这类错误可通过同账号短等待重试解决。"""
96
+ text = str(message or "").lower()
97
+ return (
98
+ "curl: (28)" in text
99
+ or "operation timed out" in text
100
+ or "connection timed out" in text
101
+ or "read timed out" in text
102
+ or "connect timeout" in text
103
+ )
104
+
105
+
106
+ def image_stream_error_message(message: str) -> str:
107
+ text = str(message or "")
108
+ if is_token_invalid_error(text):
109
+ return "image generation failed"
110
+ if is_tls_connection_error(text):
111
+ return "upstream image connection failed, please retry later"
112
+ if is_connection_timeout_error(text):
113
+ return "upstream connection timed out, please retry later"
114
+ return text or "image generation failed"
115
+
116
+
117
+ REFERENCED_IMAGE_IDS_RE = re.compile(r'"referenced_image_ids"\s*:\s*\[([^\]]+)\]')
118
+ # 检测模型返回的部分工具调用 JSON(如 {"size":"1920x1088","n":1})
119
+ # 这些 JSON 包含图片生成工具的参数,但没有实际生成图片
120
+ TOOL_PARAMS_JSON_RE = re.compile(
121
+ r'\{\s*"size"\s*:\s*"\d+x\d+"\s*,\s*"n"\s*:\s*\d+\s*\}'
122
+ )
123
+
124
+
125
+ def is_model_text_reply_instead_of_image(message: str) -> bool:
126
+ """检测模型是否返回了文本回复(包含工具调用 JSON)而非实际生成图片。
127
+
128
+ 当上游 ChatGPT 未能触发图片生成工具时,会返回一段描述性文本,
129
+ 其中可能包含 JSON 参数(如 prompt、referenced_image_ids、size/n 等)。
130
+ 这种情况应被视为「上游未生成图片」而非「内容策略违规」。
131
+
132
+ 检测两种模式:
133
+ 1. 完整的工具调用 JSON(含 referenced_image_ids)
134
+ 2. 部分的工具参数 JSON(如 {"size":"1920x1088","n":1})
135
+ """
136
+ if not message:
137
+ return False
138
+ if REFERENCED_IMAGE_IDS_RE.search(message):
139
+ return True
140
+ # 检测部分工具参数 JSON(模型返回了工具参数但未触发工具)
141
+ if TOOL_PARAMS_JSON_RE.search(message):
142
+ return True
143
+ return False
144
+
145
+
146
+ def encode_images(images: Iterable[tuple[bytes, str, str]]) -> list[str]:
147
+ return [base64.b64encode(data).decode("ascii") for data, _, _ in images if data]
148
+
149
+
150
+ def save_image_bytes(image_data: bytes, base_url: str | None = None) -> str:
151
+ return image_storage_service.save(image_data, base_url).url
152
+
153
+
154
+ def message_text(content: Any) -> str:
155
+ if isinstance(content, str):
156
+ return content
157
+ if isinstance(content, list):
158
+ parts = []
159
+ for item in content:
160
+ if isinstance(item, str):
161
+ parts.append(item)
162
+ elif isinstance(item, dict) and str(item.get("type") or "") in {"text", "input_text", "output_text"}:
163
+ parts.append(str(item.get("text") or ""))
164
+ return "".join(parts)
165
+ return ""
166
+
167
+
168
+ def normalize_messages(messages: object, system: Any = None) -> list[dict[str, Any]]:
169
+ normalized = []
170
+ if config.global_system_prompt:
171
+ normalized.append({"role": "system", "content": config.global_system_prompt})
172
+ system_text = message_text(system)
173
+ if system_text:
174
+ normalized.append({"role": "system", "content": system_text})
175
+ if isinstance(messages, list):
176
+ for message in messages:
177
+ if not isinstance(message, dict):
178
+ continue
179
+ role = message.get("role", "user")
180
+ content = message.get("content", "")
181
+ text = message_text(content)
182
+ images: list[tuple[bytes, str]] = []
183
+ if role == "user":
184
+ images.extend(extract_image_from_message_content(content))
185
+ if isinstance(content, list):
186
+ for part in content:
187
+ if not isinstance(part, dict) or part.get("type") != "image":
188
+ continue
189
+ data = part.get("data")
190
+ if isinstance(data, (bytes, bytearray)) and all(existing[0] != bytes(data) for existing in images):
191
+ images.append((bytes(data), str(part.get("mime") or "image/png")))
192
+ if images:
193
+ parts: list[Any] = []
194
+ if text:
195
+ parts.append({"type": "text", "text": text})
196
+ for data, mime in images:
197
+ parts.append({"type": "image", "data": data, "mime": mime})
198
+ normalized.append({"role": role, "content": parts})
199
+ else:
200
+ normalized.append({"role": role, "content": text})
201
+ return normalized
202
+
203
+
204
+ def prompt_with_global_system(prompt: str) -> str:
205
+ return f"{config.global_system_prompt}\n\n{prompt}" if config.global_system_prompt else prompt
206
+
207
+
208
+ def assistant_history_text(messages: list[dict[str, Any]]) -> str:
209
+ return "".join(str(item.get("content") or "") for item in messages if item.get("role") == "assistant")
210
+
211
+
212
+ def assistant_history_messages(messages: list[dict[str, Any]]) -> list[str]:
213
+ return [str(item.get("content") or "") for item in messages if item.get("role") == "assistant" and item.get("content")]
214
+
215
+
216
+ def build_image_prompt(prompt: str, size: str | None, quality: str = "auto") -> str:
217
+ hints = []
218
+ if size:
219
+ hints.append(f"输出图片尺寸为 {size}。")
220
+ if quality:
221
+ hints.append(f"输出图片质量为 {quality}。")
222
+ return f"{prompt.strip()}\n\n{''.join(hints)}" if hints else prompt
223
+
224
+
225
+ def encoding_for_model(model: str):
226
+ try:
227
+ return tiktoken.encoding_for_model(model)
228
+ except KeyError:
229
+ try:
230
+ return tiktoken.get_encoding("o200k_base")
231
+ except KeyError:
232
+ return tiktoken.get_encoding("cl100k_base")
233
+
234
+
235
+ def count_message_image_tokens(messages: list[dict[str, Any]], model: str) -> int:
236
+ return sum(count_image_content_tokens(message.get("content"), model) for message in messages)
237
+
238
+
239
+ def count_message_text_tokens(messages: list[dict[str, Any]], model: str) -> int:
240
+ encoding = encoding_for_model(model)
241
+ total = 0
242
+ for message in messages:
243
+ total += 3
244
+ for key, value in message.items():
245
+ if key == "content" and isinstance(value, list):
246
+ total += len(encoding.encode(message_text(value)))
247
+ elif isinstance(value, str):
248
+ total += len(encoding.encode(value))
249
+ else:
250
+ continue
251
+ if key == "name":
252
+ total += 1
253
+ return total + 3
254
+
255
+
256
+ def count_message_tokens(messages: list[dict[str, Any]], model: str) -> int:
257
+ return count_message_text_tokens(messages, model) + count_message_image_tokens(messages, model)
258
+
259
+
260
+ def count_text_tokens(text: str, model: str) -> int:
261
+ return len(encoding_for_model(model).encode(text))
262
+
263
+
264
+ def format_image_result(
265
+ items: list[dict[str, Any]],
266
+ prompt: str,
267
+ response_format: str,
268
+ base_url: str | None = None,
269
+ created: int | None = None,
270
+ message: str = "",
271
+ ) -> dict[str, Any]:
272
+ data: list[dict[str, Any]] = []
273
+ for item in items:
274
+ b64_json = str(item.get("b64_json") or "").strip()
275
+ if not b64_json:
276
+ continue
277
+ revised_prompt = str(item.get("revised_prompt") or prompt).strip() or prompt
278
+ if response_format == "b64_json":
279
+ data.append({
280
+ "b64_json": b64_json,
281
+ "url": save_image_bytes(base64.b64decode(b64_json), base_url),
282
+ "revised_prompt": revised_prompt,
283
+ })
284
+ else:
285
+ data.append({
286
+ "url": save_image_bytes(base64.b64decode(b64_json), base_url),
287
+ "revised_prompt": revised_prompt,
288
+ })
289
+ result: dict[str, Any] = {"created": created or int(time.time()), "data": data}
290
+ if message and not data:
291
+ result["message"] = message
292
+ return result
293
+
294
+
295
+ @dataclass
296
+ class ConversationRequest:
297
+ model: str = "auto"
298
+ prompt: str = ""
299
+ messages: list[dict[str, Any]] | None = None
300
+ images: list[str] | None = None
301
+ n: int = 1
302
+ size: str | None = None
303
+ quality: str = "auto"
304
+ response_format: str = "b64_json"
305
+ base_url: str | None = None
306
+ message_as_error: bool = False
307
+ progress_callback: Any = None # Callable[[str], None] | None
308
+
309
+
310
+ @dataclass
311
+ class ConversationState:
312
+ text: str = ""
313
+ raw_text: str = ""
314
+ conversation_id: str = ""
315
+ file_ids: list[str] = field(default_factory=list)
316
+ sediment_ids: list[str] = field(default_factory=list)
317
+ blocked: bool = False
318
+ tool_invoked: bool | None = None
319
+ turn_use_case: str = ""
320
+
321
+
322
+ @dataclass
323
+ class ImageOutput:
324
+ kind: str
325
+ model: str
326
+ index: int
327
+ total: int
328
+ created: int = field(default_factory=lambda: int(time.time()))
329
+ text: str = ""
330
+ upstream_event_type: str = ""
331
+ data: list[dict[str, Any]] = field(default_factory=list)
332
+ account_email: str = ""
333
+ conversation_id: str = ""
334
+
335
+ def to_chunk(self) -> dict[str, Any]:
336
+ chunk: dict[str, Any] = {
337
+ "object": "image.generation.chunk",
338
+ "created": self.created,
339
+ "model": self.model,
340
+ "index": self.index,
341
+ "total": self.total,
342
+ "progress_text": self.text,
343
+ "upstream_event_type": self.upstream_event_type,
344
+ "data": [],
345
+ }
346
+ if self.account_email:
347
+ chunk["_account_email"] = self.account_email
348
+ if self.conversation_id:
349
+ chunk["_conversation_id"] = self.conversation_id
350
+ if self.kind == "message":
351
+ chunk.update({
352
+ "object": "image.generation.message",
353
+ "message": self.text,
354
+ })
355
+ chunk.pop("progress_text", None)
356
+ chunk.pop("upstream_event_type", None)
357
+ elif self.kind == "result":
358
+ chunk.update({
359
+ "object": "image.generation.result",
360
+ "data": self.data,
361
+ })
362
+ chunk.pop("progress_text", None)
363
+ chunk.pop("upstream_event_type", None)
364
+ return chunk
365
+
366
+
367
+ def assistant_message_text(message: dict[str, Any]) -> str:
368
+ content = message.get("content") or {}
369
+ parts = content.get("parts") or []
370
+ if isinstance(parts, list) and parts:
371
+ text = "".join(part for part in parts if isinstance(part, str))
372
+ if text:
373
+ return text
374
+ # Fallback: content_type "code" stores text in the "text" field instead of "parts"
375
+ text_field = str(content.get("text") or "")
376
+ if text_field:
377
+ return text_field
378
+ return ""
379
+
380
+
381
+ def strip_history(text: str, history_text: str = "") -> str:
382
+ text = str(text or "")
383
+ history_text = str(history_text or "")
384
+ while history_text and text.startswith(history_text):
385
+ text = text[len(history_text):]
386
+ return text
387
+
388
+
389
+ def sanitize_output_text(text: str) -> str:
390
+ text = str(text or "")
391
+
392
+ def is_internal_annotation_part(part: str) -> bool:
393
+ value = part.strip()
394
+ if not value:
395
+ return True
396
+ lower = value.lower()
397
+ return bool(
398
+ re.fullmatch(r"turn\d+[a-z]*\d*", lower)
399
+ or re.fullmatch(r"turn\d+\w*", lower)
400
+ or lower.startswith(("turn", "source", "sources"))
401
+ )
402
+
403
+ def readable_annotation_part(parts: list[str]) -> str:
404
+ for part in parts:
405
+ value = part.strip()
406
+ if value and not is_internal_annotation_part(value):
407
+ return value
408
+ return ""
409
+
410
+ def replace_annotation(match: re.Match[str]) -> str:
411
+ payload = match.group(1)
412
+ parts = [part.strip() for part in payload.split("\ue202")]
413
+ kind = (parts[0] if parts else "").lower()
414
+ data = parts[1:]
415
+ if kind == "url":
416
+ label = data[0] if data else ""
417
+ url = data[1] if len(data) > 1 else ""
418
+ if label and url.startswith(("http://", "https://")):
419
+ return f"{label} ({url})"
420
+ return label or url
421
+ if kind == "cite":
422
+ return readable_annotation_part(data)
423
+ return readable_annotation_part(data)
424
+
425
+ # ChatGPT web sometimes returns rich annotation markers using private-use
426
+ # characters. API clients cannot render those. Preserve readable labels
427
+ # from entity/link annotations, while removing internal citation pointers.
428
+ text = re.sub(r"\ue200([^\ue201]*)\ue201", replace_annotation, text)
429
+ text = re.sub(r"\ue200[^\ue201]*$", "", text)
430
+ text = re.sub(r"\s+([.,;:!?])", r"\1", text)
431
+ return text
432
+
433
+
434
+ def assistant_raw_text(event: dict[str, Any], current_text: str = "", history_text: str = "") -> str:
435
+ for candidate in (event, event.get("v")):
436
+ if not isinstance(candidate, dict):
437
+ continue
438
+ message = candidate.get("message")
439
+ if not isinstance(message, dict):
440
+ continue
441
+ role = str((message.get("author") or {}).get("role") or "").strip().lower()
442
+ if role != "assistant":
443
+ continue
444
+ text = assistant_message_text(message)
445
+ if text:
446
+ return strip_history(text, history_text)
447
+ return apply_text_patch(event, current_text, history_text)
448
+
449
+
450
+ def assistant_text(event: dict[str, Any], current_text: str = "", history_text: str = "") -> str:
451
+ return sanitize_output_text(assistant_raw_text(event, current_text, history_text))
452
+
453
+
454
+ def event_assistant_text(event: dict[str, Any], history_text: str = "") -> str:
455
+ for candidate in (event, event.get("v")):
456
+ if not isinstance(candidate, dict):
457
+ continue
458
+ message = candidate.get("message")
459
+ if isinstance(message, dict) and (message.get("author") or {}).get("role") == "assistant":
460
+ return strip_history(assistant_message_text(message), history_text)
461
+ return ""
462
+
463
+
464
+ def apply_text_patch(event: dict[str, Any], current_text: str = "", history_text: str = "") -> str:
465
+ if event.get("p") == "/message/content/parts/0":
466
+ return apply_patch_op(event, current_text, history_text)
467
+
468
+ operations = event.get("v")
469
+ if isinstance(operations, str) and current_text and not event.get("p") and not event.get("o"):
470
+ return current_text + operations
471
+
472
+ if event.get("o") == "patch" and isinstance(operations, list):
473
+ text = current_text
474
+ for item in operations:
475
+ if isinstance(item, dict):
476
+ text = apply_text_patch(item, text, history_text)
477
+ return text
478
+
479
+ if not isinstance(operations, list):
480
+ return current_text
481
+
482
+ text = current_text
483
+ for item in operations:
484
+ if isinstance(item, dict):
485
+ text = apply_text_patch(item, text, history_text)
486
+ return text
487
+
488
+
489
+ def apply_patch_op(operation: dict[str, Any], current_text: str, history_text: str = "") -> str:
490
+ op = operation.get("o")
491
+ value = str(operation.get("v") or "")
492
+ if op == "append":
493
+ return current_text + value
494
+ if op == "replace":
495
+ return strip_history(value, history_text)
496
+ return current_text
497
+
498
+
499
+ def add_unique(values: list[str], candidates: list[str]) -> None:
500
+ for candidate in candidates:
501
+ if candidate and candidate not in values:
502
+ values.append(candidate)
503
+
504
+
505
+ FILE_SERVICE_ID_RE = re.compile(r"file-service://([A-Za-z0-9_-]+)")
506
+ FILE_ID_RE = re.compile(r"\b(file[-_](?!service\b)[A-Za-z0-9_-]+)\b")
507
+ # 真正的图片文件 ID 格式:file_00000000 + 24位十六进制字符(共32字符)
508
+ # 用于过滤非图片文件 ID(如 file_upload_business_upsell)
509
+ REAL_IMAGE_FILE_ID_RE = re.compile(r"\bfile_00000000[a-f0-9]{24}\b")
510
+ SEDIMENT_ID_RE = re.compile(r"sediment://([A-Za-z0-9_-]+)")
511
+
512
+
513
+ def extract_conversation_ids(payload: str) -> tuple[str, list[str], list[str]]:
514
+ conversation_match = re.search(r'"conversation_id"\s*:\s*"([^"]+)"', payload)
515
+ conversation_id = conversation_match.group(1) if conversation_match else ""
516
+ file_ids: list[str] = []
517
+ # Negative lookahead excludes "file-service" (URI prefix, not a real id).
518
+ add_unique(file_ids, FILE_SERVICE_ID_RE.findall(payload))
519
+ # 只提取真正的图片文件 ID(file_00000000... 格式),过滤非图片文件 ID(如 file_upload_business_upsell)
520
+ add_unique(file_ids, REAL_IMAGE_FILE_ID_RE.findall(payload))
521
+ sediment_ids = SEDIMENT_ID_RE.findall(payload)
522
+ return conversation_id, file_ids, sediment_ids
523
+
524
+
525
+ def is_image_tool_event(event: dict[str, Any]) -> bool:
526
+ value = event.get("v")
527
+ message = event.get("message") or (value.get("message") if isinstance(value, dict) else None)
528
+ if not isinstance(message, dict):
529
+ return False
530
+ metadata = message.get("metadata") or {}
531
+ author = message.get("author") or {}
532
+ content = message.get("content") or {}
533
+ if author.get("role") != "tool":
534
+ return False
535
+ if metadata.get("async_task_type") == "image_gen":
536
+ return True
537
+ if content.get("content_type") != "multimodal_text":
538
+ return False
539
+ return any(
540
+ isinstance(part, dict) and (
541
+ part.get("content_type") == "image_asset_pointer"
542
+ or str(part.get("asset_pointer") or "").startswith(("file-service://", "sediment://"))
543
+ )
544
+ for part in content.get("parts") or []
545
+ )
546
+
547
+
548
+ def _is_user_message_event(event: dict[str, Any]) -> bool:
549
+ """检查事件是否来自 user 角色消息。"""
550
+ value = event.get("v")
551
+ message = event.get("message") or (value.get("message") if isinstance(value, dict) else None)
552
+ if isinstance(message, dict):
553
+ author = message.get("author") or {}
554
+ if str(author.get("role") or "").strip().lower() == "user":
555
+ return True
556
+ return False
557
+
558
+
559
+ def update_conversation_state(state: ConversationState, payload: str, event: dict[str, Any] | None = None) -> None:
560
+ conversation_id, file_ids, sediment_ids = extract_conversation_ids(payload)
561
+ if conversation_id and not state.conversation_id:
562
+ state.conversation_id = conversation_id
563
+ # Accept file_id / sediment_id when any of:
564
+ # 1) event is a complete image_gen tool message
565
+ # 2) prior server_ste_metadata already flipped tool_invoked True (in an image_gen turn),
566
+ # BUT only for non-user messages — user messages contain the uploaded input image
567
+ # which must NOT be treated as a generated output.
568
+ # 3) patch event whose payload references asset_pointer / file-service://,
569
+ # BUT only when the event is not a user message.
570
+ is_patch_event = isinstance(event, dict) and event.get("o") == "patch"
571
+ is_user_msg = isinstance(event, dict) and _is_user_message_event(event)
572
+ image_context = (
573
+ (isinstance(event, dict) and is_image_tool_event(event))
574
+ or (state.tool_invoked is True and not is_user_msg)
575
+ or (is_patch_event and not is_user_msg and ("asset_pointer" in payload or "file-service://" in payload))
576
+ )
577
+ if image_context:
578
+ add_unique(state.file_ids, file_ids)
579
+ add_unique(state.sediment_ids, sediment_ids)
580
+ if not isinstance(event, dict):
581
+ return
582
+ state.conversation_id = str(event.get("conversation_id") or state.conversation_id)
583
+ value = event.get("v")
584
+ if isinstance(value, dict):
585
+ state.conversation_id = str(value.get("conversation_id") or state.conversation_id)
586
+ if event.get("type") == "moderation":
587
+ moderation = event.get("moderation_response")
588
+ if isinstance(moderation, dict) and moderation.get("blocked") is True:
589
+ state.blocked = True
590
+ if event.get("type") == "server_ste_metadata":
591
+ metadata = event.get("metadata")
592
+ if isinstance(metadata, dict):
593
+ if isinstance(metadata.get("tool_invoked"), bool):
594
+ state.tool_invoked = metadata["tool_invoked"]
595
+ state.turn_use_case = str(metadata.get("turn_use_case") or state.turn_use_case)
596
+
597
+
598
+ def conversation_base_event(event_type: str, state: ConversationState, **extra: Any) -> dict[str, Any]:
599
+ return {
600
+ "type": event_type,
601
+ "text": state.text,
602
+ "conversation_id": state.conversation_id,
603
+ "file_ids": list(state.file_ids),
604
+ "sediment_ids": list(state.sediment_ids),
605
+ "blocked": state.blocked,
606
+ "tool_invoked": state.tool_invoked,
607
+ "turn_use_case": state.turn_use_case,
608
+ **extra,
609
+ }
610
+
611
+
612
+ def iter_conversation_payloads(payloads: Iterator[str], history_text: str = "",
613
+ history_messages: list[str] | None = None) -> Iterator[dict[str, Any]]:
614
+ state = ConversationState()
615
+ history_messages = history_messages or []
616
+ history_index = 0
617
+ for payload in payloads:
618
+ # print(f"[upstream_sse] {payload}", flush=True)
619
+ if not payload:
620
+ continue
621
+ if payload == "[DONE]":
622
+ yield conversation_base_event("conversation.done", state, done=True)
623
+ break
624
+ try:
625
+ event = json.loads(payload)
626
+ except json.JSONDecodeError:
627
+ update_conversation_state(state, payload)
628
+ yield conversation_base_event("conversation.raw", state, payload=payload)
629
+ continue
630
+ if not isinstance(event, dict):
631
+ yield conversation_base_event("conversation.event", state, raw=event)
632
+ continue
633
+ update_conversation_state(state, payload, event)
634
+ if history_index < len(history_messages) and event_assistant_text(event, history_text) == history_messages[history_index]:
635
+ history_index += 1
636
+ state.raw_text = ""
637
+ state.text = ""
638
+ continue
639
+ next_raw_text = assistant_raw_text(event, state.raw_text, history_text)
640
+ next_text = sanitize_output_text(next_raw_text)
641
+ state.raw_text = next_raw_text
642
+ if next_text != state.text:
643
+ delta = next_text[len(state.text):] if next_text.startswith(state.text) else next_text
644
+ state.text = next_text
645
+ yield conversation_base_event("conversation.delta", state, raw=event, delta=delta)
646
+ continue
647
+ yield conversation_base_event("conversation.event", state, raw=event)
648
+
649
+
650
+ def conversation_events(
651
+ backend: OpenAIBackendAPI,
652
+ messages: list[dict[str, Any]] | None = None,
653
+ model: str = "auto",
654
+ prompt: str = "",
655
+ images: list[str] | None = None,
656
+ size: str | None = None,
657
+ quality: str = "auto",
658
+ ) -> Iterator[dict[str, Any]]:
659
+ normalized = normalize_messages(messages or ([{"role": "user", "content": prompt}] if prompt else []))
660
+ image_model = is_supported_image_model(model)
661
+ history_text = "" if image_model else assistant_history_text(normalized)
662
+ history_messages = [] if image_model else assistant_history_messages(normalized)
663
+ final_prompt = prompt_with_global_system(build_image_prompt(prompt, size, quality)) if image_model else prompt
664
+ payloads = backend.stream_conversation(
665
+ messages=normalized,
666
+ model=model,
667
+ prompt=final_prompt,
668
+ images=images if image_model else None,
669
+ system_hints=["picture_v2"] if image_model else None,
670
+ )
671
+ yield from iter_conversation_payloads(payloads, history_text, history_messages)
672
+
673
+
674
+ def text_backend() -> OpenAIBackendAPI:
675
+ return OpenAIBackendAPI(access_token=account_service.get_text_access_token())
676
+
677
+
678
+ def stream_text_deltas(backend: OpenAIBackendAPI, request: ConversationRequest) -> Iterator[str]:
679
+ attempted_tokens: set[str] = set()
680
+ token = getattr(backend, "access_token", "")
681
+ emitted = False
682
+ while True:
683
+ if token and token in attempted_tokens:
684
+ raise RuntimeError("no available text account")
685
+ if token:
686
+ attempted_tokens.add(token)
687
+ try:
688
+ active_backend = OpenAIBackendAPI(access_token=token)
689
+ for event in conversation_events(active_backend, messages=request.messages, model=request.model, prompt=request.prompt):
690
+ if event.get("type") != "conversation.delta":
691
+ continue
692
+ delta = str(event.get("delta") or "")
693
+ if delta:
694
+ emitted = True
695
+ yield delta
696
+ account_service.mark_text_used(token)
697
+ return
698
+ except Exception as exc:
699
+ error_message = str(exc)
700
+ if token and not emitted and is_token_invalid_error(error_message):
701
+ refreshed_token = account_service.refresh_access_token(token, force=True, event="text_stream")
702
+ if refreshed_token and refreshed_token != token and refreshed_token not in attempted_tokens:
703
+ token = refreshed_token
704
+ else:
705
+ account_service.remove_invalid_token(token, "text_stream")
706
+ token = account_service.get_text_access_token(attempted_tokens)
707
+ if token:
708
+ continue
709
+ raise
710
+
711
+
712
+ def collect_text(backend: OpenAIBackendAPI, request: ConversationRequest) -> str:
713
+ return "".join(stream_text_deltas(backend, request))
714
+
715
+
716
+ def _get_detailed_error_from_tasks(
717
+ backend: OpenAIBackendAPI,
718
+ conversation_id: str,
719
+ timeout_secs: float = 10.0,
720
+ wait_secs: float = 2.0,
721
+ ) -> str:
722
+ """从 /backend-api/tasks/ 接口获取结构化错误信息。
723
+
724
+ 当 SSE 流检测到 moderation 拦截时,轮询 tasks 接口获取详细错误文本。
725
+ 使用结构化字段(metadata.is_error, author.role, content.content_type)判断,
726
+ 而非依赖易变的文本匹配。
727
+
728
+ 参数:
729
+ - `backend`:OpenAIBackendAPI 实例。
730
+ - `conversation_id`:会话 ID。
731
+ - `timeout_secs`:请求超时秒数。
732
+ - `wait_secs`:等待任务创建的秒数。设为 0 可跳过等待。
733
+
734
+ 返回:
735
+ - 详细错误信息文本,如果未找到则返回空字符串。
736
+ """
737
+ import time as _time
738
+ try:
739
+ if wait_secs > 0:
740
+ _time.sleep(wait_secs)
741
+ tasks = backend._query_backend_tasks(conversation_id=conversation_id, timeout_secs=timeout_secs)
742
+ if not tasks:
743
+ return ""
744
+
745
+ for task in tasks:
746
+ is_error, error_msg, metadata = backend.check_task_error(task)
747
+ if is_error and error_msg:
748
+ logger.info({
749
+ "event": "image_task_structured_error",
750
+ "conversation_id": conversation_id,
751
+ "error_msg": error_msg,
752
+ "metadata": metadata,
753
+ })
754
+ return error_msg
755
+ return ""
756
+ except Exception as exc:
757
+ logger.warning({
758
+ "event": "image_task_error_query_failed",
759
+ "conversation_id": conversation_id,
760
+ "error": str(exc),
761
+ })
762
+ return ""
763
+
764
+
765
+ def stream_image_outputs(
766
+ backend: OpenAIBackendAPI,
767
+ request: ConversationRequest,
768
+ index: int = 1,
769
+ total: int = 1,
770
+ ) -> Iterator[ImageOutput]:
771
+ last: dict[str, Any] = {}
772
+ for event in conversation_events(
773
+ backend,
774
+ prompt=request.prompt,
775
+ model=request.model,
776
+ images=request.images or [],
777
+ size=request.size,
778
+ quality=request.quality,
779
+ ):
780
+ last = event
781
+ if event.get("type") == "conversation.delta":
782
+ yield ImageOutput(
783
+ kind="progress",
784
+ model=request.model,
785
+ index=index,
786
+ total=total,
787
+ text=str(event.get("delta") or ""),
788
+ upstream_event_type="conversation.delta",
789
+ )
790
+ continue
791
+ if event.get("type") == "conversation.event":
792
+ raw = event.get("raw")
793
+ raw_type = str(raw.get("type") or "") if isinstance(raw, dict) else ""
794
+ yield ImageOutput(
795
+ kind="progress",
796
+ model=request.model,
797
+ index=index,
798
+ total=total,
799
+ upstream_event_type=raw_type,
800
+ )
801
+
802
+ conversation_id = str(last.get("conversation_id") or "")
803
+ file_ids = [str(item) for item in last.get("file_ids") or []]
804
+ sediment_ids = [str(item) for item in last.get("sediment_ids") or []]
805
+ message = str(last.get("text") or "").strip()
806
+ logger.info({
807
+ "event": "image_stream_resolve_start",
808
+ "conversation_id": conversation_id,
809
+ "file_ids": file_ids,
810
+ "sediment_ids": sediment_ids,
811
+ "tool_invoked": last.get("tool_invoked"),
812
+ "turn_use_case": last.get("turn_use_case"),
813
+ })
814
+ if request.progress_callback:
815
+ request.progress_callback("image_stream_resolve_start")
816
+ if message and not file_ids and not sediment_ids and last.get("blocked"):
817
+ # 尝试从 /backend-api/tasks/ 获取详细错误信息
818
+ detailed_error = _get_detailed_error_from_tasks(backend, conversation_id)
819
+ error_text = detailed_error or message or "Image generation was rejected by upstream policy."
820
+ yield ImageOutput(kind="message", model=request.model, index=index, total=total, text=error_text, conversation_id=conversation_id)
821
+ return
822
+ should_poll_for_image = bool(request.images) or last.get("turn_use_case") == "image gen"
823
+ if message and not file_ids and not sediment_ids and not should_poll_for_image:
824
+ yield ImageOutput(kind="message", model=request.model, index=index, total=total, text=message, conversation_id=conversation_id)
825
+ return
826
+
827
+ # 检测模型是否返回了文本描述(含 referenced_image_ids)而非实际生成图片
828
+ # 这说明模型已发起图片生成工具调用,但 SSE 在工具完成前断开,
829
+ # 图片可能正在异步生成中。需要使用更积极的轮询策略来获取结果。
830
+ is_text_reply = bool(message and is_model_text_reply_instead_of_image(message))
831
+ if is_text_reply:
832
+ logger.info({
833
+ "event": "image_detected_text_reply_with_ids",
834
+ "conversation_id": conversation_id,
835
+ "message_preview": message[:200],
836
+ })
837
+
838
+ # 当检测到文本回复但 conversation_id 丢失时,尝试从最近对话列表中恢复
839
+ # SSE 流太短时(模型返回文本而非触发图片工具),conversation_id 可能未被捕获,
840
+ # 但图片已在上游异步生成。通过列出最近对话来恢复 conversation_id。
841
+ if is_text_reply and not conversation_id:
842
+ try:
843
+ import time as _time
844
+ recovered_id = backend.find_conversation_by_prompt(
845
+ request.prompt, _time.time(), timeout_secs=5.0,
846
+ )
847
+ if recovered_id:
848
+ conversation_id = recovered_id
849
+ logger.info({
850
+ "event": "image_conversation_id_recovered",
851
+ "conversation_id": conversation_id,
852
+ "message_preview": message[:200],
853
+ })
854
+ except Exception as exc:
855
+ logger.warning({
856
+ "event": "image_conversation_id_recovery_failed",
857
+ "error": repr(exc)[:300],
858
+ })
859
+
860
+ # 在轮询图片之前,先检查 /backend-api/tasks/ 是否有 moderation 拦截
861
+ # 这样可以避免不必要的长时间轮询超时
862
+ # 注意:当 should_poll_for_image 为 True 或检测到文本回复时,
863
+ # 即使 tasks 报告了"错误",也不能直接返回——因为上游可能将工具调用的 JSON 参数
864
+ # (如 {"size":"1792x1024","n":1})标记为 is_error,而实际上图片正在异步生成中。
865
+ # 此时应继续轮询图片。
866
+ detailed_error = ""
867
+ if not file_ids and not sediment_ids and conversation_id:
868
+ detailed_error = _get_detailed_error_from_tasks(backend, conversation_id, timeout_secs=5.0, wait_secs=1.0)
869
+ if detailed_error and not should_poll_for_image and not is_text_reply:
870
+ logger.info({
871
+ "event": "image_task_error_before_poll",
872
+ "conversation_id": conversation_id,
873
+ "error": detailed_error,
874
+ })
875
+ yield ImageOutput(kind="message", model=request.model, index=index, total=total, text=detailed_error, conversation_id=conversation_id)
876
+ return
877
+ if detailed_error and (should_poll_for_image or is_text_reply):
878
+ logger.info({
879
+ "event": "image_task_error_skipped_for_poll",
880
+ "conversation_id": conversation_id,
881
+ "error": detailed_error,
882
+ })
883
+
884
+ # 当检测到文本回复(含 referenced_image_ids)时,使用更长的超时来轮询图片结果。
885
+ # 因为上游可能将图片生成作为异步任务执行,SSE 流在工具完成前就断开了,
886
+ # 导致对话文档中尚未写入图片工具的响应记录。
887
+ poll_timeout = config.image_poll_timeout_secs
888
+ if is_text_reply and conversation_id:
889
+ # 文本回复场景下图片可能仍在异步生成,使用更长超时(默认 120s → 额外 180s = 300s)
890
+ poll_timeout = max(poll_timeout, 300)
891
+ logger.info({
892
+ "event": "image_text_reply_extended_poll",
893
+ "conversation_id": conversation_id,
894
+ "poll_timeout_secs": poll_timeout,
895
+ })
896
+
897
+ try:
898
+ image_urls = backend.resolve_conversation_image_urls(
899
+ conversation_id, file_ids, sediment_ids, poll_timeout_secs=poll_timeout,
900
+ )
901
+ except (ImageContentPolicyError, ImagePollTimeoutError) as exc:
902
+ # 当检测到文本回复时,task error 不应直接判定为内容策略违规,
903
+ # 因为图片可能仍在后台异步生成中
904
+ if is_text_reply and isinstance(exc, ImageContentPolicyError):
905
+ logger.warning({
906
+ "event": "image_text_reply_task_error_ignored",
907
+ "conversation_id": conversation_id,
908
+ "error": str(exc),
909
+ })
910
+ image_urls = []
911
+ else:
912
+ raise
913
+ except Exception as exc:
914
+ # 当检测到文本回复时,首次轮询的临时网络错误不应直接中断,
915
+ # 因为图片可能仍在后台异步生成中,后续 retry poll 会继续尝试。
916
+ if is_text_reply and conversation_id:
917
+ logger.warning({
918
+ "event": "image_text_reply_first_poll_error_ignored",
919
+ "conversation_id": conversation_id,
920
+ "error": repr(exc)[:300],
921
+ })
922
+ image_urls = []
923
+ else:
924
+ raise
925
+
926
+ if image_urls:
927
+ if request.progress_callback:
928
+ request.progress_callback("receiving_image")
929
+ image_items = [
930
+ {"b64_json": base64.b64encode(image_data).decode("ascii")}
931
+ for image_data in backend.download_image_bytes(image_urls)
932
+ ]
933
+ data = format_image_result(
934
+ image_items,
935
+ request.prompt,
936
+ request.response_format,
937
+ request.base_url,
938
+ int(time.time()),
939
+ )["data"]
940
+ if data:
941
+ yield ImageOutput(kind="result", model=request.model, index=index, total=total, data=data, conversation_id=conversation_id)
942
+ return
943
+
944
+ if message:
945
+ # 检测模型是否返回了文本描述(含 referenced_image_ids)而非实际生成图片
946
+ # 这说明模型已发起图片生成工具调用,但 SSE 在工具完成前断开。
947
+ # 此时应再尝试轮询图片结果,而不是直接把文本当作最终输出。
948
+ # 当 is_text_reply 但 conversation_id 丢失时,尝试从最近对话列表恢复
949
+ if is_text_reply and not conversation_id:
950
+ try:
951
+ import time as _time
952
+ recovered_id = backend.find_conversation_by_prompt(
953
+ request.prompt, _time.time(), timeout_secs=5.0,
954
+ )
955
+ if recovered_id:
956
+ conversation_id = recovered_id
957
+ logger.info({
958
+ "event": "image_text_reply_conversation_id_recovered",
959
+ "conversation_id": conversation_id,
960
+ "message_preview": message[:200],
961
+ })
962
+ except Exception as exc:
963
+ logger.warning({
964
+ "event": "image_text_reply_conversation_id_recovery_failed",
965
+ "error": repr(exc)[:300],
966
+ })
967
+ if is_text_reply and conversation_id:
968
+ logger.info({
969
+ "event": "image_model_text_reply_retry_poll",
970
+ "conversation_id": conversation_id,
971
+ "message_preview": message[:200],
972
+ })
973
+ # 文本回复场景下,图片可能需要 4-5 分钟才能异步生成完成。
974
+ # 使用 300s 超时并允许多次重试,避免因临时网络问题提前退出。
975
+ retry_poll_timeout = max(config.image_poll_timeout_secs, 300)
976
+ MAX_POLL_RETRIES = 3
977
+ for poll_attempt in range(1, MAX_POLL_RETRIES + 1):
978
+ try:
979
+ polled_file_ids, polled_sediment_ids = backend._poll_image_results(
980
+ conversation_id,
981
+ retry_poll_timeout,
982
+ file_ids,
983
+ sediment_ids,
984
+ )
985
+ file_ids.extend(item for item in polled_file_ids if item and item not in file_ids)
986
+ sediment_ids.extend(item for item in polled_sediment_ids if item and item not in sediment_ids)
987
+ break # 轮询成功,退出重试循环
988
+ except Exception as exc:
989
+ error_str = str(exc)
990
+ is_transient = (
991
+ isinstance(exc, ImagePollTimeoutError)
992
+ or is_tls_connection_error(error_str)
993
+ or "upstream" in error_str.lower()
994
+ or "connection" in error_str.lower()
995
+ or "timeout" in error_str.lower()
996
+ )
997
+ logger.warning({
998
+ "event": "image_model_text_reply_poll_failed",
999
+ "conversation_id": conversation_id,
1000
+ "poll_attempt": poll_attempt,
1001
+ "error": repr(exc)[:300],
1002
+ "is_transient": is_transient,
1003
+ })
1004
+ # 如果还有重试次数且不是超时/内容违规错误,继续重试
1005
+ if poll_attempt < MAX_POLL_RETRIES and not isinstance(exc, (ImagePollTimeoutError, ImageContentPolicyError)):
1006
+ # 递增退避:30s, 60s, 90s
1007
+ backoff = 30.0 * poll_attempt
1008
+ logger.info({
1009
+ "event": "image_model_text_reply_poll_retry",
1010
+ "conversation_id": conversation_id,
1011
+ "poll_attempt": poll_attempt,
1012
+ "backoff_secs": backoff,
1013
+ })
1014
+ time.sleep(backoff)
1015
+ continue
1016
+ # 超时错误或重试次数用尽,停止重试
1017
+ break
1018
+
1019
+ if file_ids or sediment_ids:
1020
+ image_urls = backend.resolve_conversation_image_urls(
1021
+ conversation_id, file_ids, sediment_ids, poll=False,
1022
+ )
1023
+ if image_urls:
1024
+ if request.progress_callback:
1025
+ request.progress_callback("receiving_image")
1026
+ image_items = [
1027
+ {"b64_json": base64.b64encode(image_data).decode("ascii")}
1028
+ for image_data in backend.download_image_bytes(image_urls)
1029
+ ]
1030
+ data = format_image_result(
1031
+ image_items,
1032
+ request.prompt,
1033
+ request.response_format,
1034
+ request.base_url,
1035
+ int(time.time()),
1036
+ )["data"]
1037
+ if data:
1038
+ yield ImageOutput(kind="result", model=request.model, index=index, total=total, data=data, conversation_id=conversation_id)
1039
+ return
1040
+ elif is_text_reply:
1041
+ logger.warning({
1042
+ "event": "image_model_text_reply_no_image",
1043
+ "conversation_id": conversation_id,
1044
+ "message_preview": message[:200],
1045
+ })
1046
+ yield ImageOutput(kind="message", model=request.model, index=index, total=total, text=message, conversation_id=conversation_id)
1047
+ return
1048
+
1049
+ # 兜底:当 message 为空且图片 URL 解析失败时,先尝试一次短延迟重试轮询
1050
+ # 然后抛出明确错误而非让调用方得到 "upstream completed without generating images" 这种模糊报错
1051
+ logger.warning({
1052
+ "event": "image_stream_no_result_fallback",
1053
+ "conversation_id": conversation_id,
1054
+ "file_ids": file_ids,
1055
+ "sediment_ids": sediment_ids,
1056
+ "should_poll_for_image": should_poll_for_image,
1057
+ })
1058
+ # 当 should_poll_for_image 为 True 但 conversation_id 丢失时,尝试恢复
1059
+ if should_poll_for_image and not conversation_id:
1060
+ try:
1061
+ import time as _time
1062
+ recovered_id = backend.find_conversation_by_prompt(
1063
+ request.prompt, _time.time(), timeout_secs=5.0,
1064
+ )
1065
+ if recovered_id:
1066
+ conversation_id = recovered_id
1067
+ logger.info({
1068
+ "event": "image_fallback_conversation_id_recovered",
1069
+ "conversation_id": conversation_id,
1070
+ })
1071
+ except Exception as exc:
1072
+ logger.warning({
1073
+ "event": "image_fallback_conversation_id_recovery_failed",
1074
+ "error": repr(exc)[:300],
1075
+ })
1076
+ if should_poll_for_image and conversation_id:
1077
+ # 图片可能仍在异步处理中(上游 SSE 流在图片生成完成前就结束了)。
1078
+ # 使用 300s 超时并允许多次重试,避免因临时网络问题或图片尚未提交而提前退出。
1079
+ retry_poll_timeout = max(config.image_poll_timeout_secs, 300)
1080
+ MAX_FALLBACK_POLL_RETRIES = 3
1081
+ for poll_attempt in range(1, MAX_FALLBACK_POLL_RETRIES + 1):
1082
+ retry_wait_secs = min(30.0 * poll_attempt, config.image_poll_initial_wait_secs * poll_attempt)
1083
+ logger.info({
1084
+ "event": "image_stream_retry_poll_after_wait",
1085
+ "conversation_id": conversation_id,
1086
+ "retry_wait_secs": retry_wait_secs,
1087
+ "poll_attempt": poll_attempt,
1088
+ })
1089
+ time.sleep(retry_wait_secs)
1090
+ try:
1091
+ polled_file_ids, polled_sediment_ids = backend._poll_image_results(
1092
+ conversation_id,
1093
+ retry_poll_timeout,
1094
+ file_ids,
1095
+ sediment_ids,
1096
+ )
1097
+ file_ids.extend(item for item in polled_file_ids if item and item not in file_ids)
1098
+ sediment_ids.extend(item for item in polled_sediment_ids if item and item not in sediment_ids)
1099
+ break # 轮询成功,退出重试循环
1100
+ except Exception as exc:
1101
+ error_str = str(exc)
1102
+ is_transient = (
1103
+ isinstance(exc, ImagePollTimeoutError)
1104
+ or is_tls_connection_error(error_str)
1105
+ or "upstream" in error_str.lower()
1106
+ or "connection" in error_str.lower()
1107
+ or "timeout" in error_str.lower()
1108
+ )
1109
+ logger.warning({
1110
+ "event": "image_stream_retry_poll_failed",
1111
+ "conversation_id": conversation_id,
1112
+ "poll_attempt": poll_attempt,
1113
+ "error": repr(exc)[:300],
1114
+ "is_transient": is_transient,
1115
+ })
1116
+ # 如果还有重试次数且不是超时/内容违规错误,继续重试
1117
+ if poll_attempt < MAX_FALLBACK_POLL_RETRIES and not isinstance(exc, (ImagePollTimeoutError, ImageContentPolicyError)):
1118
+ # 递增退避:30s, 60s
1119
+ backoff = 30.0 * poll_attempt
1120
+ logger.info({
1121
+ "event": "image_stream_retry_poll_retry",
1122
+ "conversation_id": conversation_id,
1123
+ "poll_attempt": poll_attempt,
1124
+ "backoff_secs": backoff,
1125
+ })
1126
+ time.sleep(backoff)
1127
+ continue
1128
+ # 超时错误或重试次数用尽,停止重试
1129
+ break
1130
+
1131
+ if file_ids or sediment_ids:
1132
+ image_urls = backend.resolve_conversation_image_urls(
1133
+ conversation_id, file_ids, sediment_ids, poll=False,
1134
+ )
1135
+ if image_urls:
1136
+ if request.progress_callback:
1137
+ request.progress_callback("receiving_image")
1138
+ image_items = [
1139
+ {"b64_json": base64.b64encode(image_data).decode("ascii")}
1140
+ for image_data in backend.download_image_bytes(image_urls)
1141
+ ]
1142
+ data = format_image_result(
1143
+ image_items,
1144
+ request.prompt,
1145
+ request.response_format,
1146
+ request.base_url,
1147
+ int(time.time()),
1148
+ )["data"]
1149
+ if data:
1150
+ yield ImageOutput(kind="result", model=request.model, index=index, total=total, data=data, conversation_id=conversation_id)
1151
+ return
1152
+
1153
+ # 重试后仍然失败,yield 错误消息
1154
+ yield ImageOutput(kind="message", model=request.model, index=index, total=total,
1155
+ text="Image generation completed upstream but the result could not be retrieved. "
1156
+ "The image may still be processing. Please try again in a moment.",
1157
+ conversation_id=conversation_id)
1158
+ elif message:
1159
+ yield ImageOutput(kind="message", model=request.model, index=index, total=total, text=message, conversation_id=conversation_id)
1160
+ else:
1161
+ # conversation_id 也为空时(SSE 流极短、未捕获到会话 ID),
1162
+ # 仍然 yield 一条消息,避免 stream_image_outputs_with_pool 产生
1163
+ # "upstream completed without generating images" 模糊报错
1164
+ yield ImageOutput(kind="message", model=request.model, index=index, total=total,
1165
+ text="Image generation started upstream but the response was incomplete. "
1166
+ "Please try again.",
1167
+ conversation_id=conversation_id)
1168
+
1169
+
1170
+ def _codex_response_images(value: Any) -> list[str]:
1171
+ if isinstance(value, dict):
1172
+ if value.get("type") == "image_generation_call" and isinstance(value.get("result"), str):
1173
+ result = value["result"].strip()
1174
+ if result:
1175
+ return [result.split(",", 1)[1] if result.startswith("data:image/") else result]
1176
+ images: list[str] = []
1177
+ for item in value.values():
1178
+ images.extend(_codex_response_images(item))
1179
+ return images
1180
+ if isinstance(value, list):
1181
+ images: list[str] = []
1182
+ for item in value:
1183
+ images.extend(_codex_response_images(item))
1184
+ return images
1185
+ return []
1186
+
1187
+
1188
+ def stream_codex_image_outputs(
1189
+ backend: OpenAIBackendAPI,
1190
+ request: ConversationRequest,
1191
+ index: int = 1,
1192
+ total: int = 1,
1193
+ ) -> Iterator[ImageOutput]:
1194
+ images = _codex_response_images(list(backend.iter_codex_image_response_events(
1195
+ prompt=request.prompt,
1196
+ images=request.images or [],
1197
+ size=request.size,
1198
+ quality=request.quality,
1199
+ )))
1200
+ if not images:
1201
+ raise ImageGenerationError("No image result found in response")
1202
+ data = format_image_result(
1203
+ [{"b64_json": item, "revised_prompt": request.prompt} for item in images],
1204
+ request.prompt,
1205
+ request.response_format,
1206
+ request.base_url,
1207
+ int(time.time()),
1208
+ )["data"]
1209
+ if data:
1210
+ yield ImageOutput(kind="result", model=request.model, index=index, total=total, data=data)
1211
+ return
1212
+ raise ImageGenerationError("No image result found in response")
1213
+
1214
+
1215
+ def _generate_single_image(
1216
+ request: ConversationRequest,
1217
+ index: int,
1218
+ total: int,
1219
+ ) -> list[ImageOutput]:
1220
+ """为单张图片执行生成逻辑(含重试),返回结果列表。
1221
+
1222
+ 该函数在独立线程中运行,每个线程使用不同的账号,
1223
+ 实现并行生图,避免串行超时阻塞。
1224
+ """
1225
+ # 模型返回文本而非图片的最大重试次数
1226
+ MAX_TEXT_REPLY_RETRIES = 3
1227
+ # TLS 连接错误最大重试次数
1228
+ MAX_TLS_RETRIES = 3
1229
+ # 连接超时错误最大重试次数(同账号短等待重试)
1230
+ MAX_CONN_TIMEOUT_RETRIES = 3
1231
+ # 轮询超时错误最大重试次数(换账号重试)
1232
+ MAX_POLL_TIMEOUT_RETRIES = 4
1233
+
1234
+ text_reply_retry_count = 0
1235
+ tls_retry_count = 0
1236
+ conn_timeout_retry_count = 0
1237
+ poll_timeout_retry_count = 0
1238
+ account_email = ""
1239
+
1240
+ while True:
1241
+ try:
1242
+ if request.progress_callback:
1243
+ request.progress_callback("getting_account")
1244
+ plan_type, _ = split_image_model(request.model)
1245
+ codex_model = is_codex_image_model(request.model)
1246
+ token = account_service.get_available_access_token(
1247
+ plan_type=plan_type,
1248
+ source_type="codex" if codex_model else None,
1249
+ plan_types=("plus", "team", "pro") if codex_model and not plan_type else None,
1250
+ )
1251
+ except RuntimeError as exc:
1252
+ raise ImageGenerationError(str(exc) or "image generation failed", account_email=account_email) from exc
1253
+
1254
+ emitted_for_token = False
1255
+ returned_message = False
1256
+ returned_result = False
1257
+ account = account_service.get_account(token) or {}
1258
+ account_email = str(account.get("email") or "").strip()
1259
+ logger.debug({
1260
+ "event": "image_account_lookup",
1261
+ "token_prefix": token[:12] + "..." if len(token) > 12 else token,
1262
+ "account_email": account_email,
1263
+ "account_found": bool(account),
1264
+ "index": index,
1265
+ })
1266
+ try:
1267
+ backend = OpenAIBackendAPI(access_token=token)
1268
+ if request.progress_callback:
1269
+ backend.progress_callback = request.progress_callback
1270
+ stream_fn = stream_codex_image_outputs if is_codex_image_model(request.model) else stream_image_outputs
1271
+ outputs: list[ImageOutput] = []
1272
+ for output in stream_fn(backend, request, index, total):
1273
+ if account_email and not output.account_email:
1274
+ output.account_email = account_email
1275
+ if output.kind == "message" and request.message_as_error:
1276
+ raise ImageGenerationError(
1277
+ output.text or "Image generation was rejected by upstream policy.",
1278
+ status_code=400,
1279
+ error_type="invalid_request_error",
1280
+ code="content_policy_violation",
1281
+ account_email=account_email,
1282
+ conversation_id=output.conversation_id,
1283
+ )
1284
+ emitted_for_token = True
1285
+ returned_message = output.kind == "message"
1286
+ returned_result = returned_result or output.kind == "result"
1287
+ outputs.append(output)
1288
+ if returned_message:
1289
+ account_service.mark_image_result(token, False)
1290
+ return outputs
1291
+ if not returned_result:
1292
+ account_service.mark_image_result(token, False)
1293
+ if emitted_for_token:
1294
+ conv_id = outputs[-1].conversation_id if outputs else ""
1295
+ raise ImageGenerationError(
1296
+ "upstream completed without generating images",
1297
+ status_code=400,
1298
+ error_type="invalid_request_error",
1299
+ code="no_image_generated",
1300
+ account_email=account_email,
1301
+ conversation_id=conv_id,
1302
+ )
1303
+ return outputs
1304
+ account_service.mark_image_result(token, True)
1305
+ return outputs
1306
+ except ImagePollTimeoutError as exc:
1307
+ account_service.mark_image_result(token, False)
1308
+ if account_email:
1309
+ setattr(exc, "account_email", account_email)
1310
+ # 轮询超时:换账号重试
1311
+ if not emitted_for_token:
1312
+ poll_timeout_retry_count += 1
1313
+ if poll_timeout_retry_count <= MAX_POLL_TIMEOUT_RETRIES:
1314
+ logger.warning({
1315
+ "event": "image_poll_timeout_retry",
1316
+ "request_token": token,
1317
+ "account_email": account_email,
1318
+ "retry_count": poll_timeout_retry_count,
1319
+ "index": index,
1320
+ "error": str(exc)[:200],
1321
+ })
1322
+ continue
1323
+ logger.warning({
1324
+ "event": "image_poll_timeout_exhausted_retries",
1325
+ "request_token": token,
1326
+ "account_email": account_email,
1327
+ "retry_count": poll_timeout_retry_count,
1328
+ "index": index,
1329
+ })
1330
+ raise
1331
+ raise
1332
+ except ImageContentPolicyError as exc:
1333
+ account_service.mark_image_result(token, False)
1334
+ logger.warning({
1335
+ "event": "image_stream_content_policy_error",
1336
+ "request_token": token,
1337
+ "account_email": account_email,
1338
+ "error": str(exc),
1339
+ "index": index,
1340
+ })
1341
+ raise ImageGenerationError(
1342
+ str(exc) or "Image generation was rejected by upstream policy.",
1343
+ status_code=400,
1344
+ error_type="invalid_request_error",
1345
+ code="content_policy_violation",
1346
+ account_email=account_email,
1347
+ conversation_id=getattr(exc, "conversation_id", ""),
1348
+ ) from exc
1349
+ except ImageGenerationError as exc:
1350
+ account_service.mark_image_result(token, False)
1351
+ if account_email and not getattr(exc, "account_email", ""):
1352
+ exc.account_email = account_email
1353
+ error_text = str(exc)
1354
+ # 如果是模型返回文本而非图片,尝试换账号重试
1355
+ if is_model_text_reply_instead_of_image(error_text) and not emitted_for_token:
1356
+ text_reply_retry_count += 1
1357
+ if text_reply_retry_count <= MAX_TEXT_REPLY_RETRIES:
1358
+ logger.warning({
1359
+ "event": "image_model_text_reply_retry",
1360
+ "request_token": token,
1361
+ "account_email": account_email,
1362
+ "retry_count": text_reply_retry_count,
1363
+ "index": index,
1364
+ "error": error_text[:200],
1365
+ })
1366
+ continue
1367
+ logger.warning({
1368
+ "event": "image_model_text_reply_exhausted_retries",
1369
+ "request_token": token,
1370
+ "account_email": account_email,
1371
+ "retry_count": text_reply_retry_count,
1372
+ "index": index,
1373
+ })
1374
+ raise ImageGenerationError(
1375
+ "Image generation failed: the upstream model returned a text description "
1376
+ "instead of generating an image. Please try again later.",
1377
+ status_code=502,
1378
+ error_type="server_error",
1379
+ code="upstream_text_reply",
1380
+ account_email=account_email,
1381
+ conversation_id=getattr(exc, "conversation_id", ""),
1382
+ ) from exc
1383
+ logger.warning({
1384
+ "event": "image_stream_generation_error",
1385
+ "request_token": token,
1386
+ "account_email": account_email,
1387
+ "error": error_text,
1388
+ "index": index,
1389
+ })
1390
+ raise
1391
+ except Exception as exc:
1392
+ account_service.mark_image_result(token, False)
1393
+ last_error = str(exc)
1394
+ logger.warning({
1395
+ "event": "image_stream_fail",
1396
+ "request_token": token,
1397
+ "account_email": account_email,
1398
+ "error": last_error,
1399
+ "index": index,
1400
+ })
1401
+ if not emitted_for_token and is_token_invalid_error(last_error):
1402
+ refreshed_token = account_service.refresh_access_token(token, force=True, event="image_stream")
1403
+ if refreshed_token and refreshed_token != token:
1404
+ token = refreshed_token
1405
+ continue
1406
+ account_service.remove_invalid_token(token, "image_stream")
1407
+ continue
1408
+ # TLS/SSL 连接错���:自动重试
1409
+ if not emitted_for_token and is_tls_connection_error(last_error):
1410
+ tls_retry_count += 1
1411
+ if tls_retry_count <= MAX_TLS_RETRIES:
1412
+ logger.warning({
1413
+ "event": "image_stream_tls_retry",
1414
+ "request_token": token,
1415
+ "account_email": account_email,
1416
+ "retry_count": tls_retry_count,
1417
+ "index": index,
1418
+ "error": last_error[:200],
1419
+ })
1420
+ time.sleep(min(2.0 * tls_retry_count, 10.0))
1421
+ continue
1422
+ # 连接超时错误(curl 28):同账号短等待重试,不切换账号
1423
+ if not emitted_for_token and is_connection_timeout_error(last_error):
1424
+ conn_timeout_retry_count += 1
1425
+ if conn_timeout_retry_count <= MAX_CONN_TIMEOUT_RETRIES:
1426
+ wait_secs = min(3.0 * conn_timeout_retry_count, 9.0)
1427
+ logger.warning({
1428
+ "event": "image_stream_conn_timeout_retry",
1429
+ "request_token": token,
1430
+ "account_email": account_email,
1431
+ "retry_count": conn_timeout_retry_count,
1432
+ "index": index,
1433
+ "wait_secs": wait_secs,
1434
+ "error": last_error[:200],
1435
+ })
1436
+ time.sleep(wait_secs)
1437
+ continue
1438
+ raise ImageGenerationError(image_stream_error_message(last_error), account_email=account_email, conversation_id="") from exc
1439
+
1440
+
1441
+ def stream_image_outputs_with_pool(request: ConversationRequest) -> Iterator[ImageOutput]:
1442
+ """并行生成多张图片,每张图片使用独立线程和账号,互不阻塞。"""
1443
+ if not is_supported_image_model(request.model):
1444
+ raise ImageGenerationError("unsupported image model,supported models: " + ", ".join(sorted(IMAGE_MODELS)))
1445
+
1446
+ if request.n <= 1:
1447
+ # 单张图片,直接执行(无需线程池开销)
1448
+ outputs = _generate_single_image(request, 1, 1)
1449
+ for output in outputs:
1450
+ yield output
1451
+ return
1452
+
1453
+ # 多张图片:根据配置选择并行或串行执行
1454
+ if not config.image_parallel_generation:
1455
+ logger.info({
1456
+ "event": "image_serial_generation_start",
1457
+ "n": request.n,
1458
+ "model": request.model,
1459
+ })
1460
+ for index in range(1, request.n + 1):
1461
+ outputs = _generate_single_image(request, index, request.n)
1462
+ for output in outputs:
1463
+ yield output
1464
+ return
1465
+
1466
+ logger.info({
1467
+ "event": "image_parallel_generation_start",
1468
+ "n": request.n,
1469
+ "model": request.model,
1470
+ })
1471
+ # 每张图片一个线程,同时启动
1472
+ futures = {}
1473
+ results: dict[int, list[ImageOutput]] = {}
1474
+ errors: dict[int, Exception] = {}
1475
+ with ThreadPoolExecutor(max_workers=request.n) as executor:
1476
+ for index in range(1, request.n + 1):
1477
+ future = executor.submit(_generate_single_image, request, index, request.n)
1478
+ futures[future] = index
1479
+
1480
+ # 按完成顺序收集结果
1481
+ for future in as_completed(futures):
1482
+ index = futures[future]
1483
+ try:
1484
+ results[index] = future.result()
1485
+ except Exception as exc:
1486
+ errors[index] = exc
1487
+ logger.warning({
1488
+ "event": "image_parallel_generation_error",
1489
+ "index": index,
1490
+ "error": str(exc)[:300],
1491
+ })
1492
+
1493
+ # yield 结果:跳过索引顺序限制,不再让低索引失败阻塞高索引成功结果
1494
+ emitted = False
1495
+ last_error = ""
1496
+ # 先 yield 所有成功的结果
1497
+ for index in range(1, request.n + 1):
1498
+ if index in results:
1499
+ for output in results[index]:
1500
+ emitted = True
1501
+ yield output
1502
+ elif index in errors:
1503
+ last_error = str(errors[index])
1504
+ if not emitted:
1505
+ logger.warning({
1506
+ "event": "image_parallel_failure_before_success",
1507
+ "failed_index": index,
1508
+ "error": last_error[:200],
1509
+ })
1510
+
1511
+ # 如果有失败但也有成功,记录警告
1512
+ if emitted:
1513
+ for index in range(1, request.n + 1):
1514
+ if index in errors:
1515
+ logger.warning({
1516
+ "event": "image_parallel_partial_failure",
1517
+ "failed_index": index,
1518
+ "error": str(errors[index])[:200],
1519
+ })
1520
+
1521
+ if not emitted:
1522
+ if not last_error:
1523
+ last_error = "no account in the pool could generate images — check account quota and rate-limit status"
1524
+ raise ImageGenerationError(image_stream_error_message(last_error), conversation_id="")
1525
+
1526
+
1527
+ def stream_image_chunks(outputs: Iterable[ImageOutput]) -> Iterator[dict[str, Any]]:
1528
+ for output in outputs:
1529
+ yield output.to_chunk()
1530
+
1531
+
1532
+ def collect_image_outputs(outputs: Iterable[ImageOutput]) -> dict[str, Any]:
1533
+ created = None
1534
+ data: list[dict[str, Any]] = []
1535
+ message = ""
1536
+ progress_parts: list[str] = []
1537
+ account_email = ""
1538
+ for output in outputs:
1539
+ created = created or output.created
1540
+ if output.account_email and not account_email:
1541
+ account_email = output.account_email
1542
+ if output.kind == "progress" and output.text:
1543
+ progress_parts.append(output.text)
1544
+ elif output.kind == "message":
1545
+ message = output.text
1546
+ elif output.kind == "result":
1547
+ data.extend(output.data)
1548
+
1549
+ result: dict[str, Any] = {"created": created or int(time.time()), "data": data}
1550
+ if not data:
1551
+ text = message or "".join(progress_parts).strip()
1552
+ if text:
1553
+ result["message"] = text
1554
+ if account_email:
1555
+ result["_account_email"] = account_email
1556
+ return result