peijun1 commited on
Commit
dc03b36
·
0 Parent(s):

clean deploy

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +14 -0
  2. .env.example +48 -0
  3. .github/workflows/docker-publish.yml +61 -0
  4. .gitignore +30 -0
  5. .python-version +1 -0
  6. CHANGELOG.md +20 -0
  7. Dockerfile +56 -0
  8. LICENSE +21 -0
  9. README.md +336 -0
  10. VERSION +1 -0
  11. api/__init__.py +2 -0
  12. api/accounts.py +488 -0
  13. api/ai.py +141 -0
  14. api/app.py +63 -0
  15. api/errors.py +46 -0
  16. api/image_inputs.py +294 -0
  17. api/image_tasks.py +96 -0
  18. api/register.py +68 -0
  19. api/support.py +132 -0
  20. api/system.py +334 -0
  21. docker-compose.local.yml +34 -0
  22. docker-compose.yml +29 -0
  23. docs/feature-status.en.md +37 -0
  24. docs/review.md +9 -0
  25. docs/upstream-sse-conversation.md +271 -0
  26. main.py +9 -0
  27. pyproject.toml +27 -0
  28. scripts/migrate_storage.py +163 -0
  29. scripts/test_storage.py +129 -0
  30. scripts/verify_oauth_refresh.py +92 -0
  31. services/__init__.py +0 -0
  32. services/account_service.py +1031 -0
  33. services/auth_service.py +253 -0
  34. services/backup_service.py +673 -0
  35. services/config.py +446 -0
  36. services/content_filter.py +196 -0
  37. services/cpa_service.py +315 -0
  38. services/image_service.py +356 -0
  39. services/image_storage_service.py +405 -0
  40. services/image_tags_service.py +76 -0
  41. services/image_task_service.py +404 -0
  42. services/log_service.py +308 -0
  43. services/oauth_login_service.py +272 -0
  44. services/openai_backend_api.py +1264 -0
  45. services/protocol/__init__.py +2 -0
  46. services/protocol/anthropic_v1_messages.py +306 -0
  47. services/protocol/chat_completion_cache.py +236 -0
  48. services/protocol/conversation.py +835 -0
  49. services/protocol/error_response.py +124 -0
  50. services/protocol/openai_v1_chat_complete.py +231 -0
.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,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+ web_dist
9
+ # Virtual environments
10
+ .venv
11
+ .idea
12
+ data
13
+ config.json
14
+ edit
15
+
16
+ docker-compose-local.yml
17
+
18
+ .claude/settings.local.json
19
+
20
+ # Environment variables
21
+ .env
22
+ .env.local
23
+
24
+ # Database files
25
+ *.db
26
+ *.sqlite
27
+ *.sqlite3
28
+
29
+ # Git cache
30
+ git_cache/
.python-version ADDED
@@ -0,0 +1 @@
 
 
1
+ 3.13
CHANGELOG.md ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ ## 1.2.3 - 2026-05-29
6
+
7
+ + [新增] 新增账号级代理。
8
+ + [修复] 修复503异常信息、前端邮箱换行问题。
9
+
10
+ ## 1.2.2 - 2026-05-29
11
+
12
+ + [新增] 新增Codex链路生图、支持2k,4k。
13
+ + [新增] 支持RT刷新账号信息。
14
+
15
+ ## 1.2.0 - 2026-05-28
16
+
17
+ + [新增] 当前版本基线,包含 Web 面板、画图、号池管理、注册机、图片管理、日志管理和设置能力。
18
+ + [新增] 前端版本号支持点击查看版本更新弹窗,展示当前版本、最新版本和更新日志。
19
+ + [优化] 优化注册机效率,成功率大幅提高。
20
+ + [优化] 优化生图页面配置选项。
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 80
55
+
56
+ CMD ["uv", "run", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "80", "--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 ADDED
@@ -0,0 +1,336 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <h1 align="center">ChatGPT2API</h1>
2
+
3
+
4
+ <p align="center">ChatGPT2API 主要是对 ChatGPT 官网相关能力进行逆向整理与封装,提供面向 ChatGPT 图片生成、图片编辑、多图组图编辑场景的 OpenAI 兼容图片 API / 代理,并集成在线画图、号池管理、多种账号导入方式与 Docker 自托管部署能力。</p>
5
+
6
+ > [!WARNING]
7
+ > 免责声明:
8
+ >
9
+ > 本项目涉及对 ChatGPT 官网文本生成、图片生成与图片编辑等相关接口的逆向研究,仅供个人学习、技术研究与非商业性技术交流使用。
10
+ >
11
+ > - 严禁将本项目用于任何商业用途、盈利性使用、批量操作、自动化滥用或规模化调用。
12
+ > - 严禁将本项目用于破坏市场秩序、恶意竞争、套利倒卖、二次售卖相关服务,以及任何违反 OpenAI 服务条款或当地法律法规的行为。
13
+ > - 严禁将本项目用于生成、传播或协助生成违法、暴力、色情、未成年人相关内容,或用于诈骗、欺诈、骚扰等非法或不当用途。
14
+ > - 使用者应自行承担全部风险,包括但不限于账号被限制、临时封禁或永久封禁以及因违规使用等所导致的法律责任。
15
+ > - 使用本项目即视为你已充分理解并同意本免责声明全部内容;如因滥用、违规或违法使用造成任何后果,均由使用者自行承担。
16
+ > - 本项目基于对 ChatGPT 官网相关能力的逆向研究实现,存在账号受限、临时封禁或永久封禁的风险。请勿使用你自己的重要账号、常用账号或高价值账号进行测试。
17
+
18
+ ## 快速开始
19
+
20
+ 已发布镜像支持 `linux/amd64` 与 `linux/arm64`,在 x86 服务器和 Apple Silicon / ARM Linux 设备上都会自动拉取匹配架构的版本。
21
+
22
+ ### Docker 运行
23
+
24
+ ```bash
25
+ git clone git@github.com:basketikun/chatgpt2api.git
26
+ cd chatgpt2api
27
+ docker compose up -d
28
+ ```
29
+
30
+ 启动前请先在 `config.json` 中设置 `auth-key`,也可以在 `docker-compose.yml` 中通过 `CHATGPT2API_AUTH_KEY` 覆盖。
31
+
32
+ - Web 面板:`http://localhost:3000`
33
+ - API 地址:`http://localhost:3000/v1`
34
+ - 数据目录:`./data`
35
+
36
+ ### 本地开发
37
+
38
+ 启动后端:
39
+
40
+ ```bash
41
+ git clone git@github.com:basketikun/chatgpt2api.git
42
+ cd chatgpt2api
43
+ uv sync
44
+ uv run main.py
45
+ ```
46
+
47
+ 启动前端:
48
+
49
+ ```bash
50
+ cd chatgpt2api/web
51
+ bun install
52
+ bun run dev
53
+ ```
54
+
55
+ 后续更新新版本:
56
+
57
+ ```bash
58
+ docker pull ghcr.io/basketikun/chatgpt2api:latest
59
+ docker-compose down
60
+ docker-compose up -d
61
+
62
+ ```
63
+
64
+ ### 存储后端配置
65
+
66
+ 支持通过环境变量 `STORAGE_BACKEND` 切换存储方式:
67
+
68
+ - `json` - 本地 JSON 文件(默认)
69
+ - `sqlite` - 本地 SQLite 数据库
70
+ - `postgres` - 外部 PostgreSQL(需配置 `DATABASE_URL`)
71
+ - `git` - Git 私有仓库(需配置 `GIT_REPO_URL` 和 `GIT_TOKEN`)
72
+
73
+ 示例:使用 PostgreSQL
74
+
75
+ ```yaml
76
+ environment:
77
+ - STORAGE_BACKEND=postgres
78
+ - DATABASE_URL=postgresql://user:password@host:5432/dbname
79
+ ```
80
+
81
+ ## 功能
82
+
83
+ ### API 兼容能力
84
+
85
+ - 兼容 `POST /v1/images/generations` 图片生成接口
86
+ - 兼容 `POST /v1/images/edits` 图片编辑接口
87
+ - 兼容面向图片场景的 `POST /v1/chat/completions`
88
+ - 兼容面向图片场景的 `POST /v1/responses`
89
+ - `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`、
90
+ `gpt-5-mini`
91
+ - 支持通过 `n` 返回多张生成结果
92
+ - 支持 Codex 中的画图接口逆向,仅 `Plus` / `Team` / `Pro` 订阅可用,模型别名为 `codex-gpt-image-2`,如有需要可自行在其他场景映射回
93
+ `gpt-image-2`,用于和官网画图区分;也就意味着同一账号会同时有官网和 Codex 两份生图额度
94
+
95
+ ### 在线画图功能
96
+
97
+ - 内置在线画图工作台,支持生成、图片编辑与多图组图编辑
98
+ - 支持 `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` 模型选择
99
+ - 编辑模式支持参考图上传
100
+ - 前端支持多图生成交互
101
+ - 本地保存图片会话历史,支持回看、删除和清空
102
+ - 支持服务端缓存图片URL
103
+
104
+ ### 号池管理功能
105
+
106
+ - 自动刷新账号邮箱、类型、额度和恢复时间
107
+ - 轮询可用账号执行图片生成与图片编辑
108
+ - 遇到 Token 失效类错误时自动剔除无效 Token
109
+ - 定时检查限流账号并自动刷新
110
+ - 支持网页端配置全局 HTTP / HTTPS / SOCKS5 / SOCKS5H 代理
111
+ - 支持搜索、筛选、批量刷新、导出、手动编辑和清理账号
112
+ - 支持四种导入方式:本地 CPA JSON 文件导入、远程 CPA 服务器导入、`sub2api` 服务器导入、`access_token` 导入
113
+ - 支持在设置页配置 `sub2api` 服务器,筛选并批量导入其中的 OpenAI OAuth 账号
114
+
115
+ ### 实验性 / 规划中
116
+
117
+ - `/v1/complete` 文本补全与流式输出已实现,但仍在测试,目前会出现对话重复的问题,请谨慎测试使用
118
+ - `/v1/chat/completions` 文本链路支持短 TTL 缓存、重复请求合并与相邻重复消息清理,可通过 `chat_completion_cache` 配置调整
119
+ - 详细状态说明见:[功能清单](./docs/feature-status.en.md)
120
+
121
+ ## 效果展示
122
+
123
+ <table width="100%">
124
+ <tr>
125
+ <td width="50%"><img src="https://i.ibb.co/Jj8nfwwP/image.png" alt="image" border="0"></td>
126
+ <td width="50%"><img src="https://i.ibb.co/pqf235v/image-edit.png" alt="image edit" border="0"></td>
127
+ </tr>
128
+ <tr>
129
+ <td width="50%"><img src="https://i.ibb.co/tPcqtVfd/chery-studio.png" alt="chery studio" border="0"></td>
130
+ <td width="50%"><img src="https://i.ibb.co/PsT9YHBV/account-pool.png" alt="account pool" border="0"></td>
131
+ </tr>
132
+ <tr>
133
+ <td width="50%"><img src="https://i.ibb.co/rRWLG08q/new-api.png" alt="new api" border="0"></td>
134
+ </tr>
135
+ </table>
136
+
137
+ ## API
138
+
139
+ 所有 AI 接口都需要请求头:
140
+
141
+ ```http
142
+ Authorization: Bearer <auth-key>
143
+ ```
144
+
145
+ <details>
146
+ <summary><code>GET /v1/models</code></summary>
147
+ <br>
148
+
149
+ 返回当前暴露的图片模型列表。
150
+
151
+ ```bash
152
+ curl http://localhost:8000/v1/models \
153
+ -H "Authorization: Bearer <auth-key>"
154
+ ```
155
+
156
+ <details>
157
+ <summary>说明</summary>
158
+ <br>
159
+
160
+ | 字段 | 说明 |
161
+ |:-----|:-----------------------------------------------------------------------------------------------------------|
162
+ | 返回模型 | `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` |
163
+ | 接入场景 | 可接入 Cherry Studio、New API 等上游或客户端 |
164
+
165
+ <br>
166
+ </details>
167
+ </details>
168
+
169
+ <details>
170
+ <summary><code>POST /v1/images/generations</code></summary>
171
+ <br>
172
+
173
+ OpenAI 兼容图片生成接口,用于文生图。
174
+
175
+ ```bash
176
+ curl http://localhost:8000/v1/images/generations \
177
+ -H "Content-Type: application/json" \
178
+ -H "Authorization: Bearer <auth-key>" \
179
+ -d '{
180
+ "model": "gpt-image-2",
181
+ "prompt": "一只漂浮在太空里的猫",
182
+ "n": 1,
183
+ "response_format": "b64_json"
184
+ }'
185
+ ```
186
+
187
+ <details>
188
+ <summary>字段说明</summary>
189
+ <br>
190
+
191
+ | 字段 | 说明 |
192
+ |:------------------|:---------------------------------------------------|
193
+ | `model` | 图片模型,当前可用值以 `/v1/models` 返回结果为准,推荐使用 `gpt-image-2` |
194
+ | `prompt` | 图片生成提示词 |
195
+ | `n` | 生成数量,当前后端限制为 `1-4` |
196
+ | `response_format` | 当前请求模型中包含该字段,默认值为 `b64_json` |
197
+
198
+ <br>
199
+ </details>
200
+ </details>
201
+
202
+ <details>
203
+ <summary><code>POST /v1/images/edits</code></summary>
204
+ <br>
205
+
206
+ OpenAI 兼容图片编辑接口,可上传图片文件,也可按官方 JSON 格式传入图片链接并生成编辑结果。
207
+
208
+ ```bash
209
+ curl http://localhost:8000/v1/images/edits \
210
+ -H "Authorization: Bearer <auth-key>" \
211
+ -F "model=gpt-image-2" \
212
+ -F "prompt=把这张图改成赛博朋克夜景风格" \
213
+ -F "n=1" \
214
+ -F "image=@./input.png"
215
+ ```
216
+
217
+ 也可以直接传图片 URL:
218
+
219
+ ```bash
220
+ curl http://localhost:8000/v1/images/edits \
221
+ -H "Authorization: Bearer <auth-key>" \
222
+ -H "Content-Type: application/json" \
223
+ -d '{
224
+ "model": "gpt-image-2",
225
+ "prompt": "把这张图改成赛博朋克夜景风格",
226
+ "images": [
227
+ {"image_url": "https://example.com/input.png"}
228
+ ]
229
+ }'
230
+ ```
231
+
232
+ <details>
233
+ <summary>字段说明</summary>
234
+ <br>
235
+
236
+ | 字段 | 说明 |
237
+ |:------------|:----------------------------------------------|
238
+ | `model` | 图片模型, `gpt-image-2` |
239
+ | `prompt` | 图片编辑提示词 |
240
+ | `n` | 生成数量,当前后端限制为 `1-4` |
241
+ | `image` | 需要编辑的图片文件,使用 multipart/form-data 上传 |
242
+ | `images` | JSON 图片引用数组,支持 `{"image_url": "https://..."}` |
243
+ | `image_url` | 表单模式下也可直接传图片链接,支持重复字段传多张图 |
244
+
245
+ <br>
246
+ </details>
247
+ </details>
248
+
249
+ <details>
250
+ <summary><code>POST /v1/chat/completions</code></summary>
251
+ <br>
252
+
253
+ 面向图片场景的 Chat Completions 兼容接口,不是完整通用聊天代理。
254
+
255
+ ```bash
256
+ curl http://localhost:8000/v1/chat/completions \
257
+ -H "Content-Type: application/json" \
258
+ -H "Authorization: Bearer <auth-key>" \
259
+ -d '{
260
+ "model": "gpt-image-2",
261
+ "messages": [
262
+ {
263
+ "role": "user",
264
+ "content": "生成一张雨夜东京街头的赛博朋克猫"
265
+ }
266
+ ],
267
+ "n": 1
268
+ }'
269
+ ```
270
+
271
+ <details>
272
+ <summary>字段说明</summary>
273
+ <br>
274
+
275
+ | 字段 | 说明 |
276
+ |:-----------|:------------------|
277
+ | `model` | 图片模型,默认按图片生成场景处理 |
278
+ | `messages` | 消息数组,需要是图片相关请求内容 |
279
+ | `n` | 生成数量,按当前实现解析为图片数量 |
280
+ | `stream` | 已实现,但仍在测试 |
281
+
282
+ <br>
283
+ </details>
284
+ </details>
285
+
286
+ <details>
287
+ <summary><code>POST /v1/responses</code></summary>
288
+ <br>
289
+
290
+ 面向图片生成工具调用的 Responses API 兼容接口,不是完整通用 Responses API 代理。
291
+
292
+ ```bash
293
+ curl http://localhost:8000/v1/responses \
294
+ -H "Content-Type: application/json" \
295
+ -H "Authorization: Bearer <auth-key>" \
296
+ -d '{
297
+ "model": "gpt-5",
298
+ "input": "生成一张未来感城市天际线图片",
299
+ "tools": [
300
+ {
301
+ "type": "image_generation"
302
+ }
303
+ ]
304
+ }'
305
+ ```
306
+
307
+ <details>
308
+ <summary>字段说明</summary>
309
+ <br>
310
+
311
+ | 字段 | 说明 |
312
+ |:---------|:------------------------------|
313
+ | `model` | 响应中会回显该模型字段,但图片生成当前仍走图片生成兼容逻辑 |
314
+ | `input` | 输入内容,需要能解析出图片生成提示词 |
315
+ | `tools` | 必须包含 `image_generation` 工具请求 |
316
+ | `stream` | 已实现,但仍在测试 |
317
+
318
+ <br>
319
+ </details>
320
+ </details>
321
+
322
+ ## 社区支持
323
+
324
+ 学 AI , 上 L 站:[LinuxDO](https://linux.do)
325
+
326
+ ## Contributors
327
+
328
+ 感谢所有为本项目做出贡献的开发者:
329
+
330
+ <a href="https://github.com/basketikun/chatgpt2api/graphs/contributors">
331
+ <img alt="Contributors" src="https://contrib.rocks/image?repo=basketikun/chatgpt2api" />
332
+ </a>
333
+
334
+ ## Star History
335
+
336
+ [![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.2.3
api/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ from api.app import create_app
2
+
api/accounts.py ADDED
@@ -0,0 +1,488 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import json
5
+ import re
6
+ import zipfile
7
+ from datetime import datetime
8
+ from typing import Any, Literal
9
+
10
+ from fastapi import APIRouter, Header, HTTPException
11
+ from fastapi.concurrency import run_in_threadpool
12
+ from fastapi.responses import Response
13
+ from pydantic import BaseModel, Field
14
+
15
+ from services.auth_service import auth_service
16
+
17
+ from api.support import (
18
+ require_admin,
19
+ sanitize_cpa_pool,
20
+ sanitize_cpa_pools,
21
+ sanitize_sub2api_server,
22
+ sanitize_sub2api_servers,
23
+ )
24
+ from services.account_service import account_service
25
+ from services.cpa_service import cpa_config, cpa_import_service, list_remote_files
26
+ from services.oauth_login_service import OAuthLoginError, oauth_login_service
27
+ from services.sub2api_service import (
28
+ list_remote_accounts as sub2api_list_remote_accounts,
29
+ list_remote_groups as sub2api_list_remote_groups,
30
+ sub2api_config,
31
+ sub2api_import_service,
32
+ )
33
+
34
+
35
+
36
+ class UserKeyCreateRequest(BaseModel):
37
+ name: str = ""
38
+
39
+
40
+ class UserKeyUpdateRequest(BaseModel):
41
+ name: str | None = None
42
+ enabled: bool | None = None
43
+ key: str | None = None
44
+
45
+
46
+ class AccountCreateRequest(BaseModel):
47
+ tokens: list[str] = Field(default_factory=list)
48
+ accounts: list[dict[str, Any]] = Field(default_factory=list)
49
+
50
+
51
+ class AccountDeleteRequest(BaseModel):
52
+ tokens: list[str] = Field(default_factory=list)
53
+
54
+
55
+ class AccountRefreshRequest(BaseModel):
56
+ access_tokens: list[str] = Field(default_factory=list)
57
+
58
+
59
+ class AccountExportRequest(BaseModel):
60
+ access_tokens: list[str] = Field(default_factory=list)
61
+ format: Literal["json", "zip"] = "json"
62
+
63
+
64
+ class AccountUpdateRequest(BaseModel):
65
+ access_token: str = ""
66
+ type: str | None = None
67
+ status: str | None = None
68
+ quota: int | None = None
69
+ proxy: str | None = None
70
+
71
+
72
+ class CPAPoolCreateRequest(BaseModel):
73
+ name: str = ""
74
+ base_url: str = ""
75
+ secret_key: str = ""
76
+
77
+
78
+ class CPAPoolUpdateRequest(BaseModel):
79
+ name: str | None = None
80
+ base_url: str | None = None
81
+ secret_key: str | None = None
82
+
83
+
84
+ class CPAImportRequest(BaseModel):
85
+ names: list[str] = Field(default_factory=list)
86
+
87
+
88
+ class Sub2APIServerCreateRequest(BaseModel):
89
+ name: str = ""
90
+ base_url: str = ""
91
+ email: str = ""
92
+ password: str = ""
93
+ api_key: str = ""
94
+ group_id: str = ""
95
+
96
+
97
+ class Sub2APIServerUpdateRequest(BaseModel):
98
+ name: str | None = None
99
+ base_url: str | None = None
100
+ email: str | None = None
101
+ password: str | None = None
102
+ api_key: str | None = None
103
+ group_id: str | None = None
104
+
105
+
106
+ class Sub2APIImportRequest(BaseModel):
107
+ account_ids: list[str] = Field(default_factory=list)
108
+
109
+
110
+ class OAuthLoginStartRequest(BaseModel):
111
+ """起始 OAuth 桥。email_hint 可选,仅用于让 OpenAI 登录页预填邮箱。"""
112
+ email_hint: str = ""
113
+
114
+
115
+ class OAuthLoginFinishRequest(BaseModel):
116
+ """提交 callback。callback 既可以是完整 URL 也可以只填 code。"""
117
+ session_id: str = ""
118
+ callback: str = ""
119
+
120
+
121
+ def _account_payload_token(item: dict[str, Any]) -> str:
122
+ return str(item.get("access_token") or item.get("accessToken") or "").strip()
123
+
124
+
125
+ def _unique_tokens(tokens: list[str]) -> list[str]:
126
+ return list(dict.fromkeys(str(token or "").strip() for token in tokens if str(token or "").strip()))
127
+
128
+
129
+ def _download_timestamp() -> str:
130
+ return datetime.now().strftime("%Y%m%d-%H%M%S")
131
+
132
+
133
+ def _safe_export_name(value: str, fallback: str) -> str:
134
+ clean = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip()).strip("-._")
135
+ return (clean or fallback)[:80]
136
+
137
+
138
+ def _account_zip_bytes(items: list[dict[str, str]]) -> bytes:
139
+ buf = io.BytesIO()
140
+ used_names: set[str] = set()
141
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as archive:
142
+ for index, item in enumerate(items, start=1):
143
+ raw_name = item.get("email") or item.get("account_id") or f"account-{index:03d}"
144
+ base_name = _safe_export_name(raw_name, f"account-{index:03d}")
145
+ name = base_name
146
+ suffix = 2
147
+ while name in used_names:
148
+ name = f"{base_name}-{suffix}"
149
+ suffix += 1
150
+ used_names.add(name)
151
+ archive.writestr(
152
+ f"{name}.json",
153
+ json.dumps(item, ensure_ascii=False, indent=2) + "\n",
154
+ )
155
+ return buf.getvalue()
156
+
157
+
158
+ def create_router() -> APIRouter:
159
+ router = APIRouter()
160
+
161
+ @router.get("/api/auth/users")
162
+ async def list_user_keys(authorization: str | None = Header(default=None)):
163
+ require_admin(authorization)
164
+ return {"items": auth_service.list_keys(role="user")}
165
+
166
+ @router.post("/api/auth/users")
167
+ async def create_user_key(body: UserKeyCreateRequest, authorization: str | None = Header(default=None)):
168
+ require_admin(authorization)
169
+ try:
170
+ item, raw_key = auth_service.create_key(role="user", name=body.name)
171
+ except ValueError as exc:
172
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
173
+ return {"item": item, "key": raw_key, "items": auth_service.list_keys(role="user")}
174
+
175
+ @router.post("/api/auth/users/{key_id}")
176
+ async def update_user_key(
177
+ key_id: str,
178
+ body: UserKeyUpdateRequest,
179
+ authorization: str | None = Header(default=None),
180
+ ):
181
+ require_admin(authorization)
182
+ updates = {
183
+ key: value
184
+ for key, value in {
185
+ "name": body.name,
186
+ "enabled": body.enabled,
187
+ "key": body.key,
188
+ }.items()
189
+ if value is not None
190
+ }
191
+ if not updates:
192
+ raise HTTPException(status_code=400, detail={"error": "还没有检测到改动,请修改后再保存"})
193
+ try:
194
+ item = auth_service.update_key(key_id, updates, role="user")
195
+ except ValueError as exc:
196
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
197
+ if item is None:
198
+ raise HTTPException(status_code=404, detail={"error": "这条用户密钥不存在,可能已经被删除"})
199
+ return {"item": item, "items": auth_service.list_keys(role="user")}
200
+
201
+ @router.delete("/api/auth/users/{key_id}")
202
+ async def delete_user_key(key_id: str, authorization: str | None = Header(default=None)):
203
+ require_admin(authorization)
204
+ if not auth_service.delete_key(key_id, role="user"):
205
+ raise HTTPException(status_code=404, detail={"error": "这条用户密钥不存在,可能已经被删除"})
206
+ return {"items": auth_service.list_keys(role="user")}
207
+
208
+ @router.get("/api/accounts")
209
+ async def get_accounts(authorization: str | None = Header(default=None)):
210
+ require_admin(authorization)
211
+ return {"items": account_service.list_accounts()}
212
+
213
+ @router.post("/api/accounts")
214
+ async def create_accounts(body: AccountCreateRequest, authorization: str | None = Header(default=None)):
215
+ require_admin(authorization)
216
+ account_payloads = [item for item in body.accounts if isinstance(item, dict)]
217
+ payload_tokens = [_account_payload_token(item) for item in account_payloads]
218
+ tokens = _unique_tokens([*body.tokens, *payload_tokens])
219
+ if not tokens:
220
+ raise HTTPException(status_code=400, detail={"error": "tokens is required"})
221
+ if account_payloads:
222
+ result = account_service.add_account_items(account_payloads)
223
+ payload_token_set = set(_unique_tokens(payload_tokens))
224
+ extra_tokens = [token for token in tokens if token not in payload_token_set]
225
+ if extra_tokens:
226
+ extra_result = account_service.add_accounts(extra_tokens)
227
+ result["added"] = int(result.get("added") or 0) + int(extra_result.get("added") or 0)
228
+ result["skipped"] = int(result.get("skipped") or 0) + int(extra_result.get("skipped") or 0)
229
+ else:
230
+ result = account_service.add_accounts(tokens)
231
+ refresh_result = account_service.refresh_accounts(tokens)
232
+ return {
233
+ **result,
234
+ "refreshed": refresh_result.get("refreshed", 0),
235
+ "errors": refresh_result.get("errors", []),
236
+ "items": refresh_result.get("items", result.get("items", [])),
237
+ }
238
+
239
+ @router.delete("/api/accounts")
240
+ async def delete_accounts(body: AccountDeleteRequest, authorization: str | None = Header(default=None)):
241
+ require_admin(authorization)
242
+ tokens = [str(token or "").strip() for token in body.tokens if str(token or "").strip()]
243
+ if not tokens:
244
+ raise HTTPException(status_code=400, detail={"error": "tokens is required"})
245
+ return account_service.delete_accounts(tokens)
246
+
247
+ @router.post("/api/accounts/refresh")
248
+ async def refresh_accounts(body: AccountRefreshRequest, authorization: str | None = Header(default=None)):
249
+ require_admin(authorization)
250
+ access_tokens = [str(token or "").strip() for token in body.access_tokens if str(token or "").strip()]
251
+ if not access_tokens:
252
+ access_tokens = account_service.list_tokens()
253
+ if not access_tokens:
254
+ raise HTTPException(status_code=400, detail={"error": "access_tokens is required"})
255
+ return account_service.refresh_accounts(access_tokens)
256
+
257
+ @router.post("/api/accounts/export")
258
+ async def export_accounts(body: AccountExportRequest, authorization: str | None = Header(default=None)):
259
+ require_admin(authorization)
260
+ access_tokens = _unique_tokens(body.access_tokens)
261
+ items = account_service.build_export_items(access_tokens)
262
+ if not items:
263
+ raise HTTPException(
264
+ status_code=400,
265
+ detail={"error": "没有可导出的完整账号,需要同时有 access_token、refresh_token 和 id_token"},
266
+ )
267
+
268
+ timestamp = _download_timestamp()
269
+ if body.format == "zip":
270
+ content = _account_zip_bytes(items)
271
+ return Response(
272
+ content,
273
+ media_type="application/zip",
274
+ headers={"Content-Disposition": f'attachment; filename="codex-accounts-{timestamp}.zip"'},
275
+ )
276
+
277
+ payload: dict[str, str] | list[dict[str, str]] = items[0] if len(items) == 1 else items
278
+ return Response(
279
+ json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
280
+ media_type="application/json",
281
+ headers={"Content-Disposition": f'attachment; filename="codex-accounts-{timestamp}.json"'},
282
+ )
283
+
284
+ @router.post("/api/accounts/update")
285
+ async def update_account(body: AccountUpdateRequest, authorization: str | None = Header(default=None)):
286
+ require_admin(authorization)
287
+ access_token = str(body.access_token or "").strip()
288
+ if not access_token:
289
+ raise HTTPException(status_code=400, detail={"error": "access_token is required"})
290
+ 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}
291
+ if not updates:
292
+ raise HTTPException(status_code=400, detail={"error": "还没有检测到改动,请修改后再保存"})
293
+ account = account_service.update_account(access_token, updates)
294
+ if account is None:
295
+ raise HTTPException(status_code=404, detail={"error": "account not found"})
296
+ return {"item": account, "items": account_service.list_accounts()}
297
+
298
+ @router.post("/api/accounts/oauth/start")
299
+ async def start_oauth_login(
300
+ body: OAuthLoginStartRequest,
301
+ authorization: str | None = Header(default=None),
302
+ ):
303
+ """登记一次 PKCE 会话,返回可让用户浏览器打开的 authorize URL。"""
304
+ require_admin(authorization)
305
+ try:
306
+ return await run_in_threadpool(oauth_login_service.start, body.email_hint)
307
+ except OAuthLoginError as exc:
308
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
309
+
310
+ @router.post("/api/accounts/oauth/finish")
311
+ async def finish_oauth_login(
312
+ body: OAuthLoginFinishRequest,
313
+ authorization: str | None = Header(default=None),
314
+ ):
315
+ """收用户从浏览器抓回的 callback URL / code,换出 token 三件套并落盘。"""
316
+ require_admin(authorization)
317
+ # 入参日志:截断敏感字段,仅保留前几位,方便排错而不泄密
318
+ cb_preview = (body.callback or "")[:80]
319
+ sid_preview = (body.session_id or "")[:8]
320
+ print(
321
+ f"[oauth-login] finish called: session_id={sid_preview}..., callback_preview={cb_preview!r}",
322
+ flush=True,
323
+ )
324
+ try:
325
+ tokens = await run_in_threadpool(oauth_login_service.finish, body.session_id, body.callback)
326
+ except OAuthLoginError as exc:
327
+ print(f"[oauth-login] finish rejected: {exc}", flush=True)
328
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
329
+
330
+ payload = {
331
+ "access_token": tokens["access_token"],
332
+ "refresh_token": tokens["refresh_token"],
333
+ "id_token": tokens["id_token"],
334
+ "source_type": "oauth_login",
335
+ }
336
+ add_result = await run_in_threadpool(account_service.add_account_items, [payload])
337
+ refresh_result = await run_in_threadpool(
338
+ account_service.refresh_accounts, [tokens["access_token"]]
339
+ )
340
+ return {
341
+ **add_result,
342
+ "refreshed": refresh_result.get("refreshed", 0),
343
+ "errors": refresh_result.get("errors", []),
344
+ "items": refresh_result.get("items", add_result.get("items", [])),
345
+ }
346
+
347
+ @router.get("/api/cpa/pools")
348
+ async def list_cpa_pools(authorization: str | None = Header(default=None)):
349
+ require_admin(authorization)
350
+ return {"pools": sanitize_cpa_pools(cpa_config.list_pools())}
351
+
352
+ @router.post("/api/cpa/pools")
353
+ async def create_cpa_pool(body: CPAPoolCreateRequest, authorization: str | None = Header(default=None)):
354
+ require_admin(authorization)
355
+ if not body.base_url.strip():
356
+ raise HTTPException(status_code=400, detail={"error": "base_url is required"})
357
+ if not body.secret_key.strip():
358
+ raise HTTPException(status_code=400, detail={"error": "secret_key is required"})
359
+ pool = cpa_config.add_pool(name=body.name, base_url=body.base_url, secret_key=body.secret_key)
360
+ return {"pool": sanitize_cpa_pool(pool), "pools": sanitize_cpa_pools(cpa_config.list_pools())}
361
+
362
+ @router.post("/api/cpa/pools/{pool_id}")
363
+ async def update_cpa_pool(pool_id: str, body: CPAPoolUpdateRequest, authorization: str | None = Header(default=None)):
364
+ require_admin(authorization)
365
+ pool = cpa_config.update_pool(pool_id, body.model_dump(exclude_none=True))
366
+ if pool is None:
367
+ raise HTTPException(status_code=404, detail={"error": "pool not found"})
368
+ return {"pool": sanitize_cpa_pool(pool), "pools": sanitize_cpa_pools(cpa_config.list_pools())}
369
+
370
+ @router.delete("/api/cpa/pools/{pool_id}")
371
+ async def delete_cpa_pool(pool_id: str, authorization: str | None = Header(default=None)):
372
+ require_admin(authorization)
373
+ if not cpa_config.delete_pool(pool_id):
374
+ raise HTTPException(status_code=404, detail={"error": "pool not found"})
375
+ return {"pools": sanitize_cpa_pools(cpa_config.list_pools())}
376
+
377
+ @router.get("/api/cpa/pools/{pool_id}/files")
378
+ async def cpa_pool_files(pool_id: str, authorization: str | None = Header(default=None)):
379
+ require_admin(authorization)
380
+ pool = cpa_config.get_pool(pool_id)
381
+ if pool is None:
382
+ raise HTTPException(status_code=404, detail={"error": "pool not found"})
383
+ return {"pool_id": pool_id, "files": await run_in_threadpool(list_remote_files, pool)}
384
+
385
+ @router.post("/api/cpa/pools/{pool_id}/import")
386
+ async def cpa_pool_import(pool_id: str, body: CPAImportRequest, authorization: str | None = Header(default=None)):
387
+ require_admin(authorization)
388
+ pool = cpa_config.get_pool(pool_id)
389
+ if pool is None:
390
+ raise HTTPException(status_code=404, detail={"error": "pool not found"})
391
+ try:
392
+ job = cpa_import_service.start_import(pool, body.names)
393
+ except ValueError as exc:
394
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
395
+ return {"import_job": job}
396
+
397
+ @router.get("/api/cpa/pools/{pool_id}/import")
398
+ async def cpa_pool_import_progress(pool_id: str, authorization: str | None = Header(default=None)):
399
+ require_admin(authorization)
400
+ pool = cpa_config.get_pool(pool_id)
401
+ if pool is None:
402
+ raise HTTPException(status_code=404, detail={"error": "pool not found"})
403
+ return {"import_job": pool.get("import_job")}
404
+
405
+ @router.get("/api/sub2api/servers")
406
+ async def list_sub2api_servers(authorization: str | None = Header(default=None)):
407
+ require_admin(authorization)
408
+ return {"servers": sanitize_sub2api_servers(sub2api_config.list_servers())}
409
+
410
+ @router.post("/api/sub2api/servers")
411
+ async def create_sub2api_server(body: Sub2APIServerCreateRequest, authorization: str | None = Header(default=None)):
412
+ require_admin(authorization)
413
+ if not body.base_url.strip():
414
+ raise HTTPException(status_code=400, detail={"error": "base_url is required"})
415
+ has_login = body.email.strip() and body.password.strip()
416
+ has_api_key = bool(body.api_key.strip())
417
+ if not has_login and not has_api_key:
418
+ raise HTTPException(status_code=400, detail={"error": "email+password or api_key is required"})
419
+ server = sub2api_config.add_server(
420
+ name=body.name,
421
+ base_url=body.base_url,
422
+ email=body.email,
423
+ password=body.password,
424
+ api_key=body.api_key,
425
+ group_id=body.group_id,
426
+ )
427
+ return {"server": sanitize_sub2api_server(server), "servers": sanitize_sub2api_servers(sub2api_config.list_servers())}
428
+
429
+ @router.post("/api/sub2api/servers/{server_id}")
430
+ async def update_sub2api_server(server_id: str, body: Sub2APIServerUpdateRequest, authorization: str | None = Header(default=None)):
431
+ require_admin(authorization)
432
+ server = sub2api_config.update_server(server_id, body.model_dump(exclude_none=True))
433
+ if server is None:
434
+ raise HTTPException(status_code=404, detail={"error": "server not found"})
435
+ return {"server": sanitize_sub2api_server(server), "servers": sanitize_sub2api_servers(sub2api_config.list_servers())}
436
+
437
+ @router.delete("/api/sub2api/servers/{server_id}")
438
+ async def delete_sub2api_server(server_id: str, authorization: str | None = Header(default=None)):
439
+ require_admin(authorization)
440
+ if not sub2api_config.delete_server(server_id):
441
+ raise HTTPException(status_code=404, detail={"error": "server not found"})
442
+ return {"servers": sanitize_sub2api_servers(sub2api_config.list_servers())}
443
+
444
+ @router.get("/api/sub2api/servers/{server_id}/groups")
445
+ async def sub2api_server_groups(server_id: str, authorization: str | None = Header(default=None)):
446
+ require_admin(authorization)
447
+ server = sub2api_config.get_server(server_id)
448
+ if server is None:
449
+ raise HTTPException(status_code=404, detail={"error": "server not found"})
450
+ try:
451
+ groups = await run_in_threadpool(sub2api_list_remote_groups, server)
452
+ except Exception as exc:
453
+ raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
454
+ return {"server_id": server_id, "groups": groups}
455
+
456
+ @router.get("/api/sub2api/servers/{server_id}/accounts")
457
+ async def sub2api_server_accounts(server_id: str, authorization: str | None = Header(default=None)):
458
+ require_admin(authorization)
459
+ server = sub2api_config.get_server(server_id)
460
+ if server is None:
461
+ raise HTTPException(status_code=404, detail={"error": "server not found"})
462
+ try:
463
+ accounts = await run_in_threadpool(sub2api_list_remote_accounts, server)
464
+ except Exception as exc:
465
+ raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
466
+ return {"server_id": server_id, "accounts": accounts}
467
+
468
+ @router.post("/api/sub2api/servers/{server_id}/import")
469
+ async def sub2api_server_import(server_id: str, body: Sub2APIImportRequest, authorization: str | None = Header(default=None)):
470
+ require_admin(authorization)
471
+ server = sub2api_config.get_server(server_id)
472
+ if server is None:
473
+ raise HTTPException(status_code=404, detail={"error": "server not found"})
474
+ try:
475
+ job = sub2api_import_service.start_import(server, body.account_ids)
476
+ except ValueError as exc:
477
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
478
+ return {"import_job": job}
479
+
480
+ @router.get("/api/sub2api/servers/{server_id}/import")
481
+ async def sub2api_server_import_progress(server_id: str, authorization: str | None = Header(default=None)):
482
+ require_admin(authorization)
483
+ server = sub2api_config.get_server(server_id)
484
+ if server is None:
485
+ raise HTTPException(status_code=404, detail={"error": "server not found"})
486
+ return {"import_job": server.get("import_job")}
487
+
488
+ return router
api/ai.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter, Header, HTTPException, Request
4
+ from fastapi.concurrency import run_in_threadpool
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+
7
+ from api.image_inputs import parse_image_edit_request, read_image_sources
8
+ from api.support import require_identity, resolve_image_base_url
9
+ from services.content_filter import check_request, request_text
10
+ from services.log_service import LoggedCall
11
+ from services.protocol import (
12
+ anthropic_v1_messages,
13
+ openai_v1_chat_complete,
14
+ openai_v1_image_edit,
15
+ openai_v1_image_generations,
16
+ openai_v1_models,
17
+ openai_v1_response,
18
+ )
19
+
20
+
21
+ class ImageGenerationRequest(BaseModel):
22
+ prompt: str = Field(..., min_length=1)
23
+ model: str = "gpt-image-2"
24
+ n: int = Field(default=1, ge=1, le=4)
25
+ size: str | None = None
26
+ quality: str = "auto"
27
+ response_format: str = "b64_json"
28
+ history_disabled: bool = True
29
+ stream: bool | None = None
30
+
31
+
32
+ class ChatCompletionRequest(BaseModel):
33
+ model_config = ConfigDict(extra="allow")
34
+ model: str | None = None
35
+ prompt: str | None = None
36
+ n: int | None = None
37
+ stream: bool | None = None
38
+ modalities: list[str] | None = None
39
+ messages: list[dict[str, object]] | None = None
40
+
41
+
42
+ class ResponseCreateRequest(BaseModel):
43
+ model_config = ConfigDict(extra="allow")
44
+ model: str | None = None
45
+ input: object | None = None
46
+ tools: list[dict[str, object]] | None = None
47
+ tool_choice: object | None = None
48
+ stream: bool | None = None
49
+
50
+
51
+ class AnthropicMessageRequest(BaseModel):
52
+ model_config = ConfigDict(extra="allow")
53
+ model: str | None = None
54
+ messages: list[dict[str, object]] | None = None
55
+ system: object | None = None
56
+ stream: bool | None = None
57
+
58
+
59
+ async def filter_or_log(call: LoggedCall, text: str) -> None:
60
+ try:
61
+ await run_in_threadpool(check_request, text)
62
+ except HTTPException as exc:
63
+ call.log("调用失败", status="failed", error=str(exc.detail))
64
+ raise
65
+
66
+
67
+ def create_router() -> APIRouter:
68
+ router = APIRouter()
69
+
70
+ @router.get("/v1/models")
71
+ async def list_models(authorization: str | None = Header(default=None)):
72
+ require_identity(authorization)
73
+ try:
74
+ return await run_in_threadpool(openai_v1_models.list_models)
75
+ except Exception as exc:
76
+ raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
77
+
78
+ @router.post("/v1/images/generations")
79
+ async def generate_images(
80
+ body: ImageGenerationRequest,
81
+ request: Request,
82
+ authorization: str | None = Header(default=None),
83
+ ):
84
+ identity = require_identity(authorization)
85
+ payload = body.model_dump(mode="python")
86
+ payload["base_url"] = resolve_image_base_url(request)
87
+ call = LoggedCall(identity, "/v1/images/generations", body.model, "文生图", request_text=body.prompt)
88
+ await filter_or_log(call, body.prompt)
89
+ return await call.run(openai_v1_image_generations.handle, payload)
90
+
91
+ @router.post("/v1/images/edits")
92
+ async def edit_images(
93
+ request: Request,
94
+ authorization: str | None = Header(default=None),
95
+ ):
96
+ identity = require_identity(authorization)
97
+ payload, image_sources = await parse_image_edit_request(request)
98
+ prompt = str(payload["prompt"])
99
+ model = str(payload["model"])
100
+ call = LoggedCall(identity, "/v1/images/edits", model, "图生图", request_text=prompt)
101
+ await filter_or_log(call, prompt)
102
+ payload["images"] = await read_image_sources(image_sources)
103
+ payload["base_url"] = resolve_image_base_url(request)
104
+ return await call.run(openai_v1_image_edit.handle, payload)
105
+
106
+ @router.post("/v1/chat/completions")
107
+ async def create_chat_completion(body: ChatCompletionRequest, authorization: str | None = Header(default=None)):
108
+ identity = require_identity(authorization)
109
+ payload = body.model_dump(mode="python")
110
+ model = str(payload.get("model") or "auto")
111
+ request_preview = request_text(payload.get("prompt"), payload.get("messages"))
112
+ call = LoggedCall(identity, "/v1/chat/completions", model, "文本生成", request_text=request_preview)
113
+ await filter_or_log(call, request_preview)
114
+ return await call.run(openai_v1_chat_complete.handle, payload)
115
+
116
+ @router.post("/v1/responses")
117
+ async def create_response(body: ResponseCreateRequest, authorization: str | None = Header(default=None)):
118
+ identity = require_identity(authorization)
119
+ payload = body.model_dump(mode="python")
120
+ model = str(payload.get("model") or "auto")
121
+ request_preview = request_text(payload.get("input"), payload.get("instructions"))
122
+ call = LoggedCall(identity, "/v1/responses", model, "Responses", request_text=request_preview)
123
+ await filter_or_log(call, request_preview)
124
+ return await call.run(openai_v1_response.handle, payload)
125
+
126
+ @router.post("/v1/messages")
127
+ async def create_message(
128
+ body: AnthropicMessageRequest,
129
+ authorization: str | None = Header(default=None),
130
+ x_api_key: str | None = Header(default=None, alias="x-api-key"),
131
+ anthropic_version: str | None = Header(default=None, alias="anthropic-version"),
132
+ ):
133
+ identity = require_identity(authorization or (f"Bearer {x_api_key}" if x_api_key else None))
134
+ payload = body.model_dump(mode="python")
135
+ model = str(payload.get("model") or "auto")
136
+ request_preview = request_text(payload.get("system"), payload.get("messages"), payload.get("tools"))
137
+ call = LoggedCall(identity, "/v1/messages", model, "Messages", request_text=request_preview)
138
+ await filter_or_log(call, request_preview)
139
+ return await call.run(anthropic_v1_messages.handle, payload, sse="anthropic")
140
+
141
+ return router
api/app.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from contextlib import asynccontextmanager
4
+ from threading import Event
5
+
6
+ from fastapi import FastAPI, HTTPException
7
+ from fastapi.middleware.cors import CORSMiddleware
8
+ from fastapi.responses import FileResponse
9
+
10
+ from api import accounts, ai, image_tasks, register, system
11
+ from api.errors import install_exception_handlers
12
+ from api.support import resolve_web_asset, start_limited_account_watcher
13
+ from services.backup_service import backup_service
14
+ from services.config import config
15
+ from services.image_service import start_image_cleanup_scheduler
16
+
17
+
18
+ def create_app() -> FastAPI:
19
+ app_version = config.app_version
20
+
21
+ @asynccontextmanager
22
+ async def lifespan(_: FastAPI):
23
+ stop_event = Event()
24
+ thread = start_limited_account_watcher(stop_event)
25
+ cleanup_thread = start_image_cleanup_scheduler(stop_event)
26
+ backup_service.start()
27
+ config.cleanup_old_images()
28
+ try:
29
+ yield
30
+ finally:
31
+ stop_event.set()
32
+ thread.join(timeout=1)
33
+ cleanup_thread.join(timeout=1)
34
+ backup_service.stop()
35
+
36
+ app = FastAPI(title="chatgpt2api", version=app_version, lifespan=lifespan)
37
+ install_exception_handlers(app)
38
+ app.add_middleware(
39
+ CORSMiddleware,
40
+ allow_origins=["*"],
41
+ allow_credentials=False,
42
+ allow_methods=["*"],
43
+ allow_headers=["*"],
44
+ )
45
+ app.include_router(ai.create_router())
46
+ app.include_router(accounts.create_router())
47
+ app.include_router(image_tasks.create_router())
48
+ app.include_router(register.create_router())
49
+ app.include_router(system.create_router(app_version))
50
+
51
+ @app.get("/{full_path:path}", include_in_schema=False)
52
+ async def serve_web(full_path: str):
53
+ asset = resolve_web_asset(full_path)
54
+ if asset is not None:
55
+ return FileResponse(asset)
56
+ if full_path.strip("/").startswith("_next/"):
57
+ raise HTTPException(status_code=404, detail="Not Found")
58
+ fallback = resolve_web_asset("")
59
+ if fallback is None:
60
+ raise HTTPException(status_code=404, detail="Not Found")
61
+ return FileResponse(fallback)
62
+
63
+ return app
api/errors.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from fastapi import FastAPI, Request
4
+ from fastapi.encoders import jsonable_encoder
5
+ from fastapi.exceptions import RequestValidationError
6
+ from fastapi.responses import JSONResponse
7
+ from starlette.exceptions import HTTPException as StarletteHTTPException
8
+
9
+ from services.protocol.error_response import anthropic_error_response, openai_error_response
10
+
11
+
12
+ def _is_openai_compatible_path(path: str) -> bool:
13
+ return path == "/v1" or path.startswith("/v1/")
14
+
15
+
16
+ def _is_anthropic_messages_path(path: str) -> bool:
17
+ return path == "/v1/messages"
18
+
19
+
20
+ def _compatible_error_response(
21
+ request: Request,
22
+ detail: object,
23
+ status_code: int,
24
+ headers: dict[str, str] | None = None,
25
+ ) -> JSONResponse:
26
+ if _is_anthropic_messages_path(request.url.path):
27
+ return anthropic_error_response(detail, status_code, headers=headers)
28
+ return openai_error_response(detail, status_code, headers=headers)
29
+
30
+
31
+ def install_exception_handlers(app: FastAPI) -> None:
32
+ @app.exception_handler(StarletteHTTPException)
33
+ async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse:
34
+ if _is_openai_compatible_path(request.url.path):
35
+ return _compatible_error_response(request, exc.detail, exc.status_code, exc.headers)
36
+ return JSONResponse(
37
+ status_code=exc.status_code,
38
+ content={"detail": jsonable_encoder(exc.detail)},
39
+ headers=exc.headers,
40
+ )
41
+
42
+ @app.exception_handler(RequestValidationError)
43
+ async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
44
+ if _is_openai_compatible_path(request.url.path):
45
+ return _compatible_error_response(request, exc.errors(), 422)
46
+ return JSONResponse(status_code=422, content={"detail": jsonable_encoder(exc.errors())})
api/image_inputs.py ADDED
@@ -0,0 +1,294 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import binascii
5
+ import json
6
+ import mimetypes
7
+ import re
8
+ from pathlib import PurePosixPath
9
+ from typing import Any, TypeGuard
10
+ from urllib.parse import unquote, unquote_to_bytes, urlparse
11
+
12
+ from curl_cffi import requests
13
+ from fastapi import HTTPException, Request
14
+ from fastapi.concurrency import run_in_threadpool
15
+ from starlette.datastructures import UploadFile
16
+
17
+ from services.proxy_service import proxy_settings
18
+
19
+ ImageInput = tuple[bytes, str, str]
20
+ ImageSource = str | UploadFile | ImageInput
21
+
22
+ MAX_IMAGE_REFERENCE_BYTES = 50 * 1024 * 1024
23
+ IMAGE_REFERENCE_FIELDS = {"image", "image[]", "images", "images[]", "image_url", "image_url[]"}
24
+
25
+
26
+ def _clean(value: object, default: str = "") -> str:
27
+ """清理字符串:转换为字符串并去掉首尾空白。"""
28
+ text = str(value if value is not None else default).strip()
29
+ return text or default
30
+
31
+
32
+ def _is_upload(value: object) -> TypeGuard[UploadFile]:
33
+ """识别上传文件:兼容 Starlette 表单返回的 UploadFile。"""
34
+ return isinstance(value, UploadFile)
35
+
36
+
37
+ def _parse_bool(value: object) -> bool | None:
38
+ """解析布尔字段:兼容 JSON 布尔值和表单字符串。"""
39
+ if value is None or value == "":
40
+ return None
41
+ if isinstance(value, bool):
42
+ return value
43
+ text = _clean(value).lower()
44
+ if text in {"true", "1", "yes", "y", "on"}:
45
+ return True
46
+ if text in {"false", "0", "no", "n", "off"}:
47
+ return False
48
+ raise HTTPException(status_code=400, detail={"error": "stream must be a boolean"})
49
+
50
+
51
+ def _parse_count(value: object) -> int:
52
+ """解析生成数量:保持图片接口的 1 到 4 限制。"""
53
+ try:
54
+ count = int(value or 1)
55
+ except (TypeError, ValueError) as exc:
56
+ raise HTTPException(status_code=400, detail={"error": "n must be an integer"}) from exc
57
+ if count < 1 or count > 4:
58
+ raise HTTPException(status_code=400, detail={"error": "n must be between 1 and 4"})
59
+ return count
60
+
61
+
62
+ def _payload_from_fields(fields: dict[str, Any]) -> dict[str, Any]:
63
+ """构造图片编辑载荷:从表单或 JSON 字段提取通用参数。"""
64
+ prompt = _clean(fields.get("prompt"))
65
+ if not prompt:
66
+ raise HTTPException(status_code=400, detail={"error": "prompt is required"})
67
+ payload = {
68
+ "prompt": prompt,
69
+ "model": _clean(fields.get("model"), "gpt-image-2"),
70
+ "n": _parse_count(fields.get("n")),
71
+ "size": _clean(fields.get("size")) or None,
72
+ "quality": _clean(fields.get("quality"), "auto"),
73
+ "response_format": _clean(fields.get("response_format"), "b64_json"),
74
+ "stream": _parse_bool(fields.get("stream")),
75
+ }
76
+ if "client_task_id" in fields:
77
+ payload["client_task_id"] = _clean(fields.get("client_task_id"))
78
+ return payload
79
+
80
+
81
+ def _json_reference_value(value: object) -> object:
82
+ """解析表单图片引用:支持把 images 字段写成 JSON 字符串。"""
83
+ if not isinstance(value, str):
84
+ return value
85
+ text = value.strip()
86
+ if not text or text[0] not in "[{":
87
+ return value
88
+ try:
89
+ return json.loads(text)
90
+ except json.JSONDecodeError:
91
+ return value
92
+
93
+
94
+ def _decode_base64_image(value: object, filename: str, mime_type: str) -> ImageInput:
95
+ try:
96
+ data = base64.b64decode(str(value).strip(), validate=True)
97
+ except (binascii.Error, ValueError) as exc:
98
+ raise HTTPException(status_code=400, detail={"error": "invalid base64 image data"}) from exc
99
+ if not data:
100
+ raise HTTPException(status_code=400, detail={"error": "image file is empty"})
101
+ if len(data) > MAX_IMAGE_REFERENCE_BYTES:
102
+ raise HTTPException(status_code=400, detail={"error": "image URL exceeds 50MB limit"})
103
+ return data, filename, mime_type
104
+
105
+
106
+ def _source_from_object(value: dict[str, Any]) -> list[ImageSource]:
107
+ """提取图片引用对象:支持 image_url 或 url,明确拒绝 file_id。"""
108
+ has_url = "image_url" in value or "url" in value
109
+ if value.get("file_id"):
110
+ raise HTTPException(
111
+ status_code=400,
112
+ detail={"error": "file_id image references are not supported; use image_url instead"},
113
+ )
114
+ inline = value.get("b64_json") or value.get("base64")
115
+ if inline:
116
+ filename = _clean(value.get("filename") or value.get("file_name"), "image.png")
117
+ mime_type = _clean(value.get("mime_type") or value.get("mimeType"), "image/png")
118
+ return [_decode_base64_image(inline, filename, mime_type)]
119
+ if not has_url:
120
+ raise HTTPException(status_code=400, detail={"error": "image reference must include image_url"})
121
+ image_url = value.get("image_url", value.get("url"))
122
+ if isinstance(image_url, dict):
123
+ image_url = image_url.get("url")
124
+ return _sources_from_value(image_url)
125
+
126
+
127
+ def _sources_from_value(value: object) -> list[ImageSource]:
128
+ """展开图片引用:把字符串、数组和对象统一成图片来源列表。"""
129
+ value = _json_reference_value(value)
130
+ if _is_upload(value):
131
+ return [value]
132
+ if isinstance(value, str):
133
+ text = value.strip()
134
+ if not text:
135
+ return []
136
+ if text.lower().startswith(("data:", "http://", "https://")):
137
+ return [text]
138
+ return [_decode_base64_image(text, "image.png", "image/png")]
139
+ if isinstance(value, list):
140
+ sources: list[ImageSource] = []
141
+ for item in value:
142
+ sources.extend(_sources_from_value(item))
143
+ return sources
144
+ if isinstance(value, dict):
145
+ return _source_from_object(value)
146
+ if value is None:
147
+ return []
148
+ raise HTTPException(status_code=400, detail={"error": "invalid image reference"})
149
+
150
+
151
+ def _json_image_sources(body: dict[str, Any]) -> list[ImageSource]:
152
+ """读取 JSON 图片引用:优先支持官方 images 数组字段。"""
153
+ sources: list[ImageSource] = []
154
+ for key in ("images", "image", "image_url"):
155
+ if key in body:
156
+ sources.extend(_sources_from_value(body.get(key)))
157
+ return sources
158
+
159
+
160
+ async def parse_image_edit_request(request: Request) -> tuple[dict[str, Any], list[ImageSource]]:
161
+ """解析图片编辑请求:同时支持 multipart 上传和官方 JSON 图片 URL。"""
162
+ content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
163
+ if content_type == "application/json":
164
+ try:
165
+ body = await request.json()
166
+ except json.JSONDecodeError as exc:
167
+ raise HTTPException(status_code=400, detail={"error": "invalid JSON body"}) from exc
168
+ if not isinstance(body, dict):
169
+ raise HTTPException(status_code=400, detail={"error": "JSON body must be an object"})
170
+ return _payload_from_fields(body), _json_image_sources(body)
171
+
172
+ form = await request.form()
173
+ fields: dict[str, Any] = {}
174
+ for key in ("client_task_id", "prompt", "model", "n", "size", "quality", "response_format", "stream"):
175
+ value = form.get(key)
176
+ if isinstance(value, str):
177
+ fields[key] = value
178
+ sources: list[ImageSource] = []
179
+ for key, value in form.multi_items():
180
+ if key in IMAGE_REFERENCE_FIELDS:
181
+ sources.extend(_sources_from_value(value))
182
+ return _payload_from_fields(fields), sources
183
+
184
+
185
+ def _extension_from_mime(mime_type: str) -> str:
186
+ """推导图片扩展名:把 MIME 类型转换为常见文件后缀。"""
187
+ subtype = mime_type.split("/", 1)[1].split("+", 1)[0] if "/" in mime_type else "png"
188
+ if subtype == "jpeg":
189
+ return "jpg"
190
+ return re.sub(r"[^a-z0-9]+", "", subtype.lower()) or "png"
191
+
192
+
193
+ def _safe_filename(name: str, mime_type: str, fallback: str) -> str:
194
+ """生成安全文件名:清理 URL 文件名并补齐扩展名。"""
195
+ cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", name).strip("._")
196
+ if not cleaned:
197
+ cleaned = fallback
198
+ if "." not in cleaned:
199
+ cleaned = f"{cleaned}.{_extension_from_mime(mime_type)}"
200
+ return cleaned
201
+
202
+
203
+ def _decode_data_url(url: str) -> ImageInput:
204
+ """解码 data URL:把内联图片转成标准图片输入元组。"""
205
+ header, separator, payload = url.partition(",")
206
+ if not separator:
207
+ raise HTTPException(status_code=400, detail={"error": "invalid data image URL"})
208
+ mime_type = header.split(";", 1)[0].removeprefix("data:") or "image/png"
209
+ if not mime_type.startswith("image/"):
210
+ raise HTTPException(status_code=400, detail={"error": "image_url must point to an image"})
211
+ try:
212
+ data = base64.b64decode(payload, validate=True) if ";base64" in header else unquote_to_bytes(payload)
213
+ except (binascii.Error, ValueError) as exc:
214
+ raise HTTPException(status_code=400, detail={"error": "invalid data image URL"}) from exc
215
+ if not data:
216
+ raise HTTPException(status_code=400, detail={"error": "image URL is empty"})
217
+ if len(data) > MAX_IMAGE_REFERENCE_BYTES:
218
+ raise HTTPException(status_code=400, detail={"error": "image URL exceeds 50MB limit"})
219
+ return data, f"image_url.{_extension_from_mime(mime_type)}", mime_type
220
+
221
+
222
+ def _response_mime_type(response: requests.Response, parsed_path: str) -> str:
223
+ """识别下载图片类型:优先响应头,必要时按 URL 后缀推断。"""
224
+ header_type = str(response.headers.get("content-type") or "").split(";", 1)[0].strip().lower()
225
+ guessed_type = mimetypes.guess_type(parsed_path)[0] or ""
226
+ if header_type.startswith("image/"):
227
+ return header_type
228
+ if header_type and header_type not in {"application/octet-stream", "binary/octet-stream"}:
229
+ raise HTTPException(status_code=400, detail={"error": "image_url must point to an image"})
230
+ if guessed_type.startswith("image/"):
231
+ return guessed_type
232
+ if not header_type or header_type in {"application/octet-stream", "binary/octet-stream"}:
233
+ return "image/png"
234
+ raise HTTPException(status_code=400, detail={"error": "image_url must point to an image"})
235
+
236
+
237
+ def _filename_from_url(parsed_path: str, mime_type: str) -> str:
238
+ """生成 URL 图片文件名:从链接路径提取名称并做安全化。"""
239
+ raw_name = PurePosixPath(unquote(parsed_path)).name
240
+ return _safe_filename(raw_name, mime_type, "image_url")
241
+
242
+
243
+ def _download_image_url(url: str) -> ImageInput:
244
+ """下载远程图片:把 http/https 图片链接转成标准图片输入元组。"""
245
+ source = _clean(url)
246
+ if source.startswith("data:"):
247
+ return _decode_data_url(source)
248
+ parsed = urlparse(source)
249
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
250
+ raise HTTPException(status_code=400, detail={"error": "image_url must be an http or https URL"})
251
+ try:
252
+ response = requests.get(
253
+ source,
254
+ headers={"Accept": "image/*,*/*;q=0.8", "User-Agent": "chatgpt2api image fetcher"},
255
+ timeout=60,
256
+ allow_redirects=True,
257
+ **proxy_settings.build_session_kwargs(),
258
+ )
259
+ except Exception as exc:
260
+ raise HTTPException(status_code=400, detail={"error": f"image_url fetch failed: {exc}"}) from exc
261
+ if not 200 <= response.status_code < 300:
262
+ raise HTTPException(status_code=400, detail={"error": f"image_url fetch failed: HTTP {response.status_code}"})
263
+ content_length = _clean(response.headers.get("content-length"))
264
+ if content_length and content_length.isdigit() and int(content_length) > MAX_IMAGE_REFERENCE_BYTES:
265
+ raise HTTPException(status_code=400, detail={"error": "image_url exceeds 50MB limit"})
266
+ data = response.content
267
+ if not data:
268
+ raise HTTPException(status_code=400, detail={"error": "image_url returned empty content"})
269
+ if len(data) > MAX_IMAGE_REFERENCE_BYTES:
270
+ raise HTTPException(status_code=400, detail={"error": "image_url exceeds 50MB limit"})
271
+ mime_type = _response_mime_type(response, parsed.path)
272
+ return data, _filename_from_url(parsed.path, mime_type), mime_type
273
+
274
+
275
+ async def read_image_sources(sources: list[ImageSource]) -> list[ImageInput]:
276
+ """读取图片来源:上传文件直接读取,URL 下载后统一返回图片元组。"""
277
+ images: list[ImageInput] = []
278
+ for source in sources:
279
+ if isinstance(source, tuple):
280
+ images.append(source)
281
+ continue
282
+ if _is_upload(source):
283
+ try:
284
+ image_data = await source.read()
285
+ finally:
286
+ await source.close()
287
+ if not image_data:
288
+ raise HTTPException(status_code=400, detail={"error": "image file is empty"})
289
+ images.append((image_data, source.filename or "image.png", source.content_type or "image/png"))
290
+ continue
291
+ images.append(await run_in_threadpool(_download_image_url, source))
292
+ if not images:
293
+ raise HTTPException(status_code=400, detail={"error": "image file or image_url is required"})
294
+ return images
api/image_tasks.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter, Header, HTTPException, Query, Request
4
+ from fastapi.concurrency import run_in_threadpool
5
+ from pydantic import BaseModel, Field
6
+
7
+ from api.image_inputs import parse_image_edit_request, read_image_sources
8
+ from api.support import require_identity, resolve_image_base_url
9
+ from services.content_filter import check_request
10
+ from services.image_task_service import image_task_service
11
+ from services.log_service import LoggedCall
12
+
13
+
14
+ class ImageGenerationTaskRequest(BaseModel):
15
+ client_task_id: str = Field(..., min_length=1)
16
+ prompt: str = Field(..., min_length=1)
17
+ model: str = "gpt-image-2"
18
+ size: str | None = None
19
+ quality: str = "auto"
20
+
21
+
22
+ def _parse_task_ids(value: str) -> list[str]:
23
+ return [item.strip() for item in value.split(",") if item.strip()]
24
+
25
+
26
+ async def filter_or_log(call: LoggedCall, text: str) -> None:
27
+ try:
28
+ await run_in_threadpool(check_request, text)
29
+ except HTTPException as exc:
30
+ call.log("调用失败", status="failed", error=str(exc.detail))
31
+ raise
32
+
33
+
34
+ def create_router() -> APIRouter:
35
+ router = APIRouter()
36
+
37
+ @router.get("/api/image-tasks")
38
+ async def list_image_tasks(
39
+ ids: str = Query(default=""),
40
+ authorization: str | None = Header(default=None),
41
+ ):
42
+ identity = require_identity(authorization)
43
+ return await run_in_threadpool(image_task_service.list_tasks, identity, _parse_task_ids(ids))
44
+
45
+ @router.post("/api/image-tasks/generations")
46
+ async def create_generation_task(
47
+ body: ImageGenerationTaskRequest,
48
+ request: Request,
49
+ authorization: str | None = Header(default=None),
50
+ ):
51
+ identity = require_identity(authorization)
52
+ await filter_or_log(LoggedCall(identity, "/api/image-tasks/generations", body.model, "文生图任务", request_text=body.prompt), body.prompt)
53
+ try:
54
+ return await run_in_threadpool(
55
+ image_task_service.submit_generation,
56
+ identity,
57
+ client_task_id=body.client_task_id,
58
+ prompt=body.prompt,
59
+ model=body.model,
60
+ size=body.size,
61
+ quality=body.quality,
62
+ base_url=resolve_image_base_url(request),
63
+ )
64
+ except ValueError as exc:
65
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
66
+
67
+ @router.post("/api/image-tasks/edits")
68
+ async def create_edit_task(
69
+ request: Request,
70
+ authorization: str | None = Header(default=None),
71
+ ):
72
+ identity = require_identity(authorization)
73
+ payload, image_sources = await parse_image_edit_request(request)
74
+ client_task_id = str(payload.get("client_task_id") or "").strip()
75
+ if not client_task_id:
76
+ raise HTTPException(status_code=400, detail={"error": "client_task_id is required"})
77
+ prompt = str(payload["prompt"])
78
+ model = str(payload["model"])
79
+ await filter_or_log(LoggedCall(identity, "/api/image-tasks/edits", model, "图生图任务", request_text=prompt), prompt)
80
+ images = await read_image_sources(image_sources)
81
+ try:
82
+ return await run_in_threadpool(
83
+ image_task_service.submit_edit,
84
+ identity,
85
+ client_task_id=client_task_id,
86
+ prompt=prompt,
87
+ model=model,
88
+ size=payload["size"],
89
+ quality=payload["quality"],
90
+ base_url=resolve_image_base_url(request),
91
+ images=images,
92
+ )
93
+ except ValueError as exc:
94
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
95
+
96
+ return router
api/register.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+
6
+ from fastapi import APIRouter, Header
7
+ from fastapi.responses import StreamingResponse
8
+ from pydantic import BaseModel
9
+
10
+ from api.support import require_admin
11
+ from services.register_service import register_service
12
+
13
+
14
+ class RegisterConfigRequest(BaseModel):
15
+ mail: dict | None = None
16
+ proxy: str | None = None
17
+ total: int | None = None
18
+ threads: int | None = None
19
+ mode: str | None = None
20
+ target_quota: int | None = None
21
+ target_available: int | None = None
22
+ check_interval: int | None = None
23
+
24
+
25
+ def create_router() -> APIRouter:
26
+ router = APIRouter()
27
+
28
+ @router.get("/api/register")
29
+ async def get_register_config(authorization: str | None = Header(default=None)):
30
+ require_admin(authorization)
31
+ return {"register": register_service.get()}
32
+
33
+ @router.post("/api/register")
34
+ async def update_register_config(body: RegisterConfigRequest, authorization: str | None = Header(default=None)):
35
+ require_admin(authorization)
36
+ return {"register": register_service.update(body.model_dump(exclude_none=True))}
37
+
38
+ @router.post("/api/register/start")
39
+ async def start_register(authorization: str | None = Header(default=None)):
40
+ require_admin(authorization)
41
+ return {"register": register_service.start()}
42
+
43
+ @router.post("/api/register/stop")
44
+ async def stop_register(authorization: str | None = Header(default=None)):
45
+ require_admin(authorization)
46
+ return {"register": register_service.stop()}
47
+
48
+ @router.post("/api/register/reset")
49
+ async def reset_register(authorization: str | None = Header(default=None)):
50
+ require_admin(authorization)
51
+ return {"register": register_service.reset()}
52
+
53
+ @router.get("/api/register/events")
54
+ async def register_events(token: str = ""):
55
+ require_admin(f"Bearer {token}")
56
+
57
+ async def stream():
58
+ last = ""
59
+ while True:
60
+ payload = json.dumps(register_service.get(), ensure_ascii=False)
61
+ if payload != last:
62
+ last = payload
63
+ yield f"data: {payload}\n\n"
64
+ await asyncio.sleep(0.5)
65
+
66
+ return StreamingResponse(stream(), media_type="text/event-stream")
67
+
68
+ return router
api/support.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from threading import Event, Thread
5
+
6
+ from fastapi import HTTPException, Request
7
+
8
+ from services.account_service import account_service
9
+ from services.auth_service import auth_service
10
+ from services.config import config
11
+
12
+ BASE_DIR = Path(__file__).resolve().parents[1]
13
+ WEB_DIST_DIR = BASE_DIR / "web_dist"
14
+
15
+
16
+ def extract_bearer_token(authorization: str | None) -> str:
17
+ scheme, _, value = str(authorization or "").partition(" ")
18
+ if scheme.lower() != "bearer" or not value.strip():
19
+ return ""
20
+ return value.strip()
21
+
22
+
23
+ def _legacy_admin_identity(token: str) -> dict[str, object] | None:
24
+ auth_key = str(config.auth_key or "").strip()
25
+ if auth_key and token == auth_key:
26
+ return {"id": "admin", "name": "管理员", "role": "admin"}
27
+ return None
28
+
29
+
30
+ def require_identity(authorization: str | None) -> dict[str, object]:
31
+ token = extract_bearer_token(authorization)
32
+ identity = _legacy_admin_identity(token) or auth_service.authenticate(token)
33
+ if identity is None:
34
+ raise HTTPException(status_code=401, detail={"error": "密钥无效或已失效,请重新登录"})
35
+ return identity
36
+
37
+
38
+ def require_auth_key(authorization: str | None) -> None:
39
+ require_identity(authorization)
40
+
41
+
42
+ def require_admin(authorization: str | None) -> dict[str, object]:
43
+ identity = require_identity(authorization)
44
+ if identity.get("role") != "admin":
45
+ raise HTTPException(status_code=403, detail={"error": "需要管理员权限才能执行这个操作"})
46
+ return identity
47
+
48
+
49
+ def resolve_image_base_url(request: Request) -> str:
50
+ return config.base_url or f"{request.url.scheme}://{request.headers.get('host', request.url.netloc)}"
51
+
52
+
53
+ def raise_image_quota_error(exc: Exception) -> None:
54
+ message = str(exc)
55
+ if "no available image quota" in message.lower():
56
+ raise HTTPException(status_code=429, detail={"error": "no available image quota"}) from exc
57
+ raise HTTPException(status_code=502, detail={"error": message}) from exc
58
+
59
+
60
+ def sanitize_cpa_pool(pool: dict | None) -> dict | None:
61
+ if not isinstance(pool, dict):
62
+ return None
63
+ return {key: value for key, value in pool.items() if key != "secret_key"}
64
+
65
+
66
+ def sanitize_cpa_pools(pools: list[dict]) -> list[dict]:
67
+ return [sanitized for pool in pools if (sanitized := sanitize_cpa_pool(pool)) is not None]
68
+
69
+
70
+ def sanitize_sub2api_server(server: dict | None) -> dict | None:
71
+ if not isinstance(server, dict):
72
+ return None
73
+ sanitized = {key: value for key, value in server.items() if key not in {"password", "api_key"}}
74
+ sanitized["has_api_key"] = bool(str(server.get("api_key") or "").strip())
75
+ return sanitized
76
+
77
+
78
+ def sanitize_sub2api_servers(servers: list[dict]) -> list[dict]:
79
+ return [sanitized for server in servers if (sanitized := sanitize_sub2api_server(server)) is not None]
80
+
81
+
82
+ def start_limited_account_watcher(stop_event: Event) -> Thread:
83
+ interval_seconds = config.refresh_account_interval_minute * 60
84
+
85
+ def worker() -> None:
86
+ while not stop_event.is_set():
87
+ try:
88
+ limited_tokens = account_service.list_limited_tokens()
89
+ expiring_tokens = account_service.list_expiring_access_tokens()
90
+ keepalive_tokens = account_service.list_refresh_token_keepalive_tokens()
91
+ tokens = list(dict.fromkeys([*limited_tokens, *expiring_tokens]))
92
+ expiring_token_set = set(expiring_tokens)
93
+ keepalive_tokens = [token for token in keepalive_tokens if token not in expiring_token_set]
94
+ if tokens:
95
+ print(
96
+ "[account-watcher] checking "
97
+ f"{len(limited_tokens)} limited accounts, "
98
+ f"{len(expiring_tokens)} expiring access tokens"
99
+ )
100
+ account_service.refresh_accounts(tokens)
101
+ if keepalive_tokens:
102
+ print(f"[account-watcher] keepalive {len(keepalive_tokens)} refresh tokens")
103
+ result = account_service.keepalive_refresh_tokens(keepalive_tokens)
104
+ if result.get("errors"):
105
+ print(f"[account-watcher] keepalive errors: {result['errors']}")
106
+ except Exception as exc:
107
+ print(f"[account-watcher] fail {exc}")
108
+ stop_event.wait(interval_seconds)
109
+
110
+ thread = Thread(target=worker, name="account-watcher", daemon=True)
111
+ thread.start()
112
+ return thread
113
+
114
+
115
+ def resolve_web_asset(requested_path: str) -> Path | None:
116
+ if not WEB_DIST_DIR.exists():
117
+ return None
118
+ clean_path = requested_path.strip("/")
119
+ base_dir = WEB_DIST_DIR.resolve()
120
+ candidates = [base_dir / "index.html"] if not clean_path else [
121
+ base_dir / Path(clean_path),
122
+ base_dir / clean_path / "index.html",
123
+ base_dir / f"{clean_path}.html",
124
+ ]
125
+ for candidate in candidates:
126
+ try:
127
+ candidate.resolve().relative_to(base_dir)
128
+ except ValueError:
129
+ continue
130
+ if candidate.is_file():
131
+ return candidate
132
+ return None
api/system.py ADDED
@@ -0,0 +1,334 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from urllib.parse import quote
4
+
5
+ from fastapi import APIRouter, Header, HTTPException, Query, Request
6
+ from fastapi.concurrency import run_in_threadpool
7
+ from fastapi.responses import HTMLResponse, Response, StreamingResponse
8
+ from pydantic import BaseModel, ConfigDict
9
+
10
+ from api.support import require_admin, require_identity, resolve_image_base_url
11
+ from services.backup_service import BackupError, backup_service
12
+ from services.config import config
13
+ from services.image_service import (
14
+ compress_images,
15
+ delete_images,
16
+ delete_to_target,
17
+ download_images_zip,
18
+ get_image_download_response,
19
+ get_image_response,
20
+ get_thumbnail_response,
21
+ list_images,
22
+ storage_stats,
23
+ )
24
+ from services.image_storage_service import ImageStorageError, image_storage_service
25
+ from services.image_tags_service import delete_tag, get_all_tags, set_tags
26
+ from services.log_service import log_service
27
+ from services.proxy_service import test_proxy
28
+
29
+
30
+ class SettingsUpdateRequest(BaseModel):
31
+ model_config = ConfigDict(extra="allow")
32
+
33
+
34
+ class ProxyTestRequest(BaseModel):
35
+ url: str = ""
36
+
37
+
38
+ class ImageDeleteRequest(BaseModel):
39
+ paths: list[str] = []
40
+ start_date: str = ""
41
+ end_date: str = ""
42
+ all_matching: bool = False
43
+
44
+ class ImageDownloadRequest(BaseModel):
45
+ paths: list[str]
46
+
47
+ class ImageTagsRequest(BaseModel):
48
+ path: str
49
+ tags: list[str]
50
+
51
+ class LogDeleteRequest(BaseModel):
52
+ ids: list[str] = []
53
+ class BackupDeleteRequest(BaseModel):
54
+ key: str = ""
55
+
56
+
57
+ def create_router(app_version: str) -> APIRouter:
58
+ router = APIRouter()
59
+
60
+ @router.post("/auth/login")
61
+ async def login(authorization: str | None = Header(default=None)):
62
+ identity = require_identity(authorization)
63
+ return {
64
+ "ok": True,
65
+ "version": app_version,
66
+ "role": identity.get("role"),
67
+ "subject_id": identity.get("id"),
68
+ "name": identity.get("name"),
69
+ }
70
+
71
+ @router.get("/version")
72
+ async def get_version():
73
+ return {"version": app_version}
74
+
75
+ @router.get("/api/settings")
76
+ async def get_settings(authorization: str | None = Header(default=None)):
77
+ require_admin(authorization)
78
+ return {"config": config.get()}
79
+
80
+ @router.post("/api/settings")
81
+ async def save_settings(body: SettingsUpdateRequest, authorization: str | None = Header(default=None)):
82
+ require_admin(authorization)
83
+ try:
84
+ return {"config": config.update(body.model_dump(mode="python"))}
85
+ except ValueError as exc:
86
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
87
+
88
+ @router.get("/api/images")
89
+ async def get_images(request: Request, start_date: str = "", end_date: str = "", authorization: str | None = Header(default=None)):
90
+ require_admin(authorization)
91
+ return list_images(resolve_image_base_url(request), start_date=start_date.strip(), end_date=end_date.strip())
92
+
93
+ @router.get("/images/{image_path:path}", include_in_schema=False)
94
+ async def get_image(image_path: str):
95
+ return get_image_response(image_path)
96
+
97
+ @router.get("/image-thumbnails/{image_path:path}", include_in_schema=False)
98
+ async def get_image_thumbnail(image_path: str):
99
+ return get_thumbnail_response(image_path)
100
+
101
+ @router.post("/api/images/delete")
102
+ async def delete_images_endpoint(body: ImageDeleteRequest, authorization: str | None = Header(default=None)):
103
+ require_admin(authorization)
104
+ return delete_images(body.paths, start_date=body.start_date.strip(), end_date=body.end_date.strip(), all_matching=body.all_matching)
105
+
106
+ @router.post("/api/images/download")
107
+ async def download_images_endpoint(body: ImageDownloadRequest, authorization: str | None = Header(default=None)):
108
+ require_admin(authorization)
109
+ buf = download_images_zip(body.paths)
110
+ return StreamingResponse(
111
+ buf,
112
+ media_type="application/zip",
113
+ headers={"Content-Disposition": 'attachment; filename="images.zip"'},
114
+ )
115
+
116
+ @router.get("/api/images/download/{image_path:path}")
117
+ async def download_single_image_endpoint(image_path: str, authorization: str | None = Header(default=None)):
118
+ require_admin(authorization)
119
+ return get_image_download_response(image_path)
120
+
121
+ @router.get("/api/logs")
122
+ async def get_logs(type: str = "", start_date: str = "", end_date: str = "", authorization: str | None = Header(default=None)):
123
+ require_admin(authorization)
124
+ return {"items": log_service.list(type=type.strip(), start_date=start_date.strip(), end_date=end_date.strip())}
125
+
126
+ @router.post("/api/logs/delete")
127
+ async def delete_logs(body: LogDeleteRequest, authorization: str | None = Header(default=None)):
128
+ require_admin(authorization)
129
+ return log_service.delete(body.ids)
130
+
131
+ @router.post("/api/proxy/test")
132
+ async def test_proxy_endpoint(body: ProxyTestRequest, authorization: str | None = Header(default=None)):
133
+ require_admin(authorization)
134
+ candidate = (body.url or "").strip() or config.get_proxy_settings()
135
+ if not candidate:
136
+ raise HTTPException(status_code=400, detail={"error": "proxy url is required"})
137
+ return {"result": await run_in_threadpool(test_proxy, candidate)}
138
+
139
+ @router.get("/api/storage/info")
140
+ async def get_storage_info(authorization: str | None = Header(default=None)):
141
+ require_admin(authorization)
142
+ storage = config.get_storage_backend()
143
+ return {
144
+ "backend": storage.get_backend_info(),
145
+ "health": storage.health_check(),
146
+ }
147
+
148
+ @router.post("/api/backup/test")
149
+ async def test_backup_connection(authorization: str | None = Header(default=None)):
150
+ require_admin(authorization)
151
+ try:
152
+ return {"result": await run_in_threadpool(backup_service.test_connection)}
153
+ except BackupError as exc:
154
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
155
+
156
+ @router.post("/api/image-storage/test")
157
+ async def test_image_storage_endpoint(authorization: str | None = Header(default=None)):
158
+ require_admin(authorization)
159
+ return {"result": await run_in_threadpool(image_storage_service.test_webdav)}
160
+
161
+ @router.post("/api/image-storage/sync")
162
+ async def sync_image_storage_endpoint(authorization: str | None = Header(default=None)):
163
+ require_admin(authorization)
164
+ try:
165
+ return {"result": await run_in_threadpool(image_storage_service.sync_all)}
166
+ except ImageStorageError as exc:
167
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
168
+
169
+ @router.get("/api/backups")
170
+ async def get_backups(authorization: str | None = Header(default=None)):
171
+ require_admin(authorization)
172
+ try:
173
+ return {
174
+ "items": await run_in_threadpool(backup_service.list_backups),
175
+ "state": backup_service.get_status(),
176
+ "settings": backup_service.get_settings(),
177
+ }
178
+ except BackupError as exc:
179
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
180
+
181
+ @router.post("/api/backups/run")
182
+ async def run_backup_endpoint(authorization: str | None = Header(default=None)):
183
+ require_admin(authorization)
184
+ try:
185
+ return {"result": await run_in_threadpool(backup_service.run_backup)}
186
+ except BackupError as exc:
187
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
188
+
189
+ @router.post("/api/backups/delete")
190
+ async def delete_backup_endpoint(body: BackupDeleteRequest, authorization: str | None = Header(default=None)):
191
+ require_admin(authorization)
192
+ try:
193
+ await run_in_threadpool(backup_service.delete_backup, body.key)
194
+ return {"ok": True}
195
+ except BackupError as exc:
196
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
197
+
198
+ @router.get("/api/backups/detail")
199
+ async def get_backup_detail(key: str = "", authorization: str | None = Header(default=None)):
200
+ require_admin(authorization)
201
+ try:
202
+ return {"item": await run_in_threadpool(backup_service.get_backup_detail, key)}
203
+ except BackupError as exc:
204
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
205
+
206
+ @router.get("/api/backups/download")
207
+ async def download_backup_endpoint(key: str = "", authorization: str | None = Header(default=None)):
208
+ require_admin(authorization)
209
+ try:
210
+ item = await run_in_threadpool(backup_service.download_backup, key)
211
+ except BackupError as exc:
212
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
213
+ filename = str(item.get("name") or "backup.bin")
214
+ quoted = quote(filename)
215
+ headers = {
216
+ "Content-Disposition": f"attachment; filename*=UTF-8''{quoted}",
217
+ "Content-Length": str(int(item.get("size") or 0)),
218
+ }
219
+ return Response(
220
+ content=bytes(item.get("payload") or b""),
221
+ media_type=str(item.get("content_type") or "application/octet-stream"),
222
+ headers=headers,
223
+ )
224
+
225
+
226
+ @router.get("/api/images/tags")
227
+ async def list_image_tags(authorization: str | None = Header(default=None)):
228
+ require_admin(authorization)
229
+ return {"tags": get_all_tags()}
230
+
231
+ @router.post("/api/images/tags")
232
+ async def update_image_tags(body: ImageTagsRequest, authorization: str | None = Header(default=None)):
233
+ require_admin(authorization)
234
+ rel = body.path.strip().lstrip("/")
235
+ if not rel:
236
+ raise HTTPException(status_code=400, detail={"error": "path is required"})
237
+ tags = set_tags(rel, body.tags)
238
+ return {"ok": True, "tags": tags}
239
+
240
+ @router.delete("/api/images/tags/{tag}")
241
+ async def delete_image_tag(tag: str, authorization: str | None = Header(default=None)):
242
+ require_admin(authorization)
243
+ count = delete_tag(tag)
244
+ return {"ok": True, "removed_from": count}
245
+
246
+ @router.get("/api/images/storage")
247
+ async def get_image_storage(authorization: str | None = Header(default=None)):
248
+ require_admin(authorization)
249
+ return storage_stats()
250
+
251
+ @router.post("/api/images/storage/compress")
252
+ async def compress_all_images(authorization: str | None = Header(default=None)):
253
+ require_admin(authorization)
254
+ return await run_in_threadpool(compress_images)
255
+
256
+ @router.post("/api/images/storage/cleanup-to-target")
257
+ async def cleanup_to_target(
258
+ target_free_mb: int = 500,
259
+ dry_run: bool = False,
260
+ authorization: str | None = Header(default=None),
261
+ ):
262
+ require_admin(authorization)
263
+ return await run_in_threadpool(delete_to_target, target_free_mb, dry_run)
264
+
265
+ @router.get("/health", response_model=None)
266
+ async def health_dashboard(format: str = Query(default="html")):
267
+ from services.account_service import account_service as acct_svc
268
+ stats = acct_svc.get_stats()
269
+ storage = config.get_storage_backend()
270
+ storage_health = storage.health_check()
271
+ healthy = stats["active"] > 0 or stats["unlimited_quota_count"] > 0
272
+
273
+ stats_json = {
274
+ "status": "ok" if healthy else "degraded",
275
+ "healthy": healthy,
276
+ "version": app_version,
277
+ "storage": {"backend": storage.get_backend_info(), "health": storage_health},
278
+ "accounts": stats,
279
+ }
280
+ if format == "json":
281
+ return stats_json
282
+ return HTMLResponse(f"""<!DOCTYPE html>
283
+ <html lang="zh">
284
+ <head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
285
+ <title>号池健康监控 - chatgpt2api</title>
286
+ <style>
287
+ *{{margin:0;padding:0;box-sizing:border-box}}
288
+ body{{font-family:system-ui,-apple-system,sans-serif;background:#0f1117;color:#e2e8f0;min-height:100vh}}
289
+ .header{{background:#1a1d27;border-bottom:1px solid #2a2d3a;padding:16px 24px;display:flex;justify-content:space-between;align-items:center}}
290
+ .header h1{{font-size:20px}}
291
+ .status-dot{{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:8px}}
292
+ .status-ok{{background:#22c55e;box-shadow:0 0 8px #22c55e88}}
293
+ .status-degraded{{background:#f59e0b;box-shadow:0 0 8px #f59e0b88}}
294
+ .container{{max-width:960px;margin:0 auto;padding:24px}}
295
+ .cards{{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:24px}}
296
+ .card{{background:#1a1d27;border:1px solid #2a2d3a;border-radius:10px;padding:16px}}
297
+ .card .value{{font-size:28px;font-weight:700;margin:4px 0}}
298
+ .card .label{{font-size:13px;color:#94a3b8}}
299
+ .green{{color:#22c55e}}.yellow{{color:#f59e0b}}.red{{color:#ef4444}}.blue{{color:#6c63ff}}
300
+ table{{width:100%;border-collapse:collapse;background:#1a1d27;border:1px solid #2a2d3a;border-radius:10px;overflow:hidden}}
301
+ th{{background:#242836;font-weight:600;text-align:left;padding:10px 12px;font-size:12px;color:#94a3b8;text-transform:uppercase}}
302
+ td{{padding:8px 12px;border-top:1px solid #2a2d3a;font-size:14px}}tr:hover td{{background:rgba(108,99,255,.05)}}
303
+ .api-url{{font-family:monospace;font-size:12px;color:#6c63ff}}
304
+ .refresh{{font-size:12px;color:#64748b;text-align:center;margin-top:24px}}
305
+ </style>
306
+ <meta http-equiv="refresh" content="30">
307
+ </head>
308
+ <body>
309
+ <div class="header">
310
+ <h1><span class="status-dot {'status-ok' if healthy else 'status-degraded'}"></span>号池健康监控</h1>
311
+ <div style="font-size:13px;color:#94a3b8">v{app_version} · 30s 自动刷新</div>
312
+ </div>
313
+ <div class="container">
314
+ <div class="cards">
315
+ <div class="card"><div class="label">号池状态</div><div class="value {'green' if healthy else 'yellow'}">{'正常' if healthy else '异常'}</div></div>
316
+ <div class="card"><div class="label">当前账号</div><div class="value blue">{stats['total']}</div></div>
317
+ <div class="card"><div class="label">累计入库</div><div class="value">{stats['cumulative_total']}</div></div>
318
+ <div class="card"><div class="label">可用账号</div><div class="value green">{stats['active']}</div></div>
319
+ <div class="card"><div class="label">无限额</div><div class="value">{stats['unlimited_quota_count']}</div></div>
320
+ <div class="card"><div class="label">剩余额度</div><div class="value">{stats['total_quota']}</div></div>
321
+ <div class="card"><div class="label">限流</div><div class="value yellow">{stats['limited']}</div></div>
322
+ <div class="card"><div class="label">异常</div><div class="value red">{stats['abnormal']}</div></div>
323
+ <div class="card"><div class="label">禁用</div><div class="value">{stats['disabled']}</div></div>
324
+ <div class="card"><div class="label">成功/失败</div><div class="value">{stats['total_success']}<span style="font-size:18px;color:#94a3b8">/</span><span class="red">{stats['total_fail']}</span></div></div>
325
+ </div>
326
+ <h2 style="margin-bottom:12px;font-size:16px">账号类型分布</h2>
327
+ <table>
328
+ <tr><th>类型</th><th>数量</th></tr>
329
+ {''.join(f'<tr><td>{t}</td><td>{c}</td></tr>' for t,c in sorted(stats['by_type'].items()))}
330
+ </table>
331
+ <div class="refresh">JSON: <span class="api-url">/health?format=json</span></div>
332
+ </div></body></html>""")
333
+
334
+ return router
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,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ | 前端图片工作台 | ✅ | 已支持图片生成、图片编辑、模型选择、历史记录与查看大图。 |
14
+ | 前端图片输入 / 参考图交互 | ✅ | 已支持参考图上传、预览、移除和编辑模式工作流。 |
15
+ | Codex 画图接口逆向 | ✅ | 已支持,仅 `Plus` / `Team` / `Pro` 订阅可用,模型别名为 `codex-gpt-image-2`;如有需要可自行在其他场景映射回 `gpt-image-2`。这是 Codex 逆向链路,用于和官网画图区分,同一账号通常会同时支持官网和 Codex 两份生图额度。 |
16
+ | Cherry Studio 接入 | ✅ | 已支持作为绘图接口接入 Cherry Studio。 |
17
+ | New API 接入 | ✅ | 已支持接入 New API。 |
18
+ | 账号池管理 | ✅ | 已支持列表、筛选、批量操作、导出、手动编辑、刷新和删除。 |
19
+ | 账号额度刷新与恢复时间同步 | ✅ | 已支持账号信息刷新,限流账号也会自动继续检查。 |
20
+ | 失效 Token 自动清理 | ✅ | 已支持自动移除失效 Token。 |
21
+ | CPA 连接管理 | ✅ | 已支持 CPA 连接的新增、修改、查询和删除。 |
22
+ | CPA 文件浏览与按需导入 | ✅ | 已支持读取远程文件列表、筛选、勾选并导入到本地号池。 |
23
+ | CPA 导入进度跟踪 | ✅ | 已支持导入进度展示与轮询更新。 |
24
+ | `sub2api` 连接管理与账号浏览 | ✅ | 已支持 `sub2api` 服务器的新增、修改、删除、分组查询和 OpenAI OAuth 账号列表读取。 |
25
+ | `sub2api` 导入 | ✅ | 已支持勾选 `sub2api` 中的 OpenAI OAuth 账号,批量拉取 `access_token` 导入本地号池,并展示导入进度。 |
26
+ | Docker 自托管部署 | ✅ | 已支持 Docker Compose 部署,并提供多架构镜像。 |
27
+ | 兼容接口中的多参考图能力 | ✅ | 已实现,支持在兼容接口中传入多参考图。 |
28
+ | 更高级的 Token 调度策略 | ⚠️ | 当前已有基础轮询与限流刷新机制,更复杂的调度策略仍在完善中。 |
29
+ | Render / Vercel 等部署表述 | ⚠️ | 当前主要以 Docker 部署为主,其他平台部署方式暂未重点说明。 |
30
+ | `/v1/complete` 文本补全与流式输出 | ✅ | 已实现。 |
31
+ | 流式输出支持 | ✅ | 已实现。 |
32
+ | 文本补全缓存与重复请求合并 | ✅ | `/v1/chat/completions` 文本链路默认启用 60 秒短缓存、流式结果回放、in-flight 请求合并和相邻重复消息清理;可通过 `chat_completion_cache` 配置关闭或调整。 |
33
+ | 图片尺寸参数 | ❌ | 待实现。 |
34
+ | 服务端图片 URL 缓存 | ✅ | 已实现。 |
35
+ | `rt_token` 刷新 | ❌ | 待实现。 |
36
+ | 代理配置功能 | ✅ | 已支持网页端配置全局 HTTP / HTTPS / SOCKS5 / SOCKS5H 代理,并应用到出站请求。 |
37
+ | 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,1031 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from concurrent.futures import ThreadPoolExecutor, as_completed
5
+ from datetime import datetime, timedelta, timezone
6
+ from pathlib import Path
7
+ from threading import Condition, Lock
8
+ from typing import Any
9
+
10
+ from services.config import config
11
+ from services.log_service import (
12
+ LOG_TYPE_ACCOUNT,
13
+ log_service,
14
+ )
15
+ from services.storage.base import StorageBackend
16
+ from utils.helper import anonymize_token
17
+
18
+
19
+ class AccountService:
20
+ """账号池服务,使用 token -> account 的 dict 保存账号。"""
21
+
22
+ _NEW_ACCOUNT_INVALID_GRACE_SECONDS = 10 * 60
23
+ _INVALID_CONFIRM_SECONDS = 30
24
+ _ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 24 * 60 * 60
25
+ _REFRESH_TOKEN_KEEPALIVE_SECONDS = 3 * 24 * 60 * 60
26
+ _REFRESH_TOKEN_KEEPALIVE_ERROR_BACKOFF_SECONDS = 6 * 60 * 60
27
+ _REFRESH_TOKEN_KEEPALIVE_BATCH_SIZE = 3
28
+ _TOKEN_REFRESH_ERROR_BACKOFF_SECONDS = 5 * 60
29
+ _OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token"
30
+ _OAUTH_CLIENT_ID = "app_2SKx67EdpoN0G6j64rFvigXD"
31
+ _OAUTH_USER_AGENT = (
32
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
33
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
34
+ "Chrome/145.0.0.0 Safari/537.36"
35
+ )
36
+
37
+ def __init__(self, storage_backend: StorageBackend):
38
+ self.storage = storage_backend
39
+ self._lock = Lock()
40
+ self._token_refresh_lock = Lock()
41
+ self._image_slot_condition = Condition(self._lock)
42
+ self._index = 0
43
+ self._accounts = self._load_accounts()
44
+ self._image_inflight: dict[str, int] = {}
45
+ self._token_aliases: dict[str, str] = {}
46
+ self._cumulative_total = self._load_cumulative_total()
47
+
48
+ def _get_cumulative_file(self) -> Path:
49
+ from services.config import DATA_DIR
50
+ return DATA_DIR / ".cumulative_total"
51
+
52
+ def _load_cumulative_total(self) -> int:
53
+ try:
54
+ f = self._get_cumulative_file()
55
+ if f.exists():
56
+ return int(f.read_text().strip())
57
+ except Exception:
58
+ pass
59
+ return len(self._accounts)
60
+
61
+ def _save_cumulative_total(self) -> None:
62
+ try:
63
+ self._get_cumulative_file().write_text(str(self._cumulative_total))
64
+ except Exception:
65
+ pass
66
+
67
+ @staticmethod
68
+ def _now() -> str:
69
+ return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
70
+
71
+ @staticmethod
72
+ def _decode_jwt_payload(token: str) -> dict:
73
+ try:
74
+ payload = str(token or "").split(".")[1]
75
+ payload += "=" * ((4 - len(payload) % 4) % 4)
76
+ import base64
77
+ import json
78
+ data = json.loads(base64.urlsafe_b64decode(payload.encode("ascii")))
79
+ return data if isinstance(data, dict) else {}
80
+ except Exception:
81
+ return {}
82
+
83
+ @staticmethod
84
+ def _parse_time(value: object) -> datetime | None:
85
+ raw = str(value or "").strip()
86
+ if not raw:
87
+ return None
88
+ try:
89
+ parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
90
+ except Exception:
91
+ try:
92
+ parsed = datetime.strptime(raw, "%Y-%m-%d %H:%M:%S")
93
+ except Exception:
94
+ return None
95
+ if parsed.tzinfo is None:
96
+ parsed = parsed.replace(tzinfo=timezone.utc)
97
+ return parsed.astimezone(timezone.utc)
98
+
99
+ @staticmethod
100
+ def _timestamp_to_iso(value: object) -> str:
101
+ try:
102
+ ts = int(value)
103
+ except (TypeError, ValueError):
104
+ return ""
105
+ tz = timezone(timedelta(hours=8))
106
+ return datetime.fromtimestamp(ts, tz=timezone.utc).astimezone(tz).isoformat()
107
+
108
+ def _load_accounts(self) -> dict[str, dict]:
109
+ accounts = self.storage.load_accounts()
110
+ return {
111
+ normalized["access_token"]: normalized
112
+ for item in accounts
113
+ if (normalized := self._normalize_account(item)) is not None
114
+ }
115
+
116
+ def _save_accounts(self) -> None:
117
+ self.storage.save_accounts(list(self._accounts.values()))
118
+
119
+ @staticmethod
120
+ def _is_image_account_available(account: dict) -> bool:
121
+ if not isinstance(account, dict):
122
+ return False
123
+ if account.get("status") in {"禁用", "限流", "异常"}:
124
+ return False
125
+ if bool(account.get("image_quota_unknown")):
126
+ return True
127
+ return int(account.get("quota") or 0) > 0
128
+
129
+ @classmethod
130
+ def _account_matches_plan_type(cls, account: dict, plan_type: str | None = None) -> bool:
131
+ if not plan_type:
132
+ return True
133
+ normalized_plan = cls._normalize_account_type(plan_type)
134
+ normalized_account = cls._normalize_account_type(account.get("type"))
135
+ if not normalized_plan or not normalized_account:
136
+ return False
137
+ return normalized_plan.lower() == normalized_account.lower()
138
+
139
+ @classmethod
140
+ def _account_matches_source_type(cls, account: dict, source_type: str | None = None) -> bool:
141
+ if not source_type:
142
+ return True
143
+ return cls._normalize_source_type(account.get("source_type")) == cls._normalize_source_type(source_type)
144
+
145
+ @classmethod
146
+ def _account_matches_any_plan_type(cls, account: dict, plan_types: set[str] | tuple[str, ...] | None = None) -> bool:
147
+ if not plan_types:
148
+ return True
149
+ normalized_account = cls._normalize_account_type(account.get("type"))
150
+ normalized_plans = {
151
+ normalized
152
+ for plan_type in plan_types
153
+ if (normalized := cls._normalize_account_type(plan_type))
154
+ }
155
+ return bool(normalized_account and normalized_account in normalized_plans)
156
+
157
+ @staticmethod
158
+ def _normalize_source_type(value: object) -> str:
159
+ return str(value or "web").strip().lower() or "web"
160
+
161
+ @staticmethod
162
+ def _normalize_account_type(value: object) -> str | None:
163
+ raw = str(value or "").strip()
164
+ if not raw:
165
+ return None
166
+ key = raw.lower().replace("-", "_").replace(" ", "_")
167
+ compact = key.replace("_", "")
168
+ aliases = {
169
+ "free": "free",
170
+ "plus": "Plus",
171
+ "pro": "Pro",
172
+ "prolite": "ProLite",
173
+ "team": "Team",
174
+ "business": "Team",
175
+ "enterprise": "Enterprise",
176
+ }
177
+ return aliases.get(compact) or aliases.get(key) or raw
178
+
179
+ def _search_account_type(self, payload: object) -> str | None:
180
+ if isinstance(payload, dict):
181
+ for key in ("plan_type", "account_plan", "account_type", "subscription_type", "type"):
182
+ plan = self._normalize_account_type(payload.get(key))
183
+ if plan:
184
+ return plan
185
+ for value in payload.values():
186
+ plan = self._search_account_type(value)
187
+ if plan:
188
+ return plan
189
+ elif isinstance(payload, list):
190
+ for value in payload:
191
+ plan = self._search_account_type(value)
192
+ if plan:
193
+ return plan
194
+ return None
195
+
196
+ def _normalize_account(self, item: dict) -> dict | None:
197
+ if not isinstance(item, dict):
198
+ return None
199
+ access_token = item.get("access_token") or item.get("accessToken") or ""
200
+ if not access_token:
201
+ return None
202
+ normalized = dict(item)
203
+ normalized.pop("accessToken", None)
204
+ normalized["access_token"] = access_token
205
+ if str(normalized.get("type") or "").strip().lower() == "codex":
206
+ normalized["export_type"] = "codex"
207
+ normalized.pop("type", None)
208
+ normalized["type"] = normalized.get("type") or "free"
209
+ normalized["status"] = normalized.get("status") or "正常"
210
+ normalized["quota"] = max(0, int(normalized.get("quota") if normalized.get("quota") is not None else 0))
211
+ normalized["image_quota_unknown"] = bool(normalized.get("image_quota_unknown"))
212
+ normalized["email"] = normalized.get("email") or None
213
+ normalized["user_id"] = normalized.get("user_id") or None
214
+ normalized["proxy"] = str(normalized.get("proxy") or "").strip()
215
+ source_type = normalized.get("source_type")
216
+ if not source_type and str(normalized.get("export_type") or "").strip().lower() == "codex":
217
+ source_type = "codex"
218
+ normalized["source_type"] = self._normalize_source_type(source_type)
219
+ limits_progress = normalized.get("limits_progress")
220
+ normalized["limits_progress"] = limits_progress if isinstance(limits_progress, list) else []
221
+ normalized["default_model_slug"] = normalized.get("default_model_slug") or None
222
+ normalized["restore_at"] = normalized.get("restore_at") or None
223
+ normalized["success"] = int(normalized.get("success") or 0)
224
+ normalized["fail"] = int(normalized.get("fail") or 0)
225
+ normalized["invalid_count"] = int(normalized.get("invalid_count") or 0)
226
+ normalized["last_used_at"] = normalized.get("last_used_at")
227
+ normalized["last_invalid_at"] = normalized.get("last_invalid_at") or None
228
+ normalized["last_refresh_error"] = normalized.get("last_refresh_error") or None
229
+ normalized["last_refresh_error_at"] = normalized.get("last_refresh_error_at") or None
230
+ normalized["last_token_refresh_at"] = normalized.get("last_token_refresh_at") or None
231
+ normalized["last_token_refresh_error"] = normalized.get("last_token_refresh_error") or None
232
+ normalized["last_token_refresh_error_at"] = normalized.get("last_token_refresh_error_at") or None
233
+ normalized["created_at"] = normalized.get("created_at") or AccountService._now()
234
+ return normalized
235
+
236
+ @staticmethod
237
+ def _jwt_exp(access_token: str) -> int:
238
+ try:
239
+ return int(AccountService._decode_jwt_payload(access_token).get("exp") or 0)
240
+ except (TypeError, ValueError):
241
+ return 0
242
+
243
+ @classmethod
244
+ def _token_expires_in(cls, access_token: str) -> int | None:
245
+ exp = cls._jwt_exp(access_token)
246
+ if exp <= 0:
247
+ return None
248
+ return exp - int(time.time())
249
+
250
+ @classmethod
251
+ def _token_needs_refresh(cls, access_token: str, *, force: bool = False) -> bool:
252
+ if force:
253
+ return True
254
+ remaining = cls._token_expires_in(access_token)
255
+ return remaining is not None and remaining <= cls._ACCESS_TOKEN_REFRESH_SKEW_SECONDS
256
+
257
+ @classmethod
258
+ def _token_issued_at(cls, access_token: str) -> datetime | None:
259
+ try:
260
+ iat = int(cls._decode_jwt_payload(access_token).get("iat") or 0)
261
+ except (TypeError, ValueError):
262
+ return None
263
+ if iat <= 0:
264
+ return None
265
+ return datetime.fromtimestamp(iat, tz=timezone.utc)
266
+
267
+ @staticmethod
268
+ def _safe_response_text(response: object, limit: int = 300) -> str:
269
+ try:
270
+ return str(getattr(response, "text", "") or "")[:limit]
271
+ except Exception:
272
+ return ""
273
+
274
+ def _resolve_access_token_locked(self, access_token: str) -> str:
275
+ token = str(access_token or "").strip()
276
+ seen: set[str] = set()
277
+ while token and token not in self._accounts and token in self._token_aliases and token not in seen:
278
+ seen.add(token)
279
+ token = self._token_aliases.get(token, token)
280
+ return token
281
+
282
+ def resolve_access_token(self, access_token: str) -> str:
283
+ if not access_token:
284
+ return ""
285
+ with self._lock:
286
+ return self._resolve_access_token_locked(access_token)
287
+
288
+ def _get_account_for_token(self, access_token: str) -> tuple[str, dict | None]:
289
+ with self._lock:
290
+ resolved = self._resolve_access_token_locked(access_token)
291
+ account = self._accounts.get(resolved)
292
+ return resolved, dict(account) if account else None
293
+
294
+ def _record_token_refresh_error(self, access_token: str, event: str, error: str) -> None:
295
+ now = datetime.now(timezone.utc).isoformat()
296
+ with self._lock:
297
+ resolved = self._resolve_access_token_locked(access_token)
298
+ current = self._accounts.get(resolved)
299
+ if current is None:
300
+ return
301
+ next_item = dict(current)
302
+ next_item["last_token_refresh_error"] = str(error or "refresh token failed")
303
+ next_item["last_token_refresh_error_at"] = now
304
+ account = self._normalize_account(next_item)
305
+ if account is not None:
306
+ self._accounts[resolved] = account
307
+ self._save_accounts()
308
+ log_service.add(
309
+ LOG_TYPE_ACCOUNT,
310
+ "refresh_token 刷新 access_token 失败",
311
+ {"source": event, "token": anonymize_token(access_token), "error": str(error or "")},
312
+ )
313
+
314
+ def _recent_token_refresh_error(self, account: dict) -> bool:
315
+ last_error_at = self._parse_time(account.get("last_token_refresh_error_at"))
316
+ if last_error_at is None:
317
+ return False
318
+ return (datetime.now(timezone.utc) - last_error_at).total_seconds() < self._TOKEN_REFRESH_ERROR_BACKOFF_SECONDS
319
+
320
+ def _recent_refresh_token_keepalive_error(self, account: dict, now: datetime) -> bool:
321
+ last_error_at = self._parse_time(account.get("last_token_refresh_error_at"))
322
+ if last_error_at is None:
323
+ return False
324
+ return (now - last_error_at).total_seconds() < self._REFRESH_TOKEN_KEEPALIVE_ERROR_BACKOFF_SECONDS
325
+
326
+ def _refresh_token_keepalive_anchor(self, account: dict) -> datetime | None:
327
+ return (
328
+ self._parse_time(account.get("last_token_refresh_at"))
329
+ or self._token_issued_at(str(account.get("access_token") or ""))
330
+ or self._parse_time(account.get("created_at"))
331
+ )
332
+
333
+ def _refresh_token_keepalive_due_at(self, account: dict, now: datetime) -> datetime | None:
334
+ if not str(account.get("refresh_token") or "").strip():
335
+ return None
336
+ if account.get("status") == "禁用":
337
+ return None
338
+ if self._recent_refresh_token_keepalive_error(account, now):
339
+ return None
340
+ anchor = self._refresh_token_keepalive_anchor(account)
341
+ if anchor is None:
342
+ return now
343
+ due_at = anchor + timedelta(seconds=self._REFRESH_TOKEN_KEEPALIVE_SECONDS)
344
+ return due_at if due_at <= now else None
345
+
346
+ def _request_access_token_refresh(self, refresh_token: str, account: dict | None = None) -> dict[str, str]:
347
+ from curl_cffi import requests
348
+ from services.proxy_service import proxy_settings
349
+
350
+ session = requests.Session(**proxy_settings.build_session_kwargs(account=account, impersonate="chrome", verify=True))
351
+ try:
352
+ response = session.post(
353
+ self._OAUTH_TOKEN_URL,
354
+ headers={
355
+ "Accept": "application/json",
356
+ "Content-Type": "application/x-www-form-urlencoded",
357
+ "User-Agent": self._OAUTH_USER_AGENT,
358
+ },
359
+ data={
360
+ "grant_type": "refresh_token",
361
+ "refresh_token": refresh_token,
362
+ "client_id": self._OAUTH_CLIENT_ID,
363
+ },
364
+ timeout=60,
365
+ )
366
+ data = response.json() if response.text else {}
367
+ if response.status_code != 200 or not isinstance(data, dict) or not data.get("access_token"):
368
+ detail = ""
369
+ if isinstance(data, dict):
370
+ detail = str(data.get("error_description") or data.get("error") or data.get("message") or "")
371
+ detail = detail or self._safe_response_text(response)
372
+ raise RuntimeError(f"oauth_refresh_http_{response.status_code}{': ' + detail if detail else ''}")
373
+ return {
374
+ "access_token": str(data.get("access_token") or "").strip(),
375
+ "refresh_token": str(data.get("refresh_token") or refresh_token).strip(),
376
+ "id_token": str(data.get("id_token") or "").strip(),
377
+ }
378
+ finally:
379
+ session.close()
380
+
381
+ def _apply_refreshed_tokens(self, old_access_token: str, token_data: dict, event: str) -> str:
382
+ now = datetime.now(timezone.utc).isoformat()
383
+ with self._image_slot_condition:
384
+ old_token = self._resolve_access_token_locked(old_access_token)
385
+ current = self._accounts.get(old_token)
386
+ if current is None:
387
+ return old_token
388
+ new_token = str(token_data.get("access_token") or old_token).strip()
389
+ if not new_token:
390
+ return old_token
391
+
392
+ next_item = dict(current)
393
+ next_item["access_token"] = new_token
394
+ if token_data.get("refresh_token"):
395
+ next_item["refresh_token"] = str(token_data.get("refresh_token") or "").strip()
396
+ if token_data.get("id_token"):
397
+ next_item["id_token"] = str(token_data.get("id_token") or "").strip()
398
+ next_item["last_token_refresh_at"] = now
399
+ next_item["last_token_refresh_error"] = None
400
+ next_item["last_token_refresh_error_at"] = None
401
+ next_item["invalid_count"] = 0
402
+ next_item["last_invalid_at"] = None
403
+ next_item["last_refresh_error"] = None
404
+ next_item["last_refresh_error_at"] = None
405
+
406
+ account = self._normalize_account(next_item)
407
+ if account is None:
408
+ return old_token
409
+
410
+ rotated = new_token != old_token
411
+ if rotated:
412
+ self._accounts.pop(old_token, None)
413
+ self._token_aliases[old_token] = new_token
414
+ old_inflight = int(self._image_inflight.pop(old_token, 0))
415
+ if old_inflight:
416
+ self._image_inflight[new_token] = int(self._image_inflight.get(new_token, 0)) + old_inflight
417
+ self._accounts[new_token] = account
418
+ self._save_accounts()
419
+ self._image_slot_condition.notify_all()
420
+
421
+ log_service.add(
422
+ LOG_TYPE_ACCOUNT,
423
+ "refresh_token 已刷新 access_token",
424
+ {"source": event, "token": anonymize_token(new_token), "rotated": rotated},
425
+ )
426
+ return new_token
427
+
428
+ def refresh_access_token(self, access_token: str, *, force: bool = False, event: str = "refresh_access_token") -> str:
429
+ if not access_token:
430
+ return ""
431
+ with self._token_refresh_lock:
432
+ resolved_token, account = self._get_account_for_token(access_token)
433
+ if not account:
434
+ return access_token
435
+ active_token = str(account.get("access_token") or resolved_token or access_token)
436
+ if not self._token_needs_refresh(active_token, force=force):
437
+ return active_token
438
+ refresh_token = str(account.get("refresh_token") or "").strip()
439
+ if not refresh_token:
440
+ return active_token
441
+ if not force and self._recent_token_refresh_error(account):
442
+ return active_token
443
+ try:
444
+ token_data = self._request_access_token_refresh(refresh_token, account)
445
+ except Exception as exc:
446
+ self._record_token_refresh_error(active_token, event, str(exc))
447
+ return active_token
448
+ return self._apply_refreshed_tokens(active_token, token_data, event)
449
+
450
+ def list_expiring_access_tokens(self) -> list[str]:
451
+ with self._lock:
452
+ return [
453
+ token
454
+ for account in self._accounts.values()
455
+ if str(account.get("refresh_token") or "").strip()
456
+ and (token := str(account.get("access_token") or "").strip())
457
+ and self._token_needs_refresh(token)
458
+ ]
459
+
460
+ def list_refresh_token_keepalive_tokens(self) -> list[str]:
461
+ now = datetime.now(timezone.utc)
462
+ due_items: list[tuple[datetime, str]] = []
463
+ with self._lock:
464
+ for account in self._accounts.values():
465
+ due_at = self._refresh_token_keepalive_due_at(account, now)
466
+ token = str(account.get("access_token") or "").strip()
467
+ if due_at is not None and token:
468
+ due_items.append((due_at, token))
469
+ due_items.sort(key=lambda item: item[0])
470
+ return [token for _, token in due_items[: self._REFRESH_TOKEN_KEEPALIVE_BATCH_SIZE]]
471
+
472
+ def keepalive_refresh_tokens(self, access_tokens: list[str]) -> dict[str, Any]:
473
+ access_tokens = list(dict.fromkeys(token for token in access_tokens if token))
474
+ if not access_tokens:
475
+ return {"refreshed": 0, "errors": [], "items": self.list_accounts()}
476
+
477
+ refreshed = 0
478
+ errors = []
479
+ for access_token in access_tokens:
480
+ before = self.resolve_access_token(access_token)
481
+ after = self.refresh_access_token(before, force=True, event="refresh_token_keepalive")
482
+ account = self.get_account(after)
483
+ if account and str(account.get("last_token_refresh_error") or "").strip():
484
+ errors.append({
485
+ "token": anonymize_token(before),
486
+ "error": str(account.get("last_token_refresh_error") or "refresh token failed"),
487
+ })
488
+ continue
489
+ if account:
490
+ refreshed += 1
491
+
492
+ return {
493
+ "refreshed": refreshed,
494
+ "errors": errors,
495
+ "items": self.list_accounts(),
496
+ }
497
+
498
+ def list_tokens(self) -> list[str]:
499
+ with self._lock:
500
+ return list(self._accounts)
501
+
502
+ def _list_ready_candidate_tokens(
503
+ self,
504
+ excluded_tokens: set[str] | None = None,
505
+ plan_type: str | None = None,
506
+ source_type: str | None = None,
507
+ plan_types: set[str] | tuple[str, ...] | None = None,
508
+ ) -> list[str]:
509
+ excluded = set(excluded_tokens or set())
510
+ return [
511
+ token
512
+ for item in self._accounts.values()
513
+ if self._is_image_account_available(item)
514
+ and self._account_matches_plan_type(item, plan_type)
515
+ and self._account_matches_any_plan_type(item, plan_types)
516
+ and self._account_matches_source_type(item, source_type)
517
+ and (token := item.get("access_token") or "")
518
+ and token not in excluded
519
+ ]
520
+
521
+ def _list_available_candidate_tokens(
522
+ self,
523
+ excluded_tokens: set[str] | None = None,
524
+ plan_type: str | None = None,
525
+ source_type: str | None = None,
526
+ plan_types: set[str] | tuple[str, ...] | None = None,
527
+ ) -> list[str]:
528
+ max_concurrency = max(1, int(config.image_account_concurrency or 1))
529
+ return [
530
+ token
531
+ for token in self._list_ready_candidate_tokens(excluded_tokens, plan_type, source_type, plan_types)
532
+ if int(self._image_inflight.get(token, 0)) < max_concurrency
533
+ ]
534
+
535
+ def _acquire_next_candidate_token(
536
+ self,
537
+ excluded_tokens: set[str] | None = None,
538
+ plan_type: str | None = None,
539
+ source_type: str | None = None,
540
+ plan_types: set[str] | tuple[str, ...] | None = None,
541
+ ) -> str:
542
+ with self._image_slot_condition:
543
+ while True:
544
+ if not self._list_ready_candidate_tokens(excluded_tokens, plan_type, source_type, plan_types):
545
+ raise RuntimeError(
546
+ f"no available {plan_type or source_type or ''} image quota".replace(" ", " ").strip()
547
+ if plan_type or source_type else "no available image quota"
548
+ )
549
+ tokens = self._list_available_candidate_tokens(excluded_tokens, plan_type, source_type, plan_types)
550
+ if tokens:
551
+ access_token = tokens[self._index % len(tokens)]
552
+ self._index += 1
553
+ self._image_inflight[access_token] = int(self._image_inflight.get(access_token, 0)) + 1
554
+ return access_token
555
+ self._image_slot_condition.wait(timeout=1.0)
556
+
557
+ def release_image_slot(self, access_token: str) -> None:
558
+ if not access_token:
559
+ return
560
+ with self._image_slot_condition:
561
+ access_token = self._resolve_access_token_locked(access_token)
562
+ current_inflight = int(self._image_inflight.get(access_token, 0))
563
+ if current_inflight <= 1:
564
+ self._image_inflight.pop(access_token, None)
565
+ else:
566
+ self._image_inflight[access_token] = current_inflight - 1
567
+ self._image_slot_condition.notify_all()
568
+
569
+ def get_available_access_token(
570
+ self,
571
+ plan_type: str | None = None,
572
+ source_type: str | None = None,
573
+ plan_types: set[str] | tuple[str, ...] | None = None,
574
+ ) -> str:
575
+ attempted_tokens: set[str] = set()
576
+ while True:
577
+ access_token = self._acquire_next_candidate_token(
578
+ excluded_tokens=attempted_tokens,
579
+ plan_type=plan_type,
580
+ source_type=source_type,
581
+ plan_types=plan_types,
582
+ )
583
+ attempted_tokens.add(access_token)
584
+ try:
585
+ account = self.fetch_remote_info(access_token, "get_available_access_token")
586
+ except Exception:
587
+ self.release_image_slot(access_token)
588
+ continue
589
+ if (
590
+ self._is_image_account_available(account or {})
591
+ and self._account_matches_plan_type(account or {}, plan_type)
592
+ and self._account_matches_any_plan_type(account or {}, plan_types)
593
+ and self._account_matches_source_type(account or {}, source_type)
594
+ ):
595
+ return str((account or {}).get("access_token") or access_token)
596
+ self.release_image_slot(access_token)
597
+
598
+ def get_text_access_token(self, excluded_tokens: set[str] | None = None) -> str:
599
+ excluded = set(excluded_tokens or set())
600
+ with self._lock:
601
+ candidates = [
602
+ token
603
+ for account in self._accounts.values()
604
+ if account.get("status") not in {"禁用", "异常"}
605
+ and (token := account.get("access_token") or "")
606
+ and token not in excluded
607
+ ]
608
+ if not candidates:
609
+ return ""
610
+ access_token = candidates[self._index % len(candidates)]
611
+ self._index += 1
612
+ return self.refresh_access_token(access_token, event="get_text_access_token") or access_token
613
+
614
+ def mark_text_used(self, access_token: str) -> None:
615
+ if not access_token:
616
+ return
617
+ with self._lock:
618
+ access_token = self._resolve_access_token_locked(access_token)
619
+ current = self._accounts.get(access_token)
620
+ if current is None:
621
+ return
622
+ next_item = dict(current)
623
+ next_item["last_used_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
624
+ account = self._normalize_account(next_item)
625
+ if account is None:
626
+ return
627
+ self._accounts[access_token] = account
628
+ self._save_accounts()
629
+
630
+ def remove_invalid_token(self, access_token: str, event: str) -> bool:
631
+ if not config.auto_remove_invalid_accounts:
632
+ self.update_account(access_token, {"status": "异常", "quota": 0})
633
+ return False
634
+ removed = bool(self.delete_accounts([access_token])["removed"])
635
+ if removed:
636
+ log_service.add(LOG_TYPE_ACCOUNT, "自动移除异常账号",
637
+ {"source": event, "token": anonymize_token(access_token)})
638
+ elif access_token:
639
+ self.update_account(access_token, {"status": "异常", "quota": 0})
640
+ return removed
641
+
642
+ def get_account(self, access_token: str) -> dict | None:
643
+ if not access_token:
644
+ return None
645
+ with self._lock:
646
+ access_token = self._resolve_access_token_locked(access_token)
647
+ account = self._accounts.get(access_token)
648
+ return dict(account) if account else None
649
+
650
+ def list_accounts(self) -> list[dict]:
651
+ with self._lock:
652
+ return [dict(item) for item in self._accounts.values()]
653
+
654
+ def list_limited_tokens(self) -> list[str]:
655
+ with self._lock:
656
+ return [
657
+ token
658
+ for item in self._accounts.values()
659
+ if item.get("status") == "限流"
660
+ and (token := item.get("access_token") or "")
661
+ ]
662
+
663
+ @staticmethod
664
+ def _account_payload_token(item: dict) -> str:
665
+ return str(item.get("access_token") or item.get("accessToken") or "").strip()
666
+
667
+ @staticmethod
668
+ def _prepare_account_payload(item: dict) -> dict | None:
669
+ if not isinstance(item, dict):
670
+ return None
671
+ access_token = AccountService._account_payload_token(item)
672
+ if not access_token:
673
+ return None
674
+ payload = dict(item)
675
+ payload.pop("accessToken", None)
676
+ payload["access_token"] = access_token
677
+ # CPA/Codex 导出文件里的 `type=codex` 是导出格式,不是号池套餐类型。
678
+ if str(payload.get("type") or "").strip().lower() == "codex":
679
+ payload["export_type"] = "codex"
680
+ payload["source_type"] = "codex"
681
+ payload.pop("type", None)
682
+ if str(payload.get("export_type") or "").strip().lower() == "codex":
683
+ payload["source_type"] = "codex"
684
+ if payload.get("plan_type") and not payload.get("type"):
685
+ payload["type"] = str(payload.get("plan_type") or "").strip()
686
+ return payload
687
+
688
+ def add_account_items(self, items: list[dict]) -> dict:
689
+ payloads = [
690
+ payload
691
+ for item in items
692
+ if (payload := self._prepare_account_payload(item)) is not None
693
+ ]
694
+ return self._add_account_payloads(payloads)
695
+
696
+ def add_accounts(self, tokens: list[str], source_type: str = "web") -> dict:
697
+ tokens = list(dict.fromkeys(token for token in tokens if token))
698
+ if not tokens:
699
+ return {"added": 0, "skipped": 0, "items": self.list_accounts()}
700
+ return self._add_account_payloads([
701
+ {"access_token": token, "source_type": self._normalize_source_type(source_type)}
702
+ for token in tokens
703
+ ])
704
+
705
+ def _add_account_payloads(self, payloads: list[dict]) -> dict:
706
+ deduped: dict[str, dict] = {}
707
+ for payload in payloads:
708
+ if not isinstance(payload, dict):
709
+ continue
710
+ access_token = self._account_payload_token(payload)
711
+ if not access_token:
712
+ continue
713
+ current = deduped.get(access_token, {})
714
+ deduped[access_token] = {**current, **payload, "access_token": access_token}
715
+
716
+ if not deduped:
717
+ return {"added": 0, "skipped": 0, "items": self.list_accounts()}
718
+
719
+ with self._lock:
720
+ added = 0
721
+ skipped = 0
722
+ for access_token, payload in deduped.items():
723
+ current = self._accounts.get(access_token)
724
+ if current is None:
725
+ added += 1
726
+ self._cumulative_total += 1
727
+ self._save_cumulative_total()
728
+ current = {"created_at": self._now()}
729
+ else:
730
+ skipped += 1
731
+ incoming = dict(payload)
732
+ if not incoming.get("created_at"):
733
+ incoming.pop("created_at", None)
734
+ account = self._normalize_account(
735
+ {
736
+ **current,
737
+ **incoming,
738
+ "access_token": access_token,
739
+ "type": str(incoming.get("type") or current.get("type") or "free"),
740
+ }
741
+ )
742
+ if account is not None:
743
+ self._accounts[access_token] = account
744
+ self._save_accounts()
745
+ items = [dict(item) for item in self._accounts.values()]
746
+ log_service.add(LOG_TYPE_ACCOUNT, f"新增 {added} 个账号,跳过 {skipped} 个",
747
+ {"added": added, "skipped": skipped})
748
+ return {"added": added, "skipped": skipped, "items": items}
749
+
750
+ def delete_accounts(self, tokens: list[str]) -> dict:
751
+ target_set = set(token for token in tokens if token)
752
+ if not target_set:
753
+ return {"removed": 0, "items": self.list_accounts()}
754
+ with self._lock:
755
+ target_set = {self._resolve_access_token_locked(token) for token in target_set if token}
756
+ removed = sum(self._accounts.pop(token, None) is not None for token in target_set)
757
+ for token in target_set:
758
+ self._image_inflight.pop(token, None)
759
+ self._token_aliases = {
760
+ old: new
761
+ for old, new in self._token_aliases.items()
762
+ if old not in target_set and new not in target_set
763
+ }
764
+ if removed:
765
+ if self._accounts:
766
+ self._index %= len(self._accounts)
767
+ else:
768
+ self._index = 0
769
+ self._save_accounts()
770
+ log_service.add(LOG_TYPE_ACCOUNT, f"删除 {removed} 个账号", {"removed": removed})
771
+ items = [dict(item) for item in self._accounts.values()]
772
+ return {"removed": removed, "items": items}
773
+
774
+ def update_account(self, access_token: str, updates: dict) -> dict | None:
775
+ if not access_token:
776
+ return None
777
+ with self._lock:
778
+ access_token = self._resolve_access_token_locked(access_token)
779
+ current = self._accounts.get(access_token)
780
+ if current is None:
781
+ return None
782
+ account = self._normalize_account({**current, **updates, "access_token": access_token})
783
+ if account is None:
784
+ return None
785
+ if account.get("status") == "限流" and config.auto_remove_rate_limited_accounts:
786
+ self._accounts.pop(access_token, None)
787
+ self._save_accounts()
788
+ log_service.add(LOG_TYPE_ACCOUNT, "自动移除限流账号", {"token": anonymize_token(access_token)})
789
+ return None
790
+ self._accounts[access_token] = account
791
+ self._save_accounts()
792
+ log_service.add(LOG_TYPE_ACCOUNT, "更新账号",
793
+ {"token": anonymize_token(access_token), "status": account.get("status")})
794
+ return dict(account)
795
+ return None
796
+
797
+ def _record_refresh_success(self, access_token: str) -> None:
798
+ with self._lock:
799
+ access_token = self._resolve_access_token_locked(access_token)
800
+ current = self._accounts.get(access_token)
801
+ if current is None:
802
+ return
803
+ next_item = dict(current)
804
+ next_item["invalid_count"] = 0
805
+ next_item["last_invalid_at"] = None
806
+ next_item["last_refresh_error"] = None
807
+ next_item["last_refresh_error_at"] = None
808
+ account = self._normalize_account(next_item)
809
+ if account is not None:
810
+ self._accounts[access_token] = account
811
+
812
+ def _should_defer_invalid_token(self, account: dict | None, now: datetime) -> bool:
813
+ if not isinstance(account, dict):
814
+ return False
815
+ created_at = self._parse_time(account.get("created_at"))
816
+ if created_at is not None and (now - created_at).total_seconds() < self._NEW_ACCOUNT_INVALID_GRACE_SECONDS:
817
+ return True
818
+ last_invalid_at = self._parse_time(account.get("last_invalid_at"))
819
+ invalid_count = int(account.get("invalid_count") or 0)
820
+ if invalid_count <= 1:
821
+ return True
822
+ if last_invalid_at is not None and (now - last_invalid_at).total_seconds() < self._INVALID_CONFIRM_SECONDS:
823
+ return True
824
+ return False
825
+
826
+ def _record_invalid_token_seen(self, access_token: str, event: str, error: str) -> bool:
827
+ now = datetime.now(timezone.utc)
828
+ with self._lock:
829
+ access_token = self._resolve_access_token_locked(access_token)
830
+ current = self._accounts.get(access_token)
831
+ if current is None:
832
+ return True
833
+ should_defer = self._should_defer_invalid_token(current, now)
834
+ next_item = dict(current)
835
+ next_item["invalid_count"] = int(next_item.get("invalid_count") or 0) + 1
836
+ next_item["last_invalid_at"] = now.isoformat()
837
+ next_item["last_refresh_error"] = str(error or "invalid access token")
838
+ next_item["last_refresh_error_at"] = now.isoformat()
839
+ account = self._normalize_account(next_item)
840
+ if account is not None:
841
+ self._accounts[access_token] = account
842
+ self._save_accounts()
843
+ if should_defer:
844
+ log_service.add(
845
+ LOG_TYPE_ACCOUNT,
846
+ "暂缓标记异常账号",
847
+ {"source": event, "token": anonymize_token(access_token), "error": str(error or "")},
848
+ )
849
+ return False
850
+ return True
851
+
852
+ def mark_image_result(self, access_token: str, success: bool) -> dict | None:
853
+ if not access_token:
854
+ return None
855
+ self.release_image_slot(access_token)
856
+ with self._lock:
857
+ access_token = self._resolve_access_token_locked(access_token)
858
+ current = self._accounts.get(access_token)
859
+ if current is None:
860
+ return None
861
+ next_item = dict(current)
862
+ next_item["last_used_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
863
+ image_quota_unknown = bool(next_item.get("image_quota_unknown"))
864
+ if success:
865
+ next_item["success"] = int(next_item.get("success") or 0) + 1
866
+ if not image_quota_unknown:
867
+ next_item["quota"] = max(0, int(next_item.get("quota") or 0) - 1)
868
+ if not image_quota_unknown and next_item["quota"] == 0:
869
+ next_item["status"] = "限流"
870
+ next_item["restore_at"] = next_item.get("restore_at") or None
871
+ elif next_item.get("status") == "限流":
872
+ next_item["status"] = "正常"
873
+ else:
874
+ next_item["fail"] = int(next_item.get("fail") or 0) + 1
875
+ account = self._normalize_account(next_item)
876
+ if account is None:
877
+ return None
878
+ if account.get("status") == "限流" and config.auto_remove_rate_limited_accounts:
879
+ self._accounts.pop(access_token, None)
880
+ self._save_accounts()
881
+ log_service.add(LOG_TYPE_ACCOUNT, "自动移除限流账号", {"token": anonymize_token(access_token)})
882
+ return None
883
+ self._accounts[access_token] = account
884
+ self._save_accounts()
885
+ return dict(account)
886
+ return None
887
+
888
+ def fetch_remote_info(self, access_token: str, event: str = "fetch_remote_info") -> dict[str, Any] | None:
889
+ if not access_token:
890
+ raise ValueError("access_token is required")
891
+
892
+ active_token = self.refresh_access_token(access_token, event=f"{event}:preflight") or access_token
893
+ try:
894
+ from services.openai_backend_api import InvalidAccessTokenError, OpenAIBackendAPI
895
+ result = OpenAIBackendAPI(active_token).get_user_info()
896
+ except InvalidAccessTokenError as exc:
897
+ refreshed_token = self.refresh_access_token(active_token, force=True, event=f"{event}:invalid_access_token")
898
+ if refreshed_token and refreshed_token != active_token:
899
+ try:
900
+ result = OpenAIBackendAPI(refreshed_token).get_user_info()
901
+ except InvalidAccessTokenError as retry_exc:
902
+ if self._record_invalid_token_seen(refreshed_token, event, str(retry_exc)):
903
+ self.remove_invalid_token(refreshed_token, event)
904
+ raise
905
+ active_token = refreshed_token
906
+ else:
907
+ if self._record_invalid_token_seen(active_token, event, str(exc)):
908
+ self.remove_invalid_token(active_token, event)
909
+ raise
910
+ self._record_refresh_success(active_token)
911
+ return self.update_account(active_token, result)
912
+
913
+ def refresh_accounts(self, access_tokens: list[str]) -> dict[str, Any]:
914
+ access_tokens = list(dict.fromkeys(token for token in access_tokens if token))
915
+ if not access_tokens:
916
+ return {"refreshed": 0, "errors": [], "items": self.list_accounts()}
917
+
918
+ refreshed = 0
919
+ errors = []
920
+ max_workers = min(10, len(access_tokens))
921
+
922
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
923
+ futures = {
924
+ executor.submit(self.fetch_remote_info, token, "refresh_accounts"): token
925
+ for token in access_tokens
926
+ }
927
+ for future in as_completed(futures):
928
+ try:
929
+ account = future.result()
930
+ except Exception as exc:
931
+ errors.append({"token": anonymize_token(futures[future]), "error": str(exc)})
932
+ continue
933
+ if account is not None:
934
+ refreshed += 1
935
+
936
+ return {
937
+ "refreshed": refreshed,
938
+ "errors": errors,
939
+ "items": self.list_accounts(),
940
+ }
941
+
942
+ def build_export_items(self, access_tokens: list[str] | None = None) -> list[dict[str, str]]:
943
+ target_tokens = set(token for token in (access_tokens or []) if token)
944
+ with self._lock:
945
+ accounts = [
946
+ dict(item)
947
+ for item in self._accounts.values()
948
+ if not target_tokens or str(item.get("access_token") or "") in target_tokens
949
+ ]
950
+
951
+ items: list[dict[str, str]] = []
952
+ for account in accounts:
953
+ access_token = str(account.get("access_token") or "").strip()
954
+ refresh_token = str(account.get("refresh_token") or "").strip()
955
+ id_token = str(account.get("id_token") or "").strip()
956
+ if not access_token or not refresh_token or not id_token:
957
+ continue
958
+
959
+ access_payload = self._decode_jwt_payload(access_token)
960
+ id_payload = self._decode_jwt_payload(id_token)
961
+ auth_claim = access_payload.get("https://api.openai.com/auth")
962
+ auth_claim = auth_claim if isinstance(auth_claim, dict) else {}
963
+ profile_claim = access_payload.get("https://api.openai.com/profile")
964
+ profile_claim = profile_claim if isinstance(profile_claim, dict) else {}
965
+
966
+ email = (
967
+ str(account.get("email") or "").strip()
968
+ or str(profile_claim.get("email") or "").strip()
969
+ or str(id_payload.get("email") or "").strip()
970
+ )
971
+ account_id = (
972
+ str(account.get("account_id") or "").strip()
973
+ or str(auth_claim.get("chatgpt_account_id") or "").strip()
974
+ or str(account.get("user_id") or "").strip()
975
+ )
976
+ item = {
977
+ "type": str(account.get("export_type") or "codex"),
978
+ "email": email,
979
+ "account_id": account_id,
980
+ "access_token": access_token,
981
+ "refresh_token": refresh_token,
982
+ "id_token": id_token,
983
+ "expired": self._timestamp_to_iso(access_payload.get("exp")),
984
+ "last_refresh": self._timestamp_to_iso(access_payload.get("iat")),
985
+ }
986
+ password = str(account.get("password") or "").strip()
987
+ if password:
988
+ item["password"] = password
989
+ items.append(item)
990
+ return items
991
+
992
+ def get_stats(self) -> dict:
993
+ with self._lock:
994
+ items = list(self._accounts.values())
995
+ total = len(items)
996
+ active = sum(1 for a in items if a.get("status") == "正常")
997
+ limited = sum(1 for a in items if a.get("status") == "限流")
998
+ abnormal = sum(1 for a in items if a.get("status") == "异常")
999
+ disabled = sum(1 for a in items if a.get("status") == "禁用")
1000
+ total_quota = sum(max(0, int(a.get("quota") or 0)) for a in items if a.get("status") == "正常")
1001
+ unlimited = sum(1 for a in items if a.get("status") == "正常" and bool(a.get("image_quota_unknown")))
1002
+ total_success = sum(int(a.get("success") or 0) for a in items)
1003
+ total_fail = sum(int(a.get("fail") or 0) for a in items)
1004
+ by_type = {}
1005
+ for a in items:
1006
+ t = a.get("type", "unknown")
1007
+ by_type[t] = by_type.get(t, 0) + 1
1008
+ return {
1009
+ "total": total,
1010
+ "cumulative_total": self._cumulative_total,
1011
+ "active": active,
1012
+ "limited": limited,
1013
+ "abnormal": abnormal,
1014
+ "disabled": disabled,
1015
+ "total_quota": total_quota,
1016
+ "unlimited_quota_count": unlimited,
1017
+ "total_success": total_success,
1018
+ "total_fail": total_fail,
1019
+ "by_type": by_type,
1020
+ }
1021
+
1022
+ def account_health(self) -> dict:
1023
+ stats = self.get_stats()
1024
+ return {
1025
+ "healthy": stats["active"] > 0 or stats["unlimited_quota_count"] > 0,
1026
+ "status": "ok" if stats["active"] > 0 else "degraded",
1027
+ **stats,
1028
+ }
1029
+
1030
+
1031
+ 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,446 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 auto_remove_invalid_accounts(self) -> bool:
303
+ value = self.data.get("auto_remove_invalid_accounts", False)
304
+ if isinstance(value, str):
305
+ return value.strip().lower() in {"1", "true", "yes", "on"}
306
+ return bool(value)
307
+
308
+ @property
309
+ def auto_remove_rate_limited_accounts(self) -> bool:
310
+ value = self.data.get("auto_remove_rate_limited_accounts", False)
311
+ if isinstance(value, str):
312
+ return value.strip().lower() in {"1", "true", "yes", "on"}
313
+ return bool(value)
314
+
315
+ @property
316
+ def log_levels(self) -> list[str]:
317
+ levels = self.data.get("log_levels")
318
+ if not isinstance(levels, list):
319
+ return []
320
+ allowed = {"debug", "info", "warning", "error"}
321
+ return [level for item in levels if (level := str(item or "").strip().lower()) in allowed]
322
+
323
+ @property
324
+ def sensitive_words(self) -> list[str]:
325
+ words = self.data.get("sensitive_words")
326
+ return [word for item in words if (word := str(item or "").strip())] if isinstance(words, list) else []
327
+
328
+ @property
329
+ def ai_review(self) -> dict[str, object]:
330
+ value = self.data.get("ai_review")
331
+ return value if isinstance(value, dict) else {}
332
+
333
+ @property
334
+ def global_system_prompt(self) -> str:
335
+ return str(self.data.get("global_system_prompt") or "").strip()
336
+
337
+ @property
338
+ def images_dir(self) -> Path:
339
+ path = DATA_DIR / "images"
340
+ path.mkdir(parents=True, exist_ok=True)
341
+ return path
342
+
343
+ @property
344
+ def image_thumbnails_dir(self) -> Path:
345
+ path = DATA_DIR / "image_thumbnails"
346
+ path.mkdir(parents=True, exist_ok=True)
347
+ return path
348
+
349
+ def cleanup_old_images(self) -> int:
350
+ cutoff = time.time() - self.image_retention_days * 86400
351
+ removed = 0
352
+ for path in self.images_dir.rglob("*"):
353
+ if path.is_file() and path.stat().st_mtime < cutoff:
354
+ path.unlink()
355
+ removed += 1
356
+ for path in sorted((p for p in self.images_dir.rglob("*") if p.is_dir()), key=lambda p: len(p.parts), reverse=True):
357
+ try:
358
+ path.rmdir()
359
+ except OSError:
360
+ pass
361
+ return removed
362
+
363
+ @property
364
+ def base_url(self) -> str:
365
+ return str(
366
+ os.getenv("CHATGPT2API_BASE_URL")
367
+ or self.data.get("base_url")
368
+ or ""
369
+ ).strip().rstrip("/")
370
+
371
+ @property
372
+ def app_version(self) -> str:
373
+ try:
374
+ value = VERSION_FILE.read_text(encoding="utf-8").strip()
375
+ except FileNotFoundError:
376
+ return "0.0.0"
377
+ return value or "0.0.0"
378
+
379
+ def get(self) -> dict[str, object]:
380
+ data = dict(self.data)
381
+ data["refresh_account_interval_minute"] = self.refresh_account_interval_minute
382
+ data["image_retention_days"] = self.image_retention_days
383
+ data["image_poll_timeout_secs"] = self.image_poll_timeout_secs
384
+ data["image_poll_interval_secs"] = self.image_poll_interval_secs
385
+ data["image_poll_initial_wait_secs"] = self.image_poll_initial_wait_secs
386
+ data["image_account_concurrency"] = self.image_account_concurrency
387
+ data["auto_remove_invalid_accounts"] = self.auto_remove_invalid_accounts
388
+ data["auto_remove_rate_limited_accounts"] = self.auto_remove_rate_limited_accounts
389
+ data["log_levels"] = self.log_levels
390
+ data["sensitive_words"] = self.sensitive_words
391
+ data["ai_review"] = self.ai_review
392
+ data["global_system_prompt"] = self.global_system_prompt
393
+ data["backup"] = self.get_backup_settings()
394
+ data["image_storage"] = self.get_image_storage_settings()
395
+ data["chat_completion_cache"] = self.get_chat_completion_cache_settings()
396
+ data.pop("auth-key", None)
397
+ return data
398
+
399
+ def get_proxy_settings(self) -> str:
400
+ return str(self.data.get("proxy") or "").strip()
401
+
402
+ def update(self, data: dict[str, object]) -> dict[str, object]:
403
+ next_data = dict(self.data)
404
+ next_data.update(dict(data or {}))
405
+ if "backup" in next_data:
406
+ next_data["backup"] = _normalize_backup_settings(next_data.get("backup"))
407
+ if "image_storage" in next_data:
408
+ next_data["image_storage"] = _normalize_image_storage_settings(next_data.get("image_storage"))
409
+ _validate_image_storage_settings(next_data["image_storage"])
410
+ if "chat_completion_cache" in next_data:
411
+ next_data["chat_completion_cache"] = _normalize_chat_completion_cache_settings(
412
+ next_data.get("chat_completion_cache")
413
+ )
414
+ next_data.pop("backup_state", None)
415
+ self.data = next_data
416
+ self._save()
417
+ return self.get()
418
+
419
+ def get_backup_settings(self) -> dict[str, object]:
420
+ return _normalize_backup_settings(self.data.get("backup"))
421
+
422
+ def get_image_storage_settings(self) -> dict[str, object]:
423
+ return _normalize_image_storage_settings(self.data.get("image_storage"))
424
+
425
+ def get_chat_completion_cache_settings(self) -> dict[str, object]:
426
+ return _normalize_chat_completion_cache_settings(self.data.get("chat_completion_cache"))
427
+
428
+ def get_storage_backend(self) -> StorageBackend:
429
+ """获取存储后端实例(单例)"""
430
+ if self._storage_backend is None:
431
+ from services.storage.factory import create_storage_backend
432
+ self._storage_backend = create_storage_backend(DATA_DIR)
433
+ return self._storage_backend
434
+
435
+
436
+ def load_backup_state() -> dict[str, object]:
437
+ return _normalize_backup_state(_read_json_object(BACKUP_STATE_FILE, name="backup_state.json"))
438
+
439
+
440
+ def save_backup_state(state: dict[str, object]) -> dict[str, object]:
441
+ normalized = _normalize_backup_state(state)
442
+ BACKUP_STATE_FILE.write_text(json.dumps(normalized, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
443
+ return normalized
444
+
445
+
446
+ config = ConfigStore(CONFIG_FILE)
services/content_filter.py ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ from curl_cffi import requests
6
+ from fastapi import HTTPException
7
+
8
+ from services.config import config
9
+ from services.proxy_service import proxy_settings
10
+ from utils.log import logger
11
+
12
+ DEFAULT_REVIEW_PROMPT = "判断用户请求是否允许。只回答 ALLOW 或 REJECT。"
13
+
14
+ # Strip base64 image data URIs before review: a text-only review model can't
15
+ # analyze image bytes, and a single inlined image easily blows past the token
16
+ # budget of the upstream review service.
17
+ _BASE64_DATA_URI = re.compile(r"data:[\w/.+;-]+;base64,[A-Za-z0-9+/=]+")
18
+
19
+ # Cap aligned to the upstream review service's max context. If text still
20
+ # exceeds the cap after base64 stripping, keep equal head/tail halves so both
21
+ # the system prompt and the most recent user message survive.
22
+ _MAX_REVIEW_TEXT_LEN = 100_000
23
+ _TRUNCATION_MARKER = "\n…[truncated]…\n"
24
+
25
+
26
+ def _text(value: object) -> str:
27
+ if isinstance(value, str):
28
+ return value
29
+ if isinstance(value, list):
30
+ return "\n".join(_text(item) for item in value)
31
+ if isinstance(value, dict):
32
+ return "\n".join(_text(value.get(key)) for key in ("text", "input_text", "content", "input", "instructions", "system", "prompt"))
33
+ return ""
34
+
35
+
36
+ def request_text(*values: object) -> str:
37
+ return "\n".join(part for value in values if (part := _text(value).strip()))
38
+
39
+
40
+ def _sanitize_for_review(text: str) -> tuple[str, dict[str, int]]:
41
+ """Strip base64 data URIs and truncate to the review-service context limit.
42
+
43
+ Returns (sanitized_text, stats) where stats carries base64_blocks_stripped
44
+ and truncated_chars so callers can emit structured logs.
45
+ """
46
+ sanitized, base64_blocks_stripped = _BASE64_DATA_URI.subn("[image]", text)
47
+ truncated_chars = 0
48
+ if len(sanitized) > _MAX_REVIEW_TEXT_LEN:
49
+ # Reserve marker space so the result stays within the cap.
50
+ half = (_MAX_REVIEW_TEXT_LEN - len(_TRUNCATION_MARKER)) // 2
51
+ truncated_chars = len(sanitized) - 2 * half
52
+ sanitized = sanitized[:half] + _TRUNCATION_MARKER + sanitized[-half:]
53
+ stats = {
54
+ "base64_blocks_stripped": base64_blocks_stripped,
55
+ "truncated_chars": truncated_chars,
56
+ }
57
+ return sanitized, stats
58
+
59
+
60
+ def _extract_review_decision(data: object) -> str | None:
61
+ """Defensively pull the decision text out of the review service response.
62
+
63
+ Returns None when the response shape doesn't match the OpenAI chat-completion
64
+ contract (e.g. {"error": ...} with no choices). The caller treats None as
65
+ "undecided" and applies the configured fail-open policy.
66
+ """
67
+ if not isinstance(data, dict):
68
+ return None
69
+ choices = data.get("choices")
70
+ if not isinstance(choices, list) or not choices:
71
+ return None
72
+ first = choices[0]
73
+ if not isinstance(first, dict):
74
+ return None
75
+ message = first.get("message")
76
+ if not isinstance(message, dict):
77
+ return None
78
+ content = message.get("content")
79
+ if content is None:
80
+ return None
81
+ return str(content).strip().lower()
82
+
83
+
84
+ def _is_allow_decision(decision: str) -> bool:
85
+ return decision.startswith(("allow", "pass", "true", "yes", "通过", "允许", "安全"))
86
+
87
+
88
+ def _is_reject_decision(decision: str) -> bool:
89
+ return decision.startswith(("reject", "deny", "block", "false", "no", "拒绝", "不允许", "违规", "禁止"))
90
+
91
+
92
+ def _resolve_fail_open(review: dict) -> bool:
93
+ """Resolve fail_open from review config. Defaults to True."""
94
+ value = review.get("fail_open")
95
+ if value is None:
96
+ return True
97
+ if isinstance(value, bool):
98
+ return value
99
+ if isinstance(value, str):
100
+ return value.strip().lower() in {"1", "true", "yes", "on"}
101
+ return bool(value)
102
+
103
+
104
+ def check_request(text: str) -> None:
105
+ text = str(text or "")
106
+ if not text.strip():
107
+ return
108
+ # Local sensitive-word match runs on the raw text (cheap, no network).
109
+ for word in config.sensitive_words:
110
+ if word in text:
111
+ raise HTTPException(status_code=400, detail={"error": "检测到敏感词,拒绝本次任务"})
112
+ review = config.ai_review
113
+ if not review.get("enabled"):
114
+ return
115
+ base_url = str(review.get("base_url") or "").strip().rstrip("/")
116
+ api_key = str(review.get("api_key") or "").strip()
117
+ model = str(review.get("model") or "").strip()
118
+ if not base_url or not api_key or not model:
119
+ raise HTTPException(status_code=400, detail={"error": "ai review config is incomplete"})
120
+
121
+ fail_open = _resolve_fail_open(review)
122
+
123
+ review_text, sanitize_stats = _sanitize_for_review(text)
124
+ if sanitize_stats["base64_blocks_stripped"] or sanitize_stats["truncated_chars"]:
125
+ logger.info({
126
+ "event": "ai_review_text_sanitized",
127
+ "original_text_len": len(text),
128
+ "review_text_len": len(review_text),
129
+ **sanitize_stats,
130
+ })
131
+ prompt = str(review.get("prompt") or DEFAULT_REVIEW_PROMPT).strip()
132
+ content = f"{prompt}\n\n用户请求:\n{review_text}\n\n只回答 ALLOW 或 REJECT。"
133
+
134
+ # fail_open=True (default): on upstream failure or ambiguous reply, let the
135
+ # request through. The review is a soft safety net; one missed review is
136
+ # preferable to a 5xx storm when the review service is flaky. Set
137
+ # config.ai_review.fail_open=false for strict-compliance deployments.
138
+ def _on_failure(event_payload: dict) -> None:
139
+ logger.warning(event_payload)
140
+ if not fail_open:
141
+ raise HTTPException(
142
+ status_code=503,
143
+ detail={"error": "AI 审核服务暂时不可用,请稍后重试"},
144
+ )
145
+
146
+ try:
147
+ response = requests.post(
148
+ f"{base_url}/v1/chat/completions",
149
+ headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
150
+ json={"model": model, "messages": [{"role": "user", "content": content}], "temperature": 0},
151
+ timeout=60,
152
+ **proxy_settings.build_session_kwargs(),
153
+ )
154
+ except Exception as exc:
155
+ _on_failure({
156
+ "event": "ai_review_request_failed",
157
+ "error": str(exc),
158
+ "error_type": exc.__class__.__name__,
159
+ "review_text_len": len(review_text),
160
+ "original_text_len": len(text),
161
+ })
162
+ return
163
+
164
+ try:
165
+ data = response.json()
166
+ except Exception as exc:
167
+ _on_failure({
168
+ "event": "ai_review_response_not_json",
169
+ "status_code": response.status_code,
170
+ "body_preview": str(response.text or "")[:200],
171
+ "error": str(exc),
172
+ })
173
+ return
174
+
175
+ decision = _extract_review_decision(data)
176
+ if decision is None:
177
+ _on_failure({
178
+ "event": "ai_review_malformed_response",
179
+ "status_code": response.status_code,
180
+ "body_preview": str(data)[:300],
181
+ "review_text_len": len(review_text),
182
+ "original_text_len": len(text),
183
+ })
184
+ return
185
+
186
+ if _is_allow_decision(decision):
187
+ return
188
+ if _is_reject_decision(decision):
189
+ raise HTTPException(status_code=400, detail={"error": "AI 审核未通过,拒绝本次任务"})
190
+ # Ambiguous decisions (e.g. "MAYBE", empty content) fall back to fail-open policy.
191
+ _on_failure({
192
+ "event": "ai_review_ambiguous_decision",
193
+ "decision": decision[:100],
194
+ "review_text_len": len(review_text),
195
+ })
196
+ return
services/cpa_service.py ADDED
@@ -0,0 +1,315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CLIProxyAPI integration for browsing remote auth files and importing selected tokens."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import threading
7
+ import uuid
8
+ from concurrent.futures import ThreadPoolExecutor, as_completed
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from threading import Lock
12
+
13
+ from curl_cffi.requests import Session
14
+
15
+ from services.account_service import account_service
16
+ from services.config import DATA_DIR
17
+ from services.proxy_service import proxy_settings
18
+
19
+
20
+ CPA_CONFIG_FILE = DATA_DIR / "cpa_config.json"
21
+
22
+
23
+ def _new_id() -> str:
24
+ return uuid.uuid4().hex[:12]
25
+
26
+
27
+ def _now_iso() -> str:
28
+ return datetime.now(timezone.utc).isoformat()
29
+
30
+
31
+ def _normalize_import_job(raw: object, *, fail_unfinished: bool) -> dict | None:
32
+ if not isinstance(raw, dict):
33
+ return None
34
+ status = str(raw.get("status") or "failed").strip() or "failed"
35
+ if fail_unfinished and status in {"pending", "running"}:
36
+ status = "failed"
37
+ return {
38
+ "job_id": str(raw.get("job_id") or uuid.uuid4().hex).strip(),
39
+ "status": status,
40
+ "created_at": str(raw.get("created_at") or _now_iso()).strip() or _now_iso(),
41
+ "updated_at": str(raw.get("updated_at") or raw.get("created_at") or _now_iso()).strip() or _now_iso(),
42
+ "total": int(raw.get("total") or 0),
43
+ "completed": int(raw.get("completed") or 0),
44
+ "added": int(raw.get("added") or 0),
45
+ "skipped": int(raw.get("skipped") or 0),
46
+ "refreshed": int(raw.get("refreshed") or 0),
47
+ "failed": int(raw.get("failed") or 0),
48
+ "errors": raw.get("errors") if isinstance(raw.get("errors"), list) else [],
49
+ }
50
+
51
+
52
+ def _normalize_pool(raw: dict) -> dict:
53
+ return {
54
+ "id": str(raw.get("id") or _new_id()).strip(),
55
+ "name": str(raw.get("name") or "").strip(),
56
+ "base_url": str(raw.get("base_url") or "").strip(),
57
+ "secret_key": str(raw.get("secret_key") or "").strip(),
58
+ "import_job": _normalize_import_job(raw.get("import_job"), fail_unfinished=True),
59
+ }
60
+
61
+
62
+ def _management_headers(secret_key: str) -> dict[str, str]:
63
+ return {
64
+ "Authorization": f"Bearer {secret_key}",
65
+ "Accept": "application/json",
66
+ }
67
+
68
+
69
+ class CPAConfig:
70
+ def __init__(self, store_file: Path):
71
+ self._store_file = store_file
72
+ self._lock = Lock()
73
+ self._pools: list[dict] = self._load()
74
+
75
+ def _load(self) -> list[dict]:
76
+ if not self._store_file.exists():
77
+ return []
78
+ try:
79
+ raw = json.loads(self._store_file.read_text(encoding="utf-8"))
80
+ if isinstance(raw, dict) and "base_url" in raw:
81
+ pool = _normalize_pool(raw)
82
+ return [pool] if pool["base_url"] else []
83
+ if isinstance(raw, list):
84
+ return [_normalize_pool(item) for item in raw if isinstance(item, dict)]
85
+ except Exception:
86
+ pass
87
+ return []
88
+
89
+ def _save(self) -> None:
90
+ self._store_file.parent.mkdir(parents=True, exist_ok=True)
91
+ self._store_file.write_text(json.dumps(self._pools, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
92
+
93
+ def list_pools(self) -> list[dict]:
94
+ with self._lock:
95
+ return [dict(pool) for pool in self._pools]
96
+
97
+ def get_pool(self, pool_id: str) -> dict | None:
98
+ with self._lock:
99
+ for pool in self._pools:
100
+ if pool["id"] == pool_id:
101
+ return dict(pool)
102
+ return None
103
+
104
+ def add_pool(self, name: str, base_url: str, secret_key: str) -> dict:
105
+ pool = _normalize_pool({"id": _new_id(), "name": name, "base_url": base_url, "secret_key": secret_key})
106
+ with self._lock:
107
+ self._pools.append(pool)
108
+ self._save()
109
+ return dict(pool)
110
+
111
+ def update_pool(self, pool_id: str, updates: dict) -> dict | None:
112
+ with self._lock:
113
+ for index, pool in enumerate(self._pools):
114
+ if pool["id"] != pool_id:
115
+ continue
116
+ merged = {**pool, **{key: value for key, value in updates.items() if value is not None}, "id": pool_id}
117
+ self._pools[index] = _normalize_pool(merged)
118
+ self._save()
119
+ return dict(self._pools[index])
120
+ return None
121
+
122
+ def delete_pool(self, pool_id: str) -> bool:
123
+ with self._lock:
124
+ before = len(self._pools)
125
+ self._pools = [pool for pool in self._pools if pool["id"] != pool_id]
126
+ if len(self._pools) < before:
127
+ self._save()
128
+ return True
129
+ return False
130
+
131
+ def set_import_job(self, pool_id: str, import_job: dict | None) -> dict | None:
132
+ with self._lock:
133
+ for index, pool in enumerate(self._pools):
134
+ if pool["id"] != pool_id:
135
+ continue
136
+ next_pool = dict(pool)
137
+ next_pool["import_job"] = _normalize_import_job(import_job, fail_unfinished=False)
138
+ self._pools[index] = next_pool
139
+ self._save()
140
+ return dict(next_pool)
141
+ return None
142
+
143
+ def get_import_job(self, pool_id: str) -> dict | None:
144
+ with self._lock:
145
+ for pool in self._pools:
146
+ if pool["id"] == pool_id:
147
+ job = pool.get("import_job")
148
+ return dict(job) if isinstance(job, dict) else None
149
+ return None
150
+
151
+
152
+ def list_remote_files(pool: dict) -> list[dict]:
153
+ base_url = str(pool.get("base_url") or "").strip()
154
+ secret_key = str(pool.get("secret_key") or "").strip()
155
+ if not base_url or not secret_key:
156
+ return []
157
+
158
+ url = f"{base_url.rstrip('/')}/v0/management/auth-files"
159
+ session = Session(**proxy_settings.build_session_kwargs(verify=True))
160
+ try:
161
+ response = session.get(url, headers=_management_headers(secret_key), timeout=30)
162
+ if not response.ok:
163
+ raise RuntimeError(f"remote list failed: HTTP {response.status_code}")
164
+ payload = response.json()
165
+ finally:
166
+ session.close()
167
+
168
+ files = payload.get("files") if isinstance(payload, dict) else None
169
+ if not isinstance(files, list):
170
+ raise RuntimeError("remote list payload is invalid")
171
+
172
+ items: list[dict] = []
173
+ for item in files:
174
+ if not isinstance(item, dict):
175
+ continue
176
+ name = str(item.get("name") or "").strip()
177
+ email = str(item.get("email") or item.get("account") or "").strip()
178
+ if not name:
179
+ continue
180
+ items.append({"name": name, "email": email})
181
+ return items
182
+
183
+
184
+ def fetch_remote_access_token(pool: dict, file_name: str) -> tuple[str | None, str | None]:
185
+ base_url = str(pool.get("base_url") or "").strip()
186
+ secret_key = str(pool.get("secret_key") or "").strip()
187
+ file_name = str(file_name or "").strip()
188
+ if not base_url or not secret_key or not file_name:
189
+ return None, "invalid request"
190
+
191
+ url = f"{base_url.rstrip('/')}/v0/management/auth-files/download"
192
+ session = Session(**proxy_settings.build_session_kwargs(verify=True))
193
+ try:
194
+ response = session.get(url, headers=_management_headers(secret_key), params={"name": file_name}, timeout=30)
195
+ if not response.ok:
196
+ return None, f"HTTP {response.status_code}"
197
+ payload = response.json()
198
+ except Exception as exc:
199
+ return None, str(exc)
200
+ finally:
201
+ session.close()
202
+
203
+ if not isinstance(payload, dict):
204
+ return None, "invalid payload"
205
+
206
+ access_token = str(payload.get("access_token") or "").strip()
207
+ if not access_token:
208
+ return None, "missing access_token"
209
+ return access_token, None
210
+
211
+
212
+ class CPAImportService:
213
+ def __init__(self, cpa_config: CPAConfig):
214
+ self._config = cpa_config
215
+
216
+ def start_import(self, pool: dict, selected_files: list[str]) -> dict:
217
+ names = [str(name or "").strip() for name in selected_files if str(name or "").strip()]
218
+ if not names:
219
+ raise ValueError("selected files is required")
220
+
221
+ pool_id = str(pool.get("id") or "").strip()
222
+ job = {
223
+ "job_id": uuid.uuid4().hex,
224
+ "status": "pending",
225
+ "created_at": _now_iso(),
226
+ "updated_at": _now_iso(),
227
+ "total": len(names),
228
+ "completed": 0,
229
+ "added": 0,
230
+ "skipped": 0,
231
+ "refreshed": 0,
232
+ "failed": 0,
233
+ "errors": [],
234
+ }
235
+ saved_pool = self._config.set_import_job(pool_id, job)
236
+ if saved_pool is None:
237
+ raise ValueError("pool not found")
238
+
239
+ thread = threading.Thread(
240
+ target=self._run_import,
241
+ args=(pool_id, pool, names),
242
+ name=f"cpa-import-{pool_id}",
243
+ daemon=True,
244
+ )
245
+ thread.start()
246
+ return dict(saved_pool.get("import_job") or job)
247
+
248
+ def _update_job(self, pool_id: str, **updates) -> dict | None:
249
+ current = self._config.get_import_job(pool_id)
250
+ if current is None:
251
+ return None
252
+ next_job = {**current, **updates, "updated_at": _now_iso()}
253
+ pool = self._config.set_import_job(pool_id, next_job)
254
+ if pool is None:
255
+ return None
256
+ job = pool.get("import_job")
257
+ return dict(job) if isinstance(job, dict) else None
258
+
259
+ def _append_error(self, pool_id: str, file_name: str, message: str) -> None:
260
+ current = self._config.get_import_job(pool_id)
261
+ if current is None:
262
+ return
263
+ errors = list(current.get("errors") or [])
264
+ errors.append({"name": file_name, "error": message})
265
+ self._update_job(pool_id, errors=errors, failed=len(errors))
266
+
267
+ def _run_import(self, pool_id: str, pool: dict, names: list[str]) -> None:
268
+ self._update_job(pool_id, status="running")
269
+
270
+ tokens: list[str] = []
271
+ max_workers = min(16, max(1, len(names)))
272
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
273
+ future_map = {executor.submit(fetch_remote_access_token, pool, name): name for name in names}
274
+ for future in as_completed(future_map):
275
+ file_name = future_map[future]
276
+ try:
277
+ token, error = future.result()
278
+ except Exception as exc:
279
+ token, error = None, str(exc)
280
+
281
+ if token:
282
+ tokens.append(token)
283
+ else:
284
+ self._append_error(pool_id, file_name, error or "unknown error")
285
+
286
+ current = self._config.get_import_job(pool_id) or {}
287
+ failed = len(current.get("errors") or [])
288
+ self._update_job(pool_id, completed=int(current.get("completed") or 0) + 1, failed=failed)
289
+
290
+ if not tokens:
291
+ current = self._config.get_import_job(pool_id) or {}
292
+ self._update_job(
293
+ pool_id,
294
+ status="failed",
295
+ completed=int(current.get("total") or 0),
296
+ failed=len(current.get("errors") or []),
297
+ )
298
+ return
299
+
300
+ add_result = account_service.add_accounts(tokens, 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/image_service.py ADDED
@@ -0,0 +1,356 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import shutil
5
+ import threading
6
+ import time
7
+ import zipfile
8
+ from pathlib import Path
9
+
10
+ from fastapi import HTTPException
11
+ from fastapi.responses import FileResponse, Response
12
+ from PIL import Image, ImageOps
13
+
14
+ from services.config import config
15
+ from services.image_storage_service import image_storage_service
16
+ from services.image_tags_service import load_tags, remove_tags
17
+ from utils.log import logger
18
+
19
+ THUMBNAIL_SIZE = (320, 320)
20
+
21
+
22
+ def _cleanup_empty_dirs(root: Path) -> None:
23
+ for path in sorted((p for p in root.rglob("*") if p.is_dir()), key=lambda p: len(p.parts), reverse=True):
24
+ try:
25
+ path.rmdir()
26
+ except OSError:
27
+ pass
28
+
29
+
30
+ def _safe_relative_path(path: str) -> str:
31
+ value = str(path or "").strip().replace("\\", "/").lstrip("/")
32
+ if not value:
33
+ raise HTTPException(status_code=404, detail="image not found")
34
+ parts = Path(value).parts
35
+ if any(part in {"", ".", ".."} for part in parts):
36
+ raise HTTPException(status_code=404, detail="image not found")
37
+ return Path(*parts).as_posix()
38
+
39
+
40
+ def _safe_image_path(relative_path: str) -> Path:
41
+ rel = _safe_relative_path(relative_path)
42
+ root = config.images_dir.resolve()
43
+ path = (root / rel).resolve()
44
+ try:
45
+ path.relative_to(root)
46
+ except ValueError as exc:
47
+ raise HTTPException(status_code=404, detail="image not found") from exc
48
+ if not path.is_file():
49
+ raise HTTPException(status_code=404, detail="image not found")
50
+ return path
51
+
52
+
53
+ def get_image_response(relative_path: str) -> FileResponse | Response:
54
+ if image_storage_service.has_local(relative_path):
55
+ return FileResponse(_safe_image_path(relative_path))
56
+ return Response(content=image_storage_service.get_bytes(relative_path), media_type="image/png")
57
+
58
+
59
+ def _thumbnail_path(relative_path: str) -> Path:
60
+ rel = _safe_relative_path(relative_path)
61
+ return config.image_thumbnails_dir / f"{rel}.png"
62
+
63
+
64
+ def thumbnail_url(base_url: str, relative_path: str) -> str:
65
+ return f"{base_url.rstrip('/')}/image-thumbnails/{_safe_relative_path(relative_path)}"
66
+
67
+
68
+ def _image_dimensions(path: Path) -> tuple[int, int] | None:
69
+ try:
70
+ with Image.open(path) as image:
71
+ return image.size
72
+ except Exception:
73
+ return None
74
+
75
+
76
+ def ensure_thumbnail(relative_path: str) -> Path:
77
+ target = _thumbnail_path(relative_path)
78
+ source_mtime = 0.0
79
+ source: Path | None = None
80
+ if image_storage_service.has_local(relative_path):
81
+ source = _safe_image_path(relative_path)
82
+ source_mtime = source.stat().st_mtime
83
+ if target.exists() and (not source_mtime or target.stat().st_mtime >= source_mtime):
84
+ return target
85
+
86
+ target.parent.mkdir(parents=True, exist_ok=True)
87
+ try:
88
+ image_source = source if source is not None else io.BytesIO(image_storage_service.get_bytes(relative_path))
89
+ with Image.open(image_source) as image:
90
+ image = ImageOps.exif_transpose(image)
91
+ if image.mode not in {"RGB", "RGBA"}:
92
+ image = image.convert("RGBA" if "A" in image.getbands() else "RGB")
93
+ image.thumbnail(THUMBNAIL_SIZE, Image.Resampling.LANCZOS)
94
+ image.save(target, format="PNG", optimize=True)
95
+ except HTTPException:
96
+ raise
97
+ except Exception as exc:
98
+ raise HTTPException(status_code=422, detail="failed to create thumbnail") from exc
99
+ return target
100
+
101
+
102
+ def get_thumbnail_response(relative_path: str) -> FileResponse:
103
+ return FileResponse(ensure_thumbnail(relative_path))
104
+
105
+
106
+ def get_image_download_response(relative_path: str) -> FileResponse:
107
+ if image_storage_service.has_local(relative_path):
108
+ path = _safe_image_path(relative_path)
109
+ return FileResponse(path, filename=path.name)
110
+ rel = _safe_relative_path(relative_path)
111
+ return Response(
112
+ content=image_storage_service.get_bytes(rel),
113
+ media_type="image/png",
114
+ headers={"Content-Disposition": f'attachment; filename="{Path(rel).name}"'},
115
+ )
116
+
117
+
118
+ def cleanup_image_thumbnails() -> int:
119
+ thumbnails_root = config.image_thumbnails_dir
120
+ removed = 0
121
+ for path in thumbnails_root.rglob("*"):
122
+ if not path.is_file():
123
+ continue
124
+ rel = path.relative_to(thumbnails_root).as_posix()
125
+ if not rel.endswith(".png") or not image_storage_service.exists(rel[:-4]):
126
+ path.unlink()
127
+ removed += 1
128
+ _cleanup_empty_dirs(thumbnails_root)
129
+ return removed
130
+
131
+ def list_images(base_url: str, start_date: str = "", end_date: str = "") -> dict[str, object]:
132
+ config.cleanup_old_images()
133
+ cleanup_image_thumbnails()
134
+ all_tags = load_tags()
135
+ items = [
136
+ {
137
+ **item,
138
+ "url": str(item.get("url") or f"{base_url.rstrip('/')}/images/{item['path']}"),
139
+ "thumbnail_url": thumbnail_url(base_url, str(item["path"])),
140
+ "tags": all_tags.get(str(item["path"]), []),
141
+ }
142
+ for item in image_storage_service.list_items(base_url, start_date, end_date)
143
+ ]
144
+ groups: dict[str, list[dict[str, object]]] = {}
145
+ for item in items:
146
+ groups.setdefault(str(item["date"]), []).append(item)
147
+ return {"items": items, "groups": [{"date": key, "items": value} for key, value in groups.items()]}
148
+
149
+
150
+ def delete_images(paths: list[str] | None = None, start_date: str = "", end_date: str = "", all_matching: bool = False) -> dict[str, int]:
151
+ root = config.images_dir.resolve()
152
+ targets = [
153
+ str(item["path"])
154
+ for item in image_storage_service.list_items("", start_date=start_date, end_date=end_date)
155
+ ] if all_matching else (paths or [])
156
+ removed = 0
157
+ for item in targets:
158
+ path = (root / item).resolve()
159
+ try:
160
+ path.relative_to(root)
161
+ except ValueError:
162
+ continue
163
+ if image_storage_service.delete(item):
164
+ removed += 1
165
+ for thumbnail in (_thumbnail_path(item), config.image_thumbnails_dir / _safe_relative_path(item)):
166
+ if thumbnail.is_file():
167
+ thumbnail.unlink()
168
+ remove_tags(item)
169
+ _cleanup_empty_dirs(root)
170
+ _cleanup_empty_dirs(config.image_thumbnails_dir)
171
+ return {"removed": removed}
172
+
173
+
174
+ def download_images_zip(paths: list[str]) -> io.BytesIO:
175
+ root = config.images_dir.resolve()
176
+ buf = io.BytesIO()
177
+ added = 0
178
+ used_names: set[str] = set()
179
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
180
+ for item in paths:
181
+ rel = _safe_relative_path(item)
182
+ path = (root / rel).resolve()
183
+ payload: bytes | None = None
184
+ try:
185
+ path.relative_to(root)
186
+ except ValueError:
187
+ continue
188
+ if path.is_file():
189
+ payload = path.read_bytes()
190
+ else:
191
+ try:
192
+ payload = image_storage_service.get_bytes(rel)
193
+ except Exception:
194
+ continue
195
+ name = path.name
196
+ if name in used_names:
197
+ stem = path.stem
198
+ suffix = path.suffix
199
+ counter = 2
200
+ while f"{stem}_{counter}{suffix}" in used_names:
201
+ counter += 1
202
+ name = f"{stem}_{counter}{suffix}"
203
+ used_names.add(name)
204
+ zf.writestr(name, payload)
205
+ added += 1
206
+ if added == 0:
207
+ raise HTTPException(status_code=404, detail="no images found")
208
+ buf.seek(0)
209
+ return buf
210
+ def storage_stats() -> dict:
211
+ import shutil
212
+ usage = shutil.disk_usage(config.images_dir)
213
+ total_mb = usage.total // (1024 * 1024)
214
+ used_mb = usage.used // (1024 * 1024)
215
+ free_mb = usage.free // (1024 * 1024)
216
+
217
+ image_count = 0
218
+ image_size = 0
219
+ for p in config.images_dir.rglob("*"):
220
+ if p.is_file():
221
+ image_count += 1
222
+ image_size += p.stat().st_size
223
+
224
+ return {
225
+ "disk_total_mb": total_mb,
226
+ "disk_used_mb": used_mb,
227
+ "disk_free_mb": free_mb,
228
+ "image_count": image_count,
229
+ "image_size_mb": image_size // (1024 * 1024),
230
+ "image_size_bytes": image_size,
231
+ }
232
+
233
+
234
+ def compress_images(quality: int = 60) -> dict:
235
+ """重新压缩所有图片,返回节省的空间"""
236
+ saved = 0
237
+ count = 0
238
+ for p in sorted(config.images_dir.rglob("*.png")):
239
+ if not p.is_file():
240
+ continue
241
+ try:
242
+ orig = p.stat().st_size
243
+ with Image.open(p) as img:
244
+ img = ImageOps.exif_transpose(img)
245
+ img.save(str(p) + ".tmp", format="PNG", optimize=True)
246
+ new_size = Path(str(p) + ".tmp").stat().st_size
247
+ if new_size < orig:
248
+ Path(str(p) + ".tmp").replace(p)
249
+ saved += orig - new_size
250
+ count += 1
251
+ else:
252
+ Path(str(p) + ".tmp").unlink()
253
+ except Exception:
254
+ pass
255
+ return {"compressed": count, "saved_bytes": saved, "saved_mb": saved // (1024 * 1024)}
256
+
257
+
258
+ def delete_to_target(target_free_mb: int, dry_run: bool = False) -> dict:
259
+ """删除最旧的图片直到剩余空间达到 target_free_mb"""
260
+ import shutil
261
+ usage = shutil.disk_usage(config.images_dir)
262
+ current_free = usage.free // (1024 * 1024)
263
+ if current_free >= target_free_mb and not dry_run:
264
+ return {"removed": 0, "current_free_mb": current_free, "target_free_mb": target_free_mb, "done": True}
265
+
266
+ files = sorted(
267
+ (p for p in config.images_dir.rglob("*.png") if p.is_file()),
268
+ key=lambda p: p.stat().st_mtime,
269
+ )
270
+ removed = 0
271
+ freed = 0
272
+ for p in files:
273
+ if current_free + freed // (1024 * 1024) >= target_free_mb:
274
+ break
275
+ size = p.stat().st_size
276
+ if not dry_run:
277
+ rel = p.relative_to(config.images_dir).as_posix()
278
+ for tp in (_thumbnail_path(rel), config.image_thumbnails_dir / _safe_relative_path(rel)):
279
+ if tp.is_file():
280
+ tp.unlink()
281
+ remove_tags(rel)
282
+ p.unlink()
283
+ freed += size
284
+ removed += 1
285
+
286
+ if not dry_run:
287
+ _cleanup_empty_dirs(config.images_dir)
288
+ _cleanup_empty_dirs(config.image_thumbnails_dir)
289
+
290
+ return {
291
+ "removed": removed,
292
+ "freed_mb": freed // (1024 * 1024),
293
+ "target_free_mb": target_free_mb,
294
+ "current_free_mb": current_free + (freed // (1024 * 1024)),
295
+ "done": (current_free + freed // (1024 * 1024)) >= target_free_mb,
296
+ "dry_run": dry_run,
297
+ }
298
+
299
+
300
+ def download_images_zip(paths: list[str]) -> io.BytesIO:
301
+ root = config.images_dir.resolve()
302
+ buf = io.BytesIO()
303
+ added = 0
304
+ used_names: set[str] = set()
305
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
306
+ for item in paths:
307
+ rel = _safe_relative_path(item)
308
+ path = (root / rel).resolve()
309
+ try:
310
+ path.relative_to(root)
311
+ except ValueError:
312
+ continue
313
+ if not path.is_file():
314
+ continue
315
+ name = path.name
316
+ if name in used_names:
317
+ stem = path.stem
318
+ suffix = path.suffix
319
+ counter = 2
320
+ while f"{stem}_{counter}{suffix}" in used_names:
321
+ counter += 1
322
+ name = f"{stem}_{counter}{suffix}"
323
+ used_names.add(name)
324
+ zf.write(path, name)
325
+ added += 1
326
+ if added == 0:
327
+ raise HTTPException(status_code=404, detail="no images found")
328
+ buf.seek(0)
329
+ return buf
330
+
331
+
332
+ def _auto_cleanup_worker(stop_event: threading.Event) -> None:
333
+ """后台线程:每30分钟检查存储,空间低于阈值自动清理最旧图片"""
334
+ import shutil
335
+ min_free_mb = getattr(config, "image_min_free_mb", None)
336
+ if min_free_mb is None:
337
+ min_free_mb = 500
338
+
339
+ while not stop_event.wait(1800): # 每30分钟
340
+ try:
341
+ config.cleanup_old_images()
342
+ cleanup_image_thumbnails()
343
+ usage = shutil.disk_usage(config.images_dir)
344
+ free_mb = usage.free // (1024 * 1024)
345
+ if free_mb < min_free_mb:
346
+ logger.info({"event": "image_auto_cleanup", "free_mb": free_mb, "min_free_mb": min_free_mb})
347
+ result = delete_to_target(min_free_mb)
348
+ logger.info({"event": "image_auto_cleanup_done", **result})
349
+ except Exception:
350
+ pass
351
+
352
+
353
+ def start_image_cleanup_scheduler(stop_event: threading.Event) -> threading.Thread:
354
+ t = threading.Thread(target=_auto_cleanup_worker, args=(stop_event,), daemon=True, name="image-cleanup")
355
+ t.start()
356
+ return t
services/image_storage_service.py ADDED
@@ -0,0 +1,405 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import io
5
+ import json
6
+ import time
7
+ from dataclasses import dataclass
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from threading import Lock
11
+ from urllib.parse import quote, urlparse
12
+
13
+ from curl_cffi import requests
14
+ from fastapi import HTTPException
15
+ from PIL import Image
16
+
17
+ from services.config import DATA_DIR, config
18
+
19
+ IMAGE_INDEX_FILE = DATA_DIR / "image_index.json"
20
+ IMAGE_INDEX_LOCK = Lock()
21
+ IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"}
22
+
23
+
24
+ class ImageStorageError(RuntimeError):
25
+ pass
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class StoredImage:
30
+ rel: str
31
+ url: str
32
+ storage: str
33
+ size: int
34
+
35
+
36
+ def _clean(value: object) -> str:
37
+ return str(value or "").strip()
38
+
39
+
40
+ def _now_iso() -> str:
41
+ return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
42
+
43
+
44
+ def _safe_relative_path(path: str) -> str:
45
+ value = str(path or "").strip().replace("\\", "/").lstrip("/")
46
+ if not value:
47
+ raise HTTPException(status_code=404, detail="image not found")
48
+ parts = Path(value).parts
49
+ if any(part in {"", ".", ".."} for part in parts):
50
+ raise HTTPException(status_code=404, detail="image not found")
51
+ return Path(*parts).as_posix()
52
+
53
+
54
+ def _image_dimensions(payload: bytes) -> tuple[int, int] | None:
55
+ try:
56
+ with Image.open(io.BytesIO(payload)) as image:
57
+ return image.size
58
+ except Exception:
59
+ return None
60
+
61
+
62
+ def _is_image_rel(path: str) -> bool:
63
+ try:
64
+ safe_rel = _safe_relative_path(path)
65
+ except HTTPException:
66
+ return False
67
+ return Path(safe_rel).suffix.lower() in IMAGE_EXTENSIONS
68
+
69
+
70
+ def _local_image_path(relative_path: str) -> Path:
71
+ rel = _safe_relative_path(relative_path)
72
+ root = config.images_dir.resolve()
73
+ path = (root / rel).resolve()
74
+ try:
75
+ path.relative_to(root)
76
+ except ValueError as exc:
77
+ raise HTTPException(status_code=404, detail="image not found") from exc
78
+ return path
79
+
80
+
81
+ def _read_json_object(path: Path) -> dict[str, object]:
82
+ if not path.exists():
83
+ return {}
84
+ try:
85
+ data = json.loads(path.read_text(encoding="utf-8"))
86
+ except Exception:
87
+ return {}
88
+ return data if isinstance(data, dict) else {}
89
+
90
+
91
+ def _write_json_object(path: Path, data: dict[str, object]) -> None:
92
+ path.parent.mkdir(parents=True, exist_ok=True)
93
+ tmp_path = path.with_suffix(path.suffix + ".tmp")
94
+ tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
95
+ tmp_path.replace(path)
96
+
97
+
98
+ class WebDAVClient:
99
+ def __init__(self, settings: dict[str, object]):
100
+ self.url = _clean(settings.get("webdav_url")).rstrip("/")
101
+ self.username = _clean(settings.get("webdav_username"))
102
+ self.password = _clean(settings.get("webdav_password"))
103
+ self.root_path = _clean(settings.get("webdav_root_path")).strip("/")
104
+ self.session = requests.Session()
105
+
106
+ def _auth_kwargs(self) -> dict[str, object]:
107
+ return {"auth": (self.username, self.password)} if self.username or self.password else {}
108
+
109
+ def _request(self, method: str, url: str, **kwargs):
110
+ response = self.session.request(method, url, timeout=30, **self._auth_kwargs(), **kwargs)
111
+ if response.status_code >= 400 and not (method == "MKCOL" and response.status_code in {405}):
112
+ raise ImageStorageError(f"WebDAV {method} failed: HTTP {response.status_code}")
113
+ return response
114
+
115
+ def remote_url(self, rel: str = "") -> str:
116
+ parts = [part for part in [self.root_path, _safe_relative_path(rel) if rel else ""] if part]
117
+ encoded = "/".join(quote(part, safe="") for item in parts for part in item.split("/") if part)
118
+ return f"{self.url}/{encoded}" if encoded else self.url
119
+
120
+ def ensure_dirs(self, rel: str) -> None:
121
+ parts = [part for part in [self.root_path, Path(_safe_relative_path(rel)).parent.as_posix()] if part and part != "."]
122
+ current = self.url
123
+ for item in "/".join(parts).split("/"):
124
+ if not item:
125
+ continue
126
+ current = f"{current}/{quote(item, safe='')}"
127
+ response = self.session.request("MKCOL", current, timeout=30, **self._auth_kwargs())
128
+ if response.status_code in {201, 405}:
129
+ continue
130
+ if response.status_code >= 400:
131
+ raise ImageStorageError(f"WebDAV MKCOL failed: HTTP {response.status_code}")
132
+
133
+ def put(self, rel: str, payload: bytes, content_type: str = "image/png") -> str:
134
+ self.ensure_dirs(rel)
135
+ url = self.remote_url(rel)
136
+ self._request("PUT", url, data=payload, headers={"Content-Type": content_type})
137
+ return url
138
+
139
+ def get(self, rel: str) -> bytes:
140
+ response = self._request("GET", self.remote_url(rel))
141
+ return bytes(response.content)
142
+
143
+ def delete(self, rel: str) -> bool:
144
+ response = self.session.request("DELETE", self.remote_url(rel), timeout=30, **self._auth_kwargs())
145
+ if response.status_code in {200, 202, 204, 404}:
146
+ return response.status_code != 404
147
+ raise ImageStorageError(f"WebDAV DELETE failed: HTTP {response.status_code}")
148
+
149
+ def test(self) -> dict[str, object]:
150
+ if not self.url:
151
+ return {"ok": False, "status": 0, "error": "WebDAV URL is required"}
152
+ if urlparse(self.url).scheme not in {"http", "https"}:
153
+ return {"ok": False, "status": 0, "error": "invalid WebDAV URL"}
154
+ test_rel = ".chatgpt2api_webdav_test.txt"
155
+ try:
156
+ self.put(test_rel, b"chatgpt2api webdav test\n", content_type="text/plain")
157
+ self.delete(test_rel)
158
+ return {"ok": True, "status": 200, "error": None}
159
+ except ImageStorageError as exc:
160
+ return {"ok": False, "status": 0, "error": str(exc)}
161
+ except Exception as exc:
162
+ return {"ok": False, "status": 0, "error": str(exc) or exc.__class__.__name__}
163
+ finally:
164
+ self.session.close()
165
+
166
+
167
+ class ImageStorageService:
168
+ def __init__(self, index_file: Path = IMAGE_INDEX_FILE):
169
+ self.index_file = index_file
170
+ self._index_lock = IMAGE_INDEX_LOCK
171
+
172
+ def settings(self) -> dict[str, object]:
173
+ return config.get_image_storage_settings()
174
+
175
+ def mode(self) -> str:
176
+ return _clean(self.settings().get("mode")) or "local"
177
+
178
+ def _load_index(self) -> dict[str, dict[str, object]]:
179
+ raw = _read_json_object(self.index_file)
180
+ items = raw.get("items")
181
+ if not isinstance(items, dict):
182
+ return {}
183
+ return {str(key): value for key, value in items.items() if isinstance(value, dict)}
184
+
185
+ def _load_clean_index(self) -> dict[str, dict[str, object]]:
186
+ items = self._load_index()
187
+ return {rel: item for rel, item in items.items() if _is_image_rel(rel)}
188
+
189
+ def _save_index(self, items: dict[str, dict[str, object]]) -> None:
190
+ _write_json_object(self.index_file, {"items": items})
191
+
192
+ def _public_url(self, rel: str, base_url: str | None = None) -> str:
193
+ settings = self.settings()
194
+ public_base_url = _clean(settings.get("public_base_url"))
195
+ if public_base_url:
196
+ return f"{public_base_url.rstrip('/')}/{_safe_relative_path(rel)}"
197
+ return f"{(base_url or config.base_url).rstrip('/')}/images/{_safe_relative_path(rel)}"
198
+
199
+ def make_relative_path(self, image_data: bytes) -> str:
200
+ file_hash = hashlib.md5(image_data).hexdigest()
201
+ filename = f"{int(time.time())}_{file_hash}.png"
202
+ relative_dir = Path(time.strftime("%Y"), time.strftime("%m"), time.strftime("%d"))
203
+ return f"{relative_dir.as_posix()}/{filename}"
204
+
205
+ def save(self, image_data: bytes, base_url: str | None = None) -> StoredImage:
206
+ config.cleanup_old_images()
207
+ rel = self.make_relative_path(image_data)
208
+ mode = self.mode()
209
+ if mode not in {"local", "webdav", "both"}:
210
+ mode = "local"
211
+ stored_local = False
212
+ stored_webdav = False
213
+ remote_url = ""
214
+
215
+ if mode in {"local", "both"}:
216
+ path = _local_image_path(rel)
217
+ path.parent.mkdir(parents=True, exist_ok=True)
218
+ path.write_bytes(image_data)
219
+ stored_local = True
220
+
221
+ if mode in {"webdav", "both"}:
222
+ remote_url = WebDAVClient(self.settings()).put(rel, image_data)
223
+ stored_webdav = True
224
+
225
+ dimensions = _image_dimensions(image_data)
226
+ item = {
227
+ "rel": rel,
228
+ "path": rel,
229
+ "name": Path(rel).name,
230
+ "date": "-".join(rel.split("/")[:3]),
231
+ "size": len(image_data),
232
+ "created_at": _now_iso(),
233
+ "storage": "both" if stored_local and stored_webdav else ("webdav" if stored_webdav else "local"),
234
+ "local": stored_local,
235
+ "webdav": stored_webdav,
236
+ "remote_url": remote_url,
237
+ }
238
+ if dimensions:
239
+ item["width"], item["height"] = dimensions
240
+ with self._index_lock:
241
+ items = self._load_clean_index()
242
+ items[rel] = item
243
+ self._save_index(items)
244
+ return StoredImage(rel=rel, url=self._public_url(rel, base_url), storage=str(item["storage"]), size=len(image_data))
245
+
246
+ def get_bytes(self, rel: str) -> bytes:
247
+ safe_rel = _safe_relative_path(rel)
248
+ if not _is_image_rel(safe_rel):
249
+ raise HTTPException(status_code=404, detail="image not found")
250
+ path = _local_image_path(safe_rel)
251
+ if path.is_file():
252
+ return path.read_bytes()
253
+ item = self._load_clean_index().get(safe_rel, {})
254
+ if item.get("webdav"):
255
+ return WebDAVClient(self.settings()).get(safe_rel)
256
+ raise HTTPException(status_code=404, detail="image not found")
257
+
258
+ def exists(self, rel: str) -> bool:
259
+ safe_rel = _safe_relative_path(rel)
260
+ if not _is_image_rel(safe_rel):
261
+ return False
262
+ if _local_image_path(safe_rel).is_file():
263
+ return True
264
+ item = self._load_clean_index().get(safe_rel, {})
265
+ return bool(item.get("webdav"))
266
+
267
+ def has_local(self, rel: str) -> bool:
268
+ safe_rel = _safe_relative_path(rel)
269
+ return _is_image_rel(safe_rel) and _local_image_path(safe_rel).is_file()
270
+
271
+ def list_items(self, base_url: str, start_date: str = "", end_date: str = "") -> list[dict[str, object]]:
272
+ with self._index_lock:
273
+ indexed = self._load_clean_index()
274
+ root = config.images_dir
275
+ changed = False
276
+ for path in root.rglob("*"):
277
+ if not path.is_file() or not _is_image_rel(path.name):
278
+ continue
279
+ rel = path.relative_to(root).as_posix()
280
+ if rel in indexed:
281
+ continue
282
+ dimensions = None
283
+ try:
284
+ dimensions = _image_dimensions(path.read_bytes())
285
+ except Exception:
286
+ dimensions = None
287
+ indexed[rel] = {
288
+ "rel": rel,
289
+ "path": rel,
290
+ "name": path.name,
291
+ "date": "-".join(rel.split("/")[:3]) if len(rel.split("/")) >= 4 else datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d"),
292
+ "size": path.stat().st_size,
293
+ "created_at": datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
294
+ "storage": "local",
295
+ "local": True,
296
+ "webdav": False,
297
+ **({"width": dimensions[0], "height": dimensions[1]} if dimensions else {}),
298
+ }
299
+ changed = True
300
+
301
+ items: list[dict[str, object]] = []
302
+ for rel, item in list(indexed.items()):
303
+ if not _is_image_rel(rel):
304
+ indexed.pop(rel, None)
305
+ changed = True
306
+ continue
307
+ local = _local_image_path(rel).is_file()
308
+ webdav = bool(item.get("webdav"))
309
+ if not local and not webdav:
310
+ indexed.pop(rel, None)
311
+ changed = True
312
+ continue
313
+ storage = "both" if local and webdav else ("webdav" if webdav else "local")
314
+ if item.get("local") != local or item.get("storage") != storage:
315
+ item = {
316
+ **item,
317
+ "local": local,
318
+ "storage": storage,
319
+ }
320
+ indexed[rel] = item
321
+ changed = True
322
+ day = str(item.get("date") or "")
323
+ if start_date and day < start_date:
324
+ continue
325
+ if end_date and day > end_date:
326
+ continue
327
+ items.append({
328
+ **item,
329
+ "rel": rel,
330
+ "path": rel,
331
+ "url": self._public_url(rel, base_url),
332
+ })
333
+ if changed:
334
+ self._save_index(indexed)
335
+ items.sort(key=lambda item: str(item.get("created_at") or ""), reverse=True)
336
+ return items
337
+
338
+ def delete(self, rel: str) -> bool:
339
+ safe_rel = _safe_relative_path(rel)
340
+ removed = False
341
+ path = _local_image_path(safe_rel)
342
+ if path.is_file():
343
+ path.unlink()
344
+ removed = True
345
+ with self._index_lock:
346
+ items = self._load_clean_index()
347
+ item = items.get(safe_rel, {})
348
+ if item.get("webdav"):
349
+ try:
350
+ removed = WebDAVClient(self.settings()).delete(safe_rel) or removed
351
+ except ImageStorageError:
352
+ if not removed:
353
+ raise
354
+ if safe_rel in items:
355
+ items.pop(safe_rel, None)
356
+ self._save_index(items)
357
+ return removed
358
+
359
+ def sync_all(self) -> dict[str, int]:
360
+ settings = self.settings()
361
+ if self.mode() not in {"webdav", "both"}:
362
+ raise ImageStorageError("WebDAV 图片存储未启用")
363
+ uploaded = 0
364
+ skipped = 0
365
+ failed = 0
366
+ with self._index_lock:
367
+ items = self._load_clean_index()
368
+ client = WebDAVClient(settings)
369
+ for path in sorted(config.images_dir.rglob("*")):
370
+ if not path.is_file() or not _is_image_rel(path.name):
371
+ continue
372
+ rel = path.relative_to(config.images_dir).as_posix()
373
+ item = items.get(rel, {})
374
+ if item.get("webdav"):
375
+ skipped += 1
376
+ continue
377
+ try:
378
+ payload = path.read_bytes()
379
+ remote_url = client.put(rel, payload)
380
+ dimensions = _image_dimensions(payload)
381
+ items[rel] = {
382
+ **item,
383
+ "rel": rel,
384
+ "path": rel,
385
+ "name": path.name,
386
+ "date": "-".join(rel.split("/")[:3]) if len(rel.split("/")) >= 4 else datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d"),
387
+ "size": len(payload),
388
+ "created_at": str(item.get("created_at") or datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")),
389
+ "storage": "both",
390
+ "local": True,
391
+ "webdav": True,
392
+ "remote_url": remote_url,
393
+ **({"width": dimensions[0], "height": dimensions[1]} if dimensions else {}),
394
+ }
395
+ uploaded += 1
396
+ except Exception:
397
+ failed += 1
398
+ self._save_index(items)
399
+ return {"uploaded": uploaded, "skipped": skipped, "failed": failed}
400
+
401
+ def test_webdav(self) -> dict[str, object]:
402
+ return WebDAVClient(self.settings()).test()
403
+
404
+
405
+ image_storage_service = ImageStorageService()
services/image_tags_service.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from services.config import DATA_DIR
7
+
8
+ TAGS_FILE = DATA_DIR / "image_tags.json"
9
+
10
+
11
+ def _ensure_file() -> None:
12
+ TAGS_FILE.parent.mkdir(parents=True, exist_ok=True)
13
+ if not TAGS_FILE.exists():
14
+ TAGS_FILE.write_text("{}", encoding="utf-8")
15
+
16
+
17
+ def load_tags() -> dict[str, list[str]]:
18
+ _ensure_file()
19
+ try:
20
+ data = json.loads(TAGS_FILE.read_text(encoding="utf-8"))
21
+ except Exception:
22
+ return {}
23
+ return data if isinstance(data, dict) else {}
24
+
25
+
26
+ def save_tags(data: dict[str, list[str]]) -> None:
27
+ _ensure_file()
28
+ TAGS_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
29
+
30
+
31
+ def get_tags(image_rel: str) -> list[str]:
32
+ return load_tags().get(image_rel, [])
33
+
34
+
35
+ def set_tags(image_rel: str, tags: list[str]) -> list[str]:
36
+ data = load_tags()
37
+ cleaned = list(dict.fromkeys(t.strip() for t in tags if t.strip()))
38
+ if cleaned:
39
+ data[image_rel] = cleaned
40
+ else:
41
+ data.pop(image_rel, None)
42
+ save_tags(data)
43
+ return cleaned
44
+
45
+
46
+ def remove_tags(image_rel: str) -> None:
47
+ data = load_tags()
48
+ if data.pop(image_rel, None) is not None:
49
+ save_tags(data)
50
+
51
+
52
+ def delete_tag(tag: str) -> int:
53
+ """从所有图片中删除指定标签,返回受影响的图片数。"""
54
+ data = load_tags()
55
+ count = 0
56
+ for rel in list(data):
57
+ if tag in data[rel]:
58
+ data[rel] = [t for t in data[rel] if t != tag]
59
+ if not data[rel]:
60
+ del data[rel]
61
+ count += 1
62
+ if count > 0:
63
+ save_tags(data)
64
+ return count
65
+
66
+
67
+ def get_all_tags() -> list[str]:
68
+ data = load_tags()
69
+ seen: set[str] = set()
70
+ result: list[str] = []
71
+ for tags in data.values():
72
+ for t in tags:
73
+ if t not in seen:
74
+ seen.add(t)
75
+ result.append(t)
76
+ return result
services/image_task_service.py ADDED
@@ -0,0 +1,404 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import threading
5
+ import time
6
+ from collections.abc import Callable
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from services.config import DATA_DIR, config
12
+ from services.content_filter import request_text
13
+ from services.log_service import LOG_TYPE_CALL, log_service
14
+ from services.protocol import openai_v1_image_edit, openai_v1_image_generations
15
+
16
+ TASK_STATUS_QUEUED = "queued"
17
+ TASK_STATUS_RUNNING = "running"
18
+ TASK_STATUS_SUCCESS = "success"
19
+ TASK_STATUS_ERROR = "error"
20
+ TERMINAL_STATUSES = {TASK_STATUS_SUCCESS, TASK_STATUS_ERROR}
21
+ UNFINISHED_STATUSES = {TASK_STATUS_QUEUED, TASK_STATUS_RUNNING}
22
+
23
+
24
+ def _now_iso() -> str:
25
+ return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
26
+
27
+
28
+ def _timestamp(value: object) -> float:
29
+ if not isinstance(value, str) or not value.strip():
30
+ return 0.0
31
+ for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S"):
32
+ try:
33
+ return datetime.strptime(value[:26], fmt).timestamp()
34
+ except ValueError:
35
+ continue
36
+ try:
37
+ return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
38
+ except Exception:
39
+ return 0.0
40
+
41
+
42
+ def _clean(value: object, default: str = "") -> str:
43
+ return str(value or default).strip()
44
+
45
+
46
+ def _owner_id(identity: dict[str, object]) -> str:
47
+ return _clean(identity.get("id")) or "anonymous"
48
+
49
+
50
+ def _task_key(owner_id: str, task_id: str) -> str:
51
+ return f"{owner_id}:{task_id}"
52
+
53
+
54
+ def _collect_image_urls(data: list[Any]) -> list[str]:
55
+ urls: list[str] = []
56
+ for item in data:
57
+ if isinstance(item, dict):
58
+ url = item.get("url")
59
+ if isinstance(url, str) and url:
60
+ urls.append(url)
61
+ return urls
62
+
63
+
64
+ def _public_task(task: dict[str, Any]) -> dict[str, Any]:
65
+ item = {
66
+ "id": task.get("id"),
67
+ "status": task.get("status"),
68
+ "mode": task.get("mode"),
69
+ "model": task.get("model"),
70
+ "size": task.get("size"),
71
+ "quality": task.get("quality"),
72
+ "created_at": task.get("created_at"),
73
+ "updated_at": task.get("updated_at"),
74
+ }
75
+ if task.get("data") is not None:
76
+ item["data"] = task.get("data")
77
+ if task.get("usage") is not None:
78
+ item["usage"] = task.get("usage")
79
+ if task.get("error"):
80
+ item["error"] = task.get("error")
81
+ return item
82
+
83
+
84
+ class ImageTaskService:
85
+ def __init__(
86
+ self,
87
+ path: Path,
88
+ *,
89
+ generation_handler: Callable[[dict[str, Any]], dict[str, Any]] = openai_v1_image_generations.handle,
90
+ edit_handler: Callable[[dict[str, Any]], dict[str, Any]] = openai_v1_image_edit.handle,
91
+ retention_days_getter: Callable[[], int] | None = None,
92
+ ):
93
+ self.path = path
94
+ self.generation_handler = generation_handler
95
+ self.edit_handler = edit_handler
96
+ self.retention_days_getter = retention_days_getter or (lambda: config.image_retention_days)
97
+ self._lock = threading.RLock()
98
+ self._tasks: dict[str, dict[str, Any]] = {}
99
+ self.path.parent.mkdir(parents=True, exist_ok=True)
100
+ with self._lock:
101
+ self._tasks = self._load_locked()
102
+ changed = self._recover_unfinished_locked()
103
+ changed = self._cleanup_locked() or changed
104
+ if changed:
105
+ self._save_locked()
106
+
107
+ def submit_generation(
108
+ self,
109
+ identity: dict[str, object],
110
+ *,
111
+ client_task_id: str,
112
+ prompt: str,
113
+ model: str,
114
+ size: str | None,
115
+ quality: str = "auto",
116
+ base_url: str = "",
117
+ ) -> dict[str, Any]:
118
+ payload = {
119
+ "prompt": prompt,
120
+ "model": model,
121
+ "n": 1,
122
+ "size": size,
123
+ "quality": quality,
124
+ "response_format": "url",
125
+ "base_url": base_url,
126
+ }
127
+ return self._submit(identity, client_task_id=client_task_id, mode="generate", payload=payload)
128
+
129
+ def submit_edit(
130
+ self,
131
+ identity: dict[str, object],
132
+ *,
133
+ client_task_id: str,
134
+ prompt: str,
135
+ model: str,
136
+ size: str | None,
137
+ quality: str = "auto",
138
+ base_url: str = "",
139
+ images: list[tuple[bytes, str, str]] | None = None,
140
+ ) -> dict[str, Any]:
141
+ payload = {
142
+ "prompt": prompt,
143
+ "images": images or [],
144
+ "model": model,
145
+ "n": 1,
146
+ "size": size,
147
+ "quality": quality,
148
+ "response_format": "url",
149
+ "base_url": base_url,
150
+ }
151
+ return self._submit(identity, client_task_id=client_task_id, mode="edit", payload=payload)
152
+
153
+ def list_tasks(self, identity: dict[str, object], task_ids: list[str]) -> dict[str, Any]:
154
+ owner = _owner_id(identity)
155
+ requested_ids = [_clean(task_id) for task_id in task_ids if _clean(task_id)]
156
+ with self._lock:
157
+ if self._cleanup_locked():
158
+ self._save_locked()
159
+ items = []
160
+ missing_ids = []
161
+ for task_id in requested_ids:
162
+ task = self._tasks.get(_task_key(owner, task_id))
163
+ if task is None:
164
+ missing_ids.append(task_id)
165
+ else:
166
+ items.append(_public_task(task))
167
+ if not requested_ids:
168
+ items = [
169
+ _public_task(task)
170
+ for task in self._tasks.values()
171
+ if task.get("owner_id") == owner
172
+ ]
173
+ items.sort(key=lambda item: str(item.get("updated_at") or ""), reverse=True)
174
+ missing_ids = []
175
+ return {"items": items, "missing_ids": missing_ids}
176
+
177
+ def _submit(
178
+ self,
179
+ identity: dict[str, object],
180
+ *,
181
+ client_task_id: str,
182
+ mode: str,
183
+ payload: dict[str, Any],
184
+ ) -> dict[str, Any]:
185
+ task_id = _clean(client_task_id)
186
+ if not task_id:
187
+ raise ValueError("client_task_id is required")
188
+ owner = _owner_id(identity)
189
+ key = _task_key(owner, task_id)
190
+ now = _now_iso()
191
+ should_start = False
192
+ with self._lock:
193
+ cleaned = self._cleanup_locked()
194
+ task = self._tasks.get(key)
195
+ if task is not None:
196
+ if cleaned:
197
+ self._save_locked()
198
+ return _public_task(task)
199
+ task = {
200
+ "id": task_id,
201
+ "owner_id": owner,
202
+ "status": TASK_STATUS_QUEUED,
203
+ "mode": mode,
204
+ "model": _clean(payload.get("model"), "gpt-image-2"),
205
+ "size": _clean(payload.get("size")),
206
+ "quality": _clean(payload.get("quality"), "auto"),
207
+ "created_at": now,
208
+ "updated_at": now,
209
+ }
210
+ self._tasks[key] = task
211
+ self._save_locked()
212
+ should_start = True
213
+
214
+ if should_start:
215
+ thread = threading.Thread(
216
+ target=self._run_task,
217
+ args=(key, mode, payload, dict(identity), _clean(payload.get("model"), "gpt-image-2")),
218
+ name=f"image-task-{task_id[:16]}",
219
+ daemon=True,
220
+ )
221
+ thread.start()
222
+ return _public_task(task)
223
+
224
+ def _run_task(
225
+ self,
226
+ key: str,
227
+ mode: str,
228
+ payload: dict[str, Any],
229
+ identity: dict[str, object],
230
+ model: str,
231
+ ) -> None:
232
+ started = time.time()
233
+ self._update_task(key, status=TASK_STATUS_RUNNING, error="")
234
+ try:
235
+ handler = self.edit_handler if mode == "edit" else self.generation_handler
236
+ result = handler(payload)
237
+ if not isinstance(result, dict):
238
+ raise RuntimeError("image task returned streaming result unexpectedly")
239
+ data = result.get("data")
240
+ account_email = _clean(result.get("_account_email") or result.get("account_email"))
241
+ if not isinstance(data, list) or not data:
242
+ upstream = _clean(result.get("message"))
243
+ if upstream:
244
+ message = upstream
245
+ else:
246
+ message = "号池中没有可用账号或所有账号均被限流,请检查号池状态(账号额度、是否被封禁、是否到达生图上限)"
247
+ error = RuntimeError(message)
248
+ if account_email:
249
+ setattr(error, "account_email", account_email)
250
+ raise error
251
+ usage = result.get("usage")
252
+ self._update_task(key, status=TASK_STATUS_SUCCESS, data=data, usage=usage, error="")
253
+ self._log_call(
254
+ identity,
255
+ mode,
256
+ model,
257
+ started,
258
+ "调用完成",
259
+ request_preview=request_text(payload.get("prompt")),
260
+ urls=_collect_image_urls(data),
261
+ account_email=account_email,
262
+ )
263
+ except Exception as exc:
264
+ error_message = str(exc) or "image task failed"
265
+ account_email = _clean(getattr(exc, "account_email", ""))
266
+ self._update_task(key, status=TASK_STATUS_ERROR, error=error_message, data=[])
267
+ self._log_call(
268
+ identity,
269
+ mode,
270
+ model,
271
+ started,
272
+ "调用失败",
273
+ request_preview=request_text(payload.get("prompt")),
274
+ status="failed",
275
+ error=error_message,
276
+ account_email=account_email,
277
+ )
278
+
279
+ def _log_call(
280
+ self,
281
+ identity: dict[str, object],
282
+ mode: str,
283
+ model: str,
284
+ started: float,
285
+ suffix: str,
286
+ *,
287
+ request_preview: str = "",
288
+ status: str = "success",
289
+ error: str = "",
290
+ urls: list[str] | None = None,
291
+ account_email: str = "",
292
+ ) -> None:
293
+ endpoint = "/v1/images/edits" if mode == "edit" else "/v1/images/generations"
294
+ summary_prefix = "图生图" if mode == "edit" else "文生图"
295
+ detail = {
296
+ "key_id": identity.get("id"),
297
+ "key_name": identity.get("name"),
298
+ "role": identity.get("role"),
299
+ "endpoint": endpoint,
300
+ "model": model,
301
+ "started_at": datetime.fromtimestamp(started).strftime("%Y-%m-%d %H:%M:%S"),
302
+ "ended_at": _now_iso(),
303
+ "duration_ms": int((time.time() - started) * 1000),
304
+ "status": status,
305
+ }
306
+ if request_preview:
307
+ detail["request_text"] = request_preview
308
+ if error:
309
+ detail["error"] = error
310
+ if account_email:
311
+ detail["account_email"] = account_email
312
+ if urls:
313
+ detail["urls"] = list(dict.fromkeys(urls))
314
+ try:
315
+ log_service.add(LOG_TYPE_CALL, f"{summary_prefix}{suffix}", detail)
316
+ except Exception:
317
+ pass
318
+
319
+ def _update_task(self, key: str, **updates: Any) -> None:
320
+ with self._lock:
321
+ task = self._tasks.get(key)
322
+ if task is None:
323
+ return
324
+ task.update(updates)
325
+ task["updated_at"] = _now_iso()
326
+ self._save_locked()
327
+
328
+ def _load_locked(self) -> dict[str, dict[str, Any]]:
329
+ if not self.path.exists():
330
+ return {}
331
+ try:
332
+ raw = json.loads(self.path.read_text(encoding="utf-8"))
333
+ except Exception:
334
+ return {}
335
+ raw_items = raw.get("tasks") if isinstance(raw, dict) else raw
336
+ if not isinstance(raw_items, list):
337
+ return {}
338
+ tasks: dict[str, dict[str, Any]] = {}
339
+ for item in raw_items:
340
+ if not isinstance(item, dict):
341
+ continue
342
+ task_id = _clean(item.get("id"))
343
+ owner = _clean(item.get("owner_id"))
344
+ if not task_id or not owner:
345
+ continue
346
+ status = _clean(item.get("status"))
347
+ if status not in {TASK_STATUS_QUEUED, TASK_STATUS_RUNNING, TASK_STATUS_SUCCESS, TASK_STATUS_ERROR}:
348
+ status = TASK_STATUS_ERROR
349
+ task = {
350
+ "id": task_id,
351
+ "owner_id": owner,
352
+ "status": status,
353
+ "mode": "edit" if item.get("mode") == "edit" else "generate",
354
+ "model": _clean(item.get("model"), "gpt-image-2"),
355
+ "size": _clean(item.get("size")),
356
+ "quality": _clean(item.get("quality"), "auto"),
357
+ "created_at": _clean(item.get("created_at"), _now_iso()),
358
+ "updated_at": _clean(item.get("updated_at"), _clean(item.get("created_at"), _now_iso())),
359
+ }
360
+ data = item.get("data")
361
+ if isinstance(data, list):
362
+ task["data"] = data
363
+ usage = item.get("usage")
364
+ if isinstance(usage, dict):
365
+ task["usage"] = usage
366
+ error = _clean(item.get("error"))
367
+ if error:
368
+ task["error"] = error
369
+ tasks[_task_key(owner, task_id)] = task
370
+ return tasks
371
+
372
+ def _save_locked(self) -> None:
373
+ items = sorted(self._tasks.values(), key=lambda item: str(item.get("updated_at") or ""), reverse=True)
374
+ tmp_path = self.path.with_suffix(self.path.suffix + ".tmp")
375
+ tmp_path.write_text(json.dumps({"tasks": items}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
376
+ tmp_path.replace(self.path)
377
+
378
+ def _recover_unfinished_locked(self) -> bool:
379
+ changed = False
380
+ for task in self._tasks.values():
381
+ if task.get("status") in UNFINISHED_STATUSES:
382
+ task["status"] = TASK_STATUS_ERROR
383
+ task["error"] = "服务已重启,未完成的图片任务已中断"
384
+ task["updated_at"] = _now_iso()
385
+ changed = True
386
+ return changed
387
+
388
+ def _cleanup_locked(self) -> bool:
389
+ try:
390
+ retention_days = max(1, int(self.retention_days_getter()))
391
+ except Exception:
392
+ retention_days = 30
393
+ cutoff = time.time() - retention_days * 86400
394
+ removed_keys = [
395
+ key
396
+ for key, task in self._tasks.items()
397
+ if task.get("status") in TERMINAL_STATUSES and _timestamp(task.get("updated_at")) < cutoff
398
+ ]
399
+ for key in removed_keys:
400
+ self._tasks.pop(key, None)
401
+ return bool(removed_keys)
402
+
403
+
404
+ image_task_service = ImageTaskService(DATA_DIR / "image_tasks.json")
services/log_service.py ADDED
@@ -0,0 +1,308 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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"}
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 _strip_internal_response_fields(value: object) -> object:
147
+ if isinstance(value, dict):
148
+ return {
149
+ key: _strip_internal_response_fields(item)
150
+ for key, item in value.items()
151
+ if key not in INTERNAL_RESPONSE_KEYS
152
+ }
153
+ if isinstance(value, list):
154
+ return [_strip_internal_response_fields(item) for item in value]
155
+ return value
156
+
157
+
158
+ def _request_excerpt(text: object, limit: int = 1000) -> str:
159
+ value = str(text or "").strip()
160
+ if not value:
161
+ return ""
162
+ normalized = " ".join(value.split())
163
+ if len(normalized) <= limit:
164
+ return normalized
165
+ return normalized[: limit - 1].rstrip() + "…"
166
+
167
+
168
+ def _image_error_response(exc: Exception) -> JSONResponse:
169
+ from services.protocol.conversation import public_image_error_message
170
+
171
+ message = public_image_error_message(str(exc))
172
+ if "no available image quota" in message.lower():
173
+ return openai_error_response(
174
+ {
175
+ "error": {
176
+ "message": "no available image quota",
177
+ "type": "insufficient_quota",
178
+ "param": None,
179
+ "code": "insufficient_quota",
180
+ }
181
+ },
182
+ 429,
183
+ )
184
+ if hasattr(exc, "to_openai_error") and hasattr(exc, "status_code"):
185
+ return JSONResponse(status_code=int(exc.status_code), content=exc.to_openai_error())
186
+ return openai_error_response(message, 502)
187
+
188
+
189
+ def _protocol_error_response(exc: Exception, status_code: int, sse: str) -> JSONResponse:
190
+ message = str(exc)
191
+ if sse == "anthropic":
192
+ return anthropic_error_response(message, status_code)
193
+ return openai_error_response(message, status_code)
194
+
195
+
196
+ def _next_item(items):
197
+ try:
198
+ return True, next(items)
199
+ except StopIteration:
200
+ return False, None
201
+
202
+
203
+ @dataclass
204
+ class LoggedCall:
205
+ identity: dict[str, object]
206
+ endpoint: str
207
+ model: str
208
+ summary: str
209
+ started: float = field(default_factory=time.time)
210
+ request_text: str = ""
211
+
212
+ async def run(self, handler, *args, sse: str = "openai"):
213
+ from services.protocol.conversation import ImageGenerationError
214
+
215
+ try:
216
+ result = await run_in_threadpool(handler, *args)
217
+ except ImageGenerationError as exc:
218
+ self.log("调用失败", status="failed", error=str(exc), account_email=getattr(exc, "account_email", ""))
219
+ return _image_error_response(exc)
220
+ except HTTPException as exc:
221
+ self.log("调用失败", status="failed", error=str(exc.detail))
222
+ raise
223
+ except Exception as exc:
224
+ self.log("调用失败", status="failed", error=str(exc), account_email=getattr(exc, "account_email", ""))
225
+ if self.endpoint.startswith("/v1/images"):
226
+ return _image_error_response(exc)
227
+ return _protocol_error_response(exc, 502, sse)
228
+
229
+ if isinstance(result, dict):
230
+ self.log("调用完成", result)
231
+ response = dict(result)
232
+ response.pop("_account_email", None)
233
+ return response
234
+
235
+ sender = anthropic_sse_stream if sse == "anthropic" else sse_json_stream
236
+ try:
237
+ has_first, first = await run_in_threadpool(_next_item, result)
238
+ except ImageGenerationError as exc:
239
+ self.log("调用失败", status="failed", error=str(exc), account_email=getattr(exc, "account_email", ""))
240
+ return _image_error_response(exc)
241
+ except HTTPException as exc:
242
+ self.log("调用失败", status="failed", error=str(exc.detail))
243
+ raise
244
+ except Exception as exc:
245
+ self.log("调用失败", status="failed", error=str(exc), account_email=getattr(exc, "account_email", ""))
246
+ if self.endpoint.startswith("/v1/images"):
247
+ return _image_error_response(exc)
248
+ return _protocol_error_response(exc, 502, sse)
249
+ if not has_first:
250
+ self.log("流式调用结束")
251
+ return StreamingResponse(sender(()), media_type="text/event-stream")
252
+ return StreamingResponse(sender(self.stream(itertools.chain([first], result))), media_type="text/event-stream")
253
+
254
+ def stream(self, items):
255
+ urls: list[str] = []
256
+ account_emails: list[str] = []
257
+ failed = False
258
+ try:
259
+ for item in items:
260
+ urls.extend(_collect_urls(item))
261
+ account_emails.extend(_collect_account_emails(item))
262
+ yield _strip_internal_response_fields(item)
263
+ except Exception as exc:
264
+ failed = True
265
+ self.log(
266
+ "流式调用失败",
267
+ status="failed",
268
+ error=str(exc),
269
+ urls=urls,
270
+ account_email=(account_emails[0] if account_emails else getattr(exc, "account_email", "")),
271
+ )
272
+ if self.endpoint.startswith("/v1/images") and not hasattr(exc, "to_openai_error"):
273
+ from services.protocol.conversation import ImageGenerationError, public_image_error_message
274
+
275
+ raise ImageGenerationError(public_image_error_message(str(exc))) from exc
276
+ raise
277
+ finally:
278
+ if not failed:
279
+ self.log("流式调用结束", urls=urls, account_email=account_emails[0] if account_emails else "")
280
+
281
+ def log(self, suffix: str, result: object = None, status: str = "success", error: str = "",
282
+ urls: list[str] | None = None, account_email: str = "") -> None:
283
+ detail = {
284
+ "key_id": self.identity.get("id"),
285
+ "key_name": self.identity.get("name"),
286
+ "role": self.identity.get("role"),
287
+ "endpoint": self.endpoint,
288
+ "model": self.model,
289
+ "started_at": datetime.fromtimestamp(self.started).strftime("%Y-%m-%d %H:%M:%S"),
290
+ "ended_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
291
+ "duration_ms": int((time.time() - self.started) * 1000),
292
+ "status": status,
293
+ }
294
+ request_excerpt = _request_excerpt(self.request_text)
295
+ if request_excerpt:
296
+ detail["request_text"] = request_excerpt
297
+ if error:
298
+ detail["error"] = error
299
+ email = str(account_email or "").strip()
300
+ if not email:
301
+ emails = _collect_account_emails(result)
302
+ email = emails[0] if emails else ""
303
+ if email:
304
+ detail["account_email"] = email
305
+ collected_urls = [*(urls or []), *_collect_urls(result)]
306
+ if collected_urls:
307
+ detail["urls"] = list(dict.fromkeys(collected_urls))
308
+ log_service.add(LOG_TYPE_CALL, f"{self.summary}{suffix}", detail)
services/oauth_login_service.py ADDED
@@ -0,0 +1,272 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 base64
16
+ import hashlib
17
+ import secrets
18
+ import threading
19
+ import time
20
+ import uuid
21
+ from typing import Any
22
+ from urllib.parse import parse_qs, urlencode, urlparse
23
+
24
+ from curl_cffi import requests
25
+
26
+ from services.proxy_service import proxy_settings
27
+ from services.register.openai_register import (
28
+ auth_base,
29
+ common_headers,
30
+ platform_auth0_client,
31
+ platform_base,
32
+ platform_oauth_audience,
33
+ platform_oauth_client_id,
34
+ platform_oauth_redirect_uri,
35
+ sec_ch_ua,
36
+ user_agent,
37
+ )
38
+
39
+
40
+ class OAuthLoginError(Exception):
41
+ """OAuth 桥流程中的可预期错误,会被 API 层翻译成 400。"""
42
+
43
+
44
+ class OAuthLoginService:
45
+ """维护 PKCE 临时会话,并完成 code → token 的兑换。"""
46
+
47
+ _SESSION_TTL_SECONDS = 10 * 60 # 用户点开浏览器 + 拿 code 给的时间上限
48
+ _MAX_SESSIONS = 64 # 防止异常累积;超过容量时清理最老的
49
+
50
+ def __init__(self) -> None:
51
+ self._lock = threading.Lock()
52
+ self._sessions: dict[str, dict[str, Any]] = {}
53
+
54
+ @staticmethod
55
+ def _generate_pkce() -> tuple[str, str]:
56
+ """生成 PKCE code_verifier 与对应的 code_challenge(S256)。"""
57
+ verifier = base64.urlsafe_b64encode(secrets.token_bytes(64)).rstrip(b"=").decode("ascii")
58
+ challenge = base64.urlsafe_b64encode(
59
+ hashlib.sha256(verifier.encode("ascii")).digest()
60
+ ).rstrip(b"=").decode("ascii")
61
+ return verifier, challenge
62
+
63
+ def _purge_expired_locked(self) -> None:
64
+ """清理过期或溢出容量的会话,必须在持锁状态下调用。"""
65
+ now = time.time()
66
+ expired = [sid for sid, item in self._sessions.items() if now - item["created_at"] > self._SESSION_TTL_SECONDS]
67
+ for sid in expired:
68
+ self._sessions.pop(sid, None)
69
+ if len(self._sessions) > self._MAX_SESSIONS:
70
+ ordered = sorted(self._sessions.items(), key=lambda kv: kv[1]["created_at"])
71
+ for sid, _ in ordered[: len(self._sessions) - self._MAX_SESSIONS]:
72
+ self._sessions.pop(sid, None)
73
+
74
+ def start(self, email_hint: str = "") -> dict[str, str]:
75
+ """登记一个新的 PKCE 会话,返回 session_id 与可让用户打开的 authorize_url。
76
+
77
+ state 形如 "<session_id>.<nonce>",让 callback URL 自带 session_id,
78
+ finish 时即便前端 React 状态被覆盖也能从 URL 恢复正确的 verifier。
79
+ """
80
+ verifier, challenge = self._generate_pkce()
81
+ nonce = secrets.token_urlsafe(32)
82
+ device_id = str(uuid.uuid4())
83
+ session_id = uuid.uuid4().hex
84
+ state = f"{session_id}.{secrets.token_urlsafe(16)}"
85
+
86
+ params = {
87
+ "issuer": auth_base,
88
+ "client_id": platform_oauth_client_id,
89
+ "audience": platform_oauth_audience,
90
+ "redirect_uri": platform_oauth_redirect_uri,
91
+ "device_id": device_id,
92
+ "screen_hint": "login_or_signup",
93
+ "max_age": "0",
94
+ "scope": "openid profile email offline_access",
95
+ "response_type": "code",
96
+ "response_mode": "query",
97
+ "state": state,
98
+ "nonce": nonce,
99
+ "code_challenge": challenge,
100
+ "code_challenge_method": "S256",
101
+ "auth0Client": platform_auth0_client,
102
+ }
103
+ email_hint = str(email_hint or "").strip()
104
+ if email_hint:
105
+ params["login_hint"] = email_hint
106
+
107
+ authorize_url = f"{auth_base}/api/accounts/authorize?{urlencode(params)}"
108
+
109
+ with self._lock:
110
+ self._purge_expired_locked()
111
+ self._sessions[session_id] = {
112
+ "code_verifier": verifier,
113
+ "state": state,
114
+ "created_at": time.time(),
115
+ "redirect_uri": platform_oauth_redirect_uri,
116
+ }
117
+
118
+ return {
119
+ "session_id": session_id,
120
+ "authorize_url": authorize_url,
121
+ "expires_in": str(self._SESSION_TTL_SECONDS),
122
+ "redirect_uri_prefix": platform_oauth_redirect_uri,
123
+ }
124
+
125
+ @staticmethod
126
+ def _extract_code_from_callback(value: str) -> tuple[str, str]:
127
+ """从 callback URL 或 raw code 中提取 (code, state)。
128
+
129
+ 既允许用户粘贴整段 platform.openai.com/auth/callback?code=...&state=... 的 URL,
130
+ 也允许只粘 code 本身。
131
+ """
132
+ raw = str(value or "").strip()
133
+ if not raw:
134
+ return "", ""
135
+ if raw.startswith("http://") or raw.startswith("https://"):
136
+ try:
137
+ parsed = parse_qs(urlparse(raw).query)
138
+ except Exception as exc:
139
+ raise OAuthLoginError(f"无法解析 callback URL: {exc}") from exc
140
+ code = str((parsed.get("code") or [""])[0]).strip()
141
+ state = str((parsed.get("state") or [""])[0]).strip()
142
+ if not code:
143
+ err = str((parsed.get("error_description") or parsed.get("error") or [""])[0]).strip()
144
+ raise OAuthLoginError(err or "callback URL 中没有 code 参数")
145
+ return code, state
146
+ # 用户可能直接粘了 code 字符串
147
+ return raw, ""
148
+
149
+ def finish(self, session_id: str, callback: str) -> dict[str, str]:
150
+ """用 session_id 配对的 code_verifier 把 callback 里的 code 换成 token 三件套。
151
+
152
+ - 优先用 callback URL 自带 state 里的 session_id(更可靠),
153
+ 找不到才用前端传来的 session_id;
154
+ - 失败时不立刻销毁 session(OAuth code 错配换 token 失败通常不会消耗 code),
155
+ 只有成功兑换才 pop,便于用户用同一 verifier 重试。
156
+ """
157
+ body_sid = str(session_id or "").strip()
158
+ code, state = self._extract_code_from_callback(callback)
159
+ if not code:
160
+ raise OAuthLoginError("缺少 code 或 callback URL")
161
+
162
+ # state 里嵌的 session_id 优先级最高
163
+ state_sid = state.split(".", 1)[0] if state else ""
164
+ candidate_sids = [sid for sid in (state_sid, body_sid) if sid]
165
+ if not candidate_sids:
166
+ raise OAuthLoginError("既未提供 session_id,callback URL 中也未携带 state")
167
+
168
+ with self._lock:
169
+ self._purge_expired_locked()
170
+ session = None
171
+ picked_sid = ""
172
+ for sid in candidate_sids:
173
+ cur = self._sessions.get(sid)
174
+ if cur is not None:
175
+ session = cur
176
+ picked_sid = sid
177
+ break
178
+ if session is None:
179
+ raise OAuthLoginError(
180
+ "OAuth 会话已过期或不存在,请回到导入对话框点\"重新生成\"再走一次"
181
+ )
182
+
183
+ if state and session.get("state") and state != session["state"]:
184
+ raise OAuthLoginError(
185
+ "state 不匹配。常见原因:你点过两次\"打开授权页面\",但浏览器里登录的还是前一次的窗口。请点\"重新生成\"重来。"
186
+ )
187
+
188
+ tokens = self._exchange_code(
189
+ code,
190
+ session["code_verifier"],
191
+ session.get("redirect_uri") or platform_oauth_redirect_uri,
192
+ )
193
+ # 仅在成功兑换之后才消耗 session
194
+ with self._lock:
195
+ self._sessions.pop(picked_sid, None)
196
+ return tokens
197
+
198
+ @staticmethod
199
+ def _exchange_code(code: str, code_verifier: str, redirect_uri: str) -> dict[str, str]:
200
+ """调用 /api/accounts/oauth/token 用 code+verifier 换 token 三件套。"""
201
+ kwargs = proxy_settings.build_session_kwargs(impersonate="chrome", verify=False)
202
+ session = requests.Session(**kwargs)
203
+ try:
204
+ response = session.post(
205
+ f"{auth_base}/api/accounts/oauth/token",
206
+ headers={
207
+ **common_headers,
208
+ "referer": f"{platform_base}/",
209
+ "origin": platform_base,
210
+ "auth0-client": platform_auth0_client,
211
+ "sec-ch-ua": sec_ch_ua,
212
+ "user-agent": user_agent,
213
+ },
214
+ json={
215
+ "client_id": platform_oauth_client_id,
216
+ "code_verifier": code_verifier,
217
+ "grant_type": "authorization_code",
218
+ "code": code,
219
+ "redirect_uri": redirect_uri,
220
+ },
221
+ timeout=60,
222
+ )
223
+ except Exception as exc:
224
+ raise OAuthLoginError(f"换 token 网络异常: {exc}") from exc
225
+ finally:
226
+ session.close()
227
+
228
+ try:
229
+ data = response.json() if response.text else {}
230
+ except Exception:
231
+ data = {}
232
+
233
+ if response.status_code != 200 or not isinstance(data, dict) or not data.get("access_token"):
234
+ detail = ""
235
+ if isinstance(data, dict):
236
+ detail = str(data.get("error_description") or data.get("error") or data.get("message") or "")
237
+ if not detail:
238
+ try:
239
+ detail = str(response.text or "")[:300]
240
+ except Exception:
241
+ detail = ""
242
+ # 打到 docker logs 方便排错——OAuth 换 token 的失败原因往往只有这里能看到
243
+ print(
244
+ f"[oauth-login] /api/accounts/oauth/token rejected: "
245
+ f"status={response.status_code} detail={detail!r} "
246
+ f"raw_body={(getattr(response, 'text', '') or '')[:500]!r}",
247
+ flush=True,
248
+ )
249
+ raise OAuthLoginError(
250
+ f"OpenAI 拒绝换 token (HTTP {response.status_code}){': ' + detail if detail else ''}"
251
+ )
252
+
253
+ access_token = str(data.get("access_token") or "").strip()
254
+ refresh_token = str(data.get("refresh_token") or "").strip()
255
+ id_token = str(data.get("id_token") or "").strip()
256
+
257
+ if not access_token:
258
+ raise OAuthLoginError("OpenAI 返回的 access_token 为空")
259
+ if not refresh_token:
260
+ # scope 含 offline_access 时正常会下发 refresh_token;这里给出明确提示
261
+ raise OAuthLoginError(
262
+ "OpenAI 没有返回 refresh_token(可能 scope 未包含 offline_access 或 code 已使用过)"
263
+ )
264
+
265
+ return {
266
+ "access_token": access_token,
267
+ "refresh_token": refresh_token,
268
+ "id_token": id_token,
269
+ }
270
+
271
+
272
+ oauth_login_service = OAuthLoginService()
services/openai_backend_api.py ADDED
@@ -0,0 +1,1264 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import json
3
+ import os
4
+ import random
5
+ import re
6
+ import time
7
+ import urllib.error
8
+ import urllib.request
9
+ from concurrent.futures import ThreadPoolExecutor
10
+ from dataclasses import dataclass
11
+ from io import BytesIO
12
+ from pathlib import Path
13
+ from typing import Any, Dict, Iterator, Optional
14
+
15
+ from curl_cffi import requests
16
+ from PIL import Image
17
+
18
+ from services.account_service import account_service
19
+ from services.config import config
20
+ from services.proxy_service import proxy_settings
21
+ from utils.helper import UpstreamHTTPError, ensure_ok, iter_sse_payloads, new_uuid, split_image_model
22
+ from utils.log import logger
23
+ from utils.pow import build_legacy_requirements_token, build_proof_token, parse_pow_resources
24
+ from utils.turnstile import solve_turnstile_token
25
+
26
+
27
+ class InvalidAccessTokenError(RuntimeError):
28
+ pass
29
+
30
+
31
+ class ImagePollTimeoutError(RuntimeError):
32
+ pass
33
+
34
+
35
+ @dataclass
36
+ class ChatRequirements:
37
+ """保存一次对话请求所需的 sentinel token。"""
38
+ token: str
39
+ proof_token: str = ""
40
+ turnstile_token: str = ""
41
+ so_token: str = ""
42
+ raw_finalize: Optional[Dict[str, Any]] = None
43
+
44
+
45
+ DEFAULT_CLIENT_VERSION = "prod-be885abbfcfe7b1f511e88b3003d9ee44757fbad"
46
+ DEFAULT_CLIENT_BUILD_NUMBER = "5955942"
47
+ DEFAULT_POW_SCRIPT = "https://chatgpt.com/backend-api/sentinel/sdk.js"
48
+ CODEX_IMAGE_MODEL = "codex-gpt-image-2"
49
+ CODEX_RESPONSES_MODEL = "gpt-5.5"
50
+ CODEX_RESPONSES_INSTRUCTIONS = (
51
+ "Use the image_generation tool to create exactly one image for the user's request. "
52
+ "Return the generated image result."
53
+ )
54
+
55
+
56
+ class OpenAIBackendAPI:
57
+ """ChatGPT Web 后端封装。
58
+
59
+ 说明:
60
+ - 传入 `access_token` 时,聊天和模型列表都会走已登录链路
61
+ 例如 `/backend-api/sentinel/chat-requirements`、`/backend-api/conversation`
62
+ - 不传 `access_token` 时,会走未登录链路
63
+ 例如 `/backend-anon/sentinel/chat-requirements`、`/backend-anon/conversation`
64
+ - `stream_conversation()` 是底层统一流式入口
65
+ - 协议兼容转换放在 `services.protocol`
66
+ """
67
+
68
+ def __init__(self, access_token: str = "") -> None:
69
+ """初始化后端客户端。
70
+
71
+ 参数:
72
+ - `access_token`:可选。传入后表示使用已登录链路;不传则使用未登录链路。
73
+ """
74
+ self.base_url = "https://chatgpt.com"
75
+ self.client_version = DEFAULT_CLIENT_VERSION
76
+ self.client_build_number = DEFAULT_CLIENT_BUILD_NUMBER
77
+ self.access_token = access_token
78
+ self.account = account_service.get_account(self.access_token) if self.access_token else {}
79
+ self.account = self.account if isinstance(self.account, dict) else {}
80
+ self.fp = self._build_fp()
81
+ self.user_agent = self.fp["user-agent"]
82
+ self.device_id = self.fp["oai-device-id"]
83
+ self.session_id = self.fp["oai-session-id"]
84
+ self.pow_script_sources: list[str] = []
85
+ self.pow_data_build = ""
86
+ self.session = requests.Session(**proxy_settings.build_session_kwargs(
87
+ account=self.account,
88
+ impersonate=self.fp["impersonate"],
89
+ verify=True,
90
+ ))
91
+ self.session.headers.update({
92
+ "User-Agent": self.user_agent,
93
+ "Origin": self.base_url,
94
+ "Referer": self.base_url + "/",
95
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8,en-US;q=0.7",
96
+ "Cache-Control": "no-cache",
97
+ "Pragma": "no-cache",
98
+ "Priority": "u=1, i",
99
+ "Sec-Ch-Ua": self.fp["sec-ch-ua"],
100
+ "Sec-Ch-Ua-Arch": '"x86"',
101
+ "Sec-Ch-Ua-Bitness": '"64"',
102
+ "Sec-Ch-Ua-Full-Version": '"143.0.3650.96"',
103
+ "Sec-Ch-Ua-Full-Version-List": '"Microsoft Edge";v="143.0.3650.96", "Chromium";v="143.0.7499.147", "Not A(Brand";v="24.0.0.0"',
104
+ "Sec-Ch-Ua-Mobile": self.fp["sec-ch-ua-mobile"],
105
+ "Sec-Ch-Ua-Model": '""',
106
+ "Sec-Ch-Ua-Platform": self.fp["sec-ch-ua-platform"],
107
+ "Sec-Ch-Ua-Platform-Version": '"19.0.0"',
108
+ "Sec-Fetch-Dest": "empty",
109
+ "Sec-Fetch-Mode": "cors",
110
+ "Sec-Fetch-Site": "same-origin",
111
+ "OAI-Device-Id": self.device_id,
112
+ "OAI-Session-Id": self.session_id,
113
+ "OAI-Language": "zh-CN",
114
+ "OAI-Client-Version": self.client_version,
115
+ "OAI-Client-Build-Number": self.client_build_number,
116
+ })
117
+ if self.access_token:
118
+ self.session.headers["Authorization"] = f"Bearer {self.access_token}"
119
+
120
+ def _build_fp(self) -> Dict[str, str]:
121
+ account = self.account
122
+ raw_fp = account.get("fp")
123
+ fp = {str(k).lower(): str(v) for k, v in raw_fp.items()} if isinstance(raw_fp, dict) else {}
124
+ for key in (
125
+ "user-agent",
126
+ "impersonate",
127
+ "oai-device-id",
128
+ "oai-session-id",
129
+ "sec-ch-ua",
130
+ "sec-ch-ua-mobile",
131
+ "sec-ch-ua-platform",
132
+ ):
133
+ value = str(account.get(key) or "").strip()
134
+ if value:
135
+ fp[key] = value
136
+ fp.setdefault(
137
+ "user-agent",
138
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
139
+ "(KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0",
140
+ )
141
+ fp.setdefault("impersonate", "edge101")
142
+ fp.setdefault("oai-device-id", new_uuid())
143
+ fp.setdefault("oai-session-id", new_uuid())
144
+ fp.setdefault("sec-ch-ua", '"Microsoft Edge";v="143", "Chromium";v="143", "Not A(Brand";v="24"')
145
+ fp.setdefault("sec-ch-ua-mobile", "?0")
146
+ fp.setdefault("sec-ch-ua-platform", '"Windows"')
147
+ return fp
148
+
149
+ def _headers(self, path: str, extra: Optional[Dict[str, str]] = None) -> Dict[str, str]:
150
+ """构造请求头,并补上 web 端要求的 target path/route。"""
151
+ headers = dict(self.session.headers)
152
+ headers["X-OpenAI-Target-Path"] = path
153
+ headers["X-OpenAI-Target-Route"] = path
154
+ if extra:
155
+ headers.update(extra)
156
+ return headers
157
+
158
+ @staticmethod
159
+ def _extract_quota_and_restore_at(limits_progress: list[Any]) -> tuple[int, str | None, bool]:
160
+ for item in limits_progress:
161
+ if isinstance(item, dict) and item.get("feature_name") == "image_gen":
162
+ return int(item.get("remaining") or 0), str(item.get("reset_after") or "") or None, False
163
+ return 0, None, True
164
+
165
+ def _get_me(self) -> Dict[str, Any]:
166
+ path = "/backend-api/me"
167
+ response = self.session.get(self.base_url + path, headers=self._headers(path), timeout=20)
168
+ if response.status_code != 200:
169
+ if response.status_code == 401:
170
+ raise InvalidAccessTokenError(f"{path} failed: HTTP {response.status_code}")
171
+ raise RuntimeError(f"{path} failed: HTTP {response.status_code}")
172
+ return response.json()
173
+
174
+ def _get_conversation_init(self) -> Dict[str, Any]:
175
+ path = "/backend-api/conversation/init"
176
+ response = self.session.post(
177
+ self.base_url + path,
178
+ headers=self._headers(path, {"Content-Type": "application/json"}),
179
+ json={
180
+ "gizmo_id": None,
181
+ "requested_default_model": None,
182
+ "conversation_id": None,
183
+ "timezone_offset_min": -480,
184
+ },
185
+ timeout=20,
186
+ )
187
+ if response.status_code != 200:
188
+ if response.status_code == 401:
189
+ raise InvalidAccessTokenError(f"{path} failed: HTTP {response.status_code}")
190
+ raise RuntimeError(f"{path} failed: HTTP {response.status_code}")
191
+ return response.json()
192
+
193
+ def _get_default_account(self) -> Dict[str, Any]:
194
+ route = "/backend-api/accounts/check/v4-2023-04-27"
195
+ response = self.session.get(self.base_url + route + "?timezone_offset_min=-480", headers=self._headers(route),
196
+ timeout=20)
197
+ if response.status_code != 200:
198
+ if response.status_code == 401:
199
+ raise InvalidAccessTokenError(f"{route} failed: HTTP {response.status_code}")
200
+ raise RuntimeError(f"/backend-api/accounts/check failed: HTTP {response.status_code}")
201
+ payload = response.json()
202
+ logger.debug({"event": "backend_user_info_account_payload", "account_payload": payload})
203
+ return ((payload.get("accounts") or {}).get("default") or {}).get("account") or {}
204
+
205
+ def get_user_info(self) -> Dict[str, Any]:
206
+ """获取当前 token 的账号信息。"""
207
+ if not self.access_token:
208
+ raise RuntimeError("access_token is required")
209
+ logger.debug({"event": "backend_user_info_start"})
210
+ with ThreadPoolExecutor(max_workers=3) as executor:
211
+ me_future = executor.submit(self._get_me)
212
+ init_future = executor.submit(self._get_conversation_init)
213
+ account_future = executor.submit(self._get_default_account)
214
+ me_payload, init_payload, default_account = me_future.result(), init_future.result(), account_future.result()
215
+
216
+ plan_type = str(default_account.get("plan_type") or "free")
217
+
218
+ limits_progress = init_payload.get("limits_progress")
219
+ limits_progress = limits_progress if isinstance(limits_progress, list) else []
220
+ quota, restore_at, image_quota_unknown = self._extract_quota_and_restore_at(limits_progress)
221
+ result = {
222
+ "email": me_payload.get("email"),
223
+ "user_id": me_payload.get("id"),
224
+ "type": plan_type,
225
+ "quota": quota,
226
+ "image_quota_unknown": image_quota_unknown,
227
+ "limits_progress": limits_progress,
228
+ "default_model_slug": init_payload.get("default_model_slug"),
229
+ "restore_at": restore_at,
230
+ "status": "正常" if image_quota_unknown and plan_type.lower() != "free" else ("限流" if quota == 0 else "正常"),
231
+ }
232
+ logger.debug({
233
+ "event": "backend_user_info_result",
234
+ "email": result.get("email"),
235
+ "user_id": result.get("user_id"),
236
+ "type": result.get("type"),
237
+ "quota": result.get("quota"),
238
+ "image_quota_unknown": result.get("image_quota_unknown"),
239
+ "default_model_slug": result.get("default_model_slug"),
240
+ "restore_at": result.get("restore_at"),
241
+ "status": result.get("status"),
242
+ })
243
+ return result
244
+
245
+ def _bootstrap_headers(self) -> Dict[str, str]:
246
+ """构造首页预热请求头。"""
247
+ return {
248
+ "User-Agent": self.user_agent,
249
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
250
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
251
+ "Sec-Ch-Ua": self.session.headers["Sec-Ch-Ua"],
252
+ "Sec-Ch-Ua-Mobile": self.session.headers["Sec-Ch-Ua-Mobile"],
253
+ "Sec-Ch-Ua-Platform": self.session.headers["Sec-Ch-Ua-Platform"],
254
+ "Sec-Fetch-Dest": "document",
255
+ "Sec-Fetch-Mode": "navigate",
256
+ "Sec-Fetch-Site": "none",
257
+ "Sec-Fetch-User": "?1",
258
+ "Upgrade-Insecure-Requests": "1",
259
+ }
260
+
261
+ def _build_requirements(self, data: Dict[str, Any], source_p: str = "") -> ChatRequirements:
262
+ """把 sentinel 响应整理成后续对话需要的 token 集合。"""
263
+ if (data.get("arkose") or {}).get("required"):
264
+ raise RuntimeError("chat requirements requires arkose token, which is not implemented")
265
+
266
+ proof_token = ""
267
+ proof_info = data.get("proofofwork") or {}
268
+ if proof_info.get("required"):
269
+ proof_token = build_proof_token(
270
+ proof_info.get("seed", ""),
271
+ proof_info.get("difficulty", ""),
272
+ self.user_agent,
273
+ script_sources=self.pow_script_sources,
274
+ data_build=self.pow_data_build,
275
+ )
276
+
277
+ turnstile_token = ""
278
+ turnstile_info = data.get("turnstile") or {}
279
+ if turnstile_info.get("required") and turnstile_info.get("dx"):
280
+ turnstile_token = solve_turnstile_token(turnstile_info["dx"], source_p) or ""
281
+
282
+ return ChatRequirements(
283
+ token=data.get("token", ""),
284
+ proof_token=proof_token,
285
+ turnstile_token=turnstile_token,
286
+ so_token=data.get("so_token", ""),
287
+ raw_finalize=data,
288
+ )
289
+
290
+ def _conversation_headers(self, path: str, requirements: ChatRequirements) -> Dict[str, str]:
291
+ """根据当前 requirements 构造对话 SSE 请求头。"""
292
+ headers = {
293
+ "Accept": "text/event-stream",
294
+ "Content-Type": "application/json",
295
+ "OpenAI-Sentinel-Chat-Requirements-Token": requirements.token,
296
+ }
297
+ if requirements.proof_token:
298
+ headers["OpenAI-Sentinel-Proof-Token"] = requirements.proof_token
299
+ if requirements.turnstile_token:
300
+ headers["OpenAI-Sentinel-Turnstile-Token"] = requirements.turnstile_token
301
+ if requirements.so_token:
302
+ headers["OpenAI-Sentinel-SO-Token"] = requirements.so_token
303
+ return self._headers(path, headers)
304
+
305
+ def _api_messages_to_conversation_messages(self, messages: list[Dict[str, Any]]) -> list[Dict[str, Any]]:
306
+ """把标准 chat messages 转成 web conversation 所需的 messages。"""
307
+ conversation_messages = []
308
+ for item in messages:
309
+ role = item.get("role", "user")
310
+ content = item.get("content", "")
311
+ if isinstance(content, str):
312
+ conversation_messages.append({
313
+ "id": new_uuid(),
314
+ "author": {"role": role},
315
+ "content": {"content_type": "text", "parts": [content]},
316
+ })
317
+ continue
318
+ if not isinstance(content, list):
319
+ raise RuntimeError("only string or list message content is supported")
320
+ text_parts: list[str] = []
321
+ image_inputs: list[tuple[bytes, str]] = []
322
+ for part in content:
323
+ if not isinstance(part, dict):
324
+ continue
325
+ part_type = str(part.get("type") or "")
326
+ if part_type == "text":
327
+ text_parts.append(str(part.get("text") or ""))
328
+ elif part_type == "image":
329
+ data = part.get("data")
330
+ mime = str(part.get("mime") or "image/png")
331
+ if isinstance(data, (bytes, bytearray)):
332
+ image_inputs.append((bytes(data), mime))
333
+ if not image_inputs:
334
+ conversation_messages.append({
335
+ "id": new_uuid(),
336
+ "author": {"role": role},
337
+ "content": {"content_type": "text", "parts": ["".join(text_parts)]},
338
+ })
339
+ continue
340
+ if not self.access_token:
341
+ raise RuntimeError("authenticated upstream account required for image input")
342
+ uploaded: list[Dict[str, Any]] = []
343
+ for idx, (data, mime) in enumerate(image_inputs, start=1):
344
+ ext_part = mime.split("/", 1)[1].split("+")[0] if "/" in mime else "png"
345
+ extension = "jpg" if ext_part == "jpeg" else (ext_part or "png")
346
+ b64 = base64.b64encode(data).decode("ascii")
347
+ uploaded.append(self._upload_image(f"data:{mime};base64,{b64}", f"image_{idx}.{extension}"))
348
+ parts: list[Any] = []
349
+ for ref in uploaded:
350
+ parts.append({
351
+ "content_type": "image_asset_pointer",
352
+ "asset_pointer": f"file-service://{ref['file_id']}",
353
+ "width": ref["width"],
354
+ "height": ref["height"],
355
+ "size_bytes": ref["file_size"],
356
+ })
357
+ text = "".join(text_parts)
358
+ if text:
359
+ parts.append(text)
360
+ conversation_messages.append({
361
+ "id": new_uuid(),
362
+ "author": {"role": role},
363
+ "content": {"content_type": "multimodal_text", "parts": parts},
364
+ "metadata": {
365
+ "attachments": [{
366
+ "id": ref["file_id"],
367
+ "mimeType": ref["mime_type"],
368
+ "name": ref["file_name"],
369
+ "size": ref["file_size"],
370
+ "width": ref["width"],
371
+ "height": ref["height"],
372
+ } for ref in uploaded],
373
+ },
374
+ })
375
+ return conversation_messages
376
+
377
+ def _conversation_payload(self, messages: list[Dict[str, Any]], model: str, timezone: str) -> Dict[str, Any]:
378
+ """把标准 messages 构造成 web 对话请求体。"""
379
+ return {
380
+ "action": "next",
381
+ "messages": self._api_messages_to_conversation_messages(messages),
382
+ "model": model,
383
+ "parent_message_id": new_uuid(),
384
+ "conversation_mode": {"kind": "primary_assistant"},
385
+ "conversation_origin": None,
386
+ "force_paragen": False,
387
+ "force_paragen_model_slug": "",
388
+ "force_rate_limit": False,
389
+ "force_use_sse": True,
390
+ "history_and_training_disabled": True,
391
+ "reset_rate_limits": False,
392
+ "suggestions": [],
393
+ "supported_encodings": [],
394
+ "system_hints": [],
395
+ "timezone": timezone,
396
+ "timezone_offset_min": -480,
397
+ "variant_purpose": "comparison_implicit",
398
+ "websocket_request_id": new_uuid(),
399
+ "client_contextual_info": {
400
+ "is_dark_mode": False,
401
+ "time_since_loaded": 120,
402
+ "page_height": 900,
403
+ "page_width": 1400,
404
+ "pixel_ratio": 2,
405
+ "screen_height": 1440,
406
+ "screen_width": 2560,
407
+ },
408
+ }
409
+
410
+ def _image_model_slug(self, model: str) -> str:
411
+ """把标准图片模型名映射到底层 model slug。"""
412
+ _, base_model = split_image_model(model)
413
+ if not base_model:
414
+ return "auto"
415
+ if base_model == "gpt-image-2":
416
+ return "gpt-5-3"
417
+ if base_model == CODEX_IMAGE_MODEL:
418
+ return base_model
419
+ return "auto"
420
+
421
+ def _image_headers(self, path: str, requirements: ChatRequirements, conduit_token: str = "", accept: str = "*/*") -> \
422
+ Dict[str, str]:
423
+ """构造图片链路请求头。"""
424
+ headers = {
425
+ "Content-Type": "application/json",
426
+ "Accept": accept,
427
+ "OpenAI-Sentinel-Chat-Requirements-Token": requirements.token,
428
+ }
429
+ if requirements.proof_token:
430
+ headers["OpenAI-Sentinel-Proof-Token"] = requirements.proof_token
431
+ if conduit_token:
432
+ headers["X-Conduit-Token"] = conduit_token
433
+ if accept == "text/event-stream":
434
+ headers["X-Oai-Turn-Trace-Id"] = new_uuid()
435
+ return self._headers(path, headers)
436
+
437
+ def _codex_responses_headers(self) -> Dict[str, str]:
438
+ return {
439
+ "Authorization": f"Bearer {self.access_token}",
440
+ "Content-Type": "application/json",
441
+ }
442
+
443
+ def _ensure_codex_source_account(self) -> None:
444
+ account = account_service.get_account(self.access_token)
445
+ source_type = str((account or {}).get("source_type") or "web").strip().lower()
446
+ if source_type != "codex":
447
+ raise RuntimeError("codex responses endpoint requires a codex source account")
448
+
449
+ @staticmethod
450
+ def _codex_image_input(prompt: str, images: list[str]) -> list[Dict[str, Any]]:
451
+ content: list[Dict[str, Any]] = [{"type": "input_text", "text": prompt}]
452
+ for image in images:
453
+ payload = image if image.startswith("data:image/") else f"data:image/png;base64,{image}"
454
+ content.append({"type": "input_image", "image_url": payload})
455
+ return [{"role": "user", "content": content}]
456
+
457
+ @staticmethod
458
+ def _codex_body_preview(body: Any, limit: int = 4000) -> str:
459
+ if isinstance(body, (dict, list)):
460
+ try:
461
+ text = json.dumps(body, ensure_ascii=False)
462
+ except Exception:
463
+ text = repr(body)
464
+ else:
465
+ text = str(body or "")
466
+ return text if len(text) <= limit else text[:limit] + "...[truncated]"
467
+
468
+ @staticmethod
469
+ def _codex_event_image_result_lengths(value: Any) -> list[int]:
470
+ if isinstance(value, dict):
471
+ lengths: list[int] = []
472
+ if value.get("type") == "image_generation_call" and isinstance(value.get("result"), str):
473
+ lengths.append(len(value["result"]))
474
+ for item in value.values():
475
+ lengths.extend(OpenAIBackendAPI._codex_event_image_result_lengths(item))
476
+ return lengths
477
+ if isinstance(value, list):
478
+ lengths: list[int] = []
479
+ for item in value:
480
+ lengths.extend(OpenAIBackendAPI._codex_event_image_result_lengths(item))
481
+ return lengths
482
+ return []
483
+
484
+ @staticmethod
485
+ def _codex_event_summary(event: Dict[str, Any]) -> Dict[str, Any]:
486
+ summary: Dict[str, Any] = {
487
+ "type": str(event.get("type") or ""),
488
+ "keys": list(event.keys())[:30],
489
+ }
490
+ for key in ("id", "status", "sequence_number", "response_id", "item_id", "output_index", "content_index"):
491
+ value = event.get(key)
492
+ if isinstance(value, (str, int, float, bool)) or value is None:
493
+ summary[key] = value
494
+ for key in ("response", "item", "output"):
495
+ value = event.get(key)
496
+ if isinstance(value, dict):
497
+ summary[f"{key}_type"] = value.get("type")
498
+ summary[f"{key}_status"] = value.get("status")
499
+ summary[f"{key}_keys"] = list(value.keys())[:30]
500
+ elif isinstance(value, list):
501
+ summary[f"{key}_len"] = len(value)
502
+ summary[f"{key}_types"] = [
503
+ item.get("type") for item in value[:10] if isinstance(item, dict)
504
+ ]
505
+ error = event.get("error")
506
+ if isinstance(error, dict):
507
+ summary["error"] = {
508
+ key: error.get(key)
509
+ for key in ("type", "code", "message")
510
+ if error.get(key) is not None
511
+ }
512
+ delta = event.get("delta")
513
+ if isinstance(delta, str):
514
+ summary["delta_len"] = len(delta)
515
+ summary["delta_preview"] = delta[:200]
516
+ result_lengths = OpenAIBackendAPI._codex_event_image_result_lengths(event)
517
+ if result_lengths:
518
+ summary["image_result_lengths"] = result_lengths[:10]
519
+ return summary
520
+
521
+ def _log_codex_response_failure(
522
+ self,
523
+ path: str,
524
+ status_code: int,
525
+ headers: Any,
526
+ payload: Dict[str, Any],
527
+ body: Any,
528
+ ) -> None:
529
+ request_headers = self._codex_responses_headers()
530
+ safe_request_headers = {
531
+ key: value for key, value in request_headers.items() if key.lower() != "authorization"
532
+ }
533
+ response_headers = dict(headers.items()) if hasattr(headers, "items") else dict(headers or {})
534
+ tool = ((payload.get("tools") or [{}])[0]) if isinstance(payload.get("tools"), list) else {}
535
+ logger.warning({
536
+ "event": "codex_responses_http_error",
537
+ "path": path,
538
+ "status_code": status_code,
539
+ "request": {
540
+ "model": payload.get("model"),
541
+ "tool_model": tool.get("model"),
542
+ "tool_action": tool.get("action"),
543
+ "size": tool.get("size"),
544
+ "quality": tool.get("quality"),
545
+ "image_input_count": max(len((payload.get("input") or [{}])[0].get("content") or []) - 1, 0),
546
+ "prompt_preview": self._codex_body_preview(
547
+ (((payload.get("input") or [{}])[0].get("content") or [{}])[0].get("text") or ""),
548
+ 500,
549
+ ),
550
+ "headers": safe_request_headers,
551
+ },
552
+ "response": {
553
+ "headers": response_headers,
554
+ "body_preview": self._codex_body_preview(body),
555
+ },
556
+ })
557
+
558
+ @staticmethod
559
+ def _iter_codex_response_events(raw: Any) -> Iterator[Dict[str, Any]]:
560
+ content_type = str(raw.headers.get("content-type") or "").lower()
561
+ text = raw.read().decode("utf-8", "replace")
562
+ status_code = getattr(raw, "status", None)
563
+ parse_errors: list[str] = []
564
+ events: list[Dict[str, Any]] = []
565
+ if "application/json" in content_type:
566
+ try:
567
+ data = json.loads(text)
568
+ if isinstance(data, dict):
569
+ events.append(data)
570
+ except Exception as exc:
571
+ parse_errors.append(str(exc))
572
+ else:
573
+ lines: list[str] = []
574
+ for line in text.splitlines() + [""]:
575
+ if not line:
576
+ if lines:
577
+ payload_text = "\n".join(lines).strip()
578
+ if payload_text and payload_text != "[DONE]":
579
+ try:
580
+ data = json.loads(payload_text)
581
+ except Exception as exc:
582
+ parse_errors.append(str(exc))
583
+ data = None
584
+ if isinstance(data, dict):
585
+ events.append(data)
586
+ lines = []
587
+ elif line.startswith("data:"):
588
+ lines.append(line[5:].lstrip())
589
+
590
+ event_types: Dict[str, int] = {}
591
+ image_result_lengths: list[int] = []
592
+ for event in events:
593
+ event_type = str(event.get("type") or "<missing>")
594
+ event_types[event_type] = event_types.get(event_type, 0) + 1
595
+ image_result_lengths.extend(OpenAIBackendAPI._codex_event_image_result_lengths(event))
596
+ logger.info({
597
+ "event": "codex_responses_response_debug",
598
+ "status_code": status_code,
599
+ "content_type": content_type,
600
+ "response_text_len": len(text),
601
+ "event_count": len(events),
602
+ "event_types": event_types,
603
+ "image_result_lengths": image_result_lengths[:10],
604
+ "parse_error_count": len(parse_errors),
605
+ "parse_errors": parse_errors[:5],
606
+ "event_summaries": [OpenAIBackendAPI._codex_event_summary(event) for event in events[:30]],
607
+ "event_previews": [
608
+ OpenAIBackendAPI._codex_body_preview(event, 1500)
609
+ for event in events[:10]
610
+ ] if not image_result_lengths else [],
611
+ "body_preview": text[:1000] if not events else "",
612
+ })
613
+ for event in events:
614
+ yield event
615
+
616
+ def iter_codex_image_response_events(
617
+ self,
618
+ prompt: str,
619
+ images: list[str] | None = None,
620
+ size: str | None = None,
621
+ quality: str = "auto",
622
+ ) -> Iterator[Dict[str, Any]]:
623
+ if not self.access_token:
624
+ raise RuntimeError("access_token is required for codex image endpoints")
625
+ self._ensure_codex_source_account()
626
+ path = "/backend-api/codex/responses"
627
+ payload = {
628
+ "model": CODEX_RESPONSES_MODEL,
629
+ "instructions": CODEX_RESPONSES_INSTRUCTIONS,
630
+ "store": False,
631
+ "input": self._codex_image_input(prompt, images or []),
632
+ "tools": [{
633
+ "type": "image_generation",
634
+ "model": "gpt-image-2",
635
+ "action": "edit" if images else "generate",
636
+ "size": str(size or "1024x1024"),
637
+ "quality": str(quality or "auto"),
638
+ "output_format": "png",
639
+ }],
640
+ "tool_choice": {"type": "image_generation"},
641
+ "stream": True,
642
+ }
643
+ request = urllib.request.Request(
644
+ self.base_url + path,
645
+ json.dumps(payload).encode(),
646
+ self._codex_responses_headers(),
647
+ method="POST",
648
+ )
649
+ account = account_service.get_account(self.access_token) or {}
650
+ token_payload = account_service._decode_jwt_payload(self.access_token)
651
+ auth_claim = token_payload.get("https://api.openai.com/auth")
652
+ auth_claim = auth_claim if isinstance(auth_claim, dict) else {}
653
+ tool = payload["tools"][0]
654
+ logger.info({
655
+ "event": "codex_responses_request_debug",
656
+ "url": self.base_url + path,
657
+ "transport": "urllib.request",
658
+ "timeout_secs": 1200,
659
+ "account_email": str(account.get("email") or "").strip(),
660
+ "source_type": str(account.get("source_type") or "").strip(),
661
+ "account_type": str(account.get("type") or "").strip(),
662
+ "token_claims": {
663
+ "jti": token_payload.get("jti"),
664
+ "iat": token_payload.get("iat"),
665
+ "exp": token_payload.get("exp"),
666
+ "client_id": token_payload.get("client_id"),
667
+ "chatgpt_account_id": auth_claim.get("chatgpt_account_id"),
668
+ "chatgpt_plan_type": auth_claim.get("chatgpt_plan_type"),
669
+ "localhost": auth_claim.get("localhost"),
670
+ },
671
+ "request": {
672
+ "model": payload.get("model"),
673
+ "tool_model": tool.get("model"),
674
+ "tool_action": tool.get("action"),
675
+ "size": tool.get("size"),
676
+ "quality": tool.get("quality"),
677
+ "output_format": tool.get("output_format"),
678
+ "stream": payload.get("stream"),
679
+ "image_input_count": max(len((payload.get("input") or [{}])[0].get("content") or []) - 1, 0),
680
+ "prompt_preview": self._codex_body_preview(
681
+ (((payload.get("input") or [{}])[0].get("content") or [{}])[0].get("text") or ""),
682
+ 500,
683
+ ),
684
+ },
685
+ "headers": {
686
+ key: value for key, value in self._codex_responses_headers().items()
687
+ if key.lower() != "authorization"
688
+ },
689
+ })
690
+ try:
691
+ with urllib.request.urlopen(request, timeout=1200) as raw:
692
+ yield from self._iter_codex_response_events(raw)
693
+ except urllib.error.HTTPError as error:
694
+ body_text = error.read().decode("utf-8", "replace")
695
+ body: Any = body_text
696
+ try:
697
+ body = json.loads(body_text)
698
+ except Exception:
699
+ pass
700
+ self._log_codex_response_failure(path, error.code, error.headers, payload, body)
701
+ retry_after_header = error.headers.get("Retry-After") if error.headers else None
702
+ retry_after = int(retry_after_header) if str(retry_after_header or "").isdigit() else None
703
+ raise UpstreamHTTPError(path, error.code, body, retry_after=retry_after) from error
704
+
705
+ def _prepare_image_conversation(self, prompt: str, requirements: ChatRequirements, model: str) -> str:
706
+ """为图片生成准备 conduit token。"""
707
+ path = "/backend-api/f/conversation/prepare"
708
+ payload = {
709
+ "action": "next",
710
+ "fork_from_shared_post": False,
711
+ "parent_message_id": new_uuid(),
712
+ "model": self._image_model_slug(model),
713
+ "client_prepare_state": "success",
714
+ "timezone_offset_min": -480,
715
+ "timezone": "Asia/Shanghai",
716
+ "conversation_mode": {"kind": "primary_assistant"},
717
+ "system_hints": ["picture_v2"],
718
+ "partial_query": {
719
+ "id": new_uuid(),
720
+ "author": {"role": "user"},
721
+ "content": {"content_type": "text", "parts": [prompt]},
722
+ },
723
+ "supports_buffering": True,
724
+ "supported_encodings": ["v1"],
725
+ "client_contextual_info": {"app_name": "chatgpt.com"},
726
+ }
727
+ response = self.session.post(
728
+ self.base_url + path,
729
+ headers=self._image_headers(path, requirements),
730
+ json=payload,
731
+ timeout=60,
732
+ )
733
+ ensure_ok(response, path)
734
+ return response.json().get("conduit_token", "")
735
+
736
+ def _decode_image_base64(self, image: str) -> bytes:
737
+ """把 base64 图片字符串或本地路径解码成二进制。"""
738
+ if (
739
+ image
740
+ and len(image) < 512
741
+ and not image.startswith("data:")
742
+ and "\n" not in image
743
+ and "\r" not in image
744
+ ):
745
+ file_path = Path(os.path.expanduser(image))
746
+ if file_path.exists() and file_path.is_file():
747
+ return file_path.read_bytes()
748
+ payload = image.split(",", 1)[1] if image.startswith("data:") and "," in image else image
749
+ return base64.b64decode(payload)
750
+
751
+ def _upload_image(self, image: str, file_name: str = "image.png") -> Dict[str, Any]:
752
+ """上传一张 base64 图片,返回底层文件元数据。"""
753
+ data = self._decode_image_base64(image)
754
+ if (
755
+ image
756
+ and len(image) < 512
757
+ and not image.startswith("data:")
758
+ and "\n" not in image
759
+ and "\r" not in image
760
+ ):
761
+ candidate_path = Path(os.path.expanduser(image))
762
+ if candidate_path.exists() and candidate_path.is_file():
763
+ file_name = candidate_path.name
764
+ image = Image.open(BytesIO(data))
765
+ width, height = image.size
766
+ mime_type = Image.MIME.get(image.format, "image/png")
767
+ path = "/backend-api/files"
768
+ response = self.session.post(
769
+ self.base_url + path,
770
+ headers=self._headers(path, {"Content-Type": "application/json", "Accept": "application/json"}),
771
+ json={"file_name": file_name, "file_size": len(data), "use_case": "multimodal", "width": width,
772
+ "height": height},
773
+ timeout=60,
774
+ )
775
+ ensure_ok(response, path)
776
+ upload_meta = response.json()
777
+ time.sleep(0.5)
778
+ response = self.session.put(
779
+ upload_meta["upload_url"],
780
+ headers={
781
+ "Content-Type": mime_type,
782
+ "x-ms-blob-type": "BlockBlob",
783
+ "x-ms-version": "2020-04-08",
784
+ "Origin": self.base_url,
785
+ "Referer": self.base_url + "/",
786
+ "User-Agent": self.user_agent,
787
+ "Accept": "application/json, text/plain, */*",
788
+ "Accept-Language": "en-US,en;q=0.8",
789
+ },
790
+ data=data,
791
+ timeout=120,
792
+ )
793
+ ensure_ok(response, "image_upload")
794
+ path = f"/backend-api/files/{upload_meta['file_id']}/uploaded"
795
+ response = self.session.post(
796
+ self.base_url + path,
797
+ headers=self._headers(path, {"Content-Type": "application/json", "Accept": "application/json"}),
798
+ data="{}",
799
+ timeout=60,
800
+ )
801
+ ensure_ok(response, path)
802
+ return {
803
+ "file_id": upload_meta["file_id"],
804
+ "file_name": file_name,
805
+ "file_size": len(data),
806
+ "mime_type": mime_type,
807
+ "width": width,
808
+ "height": height,
809
+ }
810
+
811
+ def _start_image_generation(self, prompt: str, requirements: ChatRequirements, conduit_token: str, model: str,
812
+ references: Optional[list[Dict[str, Any]]] = None) -> requests.Response:
813
+ """启动图片生成或编辑的 SSE 请求。"""
814
+ references = references or []
815
+ parts = [{
816
+ "content_type": "image_asset_pointer",
817
+ "asset_pointer": f"file-service://{item['file_id']}",
818
+ "width": item["width"],
819
+ "height": item["height"],
820
+ "size_bytes": item["file_size"],
821
+ } for item in references]
822
+ parts.append(prompt)
823
+ content = {"content_type": "multimodal_text", "parts": parts} if references else {"content_type": "text",
824
+ "parts": [prompt]}
825
+ metadata = {
826
+ "developer_mode_connector_ids": [],
827
+ "selected_github_repos": [],
828
+ "selected_all_github_repos": False,
829
+ "system_hints": ["picture_v2"],
830
+ "serialization_metadata": {"custom_symbol_offsets": []},
831
+ }
832
+ if references:
833
+ metadata["attachments"] = [{
834
+ "id": item["file_id"],
835
+ "mimeType": item["mime_type"],
836
+ "name": item["file_name"],
837
+ "size": item["file_size"],
838
+ "width": item["width"],
839
+ "height": item["height"],
840
+ } for item in references]
841
+ payload = {
842
+ "action": "next",
843
+ "messages": [{
844
+ "id": new_uuid(),
845
+ "author": {"role": "user"},
846
+ "create_time": time.time(),
847
+ "content": content,
848
+ "metadata": metadata,
849
+ }],
850
+ "parent_message_id": new_uuid(),
851
+ "model": self._image_model_slug(model),
852
+ "client_prepare_state": "sent",
853
+ "timezone_offset_min": -480,
854
+ "timezone": "Asia/Shanghai",
855
+ "conversation_mode": {"kind": "primary_assistant"},
856
+ "enable_message_followups": True,
857
+ "system_hints": ["picture_v2"],
858
+ "supports_buffering": True,
859
+ "supported_encodings": ["v1"],
860
+ "client_contextual_info": {
861
+ "is_dark_mode": False,
862
+ "time_since_loaded": 1200,
863
+ "page_height": 1072,
864
+ "page_width": 1724,
865
+ "pixel_ratio": 1.2,
866
+ "screen_height": 1440,
867
+ "screen_width": 2560,
868
+ "app_name": "chatgpt.com",
869
+ },
870
+ "paragen_cot_summary_display_override": "allow",
871
+ "force_parallel_switch": "auto",
872
+ }
873
+ path = "/backend-api/f/conversation"
874
+ response = self.session.post(
875
+ self.base_url + path,
876
+ headers=self._image_headers(path, requirements, conduit_token, "text/event-stream"),
877
+ json=payload,
878
+ timeout=300,
879
+ stream=True,
880
+ )
881
+ ensure_ok(response, path)
882
+ return response
883
+
884
+ def _get_conversation(self, conversation_id: str) -> Dict[str, Any]:
885
+ """获取完整 conversation 详情。"""
886
+ path = f"/backend-api/conversation/{conversation_id}"
887
+ response = self.session.get(self.base_url + path, headers=self._headers(path, {"Accept": "application/json"}),
888
+ timeout=60)
889
+ ensure_ok(response, path)
890
+ return response.json()
891
+
892
+ def _extract_image_tool_records(self, data: Dict[str, Any]) -> list[Dict[str, Any]]:
893
+ """从 conversation 明细里提取图片工具输出记录。"""
894
+ mapping = data.get("mapping") or {}
895
+ file_pat = re.compile(r"file-service://([A-Za-z0-9_-]+)")
896
+ sed_pat = re.compile(r"sediment://([A-Za-z0-9_-]+)")
897
+ records = []
898
+ for message_id, node in mapping.items():
899
+ message = (node or {}).get("message") or {}
900
+ author = message.get("author") or {}
901
+ metadata = message.get("metadata") or {}
902
+ content = message.get("content") or {}
903
+ if author.get("role") != "tool":
904
+ continue
905
+ if content.get("content_type") != "multimodal_text":
906
+ continue
907
+ file_ids, sediment_ids = [], []
908
+ for part in content.get("parts") or []:
909
+ text = (part.get("asset_pointer") or "") if isinstance(part, dict) else (
910
+ part if isinstance(part, str) else "")
911
+ for hit in file_pat.findall(text):
912
+ if hit not in file_ids:
913
+ file_ids.append(hit)
914
+ for hit in sed_pat.findall(text):
915
+ if hit not in sediment_ids:
916
+ sediment_ids.append(hit)
917
+ if metadata.get("async_task_type") != "image_gen" and not file_ids and not sediment_ids:
918
+ continue
919
+ records.append(
920
+ {"message_id": message_id, "create_time": message.get("create_time") or 0, "file_ids": file_ids,
921
+ "sediment_ids": sediment_ids})
922
+ return sorted(records, key=lambda item: item["create_time"])
923
+
924
+ def _poll_image_results(self, conversation_id: str, timeout_secs: float = 120.0) -> tuple[list[str], list[str]]:
925
+ """Poll the conversation document until image file ids appear or budget runs out.
926
+
927
+ - Sleeps image_poll_initial_wait_secs first (default 10s, +jitter). ChatGPT
928
+ image generation takes ~30s; polling immediately wastes requests and trips
929
+ a transient 429 the upstream returns within ~200ms of the SSE stream
930
+ closing (the conversation document is not yet committed).
931
+ - Subsequent polls are image_poll_interval_secs apart (default 10s).
932
+ - On upstream 429 / 5xx or network errors, backs off exponentially
933
+ (capped at 16s, +jitter) honoring Retry-After when present.
934
+ - All sleeps stay within timeout_secs; on exhaustion raises ImagePollTimeoutError.
935
+ """
936
+ start = time.time()
937
+ attempt = 0
938
+ interval = float(config.image_poll_interval_secs)
939
+ initial_wait = float(config.image_poll_initial_wait_secs)
940
+ logger.info({
941
+ "event": "image_poll_start",
942
+ "conversation_id": conversation_id,
943
+ "timeout_secs": timeout_secs,
944
+ "initial_wait_secs": initial_wait,
945
+ "interval_secs": interval,
946
+ })
947
+
948
+ def _remaining() -> float:
949
+ return timeout_secs - (time.time() - start)
950
+
951
+ if initial_wait > 0:
952
+ jitter = random.uniform(0, min(2.0, initial_wait * 0.2))
953
+ sleep_for = min(initial_wait + jitter, max(0.0, _remaining()))
954
+ if sleep_for > 0:
955
+ time.sleep(sleep_for)
956
+
957
+ def _retry_sleep(reason: str, status_code: int | None, error: str | None, retry_after: int | None) -> bool:
958
+ # retry_after=0 means "retry immediately" — must not be coerced via falsy check.
959
+ base = retry_after if retry_after is not None else min(2 ** min(attempt, 4), 16)
960
+ backoff = base + random.uniform(0, 0.5)
961
+ remaining = _remaining()
962
+ if remaining <= 0:
963
+ return False
964
+ sleep_for = min(backoff, remaining)
965
+ log_payload: Dict[str, Any] = {
966
+ "event": "image_poll_retry",
967
+ "conversation_id": conversation_id,
968
+ "attempt": attempt,
969
+ "reason": reason,
970
+ "sleep_secs": round(sleep_for, 2),
971
+ }
972
+ if status_code is not None:
973
+ log_payload["status_code"] = status_code
974
+ if error is not None:
975
+ log_payload["error"] = error
976
+ logger.warning(log_payload)
977
+ time.sleep(sleep_for)
978
+ return True
979
+
980
+ while _remaining() > 0:
981
+ attempt += 1
982
+ try:
983
+ conversation = self._get_conversation(conversation_id)
984
+ except UpstreamHTTPError as exc:
985
+ if exc.status_code in (429, 500, 502, 503, 504):
986
+ if _retry_sleep("upstream_status", exc.status_code, None, exc.retry_after):
987
+ continue
988
+ break
989
+ raise
990
+ except requests.exceptions.RequestException as exc:
991
+ if _retry_sleep("network", None, str(exc), None):
992
+ continue
993
+ break
994
+
995
+ file_ids, sediment_ids = [], []
996
+ for record in self._extract_image_tool_records(conversation):
997
+ for file_id in record["file_ids"]:
998
+ if file_id not in file_ids:
999
+ file_ids.append(file_id)
1000
+ for sediment_id in record["sediment_ids"]:
1001
+ if sediment_id not in sediment_ids:
1002
+ sediment_ids.append(sediment_id)
1003
+ logger.debug({"event": "image_poll_check", "conversation_id": conversation_id, "attempt": attempt,
1004
+ "file_ids": file_ids, "sediment_ids": sediment_ids})
1005
+ if file_ids:
1006
+ logger.info({"event": "image_poll_hit", "conversation_id": conversation_id, "file_ids": file_ids,
1007
+ "sediment_ids": sediment_ids})
1008
+ return file_ids, sediment_ids
1009
+ if sediment_ids:
1010
+ logger.info({"event": "image_poll_hit", "conversation_id": conversation_id, "file_ids": [],
1011
+ "sediment_ids": sediment_ids})
1012
+ return [], sediment_ids
1013
+ logger.debug({"event": "image_poll_wait", "conversation_id": conversation_id,
1014
+ "elapsed_secs": round(time.time() - start, 1)})
1015
+ wait = min(interval, max(0.0, _remaining()))
1016
+ if wait > 0:
1017
+ time.sleep(wait)
1018
+ logger.info({
1019
+ "event": "image_poll_timeout",
1020
+ "conversation_id": conversation_id,
1021
+ "timeout_secs": timeout_secs,
1022
+ "attempts_made": attempt,
1023
+ # attempts_made == 0 means the initial_wait consumed the entire budget — no HTTP attempted.
1024
+ "initial_wait_exhausted_budget": attempt == 0,
1025
+ })
1026
+ raise ImagePollTimeoutError(
1027
+ f"ChatGPT 生图超时(已等待 {timeout_secs} 秒)。"
1028
+ f"当前超时阈值可在 config.json 中调大 image_poll_timeout_secs,"
1029
+ f"也可能是账号被限流或生图队列拥堵导致。"
1030
+ )
1031
+
1032
+ def _get_file_download_url(self, file_id: str) -> str:
1033
+ """获取文件下载地址。"""
1034
+ path = f"/backend-api/files/{file_id}/download"
1035
+ response = self.session.get(self.base_url + path, headers=self._headers(path, {"Accept": "application/json"}),
1036
+ timeout=60)
1037
+ ensure_ok(response, path)
1038
+ data = response.json()
1039
+ return data.get("download_url") or data.get("url") or ""
1040
+
1041
+ def _get_attachment_download_url(self, conversation_id: str, attachment_id: str) -> str:
1042
+ """通过 conversation 附件接口获取下载地址。"""
1043
+ path = f"/backend-api/conversation/{conversation_id}/attachment/{attachment_id}/download"
1044
+ response = self.session.get(self.base_url + path, headers=self._headers(path, {"Accept": "application/json"}),
1045
+ timeout=60)
1046
+ ensure_ok(response, path)
1047
+ data = response.json()
1048
+ return data.get("download_url") or data.get("url") or ""
1049
+
1050
+ def _resolve_image_urls(self, conversation_id: str, file_ids: list[str], sediment_ids: list[str]) -> list[str]:
1051
+ """把图片结果 id 解析成可下载 URL。"""
1052
+ urls = []
1053
+ skip_patterns = {"file_upload"}
1054
+ for file_id in file_ids:
1055
+ if file_id in skip_patterns:
1056
+ logger.debug({
1057
+ "event": "image_file_id_skipped",
1058
+ "source": "file",
1059
+ "conversation_id": conversation_id,
1060
+ "id": file_id,
1061
+ })
1062
+ continue
1063
+ try:
1064
+ url = self._get_file_download_url(file_id)
1065
+ except Exception as exc:
1066
+ logger.debug({
1067
+ "event": "image_download_url_failed",
1068
+ "source": "file",
1069
+ "conversation_id": conversation_id,
1070
+ "id": file_id,
1071
+ "error": repr(exc),
1072
+ })
1073
+ continue
1074
+ if url:
1075
+ urls.append(url)
1076
+ else:
1077
+ logger.debug({
1078
+ "event": "image_download_url_empty",
1079
+ "source": "file",
1080
+ "conversation_id": conversation_id,
1081
+ "id": file_id,
1082
+ })
1083
+ if urls or not conversation_id:
1084
+ logger.debug({
1085
+ "event": "image_urls_resolved",
1086
+ "conversation_id": conversation_id,
1087
+ "file_ids": file_ids,
1088
+ "sediment_ids": sediment_ids,
1089
+ "urls": urls,
1090
+ })
1091
+ return urls
1092
+ for sediment_id in sediment_ids:
1093
+ try:
1094
+ url = self._get_attachment_download_url(conversation_id, sediment_id)
1095
+ except Exception as exc:
1096
+ logger.debug({
1097
+ "event": "image_download_url_failed",
1098
+ "source": "sediment",
1099
+ "conversation_id": conversation_id,
1100
+ "id": sediment_id,
1101
+ "error": repr(exc),
1102
+ })
1103
+ continue
1104
+ if url:
1105
+ urls.append(url)
1106
+ else:
1107
+ logger.debug({
1108
+ "event": "image_download_url_empty",
1109
+ "source": "sediment",
1110
+ "conversation_id": conversation_id,
1111
+ "id": sediment_id,
1112
+ })
1113
+ logger.debug({
1114
+ "event": "image_urls_resolved",
1115
+ "conversation_id": conversation_id,
1116
+ "file_ids": file_ids,
1117
+ "sediment_ids": sediment_ids,
1118
+ "urls": urls,
1119
+ })
1120
+ return urls
1121
+
1122
+ def resolve_conversation_image_urls(
1123
+ self,
1124
+ conversation_id: str,
1125
+ file_ids: list[str],
1126
+ sediment_ids: list[str],
1127
+ poll: bool = True,
1128
+ ) -> list[str]:
1129
+ file_ids = [item for item in file_ids if item != "file_upload"]
1130
+ sediment_ids = list(sediment_ids)
1131
+ if poll and conversation_id and not file_ids and not sediment_ids:
1132
+ logger.info({"event": "image_resolve_poll_needed", "conversation_id": conversation_id})
1133
+ polled_file_ids, polled_sediment_ids = self._poll_image_results(conversation_id,
1134
+ config.image_poll_timeout_secs)
1135
+ file_ids.extend(item for item in polled_file_ids if item and item not in file_ids)
1136
+ sediment_ids.extend(item for item in polled_sediment_ids if item and item not in sediment_ids)
1137
+ return self._resolve_image_urls(conversation_id, file_ids, sediment_ids)
1138
+
1139
+ def download_image_bytes(self, urls: list[str]) -> list[bytes]:
1140
+ images = []
1141
+ for url in urls:
1142
+ response = self.session.get(url, timeout=120)
1143
+ ensure_ok(response, "image_download")
1144
+ images.append(response.content)
1145
+ return images
1146
+
1147
+ def stream_conversation(
1148
+ self,
1149
+ messages: Optional[list[Dict[str, Any]]] = None,
1150
+ model: str = "auto",
1151
+ prompt: str = "",
1152
+ images: Optional[list[str]] = None,
1153
+ system_hints: Optional[list[str]] = None,
1154
+ ) -> Iterator[str]:
1155
+ system_hints = system_hints or []
1156
+ if "picture_v2" in system_hints:
1157
+ yield from self._stream_picture_conversation(prompt, model, images or [])
1158
+ return
1159
+
1160
+ normalized = messages or [{"role": "user", "content": prompt}]
1161
+ self._bootstrap()
1162
+ requirements = self._get_chat_requirements()
1163
+ path, timezone = self._chat_target()
1164
+ payload = self._conversation_payload(normalized, model, timezone)
1165
+ response = self.session.post(
1166
+ self.base_url + path,
1167
+ headers=self._conversation_headers(path, requirements),
1168
+ json=payload,
1169
+ timeout=300,
1170
+ stream=True,
1171
+ )
1172
+ ensure_ok(response, path)
1173
+ try:
1174
+ yield from iter_sse_payloads(response)
1175
+ finally:
1176
+ response.close()
1177
+
1178
+ def _stream_picture_conversation(
1179
+ self,
1180
+ prompt: str,
1181
+ model: str,
1182
+ images: list[str],
1183
+ ) -> Iterator[str]:
1184
+ if not self.access_token:
1185
+ raise RuntimeError("access_token is required for image endpoints")
1186
+ references = [self._upload_image(image, f"image_{idx}.png") for idx, image in enumerate(images, start=1)]
1187
+ self._bootstrap()
1188
+ requirements = self._get_chat_requirements()
1189
+ conduit_token = self._prepare_image_conversation(prompt, requirements, model)
1190
+ response = self._start_image_generation(prompt, requirements, conduit_token, model, references)
1191
+ try:
1192
+ yield from iter_sse_payloads(response)
1193
+ finally:
1194
+ response.close()
1195
+
1196
+ def _bootstrap(self) -> None:
1197
+ """预热首页,并提取 PoW 相关脚本引用。"""
1198
+ response = self.session.get(
1199
+ self.base_url + "/",
1200
+ headers=self._bootstrap_headers(),
1201
+ timeout=30,
1202
+ )
1203
+ ensure_ok(response, "bootstrap")
1204
+ self.pow_script_sources, self.pow_data_build = parse_pow_resources(response.text)
1205
+ if not self.pow_script_sources:
1206
+ self.pow_script_sources = [DEFAULT_POW_SCRIPT]
1207
+
1208
+ def _get_chat_requirements(self) -> ChatRequirements:
1209
+ """获取当前模式对话所需的 sentinel token。"""
1210
+ path = "/backend-api/sentinel/chat-requirements" if self.access_token else "/backend-anon/sentinel/chat-requirements"
1211
+ context = "auth_chat_requirements" if self.access_token else "noauth_chat_requirements"
1212
+ body = {"p": build_legacy_requirements_token(self.user_agent, self.pow_script_sources, self.pow_data_build)}
1213
+ response = self.session.post(
1214
+ self.base_url + path,
1215
+ headers=self._headers(path, {"Content-Type": "application/json"}),
1216
+ json=body,
1217
+ timeout=30,
1218
+ )
1219
+ ensure_ok(response, context)
1220
+ requirements = self._build_requirements(response.json(), "" if self.access_token else body["p"])
1221
+ if not requirements.token:
1222
+ message = "missing auth chat requirements token" if self.access_token else "missing chat requirements token"
1223
+ raise RuntimeError(f"{message}: {requirements.raw_finalize}")
1224
+ return requirements
1225
+
1226
+ def _chat_target(self) -> tuple[str, str]:
1227
+ if self.access_token:
1228
+ return "/backend-api/conversation", "Asia/Shanghai"
1229
+ return "/backend-anon/conversation", "America/Los_Angeles"
1230
+
1231
+ def list_models(self) -> Dict[str, Any]:
1232
+ """返回当前模式下可用模型,格式对齐 OpenAI `/v1/models`。"""
1233
+ self._bootstrap()
1234
+ path = "/backend-api/models?history_and_training_disabled=false" if self.access_token else (
1235
+ "/backend-anon/models?iim=false&is_gizmo=false"
1236
+ )
1237
+ route = "/backend-api/models" if self.access_token else "/backend-anon/models"
1238
+ context = "auth_models" if self.access_token else "anon_models"
1239
+ response = self.session.get(
1240
+ self.base_url + path,
1241
+ headers=self._headers(route),
1242
+ timeout=30,
1243
+ )
1244
+ ensure_ok(response, context)
1245
+ data = []
1246
+ seen = set()
1247
+ for item in response.json().get("models", []):
1248
+ if not isinstance(item, dict):
1249
+ continue
1250
+ slug = str(item.get("slug", "")).strip()
1251
+ if not slug or slug in seen:
1252
+ continue
1253
+ seen.add(slug)
1254
+ data.append({
1255
+ "id": slug,
1256
+ "object": "model",
1257
+ "created": int(item.get("created") or 0),
1258
+ "owned_by": str(item.get("owned_by") or "chatgpt"),
1259
+ "permission": [],
1260
+ "root": slug,
1261
+ "parent": None,
1262
+ })
1263
+ data.sort(key=lambda item: item["id"])
1264
+ return {"object": "list", "data": data}
services/protocol/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """Protocol converters for OpenAI-compatible endpoints."""
2
+
services/protocol/anthropic_v1_messages.py ADDED
@@ -0,0 +1,306 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import html
4
+ import json
5
+ import re
6
+ import time
7
+ import uuid
8
+ from collections.abc import Callable, Iterable, Iterator
9
+ from dataclasses import dataclass
10
+ from typing import Any
11
+
12
+ from services.account_service import account_service
13
+ from services.openai_backend_api import OpenAIBackendAPI
14
+ from services.protocol.conversation import count_message_tokens, count_text_tokens, normalize_messages
15
+ from services.protocol.openai_v1_chat_complete import collect_chat_content, stream_text_chat_completion
16
+
17
+ XML_TOOL_RULE = """Tool output adapter: when calling tools, output ONLY this XML and no prose/markdown:
18
+ <tool_calls><tool_call><tool_name>TOOL_NAME</tool_name><parameters><PARAM><![CDATA[value]]></PARAM></parameters></tool_call></tool_calls>"""
19
+
20
+
21
+ @dataclass
22
+ class MessageRequest:
23
+ backend: OpenAIBackendAPI
24
+ messages: list[dict[str, Any]]
25
+ model: str
26
+ tools: Any = None
27
+
28
+
29
+ def _tool_meta(tool: dict[str, object]) -> tuple[str, str, object]:
30
+ fn = tool.get("function") if isinstance(tool.get("function"), dict) else {}
31
+ name = str(tool.get("name") or fn.get("name") or "").strip()
32
+ desc = str(tool.get("description") or fn.get("description") or "").strip()
33
+ schema = tool.get("input_schema") or tool.get("parameters") or fn.get("input_schema") or fn.get("parameters") or {}
34
+ return name, desc, schema
35
+
36
+
37
+ def build_tool_prompt(tools: object) -> str:
38
+ if not isinstance(tools, list):
39
+ return ""
40
+ blocks = []
41
+ for tool in tools:
42
+ if not isinstance(tool, dict):
43
+ continue
44
+ name, desc, schema = _tool_meta(tool)
45
+ if name:
46
+ blocks.append(f"Tool: {name}\nDescription: {desc}\nParameters: {json.dumps(schema, ensure_ascii=False)}")
47
+ if not blocks:
48
+ return ""
49
+ return "Available tools:\n" + "\n".join(blocks) + """
50
+
51
+ Tool use rules:
52
+ - If the user asks to list/read/search files, inspect project state, run a command, or answer from local code, you MUST call a suitable tool first. Do not say you cannot access files.
53
+ - To call tools, output ONLY XML and no prose/markdown:
54
+ <tool_calls><tool_call><tool_name>TOOL_NAME</tool_name><parameters><PARAM><![CDATA[value]]></PARAM></parameters></tool_call></tool_calls>
55
+ - Put parameters under <parameters> using the exact schema names.
56
+ """.strip()
57
+
58
+
59
+ def merge_system(system: object, extra: str) -> object:
60
+ system = compact_system(system)
61
+ if _has_claude_code_system(system):
62
+ extra = XML_TOOL_RULE
63
+ if not extra:
64
+ return system
65
+ if isinstance(system, str) and system.strip():
66
+ return f"{system.strip()}\n\n{extra}"
67
+ if isinstance(system, list):
68
+ return [*system, {"type": "text", "text": extra}]
69
+ return extra
70
+
71
+
72
+ def _has_claude_code_system(system: object) -> bool:
73
+ if isinstance(system, str):
74
+ return "You are Claude Code" in system
75
+ if isinstance(system, list):
76
+ return any(isinstance(item, dict) and "You are Claude Code" in str(item.get("text") or "") for item in system)
77
+ return False
78
+
79
+
80
+ def compact_system(system: object) -> object:
81
+ if isinstance(system, str):
82
+ return _compact_system_text(system)
83
+ if isinstance(system, list):
84
+ result = []
85
+ for item in system:
86
+ if isinstance(item, dict) and str(item.get("type") or "") == "text":
87
+ copied = dict(item)
88
+ copied["text"] = _compact_system_text(str(item.get("text") or ""))
89
+ result.append(copied)
90
+ else:
91
+ result.append(item)
92
+ return result
93
+ return system
94
+
95
+
96
+ def _compact_system_text(text: str) -> str:
97
+ return text or ""
98
+
99
+
100
+ def _compact_message_text(text: str) -> str:
101
+ return text or ""
102
+
103
+
104
+ def preprocess_payload(payload: dict[str, object], text_mapper: Callable[[str], str] | None = None) -> dict[str, object]:
105
+ payload["messages"] = preprocess_messages(payload.get("messages"), text_mapper)
106
+ payload["system"] = merge_system(payload.get("system"), build_tool_prompt(payload.get("tools")))
107
+ return payload
108
+
109
+
110
+ def message_request(body: dict[str, Any]) -> MessageRequest:
111
+ payload = preprocess_payload(dict(body))
112
+ return MessageRequest(
113
+ backend=OpenAIBackendAPI(access_token=account_service.get_text_access_token()),
114
+ messages=normalize_messages(payload.get("messages"), payload.get("system")),
115
+ model=str(payload.get("model") or "auto").strip() or "auto",
116
+ tools=payload.get("tools"),
117
+ )
118
+
119
+
120
+ def preprocess_messages(messages: object, text_mapper: Callable[[str], str] | None = None) -> object:
121
+ if not isinstance(messages, list):
122
+ return messages
123
+ mapper = text_mapper or (lambda text: text)
124
+ result = []
125
+ for message in messages:
126
+ if not isinstance(message, dict):
127
+ continue
128
+ item = dict(message)
129
+ content = item.get("content")
130
+ if isinstance(content, str):
131
+ item["content"] = _compact_message_text(mapper(content))
132
+ elif isinstance(content, list):
133
+ item["content"] = [_preprocess_block(block, mapper) for block in content]
134
+ result.append(item)
135
+ return result
136
+
137
+
138
+ def _preprocess_block(block: object, text_mapper: Callable[[str], str]) -> object:
139
+ if not isinstance(block, dict):
140
+ return block
141
+ block_type = str(block.get("type") or "")
142
+ if block_type == "text":
143
+ item = dict(block)
144
+ item["text"] = _compact_message_text(text_mapper(str(block.get("text") or "")))
145
+ return item
146
+ if block_type == "tool_use":
147
+ return {"type": "text", "text": f"<tool_calls><tool_call><tool_name>{block.get('name') or ''}</tool_name><parameters>{json.dumps(block.get('input') or {}, ensure_ascii=False)}</parameters></tool_call></tool_calls>"}
148
+ if block_type == "tool_result":
149
+ return {"type": "text", "text": f"Tool result {block.get('tool_use_id') or ''}: {block.get('content') or ''}"}
150
+ return block
151
+
152
+
153
+ def message_response(model: str, text: str, input_tokens: int, output_tokens: int, tools: object = None) -> dict[str, object]:
154
+ content, stop_reason = content_blocks(text, tools)
155
+ return {
156
+ "id": f"msg_{uuid.uuid4()}",
157
+ "type": "message",
158
+ "role": "assistant",
159
+ "model": model,
160
+ "content": content,
161
+ "stop_reason": stop_reason,
162
+ "stop_sequence": None,
163
+ "usage": {"input_tokens": input_tokens, "output_tokens": output_tokens},
164
+ }
165
+
166
+
167
+ def content_blocks(text: str, tools: object = None) -> tuple[list[dict[str, object]], str]:
168
+ calls = parse_tool_calls(text) if isinstance(tools, list) and tools else []
169
+ text = strip_tool_markup(text)
170
+ if calls:
171
+ content = ([{"type": "text", "text": text}] if text else []) + [{"type": "tool_use", "id": f"toolu_{uuid.uuid4()}", "name": name, "input": args} for name, args in calls]
172
+ return content, "tool_use"
173
+ return [{"type": "text", "text": text}], "end_turn"
174
+
175
+
176
+ def strip_tool_markup(text: str) -> str:
177
+ return re.sub(r"(?is)<tool_calls\b[^>]*>.*?</tool_calls>|<tool_call\b[^>]*>.*?</tool_call>|<function_call\b[^>]*>.*?</function_call>|<invoke\b[^>]*>.*?</invoke>", "", text or "").strip()
178
+
179
+
180
+ def streamable_text(text: str) -> str:
181
+ text = text or ""
182
+ match = re.search(r"(?is)<tool_calls\b|<tool_call\b|<function_call\b|<invoke\b", text)
183
+ return text[:match.start()].rstrip() if match else text
184
+
185
+
186
+ def parse_tool_calls(text: str) -> list[tuple[str, dict[str, object]]]:
187
+ text = re.sub(r"(?is)```.*?```", "", text or "").strip()
188
+ blocks = re.findall(r"(?is)<tool_call\b[^>]*>(.*?)</tool_call>|<function_call\b[^>]*>(.*?)</function_call>|<invoke\b[^>]*>(.*?)</invoke>", text)
189
+ result = []
190
+ for block in (next((part for part in match if part), "") for match in blocks):
191
+ name = xml_value(block, "tool_name") or xml_value(block, "name") or xml_value(block, "function")
192
+ params = xml_value(block, "parameters") or xml_value(block, "input") or xml_value(block, "arguments") or "{}"
193
+ if name:
194
+ result.append((name, parse_tool_params(params)))
195
+ return result
196
+
197
+
198
+ def xml_value(text: str, tag: str) -> str:
199
+ match = re.search(rf"(?is)<{tag}\b[^>]*>(.*?)</{tag}>", text)
200
+ if not match:
201
+ return ""
202
+ value = match.group(1).strip()
203
+ cdata = re.fullmatch(r"(?is)<!\[CDATA\[(.*?)]]>", value)
204
+ return html.unescape(cdata.group(1) if cdata else value).strip()
205
+
206
+
207
+ def parse_tool_params(raw: str) -> dict[str, object]:
208
+ raw = raw.strip()
209
+ try:
210
+ parsed = json.loads(raw)
211
+ return parsed if isinstance(parsed, dict) else {}
212
+ except Exception:
213
+ return {m.group(1): parse_tool_value(m.group(2)) for m in re.finditer(r"(?is)<([\w.-]+)\b[^>]*>(.*?)</\1>", raw)}
214
+
215
+
216
+ def parse_tool_value(raw: str) -> object:
217
+ value = xml_value(f"<x>{raw}</x>", "x")
218
+ try:
219
+ return json.loads(value)
220
+ except Exception:
221
+ return value
222
+
223
+
224
+ def stream_events(chunks: Iterable[dict[str, object]], model: str, input_tokens: int, output_tokens: Callable[[str], int], tools: object = None) -> Iterator[dict[str, object]]:
225
+ message_id = f"msg_{uuid.uuid4()}"
226
+ created = int(time.time())
227
+ current_text = ""
228
+ streamed_text = ""
229
+ tool_mode = isinstance(tools, list) and bool(tools)
230
+ tool_started = False
231
+ text_open = False
232
+ yield {"type": "message_start", "message": {"id": message_id, "type": "message", "role": "assistant", "model": model, "content": [], "stop_reason": None, "stop_sequence": None, "usage": {"input_tokens": input_tokens, "output_tokens": 0}}}
233
+ if not tool_mode:
234
+ text_open = True
235
+ yield {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}
236
+ for chunk in chunks:
237
+ choice = (chunk.get("choices") or [{}])[0]
238
+ delta = choice.get("delta") or {}
239
+ text_delta = delta.get("content", "") if isinstance(delta, dict) else ""
240
+ if text_delta:
241
+ current_text += text_delta
242
+ if not tool_started:
243
+ visible_text = current_text if not tool_mode else streamable_text(current_text)
244
+ if visible_text.startswith(streamed_text):
245
+ text_delta = visible_text[len(streamed_text):]
246
+ if text_delta:
247
+ if not text_open:
248
+ text_open = True
249
+ yield {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}
250
+ streamed_text = visible_text
251
+ yield {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": text_delta}}
252
+ tool_started = tool_mode and visible_text != current_text
253
+ if choice.get("finish_reason"):
254
+ content, stop_reason = content_blocks(current_text, tools)
255
+ if text_open:
256
+ yield {"type": "content_block_stop", "index": 0}
257
+ if stop_reason == "tool_use":
258
+ start_index = 1 if text_open else 0
259
+ if content and content[0]["type"] == "text":
260
+ remaining = str(content[0].get("text") or "")[len(streamed_text):]
261
+ if remaining:
262
+ if not text_open:
263
+ yield {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}
264
+ yield {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": remaining}}
265
+ if not text_open:
266
+ yield {"type": "content_block_stop", "index": 0}
267
+ start_index = 1
268
+ content = content[1:]
269
+ yield from _stream_buffered_blocks(content, start_index)
270
+ yield {"type": "message_delta", "delta": {"stop_reason": stop_reason, "stop_sequence": None}, "usage": {"output_tokens": output_tokens(current_text)}}
271
+ break
272
+ yield {"type": "message_stop", "created": created}
273
+
274
+
275
+ def _stream_buffered_blocks(content: list[dict[str, object]], start_index: int = 0) -> Iterator[dict[str, object]]:
276
+ for offset, block in enumerate(content):
277
+ index = start_index + offset
278
+ if block["type"] == "tool_use":
279
+ start = {"type": "tool_use", "id": block["id"], "name": block["name"], "input": {}}
280
+ delta = {"type": "input_json_delta", "partial_json": json.dumps(block.get("input") or {}, ensure_ascii=False)}
281
+ else:
282
+ start = {"type": "text", "text": ""}
283
+ delta = {"type": "text_delta", "text": block.get("text") or ""}
284
+ yield {"type": "content_block_start", "index": index, "content_block": start}
285
+ yield {"type": "content_block_delta", "index": index, "delta": delta}
286
+ yield {"type": "content_block_stop", "index": index}
287
+
288
+
289
+ def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]:
290
+ request = message_request(body)
291
+ if body.get("stream"):
292
+ return stream_events(
293
+ stream_text_chat_completion(request.backend, request.messages, request.model),
294
+ request.model,
295
+ count_message_tokens(request.messages, request.model),
296
+ lambda text: count_text_tokens(text, request.model),
297
+ request.tools,
298
+ )
299
+ text = collect_chat_content(stream_text_chat_completion(request.backend, request.messages, request.model))
300
+ return message_response(
301
+ request.model,
302
+ text,
303
+ count_message_tokens(request.messages, request.model),
304
+ count_text_tokens(text, request.model),
305
+ request.tools,
306
+ )
services/protocol/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,835 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import re
6
+ import time
7
+ from dataclasses import dataclass, field
8
+ from typing import Any, Iterable, Iterator
9
+
10
+ import tiktoken
11
+
12
+ from services.account_service import account_service
13
+ from services.config import config
14
+ from services.image_storage_service import image_storage_service
15
+ from services.openai_backend_api import ImagePollTimeoutError, OpenAIBackendAPI
16
+ from utils.helper import (
17
+ IMAGE_MODELS,
18
+ extract_image_from_message_content,
19
+ is_codex_image_model,
20
+ is_supported_image_model,
21
+ split_image_model,
22
+ )
23
+ from utils.image_tokens import count_image_content_tokens
24
+ from utils.log import logger
25
+
26
+
27
+ class ImageGenerationError(Exception):
28
+ def __init__(
29
+ self,
30
+ message: str,
31
+ status_code: int = 502,
32
+ error_type: str = "server_error",
33
+ code: str | None = "upstream_error",
34
+ param: str | None = None,
35
+ account_email: str = "",
36
+ ) -> None:
37
+ super().__init__(message)
38
+ self.status_code = status_code
39
+ self.error_type = error_type
40
+ self.code = code
41
+ self.param = param
42
+ self.account_email = account_email
43
+
44
+ def to_openai_error(self) -> dict[str, Any]:
45
+ return {
46
+ "error": {
47
+ "message": public_image_error_message(str(self)),
48
+ "type": self.error_type,
49
+ "param": self.param,
50
+ "code": self.code,
51
+ }
52
+ }
53
+
54
+
55
+ def public_image_error_message(message: str) -> str:
56
+ text = str(message or "").strip()
57
+ lower = text.lower()
58
+ if any(item in lower for item in ("backend-api/", "status=", "body=", "chatgpt.com", "upstreamhttperror")):
59
+ return "The image generation request failed. Please try again later."
60
+ return text or "The image generation request failed. Please try again later."
61
+
62
+
63
+ def is_token_invalid_error(message: str) -> bool:
64
+ text = str(message or "").lower()
65
+ return (
66
+ "token_invalidated" in text
67
+ or "token_revoked" in text
68
+ or "authentication token has been invalidated" in text
69
+ or "invalidated oauth token" in text
70
+ )
71
+
72
+
73
+ def image_stream_error_message(message: str) -> str:
74
+ text = str(message or "")
75
+ lower = text.lower()
76
+ if is_token_invalid_error(text):
77
+ return "image generation failed"
78
+ if "curl: (35)" in lower or "tls connect error" in lower or "openssl_internal" in lower:
79
+ return "upstream image connection failed, please retry later"
80
+ return text or "image generation failed"
81
+
82
+
83
+ def encode_images(images: Iterable[tuple[bytes, str, str]]) -> list[str]:
84
+ return [base64.b64encode(data).decode("ascii") for data, _, _ in images if data]
85
+
86
+
87
+ def save_image_bytes(image_data: bytes, base_url: str | None = None) -> str:
88
+ return image_storage_service.save(image_data, base_url).url
89
+
90
+
91
+ def message_text(content: Any) -> str:
92
+ if isinstance(content, str):
93
+ return content
94
+ if isinstance(content, list):
95
+ parts = []
96
+ for item in content:
97
+ if isinstance(item, str):
98
+ parts.append(item)
99
+ elif isinstance(item, dict) and str(item.get("type") or "") in {"text", "input_text", "output_text"}:
100
+ parts.append(str(item.get("text") or ""))
101
+ return "".join(parts)
102
+ return ""
103
+
104
+
105
+ def normalize_messages(messages: object, system: Any = None) -> list[dict[str, Any]]:
106
+ normalized = []
107
+ if config.global_system_prompt:
108
+ normalized.append({"role": "system", "content": config.global_system_prompt})
109
+ system_text = message_text(system)
110
+ if system_text:
111
+ normalized.append({"role": "system", "content": system_text})
112
+ if isinstance(messages, list):
113
+ for message in messages:
114
+ if not isinstance(message, dict):
115
+ continue
116
+ role = message.get("role", "user")
117
+ content = message.get("content", "")
118
+ text = message_text(content)
119
+ images: list[tuple[bytes, str]] = []
120
+ if role == "user":
121
+ images.extend(extract_image_from_message_content(content))
122
+ if isinstance(content, list):
123
+ for part in content:
124
+ if not isinstance(part, dict) or part.get("type") != "image":
125
+ continue
126
+ data = part.get("data")
127
+ if isinstance(data, (bytes, bytearray)):
128
+ images.append((bytes(data), str(part.get("mime") or "image/png")))
129
+ if images:
130
+ parts: list[Any] = []
131
+ if text:
132
+ parts.append({"type": "text", "text": text})
133
+ for data, mime in images:
134
+ parts.append({"type": "image", "data": data, "mime": mime})
135
+ normalized.append({"role": role, "content": parts})
136
+ else:
137
+ normalized.append({"role": role, "content": text})
138
+ return normalized
139
+
140
+
141
+ def prompt_with_global_system(prompt: str) -> str:
142
+ return f"{config.global_system_prompt}\n\n{prompt}" if config.global_system_prompt else prompt
143
+
144
+
145
+ def assistant_history_text(messages: list[dict[str, Any]]) -> str:
146
+ return "".join(str(item.get("content") or "") for item in messages if item.get("role") == "assistant")
147
+
148
+
149
+ def assistant_history_messages(messages: list[dict[str, Any]]) -> list[str]:
150
+ return [str(item.get("content") or "") for item in messages if item.get("role") == "assistant" and item.get("content")]
151
+
152
+
153
+ def build_image_prompt(prompt: str, size: str | None, quality: str = "auto") -> str:
154
+ hints = []
155
+ if size:
156
+ hints.append(f"输出图片尺寸为 {size}。")
157
+ if quality:
158
+ hints.append(f"输出图片质量为 {quality}。")
159
+ return f"{prompt.strip()}\n\n{''.join(hints)}" if hints else prompt
160
+
161
+
162
+ def encoding_for_model(model: str):
163
+ try:
164
+ return tiktoken.encoding_for_model(model)
165
+ except KeyError:
166
+ try:
167
+ return tiktoken.get_encoding("o200k_base")
168
+ except KeyError:
169
+ return tiktoken.get_encoding("cl100k_base")
170
+
171
+
172
+ def count_message_image_tokens(messages: list[dict[str, Any]], model: str) -> int:
173
+ return sum(count_image_content_tokens(message.get("content"), model) for message in messages)
174
+
175
+
176
+ def count_message_text_tokens(messages: list[dict[str, Any]], model: str) -> int:
177
+ encoding = encoding_for_model(model)
178
+ total = 0
179
+ for message in messages:
180
+ total += 3
181
+ for key, value in message.items():
182
+ if key == "content" and isinstance(value, list):
183
+ total += len(encoding.encode(message_text(value)))
184
+ elif isinstance(value, str):
185
+ total += len(encoding.encode(value))
186
+ else:
187
+ continue
188
+ if key == "name":
189
+ total += 1
190
+ return total + 3
191
+
192
+
193
+ def count_message_tokens(messages: list[dict[str, Any]], model: str) -> int:
194
+ return count_message_text_tokens(messages, model) + count_message_image_tokens(messages, model)
195
+
196
+
197
+ def count_text_tokens(text: str, model: str) -> int:
198
+ return len(encoding_for_model(model).encode(text))
199
+
200
+
201
+ def format_image_result(
202
+ items: list[dict[str, Any]],
203
+ prompt: str,
204
+ response_format: str,
205
+ base_url: str | None = None,
206
+ created: int | None = None,
207
+ message: str = "",
208
+ ) -> dict[str, Any]:
209
+ data: list[dict[str, Any]] = []
210
+ for item in items:
211
+ b64_json = str(item.get("b64_json") or "").strip()
212
+ if not b64_json:
213
+ continue
214
+ revised_prompt = str(item.get("revised_prompt") or prompt).strip() or prompt
215
+ if response_format == "b64_json":
216
+ data.append({
217
+ "b64_json": b64_json,
218
+ "url": save_image_bytes(base64.b64decode(b64_json), base_url),
219
+ "revised_prompt": revised_prompt,
220
+ })
221
+ else:
222
+ data.append({
223
+ "url": save_image_bytes(base64.b64decode(b64_json), base_url),
224
+ "revised_prompt": revised_prompt,
225
+ })
226
+ result: dict[str, Any] = {"created": created or int(time.time()), "data": data}
227
+ if message and not data:
228
+ result["message"] = message
229
+ return result
230
+
231
+
232
+ @dataclass
233
+ class ConversationRequest:
234
+ model: str = "auto"
235
+ prompt: str = ""
236
+ messages: list[dict[str, Any]] | None = None
237
+ images: list[str] | None = None
238
+ n: int = 1
239
+ size: str | None = None
240
+ quality: str = "auto"
241
+ response_format: str = "b64_json"
242
+ base_url: str | None = None
243
+ message_as_error: bool = False
244
+
245
+
246
+ @dataclass
247
+ class ConversationState:
248
+ text: str = ""
249
+ raw_text: str = ""
250
+ conversation_id: str = ""
251
+ file_ids: list[str] = field(default_factory=list)
252
+ sediment_ids: list[str] = field(default_factory=list)
253
+ blocked: bool = False
254
+ tool_invoked: bool | None = None
255
+ turn_use_case: str = ""
256
+
257
+
258
+ @dataclass
259
+ class ImageOutput:
260
+ kind: str
261
+ model: str
262
+ index: int
263
+ total: int
264
+ created: int = field(default_factory=lambda: int(time.time()))
265
+ text: str = ""
266
+ upstream_event_type: str = ""
267
+ data: list[dict[str, Any]] = field(default_factory=list)
268
+ account_email: str = ""
269
+
270
+ def to_chunk(self) -> dict[str, Any]:
271
+ chunk: dict[str, Any] = {
272
+ "object": "image.generation.chunk",
273
+ "created": self.created,
274
+ "model": self.model,
275
+ "index": self.index,
276
+ "total": self.total,
277
+ "progress_text": self.text,
278
+ "upstream_event_type": self.upstream_event_type,
279
+ "data": [],
280
+ }
281
+ if self.account_email:
282
+ chunk["_account_email"] = self.account_email
283
+ if self.kind == "message":
284
+ chunk.update({
285
+ "object": "image.generation.message",
286
+ "message": self.text,
287
+ })
288
+ chunk.pop("progress_text", None)
289
+ chunk.pop("upstream_event_type", None)
290
+ elif self.kind == "result":
291
+ chunk.update({
292
+ "object": "image.generation.result",
293
+ "data": self.data,
294
+ })
295
+ chunk.pop("progress_text", None)
296
+ chunk.pop("upstream_event_type", None)
297
+ return chunk
298
+
299
+
300
+ def assistant_message_text(message: dict[str, Any]) -> str:
301
+ content = message.get("content") or {}
302
+ parts = content.get("parts") or []
303
+ if not isinstance(parts, list):
304
+ return ""
305
+ return "".join(part for part in parts if isinstance(part, str))
306
+
307
+
308
+ def strip_history(text: str, history_text: str = "") -> str:
309
+ text = str(text or "")
310
+ history_text = str(history_text or "")
311
+ while history_text and text.startswith(history_text):
312
+ text = text[len(history_text):]
313
+ return text
314
+
315
+
316
+ def sanitize_output_text(text: str) -> str:
317
+ text = str(text or "")
318
+
319
+ def replace_url(match: re.Match[str]) -> str:
320
+ label = match.group(1).strip()
321
+ url = match.group(2).strip()
322
+ if label and url.startswith(("http://", "https://")):
323
+ return f"{label} ({url})"
324
+ return label or url
325
+
326
+ # ChatGPT web sometimes returns rich annotation markers using private-use
327
+ # characters. API clients cannot render those, so convert links and drop
328
+ # citations before emitting OpenAI-compatible text.
329
+ text = re.sub(r"\ue200url\ue202([^\ue202\ue201]*)\ue202([^\ue201]*)\ue201", replace_url, text)
330
+ text = re.sub(r"\ue200cite\ue202[^\ue201]*\ue201", "", text)
331
+ text = re.sub(r"\ue200[^\ue201]*\ue201", "", text)
332
+ text = re.sub(r"\ue200[^\ue201]*$", "", text)
333
+ return text
334
+
335
+
336
+ def assistant_raw_text(event: dict[str, Any], current_text: str = "", history_text: str = "") -> str:
337
+ for candidate in (event, event.get("v")):
338
+ if not isinstance(candidate, dict):
339
+ continue
340
+ message = candidate.get("message")
341
+ if not isinstance(message, dict):
342
+ continue
343
+ role = str((message.get("author") or {}).get("role") or "").strip().lower()
344
+ if role != "assistant":
345
+ continue
346
+ text = assistant_message_text(message)
347
+ if text:
348
+ return strip_history(text, history_text)
349
+ return apply_text_patch(event, current_text, history_text)
350
+
351
+
352
+ def assistant_text(event: dict[str, Any], current_text: str = "", history_text: str = "") -> str:
353
+ return sanitize_output_text(assistant_raw_text(event, current_text, history_text))
354
+
355
+
356
+ def event_assistant_text(event: dict[str, Any], history_text: str = "") -> str:
357
+ for candidate in (event, event.get("v")):
358
+ if not isinstance(candidate, dict):
359
+ continue
360
+ message = candidate.get("message")
361
+ if isinstance(message, dict) and (message.get("author") or {}).get("role") == "assistant":
362
+ return strip_history(assistant_message_text(message), history_text)
363
+ return ""
364
+
365
+
366
+ def apply_text_patch(event: dict[str, Any], current_text: str = "", history_text: str = "") -> str:
367
+ if event.get("p") == "/message/content/parts/0":
368
+ return apply_patch_op(event, current_text, history_text)
369
+
370
+ operations = event.get("v")
371
+ if isinstance(operations, str) and current_text and not event.get("p") and not event.get("o"):
372
+ return current_text + operations
373
+
374
+ if event.get("o") == "patch" and isinstance(operations, list):
375
+ text = current_text
376
+ for item in operations:
377
+ if isinstance(item, dict):
378
+ text = apply_text_patch(item, text, history_text)
379
+ return text
380
+
381
+ if not isinstance(operations, list):
382
+ return current_text
383
+
384
+ text = current_text
385
+ for item in operations:
386
+ if isinstance(item, dict):
387
+ text = apply_text_patch(item, text, history_text)
388
+ return text
389
+
390
+
391
+ def apply_patch_op(operation: dict[str, Any], current_text: str, history_text: str = "") -> str:
392
+ op = operation.get("o")
393
+ value = str(operation.get("v") or "")
394
+ if op == "append":
395
+ return current_text + value
396
+ if op == "replace":
397
+ return strip_history(value, history_text)
398
+ return current_text
399
+
400
+
401
+ def add_unique(values: list[str], candidates: list[str]) -> None:
402
+ for candidate in candidates:
403
+ if candidate and candidate not in values:
404
+ values.append(candidate)
405
+
406
+
407
+ def extract_conversation_ids(payload: str) -> tuple[str, list[str], list[str]]:
408
+ conversation_match = re.search(r'"conversation_id"\s*:\s*"([^"]+)"', payload)
409
+ conversation_id = conversation_match.group(1) if conversation_match else ""
410
+ # Negative lookahead excludes "file-service" (URI prefix, not a real id).
411
+ file_ids = re.findall(r"(file[-_](?!service\b)[A-Za-z0-9]+)", payload)
412
+ sediment_ids = re.findall(r"sediment://([A-Za-z0-9_-]+)", payload)
413
+ return conversation_id, file_ids, sediment_ids
414
+
415
+
416
+ def is_image_tool_event(event: dict[str, Any]) -> bool:
417
+ value = event.get("v")
418
+ message = event.get("message") or (value.get("message") if isinstance(value, dict) else None)
419
+ if not isinstance(message, dict):
420
+ return False
421
+ metadata = message.get("metadata") or {}
422
+ author = message.get("author") or {}
423
+ content = message.get("content") or {}
424
+ if author.get("role") != "tool":
425
+ return False
426
+ if metadata.get("async_task_type") == "image_gen":
427
+ return True
428
+ if content.get("content_type") != "multimodal_text":
429
+ return False
430
+ return any(
431
+ isinstance(part, dict) and (
432
+ part.get("content_type") == "image_asset_pointer"
433
+ or str(part.get("asset_pointer") or "").startswith(("file-service://", "sediment://"))
434
+ )
435
+ for part in content.get("parts") or []
436
+ )
437
+
438
+
439
+ def update_conversation_state(state: ConversationState, payload: str, event: dict[str, Any] | None = None) -> None:
440
+ conversation_id, file_ids, sediment_ids = extract_conversation_ids(payload)
441
+ if conversation_id and not state.conversation_id:
442
+ state.conversation_id = conversation_id
443
+ # Accept file_id / sediment_id when any of:
444
+ # 1) event is a complete image_gen tool message
445
+ # 2) prior server_ste_metadata already flipped tool_invoked True (in an image_gen turn)
446
+ # 3) patch event whose payload references asset_pointer / file-service://
447
+ # User messages (type=conversation.message) never satisfy these, so attacker-controlled
448
+ # substrings in user input cannot inject file ids into state.
449
+ is_patch_event = isinstance(event, dict) and event.get("o") == "patch"
450
+ image_context = (
451
+ (isinstance(event, dict) and is_image_tool_event(event))
452
+ or state.tool_invoked is True
453
+ or (is_patch_event and ("asset_pointer" in payload or "file-service://" in payload))
454
+ )
455
+ if image_context:
456
+ add_unique(state.file_ids, file_ids)
457
+ add_unique(state.sediment_ids, sediment_ids)
458
+ if not isinstance(event, dict):
459
+ return
460
+ state.conversation_id = str(event.get("conversation_id") or state.conversation_id)
461
+ value = event.get("v")
462
+ if isinstance(value, dict):
463
+ state.conversation_id = str(value.get("conversation_id") or state.conversation_id)
464
+ if event.get("type") == "moderation":
465
+ moderation = event.get("moderation_response")
466
+ if isinstance(moderation, dict) and moderation.get("blocked") is True:
467
+ state.blocked = True
468
+ if event.get("type") == "server_ste_metadata":
469
+ metadata = event.get("metadata")
470
+ if isinstance(metadata, dict):
471
+ if isinstance(metadata.get("tool_invoked"), bool):
472
+ state.tool_invoked = metadata["tool_invoked"]
473
+ state.turn_use_case = str(metadata.get("turn_use_case") or state.turn_use_case)
474
+
475
+
476
+ def conversation_base_event(event_type: str, state: ConversationState, **extra: Any) -> dict[str, Any]:
477
+ return {
478
+ "type": event_type,
479
+ "text": state.text,
480
+ "conversation_id": state.conversation_id,
481
+ "file_ids": list(state.file_ids),
482
+ "sediment_ids": list(state.sediment_ids),
483
+ "blocked": state.blocked,
484
+ "tool_invoked": state.tool_invoked,
485
+ "turn_use_case": state.turn_use_case,
486
+ **extra,
487
+ }
488
+
489
+
490
+ def iter_conversation_payloads(payloads: Iterator[str], history_text: str = "",
491
+ history_messages: list[str] | None = None) -> Iterator[dict[str, Any]]:
492
+ state = ConversationState()
493
+ history_messages = history_messages or []
494
+ history_index = 0
495
+ for payload in payloads:
496
+ # print(f"[upstream_sse] {payload}", flush=True)
497
+ if not payload:
498
+ continue
499
+ if payload == "[DONE]":
500
+ yield conversation_base_event("conversation.done", state, done=True)
501
+ break
502
+ try:
503
+ event = json.loads(payload)
504
+ except json.JSONDecodeError:
505
+ update_conversation_state(state, payload)
506
+ yield conversation_base_event("conversation.raw", state, payload=payload)
507
+ continue
508
+ if not isinstance(event, dict):
509
+ yield conversation_base_event("conversation.event", state, raw=event)
510
+ continue
511
+ update_conversation_state(state, payload, event)
512
+ if history_index < len(history_messages) and event_assistant_text(event, history_text) == history_messages[history_index]:
513
+ history_index += 1
514
+ state.raw_text = ""
515
+ state.text = ""
516
+ continue
517
+ next_raw_text = assistant_raw_text(event, state.raw_text, history_text)
518
+ next_text = sanitize_output_text(next_raw_text)
519
+ state.raw_text = next_raw_text
520
+ if next_text != state.text:
521
+ delta = next_text[len(state.text):] if next_text.startswith(state.text) else next_text
522
+ state.text = next_text
523
+ yield conversation_base_event("conversation.delta", state, raw=event, delta=delta)
524
+ continue
525
+ yield conversation_base_event("conversation.event", state, raw=event)
526
+
527
+
528
+ def conversation_events(
529
+ backend: OpenAIBackendAPI,
530
+ messages: list[dict[str, Any]] | None = None,
531
+ model: str = "auto",
532
+ prompt: str = "",
533
+ images: list[str] | None = None,
534
+ size: str | None = None,
535
+ quality: str = "auto",
536
+ ) -> Iterator[dict[str, Any]]:
537
+ normalized = normalize_messages(messages or ([{"role": "user", "content": prompt}] if prompt else []))
538
+ image_model = is_supported_image_model(model)
539
+ history_text = "" if image_model else assistant_history_text(normalized)
540
+ history_messages = [] if image_model else assistant_history_messages(normalized)
541
+ final_prompt = prompt_with_global_system(build_image_prompt(prompt, size, quality)) if image_model else prompt
542
+ payloads = backend.stream_conversation(
543
+ messages=normalized,
544
+ model=model,
545
+ prompt=final_prompt,
546
+ images=images if image_model else None,
547
+ system_hints=["picture_v2"] if image_model else None,
548
+ )
549
+ yield from iter_conversation_payloads(payloads, history_text, history_messages)
550
+
551
+
552
+ def text_backend() -> OpenAIBackendAPI:
553
+ return OpenAIBackendAPI(access_token=account_service.get_text_access_token())
554
+
555
+
556
+ def stream_text_deltas(backend: OpenAIBackendAPI, request: ConversationRequest) -> Iterator[str]:
557
+ attempted_tokens: set[str] = set()
558
+ token = getattr(backend, "access_token", "")
559
+ emitted = False
560
+ while True:
561
+ if token and token in attempted_tokens:
562
+ raise RuntimeError("no available text account")
563
+ if token:
564
+ attempted_tokens.add(token)
565
+ try:
566
+ active_backend = OpenAIBackendAPI(access_token=token)
567
+ for event in conversation_events(active_backend, messages=request.messages, model=request.model, prompt=request.prompt):
568
+ if event.get("type") != "conversation.delta":
569
+ continue
570
+ delta = str(event.get("delta") or "")
571
+ if delta:
572
+ emitted = True
573
+ yield delta
574
+ account_service.mark_text_used(token)
575
+ return
576
+ except Exception as exc:
577
+ error_message = str(exc)
578
+ if token and not emitted and is_token_invalid_error(error_message):
579
+ refreshed_token = account_service.refresh_access_token(token, force=True, event="text_stream")
580
+ if refreshed_token and refreshed_token != token and refreshed_token not in attempted_tokens:
581
+ token = refreshed_token
582
+ else:
583
+ account_service.remove_invalid_token(token, "text_stream")
584
+ token = account_service.get_text_access_token(attempted_tokens)
585
+ if token:
586
+ continue
587
+ raise
588
+
589
+
590
+ def collect_text(backend: OpenAIBackendAPI, request: ConversationRequest) -> str:
591
+ return "".join(stream_text_deltas(backend, request))
592
+
593
+
594
+ def stream_image_outputs(
595
+ backend: OpenAIBackendAPI,
596
+ request: ConversationRequest,
597
+ index: int = 1,
598
+ total: int = 1,
599
+ ) -> Iterator[ImageOutput]:
600
+ last: dict[str, Any] = {}
601
+ for event in conversation_events(
602
+ backend,
603
+ prompt=request.prompt,
604
+ model=request.model,
605
+ images=request.images or [],
606
+ size=request.size,
607
+ quality=request.quality,
608
+ ):
609
+ last = event
610
+ if event.get("type") == "conversation.delta":
611
+ yield ImageOutput(
612
+ kind="progress",
613
+ model=request.model,
614
+ index=index,
615
+ total=total,
616
+ text=str(event.get("delta") or ""),
617
+ upstream_event_type="conversation.delta",
618
+ )
619
+ continue
620
+ if event.get("type") == "conversation.event":
621
+ raw = event.get("raw")
622
+ raw_type = str(raw.get("type") or "") if isinstance(raw, dict) else ""
623
+ yield ImageOutput(
624
+ kind="progress",
625
+ model=request.model,
626
+ index=index,
627
+ total=total,
628
+ upstream_event_type=raw_type,
629
+ )
630
+
631
+ conversation_id = str(last.get("conversation_id") or "")
632
+ file_ids = [str(item) for item in last.get("file_ids") or []]
633
+ sediment_ids = [str(item) for item in last.get("sediment_ids") or []]
634
+ message = str(last.get("text") or "").strip()
635
+ logger.info({
636
+ "event": "image_stream_resolve_start",
637
+ "conversation_id": conversation_id,
638
+ "file_ids": file_ids,
639
+ "sediment_ids": sediment_ids,
640
+ "tool_invoked": last.get("tool_invoked"),
641
+ "turn_use_case": last.get("turn_use_case"),
642
+ })
643
+ if message and not file_ids and not sediment_ids and last.get("blocked"):
644
+ yield ImageOutput(kind="message", model=request.model, index=index, total=total, text=message)
645
+ return
646
+ should_poll_for_image = bool(request.images) or last.get("turn_use_case") == "image gen"
647
+ if message and not file_ids and not sediment_ids and not should_poll_for_image:
648
+ yield ImageOutput(kind="message", model=request.model, index=index, total=total, text=message)
649
+ return
650
+
651
+ image_urls = backend.resolve_conversation_image_urls(conversation_id, file_ids, sediment_ids)
652
+ if image_urls:
653
+ image_items = [
654
+ {"b64_json": base64.b64encode(image_data).decode("ascii")}
655
+ for image_data in backend.download_image_bytes(image_urls)
656
+ ]
657
+ data = format_image_result(
658
+ image_items,
659
+ request.prompt,
660
+ request.response_format,
661
+ request.base_url,
662
+ int(time.time()),
663
+ )["data"]
664
+ if data:
665
+ yield ImageOutput(kind="result", model=request.model, index=index, total=total, data=data)
666
+ return
667
+
668
+ if message:
669
+ yield ImageOutput(kind="message", model=request.model, index=index, total=total, text=message)
670
+
671
+
672
+ def _codex_response_images(value: Any) -> list[str]:
673
+ if isinstance(value, dict):
674
+ if value.get("type") == "image_generation_call" and isinstance(value.get("result"), str):
675
+ result = value["result"].strip()
676
+ if result:
677
+ return [result.split(",", 1)[1] if result.startswith("data:image/") else result]
678
+ images: list[str] = []
679
+ for item in value.values():
680
+ images.extend(_codex_response_images(item))
681
+ return images
682
+ if isinstance(value, list):
683
+ images: list[str] = []
684
+ for item in value:
685
+ images.extend(_codex_response_images(item))
686
+ return images
687
+ return []
688
+
689
+
690
+ def stream_codex_image_outputs(
691
+ backend: OpenAIBackendAPI,
692
+ request: ConversationRequest,
693
+ index: int = 1,
694
+ total: int = 1,
695
+ ) -> Iterator[ImageOutput]:
696
+ images = _codex_response_images(list(backend.iter_codex_image_response_events(
697
+ prompt=request.prompt,
698
+ images=request.images or [],
699
+ size=request.size,
700
+ quality=request.quality,
701
+ )))
702
+ if not images:
703
+ raise ImageGenerationError("No image result found in response")
704
+ data = format_image_result(
705
+ [{"b64_json": item, "revised_prompt": request.prompt} for item in images],
706
+ request.prompt,
707
+ request.response_format,
708
+ request.base_url,
709
+ int(time.time()),
710
+ )["data"]
711
+ if data:
712
+ yield ImageOutput(kind="result", model=request.model, index=index, total=total, data=data)
713
+ return
714
+ raise ImageGenerationError("No image result found in response")
715
+
716
+
717
+ def stream_image_outputs_with_pool(request: ConversationRequest) -> Iterator[ImageOutput]:
718
+ if not is_supported_image_model(request.model):
719
+ raise ImageGenerationError("unsupported image model,supported models: " + ", ".join(sorted(IMAGE_MODELS)))
720
+
721
+ emitted = False
722
+ last_error = ""
723
+ for index in range(1, request.n + 1):
724
+ while True:
725
+ try:
726
+ plan_type, _ = split_image_model(request.model)
727
+ codex_model = is_codex_image_model(request.model)
728
+ token = account_service.get_available_access_token(
729
+ plan_type=plan_type,
730
+ source_type="codex" if codex_model else None,
731
+ plan_types=("plus", "team", "pro") if codex_model and not plan_type else None,
732
+ )
733
+ except RuntimeError as exc:
734
+ if emitted:
735
+ return
736
+ raise ImageGenerationError(str(exc) or "image generation failed") from exc
737
+
738
+ emitted_for_token = False
739
+ returned_message = False
740
+ returned_result = False
741
+ account = account_service.get_account(token) or {}
742
+ account_email = str(account.get("email") or "").strip()
743
+ try:
744
+ backend = OpenAIBackendAPI(access_token=token)
745
+ stream_fn = stream_codex_image_outputs if is_codex_image_model(request.model) else stream_image_outputs
746
+ for output in stream_fn(backend, request, index, request.n):
747
+ if account_email and not output.account_email:
748
+ output.account_email = account_email
749
+ if output.kind == "message" and request.message_as_error:
750
+ raise ImageGenerationError(
751
+ output.text or "Image generation was rejected by upstream policy.",
752
+ status_code=400,
753
+ error_type="invalid_request_error",
754
+ code="content_policy_violation",
755
+ account_email=account_email,
756
+ )
757
+ emitted = True
758
+ emitted_for_token = True
759
+ returned_message = output.kind == "message"
760
+ returned_result = returned_result or output.kind == "result"
761
+ yield output
762
+ if returned_message or not returned_result:
763
+ account_service.mark_image_result(token, False)
764
+ return
765
+ account_service.mark_image_result(token, True)
766
+ break
767
+ except ImagePollTimeoutError as exc:
768
+ if account_email and not getattr(exc, "account_email", ""):
769
+ exc.account_email = account_email
770
+ raise
771
+ except ImageGenerationError as exc:
772
+ account_service.mark_image_result(token, False)
773
+ if account_email and not getattr(exc, "account_email", ""):
774
+ exc.account_email = account_email
775
+ logger.warning({
776
+ "event": "image_stream_generation_error",
777
+ "request_token": token,
778
+ "account_email": account_email,
779
+ "error": str(exc),
780
+ })
781
+ raise
782
+ except Exception as exc:
783
+ account_service.mark_image_result(token, False)
784
+ last_error = str(exc)
785
+ logger.warning({
786
+ "event": "image_stream_fail",
787
+ "request_token": token,
788
+ "account_email": account_email,
789
+ "error": last_error,
790
+ })
791
+ if not emitted_for_token and is_token_invalid_error(last_error):
792
+ refreshed_token = account_service.refresh_access_token(token, force=True, event="image_stream")
793
+ if refreshed_token and refreshed_token != token:
794
+ token = refreshed_token
795
+ continue
796
+ account_service.remove_invalid_token(token, "image_stream")
797
+ continue
798
+ raise ImageGenerationError(image_stream_error_message(last_error), account_email=account_email) from exc
799
+
800
+ if not emitted:
801
+ if not last_error:
802
+ last_error = "no account in the pool could generate images — check account quota and rate-limit status"
803
+ raise ImageGenerationError(image_stream_error_message(last_error))
804
+
805
+
806
+ def stream_image_chunks(outputs: Iterable[ImageOutput]) -> Iterator[dict[str, Any]]:
807
+ for output in outputs:
808
+ yield output.to_chunk()
809
+
810
+
811
+ def collect_image_outputs(outputs: Iterable[ImageOutput]) -> dict[str, Any]:
812
+ created = None
813
+ data: list[dict[str, Any]] = []
814
+ message = ""
815
+ progress_parts: list[str] = []
816
+ account_email = ""
817
+ for output in outputs:
818
+ created = created or output.created
819
+ if output.account_email and not account_email:
820
+ account_email = output.account_email
821
+ if output.kind == "progress" and output.text:
822
+ progress_parts.append(output.text)
823
+ elif output.kind == "message":
824
+ message = output.text
825
+ elif output.kind == "result":
826
+ data.extend(output.data)
827
+
828
+ result: dict[str, Any] = {"created": created or int(time.time()), "data": data}
829
+ if not data:
830
+ text = message or "".join(progress_parts).strip()
831
+ if text:
832
+ result["message"] = text
833
+ if account_email:
834
+ result["_account_email"] = account_email
835
+ return result
services/protocol/error_response.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from fastapi.responses import JSONResponse
6
+
7
+
8
+ def _message_from_value(value: object) -> str:
9
+ if isinstance(value, str):
10
+ return value
11
+ if not isinstance(value, dict):
12
+ return ""
13
+ message = value.get("message")
14
+ if isinstance(message, str) and message:
15
+ return message
16
+ return _message_from_value(value.get("error"))
17
+
18
+
19
+ def error_message_from_detail(detail: object) -> str:
20
+ if isinstance(detail, list):
21
+ messages = []
22
+ for item in detail:
23
+ if not isinstance(item, dict):
24
+ continue
25
+ location = ".".join(str(part) for part in item.get("loc", []) if part != "body")
26
+ message = str(item.get("msg") or "").strip()
27
+ if location and message:
28
+ messages.append(f"{location}: {message}")
29
+ elif message:
30
+ messages.append(message)
31
+ return "; ".join(messages)
32
+ if isinstance(detail, dict):
33
+ message = _message_from_value(detail.get("error")) or _message_from_value(detail)
34
+ if message:
35
+ return message
36
+ return str(detail or "").strip()
37
+
38
+
39
+ def _default_error_type(status_code: int) -> str:
40
+ if status_code == 401:
41
+ return "authentication_error"
42
+ if status_code == 403:
43
+ return "permission_error"
44
+ if status_code == 429:
45
+ return "rate_limit_error"
46
+ if 400 <= status_code < 500:
47
+ return "invalid_request_error"
48
+ return "server_error"
49
+
50
+
51
+ def _default_error_code(status_code: int) -> str:
52
+ if status_code == 401:
53
+ return "invalid_api_key"
54
+ if status_code == 403:
55
+ return "permission_denied"
56
+ if status_code == 429:
57
+ return "rate_limit_exceeded"
58
+ if 400 <= status_code < 500:
59
+ return "bad_request"
60
+ return "upstream_error"
61
+
62
+
63
+ def openai_error_payload(
64
+ detail: object,
65
+ status_code: int,
66
+ *,
67
+ error_type: str | None = None,
68
+ code: object | None = None,
69
+ param: object | None = None,
70
+ ) -> dict[str, Any]:
71
+ error_detail = detail.get("error") if isinstance(detail, dict) else None
72
+ if isinstance(error_detail, dict):
73
+ return {
74
+ "error": {
75
+ "message": error_message_from_detail(error_detail) or "request failed",
76
+ "type": str(error_detail.get("type") or error_type or _default_error_type(status_code)),
77
+ "param": error_detail.get("param", param),
78
+ "code": error_detail.get("code", code if code is not None else _default_error_code(status_code)),
79
+ }
80
+ }
81
+ return {
82
+ "error": {
83
+ "message": error_message_from_detail(detail) or "request failed",
84
+ "type": error_type or _default_error_type(status_code),
85
+ "param": param,
86
+ "code": code if code is not None else _default_error_code(status_code),
87
+ }
88
+ }
89
+
90
+
91
+ def openai_error_response(
92
+ detail: object,
93
+ status_code: int,
94
+ *,
95
+ headers: dict[str, str] | None = None,
96
+ error_type: str | None = None,
97
+ code: object | None = None,
98
+ param: object | None = None,
99
+ ) -> JSONResponse:
100
+ return JSONResponse(
101
+ status_code=status_code,
102
+ content=openai_error_payload(detail, status_code, error_type=error_type, code=code, param=param),
103
+ headers=headers,
104
+ )
105
+
106
+
107
+ def anthropic_error_response(
108
+ detail: object,
109
+ status_code: int,
110
+ *,
111
+ headers: dict[str, str] | None = None,
112
+ ) -> JSONResponse:
113
+ error_type = "api_error" if status_code >= 500 else _default_error_type(status_code)
114
+ return JSONResponse(
115
+ status_code=status_code,
116
+ content={
117
+ "type": "error",
118
+ "error": {
119
+ "type": error_type,
120
+ "message": error_message_from_detail(detail) or "request failed",
121
+ },
122
+ },
123
+ headers=headers,
124
+ )
services/protocol/openai_v1_chat_complete.py ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ import uuid
5
+ from typing import Any, Iterable, Iterator
6
+
7
+ from fastapi import HTTPException
8
+
9
+ from services.protocol.chat_completion_cache import cache_key, chat_completion_cache, normalize_text_messages
10
+ from services.protocol.conversation import (
11
+ ConversationRequest,
12
+ ImageOutput,
13
+ collect_image_outputs,
14
+ collect_text,
15
+ count_message_image_tokens,
16
+ count_message_text_tokens,
17
+ count_text_tokens,
18
+ encode_images,
19
+ normalize_messages,
20
+ stream_image_outputs_with_pool,
21
+ stream_text_deltas,
22
+ text_backend,
23
+ )
24
+ from utils.helper import build_chat_image_markdown_content, extract_chat_image, extract_chat_prompt, is_image_chat_request, parse_image_count
25
+ from utils.image_tokens import (
26
+ chat_usage_from_image_usage,
27
+ count_image_inputs_tokens,
28
+ count_image_output_items_tokens,
29
+ image_usage,
30
+ )
31
+
32
+ TOOL_UNAVAILABLE_SYSTEM_MESSAGE = (
33
+ "This compatibility backend cannot execute local tools, shell commands, web searches, "
34
+ "or file operations. Do not claim to have run tools or inspected external resources. "
35
+ "If a user asks you to use a tool, say that tool execution is unavailable through this backend."
36
+ )
37
+
38
+
39
+ def completion_chunk(model: str, delta: dict[str, Any], finish_reason: str | None = None, completion_id: str = "", created: int | None = None) -> dict[str, Any]:
40
+ return {
41
+ "id": completion_id or f"chatcmpl-{uuid.uuid4().hex}",
42
+ "object": "chat.completion.chunk",
43
+ "created": created or int(time.time()),
44
+ "model": model,
45
+ "choices": [{"index": 0, "delta": delta, "finish_reason": finish_reason}],
46
+ }
47
+
48
+
49
+ def completion_response(
50
+ model: str,
51
+ content: str,
52
+ created: int | None = None,
53
+ messages: list[dict[str, Any]] | None = None,
54
+ ) -> dict[str, Any]:
55
+ prompt_text_tokens = count_message_text_tokens(messages, model) if messages else 0
56
+ prompt_image_tokens = count_message_image_tokens(messages, model) if messages else 0
57
+ prompt_tokens = prompt_text_tokens + prompt_image_tokens
58
+ completion_tokens = count_text_tokens(content, model) if messages else 0
59
+ return {
60
+ "id": f"chatcmpl-{uuid.uuid4().hex}",
61
+ "object": "chat.completion",
62
+ "created": created or int(time.time()),
63
+ "model": model,
64
+ "choices": [{
65
+ "index": 0,
66
+ "message": {"role": "assistant", "content": content},
67
+ "finish_reason": "stop",
68
+ }],
69
+ "usage": {
70
+ "prompt_tokens": prompt_tokens,
71
+ "completion_tokens": completion_tokens,
72
+ "total_tokens": prompt_tokens + completion_tokens,
73
+ "prompt_tokens_details": {
74
+ "text_tokens": prompt_text_tokens,
75
+ "image_tokens": prompt_image_tokens,
76
+ "cached_tokens": 0,
77
+ },
78
+ "completion_tokens_details": {
79
+ "text_tokens": completion_tokens,
80
+ "image_tokens": 0,
81
+ "reasoning_tokens": 0,
82
+ },
83
+ },
84
+ }
85
+
86
+
87
+ def stream_text_chat_completion(backend, messages: list[dict[str, Any]], model: str) -> Iterator[dict[str, Any]]:
88
+ completion_id = f"chatcmpl-{uuid.uuid4().hex}"
89
+ created = int(time.time())
90
+ sent_role = False
91
+ request = ConversationRequest(model=model, messages=messages)
92
+ for delta_text in stream_text_deltas(backend, request):
93
+ if not sent_role:
94
+ sent_role = True
95
+ yield completion_chunk(model, {"role": "assistant", "content": delta_text}, None, completion_id, created)
96
+ else:
97
+ yield completion_chunk(model, {"content": delta_text}, None, completion_id, created)
98
+ if not sent_role:
99
+ yield completion_chunk(model, {"role": "assistant", "content": ""}, None, completion_id, created)
100
+ yield completion_chunk(model, {}, "stop", completion_id, created)
101
+
102
+
103
+ def collect_chat_content(chunks: Iterable[dict[str, Any]]) -> str:
104
+ parts: list[str] = []
105
+ for chunk in chunks:
106
+ choices = chunk.get("choices")
107
+ first = choices[0] if isinstance(choices, list) and choices and isinstance(choices[0], dict) else {}
108
+ delta = first.get("delta") if isinstance(first.get("delta"), dict) else {}
109
+ content = str(delta.get("content") or "")
110
+ if content:
111
+ parts.append(content)
112
+ return "".join(parts)
113
+
114
+
115
+ def chat_messages_from_body(body: dict[str, Any]) -> list[dict[str, Any]]:
116
+ messages = body.get("messages")
117
+ if isinstance(messages, list) and messages:
118
+ return [message for message in messages if isinstance(message, dict)]
119
+ prompt = str(body.get("prompt") or "").strip()
120
+ if prompt:
121
+ return [{"role": "user", "content": prompt}]
122
+ raise HTTPException(status_code=400, detail={"error": "messages or prompt is required"})
123
+
124
+
125
+ def chat_image_args(body: dict[str, Any]) -> tuple[str, str, int, list[tuple[bytes, str, str]]]:
126
+ model = str(body.get("model") or "gpt-image-2").strip() or "gpt-image-2"
127
+ prompt = extract_chat_prompt(body)
128
+ if not prompt:
129
+ raise HTTPException(status_code=400, detail={"error": "prompt is required"})
130
+ images = [
131
+ (data, f"image_{idx}.png", mime)
132
+ for idx, (data, mime) in enumerate(extract_chat_image(body), start=1)
133
+ ]
134
+ return model, prompt, parse_image_count(body.get("n")), images
135
+
136
+
137
+ def text_chat_parts(body: dict[str, Any]) -> tuple[str, list[dict[str, Any]]]:
138
+ model = str(body.get("model") or "auto").strip() or "auto"
139
+ messages = normalize_text_messages(normalize_messages(chat_messages_from_body(body)))
140
+ tools = body.get("tools")
141
+ if isinstance(tools, list) and tools:
142
+ messages.insert(0, {"role": "system", "content": TOOL_UNAVAILABLE_SYSTEM_MESSAGE})
143
+ return model, messages
144
+
145
+
146
+ def image_result_content(result: dict[str, Any]) -> str:
147
+ data = result.get("data")
148
+ if isinstance(data, list) and data:
149
+ return build_chat_image_markdown_content(result)
150
+ return str(result.get("message") or "Image generation completed.")
151
+
152
+
153
+ def image_chat_response(body: dict[str, Any]) -> dict[str, Any]:
154
+ model, prompt, n, images = chat_image_args(body)
155
+ result = collect_image_outputs(stream_image_outputs_with_pool(ConversationRequest(
156
+ prompt=prompt,
157
+ model=model,
158
+ n=n,
159
+ response_format="b64_json",
160
+ images=encode_images(images) or None,
161
+ )))
162
+ response = completion_response(model, image_result_content(result), int(result.get("created") or 0) or None)
163
+ usage = image_usage(
164
+ input_text_tokens=count_text_tokens(prompt, model),
165
+ input_image_tokens=count_image_inputs_tokens(images, model),
166
+ output_tokens=count_image_output_items_tokens(result.get("data")),
167
+ )
168
+ response["usage"] = chat_usage_from_image_usage(usage)
169
+ return response
170
+
171
+
172
+ def image_chat_events(body: dict[str, Any]) -> Iterator[dict[str, Any]]:
173
+ model, prompt, n, images = chat_image_args(body)
174
+ image_outputs = stream_image_outputs_with_pool(ConversationRequest(
175
+ prompt=prompt,
176
+ model=model,
177
+ n=n,
178
+ response_format="b64_json",
179
+ images=encode_images(images) or None,
180
+ ))
181
+ yield from stream_image_chat_completion(image_outputs, model)
182
+
183
+
184
+ def stream_image_chat_completion(image_outputs: Iterable[ImageOutput], model: str) -> Iterator[dict[str, Any]]:
185
+ completion_id = f"chatcmpl-{uuid.uuid4().hex}"
186
+ created = int(time.time())
187
+ sent_role = False
188
+ sent_text = ""
189
+ for output in image_outputs:
190
+ content = ""
191
+ if output.kind == "progress":
192
+ content = output.text
193
+ sent_text += content
194
+ elif output.kind == "result":
195
+ content = build_chat_image_markdown_content({"data": output.data})
196
+ elif output.kind == "message":
197
+ content = output.text[len(sent_text):] if output.text.startswith(sent_text) else output.text
198
+ if not content:
199
+ continue
200
+ if not sent_role:
201
+ sent_role = True
202
+ yield completion_chunk(model, {"role": "assistant", "content": content}, None, completion_id, created)
203
+ else:
204
+ yield completion_chunk(model, {"content": content}, None, completion_id, created)
205
+ if not sent_role:
206
+ yield completion_chunk(model, {"role": "assistant", "content": ""}, None, completion_id, created)
207
+ yield completion_chunk(model, {}, "stop", completion_id, created)
208
+
209
+
210
+ def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]:
211
+ if body.get("stream"):
212
+ if is_image_chat_request(body):
213
+ return image_chat_events(body)
214
+ model, messages = text_chat_parts(body)
215
+ key = cache_key(body, messages, stream=True)
216
+ return chat_completion_cache.get_or_compute_stream(
217
+ key,
218
+ lambda: stream_text_chat_completion(text_backend(), messages, model),
219
+ )
220
+ if is_image_chat_request(body):
221
+ return image_chat_response(body)
222
+ model, messages = text_chat_parts(body)
223
+ key = cache_key(body, messages, stream=False)
224
+ return chat_completion_cache.get_or_compute_response(
225
+ key,
226
+ lambda: completion_response(
227
+ model,
228
+ collect_text(text_backend(), ConversationRequest(model=model, messages=messages)),
229
+ messages=messages,
230
+ ),
231
+ )