cxk commited on
Commit
d93bff5
·
1 Parent(s): db809d9

feat(api): 集成ChatGPT服务并支持图像生成功能

Browse files

- 添加ChatGPTService类用于处理图像生成请求
- 实现图像生成相关API端点(/v1/images/generations, /v1/chat/completions, /v1/responses)
- 实现图像生成结果的处理和返回
- 添加图像服务模块处理底层API交互
- 新增测试文件验证图像生成功能

README.md CHANGED
@@ -6,7 +6,10 @@ ChatGPT 图片生成代理与账号池管理面板,提供账号维护、额度
6
 
7
  ## 功能
8
 
9
- - 批量导入和管理 `access_token`
 
 
 
10
  - 自动刷新账号邮箱、类型、图片额度、恢复时间
11
  - 轮询可用账号进行图片生成
12
  - 失效 Token 自动剔除
@@ -15,9 +18,15 @@ ChatGPT 图片生成代理与账号池管理面板,提供账号维护、额度
15
  > 目前仅实现了生图效果,编辑图片以及gpt-image-2模型尚未实现,需要后续更新。
16
 
17
  生图界面:
 
18
  ![image](assets/image.png)
19
 
 
 
 
 
20
  号池管理:
 
21
  ![image](assets/account_pool.png)
22
 
23
  ## 接口
@@ -34,6 +43,14 @@ Authorization: Bearer <auth-key>
34
  POST /v1/images/generations
35
  ```
36
 
 
 
 
 
 
 
 
 
37
  请求体示例:
38
 
39
  ```json
 
6
 
7
  ## 功能
8
 
9
+ - 兼容 OpenAI `Chat Completions` 图片响应
10
+ - 兼容 OpenAI `Responses API` 图片生成接口
11
+ - 支持导入 CPA 格式文件
12
+ - 支持多种方式导入 `access_token`
13
  - 自动刷新账号邮箱、类型、图片额度、恢复时间
14
  - 轮询可用账号进行图片生成
15
  - 失效 Token 自动剔除
 
18
  > 目前仅实现了生图效果,编辑图片以及gpt-image-2模型尚未实现,需要后续更新。
19
 
20
  生图界面:
21
+
22
  ![image](assets/image.png)
23
 
24
+ Chery Studio 中使用:
25
+
26
+ ![image](assets/chery_studio.png)
27
+
28
  号池管理:
29
+
30
  ![image](assets/account_pool.png)
31
 
32
  ## 接口
 
43
  POST /v1/images/generations
44
  ```
45
 
46
+ ```http
47
+ POST /v1/chat/completions
48
+ ```
49
+
50
+ ```http
51
+ POST /v1/responses
52
+ ```
53
+
54
  请求体示例:
55
 
56
  ```json
services/account_service.py CHANGED
@@ -221,22 +221,59 @@ class AccountService:
221
  with self._lock:
222
  return [token for item in self._accounts if (token := self._clean_token(item.get("access_token")))]
223
 
224
- def next_token(self, excluded_tokens: set[str] | None = None) -> str:
 
 
 
 
 
 
 
 
 
 
225
  with self._lock:
226
- excluded = {self._clean_token(token) for token in (excluded_tokens or set()) if self._clean_token(token)}
227
- tokens = [
228
- token
229
- for item in self._accounts
230
- if self._is_image_account_available(item)
231
- and (token := self._clean_token(item.get("access_token")))
232
- and token not in excluded
233
- ]
234
  if not tokens:
235
  raise RuntimeError(f"No available tokens found in {self.store_file}")
236
  access_token = tokens[self._index % len(tokens)]
237
  self._index += 1
238
  return access_token
239
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  def get_account(self, access_token: str) -> dict | None:
241
  access_token = self._clean_token(access_token)
242
  if not access_token:
 
221
  with self._lock:
222
  return [token for item in self._accounts if (token := self._clean_token(item.get("access_token")))]
223
 
224
+ def _list_available_candidate_tokens(self, excluded_tokens: set[str] | None = None) -> list[str]:
225
+ excluded = {self._clean_token(token) for token in (excluded_tokens or set()) if self._clean_token(token)}
226
+ return [
227
+ token
228
+ for item in self._accounts
229
+ if self._is_image_account_available(item)
230
+ and (token := self._clean_token(item.get("access_token")))
231
+ and token not in excluded
232
+ ]
233
+
234
+ def _pick_next_candidate_token(self, excluded_tokens: set[str] | None = None) -> str:
235
  with self._lock:
236
+ tokens = self._list_available_candidate_tokens(excluded_tokens)
 
 
 
 
 
 
 
237
  if not tokens:
238
  raise RuntimeError(f"No available tokens found in {self.store_file}")
239
  access_token = tokens[self._index % len(tokens)]
240
  self._index += 1
241
  return access_token
242
 
243
+ def refresh_account_state(self, access_token: str) -> dict | None:
244
+ try:
245
+ remote_info = self.fetch_remote_info(access_token)
246
+ except Exception as exc:
247
+ message = str(exc)
248
+ print(f"[account-available] refresh token={access_token[:12]}... fail {message}")
249
+ if "/backend-api/me failed: HTTP 401" in message:
250
+ return self.update_account(
251
+ access_token,
252
+ {
253
+ "status": "异常",
254
+ "quota": 0,
255
+ },
256
+ )
257
+ return None
258
+ return self.update_account(access_token, remote_info)
259
+
260
+ def get_available_access_token(self) -> str:
261
+ attempted_tokens: set[str] = set()
262
+ while True:
263
+ access_token = self._pick_next_candidate_token(excluded_tokens=attempted_tokens)
264
+ attempted_tokens.add(access_token)
265
+ account = self.refresh_account_state(access_token)
266
+ if self._is_image_account_available(account or {}):
267
+ return access_token
268
+ print(
269
+ f"[account-available] skip token={access_token[:12]}... "
270
+ f"quota={account.get('quota') if account else 'unknown'} "
271
+ f"status={account.get('status') if account else 'unknown'}"
272
+ )
273
+
274
+ def next_token(self) -> str:
275
+ return self.get_available_access_token()
276
+
277
  def get_account(self, access_token: str) -> dict | None:
278
  access_token = self._clean_token(access_token)
279
  if not access_token:
services/api.py CHANGED
@@ -8,16 +8,14 @@ from fastapi import APIRouter, FastAPI, Header, HTTPException
8
  from fastapi.concurrency import run_in_threadpool
9
  from fastapi.middleware.cors import CORSMiddleware
10
  from fastapi.responses import FileResponse
11
- from pydantic import BaseModel, Field
12
 
13
  from services.account_service import account_service
 
14
  from services.config import config
15
- from services.backend_service import BackendService
16
- from services.chat_image_service import ChatImageService
17
  from services.image_service import ImageGenerationError
18
  from services.version import get_app_version
19
 
20
-
21
  BASE_DIR = Path(__file__).resolve().parents[1]
22
  WEB_DIST_DIR = BASE_DIR / "web_dist"
23
 
@@ -49,6 +47,27 @@ class AccountUpdateRequest(BaseModel):
49
  quota: int | None = None
