cjovs commited on
Commit
66a73ac
·
1 Parent(s): 230966f

Deploy chatgpt2api with bucket mounted at /app/data

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.example +48 -0
  2. .gitignore +30 -0
  3. Dockerfile +26 -0
  4. LICENSE +21 -0
  5. README.md +351 -6
  6. VERSION +1 -0
  7. api/__init__.py +2 -0
  8. api/accounts.py +426 -0
  9. api/ai.py +141 -0
  10. api/app.py +63 -0
  11. api/errors.py +46 -0
  12. api/image_inputs.py +294 -0
  13. api/image_tasks.py +96 -0
  14. api/register.py +68 -0
  15. api/support.py +132 -0
  16. api/system.py +334 -0
  17. config.json +50 -0
  18. main.py +9 -0
  19. pyproject.toml +27 -0
  20. scripts/migrate_storage.py +163 -0
  21. scripts/test_storage.py +129 -0
  22. services/__init__.py +0 -0
  23. services/account_service.py +946 -0
  24. services/auth_service.py +253 -0
  25. services/backup_service.py +673 -0
  26. services/config.py +390 -0
  27. services/content_filter.py +196 -0
  28. services/cpa_service.py +315 -0
  29. services/image_service.py +356 -0
  30. services/image_storage_service.py +405 -0
  31. services/image_tags_service.py +76 -0
  32. services/image_task_service.py +388 -0
  33. services/log_service.py +255 -0
  34. services/openai_backend_api.py +986 -0
  35. services/protocol/__init__.py +2 -0
  36. services/protocol/anthropic_v1_messages.py +306 -0
  37. services/protocol/conversation.py +699 -0
  38. services/protocol/error_response.py +124 -0
  39. services/protocol/openai_v1_chat_complete.py +184 -0
  40. services/protocol/openai_v1_image_edit.py +40 -0
  41. services/protocol/openai_v1_image_generations.py +33 -0
  42. services/protocol/openai_v1_models.py +26 -0
  43. services/protocol/openai_v1_response.py +225 -0
  44. services/proxy_service.py +63 -0
  45. services/register/__init__.py +0 -0
  46. services/register/mail_provider.py +991 -0
  47. services/register/openai_register.py +766 -0
  48. services/register_service.py +197 -0
  49. services/storage/__init__.py +5 -0
  50. services/storage/base.py +38 -0
.env.example ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============================================
2
+ # ChatGPT2API 环境变量配置示例
3
+ # ============================================
4
+
5
+ # 认证密钥(必填)
6
+ CHATGPT2API_AUTH_KEY=your_secret_key_here
7
+
8
+ # 基础 URL(可选,用于生成图片 URL)
9
+ # CHATGPT2API_BASE_URL=https://your-domain.com
10
+
11
+ # ============================================
12
+ # 存储后端配置
13
+ # ============================================
14
+
15
+ # 存储后端类型(可选值: json, sqlite, postgres, git)
16
+ # 默认: json
17
+ STORAGE_BACKEND=json
18
+
19
+ # ============================================
20
+ # 数据库配置(当 STORAGE_BACKEND=sqlite/postgres 时使用)
21
+ # ============================================
22
+
23
+ # PostgreSQL 示例
24
+ # DATABASE_URL=postgresql://user:password@localhost:5432/chatgpt2api
25
+
26
+ # Supabase 示例
27
+ # DATABASE_URL=postgresql://postgres.xxx:password@aws-1-us-west-1.pooler.supabase.com:5432/postgres
28
+
29
+ # SQLite 示例(不指定时自动使用 data/accounts.db)
30
+ # DATABASE_URL=sqlite:///app/data/accounts.db
31
+
32
+ # ============================================
33
+ # Git 仓库配置(当 STORAGE_BACKEND=git 时使用)
34
+ # ============================================
35
+
36
+ # Git 仓库地址(必填)
37
+ # GIT_REPO_URL=https://github.com/your-username/your-private-repo.git
38
+
39
+ # Git 访问令牌(必填)
40
+ # GitHub: ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
41
+ # GitLab: glpat-xxxxxxxxxxxxxxxxxxxx
42
+ # GIT_TOKEN=your_git_token_here
43
+
44
+ # Git 分支(可选,默认: main)
45
+ # GIT_BRANCH=main
46
+
47
+ # Git 仓库中的文件路径(可选,默认: accounts.json)
48
+ # GIT_FILE_PATH=accounts.json
.gitignore ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+ web_dist
9
+ # Virtual environments
10
+ .venv
11
+ .idea
12
+ data
13
+ config.json
14
+ edit
15
+
16
+ docker-compose-local.yml
17
+
18
+ .claude/settings.local.json
19
+
20
+ # Environment variables
21
+ .env
22
+ .env.local
23
+
24
+ # Database files
25
+ *.db
26
+ *.sqlite
27
+ *.sqlite3
28
+
29
+ # Git cache
30
+ git_cache/
Dockerfile ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:22-alpine AS web-build
2
+ WORKDIR /app/web
3
+ COPY web/package.json web/bun.lock ./
4
+ RUN npm install
5
+ COPY VERSION /app/VERSION
6
+ COPY web ./
7
+ RUN NEXT_PUBLIC_APP_VERSION="$(cat /app/VERSION)" npm run build
8
+
9
+ FROM python:3.13-slim AS app
10
+ ENV PYTHONDONTWRITEBYTECODE=1 \
11
+ PYTHONUNBUFFERED=1 \
12
+ UV_LINK_MODE=copy
13
+ WORKDIR /app
14
+ RUN apt-get update && apt-get install -y --no-install-recommends \
15
+ git libpq-dev gcc openssl \
16
+ && rm -rf /var/lib/apt/lists/*
17
+ RUN pip install --no-cache-dir uv
18
+ COPY pyproject.toml uv.lock main.py config.json VERSION ./
19
+ RUN uv sync --frozen --no-dev --no-install-project
20
+ COPY api ./api
21
+ COPY services ./services
22
+ COPY utils ./utils
23
+ COPY scripts ./scripts
24
+ COPY --from=web-build /app/web/out ./web_dist
25
+ EXPOSE 7860
26
+ CMD ["uv", "run", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860", "--access-log"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kunkun
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md CHANGED
@@ -1,10 +1,355 @@
1
- ---
2
- title: Chatgpt2api
3
- emoji: 📊
4
- colorFrom: red
5
- colorTo: indigo
6
  sdk: docker
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: ChatGPT2API
3
+ emoji: 🎨
4
+ colorFrom: blue
5
+ colorTo: green
6
  sdk: docker
7
  pinned: false
8
  ---
9
 
10
+ <h1 align="center">ChatGPT2API</h1>
11
+
12
+
13
+ <p align="center">ChatGPT2API 主要是对 ChatGPT 官网相关能力进行逆向整理与封装,提供面向 ChatGPT 图片生成、图片编辑、多图组图编辑场景的 OpenAI 兼容图片 API / 代理,并集成在线画图、号池管理、多种账号导入方式与 Docker 自托管部署能力。</p>
14
+
15
+ > [!WARNING]
16
+ > 免责声明:
17
+ >
18
+ > 本项目涉及对 ChatGPT 官网文本生成、图片生成与图片编辑等相关接口的逆向研究,仅供个人学习、技术研究与非商业性技术交流使用。
19
+ >
20
+ > - 严禁将本项目用于任何商业用途、盈利性使用、批量操作、自动化滥用或规模化调用。
21
+ > - 严禁将本项目用于破坏市场秩序、恶意竞争、套利倒卖、二次售卖相关服务,以及任何违反 OpenAI 服务条款或当地法律法规的行为。
22
+ > - 严禁将本项目用于生成、传播或协助生成违法、暴力、色情、未成年人相关内容,或用于诈骗、欺诈、骚扰等非法或不当用途。
23
+ > - 使用者应自行承担全部风险,包括但不限于账号被限制、临时封禁或永久封禁以及因违规使用等所导致的法律责任。
24
+ > - 使用本项目即视为你已充分理解并同意本免责声明全部内容;如因滥用、违规或违法使用造成任何后果,均由使用者自行承担。
25
+
26
+ > [!IMPORTANT]
27
+ > 本项目基于对 ChatGPT 官网相关能力的逆向研究实现,存在账号受限、临时封禁或永久封禁的风险。请勿使用你自己的重要账号、常用账号或高价值账号进行测试。
28
+
29
+ > [!CAUTION]
30
+ > 旧版本存在已知漏洞,请尽快升级到最新版本。公网部署时请尽量不要放置敏感信息,并自行做好访问控制与隔离。
31
+
32
+ ## 快速开始
33
+
34
+ 已发布镜像支持 `linux/amd64` 与 `linux/arm64`,在 x86 服务器和 Apple Silicon / ARM Linux 设备上都会自动拉取匹配架构的版本。
35
+
36
+ ### Docker 运行
37
+
38
+ ```bash
39
+ git clone git@github.com:basketikun/chatgpt2api.git
40
+ cd chatgpt2api
41
+ docker compose up -d
42
+ ```
43
+
44
+ 启动前请先在 `config.json` 中设置 `auth-key`,也可以在 `docker-compose.yml` 中通过 `CHATGPT2API_AUTH_KEY` 覆盖。
45
+
46
+ - Web 面板:`http://localhost:3000`
47
+ - API 地址:`http://localhost:3000/v1`
48
+ - 数据目录:`./data`
49
+
50
+ ### 本地开发
51
+
52
+ 启动后端:
53
+
54
+ ```bash
55
+ git clone git@github.com:basketikun/chatgpt2api.git
56
+ cd chatgpt2api
57
+ uv sync
58
+ uv run main.py
59
+ ```
60
+
61
+ 启动前端:
62
+
63
+ ```bash
64
+ cd chatgpt2api/web
65
+ bun install
66
+ bun run dev
67
+ ```
68
+
69
+ 后续更新新版本:
70
+
71
+ ```bash
72
+ docker pull ghcr.io/basketikun/chatgpt2api:latest
73
+ docker-compose down
74
+ docker-compose up -d
75
+
76
+ ```
77
+
78
+ ### 存储后端配置
79
+
80
+ 支持通过环境变量 `STORAGE_BACKEND` 切换存储方式:
81
+
82
+ - `json` - 本地 JSON 文件(默认)
83
+ - `sqlite` - 本地 SQLite 数据库
84
+ - `postgres` - 外部 PostgreSQL(需配置 `DATABASE_URL`)
85
+ - `git` - Git 私有仓库(需配置 `GIT_REPO_URL` 和 `GIT_TOKEN`)
86
+
87
+ 示例:使用 PostgreSQL
88
+
89
+ ```yaml
90
+ environment:
91
+ - STORAGE_BACKEND=postgres
92
+ - DATABASE_URL=postgresql://user:password@host:5432/dbname
93
+ ```
94
+
95
+ ## 功能
96
+
97
+ ### API 兼容能力
98
+
99
+ - 兼容 `POST /v1/images/generations` 图片生成接口
100
+ - 兼容 `POST /v1/images/edits` 图片编辑接口
101
+ - 兼容面向图片场景的 `POST /v1/chat/completions`
102
+ - 兼容面向图片场景的 `POST /v1/responses`
103
+ - `GET /v1/models` 返回 `gpt-image-2`、`codex-gpt-image-2`、`auto`、`gpt-5`、`gpt-5-1`、`gpt-5-2`、`gpt-5-3`、`gpt-5-3-mini`、
104
+ `gpt-5-mini`
105
+ - 支持通过 `n` 返回多张生成结果
106
+ - 支持 Codex 中的画图接口逆向,仅 `Plus` / `Team` / `Pro` 订阅可用,模型别名为 `codex-gpt-image-2`,如有需要可自行在其他场景映射回
107
+ `gpt-image-2`,用于和官网画图区分;也就意味着同一账号会同时有官网和 Codex 两份生图额度
108
+
109
+ ### 在线画图功能
110
+
111
+ - 内置在线画图工作台,支持生成、图片编辑与多图组图编辑
112
+ - 支持 `gpt-image-2`、`codex-gpt-image-2`、`auto`、`gpt-5`、`gpt-5-1`、`gpt-5-2`、`gpt-5-3`、`gpt-5-3-mini`、`gpt-5-mini` 模型选择
113
+ - 编辑模式支持参考图上传
114
+ - 前端支持多图生成交互
115
+ - 本地保存图片会话历史,支持回看、删除和清空
116
+ - 支持服务端缓存图片URL
117
+
118
+ ### 号池管理功能
119
+
120
+ - 自动刷新账号邮箱、类型、额度和恢复时间
121
+ - 轮询可用账号执行图片生成与图片编辑
122
+ - 遇到 Token 失效类错误时自动剔除无效 Token
123
+ - 定时检查限流账号并自动刷新
124
+ - 支持网页端配置全局 HTTP / HTTPS / SOCKS5 / SOCKS5H 代理
125
+ - 支持搜索、筛选、批量刷新、导出、手动编辑和清理账号
126
+ - 支持四种导入方式:本地 CPA JSON 文件导入、���程 CPA 服务器导入、`sub2api` 服务器导入、`access_token` 导入
127
+ - 支持在设置页配置 `sub2api` 服务器,筛选并批量导入其中的 OpenAI OAuth 账号
128
+
129
+ ### 实验性 / 规划中
130
+
131
+ - `/v1/complete` 文本补全与流式输出已实现,但仍在测试,目前会出现对话重复的问题,请谨慎测试使用
132
+ - 详细状态说明见:[功能清单](./docs/feature-status.en.md)
133
+
134
+ ## Screenshots
135
+
136
+ 文生图界面:
137
+
138
+ ![image](assets/image.png)
139
+
140
+ 编辑图:
141
+
142
+ ![image](assets/image_edit.png)
143
+
144
+ Cherry Studio 中使用,支持作为绘图接口接入:
145
+
146
+ ![image](assets/chery_studio.png)
147
+
148
+ 号池管理:
149
+
150
+ ![image](assets/account_pool.png)
151
+
152
+ New Api 接入:
153
+
154
+ ![image](assets/new_api.png)
155
+
156
+ ## API
157
+
158
+ 所有 AI 接口都需要请求头:
159
+
160
+ ```http
161
+ Authorization: Bearer <auth-key>
162
+ ```
163
+
164
+ <details>
165
+ <summary><code>GET /v1/models</code></summary>
166
+ <br>
167
+
168
+ 返回当前暴露的图片模型列表。
169
+
170
+ ```bash
171
+ curl http://localhost:8000/v1/models \
172
+ -H "Authorization: Bearer <auth-key>"
173
+ ```
174
+
175
+ <details>
176
+ <summary>说明</summary>
177
+ <br>
178
+
179
+ | 字段 | 说明 |
180
+ |:-----|:-----------------------------------------------------------------------------------------------------------|
181
+ | 返回模型 | `gpt-image-2`、`codex-gpt-image-2`、`auto`、`gpt-5`、`gpt-5-1`、`gpt-5-2`、`gpt-5-3`、`gpt-5-3-mini`、`gpt-5-mini` |
182
+ | 接入场景 | 可接入 Cherry Studio、New API 等上游或客户端 |
183
+
184
+ <br>
185
+ </details>
186
+ </details>
187
+
188
+ <details>
189
+ <summary><code>POST /v1/images/generations</code></summary>
190
+ <br>
191
+
192
+ OpenAI 兼容图片生成接口,用于文生图。
193
+
194
+ ```bash
195
+ curl http://localhost:8000/v1/images/generations \
196
+ -H "Content-Type: application/json" \
197
+ -H "Authorization: Bearer <auth-key>" \
198
+ -d '{
199
+ "model": "gpt-image-2",
200
+ "prompt": "一只漂浮在太空里的猫",
201
+ "n": 1,
202
+ "response_format": "b64_json"
203
+ }'
204
+ ```
205
+
206
+ <details>
207
+ <summary>字段说明</summary>
208
+ <br>
209
+
210
+ | 字段 | 说明 |
211
+ |:------------------|:---------------------------------------------------|
212
+ | `model` | 图片模型,当前可用值以 `/v1/models` 返回结果为准,推荐使用 `gpt-image-2` |
213
+ | `prompt` | 图片生成提示词 |
214
+ | `n` | 生成数量,当前后端限制为 `1-4` |
215
+ | `response_format` | 当前请求模型中包含该字段,默认值为 `b64_json` |
216
+
217
+ <br>
218
+ </details>
219
+ </details>
220
+
221
+ <details>
222
+ <summary><code>POST /v1/images/edits</code></summary>
223
+ <br>
224
+
225
+ OpenAI 兼容图片编辑接口,可上传图片文件,也可按官方 JSON 格式传入图片链接并生成编辑结果。
226
+
227
+ ```bash
228
+ curl http://localhost:8000/v1/images/edits \
229
+ -H "Authorization: Bearer <auth-key>" \
230
+ -F "model=gpt-image-2" \
231
+ -F "prompt=把这张图改成赛博朋克夜景风格" \
232
+ -F "n=1" \
233
+ -F "image=@./input.png"
234
+ ```
235
+
236
+ 也可以直接传图片 URL:
237
+
238
+ ```bash
239
+ curl http://localhost:8000/v1/images/edits \
240
+ -H "Authorization: Bearer <auth-key>" \
241
+ -H "Content-Type: application/json" \
242
+ -d '{
243
+ "model": "gpt-image-2",
244
+ "prompt": "把这张图改成赛博朋克夜景风格",
245
+ "images": [
246
+ {"image_url": "https://example.com/input.png"}
247
+ ]
248
+ }'
249
+ ```
250
+
251
+ <details>
252
+ <summary>字段说明</summary>
253
+ <br>
254
+
255
+ | 字段 | 说明 |
256
+ |:------------|:----------------------------------------------|
257
+ | `model` | 图片模型, `gpt-image-2` |
258
+ | `prompt` | 图片编辑提示词 |
259
+ | `n` | 生成数量,当前后端限制为 `1-4` |
260
+ | `image` | 需要编辑的图片文件,使用 multipart/form-data 上传 |
261
+ | `images` | JSON 图片引用数组,支持 `{"image_url": "https://..."}` |
262
+ | `image_url` | 表单模式下也可直接传图片链接,支持重复字段传多张图 |
263
+
264
+ <br>
265
+ </details>
266
+ </details>
267
+
268
+ <details>
269
+ <summary><code>POST /v1/chat/completions</code></summary>
270
+ <br>
271
+
272
+ 面向图片场景的 Chat Completions 兼容接口,不是完整通用聊天代理。
273
+
274
+ ```bash
275
+ curl http://localhost:8000/v1/chat/completions \
276
+ -H "Content-Type: application/json" \
277
+ -H "Authorization: Bearer <auth-key>" \
278
+ -d '{
279
+ "model": "gpt-image-2",
280
+ "messages": [
281
+ {
282
+ "role": "user",
283
+ "content": "生成一张雨夜东京街头的赛博朋克猫"
284
+ }
285
+ ],
286
+ "n": 1
287
+ }'
288
+ ```
289
+
290
+ <details>
291
+ <summary>字段说明</summary>
292
+ <br>
293
+
294
+ | 字段 | 说明 |
295
+ |:-----------|:------------------|
296
+ | `model` | 图片模型,默认按图片生成场景处理 |
297
+ | `messages` | 消息数组,需要是图片相关请求内容 |
298
+ | `n` | 生成数量,按当前实现解析为图片数量 |
299
+ | `stream` | 已实现,但仍在测试 |
300
+
301
+ <br>
302
+ </details>
303
+ </details>
304
+
305
+ <details>
306
+ <summary><code>POST /v1/responses</code></summary>
307
+ <br>
308
+
309
+ 面向图片生成工具调用的 Responses API 兼容接口,不是完整通用 Responses API 代理。
310
+
311
+ ```bash
312
+ curl http://localhost:8000/v1/responses \
313
+ -H "Content-Type: application/json" \
314
+ -H "Authorization: Bearer <auth-key>" \
315
+ -d '{
316
+ "model": "gpt-5",
317
+ "input": "生成一张未来感城市天际线图片",
318
+ "tools": [
319
+ {
320
+ "type": "image_generation"
321
+ }
322
+ ]
323
+ }'
324
+ ```
325
+
326
+ <details>
327
+ <summary>字段说明</summary>
328
+ <br>
329
+
330
+ | 字段 | 说明 |
331
+ |:---------|:------------------------------|
332
+ | `model` | 响应中会回显该模型字段,但图片生成当前仍走图片生成兼容逻辑 |
333
+ | `input` | 输入内容,需要能解析出图片生成提示词 |
334
+ | `tools` | 必须包含 `image_generation` 工具请求 |
335
+ | `stream` | 已实现,但仍在测试 |
336
+
337
+ <br>
338
+ </details>
339
+ </details>
340
+
341
+ ## 社区支持
342
+
343
+ 学 AI , 上 L 站:[LinuxDO](https://linux.do)
344
+
345
+ ## Contributors
346
+
347
+ 感谢所有为本项目做出贡献的开发者:
348
+
349
+ <a href="https://github.com/basketikun/chatgpt2api/graphs/contributors">
350
+ <img alt="Contributors" src="https://contrib.rocks/image?repo=basketikun/chatgpt2api" />
351
+ </a>
352
+
353
+ ## Star History
354
+
355
+ [![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.1.7
api/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ from api.app import create_app
2
+
api/accounts.py ADDED
@@ -0,0 +1,426 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import json
5
+ import re
6
+ import zipfile
7
+ from datetime import datetime
8
+ from typing import Any, Literal
9
+
10
+ from fastapi import APIRouter, Header, HTTPException
11
+ from fastapi.concurrency import run_in_threadpool
12
+ from fastapi.responses import Response
13
+ from pydantic import BaseModel, Field
14
+
15
+ from services.auth_service import auth_service
16
+
17
+ from api.support import (
18
+ require_admin,
19
+ sanitize_cpa_pool,
20
+ sanitize_cpa_pools,
21
+ sanitize_sub2api_server,
22
+ sanitize_sub2api_servers,
23
+ )
24
+ from services.account_service import account_service
25
+ from services.cpa_service import cpa_config, cpa_import_service, list_remote_files
26
+ from services.sub2api_service import (
27
+ list_remote_accounts as sub2api_list_remote_accounts,
28
+ list_remote_groups as sub2api_list_remote_groups,
29
+ sub2api_config,
30
+ sub2api_import_service,
31
+ )
32
+
33
+
34
+
35
+ class UserKeyCreateRequest(BaseModel):
36
+ name: str = ""
37
+
38
+
39
+ class UserKeyUpdateRequest(BaseModel):
40
+ name: str | None = None
41
+ enabled: bool | None = None
42
+ key: str | None = None
43
+
44
+
45
+ class AccountCreateRequest(BaseModel):
46
+ tokens: list[str] = Field(default_factory=list)
47
+ accounts: list[dict[str, Any]] = Field(default_factory=list)
48
+
49
+
50
+ class AccountDeleteRequest(BaseModel):
51
+ tokens: list[str] = Field(default_factory=list)
52
+
53
+
54
+ class AccountRefreshRequest(BaseModel):
55
+ access_tokens: list[str] = Field(default_factory=list)
56
+
57
+
58
+ class AccountExportRequest(BaseModel):
59
+ access_tokens: list[str] = Field(default_factory=list)
60
+ format: Literal["json", "zip"] = "json"
61
+
62
+
63
+ class AccountUpdateRequest(BaseModel):
64
+ access_token: str = ""
65
+ type: str | None = None
66
+ status: str | None = None
67
+ quota: int | None = None
68
+
69
+
70
+ class CPAPoolCreateRequest(BaseModel):
71
+ name: str = ""
72
+ base_url: str = ""
73
+ secret_key: str = ""
74
+
75
+
76
+ class CPAPoolUpdateRequest(BaseModel):
77
+ name: str | None = None
78
+ base_url: str | None = None
79
+ secret_key: str | None = None
80
+
81
+
82
+ class CPAImportRequest(BaseModel):
83
+ names: list[str] = Field(default_factory=list)
84
+
85
+
86
+ class Sub2APIServerCreateRequest(BaseModel):
87
+ name: str = ""
88
+ base_url: str = ""
89
+ email: str = ""
90
+ password: str = ""
91
+ api_key: str = ""
92
+ group_id: str = ""
93
+
94
+
95
+ class Sub2APIServerUpdateRequest(BaseModel):
96
+ name: str | None = None
97
+ base_url: str | None = None
98
+ email: str | None = None
99
+ password: str | None = None
100
+ api_key: str | None = None
101
+ group_id: str | None = None
102
+
103
+
104
+ class Sub2APIImportRequest(BaseModel):
105
+ account_ids: list[str] = Field(default_factory=list)
106
+
107
+
108
+ def _account_payload_token(item: dict[str, Any]) -> str:
109
+ return str(item.get("access_token") or item.get("accessToken") or "").strip()
110
+
111
+
112
+ def _unique_tokens(tokens: list[str]) -> list[str]:
113
+ return list(dict.fromkeys(str(token or "").strip() for token in tokens if str(token or "").strip()))
114
+
115
+
116
+ def _download_timestamp() -> str:
117
+ return datetime.now().strftime("%Y%m%d-%H%M%S")
118
+
119
+
120
+ def _safe_export_name(value: str, fallback: str) -> str:
121
+ clean = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip()).strip("-._")
122
+ return (clean or fallback)[:80]
123
+
124
+
125
+ def _account_zip_bytes(items: list[dict[str, str]]) -> bytes:
126
+ buf = io.BytesIO()
127
+ used_names: set[str] = set()
128
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as archive:
129
+ for index, item in enumerate(items, start=1):
130
+ raw_name = item.get("email") or item.get("account_id") or f"account-{index:03d}"
131
+ base_name = _safe_export_name(raw_name, f"account-{index:03d}")
132
+ name = base_name
133
+ suffix = 2
134
+ while name in used_names:
135
+ name = f"{base_name}-{suffix}"
136
+ suffix += 1
137
+ used_names.add(name)
138
+ archive.writestr(
139
+ f"{name}.json",
140
+ json.dumps(item, ensure_ascii=False, indent=2) + "\n",
141
+ )
142
+ return buf.getvalue()
143
+
144
+
145
+ def create_router() -> APIRouter:
146
+ router = APIRouter()
147
+
148
+ @router.get("/api/auth/users")
149
+ async def list_user_keys(authorization: str | None = Header(default=None)):
150
+ require_admin(authorization)
151
+ return {"items": auth_service.list_keys(role="user")}
152
+
153
+ @router.post("/api/auth/users")
154
+ async def create_user_key(body: UserKeyCreateRequest, authorization: str | None = Header(default=None)):
155
+ require_admin(authorization)
156
+ try:
157
+ item, raw_key = auth_service.create_key(role="user", name=body.name)
158
+ except ValueError as exc:
159
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
160
+ return {"item": item, "key": raw_key, "items": auth_service.list_keys(role="user")}
161
+
162
+ @router.post("/api/auth/users/{key_id}")
163
+ async def update_user_key(
164
+ key_id: str,
165
+ body: UserKeyUpdateRequest,
166
+ authorization: str | None = Header(default=None),
167
+ ):
168
+ require_admin(authorization)
169
+ updates = {
170
+ key: value
171
+ for key, value in {
172
+ "name": body.name,
173
+ "enabled": body.enabled,
174
+ "key": body.key,
175
+ }.items()
176
+ if value is not None
177
+ }
178
+ if not updates:
179
+ raise HTTPException(status_code=400, detail={"error": "还没有检测到改动,请修改后再保存"})
180
+ try:
181
+ item = auth_service.update_key(key_id, updates, role="user")
182
+ except ValueError as exc:
183
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
184
+ if item is None:
185
+ raise HTTPException(status_code=404, detail={"error": "这条用户密钥不存在,可能已经被删除"})
186
+ return {"item": item, "items": auth_service.list_keys(role="user")}
187
+
188
+ @router.delete("/api/auth/users/{key_id}")
189
+ async def delete_user_key(key_id: str, authorization: str | None = Header(default=None)):
190
+ require_admin(authorization)
191
+ if not auth_service.delete_key(key_id, role="user"):
192
+ raise HTTPException(status_code=404, detail={"error": "这条用户密钥不存在,可能已经被删除"})
193
+ return {"items": auth_service.list_keys(role="user")}
194
+
195
+ @router.get("/api/accounts")
196
+ async def get_accounts(authorization: str | None = Header(default=None)):
197
+ require_admin(authorization)
198
+ return {"items": account_service.list_accounts()}
199
+
200
+ @router.post("/api/accounts")
201
+ async def create_accounts(body: AccountCreateRequest, authorization: str | None = Header(default=None)):
202
+ require_admin(authorization)
203
+ account_payloads = [item for item in body.accounts if isinstance(item, dict)]
204
+ payload_tokens = [_account_payload_token(item) for item in account_payloads]
205
+ tokens = _unique_tokens([*body.tokens, *payload_tokens])
206
+ if not tokens:
207
+ raise HTTPException(status_code=400, detail={"error": "tokens is required"})
208
+ if account_payloads:
209
+ result = account_service.add_account_items(account_payloads)
210
+ payload_token_set = set(_unique_tokens(payload_tokens))
211
+ extra_tokens = [token for token in tokens if token not in payload_token_set]
212
+ if extra_tokens:
213
+ extra_result = account_service.add_accounts(extra_tokens)
214
+ result["added"] = int(result.get("added") or 0) + int(extra_result.get("added") or 0)
215
+ result["skipped"] = int(result.get("skipped") or 0) + int(extra_result.get("skipped") or 0)
216
+ else:
217
+ result = account_service.add_accounts(tokens)
218
+ refresh_result = account_service.refresh_accounts(tokens)
219
+ return {
220
+ **result,
221
+ "refreshed": refresh_result.get("refreshed", 0),
222
+ "errors": refresh_result.get("errors", []),
223
+ "items": refresh_result.get("items", result.get("items", [])),
224
+ }
225
+
226
+ @router.delete("/api/accounts")
227
+ async def delete_accounts(body: AccountDeleteRequest, authorization: str | None = Header(default=None)):
228
+ require_admin(authorization)
229
+ tokens = [str(token or "").strip() for token in body.tokens if str(token or "").strip()]
230
+ if not tokens:
231
+ raise HTTPException(status_code=400, detail={"error": "tokens is required"})
232
+ return account_service.delete_accounts(tokens)
233
+
234
+ @router.post("/api/accounts/refresh")
235
+ async def refresh_accounts(body: AccountRefreshRequest, authorization: str | None = Header(default=None)):
236
+ require_admin(authorization)
237
+ access_tokens = [str(token or "").strip() for token in body.access_tokens if str(token or "").strip()]
238
+ if not access_tokens:
239
+ access_tokens = account_service.list_tokens()
240
+ if not access_tokens:
241
+ raise HTTPException(status_code=400, detail={"error": "access_tokens is required"})
242
+ return account_service.refresh_accounts(access_tokens)
243
+
244
+ @router.post("/api/accounts/export")
245
+ async def export_accounts(body: AccountExportRequest, authorization: str | None = Header(default=None)):
246
+ require_admin(authorization)
247
+ access_tokens = _unique_tokens(body.access_tokens)
248
+ items = account_service.build_export_items(access_tokens)
249
+ if not items:
250
+ raise HTTPException(
251
+ status_code=400,
252
+ detail={"error": "没有可导出的完整账号,需要同时有 access_token、refresh_token 和 id_token"},
253
+ )
254
+
255
+ timestamp = _download_timestamp()
256
+ if body.format == "zip":
257
+ content = _account_zip_bytes(items)
258
+ return Response(
259
+ content,
260
+ media_type="application/zip",
261
+ headers={"Content-Disposition": f'attachment; filename="codex-accounts-{timestamp}.zip"'},
262
+ )
263
+
264
+ payload: dict[str, str] | list[dict[str, str]] = items[0] if len(items) == 1 else items
265
+ return Response(
266
+ json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
267
+ media_type="application/json",
268
+ headers={"Content-Disposition": f'attachment; filename="codex-accounts-{timestamp}.json"'},
269
+ )
270
+
271
+ @router.post("/api/accounts/update")
272
+ async def update_account(body: AccountUpdateRequest, authorization: str | None = Header(default=None)):
273
+ require_admin(authorization)
274
+ access_token = str(body.access_token or "").strip()
275
+ if not access_token:
276
+ raise HTTPException(status_code=400, detail={"error": "access_token is required"})
277
+ updates = {key: value for key, value in {"type": body.type, "status": body.status, "quota": body.quota}.items() if value is not None}
278
+ if not updates:
279
+ raise HTTPException(status_code=400, detail={"error": "还没有检测到改动,请修改后再保存"})
280
+ account = account_service.update_account(access_token, updates)
281
+ if account is None:
282
+ raise HTTPException(status_code=404, detail={"error": "account not found"})
283
+ return {"item": account, "items": account_service.list_accounts()}
284
+
285
+ @router.get("/api/cpa/pools")
286
+ async def list_cpa_pools(authorization: str | None = Header(default=None)):
287
+ require_admin(authorization)
288
+ return {"pools": sanitize_cpa_pools(cpa_config.list_pools())}
289
+
290
+ @router.post("/api/cpa/pools")
291
+ async def create_cpa_pool(body: CPAPoolCreateRequest, authorization: str | None = Header(default=None)):
292
+ require_admin(authorization)
293
+ if not body.base_url.strip():
294
+ raise HTTPException(status_code=400, detail={"error": "base_url is required"})
295
+ if not body.secret_key.strip():
296
+ raise HTTPException(status_code=400, detail={"error": "secret_key is required"})
297
+ pool = cpa_config.add_pool(name=body.name, base_url=body.base_url, secret_key=body.secret_key)
298
+ return {"pool": sanitize_cpa_pool(pool), "pools": sanitize_cpa_pools(cpa_config.list_pools())}
299
+
300
+ @router.post("/api/cpa/pools/{pool_id}")
301
+ async def update_cpa_pool(pool_id: str, body: CPAPoolUpdateRequest, authorization: str | None = Header(default=None)):
302
+ require_admin(authorization)
303
+ pool = cpa_config.update_pool(pool_id, body.model_dump(exclude_none=True))
304
+ if pool is None:
305
+ raise HTTPException(status_code=404, detail={"error": "pool not found"})
306
+ return {"pool": sanitize_cpa_pool(pool), "pools": sanitize_cpa_pools(cpa_config.list_pools())}
307
+
308
+ @router.delete("/api/cpa/pools/{pool_id}")
309
+ async def delete_cpa_pool(pool_id: str, authorization: str | None = Header(default=None)):
310
+ require_admin(authorization)
311
+ if not cpa_config.delete_pool(pool_id):
312
+ raise HTTPException(status_code=404, detail={"error": "pool not found"})
313
+ return {"pools": sanitize_cpa_pools(cpa_config.list_pools())}
314
+
315
+ @router.get("/api/cpa/pools/{pool_id}/files")
316
+ async def cpa_pool_files(pool_id: str, authorization: str | None = Header(default=None)):
317
+ require_admin(authorization)
318
+ pool = cpa_config.get_pool(pool_id)
319
+ if pool is None:
320
+ raise HTTPException(status_code=404, detail={"error": "pool not found"})
321
+ return {"pool_id": pool_id, "files": await run_in_threadpool(list_remote_files, pool)}
322
+
323
+ @router.post("/api/cpa/pools/{pool_id}/import")
324
+ async def cpa_pool_import(pool_id: str, body: CPAImportRequest, authorization: str | None = Header(default=None)):
325
+ require_admin(authorization)
326
+ pool = cpa_config.get_pool(pool_id)
327
+ if pool is None:
328
+ raise HTTPException(status_code=404, detail={"error": "pool not found"})
329
+ try:
330
+ job = cpa_import_service.start_import(pool, body.names)
331
+ except ValueError as exc:
332
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
333
+ return {"import_job": job}
334
+
335
+ @router.get("/api/cpa/pools/{pool_id}/import")
336
+ async def cpa_pool_import_progress(pool_id: str, authorization: str | None = Header(default=None)):
337
+ require_admin(authorization)
338
+ pool = cpa_config.get_pool(pool_id)
339
+ if pool is None:
340
+ raise HTTPException(status_code=404, detail={"error": "pool not found"})
341
+ return {"import_job": pool.get("import_job")}
342
+
343
+ @router.get("/api/sub2api/servers")
344
+ async def list_sub2api_servers(authorization: str | None = Header(default=None)):
345
+ require_admin(authorization)
346
+ return {"servers": sanitize_sub2api_servers(sub2api_config.list_servers())}
347
+
348
+ @router.post("/api/sub2api/servers")
349
+ async def create_sub2api_server(body: Sub2APIServerCreateRequest, authorization: str | None = Header(default=None)):
350
+ require_admin(authorization)
351
+ if not body.base_url.strip():
352
+ raise HTTPException(status_code=400, detail={"error": "base_url is required"})
353
+ has_login = body.email.strip() and body.password.strip()
354
+ has_api_key = bool(body.api_key.strip())
355
+ if not has_login and not has_api_key:
356
+ raise HTTPException(status_code=400, detail={"error": "email+password or api_key is required"})
357
+ server = sub2api_config.add_server(
358
+ name=body.name,
359
+ base_url=body.base_url,
360
+ email=body.email,
361
+ password=body.password,
362
+ api_key=body.api_key,
363
+ group_id=body.group_id,
364
+ )
365
+ return {"server": sanitize_sub2api_server(server), "servers": sanitize_sub2api_servers(sub2api_config.list_servers())}
366
+
367
+ @router.post("/api/sub2api/servers/{server_id}")
368
+ async def update_sub2api_server(server_id: str, body: Sub2APIServerUpdateRequest, authorization: str | None = Header(default=None)):
369
+ require_admin(authorization)
370
+ server = sub2api_config.update_server(server_id, body.model_dump(exclude_none=True))
371
+ if server is None:
372
+ raise HTTPException(status_code=404, detail={"error": "server not found"})
373
+ return {"server": sanitize_sub2api_server(server), "servers": sanitize_sub2api_servers(sub2api_config.list_servers())}
374
+
375
+ @router.delete("/api/sub2api/servers/{server_id}")
376
+ async def delete_sub2api_server(server_id: str, authorization: str | None = Header(default=None)):
377
+ require_admin(authorization)
378
+ if not sub2api_config.delete_server(server_id):
379
+ raise HTTPException(status_code=404, detail={"error": "server not found"})
380
+ return {"servers": sanitize_sub2api_servers(sub2api_config.list_servers())}
381
+
382
+ @router.get("/api/sub2api/servers/{server_id}/groups")
383
+ async def sub2api_server_groups(server_id: str, authorization: str | None = Header(default=None)):
384
+ require_admin(authorization)
385
+ server = sub2api_config.get_server(server_id)
386
+ if server is None:
387
+ raise HTTPException(status_code=404, detail={"error": "server not found"})
388
+ try:
389
+ groups = await run_in_threadpool(sub2api_list_remote_groups, server)
390
+ except Exception as exc:
391
+ raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
392
+ return {"server_id": server_id, "groups": groups}
393
+
394
+ @router.get("/api/sub2api/servers/{server_id}/accounts")
395
+ async def sub2api_server_accounts(server_id: str, authorization: str | None = Header(default=None)):
396
+ require_admin(authorization)
397
+ server = sub2api_config.get_server(server_id)
398
+ if server is None:
399
+ raise HTTPException(status_code=404, detail={"error": "server not found"})
400
+ try:
401
+ accounts = await run_in_threadpool(sub2api_list_remote_accounts, server)
402
+ except Exception as exc:
403
+ raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
404
+ return {"server_id": server_id, "accounts": accounts}
405
+
406
+ @router.post("/api/sub2api/servers/{server_id}/import")
407
+ async def sub2api_server_import(server_id: str, body: Sub2APIImportRequest, authorization: str | None = Header(default=None)):
408
+ require_admin(authorization)
409
+ server = sub2api_config.get_server(server_id)
410
+ if server is None:
411
+ raise HTTPException(status_code=404, detail={"error": "server not found"})
412
+ try:
413
+ job = sub2api_import_service.start_import(server, body.account_ids)
414
+ except ValueError as exc:
415
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
416
+ return {"import_job": job}
417
+
418
+ @router.get("/api/sub2api/servers/{server_id}/import")
419
+ async def sub2api_server_import_progress(server_id: str, authorization: str | None = Header(default=None)):
420
+ require_admin(authorization)
421
+ server = sub2api_config.get_server(server_id)
422
+ if server is None:
423
+ raise HTTPException(status_code=404, detail={"error": "server not found"})
424
+ return {"import_job": server.get("import_job")}
425
+
426
+ return router
api/ai.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter, Header, HTTPException, Request
4
+ from fastapi.concurrency import run_in_threadpool
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+
7
+ from api.image_inputs import parse_image_edit_request, read_image_sources
8
+ from api.support import require_identity, resolve_image_base_url
9
+ from services.content_filter import check_request, request_text
10
+ from services.log_service import LoggedCall
11
+ from services.protocol import (
12
+ anthropic_v1_messages,
13
+ openai_v1_chat_complete,
14
+ openai_v1_image_edit,
15
+ openai_v1_image_generations,
16
+ openai_v1_models,
17
+ openai_v1_response,
18
+ )
19
+
20
+
21
+ class ImageGenerationRequest(BaseModel):
22
+ prompt: str = Field(..., min_length=1)
23
+ model: str = "gpt-image-2"
24
+ n: int = Field(default=1, ge=1, le=4)
25
+ size: str | None = None
26
+ quality: str = "auto"
27
+ response_format: str = "b64_json"
28
+ history_disabled: bool = True
29
+ stream: bool | None = None
30
+
31
+
32
+ class ChatCompletionRequest(BaseModel):
33
+ model_config = ConfigDict(extra="allow")
34
+ model: str | None = None
35
+ prompt: str | None = None
36
+ n: int | None = None
37
+ stream: bool | None = None
38
+ modalities: list[str] | None = None
39
+ messages: list[dict[str, object]] | None = None
40
+
41
+
42
+ class ResponseCreateRequest(BaseModel):
43
+ model_config = ConfigDict(extra="allow")
44
+ model: str | None = None
45
+ input: object | None = None
46
+ tools: list[dict[str, object]] | None = None
47
+ tool_choice: object | None = None
48
+ stream: bool | None = None
49
+
50
+
51
+ class AnthropicMessageRequest(BaseModel):
52
+ model_config = ConfigDict(extra="allow")
53
+ model: str | None = None
54
+ messages: list[dict[str, object]] | None = None
55
+ system: object | None = None
56
+ stream: bool | None = None
57
+
58
+
59
+ async def filter_or_log(call: LoggedCall, text: str) -> None:
60
+ try:
61
+ await run_in_threadpool(check_request, text)
62
+ except HTTPException as exc:
63
+ call.log("调用失败", status="failed", error=str(exc.detail))
64
+ raise
65
+
66
+
67
+ def create_router() -> APIRouter:
68
+ router = APIRouter()
69
+
70
+ @router.get("/v1/models")
71
+ async def list_models(authorization: str | None = Header(default=None)):
72
+ require_identity(authorization)
73
+ try:
74
+ return await run_in_threadpool(openai_v1_models.list_models)
75
+ except Exception as exc:
76
+ raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
77
+
78
+ @router.post("/v1/images/generations")
79
+ async def generate_images(
80
+ body: ImageGenerationRequest,
81
+ request: Request,
82
+ authorization: str | None = Header(default=None),
83
+ ):
84
+ identity = require_identity(authorization)
85
+ payload = body.model_dump(mode="python")
86
+ payload["base_url"] = resolve_image_base_url(request)
87
+ call = LoggedCall(identity, "/v1/images/generations", body.model, "文生图", request_text=body.prompt)
88
+ await filter_or_log(call, body.prompt)
89
+ return await call.run(openai_v1_image_generations.handle, payload)
90
+
91
+ @router.post("/v1/images/edits")
92
+ async def edit_images(
93
+ request: Request,
94
+ authorization: str | None = Header(default=None),
95
+ ):
96
+ identity = require_identity(authorization)
97
+ payload, image_sources = await parse_image_edit_request(request)
98
+ prompt = str(payload["prompt"])
99
+ model = str(payload["model"])
100
+ call = LoggedCall(identity, "/v1/images/edits", model, "图生图", request_text=prompt)
101
+ await filter_or_log(call, prompt)
102
+ payload["images"] = await read_image_sources(image_sources)
103
+ payload["base_url"] = resolve_image_base_url(request)
104
+ return await call.run(openai_v1_image_edit.handle, payload)
105
+
106
+ @router.post("/v1/chat/completions")
107
+ async def create_chat_completion(body: ChatCompletionRequest, authorization: str | None = Header(default=None)):
108
+ identity = require_identity(authorization)
109
+ payload = body.model_dump(mode="python")
110
+ model = str(payload.get("model") or "auto")
111
+ request_preview = request_text(payload.get("prompt"), payload.get("messages"))
112
+ call = LoggedCall(identity, "/v1/chat/completions", model, "文本生成", request_text=request_preview)
113
+ await filter_or_log(call, request_preview)
114
+ return await call.run(openai_v1_chat_complete.handle, payload)
115
+
116
+ @router.post("/v1/responses")
117
+ async def create_response(body: ResponseCreateRequest, authorization: str | None = Header(default=None)):
118
+ identity = require_identity(authorization)
119
+ payload = body.model_dump(mode="python")
120
+ model = str(payload.get("model") or "auto")
121
+ request_preview = request_text(payload.get("input"), payload.get("instructions"))
122
+ call = LoggedCall(identity, "/v1/responses", model, "Responses", request_text=request_preview)
123
+ await filter_or_log(call, request_preview)
124
+ return await call.run(openai_v1_response.handle, payload)
125
+
126
+ @router.post("/v1/messages")
127
+ async def create_message(
128
+ body: AnthropicMessageRequest,
129
+ authorization: str | None = Header(default=None),
130
+ x_api_key: str | None = Header(default=None, alias="x-api-key"),
131
+ anthropic_version: str | None = Header(default=None, alias="anthropic-version"),
132
+ ):
133
+ identity = require_identity(authorization or (f"Bearer {x_api_key}" if x_api_key else None))
134
+ payload = body.model_dump(mode="python")
135
+ model = str(payload.get("model") or "auto")
136
+ request_preview = request_text(payload.get("system"), payload.get("messages"), payload.get("tools"))
137
+ call = LoggedCall(identity, "/v1/messages", model, "Messages", request_text=request_preview)
138
+ await filter_or_log(call, request_preview)
139
+ return await call.run(anthropic_v1_messages.handle, payload, sse="anthropic")
140
+
141
+ return router
api/app.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from contextlib import asynccontextmanager
4
+ from threading import Event
5
+
6
+ from fastapi import FastAPI, HTTPException
7
+ from fastapi.middleware.cors import CORSMiddleware
8
+ from fastapi.responses import FileResponse
9
+
10
+ from api import accounts, ai, image_tasks, register, system
11
+ from api.errors import install_exception_handlers
12
+ from api.support import resolve_web_asset, start_limited_account_watcher
13
+ from services.backup_service import backup_service
14
+ from services.config import config
15
+ from services.image_service import start_image_cleanup_scheduler
16
+
17
+
18
+ def create_app() -> FastAPI:
19
+ app_version = config.app_version
20
+
21
+ @asynccontextmanager
22
+ async def lifespan(_: FastAPI):
23
+ stop_event = Event()
24
+ thread = start_limited_account_watcher(stop_event)
25
+ cleanup_thread = start_image_cleanup_scheduler(stop_event)
26
+ backup_service.start()
27
+ config.cleanup_old_images()
28
+ try:
29
+ yield
30
+ finally:
31
+ stop_event.set()
32
+ thread.join(timeout=1)
33
+ cleanup_thread.join(timeout=1)
34
+ backup_service.stop()
35
+
36
+ app = FastAPI(title="chatgpt2api", version=app_version, lifespan=lifespan)
37
+ install_exception_handlers(app)
38
+ app.add_middleware(
39
+ CORSMiddleware,
40
+ allow_origins=["*"],
41
+ allow_credentials=False,
42
+ allow_methods=["*"],
43
+ allow_headers=["*"],
44
+ )
45
+ app.include_router(ai.create_router())
46
+ app.include_router(accounts.create_router())
47
+ app.include_router(image_tasks.create_router())
48
+ app.include_router(register.create_router())
49
+ app.include_router(system.create_router(app_version))
50
+
51
+ @app.get("/{full_path:path}", include_in_schema=False)
52
+ async def serve_web(full_path: str):
53
+ asset = resolve_web_asset(full_path)
54
+ if asset is not None:
55
+ return FileResponse(asset)
56
+ if full_path.strip("/").startswith("_next/"):
57
+ raise HTTPException(status_code=404, detail="Not Found")
58
+ fallback = resolve_web_asset("")
59
+ if fallback is None:
60
+ raise HTTPException(status_code=404, detail="Not Found")
61
+ return FileResponse(fallback)
62
+
63
+ return app
api/errors.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from fastapi import FastAPI, Request
4
+ from fastapi.encoders import jsonable_encoder
5
+ from fastapi.exceptions import RequestValidationError
6
+ from fastapi.responses import JSONResponse
7
+ from starlette.exceptions import HTTPException as StarletteHTTPException
8
+
9
+ from services.protocol.error_response import anthropic_error_response, openai_error_response
10
+
11
+
12
+ def _is_openai_compatible_path(path: str) -> bool:
13
+ return path == "/v1" or path.startswith("/v1/")
14
+
15
+
16
+ def _is_anthropic_messages_path(path: str) -> bool:
17
+ return path == "/v1/messages"
18
+
19
+
20
+ def _compatible_error_response(
21
+ request: Request,
22
+ detail: object,
23
+ status_code: int,
24
+ headers: dict[str, str] | None = None,
25
+ ) -> JSONResponse:
26
+ if _is_anthropic_messages_path(request.url.path):
27
+ return anthropic_error_response(detail, status_code, headers=headers)
28
+ return openai_error_response(detail, status_code, headers=headers)
29
+
30
+
31
+ def install_exception_handlers(app: FastAPI) -> None:
32
+ @app.exception_handler(StarletteHTTPException)
33
+ async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse:
34
+ if _is_openai_compatible_path(request.url.path):
35
+ return _compatible_error_response(request, exc.detail, exc.status_code, exc.headers)
36
+ return JSONResponse(
37
+ status_code=exc.status_code,
38
+ content={"detail": jsonable_encoder(exc.detail)},
39
+ headers=exc.headers,
40
+ )
41
+
42
+ @app.exception_handler(RequestValidationError)
43
+ async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
44
+ if _is_openai_compatible_path(request.url.path):
45
+ return _compatible_error_response(request, exc.errors(), 422)
46
+ return JSONResponse(status_code=422, content={"detail": jsonable_encoder(exc.errors())})
api/image_inputs.py ADDED
@@ -0,0 +1,294 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import binascii
5
+ import json
6
+ import mimetypes
7
+ import re
8
+ from pathlib import PurePosixPath
9
+ from typing import Any, TypeGuard
10
+ from urllib.parse import unquote, unquote_to_bytes, urlparse
11
+
12
+ from curl_cffi import requests
13
+ from fastapi import HTTPException, Request
14
+ from fastapi.concurrency import run_in_threadpool
15
+ from starlette.datastructures import UploadFile
16
+
17
+ from services.proxy_service import proxy_settings
18
+
19
+ ImageInput = tuple[bytes, str, str]
20
+ ImageSource = str | UploadFile | ImageInput
21
+
22
+ MAX_IMAGE_REFERENCE_BYTES = 50 * 1024 * 1024
23
+ IMAGE_REFERENCE_FIELDS = {"image", "image[]", "images", "images[]", "image_url", "image_url[]"}
24
+
25
+
26
+ def _clean(value: object, default: str = "") -> str:
27
+ """清理字符串:转换为字符串并去掉首尾空白。"""
28
+ text = str(value if value is not None else default).strip()
29
+ return text or default
30
+
31
+
32
+ def _is_upload(value: object) -> TypeGuard[UploadFile]:
33
+ """识别上传文件:兼容 Starlette 表单返回的 UploadFile。"""
34
+ return isinstance(value, UploadFile)
35
+
36
+
37
+ def _parse_bool(value: object) -> bool | None:
38
+ """解析布尔字段:兼容 JSON 布尔值和表单字符串。"""
39
+ if value is None or value == "":
40
+ return None
41
+ if isinstance(value, bool):
42
+ return value
43
+ text = _clean(value).lower()
44
+ if text in {"true", "1", "yes", "y", "on"}:
45
+ return True
46
+ if text in {"false", "0", "no", "n", "off"}:
47
+ return False
48
+ raise HTTPException(status_code=400, detail={"error": "stream must be a boolean"})
49
+
50
+
51
+ def _parse_count(value: object) -> int:
52
+ """解析生成数量:保持图片接口的 1 到 4 限制。"""
53
+ try:
54
+ count = int(value or 1)
55
+ except (TypeError, ValueError) as exc:
56
+ raise HTTPException(status_code=400, detail={"error": "n must be an integer"}) from exc
57
+ if count < 1 or count > 4:
58
+ raise HTTPException(status_code=400, detail={"error": "n must be between 1 and 4"})
59
+ return count
60
+
61
+
62
+ def _payload_from_fields(fields: dict[str, Any]) -> dict[str, Any]:
63
+ """构造图片编辑载荷:从表单或 JSON 字段提取通用参数。"""
64
+ prompt = _clean(fields.get("prompt"))
65
+ if not prompt:
66
+ raise HTTPException(status_code=400, detail={"error": "prompt is required"})
67
+ payload = {
68
+ "prompt": prompt,
69
+ "model": _clean(fields.get("model"), "gpt-image-2"),
70
+ "n": _parse_count(fields.get("n")),
71
+ "size": _clean(fields.get("size")) or None,
72
+ "quality": _clean(fields.get("quality"), "auto"),
73
+ "response_format": _clean(fields.get("response_format"), "b64_json"),
74
+ "stream": _parse_bool(fields.get("stream")),
75
+ }
76
+ if "client_task_id" in fields:
77
+ payload["client_task_id"] = _clean(fields.get("client_task_id"))
78
+ return payload
79
+
80
+
81
+ def _json_reference_value(value: object) -> object:
82
+ """解析表单图片引用:支持把 images 字段写成 JSON 字符串。"""
83
+ if not isinstance(value, str):
84
+ return value
85
+ text = value.strip()
86
+ if not text or text[0] not in "[{":
87
+ return value
88
+ try:
89
+ return json.loads(text)
90
+ except json.JSONDecodeError:
91
+ return value
92
+
93
+
94
+ def _decode_base64_image(value: object, filename: str, mime_type: str) -> ImageInput:
95
+ try:
96
+ data = base64.b64decode(str(value).strip(), validate=True)
97
+ except (binascii.Error, ValueError) as exc:
98
+ raise HTTPException(status_code=400, detail={"error": "invalid base64 image data"}) from exc
99
+ if not data:
100
+ raise HTTPException(status_code=400, detail={"error": "image file is empty"})
101
+ if len(data) > MAX_IMAGE_REFERENCE_BYTES:
102
+ raise HTTPException(status_code=400, detail={"error": "image URL exceeds 50MB limit"})
103
+ return data, filename, mime_type
104
+
105
+
106
+ def _source_from_object(value: dict[str, Any]) -> list[ImageSource]:
107
+ """提取图片引用对象:支持 image_url 或 url,明确拒绝 file_id。"""
108
+ has_url = "image_url" in value or "url" in value
109
+ if value.get("file_id"):
110
+ raise HTTPException(
111
+ status_code=400,
112
+ detail={"error": "file_id image references are not supported; use image_url instead"},
113
+ )
114
+ inline = value.get("b64_json") or value.get("base64")
115
+ if inline:
116
+ filename = _clean(value.get("filename") or value.get("file_name"), "image.png")
117
+ mime_type = _clean(value.get("mime_type") or value.get("mimeType"), "image/png")
118
+ return [_decode_base64_image(inline, filename, mime_type)]
119
+ if not has_url:
120
+ raise HTTPException(status_code=400, detail={"error": "image reference must include image_url"})
121
+ image_url = value.get("image_url", value.get("url"))
122
+ if isinstance(image_url, dict):
123
+ image_url = image_url.get("url")
124
+ return _sources_from_value(image_url)
125
+
126
+
127
+ def _sources_from_value(value: object) -> list[ImageSource]:
128
+ """展开图片引用:把字符串、数组和对象统一成图片来源列表。"""
129
+ value = _json_reference_value(value)
130
+ if _is_upload(value):
131
+ return [value]
132
+ if isinstance(value, str):
133
+ text = value.strip()
134
+ if not text:
135
+ return []
136
+ if text.lower().startswith(("data:", "http://", "https://")):
137
+ return [text]
138
+ return [_decode_base64_image(text, "image.png", "image/png")]
139
+ if isinstance(value, list):
140
+ sources: list[ImageSource] = []
141
+ for item in value:
142
+ sources.extend(_sources_from_value(item))
143
+ return sources
144
+ if isinstance(value, dict):
145
+ return _source_from_object(value)
146
+ if value is None:
147
+ return []
148
+ raise HTTPException(status_code=400, detail={"error": "invalid image reference"})
149
+
150
+
151
+ def _json_image_sources(body: dict[str, Any]) -> list[ImageSource]:
152
+ """读取 JSON 图片引用:优先支持官方 images 数组字段。"""
153
+ sources: list[ImageSource] = []
154
+ for key in ("images", "image", "image_url"):
155
+ if key in body:
156
+ sources.extend(_sources_from_value(body.get(key)))
157
+ return sources
158
+
159
+
160
+ async def parse_image_edit_request(request: Request) -> tuple[dict[str, Any], list[ImageSource]]:
161
+ """解析图片编辑请求:同时支持 multipart 上传和官方 JSON 图片 URL。"""
162
+ content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
163
+ if content_type == "application/json":
164
+ try:
165
+ body = await request.json()
166
+ except json.JSONDecodeError as exc:
167
+ raise HTTPException(status_code=400, detail={"error": "invalid JSON body"}) from exc
168
+ if not isinstance(body, dict):
169
+ raise HTTPException(status_code=400, detail={"error": "JSON body must be an object"})
170
+ return _payload_from_fields(body), _json_image_sources(body)
171
+
172
+ form = await request.form()
173
+ fields: dict[str, Any] = {}
174
+ for key in ("client_task_id", "prompt", "model", "n", "size", "quality", "response_format", "stream"):
175
+ value = form.get(key)
176
+ if isinstance(value, str):
177
+ fields[key] = value
178
+ sources: list[ImageSource] = []
179
+ for key, value in form.multi_items():
180
+ if key in IMAGE_REFERENCE_FIELDS:
181
+ sources.extend(_sources_from_value(value))
182
+ return _payload_from_fields(fields), sources
183
+
184
+
185
+ def _extension_from_mime(mime_type: str) -> str:
186
+ """推导图片扩展名:把 MIME 类型转换为常见文件后缀。"""
187
+ subtype = mime_type.split("/", 1)[1].split("+", 1)[0] if "/" in mime_type else "png"
188
+ if subtype == "jpeg":
189
+ return "jpg"
190
+ return re.sub(r"[^a-z0-9]+", "", subtype.lower()) or "png"
191
+
192
+
193
+ def _safe_filename(name: str, mime_type: str, fallback: str) -> str:
194
+ """生成安全文件名:清理 URL 文件名并补齐扩展名。"""
195
+ cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", name).strip("._")
196
+ if not cleaned:
197
+ cleaned = fallback
198
+ if "." not in cleaned:
199
+ cleaned = f"{cleaned}.{_extension_from_mime(mime_type)}"
200
+ return cleaned
201
+
202
+
203
+ def _decode_data_url(url: str) -> ImageInput:
204
+ """解码 data URL:把内联图片转成标准图片输入元组。"""
205
+ header, separator, payload = url.partition(",")
206
+ if not separator:
207
+ raise HTTPException(status_code=400, detail={"error": "invalid data image URL"})
208
+ mime_type = header.split(";", 1)[0].removeprefix("data:") or "image/png"
209
+ if not mime_type.startswith("image/"):
210
+ raise HTTPException(status_code=400, detail={"error": "image_url must point to an image"})
211
+ try:
212
+ data = base64.b64decode(payload, validate=True) if ";base64" in header else unquote_to_bytes(payload)
213
+ except (binascii.Error, ValueError) as exc:
214
+ raise HTTPException(status_code=400, detail={"error": "invalid data image URL"}) from exc
215
+ if not data:
216
+ raise HTTPException(status_code=400, detail={"error": "image URL is empty"})
217
+ if len(data) > MAX_IMAGE_REFERENCE_BYTES:
218
+ raise HTTPException(status_code=400, detail={"error": "image URL exceeds 50MB limit"})
219
+ return data, f"image_url.{_extension_from_mime(mime_type)}", mime_type
220
+
221
+
222
+ def _response_mime_type(response: requests.Response, parsed_path: str) -> str:
223
+ """识别下载图片类型:优先响应头,必要时按 URL 后缀推断。"""
224
+ header_type = str(response.headers.get("content-type") or "").split(";", 1)[0].strip().lower()
225
+ guessed_type = mimetypes.guess_type(parsed_path)[0] or ""
226
+ if header_type.startswith("image/"):
227
+ return header_type
228
+ if header_type and header_type not in {"application/octet-stream", "binary/octet-stream"}:
229
+ raise HTTPException(status_code=400, detail={"error": "image_url must point to an image"})
230
+ if guessed_type.startswith("image/"):
231
+ return guessed_type
232
+ if not header_type or header_type in {"application/octet-stream", "binary/octet-stream"}:
233
+ return "image/png"
234
+ raise HTTPException(status_code=400, detail={"error": "image_url must point to an image"})
235
+
236
+
237
+ def _filename_from_url(parsed_path: str, mime_type: str) -> str:
238
+ """生成 URL 图片文件名:从链接路径提取名称并做安全化。"""
239
+ raw_name = PurePosixPath(unquote(parsed_path)).name
240
+ return _safe_filename(raw_name, mime_type, "image_url")
241
+
242
+
243
+ def _download_image_url(url: str) -> ImageInput:
244
+ """下载远程图片:把 http/https 图片链接转成标准图片输入元组。"""
245
+ source = _clean(url)
246
+ if source.startswith("data:"):
247
+ return _decode_data_url(source)
248
+ parsed = urlparse(source)
249
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
250
+ raise HTTPException(status_code=400, detail={"error": "image_url must be an http or https URL"})
251
+ try:
252
+ response = requests.get(
253
+ source,
254
+ headers={"Accept": "image/*,*/*;q=0.8", "User-Agent": "chatgpt2api image fetcher"},
255
+ timeout=60,
256
+ allow_redirects=True,
257
+ **proxy_settings.build_session_kwargs(),
258
+ )
259
+ except Exception as exc:
260
+ raise HTTPException(status_code=400, detail={"error": f"image_url fetch failed: {exc}"}) from exc
261
+ if not 200 <= response.status_code < 300:
262
+ raise HTTPException(status_code=400, detail={"error": f"image_url fetch failed: HTTP {response.status_code}"})
263
+ content_length = _clean(response.headers.get("content-length"))
264
+ if content_length and content_length.isdigit() and int(content_length) > MAX_IMAGE_REFERENCE_BYTES:
265
+ raise HTTPException(status_code=400, detail={"error": "image_url exceeds 50MB limit"})
266
+ data = response.content
267
+ if not data:
268
+ raise HTTPException(status_code=400, detail={"error": "image_url returned empty content"})
269
+ if len(data) > MAX_IMAGE_REFERENCE_BYTES:
270
+ raise HTTPException(status_code=400, detail={"error": "image_url exceeds 50MB limit"})
271
+ mime_type = _response_mime_type(response, parsed.path)
272
+ return data, _filename_from_url(parsed.path, mime_type), mime_type
273
+
274
+
275
+ async def read_image_sources(sources: list[ImageSource]) -> list[ImageInput]:
276
+ """读取图片来源:上传文件直接读取,URL 下载后统一返回图片元组。"""
277
+ images: list[ImageInput] = []
278
+ for source in sources:
279
+ if isinstance(source, tuple):
280
+ images.append(source)
281
+ continue
282
+ if _is_upload(source):
283
+ try:
284
+ image_data = await source.read()
285
+ finally:
286
+ await source.close()
287
+ if not image_data:
288
+ raise HTTPException(status_code=400, detail={"error": "image file is empty"})
289
+ images.append((image_data, source.filename or "image.png", source.content_type or "image/png"))
290
+ continue
291
+ images.append(await run_in_threadpool(_download_image_url, source))
292
+ if not images:
293
+ raise HTTPException(status_code=400, detail={"error": "image file or image_url is required"})
294
+ return images
api/image_tasks.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter, Header, HTTPException, Query, Request
4
+ from fastapi.concurrency import run_in_threadpool
5
+ from pydantic import BaseModel, Field
6
+
7
+ from api.image_inputs import parse_image_edit_request, read_image_sources
8
+ from api.support import require_identity, resolve_image_base_url
9
+ from services.content_filter import check_request
10
+ from services.image_task_service import image_task_service
11
+ from services.log_service import LoggedCall
12
+
13
+
14
+ class ImageGenerationTaskRequest(BaseModel):
15
+ client_task_id: str = Field(..., min_length=1)
16
+ prompt: str = Field(..., min_length=1)
17
+ model: str = "gpt-image-2"
18
+ size: str | None = None
19
+ quality: str = "auto"
20
+
21
+
22
+ def _parse_task_ids(value: str) -> list[str]:
23
+ return [item.strip() for item in value.split(",") if item.strip()]
24
+
25
+
26
+ async def filter_or_log(call: LoggedCall, text: str) -> None:
27
+ try:
28
+ await run_in_threadpool(check_request, text)
29
+ except HTTPException as exc:
30
+ call.log("调用失败", status="failed", error=str(exc.detail))
31
+ raise
32
+
33
+
34
+ def create_router() -> APIRouter:
35
+ router = APIRouter()
36
+
37
+ @router.get("/api/image-tasks")
38
+ async def list_image_tasks(
39
+ ids: str = Query(default=""),
40
+ authorization: str | None = Header(default=None),
41
+ ):
42
+ identity = require_identity(authorization)
43
+ return await run_in_threadpool(image_task_service.list_tasks, identity, _parse_task_ids(ids))
44
+
45
+ @router.post("/api/image-tasks/generations")
46
+ async def create_generation_task(
47
+ body: ImageGenerationTaskRequest,
48
+ request: Request,
49
+ authorization: str | None = Header(default=None),
50
+ ):
51
+ identity = require_identity(authorization)
52
+ await filter_or_log(LoggedCall(identity, "/api/image-tasks/generations", body.model, "文生图任务", request_text=body.prompt), body.prompt)
53
+ try:
54
+ return await run_in_threadpool(
55
+ image_task_service.submit_generation,
56
+ identity,
57
+ client_task_id=body.client_task_id,
58
+ prompt=body.prompt,
59
+ model=body.model,
60
+ size=body.size,
61
+ quality=body.quality,
62
+ base_url=resolve_image_base_url(request),
63
+ )
64
+ except ValueError as exc:
65
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
66
+
67
+ @router.post("/api/image-tasks/edits")
68
+ async def create_edit_task(
69
+ request: Request,
70
+ authorization: str | None = Header(default=None),
71
+ ):
72
+ identity = require_identity(authorization)
73
+ payload, image_sources = await parse_image_edit_request(request)
74
+ client_task_id = str(payload.get("client_task_id") or "").strip()
75
+ if not client_task_id:
76
+ raise HTTPException(status_code=400, detail={"error": "client_task_id is required"})
77
+ prompt = str(payload["prompt"])
78
+ model = str(payload["model"])
79
+ await filter_or_log(LoggedCall(identity, "/api/image-tasks/edits", model, "图生图任务", request_text=prompt), prompt)
80
+ images = await read_image_sources(image_sources)
81
+ try:
82
+ return await run_in_threadpool(
83
+ image_task_service.submit_edit,
84
+ identity,
85
+ client_task_id=client_task_id,
86
+ prompt=prompt,
87
+ model=model,
88
+ size=payload["size"],
89
+ quality=payload["quality"],
90
+ base_url=resolve_image_base_url(request),
91
+ images=images,
92
+ )
93
+ except ValueError as exc:
94
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
95
+
96
+ return router
api/register.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+
6
+ from fastapi import APIRouter, Header
7
+ from fastapi.responses import StreamingResponse
8
+ from pydantic import BaseModel
9
+
10
+ from api.support import require_admin
11
+ from services.register_service import register_service
12
+
13
+
14
+ class RegisterConfigRequest(BaseModel):
15
+ mail: dict | None = None
16
+ proxy: str | None = None
17
+ total: int | None = None
18
+ threads: int | None = None
19
+ mode: str | None = None
20
+ target_quota: int | None = None
21
+ target_available: int | None = None
22
+ check_interval: int | None = None
23
+
24
+
25
+ def create_router() -> APIRouter:
26
+ router = APIRouter()
27
+
28
+ @router.get("/api/register")
29
+ async def get_register_config(authorization: str | None = Header(default=None)):
30
+ require_admin(authorization)
31
+ return {"register": register_service.get()}
32
+
33
+ @router.post("/api/register")
34
+ async def update_register_config(body: RegisterConfigRequest, authorization: str | None = Header(default=None)):
35
+ require_admin(authorization)
36
+ return {"register": register_service.update(body.model_dump(exclude_none=True))}
37
+
38
+ @router.post("/api/register/start")
39
+ async def start_register(authorization: str | None = Header(default=None)):
40
+ require_admin(authorization)
41
+ return {"register": register_service.start()}
42
+
43
+ @router.post("/api/register/stop")
44
+ async def stop_register(authorization: str | None = Header(default=None)):
45
+ require_admin(authorization)
46
+ return {"register": register_service.stop()}
47
+
48
+ @router.post("/api/register/reset")
49
+ async def reset_register(authorization: str | None = Header(default=None)):
50
+ require_admin(authorization)
51
+ return {"register": register_service.reset()}
52
+
53
+ @router.get("/api/register/events")
54
+ async def register_events(token: str = ""):
55
+ require_admin(f"Bearer {token}")
56
+
57
+ async def stream():
58
+ last = ""
59
+ while True:
60
+ payload = json.dumps(register_service.get(), ensure_ascii=False)
61
+ if payload != last:
62
+ last = payload
63
+ yield f"data: {payload}\n\n"
64
+ await asyncio.sleep(0.5)
65
+
66
+ return StreamingResponse(stream(), media_type="text/event-stream")
67
+
68
+ return router
api/support.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from threading import Event, Thread
5
+
6
+ from fastapi import HTTPException, Request
7
+
8
+ from services.account_service import account_service
9
+ from services.auth_service import auth_service
10
+ from services.config import config
11
+
12
+ BASE_DIR = Path(__file__).resolve().parents[1]
13
+ WEB_DIST_DIR = BASE_DIR / "web_dist"
14
+
15
+
16
+ def extract_bearer_token(authorization: str | None) -> str:
17
+ scheme, _, value = str(authorization or "").partition(" ")
18
+ if scheme.lower() != "bearer" or not value.strip():
19
+ return ""
20
+ return value.strip()
21
+
22
+
23
+ def _legacy_admin_identity(token: str) -> dict[str, object] | None:
24
+ auth_key = str(config.auth_key or "").strip()
25
+ if auth_key and token == auth_key:
26
+ return {"id": "admin", "name": "管理员", "role": "admin"}
27
+ return None
28
+
29
+
30
+ def require_identity(authorization: str | None) -> dict[str, object]:
31
+ token = extract_bearer_token(authorization)
32
+ identity = _legacy_admin_identity(token) or auth_service.authenticate(token)
33
+ if identity is None:
34
+ raise HTTPException(status_code=401, detail={"error": "密钥无效或已失效,请重新登录"})
35
+ return identity
36
+
37
+
38
+ def require_auth_key(authorization: str | None) -> None:
39
+ require_identity(authorization)
40
+
41
+
42
+ def require_admin(authorization: str | None) -> dict[str, object]:
43
+ identity = require_identity(authorization)
44
+ if identity.get("role") != "admin":
45
+ raise HTTPException(status_code=403, detail={"error": "需要管理员权限才能执行这个操作"})
46
+ return identity
47
+
48
+
49
+ def resolve_image_base_url(request: Request) -> str:
50
+ return config.base_url or f"{request.url.scheme}://{request.headers.get('host', request.url.netloc)}"
51
+
52
+
53
+ def raise_image_quota_error(exc: Exception) -> None:
54
+ message = str(exc)
55
+ if "no available image quota" in message.lower():
56
+ raise HTTPException(status_code=429, detail={"error": "no available image quota"}) from exc
57
+ raise HTTPException(status_code=502, detail={"error": message}) from exc
58
+
59
+
60
+ def sanitize_cpa_pool(pool: dict | None) -> dict | None:
61
+ if not isinstance(pool, dict):
62
+ return None
63
+ return {key: value for key, value in pool.items() if key != "secret_key"}
64
+
65
+
66
+ def sanitize_cpa_pools(pools: list[dict]) -> list[dict]:
67
+ return [sanitized for pool in pools if (sanitized := sanitize_cpa_pool(pool)) is not None]
68
+
69
+
70
+ def sanitize_sub2api_server(server: dict | None) -> dict | None:
71
+ if not isinstance(server, dict):
72
+ return None
73
+ sanitized = {key: value for key, value in server.items() if key not in {"password", "api_key"}}
74
+ sanitized["has_api_key"] = bool(str(server.get("api_key") or "").strip())
75
+ return sanitized
76
+
77
+
78
+ def sanitize_sub2api_servers(servers: list[dict]) -> list[dict]:
79
+ return [sanitized for server in servers if (sanitized := sanitize_sub2api_server(server)) is not None]
80
+
81
+
82
+ def start_limited_account_watcher(stop_event: Event) -> Thread:
83
+ interval_seconds = config.refresh_account_interval_minute * 60
84
+
85
+ def worker() -> None:
86
+ while not stop_event.is_set():
87
+ try:
88
+ limited_tokens = account_service.list_limited_tokens()
89
+ expiring_tokens = account_service.list_expiring_access_tokens()
90
+ keepalive_tokens = account_service.list_refresh_token_keepalive_tokens()
91
+ tokens = list(dict.fromkeys([*limited_tokens, *expiring_tokens]))
92
+ expiring_token_set = set(expiring_tokens)
93
+ keepalive_tokens = [token for token in keepalive_tokens if token not in expiring_token_set]
94
+ if tokens:
95
+ print(
96
+ "[account-watcher] checking "
97
+ f"{len(limited_tokens)} limited accounts, "
98
+ f"{len(expiring_tokens)} expiring access tokens"
99
+ )
100
+ account_service.refresh_accounts(tokens)
101
+ if keepalive_tokens:
102
+ print(f"[account-watcher] keepalive {len(keepalive_tokens)} refresh tokens")
103
+ result = account_service.keepalive_refresh_tokens(keepalive_tokens)
104
+ if result.get("errors"):
105
+ print(f"[account-watcher] keepalive errors: {result['errors']}")
106
+ except Exception as exc:
107
+ print(f"[account-watcher] fail {exc}")
108
+ stop_event.wait(interval_seconds)
109
+
110
+ thread = Thread(target=worker, name="account-watcher", daemon=True)
111
+ thread.start()
112
+ return thread
113
+
114
+
115
+ def resolve_web_asset(requested_path: str) -> Path | None:
116
+ if not WEB_DIST_DIR.exists():
117
+ return None
118
+ clean_path = requested_path.strip("/")
119
+ base_dir = WEB_DIST_DIR.resolve()
120
+ candidates = [base_dir / "index.html"] if not clean_path else [
121
+ base_dir / Path(clean_path),
122
+ base_dir / clean_path / "index.html",
123
+ base_dir / f"{clean_path}.html",
124
+ ]
125
+ for candidate in candidates:
126
+ try:
127
+ candidate.resolve().relative_to(base_dir)
128
+ except ValueError:
129
+ continue
130
+ if candidate.is_file():
131
+ return candidate
132
+ return None
api/system.py ADDED
@@ -0,0 +1,334 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from urllib.parse import quote
4
+
5
+ from fastapi import APIRouter, Header, HTTPException, Query, Request
6
+ from fastapi.concurrency import run_in_threadpool
7
+ from fastapi.responses import HTMLResponse, Response, StreamingResponse
8
+ from pydantic import BaseModel, ConfigDict
9
+
10
+ from api.support import require_admin, require_identity, resolve_image_base_url
11
+ from services.backup_service import BackupError, backup_service
12
+ from services.config import config
13
+ from services.image_service import (
14
+ compress_images,
15
+ delete_images,
16
+ delete_to_target,
17
+ download_images_zip,
18
+ get_image_download_response,
19
+ get_image_response,
20
+ get_thumbnail_response,
21
+ list_images,
22
+ storage_stats,
23
+ )
24
+ from services.image_storage_service import ImageStorageError, image_storage_service
25
+ from services.image_tags_service import delete_tag, get_all_tags, set_tags
26
+ from services.log_service import log_service
27
+ from services.proxy_service import test_proxy
28
+
29
+
30
+ class SettingsUpdateRequest(BaseModel):
31
+ model_config = ConfigDict(extra="allow")
32
+
33
+
34
+ class ProxyTestRequest(BaseModel):
35
+ url: str = ""
36
+
37
+
38
+ class ImageDeleteRequest(BaseModel):
39
+ paths: list[str] = []
40
+ start_date: str = ""
41
+ end_date: str = ""
42
+ all_matching: bool = False
43
+
44
+ class ImageDownloadRequest(BaseModel):
45
+ paths: list[str]
46
+
47
+ class ImageTagsRequest(BaseModel):
48
+ path: str
49
+ tags: list[str]
50
+
51
+ class LogDeleteRequest(BaseModel):
52
+ ids: list[str] = []
53
+ class BackupDeleteRequest(BaseModel):
54
+ key: str = ""
55
+
56
+
57
+ def create_router(app_version: str) -> APIRouter:
58
+ router = APIRouter()
59
+
60
+ @router.post("/auth/login")
61
+ async def login(authorization: str | None = Header(default=None)):
62
+ identity = require_identity(authorization)
63
+ return {
64
+ "ok": True,
65
+ "version": app_version,
66
+ "role": identity.get("role"),
67
+ "subject_id": identity.get("id"),
68
+ "name": identity.get("name"),
69
+ }
70
+
71
+ @router.get("/version")
72
+ async def get_version():
73
+ return {"version": app_version}
74
+
75
+ @router.get("/api/settings")
76
+ async def get_settings(authorization: str | None = Header(default=None)):
77
+ require_admin(authorization)
78
+ return {"config": config.get()}
79
+
80
+ @router.post("/api/settings")
81
+ async def save_settings(body: SettingsUpdateRequest, authorization: str | None = Header(default=None)):
82
+ require_admin(authorization)
83
+ try:
84
+ return {"config": config.update(body.model_dump(mode="python"))}
85
+ except ValueError as exc:
86
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
87
+
88
+ @router.get("/api/images")
89
+ async def get_images(request: Request, start_date: str = "", end_date: str = "", authorization: str | None = Header(default=None)):
90
+ require_admin(authorization)
91
+ return list_images(resolve_image_base_url(request), start_date=start_date.strip(), end_date=end_date.strip())
92
+
93
+ @router.get("/images/{image_path:path}", include_in_schema=False)
94
+ async def get_image(image_path: str):
95
+ return get_image_response(image_path)
96
+
97
+ @router.get("/image-thumbnails/{image_path:path}", include_in_schema=False)
98
+ async def get_image_thumbnail(image_path: str):
99
+ return get_thumbnail_response(image_path)
100
+
101
+ @router.post("/api/images/delete")
102
+ async def delete_images_endpoint(body: ImageDeleteRequest, authorization: str | None = Header(default=None)):
103
+ require_admin(authorization)
104
+ return delete_images(body.paths, start_date=body.start_date.strip(), end_date=body.end_date.strip(), all_matching=body.all_matching)
105
+
106
+ @router.post("/api/images/download")
107
+ async def download_images_endpoint(body: ImageDownloadRequest, authorization: str | None = Header(default=None)):
108
+ require_admin(authorization)
109
+ buf = download_images_zip(body.paths)
110
+ return StreamingResponse(
111
+ buf,
112
+ media_type="application/zip",
113
+ headers={"Content-Disposition": 'attachment; filename="images.zip"'},
114
+ )
115
+
116
+ @router.get("/api/images/download/{image_path:path}")
117
+ async def download_single_image_endpoint(image_path: str, authorization: str | None = Header(default=None)):
118
+ require_admin(authorization)
119
+ return get_image_download_response(image_path)
120
+
121
+ @router.get("/api/logs")
122
+ async def get_logs(type: str = "", start_date: str = "", end_date: str = "", authorization: str | None = Header(default=None)):
123
+ require_admin(authorization)
124
+ return {"items": log_service.list(type=type.strip(), start_date=start_date.strip(), end_date=end_date.strip())}
125
+
126
+ @router.post("/api/logs/delete")
127
+ async def delete_logs(body: LogDeleteRequest, authorization: str | None = Header(default=None)):
128
+ require_admin(authorization)
129
+ return log_service.delete(body.ids)
130
+
131
+ @router.post("/api/proxy/test")
132
+ async def test_proxy_endpoint(body: ProxyTestRequest, authorization: str | None = Header(default=None)):
133
+ require_admin(authorization)
134
+ candidate = (body.url or "").strip() or config.get_proxy_settings()
135
+ if not candidate:
136
+ raise HTTPException(status_code=400, detail={"error": "proxy url is required"})
137
+ return {"result": await run_in_threadpool(test_proxy, candidate)}
138
+
139
+ @router.get("/api/storage/info")
140
+ async def get_storage_info(authorization: str | None = Header(default=None)):
141
+ require_admin(authorization)
142
+ storage = config.get_storage_backend()
143
+ return {
144
+ "backend": storage.get_backend_info(),
145
+ "health": storage.health_check(),
146
+ }
147
+
148
+ @router.post("/api/backup/test")
149
+ async def test_backup_connection(authorization: str | None = Header(default=None)):
150
+ require_admin(authorization)
151
+ try:
152
+ return {"result": await run_in_threadpool(backup_service.test_connection)}
153
+ except BackupError as exc:
154
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
155
+
156
+ @router.post("/api/image-storage/test")
157
+ async def test_image_storage_endpoint(authorization: str | None = Header(default=None)):
158
+ require_admin(authorization)
159
+ return {"result": await run_in_threadpool(image_storage_service.test_webdav)}
160
+
161
+ @router.post("/api/image-storage/sync")
162
+ async def sync_image_storage_endpoint(authorization: str | None = Header(default=None)):
163
+ require_admin(authorization)
164
+ try:
165
+ return {"result": await run_in_threadpool(image_storage_service.sync_all)}
166
+ except ImageStorageError as exc:
167
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
168
+
169
+ @router.get("/api/backups")
170
+ async def get_backups(authorization: str | None = Header(default=None)):
171
+ require_admin(authorization)
172
+ try:
173
+ return {
174
+ "items": await run_in_threadpool(backup_service.list_backups),
175
+ "state": backup_service.get_status(),
176
+ "settings": backup_service.get_settings(),
177
+ }
178
+ except BackupError as exc:
179
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
180
+
181
+ @router.post("/api/backups/run")
182
+ async def run_backup_endpoint(authorization: str | None = Header(default=None)):
183
+ require_admin(authorization)
184
+ try:
185
+ return {"result": await run_in_threadpool(backup_service.run_backup)}
186
+ except BackupError as exc:
187
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
188
+
189
+ @router.post("/api/backups/delete")
190
+ async def delete_backup_endpoint(body: BackupDeleteRequest, authorization: str | None = Header(default=None)):
191
+ require_admin(authorization)
192
+ try:
193
+ await run_in_threadpool(backup_service.delete_backup, body.key)
194
+ return {"ok": True}
195
+ except BackupError as exc:
196
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
197
+
198
+ @router.get("/api/backups/detail")
199
+ async def get_backup_detail(key: str = "", authorization: str | None = Header(default=None)):
200
+ require_admin(authorization)
201
+ try:
202
+ return {"item": await run_in_threadpool(backup_service.get_backup_detail, key)}
203
+ except BackupError as exc:
204
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
205
+
206
+ @router.get("/api/backups/download")
207
+ async def download_backup_endpoint(key: str = "", authorization: str | None = Header(default=None)):
208
+ require_admin(authorization)
209
+ try:
210
+ item = await run_in_threadpool(backup_service.download_backup, key)
211
+ except BackupError as exc:
212
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
213
+ filename = str(item.get("name") or "backup.bin")
214
+ quoted = quote(filename)
215
+ headers = {
216
+ "Content-Disposition": f"attachment; filename*=UTF-8''{quoted}",
217
+ "Content-Length": str(int(item.get("size") or 0)),
218
+ }
219
+ return Response(
220
+ content=bytes(item.get("payload") or b""),
221
+ media_type=str(item.get("content_type") or "application/octet-stream"),
222
+ headers=headers,
223
+ )
224
+
225
+
226
+ @router.get("/api/images/tags")
227
+ async def list_image_tags(authorization: str | None = Header(default=None)):
228
+ require_admin(authorization)
229
+ return {"tags": get_all_tags()}
230
+
231
+ @router.post("/api/images/tags")
232
+ async def update_image_tags(body: ImageTagsRequest, authorization: str | None = Header(default=None)):
233
+ require_admin(authorization)
234
+ rel = body.path.strip().lstrip("/")
235
+ if not rel:
236
+ raise HTTPException(status_code=400, detail={"error": "path is required"})
237
+ tags = set_tags(rel, body.tags)
238
+ return {"ok": True, "tags": tags}
239
+
240
+ @router.delete("/api/images/tags/{tag}")
241
+ async def delete_image_tag(tag: str, authorization: str | None = Header(default=None)):
242
+ require_admin(authorization)
243
+ count = delete_tag(tag)
244
+ return {"ok": True, "removed_from": count}
245
+
246
+ @router.get("/api/images/storage")
247
+ async def get_image_storage(authorization: str | None = Header(default=None)):
248
+ require_admin(authorization)
249
+ return storage_stats()
250
+
251
+ @router.post("/api/images/storage/compress")
252
+ async def compress_all_images(authorization: str | None = Header(default=None)):
253
+ require_admin(authorization)
254
+ return await run_in_threadpool(compress_images)
255
+
256
+ @router.post("/api/images/storage/cleanup-to-target")
257
+ async def cleanup_to_target(
258
+ target_free_mb: int = 500,
259
+ dry_run: bool = False,
260
+ authorization: str | None = Header(default=None),
261
+ ):
262
+ require_admin(authorization)
263
+ return await run_in_threadpool(delete_to_target, target_free_mb, dry_run)
264
+
265
+ @router.get("/health", response_model=None)
266
+ async def health_dashboard(format: str = Query(default="html")):
267
+ from services.account_service import account_service as acct_svc
268
+ stats = acct_svc.get_stats()
269
+ storage = config.get_storage_backend()
270
+ storage_health = storage.health_check()
271
+ healthy = stats["active"] > 0 or stats["unlimited_quota_count"] > 0
272
+
273
+ stats_json = {
274
+ "status": "ok" if healthy else "degraded",
275
+ "healthy": healthy,
276
+ "version": app_version,
277
+ "storage": {"backend": storage.get_backend_info(), "health": storage_health},
278
+ "accounts": stats,
279
+ }
280
+ if format == "json":
281
+ return stats_json
282
+ return HTMLResponse(f"""<!DOCTYPE html>
283
+ <html lang="zh">
284
+ <head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
285
+ <title>号池健康监控 - chatgpt2api</title>
286
+ <style>
287
+ *{{margin:0;padding:0;box-sizing:border-box}}
288
+ body{{font-family:system-ui,-apple-system,sans-serif;background:#0f1117;color:#e2e8f0;min-height:100vh}}
289
+ .header{{background:#1a1d27;border-bottom:1px solid #2a2d3a;padding:16px 24px;display:flex;justify-content:space-between;align-items:center}}
290
+ .header h1{{font-size:20px}}
291
+ .status-dot{{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:8px}}
292
+ .status-ok{{background:#22c55e;box-shadow:0 0 8px #22c55e88}}
293
+ .status-degraded{{background:#f59e0b;box-shadow:0 0 8px #f59e0b88}}
294
+ .container{{max-width:960px;margin:0 auto;padding:24px}}
295
+ .cards{{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:24px}}
296
+ .card{{background:#1a1d27;border:1px solid #2a2d3a;border-radius:10px;padding:16px}}
297
+ .card .value{{font-size:28px;font-weight:700;margin:4px 0}}
298
+ .card .label{{font-size:13px;color:#94a3b8}}
299
+ .green{{color:#22c55e}}.yellow{{color:#f59e0b}}.red{{color:#ef4444}}.blue{{color:#6c63ff}}
300
+ table{{width:100%;border-collapse:collapse;background:#1a1d27;border:1px solid #2a2d3a;border-radius:10px;overflow:hidden}}
301
+ th{{background:#242836;font-weight:600;text-align:left;padding:10px 12px;font-size:12px;color:#94a3b8;text-transform:uppercase}}
302
+ td{{padding:8px 12px;border-top:1px solid #2a2d3a;font-size:14px}}tr:hover td{{background:rgba(108,99,255,.05)}}
303
+ .api-url{{font-family:monospace;font-size:12px;color:#6c63ff}}
304
+ .refresh{{font-size:12px;color:#64748b;text-align:center;margin-top:24px}}
305
+ </style>
306
+ <meta http-equiv="refresh" content="30">
307
+ </head>
308
+ <body>
309
+ <div class="header">
310
+ <h1><span class="status-dot {'status-ok' if healthy else 'status-degraded'}"></span>号池健康监控</h1>
311
+ <div style="font-size:13px;color:#94a3b8">v{app_version} · 30s 自动刷新</div>
312
+ </div>
313
+ <div class="container">
314
+ <div class="cards">
315
+ <div class="card"><div class="label">号池状态</div><div class="value {'green' if healthy else 'yellow'}">{'正常' if healthy else '异常'}</div></div>
316
+ <div class="card"><div class="label">当前账号</div><div class="value blue">{stats['total']}</div></div>
317
+ <div class="card"><div class="label">累计入库</div><div class="value">{stats['cumulative_total']}</div></div>
318
+ <div class="card"><div class="label">可用账号</div><div class="value green">{stats['active']}</div></div>
319
+ <div class="card"><div class="label">无限额</div><div class="value">{stats['unlimited_quota_count']}</div></div>
320
+ <div class="card"><div class="label">剩余额度</div><div class="value">{stats['total_quota']}</div></div>
321
+ <div class="card"><div class="label">限流</div><div class="value yellow">{stats['limited']}</div></div>
322
+ <div class="card"><div class="label">异常</div><div class="value red">{stats['abnormal']}</div></div>
323
+ <div class="card"><div class="label">禁用</div><div class="value">{stats['disabled']}</div></div>
324
+ <div class="card"><div class="label">成功/失败</div><div class="value">{stats['total_success']}<span style="font-size:18px;color:#94a3b8">/</span><span class="red">{stats['total_fail']}</span></div></div>
325
+ </div>
326
+ <h2 style="margin-bottom:12px;font-size:16px">账号类型分布</h2>
327
+ <table>
328
+ <tr><th>类型</th><th>数量</th></tr>
329
+ {''.join(f'<tr><td>{t}</td><td>{c}</td></tr>' for t,c in sorted(stats['by_type'].items()))}
330
+ </table>
331
+ <div class="refresh">JSON: <span class="api-url">/health?format=json</span></div>
332
+ </div></body></html>""")
333
+
334
+ return router
config.json ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "auth-key": "chatgpt2api",
3
+ "refresh_account_interval_minute": 60,
4
+ "image_retention_days": 15,
5
+ "image_poll_timeout_secs": 120,
6
+ "auto_remove_rate_limited_accounts": false,
7
+ "auto_remove_invalid_accounts": true,
8
+ "log_levels": [
9
+ "debug",
10
+ "error",
11
+ "info",
12
+ "warning"
13
+ ],
14
+ "proxy": "",
15
+ "base_url": "",
16
+ "sensitive_words": [],
17
+ "global_system_prompt": "",
18
+ "ai_review": {
19
+ "enabled": false,
20
+ "base_url": "",
21
+ "api_key": "",
22
+ "model": "",
23
+ "prompt": ""
24
+ },
25
+ "backup": {
26
+ "enabled": false,
27
+ "provider": "cloudflare_r2",
28
+ "account_id": "",
29
+ "access_key_id": "",
30
+ "secret_access_key": "",
31
+ "bucket": "",
32
+ "prefix": "backups",
33
+ "interval_minutes": 1440,
34
+ "rotation_keep": 10,
35
+ "encrypt": false,
36
+ "passphrase": "",
37
+ "include": {
38
+ "config": true,
39
+ "register": true,
40
+ "cpa": true,
41
+ "sub2api": true,
42
+ "logs": true,
43
+ "image_tasks": true,
44
+ "accounts_snapshot": true,
45
+ "auth_keys_snapshot": true,
46
+ "images": false
47
+ }
48
+ },
49
+ "image_account_concurrency": 3
50
+ }
main.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import uvicorn
4
+ from api import create_app
5
+
6
+ app = create_app()
7
+
8
+ if __name__ == "__main__":
9
+ uvicorn.run(app, access_log=False, log_level="info")
pyproject.toml ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "chatgpt2api"
3
+ version = "0.1.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ dependencies = [
8
+ "curl-cffi>=0.15.0",
9
+ "fastapi>=0.136.0",
10
+ "pillow>=12.2.0",
11
+ "pybase64>=1.4.3",
12
+ "python-multipart>=0.0.26",
13
+ "tiktoken>=0.12.0",
14
+ "uvicorn>=0.44.0",
15
+ "sqlalchemy>=2.0.0",
16
+ "psycopg2-binary>=2.9.0",
17
+ "gitpython>=3.1.0",
18
+ ]
19
+
20
+ [dependency-groups]
21
+ dev = [
22
+ "httpx>=0.28.1",
23
+ ]
24
+
25
+ [[tool.uv.index]]
26
+ url = "https://mirrors.aliyun.com/pypi/simple"
27
+ default = true
scripts/migrate_storage.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ 存储后端数据迁移脚本
4
+
5
+ 用法:
6
+ python scripts/migrate_storage.py --from json --to postgres
7
+ python scripts/migrate_storage.py --from postgres --to git
8
+ python scripts/migrate_storage.py --export accounts.json
9
+ python scripts/migrate_storage.py --import accounts.json
10
+ """
11
+
12
+ import argparse
13
+ import json
14
+ import os
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ # 添加项目根目录到 Python 路径
19
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
20
+
21
+ DATA_DIR = Path(__file__).resolve().parents[1] / "data"
22
+
23
+ from services.storage.factory import create_storage_backend
24
+
25
+
26
+ def export_to_json(output_file: str):
27
+ """导出当前存储后端的数据到 JSON 文件"""
28
+ print(f"[migrate] Exporting data to {output_file}")
29
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
30
+ storage = create_storage_backend(DATA_DIR)
31
+ accounts = storage.load_accounts()
32
+
33
+ output_path = Path(output_file)
34
+ output_path.parent.mkdir(parents=True, exist_ok=True)
35
+ output_path.write_text(
36
+ json.dumps(accounts, ensure_ascii=False, indent=2) + "\n",
37
+ encoding="utf-8",
38
+ )
39
+
40
+ print(f"[migrate] Exported {len(accounts)} accounts to {output_file}")
41
+
42
+
43
+ def import_from_json(input_file: str):
44
+ """从 JSON 文件导入数据到当前存储后端"""
45
+ print(f"[migrate] Importing data from {input_file}")
46
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
47
+ input_path = Path(input_file)
48
+ if not input_path.exists():
49
+ print(f"[migrate] Error: File not found: {input_file}")
50
+ sys.exit(1)
51
+
52
+ try:
53
+ accounts = json.loads(input_path.read_text(encoding="utf-8"))
54
+ if not isinstance(accounts, list):
55
+ print(f"[migrate] Error: Invalid JSON format, expected array")
56
+ sys.exit(1)
57
+ except json.JSONDecodeError as e:
58
+ print(f"[migrate] Error: Invalid JSON: {e}")
59
+ sys.exit(1)
60
+
61
+ storage = create_storage_backend(DATA_DIR)
62
+ storage.save_accounts(accounts)
63
+
64
+ print(f"[migrate] Imported {len(accounts)} accounts")
65
+
66
+
67
+ def migrate_data(from_backend: str, to_backend: str):
68
+ """从一个存储后端迁移到另一个"""
69
+ print(f"[migrate] Migrating from {from_backend} to {to_backend}")
70
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
71
+ # 保存原始环境变量
72
+ original_backend = os.environ.get("STORAGE_BACKEND")
73
+
74
+ try:
75
+ # 从源后端读取数据
76
+ os.environ["STORAGE_BACKEND"] = from_backend
77
+ from_storage = create_storage_backend(DATA_DIR)
78
+ accounts = from_storage.load_accounts()
79
+ print(f"[migrate] Loaded {len(accounts)} accounts from {from_backend}")
80
+
81
+ # 写入目标后端
82
+ os.environ["STORAGE_BACKEND"] = to_backend
83
+ to_storage = create_storage_backend(DATA_DIR)
84
+ to_storage.save_accounts(accounts)
85
+ print(f"[migrate] Saved {len(accounts)} accounts to {to_backend}")
86
+
87
+ print(f"[migrate] Migration completed successfully!")
88
+
89
+ finally:
90
+ # 恢复原始环境变量
91
+ if original_backend:
92
+ os.environ["STORAGE_BACKEND"] = original_backend
93
+ elif "STORAGE_BACKEND" in os.environ:
94
+ del os.environ["STORAGE_BACKEND"]
95
+
96
+
97
+ def main():
98
+ parser = argparse.ArgumentParser(
99
+ description="ChatGPT2API 存储后端数据迁移工具",
100
+ formatter_class=argparse.RawDescriptionHelpFormatter,
101
+ epilog="""
102
+ 示例:
103
+ # 从 JSON 迁移到 PostgreSQL
104
+ python scripts/migrate_storage.py --from json --to postgres
105
+
106
+ # 从 PostgreSQL 迁移到 Git
107
+ python scripts/migrate_storage.py --from postgres --to git
108
+
109
+ # 导出当前数据到 JSON 文件
110
+ python scripts/migrate_storage.py --export backup.json
111
+
112
+ # 从 JSON 文件导入数据
113
+ python scripts/migrate_storage.py --import backup.json
114
+
115
+ 环境变量:
116
+ STORAGE_BACKEND - 存储后端类型 (json, sqlite, postgres, git)
117
+ DATABASE_URL - 数据库连接字符串
118
+ GIT_REPO_URL - Git 仓库地址
119
+ GIT_TOKEN - Git 访问令牌
120
+ """
121
+ )
122
+
123
+ parser.add_argument(
124
+ "--from",
125
+ dest="from_backend",
126
+ choices=["json", "sqlite", "postgres", "git"],
127
+ help="源存储后端",
128
+ )
129
+ parser.add_argument(
130
+ "--to",
131
+ dest="to_backend",
132
+ choices=["json", "sqlite", "postgres", "git"],
133
+ help="目标存储后端",
134
+ )
135
+ parser.add_argument(
136
+ "--export",
137
+ dest="export_file",
138
+ metavar="FILE",
139
+ help="导出数据到 JSON 文件",
140
+ )
141
+ parser.add_argument(
142
+ "--import",
143
+ dest="import_file",
144
+ metavar="FILE",
145
+ help="从 JSON 文件导入数据",
146
+ )
147
+
148
+ args = parser.parse_args()
149
+
150
+ # 检查参数
151
+ if args.from_backend and args.to_backend:
152
+ migrate_data(args.from_backend, args.to_backend)
153
+ elif args.export_file:
154
+ export_to_json(args.export_file)
155
+ elif args.import_file:
156
+ import_from_json(args.import_file)
157
+ else:
158
+ parser.print_help()
159
+ sys.exit(1)
160
+
161
+
162
+ if __name__ == "__main__":
163
+ main()
scripts/test_storage.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ 存储后端测试脚本
4
+
5
+ 用法:
6
+ python scripts/test_storage.py
7
+ """
8
+
9
+ import os
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ # 添加项目根目录到 Python 路径
14
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
15
+
16
+ DATA_DIR = Path(__file__).resolve().parents[1] / "data"
17
+
18
+ from services.storage.factory import create_storage_backend
19
+
20
+
21
+ def test_storage():
22
+ """测试当前配置的存储后端"""
23
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
24
+ print("=" * 60)
25
+ print("ChatGPT2API 存储后端测试")
26
+ print("=" * 60)
27
+
28
+ # 显示当前配置
29
+ backend_type = os.getenv("STORAGE_BACKEND", "json")
30
+ print(f"\n当前存储后端: {backend_type}")
31
+
32
+ if backend_type in ("sqlite", "postgres", "postgresql", "mysql", "database"):
33
+ database_url = os.getenv("DATABASE_URL", "")
34
+ if database_url:
35
+ # 隐藏密码
36
+ if "://" in database_url and "@" in database_url:
37
+ protocol, rest = database_url.split("://", 1)
38
+ if "@" in rest:
39
+ credentials, host = rest.split("@", 1)
40
+ if ":" in credentials:
41
+ username, _ = credentials.split(":", 1)
42
+ database_url = f"{protocol}://{username}:****@{host}"
43
+ print(f"数据库连接: {database_url}")
44
+ else:
45
+ print(f"数据库连接: 本地 SQLite (data/accounts.db)")
46
+
47
+ elif backend_type == "git":
48
+ repo_url = os.getenv("GIT_REPO_URL", "")
49
+ branch = os.getenv("GIT_BRANCH", "main")
50
+ file_path = os.getenv("GIT_FILE_PATH", "accounts.json")
51
+ print(f"Git 仓库: {repo_url}")
52
+ print(f"Git 分支: {branch}")
53
+ print(f"文件路径: {file_path}")
54
+
55
+ print("\n" + "=" * 60)
56
+
57
+ try:
58
+ # 创建存储后端
59
+ print("\n[1/5] 创建存储后端...")
60
+ storage = create_storage_backend(DATA_DIR)
61
+ print("✅ 存储后端创建成功")
62
+
63
+ # 获取后端信息
64
+ print("\n[2/5] 获取后端信息...")
65
+ info = storage.get_backend_info()
66
+ print(f"✅ 后端类型: {info.get('type')}")
67
+ print(f" 描述: {info.get('description')}")
68
+ for key, value in info.items():
69
+ if key not in ('type', 'description'):
70
+ print(f" {key}: {value}")
71
+
72
+ # 健康检查
73
+ print("\n[3/5] 执行健康检查...")
74
+ health = storage.health_check()
75
+ status = health.get("status")
76
+ if status == "healthy":
77
+ print(f"✅ 健康状态: {status}")
78
+ else:
79
+ print(f"❌ 健康状态: {status}")
80
+ print(f" 错误: {health.get('error')}")
81
+ return False
82
+
83
+ # 读取数据
84
+ print("\n[4/5] 读取账号数据...")
85
+ accounts = storage.load_accounts()
86
+ print(f"✅ 成功读取 {len(accounts)} 个账号")
87
+
88
+ # 写入测试(可选)
89
+ print("\n[5/5] 测试写入功能...")
90
+ test_account = {
91
+ "access_token": "test_token_" + str(os.getpid()),
92
+ "type": "Free",
93
+ "status": "测试",
94
+ "quota": 0,
95
+ "email": "test@example.com",
96
+ }
97
+
98
+ # 添加测试账号
99
+ test_accounts = accounts + [test_account]
100
+ storage.save_accounts(test_accounts)
101
+ print("✅ 写入测试账号成功")
102
+
103
+ # 验证写入
104
+ reloaded = storage.load_accounts()
105
+ if len(reloaded) == len(test_accounts):
106
+ print("✅ 验证写入成功")
107
+ else:
108
+ print(f"❌ 验证失败: 期望 {len(test_accounts)} 个账号,实际 {len(reloaded)} 个")
109
+ return False
110
+
111
+ # 恢复原始数据
112
+ storage.save_accounts(accounts)
113
+ print("✅ 恢复原始数据")
114
+
115
+ print("\n" + "=" * 60)
116
+ print("✅ 所有测试通过!")
117
+ print("=" * 60)
118
+ return True
119
+
120
+ except Exception as e:
121
+ print(f"\n❌ 测试失败: {e}")
122
+ import traceback
123
+ traceback.print_exc()
124
+ return False
125
+
126
+
127
+ if __name__ == "__main__":
128
+ success = test_storage()
129
+ sys.exit(0 if success else 1)
services/__init__.py ADDED
File without changes
services/account_service.py ADDED
@@ -0,0 +1,946 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from concurrent.futures import ThreadPoolExecutor, as_completed
5
+ from datetime import datetime, timedelta, timezone
6
+ from pathlib import Path
7
+ from threading import Condition, Lock
8
+ from typing import Any
9
+
10
+ from services.config import config
11
+ from services.log_service import (
12
+ LOG_TYPE_ACCOUNT,
13
+ log_service,
14
+ )
15
+ from services.storage.base import StorageBackend
16
+ from utils.helper import anonymize_token
17
+
18
+
19
+ class AccountService:
20
+ """账号池服务,使用 token -> account 的 dict 保存账号。"""
21
+
22
+ _NEW_ACCOUNT_INVALID_GRACE_SECONDS = 10 * 60
23
+ _INVALID_CONFIRM_SECONDS = 30
24
+ _ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 24 * 60 * 60
25
+ _REFRESH_TOKEN_KEEPALIVE_SECONDS = 3 * 24 * 60 * 60
26
+ _REFRESH_TOKEN_KEEPALIVE_ERROR_BACKOFF_SECONDS = 6 * 60 * 60
27
+ _REFRESH_TOKEN_KEEPALIVE_BATCH_SIZE = 3
28
+ _TOKEN_REFRESH_ERROR_BACKOFF_SECONDS = 5 * 60
29
+ _OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token"
30
+ _OAUTH_CLIENT_ID = "app_2SKx67EdpoN0G6j64rFvigXD"
31
+ _OAUTH_USER_AGENT = (
32
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
33
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
34
+ "Chrome/145.0.0.0 Safari/537.36"
35
+ )
36
+
37
+ def __init__(self, storage_backend: StorageBackend):
38
+ self.storage = storage_backend
39
+ self._lock = Lock()
40
+ self._token_refresh_lock = Lock()
41
+ self._image_slot_condition = Condition(self._lock)
42
+ self._index = 0
43
+ self._accounts = self._load_accounts()
44
+ self._image_inflight: dict[str, int] = {}
45
+ self._token_aliases: dict[str, str] = {}
46
+ self._cumulative_total = self._load_cumulative_total()
47
+
48
+ def _get_cumulative_file(self) -> Path:
49
+ from services.config import DATA_DIR
50
+ return DATA_DIR / ".cumulative_total"
51
+
52
+ def _load_cumulative_total(self) -> int:
53
+ try:
54
+ f = self._get_cumulative_file()
55
+ if f.exists():
56
+ return int(f.read_text().strip())
57
+ except Exception:
58
+ pass
59
+ return len(self._accounts)
60
+
61
+ def _save_cumulative_total(self) -> None:
62
+ try:
63
+ self._get_cumulative_file().write_text(str(self._cumulative_total))
64
+ except Exception:
65
+ pass
66
+
67
+ @staticmethod
68
+ def _now() -> str:
69
+ return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
70
+
71
+ @staticmethod
72
+ def _decode_jwt_payload(token: str) -> dict:
73
+ try:
74
+ payload = str(token or "").split(".")[1]
75
+ payload += "=" * ((4 - len(payload) % 4) % 4)
76
+ import base64
77
+ import json
78
+ data = json.loads(base64.urlsafe_b64decode(payload.encode("ascii")))
79
+ return data if isinstance(data, dict) else {}
80
+ except Exception:
81
+ return {}
82
+
83
+ @staticmethod
84
+ def _parse_time(value: object) -> datetime | None:
85
+ raw = str(value or "").strip()
86
+ if not raw:
87
+ return None
88
+ try:
89
+ parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
90
+ except Exception:
91
+ try:
92
+ parsed = datetime.strptime(raw, "%Y-%m-%d %H:%M:%S")
93
+ except Exception:
94
+ return None
95
+ if parsed.tzinfo is None:
96
+ parsed = parsed.replace(tzinfo=timezone.utc)
97
+ return parsed.astimezone(timezone.utc)
98
+
99
+ @staticmethod
100
+ def _timestamp_to_iso(value: object) -> str:
101
+ try:
102
+ ts = int(value)
103
+ except (TypeError, ValueError):
104
+ return ""
105
+ tz = timezone(timedelta(hours=8))
106
+ return datetime.fromtimestamp(ts, tz=timezone.utc).astimezone(tz).isoformat()
107
+
108
+ def _load_accounts(self) -> dict[str, dict]:
109
+ accounts = self.storage.load_accounts()
110
+ return {
111
+ normalized["access_token"]: normalized
112
+ for item in accounts
113
+ if (normalized := self._normalize_account(item)) is not None
114
+ }
115
+
116
+ def _save_accounts(self) -> None:
117
+ self.storage.save_accounts(list(self._accounts.values()))
118
+
119
+ @staticmethod
120
+ def _is_image_account_available(account: dict) -> bool:
121
+ if not isinstance(account, dict):
122
+ return False
123
+ if account.get("status") in {"禁用", "限流", "异常"}:
124
+ return False
125
+ if bool(account.get("image_quota_unknown")):
126
+ return True
127
+ return int(account.get("quota") or 0) > 0
128
+
129
+ @staticmethod
130
+ def _normalize_account_type(value: object) -> str | None:
131
+ raw = str(value or "").strip()
132
+ if not raw:
133
+ return None
134
+ key = raw.lower().replace("-", "_").replace(" ", "_")
135
+ compact = key.replace("_", "")
136
+ aliases = {
137
+ "free": "free",
138
+ "plus": "Plus",
139
+ "pro": "Pro",
140
+ "prolite": "ProLite",
141
+ "team": "Team",
142
+ "business": "Team",
143
+ "enterprise": "Enterprise",
144
+ }
145
+ return aliases.get(compact) or aliases.get(key) or raw
146
+
147
+ def _search_account_type(self, payload: object) -> str | None:
148
+ if isinstance(payload, dict):
149
+ for key in ("plan_type", "account_plan", "account_type", "subscription_type", "type"):
150
+ plan = self._normalize_account_type(payload.get(key))
151
+ if plan:
152
+ return plan
153
+ for value in payload.values():
154
+ plan = self._search_account_type(value)
155
+ if plan:
156
+ return plan
157
+ elif isinstance(payload, list):
158
+ for value in payload:
159
+ plan = self._search_account_type(value)
160
+ if plan:
161
+ return plan
162
+ return None
163
+
164
+ def _normalize_account(self, item: dict) -> dict | None:
165
+ if not isinstance(item, dict):
166
+ return None
167
+ access_token = item.get("access_token") or item.get("accessToken") or ""
168
+ if not access_token:
169
+ return None
170
+ normalized = dict(item)
171
+ normalized.pop("accessToken", None)
172
+ normalized["access_token"] = access_token
173
+ normalized["type"] = normalized.get("type") or "free"
174
+ normalized["status"] = normalized.get("status") or "正常"
175
+ normalized["quota"] = max(0, int(normalized.get("quota") if normalized.get("quota") is not None else 0))
176
+ normalized["image_quota_unknown"] = bool(normalized.get("image_quota_unknown"))
177
+ normalized["email"] = normalized.get("email") or None
178
+ normalized["user_id"] = normalized.get("user_id") or None
179
+ limits_progress = normalized.get("limits_progress")
180
+ normalized["limits_progress"] = limits_progress if isinstance(limits_progress, list) else []
181
+ normalized["default_model_slug"] = normalized.get("default_model_slug") or None
182
+ normalized["restore_at"] = normalized.get("restore_at") or None
183
+ normalized["success"] = int(normalized.get("success") or 0)
184
+ normalized["fail"] = int(normalized.get("fail") or 0)
185
+ normalized["invalid_count"] = int(normalized.get("invalid_count") or 0)
186
+ normalized["last_used_at"] = normalized.get("last_used_at")
187
+ normalized["last_invalid_at"] = normalized.get("last_invalid_at") or None
188
+ normalized["last_refresh_error"] = normalized.get("last_refresh_error") or None
189
+ normalized["last_refresh_error_at"] = normalized.get("last_refresh_error_at") or None
190
+ normalized["last_token_refresh_at"] = normalized.get("last_token_refresh_at") or None
191
+ normalized["last_token_refresh_error"] = normalized.get("last_token_refresh_error") or None
192
+ normalized["last_token_refresh_error_at"] = normalized.get("last_token_refresh_error_at") or None
193
+ normalized["created_at"] = normalized.get("created_at") or AccountService._now()
194
+ return normalized
195
+
196
+ @staticmethod
197
+ def _jwt_exp(access_token: str) -> int:
198
+ try:
199
+ return int(AccountService._decode_jwt_payload(access_token).get("exp") or 0)
200
+ except (TypeError, ValueError):
201
+ return 0
202
+
203
+ @classmethod
204
+ def _token_expires_in(cls, access_token: str) -> int | None:
205
+ exp = cls._jwt_exp(access_token)
206
+ if exp <= 0:
207
+ return None
208
+ return exp - int(time.time())
209
+
210
+ @classmethod
211
+ def _token_needs_refresh(cls, access_token: str, *, force: bool = False) -> bool:
212
+ if force:
213
+ return True
214
+ remaining = cls._token_expires_in(access_token)
215
+ return remaining is not None and remaining <= cls._ACCESS_TOKEN_REFRESH_SKEW_SECONDS
216
+
217
+ @classmethod
218
+ def _token_issued_at(cls, access_token: str) -> datetime | None:
219
+ try:
220
+ iat = int(cls._decode_jwt_payload(access_token).get("iat") or 0)
221
+ except (TypeError, ValueError):
222
+ return None
223
+ if iat <= 0:
224
+ return None
225
+ return datetime.fromtimestamp(iat, tz=timezone.utc)
226
+
227
+ @staticmethod
228
+ def _safe_response_text(response: object, limit: int = 300) -> str:
229
+ try:
230
+ return str(getattr(response, "text", "") or "")[:limit]
231
+ except Exception:
232
+ return ""
233
+
234
+ def _resolve_access_token_locked(self, access_token: str) -> str:
235
+ token = str(access_token or "").strip()
236
+ seen: set[str] = set()
237
+ while token and token not in self._accounts and token in self._token_aliases and token not in seen:
238
+ seen.add(token)
239
+ token = self._token_aliases.get(token, token)
240
+ return token
241
+
242
+ def resolve_access_token(self, access_token: str) -> str:
243
+ if not access_token:
244
+ return ""
245
+ with self._lock:
246
+ return self._resolve_access_token_locked(access_token)
247
+
248
+ def _get_account_for_token(self, access_token: str) -> tuple[str, dict | None]:
249
+ with self._lock:
250
+ resolved = self._resolve_access_token_locked(access_token)
251
+ account = self._accounts.get(resolved)
252
+ return resolved, dict(account) if account else None
253
+
254
+ def _record_token_refresh_error(self, access_token: str, event: str, error: str) -> None:
255
+ now = datetime.now(timezone.utc).isoformat()
256
+ with self._lock:
257
+ resolved = self._resolve_access_token_locked(access_token)
258
+ current = self._accounts.get(resolved)
259
+ if current is None:
260
+ return
261
+ next_item = dict(current)
262
+ next_item["last_token_refresh_error"] = str(error or "refresh token failed")
263
+ next_item["last_token_refresh_error_at"] = now
264
+ account = self._normalize_account(next_item)
265
+ if account is not None:
266
+ self._accounts[resolved] = account
267
+ self._save_accounts()
268
+ log_service.add(
269
+ LOG_TYPE_ACCOUNT,
270
+ "refresh_token 刷新 access_token 失败",
271
+ {"source": event, "token": anonymize_token(access_token), "error": str(error or "")},
272
+ )
273
+
274
+ def _recent_token_refresh_error(self, account: dict) -> bool:
275
+ last_error_at = self._parse_time(account.get("last_token_refresh_error_at"))
276
+ if last_error_at is None:
277
+ return False
278
+ return (datetime.now(timezone.utc) - last_error_at).total_seconds() < self._TOKEN_REFRESH_ERROR_BACKOFF_SECONDS
279
+
280
+ def _recent_refresh_token_keepalive_error(self, account: dict, now: datetime) -> bool:
281
+ last_error_at = self._parse_time(account.get("last_token_refresh_error_at"))
282
+ if last_error_at is None:
283
+ return False
284
+ return (now - last_error_at).total_seconds() < self._REFRESH_TOKEN_KEEPALIVE_ERROR_BACKOFF_SECONDS
285
+
286
+ def _refresh_token_keepalive_anchor(self, account: dict) -> datetime | None:
287
+ return (
288
+ self._parse_time(account.get("last_token_refresh_at"))
289
+ or self._token_issued_at(str(account.get("access_token") or ""))
290
+ or self._parse_time(account.get("created_at"))
291
+ )
292
+
293
+ def _refresh_token_keepalive_due_at(self, account: dict, now: datetime) -> datetime | None:
294
+ if not str(account.get("refresh_token") or "").strip():
295
+ return None
296
+ if account.get("status") == "禁用":
297
+ return None
298
+ if self._recent_refresh_token_keepalive_error(account, now):
299
+ return None
300
+ anchor = self._refresh_token_keepalive_anchor(account)
301
+ if anchor is None:
302
+ return now
303
+ due_at = anchor + timedelta(seconds=self._REFRESH_TOKEN_KEEPALIVE_SECONDS)
304
+ return due_at if due_at <= now else None
305
+
306
+ def _request_access_token_refresh(self, refresh_token: str) -> dict[str, str]:
307
+ from curl_cffi import requests
308
+ from services.proxy_service import proxy_settings
309
+
310
+ session = requests.Session(**proxy_settings.build_session_kwargs(impersonate="chrome", verify=True))
311
+ try:
312
+ response = session.post(
313
+ self._OAUTH_TOKEN_URL,
314
+ headers={
315
+ "Accept": "application/json",
316
+ "Content-Type": "application/x-www-form-urlencoded",
317
+ "User-Agent": self._OAUTH_USER_AGENT,
318
+ },
319
+ data={
320
+ "grant_type": "refresh_token",
321
+ "refresh_token": refresh_token,
322
+ "client_id": self._OAUTH_CLIENT_ID,
323
+ },
324
+ timeout=60,
325
+ )
326
+ data = response.json() if response.text else {}
327
+ if response.status_code != 200 or not isinstance(data, dict) or not data.get("access_token"):
328
+ detail = ""
329
+ if isinstance(data, dict):
330
+ detail = str(data.get("error_description") or data.get("error") or data.get("message") or "")
331
+ detail = detail or self._safe_response_text(response)
332
+ raise RuntimeError(f"oauth_refresh_http_{response.status_code}{': ' + detail if detail else ''}")
333
+ return {
334
+ "access_token": str(data.get("access_token") or "").strip(),
335
+ "refresh_token": str(data.get("refresh_token") or refresh_token).strip(),
336
+ "id_token": str(data.get("id_token") or "").strip(),
337
+ }
338
+ finally:
339
+ session.close()
340
+
341
+ def _apply_refreshed_tokens(self, old_access_token: str, token_data: dict, event: str) -> str:
342
+ now = datetime.now(timezone.utc).isoformat()
343
+ with self._image_slot_condition:
344
+ old_token = self._resolve_access_token_locked(old_access_token)
345
+ current = self._accounts.get(old_token)
346
+ if current is None:
347
+ return old_token
348
+ new_token = str(token_data.get("access_token") or old_token).strip()
349
+ if not new_token:
350
+ return old_token
351
+
352
+ next_item = dict(current)
353
+ next_item["access_token"] = new_token
354
+ if token_data.get("refresh_token"):
355
+ next_item["refresh_token"] = str(token_data.get("refresh_token") or "").strip()
356
+ if token_data.get("id_token"):
357
+ next_item["id_token"] = str(token_data.get("id_token") or "").strip()
358
+ next_item["last_token_refresh_at"] = now
359
+ next_item["last_token_refresh_error"] = None
360
+ next_item["last_token_refresh_error_at"] = None
361
+ next_item["invalid_count"] = 0
362
+ next_item["last_invalid_at"] = None
363
+ next_item["last_refresh_error"] = None
364
+ next_item["last_refresh_error_at"] = None
365
+
366
+ account = self._normalize_account(next_item)
367
+ if account is None:
368
+ return old_token
369
+
370
+ rotated = new_token != old_token
371
+ if rotated:
372
+ self._accounts.pop(old_token, None)
373
+ self._token_aliases[old_token] = new_token
374
+ old_inflight = int(self._image_inflight.pop(old_token, 0))
375
+ if old_inflight:
376
+ self._image_inflight[new_token] = int(self._image_inflight.get(new_token, 0)) + old_inflight
377
+ self._accounts[new_token] = account
378
+ self._save_accounts()
379
+ self._image_slot_condition.notify_all()
380
+
381
+ log_service.add(
382
+ LOG_TYPE_ACCOUNT,
383
+ "refresh_token 已刷新 access_token",
384
+ {"source": event, "token": anonymize_token(new_token), "rotated": rotated},
385
+ )
386
+ return new_token
387
+
388
+ def refresh_access_token(self, access_token: str, *, force: bool = False, event: str = "refresh_access_token") -> str:
389
+ if not access_token:
390
+ return ""
391
+ with self._token_refresh_lock:
392
+ resolved_token, account = self._get_account_for_token(access_token)
393
+ if not account:
394
+ return access_token
395
+ active_token = str(account.get("access_token") or resolved_token or access_token)
396
+ if not self._token_needs_refresh(active_token, force=force):
397
+ return active_token
398
+ refresh_token = str(account.get("refresh_token") or "").strip()
399
+ if not refresh_token:
400
+ return active_token
401
+ if not force and self._recent_token_refresh_error(account):
402
+ return active_token
403
+ try:
404
+ token_data = self._request_access_token_refresh(refresh_token)
405
+ except Exception as exc:
406
+ self._record_token_refresh_error(active_token, event, str(exc))
407
+ return active_token
408
+ return self._apply_refreshed_tokens(active_token, token_data, event)
409
+
410
+ def list_expiring_access_tokens(self) -> list[str]:
411
+ with self._lock:
412
+ return [
413
+ token
414
+ for account in self._accounts.values()
415
+ if str(account.get("refresh_token") or "").strip()
416
+ and (token := str(account.get("access_token") or "").strip())
417
+ and self._token_needs_refresh(token)
418
+ ]
419
+
420
+ def list_refresh_token_keepalive_tokens(self) -> list[str]:
421
+ now = datetime.now(timezone.utc)
422
+ due_items: list[tuple[datetime, str]] = []
423
+ with self._lock:
424
+ for account in self._accounts.values():
425
+ due_at = self._refresh_token_keepalive_due_at(account, now)
426
+ token = str(account.get("access_token") or "").strip()
427
+ if due_at is not None and token:
428
+ due_items.append((due_at, token))
429
+ due_items.sort(key=lambda item: item[0])
430
+ return [token for _, token in due_items[: self._REFRESH_TOKEN_KEEPALIVE_BATCH_SIZE]]
431
+
432
+ def keepalive_refresh_tokens(self, access_tokens: list[str]) -> dict[str, Any]:
433
+ access_tokens = list(dict.fromkeys(token for token in access_tokens if token))
434
+ if not access_tokens:
435
+ return {"refreshed": 0, "errors": [], "items": self.list_accounts()}
436
+
437
+ refreshed = 0
438
+ errors = []
439
+ for access_token in access_tokens:
440
+ before = self.resolve_access_token(access_token)
441
+ after = self.refresh_access_token(before, force=True, event="refresh_token_keepalive")
442
+ account = self.get_account(after)
443
+ if account and str(account.get("last_token_refresh_error") or "").strip():
444
+ errors.append({
445
+ "token": anonymize_token(before),
446
+ "error": str(account.get("last_token_refresh_error") or "refresh token failed"),
447
+ })
448
+ continue
449
+ if account:
450
+ refreshed += 1
451
+
452
+ return {
453
+ "refreshed": refreshed,
454
+ "errors": errors,
455
+ "items": self.list_accounts(),
456
+ }
457
+
458
+ def list_tokens(self) -> list[str]:
459
+ with self._lock:
460
+ return list(self._accounts)
461
+
462
+ def _list_ready_candidate_tokens(self, excluded_tokens: set[str] | None = None) -> list[str]:
463
+ excluded = set(excluded_tokens or set())
464
+ return [
465
+ token
466
+ for item in self._accounts.values()
467
+ if self._is_image_account_available(item)
468
+ and (token := item.get("access_token") or "")
469
+ and token not in excluded
470
+ ]
471
+
472
+ def _list_available_candidate_tokens(self, excluded_tokens: set[str] | None = None) -> list[str]:
473
+ max_concurrency = max(1, int(config.image_account_concurrency or 1))
474
+ return [
475
+ token
476
+ for token in self._list_ready_candidate_tokens(excluded_tokens)
477
+ if int(self._image_inflight.get(token, 0)) < max_concurrency
478
+ ]
479
+
480
+ def _acquire_next_candidate_token(self, excluded_tokens: set[str] | None = None) -> str:
481
+ with self._image_slot_condition:
482
+ while True:
483
+ if not self._list_ready_candidate_tokens(excluded_tokens):
484
+ raise RuntimeError("no available image quota")
485
+ tokens = self._list_available_candidate_tokens(excluded_tokens)
486
+ if tokens:
487
+ access_token = tokens[self._index % len(tokens)]
488
+ self._index += 1
489
+ self._image_inflight[access_token] = int(self._image_inflight.get(access_token, 0)) + 1
490
+ return access_token
491
+ self._image_slot_condition.wait(timeout=1.0)
492
+
493
+ def release_image_slot(self, access_token: str) -> None:
494
+ if not access_token:
495
+ return
496
+ with self._image_slot_condition:
497
+ access_token = self._resolve_access_token_locked(access_token)
498
+ current_inflight = int(self._image_inflight.get(access_token, 0))
499
+ if current_inflight <= 1:
500
+ self._image_inflight.pop(access_token, None)
501
+ else:
502
+ self._image_inflight[access_token] = current_inflight - 1
503
+ self._image_slot_condition.notify_all()
504
+
505
+ def get_available_access_token(self) -> str:
506
+ attempted_tokens: set[str] = set()
507
+ while True:
508
+ access_token = self._acquire_next_candidate_token(excluded_tokens=attempted_tokens)
509
+ attempted_tokens.add(access_token)
510
+ try:
511
+ account = self.fetch_remote_info(access_token, "get_available_access_token")
512
+ except Exception:
513
+ self.release_image_slot(access_token)
514
+ continue
515
+ if self._is_image_account_available(account or {}):
516
+ return str((account or {}).get("access_token") or access_token)
517
+ self.release_image_slot(access_token)
518
+
519
+ def get_text_access_token(self, excluded_tokens: set[str] | None = None) -> str:
520
+ excluded = set(excluded_tokens or set())
521
+ with self._lock:
522
+ candidates = [
523
+ token
524
+ for account in self._accounts.values()
525
+ if account.get("status") not in {"禁用", "异常"}
526
+ and (token := account.get("access_token") or "")
527
+ and token not in excluded
528
+ ]
529
+ if not candidates:
530
+ return ""
531
+ access_token = candidates[self._index % len(candidates)]
532
+ self._index += 1
533
+ return self.refresh_access_token(access_token, event="get_text_access_token") or access_token
534
+
535
+ def mark_text_used(self, access_token: str) -> None:
536
+ if not access_token:
537
+ return
538
+ with self._lock:
539
+ access_token = self._resolve_access_token_locked(access_token)
540
+ current = self._accounts.get(access_token)
541
+ if current is None:
542
+ return
543
+ next_item = dict(current)
544
+ next_item["last_used_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
545
+ account = self._normalize_account(next_item)
546
+ if account is None:
547
+ return
548
+ self._accounts[access_token] = account
549
+ self._save_accounts()
550
+
551
+ def remove_invalid_token(self, access_token: str, event: str) -> bool:
552
+ if not config.auto_remove_invalid_accounts:
553
+ self.update_account(access_token, {"status": "异常", "quota": 0})
554
+ return False
555
+ removed = bool(self.delete_accounts([access_token])["removed"])
556
+ if removed:
557
+ log_service.add(LOG_TYPE_ACCOUNT, "自动移除异常账号",
558
+ {"source": event, "token": anonymize_token(access_token)})
559
+ elif access_token:
560
+ self.update_account(access_token, {"status": "异常", "quota": 0})
561
+ return removed
562
+
563
+ def get_account(self, access_token: str) -> dict | None:
564
+ if not access_token:
565
+ return None
566
+ with self._lock:
567
+ access_token = self._resolve_access_token_locked(access_token)
568
+ account = self._accounts.get(access_token)
569
+ return dict(account) if account else None
570
+
571
+ def list_accounts(self) -> list[dict]:
572
+ with self._lock:
573
+ return [dict(item) for item in self._accounts.values()]
574
+
575
+ def list_limited_tokens(self) -> list[str]:
576
+ with self._lock:
577
+ return [
578
+ token
579
+ for item in self._accounts.values()
580
+ if item.get("status") == "限流"
581
+ and (token := item.get("access_token") or "")
582
+ ]
583
+
584
+ @staticmethod
585
+ def _account_payload_token(item: dict) -> str:
586
+ return str(item.get("access_token") or item.get("accessToken") or "").strip()
587
+
588
+ @staticmethod
589
+ def _prepare_account_payload(item: dict) -> dict | None:
590
+ if not isinstance(item, dict):
591
+ return None
592
+ access_token = AccountService._account_payload_token(item)
593
+ if not access_token:
594
+ return None
595
+ payload = dict(item)
596
+ payload.pop("accessToken", None)
597
+ payload["access_token"] = access_token
598
+ # CPA/Codex 导出文件里的 `type=codex` 是导出格式,不是号池套餐类型。
599
+ if str(payload.get("type") or "").strip().lower() == "codex":
600
+ payload["export_type"] = "codex"
601
+ payload.pop("type", None)
602
+ if payload.get("plan_type") and not payload.get("type"):
603
+ payload["type"] = str(payload.get("plan_type") or "").strip()
604
+ return payload
605
+
606
+ def add_account_items(self, items: list[dict]) -> dict:
607
+ payloads = [
608
+ payload
609
+ for item in items
610
+ if (payload := self._prepare_account_payload(item)) is not None
611
+ ]
612
+ return self._add_account_payloads(payloads)
613
+
614
+ def add_accounts(self, tokens: list[str]) -> dict:
615
+ tokens = list(dict.fromkeys(token for token in tokens if token))
616
+ if not tokens:
617
+ return {"added": 0, "skipped": 0, "items": self.list_accounts()}
618
+ return self._add_account_payloads([{"access_token": token} for token in tokens])
619
+
620
+ def _add_account_payloads(self, payloads: list[dict]) -> dict:
621
+ deduped: dict[str, dict] = {}
622
+ for payload in payloads:
623
+ if not isinstance(payload, dict):
624
+ continue
625
+ access_token = self._account_payload_token(payload)
626
+ if not access_token:
627
+ continue
628
+ current = deduped.get(access_token, {})
629
+ deduped[access_token] = {**current, **payload, "access_token": access_token}
630
+
631
+ if not deduped:
632
+ return {"added": 0, "skipped": 0, "items": self.list_accounts()}
633
+
634
+ with self._lock:
635
+ added = 0
636
+ skipped = 0
637
+ for access_token, payload in deduped.items():
638
+ current = self._accounts.get(access_token)
639
+ if current is None:
640
+ added += 1
641
+ self._cumulative_total += 1
642
+ self._save_cumulative_total()
643
+ current = {"created_at": self._now()}
644
+ else:
645
+ skipped += 1
646
+ incoming = dict(payload)
647
+ if not incoming.get("created_at"):
648
+ incoming.pop("created_at", None)
649
+ account = self._normalize_account(
650
+ {
651
+ **current,
652
+ **incoming,
653
+ "access_token": access_token,
654
+ "type": str(incoming.get("type") or current.get("type") or "free"),
655
+ }
656
+ )
657
+ if account is not None:
658
+ self._accounts[access_token] = account
659
+ self._save_accounts()
660
+ items = [dict(item) for item in self._accounts.values()]
661
+ log_service.add(LOG_TYPE_ACCOUNT, f"新增 {added} 个账号,跳过 {skipped} 个",
662
+ {"added": added, "skipped": skipped})
663
+ return {"added": added, "skipped": skipped, "items": items}
664
+
665
+ def delete_accounts(self, tokens: list[str]) -> dict:
666
+ target_set = set(token for token in tokens if token)
667
+ if not target_set:
668
+ return {"removed": 0, "items": self.list_accounts()}
669
+ with self._lock:
670
+ target_set = {self._resolve_access_token_locked(token) for token in target_set if token}
671
+ removed = sum(self._accounts.pop(token, None) is not None for token in target_set)
672
+ for token in target_set:
673
+ self._image_inflight.pop(token, None)
674
+ self._token_aliases = {
675
+ old: new
676
+ for old, new in self._token_aliases.items()
677
+ if old not in target_set and new not in target_set
678
+ }
679
+ if removed:
680
+ if self._accounts:
681
+ self._index %= len(self._accounts)
682
+ else:
683
+ self._index = 0
684
+ self._save_accounts()
685
+ log_service.add(LOG_TYPE_ACCOUNT, f"删除 {removed} 个账号", {"removed": removed})
686
+ items = [dict(item) for item in self._accounts.values()]
687
+ return {"removed": removed, "items": items}
688
+
689
+ def update_account(self, access_token: str, updates: dict) -> dict | None:
690
+ if not access_token:
691
+ return None
692
+ with self._lock:
693
+ access_token = self._resolve_access_token_locked(access_token)
694
+ current = self._accounts.get(access_token)
695
+ if current is None:
696
+ return None
697
+ account = self._normalize_account({**current, **updates, "access_token": access_token})
698
+ if account is None:
699
+ return None
700
+ if account.get("status") == "限流" and config.auto_remove_rate_limited_accounts:
701
+ self._accounts.pop(access_token, None)
702
+ self._save_accounts()
703
+ log_service.add(LOG_TYPE_ACCOUNT, "自动移除限流账号", {"token": anonymize_token(access_token)})
704
+ return None
705
+ self._accounts[access_token] = account
706
+ self._save_accounts()
707
+ log_service.add(LOG_TYPE_ACCOUNT, "更新账号",
708
+ {"token": anonymize_token(access_token), "status": account.get("status")})
709
+ return dict(account)
710
+ return None
711
+
712
+ def _record_refresh_success(self, access_token: str) -> None:
713
+ with self._lock:
714
+ access_token = self._resolve_access_token_locked(access_token)
715
+ current = self._accounts.get(access_token)
716
+ if current is None:
717
+ return
718
+ next_item = dict(current)
719
+ next_item["invalid_count"] = 0
720
+ next_item["last_invalid_at"] = None
721
+ next_item["last_refresh_error"] = None
722
+ next_item["last_refresh_error_at"] = None
723
+ account = self._normalize_account(next_item)
724
+ if account is not None:
725
+ self._accounts[access_token] = account
726
+
727
+ def _should_defer_invalid_token(self, account: dict | None, now: datetime) -> bool:
728
+ if not isinstance(account, dict):
729
+ return False
730
+ created_at = self._parse_time(account.get("created_at"))
731
+ if created_at is not None and (now - created_at).total_seconds() < self._NEW_ACCOUNT_INVALID_GRACE_SECONDS:
732
+ return True
733
+ last_invalid_at = self._parse_time(account.get("last_invalid_at"))
734
+ invalid_count = int(account.get("invalid_count") or 0)
735
+ if invalid_count <= 1:
736
+ return True
737
+ if last_invalid_at is not None and (now - last_invalid_at).total_seconds() < self._INVALID_CONFIRM_SECONDS:
738
+ return True
739
+ return False
740
+
741
+ def _record_invalid_token_seen(self, access_token: str, event: str, error: str) -> bool:
742
+ now = datetime.now(timezone.utc)
743
+ with self._lock:
744
+ access_token = self._resolve_access_token_locked(access_token)
745
+ current = self._accounts.get(access_token)
746
+ if current is None:
747
+ return True
748
+ should_defer = self._should_defer_invalid_token(current, now)
749
+ next_item = dict(current)
750
+ next_item["invalid_count"] = int(next_item.get("invalid_count") or 0) + 1
751
+ next_item["last_invalid_at"] = now.isoformat()
752
+ next_item["last_refresh_error"] = str(error or "invalid access token")
753
+ next_item["last_refresh_error_at"] = now.isoformat()
754
+ account = self._normalize_account(next_item)
755
+ if account is not None:
756
+ self._accounts[access_token] = account
757
+ self._save_accounts()
758
+ if should_defer:
759
+ log_service.add(
760
+ LOG_TYPE_ACCOUNT,
761
+ "暂缓标记异常账号",
762
+ {"source": event, "token": anonymize_token(access_token), "error": str(error or "")},
763
+ )
764
+ return False
765
+ return True
766
+
767
+ def mark_image_result(self, access_token: str, success: bool) -> dict | None:
768
+ if not access_token:
769
+ return None
770
+ self.release_image_slot(access_token)
771
+ with self._lock:
772
+ access_token = self._resolve_access_token_locked(access_token)
773
+ current = self._accounts.get(access_token)
774
+ if current is None:
775
+ return None
776
+ next_item = dict(current)
777
+ next_item["last_used_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
778
+ image_quota_unknown = bool(next_item.get("image_quota_unknown"))
779
+ if success:
780
+ next_item["success"] = int(next_item.get("success") or 0) + 1
781
+ if not image_quota_unknown:
782
+ next_item["quota"] = max(0, int(next_item.get("quota") or 0) - 1)
783
+ if not image_quota_unknown and next_item["quota"] == 0:
784
+ next_item["status"] = "限流"
785
+ next_item["restore_at"] = next_item.get("restore_at") or None
786
+ elif next_item.get("status") == "限流":
787
+ next_item["status"] = "正常"
788
+ else:
789
+ next_item["fail"] = int(next_item.get("fail") or 0) + 1
790
+ account = self._normalize_account(next_item)
791
+ if account is None:
792
+ return None
793
+ if account.get("status") == "限流" and config.auto_remove_rate_limited_accounts:
794
+ self._accounts.pop(access_token, None)
795
+ self._save_accounts()
796
+ log_service.add(LOG_TYPE_ACCOUNT, "自动移除限流账号", {"token": anonymize_token(access_token)})
797
+ return None
798
+ self._accounts[access_token] = account
799
+ self._save_accounts()
800
+ return dict(account)
801
+ return None
802
+
803
+ def fetch_remote_info(self, access_token: str, event: str = "fetch_remote_info") -> dict[str, Any] | None:
804
+ if not access_token:
805
+ raise ValueError("access_token is required")
806
+
807
+ active_token = self.refresh_access_token(access_token, event=f"{event}:preflight") or access_token
808
+ try:
809
+ from services.openai_backend_api import InvalidAccessTokenError, OpenAIBackendAPI
810
+ result = OpenAIBackendAPI(active_token).get_user_info()
811
+ except InvalidAccessTokenError as exc:
812
+ refreshed_token = self.refresh_access_token(active_token, force=True, event=f"{event}:invalid_access_token")
813
+ if refreshed_token and refreshed_token != active_token:
814
+ try:
815
+ result = OpenAIBackendAPI(refreshed_token).get_user_info()
816
+ except InvalidAccessTokenError as retry_exc:
817
+ if self._record_invalid_token_seen(refreshed_token, event, str(retry_exc)):
818
+ self.remove_invalid_token(refreshed_token, event)
819
+ raise
820
+ active_token = refreshed_token
821
+ else:
822
+ if self._record_invalid_token_seen(active_token, event, str(exc)):
823
+ self.remove_invalid_token(active_token, event)
824
+ raise
825
+ self._record_refresh_success(active_token)
826
+ return self.update_account(active_token, result)
827
+
828
+ def refresh_accounts(self, access_tokens: list[str]) -> dict[str, Any]:
829
+ access_tokens = list(dict.fromkeys(token for token in access_tokens if token))
830
+ if not access_tokens:
831
+ return {"refreshed": 0, "errors": [], "items": self.list_accounts()}
832
+
833
+ refreshed = 0
834
+ errors = []
835
+ max_workers = min(10, len(access_tokens))
836
+
837
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
838
+ futures = {
839
+ executor.submit(self.fetch_remote_info, token, "refresh_accounts"): token
840
+ for token in access_tokens
841
+ }
842
+ for future in as_completed(futures):
843
+ try:
844
+ account = future.result()
845
+ except Exception as exc:
846
+ errors.append({"token": anonymize_token(futures[future]), "error": str(exc)})
847
+ continue
848
+ if account is not None:
849
+ refreshed += 1
850
+
851
+ return {
852
+ "refreshed": refreshed,
853
+ "errors": errors,
854
+ "items": self.list_accounts(),
855
+ }
856
+
857
+ def build_export_items(self, access_tokens: list[str] | None = None) -> list[dict[str, str]]:
858
+ target_tokens = set(token for token in (access_tokens or []) if token)
859
+ with self._lock:
860
+ accounts = [
861
+ dict(item)
862
+ for item in self._accounts.values()
863
+ if not target_tokens or str(item.get("access_token") or "") in target_tokens
864
+ ]
865
+
866
+ items: list[dict[str, str]] = []
867
+ for account in accounts:
868
+ access_token = str(account.get("access_token") or "").strip()
869
+ refresh_token = str(account.get("refresh_token") or "").strip()
870
+ id_token = str(account.get("id_token") or "").strip()
871
+ if not access_token or not refresh_token or not id_token:
872
+ continue
873
+
874
+ access_payload = self._decode_jwt_payload(access_token)
875
+ id_payload = self._decode_jwt_payload(id_token)
876
+ auth_claim = access_payload.get("https://api.openai.com/auth")
877
+ auth_claim = auth_claim if isinstance(auth_claim, dict) else {}
878
+ profile_claim = access_payload.get("https://api.openai.com/profile")
879
+ profile_claim = profile_claim if isinstance(profile_claim, dict) else {}
880
+
881
+ email = (
882
+ str(account.get("email") or "").strip()
883
+ or str(profile_claim.get("email") or "").strip()
884
+ or str(id_payload.get("email") or "").strip()
885
+ )
886
+ account_id = (
887
+ str(account.get("account_id") or "").strip()
888
+ or str(auth_claim.get("chatgpt_account_id") or "").strip()
889
+ or str(account.get("user_id") or "").strip()
890
+ )
891
+ item = {
892
+ "type": str(account.get("export_type") or "codex"),
893
+ "email": email,
894
+ "account_id": account_id,
895
+ "access_token": access_token,
896
+ "refresh_token": refresh_token,
897
+ "id_token": id_token,
898
+ "expired": self._timestamp_to_iso(access_payload.get("exp")),
899
+ "last_refresh": self._timestamp_to_iso(access_payload.get("iat")),
900
+ }
901
+ password = str(account.get("password") or "").strip()
902
+ if password:
903
+ item["password"] = password
904
+ items.append(item)
905
+ return items
906
+
907
+ def get_stats(self) -> dict:
908
+ with self._lock:
909
+ items = list(self._accounts.values())
910
+ total = len(items)
911
+ active = sum(1 for a in items if a.get("status") == "正常")
912
+ limited = sum(1 for a in items if a.get("status") == "限流")
913
+ abnormal = sum(1 for a in items if a.get("status") == "异常")
914
+ disabled = sum(1 for a in items if a.get("status") == "禁用")
915
+ total_quota = sum(max(0, int(a.get("quota") or 0)) for a in items if a.get("status") == "正常")
916
+ unlimited = sum(1 for a in items if a.get("status") == "正常" and bool(a.get("image_quota_unknown")))
917
+ total_success = sum(int(a.get("success") or 0) for a in items)
918
+ total_fail = sum(int(a.get("fail") or 0) for a in items)
919
+ by_type = {}
920
+ for a in items:
921
+ t = a.get("type", "unknown")
922
+ by_type[t] = by_type.get(t, 0) + 1
923
+ return {
924
+ "total": total,
925
+ "cumulative_total": self._cumulative_total,
926
+ "active": active,
927
+ "limited": limited,
928
+ "abnormal": abnormal,
929
+ "disabled": disabled,
930
+ "total_quota": total_quota,
931
+ "unlimited_quota_count": unlimited,
932
+ "total_success": total_success,
933
+ "total_fail": total_fail,
934
+ "by_type": by_type,
935
+ }
936
+
937
+ def account_health(self) -> dict:
938
+ stats = self.get_stats()
939
+ return {
940
+ "healthy": stats["active"] > 0 or stats["unlimited_quota_count"] > 0,
941
+ "status": "ok" if stats["active"] > 0 else "degraded",
942
+ **stats,
943
+ }
944
+
945
+
946
+ account_service = AccountService(config.get_storage_backend())
services/auth_service.py ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import hmac
5
+ import secrets
6
+ import uuid
7
+ from datetime import datetime, timezone
8
+ from threading import Lock
9
+ from typing import Literal
10
+
11
+ from services.config import config
12
+ from services.storage.base import StorageBackend
13
+
14
+ AuthRole = Literal["admin", "user"]
15
+
16
+
17
+ def _now_iso() -> str:
18
+ return datetime.now(timezone.utc).isoformat()
19
+
20
+
21
+ def _hash_key(value: str) -> str:
22
+ return hashlib.sha256(value.encode("utf-8")).hexdigest()
23
+
24
+
25
+ class AuthService:
26
+ def __init__(self, storage: StorageBackend):
27
+ self.storage = storage
28
+ self._lock = Lock()
29
+ self._items = self._load()
30
+ self._last_used_flush_at: dict[str, datetime] = {}
31
+
32
+ @staticmethod
33
+ def _clean(value: object) -> str:
34
+ return str(value or "").strip()
35
+
36
+ @staticmethod
37
+ def _default_name(role: object) -> str:
38
+ return "管理员密钥" if str(role or "").strip().lower() == "admin" else "普通用户"
39
+
40
+ def _normalize_item(self, raw: object) -> dict[str, object] | None:
41
+ if not isinstance(raw, dict):
42
+ return None
43
+ role = self._clean(raw.get("role")).lower()
44
+ if role not in {"admin", "user"}:
45
+ return None
46
+ key_hash = self._clean(raw.get("key_hash"))
47
+ if not key_hash:
48
+ return None
49
+ item_id = self._clean(raw.get("id")) or uuid.uuid4().hex[:12]
50
+ name = self._clean(raw.get("name")) or self._default_name(role)
51
+ created_at = self._clean(raw.get("created_at")) or _now_iso()
52
+ last_used_at = self._clean(raw.get("last_used_at")) or None
53
+ return {
54
+ "id": item_id,
55
+ "name": name,
56
+ "role": role,
57
+ "key_hash": key_hash,
58
+ "enabled": bool(raw.get("enabled", True)),
59
+ "created_at": created_at,
60
+ "last_used_at": last_used_at,
61
+ }
62
+
63
+ def _load(self) -> list[dict[str, object]]:
64
+ try:
65
+ items = self.storage.load_auth_keys()
66
+ except Exception:
67
+ return []
68
+ if not isinstance(items, list):
69
+ return []
70
+ return [normalized for item in items if (normalized := self._normalize_item(item)) is not None]
71
+
72
+ def _save(self) -> None:
73
+ self.storage.save_auth_keys(self._items)
74
+
75
+ def _reload_locked(self) -> None:
76
+ self._items = self._load()
77
+
78
+ @staticmethod
79
+ def _public_item(item: dict[str, object]) -> dict[str, object]:
80
+ return {
81
+ "id": item.get("id"),
82
+ "name": item.get("name"),
83
+ "role": item.get("role"),
84
+ "enabled": bool(item.get("enabled", True)),
85
+ "created_at": item.get("created_at"),
86
+ "last_used_at": item.get("last_used_at"),
87
+ }
88
+
89
+ def list_keys(self, role: AuthRole | None = None) -> list[dict[str, object]]:
90
+ with self._lock:
91
+ self._reload_locked()
92
+ items = [item for item in self._items if role is None or item.get("role") == role]
93
+ return [self._public_item(item) for item in items]
94
+
95
+ def _has_key_hash_locked(self, key_hash: str, *, exclude_id: str = "") -> bool:
96
+ for item in self._items:
97
+ item_id = self._clean(item.get("id"))
98
+ if exclude_id and item_id == exclude_id:
99
+ continue
100
+ stored_hash = self._clean(item.get("key_hash"))
101
+ if stored_hash and hmac.compare_digest(stored_hash, key_hash):
102
+ return True
103
+ return False
104
+
105
+ def _build_key_hash_locked(self, raw_key: str, *, exclude_id: str = "") -> str:
106
+ candidate = self._clean(raw_key)
107
+ if not candidate:
108
+ raise ValueError("请输入新的专用密钥")
109
+ admin_key = self._clean(config.auth_key)
110
+ if admin_key and hmac.compare_digest(candidate, admin_key):
111
+ raise ValueError("这个密钥和管理员密钥冲突了,请换一个新的密钥")
112
+ key_hash = _hash_key(candidate)
113
+ if self._has_key_hash_locked(key_hash, exclude_id=exclude_id):
114
+ raise ValueError("这个专用密钥已经存在,请换一个新的密钥")
115
+ return key_hash
116
+
117
+ def _has_name_locked(self, name: str, *, role: AuthRole | None = None, exclude_id: str = "") -> bool:
118
+ candidate = self._clean(name)
119
+ if not candidate:
120
+ return False
121
+ for item in self._items:
122
+ item_id = self._clean(item.get("id"))
123
+ if exclude_id and item_id == exclude_id:
124
+ continue
125
+ if role is not None and item.get("role") != role:
126
+ continue
127
+ if self._clean(item.get("name")) == candidate:
128
+ return True
129
+ return False
130
+
131
+ def _build_default_name_locked(self, role: AuthRole, *, exclude_id: str = "") -> str:
132
+ base_name = self._default_name(role)
133
+ if not self._has_name_locked(base_name, role=role, exclude_id=exclude_id):
134
+ return base_name
135
+ suffix = 2
136
+ while True:
137
+ candidate = f"{base_name} {suffix}"
138
+ if not self._has_name_locked(candidate, role=role, exclude_id=exclude_id):
139
+ return candidate
140
+ suffix += 1
141
+
142
+ def _build_name_locked(self, name: str, *, role: AuthRole, exclude_id: str = "") -> str:
143
+ candidate = self._clean(name)
144
+ if not candidate:
145
+ return self._build_default_name_locked(role, exclude_id=exclude_id)
146
+ if self._has_name_locked(candidate, role=role, exclude_id=exclude_id):
147
+ raise ValueError("这个名称已经在使用中了,换一个更容易区分的名称吧")
148
+ return candidate
149
+
150
+ def create_key(self, *, role: AuthRole, name: str = "") -> tuple[dict[str, object], str]:
151
+ with self._lock:
152
+ self._reload_locked()
153
+ normalized_name = self._build_name_locked(name, role=role)
154
+ while True:
155
+ raw_key = f"sk-{secrets.token_urlsafe(24)}"
156
+ try:
157
+ key_hash = self._build_key_hash_locked(raw_key)
158
+ break
159
+ except ValueError:
160
+ continue
161
+ item = {
162
+ "id": uuid.uuid4().hex[:12],
163
+ "name": normalized_name,
164
+ "role": role,
165
+ "key_hash": key_hash,
166
+ "enabled": True,
167
+ "created_at": _now_iso(),
168
+ "last_used_at": None,
169
+ }
170
+ self._items.append(item)
171
+ self._save()
172
+ return self._public_item(item), raw_key
173
+
174
+ def update_key(
175
+ self,
176
+ key_id: str,
177
+ updates: dict[str, object],
178
+ *,
179
+ role: AuthRole | None = None,
180
+ ) -> dict[str, object] | None:
181
+ normalized_id = self._clean(key_id)
182
+ if not normalized_id:
183
+ return None
184
+ with self._lock:
185
+ self._reload_locked()
186
+ for index, item in enumerate(self._items):
187
+ if item.get("id") != normalized_id:
188
+ continue
189
+ if role is not None and item.get("role") != role:
190
+ return None
191
+ next_item = dict(item)
192
+ next_role = "admin" if str(next_item.get("role") or "").strip().lower() == "admin" else "user"
193
+ if "name" in updates and updates.get("name") is not None:
194
+ next_item["name"] = self._build_name_locked(
195
+ str(updates.get("name") or ""),
196
+ role=next_role,
197
+ exclude_id=normalized_id,
198
+ )
199
+ if "enabled" in updates and updates.get("enabled") is not None:
200
+ next_item["enabled"] = bool(updates.get("enabled"))
201
+ if "key" in updates and updates.get("key") is not None:
202
+ next_item["key_hash"] = self._build_key_hash_locked(str(updates.get("key") or ""), exclude_id=normalized_id)
203
+ self._items[index] = next_item
204
+ self._save()
205
+ return self._public_item(next_item)
206
+ return None
207
+
208
+ def delete_key(self, key_id: str, *, role: AuthRole | None = None) -> bool:
209
+ normalized_id = self._clean(key_id)
210
+ if not normalized_id:
211
+ return False
212
+ with self._lock:
213
+ self._reload_locked()
214
+ before = len(self._items)
215
+ self._items = [
216
+ item
217
+ for item in self._items
218
+ if not (item.get("id") == normalized_id and (role is None or item.get("role") == role))
219
+ ]
220
+ if len(self._items) == before:
221
+ return False
222
+ self._save()
223
+ return True
224
+
225
+ def authenticate(self, raw_key: str) -> dict[str, object] | None:
226
+ candidate = self._clean(raw_key)
227
+ if not candidate:
228
+ return None
229
+ candidate_hash = _hash_key(candidate)
230
+ with self._lock:
231
+ for index, item in enumerate(self._items):
232
+ if not bool(item.get("enabled", True)):
233
+ continue
234
+ stored_hash = self._clean(item.get("key_hash"))
235
+ if not stored_hash or not hmac.compare_digest(stored_hash, candidate_hash):
236
+ continue
237
+ next_item = dict(item)
238
+ now = datetime.now(timezone.utc)
239
+ next_item["last_used_at"] = now.isoformat()
240
+ self._items[index] = next_item
241
+ item_id = self._clean(next_item.get("id"))
242
+ last_flush_at = self._last_used_flush_at.get(item_id)
243
+ if last_flush_at is None or (now - last_flush_at).total_seconds() >= 60:
244
+ try:
245
+ self._save()
246
+ self._last_used_flush_at[item_id] = now
247
+ except Exception:
248
+ pass
249
+ return self._public_item(next_item)
250
+ return None
251
+
252
+
253
+ auth_service = AuthService(config.get_storage_backend())
services/backup_service.py ADDED
@@ -0,0 +1,673 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import hmac
5
+ import io
6
+ import json
7
+ import os
8
+ import random
9
+ import subprocess
10
+ import tarfile
11
+ import threading
12
+ from datetime import UTC, datetime
13
+ from pathlib import Path
14
+ from urllib.parse import quote, urlencode
15
+
16
+ from curl_cffi import requests
17
+
18
+ from services.config import BASE_DIR, CONFIG_FILE, DATA_DIR, config, load_backup_state, save_backup_state
19
+ from services.image_storage_service import IMAGE_INDEX_FILE
20
+ from services.image_tags_service import TAGS_FILE
21
+
22
+
23
+ def _utc_now() -> datetime:
24
+ return datetime.now(UTC)
25
+
26
+
27
+ def _iso_now() -> str:
28
+ return _utc_now().replace(microsecond=0).isoformat().replace("+00:00", "Z")
29
+
30
+
31
+ def _clean(value: object) -> str:
32
+ return str(value or "").strip()
33
+
34
+
35
+ def _sha256_hex(value: bytes) -> str:
36
+ return hashlib.sha256(value).hexdigest()
37
+
38
+
39
+ def _is_backup_object(key: object) -> bool:
40
+ name = _clean(key).rsplit("/", 1)[-1]
41
+ return name.startswith("backup-") and (name.endswith(".tar.gz") or name.endswith(".tar.gz.enc"))
42
+
43
+
44
+ def _hmac_sha256(key: bytes, message: str) -> bytes:
45
+ return hmac.new(key, message.encode("utf-8"), hashlib.sha256).digest()
46
+
47
+
48
+ def _openssl_encrypt(data: bytes, passphrase: str) -> bytes:
49
+ env = dict(os.environ)
50
+ env["CHATGPT2API_BACKUP_PASSPHRASE"] = passphrase
51
+ try:
52
+ result = subprocess.run(
53
+ [
54
+ "openssl",
55
+ "enc",
56
+ "-aes-256-cbc",
57
+ "-pbkdf2",
58
+ "-salt",
59
+ "-md",
60
+ "sha256",
61
+ "-pass",
62
+ "env:CHATGPT2API_BACKUP_PASSPHRASE",
63
+ ],
64
+ input=data,
65
+ stdout=subprocess.PIPE,
66
+ stderr=subprocess.PIPE,
67
+ check=True,
68
+ env=env,
69
+ )
70
+ except FileNotFoundError as exc:
71
+ raise BackupError("当前环境缺少 openssl,无法执行加密备份") from exc
72
+ except subprocess.CalledProcessError as exc:
73
+ detail = (exc.stderr or b"").decode("utf-8", errors="replace").strip()
74
+ raise BackupError(f"加密备份失败:{detail or 'openssl 执行失败'}") from exc
75
+ return result.stdout
76
+
77
+
78
+ def _openssl_decrypt(data: bytes, passphrase: str) -> bytes:
79
+ env = dict(os.environ)
80
+ env["CHATGPT2API_BACKUP_PASSPHRASE"] = passphrase
81
+ try:
82
+ result = subprocess.run(
83
+ [
84
+ "openssl",
85
+ "enc",
86
+ "-d",
87
+ "-aes-256-cbc",
88
+ "-pbkdf2",
89
+ "-md",
90
+ "sha256",
91
+ "-pass",
92
+ "env:CHATGPT2API_BACKUP_PASSPHRASE",
93
+ ],
94
+ input=data,
95
+ stdout=subprocess.PIPE,
96
+ stderr=subprocess.PIPE,
97
+ check=True,
98
+ env=env,
99
+ )
100
+ except FileNotFoundError as exc:
101
+ raise BackupError("当前环境缺少 openssl,无法解密备份内容") from exc
102
+ except subprocess.CalledProcessError as exc:
103
+ detail = (exc.stderr or b"").decode("utf-8", errors="replace").strip()
104
+ raise BackupError(f"解密备份失败:{detail or 'openssl 执行失败'}") from exc
105
+ return result.stdout
106
+
107
+
108
+ def _guess_content_type(name: str) -> str:
109
+ if name.endswith(".json"):
110
+ return "application/json"
111
+ if name.endswith(".jsonl"):
112
+ return "application/x-ndjson"
113
+ if name.endswith(".tar.gz"):
114
+ return "application/gzip"
115
+ if name.endswith(".gz"):
116
+ return "application/gzip"
117
+ return "application/octet-stream"
118
+
119
+
120
+ def _json_bytes(value: object) -> bytes:
121
+ return json.dumps(value, ensure_ascii=False, indent=2).encode("utf-8")
122
+
123
+
124
+ def _count_items(value: object) -> int:
125
+ if isinstance(value, list):
126
+ return len(value)
127
+ if isinstance(value, dict):
128
+ return len(value)
129
+ return 0
130
+
131
+
132
+ class BackupError(RuntimeError):
133
+ pass
134
+
135
+
136
+ class CloudflareR2Client:
137
+ def __init__(self, settings: dict[str, object]) -> None:
138
+ self.account_id = _clean(settings.get("account_id"))
139
+ self.access_key_id = _clean(settings.get("access_key_id"))
140
+ self.secret_access_key = _clean(settings.get("secret_access_key"))
141
+ self.bucket = _clean(settings.get("bucket"))
142
+ self.prefix = _clean(settings.get("prefix")) or "backups"
143
+ self.session = requests.Session(impersonate="chrome", verify=True)
144
+
145
+ def validate(self) -> None:
146
+ missing = []
147
+ if not self.account_id:
148
+ missing.append("Account ID")
149
+ if not self.access_key_id:
150
+ missing.append("Access Key ID")
151
+ if not self.secret_access_key:
152
+ missing.append("Secret Access Key")
153
+ if not self.bucket:
154
+ missing.append("Bucket")
155
+ if missing:
156
+ raise BackupError(f"R2 配置不完整:缺少 {'、'.join(missing)}")
157
+
158
+ @property
159
+ def endpoint(self) -> str:
160
+ return f"https://{self.account_id}.r2.cloudflarestorage.com"
161
+
162
+ def _aws_v4_headers(
163
+ self,
164
+ method: str,
165
+ path: str,
166
+ *,
167
+ query: dict[str, str] | None = None,
168
+ body: bytes = b"",
169
+ extra_headers: dict[str, str] | None = None,
170
+ ) -> tuple[str, dict[str, str]]:
171
+ now = _utc_now()
172
+ amz_date = now.strftime("%Y%m%dT%H%M%SZ")
173
+ date_stamp = now.strftime("%Y%m%d")
174
+ encoded_query = urlencode(sorted((query or {}).items()))
175
+ payload_hash = _sha256_hex(body)
176
+ host = f"{self.account_id}.r2.cloudflarestorage.com"
177
+ headers = {
178
+ "host": host,
179
+ "x-amz-content-sha256": payload_hash,
180
+ "x-amz-date": amz_date,
181
+ }
182
+ if extra_headers:
183
+ for key, value in extra_headers.items():
184
+ headers[key.lower()] = value.strip()
185
+ sorted_items = sorted((key.lower(), " ".join(str(value).strip().split())) for key, value in headers.items())
186
+ canonical_headers = "".join(f"{key}:{value}\n" for key, value in sorted_items)
187
+ signed_headers = ";".join(key for key, _ in sorted_items)
188
+ canonical_request = "\n".join([
189
+ method.upper(),
190
+ path,
191
+ encoded_query,
192
+ canonical_headers,
193
+ signed_headers,
194
+ payload_hash,
195
+ ])
196
+ credential_scope = f"{date_stamp}/auto/s3/aws4_request"
197
+ string_to_sign = "\n".join([
198
+ "AWS4-HMAC-SHA256",
199
+ amz_date,
200
+ credential_scope,
201
+ _sha256_hex(canonical_request.encode("utf-8")),
202
+ ])
203
+ k_date = _hmac_sha256(("AWS4" + self.secret_access_key).encode("utf-8"), date_stamp)
204
+ k_region = hmac.new(k_date, b"auto", hashlib.sha256).digest()
205
+ k_service = hmac.new(k_region, b"s3", hashlib.sha256).digest()
206
+ k_signing = hmac.new(k_service, b"aws4_request", hashlib.sha256).digest()
207
+ signature = hmac.new(k_signing, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
208
+ authorization = (
209
+ "AWS4-HMAC-SHA256 "
210
+ f"Credential={self.access_key_id}/{credential_scope}, "
211
+ f"SignedHeaders={signed_headers}, "
212
+ f"Signature={signature}"
213
+ )
214
+ request_headers = {key: value for key, value in headers.items()}
215
+ request_headers["authorization"] = authorization
216
+ return encoded_query, request_headers
217
+
218
+ def _request(
219
+ self,
220
+ method: str,
221
+ key: str = "",
222
+ *,
223
+ query: dict[str, str] | None = None,
224
+ body: bytes = b"",
225
+ extra_headers: dict[str, str] | None = None,
226
+ timeout: float = 60.0,
227
+ ):
228
+ object_path = f"/{self.bucket}"
229
+ if key:
230
+ object_path += f"/{quote(key.lstrip('/'), safe='/')}"
231
+ encoded_query, headers = self._aws_v4_headers(method, object_path, query=query, body=body, extra_headers=extra_headers)
232
+ url = f"{self.endpoint}{object_path}"
233
+ if encoded_query:
234
+ url += f"?{encoded_query}"
235
+ response = self.session.request(method.upper(), url, headers=headers, data=body, timeout=timeout)
236
+ return response
237
+
238
+ def test_connection(self) -> dict[str, object]:
239
+ self.validate()
240
+ response = self._request("GET", query={"list-type": "2", "max-keys": "1"}, timeout=30.0)
241
+ if response.status_code >= 400:
242
+ raise BackupError(f"连接 R2 失败:HTTP {response.status_code}")
243
+ return {"ok": True, "status": int(response.status_code)}
244
+
245
+ def upload_bytes(self, key: str, payload: bytes, *, content_type: str, metadata: dict[str, str] | None = None) -> dict[str, object]:
246
+ headers = {"content-type": content_type}
247
+ if metadata:
248
+ for item_key, item_value in metadata.items():
249
+ headers[f"x-amz-meta-{item_key}"] = str(item_value)
250
+ response = self._request("PUT", key, body=payload, extra_headers=headers)
251
+ if response.status_code >= 400:
252
+ raise BackupError(f"上传备份失败:HTTP {response.status_code}")
253
+ return {"key": key, "etag": str(response.headers.get("etag") or "").strip('"')}
254
+
255
+ def delete_object(self, key: str) -> None:
256
+ response = self._request("DELETE", key, timeout=30.0)
257
+ if response.status_code >= 400 and response.status_code != 404:
258
+ raise BackupError(f"删除备份失败:HTTP {response.status_code}")
259
+
260
+ def download_bytes(self, key: str) -> bytes:
261
+ response = self._request("GET", key, timeout=60.0)
262
+ if response.status_code >= 400:
263
+ raise BackupError(f"读取备份失败:HTTP {response.status_code}")
264
+ return bytes(response.content or b"")
265
+
266
+ def list_objects(self) -> list[dict[str, object]]:
267
+ items: list[dict[str, object]] = []
268
+ continuation = ""
269
+ while True:
270
+ query = {"list-type": "2", "prefix": f"{self.prefix.rstrip('/')}/", "max-keys": "1000"}
271
+ if continuation:
272
+ query["continuation-token"] = continuation
273
+ response = self._request("GET", query=query, timeout=30.0)
274
+ if response.status_code >= 400:
275
+ raise BackupError(f"获取备份列表失败:HTTP {response.status_code}")
276
+ text = response.text
277
+ for block in text.split("<Contents>")[1:]:
278
+ key = _clean(block.split("<Key>", 1)[1].split("</Key>", 1)[0]) if "<Key>" in block else ""
279
+ if not key:
280
+ continue
281
+ size_text = _clean(block.split("<Size>", 1)[1].split("</Size>", 1)[0]) if "<Size>" in block else "0"
282
+ updated = _clean(block.split("<LastModified>", 1)[1].split("</LastModified>", 1)[0]) if "<LastModified>" in block else ""
283
+ items.append({
284
+ "key": key,
285
+ "size": int(size_text or 0),
286
+ "updated_at": updated,
287
+ })
288
+ truncated = "<IsTruncated>true</IsTruncated>" in text
289
+ if not truncated:
290
+ break
291
+ if "<NextContinuationToken>" not in text:
292
+ break
293
+ continuation = _clean(text.split("<NextContinuationToken>", 1)[1].split("</NextContinuationToken>", 1)[0])
294
+ if not continuation:
295
+ break
296
+ items.sort(key=lambda item: str(item.get("updated_at") or ""), reverse=True)
297
+ return items
298
+
299
+ def close(self) -> None:
300
+ self.session.close()
301
+
302
+
303
+ class BackupService:
304
+ def __init__(self) -> None:
305
+ self._lock = threading.RLock()
306
+ self._stop_event = threading.Event()
307
+ self._thread: threading.Thread | None = None
308
+ self._running = False
309
+
310
+ def start(self) -> None:
311
+ with self._lock:
312
+ if self._thread and self._thread.is_alive():
313
+ return
314
+ self._stop_event.clear()
315
+ self._thread = threading.Thread(target=self._run, daemon=True, name="r2-backup-scheduler")
316
+ self._thread.start()
317
+
318
+ def stop(self) -> None:
319
+ with self._lock:
320
+ self._stop_event.set()
321
+ thread = self._thread
322
+ self._thread = None
323
+ if thread and thread.is_alive():
324
+ thread.join(timeout=2)
325
+
326
+ def _run(self) -> None:
327
+ while not self._stop_event.is_set():
328
+ try:
329
+ self.run_scheduled_backup_if_needed()
330
+ except Exception:
331
+ pass
332
+ self._stop_event.wait(30)
333
+
334
+ def run_scheduled_backup_if_needed(self) -> None:
335
+ settings = config.get_backup_settings()
336
+ if not settings.get("enabled"):
337
+ return
338
+ state = self.get_status()
339
+ if state.get("running"):
340
+ return
341
+ interval_minutes = int(settings.get("interval_minutes") or 360)
342
+ last_finished_raw = _clean(state.get("last_finished_at"))
343
+ if last_finished_raw:
344
+ try:
345
+ last_finished = datetime.fromisoformat(last_finished_raw.replace("Z", "+00:00"))
346
+ elapsed = (_utc_now() - last_finished.astimezone(UTC)).total_seconds()
347
+ if elapsed < interval_minutes * 60:
348
+ return
349
+ except Exception:
350
+ pass
351
+ self.run_backup(trigger="schedule")
352
+
353
+ def get_status(self) -> dict[str, object]:
354
+ return {
355
+ **load_backup_state(),
356
+ "running": self._running,
357
+ }
358
+
359
+ def is_configured(self) -> bool:
360
+ settings = config.get_backup_settings()
361
+ return all([
362
+ _clean(settings.get("account_id")),
363
+ _clean(settings.get("access_key_id")),
364
+ _clean(settings.get("secret_access_key")),
365
+ _clean(settings.get("bucket")),
366
+ ])
367
+
368
+ def get_settings(self) -> dict[str, object]:
369
+ settings = dict(config.get_backup_settings())
370
+ settings["secret_access_key"] = "********" if _clean(settings.get("secret_access_key")) else ""
371
+ settings["passphrase"] = "********" if _clean(settings.get("passphrase")) else ""
372
+ return settings
373
+
374
+ def update_settings(self, payload: dict[str, object]) -> dict[str, object]:
375
+ current = config.get_backup_settings()
376
+ merged = dict(current)
377
+ merged.update(dict(payload or {}))
378
+ if "include" in payload and isinstance(payload.get("include"), dict):
379
+ include = dict(current.get("include") or {})
380
+ include.update(payload.get("include") or {})
381
+ merged["include"] = include
382
+ if payload.get("secret_access_key") == "********":
383
+ merged["secret_access_key"] = current.get("secret_access_key")
384
+ if payload.get("passphrase") == "********":
385
+ merged["passphrase"] = current.get("passphrase")
386
+ updated = config.update({"backup": merged})
387
+ return dict(updated.get("backup") or {})
388
+
389
+ def test_connection(self) -> dict[str, object]:
390
+ client = CloudflareR2Client(config.get_backup_settings())
391
+ try:
392
+ return client.test_connection()
393
+ finally:
394
+ client.close()
395
+
396
+ def list_backups(self) -> list[dict[str, object]]:
397
+ if not self.is_configured():
398
+ return []
399
+ client = CloudflareR2Client(config.get_backup_settings())
400
+ try:
401
+ items = client.list_objects()
402
+ finally:
403
+ client.close()
404
+ parsed: list[dict[str, object]] = []
405
+ for item in items:
406
+ key = _clean(item.get("key"))
407
+ name = key.rsplit("/", 1)[-1]
408
+ encrypted = name.endswith(".enc")
409
+ parsed.append({
410
+ "key": key,
411
+ "name": name,
412
+ "size": int(item.get("size") or 0),
413
+ "updated_at": item.get("updated_at"),
414
+ "encrypted": encrypted,
415
+ })
416
+ return parsed
417
+
418
+ def delete_backup(self, key: str) -> None:
419
+ candidate = _clean(key)
420
+ if not candidate:
421
+ raise BackupError("备份对象 key 不能为空")
422
+ client = CloudflareR2Client(config.get_backup_settings())
423
+ try:
424
+ client.delete_object(candidate)
425
+ finally:
426
+ client.close()
427
+
428
+ def download_backup(self, key: str) -> dict[str, object]:
429
+ candidate = _clean(key)
430
+ if not candidate:
431
+ raise BackupError("备份对象 key 不能为空")
432
+ client = CloudflareR2Client(config.get_backup_settings())
433
+ try:
434
+ payload = client.download_bytes(candidate)
435
+ finally:
436
+ client.close()
437
+ name = candidate.rsplit("/", 1)[-1] or "backup.bin"
438
+ if candidate.endswith(".enc"):
439
+ passphrase = _clean(config.get_backup_settings().get("passphrase"))
440
+ if not passphrase:
441
+ raise BackupError("当前未配置加密口令,无法下载并解密已加密备份")
442
+ payload = _openssl_decrypt(payload, passphrase)
443
+ if name.endswith(".enc"):
444
+ name = name[:-4] or "backup.tar.gz"
445
+ return {
446
+ "key": candidate,
447
+ "name": name,
448
+ "content_type": _guess_content_type(name),
449
+ "payload": payload,
450
+ "size": len(payload),
451
+ }
452
+
453
+ def get_backup_detail(self, key: str) -> dict[str, object]:
454
+ candidate = _clean(key)
455
+ if not candidate:
456
+ raise BackupError("备份对象 key 不能为空")
457
+ client = CloudflareR2Client(config.get_backup_settings())
458
+ try:
459
+ payload = client.download_bytes(candidate)
460
+ finally:
461
+ client.close()
462
+ detail = self._decode_backup_payload(candidate, payload)
463
+ detail["key"] = candidate
464
+ detail["name"] = candidate.rsplit("/", 1)[-1]
465
+ detail["encrypted"] = candidate.endswith(".enc")
466
+ return detail
467
+
468
+ def run_backup(self, *, trigger: str = "manual") -> dict[str, object]:
469
+ with self._lock:
470
+ current = self.get_status()
471
+ if self._running:
472
+ raise BackupError("当前已有备份任务正在执行")
473
+ started_at = _iso_now()
474
+ self._running = True
475
+ save_backup_state({
476
+ "last_started_at": started_at,
477
+ "last_finished_at": current.get("last_finished_at"),
478
+ "last_status": "idle",
479
+ "last_error": None,
480
+ "last_object_key": current.get("last_object_key"),
481
+ })
482
+ try:
483
+ result = self._run_backup_once(trigger=trigger)
484
+ save_backup_state({
485
+ "last_started_at": started_at,
486
+ "last_finished_at": _iso_now(),
487
+ "last_status": "success",
488
+ "last_error": None,
489
+ "last_object_key": result["key"],
490
+ })
491
+ return result
492
+ except Exception as exc:
493
+ save_backup_state({
494
+ "last_started_at": started_at,
495
+ "last_finished_at": _iso_now(),
496
+ "last_status": "error",
497
+ "last_error": str(exc) or exc.__class__.__name__,
498
+ "last_object_key": current.get("last_object_key"),
499
+ })
500
+ raise
501
+ finally:
502
+ self._running = False
503
+
504
+ def _run_backup_once(self, *, trigger: str) -> dict[str, object]:
505
+ settings = config.get_backup_settings()
506
+ client = CloudflareR2Client(settings)
507
+ client.validate()
508
+ payload_raw = self._build_backup_archive(settings, trigger=trigger)
509
+ encrypted = bool(settings.get("encrypt"))
510
+ if encrypted:
511
+ passphrase = _clean(settings.get("passphrase"))
512
+ if not passphrase:
513
+ raise BackupError("已启用备份加密,但未设置加密口令")
514
+ payload = _openssl_encrypt(payload_raw, passphrase)
515
+ suffix = ".tar.gz.enc"
516
+ else:
517
+ payload = payload_raw
518
+ suffix = ".tar.gz"
519
+ timestamp = _utc_now().strftime("%Y%m%dT%H%M%SZ")
520
+ random_tag = f"{random.randint(0, 0xFFFF):04x}"
521
+ object_key = f"{client.prefix.rstrip('/')}/backup-{timestamp}-{random_tag}{suffix}"
522
+ metadata = {
523
+ "created-at": _iso_now(),
524
+ "encrypted": "true" if encrypted else "false",
525
+ "trigger": trigger,
526
+ }
527
+ try:
528
+ result = client.upload_bytes(object_key, payload, content_type="application/octet-stream", metadata=metadata)
529
+ self._apply_rotation(client, int(settings.get("rotation_keep") or 0))
530
+ return {
531
+ "key": result["key"],
532
+ "size": len(payload),
533
+ "encrypted": encrypted,
534
+ }
535
+ finally:
536
+ client.close()
537
+
538
+ def _decode_backup_payload(self, key: str, payload: bytes) -> dict[str, object]:
539
+ decoded = payload
540
+ if key.endswith(".enc"):
541
+ passphrase = _clean(config.get_backup_settings().get("passphrase"))
542
+ if not passphrase:
543
+ raise BackupError("当前未配置加密口令,无法查看已加密备份")
544
+ decoded = _openssl_decrypt(decoded, passphrase)
545
+ return self._decode_archive_detail(decoded)
546
+
547
+ def _apply_rotation(self, client: CloudflareR2Client, keep: int) -> None:
548
+ if keep <= 0:
549
+ return
550
+ items = [item for item in client.list_objects() if _is_backup_object(item.get("key"))]
551
+ if len(items) <= keep:
552
+ return
553
+ for item in items[keep:]:
554
+ key = _clean(item.get("key"))
555
+ if key:
556
+ client.delete_object(key)
557
+
558
+ def _decode_archive_detail(self, payload: bytes) -> dict[str, object]:
559
+ files: list[dict[str, object]] = []
560
+ snapshots: list[dict[str, object]] = []
561
+ metadata: dict[str, object] = {}
562
+ try:
563
+ with tarfile.open(fileobj=io.BytesIO(payload), mode="r:gz") as archive:
564
+ members = [member for member in archive.getmembers() if member.isfile()]
565
+ for member in members:
566
+ extracted = archive.extractfile(member)
567
+ if extracted is None:
568
+ continue
569
+ raw = extracted.read()
570
+ name = member.name
571
+ if name == "backup-metadata.json":
572
+ try:
573
+ parsed = json.loads(raw.decode("utf-8"))
574
+ if isinstance(parsed, dict):
575
+ metadata = parsed
576
+ except Exception:
577
+ metadata = {}
578
+ continue
579
+ if name.startswith("snapshots/") and name.endswith(".json"):
580
+ count = 0
581
+ try:
582
+ parsed_snapshot = json.loads(raw.decode("utf-8"))
583
+ count = _count_items(parsed_snapshot)
584
+ except Exception:
585
+ count = 0
586
+ snapshots.append({
587
+ "name": name.removeprefix("snapshots/").removesuffix(".json"),
588
+ "count": count,
589
+ })
590
+ continue
591
+ files.append({
592
+ "name": name,
593
+ "exists": True,
594
+ "content_type": _guess_content_type(name),
595
+ "size": len(raw),
596
+ "sha256": _sha256_hex(raw),
597
+ })
598
+ except tarfile.TarError as exc:
599
+ raise BackupError("解析备份压缩包失败,备份可能已损坏") from exc
600
+ files.sort(key=lambda item: str(item.get("name") or ""))
601
+ snapshots.sort(key=lambda item: str(item.get("name") or ""))
602
+ return {
603
+ "created_at": metadata.get("created_at"),
604
+ "trigger": metadata.get("trigger"),
605
+ "app_version": metadata.get("app_version"),
606
+ "storage_backend": metadata.get("storage_backend"),
607
+ "files": files,
608
+ "snapshots": snapshots,
609
+ }
610
+
611
+ def _build_backup_archive(self, settings: dict[str, object], *, trigger: str) -> bytes:
612
+ include = settings.get("include") if isinstance(settings.get("include"), dict) else {}
613
+ metadata = {
614
+ "version": 2,
615
+ "created_at": _iso_now(),
616
+ "trigger": trigger,
617
+ "app_version": config.app_version,
618
+ "storage_backend": config.get_storage_backend().get_backend_info(),
619
+ }
620
+ buffer = io.BytesIO()
621
+ with tarfile.open(fileobj=buffer, mode="w:gz") as archive:
622
+ self._add_bytes_to_archive(archive, "backup-metadata.json", _json_bytes(metadata))
623
+ if include.get("config"):
624
+ self._add_file_to_archive(archive, CONFIG_FILE, "config.json")
625
+ if include.get("register"):
626
+ self._add_file_to_archive(archive, DATA_DIR / "register.json", "data/register.json")
627
+ if include.get("cpa"):
628
+ self._add_file_to_archive(archive, DATA_DIR / "cpa_config.json", "data/cpa_config.json")
629
+ if include.get("sub2api"):
630
+ self._add_file_to_archive(archive, DATA_DIR / "sub2api_config.json", "data/sub2api_config.json")
631
+ if include.get("logs"):
632
+ self._add_file_to_archive(archive, DATA_DIR / "logs.jsonl", "data/logs.jsonl")
633
+ if include.get("image_tasks"):
634
+ self._add_file_to_archive(archive, DATA_DIR / "image_tasks.json", "data/image_tasks.json")
635
+ self._add_file_to_archive(archive, IMAGE_INDEX_FILE, "data/image_index.json")
636
+ if include.get("accounts_snapshot"):
637
+ self._add_bytes_to_archive(
638
+ archive,
639
+ "snapshots/accounts.json",
640
+ _json_bytes(config.get_storage_backend().load_accounts()),
641
+ )
642
+ if include.get("auth_keys_snapshot"):
643
+ self._add_bytes_to_archive(
644
+ archive,
645
+ "snapshots/auth_keys.json",
646
+ _json_bytes(config.get_storage_backend().load_auth_keys()),
647
+ )
648
+ if include.get("images"):
649
+ self._add_file_to_archive(archive, TAGS_FILE, "data/image_tags.json")
650
+ self._add_directory_to_archive(archive, config.images_dir, "data/images")
651
+ return buffer.getvalue()
652
+
653
+ def _add_bytes_to_archive(self, archive: tarfile.TarFile, name: str, payload: bytes) -> None:
654
+ info = tarfile.TarInfo(name=name)
655
+ info.size = len(payload)
656
+ info.mtime = int(_utc_now().timestamp())
657
+ archive.addfile(info, io.BytesIO(payload))
658
+
659
+ def _add_file_to_archive(self, archive: tarfile.TarFile, source: Path, arcname: str) -> None:
660
+ if not source.exists() or not source.is_file():
661
+ return
662
+ archive.add(source, arcname=arcname)
663
+
664
+ def _add_directory_to_archive(self, archive: tarfile.TarFile, source_dir: Path, arcname_root: str) -> None:
665
+ if not source_dir.exists() or not source_dir.is_dir():
666
+ return
667
+ for path in sorted(source_dir.rglob("*")):
668
+ if path.is_file():
669
+ relative = path.relative_to(source_dir).as_posix()
670
+ archive.add(path, arcname=f"{arcname_root}/{relative}")
671
+
672
+
673
+ backup_service = BackupService()
services/config.py ADDED
@@ -0,0 +1,390 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import json
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+ import time
9
+
10
+ from services.storage.base import StorageBackend
11
+
12
+ BASE_DIR = Path(__file__).resolve().parents[1]
13
+ DATA_DIR = BASE_DIR / "data"
14
+ CONFIG_FILE = BASE_DIR / "config.json"
15
+ VERSION_FILE = BASE_DIR / "VERSION"
16
+ BACKUP_STATE_FILE = DATA_DIR / "backup_state.json"
17
+
18
+ DEFAULT_BACKUP_INCLUDE = {
19
+ "config": True,
20
+ "register": True,
21
+ "cpa": True,
22
+ "sub2api": True,
23
+ "logs": True,
24
+ "image_tasks": True,
25
+ "accounts_snapshot": True,
26
+ "auth_keys_snapshot": True,
27
+ "images": False,
28
+ }
29
+
30
+ DEFAULT_IMAGE_STORAGE = {
31
+ "enabled": False,
32
+ "mode": "local",
33
+ "webdav_url": "",
34
+ "webdav_username": "",
35
+ "webdav_password": "",
36
+ "webdav_root_path": "chatgpt2api/images",
37
+ "public_base_url": "",
38
+ }
39
+
40
+
41
+ def _normalize_bool(value: object, default: bool = False) -> bool:
42
+ if isinstance(value, str):
43
+ lowered = value.strip().lower()
44
+ if lowered in {"1", "true", "yes", "on"}:
45
+ return True
46
+ if lowered in {"0", "false", "no", "off"}:
47
+ return False
48
+ if value is None:
49
+ return default
50
+ return bool(value)
51
+
52
+
53
+ def _normalize_positive_int(value: object, default: int, minimum: int = 0) -> int:
54
+ try:
55
+ normalized = int(value)
56
+ except (TypeError, ValueError):
57
+ normalized = default
58
+ return max(minimum, normalized)
59
+
60
+
61
+ def _normalize_backup_include(value: object) -> dict[str, bool]:
62
+ source = value if isinstance(value, dict) else {}
63
+ normalized = dict(DEFAULT_BACKUP_INCLUDE)
64
+ for key in normalized:
65
+ normalized[key] = _normalize_bool(source.get(key), normalized[key])
66
+ return normalized
67
+
68
+
69
+ def _normalize_backup_settings(value: object) -> dict[str, object]:
70
+ source = value if isinstance(value, dict) else {}
71
+ return {
72
+ "enabled": _normalize_bool(source.get("enabled"), False),
73
+ "provider": "cloudflare_r2",
74
+ "account_id": str(source.get("account_id") or "").strip(),
75
+ "access_key_id": str(source.get("access_key_id") or "").strip(),
76
+ "secret_access_key": str(source.get("secret_access_key") or "").strip(),
77
+ "bucket": str(source.get("bucket") or "").strip(),
78
+ "prefix": str(source.get("prefix") or "backups").strip().strip("/") or "backups",
79
+ "interval_minutes": _normalize_positive_int(source.get("interval_minutes"), 360, 1),
80
+ "rotation_keep": _normalize_positive_int(source.get("rotation_keep"), 10, 0),
81
+ "encrypt": _normalize_bool(source.get("encrypt"), False),
82
+ "passphrase": str(source.get("passphrase") or "").strip(),
83
+ "include": _normalize_backup_include(source.get("include")),
84
+ }
85
+
86
+
87
+ def _normalize_backup_state(value: object) -> dict[str, object]:
88
+ source = value if isinstance(value, dict) else {}
89
+ return {
90
+ "last_started_at": str(source.get("last_started_at") or "").strip() or None,
91
+ "last_finished_at": str(source.get("last_finished_at") or "").strip() or None,
92
+ "last_status": str(source.get("last_status") or "idle").strip() or "idle",
93
+ "last_error": str(source.get("last_error") or "").strip() or None,
94
+ "last_object_key": str(source.get("last_object_key") or "").strip() or None,
95
+ }
96
+
97
+
98
+ def _normalize_image_storage_settings(value: object) -> dict[str, object]:
99
+ source = value if isinstance(value, dict) else {}
100
+ mode = str(source.get("mode") or "local").strip().lower()
101
+ if mode not in {"local", "webdav", "both"}:
102
+ mode = "local"
103
+ enabled = _normalize_bool(source.get("enabled"), False)
104
+ if not enabled:
105
+ mode = "local"
106
+ root_path = str(source.get("webdav_root_path") or DEFAULT_IMAGE_STORAGE["webdav_root_path"]).strip().strip("/")
107
+ return {
108
+ "enabled": enabled,
109
+ "mode": mode,
110
+ "webdav_url": str(source.get("webdav_url") or "").strip().rstrip("/"),
111
+ "webdav_username": str(source.get("webdav_username") or "").strip(),
112
+ "webdav_password": str(source.get("webdav_password") or "").strip(),
113
+ "webdav_root_path": root_path or str(DEFAULT_IMAGE_STORAGE["webdav_root_path"]),
114
+ "public_base_url": str(source.get("public_base_url") or "").strip().rstrip("/"),
115
+ }
116
+
117
+
118
+ def _validate_image_storage_settings(settings: dict[str, object]) -> None:
119
+ if not _normalize_bool(settings.get("enabled"), False):
120
+ return
121
+ if not str(settings.get("webdav_url") or "").strip():
122
+ raise ValueError("启用 WebDAV 图片存储后必须填写 WebDAV URL")
123
+ if not str(settings.get("webdav_password") or "").strip():
124
+ raise ValueError("启用 WebDAV 图片存储后必须填写 WebDAV 密码")
125
+
126
+
127
+ @dataclass(frozen=True)
128
+ class LoadedSettings:
129
+ auth_key: str
130
+ refresh_account_interval_minute: int
131
+
132
+
133
+ def _normalize_auth_key(value: object) -> str:
134
+ return str(value or "").strip()
135
+
136
+
137
+ def _is_invalid_auth_key(value: object) -> bool:
138
+ return _normalize_auth_key(value) == ""
139
+
140
+
141
+ def _read_json_object(path: Path, *, name: str) -> dict[str, object]:
142
+ if not path.exists():
143
+ return {}
144
+ if path.is_dir():
145
+ print(
146
+ f"Warning: {name} at '{path}' is a directory, ignoring it and falling back to other configuration sources.",
147
+ file=sys.stderr,
148
+ )
149
+ return {}
150
+ try:
151
+ data = json.loads(path.read_text(encoding="utf-8"))
152
+ except Exception:
153
+ return {}
154
+ return data if isinstance(data, dict) else {}
155
+
156
+
157
+ def _load_settings() -> LoadedSettings:
158
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
159
+ raw_config = _read_json_object(CONFIG_FILE, name="config.json")
160
+ auth_key = _normalize_auth_key(os.getenv("CHATGPT2API_AUTH_KEY") or raw_config.get("auth-key"))
161
+ if _is_invalid_auth_key(auth_key):
162
+ raise ValueError(
163
+ "❌ auth-key 未设置!\n"
164
+ "请在环境变量 CHATGPT2API_AUTH_KEY 中设置,或者在 config.json 中填写 auth-key。"
165
+ )
166
+
167
+ try:
168
+ refresh_interval = int(raw_config.get("refresh_account_interval_minute", 5))
169
+ except (TypeError, ValueError):
170
+ refresh_interval = 5
171
+
172
+ return LoadedSettings(
173
+ auth_key=auth_key,
174
+ refresh_account_interval_minute=refresh_interval,
175
+ )
176
+
177
+
178
+ class ConfigStore:
179
+ def __init__(self, path: Path):
180
+ self.path = path
181
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
182
+ self.data = self._load()
183
+ self._storage_backend: StorageBackend | None = None
184
+ if _is_invalid_auth_key(self.auth_key):
185
+ raise ValueError(
186
+ "❌ auth-key 未设置!\n"
187
+ "请按以下任意一种方式解决:\n"
188
+ "1. 在 Render 的 Environment 变量中添加:\n"
189
+ " CHATGPT2API_AUTH_KEY = your_real_auth_key\n"
190
+ "2. 或者在 config.json 中填写:\n"
191
+ ' "auth-key": "your_real_auth_key"'
192
+ )
193
+
194
+ def _load(self) -> dict[str, object]:
195
+ return _read_json_object(self.path, name="config.json")
196
+
197
+ def _save(self) -> None:
198
+ self.path.write_text(json.dumps(self.data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
199
+
200
+ @property
201
+ def auth_key(self) -> str:
202
+ return _normalize_auth_key(os.getenv("CHATGPT2API_AUTH_KEY") or self.data.get("auth-key"))
203
+
204
+ @property
205
+ def accounts_file(self) -> Path:
206
+ return DATA_DIR / "accounts.json"
207
+
208
+ @property
209
+ def refresh_account_interval_minute(self) -> int:
210
+ try:
211
+ return int(self.data.get("refresh_account_interval_minute", 5))
212
+ except (TypeError, ValueError):
213
+ return 5
214
+
215
+ @property
216
+ def image_retention_days(self) -> int:
217
+ try:
218
+ return max(1, int(self.data.get("image_retention_days", 30)))
219
+ except (TypeError, ValueError):
220
+ return 30
221
+
222
+ @property
223
+ def image_poll_timeout_secs(self) -> int:
224
+ try:
225
+ return max(1, int(self.data.get("image_poll_timeout_secs", 120)))
226
+ except (TypeError, ValueError):
227
+ return 120
228
+
229
+ @property
230
+ def image_poll_interval_secs(self) -> float:
231
+ try:
232
+ return max(0.5, float(self.data.get("image_poll_interval_secs", 10.0)))
233
+ except (TypeError, ValueError):
234
+ return 10.0
235
+
236
+ @property
237
+ def image_poll_initial_wait_secs(self) -> float:
238
+ """Image generation upstream takes ~30s; polling immediately wastes requests
239
+ and trips a transient 429. Default 10s gives the conversation document time
240
+ to commit before the first poll."""
241
+ try:
242
+ return max(0.0, float(self.data.get("image_poll_initial_wait_secs", 10.0)))
243
+ except (TypeError, ValueError):
244
+ return 10.0
245
+
246
+ @property
247
+ def image_account_concurrency(self) -> int:
248
+ try:
249
+ return max(1, int(self.data.get("image_account_concurrency", 3)))
250
+ except (TypeError, ValueError):
251
+ return 3
252
+
253
+ @property
254
+ def auto_remove_invalid_accounts(self) -> bool:
255
+ value = self.data.get("auto_remove_invalid_accounts", False)
256
+ if isinstance(value, str):
257
+ return value.strip().lower() in {"1", "true", "yes", "on"}
258
+ return bool(value)
259
+
260
+ @property
261
+ def auto_remove_rate_limited_accounts(self) -> bool:
262
+ value = self.data.get("auto_remove_rate_limited_accounts", False)
263
+ if isinstance(value, str):
264
+ return value.strip().lower() in {"1", "true", "yes", "on"}
265
+ return bool(value)
266
+
267
+ @property
268
+ def log_levels(self) -> list[str]:
269
+ levels = self.data.get("log_levels")
270
+ if not isinstance(levels, list):
271
+ return []
272
+ allowed = {"debug", "info", "warning", "error"}
273
+ return [level for item in levels if (level := str(item or "").strip().lower()) in allowed]
274
+
275
+ @property
276
+ def sensitive_words(self) -> list[str]:
277
+ words = self.data.get("sensitive_words")
278
+ return [word for item in words if (word := str(item or "").strip())] if isinstance(words, list) else []
279
+
280
+ @property
281
+ def ai_review(self) -> dict[str, object]:
282
+ value = self.data.get("ai_review")
283
+ return value if isinstance(value, dict) else {}
284
+
285
+ @property
286
+ def global_system_prompt(self) -> str:
287
+ return str(self.data.get("global_system_prompt") or "").strip()
288
+
289
+ @property
290
+ def images_dir(self) -> Path:
291
+ path = DATA_DIR / "images"
292
+ path.mkdir(parents=True, exist_ok=True)
293
+ return path
294
+
295
+ @property
296
+ def image_thumbnails_dir(self) -> Path:
297
+ path = DATA_DIR / "image_thumbnails"
298
+ path.mkdir(parents=True, exist_ok=True)
299
+ return path
300
+
301
+ def cleanup_old_images(self) -> int:
302
+ cutoff = time.time() - self.image_retention_days * 86400
303
+ removed = 0
304
+ for path in self.images_dir.rglob("*"):
305
+ if path.is_file() and path.stat().st_mtime < cutoff:
306
+ path.unlink()
307
+ removed += 1
308
+ for path in sorted((p for p in self.images_dir.rglob("*") if p.is_dir()), key=lambda p: len(p.parts), reverse=True):
309
+ try:
310
+ path.rmdir()
311
+ except OSError:
312
+ pass
313
+ return removed
314
+
315
+ @property
316
+ def base_url(self) -> str:
317
+ return str(
318
+ os.getenv("CHATGPT2API_BASE_URL")
319
+ or self.data.get("base_url")
320
+ or ""
321
+ ).strip().rstrip("/")
322
+
323
+ @property
324
+ def app_version(self) -> str:
325
+ try:
326
+ value = VERSION_FILE.read_text(encoding="utf-8").strip()
327
+ except FileNotFoundError:
328
+ return "0.0.0"
329
+ return value or "0.0.0"
330
+
331
+ def get(self) -> dict[str, object]:
332
+ data = dict(self.data)
333
+ data["refresh_account_interval_minute"] = self.refresh_account_interval_minute
334
+ data["image_retention_days"] = self.image_retention_days
335
+ data["image_poll_timeout_secs"] = self.image_poll_timeout_secs
336
+ data["image_poll_interval_secs"] = self.image_poll_interval_secs
337
+ data["image_poll_initial_wait_secs"] = self.image_poll_initial_wait_secs
338
+ data["image_account_concurrency"] = self.image_account_concurrency
339
+ data["auto_remove_invalid_accounts"] = self.auto_remove_invalid_accounts
340
+ data["auto_remove_rate_limited_accounts"] = self.auto_remove_rate_limited_accounts
341
+ data["log_levels"] = self.log_levels
342
+ data["sensitive_words"] = self.sensitive_words
343
+ data["ai_review"] = self.ai_review
344
+ data["global_system_prompt"] = self.global_system_prompt
345
+ data["backup"] = self.get_backup_settings()
346
+ data["image_storage"] = self.get_image_storage_settings()
347
+ data.pop("auth-key", None)
348
+ return data
349
+
350
+ def get_proxy_settings(self) -> str:
351
+ return str(self.data.get("proxy") or "").strip()
352
+
353
+ def update(self, data: dict[str, object]) -> dict[str, object]:
354
+ next_data = dict(self.data)
355
+ next_data.update(dict(data or {}))
356
+ if "backup" in next_data:
357
+ next_data["backup"] = _normalize_backup_settings(next_data.get("backup"))
358
+ if "image_storage" in next_data:
359
+ next_data["image_storage"] = _normalize_image_storage_settings(next_data.get("image_storage"))
360
+ _validate_image_storage_settings(next_data["image_storage"])
361
+ next_data.pop("backup_state", None)
362
+ self.data = next_data
363
+ self._save()
364
+ return self.get()
365
+
366
+ def get_backup_settings(self) -> dict[str, object]:
367
+ return _normalize_backup_settings(self.data.get("backup"))
368
+
369
+ def get_image_storage_settings(self) -> dict[str, object]:
370
+ return _normalize_image_storage_settings(self.data.get("image_storage"))
371
+
372
+ def get_storage_backend(self) -> StorageBackend:
373
+ """获取存储后端实例(单例)"""
374
+ if self._storage_backend is None:
375
+ from services.storage.factory import create_storage_backend
376
+ self._storage_backend = create_storage_backend(DATA_DIR)
377
+ return self._storage_backend
378
+
379
+
380
+ def load_backup_state() -> dict[str, object]:
381
+ return _normalize_backup_state(_read_json_object(BACKUP_STATE_FILE, name="backup_state.json"))
382
+
383
+
384
+ def save_backup_state(state: dict[str, object]) -> dict[str, object]:
385
+ normalized = _normalize_backup_state(state)
386
+ BACKUP_STATE_FILE.write_text(json.dumps(normalized, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
387
+ return normalized
388
+
389
+
390
+ config = ConfigStore(CONFIG_FILE)
services/content_filter.py ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ from curl_cffi import requests
6
+ from fastapi import HTTPException
7
+
8
+ from services.config import config
9
+ from services.proxy_service import proxy_settings
10
+ from utils.log import logger
11
+
12
+ DEFAULT_REVIEW_PROMPT = "判断用户请求是否允许。只回答 ALLOW 或 REJECT。"
13
+
14
+ # Strip base64 image data URIs before review: a text-only review model can't
15
+ # analyze image bytes, and a single inlined image easily blows past the token
16
+ # budget of the upstream review service.
17
+ _BASE64_DATA_URI = re.compile(r"data:[\w/.+;-]+;base64,[A-Za-z0-9+/=]+")
18
+
19
+ # Cap aligned to the upstream review service's max context. If text still
20
+ # exceeds the cap after base64 stripping, keep equal head/tail halves so both
21
+ # the system prompt and the most recent user message survive.
22
+ _MAX_REVIEW_TEXT_LEN = 100_000
23
+ _TRUNCATION_MARKER = "\n…[truncated]…\n"
24
+
25
+
26
+ def _text(value: object) -> str:
27
+ if isinstance(value, str):
28
+ return value
29
+ if isinstance(value, list):
30
+ return "\n".join(_text(item) for item in value)
31
+ if isinstance(value, dict):
32
+ return "\n".join(_text(value.get(key)) for key in ("text", "input_text", "content", "input", "instructions", "system", "prompt"))
33
+ return ""
34
+
35
+
36
+ def request_text(*values: object) -> str:
37
+ return "\n".join(part for value in values if (part := _text(value).strip()))
38
+
39
+
40
+ def _sanitize_for_review(text: str) -> tuple[str, dict[str, int]]:
41
+ """Strip base64 data URIs and truncate to the review-service context limit.
42
+
43
+ Returns (sanitized_text, stats) where stats carries base64_blocks_stripped
44
+ and truncated_chars so callers can emit structured logs.
45
+ """
46
+ sanitized, base64_blocks_stripped = _BASE64_DATA_URI.subn("[image]", text)
47
+ truncated_chars = 0
48
+ if len(sanitized) > _MAX_REVIEW_TEXT_LEN:
49
+ # Reserve marker space so the result stays within the cap.
50
+ half = (_MAX_REVIEW_TEXT_LEN - len(_TRUNCATION_MARKER)) // 2
51
+ truncated_chars = len(sanitized) - 2 * half
52
+ sanitized = sanitized[:half] + _TRUNCATION_MARKER + sanitized[-half:]
53
+ stats = {
54
+ "base64_blocks_stripped": base64_blocks_stripped,
55
+ "truncated_chars": truncated_chars,
56
+ }
57
+ return sanitized, stats
58
+
59
+
60
+ def _extract_review_decision(data: object) -> str | None:
61
+ """Defensively pull the decision text out of the review service response.
62
+
63
+ Returns None when the response shape doesn't match the OpenAI chat-completion
64
+ contract (e.g. {"error": ...} with no choices). The caller treats None as
65
+ "undecided" and applies the configured fail-open policy.
66
+ """
67
+ if not isinstance(data, dict):
68
+ return None
69
+ choices = data.get("choices")
70
+ if not isinstance(choices, list) or not choices:
71
+ return None
72
+ first = choices[0]
73
+ if not isinstance(first, dict):
74
+ return None
75
+ message = first.get("message")
76
+ if not isinstance(message, dict):
77
+ return None
78
+ content = message.get("content")
79
+ if content is None:
80
+ return None
81
+ return str(content).strip().lower()
82
+
83
+
84
+ def _is_allow_decision(decision: str) -> bool:
85
+ return decision.startswith(("allow", "pass", "true", "yes", "通过", "允许", "安全"))
86
+
87
+
88
+ def _is_reject_decision(decision: str) -> bool:
89
+ return decision.startswith(("reject", "deny", "block", "false", "no", "拒绝", "不允许", "违规", "禁止"))
90
+
91
+
92
+ def _resolve_fail_open(review: dict) -> bool:
93
+ """Resolve fail_open from review config. Defaults to True."""
94
+ value = review.get("fail_open")
95
+ if value is None:
96
+ return True
97
+ if isinstance(value, bool):
98
+ return value
99
+ if isinstance(value, str):
100
+ return value.strip().lower() in {"1", "true", "yes", "on"}
101
+ return bool(value)
102
+
103
+
104
+ def check_request(text: str) -> None:
105
+ text = str(text or "")
106
+ if not text.strip():
107
+ return
108
+ # Local sensitive-word match runs on the raw text (cheap, no network).
109
+ for word in config.sensitive_words:
110
+ if word in text:
111
+ raise HTTPException(status_code=400, detail={"error": "检测到敏感词,拒绝本次任务"})
112
+ review = config.ai_review
113
+ if not review.get("enabled"):
114
+ return
115
+ base_url = str(review.get("base_url") or "").strip().rstrip("/")
116
+ api_key = str(review.get("api_key") or "").strip()
117
+ model = str(review.get("model") or "").strip()
118
+ if not base_url or not api_key or not model:
119
+ raise HTTPException(status_code=400, detail={"error": "ai review config is incomplete"})
120
+
121
+ fail_open = _resolve_fail_open(review)
122
+
123
+ review_text, sanitize_stats = _sanitize_for_review(text)
124
+ if sanitize_stats["base64_blocks_stripped"] or sanitize_stats["truncated_chars"]:
125
+ logger.info({
126
+ "event": "ai_review_text_sanitized",
127
+ "original_text_len": len(text),
128
+ "review_text_len": len(review_text),
129
+ **sanitize_stats,
130
+ })
131
+ prompt = str(review.get("prompt") or DEFAULT_REVIEW_PROMPT).strip()
132
+ content = f"{prompt}\n\n用户请求:\n{review_text}\n\n只回答 ALLOW 或 REJECT。"
133
+
134
+ # fail_open=True (default): on upstream failure or ambiguous reply, let the
135
+ # request through. The review is a soft safety net; one missed review is
136
+ # preferable to a 5xx storm when the review service is flaky. Set
137
+ # config.ai_review.fail_open=false for strict-compliance deployments.
138
+ def _on_failure(event_payload: dict) -> None:
139
+ logger.warning(event_payload)
140
+ if not fail_open:
141
+ raise HTTPException(
142
+ status_code=503,
143
+ detail={"error": "AI 审核服务暂时不可用,请稍后重试"},
144
+ )
145
+
146
+ try:
147
+ response = requests.post(
148
+ f"{base_url}/v1/chat/completions",
149
+ headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
150
+ json={"model": model, "messages": [{"role": "user", "content": content}], "temperature": 0},
151
+ timeout=60,
152
+ **proxy_settings.build_session_kwargs(),
153
+ )
154
+ except Exception as exc:
155
+ _on_failure({
156
+ "event": "ai_review_request_failed",
157
+ "error": str(exc),
158
+ "error_type": exc.__class__.__name__,
159
+ "review_text_len": len(review_text),
160
+ "original_text_len": len(text),
161
+ })
162
+ return
163
+
164
+ try:
165
+ data = response.json()
166
+ except Exception as exc:
167
+ _on_failure({
168
+ "event": "ai_review_response_not_json",
169
+ "status_code": response.status_code,
170
+ "body_preview": str(response.text or "")[:200],
171
+ "error": str(exc),
172
+ })
173
+ return
174
+
175
+ decision = _extract_review_decision(data)
176
+ if decision is None:
177
+ _on_failure({
178
+ "event": "ai_review_malformed_response",
179
+ "status_code": response.status_code,
180
+ "body_preview": str(data)[:300],
181
+ "review_text_len": len(review_text),
182
+ "original_text_len": len(text),
183
+ })
184
+ return
185
+
186
+ if _is_allow_decision(decision):
187
+ return
188
+ if _is_reject_decision(decision):
189
+ raise HTTPException(status_code=400, detail={"error": "AI 审核未通过,拒绝本次任务"})
190
+ # Ambiguous decisions (e.g. "MAYBE", empty content) fall back to fail-open policy.
191
+ _on_failure({
192
+ "event": "ai_review_ambiguous_decision",
193
+ "decision": decision[:100],
194
+ "review_text_len": len(review_text),
195
+ })
196
+ return
services/cpa_service.py ADDED
@@ -0,0 +1,315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CLIProxyAPI integration for browsing remote auth files and importing selected tokens."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import threading
7
+ import uuid
8
+ from concurrent.futures import ThreadPoolExecutor, as_completed
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from threading import Lock
12
+
13
+ from curl_cffi.requests import Session
14
+
15
+ from services.account_service import account_service
16
+ from services.config import DATA_DIR
17
+ from services.proxy_service import proxy_settings
18
+
19
+
20
+ CPA_CONFIG_FILE = DATA_DIR / "cpa_config.json"
21
+
22
+
23
+ def _new_id() -> str:
24
+ return uuid.uuid4().hex[:12]
25
+
26
+
27
+ def _now_iso() -> str:
28
+ return datetime.now(timezone.utc).isoformat()
29
+
30
+
31
+ def _normalize_import_job(raw: object, *, fail_unfinished: bool) -> dict | None:
32
+ if not isinstance(raw, dict):
33
+ return None
34
+ status = str(raw.get("status") or "failed").strip() or "failed"
35
+ if fail_unfinished and status in {"pending", "running"}:
36
+ status = "failed"
37
+ return {
38
+ "job_id": str(raw.get("job_id") or uuid.uuid4().hex).strip(),
39
+ "status": status,
40
+ "created_at": str(raw.get("created_at") or _now_iso()).strip() or _now_iso(),
41
+ "updated_at": str(raw.get("updated_at") or raw.get("created_at") or _now_iso()).strip() or _now_iso(),
42
+ "total": int(raw.get("total") or 0),
43
+ "completed": int(raw.get("completed") or 0),
44
+ "added": int(raw.get("added") or 0),
45
+ "skipped": int(raw.get("skipped") or 0),
46
+ "refreshed": int(raw.get("refreshed") or 0),
47
+ "failed": int(raw.get("failed") or 0),
48
+ "errors": raw.get("errors") if isinstance(raw.get("errors"), list) else [],
49
+ }
50
+
51
+
52
+ def _normalize_pool(raw: dict) -> dict:
53
+ return {
54
+ "id": str(raw.get("id") or _new_id()).strip(),
55
+ "name": str(raw.get("name") or "").strip(),
56
+ "base_url": str(raw.get("base_url") or "").strip(),
57
+ "secret_key": str(raw.get("secret_key") or "").strip(),
58
+ "import_job": _normalize_import_job(raw.get("import_job"), fail_unfinished=True),
59
+ }
60
+
61
+
62
+ def _management_headers(secret_key: str) -> dict[str, str]:
63
+ return {
64
+ "Authorization": f"Bearer {secret_key}",
65
+ "Accept": "application/json",
66
+ }
67
+
68
+
69
+ class CPAConfig:
70
+ def __init__(self, store_file: Path):
71
+ self._store_file = store_file
72
+ self._lock = Lock()
73
+ self._pools: list[dict] = self._load()
74
+
75
+ def _load(self) -> list[dict]:
76
+ if not self._store_file.exists():
77
+ return []
78
+ try:
79
+ raw = json.loads(self._store_file.read_text(encoding="utf-8"))
80
+ if isinstance(raw, dict) and "base_url" in raw:
81
+ pool = _normalize_pool(raw)
82
+ return [pool] if pool["base_url"] else []
83
+ if isinstance(raw, list):
84
+ return [_normalize_pool(item) for item in raw if isinstance(item, dict)]
85
+ except Exception:
86
+ pass
87
+ return []
88
+
89
+ def _save(self) -> None:
90
+ self._store_file.parent.mkdir(parents=True, exist_ok=True)
91
+ self._store_file.write_text(json.dumps(self._pools, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
92
+
93
+ def list_pools(self) -> list[dict]:
94
+ with self._lock:
95
+ return [dict(pool) for pool in self._pools]
96
+
97
+ def get_pool(self, pool_id: str) -> dict | None:
98
+ with self._lock:
99
+ for pool in self._pools:
100
+ if pool["id"] == pool_id:
101
+ return dict(pool)
102
+ return None
103
+
104
+ def add_pool(self, name: str, base_url: str, secret_key: str) -> dict:
105
+ pool = _normalize_pool({"id": _new_id(), "name": name, "base_url": base_url, "secret_key": secret_key})
106
+ with self._lock:
107
+ self._pools.append(pool)
108
+ self._save()
109
+ return dict(pool)
110
+
111
+ def update_pool(self, pool_id: str, updates: dict) -> dict | None:
112
+ with self._lock:
113
+ for index, pool in enumerate(self._pools):
114
+ if pool["id"] != pool_id:
115
+ continue
116
+ merged = {**pool, **{key: value for key, value in updates.items() if value is not None}, "id": pool_id}
117
+ self._pools[index] = _normalize_pool(merged)
118
+ self._save()
119
+ return dict(self._pools[index])
120
+ return None
121
+
122
+ def delete_pool(self, pool_id: str) -> bool:
123
+ with self._lock:
124
+ before = len(self._pools)
125
+ self._pools = [pool for pool in self._pools if pool["id"] != pool_id]
126
+ if len(self._pools) < before:
127
+ self._save()
128
+ return True
129
+ return False
130
+
131
+ def set_import_job(self, pool_id: str, import_job: dict | None) -> dict | None:
132
+ with self._lock:
133
+ for index, pool in enumerate(self._pools):
134
+ if pool["id"] != pool_id:
135
+ continue
136
+ next_pool = dict(pool)
137
+ next_pool["import_job"] = _normalize_import_job(import_job, fail_unfinished=False)
138
+ self._pools[index] = next_pool
139
+ self._save()
140
+ return dict(next_pool)
141
+ return None
142
+
143
+ def get_import_job(self, pool_id: str) -> dict | None:
144
+ with self._lock:
145
+ for pool in self._pools:
146
+ if pool["id"] == pool_id:
147
+ job = pool.get("import_job")
148
+ return dict(job) if isinstance(job, dict) else None
149
+ return None
150
+
151
+
152
+ def list_remote_files(pool: dict) -> list[dict]:
153
+ base_url = str(pool.get("base_url") or "").strip()
154
+ secret_key = str(pool.get("secret_key") or "").strip()
155
+ if not base_url or not secret_key:
156
+ return []
157
+
158
+ url = f"{base_url.rstrip('/')}/v0/management/auth-files"
159
+ session = Session(**proxy_settings.build_session_kwargs(verify=True))
160
+ try:
161
+ response = session.get(url, headers=_management_headers(secret_key), timeout=30)
162
+ if not response.ok:
163
+ raise RuntimeError(f"remote list failed: HTTP {response.status_code}")
164
+ payload = response.json()
165
+ finally:
166
+ session.close()
167
+
168
+ files = payload.get("files") if isinstance(payload, dict) else None
169
+ if not isinstance(files, list):
170
+ raise RuntimeError("remote list payload is invalid")
171
+
172
+ items: list[dict] = []
173
+ for item in files:
174
+ if not isinstance(item, dict):
175
+ continue
176
+ name = str(item.get("name") or "").strip()
177
+ email = str(item.get("email") or item.get("account") or "").strip()
178
+ if not name:
179
+ continue
180
+ items.append({"name": name, "email": email})
181
+ return items
182
+
183
+
184
+ def fetch_remote_access_token(pool: dict, file_name: str) -> tuple[str | None, str | None]:
185
+ base_url = str(pool.get("base_url") or "").strip()
186
+ secret_key = str(pool.get("secret_key") or "").strip()
187
+ file_name = str(file_name or "").strip()
188
+ if not base_url or not secret_key or not file_name:
189
+ return None, "invalid request"
190
+
191
+ url = f"{base_url.rstrip('/')}/v0/management/auth-files/download"
192
+ session = Session(**proxy_settings.build_session_kwargs(verify=True))
193
+ try:
194
+ response = session.get(url, headers=_management_headers(secret_key), params={"name": file_name}, timeout=30)
195
+ if not response.ok:
196
+ return None, f"HTTP {response.status_code}"
197
+ payload = response.json()
198
+ except Exception as exc:
199
+ return None, str(exc)
200
+ finally:
201
+ session.close()
202
+
203
+ if not isinstance(payload, dict):
204
+ return None, "invalid payload"
205
+
206
+ access_token = str(payload.get("access_token") or "").strip()
207
+ if not access_token:
208
+ return None, "missing access_token"
209
+ return access_token, None
210
+
211
+
212
+ class CPAImportService:
213
+ def __init__(self, cpa_config: CPAConfig):
214
+ self._config = cpa_config
215
+
216
+ def start_import(self, pool: dict, selected_files: list[str]) -> dict:
217
+ names = [str(name or "").strip() for name in selected_files if str(name or "").strip()]
218
+ if not names:
219
+ raise ValueError("selected files is required")
220
+
221
+ pool_id = str(pool.get("id") or "").strip()
222
+ job = {
223
+ "job_id": uuid.uuid4().hex,
224
+ "status": "pending",
225
+ "created_at": _now_iso(),
226
+ "updated_at": _now_iso(),
227
+ "total": len(names),
228
+ "completed": 0,
229
+ "added": 0,
230
+ "skipped": 0,
231
+ "refreshed": 0,
232
+ "failed": 0,
233
+ "errors": [],
234
+ }
235
+ saved_pool = self._config.set_import_job(pool_id, job)
236
+ if saved_pool is None:
237
+ raise ValueError("pool not found")
238
+
239
+ thread = threading.Thread(
240
+ target=self._run_import,
241
+ args=(pool_id, pool, names),
242
+ name=f"cpa-import-{pool_id}",
243
+ daemon=True,
244
+ )
245
+ thread.start()
246
+ return dict(saved_pool.get("import_job") or job)
247
+
248
+ def _update_job(self, pool_id: str, **updates) -> dict | None:
249
+ current = self._config.get_import_job(pool_id)
250
+ if current is None:
251
+ return None
252
+ next_job = {**current, **updates, "updated_at": _now_iso()}
253
+ pool = self._config.set_import_job(pool_id, next_job)
254
+ if pool is None:
255
+ return None
256
+ job = pool.get("import_job")
257
+ return dict(job) if isinstance(job, dict) else None
258
+
259
+ def _append_error(self, pool_id: str, file_name: str, message: str) -> None:
260
+ current = self._config.get_import_job(pool_id)
261
+ if current is None:
262
+ return
263
+ errors = list(current.get("errors") or [])
264
+ errors.append({"name": file_name, "error": message})
265
+ self._update_job(pool_id, errors=errors, failed=len(errors))
266
+
267
+ def _run_import(self, pool_id: str, pool: dict, names: list[str]) -> None:
268
+ self._update_job(pool_id, status="running")
269
+
270
+ tokens: list[str] = []
271
+ max_workers = min(16, max(1, len(names)))
272
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
273
+ future_map = {executor.submit(fetch_remote_access_token, pool, name): name for name in names}
274
+ for future in as_completed(future_map):
275
+ file_name = future_map[future]
276
+ try:
277
+ token, error = future.result()
278
+ except Exception as exc:
279
+ token, error = None, str(exc)
280
+
281
+ if token:
282
+ tokens.append(token)
283
+ else:
284
+ self._append_error(pool_id, file_name, error or "unknown error")
285
+
286
+ current = self._config.get_import_job(pool_id) or {}
287
+ failed = len(current.get("errors") or [])
288
+ self._update_job(pool_id, completed=int(current.get("completed") or 0) + 1, failed=failed)
289
+
290
+ if not tokens:
291
+ current = self._config.get_import_job(pool_id) or {}
292
+ self._update_job(
293
+ pool_id,
294
+ status="failed",
295
+ completed=int(current.get("total") or 0),
296
+ failed=len(current.get("errors") or []),
297
+ )
298
+ return
299
+
300
+ add_result = account_service.add_accounts(tokens)
301
+ refresh_result = account_service.refresh_accounts(tokens)
302
+ current = self._config.get_import_job(pool_id) or {}
303
+ self._update_job(
304
+ pool_id,
305
+ status="completed",
306
+ completed=len(names),
307
+ added=int(add_result.get("added") or 0),
308
+ skipped=int(add_result.get("skipped") or 0),
309
+ refreshed=int(refresh_result.get("refreshed") or 0),
310
+ failed=len(current.get("errors") or []),
311
+ )
312
+
313
+
314
+ cpa_config = CPAConfig(CPA_CONFIG_FILE)
315
+ cpa_import_service = CPAImportService(cpa_config)
services/image_service.py ADDED
@@ -0,0 +1,356 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import shutil
5
+ import threading
6
+ import time
7
+ import zipfile
8
+ from pathlib import Path
9
+
10
+ from fastapi import HTTPException
11
+ from fastapi.responses import FileResponse, Response
12
+ from PIL import Image, ImageOps
13
+
14
+ from services.config import config
15
+ from services.image_storage_service import image_storage_service
16
+ from services.image_tags_service import load_tags, remove_tags
17
+ from utils.log import logger
18
+
19
+ THUMBNAIL_SIZE = (320, 320)
20
+
21
+
22
+ def _cleanup_empty_dirs(root: Path) -> None:
23
+ for path in sorted((p for p in root.rglob("*") if p.is_dir()), key=lambda p: len(p.parts), reverse=True):
24
+ try:
25
+ path.rmdir()
26
+ except OSError:
27
+ pass
28
+
29
+
30
+ def _safe_relative_path(path: str) -> str:
31
+ value = str(path or "").strip().replace("\\", "/").lstrip("/")
32
+ if not value:
33
+ raise HTTPException(status_code=404, detail="image not found")
34
+ parts = Path(value).parts
35
+ if any(part in {"", ".", ".."} for part in parts):
36
+ raise HTTPException(status_code=404, detail="image not found")
37
+ return Path(*parts).as_posix()
38
+
39
+
40
+ def _safe_image_path(relative_path: str) -> Path:
41
+ rel = _safe_relative_path(relative_path)
42
+ root = config.images_dir.resolve()
43
+ path = (root / rel).resolve()
44
+ try:
45
+ path.relative_to(root)
46
+ except ValueError as exc:
47
+ raise HTTPException(status_code=404, detail="image not found") from exc
48
+ if not path.is_file():
49
+ raise HTTPException(status_code=404, detail="image not found")
50
+ return path
51
+
52
+
53
+ def get_image_response(relative_path: str) -> FileResponse | Response:
54
+ if image_storage_service.has_local(relative_path):
55
+ return FileResponse(_safe_image_path(relative_path))
56
+ return Response(content=image_storage_service.get_bytes(relative_path), media_type="image/png")
57
+
58
+
59
+ def _thumbnail_path(relative_path: str) -> Path:
60
+ rel = _safe_relative_path(relative_path)
61
+ return config.image_thumbnails_dir / f"{rel}.png"
62
+
63
+
64
+ def thumbnail_url(base_url: str, relative_path: str) -> str:
65
+ return f"{base_url.rstrip('/')}/image-thumbnails/{_safe_relative_path(relative_path)}"
66
+
67
+
68
+ def _image_dimensions(path: Path) -> tuple[int, int] | None:
69
+ try:
70
+ with Image.open(path) as image:
71
+ return image.size
72
+ except Exception:
73
+ return None
74
+
75
+
76
+ def ensure_thumbnail(relative_path: str) -> Path:
77
+ target = _thumbnail_path(relative_path)
78
+ source_mtime = 0.0
79
+ source: Path | None = None
80
+ if image_storage_service.has_local(relative_path):
81
+ source = _safe_image_path(relative_path)
82
+ source_mtime = source.stat().st_mtime
83
+ if target.exists() and (not source_mtime or target.stat().st_mtime >= source_mtime):
84
+ return target
85
+
86
+ target.parent.mkdir(parents=True, exist_ok=True)
87
+ try:
88
+ image_source = source if source is not None else io.BytesIO(image_storage_service.get_bytes(relative_path))
89
+ with Image.open(image_source) as image:
90
+ image = ImageOps.exif_transpose(image)
91
+ if image.mode not in {"RGB", "RGBA"}:
92
+ image = image.convert("RGBA" if "A" in image.getbands() else "RGB")
93
+ image.thumbnail(THUMBNAIL_SIZE, Image.Resampling.LANCZOS)
94
+ image.save(target, format="PNG", optimize=True)
95
+ except HTTPException:
96
+ raise
97
+ except Exception as exc:
98
+ raise HTTPException(status_code=422, detail="failed to create thumbnail") from exc
99
+ return target
100
+
101
+
102
+ def get_thumbnail_response(relative_path: str) -> FileResponse:
103
+ return FileResponse(ensure_thumbnail(relative_path))
104
+
105
+
106
+ def get_image_download_response(relative_path: str) -> FileResponse:
107
+ if image_storage_service.has_local(relative_path):
108
+ path = _safe_image_path(relative_path)
109
+ return FileResponse(path, filename=path.name)
110
+ rel = _safe_relative_path(relative_path)
111
+ return Response(
112
+ content=image_storage_service.get_bytes(rel),
113
+ media_type="image/png",
114
+ headers={"Content-Disposition": f'attachment; filename="{Path(rel).name}"'},
115
+ )
116
+
117
+
118
+ def cleanup_image_thumbnails() -> int:
119
+ thumbnails_root = config.image_thumbnails_dir
120
+ removed = 0
121
+ for path in thumbnails_root.rglob("*"):
122
+ if not path.is_file():
123
+ continue
124
+ rel = path.relative_to(thumbnails_root).as_posix()
125
+ if not rel.endswith(".png") or not image_storage_service.exists(rel[:-4]):
126
+ path.unlink()
127
+ removed += 1
128
+ _cleanup_empty_dirs(thumbnails_root)
129
+ return removed
130
+
131
+ def list_images(base_url: str, start_date: str = "", end_date: str = "") -> dict[str, object]:
132
+ config.cleanup_old_images()
133
+ cleanup_image_thumbnails()
134
+ all_tags = load_tags()
135
+ items = [
136
+ {
137
+ **item,
138
+ "url": str(item.get("url") or f"{base_url.rstrip('/')}/images/{item['path']}"),
139
+ "thumbnail_url": thumbnail_url(base_url, str(item["path"])),
140
+ "tags": all_tags.get(str(item["path"]), []),
141
+ }
142
+ for item in image_storage_service.list_items(base_url, start_date, end_date)
143
+ ]
144
+ groups: dict[str, list[dict[str, object]]] = {}
145
+ for item in items:
146
+ groups.setdefault(str(item["date"]), []).append(item)
147
+ return {"items": items, "groups": [{"date": key, "items": value} for key, value in groups.items()]}
148
+
149
+
150
+ def delete_images(paths: list[str] | None = None, start_date: str = "", end_date: str = "", all_matching: bool = False) -> dict[str, int]:
151
+ root = config.images_dir.resolve()
152
+ targets = [
153
+ str(item["path"])
154
+ for item in image_storage_service.list_items("", start_date=start_date, end_date=end_date)
155
+ ] if all_matching else (paths or [])
156
+ removed = 0
157
+ for item in targets:
158
+ path = (root / item).resolve()
159
+ try:
160
+ path.relative_to(root)
161
+ except ValueError:
162
+ continue
163
+ if image_storage_service.delete(item):
164
+ removed += 1
165
+ for thumbnail in (_thumbnail_path(item), config.image_thumbnails_dir / _safe_relative_path(item)):
166
+ if thumbnail.is_file():
167
+ thumbnail.unlink()
168
+ remove_tags(item)
169
+ _cleanup_empty_dirs(root)
170
+ _cleanup_empty_dirs(config.image_thumbnails_dir)
171
+ return {"removed": removed}
172
+
173
+
174
+ def download_images_zip(paths: list[str]) -> io.BytesIO:
175
+ root = config.images_dir.resolve()
176
+ buf = io.BytesIO()
177
+ added = 0
178
+ used_names: set[str] = set()
179
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
180
+ for item in paths:
181
+ rel = _safe_relative_path(item)
182
+ path = (root / rel).resolve()
183
+ payload: bytes | None = None
184
+ try:
185
+ path.relative_to(root)
186
+ except ValueError:
187
+ continue
188
+ if path.is_file():
189
+ payload = path.read_bytes()
190
+ else:
191
+ try:
192
+ payload = image_storage_service.get_bytes(rel)
193
+ except Exception:
194
+ continue
195
+ name = path.name
196
+ if name in used_names:
197
+ stem = path.stem
198
+ suffix = path.suffix
199
+ counter = 2
200
+ while f"{stem}_{counter}{suffix}" in used_names:
201
+ counter += 1
202
+ name = f"{stem}_{counter}{suffix}"
203
+ used_names.add(name)
204
+ zf.writestr(name, payload)
205
+ added += 1
206
+ if added == 0:
207
+ raise HTTPException(status_code=404, detail="no images found")
208
+ buf.seek(0)
209
+ return buf
210
+ def storage_stats() -> dict:
211
+ import shutil
212
+ usage = shutil.disk_usage(config.images_dir)
213
+ total_mb = usage.total // (1024 * 1024)
214
+ used_mb = usage.used // (1024 * 1024)
215
+ free_mb = usage.free // (1024 * 1024)
216
+
217
+ image_count = 0
218
+ image_size = 0
219
+ for p in config.images_dir.rglob("*"):
220
+ if p.is_file():
221
+ image_count += 1
222
+ image_size += p.stat().st_size
223
+
224
+ return {
225
+ "disk_total_mb": total_mb,
226
+ "disk_used_mb": used_mb,
227
+ "disk_free_mb": free_mb,
228
+ "image_count": image_count,
229
+ "image_size_mb": image_size // (1024 * 1024),
230
+ "image_size_bytes": image_size,
231
+ }
232
+
233
+
234
+ def compress_images(quality: int = 60) -> dict:
235
+ """重新压缩所有图片,返回节省的空间"""
236
+ saved = 0
237
+ count = 0
238
+ for p in sorted(config.images_dir.rglob("*.png")):
239
+ if not p.is_file():
240
+ continue
241
+ try:
242
+ orig = p.stat().st_size
243
+ with Image.open(p) as img:
244
+ img = ImageOps.exif_transpose(img)
245
+ img.save(str(p) + ".tmp", format="PNG", optimize=True)
246
+ new_size = Path(str(p) + ".tmp").stat().st_size
247
+ if new_size < orig:
248
+ Path(str(p) + ".tmp").replace(p)
249
+ saved += orig - new_size
250
+ count += 1
251
+ else:
252
+ Path(str(p) + ".tmp").unlink()
253
+ except Exception:
254
+ pass
255
+ return {"compressed": count, "saved_bytes": saved, "saved_mb": saved // (1024 * 1024)}
256
+
257
+
258
+ def delete_to_target(target_free_mb: int, dry_run: bool = False) -> dict:
259
+ """删除最旧的图片直到剩余空间达到 target_free_mb"""
260
+ import shutil
261
+ usage = shutil.disk_usage(config.images_dir)
262
+ current_free = usage.free // (1024 * 1024)
263
+ if current_free >= target_free_mb and not dry_run:
264
+ return {"removed": 0, "current_free_mb": current_free, "target_free_mb": target_free_mb, "done": True}
265
+
266
+ files = sorted(
267
+ (p for p in config.images_dir.rglob("*.png") if p.is_file()),
268
+ key=lambda p: p.stat().st_mtime,
269
+ )
270
+ removed = 0
271
+ freed = 0
272
+ for p in files:
273
+ if current_free + freed // (1024 * 1024) >= target_free_mb:
274
+ break
275
+ size = p.stat().st_size
276
+ if not dry_run:
277
+ rel = p.relative_to(config.images_dir).as_posix()
278
+ for tp in (_thumbnail_path(rel), config.image_thumbnails_dir / _safe_relative_path(rel)):
279
+ if tp.is_file():
280
+ tp.unlink()
281
+ remove_tags(rel)
282
+ p.unlink()
283
+ freed += size
284
+ removed += 1
285
+
286
+ if not dry_run:
287
+ _cleanup_empty_dirs(config.images_dir)
288
+ _cleanup_empty_dirs(config.image_thumbnails_dir)
289
+
290
+ return {
291
+ "removed": removed,
292
+ "freed_mb": freed // (1024 * 1024),
293
+ "target_free_mb": target_free_mb,
294
+ "current_free_mb": current_free + (freed // (1024 * 1024)),
295
+ "done": (current_free + freed // (1024 * 1024)) >= target_free_mb,
296
+ "dry_run": dry_run,
297
+ }
298
+
299
+
300
+ def download_images_zip(paths: list[str]) -> io.BytesIO:
301
+ root = config.images_dir.resolve()
302
+ buf = io.BytesIO()
303
+ added = 0
304
+ used_names: set[str] = set()
305
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
306
+ for item in paths:
307
+ rel = _safe_relative_path(item)
308
+ path = (root / rel).resolve()
309
+ try:
310
+ path.relative_to(root)
311
+ except ValueError:
312
+ continue
313
+ if not path.is_file():
314
+ continue
315
+ name = path.name
316
+ if name in used_names:
317
+ stem = path.stem
318
+ suffix = path.suffix
319
+ counter = 2
320
+ while f"{stem}_{counter}{suffix}" in used_names:
321
+ counter += 1
322
+ name = f"{stem}_{counter}{suffix}"
323
+ used_names.add(name)
324
+ zf.write(path, name)
325
+ added += 1
326
+ if added == 0:
327
+ raise HTTPException(status_code=404, detail="no images found")
328
+ buf.seek(0)
329
+ return buf
330
+
331
+
332
+ def _auto_cleanup_worker(stop_event: threading.Event) -> None:
333
+ """后台线程:每30分钟检查存储,空间低于阈值自动清理最旧图片"""
334
+ import shutil
335
+ min_free_mb = getattr(config, "image_min_free_mb", None)
336
+ if min_free_mb is None:
337
+ min_free_mb = 500
338
+
339
+ while not stop_event.wait(1800): # 每30分钟
340
+ try:
341
+ config.cleanup_old_images()
342
+ cleanup_image_thumbnails()
343
+ usage = shutil.disk_usage(config.images_dir)
344
+ free_mb = usage.free // (1024 * 1024)
345
+ if free_mb < min_free_mb:
346
+ logger.info({"event": "image_auto_cleanup", "free_mb": free_mb, "min_free_mb": min_free_mb})
347
+ result = delete_to_target(min_free_mb)
348
+ logger.info({"event": "image_auto_cleanup_done", **result})
349
+ except Exception:
350
+ pass
351
+
352
+
353
+ def start_image_cleanup_scheduler(stop_event: threading.Event) -> threading.Thread:
354
+ t = threading.Thread(target=_auto_cleanup_worker, args=(stop_event,), daemon=True, name="image-cleanup")
355
+ t.start()
356
+ return t
services/image_storage_service.py ADDED
@@ -0,0 +1,405 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import io
5
+ import json
6
+ import time
7
+ from dataclasses import dataclass
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from threading import Lock
11
+ from urllib.parse import quote, urlparse
12
+
13
+ from curl_cffi import requests
14
+ from fastapi import HTTPException
15
+ from PIL import Image
16
+
17
+ from services.config import DATA_DIR, config
18
+
19
+ IMAGE_INDEX_FILE = DATA_DIR / "image_index.json"
20
+ IMAGE_INDEX_LOCK = Lock()
21
+ IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"}
22
+
23
+
24
+ class ImageStorageError(RuntimeError):
25
+ pass
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class StoredImage:
30
+ rel: str
31
+ url: str
32
+ storage: str
33
+ size: int
34
+
35
+
36
+ def _clean(value: object) -> str:
37
+ return str(value or "").strip()
38
+
39
+
40
+ def _now_iso() -> str:
41
+ return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
42
+
43
+
44
+ def _safe_relative_path(path: str) -> str:
45
+ value = str(path or "").strip().replace("\\", "/").lstrip("/")
46
+ if not value:
47
+ raise HTTPException(status_code=404, detail="image not found")
48
+ parts = Path(value).parts
49
+ if any(part in {"", ".", ".."} for part in parts):
50
+ raise HTTPException(status_code=404, detail="image not found")
51
+ return Path(*parts).as_posix()
52
+
53
+
54
+ def _image_dimensions(payload: bytes) -> tuple[int, int] | None:
55
+ try:
56
+ with Image.open(io.BytesIO(payload)) as image:
57
+ return image.size
58
+ except Exception:
59
+ return None
60
+
61
+
62
+ def _is_image_rel(path: str) -> bool:
63
+ try:
64
+ safe_rel = _safe_relative_path(path)
65
+ except HTTPException:
66
+ return False
67
+ return Path(safe_rel).suffix.lower() in IMAGE_EXTENSIONS
68
+
69
+
70
+ def _local_image_path(relative_path: str) -> Path:
71
+ rel = _safe_relative_path(relative_path)
72
+ root = config.images_dir.resolve()
73
+ path = (root / rel).resolve()
74
+ try:
75
+ path.relative_to(root)
76
+ except ValueError as exc:
77
+ raise HTTPException(status_code=404, detail="image not found") from exc
78
+ return path
79
+
80
+
81
+ def _read_json_object(path: Path) -> dict[str, object]:
82
+ if not path.exists():
83
+ return {}
84
+ try:
85
+ data = json.loads(path.read_text(encoding="utf-8"))
86
+ except Exception:
87
+ return {}
88
+ return data if isinstance(data, dict) else {}
89
+
90
+
91
+ def _write_json_object(path: Path, data: dict[str, object]) -> None:
92
+ path.parent.mkdir(parents=True, exist_ok=True)
93
+ tmp_path = path.with_suffix(path.suffix + ".tmp")
94
+ tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
95
+ tmp_path.replace(path)
96
+
97
+
98
+ class WebDAVClient:
99
+ def __init__(self, settings: dict[str, object]):
100
+ self.url = _clean(settings.get("webdav_url")).rstrip("/")
101
+ self.username = _clean(settings.get("webdav_username"))
102
+ self.password = _clean(settings.get("webdav_password"))
103
+ self.root_path = _clean(settings.get("webdav_root_path")).strip("/")
104
+ self.session = requests.Session()
105
+
106
+ def _auth_kwargs(self) -> dict[str, object]:
107
+ return {"auth": (self.username, self.password)} if self.username or self.password else {}
108
+
109
+ def _request(self, method: str, url: str, **kwargs):
110
+ response = self.session.request(method, url, timeout=30, **self._auth_kwargs(), **kwargs)
111
+ if response.status_code >= 400 and not (method == "MKCOL" and response.status_code in {405}):
112
+ raise ImageStorageError(f"WebDAV {method} failed: HTTP {response.status_code}")
113
+ return response
114
+
115
+ def remote_url(self, rel: str = "") -> str:
116
+ parts = [part for part in [self.root_path, _safe_relative_path(rel) if rel else ""] if part]
117
+ encoded = "/".join(quote(part, safe="") for item in parts for part in item.split("/") if part)
118
+ return f"{self.url}/{encoded}" if encoded else self.url
119
+
120
+ def ensure_dirs(self, rel: str) -> None:
121
+ parts = [part for part in [self.root_path, Path(_safe_relative_path(rel)).parent.as_posix()] if part and part != "."]
122
+ current = self.url
123
+ for item in "/".join(parts).split("/"):
124
+ if not item:
125
+ continue
126
+ current = f"{current}/{quote(item, safe='')}"
127
+ response = self.session.request("MKCOL", current, timeout=30, **self._auth_kwargs())
128
+ if response.status_code in {201, 405}:
129
+ continue
130
+ if response.status_code >= 400:
131
+ raise ImageStorageError(f"WebDAV MKCOL failed: HTTP {response.status_code}")
132
+
133
+ def put(self, rel: str, payload: bytes, content_type: str = "image/png") -> str:
134
+ self.ensure_dirs(rel)
135
+ url = self.remote_url(rel)
136
+ self._request("PUT", url, data=payload, headers={"Content-Type": content_type})
137
+ return url
138
+
139
+ def get(self, rel: str) -> bytes:
140
+ response = self._request("GET", self.remote_url(rel))
141
+ return bytes(response.content)
142
+
143
+ def delete(self, rel: str) -> bool:
144
+ response = self.session.request("DELETE", self.remote_url(rel), timeout=30, **self._auth_kwargs())
145
+ if response.status_code in {200, 202, 204, 404}:
146
+ return response.status_code != 404
147
+ raise ImageStorageError(f"WebDAV DELETE failed: HTTP {response.status_code}")
148
+
149
+ def test(self) -> dict[str, object]:
150
+ if not self.url:
151
+ return {"ok": False, "status": 0, "error": "WebDAV URL is required"}
152
+ if urlparse(self.url).scheme not in {"http", "https"}:
153
+ return {"ok": False, "status": 0, "error": "invalid WebDAV URL"}
154
+ test_rel = ".chatgpt2api_webdav_test.txt"
155
+ try:
156
+ self.put(test_rel, b"chatgpt2api webdav test\n", content_type="text/plain")
157
+ self.delete(test_rel)
158
+ return {"ok": True, "status": 200, "error": None}
159
+ except ImageStorageError as exc:
160
+ return {"ok": False, "status": 0, "error": str(exc)}
161
+ except Exception as exc:
162
+ return {"ok": False, "status": 0, "error": str(exc) or exc.__class__.__name__}
163
+ finally:
164
+ self.session.close()
165
+
166
+
167
+ class ImageStorageService:
168
+ def __init__(self, index_file: Path = IMAGE_INDEX_FILE):
169
+ self.index_file = index_file
170
+ self._index_lock = IMAGE_INDEX_LOCK
171
+
172
+ def settings(self) -> dict[str, object]:
173
+ return config.get_image_storage_settings()
174
+
175
+ def mode(self) -> str:
176
+ return _clean(self.settings().get("mode")) or "local"
177
+
178
+ def _load_index(self) -> dict[str, dict[str, object]]:
179
+ raw = _read_json_object(self.index_file)
180
+ items = raw.get("items")
181
+ if not isinstance(items, dict):
182
+ return {}
183
+ return {str(key): value for key, value in items.items() if isinstance(value, dict)}
184
+
185
+ def _load_clean_index(self) -> dict[str, dict[str, object]]:
186
+ items = self._load_index()
187
+ return {rel: item for rel, item in items.items() if _is_image_rel(rel)}
188
+
189
+ def _save_index(self, items: dict[str, dict[str, object]]) -> None:
190
+ _write_json_object(self.index_file, {"items": items})
191
+
192
+ def _public_url(self, rel: str, base_url: str | None = None) -> str:
193
+ settings = self.settings()
194
+ public_base_url = _clean(settings.get("public_base_url"))
195
+ if public_base_url:
196
+ return f"{public_base_url.rstrip('/')}/{_safe_relative_path(rel)}"
197
+ return f"{(base_url or config.base_url).rstrip('/')}/images/{_safe_relative_path(rel)}"
198
+
199
+ def make_relative_path(self, image_data: bytes) -> str:
200
+ file_hash = hashlib.md5(image_data).hexdigest()
201
+ filename = f"{int(time.time())}_{file_hash}.png"
202
+ relative_dir = Path(time.strftime("%Y"), time.strftime("%m"), time.strftime("%d"))
203
+ return f"{relative_dir.as_posix()}/{filename}"
204
+
205
+ def save(self, image_data: bytes, base_url: str | None = None) -> StoredImage:
206
+ config.cleanup_old_images()
207
+ rel = self.make_relative_path(image_data)
208
+ mode = self.mode()
209
+ if mode not in {"local", "webdav", "both"}:
210
+ mode = "local"
211
+ stored_local = False
212
+ stored_webdav = False
213
+ remote_url = ""
214
+
215
+ if mode in {"local", "both"}:
216
+ path = _local_image_path(rel)
217
+ path.parent.mkdir(parents=True, exist_ok=True)
218
+ path.write_bytes(image_data)
219
+ stored_local = True
220
+
221
+ if mode in {"webdav", "both"}:
222
+ remote_url = WebDAVClient(self.settings()).put(rel, image_data)
223
+ stored_webdav = True
224
+
225
+ dimensions = _image_dimensions(image_data)
226
+ item = {
227
+ "rel": rel,
228
+ "path": rel,
229
+ "name": Path(rel).name,
230
+ "date": "-".join(rel.split("/")[:3]),
231
+ "size": len(image_data),
232
+ "created_at": _now_iso(),
233
+ "storage": "both" if stored_local and stored_webdav else ("webdav" if stored_webdav else "local"),
234
+ "local": stored_local,
235
+ "webdav": stored_webdav,
236
+ "remote_url": remote_url,
237
+ }
238
+ if dimensions:
239
+ item["width"], item["height"] = dimensions
240
+ with self._index_lock:
241
+ items = self._load_clean_index()
242
+ items[rel] = item
243
+ self._save_index(items)
244
+ return StoredImage(rel=rel, url=self._public_url(rel, base_url), storage=str(item["storage"]), size=len(image_data))
245
+
246
+ def get_bytes(self, rel: str) -> bytes:
247
+ safe_rel = _safe_relative_path(rel)
248
+ if not _is_image_rel(safe_rel):
249
+ raise HTTPException(status_code=404, detail="image not found")
250
+ path = _local_image_path(safe_rel)
251
+ if path.is_file():
252
+ return path.read_bytes()
253
+ item = self._load_clean_index().get(safe_rel, {})
254
+ if item.get("webdav"):
255
+ return WebDAVClient(self.settings()).get(safe_rel)
256
+ raise HTTPException(status_code=404, detail="image not found")
257
+
258
+ def exists(self, rel: str) -> bool:
259
+ safe_rel = _safe_relative_path(rel)
260
+ if not _is_image_rel(safe_rel):
261
+ return False
262
+ if _local_image_path(safe_rel).is_file():
263
+ return True
264
+ item = self._load_clean_index().get(safe_rel, {})
265
+ return bool(item.get("webdav"))
266
+
267
+ def has_local(self, rel: str) -> bool:
268
+ safe_rel = _safe_relative_path(rel)
269
+ return _is_image_rel(safe_rel) and _local_image_path(safe_rel).is_file()
270
+
271
+ def list_items(self, base_url: str, start_date: str = "", end_date: str = "") -> list[dict[str, object]]:
272
+ with self._index_lock:
273
+ indexed = self._load_clean_index()
274
+ root = config.images_dir
275
+ changed = False
276
+ for path in root.rglob("*"):
277
+ if not path.is_file() or not _is_image_rel(path.name):
278
+ continue
279
+ rel = path.relative_to(root).as_posix()
280
+ if rel in indexed:
281
+ continue
282
+ dimensions = None
283
+ try:
284
+ dimensions = _image_dimensions(path.read_bytes())
285
+ except Exception:
286
+ dimensions = None
287
+ indexed[rel] = {
288
+ "rel": rel,
289
+ "path": rel,
290
+ "name": path.name,
291
+ "date": "-".join(rel.split("/")[:3]) if len(rel.split("/")) >= 4 else datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d"),
292
+ "size": path.stat().st_size,
293
+ "created_at": datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
294
+ "storage": "local",
295
+ "local": True,
296
+ "webdav": False,
297
+ **({"width": dimensions[0], "height": dimensions[1]} if dimensions else {}),
298
+ }
299
+ changed = True
300
+
301
+ items: list[dict[str, object]] = []
302
+ for rel, item in list(indexed.items()):
303
+ if not _is_image_rel(rel):
304
+ indexed.pop(rel, None)
305
+ changed = True
306
+ continue
307
+ local = _local_image_path(rel).is_file()
308
+ webdav = bool(item.get("webdav"))
309
+ if not local and not webdav:
310
+ indexed.pop(rel, None)
311
+ changed = True
312
+ continue
313
+ storage = "both" if local and webdav else ("webdav" if webdav else "local")
314
+ if item.get("local") != local or item.get("storage") != storage:
315
+ item = {
316
+ **item,
317
+ "local": local,
318
+ "storage": storage,
319
+ }
320
+ indexed[rel] = item
321
+ changed = True
322
+ day = str(item.get("date") or "")
323
+ if start_date and day < start_date:
324
+ continue
325
+ if end_date and day > end_date:
326
+ continue
327
+ items.append({
328
+ **item,
329
+ "rel": rel,
330
+ "path": rel,
331
+ "url": self._public_url(rel, base_url),
332
+ })
333
+ if changed:
334
+ self._save_index(indexed)
335
+ items.sort(key=lambda item: str(item.get("created_at") or ""), reverse=True)
336
+ return items
337
+
338
+ def delete(self, rel: str) -> bool:
339
+ safe_rel = _safe_relative_path(rel)
340
+ removed = False
341
+ path = _local_image_path(safe_rel)
342
+ if path.is_file():
343
+ path.unlink()
344
+ removed = True
345
+ with self._index_lock:
346
+ items = self._load_clean_index()
347
+ item = items.get(safe_rel, {})
348
+ if item.get("webdav"):
349
+ try:
350
+ removed = WebDAVClient(self.settings()).delete(safe_rel) or removed
351
+ except ImageStorageError:
352
+ if not removed:
353
+ raise
354
+ if safe_rel in items:
355
+ items.pop(safe_rel, None)
356
+ self._save_index(items)
357
+ return removed
358
+
359
+ def sync_all(self) -> dict[str, int]:
360
+ settings = self.settings()
361
+ if self.mode() not in {"webdav", "both"}:
362
+ raise ImageStorageError("WebDAV 图片存储未启用")
363
+ uploaded = 0
364
+ skipped = 0
365
+ failed = 0
366
+ with self._index_lock:
367
+ items = self._load_clean_index()
368
+ client = WebDAVClient(settings)
369
+ for path in sorted(config.images_dir.rglob("*")):
370
+ if not path.is_file() or not _is_image_rel(path.name):
371
+ continue
372
+ rel = path.relative_to(config.images_dir).as_posix()
373
+ item = items.get(rel, {})
374
+ if item.get("webdav"):
375
+ skipped += 1
376
+ continue
377
+ try:
378
+ payload = path.read_bytes()
379
+ remote_url = client.put(rel, payload)
380
+ dimensions = _image_dimensions(payload)
381
+ items[rel] = {
382
+ **item,
383
+ "rel": rel,
384
+ "path": rel,
385
+ "name": path.name,
386
+ "date": "-".join(rel.split("/")[:3]) if len(rel.split("/")) >= 4 else datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d"),
387
+ "size": len(payload),
388
+ "created_at": str(item.get("created_at") or datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")),
389
+ "storage": "both",
390
+ "local": True,
391
+ "webdav": True,
392
+ "remote_url": remote_url,
393
+ **({"width": dimensions[0], "height": dimensions[1]} if dimensions else {}),
394
+ }
395
+ uploaded += 1
396
+ except Exception:
397
+ failed += 1
398
+ self._save_index(items)
399
+ return {"uploaded": uploaded, "skipped": skipped, "failed": failed}
400
+
401
+ def test_webdav(self) -> dict[str, object]:
402
+ return WebDAVClient(self.settings()).test()
403
+
404
+
405
+ image_storage_service = ImageStorageService()
services/image_tags_service.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from services.config import DATA_DIR
7
+
8
+ TAGS_FILE = DATA_DIR / "image_tags.json"
9
+
10
+
11
+ def _ensure_file() -> None:
12
+ TAGS_FILE.parent.mkdir(parents=True, exist_ok=True)
13
+ if not TAGS_FILE.exists():
14
+ TAGS_FILE.write_text("{}", encoding="utf-8")
15
+
16
+
17
+ def load_tags() -> dict[str, list[str]]:
18
+ _ensure_file()
19
+ try:
20
+ data = json.loads(TAGS_FILE.read_text(encoding="utf-8"))
21
+ except Exception:
22
+ return {}
23
+ return data if isinstance(data, dict) else {}
24
+
25
+
26
+ def save_tags(data: dict[str, list[str]]) -> None:
27
+ _ensure_file()
28
+ TAGS_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
29
+
30
+
31
+ def get_tags(image_rel: str) -> list[str]:
32
+ return load_tags().get(image_rel, [])
33
+
34
+
35
+ def set_tags(image_rel: str, tags: list[str]) -> list[str]:
36
+ data = load_tags()
37
+ cleaned = list(dict.fromkeys(t.strip() for t in tags if t.strip()))
38
+ if cleaned:
39
+ data[image_rel] = cleaned
40
+ else:
41
+ data.pop(image_rel, None)
42
+ save_tags(data)
43
+ return cleaned
44
+
45
+
46
+ def remove_tags(image_rel: str) -> None:
47
+ data = load_tags()
48
+ if data.pop(image_rel, None) is not None:
49
+ save_tags(data)
50
+
51
+
52
+ def delete_tag(tag: str) -> int:
53
+ """从所有图片中删除指定标签,返回受影响的图片数。"""
54
+ data = load_tags()
55
+ count = 0
56
+ for rel in list(data):
57
+ if tag in data[rel]:
58
+ data[rel] = [t for t in data[rel] if t != tag]
59
+ if not data[rel]:
60
+ del data[rel]
61
+ count += 1
62
+ if count > 0:
63
+ save_tags(data)
64
+ return count
65
+
66
+
67
+ def get_all_tags() -> list[str]:
68
+ data = load_tags()
69
+ seen: set[str] = set()
70
+ result: list[str] = []
71
+ for tags in data.values():
72
+ for t in tags:
73
+ if t not in seen:
74
+ seen.add(t)
75
+ result.append(t)
76
+ return result
services/image_task_service.py ADDED
@@ -0,0 +1,388 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import threading
5
+ import time
6
+ from collections.abc import Callable
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from services.config import DATA_DIR, config
12
+ from services.content_filter import request_text
13
+ from services.log_service import LOG_TYPE_CALL, log_service
14
+ from services.protocol import openai_v1_image_edit, openai_v1_image_generations
15
+
16
+ TASK_STATUS_QUEUED = "queued"
17
+ TASK_STATUS_RUNNING = "running"
18
+ TASK_STATUS_SUCCESS = "success"
19
+ TASK_STATUS_ERROR = "error"
20
+ TERMINAL_STATUSES = {TASK_STATUS_SUCCESS, TASK_STATUS_ERROR}
21
+ UNFINISHED_STATUSES = {TASK_STATUS_QUEUED, TASK_STATUS_RUNNING}
22
+
23
+
24
+ def _now_iso() -> str:
25
+ return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
26
+
27
+
28
+ def _timestamp(value: object) -> float:
29
+ if not isinstance(value, str) or not value.strip():
30
+ return 0.0
31
+ for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S"):
32
+ try:
33
+ return datetime.strptime(value[:26], fmt).timestamp()
34
+ except ValueError:
35
+ continue
36
+ try:
37
+ return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
38
+ except Exception:
39
+ return 0.0
40
+
41
+
42
+ def _clean(value: object, default: str = "") -> str:
43
+ return str(value or default).strip()
44
+
45
+
46
+ def _owner_id(identity: dict[str, object]) -> str:
47
+ return _clean(identity.get("id")) or "anonymous"
48
+
49
+
50
+ def _task_key(owner_id: str, task_id: str) -> str:
51
+ return f"{owner_id}:{task_id}"
52
+
53
+
54
+ def _collect_image_urls(data: list[Any]) -> list[str]:
55
+ urls: list[str] = []
56
+ for item in data:
57
+ if isinstance(item, dict):
58
+ url = item.get("url")
59
+ if isinstance(url, str) and url:
60
+ urls.append(url)
61
+ return urls
62
+
63
+
64
+ def _public_task(task: dict[str, Any]) -> dict[str, Any]:
65
+ item = {
66
+ "id": task.get("id"),
67
+ "status": task.get("status"),
68
+ "mode": task.get("mode"),
69
+ "model": task.get("model"),
70
+ "size": task.get("size"),
71
+ "quality": task.get("quality"),
72
+ "created_at": task.get("created_at"),
73
+ "updated_at": task.get("updated_at"),
74
+ }
75
+ if task.get("data") is not None:
76
+ item["data"] = task.get("data")
77
+ if task.get("error"):
78
+ item["error"] = task.get("error")
79
+ return item
80
+
81
+
82
+ class ImageTaskService:
83
+ def __init__(
84
+ self,
85
+ path: Path,
86
+ *,
87
+ generation_handler: Callable[[dict[str, Any]], dict[str, Any]] = openai_v1_image_generations.handle,
88
+ edit_handler: Callable[[dict[str, Any]], dict[str, Any]] = openai_v1_image_edit.handle,
89
+ retention_days_getter: Callable[[], int] | None = None,
90
+ ):
91
+ self.path = path
92
+ self.generation_handler = generation_handler
93
+ self.edit_handler = edit_handler
94
+ self.retention_days_getter = retention_days_getter or (lambda: config.image_retention_days)
95
+ self._lock = threading.RLock()
96
+ self._tasks: dict[str, dict[str, Any]] = {}
97
+ self.path.parent.mkdir(parents=True, exist_ok=True)
98
+ with self._lock:
99
+ self._tasks = self._load_locked()
100
+ changed = self._recover_unfinished_locked()
101
+ changed = self._cleanup_locked() or changed
102
+ if changed:
103
+ self._save_locked()
104
+
105
+ def submit_generation(
106
+ self,
107
+ identity: dict[str, object],
108
+ *,
109
+ client_task_id: str,
110
+ prompt: str,
111
+ model: str,
112
+ size: str | None,
113
+ quality: str = "auto",
114
+ base_url: str = "",
115
+ ) -> dict[str, Any]:
116
+ payload = {
117
+ "prompt": prompt,
118
+ "model": model,
119
+ "n": 1,
120
+ "size": size,
121
+ "quality": quality,
122
+ "response_format": "url",
123
+ "base_url": base_url,
124
+ }
125
+ return self._submit(identity, client_task_id=client_task_id, mode="generate", payload=payload)
126
+
127
+ def submit_edit(
128
+ self,
129
+ identity: dict[str, object],
130
+ *,
131
+ client_task_id: str,
132
+ prompt: str,
133
+ model: str,
134
+ size: str | None,
135
+ quality: str = "auto",
136
+ base_url: str = "",
137
+ images: list[tuple[bytes, str, str]] | None = None,
138
+ ) -> dict[str, Any]:
139
+ payload = {
140
+ "prompt": prompt,
141
+ "images": images or [],
142
+ "model": model,
143
+ "n": 1,
144
+ "size": size,
145
+ "quality": quality,
146
+ "response_format": "url",
147
+ "base_url": base_url,
148
+ }
149
+ return self._submit(identity, client_task_id=client_task_id, mode="edit", payload=payload)
150
+
151
+ def list_tasks(self, identity: dict[str, object], task_ids: list[str]) -> dict[str, Any]:
152
+ owner = _owner_id(identity)
153
+ requested_ids = [_clean(task_id) for task_id in task_ids if _clean(task_id)]
154
+ with self._lock:
155
+ if self._cleanup_locked():
156
+ self._save_locked()
157
+ items = []
158
+ missing_ids = []
159
+ for task_id in requested_ids:
160
+ task = self._tasks.get(_task_key(owner, task_id))
161
+ if task is None:
162
+ missing_ids.append(task_id)
163
+ else:
164
+ items.append(_public_task(task))
165
+ if not requested_ids:
166
+ items = [
167
+ _public_task(task)
168
+ for task in self._tasks.values()
169
+ if task.get("owner_id") == owner
170
+ ]
171
+ items.sort(key=lambda item: str(item.get("updated_at") or ""), reverse=True)
172
+ missing_ids = []
173
+ return {"items": items, "missing_ids": missing_ids}
174
+
175
+ def _submit(
176
+ self,
177
+ identity: dict[str, object],
178
+ *,
179
+ client_task_id: str,
180
+ mode: str,
181
+ payload: dict[str, Any],
182
+ ) -> dict[str, Any]:
183
+ task_id = _clean(client_task_id)
184
+ if not task_id:
185
+ raise ValueError("client_task_id is required")
186
+ owner = _owner_id(identity)
187
+ key = _task_key(owner, task_id)
188
+ now = _now_iso()
189
+ should_start = False
190
+ with self._lock:
191
+ cleaned = self._cleanup_locked()
192
+ task = self._tasks.get(key)
193
+ if task is not None:
194
+ if cleaned:
195
+ self._save_locked()
196
+ return _public_task(task)
197
+ task = {
198
+ "id": task_id,
199
+ "owner_id": owner,
200
+ "status": TASK_STATUS_QUEUED,
201
+ "mode": mode,
202
+ "model": _clean(payload.get("model"), "gpt-image-2"),
203
+ "size": _clean(payload.get("size")),
204
+ "quality": _clean(payload.get("quality"), "auto"),
205
+ "created_at": now,
206
+ "updated_at": now,
207
+ }
208
+ self._tasks[key] = task
209
+ self._save_locked()
210
+ should_start = True
211
+
212
+ if should_start:
213
+ thread = threading.Thread(
214
+ target=self._run_task,
215
+ args=(key, mode, payload, dict(identity), _clean(payload.get("model"), "gpt-image-2")),
216
+ name=f"image-task-{task_id[:16]}",
217
+ daemon=True,
218
+ )
219
+ thread.start()
220
+ return _public_task(task)
221
+
222
+ def _run_task(
223
+ self,
224
+ key: str,
225
+ mode: str,
226
+ payload: dict[str, Any],
227
+ identity: dict[str, object],
228
+ model: str,
229
+ ) -> None:
230
+ started = time.time()
231
+ self._update_task(key, status=TASK_STATUS_RUNNING, error="")
232
+ try:
233
+ handler = self.edit_handler if mode == "edit" else self.generation_handler
234
+ result = handler(payload)
235
+ if not isinstance(result, dict):
236
+ raise RuntimeError("image task returned streaming result unexpectedly")
237
+ data = result.get("data")
238
+ if not isinstance(data, list) or not data:
239
+ upstream = _clean(result.get("message"))
240
+ if upstream:
241
+ message = upstream
242
+ else:
243
+ message = "号池中没有可用账号或所有账号均被限流,请检查号池状态(账号额度、是否被封禁、是否到达生图上限)"
244
+ raise RuntimeError(message)
245
+ self._update_task(key, status=TASK_STATUS_SUCCESS, data=data, error="")
246
+ self._log_call(
247
+ identity,
248
+ mode,
249
+ model,
250
+ started,
251
+ "调用完成",
252
+ request_preview=request_text(payload.get("prompt")),
253
+ urls=_collect_image_urls(data),
254
+ )
255
+ except Exception as exc:
256
+ error_message = str(exc) or "image task failed"
257
+ self._update_task(key, status=TASK_STATUS_ERROR, error=error_message, data=[])
258
+ self._log_call(
259
+ identity,
260
+ mode,
261
+ model,
262
+ started,
263
+ "调用失败",
264
+ request_preview=request_text(payload.get("prompt")),
265
+ status="failed",
266
+ error=error_message,
267
+ )
268
+
269
+ def _log_call(
270
+ self,
271
+ identity: dict[str, object],
272
+ mode: str,
273
+ model: str,
274
+ started: float,
275
+ suffix: str,
276
+ *,
277
+ request_preview: str = "",
278
+ status: str = "success",
279
+ error: str = "",
280
+ urls: list[str] | None = None,
281
+ ) -> None:
282
+ endpoint = "/v1/images/edits" if mode == "edit" else "/v1/images/generations"
283
+ summary_prefix = "图生图" if mode == "edit" else "文生图"
284
+ detail = {
285
+ "key_id": identity.get("id"),
286
+ "key_name": identity.get("name"),
287
+ "role": identity.get("role"),
288
+ "endpoint": endpoint,
289
+ "model": model,
290
+ "started_at": datetime.fromtimestamp(started).strftime("%Y-%m-%d %H:%M:%S"),
291
+ "ended_at": _now_iso(),
292
+ "duration_ms": int((time.time() - started) * 1000),
293
+ "status": status,
294
+ }
295
+ if request_preview:
296
+ detail["request_text"] = request_preview
297
+ if error:
298
+ detail["error"] = error
299
+ if urls:
300
+ detail["urls"] = list(dict.fromkeys(urls))
301
+ try:
302
+ log_service.add(LOG_TYPE_CALL, f"{summary_prefix}{suffix}", detail)
303
+ except Exception:
304
+ pass
305
+
306
+ def _update_task(self, key: str, **updates: Any) -> None:
307
+ with self._lock:
308
+ task = self._tasks.get(key)
309
+ if task is None:
310
+ return
311
+ task.update(updates)
312
+ task["updated_at"] = _now_iso()
313
+ self._save_locked()
314
+
315
+ def _load_locked(self) -> dict[str, dict[str, Any]]:
316
+ if not self.path.exists():
317
+ return {}
318
+ try:
319
+ raw = json.loads(self.path.read_text(encoding="utf-8"))
320
+ except Exception:
321
+ return {}
322
+ raw_items = raw.get("tasks") if isinstance(raw, dict) else raw
323
+ if not isinstance(raw_items, list):
324
+ return {}
325
+ tasks: dict[str, dict[str, Any]] = {}
326
+ for item in raw_items:
327
+ if not isinstance(item, dict):
328
+ continue
329
+ task_id = _clean(item.get("id"))
330
+ owner = _clean(item.get("owner_id"))
331
+ if not task_id or not owner:
332
+ continue
333
+ status = _clean(item.get("status"))
334
+ if status not in {TASK_STATUS_QUEUED, TASK_STATUS_RUNNING, TASK_STATUS_SUCCESS, TASK_STATUS_ERROR}:
335
+ status = TASK_STATUS_ERROR
336
+ task = {
337
+ "id": task_id,
338
+ "owner_id": owner,
339
+ "status": status,
340
+ "mode": "edit" if item.get("mode") == "edit" else "generate",
341
+ "model": _clean(item.get("model"), "gpt-image-2"),
342
+ "size": _clean(item.get("size")),
343
+ "quality": _clean(item.get("quality"), "auto"),
344
+ "created_at": _clean(item.get("created_at"), _now_iso()),
345
+ "updated_at": _clean(item.get("updated_at"), _clean(item.get("created_at"), _now_iso())),
346
+ }
347
+ data = item.get("data")
348
+ if isinstance(data, list):
349
+ task["data"] = data
350
+ error = _clean(item.get("error"))
351
+ if error:
352
+ task["error"] = error
353
+ tasks[_task_key(owner, task_id)] = task
354
+ return tasks
355
+
356
+ def _save_locked(self) -> None:
357
+ items = sorted(self._tasks.values(), key=lambda item: str(item.get("updated_at") or ""), reverse=True)
358
+ tmp_path = self.path.with_suffix(self.path.suffix + ".tmp")
359
+ tmp_path.write_text(json.dumps({"tasks": items}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
360
+ tmp_path.replace(self.path)
361
+
362
+ def _recover_unfinished_locked(self) -> bool:
363
+ changed = False
364
+ for task in self._tasks.values():
365
+ if task.get("status") in UNFINISHED_STATUSES:
366
+ task["status"] = TASK_STATUS_ERROR
367
+ task["error"] = "服务已重启,未完成的图片任务已中断"
368
+ task["updated_at"] = _now_iso()
369
+ changed = True
370
+ return changed
371
+
372
+ def _cleanup_locked(self) -> bool:
373
+ try:
374
+ retention_days = max(1, int(self.retention_days_getter()))
375
+ except Exception:
376
+ retention_days = 30
377
+ cutoff = time.time() - retention_days * 86400
378
+ removed_keys = [
379
+ key
380
+ for key, task in self._tasks.items()
381
+ if task.get("status") in TERMINAL_STATUSES and _timestamp(task.get("updated_at")) < cutoff
382
+ ]
383
+ for key in removed_keys:
384
+ self._tasks.pop(key, None)
385
+ return bool(removed_keys)
386
+
387
+
388
+ image_task_service = ImageTaskService(DATA_DIR / "image_tasks.json")
services/log_service.py ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import itertools
6
+ import time
7
+ from dataclasses import dataclass, field
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from typing import Any
11
+ from uuid import uuid4
12
+
13
+ from fastapi import HTTPException
14
+ from fastapi.concurrency import run_in_threadpool
15
+ from fastapi.responses import JSONResponse, StreamingResponse
16
+
17
+ from services.config import DATA_DIR
18
+ from services.protocol.error_response import anthropic_error_response, openai_error_response
19
+ from utils.helper import anthropic_sse_stream, sse_json_stream
20
+
21
+ LOG_TYPE_CALL = "call"
22
+ LOG_TYPE_ACCOUNT = "account"
23
+
24
+
25
+ class LogService:
26
+ def __init__(self, path: Path):
27
+ self.path = path
28
+ self.path.parent.mkdir(parents=True, exist_ok=True)
29
+
30
+ @staticmethod
31
+ def _legacy_id(raw_line: str, line_number: int) -> str:
32
+ payload = f"{line_number}:{raw_line}".encode("utf-8", errors="ignore")
33
+ return hashlib.sha1(payload).hexdigest()[:24]
34
+
35
+ def _parse_line(self, raw_line: str, line_number: int) -> dict[str, Any] | None:
36
+ try:
37
+ item = json.loads(raw_line)
38
+ except Exception:
39
+ return None
40
+ if not isinstance(item, dict):
41
+ return None
42
+ parsed = dict(item)
43
+ parsed["id"] = str(parsed.get("id") or self._legacy_id(raw_line, line_number))
44
+ return parsed
45
+
46
+ @staticmethod
47
+ def _serialize_item(item: dict[str, Any]) -> str:
48
+ return json.dumps(item, ensure_ascii=False, separators=(",", ":"))
49
+
50
+ @staticmethod
51
+ def _matches_filters(item: dict[str, Any], *, type: str = "", start_date: str = "", end_date: str = "") -> bool:
52
+ t = str(item.get("time") or "")
53
+ day = t[:10]
54
+ if type and item.get("type") != type:
55
+ return False
56
+ if start_date and day < start_date:
57
+ return False
58
+ if end_date and day > end_date:
59
+ return False
60
+ return True
61
+
62
+ def add(self, type: str, summary: str = "", detail: dict[str, Any] | None = None, **data: Any) -> None:
63
+ item = {
64
+ "id": uuid4().hex,
65
+ "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
66
+ "type": type,
67
+ "summary": summary,
68
+ "detail": detail or data,
69
+ }
70
+ with self.path.open("a", encoding="utf-8") as file:
71
+ file.write(self._serialize_item(item) + "\n")
72
+
73
+ def list(self, type: str = "", start_date: str = "", end_date: str = "", limit: int = 200) -> list[dict[str, Any]]:
74
+ if not self.path.exists():
75
+ return []
76
+ items: list[dict[str, Any]] = []
77
+ lines = self.path.read_text(encoding="utf-8").splitlines()
78
+ for line_number in range(len(lines) - 1, -1, -1):
79
+ item = self._parse_line(lines[line_number], line_number)
80
+ if item is None:
81
+ continue
82
+ if not self._matches_filters(item, type=type, start_date=start_date, end_date=end_date):
83
+ continue
84
+ items.append(item)
85
+ if len(items) >= limit:
86
+ break
87
+ return items
88
+
89
+ def delete(self, ids: list[str]) -> dict[str, int]:
90
+ target_ids = {str(item or "").strip() for item in ids if str(item or "").strip()}
91
+ if not self.path.exists() or not target_ids:
92
+ return {"removed": 0}
93
+ lines = self.path.read_text(encoding="utf-8").splitlines()
94
+ kept_lines: list[str] = []
95
+ removed = 0
96
+ for line_number, raw_line in enumerate(lines):
97
+ item = self._parse_line(raw_line, line_number)
98
+ if item is None:
99
+ kept_lines.append(raw_line)
100
+ continue
101
+ if str(item.get("id") or "") in target_ids:
102
+ removed += 1
103
+ continue
104
+ kept_lines.append(self._serialize_item(item))
105
+ content = "\n".join(kept_lines)
106
+ if content:
107
+ content += "\n"
108
+ self.path.write_text(content, encoding="utf-8")
109
+ return {"removed": removed}
110
+
111
+
112
+ log_service = LogService(DATA_DIR / "logs.jsonl")
113
+
114
+
115
+ def _collect_urls(value: object) -> list[str]:
116
+ urls: list[str] = []
117
+ if isinstance(value, dict):
118
+ for key, item in value.items():
119
+ if key == "url" and isinstance(item, str):
120
+ urls.append(item)
121
+ elif key == "urls" and isinstance(item, list):
122
+ urls.extend(str(url) for url in item if isinstance(url, str))
123
+ else:
124
+ urls.extend(_collect_urls(item))
125
+ elif isinstance(value, list):
126
+ for item in value:
127
+ urls.extend(_collect_urls(item))
128
+ return urls
129
+
130
+
131
+ def _request_excerpt(text: object, limit: int = 1000) -> str:
132
+ value = str(text or "").strip()
133
+ if not value:
134
+ return ""
135
+ normalized = " ".join(value.split())
136
+ if len(normalized) <= limit:
137
+ return normalized
138
+ return normalized[: limit - 1].rstrip() + "…"
139
+
140
+
141
+ def _image_error_response(exc: Exception) -> JSONResponse:
142
+ message = str(exc)
143
+ if "no available image quota" in message.lower():
144
+ return openai_error_response(
145
+ {
146
+ "error": {
147
+ "message": "no available image quota",
148
+ "type": "insufficient_quota",
149
+ "param": None,
150
+ "code": "insufficient_quota",
151
+ }
152
+ },
153
+ 429,
154
+ )
155
+ if hasattr(exc, "to_openai_error") and hasattr(exc, "status_code"):
156
+ return JSONResponse(status_code=int(exc.status_code), content=exc.to_openai_error())
157
+ return openai_error_response(message, 502)
158
+
159
+
160
+ def _protocol_error_response(exc: Exception, status_code: int, sse: str) -> JSONResponse:
161
+ message = str(exc)
162
+ if sse == "anthropic":
163
+ return anthropic_error_response(message, status_code)
164
+ return openai_error_response(message, status_code)
165
+
166
+
167
+ def _next_item(items):
168
+ try:
169
+ return True, next(items)
170
+ except StopIteration:
171
+ return False, None
172
+
173
+
174
+ @dataclass
175
+ class LoggedCall:
176
+ identity: dict[str, object]
177
+ endpoint: str
178
+ model: str
179
+ summary: str
180
+ started: float = field(default_factory=time.time)
181
+ request_text: str = ""
182
+
183
+ async def run(self, handler, *args, sse: str = "openai"):
184
+ from services.protocol.conversation import ImageGenerationError
185
+
186
+ try:
187
+ result = await run_in_threadpool(handler, *args)
188
+ except ImageGenerationError as exc:
189
+ self.log("调用失败", status="failed", error=str(exc))
190
+ return _image_error_response(exc)
191
+ except HTTPException as exc:
192
+ self.log("调用失败", status="failed", error=str(exc.detail))
193
+ raise
194
+ except Exception as exc:
195
+ self.log("调用失败", status="failed", error=str(exc))
196
+ return _protocol_error_response(exc, 502, sse)
197
+
198
+ if isinstance(result, dict):
199
+ self.log("调用完成", result)
200
+ return result
201
+
202
+ sender = anthropic_sse_stream if sse == "anthropic" else sse_json_stream
203
+ try:
204
+ has_first, first = await run_in_threadpool(_next_item, result)
205
+ except ImageGenerationError as exc:
206
+ self.log("调用失败", status="failed", error=str(exc))
207
+ return _image_error_response(exc)
208
+ except HTTPException as exc:
209
+ self.log("调用失败", status="failed", error=str(exc.detail))
210
+ raise
211
+ except Exception as exc:
212
+ self.log("调用失败", status="failed", error=str(exc))
213
+ return _protocol_error_response(exc, 502, sse)
214
+ if not has_first:
215
+ self.log("流式调用结束")
216
+ return StreamingResponse(sender(()), media_type="text/event-stream")
217
+ return StreamingResponse(sender(self.stream(itertools.chain([first], result))), media_type="text/event-stream")
218
+
219
+ def stream(self, items):
220
+ urls: list[str] = []
221
+ failed = False
222
+ try:
223
+ for item in items:
224
+ urls.extend(_collect_urls(item))
225
+ yield item
226
+ except Exception as exc:
227
+ failed = True
228
+ self.log("流式调用失败", status="failed", error=str(exc), urls=urls)
229
+ raise
230
+ finally:
231
+ if not failed:
232
+ self.log("流式调用结束", urls=urls)
233
+
234
+ def log(self, suffix: str, result: object = None, status: str = "success", error: str = "",
235
+ urls: list[str] | None = None) -> None:
236
+ detail = {
237
+ "key_id": self.identity.get("id"),
238
+ "key_name": self.identity.get("name"),
239
+ "role": self.identity.get("role"),
240
+ "endpoint": self.endpoint,
241
+ "model": self.model,
242
+ "started_at": datetime.fromtimestamp(self.started).strftime("%Y-%m-%d %H:%M:%S"),
243
+ "ended_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
244
+ "duration_ms": int((time.time() - self.started) * 1000),
245
+ "status": status,
246
+ }
247
+ request_excerpt = _request_excerpt(self.request_text)
248
+ if request_excerpt:
249
+ detail["request_text"] = request_excerpt
250
+ if error:
251
+ detail["error"] = error
252
+ collected_urls = [*(urls or []), *_collect_urls(result)]
253
+ if collected_urls:
254
+ detail["urls"] = list(dict.fromkeys(collected_urls))
255
+ log_service.add(LOG_TYPE_CALL, f"{self.summary}{suffix}", detail)
services/openai_backend_api.py ADDED
@@ -0,0 +1,986 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import os
3
+ import random
4
+ import re
5
+ import time
6
+ from concurrent.futures import ThreadPoolExecutor
7
+ from dataclasses import dataclass
8
+ from io import BytesIO
9
+ from pathlib import Path
10
+ from typing import Any, Dict, Iterator, Optional
11
+
12
+ from curl_cffi import requests
13
+ from PIL import Image
14
+
15
+ from services.account_service import account_service
16
+ from services.config import config
17
+ from services.proxy_service import proxy_settings
18
+ from utils.helper import UpstreamHTTPError, ensure_ok, iter_sse_payloads, new_uuid
19
+ from utils.log import logger
20
+ from utils.pow import build_legacy_requirements_token, build_proof_token, parse_pow_resources
21
+ from utils.turnstile import solve_turnstile_token
22
+
23
+
24
+ class InvalidAccessTokenError(RuntimeError):
25
+ pass
26
+
27
+
28
+ class ImagePollTimeoutError(RuntimeError):
29
+ pass
30
+
31
+
32
+ @dataclass
33
+ class ChatRequirements:
34
+ """保存一次对话请求所需的 sentinel token。"""
35
+ token: str
36
+ proof_token: str = ""
37
+ turnstile_token: str = ""
38
+ so_token: str = ""
39
+ raw_finalize: Optional[Dict[str, Any]] = None
40
+
41
+
42
+ DEFAULT_CLIENT_VERSION = "prod-be885abbfcfe7b1f511e88b3003d9ee44757fbad"
43
+ DEFAULT_CLIENT_BUILD_NUMBER = "5955942"
44
+ DEFAULT_POW_SCRIPT = "https://chatgpt.com/backend-api/sentinel/sdk.js"
45
+ CODEX_IMAGE_MODEL = "codex-gpt-image-2"
46
+
47
+
48
+ class OpenAIBackendAPI:
49
+ """ChatGPT Web 后端封装。
50
+
51
+ 说明:
52
+ - 传入 `access_token` 时,聊天和模型列表都会走已登录链路
53
+ 例如 `/backend-api/sentinel/chat-requirements`、`/backend-api/conversation`
54
+ - 不传 `access_token` 时,会走未登录链路
55
+ 例如 `/backend-anon/sentinel/chat-requirements`、`/backend-anon/conversation`
56
+ - `stream_conversation()` 是底层统一流式入口
57
+ - 协议兼容转换放在 `services.protocol`
58
+ """
59
+
60
+ def __init__(self, access_token: str = "") -> None:
61
+ """初始化后端客户端。
62
+
63
+ 参数:
64
+ - `access_token`:可选。传入后表示使用已登录链路;不传则使用未登录链路。
65
+ """
66
+ self.base_url = "https://chatgpt.com"
67
+ self.client_version = DEFAULT_CLIENT_VERSION
68
+ self.client_build_number = DEFAULT_CLIENT_BUILD_NUMBER
69
+ self.access_token = access_token
70
+ self.fp = self._build_fp()
71
+ self.user_agent = self.fp["user-agent"]
72
+ self.device_id = self.fp["oai-device-id"]
73
+ self.session_id = self.fp["oai-session-id"]
74
+ self.pow_script_sources: list[str] = []
75
+ self.pow_data_build = ""
76
+ self.session = requests.Session(**proxy_settings.build_session_kwargs(
77
+ impersonate=self.fp["impersonate"],
78
+ verify=True,
79
+ ))
80
+ self.session.headers.update({
81
+ "User-Agent": self.user_agent,
82
+ "Origin": self.base_url,
83
+ "Referer": self.base_url + "/",
84
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8,en-US;q=0.7",
85
+ "Cache-Control": "no-cache",
86
+ "Pragma": "no-cache",
87
+ "Priority": "u=1, i",
88
+ "Sec-Ch-Ua": self.fp["sec-ch-ua"],
89
+ "Sec-Ch-Ua-Arch": '"x86"',
90
+ "Sec-Ch-Ua-Bitness": '"64"',
91
+ "Sec-Ch-Ua-Full-Version": '"143.0.3650.96"',
92
+ "Sec-Ch-Ua-Full-Version-List": '"Microsoft Edge";v="143.0.3650.96", "Chromium";v="143.0.7499.147", "Not A(Brand";v="24.0.0.0"',
93
+ "Sec-Ch-Ua-Mobile": self.fp["sec-ch-ua-mobile"],
94
+ "Sec-Ch-Ua-Model": '""',
95
+ "Sec-Ch-Ua-Platform": self.fp["sec-ch-ua-platform"],
96
+ "Sec-Ch-Ua-Platform-Version": '"19.0.0"',
97
+ "Sec-Fetch-Dest": "empty",
98
+ "Sec-Fetch-Mode": "cors",
99
+ "Sec-Fetch-Site": "same-origin",
100
+ "OAI-Device-Id": self.device_id,
101
+ "OAI-Session-Id": self.session_id,
102
+ "OAI-Language": "zh-CN",
103
+ "OAI-Client-Version": self.client_version,
104
+ "OAI-Client-Build-Number": self.client_build_number,
105
+ })
106
+ if self.access_token:
107
+ self.session.headers["Authorization"] = f"Bearer {self.access_token}"
108
+
109
+ def _build_fp(self) -> Dict[str, str]:
110
+ account = account_service.get_account(self.access_token) if self.access_token else {}
111
+ account = account if isinstance(account, dict) else {}
112
+ raw_fp = account.get("fp")
113
+ fp = {str(k).lower(): str(v) for k, v in raw_fp.items()} if isinstance(raw_fp, dict) else {}
114
+ for key in (
115
+ "user-agent",
116
+ "impersonate",
117
+ "oai-device-id",
118
+ "oai-session-id",
119
+ "sec-ch-ua",
120
+ "sec-ch-ua-mobile",
121
+ "sec-ch-ua-platform",
122
+ ):
123
+ value = str(account.get(key) or "").strip()
124
+ if value:
125
+ fp[key] = value
126
+ fp.setdefault(
127
+ "user-agent",
128
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
129
+ "(KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0",
130
+ )
131
+ fp.setdefault("impersonate", "edge101")
132
+ fp.setdefault("oai-device-id", new_uuid())
133
+ fp.setdefault("oai-session-id", new_uuid())
134
+ fp.setdefault("sec-ch-ua", '"Microsoft Edge";v="143", "Chromium";v="143", "Not A(Brand";v="24"')
135
+ fp.setdefault("sec-ch-ua-mobile", "?0")
136
+ fp.setdefault("sec-ch-ua-platform", '"Windows"')
137
+ return fp
138
+
139
+ def _headers(self, path: str, extra: Optional[Dict[str, str]] = None) -> Dict[str, str]:
140
+ """构造请求头,并补上 web 端要求的 target path/route。"""
141
+ headers = dict(self.session.headers)
142
+ headers["X-OpenAI-Target-Path"] = path
143
+ headers["X-OpenAI-Target-Route"] = path
144
+ if extra:
145
+ headers.update(extra)
146
+ return headers
147
+
148
+ @staticmethod
149
+ def _extract_quota_and_restore_at(limits_progress: list[Any]) -> tuple[int, str | None, bool]:
150
+ for item in limits_progress:
151
+ if isinstance(item, dict) and item.get("feature_name") == "image_gen":
152
+ return int(item.get("remaining") or 0), str(item.get("reset_after") or "") or None, False
153
+ return 0, None, True
154
+
155
+ def _get_me(self) -> Dict[str, Any]:
156
+ path = "/backend-api/me"
157
+ response = self.session.get(self.base_url + path, headers=self._headers(path), timeout=20)
158
+ if response.status_code != 200:
159
+ if response.status_code == 401:
160
+ raise InvalidAccessTokenError(f"{path} failed: HTTP {response.status_code}")
161
+ raise RuntimeError(f"{path} failed: HTTP {response.status_code}")
162
+ return response.json()
163
+
164
+ def _get_conversation_init(self) -> Dict[str, Any]:
165
+ path = "/backend-api/conversation/init"
166
+ response = self.session.post(
167
+ self.base_url + path,
168
+ headers=self._headers(path, {"Content-Type": "application/json"}),
169
+ json={
170
+ "gizmo_id": None,
171
+ "requested_default_model": None,
172
+ "conversation_id": None,
173
+ "timezone_offset_min": -480,
174
+ },
175
+ timeout=20,
176
+ )
177
+ if response.status_code != 200:
178
+ if response.status_code == 401:
179
+ raise InvalidAccessTokenError(f"{path} failed: HTTP {response.status_code}")
180
+ raise RuntimeError(f"{path} failed: HTTP {response.status_code}")
181
+ return response.json()
182
+
183
+ def _get_default_account(self) -> Dict[str, Any]:
184
+ route = "/backend-api/accounts/check/v4-2023-04-27"
185
+ response = self.session.get(self.base_url + route + "?timezone_offset_min=-480", headers=self._headers(route),
186
+ timeout=20)
187
+ if response.status_code != 200:
188
+ if response.status_code == 401:
189
+ raise InvalidAccessTokenError(f"{route} failed: HTTP {response.status_code}")
190
+ raise RuntimeError(f"/backend-api/accounts/check failed: HTTP {response.status_code}")
191
+ payload = response.json()
192
+ logger.debug({"event": "backend_user_info_account_payload", "account_payload": payload})
193
+ return ((payload.get("accounts") or {}).get("default") or {}).get("account") or {}
194
+
195
+ def get_user_info(self) -> Dict[str, Any]:
196
+ """获取当前 token 的账号信息。"""
197
+ if not self.access_token:
198
+ raise RuntimeError("access_token is required")
199
+ logger.debug({"event": "backend_user_info_start"})
200
+ with ThreadPoolExecutor(max_workers=3) as executor:
201
+ me_future = executor.submit(self._get_me)
202
+ init_future = executor.submit(self._get_conversation_init)
203
+ account_future = executor.submit(self._get_default_account)
204
+ me_payload, init_payload, default_account = me_future.result(), init_future.result(), account_future.result()
205
+
206
+ plan_type = str(default_account.get("plan_type") or "free")
207
+
208
+ limits_progress = init_payload.get("limits_progress")
209
+ limits_progress = limits_progress if isinstance(limits_progress, list) else []
210
+ quota, restore_at, image_quota_unknown = self._extract_quota_and_restore_at(limits_progress)
211
+ result = {
212
+ "email": me_payload.get("email"),
213
+ "user_id": me_payload.get("id"),
214
+ "type": plan_type,
215
+ "quota": quota,
216
+ "image_quota_unknown": image_quota_unknown,
217
+ "limits_progress": limits_progress,
218
+ "default_model_slug": init_payload.get("default_model_slug"),
219
+ "restore_at": restore_at,
220
+ "status": "正常" if image_quota_unknown and plan_type.lower() != "free" else ("限流" if quota == 0 else "正常"),
221
+ }
222
+ logger.debug({
223
+ "event": "backend_user_info_result",
224
+ "email": result.get("email"),
225
+ "user_id": result.get("user_id"),
226
+ "type": result.get("type"),
227
+ "quota": result.get("quota"),
228
+ "image_quota_unknown": result.get("image_quota_unknown"),
229
+ "default_model_slug": result.get("default_model_slug"),
230
+ "restore_at": result.get("restore_at"),
231
+ "status": result.get("status"),
232
+ })
233
+ return result
234
+
235
+ def _bootstrap_headers(self) -> Dict[str, str]:
236
+ """构造首页预热请求头。"""
237
+ return {
238
+ "User-Agent": self.user_agent,
239
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
240
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
241
+ "Sec-Ch-Ua": self.session.headers["Sec-Ch-Ua"],
242
+ "Sec-Ch-Ua-Mobile": self.session.headers["Sec-Ch-Ua-Mobile"],
243
+ "Sec-Ch-Ua-Platform": self.session.headers["Sec-Ch-Ua-Platform"],
244
+ "Sec-Fetch-Dest": "document",
245
+ "Sec-Fetch-Mode": "navigate",
246
+ "Sec-Fetch-Site": "none",
247
+ "Sec-Fetch-User": "?1",
248
+ "Upgrade-Insecure-Requests": "1",
249
+ }
250
+
251
+ def _build_requirements(self, data: Dict[str, Any], source_p: str = "") -> ChatRequirements:
252
+ """把 sentinel 响应整理成后续对话需要的 token 集合。"""
253
+ if (data.get("arkose") or {}).get("required"):
254
+ raise RuntimeError("chat requirements requires arkose token, which is not implemented")
255
+
256
+ proof_token = ""
257
+ proof_info = data.get("proofofwork") or {}
258
+ if proof_info.get("required"):
259
+ proof_token = build_proof_token(
260
+ proof_info.get("seed", ""),
261
+ proof_info.get("difficulty", ""),
262
+ self.user_agent,
263
+ script_sources=self.pow_script_sources,
264
+ data_build=self.pow_data_build,
265
+ )
266
+
267
+ turnstile_token = ""
268
+ turnstile_info = data.get("turnstile") or {}
269
+ if turnstile_info.get("required") and turnstile_info.get("dx"):
270
+ turnstile_token = solve_turnstile_token(turnstile_info["dx"], source_p) or ""
271
+
272
+ return ChatRequirements(
273
+ token=data.get("token", ""),
274
+ proof_token=proof_token,
275
+ turnstile_token=turnstile_token,
276
+ so_token=data.get("so_token", ""),
277
+ raw_finalize=data,
278
+ )
279
+
280
+ def _conversation_headers(self, path: str, requirements: ChatRequirements) -> Dict[str, str]:
281
+ """根据当前 requirements 构造对话 SSE 请求头。"""
282
+ headers = {
283
+ "Accept": "text/event-stream",
284
+ "Content-Type": "application/json",
285
+ "OpenAI-Sentinel-Chat-Requirements-Token": requirements.token,
286
+ }
287
+ if requirements.proof_token:
288
+ headers["OpenAI-Sentinel-Proof-Token"] = requirements.proof_token
289
+ if requirements.turnstile_token:
290
+ headers["OpenAI-Sentinel-Turnstile-Token"] = requirements.turnstile_token
291
+ if requirements.so_token:
292
+ headers["OpenAI-Sentinel-SO-Token"] = requirements.so_token
293
+ return self._headers(path, headers)
294
+
295
+ def _api_messages_to_conversation_messages(self, messages: list[Dict[str, Any]]) -> list[Dict[str, Any]]:
296
+ """把标准 chat messages 转成 web conversation 所需的 messages。"""
297
+ conversation_messages = []
298
+ for item in messages:
299
+ role = item.get("role", "user")
300
+ content = item.get("content", "")
301
+ if isinstance(content, str):
302
+ conversation_messages.append({
303
+ "id": new_uuid(),
304
+ "author": {"role": role},
305
+ "content": {"content_type": "text", "parts": [content]},
306
+ })
307
+ continue
308
+ if not isinstance(content, list):
309
+ raise RuntimeError("only string or list message content is supported")
310
+ text_parts: list[str] = []
311
+ image_inputs: list[tuple[bytes, str]] = []
312
+ for part in content:
313
+ if not isinstance(part, dict):
314
+ continue
315
+ part_type = str(part.get("type") or "")
316
+ if part_type == "text":
317
+ text_parts.append(str(part.get("text") or ""))
318
+ elif part_type == "image":
319
+ data = part.get("data")
320
+ mime = str(part.get("mime") or "image/png")
321
+ if isinstance(data, (bytes, bytearray)):
322
+ image_inputs.append((bytes(data), mime))
323
+ if not image_inputs:
324
+ conversation_messages.append({
325
+ "id": new_uuid(),
326
+ "author": {"role": role},
327
+ "content": {"content_type": "text", "parts": ["".join(text_parts)]},
328
+ })
329
+ continue
330
+ if not self.access_token:
331
+ raise RuntimeError("authenticated upstream account required for image input")
332
+ uploaded: list[Dict[str, Any]] = []
333
+ for idx, (data, mime) in enumerate(image_inputs, start=1):
334
+ ext_part = mime.split("/", 1)[1].split("+")[0] if "/" in mime else "png"
335
+ extension = "jpg" if ext_part == "jpeg" else (ext_part or "png")
336
+ b64 = base64.b64encode(data).decode("ascii")
337
+ uploaded.append(self._upload_image(f"data:{mime};base64,{b64}", f"image_{idx}.{extension}"))
338
+ parts: list[Any] = []
339
+ for ref in uploaded:
340
+ parts.append({
341
+ "content_type": "image_asset_pointer",
342
+ "asset_pointer": f"file-service://{ref['file_id']}",
343
+ "width": ref["width"],
344
+ "height": ref["height"],
345
+ "size_bytes": ref["file_size"],
346
+ })
347
+ text = "".join(text_parts)
348
+ if text:
349
+ parts.append(text)
350
+ conversation_messages.append({
351
+ "id": new_uuid(),
352
+ "author": {"role": role},
353
+ "content": {"content_type": "multimodal_text", "parts": parts},
354
+ "metadata": {
355
+ "attachments": [{
356
+ "id": ref["file_id"],
357
+ "mimeType": ref["mime_type"],
358
+ "name": ref["file_name"],
359
+ "size": ref["file_size"],
360
+ "width": ref["width"],
361
+ "height": ref["height"],
362
+ } for ref in uploaded],
363
+ },
364
+ })
365
+ return conversation_messages
366
+
367
+ def _conversation_payload(self, messages: list[Dict[str, Any]], model: str, timezone: str) -> Dict[str, Any]:
368
+ """把标准 messages 构造成 web 对话请求体。"""
369
+ return {
370
+ "action": "next",
371
+ "messages": self._api_messages_to_conversation_messages(messages),
372
+ "model": model,
373
+ "parent_message_id": new_uuid(),
374
+ "conversation_mode": {"kind": "primary_assistant"},
375
+ "conversation_origin": None,
376
+ "force_paragen": False,
377
+ "force_paragen_model_slug": "",
378
+ "force_rate_limit": False,
379
+ "force_use_sse": True,
380
+ "history_and_training_disabled": True,
381
+ "reset_rate_limits": False,
382
+ "suggestions": [],
383
+ "supported_encodings": [],
384
+ "system_hints": [],
385
+ "timezone": timezone,
386
+ "timezone_offset_min": -480,
387
+ "variant_purpose": "comparison_implicit",
388
+ "websocket_request_id": new_uuid(),
389
+ "client_contextual_info": {
390
+ "is_dark_mode": False,
391
+ "time_since_loaded": 120,
392
+ "page_height": 900,
393
+ "page_width": 1400,
394
+ "pixel_ratio": 2,
395
+ "screen_height": 1440,
396
+ "screen_width": 2560,
397
+ },
398
+ }
399
+
400
+ def _image_model_slug(self, model: str) -> str:
401
+ """把标准图片模型名映射到底层 model slug。"""
402
+ model = str(model or "").strip()
403
+ if not model:
404
+ return "auto"
405
+ if model == "gpt-image-2":
406
+ return "gpt-5-3"
407
+ if model == CODEX_IMAGE_MODEL:
408
+ return model
409
+ return "auto"
410
+
411
+ def _image_headers(self, path: str, requirements: ChatRequirements, conduit_token: str = "", accept: str = "*/*") -> \
412
+ Dict[str, str]:
413
+ """构造图片链路请求头。"""
414
+ headers = {
415
+ "Content-Type": "application/json",
416
+ "Accept": accept,
417
+ "OpenAI-Sentinel-Chat-Requirements-Token": requirements.token,
418
+ }
419
+ if requirements.proof_token:
420
+ headers["OpenAI-Sentinel-Proof-Token"] = requirements.proof_token
421
+ if conduit_token:
422
+ headers["X-Conduit-Token"] = conduit_token
423
+ if accept == "text/event-stream":
424
+ headers["X-Oai-Turn-Trace-Id"] = new_uuid()
425
+ return self._headers(path, headers)
426
+
427
+ def _prepare_image_conversation(self, prompt: str, requirements: ChatRequirements, model: str) -> str:
428
+ """为图片生成准备 conduit token。"""
429
+ path = "/backend-api/f/conversation/prepare"
430
+ payload = {
431
+ "action": "next",
432
+ "fork_from_shared_post": False,
433
+ "parent_message_id": new_uuid(),
434
+ "model": self._image_model_slug(model),
435
+ "client_prepare_state": "success",
436
+ "timezone_offset_min": -480,
437
+ "timezone": "Asia/Shanghai",
438
+ "conversation_mode": {"kind": "primary_assistant"},
439
+ "system_hints": ["picture_v2"],
440
+ "partial_query": {
441
+ "id": new_uuid(),
442
+ "author": {"role": "user"},
443
+ "content": {"content_type": "text", "parts": [prompt]},
444
+ },
445
+ "supports_buffering": True,
446
+ "supported_encodings": ["v1"],
447
+ "client_contextual_info": {"app_name": "chatgpt.com"},
448
+ }
449
+ response = self.session.post(
450
+ self.base_url + path,
451
+ headers=self._image_headers(path, requirements),
452
+ json=payload,
453
+ timeout=60,
454
+ )
455
+ ensure_ok(response, path)
456
+ return response.json().get("conduit_token", "")
457
+
458
+ def _decode_image_base64(self, image: str) -> bytes:
459
+ """把 base64 图片字符串或本地路径解码成二进制。"""
460
+ if (
461
+ image
462
+ and len(image) < 512
463
+ and not image.startswith("data:")
464
+ and "\n" not in image
465
+ and "\r" not in image
466
+ ):
467
+ file_path = Path(os.path.expanduser(image))
468
+ if file_path.exists() and file_path.is_file():
469
+ return file_path.read_bytes()
470
+ payload = image.split(",", 1)[1] if image.startswith("data:") and "," in image else image
471
+ return base64.b64decode(payload)
472
+
473
+ def _upload_image(self, image: str, file_name: str = "image.png") -> Dict[str, Any]:
474
+ """上传一张 base64 图片,返回底层文件元数据。"""
475
+ data = self._decode_image_base64(image)
476
+ if (
477
+ image
478
+ and len(image) < 512
479
+ and not image.startswith("data:")
480
+ and "\n" not in image
481
+ and "\r" not in image
482
+ ):
483
+ candidate_path = Path(os.path.expanduser(image))
484
+ if candidate_path.exists() and candidate_path.is_file():
485
+ file_name = candidate_path.name
486
+ image = Image.open(BytesIO(data))
487
+ width, height = image.size
488
+ mime_type = Image.MIME.get(image.format, "image/png")
489
+ path = "/backend-api/files"
490
+ response = self.session.post(
491
+ self.base_url + path,
492
+ headers=self._headers(path, {"Content-Type": "application/json", "Accept": "application/json"}),
493
+ json={"file_name": file_name, "file_size": len(data), "use_case": "multimodal", "width": width,
494
+ "height": height},
495
+ timeout=60,
496
+ )
497
+ ensure_ok(response, path)
498
+ upload_meta = response.json()
499
+ time.sleep(0.5)
500
+ response = self.session.put(
501
+ upload_meta["upload_url"],
502
+ headers={
503
+ "Content-Type": mime_type,
504
+ "x-ms-blob-type": "BlockBlob",
505
+ "x-ms-version": "2020-04-08",
506
+ "Origin": self.base_url,
507
+ "Referer": self.base_url + "/",
508
+ "User-Agent": self.user_agent,
509
+ "Accept": "application/json, text/plain, */*",
510
+ "Accept-Language": "en-US,en;q=0.8",
511
+ },
512
+ data=data,
513
+ timeout=120,
514
+ )
515
+ ensure_ok(response, "image_upload")
516
+ path = f"/backend-api/files/{upload_meta['file_id']}/uploaded"
517
+ response = self.session.post(
518
+ self.base_url + path,
519
+ headers=self._headers(path, {"Content-Type": "application/json", "Accept": "application/json"}),
520
+ data="{}",
521
+ timeout=60,
522
+ )
523
+ ensure_ok(response, path)
524
+ return {
525
+ "file_id": upload_meta["file_id"],
526
+ "file_name": file_name,
527
+ "file_size": len(data),
528
+ "mime_type": mime_type,
529
+ "width": width,
530
+ "height": height,
531
+ }
532
+
533
+ def _start_image_generation(self, prompt: str, requirements: ChatRequirements, conduit_token: str, model: str,
534
+ references: Optional[list[Dict[str, Any]]] = None) -> requests.Response:
535
+ """启动图片生成或编辑的 SSE 请求。"""
536
+ references = references or []
537
+ parts = [{
538
+ "content_type": "image_asset_pointer",
539
+ "asset_pointer": f"file-service://{item['file_id']}",
540
+ "width": item["width"],
541
+ "height": item["height"],
542
+ "size_bytes": item["file_size"],
543
+ } for item in references]
544
+ parts.append(prompt)
545
+ content = {"content_type": "multimodal_text", "parts": parts} if references else {"content_type": "text",
546
+ "parts": [prompt]}
547
+ metadata = {
548
+ "developer_mode_connector_ids": [],
549
+ "selected_github_repos": [],
550
+ "selected_all_github_repos": False,
551
+ "system_hints": ["picture_v2"],
552
+ "serialization_metadata": {"custom_symbol_offsets": []},
553
+ }
554
+ if references:
555
+ metadata["attachments"] = [{
556
+ "id": item["file_id"],
557
+ "mimeType": item["mime_type"],
558
+ "name": item["file_name"],
559
+ "size": item["file_size"],
560
+ "width": item["width"],
561
+ "height": item["height"],
562
+ } for item in references]
563
+ payload = {
564
+ "action": "next",
565
+ "messages": [{
566
+ "id": new_uuid(),
567
+ "author": {"role": "user"},
568
+ "create_time": time.time(),
569
+ "content": content,
570
+ "metadata": metadata,
571
+ }],
572
+ "parent_message_id": new_uuid(),
573
+ "model": self._image_model_slug(model),
574
+ "client_prepare_state": "sent",
575
+ "timezone_offset_min": -480,
576
+ "timezone": "Asia/Shanghai",
577
+ "conversation_mode": {"kind": "primary_assistant"},
578
+ "enable_message_followups": True,
579
+ "system_hints": ["picture_v2"],
580
+ "supports_buffering": True,
581
+ "supported_encodings": ["v1"],
582
+ "client_contextual_info": {
583
+ "is_dark_mode": False,
584
+ "time_since_loaded": 1200,
585
+ "page_height": 1072,
586
+ "page_width": 1724,
587
+ "pixel_ratio": 1.2,
588
+ "screen_height": 1440,
589
+ "screen_width": 2560,
590
+ "app_name": "chatgpt.com",
591
+ },
592
+ "paragen_cot_summary_display_override": "allow",
593
+ "force_parallel_switch": "auto",
594
+ }
595
+ path = "/backend-api/f/conversation"
596
+ response = self.session.post(
597
+ self.base_url + path,
598
+ headers=self._image_headers(path, requirements, conduit_token, "text/event-stream"),
599
+ json=payload,
600
+ timeout=300,
601
+ stream=True,
602
+ )
603
+ ensure_ok(response, path)
604
+ return response
605
+
606
+ def _get_conversation(self, conversation_id: str) -> Dict[str, Any]:
607
+ """获取完整 conversation 详情。"""
608
+ path = f"/backend-api/conversation/{conversation_id}"
609
+ response = self.session.get(self.base_url + path, headers=self._headers(path, {"Accept": "application/json"}),
610
+ timeout=60)
611
+ ensure_ok(response, path)
612
+ return response.json()
613
+
614
+ def _extract_image_tool_records(self, data: Dict[str, Any]) -> list[Dict[str, Any]]:
615
+ """从 conversation 明细里提取图片工具输出记录。"""
616
+ mapping = data.get("mapping") or {}
617
+ file_pat = re.compile(r"file-service://([A-Za-z0-9_-]+)")
618
+ sed_pat = re.compile(r"sediment://([A-Za-z0-9_-]+)")
619
+ records = []
620
+ for message_id, node in mapping.items():
621
+ message = (node or {}).get("message") or {}
622
+ author = message.get("author") or {}
623
+ metadata = message.get("metadata") or {}
624
+ content = message.get("content") or {}
625
+ if author.get("role") != "tool":
626
+ continue
627
+ if content.get("content_type") != "multimodal_text":
628
+ continue
629
+ file_ids, sediment_ids = [], []
630
+ for part in content.get("parts") or []:
631
+ text = (part.get("asset_pointer") or "") if isinstance(part, dict) else (
632
+ part if isinstance(part, str) else "")
633
+ for hit in file_pat.findall(text):
634
+ if hit not in file_ids:
635
+ file_ids.append(hit)
636
+ for hit in sed_pat.findall(text):
637
+ if hit not in sediment_ids:
638
+ sediment_ids.append(hit)
639
+ if metadata.get("async_task_type") != "image_gen" and not file_ids and not sediment_ids:
640
+ continue
641
+ records.append(
642
+ {"message_id": message_id, "create_time": message.get("create_time") or 0, "file_ids": file_ids,
643
+ "sediment_ids": sediment_ids})
644
+ return sorted(records, key=lambda item: item["create_time"])
645
+
646
+ def _poll_image_results(self, conversation_id: str, timeout_secs: float = 120.0) -> tuple[list[str], list[str]]:
647
+ """Poll the conversation document until image file ids appear or budget runs out.
648
+
649
+ - Sleeps image_poll_initial_wait_secs first (default 10s, +jitter). ChatGPT
650
+ image generation takes ~30s; polling immediately wastes requests and trips
651
+ a transient 429 the upstream returns within ~200ms of the SSE stream
652
+ closing (the conversation document is not yet committed).
653
+ - Subsequent polls are image_poll_interval_secs apart (default 10s).
654
+ - On upstream 429 / 5xx or network errors, backs off exponentially
655
+ (capped at 16s, +jitter) honoring Retry-After when present.
656
+ - All sleeps stay within timeout_secs; on exhaustion raises ImagePollTimeoutError.
657
+ """
658
+ start = time.time()
659
+ attempt = 0
660
+ interval = float(config.image_poll_interval_secs)
661
+ initial_wait = float(config.image_poll_initial_wait_secs)
662
+ logger.info({
663
+ "event": "image_poll_start",
664
+ "conversation_id": conversation_id,
665
+ "timeout_secs": timeout_secs,
666
+ "initial_wait_secs": initial_wait,
667
+ "interval_secs": interval,
668
+ })
669
+
670
+ def _remaining() -> float:
671
+ return timeout_secs - (time.time() - start)
672
+
673
+ if initial_wait > 0:
674
+ jitter = random.uniform(0, min(2.0, initial_wait * 0.2))
675
+ sleep_for = min(initial_wait + jitter, max(0.0, _remaining()))
676
+ if sleep_for > 0:
677
+ time.sleep(sleep_for)
678
+
679
+ def _retry_sleep(reason: str, status_code: int | None, error: str | None, retry_after: int | None) -> bool:
680
+ # retry_after=0 means "retry immediately" ��� must not be coerced via falsy check.
681
+ base = retry_after if retry_after is not None else min(2 ** min(attempt, 4), 16)
682
+ backoff = base + random.uniform(0, 0.5)
683
+ remaining = _remaining()
684
+ if remaining <= 0:
685
+ return False
686
+ sleep_for = min(backoff, remaining)
687
+ log_payload: Dict[str, Any] = {
688
+ "event": "image_poll_retry",
689
+ "conversation_id": conversation_id,
690
+ "attempt": attempt,
691
+ "reason": reason,
692
+ "sleep_secs": round(sleep_for, 2),
693
+ }
694
+ if status_code is not None:
695
+ log_payload["status_code"] = status_code
696
+ if error is not None:
697
+ log_payload["error"] = error
698
+ logger.warning(log_payload)
699
+ time.sleep(sleep_for)
700
+ return True
701
+
702
+ while _remaining() > 0:
703
+ attempt += 1
704
+ try:
705
+ conversation = self._get_conversation(conversation_id)
706
+ except UpstreamHTTPError as exc:
707
+ if exc.status_code in (429, 500, 502, 503, 504):
708
+ if _retry_sleep("upstream_status", exc.status_code, None, exc.retry_after):
709
+ continue
710
+ break
711
+ raise
712
+ except requests.exceptions.RequestException as exc:
713
+ if _retry_sleep("network", None, str(exc), None):
714
+ continue
715
+ break
716
+
717
+ file_ids, sediment_ids = [], []
718
+ for record in self._extract_image_tool_records(conversation):
719
+ for file_id in record["file_ids"]:
720
+ if file_id not in file_ids:
721
+ file_ids.append(file_id)
722
+ for sediment_id in record["sediment_ids"]:
723
+ if sediment_id not in sediment_ids:
724
+ sediment_ids.append(sediment_id)
725
+ logger.debug({"event": "image_poll_check", "conversation_id": conversation_id, "attempt": attempt,
726
+ "file_ids": file_ids, "sediment_ids": sediment_ids})
727
+ if file_ids:
728
+ logger.info({"event": "image_poll_hit", "conversation_id": conversation_id, "file_ids": file_ids,
729
+ "sediment_ids": sediment_ids})
730
+ return file_ids, sediment_ids
731
+ if sediment_ids:
732
+ logger.info({"event": "image_poll_hit", "conversation_id": conversation_id, "file_ids": [],
733
+ "sediment_ids": sediment_ids})
734
+ return [], sediment_ids
735
+ logger.debug({"event": "image_poll_wait", "conversation_id": conversation_id,
736
+ "elapsed_secs": round(time.time() - start, 1)})
737
+ wait = min(interval, max(0.0, _remaining()))
738
+ if wait > 0:
739
+ time.sleep(wait)
740
+ logger.info({
741
+ "event": "image_poll_timeout",
742
+ "conversation_id": conversation_id,
743
+ "timeout_secs": timeout_secs,
744
+ "attempts_made": attempt,
745
+ # attempts_made == 0 means the initial_wait consumed the entire budget — no HTTP attempted.
746
+ "initial_wait_exhausted_budget": attempt == 0,
747
+ })
748
+ raise ImagePollTimeoutError(
749
+ f"ChatGPT 生图超时(已等待 {timeout_secs} 秒)。"
750
+ f"当前超时阈值可在 config.json 中调大 image_poll_timeout_secs,"
751
+ f"也可能是账号被限流或生图队列拥堵导致。"
752
+ )
753
+
754
+ def _get_file_download_url(self, file_id: str) -> str:
755
+ """获取文件下载地址。"""
756
+ path = f"/backend-api/files/{file_id}/download"
757
+ response = self.session.get(self.base_url + path, headers=self._headers(path, {"Accept": "application/json"}),
758
+ timeout=60)
759
+ ensure_ok(response, path)
760
+ data = response.json()
761
+ return data.get("download_url") or data.get("url") or ""
762
+
763
+ def _get_attachment_download_url(self, conversation_id: str, attachment_id: str) -> str:
764
+ """通过 conversation 附件接口获取下载地址。"""
765
+ path = f"/backend-api/conversation/{conversation_id}/attachment/{attachment_id}/download"
766
+ response = self.session.get(self.base_url + path, headers=self._headers(path, {"Accept": "application/json"}),
767
+ timeout=60)
768
+ ensure_ok(response, path)
769
+ data = response.json()
770
+ return data.get("download_url") or data.get("url") or ""
771
+
772
+ def _resolve_image_urls(self, conversation_id: str, file_ids: list[str], sediment_ids: list[str]) -> list[str]:
773
+ """把图片结果 id 解析成可下载 URL。"""
774
+ urls = []
775
+ skip_patterns = {"file_upload"}
776
+ for file_id in file_ids:
777
+ if file_id in skip_patterns:
778
+ logger.debug({
779
+ "event": "image_file_id_skipped",
780
+ "source": "file",
781
+ "conversation_id": conversation_id,
782
+ "id": file_id,
783
+ })
784
+ continue
785
+ try:
786
+ url = self._get_file_download_url(file_id)
787
+ except Exception as exc:
788
+ logger.debug({
789
+ "event": "image_download_url_failed",
790
+ "source": "file",
791
+ "conversation_id": conversation_id,
792
+ "id": file_id,
793
+ "error": repr(exc),
794
+ })
795
+ continue
796
+ if url:
797
+ urls.append(url)
798
+ else:
799
+ logger.debug({
800
+ "event": "image_download_url_empty",
801
+ "source": "file",
802
+ "conversation_id": conversation_id,
803
+ "id": file_id,
804
+ })
805
+ if urls or not conversation_id:
806
+ logger.debug({
807
+ "event": "image_urls_resolved",
808
+ "conversation_id": conversation_id,
809
+ "file_ids": file_ids,
810
+ "sediment_ids": sediment_ids,
811
+ "urls": urls,
812
+ })
813
+ return urls
814
+ for sediment_id in sediment_ids:
815
+ try:
816
+ url = self._get_attachment_download_url(conversation_id, sediment_id)
817
+ except Exception as exc:
818
+ logger.debug({
819
+ "event": "image_download_url_failed",
820
+ "source": "sediment",
821
+ "conversation_id": conversation_id,
822
+ "id": sediment_id,
823
+ "error": repr(exc),
824
+ })
825
+ continue
826
+ if url:
827
+ urls.append(url)
828
+ else:
829
+ logger.debug({
830
+ "event": "image_download_url_empty",
831
+ "source": "sediment",
832
+ "conversation_id": conversation_id,
833
+ "id": sediment_id,
834
+ })
835
+ logger.debug({
836
+ "event": "image_urls_resolved",
837
+ "conversation_id": conversation_id,
838
+ "file_ids": file_ids,
839
+ "sediment_ids": sediment_ids,
840
+ "urls": urls,
841
+ })
842
+ return urls
843
+
844
+ def resolve_conversation_image_urls(
845
+ self,
846
+ conversation_id: str,
847
+ file_ids: list[str],
848
+ sediment_ids: list[str],
849
+ poll: bool = True,
850
+ ) -> list[str]:
851
+ file_ids = [item for item in file_ids if item != "file_upload"]
852
+ sediment_ids = list(sediment_ids)
853
+ if poll and conversation_id and not file_ids and not sediment_ids:
854
+ logger.info({"event": "image_resolve_poll_needed", "conversation_id": conversation_id})
855
+ polled_file_ids, polled_sediment_ids = self._poll_image_results(conversation_id,
856
+ config.image_poll_timeout_secs)
857
+ file_ids.extend(item for item in polled_file_ids if item and item not in file_ids)
858
+ sediment_ids.extend(item for item in polled_sediment_ids if item and item not in sediment_ids)
859
+ return self._resolve_image_urls(conversation_id, file_ids, sediment_ids)
860
+
861
+ def download_image_bytes(self, urls: list[str]) -> list[bytes]:
862
+ images = []
863
+ for url in urls:
864
+ response = self.session.get(url, timeout=120)
865
+ ensure_ok(response, "image_download")
866
+ images.append(response.content)
867
+ return images
868
+
869
+ def stream_conversation(
870
+ self,
871
+ messages: Optional[list[Dict[str, Any]]] = None,
872
+ model: str = "auto",
873
+ prompt: str = "",
874
+ images: Optional[list[str]] = None,
875
+ system_hints: Optional[list[str]] = None,
876
+ ) -> Iterator[str]:
877
+ system_hints = system_hints or []
878
+ if "picture_v2" in system_hints:
879
+ yield from self._stream_picture_conversation(prompt, model, images or [])
880
+ return
881
+
882
+ normalized = messages or [{"role": "user", "content": prompt}]
883
+ self._bootstrap()
884
+ requirements = self._get_chat_requirements()
885
+ path, timezone = self._chat_target()
886
+ payload = self._conversation_payload(normalized, model, timezone)
887
+ response = self.session.post(
888
+ self.base_url + path,
889
+ headers=self._conversation_headers(path, requirements),
890
+ json=payload,
891
+ timeout=300,
892
+ stream=True,
893
+ )
894
+ ensure_ok(response, path)
895
+ try:
896
+ yield from iter_sse_payloads(response)
897
+ finally:
898
+ response.close()
899
+
900
+ def _stream_picture_conversation(
901
+ self,
902
+ prompt: str,
903
+ model: str,
904
+ images: list[str],
905
+ ) -> Iterator[str]:
906
+ if not self.access_token:
907
+ raise RuntimeError("access_token is required for image endpoints")
908
+ references = [self._upload_image(image, f"image_{idx}.png") for idx, image in enumerate(images, start=1)]
909
+ self._bootstrap()
910
+ requirements = self._get_chat_requirements()
911
+ conduit_token = self._prepare_image_conversation(prompt, requirements, model)
912
+ response = self._start_image_generation(prompt, requirements, conduit_token, model, references)
913
+ try:
914
+ yield from iter_sse_payloads(response)
915
+ finally:
916
+ response.close()
917
+
918
+ def _bootstrap(self) -> None:
919
+ """预热首页,并提取 PoW 相关脚本引用。"""
920
+ response = self.session.get(
921
+ self.base_url + "/",
922
+ headers=self._bootstrap_headers(),
923
+ timeout=30,
924
+ )
925
+ ensure_ok(response, "bootstrap")
926
+ self.pow_script_sources, self.pow_data_build = parse_pow_resources(response.text)
927
+ if not self.pow_script_sources:
928
+ self.pow_script_sources = [DEFAULT_POW_SCRIPT]
929
+
930
+ def _get_chat_requirements(self) -> ChatRequirements:
931
+ """获取当前模式对话所需的 sentinel token。"""
932
+ path = "/backend-api/sentinel/chat-requirements" if self.access_token else "/backend-anon/sentinel/chat-requirements"
933
+ context = "auth_chat_requirements" if self.access_token else "noauth_chat_requirements"
934
+ body = {"p": build_legacy_requirements_token(self.user_agent, self.pow_script_sources, self.pow_data_build)}
935
+ response = self.session.post(
936
+ self.base_url + path,
937
+ headers=self._headers(path, {"Content-Type": "application/json"}),
938
+ json=body,
939
+ timeout=30,
940
+ )
941
+ ensure_ok(response, context)
942
+ requirements = self._build_requirements(response.json(), "" if self.access_token else body["p"])
943
+ if not requirements.token:
944
+ message = "missing auth chat requirements token" if self.access_token else "missing chat requirements token"
945
+ raise RuntimeError(f"{message}: {requirements.raw_finalize}")
946
+ return requirements
947
+
948
+ def _chat_target(self) -> tuple[str, str]:
949
+ if self.access_token:
950
+ return "/backend-api/conversation", "Asia/Shanghai"
951
+ return "/backend-anon/conversation", "America/Los_Angeles"
952
+
953
+ def list_models(self) -> Dict[str, Any]:
954
+ """返回当前模式下可用模型,格式对齐 OpenAI `/v1/models`。"""
955
+ self._bootstrap()
956
+ path = "/backend-api/models?history_and_training_disabled=false" if self.access_token else (
957
+ "/backend-anon/models?iim=false&is_gizmo=false"
958
+ )
959
+ route = "/backend-api/models" if self.access_token else "/backend-anon/models"
960
+ context = "auth_models" if self.access_token else "anon_models"
961
+ response = self.session.get(
962
+ self.base_url + path,
963
+ headers=self._headers(route),
964
+ timeout=30,
965
+ )
966
+ ensure_ok(response, context)
967
+ data = []
968
+ seen = set()
969
+ for item in response.json().get("models", []):
970
+ if not isinstance(item, dict):
971
+ continue
972
+ slug = str(item.get("slug", "")).strip()
973
+ if not slug or slug in seen:
974
+ continue
975
+ seen.add(slug)
976
+ data.append({
977
+ "id": slug,
978
+ "object": "model",
979
+ "created": int(item.get("created") or 0),
980
+ "owned_by": str(item.get("owned_by") or "chatgpt"),
981
+ "permission": [],
982
+ "root": slug,
983
+ "parent": None,
984
+ })
985
+ data.sort(key=lambda item: item["id"])
986
+ return {"object": "list", "data": data}
services/protocol/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """Protocol converters for OpenAI-compatible endpoints."""
2
+
services/protocol/anthropic_v1_messages.py ADDED
@@ -0,0 +1,306 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import html
4
+ import json
5
+ import re
6
+ import time
7
+ import uuid
8
+ from collections.abc import Callable, Iterable, Iterator
9
+ from dataclasses import dataclass
10
+ from typing import Any
11
+
12
+ from services.account_service import account_service
13
+ from services.openai_backend_api import OpenAIBackendAPI
14
+ from services.protocol.conversation import count_message_tokens, count_text_tokens, normalize_messages
15
+ from services.protocol.openai_v1_chat_complete import collect_chat_content, stream_text_chat_completion
16
+
17
+ XML_TOOL_RULE = """Tool output adapter: when calling tools, output ONLY this XML and no prose/markdown:
18
+ <tool_calls><tool_call><tool_name>TOOL_NAME</tool_name><parameters><PARAM><![CDATA[value]]></PARAM></parameters></tool_call></tool_calls>"""
19
+
20
+
21
+ @dataclass
22
+ class MessageRequest:
23
+ backend: OpenAIBackendAPI
24
+ messages: list[dict[str, Any]]
25
+ model: str
26
+ tools: Any = None
27
+
28
+
29
+ def _tool_meta(tool: dict[str, object]) -> tuple[str, str, object]:
30
+ fn = tool.get("function") if isinstance(tool.get("function"), dict) else {}
31
+ name = str(tool.get("name") or fn.get("name") or "").strip()
32
+ desc = str(tool.get("description") or fn.get("description") or "").strip()
33
+ schema = tool.get("input_schema") or tool.get("parameters") or fn.get("input_schema") or fn.get("parameters") or {}
34
+ return name, desc, schema
35
+
36
+
37
+ def build_tool_prompt(tools: object) -> str:
38
+ if not isinstance(tools, list):
39
+ return ""
40
+ blocks = []
41
+ for tool in tools:
42
+ if not isinstance(tool, dict):
43
+ continue
44
+ name, desc, schema = _tool_meta(tool)
45
+ if name:
46
+ blocks.append(f"Tool: {name}\nDescription: {desc}\nParameters: {json.dumps(schema, ensure_ascii=False)}")
47
+ if not blocks:
48
+ return ""
49
+ return "Available tools:\n" + "\n".join(blocks) + """
50
+
51
+ Tool use rules:
52
+ - If the user asks to list/read/search files, inspect project state, run a command, or answer from local code, you MUST call a suitable tool first. Do not say you cannot access files.
53
+ - To call tools, output ONLY XML and no prose/markdown:
54
+ <tool_calls><tool_call><tool_name>TOOL_NAME</tool_name><parameters><PARAM><![CDATA[value]]></PARAM></parameters></tool_call></tool_calls>
55
+ - Put parameters under <parameters> using the exact schema names.
56
+ """.strip()
57
+
58
+
59
+ def merge_system(system: object, extra: str) -> object:
60
+ system = compact_system(system)
61
+ if _has_claude_code_system(system):
62
+ extra = XML_TOOL_RULE
63
+ if not extra:
64
+ return system
65
+ if isinstance(system, str) and system.strip():
66
+ return f"{system.strip()}\n\n{extra}"
67
+ if isinstance(system, list):
68
+ return [*system, {"type": "text", "text": extra}]
69
+ return extra
70
+
71
+
72
+ def _has_claude_code_system(system: object) -> bool:
73
+ if isinstance(system, str):
74
+ return "You are Claude Code" in system
75
+ if isinstance(system, list):
76
+ return any(isinstance(item, dict) and "You are Claude Code" in str(item.get("text") or "") for item in system)
77
+ return False
78
+
79
+
80
+ def compact_system(system: object) -> object:
81
+ if isinstance(system, str):
82
+ return _compact_system_text(system)
83
+ if isinstance(system, list):
84
+ result = []
85
+ for item in system:
86
+ if isinstance(item, dict) and str(item.get("type") or "") == "text":
87
+ copied = dict(item)
88
+ copied["text"] = _compact_system_text(str(item.get("text") or ""))
89
+ result.append(copied)
90
+ else:
91
+ result.append(item)
92
+ return result
93
+ return system
94
+
95
+
96
+ def _compact_system_text(text: str) -> str:
97
+ return text or ""
98
+
99
+
100
+ def _compact_message_text(text: str) -> str:
101
+ return text or ""
102
+
103
+
104
+ def preprocess_payload(payload: dict[str, object], text_mapper: Callable[[str], str] | None = None) -> dict[str, object]:
105
+ payload["messages"] = preprocess_messages(payload.get("messages"), text_mapper)
106
+ payload["system"] = merge_system(payload.get("system"), build_tool_prompt(payload.get("tools")))
107
+ return payload
108
+
109
+
110
+ def message_request(body: dict[str, Any]) -> MessageRequest:
111
+ payload = preprocess_payload(dict(body))
112
+ return MessageRequest(
113
+ backend=OpenAIBackendAPI(access_token=account_service.get_text_access_token()),
114
+ messages=normalize_messages(payload.get("messages"), payload.get("system")),
115
+ model=str(payload.get("model") or "auto").strip() or "auto",
116
+ tools=payload.get("tools"),
117
+ )
118
+
119
+
120
+ def preprocess_messages(messages: object, text_mapper: Callable[[str], str] | None = None) -> object:
121
+ if not isinstance(messages, list):
122
+ return messages
123
+ mapper = text_mapper or (lambda text: text)
124
+ result = []
125
+ for message in messages:
126
+ if not isinstance(message, dict):
127
+ continue
128
+ item = dict(message)
129
+ content = item.get("content")
130
+ if isinstance(content, str):
131
+ item["content"] = _compact_message_text(mapper(content))
132
+ elif isinstance(content, list):
133
+ item["content"] = [_preprocess_block(block, mapper) for block in content]
134
+ result.append(item)
135
+ return result
136
+
137
+
138
+ def _preprocess_block(block: object, text_mapper: Callable[[str], str]) -> object:
139
+ if not isinstance(block, dict):
140
+ return block
141
+ block_type = str(block.get("type") or "")
142
+ if block_type == "text":
143
+ item = dict(block)
144
+ item["text"] = _compact_message_text(text_mapper(str(block.get("text") or "")))
145
+ return item
146
+ if block_type == "tool_use":
147
+ return {"type": "text", "text": f"<tool_calls><tool_call><tool_name>{block.get('name') or ''}</tool_name><parameters>{json.dumps(block.get('input') or {}, ensure_ascii=False)}</parameters></tool_call></tool_calls>"}
148
+ if block_type == "tool_result":
149
+ return {"type": "text", "text": f"Tool result {block.get('tool_use_id') or ''}: {block.get('content') or ''}"}
150
+ return block
151
+
152
+
153
+ def message_response(model: str, text: str, input_tokens: int, output_tokens: int, tools: object = None) -> dict[str, object]:
154
+ content, stop_reason = content_blocks(text, tools)
155
+ return {
156
+ "id": f"msg_{uuid.uuid4()}",
157
+ "type": "message",
158
+ "role": "assistant",
159
+ "model": model,
160
+ "content": content,
161
+ "stop_reason": stop_reason,
162
+ "stop_sequence": None,
163
+ "usage": {"input_tokens": input_tokens, "output_tokens": output_tokens},
164
+ }
165
+
166
+
167
+ def content_blocks(text: str, tools: object = None) -> tuple[list[dict[str, object]], str]:
168
+ calls = parse_tool_calls(text) if isinstance(tools, list) and tools else []
169
+ text = strip_tool_markup(text)
170
+ if calls:
171
+ content = ([{"type": "text", "text": text}] if text else []) + [{"type": "tool_use", "id": f"toolu_{uuid.uuid4()}", "name": name, "input": args} for name, args in calls]
172
+ return content, "tool_use"
173
+ return [{"type": "text", "text": text}], "end_turn"
174
+
175
+
176
+ def strip_tool_markup(text: str) -> str:
177
+ return re.sub(r"(?is)<tool_calls\b[^>]*>.*?</tool_calls>|<tool_call\b[^>]*>.*?</tool_call>|<function_call\b[^>]*>.*?</function_call>|<invoke\b[^>]*>.*?</invoke>", "", text or "").strip()
178
+
179
+
180
+ def streamable_text(text: str) -> str:
181
+ text = text or ""
182
+ match = re.search(r"(?is)<tool_calls\b|<tool_call\b|<function_call\b|<invoke\b", text)
183
+ return text[:match.start()].rstrip() if match else text
184
+
185
+
186
+ def parse_tool_calls(text: str) -> list[tuple[str, dict[str, object]]]:
187
+ text = re.sub(r"(?is)```.*?```", "", text or "").strip()
188
+ blocks = re.findall(r"(?is)<tool_call\b[^>]*>(.*?)</tool_call>|<function_call\b[^>]*>(.*?)</function_call>|<invoke\b[^>]*>(.*?)</invoke>", text)
189
+ result = []
190
+ for block in (next((part for part in match if part), "") for match in blocks):
191
+ name = xml_value(block, "tool_name") or xml_value(block, "name") or xml_value(block, "function")
192
+ params = xml_value(block, "parameters") or xml_value(block, "input") or xml_value(block, "arguments") or "{}"
193
+ if name:
194
+ result.append((name, parse_tool_params(params)))
195
+ return result
196
+
197
+
198
+ def xml_value(text: str, tag: str) -> str:
199
+ match = re.search(rf"(?is)<{tag}\b[^>]*>(.*?)</{tag}>", text)
200
+ if not match:
201
+ return ""
202
+ value = match.group(1).strip()
203
+ cdata = re.fullmatch(r"(?is)<!\[CDATA\[(.*?)]]>", value)
204
+ return html.unescape(cdata.group(1) if cdata else value).strip()
205
+
206
+
207
+ def parse_tool_params(raw: str) -> dict[str, object]:
208
+ raw = raw.strip()
209
+ try:
210
+ parsed = json.loads(raw)
211
+ return parsed if isinstance(parsed, dict) else {}
212
+ except Exception:
213
+ return {m.group(1): parse_tool_value(m.group(2)) for m in re.finditer(r"(?is)<([\w.-]+)\b[^>]*>(.*?)</\1>", raw)}
214
+
215
+
216
+ def parse_tool_value(raw: str) -> object:
217
+ value = xml_value(f"<x>{raw}</x>", "x")
218
+ try:
219
+ return json.loads(value)
220
+ except Exception:
221
+ return value
222
+
223
+
224
+ def stream_events(chunks: Iterable[dict[str, object]], model: str, input_tokens: int, output_tokens: Callable[[str], int], tools: object = None) -> Iterator[dict[str, object]]:
225
+ message_id = f"msg_{uuid.uuid4()}"
226
+ created = int(time.time())
227
+ current_text = ""
228
+ streamed_text = ""
229
+ tool_mode = isinstance(tools, list) and bool(tools)
230
+ tool_started = False
231
+ text_open = False
232
+ yield {"type": "message_start", "message": {"id": message_id, "type": "message", "role": "assistant", "model": model, "content": [], "stop_reason": None, "stop_sequence": None, "usage": {"input_tokens": input_tokens, "output_tokens": 0}}}
233
+ if not tool_mode:
234
+ text_open = True
235
+ yield {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}
236
+ for chunk in chunks:
237
+ choice = (chunk.get("choices") or [{}])[0]
238
+ delta = choice.get("delta") or {}
239
+ text_delta = delta.get("content", "") if isinstance(delta, dict) else ""
240
+ if text_delta:
241
+ current_text += text_delta
242
+ if not tool_started:
243
+ visible_text = current_text if not tool_mode else streamable_text(current_text)
244
+ if visible_text.startswith(streamed_text):
245
+ text_delta = visible_text[len(streamed_text):]
246
+ if text_delta:
247
+ if not text_open:
248
+ text_open = True
249
+ yield {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}
250
+ streamed_text = visible_text
251
+ yield {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": text_delta}}
252
+ tool_started = tool_mode and visible_text != current_text
253
+ if choice.get("finish_reason"):
254
+ content, stop_reason = content_blocks(current_text, tools)
255
+ if text_open:
256
+ yield {"type": "content_block_stop", "index": 0}
257
+ if stop_reason == "tool_use":
258
+ start_index = 1 if text_open else 0
259
+ if content and content[0]["type"] == "text":
260
+ remaining = str(content[0].get("text") or "")[len(streamed_text):]
261
+ if remaining:
262
+ if not text_open:
263
+ yield {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}
264
+ yield {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": remaining}}
265
+ if not text_open:
266
+ yield {"type": "content_block_stop", "index": 0}
267
+ start_index = 1
268
+ content = content[1:]
269
+ yield from _stream_buffered_blocks(content, start_index)
270
+ yield {"type": "message_delta", "delta": {"stop_reason": stop_reason, "stop_sequence": None}, "usage": {"output_tokens": output_tokens(current_text)}}
271
+ break
272
+ yield {"type": "message_stop", "created": created}
273
+
274
+
275
+ def _stream_buffered_blocks(content: list[dict[str, object]], start_index: int = 0) -> Iterator[dict[str, object]]:
276
+ for offset, block in enumerate(content):
277
+ index = start_index + offset
278
+ if block["type"] == "tool_use":
279
+ start = {"type": "tool_use", "id": block["id"], "name": block["name"], "input": {}}
280
+ delta = {"type": "input_json_delta", "partial_json": json.dumps(block.get("input") or {}, ensure_ascii=False)}
281
+ else:
282
+ start = {"type": "text", "text": ""}
283
+ delta = {"type": "text_delta", "text": block.get("text") or ""}
284
+ yield {"type": "content_block_start", "index": index, "content_block": start}
285
+ yield {"type": "content_block_delta", "index": index, "delta": delta}
286
+ yield {"type": "content_block_stop", "index": index}
287
+
288
+
289
+ def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]:
290
+ request = message_request(body)
291
+ if body.get("stream"):
292
+ return stream_events(
293
+ stream_text_chat_completion(request.backend, request.messages, request.model),
294
+ request.model,
295
+ count_message_tokens(request.messages, request.model),
296
+ lambda text: count_text_tokens(text, request.model),
297
+ request.tools,
298
+ )
299
+ text = collect_chat_content(stream_text_chat_completion(request.backend, request.messages, request.model))
300
+ return message_response(
301
+ request.model,
302
+ text,
303
+ count_message_tokens(request.messages, request.model),
304
+ count_text_tokens(text, request.model),
305
+ request.tools,
306
+ )
services/protocol/conversation.py ADDED
@@ -0,0 +1,699 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import re
6
+ import time
7
+ from dataclasses import dataclass, field
8
+ from typing import Any, Iterable, Iterator
9
+
10
+ import tiktoken
11
+
12
+ from services.account_service import account_service
13
+ from services.config import config
14
+ from services.image_storage_service import image_storage_service
15
+ from services.openai_backend_api import ImagePollTimeoutError, OpenAIBackendAPI
16
+ from utils.helper import IMAGE_MODELS, extract_image_from_message_content
17
+ from utils.log import logger
18
+
19
+
20
+ class ImageGenerationError(Exception):
21
+ def __init__(
22
+ self,
23
+ message: str,
24
+ status_code: int = 502,
25
+ error_type: str = "server_error",
26
+ code: str | None = "upstream_error",
27
+ param: str | None = None,
28
+ ) -> None:
29
+ super().__init__(message)
30
+ self.status_code = status_code
31
+ self.error_type = error_type
32
+ self.code = code
33
+ self.param = param
34
+
35
+ def to_openai_error(self) -> dict[str, Any]:
36
+ return {
37
+ "error": {
38
+ "message": str(self),
39
+ "type": self.error_type,
40
+ "param": self.param,
41
+ "code": self.code,
42
+ }
43
+ }
44
+
45
+
46
+ def is_token_invalid_error(message: str) -> bool:
47
+ text = str(message or "").lower()
48
+ return (
49
+ "token_invalidated" in text
50
+ or "token_revoked" in text
51
+ or "authentication token has been invalidated" in text
52
+ or "invalidated oauth token" in text
53
+ )
54
+
55
+
56
+ def image_stream_error_message(message: str) -> str:
57
+ text = str(message or "")
58
+ lower = text.lower()
59
+ if is_token_invalid_error(text):
60
+ return "image generation failed"
61
+ if "curl: (35)" in lower or "tls connect error" in lower or "openssl_internal" in lower:
62
+ return "upstream image connection failed, please retry later"
63
+ return text or "image generation failed"
64
+
65
+
66
+ def encode_images(images: Iterable[tuple[bytes, str, str]]) -> list[str]:
67
+ return [base64.b64encode(data).decode("ascii") for data, _, _ in images if data]
68
+
69
+
70
+ def save_image_bytes(image_data: bytes, base_url: str | None = None) -> str:
71
+ return image_storage_service.save(image_data, base_url).url
72
+
73
+
74
+ def message_text(content: Any) -> str:
75
+ if isinstance(content, str):
76
+ return content
77
+ if isinstance(content, list):
78
+ parts = []
79
+ for item in content:
80
+ if isinstance(item, str):
81
+ parts.append(item)
82
+ elif isinstance(item, dict) and str(item.get("type") or "") in {"text", "input_text", "output_text"}:
83
+ parts.append(str(item.get("text") or ""))
84
+ return "".join(parts)
85
+ return ""
86
+
87
+
88
+ def normalize_messages(messages: object, system: Any = None) -> list[dict[str, Any]]:
89
+ normalized = []
90
+ if config.global_system_prompt:
91
+ normalized.append({"role": "system", "content": config.global_system_prompt})
92
+ system_text = message_text(system)
93
+ if system_text:
94
+ normalized.append({"role": "system", "content": system_text})
95
+ if isinstance(messages, list):
96
+ for message in messages:
97
+ if not isinstance(message, dict):
98
+ continue
99
+ role = message.get("role", "user")
100
+ content = message.get("content", "")
101
+ text = message_text(content)
102
+ images: list[tuple[bytes, str]] = []
103
+ if role == "user":
104
+ images.extend(extract_image_from_message_content(content))
105
+ if isinstance(content, list):
106
+ for part in content:
107
+ if not isinstance(part, dict) or part.get("type") != "image":
108
+ continue
109
+ data = part.get("data")
110
+ if isinstance(data, (bytes, bytearray)):
111
+ images.append((bytes(data), str(part.get("mime") or "image/png")))
112
+ if images:
113
+ parts: list[Any] = []
114
+ if text:
115
+ parts.append({"type": "text", "text": text})
116
+ for data, mime in images:
117
+ parts.append({"type": "image", "data": data, "mime": mime})
118
+ normalized.append({"role": role, "content": parts})
119
+ else:
120
+ normalized.append({"role": role, "content": text})
121
+ return normalized
122
+
123
+
124
+ def prompt_with_global_system(prompt: str) -> str:
125
+ return f"{config.global_system_prompt}\n\n{prompt}" if config.global_system_prompt else prompt
126
+
127
+
128
+ def assistant_history_text(messages: list[dict[str, Any]]) -> str:
129
+ return "".join(str(item.get("content") or "") for item in messages if item.get("role") == "assistant")
130
+
131
+
132
+ def assistant_history_messages(messages: list[dict[str, Any]]) -> list[str]:
133
+ return [str(item.get("content") or "") for item in messages if item.get("role") == "assistant" and item.get("content")]
134
+
135
+
136
+ def build_image_prompt(prompt: str, size: str | None, quality: str = "auto") -> str:
137
+ hints = []
138
+ if size:
139
+ hints.append(f"输出图片尺寸为 {size}。")
140
+ if quality:
141
+ hints.append(f"输出图片质量为 {quality}。")
142
+ return f"{prompt.strip()}\n\n{''.join(hints)}" if hints else prompt
143
+
144
+
145
+ def encoding_for_model(model: str):
146
+ try:
147
+ return tiktoken.encoding_for_model(model)
148
+ except KeyError:
149
+ try:
150
+ return tiktoken.get_encoding("o200k_base")
151
+ except KeyError:
152
+ return tiktoken.get_encoding("cl100k_base")
153
+
154
+
155
+ def count_message_tokens(messages: list[dict[str, Any]], model: str) -> int:
156
+ encoding = encoding_for_model(model)
157
+ total = 0
158
+ for message in messages:
159
+ total += 3
160
+ for key, value in message.items():
161
+ if not isinstance(value, str):
162
+ continue
163
+ total += len(encoding.encode(value))
164
+ if key == "name":
165
+ total += 1
166
+ return total + 3
167
+
168
+
169
+ def count_text_tokens(text: str, model: str) -> int:
170
+ return len(encoding_for_model(model).encode(text))
171
+
172
+
173
+ def format_image_result(
174
+ items: list[dict[str, Any]],
175
+ prompt: str,
176
+ response_format: str,
177
+ base_url: str | None = None,
178
+ created: int | None = None,
179
+ message: str = "",
180
+ ) -> dict[str, Any]:
181
+ data: list[dict[str, Any]] = []
182
+ for item in items:
183
+ b64_json = str(item.get("b64_json") or "").strip()
184
+ if not b64_json:
185
+ continue
186
+ revised_prompt = str(item.get("revised_prompt") or prompt).strip() or prompt
187
+ if response_format == "b64_json":
188
+ data.append({
189
+ "b64_json": b64_json,
190
+ "url": save_image_bytes(base64.b64decode(b64_json), base_url),
191
+ "revised_prompt": revised_prompt,
192
+ })
193
+ else:
194
+ data.append({
195
+ "url": save_image_bytes(base64.b64decode(b64_json), base_url),
196
+ "revised_prompt": revised_prompt,
197
+ })
198
+ result: dict[str, Any] = {"created": created or int(time.time()), "data": data}
199
+ if message and not data:
200
+ result["message"] = message
201
+ return result
202
+
203
+
204
+ @dataclass
205
+ class ConversationRequest:
206
+ model: str = "auto"
207
+ prompt: str = ""
208
+ messages: list[dict[str, Any]] | None = None
209
+ images: list[str] | None = None
210
+ n: int = 1
211
+ size: str | None = None
212
+ quality: str = "auto"
213
+ response_format: str = "b64_json"
214
+ base_url: str | None = None
215
+ message_as_error: bool = False
216
+
217
+
218
+ @dataclass
219
+ class ConversationState:
220
+ text: str = ""
221
+ conversation_id: str = ""
222
+ file_ids: list[str] = field(default_factory=list)
223
+ sediment_ids: list[str] = field(default_factory=list)
224
+ blocked: bool = False
225
+ tool_invoked: bool | None = None
226
+ turn_use_case: str = ""
227
+
228
+
229
+ @dataclass
230
+ class ImageOutput:
231
+ kind: str
232
+ model: str
233
+ index: int
234
+ total: int
235
+ created: int = field(default_factory=lambda: int(time.time()))
236
+ text: str = ""
237
+ upstream_event_type: str = ""
238
+ data: list[dict[str, Any]] = field(default_factory=list)
239
+
240
+ def to_chunk(self) -> dict[str, Any]:
241
+ chunk: dict[str, Any] = {
242
+ "object": "image.generation.chunk",
243
+ "created": self.created,
244
+ "model": self.model,
245
+ "index": self.index,
246
+ "total": self.total,
247
+ "progress_text": self.text,
248
+ "upstream_event_type": self.upstream_event_type,
249
+ "data": [],
250
+ }
251
+ if self.kind == "message":
252
+ chunk.update({
253
+ "object": "image.generation.message",
254
+ "message": self.text,
255
+ })
256
+ chunk.pop("progress_text", None)
257
+ chunk.pop("upstream_event_type", None)
258
+ elif self.kind == "result":
259
+ chunk.update({
260
+ "object": "image.generation.result",
261
+ "data": self.data,
262
+ })
263
+ chunk.pop("progress_text", None)
264
+ chunk.pop("upstream_event_type", None)
265
+ return chunk
266
+
267
+
268
+ def assistant_message_text(message: dict[str, Any]) -> str:
269
+ content = message.get("content") or {}
270
+ parts = content.get("parts") or []
271
+ if not isinstance(parts, list):
272
+ return ""
273
+ return "".join(part for part in parts if isinstance(part, str))
274
+
275
+
276
+ def strip_history(text: str, history_text: str = "") -> str:
277
+ text = str(text or "")
278
+ history_text = str(history_text or "")
279
+ while history_text and text.startswith(history_text):
280
+ text = text[len(history_text):]
281
+ return text
282
+
283
+
284
+ def assistant_text(event: dict[str, Any], current_text: str = "", history_text: str = "") -> str:
285
+ for candidate in (event, event.get("v")):
286
+ if not isinstance(candidate, dict):
287
+ continue
288
+ message = candidate.get("message")
289
+ if not isinstance(message, dict):
290
+ continue
291
+ role = str((message.get("author") or {}).get("role") or "").strip().lower()
292
+ if role != "assistant":
293
+ continue
294
+ text = assistant_message_text(message)
295
+ if text:
296
+ return strip_history(text, history_text)
297
+ return apply_text_patch(event, current_text, history_text)
298
+
299
+
300
+ def event_assistant_text(event: dict[str, Any], history_text: str = "") -> str:
301
+ for candidate in (event, event.get("v")):
302
+ if not isinstance(candidate, dict):
303
+ continue
304
+ message = candidate.get("message")
305
+ if isinstance(message, dict) and (message.get("author") or {}).get("role") == "assistant":
306
+ return strip_history(assistant_message_text(message), history_text)
307
+ return ""
308
+
309
+
310
+ def apply_text_patch(event: dict[str, Any], current_text: str = "", history_text: str = "") -> str:
311
+ if event.get("p") == "/message/content/parts/0":
312
+ return apply_patch_op(event, current_text, history_text)
313
+
314
+ operations = event.get("v")
315
+ if isinstance(operations, str) and current_text and not event.get("p") and not event.get("o"):
316
+ return current_text + operations
317
+
318
+ if event.get("o") == "patch" and isinstance(operations, list):
319
+ text = current_text
320
+ for item in operations:
321
+ if isinstance(item, dict):
322
+ text = apply_text_patch(item, text, history_text)
323
+ return text
324
+
325
+ if not isinstance(operations, list):
326
+ return current_text
327
+
328
+ text = current_text
329
+ for item in operations:
330
+ if isinstance(item, dict):
331
+ text = apply_text_patch(item, text, history_text)
332
+ return text
333
+
334
+
335
+ def apply_patch_op(operation: dict[str, Any], current_text: str, history_text: str = "") -> str:
336
+ op = operation.get("o")
337
+ value = str(operation.get("v") or "")
338
+ if op == "append":
339
+ return current_text + value
340
+ if op == "replace":
341
+ return strip_history(value, history_text)
342
+ return current_text
343
+
344
+
345
+ def add_unique(values: list[str], candidates: list[str]) -> None:
346
+ for candidate in candidates:
347
+ if candidate and candidate not in values:
348
+ values.append(candidate)
349
+
350
+
351
+ def extract_conversation_ids(payload: str) -> tuple[str, list[str], list[str]]:
352
+ conversation_match = re.search(r'"conversation_id"\s*:\s*"([^"]+)"', payload)
353
+ conversation_id = conversation_match.group(1) if conversation_match else ""
354
+ # Negative lookahead excludes "file-service" (URI prefix, not a real id).
355
+ file_ids = re.findall(r"(file[-_](?!service\b)[A-Za-z0-9]+)", payload)
356
+ sediment_ids = re.findall(r"sediment://([A-Za-z0-9_-]+)", payload)
357
+ return conversation_id, file_ids, sediment_ids
358
+
359
+
360
+ def is_image_tool_event(event: dict[str, Any]) -> bool:
361
+ value = event.get("v")
362
+ message = event.get("message") or (value.get("message") if isinstance(value, dict) else None)
363
+ if not isinstance(message, dict):
364
+ return False
365
+ metadata = message.get("metadata") or {}
366
+ author = message.get("author") or {}
367
+ content = message.get("content") or {}
368
+ if author.get("role") != "tool":
369
+ return False
370
+ if metadata.get("async_task_type") == "image_gen":
371
+ return True
372
+ if content.get("content_type") != "multimodal_text":
373
+ return False
374
+ return any(
375
+ isinstance(part, dict) and (
376
+ part.get("content_type") == "image_asset_pointer"
377
+ or str(part.get("asset_pointer") or "").startswith(("file-service://", "sediment://"))
378
+ )
379
+ for part in content.get("parts") or []
380
+ )
381
+
382
+
383
+ def update_conversation_state(state: ConversationState, payload: str, event: dict[str, Any] | None = None) -> None:
384
+ conversation_id, file_ids, sediment_ids = extract_conversation_ids(payload)
385
+ if conversation_id and not state.conversation_id:
386
+ state.conversation_id = conversation_id
387
+ # Accept file_id / sediment_id when any of:
388
+ # 1) event is a complete image_gen tool message
389
+ # 2) prior server_ste_metadata already flipped tool_invoked True (in an image_gen turn)
390
+ # 3) patch event whose payload references asset_pointer / file-service://
391
+ # User messages (type=conversation.message) never satisfy these, so attacker-controlled
392
+ # substrings in user input cannot inject file ids into state.
393
+ is_patch_event = isinstance(event, dict) and event.get("o") == "patch"
394
+ image_context = (
395
+ (isinstance(event, dict) and is_image_tool_event(event))
396
+ or state.tool_invoked is True
397
+ or (is_patch_event and ("asset_pointer" in payload or "file-service://" in payload))
398
+ )
399
+ if image_context:
400
+ add_unique(state.file_ids, file_ids)
401
+ add_unique(state.sediment_ids, sediment_ids)
402
+ if not isinstance(event, dict):
403
+ return
404
+ state.conversation_id = str(event.get("conversation_id") or state.conversation_id)
405
+ value = event.get("v")
406
+ if isinstance(value, dict):
407
+ state.conversation_id = str(value.get("conversation_id") or state.conversation_id)
408
+ if event.get("type") == "moderation":
409
+ moderation = event.get("moderation_response")
410
+ if isinstance(moderation, dict) and moderation.get("blocked") is True:
411
+ state.blocked = True
412
+ if event.get("type") == "server_ste_metadata":
413
+ metadata = event.get("metadata")
414
+ if isinstance(metadata, dict):
415
+ if isinstance(metadata.get("tool_invoked"), bool):
416
+ state.tool_invoked = metadata["tool_invoked"]
417
+ state.turn_use_case = str(metadata.get("turn_use_case") or state.turn_use_case)
418
+
419
+
420
+ def conversation_base_event(event_type: str, state: ConversationState, **extra: Any) -> dict[str, Any]:
421
+ return {
422
+ "type": event_type,
423
+ "text": state.text,
424
+ "conversation_id": state.conversation_id,
425
+ "file_ids": list(state.file_ids),
426
+ "sediment_ids": list(state.sediment_ids),
427
+ "blocked": state.blocked,
428
+ "tool_invoked": state.tool_invoked,
429
+ "turn_use_case": state.turn_use_case,
430
+ **extra,
431
+ }
432
+
433
+
434
+ def iter_conversation_payloads(payloads: Iterator[str], history_text: str = "",
435
+ history_messages: list[str] | None = None) -> Iterator[dict[str, Any]]:
436
+ state = ConversationState()
437
+ history_messages = history_messages or []
438
+ history_index = 0
439
+ for payload in payloads:
440
+ # print(f"[upstream_sse] {payload}", flush=True)
441
+ if not payload:
442
+ continue
443
+ if payload == "[DONE]":
444
+ yield conversation_base_event("conversation.done", state, done=True)
445
+ break
446
+ try:
447
+ event = json.loads(payload)
448
+ except json.JSONDecodeError:
449
+ update_conversation_state(state, payload)
450
+ yield conversation_base_event("conversation.raw", state, payload=payload)
451
+ continue
452
+ if not isinstance(event, dict):
453
+ yield conversation_base_event("conversation.event", state, raw=event)
454
+ continue
455
+ update_conversation_state(state, payload, event)
456
+ if history_index < len(history_messages) and event_assistant_text(event, history_text) == history_messages[history_index]:
457
+ history_index += 1
458
+ state.text = ""
459
+ continue
460
+ next_text = assistant_text(event, state.text, history_text)
461
+ if next_text != state.text:
462
+ delta = next_text[len(state.text):] if next_text.startswith(state.text) else next_text
463
+ state.text = next_text
464
+ yield conversation_base_event("conversation.delta", state, raw=event, delta=delta)
465
+ continue
466
+ yield conversation_base_event("conversation.event", state, raw=event)
467
+
468
+
469
+ def conversation_events(
470
+ backend: OpenAIBackendAPI,
471
+ messages: list[dict[str, Any]] | None = None,
472
+ model: str = "auto",
473
+ prompt: str = "",
474
+ images: list[str] | None = None,
475
+ size: str | None = None,
476
+ quality: str = "auto",
477
+ ) -> Iterator[dict[str, Any]]:
478
+ normalized = normalize_messages(messages or ([{"role": "user", "content": prompt}] if prompt else []))
479
+ image_model = str(model or "").strip() in IMAGE_MODELS
480
+ history_text = "" if image_model else assistant_history_text(normalized)
481
+ history_messages = [] if image_model else assistant_history_messages(normalized)
482
+ final_prompt = prompt_with_global_system(build_image_prompt(prompt, size, quality)) if image_model else prompt
483
+ payloads = backend.stream_conversation(
484
+ messages=normalized,
485
+ model=model,
486
+ prompt=final_prompt,
487
+ images=images if image_model else None,
488
+ system_hints=["picture_v2"] if image_model else None,
489
+ )
490
+ yield from iter_conversation_payloads(payloads, history_text, history_messages)
491
+
492
+
493
+ def text_backend() -> OpenAIBackendAPI:
494
+ return OpenAIBackendAPI(access_token=account_service.get_text_access_token())
495
+
496
+
497
+ def stream_text_deltas(backend: OpenAIBackendAPI, request: ConversationRequest) -> Iterator[str]:
498
+ attempted_tokens: set[str] = set()
499
+ token = getattr(backend, "access_token", "")
500
+ emitted = False
501
+ while True:
502
+ if token and token in attempted_tokens:
503
+ raise RuntimeError("no available text account")
504
+ if token:
505
+ attempted_tokens.add(token)
506
+ try:
507
+ active_backend = OpenAIBackendAPI(access_token=token)
508
+ for event in conversation_events(active_backend, messages=request.messages, model=request.model, prompt=request.prompt):
509
+ if event.get("type") != "conversation.delta":
510
+ continue
511
+ delta = str(event.get("delta") or "")
512
+ if delta:
513
+ emitted = True
514
+ yield delta
515
+ account_service.mark_text_used(token)
516
+ return
517
+ except Exception as exc:
518
+ error_message = str(exc)
519
+ if token and not emitted and is_token_invalid_error(error_message):
520
+ refreshed_token = account_service.refresh_access_token(token, force=True, event="text_stream")
521
+ if refreshed_token and refreshed_token != token and refreshed_token not in attempted_tokens:
522
+ token = refreshed_token
523
+ else:
524
+ account_service.remove_invalid_token(token, "text_stream")
525
+ token = account_service.get_text_access_token(attempted_tokens)
526
+ if token:
527
+ continue
528
+ raise
529
+
530
+
531
+ def collect_text(backend: OpenAIBackendAPI, request: ConversationRequest) -> str:
532
+ return "".join(stream_text_deltas(backend, request))
533
+
534
+
535
+ def stream_image_outputs(
536
+ backend: OpenAIBackendAPI,
537
+ request: ConversationRequest,
538
+ index: int = 1,
539
+ total: int = 1,
540
+ ) -> Iterator[ImageOutput]:
541
+ last: dict[str, Any] = {}
542
+ for event in conversation_events(
543
+ backend,
544
+ prompt=request.prompt,
545
+ model=request.model,
546
+ images=request.images or [],
547
+ size=request.size,
548
+ quality=request.quality,
549
+ ):
550
+ last = event
551
+ if event.get("type") == "conversation.delta":
552
+ yield ImageOutput(
553
+ kind="progress",
554
+ model=request.model,
555
+ index=index,
556
+ total=total,
557
+ text=str(event.get("delta") or ""),
558
+ upstream_event_type="conversation.delta",
559
+ )
560
+ continue
561
+ if event.get("type") == "conversation.event":
562
+ raw = event.get("raw")
563
+ raw_type = str(raw.get("type") or "") if isinstance(raw, dict) else ""
564
+ yield ImageOutput(
565
+ kind="progress",
566
+ model=request.model,
567
+ index=index,
568
+ total=total,
569
+ upstream_event_type=raw_type,
570
+ )
571
+
572
+ conversation_id = str(last.get("conversation_id") or "")
573
+ file_ids = [str(item) for item in last.get("file_ids") or []]
574
+ sediment_ids = [str(item) for item in last.get("sediment_ids") or []]
575
+ message = str(last.get("text") or "").strip()
576
+ logger.info({
577
+ "event": "image_stream_resolve_start",
578
+ "conversation_id": conversation_id,
579
+ "file_ids": file_ids,
580
+ "sediment_ids": sediment_ids,
581
+ "tool_invoked": last.get("tool_invoked"),
582
+ "turn_use_case": last.get("turn_use_case"),
583
+ })
584
+ if message and not file_ids and not sediment_ids and last.get("blocked"):
585
+ yield ImageOutput(kind="message", model=request.model, index=index, total=total, text=message)
586
+ return
587
+ should_poll_for_image = bool(request.images) or last.get("turn_use_case") == "image gen"
588
+ if message and not file_ids and not sediment_ids and not should_poll_for_image:
589
+ yield ImageOutput(kind="message", model=request.model, index=index, total=total, text=message)
590
+ return
591
+
592
+ image_urls = backend.resolve_conversation_image_urls(conversation_id, file_ids, sediment_ids)
593
+ if image_urls:
594
+ image_items = [
595
+ {"b64_json": base64.b64encode(image_data).decode("ascii")}
596
+ for image_data in backend.download_image_bytes(image_urls)
597
+ ]
598
+ data = format_image_result(
599
+ image_items,
600
+ request.prompt,
601
+ request.response_format,
602
+ request.base_url,
603
+ int(time.time()),
604
+ )["data"]
605
+ if data:
606
+ yield ImageOutput(kind="result", model=request.model, index=index, total=total, data=data)
607
+ return
608
+
609
+ if message:
610
+ yield ImageOutput(kind="message", model=request.model, index=index, total=total, text=message)
611
+
612
+
613
+ def stream_image_outputs_with_pool(request: ConversationRequest) -> Iterator[ImageOutput]:
614
+ if str(request.model or "").strip() not in IMAGE_MODELS:
615
+ raise ImageGenerationError("unsupported image model,supported models: " + ", ".join(IMAGE_MODELS))
616
+
617
+ emitted = False
618
+ last_error = ""
619
+ for index in range(1, request.n + 1):
620
+ while True:
621
+ try:
622
+ token = account_service.get_available_access_token()
623
+ except RuntimeError as exc:
624
+ if emitted:
625
+ return
626
+ raise ImageGenerationError(str(exc) or "image generation failed") from exc
627
+
628
+ emitted_for_token = False
629
+ returned_message = False
630
+ returned_result = False
631
+ try:
632
+ backend = OpenAIBackendAPI(access_token=token)
633
+ for output in stream_image_outputs(backend, request, index, request.n):
634
+ if output.kind == "message" and request.message_as_error:
635
+ raise ImageGenerationError(
636
+ output.text or "Image generation was rejected by upstream policy.",
637
+ status_code=400,
638
+ error_type="invalid_request_error",
639
+ code="content_policy_violation",
640
+ )
641
+ emitted = True
642
+ emitted_for_token = True
643
+ returned_message = output.kind == "message"
644
+ returned_result = returned_result or output.kind == "result"
645
+ yield output
646
+ if returned_message or not returned_result:
647
+ account_service.mark_image_result(token, False)
648
+ return
649
+ account_service.mark_image_result(token, True)
650
+ break
651
+ except ImagePollTimeoutError:
652
+ raise
653
+ except ImageGenerationError:
654
+ account_service.mark_image_result(token, False)
655
+ raise
656
+ except Exception as exc:
657
+ account_service.mark_image_result(token, False)
658
+ last_error = str(exc)
659
+ logger.warning({"event": "image_stream_fail", "request_token": token, "error": last_error})
660
+ if not emitted_for_token and is_token_invalid_error(last_error):
661
+ refreshed_token = account_service.refresh_access_token(token, force=True, event="image_stream")
662
+ if refreshed_token and refreshed_token != token:
663
+ token = refreshed_token
664
+ continue
665
+ account_service.remove_invalid_token(token, "image_stream")
666
+ continue
667
+ raise ImageGenerationError(image_stream_error_message(last_error)) from exc
668
+
669
+ if not emitted:
670
+ if not last_error:
671
+ last_error = "no account in the pool could generate images — check account quota and rate-limit status"
672
+ raise ImageGenerationError(image_stream_error_message(last_error))
673
+
674
+
675
+ def stream_image_chunks(outputs: Iterable[ImageOutput]) -> Iterator[dict[str, Any]]:
676
+ for output in outputs:
677
+ yield output.to_chunk()
678
+
679
+
680
+ def collect_image_outputs(outputs: Iterable[ImageOutput]) -> dict[str, Any]:
681
+ created = None
682
+ data: list[dict[str, Any]] = []
683
+ message = ""
684
+ progress_parts: list[str] = []
685
+ for output in outputs:
686
+ created = created or output.created
687
+ if output.kind == "progress" and output.text:
688
+ progress_parts.append(output.text)
689
+ elif output.kind == "message":
690
+ message = output.text
691
+ elif output.kind == "result":
692
+ data.extend(output.data)
693
+
694
+ result: dict[str, Any] = {"created": created or int(time.time()), "data": data}
695
+ if not data:
696
+ text = message or "".join(progress_parts).strip()
697
+ if text:
698
+ result["message"] = text
699
+ return result
services/protocol/error_response.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from fastapi.responses import JSONResponse
6
+
7
+
8
+ def _message_from_value(value: object) -> str:
9
+ if isinstance(value, str):
10
+ return value
11
+ if not isinstance(value, dict):
12
+ return ""
13
+ message = value.get("message")
14
+ if isinstance(message, str) and message:
15
+ return message
16
+ return _message_from_value(value.get("error"))
17
+
18
+
19
+ def error_message_from_detail(detail: object) -> str:
20
+ if isinstance(detail, list):
21
+ messages = []
22
+ for item in detail:
23
+ if not isinstance(item, dict):
24
+ continue
25
+ location = ".".join(str(part) for part in item.get("loc", []) if part != "body")
26
+ message = str(item.get("msg") or "").strip()
27
+ if location and message:
28
+ messages.append(f"{location}: {message}")
29
+ elif message:
30
+ messages.append(message)
31
+ return "; ".join(messages)
32
+ if isinstance(detail, dict):
33
+ message = _message_from_value(detail.get("error")) or _message_from_value(detail)
34
+ if message:
35
+ return message
36
+ return str(detail or "").strip()
37
+
38
+
39
+ def _default_error_type(status_code: int) -> str:
40
+ if status_code == 401:
41
+ return "authentication_error"
42
+ if status_code == 403:
43
+ return "permission_error"
44
+ if status_code == 429:
45
+ return "rate_limit_error"
46
+ if 400 <= status_code < 500:
47
+ return "invalid_request_error"
48
+ return "server_error"
49
+
50
+
51
+ def _default_error_code(status_code: int) -> str:
52
+ if status_code == 401:
53
+ return "invalid_api_key"
54
+ if status_code == 403:
55
+ return "permission_denied"
56
+ if status_code == 429:
57
+ return "rate_limit_exceeded"
58
+ if 400 <= status_code < 500:
59
+ return "bad_request"
60
+ return "upstream_error"
61
+
62
+
63
+ def openai_error_payload(
64
+ detail: object,
65
+ status_code: int,
66
+ *,
67
+ error_type: str | None = None,
68
+ code: object | None = None,
69
+ param: object | None = None,
70
+ ) -> dict[str, Any]:
71
+ error_detail = detail.get("error") if isinstance(detail, dict) else None
72
+ if isinstance(error_detail, dict):
73
+ return {
74
+ "error": {
75
+ "message": error_message_from_detail(error_detail) or "request failed",
76
+ "type": str(error_detail.get("type") or error_type or _default_error_type(status_code)),
77
+ "param": error_detail.get("param", param),
78
+ "code": error_detail.get("code", code if code is not None else _default_error_code(status_code)),
79
+ }
80
+ }
81
+ return {
82
+ "error": {
83
+ "message": error_message_from_detail(detail) or "request failed",
84
+ "type": error_type or _default_error_type(status_code),
85
+ "param": param,
86
+ "code": code if code is not None else _default_error_code(status_code),
87
+ }
88
+ }
89
+
90
+
91
+ def openai_error_response(
92
+ detail: object,
93
+ status_code: int,
94
+ *,
95
+ headers: dict[str, str] | None = None,
96
+ error_type: str | None = None,
97
+ code: object | None = None,
98
+ param: object | None = None,
99
+ ) -> JSONResponse:
100
+ return JSONResponse(
101
+ status_code=status_code,
102
+ content=openai_error_payload(detail, status_code, error_type=error_type, code=code, param=param),
103
+ headers=headers,
104
+ )
105
+
106
+
107
+ def anthropic_error_response(
108
+ detail: object,
109
+ status_code: int,
110
+ *,
111
+ headers: dict[str, str] | None = None,
112
+ ) -> JSONResponse:
113
+ error_type = "api_error" if status_code >= 500 else _default_error_type(status_code)
114
+ return JSONResponse(
115
+ status_code=status_code,
116
+ content={
117
+ "type": "error",
118
+ "error": {
119
+ "type": error_type,
120
+ "message": error_message_from_detail(detail) or "request failed",
121
+ },
122
+ },
123
+ headers=headers,
124
+ )
services/protocol/openai_v1_chat_complete.py ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ import uuid
5
+ from typing import Any, Iterable, Iterator
6
+
7
+ from fastapi import HTTPException
8
+
9
+ from services.protocol.conversation import (
10
+ ConversationRequest,
11
+ ImageOutput,
12
+ collect_image_outputs,
13
+ collect_text,
14
+ count_message_tokens,
15
+ count_text_tokens,
16
+ encode_images,
17
+ normalize_messages,
18
+ stream_image_outputs_with_pool,
19
+ stream_text_deltas,
20
+ text_backend,
21
+ )
22
+ from utils.helper import build_chat_image_markdown_content, extract_chat_image, extract_chat_prompt, is_image_chat_request, parse_image_count
23
+
24
+
25
+ def completion_chunk(model: str, delta: dict[str, Any], finish_reason: str | None = None, completion_id: str = "", created: int | None = None) -> dict[str, Any]:
26
+ return {
27
+ "id": completion_id or f"chatcmpl-{uuid.uuid4().hex}",
28
+ "object": "chat.completion.chunk",
29
+ "created": created or int(time.time()),
30
+ "model": model,
31
+ "choices": [{"index": 0, "delta": delta, "finish_reason": finish_reason}],
32
+ }
33
+
34
+
35
+ def completion_response(
36
+ model: str,
37
+ content: str,
38
+ created: int | None = None,
39
+ messages: list[dict[str, Any]] | None = None,
40
+ ) -> dict[str, Any]:
41
+ prompt_tokens = count_message_tokens(messages, model) if messages else 0
42
+ completion_tokens = count_text_tokens(content, model) if messages else 0
43
+ return {
44
+ "id": f"chatcmpl-{uuid.uuid4().hex}",
45
+ "object": "chat.completion",
46
+ "created": created or int(time.time()),
47
+ "model": model,
48
+ "choices": [{
49
+ "index": 0,
50
+ "message": {"role": "assistant", "content": content},
51
+ "finish_reason": "stop",
52
+ }],
53
+ "usage": {
54
+ "prompt_tokens": prompt_tokens,
55
+ "completion_tokens": completion_tokens,
56
+ "total_tokens": prompt_tokens + completion_tokens,
57
+ },
58
+ }
59
+
60
+
61
+ def stream_text_chat_completion(backend, messages: list[dict[str, Any]], model: str) -> Iterator[dict[str, Any]]:
62
+ completion_id = f"chatcmpl-{uuid.uuid4().hex}"
63
+ created = int(time.time())
64
+ sent_role = False
65
+ request = ConversationRequest(model=model, messages=messages)
66
+ for delta_text in stream_text_deltas(backend, request):
67
+ if not sent_role:
68
+ sent_role = True
69
+ yield completion_chunk(model, {"role": "assistant", "content": delta_text}, None, completion_id, created)
70
+ else:
71
+ yield completion_chunk(model, {"content": delta_text}, None, completion_id, created)
72
+ if not sent_role:
73
+ yield completion_chunk(model, {"role": "assistant", "content": ""}, None, completion_id, created)
74
+ yield completion_chunk(model, {}, "stop", completion_id, created)
75
+
76
+
77
+ def collect_chat_content(chunks: Iterable[dict[str, Any]]) -> str:
78
+ parts: list[str] = []
79
+ for chunk in chunks:
80
+ choices = chunk.get("choices")
81
+ first = choices[0] if isinstance(choices, list) and choices and isinstance(choices[0], dict) else {}
82
+ delta = first.get("delta") if isinstance(first.get("delta"), dict) else {}
83
+ content = str(delta.get("content") or "")
84
+ if content:
85
+ parts.append(content)
86
+ return "".join(parts)
87
+
88
+
89
+ def chat_messages_from_body(body: dict[str, Any]) -> list[dict[str, Any]]:
90
+ messages = body.get("messages")
91
+ if isinstance(messages, list) and messages:
92
+ return [message for message in messages if isinstance(message, dict)]
93
+ prompt = str(body.get("prompt") or "").strip()
94
+ if prompt:
95
+ return [{"role": "user", "content": prompt}]
96
+ raise HTTPException(status_code=400, detail={"error": "messages or prompt is required"})
97
+
98
+
99
+ def chat_image_args(body: dict[str, Any]) -> tuple[str, str, int, list[tuple[bytes, str, str]]]:
100
+ model = str(body.get("model") or "gpt-image-2").strip() or "gpt-image-2"
101
+ prompt = extract_chat_prompt(body)
102
+ if not prompt:
103
+ raise HTTPException(status_code=400, detail={"error": "prompt is required"})
104
+ images = [
105
+ (data, f"image_{idx}.png", mime)
106
+ for idx, (data, mime) in enumerate(extract_chat_image(body), start=1)
107
+ ]
108
+ return model, prompt, parse_image_count(body.get("n")), images
109
+
110
+
111
+ def text_chat_parts(body: dict[str, Any]) -> tuple[str, list[dict[str, Any]]]:
112
+ model = str(body.get("model") or "auto").strip() or "auto"
113
+ messages = normalize_messages(chat_messages_from_body(body))
114
+ return model, messages
115
+
116
+
117
+ def image_result_content(result: dict[str, Any]) -> str:
118
+ data = result.get("data")
119
+ if isinstance(data, list) and data:
120
+ return build_chat_image_markdown_content(result)
121
+ return str(result.get("message") or "Image generation completed.")
122
+
123
+
124
+ def image_chat_response(body: dict[str, Any]) -> dict[str, Any]:
125
+ model, prompt, n, images = chat_image_args(body)
126
+ result = collect_image_outputs(stream_image_outputs_with_pool(ConversationRequest(
127
+ prompt=prompt,
128
+ model=model,
129
+ n=n,
130
+ response_format="b64_json",
131
+ images=encode_images(images) or None,
132
+ )))
133
+ return completion_response(model, image_result_content(result), int(result.get("created") or 0) or None)
134
+
135
+
136
+ def image_chat_events(body: dict[str, Any]) -> Iterator[dict[str, Any]]:
137
+ model, prompt, n, images = chat_image_args(body)
138
+ image_outputs = stream_image_outputs_with_pool(ConversationRequest(
139
+ prompt=prompt,
140
+ model=model,
141
+ n=n,
142
+ response_format="b64_json",
143
+ images=encode_images(images) or None,
144
+ ))
145
+ yield from stream_image_chat_completion(image_outputs, model)
146
+
147
+
148
+ def stream_image_chat_completion(image_outputs: Iterable[ImageOutput], model: str) -> Iterator[dict[str, Any]]:
149
+ completion_id = f"chatcmpl-{uuid.uuid4().hex}"
150
+ created = int(time.time())
151
+ sent_role = False
152
+ sent_text = ""
153
+ for output in image_outputs:
154
+ content = ""
155
+ if output.kind == "progress":
156
+ content = output.text
157
+ sent_text += content
158
+ elif output.kind == "result":
159
+ content = build_chat_image_markdown_content({"data": output.data})
160
+ elif output.kind == "message":
161
+ content = output.text[len(sent_text):] if output.text.startswith(sent_text) else output.text
162
+ if not content:
163
+ continue
164
+ if not sent_role:
165
+ sent_role = True
166
+ yield completion_chunk(model, {"role": "assistant", "content": content}, None, completion_id, created)
167
+ else:
168
+ yield completion_chunk(model, {"content": content}, None, completion_id, created)
169
+ if not sent_role:
170
+ yield completion_chunk(model, {"role": "assistant", "content": ""}, None, completion_id, created)
171
+ yield completion_chunk(model, {}, "stop", completion_id, created)
172
+
173
+
174
+ def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]:
175
+ if body.get("stream"):
176
+ if is_image_chat_request(body):
177
+ return image_chat_events(body)
178
+ model, messages = text_chat_parts(body)
179
+ return stream_text_chat_completion(text_backend(), messages, model)
180
+ if is_image_chat_request(body):
181
+ return image_chat_response(body)
182
+ model, messages = text_chat_parts(body)
183
+ request = ConversationRequest(model=model, messages=messages)
184
+ return completion_response(model, collect_text(text_backend(), request), messages=messages)
services/protocol/openai_v1_image_edit.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Iterator
4
+
5
+ from services.protocol.conversation import (
6
+ ConversationRequest,
7
+ ImageGenerationError,
8
+ collect_image_outputs,
9
+ encode_images,
10
+ stream_image_chunks,
11
+ stream_image_outputs_with_pool,
12
+ )
13
+
14
+
15
+ def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]:
16
+ prompt = str(body.get("prompt") or "")
17
+ images = body.get("images") or []
18
+ model = str(body.get("model") or "gpt-image-2")
19
+ n = int(body.get("n") or 1)
20
+ size = body.get("size")
21
+ quality = str(body.get("quality") or "auto")
22
+ response_format = str(body.get("response_format") or "b64_json")
23
+ base_url = str(body.get("base_url") or "") or None
24
+ encoded_images = encode_images(images)
25
+ if not encoded_images:
26
+ raise ImageGenerationError("image is required")
27
+ outputs = stream_image_outputs_with_pool(ConversationRequest(
28
+ prompt=prompt,
29
+ model=model,
30
+ n=n,
31
+ size=size,
32
+ quality=quality,
33
+ response_format=response_format,
34
+ base_url=base_url,
35
+ images=encoded_images,
36
+ message_as_error=True,
37
+ ))
38
+ if body.get("stream"):
39
+ return stream_image_chunks(outputs)
40
+ return collect_image_outputs(outputs)
services/protocol/openai_v1_image_generations.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Iterator
4
+
5
+ from services.protocol.conversation import (
6
+ ConversationRequest,
7
+ collect_image_outputs,
8
+ stream_image_chunks,
9
+ stream_image_outputs_with_pool,
10
+ )
11
+
12
+
13
+ def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]:
14
+ prompt = str(body.get("prompt") or "")
15
+ model = str(body.get("model") or "gpt-image-2")
16
+ n = int(body.get("n") or 1)
17
+ size = body.get("size")
18
+ quality = str(body.get("quality") or "auto")
19
+ response_format = str(body.get("response_format") or "b64_json")
20
+ base_url = str(body.get("base_url") or "") or None
21
+ outputs = stream_image_outputs_with_pool(ConversationRequest(
22
+ prompt=prompt,
23
+ model=model,
24
+ n=n,
25
+ size=size,
26
+ quality=quality,
27
+ response_format=response_format,
28
+ base_url=base_url,
29
+ message_as_error=True,
30
+ ))
31
+ if body.get("stream"):
32
+ return stream_image_chunks(outputs)
33
+ return collect_image_outputs(outputs)
services/protocol/openai_v1_models.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from services.openai_backend_api import OpenAIBackendAPI
6
+ from utils.helper import IMAGE_MODELS
7
+
8
+
9
+ def list_models() -> dict[str, Any]:
10
+ result = OpenAIBackendAPI().list_models()
11
+ data = result.get("data")
12
+ if not isinstance(data, list):
13
+ return result
14
+ seen = {str(item.get("id") or "").strip() for item in data if isinstance(item, dict)}
15
+ for model in sorted(IMAGE_MODELS):
16
+ if model not in seen:
17
+ data.append({
18
+ "id": model,
19
+ "object": "model",
20
+ "created": 0,
21
+ "owned_by": "chatgpt2api",
22
+ "permission": [],
23
+ "root": model,
24
+ "parent": None,
25
+ })
26
+ return result
services/protocol/openai_v1_response.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import time
5
+ import uuid
6
+ from typing import Any, Iterable, Iterator
7
+
8
+ from fastapi import HTTPException
9
+
10
+ from services.protocol.conversation import (
11
+ ConversationRequest,
12
+ ImageOutput,
13
+ encode_images,
14
+ stream_image_outputs_with_pool,
15
+ stream_text_deltas,
16
+ text_backend,
17
+ )
18
+ from utils.helper import extract_image_from_message_content, extract_response_prompt, has_response_image_generation_tool
19
+
20
+
21
+ def is_text_response_request(body: dict[str, Any]) -> bool:
22
+ return not has_response_image_generation_tool(body)
23
+
24
+
25
+ def response_image_tool(body: dict[str, Any]) -> dict[str, object]:
26
+ for tool in body.get("tools") or []:
27
+ if isinstance(tool, dict) and tool.get("type") == "image_generation":
28
+ return tool
29
+ return {}
30
+
31
+
32
+ def extract_response_image(input_value: object) -> tuple[bytes, str] | None:
33
+ if isinstance(input_value, dict):
34
+ images = extract_image_from_message_content(input_value.get("content"))
35
+ return images[0] if images else None
36
+ if not isinstance(input_value, list):
37
+ return None
38
+ for item in reversed(input_value):
39
+ if isinstance(item, dict) and str(item.get("type") or "").strip() == "input_image":
40
+ image_url = str(item.get("image_url") or "")
41
+ if image_url.startswith("data:"):
42
+ header, _, data = image_url.partition(",")
43
+ mime = header.split(";")[0].removeprefix("data:")
44
+ return base64.b64decode(data), mime or "image/png"
45
+ if isinstance(item, dict):
46
+ images = extract_image_from_message_content(item.get("content"))
47
+ if images:
48
+ return images[0]
49
+ return None
50
+
51
+
52
+ def messages_from_input(input_value: object, instructions: object = None) -> list[dict[str, Any]]:
53
+ messages: list[dict[str, Any]] = []
54
+ system_text = str(instructions or "").strip()
55
+ if system_text:
56
+ messages.append({"role": "system", "content": system_text})
57
+ if isinstance(input_value, str):
58
+ if input_value.strip():
59
+ messages.append({"role": "user", "content": input_value.strip()})
60
+ return messages
61
+ if isinstance(input_value, dict):
62
+ messages.append({
63
+ "role": str(input_value.get("role") or "user"),
64
+ "content": extract_response_prompt([input_value]) or input_value.get("content") or "",
65
+ })
66
+ return messages
67
+ if isinstance(input_value, list):
68
+ if all(isinstance(item, dict) and item.get("type") for item in input_value):
69
+ text = extract_response_prompt(input_value)
70
+ if text:
71
+ messages.append({"role": "user", "content": text})
72
+ return messages
73
+ for item in input_value:
74
+ if isinstance(item, dict):
75
+ messages.append({
76
+ "role": str(item.get("role") or "user"),
77
+ "content": extract_response_prompt([item]) or item.get("content") or "",
78
+ })
79
+ return messages
80
+
81
+
82
+ def text_output_item(text: str, item_id: str | None = None, status: str = "completed") -> dict[str, Any]:
83
+ return {
84
+ "id": item_id or f"msg_{uuid.uuid4().hex}",
85
+ "type": "message",
86
+ "status": status,
87
+ "role": "assistant",
88
+ "content": [{"type": "output_text", "text": text, "annotations": []}],
89
+ }
90
+
91
+
92
+ def image_output_items(prompt: str, data: list[dict[str, Any]], item_id: str | None = None) -> list[dict[str, Any]]:
93
+ output = []
94
+ for item in data:
95
+ b64_json = str(item.get("b64_json") or "").strip()
96
+ if b64_json:
97
+ output.append({
98
+ "id": item_id or f"ig_{len(output) + 1}",
99
+ "type": "image_generation_call",
100
+ "status": "completed",
101
+ "result": b64_json,
102
+ "revised_prompt": str(item.get("revised_prompt") or prompt).strip() or prompt,
103
+ })
104
+ return output
105
+
106
+
107
+ def response_created(response_id: str, model: str, created: int) -> dict[str, Any]:
108
+ return {
109
+ "type": "response.created",
110
+ "response": {
111
+ "id": response_id,
112
+ "object": "response",
113
+ "created_at": created,
114
+ "status": "in_progress",
115
+ "error": None,
116
+ "incomplete_details": None,
117
+ "model": model,
118
+ "output": [],
119
+ "parallel_tool_calls": False,
120
+ },
121
+ }
122
+
123
+
124
+ def response_completed(response_id: str, model: str, created: int, output: list[dict[str, Any]]) -> dict[str, Any]:
125
+ return {
126
+ "type": "response.completed",
127
+ "response": {
128
+ "id": response_id,
129
+ "object": "response",
130
+ "created_at": created,
131
+ "status": "completed",
132
+ "error": None,
133
+ "incomplete_details": None,
134
+ "model": model,
135
+ "output": output,
136
+ "parallel_tool_calls": False,
137
+ },
138
+ }
139
+
140
+
141
+ def stream_text_response(backend, body: dict[str, Any]) -> Iterator[dict[str, Any]]:
142
+ model = str(body.get("model") or "auto").strip() or "auto"
143
+ messages = messages_from_input(body.get("input"), body.get("instructions"))
144
+ response_id = f"resp_{uuid.uuid4().hex}"
145
+ item_id = f"msg_{uuid.uuid4().hex}"
146
+ created = int(time.time())
147
+ full_text = ""
148
+ yield response_created(response_id, model, created)
149
+ yield {"type": "response.output_item.added", "output_index": 0, "item": text_output_item("", item_id, "in_progress")}
150
+ request = ConversationRequest(model=model, messages=messages)
151
+ for delta in stream_text_deltas(backend, request):
152
+ full_text += delta
153
+ yield {"type": "response.output_text.delta", "item_id": item_id, "output_index": 0, "content_index": 0, "delta": delta}
154
+ yield {"type": "response.output_text.done", "item_id": item_id, "output_index": 0, "content_index": 0, "text": full_text}
155
+ item = text_output_item(full_text, item_id, "completed")
156
+ yield {"type": "response.output_item.done", "output_index": 0, "item": item}
157
+ yield response_completed(response_id, model, created, [item])
158
+
159
+
160
+ def stream_image_response(image_outputs: Iterable[ImageOutput], prompt: str, model: str) -> Iterator[dict[str, Any]]:
161
+ response_id = f"resp_{uuid.uuid4().hex}"
162
+ created = int(time.time())
163
+ yield response_created(response_id, model, created)
164
+ for output in image_outputs:
165
+ if output.kind == "message":
166
+ text = output.text
167
+ item = text_output_item(text)
168
+ yield {"type": "response.output_text.delta", "item_id": item["id"], "output_index": 0, "content_index": 0, "delta": text}
169
+ yield {"type": "response.output_text.done", "item_id": item["id"], "output_index": 0, "content_index": 0, "text": text}
170
+ yield {"type": "response.output_item.done", "output_index": 0, "item": item}
171
+ yield response_completed(response_id, model, created, [item])
172
+ return
173
+ if output.kind != "result":
174
+ continue
175
+ items = image_output_items(prompt, output.data)
176
+ if items:
177
+ item = items[0]
178
+ yield {"type": "response.output_item.done", "output_index": 0, "item": item}
179
+ yield response_completed(response_id, model, created, [item])
180
+ return
181
+ raise RuntimeError("image generation failed")
182
+
183
+
184
+ def collect_response(events: Iterable[dict[str, Any]]) -> dict[str, Any]:
185
+ completed = {}
186
+ for event in events:
187
+ if event.get("type") == "response.completed":
188
+ completed = event.get("response") if isinstance(event.get("response"), dict) else {}
189
+ if not completed:
190
+ raise RuntimeError("response generation failed")
191
+ return completed
192
+
193
+
194
+ def response_events(body: dict[str, Any]) -> Iterator[dict[str, Any]]:
195
+ if is_text_response_request(body):
196
+ yield from stream_text_response(text_backend(), body)
197
+ return
198
+
199
+ prompt = extract_response_prompt(body.get("input"))
200
+ if not prompt:
201
+ raise HTTPException(status_code=400, detail={"error": "input text is required"})
202
+ model = str(body.get("model") or "gpt-image-2").strip() or "gpt-image-2"
203
+ image_info = extract_response_image(body.get("input"))
204
+ if image_info:
205
+ image_data, mime_type = image_info
206
+ images = encode_images([(image_data, "image.png", mime_type)])
207
+ else:
208
+ images = None
209
+ tool = response_image_tool(body)
210
+ image_outputs = stream_image_outputs_with_pool(ConversationRequest(
211
+ prompt=prompt,
212
+ model=model,
213
+ size=tool.get("size"),
214
+ quality=str(tool.get("quality") or "auto"),
215
+ response_format="b64_json",
216
+ images=images,
217
+ ))
218
+ yield from stream_image_response(image_outputs, prompt, model)
219
+
220
+
221
+ def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]:
222
+ events = response_events(body)
223
+ if body.get("stream"):
224
+ return events
225
+ return collect_response(events)
services/proxy_service.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Global outbound proxy helpers for upstream ChatGPT and CPA requests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from urllib.parse import urlparse
7
+
8
+ from curl_cffi.requests import Session
9
+
10
+ from services.config import config
11
+
12
+
13
+ class ProxySettingsStore:
14
+ def build_session_kwargs(self, **session_kwargs) -> dict[str, object]:
15
+ proxy = config.get_proxy_settings()
16
+ if proxy:
17
+ session_kwargs["proxy"] = proxy
18
+ return session_kwargs
19
+
20
+
21
+ def _clean(value: object) -> str:
22
+ return str(value or "").strip()
23
+
24
+
25
+ def _is_valid_proxy_url(url: str) -> bool:
26
+ parsed = urlparse(url)
27
+ return parsed.scheme in {"http", "https", "socks5", "socks5h"} and bool(parsed.netloc)
28
+
29
+
30
+ def test_proxy(url: str, *, timeout: float = 15.0) -> dict:
31
+ candidate = _clean(url)
32
+ if not candidate:
33
+ return {"ok": False, "status": 0, "latency_ms": 0, "error": "proxy url is required"}
34
+ if not _is_valid_proxy_url(candidate):
35
+ return {"ok": False, "status": 0, "latency_ms": 0, "error": "invalid proxy url"}
36
+ session = Session(impersonate="edge101", verify=True, proxy=candidate)
37
+ started = time.perf_counter()
38
+ try:
39
+ response = session.get(
40
+ "https://chatgpt.com/api/auth/csrf",
41
+ headers={"user-agent": "Mozilla/5.0 (chatgpt2api proxy test)"},
42
+ timeout=timeout,
43
+ )
44
+ latency_ms = int((time.perf_counter() - started) * 1000)
45
+ return {
46
+ "ok": response.status_code < 500,
47
+ "status": int(response.status_code),
48
+ "latency_ms": latency_ms,
49
+ "error": None if response.status_code < 500 else f"HTTP {response.status_code}",
50
+ }
51
+ except Exception as exc:
52
+ latency_ms = int((time.perf_counter() - started) * 1000)
53
+ return {
54
+ "ok": False,
55
+ "status": 0,
56
+ "latency_ms": latency_ms,
57
+ "error": str(exc) or exc.__class__.__name__,
58
+ }
59
+ finally:
60
+ session.close()
61
+
62
+ proxy_settings = ProxySettingsStore()
63
+
services/register/__init__.py ADDED
File without changes
services/register/mail_provider.py ADDED
@@ -0,0 +1,991 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import random
6
+ import re
7
+ import string
8
+ import time
9
+ from datetime import datetime, timezone
10
+ from email import message_from_string, policy
11
+ from email.utils import parsedate_to_datetime
12
+ from threading import Lock
13
+ from typing import Any, Callable, TypeVar
14
+
15
+ import requests
16
+ from curl_cffi import requests as curl_requests
17
+
18
+
19
+ from services.config import DATA_DIR
20
+
21
+ DDG_ALIASES_FILE = DATA_DIR / "ddg_aliases.json"
22
+ _ddg_aliases_lock = Lock()
23
+
24
+
25
+ def _load_ddg_aliases() -> set[str]:
26
+ try:
27
+ if DDG_ALIASES_FILE.exists():
28
+ data = json.loads(DDG_ALIASES_FILE.read_text(encoding="utf-8"))
29
+ if isinstance(data, list):
30
+ return {str(item).strip().lower() for item in data if str(item).strip()}
31
+ except Exception:
32
+ pass
33
+ return set()
34
+
35
+
36
+ def _save_ddg_aliases(aliases: set[str]) -> None:
37
+ DDG_ALIASES_FILE.parent.mkdir(parents=True, exist_ok=True)
38
+ DDG_ALIASES_FILE.write_text(json.dumps(sorted(aliases), ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
39
+
40
+
41
+ def _is_ddg_alias_duplicate(address: str) -> bool:
42
+ target = str(address or "").strip().lower()
43
+ if not target:
44
+ return False
45
+ with _ddg_aliases_lock:
46
+ used = _load_ddg_aliases()
47
+ return target in used
48
+
49
+
50
+ def _record_ddg_alias(address: str) -> None:
51
+ target = str(address or "").strip().lower()
52
+ if not target:
53
+ return
54
+ with _ddg_aliases_lock:
55
+ used = _load_ddg_aliases()
56
+ used.add(target)
57
+ _save_ddg_aliases(used)
58
+
59
+
60
+ ResultT = TypeVar("ResultT")
61
+ domain_lock = Lock()
62
+ provider_lock = Lock()
63
+ domain_index = 0
64
+ provider_index = 0
65
+ cloudmail_token_lock = Lock()
66
+ cloudmail_token_cache: dict[str, tuple[str, float]] = {}
67
+
68
+
69
+ def _config(mail_config: dict) -> dict:
70
+ return {
71
+ "request_timeout": float(mail_config.get("request_timeout") or 30),
72
+ "wait_timeout": float(mail_config.get("wait_timeout") or 30),
73
+ "wait_interval": float(mail_config.get("wait_interval") or 2),
74
+ "user_agent": str(mail_config.get("user_agent") or "Mozilla/5.0"),
75
+ "proxy": str(mail_config.get("proxy") or "").strip(),
76
+ }
77
+
78
+
79
+ def _random_mailbox_name() -> str:
80
+ return f"{''.join(random.choices(string.ascii_lowercase, k=5))}{''.join(random.choices(string.digits, k=random.randint(1, 3)))}{''.join(random.choices(string.ascii_lowercase, k=random.randint(1, 3)))}"
81
+
82
+
83
+ def _random_subdomain_label() -> str:
84
+ return "".join(random.choices(string.ascii_lowercase + string.digits, k=random.randint(4, 10)))
85
+
86
+
87
+ def _next_domain(domains: list[str]) -> str:
88
+ global domain_index
89
+ domains = [str(item).strip() for item in domains if str(item).strip()]
90
+ if not domains:
91
+ raise RuntimeError("mail.domain 不能为空")
92
+ if len(domains) == 1:
93
+ return domains[0]
94
+ with domain_lock:
95
+ value = domains[domain_index % len(domains)]
96
+ domain_index = (domain_index + 1) % len(domains)
97
+ return value
98
+
99
+
100
+ def _normalize_string_list(value: Any) -> list[str]:
101
+ if isinstance(value, list):
102
+ return [str(item).strip() for item in value if str(item).strip()]
103
+ text = str(value or "").strip()
104
+ return [text] if text else []
105
+
106
+
107
+ def _parse_received_at(value: Any) -> datetime | None:
108
+ if isinstance(value, (int, float)):
109
+ try:
110
+ return datetime.fromtimestamp(float(value), tz=timezone.utc)
111
+ except Exception:
112
+ return None
113
+ text = str(value or "").strip()
114
+ if not text:
115
+ return None
116
+ try:
117
+ date = datetime.fromisoformat(text[:-1] + "+00:00" if text.endswith("Z") else text)
118
+ return date if date.tzinfo else date.replace(tzinfo=timezone.utc)
119
+ except Exception:
120
+ pass
121
+ try:
122
+ date = parsedate_to_datetime(text)
123
+ return date if date.tzinfo else date.replace(tzinfo=timezone.utc)
124
+ except Exception:
125
+ return None
126
+
127
+
128
+ def _extract_content(data: dict[str, Any]) -> tuple[str, str]:
129
+ text_content = str(data.get("text_content") or data.get("text") or data.get("body") or data.get("content") or "")
130
+ html_content = str(data.get("html_content") or data.get("html") or data.get("html_body") or data.get("body_html") or "")
131
+ if text_content or html_content:
132
+ return text_content, html_content
133
+ raw = data.get("raw")
134
+ if not isinstance(raw, str) or not raw.strip():
135
+ return "", ""
136
+ try:
137
+ parsed = message_from_string(raw, policy=policy.default)
138
+ except Exception:
139
+ return raw, ""
140
+ plain: list[str] = []
141
+ html: list[str] = []
142
+ for part in parsed.walk() if parsed.is_multipart() else [parsed]:
143
+ if part.get_content_maintype() == "multipart":
144
+ continue
145
+ try:
146
+ payload = part.get_content()
147
+ except Exception:
148
+ payload = ""
149
+ if not payload:
150
+ continue
151
+ if part.get_content_type() == "text/html":
152
+ html.append(str(payload))
153
+ else:
154
+ plain.append(str(payload))
155
+ return "\n".join(plain).strip(), "\n".join(html).strip()
156
+
157
+
158
+ def _extract_text_candidates(value: Any) -> list[str]:
159
+ if isinstance(value, str):
160
+ return [value]
161
+ if isinstance(value, dict):
162
+ out: list[str] = []
163
+ for key in ("address", "email", "name", "value"):
164
+ if value.get(key):
165
+ out.extend(_extract_text_candidates(value.get(key)))
166
+ return out
167
+ if isinstance(value, list):
168
+ out: list[str] = []
169
+ for item in value:
170
+ out.extend(_extract_text_candidates(item))
171
+ return out
172
+ return []
173
+
174
+
175
+ def _message_matches_email(data: dict[str, Any], email: str) -> bool:
176
+ target = str(email or "").strip().lower()
177
+ candidates: list[str] = []
178
+ for key in ("to", "mailTo", "receiver", "receivers", "address", "email", "envelope_to"):
179
+ if key in data:
180
+ candidates.extend(_extract_text_candidates(data.get(key)))
181
+ return not target or not candidates or any(target in str(item).strip().lower() for item in candidates if str(item).strip())
182
+
183
+
184
+ def _extract_code(message: dict[str, Any]) -> str | None:
185
+ content = f"{message.get('subject', '')}\n{message.get('text_content', '')}\n{message.get('html_content', '')}".strip()
186
+ if not content:
187
+ return None
188
+ match = re.search(r"background-color:\s*#F3F3F3[^>]*>[\s\S]*?(\d{6})[\s\S]*?</p>", content, re.I)
189
+ if match:
190
+ return match.group(1)
191
+ match = re.search(r"(?:Verification code|code is|代码为|验证码)[:\s]*(\d{6})", content, re.I)
192
+ if match and match.group(1) != "177010":
193
+ return match.group(1)
194
+ for code in re.findall(r">\s*(\d{6})\s*<|(?<![#&])\b(\d{6})\b", content):
195
+ value = code[0] or code[1]
196
+ if value and value != "177010":
197
+ return value
198
+ return None
199
+
200
+
201
+ def _message_tracking_ref(message: dict[str, Any]) -> str:
202
+ provider = str(message.get("provider") or "").strip()
203
+ mailbox = str(message.get("mailbox") or "").strip()
204
+ message_id = str(message.get("message_id") or "").strip()
205
+ if message_id:
206
+ return f"id:{provider}:{mailbox}:{message_id}"
207
+ received_at = message.get("received_at")
208
+ received_value = received_at.isoformat() if isinstance(received_at, datetime) else str(received_at or "")
209
+ content = "\n".join(str(message.get(key) or "") for key in ("subject", "sender", "text_content", "html_content"))
210
+ digest = hashlib.sha256(content.encode("utf-8", errors="replace")).hexdigest()
211
+ return f"content:{provider}:{mailbox}:{received_value}:{digest}"
212
+
213
+
214
+ class BaseMailProvider:
215
+ name = "unknown"
216
+
217
+ def __init__(self, conf: dict, provider_ref: str = ""):
218
+ self.conf = conf
219
+ self.provider_ref = provider_ref
220
+
221
+ def wait_for(self, mailbox: dict[str, Any], on_message: Callable[[dict[str, Any]], ResultT | None]) -> ResultT | None:
222
+ deadline = time.monotonic() + self.conf["wait_timeout"]
223
+ while time.monotonic() < deadline:
224
+ message = self.fetch_latest_message(mailbox)
225
+ if message:
226
+ result = on_message(message)
227
+ if result is not None:
228
+ return result
229
+ time.sleep(max(0.2, self.conf["wait_interval"]))
230
+ return None
231
+
232
+ def wait_for_code(self, mailbox: dict[str, Any]) -> str | None:
233
+ seen_value = mailbox.setdefault("_seen_code_message_refs", [])
234
+ if not isinstance(seen_value, list):
235
+ seen_value = []
236
+ mailbox["_seen_code_message_refs"] = seen_value
237
+ seen_refs = {str(item) for item in seen_value}
238
+
239
+ def extract_unseen_code(message: dict[str, Any]) -> str | None:
240
+ ref = _message_tracking_ref(message)
241
+ if ref in seen_refs:
242
+ return None
243
+ code = _extract_code(message)
244
+ if code:
245
+ seen_value.append(ref)
246
+ seen_refs.add(ref)
247
+ return code
248
+
249
+ return self.wait_for(mailbox, extract_unseen_code)
250
+
251
+ def close(self) -> None:
252
+ pass
253
+
254
+
255
+ class CloudflareTempMailProvider(BaseMailProvider):
256
+ name = "cloudflare_temp_email"
257
+
258
+ def __init__(self, entry: dict, conf: dict):
259
+ super().__init__(conf, str(entry.get("provider_ref") or ""))
260
+ self.api_base = str(entry["api_base"]).rstrip("/")
261
+ self.admin_password = str(entry["admin_password"]).strip()
262
+ self.domain = entry.get("domain") or []
263
+ self.session = curl_requests.Session(impersonate="chrome")
264
+
265
+ def _request(self, method: str, path: str, headers: dict | None = None, params: dict | None = None, payload: dict | None = None, expected: tuple[int, ...] = (200,)):
266
+ resp = self.session.request(method.upper(), f"{self.api_base}{path}", headers={"Content-Type": "application/json", "User-Agent": self.conf["user_agent"], **(headers or {})}, params=params, json=payload, timeout=self.conf["request_timeout"], verify=False)
267
+ if resp.status_code not in expected:
268
+ raise RuntimeError(f"CloudflareTempMail 请求失败: {method} {path}, HTTP {resp.status_code}, body={resp.text[:300]}")
269
+ return {} if resp.status_code == 204 else resp.json()
270
+
271
+ def create_mailbox(self, username: str | None = None) -> dict[str, Any]:
272
+ data = self._request("POST", "/admin/new_address", headers={"x-admin-auth": self.admin_password}, payload={"enablePrefix": True, "name": username or _random_mailbox_name(), "domain": _next_domain(self.domain)})
273
+ address = str(data.get("address") or "").strip()
274
+ token = str(data.get("jwt") or "").strip()
275
+ if not address or not token:
276
+ raise RuntimeError("CloudflareTempMail 缺少 address 或 jwt")
277
+ return {"provider": self.name, "provider_ref": self.provider_ref, "address": address, "token": token}
278
+
279
+ def fetch_latest_message(self, mailbox: dict[str, Any]) -> dict[str, Any] | None:
280
+ data = self._request("GET", "/api/mails", headers={"Authorization": f"Bearer {mailbox['token']}"}, params={"limit": 10, "offset": 0})
281
+ raw = list(data.get("results") or []) if isinstance(data, dict) else data if isinstance(data, list) else []
282
+ messages = [item for item in raw if isinstance(item, dict) and _message_matches_email(item, str(mailbox.get("address") or ""))]
283
+ if not messages:
284
+ return None
285
+ item = messages[0]
286
+ text_content, html_content = _extract_content(item)
287
+ sender = item.get("from") or item.get("sender") or ""
288
+ if isinstance(sender, dict):
289
+ sender = sender.get("address") or sender.get("email") or sender.get("name") or ""
290
+ return {"provider": self.name, "mailbox": mailbox["address"], "message_id": str(item.get("id") or item.get("_id") or ""), "subject": str(item.get("subject") or ""), "sender": str(sender), "text_content": text_content, "html_content": html_content, "received_at": _parse_received_at(item.get("createdAt") or item.get("created_at") or item.get("receivedAt") or item.get("date") or item.get("timestamp")), "raw": item}
291
+
292
+ def close(self) -> None:
293
+ self.session.close()
294
+
295
+
296
+ class DDGMailProvider(BaseMailProvider):
297
+ name = "ddg_mail"
298
+
299
+ def __init__(self, entry: dict, conf: dict):
300
+ super().__init__(conf, str(entry.get("provider_ref") or ""))
301
+ self.label = str(entry.get("label") or self.provider_ref)
302
+ self.ddg_token = str(entry["ddg_token"]).strip()
303
+ self.cf_api_base = str(entry.get("api_base") or entry.get("cf_api_base") or "").rstrip("/")
304
+ self.cf_inbox_jwt = str(entry.get("cf_inbox_jwt") or "").strip()
305
+ self.cf_admin_password = str(entry.get("admin_password") or "").strip()
306
+ self.cf_api_key = str(entry.get("cf_api_key") or "").strip()
307
+ self.cf_auth_mode = str(entry.get("cf_auth_mode") or "none").strip().lower()
308
+ self.cf_domain = entry.get("cf_domain") or []
309
+ self.cf_create_path = str(entry.get("cf_create_path") or "/api/new_address").strip()
310
+ self.cf_messages_path = str(entry.get("cf_messages_path") or "/api/mails").strip()
311
+ self.proxy = str(conf.get("proxy") or "").strip()
312
+ self.session = curl_requests.Session(impersonate="chrome")
313
+ if self.proxy:
314
+ self.session.proxies = {"http": self.proxy, "https": self.proxy}
315
+
316
+ def _cf_build_headers(self, content_type: bool = False) -> dict:
317
+ headers = {"Content-Type": "application/json"} if content_type else {}
318
+ if self.cf_api_key:
319
+ if self.cf_auth_mode == "x-api-key":
320
+ headers["X-API-Key"] = self.cf_api_key
321
+ elif self.cf_auth_mode != "none":
322
+ headers["Authorization"] = f"Bearer {self.cf_api_key}"
323
+ return headers
324
+
325
+ def _cf_request(self, method: str, path: str, headers: dict | None = None, params: dict | None = None, payload: dict | None = None, expected: tuple[int, ...] = (200,)) -> dict:
326
+ merged_headers = {**self._cf_build_headers(True), **(headers or {}), "User-Agent": self.conf["user_agent"]}
327
+ if self.cf_admin_password and method.upper() in ("POST",):
328
+ merged_headers["x-admin-auth"] = self.cf_admin_password
329
+ if self.cf_api_key and self.cf_auth_mode == "query-key":
330
+ params = {**(params or {}), "key": self.cf_api_key}
331
+ resp = self.session.request(method.upper(), f"{self.cf_api_base}{path}", headers=merged_headers, params=params, json=payload, timeout=self.conf["request_timeout"], verify=False)
332
+ if resp.status_code not in expected:
333
+ raise RuntimeError(f"DDGMail CF请求失败: {method} {path}, HTTP {resp.status_code}, body={resp.text[:300]}")
334
+ return {} if resp.status_code == 204 else resp.json()
335
+
336
+ def _ddg_request(self, method: str, path: str, payload: dict | None = None) -> dict:
337
+ resp = self.session.request(method.upper(), f"https://quack.duckduckgo.com{path}", headers={"Authorization": f"Bearer {self.ddg_token}", "Content-Type": "application/json", "User-Agent": self.conf["user_agent"]}, json=payload, timeout=self.conf["request_timeout"], verify=False)
338
+ if resp.status_code not in (200, 201):
339
+ raise RuntimeError(f"DDG API请求失败: {method} {path}, HTTP {resp.status_code}, body={resp.text[:300]}")
340
+ return resp.json()
341
+
342
+ def _cf_list_payload(self, data: Any) -> list:
343
+ if isinstance(data, list):
344
+ return data
345
+ if isinstance(data, dict):
346
+ for key in ("results", "hydra:member", "data", "messages"):
347
+ value = data.get(key)
348
+ if isinstance(value, list):
349
+ return value
350
+ if isinstance(value, dict) and isinstance(value.get("messages"), list):
351
+ return value["messages"]
352
+ return []
353
+
354
+ def create_mailbox(self, username: str | None = None) -> dict[str, Any]:
355
+ ddg_data = self._ddg_request("POST", "/api/email/addresses", payload={})
356
+ ddg_address_part = str(ddg_data.get("address") or "").strip()
357
+ if not ddg_address_part:
358
+ raise RuntimeError("DDG API 返回无 address 字段")
359
+ ddg_address = f"{ddg_address_part}@duck.com"
360
+
361
+ if _is_ddg_alias_duplicate(ddg_address):
362
+ raise RuntimeError(f"[{self.label}] DDG日上限已达,别名 {ddg_address} 已存在,自动切换邮箱提供商")
363
+
364
+ _record_ddg_alias(ddg_address)
365
+
366
+ if not self.cf_inbox_jwt:
367
+ raise RuntimeError("DDGMail 需要 cf_inbox_jwt(DDG 转发目标的固定收件箱 JWT),请在邮箱配置中填写 CF Inbox JWT")
368
+
369
+ return {"provider": self.name, "provider_ref": self.provider_ref, "address": ddg_address, "token": self.cf_inbox_jwt, "label": self.label}
370
+
371
+ def _parse_raw_recipient(self, raw_text: str) -> str:
372
+ if not raw_text:
373
+ return ""
374
+ match = re.search(r"^To:\s*(.+?)$", raw_text, re.MULTILINE | re.IGNORECASE)
375
+ if match:
376
+ addr = match.group(1).strip()
377
+ addr = re.sub(r"\s*<[^>]*>", "", addr)
378
+ return addr.strip().lower()
379
+ try:
380
+ parsed = message_from_string(raw_text, policy=policy.default)
381
+ return str(parsed.get("To") or "").strip().lower()
382
+ except Exception:
383
+ return ""
384
+
385
+ def fetch_latest_message(self, mailbox: dict[str, Any]) -> dict[str, Any] | None:
386
+ target_address = str(mailbox.get("address") or "").strip().lower()
387
+ data = self._cf_request("GET", self.cf_messages_path, headers={"Authorization": f"Bearer {mailbox['token']}"}, params={"limit": 30, "offset": 0})
388
+ raw_list = self._cf_list_payload(data)
389
+ messages = [item for item in raw_list if isinstance(item, dict)]
390
+ if not messages:
391
+ return None
392
+
393
+ for item in messages:
394
+ message_id = str(item.get("id") or item.get("msgid") or item.get("_id") or "")
395
+ raw_text = str(item.get("raw") or "")
396
+ raw_recipient = self._parse_raw_recipient(raw_text)
397
+ if target_address and raw_recipient and target_address not in raw_recipient:
398
+ continue
399
+ text_content, html_content = _extract_content(item)
400
+ subject = str(item.get("subject") or "")
401
+ sender = item.get("from") or item.get("sender") or item.get("source") or ""
402
+ if isinstance(sender, dict):
403
+ sender = sender.get("address") or sender.get("email") or sender.get("name") or ""
404
+ if raw_text and (not subject or not sender or subject == sender == ""):
405
+ try:
406
+ parsed = message_from_string(raw_text, policy=policy.default)
407
+ if not subject:
408
+ subject = str(parsed.get("Subject") or "")
409
+ if not sender:
410
+ sender = str(parsed.get("From") or "")
411
+ except Exception:
412
+ pass
413
+ return {"provider": self.name, "mailbox": mailbox["address"], "message_id": message_id, "subject": subject, "sender": str(sender), "text_content": text_content, "html_content": html_content, "received_at": _parse_received_at(item.get("createdAt") or item.get("created_at") or item.get("receivedAt") or item.get("date") or item.get("timestamp")), "raw": item}
414
+
415
+ return None
416
+
417
+ def close(self) -> None:
418
+ self.session.close()
419
+
420
+
421
+ class CloudMailGenProvider(BaseMailProvider):
422
+ name = "cloudmail_gen"
423
+
424
+ def __init__(self, entry: dict, conf: dict):
425
+ super().__init__(conf, str(entry.get("provider_ref") or ""))
426
+ self.api_base = str(entry["api_base"]).rstrip("/")
427
+ self.admin_email = str(entry.get("admin_email") or "").strip()
428
+ self.admin_password = str(entry.get("admin_password") or "").strip()
429
+ self.domain = _normalize_string_list(entry.get("domain"))
430
+ self.subdomain = _normalize_string_list(entry.get("subdomain"))
431
+ self.email_prefix = str(entry.get("email_prefix") or "").strip()
432
+ self.session = curl_requests.Session(impersonate="chrome")
433
+
434
+ def _request(
435
+ self,
436
+ method: str,
437
+ path: str,
438
+ headers: dict | None = None,
439
+ params: dict | None = None,
440
+ payload: dict | None = None,
441
+ expected: tuple[int, ...] = (200,),
442
+ ):
443
+ resp = self.session.request(
444
+ method.upper(),
445
+ f"{self.api_base}{path}",
446
+ headers={
447
+ "Content-Type": "application/json",
448
+ "User-Agent": self.conf["user_agent"],
449
+ **(headers or {}),
450
+ },
451
+ params=params,
452
+ json=payload,
453
+ timeout=self.conf["request_timeout"],
454
+ verify=False,
455
+ )
456
+ if resp.status_code not in expected:
457
+ raise RuntimeError(f"CloudMailGen 请求失败: {method} {path}, HTTP {resp.status_code}, body={resp.text[:300]}")
458
+ return {} if resp.status_code == 204 else resp.json()
459
+
460
+ def _cache_key(self) -> str:
461
+ return f"{self.api_base}|{self.admin_email}"
462
+
463
+ def _get_token(self) -> str:
464
+ if not self.admin_email or not self.admin_password:
465
+ raise RuntimeError("CloudMailGen 缺少 admin_email 或 admin_password")
466
+ cache_key = self._cache_key()
467
+ now = time.time()
468
+ with cloudmail_token_lock:
469
+ cached = cloudmail_token_cache.get(cache_key)
470
+ if cached and now < cached[1] - 300:
471
+ return cached[0]
472
+ data = self._request(
473
+ "POST",
474
+ "/api/public/genToken",
475
+ payload={"email": self.admin_email, "password": self.admin_password},
476
+ )
477
+ token = ""
478
+ if isinstance(data, dict) and data.get("code") == 200:
479
+ token = str((data.get("data") or {}).get("token") or "").strip()
480
+ if not token:
481
+ raise RuntimeError(f"CloudMailGen genToken 返回异常: {data}")
482
+ with cloudmail_token_lock:
483
+ cloudmail_token_cache[cache_key] = (token, now + 24 * 3600)
484
+ return token
485
+
486
+ def _resolve_address(self, username: str | None = None) -> str:
487
+ domain = _next_domain(self.domain)
488
+ if self.subdomain:
489
+ domain = f"{random.choice(self.subdomain)}.{domain}"
490
+ if username:
491
+ local_part = username
492
+ elif self.email_prefix:
493
+ local_part = f"{self.email_prefix}_{''.join(random.choices(string.ascii_lowercase + string.digits, k=6))}"
494
+ else:
495
+ local_part = _random_mailbox_name()
496
+ return f"{local_part}@{domain}"
497
+
498
+ def create_mailbox(self, username: str | None = None) -> dict[str, Any]:
499
+ if not self.domain:
500
+ raise RuntimeError("CloudMailGen 需要至少配置一个 domain")
501
+ address = self._resolve_address(username)
502
+ return {"provider": self.name, "provider_ref": self.provider_ref, "address": address}
503
+
504
+ def fetch_latest_message(self, mailbox: dict[str, Any]) -> dict[str, Any] | None:
505
+ address = str(mailbox.get("address") or "").strip()
506
+ if not address:
507
+ raise RuntimeError("CloudMailGen 缺少 address")
508
+ token = self._get_token()
509
+ data = self._request(
510
+ "POST",
511
+ "/api/public/emailList",
512
+ headers={"Authorization": token},
513
+ payload={"toEmail": address, "size": 20, "timeSort": "desc"},
514
+ )
515
+ items = (data.get("data") or []) if isinstance(data, dict) and data.get("code") == 200 else []
516
+ messages = [item for item in items if isinstance(item, dict) and _message_matches_email(item, address)]
517
+ if not messages:
518
+ return None
519
+ item = messages[0]
520
+ text_content, html_content = _extract_content(item)
521
+ return {
522
+ "provider": self.name,
523
+ "mailbox": address,
524
+ "message_id": str(item.get("id") or item.get("_id") or item.get("messageId") or ""),
525
+ "subject": str(item.get("subject") or ""),
526
+ "sender": str(item.get("from") or item.get("sender") or ""),
527
+ "text_content": text_content,
528
+ "html_content": html_content,
529
+ "received_at": _parse_received_at(
530
+ item.get("createdAt") or item.get("created_at") or item.get("receivedAt") or item.get("date") or item.get("timestamp")
531
+ ),
532
+ "to": item.get("to") or item.get("toEmail") or item.get("mailTo"),
533
+ "raw": item,
534
+ }
535
+
536
+ def close(self) -> None:
537
+ self.session.close()
538
+
539
+
540
+ class TempMailLolProvider(BaseMailProvider):
541
+ name = "tempmail_lol"
542
+
543
+ def __init__(self, entry: dict, conf: dict):
544
+ super().__init__(conf, str(entry.get("provider_ref") or ""))
545
+ self.api_key = str(entry.get("api_key") or "").strip()
546
+ self.domain = [str(item).strip() for item in (entry.get("domain") or []) if str(item).strip()]
547
+ self.session = requests.Session()
548
+ self.session.trust_env = False
549
+ self.session.headers.update({"User-Agent": conf["user_agent"], "Accept": "application/json", "Content-Type": "application/json"})
550
+ if self.api_key:
551
+ self.session.headers["Authorization"] = f"Bearer {self.api_key}"
552
+
553
+ @staticmethod
554
+ def _resolve_domain(domain: str) -> tuple[str, bool]:
555
+ text = str(domain or "").strip().lower()
556
+ if text.startswith("*.") and len(text) > 2:
557
+ return f"{_random_subdomain_label()}.{text[2:]}", True
558
+ return text, False
559
+
560
+ def _request(self, method: str, path: str, params: dict | None = None, payload: dict | None = None, expected: tuple[int, ...] = (200,)):
561
+ resp = self.session.request(method.upper(), f"https://api.tempmail.lol/v2{path}", params=params, json=payload, timeout=self.conf["request_timeout"], verify=False)
562
+ if resp.status_code not in expected:
563
+ raise RuntimeError(f"TempMail.lol 请求失败: {method} {path}, HTTP {resp.status_code}, body={resp.text[:300]}")
564
+ data = resp.json()
565
+ if not isinstance(data, dict):
566
+ raise RuntimeError(f"TempMail.lol {method} {path} 返回结构不是对象")
567
+ return data
568
+
569
+ def create_mailbox(self, username: str | None = None) -> dict[str, Any]:
570
+ payload: dict[str, Any] = {}
571
+ if self.domain:
572
+ domain, force_random_prefix = self._resolve_domain(random.choice(self.domain))
573
+ payload["domain"] = domain
574
+ if force_random_prefix:
575
+ payload["prefix"] = _random_mailbox_name()
576
+ if username and "prefix" not in payload:
577
+ payload["prefix"] = username
578
+ data = self._request("POST", "/inbox/create", payload=payload, expected=(200, 201))
579
+ address = str(data.get("address") or "").strip()
580
+ token = str(data.get("token") or "").strip()
581
+ if not address or not token:
582
+ raise RuntimeError("TempMail.lol 缺少 address 或 token")
583
+ return {"provider": self.name, "provider_ref": self.provider_ref, "address": address, "token": token}
584
+
585
+ def fetch_latest_message(self, mailbox: dict[str, Any]) -> dict[str, Any] | None:
586
+ data = self._request("GET", "/inbox", params={"token": mailbox["token"]})
587
+ items = data.get("emails") or data.get("messages") or []
588
+ messages = [item for item in items if isinstance(item, dict)] if isinstance(items, list) else []
589
+ if not messages:
590
+ return None
591
+ item = max(messages, key=lambda value: ((_parse_received_at(value.get("created_at") or value.get("createdAt") or value.get("date") or value.get("received_at") or value.get("timestamp")) or datetime.fromtimestamp(0, tz=timezone.utc)).timestamp(), str(value.get("id") or value.get("token") or "")))
592
+ text_content, html_content = _extract_content(item)
593
+ return {"provider": self.name, "mailbox": mailbox["address"], "message_id": str(item.get("id") or item.get("token") or ""), "subject": str(item.get("subject") or ""), "sender": str(item.get("from") or item.get("from_address") or ""), "text_content": text_content, "html_content": html_content, "received_at": _parse_received_at(item.get("created_at") or item.get("createdAt") or item.get("date") or item.get("received_at") or item.get("timestamp")), "raw": item}
594
+
595
+ def close(self) -> None:
596
+ self.session.close()
597
+
598
+
599
+ class DuckMailProvider(BaseMailProvider):
600
+ name = "duckmail"
601
+
602
+ def __init__(self, entry: dict, conf: dict):
603
+ super().__init__(conf, str(entry.get("provider_ref") or ""))
604
+ self.api_key = str(entry["api_key"]).strip()
605
+ self.default_domain = str(entry.get("default_domain") or "duckmail.sbs").strip() or "duckmail.sbs"
606
+ self.session = requests.Session()
607
+ self.session.trust_env = False
608
+ self.session.headers.update({"User-Agent": conf["user_agent"], "Accept": "application/json", "Content-Type": "application/json"})
609
+
610
+ def _request(self, method: str, path: str, token: str = "", use_api_key: bool = False, params: dict | None = None, payload: dict | None = None, expected: tuple[int, ...] = (200, 201, 204)):
611
+ headers = {"Authorization": f"Bearer {self.api_key if use_api_key else token}"} if use_api_key or token else {}
612
+ resp = self.session.request(method.upper(), f"https://api.duckmail.sbs{path}", headers=headers, params=params, json=payload, timeout=self.conf["request_timeout"], verify=False)
613
+ if resp.status_code not in expected:
614
+ raise RuntimeError(f"DuckMail 请求失败: {method} {path}, HTTP {resp.status_code}, body={resp.text[:300]}")
615
+ return {} if resp.status_code == 204 else resp.json()
616
+
617
+ @staticmethod
618
+ def _items(data):
619
+ return data if isinstance(data, list) else data.get("hydra:member") or data.get("member") or data.get("data") or []
620
+
621
+ def create_mailbox(self, username: str | None = None) -> dict[str, Any]:
622
+ password = "".join(random.choices(string.ascii_letters + string.digits, k=12))
623
+ address = f"{username or _random_mailbox_name()}@{self.default_domain}"
624
+ payload = {"address": address, "password": password}
625
+ account = self._request("POST", "/accounts", use_api_key=True, payload=payload)
626
+ token_data = self._request("POST", "/token", use_api_key=True, payload=payload)
627
+ return {"provider": self.name, "provider_ref": self.provider_ref, "address": address, "token": str(token_data.get("token") or ""), "password": password, "account_id": str(account.get("id") or "")}
628
+
629
+ def fetch_latest_message(self, mailbox: dict[str, Any]) -> dict[str, Any] | None:
630
+ data = self._request("GET", "/messages", token=str(mailbox.get("token") or ""), params={"page": 1})
631
+ items = self._items(data)
632
+ if not items:
633
+ return None
634
+ item = items[0]
635
+ message_id = str(item.get("id") or item.get("@id") or "").replace("/messages/", "")
636
+ if message_id:
637
+ item = self._request("GET", f"/messages/{message_id}", token=str(mailbox.get("token") or ""))
638
+ sender = item.get("from") or ""
639
+ if isinstance(sender, dict):
640
+ sender = sender.get("address") or sender.get("name") or ""
641
+ html_content = item.get("html") or ""
642
+ if isinstance(html_content, list):
643
+ html_content = "".join(str(value) for value in html_content)
644
+ return {"provider": self.name, "mailbox": mailbox["address"], "message_id": message_id, "subject": str(item.get("subject") or ""), "sender": str(sender), "text_content": str(item.get("text") or item.get("text_content") or ""), "html_content": str(html_content), "received_at": _parse_received_at(item.get("createdAt") or item.get("created_at") or item.get("receivedAt") or item.get("date")), "raw": item}
645
+
646
+ def close(self) -> None:
647
+ self.session.close()
648
+
649
+
650
+ class GptMailProvider(BaseMailProvider):
651
+ name = "gptmail"
652
+
653
+ def __init__(self, entry: dict, conf: dict):
654
+ super().__init__(conf, str(entry.get("provider_ref") or ""))
655
+ self.api_key = str(entry["api_key"]).strip()
656
+ self.default_domain = str(entry.get("default_domain") or "").strip()
657
+ self.session = requests.Session()
658
+ self.session.trust_env = False
659
+ self.session.headers.update({"User-Agent": conf["user_agent"], "Accept": "application/json", "Content-Type": "application/json", "X-API-Key": self.api_key})
660
+
661
+ def _request(self, method: str, path: str, params: dict | None = None, payload: dict | None = None):
662
+ query = dict(params or {})
663
+ resp = self.session.request(method.upper(), f"https://mail.chatgpt.org.uk{path}", params=query, json=payload, timeout=self.conf["request_timeout"], verify=False)
664
+ if resp.status_code != 200:
665
+ raise RuntimeError(f"GPTMail 请求失败: {method} {path}, HTTP {resp.status_code}, body={resp.text[:300]}")
666
+ data = resp.json()
667
+ return data["data"] if isinstance(data, dict) and "data" in data else data
668
+
669
+ def create_mailbox(self, username: str | None = None) -> dict[str, Any]:
670
+ payload = {key: value for key, value in {"prefix": username, "domain": self.default_domain}.items() if value}
671
+ data = self._request("POST" if payload else "GET", "/api/generate-email", payload=payload or None)
672
+ return {"provider": self.name, "provider_ref": self.provider_ref, "address": str(data["email"])}
673
+
674
+ def fetch_latest_message(self, mailbox: dict[str, Any]) -> dict[str, Any] | None:
675
+ data = self._request("GET", "/api/emails", params={"email": mailbox["address"]})
676
+ emails = data if isinstance(data, list) else data.get("emails") or []
677
+ if not emails:
678
+ return None
679
+ item = max(emails, key=lambda value: (float(value.get("timestamp") or 0), str(value.get("id") or "")))
680
+ if item.get("id"):
681
+ item = self._request("GET", f"/api/email/{item['id']}")
682
+ return {"provider": self.name, "mailbox": mailbox["address"], "message_id": str(item.get("id") or ""), "subject": str(item.get("subject") or ""), "sender": str(item.get("from_address") or ""), "text_content": str(item.get("content") or ""), "html_content": str(item.get("html_content") or ""), "received_at": _parse_received_at(item.get("timestamp") or item.get("created_at")), "raw": item}
683
+
684
+ def close(self) -> None:
685
+ self.session.close()
686
+
687
+
688
+ class MoEmailProvider(BaseMailProvider):
689
+ name = "moemail"
690
+
691
+ def __init__(self, entry: dict, conf: dict):
692
+ super().__init__(conf, str(entry.get("provider_ref") or ""))
693
+ self.api_base = str(entry["api_base"]).rstrip("/")
694
+ self.api_key = str(entry["api_key"]).strip()
695
+ raw_domains = entry.get("domain") or []
696
+ if isinstance(raw_domains, list):
697
+ self.domain = [str(item).strip() for item in raw_domains if str(item).strip()]
698
+ else:
699
+ self.domain = [str(raw_domains).strip()] if str(raw_domains).strip() else []
700
+ self.expiry_time = int(entry.get("expiry_time") or 0)
701
+ self.session = curl_requests.Session(impersonate="chrome")
702
+
703
+ def _request(self, method: str, path: str, params: dict | None = None, payload: dict | None = None, expected: tuple[int, ...] = (200,)):
704
+ resp = self.session.request(method.upper(), f"{self.api_base}{path}", headers={"X-API-Key": self.api_key, "Content-Type": "application/json", "User-Agent": self.conf["user_agent"]}, params=params, json=payload, timeout=self.conf["request_timeout"], verify=False)
705
+ if resp.status_code not in expected:
706
+ raise RuntimeError(f"MoEmail 请求失败: {method} {path}, HTTP {resp.status_code}, body={resp.text[:300]}")
707
+ data = resp.json()
708
+ if not isinstance(data, dict):
709
+ raise RuntimeError(f"MoEmail {method} {path} 返回结构不是对象")
710
+ return data
711
+
712
+ def create_mailbox(self, username: str | None = None) -> dict[str, Any]:
713
+ data = self._request("POST", "/api/emails/generate", payload={"name": username or _random_mailbox_name(), "expiryTime": self.expiry_time, "domain": _next_domain(self.domain)}, expected=(200, 201))
714
+ address = str(data.get("email") or "").strip()
715
+ email_id = str(data.get("id") or data.get("email_id") or "").strip()
716
+ if not address or not email_id:
717
+ raise RuntimeError("MoEmail 缺少 email 或 id")
718
+ return {"provider": self.name, "provider_ref": self.provider_ref, "address": address, "email_id": email_id}
719
+
720
+ def fetch_latest_message(self, mailbox: dict[str, Any]) -> dict[str, Any] | None:
721
+ email_id = str(mailbox.get("email_id") or "").strip()
722
+ if not email_id:
723
+ raise RuntimeError("MoEmail 缺少 email_id")
724
+ data = self._request("GET", f"/api/emails/{email_id}")
725
+ items = data.get("messages") or []
726
+ messages = [item for item in items if isinstance(item, dict)] if isinstance(items, list) else []
727
+ if not messages:
728
+ return None
729
+ _, item = max(enumerate(messages), key=lambda pair: (((_parse_received_at(pair[1].get("createdAt") or pair[1].get("created_at") or pair[1].get("receivedAt") or pair[1].get("date") or pair[1].get("timestamp")) or datetime.fromtimestamp(0, tz=timezone.utc)).timestamp()), pair[0]))
730
+ message_id = str(item.get("id") or item.get("message_id") or item.get("_id") or "").strip()
731
+ detail = self._request("GET", f"/api/emails/{email_id}/{message_id}") if message_id else {"message": item}
732
+ message = detail.get("message") if isinstance(detail.get("message"), dict) else detail
733
+ text_content, html_content = _extract_content(message)
734
+ sender = message.get("from") or message.get("sender") or ""
735
+ if isinstance(sender, dict):
736
+ sender = sender.get("address") or sender.get("email") or sender.get("name") or ""
737
+ return {"provider": self.name, "mailbox": mailbox["address"], "message_id": message_id, "subject": str(message.get("subject") or item.get("subject") or ""), "sender": str(sender), "text_content": text_content, "html_content": html_content, "received_at": _parse_received_at(message.get("createdAt") or message.get("created_at") or message.get("receivedAt") or message.get("date") or message.get("timestamp") or item.get("createdAt") or item.get("created_at") or item.get("receivedAt") or item.get("date") or item.get("timestamp")), "raw": detail}
738
+
739
+ def close(self) -> None:
740
+ self.session.close()
741
+
742
+
743
+ class InbucketMailProvider(BaseMailProvider):
744
+ name = "inbucket"
745
+
746
+ def __init__(self, entry: dict, conf: dict):
747
+ super().__init__(conf, str(entry.get("provider_ref") or ""))
748
+ self.api_base = str(entry["api_base"]).rstrip("/")
749
+ raw_domains = entry.get("domain") or []
750
+ if isinstance(raw_domains, list):
751
+ self.domain = [str(item).strip() for item in raw_domains if str(item).strip()]
752
+ else:
753
+ self.domain = [str(raw_domains).strip()] if str(raw_domains).strip() else []
754
+ self.random_subdomain = bool(entry.get("random_subdomain", True))
755
+ self.session = requests.Session()
756
+ self.session.trust_env = False
757
+ self.session.headers.update({
758
+ "User-Agent": conf["user_agent"],
759
+ "Accept": "application/json",
760
+ })
761
+
762
+ def _request(self, method: str, path: str, expected: tuple[int, ...] = (200,)):
763
+ resp = self.session.request(
764
+ method.upper(),
765
+ f"{self.api_base}{path}",
766
+ timeout=self.conf["request_timeout"],
767
+ verify=False,
768
+ )
769
+ if resp.status_code not in expected:
770
+ raise RuntimeError(f"Inbucket 请求失败: {method} {path}, HTTP {resp.status_code}, body={resp.text[:300]}")
771
+ if resp.status_code == 204:
772
+ return {}
773
+ content_type = str(resp.headers.get("content-type") or "").lower()
774
+ if "application/json" in content_type:
775
+ return resp.json()
776
+ return resp.text
777
+
778
+ def _resolve_domain(self) -> str:
779
+ if self.domain:
780
+ return _next_domain(self.domain)
781
+ raise RuntimeError("Inbucket 需要至少配置一个 domain")
782
+
783
+ def _mailbox_name(self, address: str) -> str:
784
+ local_part, _, _ = str(address or "").partition("@")
785
+ return local_part.strip()
786
+
787
+ def create_mailbox(self, username: str | None = None) -> dict[str, Any]:
788
+ local_part = username or _random_mailbox_name()
789
+ base_domain = self._resolve_domain()
790
+ domain = f"{_random_subdomain_label()}.{base_domain}" if self.random_subdomain else base_domain
791
+ address = f"{local_part}@{domain}"
792
+ mailbox_name = self._mailbox_name(address)
793
+ return {
794
+ "provider": self.name,
795
+ "provider_ref": self.provider_ref,
796
+ "address": address,
797
+ "base_domain": base_domain,
798
+ "mailbox_name": mailbox_name,
799
+ }
800
+
801
+ def fetch_latest_message(self, mailbox: dict[str, Any]) -> dict[str, Any] | None:
802
+ mailbox_name = str(mailbox.get("mailbox_name") or self._mailbox_name(str(mailbox.get("address") or ""))).strip()
803
+ if not mailbox_name:
804
+ raise RuntimeError("Inbucket 缺少 mailbox_name")
805
+ data = self._request("GET", f"/api/v1/mailbox/{mailbox_name}")
806
+ items = [item for item in data if isinstance(item, dict)] if isinstance(data, list) else []
807
+ if not items:
808
+ return None
809
+ items.sort(
810
+ key=lambda value: (
811
+ (_parse_received_at(value.get("date")) or datetime.fromtimestamp(0, tz=timezone.utc)).timestamp(),
812
+ str(value.get("id") or ""),
813
+ ),
814
+ reverse=True,
815
+ )
816
+ address = str(mailbox.get("address") or "").strip()
817
+ for item in items:
818
+ message_id = str(item.get("id") or "").strip()
819
+ if not message_id:
820
+ continue
821
+ detail = self._request("GET", f"/api/v1/mailbox/{mailbox_name}/{message_id}")
822
+ if not isinstance(detail, dict):
823
+ continue
824
+ header = detail.get("header") if isinstance(detail.get("header"), dict) else {}
825
+ body = detail.get("body") if isinstance(detail.get("body"), dict) else {}
826
+ normalized = {
827
+ "provider": self.name,
828
+ "mailbox": mailbox_name,
829
+ "message_id": message_id,
830
+ "subject": str(detail.get("subject") or item.get("subject") or ""),
831
+ "sender": str(detail.get("from") or item.get("from") or ""),
832
+ "text_content": str(body.get("text") or ""),
833
+ "html_content": str(body.get("html") or ""),
834
+ "received_at": _parse_received_at(detail.get("date") or item.get("date")),
835
+ "to": header.get("To") if isinstance(header, dict) else None,
836
+ "raw": detail,
837
+ }
838
+ if _message_matches_email(normalized, address):
839
+ return normalized
840
+ return None
841
+
842
+ def close(self) -> None:
843
+ self.session.close()
844
+
845
+
846
+ class YydsMailProvider(BaseMailProvider):
847
+ name = "yyds_mail"
848
+
849
+ def __init__(self, entry: dict, conf: dict):
850
+ super().__init__(conf, str(entry.get("provider_ref") or ""))
851
+ self.api_base = str(entry.get("api_base") or "https://maliapi.215.im/v1").rstrip("/")
852
+ self.api_key = str(entry["api_key"]).strip()
853
+ self.domain = [str(item).strip() for item in (entry.get("domain") or []) if str(item).strip()]
854
+ self.subdomain = str(entry.get("subdomain") or "").strip()
855
+ self.wildcard = bool(entry.get("wildcard"))
856
+ self.session = requests.Session()
857
+ self.session.trust_env = False
858
+ self.session.headers.update({"User-Agent": conf["user_agent"], "Accept": "application/json", "Content-Type": "application/json"})
859
+
860
+ def _request(self, method: str, path: str, token: str = "", params: dict | None = None, payload: dict | None = None, expected: tuple[int, ...] = (200, 201, 204)):
861
+ headers = {"Authorization": f"Bearer {token}"} if token else {"X-API-Key": self.api_key}
862
+ resp = self.session.request(method.upper(), f"{self.api_base}{path}", headers=headers, params=params, json=payload, timeout=self.conf["request_timeout"], verify=False)
863
+ if resp.status_code not in expected:
864
+ raise RuntimeError(f"YYDSMail 请求失败: {method} {path}, HTTP {resp.status_code}, body={resp.text[:300]}")
865
+ if resp.status_code == 204:
866
+ return {}
867
+ data = resp.json()
868
+ if isinstance(data, dict) and data.get("success") is False:
869
+ raise RuntimeError(f"YYDSMail 请求失败: {data.get('errorCode') or data.get('error')}")
870
+ return data.get("data") if isinstance(data, dict) and isinstance(data.get("data"), (dict, list)) else data
871
+
872
+ @staticmethod
873
+ def _items(data):
874
+ return data if isinstance(data, list) else data.get("items") or data.get("messages") or data.get("data") or []
875
+
876
+ def create_mailbox(self, username: str | None = None) -> dict[str, Any]:
877
+ payload = {"localPart": username or _random_mailbox_name()}
878
+ if self.domain:
879
+ payload["domain"] = _next_domain(self.domain)
880
+ if self.subdomain:
881
+ payload["subdomain"] = self.subdomain
882
+ data = self._request("POST", "/accounts/wildcard" if self.wildcard else "/accounts", payload=payload)
883
+ address = str(data.get("address") or data.get("email") or "").strip()
884
+ token = str(data.get("token") or data.get("temp_token") or data.get("tempToken") or data.get("access_token") or "").strip()
885
+ if not address or not token:
886
+ raise RuntimeError("YYDSMail 缺少 address 或 token")
887
+ return {"provider": self.name, "provider_ref": self.provider_ref, "address": address, "token": token, "account_id": str(data.get("id") or "")}
888
+
889
+ def fetch_latest_message(self, mailbox: dict[str, Any]) -> dict[str, Any] | None:
890
+ data = self._request("GET", "/messages", token=str(mailbox.get("token") or ""), params={"address": mailbox["address"]})
891
+ messages = [item for item in self._items(data) if isinstance(item, dict)]
892
+ if not messages:
893
+ return None
894
+ item = max(messages, key=lambda value: ((_parse_received_at(value.get("createdAt") or value.get("created_at") or value.get("receivedAt") or value.get("date") or value.get("timestamp")) or datetime.fromtimestamp(0, tz=timezone.utc)).timestamp(), str(value.get("id") or "")))
895
+ message_id = str(item.get("id") or item.get("message_id") or "").strip()
896
+ if message_id:
897
+ item = self._request("GET", f"/messages/{message_id}", token=str(mailbox.get("token") or ""), params={"address": mailbox["address"]})
898
+ text_content, html_content = _extract_content(item)
899
+ sender = item.get("from") or item.get("sender") or ""
900
+ if isinstance(sender, dict):
901
+ sender = sender.get("address") or sender.get("email") or sender.get("name") or ""
902
+ return {"provider": self.name, "mailbox": mailbox["address"], "message_id": message_id, "subject": str(item.get("subject") or ""), "sender": str(sender), "text_content": text_content, "html_content": html_content, "received_at": _parse_received_at(item.get("createdAt") or item.get("created_at") or item.get("receivedAt") or item.get("date") or item.get("timestamp")), "raw": item}
903
+
904
+ def close(self) -> None:
905
+ self.session.close()
906
+
907
+
908
+ def _entries(mail_config: dict) -> list[dict]:
909
+ result: list[dict] = []
910
+ counters: dict[str, int] = {}
911
+ for item in mail_config["providers"]:
912
+ idx = len(result) + 1
913
+ t = item.get("type", "")
914
+ cnt = counters.get(t, 0) + 1
915
+ counters[t] = cnt
916
+ label = f"DDG-{cnt}" if t == "ddg_mail" else f"{t}#{idx}"
917
+ result.append({**item, "provider_ref": f"{item['type']}#{idx}", "label": label})
918
+ return result
919
+
920
+
921
+ def _enabled_entries(mail_config: dict) -> list[dict]:
922
+ items = [item for item in _entries(mail_config) if item.get("enable")]
923
+ if not items:
924
+ raise RuntimeError("mail.providers 没有启用的 provider")
925
+ return items
926
+
927
+
928
+ def _next_entry(mail_config: dict) -> dict:
929
+ global provider_index
930
+ items = _enabled_entries(mail_config)
931
+ if len(items) == 1:
932
+ return dict(items[0])
933
+ with provider_lock:
934
+ value = dict(items[provider_index % len(items)])
935
+ provider_index = (provider_index + 1) % len(items)
936
+ return value
937
+
938
+
939
+ def _create_provider(mail_config: dict, provider: str = "", provider_ref: str = "") -> BaseMailProvider:
940
+ entry = next((dict(item) for item in _entries(mail_config) if provider_ref and item["provider_ref"] == provider_ref), None)
941
+ entry = entry or next((dict(item) for item in _enabled_entries(mail_config) if provider and item["type"] == provider), None) or _next_entry(mail_config)
942
+ conf = _config(mail_config)
943
+ if entry["type"] == "cloudmail_gen":
944
+ return CloudMailGenProvider(entry, conf)
945
+ if entry["type"] == "cloudflare_temp_email":
946
+ return CloudflareTempMailProvider(entry, conf)
947
+ if entry["type"] == "ddg_mail":
948
+ return DDGMailProvider(entry, conf)
949
+ if entry["type"] == "tempmail_lol":
950
+ return TempMailLolProvider(entry, conf)
951
+ if entry["type"] == "duckmail":
952
+ return DuckMailProvider(entry, conf)
953
+ if entry["type"] == "gptmail":
954
+ return GptMailProvider(entry, conf)
955
+ if entry["type"] == "moemail":
956
+ return MoEmailProvider(entry, conf)
957
+ if entry["type"] == "inbucket":
958
+ return InbucketMailProvider(entry, conf)
959
+ if entry["type"] == "yyds_mail":
960
+ return YydsMailProvider(entry, conf)
961
+ raise RuntimeError(f"不支持的 mail.provider: {entry['type']}")
962
+
963
+
964
+ def create_mailbox(mail_config: dict, username: str | None = None) -> dict:
965
+ enabled = _enabled_entries(mail_config)
966
+ tried: set[str] = set()
967
+ last_error = ""
968
+ for _ in range(len(enabled)):
969
+ provider = _create_provider(mail_config)
970
+ provider_key = f"{provider.name}#{provider.provider_ref}"
971
+ try:
972
+ if provider_key in tried:
973
+ continue
974
+ tried.add(provider_key)
975
+ mailbox = provider.create_mailbox(username)
976
+ return mailbox
977
+ except RuntimeError as error:
978
+ last_error = str(error)
979
+ if "DDG日上限已达" not in last_error:
980
+ raise
981
+ finally:
982
+ provider.close()
983
+ raise RuntimeError(last_error or "所有启用的邮箱提供商均无法创建邮箱")
984
+
985
+
986
+ def wait_for_code(mail_config: dict, mailbox: dict) -> str | None:
987
+ provider = _create_provider(mail_config, str(mailbox.get("provider") or ""), str(mailbox.get("provider_ref") or ""))
988
+ try:
989
+ return provider.wait_for_code(mailbox)
990
+ finally:
991
+ provider.close()
services/register/openai_register.py ADDED
@@ -0,0 +1,766 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import json
6
+ import random
7
+ import secrets
8
+ import string
9
+ import threading
10
+ import time
11
+ import uuid
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+ from typing import Any
15
+ from urllib.parse import parse_qs, urlencode, urlparse
16
+
17
+ import requests
18
+ import urllib3
19
+ from curl_cffi import requests as curl_requests
20
+ from requests.adapters import HTTPAdapter
21
+ from urllib3.util.retry import Retry
22
+
23
+ from services.account_service import account_service
24
+ from services.register import mail_provider
25
+
26
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
27
+ base_dir = Path(__file__).resolve().parent
28
+ config = {
29
+ "mail": {
30
+ "request_timeout": 30,
31
+ "wait_timeout": 30,
32
+ "wait_interval": 2,
33
+ "providers": [],
34
+ },
35
+ "proxy": "",
36
+ "total": 10,
37
+ "threads": 3,
38
+ }
39
+ register_config_file = base_dir.parents[1] / "data" / "register.json"
40
+ try:
41
+ saved_config = json.loads(register_config_file.read_text(encoding="utf-8"))
42
+ config.update({key: saved_config[key] for key in ("mail", "proxy", "total", "threads") if key in saved_config})
43
+ except Exception:
44
+ pass
45
+
46
+ auth_base = "https://auth.openai.com"
47
+ platform_base = "https://platform.openai.com"
48
+ platform_oauth_client_id = "app_2SKx67EdpoN0G6j64rFvigXD"
49
+ platform_oauth_redirect_uri = f"{platform_base}/auth/callback"
50
+ platform_oauth_audience = "https://api.openai.com/v1"
51
+ platform_auth0_client = "eyJuYW1lIjoiYXV0aDAtc3BhLWpzIiwidmVyc2lvbiI6IjEuMjEuMCJ9"
52
+ user_agent = (
53
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
54
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
55
+ "Chrome/145.0.0.0 Safari/537.36"
56
+ )
57
+ sec_ch_ua = '"Google Chrome";v="145", "Not?A_Brand";v="8", "Chromium";v="145"'
58
+ sec_ch_ua_full_version_list = '"Chromium";v="145.0.0.0", "Not:A-Brand";v="99.0.0.0", "Google Chrome";v="145.0.0.0"'
59
+ default_timeout = 30
60
+ print_lock = threading.Lock()
61
+ stats_lock = threading.Lock()
62
+ stats = {"done": 0, "success": 0, "fail": 0, "start_time": 0.0}
63
+ register_log_sink = None
64
+
65
+ common_headers = {
66
+ "accept": "application/json",
67
+ "accept-language": "en-US,en;q=0.9",
68
+ "content-type": "application/json",
69
+ "origin": auth_base,
70
+ "priority": "u=1, i",
71
+ "user-agent": user_agent,
72
+ "sec-ch-ua": sec_ch_ua,
73
+ "sec-ch-ua-arch": '"x86_64"',
74
+ "sec-ch-ua-bitness": '"64"',
75
+ "sec-ch-ua-full-version-list": sec_ch_ua_full_version_list,
76
+ "sec-ch-ua-mobile": "?0",
77
+ "sec-ch-ua-model": '""',
78
+ "sec-ch-ua-platform": '"Windows"',
79
+ "sec-ch-ua-platform-version": '"10.0.0"',
80
+ "sec-fetch-dest": "empty",
81
+ "sec-fetch-mode": "cors",
82
+ "sec-fetch-site": "same-origin",
83
+ }
84
+
85
+ navigate_headers = {
86
+ "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
87
+ "accept-language": "en-US,en;q=0.9",
88
+ "user-agent": user_agent,
89
+ "sec-ch-ua": sec_ch_ua,
90
+ "sec-ch-ua-arch": '"x86_64"',
91
+ "sec-ch-ua-bitness": '"64"',
92
+ "sec-ch-ua-full-version-list": sec_ch_ua_full_version_list,
93
+ "sec-ch-ua-mobile": "?0",
94
+ "sec-ch-ua-model": '""',
95
+ "sec-ch-ua-platform": '"Windows"',
96
+ "sec-ch-ua-platform-version": '"10.0.0"',
97
+ "sec-fetch-dest": "document",
98
+ "sec-fetch-mode": "navigate",
99
+ "sec-fetch-site": "same-origin",
100
+ "sec-fetch-user": "?1",
101
+ "upgrade-insecure-requests": "1",
102
+ }
103
+
104
+
105
+ def log(text: str, color: str = "") -> None:
106
+ colors = {"red": "\033[31m", "green": "\033[32m", "yellow": "\033[33m"}
107
+ if register_log_sink:
108
+ try:
109
+ register_log_sink(text, color)
110
+ except Exception:
111
+ pass
112
+ with print_lock:
113
+ prefix = colors.get(color, "")
114
+ suffix = "\033[0m" if prefix else ""
115
+ print(f"{prefix}{datetime.now().strftime('%H:%M:%S')} {text}{suffix}")
116
+
117
+
118
+ def step(index: int, text: str, color: str = "") -> None:
119
+ log(f"[任务{index}] {text}", color)
120
+
121
+
122
+ def _make_trace_headers() -> dict[str, str]:
123
+ trace_id = str(random.getrandbits(64))
124
+ parent_id = str(random.getrandbits(64))
125
+ return {
126
+ "traceparent": f"00-{uuid.uuid4().hex}-{format(int(parent_id), '016x')}-01",
127
+ "tracestate": "dd=s:1;o:rum",
128
+ "x-datadog-origin": "rum",
129
+ "x-datadog-parent-id": parent_id,
130
+ "x-datadog-sampling-priority": "1",
131
+ "x-datadog-trace-id": trace_id,
132
+ }
133
+
134
+
135
+ def _generate_pkce() -> tuple[str, str]:
136
+ code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(64)).rstrip(b"=").decode("ascii")
137
+ code_challenge = base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode("ascii")).digest()).rstrip(b"=").decode("ascii")
138
+ return code_verifier, code_challenge
139
+
140
+
141
+ def _random_password(length: int = 16) -> str:
142
+ chars = string.ascii_letters + string.digits + "!@#$%"
143
+ value = list(
144
+ secrets.choice(string.ascii_uppercase)
145
+ + secrets.choice(string.ascii_lowercase)
146
+ + secrets.choice(string.digits)
147
+ + secrets.choice("!@#$%")
148
+ + "".join(secrets.choice(chars) for _ in range(max(0, length - 4)))
149
+ )
150
+ random.shuffle(value)
151
+ return "".join(value)
152
+
153
+
154
+ def _random_name() -> tuple[str, str]:
155
+ return random.choice(["James", "Robert", "John", "Michael", "David", "Mary", "Emma", "Olivia"]), random.choice(
156
+ ["Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller"]
157
+ )
158
+
159
+
160
+ def _random_birthdate() -> str:
161
+ return f"{random.randint(1996, 2006):04d}-{random.randint(1, 12):02d}-{random.randint(1, 28):02d}"
162
+
163
+
164
+ def _response_json(resp) -> dict:
165
+ try:
166
+ data = resp.json()
167
+ return data if isinstance(data, dict) else {}
168
+ except Exception:
169
+ return {}
170
+
171
+
172
+ def _decode_jwt_payload(token: str) -> dict:
173
+ try:
174
+ payload = token.split(".")[1]
175
+ padding = 4 - len(payload) % 4
176
+ if padding != 4:
177
+ payload += "=" * padding
178
+ return json.loads(base64.urlsafe_b64decode(payload))
179
+ except Exception:
180
+ return {}
181
+
182
+
183
+ def create_mailbox(username: str | None = None) -> dict:
184
+ return mail_provider.create_mailbox(config["mail"], username)
185
+
186
+
187
+ def wait_for_code(mailbox: dict) -> str | None:
188
+ return mail_provider.wait_for_code(config["mail"], mailbox)
189
+
190
+
191
+ class SentinelTokenGenerator:
192
+ MAX_ATTEMPTS = 500000
193
+ ERROR_PREFIX = "wQ8Lk5FbGpA2NcR9dShT6gYjU7VxZ4D"
194
+
195
+ def __init__(self, device_id: str, ua: str):
196
+ self.device_id = device_id
197
+ self.user_agent = ua
198
+ self.sid = str(uuid.uuid4())
199
+
200
+ @staticmethod
201
+ def _fnv1a_32(text: str) -> str:
202
+ h = 2166136261
203
+ for ch in text:
204
+ h ^= ord(ch)
205
+ h = (h * 16777619) & 0xFFFFFFFF
206
+ h ^= h >> 16
207
+ h = (h * 2246822507) & 0xFFFFFFFF
208
+ h ^= h >> 13
209
+ h = (h * 3266489909) & 0xFFFFFFFF
210
+ h ^= h >> 16
211
+ return format(h & 0xFFFFFFFF, "08x")
212
+
213
+ def _get_config(self) -> list:
214
+ perf_now = random.uniform(1000, 50000)
215
+ return [
216
+ "1920x1080",
217
+ time.strftime("%a %b %d %Y %H:%M:%S GMT+0000 (Coordinated Universal Time)", time.gmtime()),
218
+ 4294705152,
219
+ random.random(),
220
+ self.user_agent,
221
+ "https://sentinel.openai.com/sentinel/20260124ceb8/sdk.js",
222
+ None,
223
+ None,
224
+ "en-US",
225
+ random.random(),
226
+ random.choice(["vendorSub-undefined", "plugins-undefined", "mimeTypes-undefined", "hardwareConcurrency-undefined"]),
227
+ random.choice(["location", "implementation", "URL", "documentURI", "compatMode"]),
228
+ random.choice(["Object", "Function", "Array", "Number", "parseFloat", "undefined"]),
229
+ perf_now,
230
+ self.sid,
231
+ "",
232
+ random.choice([4, 8, 12, 16]),
233
+ time.time() * 1000 - perf_now,
234
+ ]
235
+
236
+ @staticmethod
237
+ def _b64(data) -> str:
238
+ return base64.b64encode(json.dumps(data, separators=(",", ":"), ensure_ascii=False).encode("utf-8")).decode("ascii")
239
+
240
+ def generate_requirements_token(self) -> str:
241
+ data = self._get_config()
242
+ data[3] = 1
243
+ data[9] = round(random.uniform(5, 50))
244
+ return "gAAAAAC" + self._b64(data)
245
+
246
+ def generate_token(self, seed: str, difficulty: str) -> str:
247
+ start = time.time()
248
+ data = self._get_config()
249
+ difficulty = str(difficulty or "0")
250
+ for i in range(self.MAX_ATTEMPTS):
251
+ data[3] = i
252
+ data[9] = round((time.time() - start) * 1000)
253
+ payload = self._b64(data)
254
+ if self._fnv1a_32(seed + payload)[: len(difficulty)] <= difficulty:
255
+ return "gAAAAAB" + payload + "~S"
256
+ return "gAAAAAB" + self.ERROR_PREFIX + self._b64(str(None))
257
+
258
+
259
+ def build_sentinel_token(session: requests.Session, device_id: str, flow: str) -> str:
260
+ generator = SentinelTokenGenerator(device_id, user_agent)
261
+ resp = session.post(
262
+ "https://sentinel.openai.com/backend-api/sentinel/req",
263
+ data=json.dumps({"p": generator.generate_requirements_token(), "id": device_id, "flow": flow}),
264
+ headers={
265
+ "Content-Type": "text/plain;charset=UTF-8",
266
+ "Referer": "https://sentinel.openai.com/backend-api/sentinel/frame.html",
267
+ "Origin": "https://sentinel.openai.com",
268
+ "User-Agent": user_agent,
269
+ "sec-ch-ua": sec_ch_ua,
270
+ "sec-ch-ua-mobile": "?0",
271
+ "sec-ch-ua-platform": '"Windows"',
272
+ },
273
+ timeout=20,
274
+ verify=False,
275
+ )
276
+ data = _response_json(resp)
277
+ token = str(data.get("token") or "").strip()
278
+ if resp.status_code != 200 or not token:
279
+ raise RuntimeError(f"sentinel_req_failed_{resp.status_code}")
280
+ pow_data = data.get("proofofwork") or {}
281
+ p_value = (
282
+ generator.generate_token(str(pow_data.get("seed") or ""), str(pow_data.get("difficulty") or "0"))
283
+ if pow_data.get("required") and pow_data.get("seed")
284
+ else generator.generate_requirements_token()
285
+ )
286
+ return json.dumps({"p": p_value, "t": "", "c": token, "id": device_id, "flow": flow}, separators=(",", ":"))
287
+
288
+
289
+ def _is_socks_proxy(proxy: str) -> bool:
290
+ candidate = str(proxy or "").strip().lower()
291
+ return candidate.startswith("socks5://") or candidate.startswith("socks5h://")
292
+
293
+
294
+ def create_session(proxy: str = "") -> Any:
295
+ if _is_socks_proxy(proxy):
296
+ return curl_requests.Session(impersonate="chrome", verify=False, proxy=proxy)
297
+ session = requests.Session()
298
+ retry = Retry(total=2, connect=2, read=2, backoff_factor=0.5, status_forcelist=(429, 500, 502, 503, 504))
299
+ adapter = HTTPAdapter(max_retries=retry, pool_connections=50, pool_maxsize=50)
300
+ session.mount("http://", adapter)
301
+ session.mount("https://", adapter)
302
+ session.verify = False
303
+ if proxy:
304
+ session.proxies.update({"http": proxy, "https": proxy})
305
+ return session
306
+
307
+
308
+ def request_with_local_retry(session: requests.Session, method: str, url: str, retry_attempts: int = 3, **kwargs):
309
+ last_error = ""
310
+ for _ in range(max(1, retry_attempts)):
311
+ try:
312
+ return session.request(method.upper(), url, timeout=default_timeout, **kwargs), ""
313
+ except Exception as error:
314
+ last_error = str(error)
315
+ time.sleep(1)
316
+ return None, last_error
317
+
318
+
319
+ def validate_otp(session: requests.Session, device_id: str, code: str):
320
+ headers = dict(common_headers)
321
+ headers["referer"] = f"{auth_base}/email-verification"
322
+ headers["oai-device-id"] = device_id
323
+ headers.update(_make_trace_headers())
324
+ resp, error = request_with_local_retry(session, "post", f"{auth_base}/api/accounts/email-otp/validate", json={"code": code}, headers=headers, verify=False)
325
+ if resp is not None and resp.status_code == 200:
326
+ return resp, ""
327
+ headers["openai-sentinel-token"] = build_sentinel_token(session, device_id, "authorize_continue")
328
+ resp, error = request_with_local_retry(session, "post", f"{auth_base}/api/accounts/email-otp/validate", json={"code": code}, headers=headers, verify=False)
329
+ return resp, error
330
+
331
+
332
+ def extract_oauth_callback_params_from_url(url: str) -> dict[str, str] | None:
333
+ if not url:
334
+ return None
335
+ try:
336
+ params = parse_qs(urlparse(url).query)
337
+ except Exception:
338
+ return None
339
+ code = str((params.get("code") or [""])[0]).strip()
340
+ if not code:
341
+ return None
342
+ return {"code": code, "state": str((params.get("state") or [""])[0]).strip(), "scope": str((params.get("scope") or [""])[0]).strip()}
343
+
344
+
345
+ def extract_oauth_callback_params_from_consent_session(session: requests.Session, consent_url: str, device_id: str) -> dict[str, str] | None:
346
+ if consent_url.startswith("/"):
347
+ consent_url = f"{auth_base}{consent_url}"
348
+ current_url = consent_url
349
+ for _ in range(10):
350
+ response = session.get(current_url, headers=navigate_headers, verify=False, timeout=30, allow_redirects=False)
351
+ callback_params = extract_oauth_callback_params_from_url(str(response.url)) or extract_oauth_callback_params_from_url(str(response.headers.get("Location") or "").strip())
352
+ if callback_params:
353
+ return callback_params
354
+ location = str(response.headers.get("Location") or "").strip()
355
+ if response.status_code not in (301, 302, 303, 307, 308) or not location:
356
+ break
357
+ current_url = f"{auth_base}{location}" if location.startswith("/") else location
358
+ raw = session.cookies.get("oai-client-auth-session", domain=".auth.openai.com") or session.cookies.get("oai-client-auth-session")
359
+ if not raw:
360
+ return None
361
+ try:
362
+ first_part = raw.split(".")[0]
363
+ padding = 4 - len(first_part) % 4
364
+ if padding != 4:
365
+ first_part += "=" * padding
366
+ payload = json.loads(base64.urlsafe_b64decode(first_part))
367
+ workspace_id = payload["workspaces"][0]["id"]
368
+ except Exception:
369
+ return None
370
+ headers = dict(common_headers)
371
+ headers["referer"] = consent_url
372
+ headers["oai-device-id"] = device_id
373
+ headers.update(_make_trace_headers())
374
+ ws_resp = session.post(f"{auth_base}/api/accounts/workspace/select", json={"workspace_id": workspace_id}, headers=headers, verify=False, timeout=30, allow_redirects=False)
375
+ callback_params = extract_oauth_callback_params_from_url(str(ws_resp.headers.get("Location") or "").strip())
376
+ if callback_params:
377
+ return callback_params
378
+ ws_data = _response_json(ws_resp)
379
+ orgs = ((ws_data.get("data") or {}).get("orgs") or []) if isinstance(ws_data, dict) else []
380
+ if not orgs:
381
+ return None
382
+ org_id = str((orgs[0] or {}).get("id") or "").strip()
383
+ project_id = str(((orgs[0] or {}).get("projects") or [{}])[0].get("id") or "").strip()
384
+ if not org_id:
385
+ return None
386
+ org_headers = dict(common_headers)
387
+ org_headers["referer"] = str(ws_data.get("continue_url") or consent_url)
388
+ org_headers["oai-device-id"] = device_id
389
+ org_headers.update(_make_trace_headers())
390
+ body = {"org_id": org_id}
391
+ if project_id:
392
+ body["project_id"] = project_id
393
+ org_resp = session.post(f"{auth_base}/api/accounts/organization/select", json=body, headers=org_headers, verify=False, timeout=30, allow_redirects=False)
394
+ return extract_oauth_callback_params_from_url(str(org_resp.headers.get("Location") or "").strip())
395
+
396
+
397
+ def exchange_platform_tokens(session: requests.Session, device_id: str, code_verifier: str, consent_url: str) -> dict | None:
398
+ callback_params = extract_oauth_callback_params_from_consent_session(session, consent_url, device_id)
399
+ if not callback_params:
400
+ # 回退方案:直接导航 consent URL(allow_redirects=True),从最终 URL 提取 code
401
+ print(f"[exchange_platform_tokens] 主方案失败,尝试回退方案, continue_url={consent_url[:120]}")
402
+ try:
403
+ r = session.get(consent_url, headers=navigate_headers, allow_redirects=True, verify=False, timeout=30)
404
+ final_url = str(r.url)
405
+ print(f"[exchange_platform_tokens] 回退 final_url={final_url[:120]}")
406
+ callback_params = extract_oauth_callback_params_from_url(final_url)
407
+ if not callback_params:
408
+ for hist in getattr(r, "history", []) or []:
409
+ loc = str(hist.headers.get("Location") or "")
410
+ callback_params = extract_oauth_callback_params_from_url(loc)
411
+ if callback_params:
412
+ break
413
+ except Exception as e:
414
+ print(f"[exchange_platform_tokens] 回退方案异常: {e}")
415
+ if not callback_params:
416
+ print("[exchange_platform_tokens] 所有方案均无法提取 OAuth code")
417
+ return None
418
+ code = str(callback_params.get("code") or "").strip()
419
+ if not code:
420
+ return None
421
+ resp = create_session(config["proxy"]).post(
422
+ f"{auth_base}/oauth/token",
423
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
424
+ data={
425
+ "grant_type": "authorization_code",
426
+ "code": code,
427
+ "redirect_uri": platform_oauth_redirect_uri,
428
+ "client_id": platform_oauth_client_id,
429
+ "code_verifier": code_verifier,
430
+ },
431
+ verify=False,
432
+ timeout=60,
433
+ )
434
+ data = _response_json(resp)
435
+ if resp.status_code != 200 or not data.get("access_token") or not data.get("refresh_token") or not data.get("id_token"):
436
+ return None
437
+ payload = _decode_jwt_payload(str(data.get("id_token") or "")) or _decode_jwt_payload(str(data.get("access_token") or ""))
438
+ return {
439
+ "email": str(payload.get("email") or "").strip(),
440
+ "access_token": str(data.get("access_token") or "").strip(),
441
+ "refresh_token": str(data.get("refresh_token") or "").strip(),
442
+ "id_token": str(data.get("id_token") or "").strip(),
443
+ }
444
+
445
+
446
+ class PlatformRegistrar:
447
+ def __init__(self, proxy: str = "") -> None:
448
+ self.session = create_session(proxy)
449
+ self.device_id = str(uuid.uuid4())
450
+
451
+ def close(self) -> None:
452
+ self.session.close()
453
+
454
+ def _navigate_headers(self, referer: str = "") -> dict[str, str]:
455
+ headers = dict(navigate_headers)
456
+ if referer:
457
+ headers["referer"] = referer
458
+ return headers
459
+
460
+ def _json_headers(self, referer: str) -> dict[str, str]:
461
+ headers = dict(common_headers)
462
+ headers["referer"] = referer
463
+ headers["oai-device-id"] = self.device_id
464
+ headers.update(_make_trace_headers())
465
+ return headers
466
+
467
+ def _platform_authorize(self, email: str, index: int) -> None:
468
+ step(index, "开始 platform authorize")
469
+ self.session.cookies.set("oai-did", self.device_id, domain=".auth.openai.com")
470
+ self.session.cookies.set("oai-did", self.device_id, domain="auth.openai.com")
471
+ _, code_challenge = _generate_pkce()
472
+ params = {
473
+ "issuer": auth_base,
474
+ "client_id": platform_oauth_client_id,
475
+ "audience": platform_oauth_audience,
476
+ "redirect_uri": platform_oauth_redirect_uri,
477
+ "device_id": self.device_id,
478
+ "screen_hint": "login_or_signup",
479
+ "max_age": "0",
480
+ "login_hint": email,
481
+ "scope": "openid profile email offline_access",
482
+ "response_type": "code",
483
+ "response_mode": "query",
484
+ "state": secrets.token_urlsafe(32),
485
+ "nonce": secrets.token_urlsafe(32),
486
+ "code_challenge": code_challenge,
487
+ "code_challenge_method": "S256",
488
+ "auth0Client": platform_auth0_client,
489
+ }
490
+ resp, error = request_with_local_retry(self.session, "get", f"{auth_base}/api/accounts/authorize?{urlencode(params)}", headers=self._navigate_headers(f"{platform_base}/"), allow_redirects=True, verify=False)
491
+ if resp is None or resp.status_code != 200:
492
+ err = _response_json(resp).get("error", {}) if resp is not None else {}
493
+ detail = f": {err.get('code', '')} - {err.get('message', '')}".strip(" -") if err else ""
494
+ raise RuntimeError(error or f"platform_authorize_http_{getattr(resp, 'status_code', 'unknown')}{detail}")
495
+ step(index, "platform authorize 完成")
496
+
497
+ def _register_user(self, email: str, password: str, index: int) -> None:
498
+ step(index, "开始提交注册密码")
499
+ headers = self._json_headers(f"{auth_base}/create-account/password")
500
+ headers["openai-sentinel-token"] = build_sentinel_token(self.session, self.device_id, "username_password_create")
501
+ resp, error = request_with_local_retry(self.session, "post", f"{auth_base}/api/accounts/user/register", json={"username": email, "password": password}, headers=headers, verify=False)
502
+ if resp is None or resp.status_code != 200:
503
+ data = _response_json(resp) if resp is not None else {}
504
+ if data.get("message") == "Failed to create account. Please try again.":
505
+ step(index, "注册失败提示: 邮箱域名很可能因滥用被封禁,请更换邮箱域名", "yellow")
506
+ detail = f", detail={json.dumps(data, ensure_ascii=False)}" if data else ""
507
+ raise RuntimeError(error or f"user_register_http_{getattr(resp, 'status_code', 'unknown')}{detail}")
508
+ step(index, "提交注册密码完成")
509
+
510
+ def _send_otp(self, index: int) -> None:
511
+ step(index, "开始发送验证码")
512
+ resp, error = request_with_local_retry(self.session, "get", f"{auth_base}/api/accounts/email-otp/send", headers=self._navigate_headers(f"{auth_base}/create-account/password"), allow_redirects=True, verify=False)
513
+ if resp is None or resp.status_code not in (200, 302):
514
+ raise RuntimeError(error or f"send_otp_http_{getattr(resp, 'status_code', 'unknown')}")
515
+ step(index, "发送验证码完成")
516
+
517
+ def _validate_otp(self, code: str, index: int) -> None:
518
+ step(index, f"开始校验验证码 {code}")
519
+ resp, error = validate_otp(self.session, self.device_id, code)
520
+ if resp is None or resp.status_code != 200:
521
+ body = ""
522
+ try:
523
+ body = (resp.text or "")[:500] if resp is not None else ""
524
+ except Exception:
525
+ pass
526
+ raise RuntimeError(error or f"validate_otp_http_{getattr(resp, 'status_code', 'unknown')}_body={body}")
527
+ step(index, "验证码校验完成")
528
+
529
+ def _create_account(self, name: str, birthdate: str, index: int) -> None:
530
+ step(index, "开始创建账号资料")
531
+ headers = self._json_headers(f"{auth_base}/about-you")
532
+ headers["openai-sentinel-token"] = build_sentinel_token(self.session, self.device_id, "oauth_create_account")
533
+ resp, error = request_with_local_retry(self.session, "post", f"{auth_base}/api/accounts/create_account", json={"name": name, "birthdate": birthdate}, headers=headers, verify=False)
534
+ if resp is None or resp.status_code not in (200, 302):
535
+ data = _response_json(resp) if resp is not None else {}
536
+ if data.get("message") == "Failed to create account. Please try again.":
537
+ step(index, "创建账号失败提示: 邮箱域名很可能因滥用被封禁,请更换邮箱域名", "yellow")
538
+ detail = f", detail={json.dumps(data, ensure_ascii=False)}" if data else ""
539
+ raise RuntimeError(error or f"create_account_http_{getattr(resp, 'status_code', 'unknown')}{detail}")
540
+ step(index, "创建账号资料完成")
541
+
542
+ def _login_and_exchange_tokens(self, email: str, password: str, mailbox: dict, index: int) -> dict:
543
+ step(index, "开始独立登录换 token")
544
+ login_session = create_session(config["proxy"])
545
+ login_device_id = str(uuid.uuid4())
546
+ login_session.cookies.set("oai-did", login_device_id, domain=".auth.openai.com")
547
+ login_session.cookies.set("oai-did", login_device_id, domain="auth.openai.com")
548
+ code_verifier, code_challenge = _generate_pkce()
549
+ params = {
550
+ "issuer": auth_base,
551
+ "client_id": platform_oauth_client_id,
552
+ "audience": platform_oauth_audience,
553
+ "redirect_uri": platform_oauth_redirect_uri,
554
+ "device_id": login_device_id,
555
+ "screen_hint": "login_or_signup",
556
+ "max_age": "0",
557
+ "login_hint": email,
558
+ "scope": "openid profile email offline_access",
559
+ "response_type": "code",
560
+ "response_mode": "query",
561
+ "state": secrets.token_urlsafe(32),
562
+ "nonce": secrets.token_urlsafe(32),
563
+ "code_challenge": code_challenge,
564
+ "code_challenge_method": "S256",
565
+ "auth0Client": platform_auth0_client,
566
+ }
567
+
568
+ def _login_nav_headers(referer: str = "") -> dict[str, str]:
569
+ h = dict(navigate_headers)
570
+ if referer:
571
+ h["referer"] = referer
572
+ return h
573
+
574
+ def _login_json_headers(referer: str) -> dict[str, str]:
575
+ h = dict(common_headers)
576
+ h["referer"] = referer
577
+ h["oai-device-id"] = login_device_id
578
+ h.update(_make_trace_headers())
579
+ return h
580
+
581
+ def _clear_login_auth_cookies() -> None:
582
+ for cookie in list(login_session.cookies):
583
+ if "auth.openai.com" in str(cookie.domain):
584
+ login_session.cookies.clear(domain=cookie.domain, path=cookie.path, name=cookie.name)
585
+ login_session.cookies.set("oai-did", login_device_id, domain=".auth.openai.com")
586
+ login_session.cookies.set("oai-did", login_device_id, domain="auth.openai.com")
587
+
588
+ def _do_login_authorize(label: str) -> None:
589
+ nonlocal resp, error
590
+ resp, error = request_with_local_retry(
591
+ login_session, "get",
592
+ f"{auth_base}/api/accounts/authorize?{urlencode(params)}",
593
+ headers=_login_nav_headers(f"{platform_base}/"),
594
+ allow_redirects=True, verify=False
595
+ )
596
+ if resp is None:
597
+ raise RuntimeError(error or f"platform_login_authorize_{label}_failed")
598
+ if resp.status_code not in (200, 302):
599
+ raise RuntimeError(error or f"platform_login_authorize_{label}_http_{getattr(resp, 'status_code', 'unknown')}")
600
+ step(index, "登录 authorize 完成" if label == "initial" else f"登录 authorize 重试完成[{label}]")
601
+
602
+ # 提交邮箱(原样,不带 state)
603
+ def _do_authorize_continue():
604
+ h = _login_json_headers(f"{auth_base}/log-in?usernameKind=email")
605
+ h["openai-sentinel-token"] = build_sentinel_token(login_session, login_device_id, "authorize_continue")
606
+ return request_with_local_retry(
607
+ login_session, "post",
608
+ f"{auth_base}/api/accounts/authorize/continue",
609
+ json={"username": {"kind": "email", "value": email}},
610
+ headers=h,
611
+ allow_redirects=False,
612
+ verify=False
613
+ )
614
+
615
+ def _submit_email_with_reauth() -> None:
616
+ nonlocal resp, error
617
+ step(index, "开始提交邮箱")
618
+ for attempt in range(3):
619
+ if attempt:
620
+ step(index, f"邮箱提交 409/状态失效,清理 cookie 后重新 authorize 重试({attempt + 1}/3)", "yellow")
621
+ _clear_login_auth_cookies()
622
+ _do_login_authorize(f"email-{attempt + 1}")
623
+ resp, error = _do_authorize_continue()
624
+ if resp is not None and resp.status_code == 409:
625
+ continue
626
+ break
627
+ if resp is None or resp.status_code != 200:
628
+ data = _response_json(resp) if resp is not None else {}
629
+ detail = json.dumps(data, ensure_ascii=False) if data else ""
630
+ raise RuntimeError(
631
+ error or f"email_submit_http_{getattr(resp, 'status_code', 'unknown')}"
632
+ + (f": {detail}" if detail else "")
633
+ )
634
+ step(index, "邮箱提交完成")
635
+
636
+ def _verify_password_once():
637
+ headers = _login_json_headers(f"{auth_base}/log-in/password")
638
+ headers["openai-sentinel-token"] = build_sentinel_token(
639
+ login_session, login_device_id, "password_verify"
640
+ )
641
+ return request_with_local_retry(
642
+ login_session, "post",
643
+ f"{auth_base}/api/accounts/password/verify",
644
+ json={"password": password},
645
+ headers=headers,
646
+ allow_redirects=False,
647
+ verify=False
648
+ )
649
+
650
+ def _verify_password_with_reauth() -> None:
651
+ nonlocal resp, error
652
+ step(index, "开始密码校验")
653
+ for attempt in range(3):
654
+ if attempt:
655
+ step(index, f"密码校验 HTTP 409,重新登录状态后重试({attempt + 1}/3)", "yellow")
656
+ _clear_login_auth_cookies()
657
+ _do_login_authorize(f"password-{attempt + 1}")
658
+ _submit_email_with_reauth()
659
+ resp, error = _verify_password_once()
660
+ if resp is not None and resp.status_code == 409:
661
+ continue
662
+ break
663
+ if resp is None or resp.status_code != 200:
664
+ body = ""
665
+ try:
666
+ body = (resp.text or "")[:500] if resp is not None else ""
667
+ except Exception:
668
+ pass
669
+ raise RuntimeError(error or f"password_verify_http_{getattr(resp, 'status_code', '')}_body={body}")
670
+ step(index, "密码校验完成")
671
+
672
+ resp = None
673
+ error = ""
674
+ _do_login_authorize("initial")
675
+ _submit_email_with_reauth()
676
+ _verify_password_with_reauth()
677
+
678
+ payload = _response_json(resp)
679
+ continue_url = str(payload.get("continue_url") or "").strip()
680
+ page_type = str(((payload.get("page") or {}).get("type")) or "")
681
+
682
+ if page_type == "email_otp_verification" or "email-verification" in continue_url or "email-otp" in continue_url:
683
+ step(index, "独立登录需要邮箱验证码")
684
+ code = wait_for_code(mailbox)
685
+ if not code:
686
+ login_session.close()
687
+ raise RuntimeError("独立登录等待验证码超时")
688
+ step(index, f"收到登录验证码: {code}")
689
+ resp, reason = validate_otp(login_session, login_device_id, code)
690
+ if resp is None or resp.status_code != 200:
691
+ print("独立登录验证码校验失败响应:", resp.text if resp is not None else "None")
692
+ data = _response_json(resp) if resp is not None else {}
693
+ message = str((data.get("error") or {}).get("message") or data.get("message") or "").strip()
694
+ login_session.close()
695
+ raise RuntimeError(reason or f"独立登录验证码校验失败{': ' + message if message else ''}")
696
+ otp_payload = _response_json(resp)
697
+ continue_url = str(otp_payload.get("continue_url") or continue_url).strip()
698
+ step(index, "独立登录验证码校验完成")
699
+
700
+ if not continue_url:
701
+ continue_url = f"{auth_base}/sign-in-with-chatgpt/codex/consent"
702
+ tokens = exchange_platform_tokens(login_session, login_device_id, code_verifier, continue_url)
703
+ login_session.close()
704
+ if not tokens:
705
+ raise RuntimeError("token换取失败")
706
+ step(index, "token 换取完成")
707
+ return tokens
708
+
709
+ def register(self, index: int) -> dict:
710
+ step(index, "开始创建邮箱")
711
+ mailbox = create_mailbox()
712
+ email = str(mailbox.get("address") or "").strip()
713
+ if not email:
714
+ raise RuntimeError("邮箱服务未返回 address")
715
+ label = str(mailbox.get("label") or "")
716
+ step(index, f"邮箱创建完成[{label}]: {email}")
717
+ password = _random_password()
718
+ first_name, last_name = _random_name()
719
+ self._platform_authorize(email, index)
720
+ self._register_user(email, password, index)
721
+ self._send_otp(index)
722
+ step(index, "开始等待注册验证码")
723
+ code = wait_for_code(mailbox)
724
+ if not code:
725
+ raise RuntimeError("等待注册验证码超时")
726
+ step(index, f"收到注册验证码: {code}")
727
+ self._validate_otp(code, index)
728
+ self._create_account(f"{first_name} {last_name}", _random_birthdate(), index)
729
+ tokens = self._login_and_exchange_tokens(email, password, mailbox, index)
730
+ return {
731
+ "email": email,
732
+ "password": password,
733
+ "access_token": str(tokens.get("access_token") or "").strip(),
734
+ "refresh_token": str(tokens.get("refresh_token") or "").strip(),
735
+ "id_token": str(tokens.get("id_token") or "").strip(),
736
+ "created_at": datetime.now(timezone.utc).isoformat(),
737
+ }
738
+
739
+
740
+ def worker(index: int) -> dict:
741
+ start = time.time()
742
+ registrar = PlatformRegistrar(config["proxy"])
743
+ try:
744
+ step(index, "任务启动")
745
+ result = registrar.register(index)
746
+ cost = time.time() - start
747
+ access_token = str(result["access_token"])
748
+ account_service.add_account_items([result])
749
+ refresh_result = account_service.refresh_accounts([access_token])
750
+ if refresh_result.get("errors"):
751
+ step(index, f"账号已保存,刷新状态暂未成功,稍后可重试: {refresh_result['errors']}", "yellow")
752
+ with stats_lock:
753
+ stats["done"] += 1
754
+ stats["success"] += 1
755
+ avg = (time.time() - stats["start_time"]) / stats["success"]
756
+ log(f'{result["email"]} 注册成功,本次耗时{cost:.1f}s,全局平均每个号注册耗时{avg:.1f}s', "green")
757
+ return {"ok": True, "index": index, "result": result}
758
+ except Exception as e:
759
+ cost = time.time() - start
760
+ with stats_lock:
761
+ stats["done"] += 1
762
+ stats["fail"] += 1
763
+ log(f"任务{index} 注册失败,本次耗时{cost:.1f}s,原因: {e}", "red")
764
+ return {"ok": False, "index": index, "error": str(e)}
765
+ finally:
766
+ registrar.close()
services/register_service.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import threading
5
+ import time
6
+ import uuid
7
+ from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+
11
+ from services.account_service import account_service
12
+ from services.config import DATA_DIR
13
+ from services.register import openai_register
14
+
15
+
16
+ REGISTER_FILE = DATA_DIR / "register.json"
17
+
18
+
19
+ def _now() -> str:
20
+ return datetime.now(timezone.utc).isoformat()
21
+
22
+
23
+ def _default_config() -> dict:
24
+ return {**openai_register.config, "mode": "total", "target_quota": 100, "target_available": 10, "check_interval": 5, "enabled": False, "stats": {"success": 0, "fail": 0, "done": 0, "running": 0, "threads": openai_register.config["threads"], "elapsed_seconds": 0, "avg_seconds": 0, "success_rate": 0, "current_quota": 0, "current_available": 0}}
25
+
26
+
27
+ def _normalize(raw: dict) -> dict:
28
+ cfg = _default_config()
29
+ cfg.update({k: v for k, v in raw.items() if k not in {"stats", "logs"}})
30
+ cfg["total"] = max(1, int(cfg.get("total") or 1))
31
+ cfg["threads"] = max(1, int(cfg.get("threads") or 1))
32
+ cfg["mode"] = str(cfg.get("mode") or "total").strip() if str(cfg.get("mode") or "total").strip() in {"total", "quota", "available"} else "total"
33
+ cfg["target_quota"] = max(1, int(cfg.get("target_quota") or 1))
34
+ cfg["target_available"] = max(1, int(cfg.get("target_available") or 1))
35
+ cfg["check_interval"] = max(1, int(cfg.get("check_interval") or 5))
36
+ cfg["proxy"] = str(cfg.get("proxy") or "").strip()
37
+ cfg["enabled"] = bool(cfg.get("enabled"))
38
+ stats = {**_default_config()["stats"], **(raw.get("stats") if isinstance(raw.get("stats"), dict) else {}),
39
+ "threads": cfg["threads"]}
40
+ cfg["stats"] = stats
41
+ return cfg
42
+
43
+
44
+ class RegisterService:
45
+ def __init__(self, store_file: Path):
46
+ self._store_file = store_file
47
+ self._lock = threading.RLock()
48
+ self._runner: threading.Thread | None = None
49
+ self._logs: list[dict] = []
50
+ openai_register.register_log_sink = self._append_log
51
+ self._config = self._load()
52
+ if self._config["enabled"]:
53
+ self.start()
54
+
55
+ def _load(self) -> dict:
56
+ try:
57
+ return _normalize(json.loads(self._store_file.read_text(encoding="utf-8")))
58
+ except Exception:
59
+ return _normalize({})
60
+
61
+ def _save(self) -> None:
62
+ self._store_file.parent.mkdir(parents=True, exist_ok=True)
63
+ self._store_file.write_text(json.dumps(self._config, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
64
+
65
+ def get(self) -> dict:
66
+ with self._lock:
67
+ return json.loads(json.dumps({**self._config, "logs": self._logs[-300:]}, ensure_ascii=False))
68
+
69
+ def _inject_proxy_to_mail(self) -> None:
70
+ proxy = str(self._config.get("proxy") or "").strip()
71
+ if proxy and isinstance(self._config.get("mail"), dict):
72
+ self._config["mail"]["proxy"] = proxy
73
+
74
+ def update(self, updates: dict) -> dict:
75
+ with self._lock:
76
+ self._config = _normalize({**self._config, **updates})
77
+ self._inject_proxy_to_mail()
78
+ openai_register.config.update({k: self._config[k] for k in ("mail", "proxy", "total", "threads")})
79
+ self._save()
80
+ return self.get()
81
+
82
+ def start(self) -> dict:
83
+ with self._lock:
84
+ if self._runner and self._runner.is_alive():
85
+ self._config["enabled"] = True
86
+ self._save()
87
+ return self.get()
88
+ self._config["enabled"] = True
89
+ self._inject_proxy_to_mail()
90
+ self._logs = []
91
+ metrics = self._pool_metrics()
92
+ self._config["stats"] = {"job_id": uuid.uuid4().hex, "success": 0, "fail": 0, "done": 0, "running": 0, "threads": self._config["threads"], **metrics, "started_at": _now(), "updated_at": _now()}
93
+ openai_register.config.update({k: self._config[k] for k in ("mail", "proxy", "total", "threads")})
94
+ with openai_register.stats_lock:
95
+ openai_register.stats.update({"done": 0, "success": 0, "fail": 0, "start_time": time.time()})
96
+ self._save()
97
+ self._runner = threading.Thread(target=self._run, daemon=True, name="openai-register")
98
+ self._runner.start()
99
+ self._append_log(f"注册任务启动,模式={self._config['mode']},线程数={self._config['threads']}", "yellow")
100
+ return self.get()
101
+
102
+ def stop(self) -> dict:
103
+ with self._lock:
104
+ self._config["enabled"] = False
105
+ self._config["stats"]["updated_at"] = _now()
106
+ self._save()
107
+ self._append_log("已请求停止注册任务,正在等待当前运行任务结束", "yellow")
108
+ return self.get()
109
+
110
+ def reset(self) -> dict:
111
+ with self._lock:
112
+ self._logs = []
113
+ self._config["stats"] = {"success": 0, "fail": 0, "done": 0, "running": 0, "threads": self._config["threads"], "elapsed_seconds": 0, "avg_seconds": 0, "success_rate": 0, **self._pool_metrics(), "updated_at": _now()}
114
+ with openai_register.stats_lock:
115
+ openai_register.stats.update({"done": 0, "success": 0, "fail": 0, "start_time": 0.0})
116
+ self._save()
117
+ return self.get()
118
+
119
+ def _append_log(self, text: str, color: str = "") -> None:
120
+ with self._lock:
121
+ self._logs.append({"time": _now(), "text": str(text), "level": str(color or "info")})
122
+ self._logs = self._logs[-300:]
123
+
124
+ def _pool_metrics(self) -> dict:
125
+ items = account_service.list_accounts()
126
+ normal = [item for item in items if item.get("status") == "正常"]
127
+ return {
128
+ "current_quota": sum(int(item.get("quota") or 0) for item in normal if not item.get("image_quota_unknown")),
129
+ "current_available": len(normal),
130
+ }
131
+
132
+ def _target_reached(self, cfg: dict, submitted: int) -> bool:
133
+ mode = str(cfg.get("mode") or "total")
134
+ metrics = self._pool_metrics()
135
+ self._bump(**metrics)
136
+ if mode == "quota":
137
+ reached = metrics["current_quota"] >= int(cfg.get("target_quota") or 1)
138
+ self._append_log(f"检查号池:当前正常账号={metrics['current_available']},当前剩余额度={metrics['current_quota']},目标额度={cfg.get('target_quota')},{'跳过注册' if reached else '继续注册'}", "yellow")
139
+ return reached
140
+ if mode == "available":
141
+ reached = metrics["current_available"] >= int(cfg.get("target_available") or 1)
142
+ self._append_log(f"检查号池:当前正常账号={metrics['current_available']},目标账号={cfg.get('target_available')},当前剩余额度={metrics['current_quota']},{'跳过注册' if reached else '继续注册'}", "yellow")
143
+ return reached
144
+ return submitted >= int(cfg.get("total") or 1)
145
+
146
+ def _bump(self, **updates) -> None:
147
+ with self._lock:
148
+ self._config["stats"].update(updates)
149
+ stats = self._config["stats"]
150
+ started_at = str(stats.get("started_at") or "")
151
+ if started_at:
152
+ try:
153
+ elapsed = max(0.0, (datetime.now(timezone.utc) - datetime.fromisoformat(started_at)).total_seconds())
154
+ except Exception:
155
+ elapsed = 0.0
156
+ done = int(stats.get("done") or 0)
157
+ success = int(stats.get("success") or 0)
158
+ fail = int(stats.get("fail") or 0)
159
+ stats["elapsed_seconds"] = round(elapsed, 1)
160
+ stats["avg_seconds"] = round(elapsed / success, 1) if success else 0
161
+ stats["success_rate"] = round(success * 100 / max(1, success + fail), 1)
162
+ self._config["stats"]["updated_at"] = _now()
163
+ self._save()
164
+
165
+ def _run(self) -> None:
166
+ threads = int(self.get()["threads"])
167
+ submitted, done, success, fail = 0, 0, 0, 0
168
+ with ThreadPoolExecutor(max_workers=threads) as executor:
169
+ futures = set()
170
+ while True:
171
+ cfg = self.get()
172
+ while self.get()["enabled"] and not self._target_reached(cfg, submitted) and len(futures) < threads:
173
+ submitted += 1
174
+ futures.add(executor.submit(openai_register.worker, submitted))
175
+ self._bump(running=len(futures), done=done, success=success, fail=fail)
176
+ if not futures and (not self.get()["enabled"] or str(cfg.get("mode") or "total") == "total"):
177
+ break
178
+ if not futures:
179
+ time.sleep(max(1, int(cfg.get("check_interval") or 5)))
180
+ continue
181
+ finished, futures = wait(futures, return_when=FIRST_COMPLETED)
182
+ for future in finished:
183
+ done += 1
184
+ try:
185
+ result = future.result()
186
+ success += 1 if result.get("ok") else 0
187
+ fail += 0 if result.get("ok") else 1
188
+ except Exception:
189
+ fail += 1
190
+ self._bump(running=0, done=done, success=success, fail=fail, finished_at=_now())
191
+ with self._lock:
192
+ self._config["enabled"] = False
193
+ self._save()
194
+ self._append_log(f"注册任务结束,成功{success},失败{fail}", "yellow")
195
+
196
+
197
+ register_service = RegisterService(REGISTER_FILE)
services/storage/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from services.storage.factory import create_storage_backend
4
+
5
+ __all__ = ["create_storage_backend"]
services/storage/base.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Any
5
+
6
+
7
+ class StorageBackend(ABC):
8
+ """抽象存储后端基类"""
9
+
10
+ @abstractmethod
11
+ def load_accounts(self) -> list[dict[str, Any]]:
12
+ """加载所有账号数据"""
13
+ pass
14
+
15
+ @abstractmethod
16
+ def save_accounts(self, accounts: list[dict[str, Any]]) -> None:
17
+ """保存所有账号数据"""
18
+ pass
19
+
20
+ @abstractmethod
21
+ def load_auth_keys(self) -> list[dict[str, Any]]:
22
+ """加载所有鉴权密钥数据"""
23
+ pass
24
+
25
+ @abstractmethod
26
+ def save_auth_keys(self, auth_keys: list[dict[str, Any]]) -> None:
27
+ """保存所有鉴权密钥数据"""
28
+ pass
29
+
30
+ @abstractmethod
31
+ def health_check(self) -> dict[str, Any]:
32
+ """健康检查,返回存储后端状态"""
33
+ pass
34
+
35
+ @abstractmethod
36
+ def get_backend_info(self) -> dict[str, Any]:
37
+ """获取存储后端信息"""
38
+ pass