50
 
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  def build_model_item(model_id: str) -> dict[str, object]:
53
  return {
54
  "id": model_id,
@@ -98,11 +117,7 @@ def resolve_web_asset(requested_path: str) -> Path | None:
98
  candidates = [WEB_DIST_DIR / "index.html"]
99
  else:
100
  relative_path = Path(clean_path)
101
- candidates = [
102
- WEB_DIST_DIR / relative_path,
103
- WEB_DIST_DIR / relative_path / "index.html",
104
- WEB_DIST_DIR / f"{clean_path}.html",
105
- ]
106
 
107
  for candidate in candidates:
108
  try:
@@ -116,8 +131,7 @@ def resolve_web_asset(requested_path: str) -> Path | None:
116
 
117
 
118
  def create_app() -> FastAPI:
119
- service = BackendService(account_service)
120
- chat_image_service = ChatImageService(service)
121
  app_version = get_app_version()
122
 
123
  @asynccontextmanager
@@ -142,13 +156,7 @@ def create_app() -> FastAPI:
142
 
143
  @router.get("/v1/models")
144
  async def list_models():
145
- return {
146
- "object": "list",
147
- "data": [
148
- build_model_item("gpt-image-1"),
149
- build_model_item("gpt-image-2"),
150
- ],
151
- }
152
 
153
  @router.post("/auth/login")
154
  async def login(authorization: str | None = Header(default=None)):
@@ -165,28 +173,17 @@ def create_app() -> FastAPI:
165
  return {"items": account_service.list_accounts()}
166
 
167
  @router.post("/api/accounts")
168
- async def create_accounts(
169
- body: AccountCreateRequest,
170
- authorization: str | None = Header(default=None),
171
- ):
172
  require_auth_key(authorization)
173
  tokens = [str(token or "").strip() for token in body.tokens if str(token or "").strip()]
174
  if not tokens:
175
  raise HTTPException(status_code=400, detail={"error": "tokens is required"})
176
  result = account_service.add_accounts(tokens)
177
  refresh_result = account_service.refresh_accounts(tokens)
178
- return {
179
- **result,
180
- "refreshed": refresh_result.get("refreshed", 0),
181
- "errors": refresh_result.get("errors", []),
182
- "items": refresh_result.get("items", result.get("items", [])),
183
- }
184
 
185
  @router.delete("/api/accounts")
186
- async def delete_accounts(
187
- body: AccountDeleteRequest,
188
- authorization: str | None = Header(default=None),
189
- ):
190
  require_auth_key(authorization)
191
  tokens = [str(token or "").strip() for token in body.tokens if str(token or "").strip()]
192
  if not tokens:
@@ -194,10 +191,7 @@ def create_app() -> FastAPI:
194
  return account_service.delete_accounts(tokens)
195
 
196
  @router.post("/api/accounts/refresh")
197
- async def refresh_accounts(
198
- body: AccountRefreshRequest,
199
- authorization: str | None = Header(default=None),
200
- ):
201
  require_auth_key(authorization)
202
  access_tokens = [str(token or "").strip() for token in body.access_tokens if str(token or "").strip()]
203
  if not access_tokens:
@@ -207,24 +201,13 @@ def create_app() -> FastAPI:
207
  return account_service.refresh_accounts(access_tokens)
208
 
209
  @router.post("/api/accounts/update")
210
- async def update_account(
211
- body: AccountUpdateRequest,
212
- authorization: str | None = Header(default=None),
213
- ):
214
  require_auth_key(authorization)
215
  access_token = str(body.access_token or "").strip()
216
  if not access_token:
217
  raise HTTPException(status_code=400, detail={"error": "access_token is required"})
218
 
219
- updates = {
220
- key: value
221
- for key, value in {
222
- "type": body.type,
223
- "status": body.status,
224
- "quota": body.quota,
225
- }.items()
226
- if value is not None
227
- }
228
  if not updates:
229
  raise HTTPException(status_code=400, detail={"error": "no updates provided"})
230
 
@@ -234,28 +217,22 @@ def create_app() -> FastAPI:
234
  return {"item": account, "items": account_service.list_accounts()}
235
 
236
  @router.post("/v1/images/generations")
237
- async def generate_images(
238
- body: ImageGenerationRequest,
239
- authorization: str | None = Header(default=None),
240
- ):
241
  require_auth_key(authorization)
242
  try:
243
- return await run_in_threadpool(
244
- service.generate_with_pool,
245
- body.prompt,
246
- body.model,
247
- body.n,
248
- )
249
  except ImageGenerationError as exc:
250
  raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
251
 
252
  @router.post("/v1/chat/completions")
253
- async def create_chat_completion(
254
- body: dict[str, object],
255
- authorization: str | None = Header(default=None),
256
- ):
 
 
257
  require_auth_key(authorization)
258
- return await run_in_threadpool(chat_image_service.create_image_completion, body)
259
 
260
  app.include_router(router)
261
 
 
8
  from fastapi.concurrency import run_in_threadpool
9
  from fastapi.middleware.cors import CORSMiddleware
10
  from fastapi.responses import FileResponse
11
+ from pydantic import BaseModel, ConfigDict, Field
12
 
13
  from services.account_service import account_service
14
+ from services.chatgpt_service import ChatGPTService
15
  from services.config import config
 
 
16
  from services.image_service import ImageGenerationError
17
  from services.version import get_app_version
18
 
 
19
  BASE_DIR = Path(__file__).resolve().parents[1]
20
  WEB_DIST_DIR = BASE_DIR / "web_dist"
21
 
 
47
  quota: int | None = None
48
 
49
 
50
+ class ChatCompletionRequest(BaseModel):
51
+ model_config = ConfigDict(extra="allow")
52
+
53
+ model: str | None = None
54
+ prompt: str | None = None
55
+ n: int | None = None
56
+ stream: bool | None = None
57
+ modalities: list[str] | None = None
58
+ messages: list[dict[str, object]] | None = None
59
+
60
+
61
+ class ResponseCreateRequest(BaseModel):
62
+ model_config = ConfigDict(extra="allow")
63
+
64
+ model: str | None = None
65
+ input: object | None = None
66
+ tools: list[dict[str, object]] | None = None
67
+ tool_choice: object | None = None
68
+ stream: bool | None = None
69
+
70
+
71
  def build_model_item(model_id: str) -> dict[str, object]:
72
  return {
73
  "id": model_id,
 
117
  candidates = [WEB_DIST_DIR / "index.html"]
118
  else:
119
  relative_path = Path(clean_path)
120
+ candidates = [WEB_DIST_DIR / relative_path, WEB_DIST_DIR / relative_path / "index.html", WEB_DIST_DIR / f"{clean_path}.html"]
 
 
 
 
121
 
122
  for candidate in candidates:
123
  try:
 
131
 
132
 
133
  def create_app() -> FastAPI:
134
+ chatgpt_service = ChatGPTService(account_service)
 
135
  app_version = get_app_version()
136
 
137
  @asynccontextmanager
 
156
 
157
  @router.get("/v1/models")
158
  async def list_models():
159
+ return {"object": "list", "data": [build_model_item("gpt-image-1"), build_model_item("gpt-image-2")]}
 
 
 
 
 
 
160
 
161
  @router.post("/auth/login")
162
  async def login(authorization: str | None = Header(default=None)):
 
173
  return {"items": account_service.list_accounts()}
174
 
175
  @router.post("/api/accounts")
176
+ async def create_accounts(body: AccountCreateRequest, authorization: str | None = Header(default=None)):
 
 
 
177
  require_auth_key(authorization)
178
  tokens = [str(token or "").strip() for token in body.tokens if str(token or "").strip()]
179
  if not tokens:
180
  raise HTTPException(status_code=400, detail={"error": "tokens is required"})
181
  result = account_service.add_accounts(tokens)
182
  refresh_result = account_service.refresh_accounts(tokens)
183
+ return {**result, "refreshed": refresh_result.get("refreshed", 0), "errors": refresh_result.get("errors", []), "items": refresh_result.get("items", result.get("items", []))}
 
 
 
 
 
184
 
185
  @router.delete("/api/accounts")
186
+ async def delete_accounts(body: AccountDeleteRequest, authorization: str | None = Header(default=None)):
 
 
 
187
  require_auth_key(authorization)
188
  tokens = [str(token or "").strip() for token in body.tokens if str(token or "").strip()]
189
  if not tokens:
 
191
  return account_service.delete_accounts(tokens)
192
 
193
  @router.post("/api/accounts/refresh")
194
+ async def refresh_accounts(body: AccountRefreshRequest, authorization: str | None = Header(default=None)):
 
 
 
195
  require_auth_key(authorization)
196
  access_tokens = [str(token or "").strip() for token in body.access_tokens if str(token or "").strip()]
197
  if not access_tokens:
 
201
  return account_service.refresh_accounts(access_tokens)
202
 
203
  @router.post("/api/accounts/update")
204
+ async def update_account(body: AccountUpdateRequest, authorization: str | None = Header(default=None)):
 
 
 
205
  require_auth_key(authorization)
206
  access_token = str(body.access_token or "").strip()
207
  if not access_token:
208
  raise HTTPException(status_code=400, detail={"error": "access_token is required"})
209
 
210
+ updates = {key: value for key, value in {"type": body.type, "status": body.status, "quota": body.quota}.items() if value is not None}
 
 
 
 
 
 
 
 
211
  if not updates:
212
  raise HTTPException(status_code=400, detail={"error": "no updates provided"})
213
 
 
217
  return {"item": account, "items": account_service.list_accounts()}
218
 
219
  @router.post("/v1/images/generations")
220
+ async def generate_images(body: ImageGenerationRequest, authorization: str | None = Header(default=None)):
 
 
 
221
  require_auth_key(authorization)
222
  try:
223
+ return await run_in_threadpool(chatgpt_service.generate_with_pool, body.prompt, body.model, body.n)
 
 
 
 
 
224
  except ImageGenerationError as exc:
225
  raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
226
 
227
  @router.post("/v1/chat/completions")
228
+ async def create_chat_completion(body: ChatCompletionRequest, authorization: str | None = Header(default=None)):
229
+ require_auth_key(authorization)
230
+ return await run_in_threadpool(chatgpt_service.create_image_completion, body.model_dump(mode="python"))
231
+
232
+ @router.post("/v1/responses")
233
+ async def create_response(body: ResponseCreateRequest, authorization: str | None = Header(default=None)):
234
  require_auth_key(authorization)
235
+ return await run_in_threadpool(chatgpt_service.create_response, body.model_dump(mode="python"))
236
 
237
  app.include_router(router)
238
 
services/backend_service.py DELETED
@@ -1,82 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from fastapi import HTTPException
4
-
5
- from services.account_service import AccountService
6
- from services.image_service import ImageGenerationError, generate_image_result, is_token_invalid_error
7
-
8
-
9
- class BackendService:
10
- def __init__(self, account_service: AccountService):
11
- self.account_service = account_service
12
-
13
- @staticmethod
14
- def _is_account_ready_for_image(account: dict | None) -> bool:
15
- if not isinstance(account, dict):
16
- return False
17
- if account.get("status") in {"禁用", "异常"}:
18
- return False
19
- return int(account.get("quota") or 0) > 0
20
-
21
- def _refresh_request_token(self, access_token: str) -> dict | None:
22
- try:
23
- remote_info = self.account_service.fetch_remote_info(access_token)
24
- except Exception as exc:
25
- message = str(exc)
26
- print(f"[image-generate] refresh token={access_token[:12]}... fail {message}")
27
- if "/backend-api/me failed: HTTP 401" in message:
28
- return self.account_service.update_account(
29
- access_token,
30
- {
31
- "status": "异常",
32
- "quota": 0,
33
- },
34
- )
35
- return None
36
- return self.account_service.update_account(access_token, remote_info)
37
-
38
- def resolve_request_token(self, excluded_tokens: set[str] | None = None) -> str:
39
- try:
40
- return self.account_service.next_token(excluded_tokens=excluded_tokens)
41
- except RuntimeError as exc:
42
- raise HTTPException(status_code=503, detail={"error": str(exc)}) from exc
43
-
44
- def generate_with_pool(self, prompt: str, model: str, n: int):
45
- attempted_tokens: set[str] = set()
46
-
47
- while True:
48
- try:
49
- request_token = self.resolve_request_token(excluded_tokens=attempted_tokens)
50
- except HTTPException:
51
- raise
52
-
53
- attempted_tokens.add(request_token)
54
- refreshed_account = self._refresh_request_token(request_token)
55
- if not self._is_account_ready_for_image(refreshed_account):
56
- print(
57
- f"[image-generate] skip token={request_token[:12]}... "
58
- f"quota={refreshed_account.get('quota') if refreshed_account else 'unknown'} "
59
- f"status={refreshed_account.get('status') if refreshed_account else 'unknown'}"
60
- )
61
- continue
62
-
63
- print(f"[image-generate] start pooled token={request_token[:12]}... model={model} n={n}")
64
- try:
65
- result = generate_image_result(request_token, prompt, model, n)
66
- account = self.account_service.mark_image_result(request_token, success=True)
67
- print(
68
- f"[image-generate] success pooled token={request_token[:12]}... "
69
- f"quota={account.get('quota') if account else 'unknown'} status={account.get('status') if account else 'unknown'}"
70
- )
71
- return result
72
- except ImageGenerationError as exc:
73
- account = self.account_service.mark_image_result(request_token, success=False)
74
- print(
75
- f"[image-generate] fail pooled token={request_token[:12]}... "
76
- f"error={exc} quota={account.get('quota') if account else 'unknown'} status={account.get('status') if account else 'unknown'}"
77
- )
78
- if is_token_invalid_error(str(exc)):
79
- self.account_service.remove_token(request_token)
80
- print(f"[image-generate] remove invalid token={request_token[:12]}...")
81
- continue
82
- raise
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
services/chat_image_service.py DELETED
@@ -1,174 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import time
4
- import uuid
5
-
6
- from fastapi import HTTPException
7
-
8
- from services.backend_service import BackendService
9
- from services.image_service import ImageGenerationError
10
-
11
-
12
- IMAGE_MODELS = {"gpt-image-1", "gpt-image-2"}
13
-
14
-
15
- class ChatImageService:
16
- def __init__(self, backend_service: BackendService):
17
- self.backend_service = backend_service
18
-
19
- def is_image_chat_request(self, body: dict[str, object]) -> bool:
20
- model = str(body.get("model") or "").strip()
21
- modalities = body.get("modalities")
22
- if model in IMAGE_MODELS:
23
- return True
24
- if isinstance(modalities, list):
25
- normalized = {str(item or "").strip().lower() for item in modalities}
26
- return "image" in normalized
27
- return False
28
-
29
- def create_image_completion(self, body: dict[str, object]) -> dict[str, object]:
30
- if not self.is_image_chat_request(body):
31
- raise HTTPException(
32
- status_code=400,
33
- detail={"error": "only image generation requests are supported on this endpoint"},
34
- )
35
-
36
- if bool(body.get("stream")):
37
- raise HTTPException(status_code=400, detail={"error": "stream is not supported for image generation"})
38
-
39
- model = str(body.get("model") or "gpt-image-1").strip() or "gpt-image-1"
40
- n = self._parse_image_count(body.get("n"))
41
- prompt = self._extract_chat_prompt(body)
42
- if not prompt:
43
- raise HTTPException(status_code=400, detail={"error": "prompt is required"})
44
-
45
- try:
46
- image_result = self.backend_service.generate_with_pool(prompt, model, n)
47
- except ImageGenerationError as exc:
48
- raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
49
-
50
- return self._build_chat_image_completion(model, prompt, image_result)
51
-
52
- def _extract_prompt_from_message_content(self, content: object) -> str:
53
- if isinstance(content, str):
54
- return content.strip()
55
- if not isinstance(content, list):
56
- return ""
57
-
58
- parts: list[str] = []
59
- for item in content:
60
- if not isinstance(item, dict):
61
- continue
62
- item_type = str(item.get("type") or "").strip()
63
- if item_type == "text":
64
- text = str(item.get("text") or "").strip()
65
- if text:
66
- parts.append(text)
67
- continue
68
- if item_type == "input_text":
69
- text = str(item.get("input_text") or "").strip()
70
- if text:
71
- parts.append(text)
72
- return "\n".join(parts).strip()
73
-
74
- def _extract_chat_prompt(self, body: dict[str, object]) -> str:
75
- direct_prompt = str(body.get("prompt") or "").strip()
76
- if direct_prompt:
77
- return direct_prompt
78
-
79
- messages = body.get("messages")
80
- if not isinstance(messages, list):
81
- return ""
82
-
83
- prompt_parts: list[str] = []
84
- for message in messages:
85
- if not isinstance(message, dict):
86
- continue
87
- role = str(message.get("role") or "").strip().lower()
88
- if role != "user":
89
- continue
90
- prompt = self._extract_prompt_from_message_content(message.get("content"))
91
- if prompt:
92
- prompt_parts.append(prompt)
93
-
94
- return "\n".join(prompt_parts).strip()
95
-
96
- def _parse_image_count(self, raw_value: object) -> int:
97
- try:
98
- value = int(raw_value or 1)
99
- except (TypeError, ValueError) as exc:
100
- raise HTTPException(status_code=400, detail={"error": "n must be an integer"}) from exc
101
- if value < 1 or value > 4:
102
- raise HTTPException(status_code=400, detail={"error": "n must be between 1 and 4"})
103
- return value
104
-
105
- def _build_chat_image_completion(
106
- self,
107
- model: str,
108
- prompt: str,
109
- image_result: dict[str, object],
110
- ) -> dict[str, object]:
111
- created = int(image_result.get("created") or time.time())
112
- image_items = image_result.get("data") if isinstance(image_result.get("data"), list) else []
113
-
114
- markdown_images = []
115
- images_payload = []
116
- content_parts = []
117
-
118
- for index, item in enumerate(image_items, start=1):
119
- if not isinstance(item, dict):
120
- continue
121
- b64_json = str(item.get("b64_json") or "").strip()
122
- revised_prompt = str(item.get("revised_prompt") or prompt).strip()
123
- if not b64_json:
124
- continue
125
- image_data_url = f"data:image/png;base64,{b64_json}"
126
- markdown_images.append(f"![image_{index}]({image_data_url})")
127
- images_payload.append(
128
- {
129
- "b64_json": b64_json,
130
- "revised_prompt": revised_prompt,
131
- "url": image_data_url,
132
- }
133
- )
134
- content_parts.append(
135
- {
136
- "type": "image_url",
137
- "image_url": {
138
- "url": image_data_url,
139
- },
140
- }
141
- )
142
-
143
- text_content = "\n\n".join(markdown_images) if markdown_images else "Image generation completed."
144
- content_parts.insert(
145
- 0,
146
- {
147
- "type": "text",
148
- "text": text_content,
149
- },
150
- )
151
-
152
- return {
153
- "id": f"chatcmpl-{uuid.uuid4().hex}",
154
- "object": "chat.completion",
155
- "created": created,
156
- "model": model,
157
- "choices": [
158
- {
159
- "index": 0,
160
- "message": {
161
- "role": "assistant",
162
- "content": text_content,
163
- "content_parts": content_parts,
164
- "images": images_payload,
165
- },
166
- "finish_reason": "stop",
167
- }
168
- ],
169
- "usage": {
170
- "prompt_tokens": 0,
171
- "completion_tokens": 0,
172
- "total_tokens": 0,
173
- },
174
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
services/chatgpt_service.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from fastapi import HTTPException
4
+
5
+ from services.account_service import AccountService
6
+ from services.image_service import ImageGenerationError, generate_image_result, is_token_invalid_error
7
+ from services.utils import (
8
+ build_chat_image_completion,
9
+ extract_chat_prompt,
10
+ extract_response_prompt,
11
+ has_response_image_generation_tool,
12
+ is_image_chat_request,
13
+ parse_image_count,
14
+ )
15
+
16
+
17
+ class ChatGPTService:
18
+ def __init__(self, account_service: AccountService):
19
+ self.account_service = account_service
20
+
21
+ def generate_with_pool(self, prompt: str, model: str, n: int):
22
+ created = None
23
+ image_items: list[dict[str, object]] = []
24
+
25
+ for index in range(1, n + 1):
26
+ while True:
27
+ try:
28
+ request_token = self.account_service.get_available_access_token()
29
+ except RuntimeError as exc:
30
+ print(f"[image-generate] stop index={index}/{n} error={exc}")
31
+ break
32
+
33
+ print(f"[image-generate] start pooled token={request_token[:12]}... model={model} index={index}/{n}")
34
+ try:
35
+ result = generate_image_result(request_token, prompt, model)
36
+ account = self.account_service.mark_image_result(request_token, success=True)
37
+ if created is None:
38
+ created = result.get("created")
39
+ data = result.get("data")
40
+ if isinstance(data, list):
41
+ image_items.extend(item for item in data if isinstance(item, dict))
42
+ print(
43
+ f"[image-generate] success pooled token={request_token[:12]}... "
44
+ f"quota={account.get('quota') if account else 'unknown'} status={account.get('status') if account else 'unknown'}"
45
+ )
46
+ break
47
+ except ImageGenerationError as exc:
48
+ account = self.account_service.mark_image_result(request_token, success=False)
49
+ message = str(exc)
50
+ print(
51
+ f"[image-generate] fail pooled token={request_token[:12]}... "
52
+ f"error={message} quota={account.get('quota') if account else 'unknown'} status={account.get('status') if account else 'unknown'}"
53
+ )
54
+ if is_token_invalid_error(message):
55
+ self.account_service.remove_token(request_token)
56
+ print(f"[image-generate] remove invalid token={request_token[:12]}...")
57
+ continue
58
+ break
59
+
60
+ if not image_items:
61
+ raise ImageGenerationError("image generation failed")
62
+
63
+ return {
64
+ "created": created,
65
+ "data": image_items,
66
+ }
67
+
68
+ def create_image_completion(self, body: dict[str, object]) -> dict[str, object]:
69
+ if not is_image_chat_request(body):
70
+ raise HTTPException(
71
+ status_code=400,
72
+ detail={"error": "only image generation requests are supported on this endpoint"},
73
+ )
74
+
75
+ if bool(body.get("stream")):
76
+ raise HTTPException(status_code=400, detail={"error": "stream is not supported for image generation"})
77
+
78
+ model = str(body.get("model") or "gpt-image-1").strip() or "gpt-image-1"
79
+ n = parse_image_count(body.get("n"))
80
+ prompt = extract_chat_prompt(body)
81
+ if not prompt:
82
+ raise HTTPException(status_code=400, detail={"error": "prompt is required"})
83
+
84
+ try:
85
+ image_result = self.generate_with_pool(prompt, model, n)
86
+ except ImageGenerationError as exc:
87
+ raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
88
+
89
+ return build_chat_image_completion(model, prompt, image_result)
90
+
91
+ def create_response(self, body: dict[str, object]) -> dict[str, object]:
92
+ if bool(body.get("stream")):
93
+ raise HTTPException(status_code=400, detail={"error": "stream is not supported"})
94
+
95
+ if not has_response_image_generation_tool(body):
96
+ raise HTTPException(
97
+ status_code=400,
98
+ detail={"error": "only image_generation tool requests are supported on this endpoint"},
99
+ )
100
+
101
+ prompt = extract_response_prompt(body.get("input"))
102
+ if not prompt:
103
+ raise HTTPException(status_code=400, detail={"error": "input text is required"})
104
+
105
+ model = str(body.get("model") or "gpt-5").strip() or "gpt-5"
106
+ try:
107
+ image_result = self.generate_with_pool(prompt, "gpt-image-1", 1)
108
+ except ImageGenerationError as exc:
109
+ raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
110
+
111
+ image_items = image_result.get("data") if isinstance(image_result.get("data"), list) else []
112
+ output = []
113
+ for item in image_items:
114
+ if not isinstance(item, dict):
115
+ continue
116
+ b64_json = str(item.get("b64_json") or "").strip()
117
+ if not b64_json:
118
+ continue
119
+ output.append(
120
+ {
121
+ "id": f"ig_{len(output) + 1}",
122
+ "type": "image_generation_call",
123
+ "status": "completed",
124
+ "result": b64_json,
125
+ "revised_prompt": str(item.get("revised_prompt") or prompt).strip(),
126
+ }
127
+ )
128
+
129
+ if not output:
130
+ raise HTTPException(status_code=502, detail={"error": "image generation failed"})
131
+
132
+ created = int(image_result.get("created") or 0)
133
+ return {
134
+ "id": f"resp_{created}",
135
+ "object": "response",
136
+ "created_at": created,
137
+ "status": "completed",
138
+ "error": None,
139
+ "incomplete_details": None,
140
+ "model": model,
141
+ "output": output,
142
+ "parallel_tool_calls": False,
143
+ }
services/image_service.py CHANGED
@@ -442,71 +442,65 @@ def _resolve_upstream_model(access_token: str, requested_model: str) -> str:
442
  return str(requested_model or DEFAULT_MODEL).strip() or DEFAULT_MODEL
443
 
444
 
445
- def generate_image_result(access_token: str, prompt: str, model: str = DEFAULT_MODEL, n: int = 1) -> dict:
446
  prompt = str(prompt or "").strip()
447
  access_token = str(access_token or "").strip()
448
  if not prompt:
449
  raise ImageGenerationError("prompt is required")
450
  if not access_token:
451
  raise ImageGenerationError("token is required")
452
- if n < 1:
453
- raise ImageGenerationError("n must be >= 1")
454
 
455
  session, fp = _new_session(access_token)
456
  try:
457
  upstream_model = _resolve_upstream_model(access_token, model)
458
  print(
459
  f"[image-upstream] start token={access_token[:12]}... "
460
- f"requested_model={model} upstream_model={upstream_model} n={n}"
461
  )
462
- results: list[GeneratedImage] = []
463
- for _ in range(n):
464
- device_id = _bootstrap(session, fp)
465
- chat_token, pow_info = _chat_requirements(session, access_token, device_id)
466
- proof_token = None
467
- if pow_info.get("required"):
468
- proof_token = _generate_proof_token(
469
- seed=str(pow_info["seed"]),
470
- difficulty=str(pow_info["difficulty"]),
471
- user_agent=USER_AGENT,
472
- proof_config=_pow_config(USER_AGENT),
473
- )
474
- parent_message_id = str(uuid.uuid4())
475
- response = _send_conversation(
476
- session,
477
- access_token,
478
- device_id,
479
- chat_token,
480
- proof_token,
481
- parent_message_id,
482
- prompt,
483
- upstream_model,
484
  )
485
- parsed = _parse_sse(response)
486
- actual_conversation_id = parsed.get("conversation_id") or ""
487
- file_ids = parsed.get("file_ids") or []
488
- response_text = str(parsed.get("text") or "").strip()
489
- if actual_conversation_id and not file_ids:
490
- file_ids = _poll_image_ids(session, access_token, device_id, actual_conversation_id)
491
- if not file_ids:
492
- if response_text:
493
- raise ImageGenerationError(response_text)
494
- raise ImageGenerationError("no image returned from upstream")
495
- first_file_id = str(file_ids[0])
496
- download_url = _fetch_download_url(session, access_token, device_id, actual_conversation_id, first_file_id)
497
- if not download_url:
498
- raise ImageGenerationError("failed to get download url")
499
- results.append(
500
- GeneratedImage(
501
- b64_json=_download_as_base64(session, download_url),
502
- revised_prompt=prompt,
503
- url=download_url,
504
- )
505
- )
506
- print(f"[image-upstream] success token={access_token[:12]}... images={len(results)}")
 
 
 
 
 
 
 
 
 
507
  return {
508
  "created": time.time_ns() // 1_000_000_000,
509
- "data": [{"b64_json": item.b64_json, "revised_prompt": item.revised_prompt} for item in results],
510
  }
511
  except Exception as exc:
512
  print(f"[image-upstream] fail token={access_token[:12]}... error={exc}")
 
442
  return str(requested_model or DEFAULT_MODEL).strip() or DEFAULT_MODEL
443
 
444
 
445
+ def generate_image_result(access_token: str, prompt: str, model: str = DEFAULT_MODEL) -> dict:
446
  prompt = str(prompt or "").strip()
447
  access_token = str(access_token or "").strip()
448
  if not prompt:
449
  raise ImageGenerationError("prompt is required")
450
  if not access_token:
451
  raise ImageGenerationError("token is required")
 
 
452
 
453
  session, fp = _new_session(access_token)
454
  try:
455
  upstream_model = _resolve_upstream_model(access_token, model)
456
  print(
457
  f"[image-upstream] start token={access_token[:12]}... "
458
+ f"requested_model={model} upstream_model={upstream_model}"
459
  )
460
+ device_id = _bootstrap(session, fp)
461
+ chat_token, pow_info = _chat_requirements(session, access_token, device_id)
462
+ proof_token = None
463
+ if pow_info.get("required"):
464
+ proof_token = _generate_proof_token(
465
+ seed=str(pow_info["seed"]),
466
+ difficulty=str(pow_info["difficulty"]),
467
+ user_agent=USER_AGENT,
468
+ proof_config=_pow_config(USER_AGENT),
 
 
 
 
 
 
 
 
 
 
 
 
 
469
  )
470
+ parent_message_id = str(uuid.uuid4())
471
+ response = _send_conversation(
472
+ session,
473
+ access_token,
474
+ device_id,
475
+ chat_token,
476
+ proof_token,
477
+ parent_message_id,
478
+ prompt,
479
+ upstream_model,
480
+ )
481
+ parsed = _parse_sse(response)
482
+ actual_conversation_id = parsed.get("conversation_id") or ""
483
+ file_ids = parsed.get("file_ids") or []
484
+ response_text = str(parsed.get("text") or "").strip()
485
+ if actual_conversation_id and not file_ids:
486
+ file_ids = _poll_image_ids(session, access_token, device_id, actual_conversation_id)
487
+ if not file_ids:
488
+ if response_text:
489
+ raise ImageGenerationError(response_text)
490
+ raise ImageGenerationError("no image returned from upstream")
491
+ first_file_id = str(file_ids[0])
492
+ download_url = _fetch_download_url(session, access_token, device_id, actual_conversation_id, first_file_id)
493
+ if not download_url:
494
+ raise ImageGenerationError("failed to get download url")
495
+ result = GeneratedImage(
496
+ b64_json=_download_as_base64(session, download_url),
497
+ revised_prompt=prompt,
498
+ url=download_url,
499
+ )
500
+ print(f"[image-upstream] success token={access_token[:12]}... images=1")
501
  return {
502
  "created": time.time_ns() // 1_000_000_000,
503
+ "data": [{"b64_json": result.b64_json, "revised_prompt": result.revised_prompt}],
504
  }
505
  except Exception as exc:
506
  print(f"[image-upstream] fail token={access_token[:12]}... error={exc}")
services/proof_of_work.py CHANGED
@@ -9,13 +9,11 @@ from html.parser import HTMLParser
9
 
10
  import pybase64
11
 
12
-
13
- class _Logger:
14
- def info(self, *args, **kwargs):
15
- return None
16
-
17
-
18
- logger = _Logger()
19
 
20
  cores = [8, 16, 24, 32]
21
  timeLayout = "%a %b %d %Y %H:%M:%S"
@@ -402,7 +400,6 @@ def get_data_build_from_html(html_content):
402
  data_build = match.group(1)
403
  cached_dpl = data_build
404
  cached_time = int(time.time())
405
- logger.info(f"Found dpl: {cached_dpl}")
406
 
407
 
408
  async def get_dpl(service):
@@ -421,7 +418,6 @@ async def get_dpl(service):
421
  else:
422
  return True
423
  except Exception as e:
424
- logger.info(f"Failed to get dpl: {e}")
425
  cached_dpl = None
426
  cached_time = int(time.time())
427
  return False
@@ -460,7 +456,6 @@ def get_answer_token(seed, diff, config):
460
  start = time.time()
461
  answer, solved = generate_answer(seed, diff, config)
462
  end = time.time()
463
- logger.info(f'diff: {diff}, time: {int((end - start) * 1e6) / 1e3}ms, solved: {solved}')
464
  return "gAAAAAB" + answer, solved
465
 
466
 
@@ -488,20 +483,3 @@ def generate_answer(seed, diff, config):
488
  def get_requirements_token(config):
489
  require, solved = generate_answer(format(random.random()), "0fffff", config)
490
  return 'gAAAAAC' + require
491
-
492
-
493
- if __name__ == "__main__":
494
- # cached_scripts.append(
495
- # "https://cdn.oaistatic.com/_next/static/cXh69klOLzS0Gy2joLDRS/_ssgManifest.js?dpl=453ebaec0d44c2decab71692e1bfe39be35a24b3")
496
- # cached_dpl = "453ebaec0d44c2decab71692e1bfe39be35a24b3"
497
- # cached_time = int(time.time())
498
- # for i in range(10):
499
- # seed = format(random.random())
500
- # diff = "000032"
501
- # config = get_config("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome")
502
- # answer = get_answer_token(seed, diff, config)
503
- cached_scripts.append(
504
- "https://cdn.oaistatic.com/_next/static/cXh69klOLzS0Gy2joLDRS/_ssgManifest.js?dpl=453ebaec0d44c2decab71692e1bfe39be35a24b3")
505
- cached_dpl = "prod-f501fe933b3edf57aea882da888e1a544df99840"
506
- config = get_config("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36")
507
- get_requirements_token(config)
 
9
 
10
  import pybase64
11
 
12
+ """
13
+ 这个文件用于生成上游 ChatGPT 网页接口需要的 PoW 参数。
14
+ 它会解析首页 HTML 并缓存构建信息,组装浏览器指纹风格的配置数据,
15
+ 然后生成图片请求流程里会用到的 requirements token 和 proof token。
16
+ """
 
 
17
 
18
  cores = [8, 16, 24, 32]
19
  timeLayout = "%a %b %d %Y %H:%M:%S"
 
400
  data_build = match.group(1)
401
  cached_dpl = data_build
402
  cached_time = int(time.time())
 
403
 
404
 
405
  async def get_dpl(service):
 
418
  else:
419
  return True
420
  except Exception as e:
 
421
  cached_dpl = None
422
  cached_time = int(time.time())
423
  return False
 
456
  start = time.time()
457
  answer, solved = generate_answer(seed, diff, config)
458
  end = time.time()
 
459
  return "gAAAAAB" + answer, solved
460
 
461
 
 
483
  def get_requirements_token(config):
484
  require, solved = generate_answer(format(random.random()), "0fffff", config)
485
  return 'gAAAAAC' + require
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
services/test_image.py DELETED
@@ -1,98 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import base64
4
- import json
5
- import sys
6
- import time
7
- from pathlib import Path
8
-
9
- ROOT_DIR = Path(__file__).resolve().parents[2]
10
- if str(ROOT_DIR) not in sys.path:
11
- sys.path.insert(0, str(ROOT_DIR))
12
-
13
- from services.account_service import account_service
14
- from services.image_service import ImageGenerationError, generate_image_result, is_token_invalid_error
15
-
16
-
17
- OUTPUT_DIR = ROOT_DIR / "data" / "output"
18
-
19
-
20
- def detect_ext(image_bytes: bytes) -> str:
21
- if image_bytes.startswith(b"\xff\xd8\xff"):
22
- return ".jpg"
23
- if image_bytes.startswith(b"RIFF") and image_bytes[8:12] == b"WEBP":
24
- return ".webp"
25
- if image_bytes.startswith(b"GIF87a") or image_bytes.startswith(b"GIF89a"):
26
- return ".gif"
27
- return ".png"
28
-
29
-
30
- def save_image(image_b64: str, index: int) -> Path:
31
- image_bytes = base64.b64decode(image_b64)
32
- OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
33
- path = OUTPUT_DIR / f"image_{index}_{int(time.time())}{detect_ext(image_bytes)}"
34
- path.write_bytes(image_bytes)
35
- return path
36
-
37
-
38
- def generate_image_direct(prompt: str, model: str = "gpt-5-3", n: int = 1) -> dict:
39
- tokens = account_service.list_tokens()
40
- if not tokens:
41
- raise ImageGenerationError(f"No tokens found in {account_service.store_file}")
42
-
43
- last_error: Exception | None = None
44
- for index, token in enumerate(tokens, start=1):
45
- try:
46
- print(f"try token {index}/{len(tokens)}")
47
- return generate_image_result(token, prompt, model, n)
48
- except ImageGenerationError as exc:
49
- last_error = exc
50
- if is_token_invalid_error(str(exc)):
51
- account_service.remove_token(token)
52
- print(f"skip invalid token {index}/{len(tokens)}")
53
- continue
54
- raise
55
-
56
- if last_error is not None:
57
- raise last_error
58
- raise ImageGenerationError("No usable token")
59
-
60
-
61
- def main() -> int:
62
- prompt = " ".join(sys.argv[1:]).strip()
63
- if not prompt:
64
- prompt = "一只橘猫坐在窗台上,午后阳光,写实摄影"
65
-
66
- print(f"prompt: {prompt}")
67
- try:
68
- print("request: direct image_service")
69
- data = generate_image_direct(prompt)
70
- except ImageGenerationError as exc:
71
- print(f"image generation error: {exc}")
72
- return 1
73
-
74
- items = data.get("data") or []
75
- if not items:
76
- print("error: response data is empty")
77
- print(json.dumps(data, ensure_ascii=False, indent=2))
78
- return 1
79
-
80
- saved = []
81
- for index, item in enumerate(items, start=1):
82
- image_b64 = str((item or {}).get("b64_json") or "").strip()
83
- if not image_b64:
84
- continue
85
- path = save_image(image_b64, index)
86
- saved.append(path)
87
- print(f"saved: {path}")
88
-
89
- if not saved:
90
- print("error: no b64_json returned")
91
- print(json.dumps(data, ensure_ascii=False, indent=2))
92
- return 1
93
-
94
- return 0
95
-
96
-
97
- if __name__ == "__main__":
98
- raise SystemExit(main())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
services/utils.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ import uuid
5
+
6
+ from fastapi import HTTPException
7
+
8
+
9
+ IMAGE_MODELS = {"gpt-image-1", "gpt-image-2"}
10
+
11
+
12
+ def is_image_chat_request(body: dict[str, object]) -> bool:
13
+ model = str(body.get("model") or "").strip()
14
+ modalities = body.get("modalities")
15
+ if model in IMAGE_MODELS:
16
+ return True
17
+ if isinstance(modalities, list):
18
+ normalized = {str(item or "").strip().lower() for item in modalities}
19
+ return "image" in normalized
20
+ return False
21
+
22
+
23
+ def extract_response_prompt(input_value: object) -> str:
24
+ if isinstance(input_value, str):
25
+ return input_value.strip()
26
+
27
+ if isinstance(input_value, dict):
28
+ role = str(input_value.get("role") or "").strip().lower()
29
+ if role and role != "user":
30
+ return ""
31
+ return extract_prompt_from_message_content(input_value.get("content"))
32
+
33
+ if not isinstance(input_value, list):
34
+ return ""
35
+
36
+ prompt_parts: list[str] = []
37
+ for item in input_value:
38
+ if isinstance(item, dict) and str(item.get("type") or "").strip() == "input_text":
39
+ text = str(item.get("text") or "").strip()
40
+ if text:
41
+ prompt_parts.append(text)
42
+ continue
43
+ if not isinstance(item, dict):
44
+ continue
45
+ role = str(item.get("role") or "").strip().lower()
46
+ if role and role != "user":
47
+ continue
48
+ prompt = extract_prompt_from_message_content(item.get("content"))
49
+ if prompt:
50
+ prompt_parts.append(prompt)
51
+ return "\n".join(prompt_parts).strip()
52
+
53
+
54
+ def has_response_image_generation_tool(body: dict[str, object]) -> bool:
55
+ tools = body.get("tools")
56
+ if isinstance(tools, list):
57
+ for tool in tools:
58
+ if isinstance(tool, dict) and str(tool.get("type") or "").strip() == "image_generation":
59
+ return True
60
+
61
+ tool_choice = body.get("tool_choice")
62
+ if isinstance(tool_choice, dict) and str(tool_choice.get("type") or "").strip() == "image_generation":
63
+ return True
64
+ return False
65
+
66
+
67
+ def extract_prompt_from_message_content(content: object) -> str:
68
+ if isinstance(content, str):
69
+ return content.strip()
70
+ if not isinstance(content, list):
71
+ return ""
72
+
73
+ parts: list[str] = []
74
+ for item in content:
75
+ if not isinstance(item, dict):
76
+ continue
77
+ item_type = str(item.get("type") or "").strip()
78
+ if item_type == "text":
79
+ text = str(item.get("text") or "").strip()
80
+ if text:
81
+ parts.append(text)
82
+ continue
83
+ if item_type == "input_text":
84
+ text = str(item.get("text") or item.get("input_text") or "").strip()
85
+ if text:
86
+ parts.append(text)
87
+ return "\n".join(parts).strip()
88
+
89
+
90
+ def extract_chat_prompt(body: dict[str, object]) -> str:
91
+ direct_prompt = str(body.get("prompt") or "").strip()
92
+ if direct_prompt:
93
+ return direct_prompt
94
+
95
+ messages = body.get("messages")
96
+ if not isinstance(messages, list):
97
+ return ""
98
+
99
+ prompt_parts: list[str] = []
100
+ for message in messages:
101
+ if not isinstance(message, dict):
102
+ continue
103
+ role = str(message.get("role") or "").strip().lower()
104
+ if role != "user":
105
+ continue
106
+ prompt = extract_prompt_from_message_content(message.get("content"))
107
+ if prompt:
108
+ prompt_parts.append(prompt)
109
+
110
+ return "\n".join(prompt_parts).strip()
111
+
112
+
113
+ def parse_image_count(raw_value: object) -> int:
114
+ try:
115
+ value = int(raw_value or 1)
116
+ except (TypeError, ValueError) as exc:
117
+ raise HTTPException(status_code=400, detail={"error": "n must be an integer"}) from exc
118
+ if value < 1 or value > 4:
119
+ raise HTTPException(status_code=400, detail={"error": "n must be between 1 and 4"})
120
+ return value
121
+
122
+
123
+ def build_chat_image_completion(
124
+ model: str,
125
+ prompt: str,
126
+ image_result: dict[str, object],
127
+ ) -> dict[str, object]:
128
+ created = int(image_result.get("created") or time.time())
129
+ image_items = image_result.get("data") if isinstance(image_result.get("data"), list) else []
130
+
131
+ markdown_images = []
132
+
133
+ for index, item in enumerate(image_items, start=1):
134
+ if not isinstance(item, dict):
135
+ continue
136
+ b64_json = str(item.get("b64_json") or "").strip()
137
+ if not b64_json:
138
+ continue
139
+ image_data_url = f"data:image/png;base64,{b64_json}"
140
+ markdown_images.append(f"![image_{index}]({image_data_url})")
141
+
142
+ text_content = "\n\n".join(markdown_images) if markdown_images else "Image generation completed."
143
+
144
+ return {
145
+ "id": f"chatcmpl-{uuid.uuid4().hex}",
146
+ "object": "chat.completion",
147
+ "created": created,
148
+ "model": model,
149
+ "choices": [
150
+ {
151
+ "index": 0,
152
+ "message": {
153
+ "role": "assistant",
154
+ "content": text_content,
155
+ },
156
+ "finish_reason": "stop",
157
+ }
158
+ ],
159
+ "usage": {
160
+ "prompt_tokens": 0,
161
+ "completion_tokens": 0,
162
+ "total_tokens": 0,
163
+ },
164
+ }
test/test_completions.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from test.utils import post_json, save_image
2
+
3
+
4
+ def main() -> None:
5
+ prompt = "A cute orange cat sitting on a chair"
6
+ result = post_json("/v1/chat/completions", {"model": "gpt-image-1", "stream": False, "messages": [{"role": "user", "content": prompt}]})
7
+ for index, item in enumerate(result["choices"][0]["message"]["images"], start=1):
8
+ print(save_image(item["b64_json"], f"completions_{index}"))
9
+
10
+
11
+ if __name__ == "__main__":
12
+ main()
test/test_generations.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from test.utils import post_json, save_image
2
+
3
+
4
+ def main() -> None:
5
+ prompt = "A cute orange cat sitting on a chair"
6
+ result = post_json("/v1/images/generations", {"prompt": prompt, "model": "gpt-image-1", "n": 1})
7
+ for index, item in enumerate(result["data"], start=1):
8
+ print(save_image(item["b64_json"], f"generations_{index}"))
9
+
10
+
11
+ if __name__ == "__main__":
12
+ main()
test/test_image.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from services.account_service import account_service
4
+ from services.chatgpt_service import ChatGPTService
5
+ from test.utils import save_image
6
+
7
+
8
+ def main() -> None:
9
+ prompt = "一只橘猫坐在窗台上,午后阳光,写实摄影"
10
+ data = ChatGPTService(account_service).generate_with_pool(prompt, "gpt-5-3", 1)
11
+ for index, item in enumerate(data["data"], start=1):
12
+ print(save_image(item["b64_json"], f"image_{index}"))
13
+
14
+
15
+ if __name__ == "__main__":
16
+ main()
test/utils.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import json
3
+ import sys
4
+ import time
5
+ import urllib.request
6
+ from pathlib import Path
7
+
8
+
9
+ ROOT_DIR = Path(__file__).resolve().parents[1]
10
+ OUTPUT_DIR = ROOT_DIR / "data" / "output"
11
+ BASE_URL = "http://127.0.0.1:8000"
12
+
13
+ if str(ROOT_DIR) not in sys.path:
14
+ sys.path.insert(0, str(ROOT_DIR))
15
+
16
+
17
+ def load_auth_key() -> str:
18
+ return json.loads((ROOT_DIR / "config.json").read_text(encoding="utf-8"))["auth-key"]
19
+
20
+
21
+ def post_json(path: str, payload: dict) -> dict:
22
+ request = urllib.request.Request(
23
+ BASE_URL + path,
24
+ data=json.dumps(payload).encode(),
25
+ headers={"Content-Type": "application/json", "Authorization": f"Bearer {load_auth_key()}"},
26
+ method="POST",
27
+ )
28
+ with urllib.request.urlopen(request) as response:
29
+ return json.loads(response.read().decode())
30
+
31
+
32
+ def detect_ext(image_bytes: bytes) -> str:
33
+ if image_bytes.startswith(b"\xff\xd8\xff"):
34
+ return ".jpg"
35
+ if image_bytes.startswith(b"RIFF") and image_bytes[8:12] == b"WEBP":
36
+ return ".webp"
37
+ if image_bytes.startswith((b"GIF87a", b"GIF89a")):
38
+ return ".gif"
39
+ return ".png"
40
+
41
+
42
+ def save_image(image_b64: str, name: str) -> Path:
43
+ image_bytes = base64.b64decode(image_b64)
44
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
45
+ path = OUTPUT_DIR / f"{name}_{int(time.time())}{detect_ext(image_bytes)}"
46
+ path.write_bytes(image_bytes)
47
+ return path
web/next.config.ts CHANGED
@@ -1,6 +1,25 @@
 
 
 
1
  import type { NextConfig } from 'next'
2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  const nextConfig: NextConfig = {
 
 
 
4
  output: 'export',
5
  images: {
6
  unoptimized: true,
 
1
+ import { readFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
  import type { NextConfig } from 'next'
5
 
6
+ const projectRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
7
+
8
+ function readAppVersion() {
9
+ try {
10
+ const version = readFileSync(join(projectRoot, 'VERSION'), 'utf-8').trim()
11
+ return version || '0.0.0'
12
+ } catch {
13
+ return '0.0.0'
14
+ }
15
+ }
16
+
17
+ const appVersion = process.env.NEXT_PUBLIC_APP_VERSION || readAppVersion()
18
+
19
  const nextConfig: NextConfig = {
20
+ env: {
21
+ NEXT_PUBLIC_APP_VERSION: appVersion,
22
+ },
23
  output: 'export',
24
  images: {
25
  unoptimized: true,
web/package.json CHANGED
@@ -6,12 +6,7 @@
6
  "scripts": {
7
  "dev": "next dev --webpack -H 0.0.0.0",
8
  "build": "next build",
9
- "start": "next start",
10
- "lint": "eslint .",
11
- "build:dev": "cp .dev .env.local && next build",
12
- "build:test": "cp .test .env.local && next build",
13
- "build:prod": "cp .prod .env.local && next build",
14
- "openapi": "node openapi.config.js"
15
  },
16
  "dependencies": {
17
  "@radix-ui/react-checkbox": "^1.3.3",
 
6
  "scripts": {
7
  "dev": "next dev --webpack -H 0.0.0.0",
8
  "build": "next build",
9
+ "start": "next start"
 
 
 
 
 
10
  },
11
  "dependencies": {
12
  "@radix-ui/react-checkbox": "^1.3.3",